Why: after #415 file markers, startup must also bind manifest.instance_id to agent_system_identity (I4). Evidence: python3 -m unittest tests.test_svc_identity tests.test_svc_recover (OK, 1 skipped NOT_RUN for real PG); Backend pin b92eaca (PR #170). Impact: svc ensure/verify + AGENT_SYSTEM_* env; stub identity; I3 SQLite tests; design doc status; modules/backend gitlink. Refs: docs/recovery/u1-identity-manifest.md §5.2; Backend #170 Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""svc.sh 实例身份回归(U1 设计 §7 T1–T6 + I3 DB marker,manifest 治理)。
|
||
|
||
覆盖:连续裸启停身份稳定(T1)、只改端口锚点不动(T2)、数据根漂移拒绝(T3)、
|
||
迁移窗口/损坏阻塞不猜测(T4)、marker 缺失/外宾/manifest 丢失均为事件(T5)、
|
||
cwd 无关性(T6)、SQLite DB marker ensure/错 id 阻塞(I3 桩路径)、原子写崩溃窗口。
|
||
桩二进制与夹具复用 tests/test_svc_recover.py。真实 PG 身份专项(跨库)在
|
||
SVC_TEST_PG=1 且具备环境时执行;否则 test_Z_real_pg_not_run 显式 NOT_RUN。
|
||
"""
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import shutil
|
||
import sqlite3
|
||
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")
|
||
|
||
# ---- I3 DB marker(SQLite 桩路径;与真实 PG 跨库专项分离) ----
|
||
|
||
def db_identity_row(self, name):
|
||
m = self.manifest(name)
|
||
if not m:
|
||
return None
|
||
db = Path(m["identity"]["data_root"]) / "agent-system.db"
|
||
if not db.exists():
|
||
return None
|
||
con = sqlite3.connect(str(db))
|
||
try:
|
||
rows = list(con.execute(
|
||
"SELECT instance_id, dbref FROM agent_system_identity"))
|
||
finally:
|
||
con.close()
|
||
return rows
|
||
|
||
def test_I3_start_ensures_sqlite_identity(self):
|
||
self.seed_agent("idb1", 9441)
|
||
self.start_ok("idb1")
|
||
iid = self.manifest("idb1")["instance_id"]
|
||
rows = self.db_identity_row("idb1")
|
||
self.assertEqual(rows, [(iid, "sqlite")], rows)
|
||
self.svc("idb1", "stop")
|
||
self.start_ok("idb1") # ensure 幂等 + verify
|
||
self.assertEqual(self.db_identity_row("idb1"), [(iid, "sqlite")])
|
||
self.svc("idb1", "stop")
|
||
|
||
def test_I3_wrong_db_identity_blocks_start(self):
|
||
self.seed_agent("idb2", 9442)
|
||
self.start_ok("idb2")
|
||
iid = self.manifest("idb2")["instance_id"]
|
||
self.svc("idb2", "stop")
|
||
db = Path(self.manifest("idb2")["identity"]["data_root"]) / "agent-system.db"
|
||
con = sqlite3.connect(str(db))
|
||
con.execute("DELETE FROM agent_system_identity")
|
||
con.execute(
|
||
"INSERT INTO agent_system_identity(instance_id,dbref,schema_version,created_at)"
|
||
" VALUES(?,?,?,?)",
|
||
("svc-foreign-deadbeef", "sqlite", 26, "2026-01-01T00:00:00Z"))
|
||
con.commit()
|
||
con.close()
|
||
result = self.svc("idb2", "start", check=False)
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertTrue(any(k in result.stdout for k in ("DB 身份", "ensure", "mismatch", "校验失败")),
|
||
result.stdout)
|
||
self.assertFalse(self.running("idb2"))
|
||
# 外宾行不得被 start 静默改写
|
||
self.assertEqual(self.db_identity_row("idb2"),
|
||
[("svc-foreign-deadbeef", "sqlite")])
|
||
self.assertNotEqual(iid, "svc-foreign-deadbeef")
|
||
|
||
# ---- 真实 PG 专项:跨库身份;缺环境显式 NOT_RUN ----
|
||
|
||
def test_Z_real_pg_not_run(self):
|
||
if os.environ.get("SVC_TEST_PG") == "1" and os.environ.get("AGENT_PG_TEST_DSN"):
|
||
self.skipTest("真 PG 专项应由隔离套件执行(本桩文件不连真实 PG)")
|
||
self.skipTest("真实 PG 身份专项 NOT_RUN:需真实实例(SVC_TEST_PG=1 + AGENT_PG_TEST_DSN)")
|
||
|
||
|
||
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()
|