Files
Agent-System-Dev/tests/test_svc_precheck.py
T
weichen 6e4ebae2f6 Merge origin/main: supersede the PR #317 minimal fix with the unified resolver
Why: main landed fix-svc-dsn-reuse-dbref (reuse-path dbref restore, env-DSN
aware precheck, DSN_ENV scoping) for the same restart-DSN incident; the
approved recovery plan is its superset and replaces those mechanisms with
resolve_db_config + recoverable svc.env keys + refuse-on-invalid semantics.
Evidence: tests/test_svc_precheck.py ported to the new units (18 cases green,
same scenario matrix; broken-dbref expectation updated to refusal per the
approved plan); tests/test_svc.py 11 cases green; bash -n OK.
Impact: conflict hunks resolved toward the unified resolver; main's source
guard at the script tail restored; agent_listen_rejected kept as a pure
predicate (used by the start precheck and the governance tests).
Refs: main 7acf318 (PR #317); task card .task/feature-svc-db-config-recover.md.
2026-10-11 09:21:27 +00:00

234 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""svc.sh 启动预检与数据库配置统一解析的治理回归。
背景(2026-10-10/10-11 dev 事故,PR #317 最小修复 → feature/svc-db-config-recover
方案超集):G1-7 把 DSN 从命令行迁到 AGENT_SYSTEM_DSN 环境变量后——
1. 预检只看参数串,「非回环 + auth auto + env DSN」被误判认证关而拒启;二进制
AuthEnabled(config.go:显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN
来源)会放行。现由 db_auth_enabled(依赖 resolve_db_config 产出的 DB_AUTH/
DB_DSN)统一判定,覆盖 on/off/auto 与命令行/环境/配置文件来源。
2. 复用启动路径(无显式参数、复用 exec=)不再有 DSN:dbref 兜底只在重建分支,
纯 start/restart 会静默回落 SQLite。现 start/restart/deploy/update/db 共用
resolve_db_config,且存量 exec= 的 --dsn 迁移为 dbref=/dsn= 专键(幂等)。
3. DSN 跨服务串用(同进程连续启动):现 env_config 按实例重置解析态、DSN 经
局部 guard_env 交付,无跨服务全局通道。
行为变化(经用户 2026-10-11 方案批准,取代 PR #317 的对应语义):dbref 无法重建
DSN 时从「告警后按无绑定启动」改为**拒绝启动**;本测试按新语义断言。
测试通过 source scripts/svc.sh 调用纯函数/fixture 函数(文件尾有 source 守卫),
不启动真实服务、不触碰真实 runtime/。端到端生命周期回归见 tests/test_svc.py。
"""
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
SVC = REPO / "scripts" / "svc.sh"
PG_ADDR = "10.1.2.3:8899"
PG_USER = "svc_test"
PG_PASS = "fixture-only-pass"
PG_DSN = f"postgresql://{PG_USER}:{PG_PASS}@{PG_ADDR}/agentdb"
NONLOOP_EXEC = "--addr 0.0.0.0:8788 --data /tmp/x --auth auto"
def _bash(script: str, *args: str) -> str:
"""source svc.sh 后在 bash 内执行脚本,返回 stdout(stderr 丢弃)。"""
proc = subprocess.run(
["bash", "-c", script, "svc-test", str(SVC), *args],
capture_output=True,
text=True,
cwd=str(REPO),
env={**os.environ, "LC_ALL": "C"},
timeout=30,
)
if proc.returncode != 0:
raise AssertionError(
f"bash 脚本失败 rc={proc.returncode}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
)
return proc.stdout
def _authed(auth_mode: str, dsn: str = "", exec_args: str = "") -> bool:
"""db_auth_enabled 判定:auth 值 + 有效 DSN(模拟 resolve_db_config 产出)。"""
out = _bash(
'source "$1"; shift; DB_AUTH="$1"; DB_DSN="$2"; '
'if db_auth_enabled; then echo authed; else echo denied; fi',
auth_mode or "auto",
dsn,
)
return out.strip() == "authed"
def _rejected(exec_args: str, default_addr: str = "0.0.0.0:8788",
insecure: str = "", auth_mode: str = "auto", dsn: str = "") -> bool:
"""agent_listen_rejected 判定(认证部分经 DB_AUTH/DB_DSN 全局)。"""
out = _bash(
"source \"$1\"; shift; DB_AUTH=\"$4\"; DB_DSN=\"$5\"; "
"if agent_listen_rejected \"$1\" \"$2\" \"$3\"; then echo rejected; else echo allowed; fi",
exec_args,
default_addr,
insecure,
auth_mode or "auto",
dsn,
)
return out.strip() == "rejected"
class DbAuthEnabledMatrix(unittest.TestCase):
"""认证判定与二进制 AuthEnabled 口径一致(config.go,默认 auth=auto)。"""
def test_auth_on_explicit(self):
self.assertTrue(_authed("on"))
def test_auth_off_beats_dsn(self):
# 显式 off 优先:与二进制 AuthMode=="off" → AuthEnabled=false 一致。
self.assertFalse(_authed("off", PG_DSN))
def test_auto_with_effective_dsn_is_authed(self):
# 2026-10-10 误拒主场景:dbref 重建/环境交付后 DSN 只在 AGENT_SYSTEM_DSN。
self.assertTrue(_authed("auto", PG_DSN))
def test_auto_without_dsn_is_denied(self):
self.assertFalse(_authed("auto"))
self.assertFalse(_authed("", ""))
def test_no_auth_no_dsn_denied(self):
self.assertFalse(_authed("auto", "", "--mock"))
class AgentListenRejectedPrecheck(unittest.TestCase):
"""预检整体判定:非回环 + 未放行 + 认证关才拒绝。"""
def test_incident_combo_not_rejected(self):
# 原事故组合:非回环 + auto + DSN 仅在环境变量 → 必须放行(曾误拒)。
self.assertFalse(_rejected(NONLOOP_EXEC, dsn=PG_DSN))
def test_nonloop_no_dsn_rejected(self):
self.assertTrue(_rejected(NONLOOP_EXEC))
def test_nonloop_auth_off_with_dsn_rejected(self):
self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off",
dsn=PG_DSN, auth_mode="off"))
def test_loopback_not_rejected(self):
self.assertFalse(_rejected("--addr 127.0.0.1:8788"))
def test_insecure_not_rejected(self):
self.assertFalse(_rejected(NONLOOP_EXEC, insecure="1"))
def test_addr_defaults_to_default_addr(self):
# exec 参数里没有 --addr 时按 DEFAULT_ADDR 判。
self.assertTrue(_rejected("--data /tmp/x", default_addr="0.0.0.0:8788"))
self.assertFalse(_rejected("--data /tmp/x", default_addr="127.0.0.1:8788"))
class ResolveDbConfigFixtures(unittest.TestCase):
"""统一解析:复用/迁移语义(防静默回落 SQLite;坏绑定拒绝)。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
rt = Path(self._tmp.name) / "runtime"
(rt / "pg1").mkdir(parents=True)
(rt / "pg1" / "svc.env").write_text(
f"kind=postgres\naddr={PG_ADDR}\nuser={PG_USER}\npassword={PG_PASS}\n"
)
(rt / "dev").mkdir()
self.rt = rt
self.dev_dir = rt / "dev"
def tearDown(self):
self._tmp.cleanup()
def _resolve(self, exec_line: str, dbref: str = "", extra: str = "",
rewrite: bool = True) -> tuple:
"""写 fixture svc.env 后 resolve_db_config;回 (rc, DB_MODE/DSN/REF/SAVE)。
rewrite=False 时不覆写登记(幂等用例:在同一登记上二次解析)。"""
if rewrite:
content = f"kind=agent\nenv=dev\nexec={exec_line}\n"
if dbref:
content += f"dbref={dbref}\n"
if extra:
content += extra + "\n"
(self.dev_dir / "svc.env").write_text(content)
out = _bash(
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; '
'if resolve_db_config; then rc=ok; else rc=fail; fi; '
'printf "RC=%s\\nMODE=%s\\nDSN=%s\\nREF=%s\\nSAVE=%s\\n" '
'"$rc" "$DB_MODE" "$DB_DSN" "$DB_REF" "$DB_DSN_SAVE"',
str(self.rt),
str(self.dev_dir),
)
return dict(part.split("=", 1) for part in out.strip().splitlines())
def test_post_g17_exec_restores_dsn_from_dbref(self):
# G1-7 后 exec= 不含 DSN:解析必须从 dbref= 重建(否则回落 SQLite)。
got = self._resolve("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb")
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["MODE"], "pg")
self.assertEqual(got["DSN"], PG_DSN)
self.assertEqual(got["REF"], "pg1/agentdb")
self.assertEqual(got["SAVE"], "")
def test_legacy_exec_dsn_migrates_to_recoverable_keys(self):
# 存量 exec= 的明文 --dsn:迁移为可恢复配置(命中实例 → dbref=),
# exec= 不再携带 DSN。
legacy = f"--addr 0.0.0.0:8788 --dsn {PG_DSN}"
got = self._resolve(legacy)
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["DSN"], PG_DSN)
self.assertEqual(got["REF"], "pg1/agentdb")
envfile = (self.dev_dir / "svc.env").read_text()
self.assertNotIn("--dsn", envfile)
self.assertIn("dbref=pg1/agentdb", envfile)
def test_legacy_exec_dsn_without_instance_goes_to_dsn_key(self):
# 未命中本机实例:迁移到受保护 dsn= 字段。
external = "postgresql://u:pw@10.9.9.9:5432/otherdb"
got = self._resolve(f"--addr 127.0.0.1:8788 --dsn {external}")
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["DSN"], external)
self.assertEqual(got["SAVE"], external)
self.assertIn(f"dsn={external}", (self.dev_dir / "svc.env").read_text())
def test_migration_is_idempotent(self):
# 第二次解析:exec 已无 --dsn,登记保持稳定。
self._resolve(f"--addr 0.0.0.0:8788 --dsn {PG_DSN}")
first = (self.dev_dir / "svc.env").read_text()
got = self._resolve("", rewrite=False)
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["DSN"], PG_DSN)
self.assertEqual((self.dev_dir / "svc.env").read_text(), first)
def test_no_dsn_no_dbref_is_sqlite(self):
got = self._resolve("--addr 127.0.0.1:8788")
self.assertEqual(got["RC"], "ok")
self.assertEqual(got["MODE"], "sqlite")
self.assertEqual(got["DSN"], "")
def test_broken_dbref_refuses(self):
# 行为变化(方案批准):实例登记不全 → 拒绝启动,不静默回落 SQLite。
got = self._resolve("--addr 127.0.0.1:8788", dbref="missing/agentdb")
self.assertEqual(got["RC"], "fail")
self.assertEqual(got["DSN"], "")
class ResolverStateIsolation(unittest.TestCase):
"""解析态按实例隔离(env_config 重置,防 update --all 串库)。"""
def test_env_config_resets_resolver_state(self):
# 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务
# 泄漏数据库配置」——DSN_ENV 全局时代的事故形态)。
out = _bash(
'source "$1"; declare -f env_config | grep -c \'SVC_DB_MODE=""\''
)
self.assertEqual(out.strip(), "1")
if __name__ == "__main__":
unittest.main()