Why: batch update leaked AGENT_SYSTEM_INSTANCE_ID/DBREF from one service to the next, and RDM artifacts without the identity CLI were stopped and overwritten before their startup identity refusal surfaced, leaving rdm1/rdmcand in a mixed registration-vs-artifact state (2026-10-12). Evidence: make test-governance 217 OK; tests.test_svc_identity and tests.test_svc_precheck green; rows recorded in the task card. Impact: env_config resets identity env per service; capability probe is timeout-guarded and rejects serve-shaped output; identity ensure moves to adoption/init (bare start is verify-only); deploy builds and gates candidates before stopping the old service (refuses identityless candidates, completes missing identity rows via the candidate); update runs svc_manifest_migrate like every other lifecycle entry. Refs: .task/feature-svc-identity-u1.md; user root-cause report 2026-10-12.
252 lines
11 KiB
Python
252 lines
11 KiB
Python
"""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()
|