- DNS 级 SSRF pin:解析后 pin IP 建连,防 DNS 重绑定 - 批量总预算与每日上限/抖动冷却/保留期 - PG schema 与迁移清单 - 双连接死锁隔离 - 改目标重置基线 - 显式代理配置与结构化失败原因 - /metrics resident 口径
685 lines
28 KiB
Python
685 lines
28 KiB
Python
"""常驻监听 P0+P1 修复批单测(cloud/resident.py 等)。
|
||
|
||
覆盖审核报告的 P0(S1 DNS 级 SSRF + 摘录口径、S2 批量总预算、
|
||
G2 每日上限/抖动冷却/保留期)与 P1(S3 PG schema 与迁移清单、
|
||
G1 ensure 移出热路径 + 逐条隔离 + 死锁重试、G3 改目标重置基线、
|
||
G4 显式代理配置 + 结构化失败原因、G5 /metrics 口径)及 P2
|
||
建议项(502 固定错误码、_resident_get polls 异常记日志不静默)。
|
||
|
||
不跑真实模型、不连真实外网:真实抓取只对本地测试 HTTP
|
||
服务,DNS 门用 getaddrinfo 补丁或注入 resolver 验证;本地
|
||
PG 验证不在本文件(见 verification/resident-p0-fixes-summary.md
|
||
里的 /tmp 一次性库记录),本文件只做 SQLite 与覆盖断言。
|
||
"""
|
||
import http.client
|
||
import http.server
|
||
import json
|
||
import os
|
||
import socket
|
||
import sqlite3
|
||
import ssl
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import unittest
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest import mock
|
||
|
||
try:
|
||
import cloud.resident as resident_module
|
||
from cloud.auth import AuthStore
|
||
from cloud.db import PostgresOperationalError
|
||
from cloud.migrations import MIGRATION_TABLES, migration_coverage
|
||
from cloud.resident import (
|
||
FetchError,
|
||
HttpStatusFetchError,
|
||
SSRFBlockedError,
|
||
_PinnedHTTPSConnection,
|
||
classify_fetch_error,
|
||
create_watch,
|
||
default_fetch,
|
||
ensure_resident_tables,
|
||
get_watch,
|
||
list_polls,
|
||
prune_old_polls,
|
||
process_due_resident_watches,
|
||
resident_config,
|
||
resident_metrics,
|
||
resolve_public_addresses,
|
||
run_due_resident_watch,
|
||
update_watch,
|
||
)
|
||
from cloud.server import create_server, init_db
|
||
from cloud.worker import Worker
|
||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
import cloud.resident as resident_module
|
||
from cloud.auth import AuthStore
|
||
from cloud.db import PostgresOperationalError
|
||
from cloud.migrations import MIGRATION_TABLES, migration_coverage
|
||
from cloud.resident import (
|
||
FetchError,
|
||
HttpStatusFetchError,
|
||
SSRFBlockedError,
|
||
_PinnedHTTPSConnection,
|
||
classify_fetch_error,
|
||
create_watch,
|
||
default_fetch,
|
||
ensure_resident_tables,
|
||
get_watch,
|
||
list_polls,
|
||
prune_old_polls,
|
||
process_due_resident_watches,
|
||
resident_config,
|
||
resident_metrics,
|
||
resolve_public_addresses,
|
||
run_due_resident_watch,
|
||
update_watch,
|
||
)
|
||
from cloud.server import create_server, init_db
|
||
from cloud.worker import Worker
|
||
|
||
URL = "https://example.com/data"
|
||
URL2 = "https://example.com/other"
|
||
|
||
|
||
class _Fetcher:
|
||
def __init__(self, value="v1"):
|
||
self.value = value
|
||
self.calls = []
|
||
|
||
def __call__(self, url, timeout):
|
||
self.calls.append((url, timeout))
|
||
return self.value
|
||
|
||
|
||
class ResidentP0TestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||
init_db(self.db_path)
|
||
self.conn = sqlite3.connect(self.db_path)
|
||
self.conn.row_factory = sqlite3.Row
|
||
ensure_resident_tables(self.conn)
|
||
self.now = 1700000000.0
|
||
|
||
def tearDown(self):
|
||
self.conn.close()
|
||
self.tmp.cleanup()
|
||
|
||
def _watch(self, name="watch1", url=URL, **kw):
|
||
kw.setdefault("interval_seconds", 60)
|
||
return create_watch(self.conn, "proj", name, url,
|
||
"变化时汇报", "owner1", now=self.now, **kw)
|
||
|
||
def _tasks(self):
|
||
return self.conn.execute(
|
||
"SELECT id, prompt, status FROM tasks").fetchall()
|
||
|
||
def _polls(self, watch_id):
|
||
return list_polls(self.conn, watch_id)
|
||
|
||
# -- S1:DNS 级 SSRF -------------------------------------------
|
||
def test_resolve_public_rejects_private_and_mixed(self):
|
||
# 单一私网解析结果即拦。
|
||
with self.assertRaises(SSRFBlockedError):
|
||
resolve_public_addresses(
|
||
"evil.test", 80,
|
||
resolver=lambda h, p: ["127.0.0.1"])
|
||
with self.assertRaises(SSRFBlockedError):
|
||
resolve_public_addresses(
|
||
"evil.test", 80, resolver=lambda h, p: ["::1"])
|
||
with self.assertRaises(SSRFBlockedError):
|
||
resolve_public_addresses(
|
||
"evil.test", 80,
|
||
resolver=lambda h, p: ["169.254.169.254"])
|
||
with self.assertRaises(SSRFBlockedError):
|
||
resolve_public_addresses(
|
||
"evil.test", 80,
|
||
resolver=lambda h, p: ["::ffff:127.0.0.1"])
|
||
# 混合结果:任一私网即整批拒绝,不取公网那个蒙混。
|
||
with self.assertRaises(SSRFBlockedError):
|
||
resolve_public_addresses(
|
||
"evil.test", 80,
|
||
resolver=lambda h, p: ["93.184.216.34", "10.0.0.5"])
|
||
# 全公网才放行。
|
||
self.assertEqual(
|
||
resolve_public_addresses(
|
||
"ok.test", 80, resolver=lambda h, p: ["93.184.216.34"]),
|
||
["93.184.216.34"])
|
||
# 解析失败按 dns,不放行。
|
||
with self.assertRaises(FetchError) as ctx:
|
||
resolve_public_addresses(
|
||
"nope.test", 80,
|
||
resolver=lambda h, p: (_ for _ in ()).throw(
|
||
socket.gaierror("no such host")))
|
||
self.assertEqual(ctx.exception.reason, "dns")
|
||
|
||
def test_ssrf_blocked_poll_has_no_excerpt_and_no_task(self):
|
||
fetcher = _Fetcher("v1")
|
||
watch = self._watch()
|
||
run_due_resident_watch(self.conn, watch["id"],
|
||
fetcher=fetcher, now=self.now)
|
||
self.now += 61
|
||
|
||
def ssrf_fetcher(url, timeout):
|
||
raise SSRFBlockedError("resolved address is not public")
|
||
|
||
poll = run_due_resident_watch(self.conn, watch["id"],
|
||
fetcher=ssrf_fetcher, now=self.now)
|
||
self.assertEqual(poll["status"], "failed")
|
||
self.assertEqual(poll["failure_reason"], "ssrf_blocked")
|
||
self.assertTrue(poll["detail"].startswith("reason=ssrf_blocked"))
|
||
self.assertEqual(len(self._tasks()), 0)
|
||
after = get_watch(self.conn, watch["id"], now=self.now)
|
||
self.assertIsNone(after["last_change_text"])
|
||
# 失败轮询的 detail 里绝不出现“内网响应正文”类内容。
|
||
self.assertNotIn("secret", poll["detail"])
|
||
|
||
def test_pinned_https_uses_hostname_for_sni_not_ip(self):
|
||
seen = {}
|
||
|
||
def fake_create_connection(address, timeout=None):
|
||
seen["address"] = address
|
||
return object()
|
||
|
||
class FakeContext:
|
||
def wrap_socket(self, sock, server_hostname=None):
|
||
seen["server_hostname"] = server_hostname
|
||
return object()
|
||
|
||
with mock.patch.object(socket, "create_connection",
|
||
fake_create_connection):
|
||
with mock.patch.object(ssl, "create_default_context",
|
||
lambda: FakeContext()):
|
||
conn = _PinnedHTTPSConnection(
|
||
"example.com", 443, ["93.184.216.34"], timeout=5)
|
||
conn.connect()
|
||
self.assertEqual(seen["address"], ("93.184.216.34", 443))
|
||
self.assertEqual(seen["server_hostname"], "example.com")
|
||
|
||
# -- S2:批量总预算 --------------------------------------------
|
||
def test_batch_budget_stops_batch_and_keeps_rest_due(self):
|
||
class FakeClock:
|
||
def __init__(self):
|
||
self.now = 0.0
|
||
|
||
def __call__(self):
|
||
return self.now
|
||
|
||
clock = FakeClock()
|
||
|
||
def slow_fetcher(url, timeout):
|
||
clock.now += 50.0
|
||
return "v1"
|
||
|
||
for name in ("a", "b", "c", "d", "e"):
|
||
self._watch(name=name)
|
||
errors = []
|
||
polls = process_due_resident_watches(
|
||
self.conn, fetcher=slow_fetcher, now=self.now,
|
||
budget_seconds=100, monotonic_fn=clock, errors=errors)
|
||
# 0 秒、50 秒处各跑一条,第 3 条开始前已满 100 秒即停。
|
||
self.assertEqual(len(polls), 2)
|
||
self.assertTrue(any(w is None and "budget" in m
|
||
for w, m in errors))
|
||
# 剩余 3 条仍是 due 状态,下批可跑,不丢。
|
||
clock2 = FakeClock()
|
||
polls2 = process_due_resident_watches(
|
||
self.conn, fetcher=slow_fetcher, now=self.now,
|
||
budget_seconds=10000, monotonic_fn=clock2)
|
||
self.assertEqual(len(polls2), 3)
|
||
|
||
# -- G1:逐条隔离 + 死锁重试 + 热路径零 DDL ---------------------
|
||
def test_batch_isolates_item_errors_and_retries_deadlock(self):
|
||
watch_a = self._watch(name="a")
|
||
watch_b = self._watch(name="b")
|
||
calls = []
|
||
|
||
def flaky(url, timeout):
|
||
return "v1"
|
||
|
||
original = resident_module.run_due_resident_watch
|
||
|
||
def patched(conn, watch_id, fetcher=None, now=None,
|
||
resolver=None):
|
||
calls.append(watch_id)
|
||
if watch_id == watch_a["id"]:
|
||
raise PostgresOperationalError("deadlock detected",
|
||
locked=True)
|
||
return original(conn, watch_id, fetcher=fetcher, now=now,
|
||
resolver=resolver)
|
||
|
||
errors = []
|
||
with mock.patch.object(resident_module,
|
||
"run_due_resident_watch", patched):
|
||
polls = process_due_resident_watches(
|
||
self.conn, fetcher=flaky, now=self.now, errors=errors)
|
||
self.assertEqual(len(polls), 1)
|
||
self.assertEqual(polls[0]["watch_id"], watch_b["id"])
|
||
self.assertEqual(len(errors), 1)
|
||
self.assertEqual(errors[0][0], watch_a["id"])
|
||
# 死锁重试成功路径:第一次锁、第二次成功,errors 为空。
|
||
attempts = {"n": 0}
|
||
|
||
def retry_once(conn, watch_id, fetcher=None, now=None,
|
||
resolver=None):
|
||
if watch_id == watch_a["id"] and attempts["n"] == 0:
|
||
attempts["n"] += 1
|
||
raise PostgresOperationalError("deadlock detected",
|
||
locked=True)
|
||
return original(conn, watch_id, fetcher=fetcher, now=now,
|
||
resolver=resolver)
|
||
|
||
errors2 = []
|
||
with mock.patch.object(resident_module,
|
||
"run_due_resident_watch", retry_once):
|
||
polls2 = process_due_resident_watches(
|
||
self.conn, fetcher=flaky, now=self.now + 120,
|
||
errors=errors2)
|
||
self.assertEqual(errors2, [])
|
||
self.assertTrue(any(p["watch_id"] == watch_a["id"]
|
||
for p in polls2))
|
||
|
||
def test_hot_path_batch_executes_no_ddl(self):
|
||
class CountingConn:
|
||
def __init__(self, real):
|
||
self._real = real
|
||
self.statements = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.statements.append(sql)
|
||
return self._real.execute(sql, params)
|
||
|
||
def commit(self):
|
||
return self._real.commit()
|
||
|
||
def rollback(self):
|
||
return self._real.rollback()
|
||
|
||
self._watch(name="a")
|
||
self._watch(name="b")
|
||
counting = CountingConn(self.conn)
|
||
polls = process_due_resident_watches(
|
||
counting, fetcher=_Fetcher("v1"), now=self.now)
|
||
self.assertEqual(len(polls), 2)
|
||
ddl = [s for s in counting.statements
|
||
if s.strip().upper().startswith("CREATE")]
|
||
self.assertEqual(ddl, [])
|
||
|
||
# -- G2:每日上限 / 抖动冷却 / 保留期 ----------------------------
|
||
def test_daily_cap_and_cooldown_suppress_without_losing_change(self):
|
||
env = {
|
||
"CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS": "2",
|
||
"CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS": "300",
|
||
}
|
||
with mock.patch.dict(os.environ, env):
|
||
fetcher = _Fetcher("v1")
|
||
watch = self._watch()
|
||
run_due_resident_watch(self.conn, watch["id"],
|
||
fetcher=fetcher, now=self.now)
|
||
# 第 1 次变化:建任务。
|
||
fetcher.value = "v2"
|
||
self.now += 61
|
||
poll = run_due_resident_watch(
|
||
self.conn, watch["id"], fetcher=fetcher, now=self.now)
|
||
self.assertEqual(poll["status"], "changed")
|
||
self.assertEqual(len(self._tasks()), 1)
|
||
# 冷却期内再变:记 changed_cooldown,不建任务,游标不动。
|
||
fetcher.value = "v3"
|
||
self.now += 61
|
||
poll = run_due_resident_watch(
|
||
self.conn, watch["id"], fetcher=fetcher, now=self.now)
|
||
self.assertEqual(poll["status"], "changed_cooldown")
|
||
self.assertEqual(len(self._tasks()), 1)
|
||
after = get_watch(self.conn, watch["id"], now=self.now)
|
||
self.assertEqual(after["last_change_text"], "v2")
|
||
# 冷却过后:第 2 次变化建任务,达到上限 2。
|
||
self.now += 300
|
||
poll = run_due_resident_watch(
|
||
self.conn, watch["id"], fetcher=fetcher, now=self.now)
|
||
self.assertEqual(poll["status"], "changed")
|
||
self.assertEqual(len(self._tasks()), 2)
|
||
# 再变:上限已满,记 changed_capped,不建任务,游标不动。
|
||
fetcher.value = "v4"
|
||
self.now += 361
|
||
poll = run_due_resident_watch(
|
||
self.conn, watch["id"], fetcher=fetcher, now=self.now)
|
||
self.assertEqual(poll["status"], "changed_capped")
|
||
self.assertEqual(len(self._tasks()), 2)
|
||
after = get_watch(self.conn, watch["id"], now=self.now)
|
||
self.assertEqual(after["last_change_text"], "v3")
|
||
|
||
def test_prune_old_polls_keeps_recent(self):
|
||
watch = self._watch()
|
||
old = self.now - 100 * 86400
|
||
self.conn.execute(
|
||
"INSERT INTO resident_polls(id, watch_id, project_id,"
|
||
" status, observed_hash, detail, task_id, created_at)"
|
||
" VALUES('old1', ?, 'proj', 'unchanged', 'h', '', NULL, ?)",
|
||
(watch["id"], old))
|
||
self.conn.execute(
|
||
"INSERT INTO resident_polls(id, watch_id, project_id,"
|
||
" status, observed_hash, detail, task_id, created_at)"
|
||
" VALUES('new1', ?, 'proj', 'unchanged', 'h', '', NULL, ?)",
|
||
(watch["id"], self.now))
|
||
self.conn.commit()
|
||
deleted = prune_old_polls(self.conn, now=self.now,
|
||
retention_days=90)
|
||
self.assertEqual(deleted, 1)
|
||
remaining = [p["id"] for p in self._polls(watch["id"])]
|
||
self.assertEqual(remaining, ["new1"])
|
||
|
||
# -- G3:改目标重置基线 ------------------------------------------
|
||
def test_update_target_resets_baseline_but_interval_does_not(self):
|
||
fetcher = _Fetcher("v1")
|
||
watch = self._watch()
|
||
run_due_resident_watch(self.conn, watch["id"],
|
||
fetcher=fetcher, now=self.now)
|
||
before = get_watch(self.conn, watch["id"], now=self.now)
|
||
self.assertIsNotNone(before["last_content_hash"])
|
||
# 只改间隔:游标保留。
|
||
ok, updated = update_watch(
|
||
self.conn, watch["id"], {"interval_seconds": 3600},
|
||
now=self.now)
|
||
self.assertTrue(ok)
|
||
self.assertIsNotNone(updated["last_content_hash"])
|
||
# 改目标:游标清掉,下一轮只建基线、不建任务。
|
||
ok, updated = update_watch(
|
||
self.conn, watch["id"], {"target_url": URL2}, now=self.now)
|
||
self.assertTrue(ok)
|
||
self.assertIsNone(updated["last_content_hash"])
|
||
|
||
def url_fetcher(url, timeout):
|
||
return "new-site-content" if url == URL2 else "v1"
|
||
|
||
self.now += 61
|
||
poll = run_due_resident_watch(
|
||
self.conn, watch["id"], fetcher=url_fetcher, now=self.now)
|
||
self.assertEqual(poll["status"], "baseline")
|
||
self.assertEqual(len(self._tasks()), 0)
|
||
|
||
# -- G4:代理配置与结构化失败 -------------------------------------
|
||
def test_proxy_config_defaults_direct_and_env_opt_in(self):
|
||
with mock.patch.dict(os.environ, {}, clear=False):
|
||
os.environ.pop("CODEX_RESIDENT_PROXY", None)
|
||
os.environ.pop("http_proxy", None)
|
||
os.environ.pop("https_proxy", None)
|
||
cfg = resident_config()
|
||
self.assertEqual(cfg["proxy"]["mode"], "direct")
|
||
with mock.patch.dict(os.environ,
|
||
{"CODEX_RESIDENT_PROXY": "env",
|
||
"https_proxy": "http://proxy.test:8080"}):
|
||
cfg = resident_config()
|
||
self.assertEqual(cfg["proxy"]["mode"], "env")
|
||
with mock.patch.dict(
|
||
os.environ,
|
||
{"CODEX_RESIDENT_PROXY": "http://proxy.test:8080"}):
|
||
cfg = resident_config()
|
||
self.assertEqual(cfg["proxy"]["mode"], "url")
|
||
self.assertEqual(cfg["proxy"]["url"],
|
||
"http://proxy.test:8080")
|
||
with mock.patch.dict(os.environ,
|
||
{"CODEX_RESIDENT_PROXY": "ftp://x/"}):
|
||
with self.assertRaises(FetchError) as ctx:
|
||
resident_module.resolve_proxy_config()
|
||
self.assertEqual(ctx.exception.reason, "proxy")
|
||
# 默认值本身是拟定值(见摘要拟定清单),这里只断言
|
||
# 当前实现值,不把它写成用户已拍板。
|
||
self.assertEqual(resident_config()["max_daily_change_tasks"], 24)
|
||
self.assertEqual(
|
||
resident_config()["change_cooldown_seconds"], 300)
|
||
self.assertEqual(resident_config()["poll_retention_days"], 90)
|
||
self.assertEqual(resident_config()["batch_budget_seconds"], 120)
|
||
|
||
def test_classify_fetch_errors(self):
|
||
self.assertEqual(classify_fetch_error(
|
||
SSRFBlockedError("blocked")), "ssrf_blocked")
|
||
self.assertEqual(classify_fetch_error(
|
||
HttpStatusFetchError(404)), "http_status")
|
||
self.assertEqual(classify_fetch_error(socket.timeout("t")),
|
||
"timeout")
|
||
self.assertEqual(classify_fetch_error(
|
||
ConnectionRefusedError("refused")), "network")
|
||
self.assertEqual(classify_fetch_error(RuntimeError("boom")),
|
||
"unknown")
|
||
|
||
def test_http_status_fetch_raises_structured_error(self):
|
||
class Handler(http.server.BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
body = b"not here"
|
||
self.send_response(404)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def log_message(self, *args):
|
||
pass
|
||
|
||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0),
|
||
Handler)
|
||
port = server.server_address[1]
|
||
thread = threading.Thread(target=server.serve_forever,
|
||
daemon=True)
|
||
thread.start()
|
||
try:
|
||
# 单跳层真实 404(本地服务,绕过 DNS 门直接调单跳)。
|
||
status, _loc, _raw = resident_module._fetch_single_hop(
|
||
"http://127.0.0.1:%d/missing" % port, 10,
|
||
["127.0.0.1"], None)
|
||
self.assertEqual(status, 404)
|
||
# default_fetch 的状态分支:解析门放行公网 IP 后,
|
||
# 单跳返回 404 即抛结构化 http_status 错误(正文丢弃)。
|
||
with mock.patch.object(
|
||
resident_module, "_fetch_single_hop",
|
||
return_value=(404, None, b"not here")):
|
||
with self.assertRaises(HttpStatusFetchError) as ctx:
|
||
default_fetch(
|
||
"http://status.test/missing", 10,
|
||
resolver=lambda h, p: ["93.184.216.34"])
|
||
self.assertEqual(ctx.exception.reason, "http_status")
|
||
self.assertEqual(ctx.exception.status, 404)
|
||
finally:
|
||
server.shutdown()
|
||
server.server_close()
|
||
thread.join(timeout=5)
|
||
|
||
# -- S3:迁移覆盖(无 PG 也可断言)--------------------------------
|
||
def test_migration_coverage_includes_resident_tables(self):
|
||
coverage = migration_coverage()
|
||
self.assertEqual(coverage["missing_from_migration"], ())
|
||
self.assertIn("resident_watches", MIGRATION_TABLES)
|
||
self.assertIn("resident_polls", MIGRATION_TABLES)
|
||
self.assertIn("resident_watches", coverage["schema_tables"])
|
||
self.assertIn("resident_polls", coverage["schema_tables"])
|
||
|
||
# -- G5:metrics 口径 ---------------------------------------------
|
||
def test_resident_metrics_counts(self):
|
||
fetcher = _Fetcher("v1")
|
||
watch = self._watch()
|
||
run_due_resident_watch(self.conn, watch["id"],
|
||
fetcher=fetcher, now=self.now)
|
||
fetcher.value = "v2"
|
||
self.now += 61
|
||
run_due_resident_watch(self.conn, watch["id"],
|
||
fetcher=fetcher, now=self.now)
|
||
stats = resident_metrics(self.conn, {"proj"}, now=self.now)
|
||
self.assertTrue(stats["available"])
|
||
self.assertEqual(stats["watches_by_status"], {"running": 1})
|
||
self.assertEqual(stats["polls_last_24h"], 2)
|
||
self.assertEqual(stats["changed_last_24h"], 1)
|
||
self.assertEqual(stats["change_tasks_last_24h"], 1)
|
||
self.assertEqual(stats["failed_last_24h"], 0)
|
||
# 到期积压口径:把 next_poll_at 推到过去,due_count 见涨。
|
||
later = self.now + 3600
|
||
stats2 = resident_metrics(self.conn, {"proj"}, now=later)
|
||
self.assertEqual(stats2["due_count"], 1)
|
||
self.assertGreater(stats2["max_overdue_seconds"], 0)
|
||
# 非成员项目不计入。
|
||
stats3 = resident_metrics(self.conn, {"other"}, now=self.now)
|
||
self.assertEqual(stats3["watches_by_status"], {})
|
||
|
||
|
||
class ResidentP0ServerTestCase(unittest.TestCase):
|
||
"""服务端侧:/metrics resident 段、502 固定错误码、polls
|
||
异常记日志不静默(响应形态仍为 polls=[] + note)。"""
|
||
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||
self.auth = AuthStore(self.db_path)
|
||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||
self.port = self.server.server_address[1]
|
||
self.thread = threading.Thread(
|
||
target=self.server.serve_forever, daemon=True)
|
||
self.thread.start()
|
||
status, self.project = self._req(
|
||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||
self.assertEqual(status, 201, self.project)
|
||
|
||
def tearDown(self):
|
||
self.server.shutdown()
|
||
self.server.server_close()
|
||
self.thread.join(timeout=5)
|
||
try:
|
||
self.auth.conn.close()
|
||
except Exception:
|
||
pass
|
||
self.tmp.cleanup()
|
||
|
||
def _req(self, method, path, body=None, key=None):
|
||
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||
timeout=10)
|
||
hdrs = {}
|
||
payload = None
|
||
if body is not None:
|
||
payload = json.dumps(body).encode("utf-8")
|
||
hdrs["Content-Type"] = "application/json"
|
||
if key is not None:
|
||
hdrs["X-API-Key"] = key
|
||
try:
|
||
conn.request(method, path, body=payload, headers=hdrs)
|
||
resp = conn.getresponse()
|
||
data = resp.read()
|
||
finally:
|
||
conn.close()
|
||
try:
|
||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||
except ValueError:
|
||
parsed = None
|
||
return resp.status, parsed
|
||
|
||
def _url(self, suffix=""):
|
||
return "/projects/%s/resident-watches%s" % (
|
||
self.project["id"], suffix)
|
||
|
||
def test_metrics_contains_resident_section(self):
|
||
status, watch = self._req(
|
||
"POST", self._url(),
|
||
{"name": "w1", "target_url": URL, "prompt": "p"},
|
||
self.owner_key)
|
||
self.assertEqual(status, 201, watch)
|
||
status, body = self._req("GET", "/metrics", None, self.owner_key)
|
||
self.assertEqual(status, 200, body)
|
||
resident = body["resident"]
|
||
self.assertTrue(resident["available"])
|
||
self.assertEqual(resident["watches_by_status"], {"running": 1})
|
||
self.assertEqual(resident["due_count"], 1)
|
||
self.assertIn("max_daily_change_tasks", resident["config"])
|
||
|
||
def test_resident_502_uses_fixed_error_code(self):
|
||
import cloud.server as server_module
|
||
original = server_module._res_create
|
||
|
||
def boom(*args, **kwargs):
|
||
raise RuntimeError("db table resident_watches secret detail")
|
||
|
||
server_module._res_create = boom
|
||
try:
|
||
status, body = self._req(
|
||
"POST", self._url(),
|
||
{"name": "w1", "target_url": URL, "prompt": "p"},
|
||
self.owner_key)
|
||
finally:
|
||
server_module._res_create = original
|
||
self.assertEqual(status, 502)
|
||
self.assertEqual(body["error"], "resident_operation_failed")
|
||
self.assertNotIn("secret", json.dumps(body))
|
||
|
||
def test_resident_get_polls_failure_keeps_note(self):
|
||
status, watch = self._req(
|
||
"POST", self._url(),
|
||
{"name": "w1", "target_url": URL, "prompt": "p"},
|
||
self.owner_key)
|
||
self.assertEqual(status, 201, watch)
|
||
import cloud.server as server_module
|
||
original = server_module._res_polls
|
||
|
||
def boom(*args, **kwargs):
|
||
raise RuntimeError("poll query exploded")
|
||
|
||
server_module._res_polls = boom
|
||
try:
|
||
status, body = self._req(
|
||
"GET", self._url("/" + watch["id"]), None,
|
||
self.owner_key)
|
||
finally:
|
||
server_module._res_polls = original
|
||
self.assertEqual(status, 200, body)
|
||
self.assertEqual(body["polls"], [])
|
||
self.assertEqual(body["polls_note"], "polls unavailable")
|
||
|
||
|
||
class ResidentP0WorkerTestCase(unittest.TestCase):
|
||
"""worker 驱动:首次批量 ensure 一次、resolver 透传。"""
|
||
|
||
def setUp(self):
|
||
self.tmp = tempfile.TemporaryDirectory()
|
||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||
init_db(self.db_path)
|
||
self.conn = sqlite3.connect(self.db_path)
|
||
self.conn.row_factory = sqlite3.Row
|
||
ensure_resident_tables(self.conn)
|
||
self.now = 1700000000.0
|
||
|
||
def tearDown(self):
|
||
self.conn.close()
|
||
self.tmp.cleanup()
|
||
|
||
def test_worker_driver_passes_resolver_and_ensures_once(self):
|
||
create_watch(self.conn, "proj", "w1", URL, "p",
|
||
"owner1", interval_seconds=60, now=self.now)
|
||
worker = Worker(self.db_path, "/fake/host.py",
|
||
env={"XCM_API_KEY": "test-placeholder"},
|
||
runner=lambda cmd: SimpleNamespace(
|
||
returncode=0, stdout="{}", stderr=""))
|
||
seen = {}
|
||
fetcher_calls = []
|
||
|
||
def recording_fetch(url, timeout):
|
||
fetcher_calls.append(url)
|
||
return "v1"
|
||
|
||
def recording_resolver(host, port):
|
||
seen["host"] = host
|
||
return ["127.0.0.1"]
|
||
|
||
# worker 经 resident_fetcher/resident_resolver 注入点取用。
|
||
worker.resident_fetcher = recording_fetch
|
||
worker.resident_resolver = recording_resolver
|
||
polls = worker.process_due_resident_watches(now=self.now)
|
||
self.assertEqual(len(polls), 1)
|
||
self.assertEqual(polls[0]["status"], "baseline")
|
||
# fetcher 注入时 resolver 不经过 default_fetch,不必被调用;
|
||
# 这里只断言 worker 首批 ensure 标志已立、次批不再重复建表
|
||
# (标志位本身即“每 worker 一次”的证据)。
|
||
self.assertTrue(
|
||
getattr(worker, "_resident_tables_ensured", False))
|
||
self.assertEqual(fetcher_calls, [URL])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|