Files
Agent-System-Dev/tests/test_svc_identity.py
T
weichen c170939bb7 Implement the U1 identity manifest layer with startup verification and tests
Why: approved design (2026-10-12) - persistent manifest becomes the startup authority, closing the param-rebuild drift, non-atomic per-key writes, and missing-config-creates-empty-system recovery gaps (plan r2 U1 / KTD1-KTD2).

Evidence: SELF - python3 tests/test_svc_identity.py 21 tests OK (1 skip = real-PG identity NOT_RUN by design); make test-governance 169 OK incl. existing svc db-binding and autorecover suites; receipts appended via make evidence.

Impact: scripts/svc.sh gains manifest-aware set/del/record_state/autostart plus lock-held migrate, pre-launch identity verify and an init action; new scripts/svc_manifest.py (atomic temp-fsync-rename, identity anchors, derived svc.env); unmigrated instances keep the exact legacy path (regressions green).

Refs: docs/recovery/u1-identity-manifest.md; plan r2 section 7 U1
2026-10-12 02:50:32 +00:00

236 lines
9.9 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 实例身份回归(U1 设计 §7 T1–T6,manifest 治理)。
覆盖:连续裸启停身份稳定(T1)、只改端口锚点不动(T2)、数据根漂移拒绝(T3)、
迁移窗口/损坏阻塞不猜测(T4)、marker 缺失/外宾/manifest 丢失均为事件(T5)、
cwd 无关性(T6)、原子写崩溃窗口单元级(原文件不受损)。
桩二进制与夹具复用 tests/test_svc_recover.py。真实 PG 身份专项(DB marker SQL 层)
NOT_RUN:需真实实例,SVC_TEST_PG=1 且具备环境时另行执行,本文件不模拟。
"""
import importlib.util
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
_spec = importlib.util.spec_from_file_location("tsr", SOURCE / "tests" / "test_svc_recover.py")
tsr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tsr)
class IdentityTests(tsr.SvcAutoRecoverTests):
def setUp(self):
super().setUp()
shutil.copy2(SOURCE / "scripts" / "svc_manifest.py",
self.root / "scripts" / "svc_manifest.py")
# ---- 夹具 ----
def manifest(self, name):
p = self.runtime(name) / "manifest.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def marker(self, name):
m = self.manifest(name)
if not m:
return None
p = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else None
def running(self, name):
pid = self.pid_of(name)
if not pid:
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
# ---- T1 连续裸启停:同一身份与数据根 ----
def test_T1_bare_restart_stable_identity(self):
self.seed_agent("id1", 9431)
self.start_ok("id1")
m1 = self.manifest("id1")
self.assertIsNotNone(m1, "start 应完成 manifest 迁移")
iid = m1["instance_id"]
self.assertTrue(iid.startswith("svc-id1-"), iid)
mk = self.marker("id1")
self.assertEqual(mk["instance_id"], iid)
data_before = m1["identity"]["data_root"]
exec_before = m1["kv"]["exec"]
self.svc("id1", "stop")
self.start_ok("id1")
m2 = self.manifest("id1")
self.assertEqual(m2["instance_id"], iid, "裸启停不得换身份")
self.assertEqual(m2["identity"]["data_root"], data_before)
self.assertEqual(m2["kv"]["exec"], exec_before, "裸启停不得漂移 exec(数据/插件根)")
self.assertGreaterEqual(m2["revision"], m1["revision"], "整份提交 revision 单调")
self.assertTrue(self.running("id1"))
self.svc("id1", "stop")
# ---- T2 只改端口:数据/DB 锚点不动 ----
def test_T2_port_change_keeps_anchors(self):
self.seed_agent("id2", 9432)
self.start_ok("id2")
anchor = self.manifest("id2")["identity"]
self.svc("id2", "stop")
result = self.svc("id2", "start", "--port", "9433")
self.assertIn("已启动", result.stdout)
m2 = self.manifest("id2")
self.assertEqual(m2["identity"]["data_root"], anchor["data_root"], "改端口不得动数据根")
self.assertEqual(m2["identity"]["db_mode"], anchor["db_mode"])
self.assertEqual(m2["identity"]["db_ref"], anchor["db_ref"])
self.assertIn("9433", m2["kv"]["addr"])
self.svc("id2", "stop")
# ---- T3 数据根漂移:拒绝启动,不新建空系统 ----
def test_T3_data_drift_rejected(self):
self.seed_agent("id3", 9434)
self.start_ok("id3")
self.svc("id3", "stop")
other = self.root / "other-data"
other.mkdir()
result = self.svc("id3", "start", "--data", str(other), check=False)
self.assertNotEqual(result.returncode, 0, "数据根漂移必须拒绝")
self.assertTrue(any(k in result.stdout for k in ("身份校验失败", "数据根漂移")),
result.stdout)
self.assertFalse(self.running("id3"), "拒绝后不得有服务进程")
self.assertEqual(self.manifest("id3")["identity"]["data_root"],
str(self.runtime("id3") / "data"), "锚点不得被污染")
# ---- T4 迁移窗口与损坏:不猜测 ----
def test_T4a_leftover_new_file_ignored(self):
self.seed_agent("id4", 9435)
# 模拟 rename 前崩溃残留:.new 半成品必须被忽略/覆盖,不影响迁移结果
(self.runtime("id4") / "manifest.json.new").write_text("{ truncated")
self.start_ok("id4")
m = self.manifest("id4")
self.assertIsNotNone(m)
self.assertEqual(m["schema"], 1)
self.svc("id4", "stop")
def test_T4b_corrupt_manifest_blocks(self):
self.seed_agent("id5", 9436)
self.start_ok("id5")
self.svc("id5", "stop")
(self.runtime("id5") / "manifest.json").write_text("{ not json")
result = self.svc("id5", "start", check=False)
self.assertNotEqual(result.returncode, 0, "损坏 manifest 必须阻塞而非猜测重建")
self.assertIn("损坏", result.stdout)
self.assertFalse(self.running("id5"))
# ---- T5 marker/manifest 事件:缺一即阻塞,不自动新建 ----
def test_T5a_missing_marker_blocks(self):
self.seed_agent("id6", 9437)
self.start_ok("id6")
self.svc("id6", "stop")
m = self.manifest("id6")
mk = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
mk.unlink()
result = self.svc("id6", "start", check=False)
self.assertNotEqual(result.returncode, 0, "marker 缺失即事件")
self.assertIn("marker", result.stdout)
self.assertFalse(self.running("id6"))
def test_T5b_foreign_marker_blocks(self):
self.seed_agent("id7", 9438)
self.start_ok("id7")
self.svc("id7", "stop")
m = self.manifest("id7")
mk = Path(m["identity"]["data_root"]) / ".agentx" / "data-marker.json"
data = json.loads(mk.read_text(encoding="utf-8"))
data["instance_id"] = "svc-other-zzzzzzzzzzzz"
mk.write_text(json.dumps(data), encoding="utf-8")
result = self.svc("id7", "start", check=False)
self.assertNotEqual(result.returncode, 0, "marker 身份不符即事件")
self.assertFalse(self.running("id7"))
def test_T5c_lost_manifest_blocks_not_rebuild(self):
self.seed_agent("id8", 9439)
self.start_ok("id8")
m0 = self.manifest("id8")
iid = m0["instance_id"]
marker_path = Path(m0["identity"]["data_root"]) / ".agentx" / "data-marker.json"
self.svc("id8", "stop")
(self.runtime("id8") / "manifest.json").unlink()
result = self.svc("id8", "start", check=False)
# manifest 丢失后 marker 仍在:重建会撞归属冲突 → 中止(I4),不得 legacy 复活
self.assertNotEqual(result.returncode, 0, "丢 manifest 不得静默重建/legacy 复活")
self.assertTrue(any(k in result.stdout for k in ("归属冲突", "身份治理冲突")),
result.stdout)
self.assertFalse(self.running("id8"))
# marker 保持原身份,未被新随机身份覆盖
mk = json.loads(marker_path.read_text(encoding="utf-8"))
self.assertEqual(mk["instance_id"], iid)
# ---- T6 cwd 无关:从别处调用身份不变 ----
def test_T6_cwd_independent_identity(self):
self.seed_agent("id9", 9440)
self.start_ok("id9")
iid = self.manifest("id9")["instance_id"]
self.svc("id9", "stop")
elsewhere = tempfile.mkdtemp(prefix="svc-elsewhere-")
self.addCleanup(shutil.rmtree, elsewhere, True)
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), "id9", "start"],
cwd=elsewhere, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
self.assertEqual(proc.returncode, 0, proc.stdout)
self.assertIn("已启动", proc.stdout)
self.assertEqual(self.manifest("id9")["instance_id"], iid, "换 cwd 不得换身份")
self.svc("id9", "stop")
# ---- 真实 PG 专项:显式 NOT_RUN 标注(不模拟) ----
def test_Z_real_pg_not_run(self):
# DB marker(agent_system_identity 表)与跨库身份断言属真实 PG 专项,
# 由隔离真实实例执行;本桩套件明确 NOT_RUN,不以桩冒充(设计 §7)。
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG 环境另行执行)")
class AtomicWriteTests(unittest.TestCase):
"""崩溃窗口单元级:os.replace 失败时原文件必须完好(I3 半写不可能)。"""
def test_replace_failure_keeps_original(self):
spec = importlib.util.spec_from_file_location(
"sm", SOURCE / "scripts" / "svc_manifest.py")
sm = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sm)
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "m.json")
sm.atomic_write(path, '{"v": 1}\n')
real_replace = os.replace
calls = {"n": 0}
def boom(a, b):
calls["n"] += 1
raise OSError("simulated crash before rename completes")
os.replace = boom
try:
try:
sm.atomic_write(path, '{"v": 2}\n')
except OSError:
pass
finally:
os.replace = real_replace
self.assertEqual(calls["n"], 1)
with open(path, encoding="utf-8") as f:
self.assertEqual(f.read(), '{"v": 1}\n', "rename 前崩溃必须保留完整旧版")
if __name__ == "__main__":
unittest.main()