Why: G1-7 moved the DSN into AGENT_SYSTEM_DSN, but the precheck only read the arg string, so a non-loopback restart with auth auto was wrongly rejected (2026-10-10 dev incident); the reuse path no longer restored the dbref-bound DSN and would silently fall back to SQLite; and DSN_ENV leaked across same-process starts (update --all) into other services' env. Evidence: tests/test_svc_precheck.py 19 cases green (auth matrix vs config.go AuthEnabled, reuse migration/dbref restore, scope isolation); full governance suite 123 green; incident-scenario replay allowed + DSN carried. Recorded via make evidence. Impact: scripts/svc.sh precheck and reuse-start DSN handling only; no binary change, no persistence-format change. Refs: .task/fix-svc-dsn-reuse-dbref.md; config.go AuthEnabled (on || (not off && DSN non-empty), env DSN counts).
217 lines
8.6 KiB
Python
217 lines
8.6 KiB
Python
"""svc.sh 启动预检与复用 DSN 解析的治理回归。
|
||
|
||
背景(2026-10-10 dev 实测,记忆 svc-g17-precheck-dsn-bug):G1-7 把 DSN 从
|
||
命令行迁到 AGENT_SYSTEM_DSN 环境变量后——
|
||
|
||
1. 预检 agent_args_authed 只看参数串,「非回环 + auth auto + env DSN」被误判
|
||
认证关而拒启;二进制 AuthEnabled(modules/backend/internal/config/config.go:
|
||
显式 on,或非显式 off 且 DSN 非空——env 为合法 DSN 来源)会放行。
|
||
2. 复用启动路径(无显式参数、复用 exec=)不再有 DSN:dbref 兜底只在重建分支,
|
||
纯 start/restart 会静默回落 SQLite。
|
||
3. DSN_ENV 为全局且每次启动不重置:update --all 同进程连续启动会跨服务泄漏 DSN。
|
||
|
||
测试通过 source scripts/svc.sh 调用纯函数/fixture 函数(文件尾有 source 守卫),
|
||
不启动真实服务、不触碰真实 runtime/。
|
||
"""
|
||
|
||
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(args: str, dsn_env: str = "") -> bool:
|
||
out = _bash(
|
||
'source "$1"; shift; if agent_args_authed "$1" "${2:-}"; then echo authed; else echo denied; fi',
|
||
args,
|
||
dsn_env,
|
||
)
|
||
return out.strip() == "authed"
|
||
|
||
|
||
def _rejected(exec_args: str, default_addr: str = "0.0.0.0:8788",
|
||
insecure: str = "", dsn_env: str = "") -> bool:
|
||
out = _bash(
|
||
"source \"$1\"; shift; if agent_listen_rejected \"$1\" \"$2\" \"$3\" \"$4\"; "
|
||
"then echo rejected; else echo allowed; fi",
|
||
exec_args,
|
||
default_addr,
|
||
insecure,
|
||
dsn_env,
|
||
)
|
||
return out.strip() == "rejected"
|
||
|
||
|
||
class AgentArgsAuthedMatrix(unittest.TestCase):
|
||
"""认证判定与二进制 AuthEnabled 口径一致(config.go:301,默认 auth=auto)。"""
|
||
|
||
def test_auth_on_explicit(self):
|
||
self.assertTrue(_authed("--addr 1.2.3.4:80 --auth on"))
|
||
self.assertTrue(_authed("--addr 1.2.3.4:80 --auth=on"))
|
||
|
||
def test_auth_off_beats_env_dsn(self):
|
||
# 显式 off 优先:与二进制 AuthMode=="off" → AuthEnabled=false 一致。
|
||
self.assertFalse(_authed("--auth off", PG_DSN))
|
||
self.assertFalse(_authed("--auth=off", PG_DSN))
|
||
|
||
def test_auto_with_env_dsn_is_authed(self):
|
||
# 2026-10-10 误拒主场景:dbref 重建/G1-7 迁移后 DSN 只在环境变量里。
|
||
self.assertTrue(_authed("--auth auto", PG_DSN))
|
||
|
||
def test_default_auto_with_env_dsn_is_authed(self):
|
||
# exec= 里连 --auth 都没有(config 默认 auto):env DSN 即认证开。
|
||
self.assertTrue(_authed(NONLOOP_EXEC, PG_DSN))
|
||
|
||
def test_auto_without_dsn_is_denied(self):
|
||
self.assertFalse(_authed("--auth auto"))
|
||
self.assertFalse(_authed(NONLOOP_EXEC))
|
||
|
||
def test_legacy_cli_dsn_still_authed(self):
|
||
# 向后兼容:历史 exec= 的命令行 --dsn 仍是合法 DSN 来源。
|
||
self.assertTrue(_authed(f"--addr 1.2.3.4:80 --dsn {PG_DSN}"))
|
||
|
||
def test_no_auth_no_dsn_denied(self):
|
||
self.assertFalse(_authed("--addr 127.0.0.1:80 --mock"))
|
||
|
||
|
||
class AgentListenRejectedPrecheck(unittest.TestCase):
|
||
"""预检整体判定:非回环 + 未放行 + 认证关才拒绝。"""
|
||
|
||
def test_incident_combo_not_rejected(self):
|
||
# 原事故组合:非回环 + auto + DSN 仅在环境变量 → 必须放行(曾误拒)。
|
||
self.assertFalse(_rejected(NONLOOP_EXEC, dsn_env=PG_DSN))
|
||
|
||
def test_nonloop_no_dsn_rejected(self):
|
||
self.assertTrue(_rejected(NONLOOP_EXEC))
|
||
|
||
def test_nonloop_auth_off_with_env_dsn_rejected(self):
|
||
self.assertTrue(_rejected("--addr 0.0.0.0:8788 --auth off", dsn_env=PG_DSN))
|
||
|
||
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 AgentReuseExec(unittest.TestCase):
|
||
"""复用启动的 DSN 解析:G1-7 迁移 + dbref 重建(防静默回落 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 _reuse(self, exec_line: str) -> tuple:
|
||
# 直接调用(非 $()):agent_reuse_exec 经 nameref 回写 exec 参数、
|
||
# 全局 DSN_ENV 传 DSN——命令替换会把 DSN_ENV 赋值留在子壳。
|
||
out = _bash(
|
||
'source "$1"; shift; RUNTIME="$1"; ENV_NAME=dev; ENV_DIR="$2"; DSN_ENV=""; '
|
||
'agent_reuse_exec "$3" exec_args; '
|
||
'printf "EXEC=%s\\n" "$exec_args"; printf "DSN=%s\\n" "${DSN_ENV:-}"',
|
||
str(self.rt),
|
||
str(self.dev_dir),
|
||
exec_line,
|
||
)
|
||
lines = dict(part.split("=", 1) for part in out.strip().splitlines())
|
||
return lines["EXEC"], lines["DSN"]
|
||
|
||
def _write_dev_env(self, exec_line: str, dbref: str = "") -> None:
|
||
content = f"kind=agent\nenv=dev\nexec={exec_line}\n"
|
||
if dbref:
|
||
content += f"dbref={dbref}\n"
|
||
(self.dev_dir / "svc.env").write_text(content)
|
||
|
||
def test_post_g17_exec_restores_dsn_from_dbref(self):
|
||
# G1-7 后 exec= 不含 DSN:复用启动必须从 dbref= 重建(否则回落 SQLite)。
|
||
self._write_dev_env("--addr 0.0.0.0:8788 --auth auto", dbref="pg1/agentdb")
|
||
exec_args, dsn = self._reuse("--addr 0.0.0.0:8788 --auth auto")
|
||
self.assertEqual(dsn, PG_DSN)
|
||
self.assertNotIn("--dsn", exec_args)
|
||
|
||
def test_legacy_exec_dsn_migrates_to_env(self):
|
||
# 存量 exec= 的明文 --dsn:剥到环境变量,命令行不再可见。
|
||
legacy = f"--addr 0.0.0.0:8788 --dsn {PG_DSN}"
|
||
self._write_dev_env(legacy, dbref="pg1/agentdb")
|
||
exec_args, dsn = self._reuse(legacy)
|
||
self.assertEqual(dsn, PG_DSN)
|
||
self.assertNotIn("--dsn", exec_args)
|
||
self.assertEqual(exec_args, "--addr 0.0.0.0:8788")
|
||
|
||
def test_migration_dsn_wins_over_dbref(self):
|
||
# 命令行 --dsn 与 dbref 并存:迁移值优先,dbref 不覆盖。
|
||
other = "postgresql://other:pw@10.9.9.9:5432/otherdb"
|
||
legacy = f"--addr 0.0.0.0:8788 --dsn {other}"
|
||
self._write_dev_env(legacy, dbref="pg1/agentdb")
|
||
_, dsn = self._reuse(legacy)
|
||
self.assertEqual(dsn, other)
|
||
|
||
def test_no_dsn_no_dbref_stays_empty(self):
|
||
self._write_dev_env("--addr 127.0.0.1:8788")
|
||
exec_args, dsn = self._reuse("--addr 127.0.0.1:8788")
|
||
self.assertEqual(dsn, "")
|
||
self.assertEqual(exec_args, "--addr 127.0.0.1:8788")
|
||
|
||
def test_broken_dbref_warns_and_starts_unbound(self):
|
||
# 实例登记不全:不伪造 DSN,保持为空(启动侧另有告警)。
|
||
self._write_dev_env("--addr 127.0.0.1:8788", dbref="missing/agentdb")
|
||
_, dsn = self._reuse("--addr 127.0.0.1:8788")
|
||
self.assertEqual(dsn, "")
|
||
|
||
|
||
class DsnEnvScopeIsolation(unittest.TestCase):
|
||
"""DSN_ENV 每次启动隔离(agent_do_start 内 local 化,防 update --all 串库)。"""
|
||
|
||
def test_agent_do_start_localizes_dsn_env(self):
|
||
# 棘轮:agent_do_start 必须以 local DSN_ENV="" 起始,删除即回归
|
||
# 「同进程连续启动跨服务泄漏 AGENT_SYSTEM_DSN」。
|
||
out = _bash(
|
||
'source "$1"; declare -f agent_do_start | grep -c \'local exec_args DSN_ENV=""\''
|
||
)
|
||
self.assertEqual(out.strip(), "1")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|