"""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 串库 / 串 pidcheck)。""" 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") def test_env_config_resets_pidcheck_runtime_state(self): # 棘轮:env_config 必须清空 SVC_PIDCHECK(删除即回归「批量 update 用上一 # 服务 mark 误判本服务进程 → 误清 pidfile / 无法 reclaim」)。 out = _bash( 'source "$1"; declare -f env_config | grep -c \'SVC_PIDCHECK=""\'' ) self.assertEqual(out.strip(), "1") def test_env_config_resets_identity_env(self): # 棘轮:env_config 必须重置 AGENT_SYSTEM_INSTANCE_ID/DBREF(删除即回归 # 「批量 update 前一服务 verify 导出的身份串给下一服务」——2026-10-12 # rdm1/rdmcand:dev 身份漏给 RDM,serve 带外宾身份被门禁拒绝)。 out = _bash( 'source "$1"; declare -f env_config | ' 'grep -c \'unset AGENT_SYSTEM_INSTANCE_ID AGENT_SYSTEM_DBREF\'' ) self.assertEqual(out.strip(), "1") if __name__ == "__main__": unittest.main()