Why: Second-resolution release IDs let staging delete a previous release when a same-source deployment ran within the same clock tick. Evidence: Fixed-clock rollback regression fails before and passes after; 250 governance checks pass. Independent review replays collision and copy-failure protection; receipts are in the primary task card. Impact: Add invocation identity and exclusive directory creation; keep old artifacts intact. Also retain the latest compatible frontend pointer. Refs: User-approved I2; Dev #483; Frontend #71/#72
245 lines
11 KiB
Python
245 lines
11 KiB
Python
"""svc.sh U2 回归:不可变版本目录(releases/)+ current 原子切换 + deploy.journal
|
||
+ 失败回退(2026-10-12 统一部署方案 U2)。
|
||
|
||
覆盖:release 布局与 current 解析(pidcheck 经未解析路径跨版本稳定)、启动失败
|
||
回退上一完整版本并恢复期望态、构建失败旧服务保持运行、中断切换经 start/recover
|
||
入口回退、schema 前进阻断回退(不触碰业务库)、legacy 布局兼容。夹具复用
|
||
tests/test_svc_identity.py(IdentityTests:manifest 治理 + 源仓轨道 + noid 桩)。
|
||
"""
|
||
import importlib.util
|
||
import json
|
||
import os
|
||
import shlex
|
||
import shutil
|
||
import subprocess
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
SOURCE = Path(__file__).resolve().parents[1]
|
||
|
||
_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)
|
||
|
||
_spec2 = importlib.util.spec_from_file_location("tsi", SOURCE / "tests" / "test_svc_identity.py")
|
||
tsi = importlib.util.module_from_spec(_spec2)
|
||
_spec2.loader.exec_module(tsi)
|
||
|
||
|
||
class U2ReleaseTests(tsi.IdentityTests):
|
||
"""manifest 治理 + identity 桩 + dev 轨道源仓(make_src_repo 等)。"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
# --from local 需要根为 git 仓(local@HEAD 作源标签);recover 夹具不初始化。
|
||
self.git_ok(self.root, "init", "--initial-branch=main", ".")
|
||
self.git_ok(self.root, "add", ".")
|
||
self.git_ok(self.root, "commit", "-m", "u2 fixture")
|
||
|
||
# ---- 夹具 ----
|
||
|
||
def cur_release(self, name):
|
||
c = self.runtime(name) / "current"
|
||
return c.resolve().name if c.is_symlink() else None
|
||
|
||
def active_bin(self, name):
|
||
return self.runtime(name) / "current" / "bin" / "agent-system"
|
||
|
||
def cmdline_of(self, pid):
|
||
return Path("/proc/%d/cmdline" % pid).read_bytes().replace(b"\0", b" ").decode()
|
||
|
||
def journal(self, name):
|
||
f = self.runtime(name) / "deploy.journal"
|
||
return json.loads(f.read_text()) if f.exists() else None
|
||
|
||
def die_stub(self):
|
||
"""identity 可用、serve 即退的桩(模拟新制品启动失败)。"""
|
||
p = self.root / "stub-agent-system-die"
|
||
if not p.exists():
|
||
stub = tsr.STUB
|
||
head, sep, tail = stub.partition(" serve)")
|
||
assert sep, "STUB 缺 serve 分支锚点"
|
||
_, sep2, tail2 = tail.partition(' wait "$child"\n ;;')
|
||
assert sep2, "STUB 缺 serve 分支结尾锚点"
|
||
p.write_text(head + ' serve)\n shift\n'
|
||
+ ' echo "die-stub: serve exits immediately" >&2\n'
|
||
+ " exit 1 ;;" + tail2)
|
||
p.chmod(0o755)
|
||
return p
|
||
|
||
def set_makefile(self, stub):
|
||
(self.root / "Makefile").write_text(
|
||
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % stub)
|
||
|
||
def bump_schema(self, name, version):
|
||
"""把库 schema_migrations 推进到 version(桩库无该表则建;模拟新制品
|
||
已迁移数据库)。"""
|
||
import sqlite3
|
||
db = self.runtime(name) / "data" / "agent-system.db"
|
||
con = sqlite3.connect(str(db))
|
||
con.execute(
|
||
"CREATE TABLE IF NOT EXISTS schema_migrations"
|
||
"(id INTEGER PRIMARY KEY, name TEXT, applied_at TEXT)")
|
||
for i in (1, 2, 3, version):
|
||
con.execute(
|
||
"INSERT OR IGNORE INTO schema_migrations(id,name,applied_at)"
|
||
" VALUES(?, 'u2-test', '2026-10-12T00:00:00Z')", (i,))
|
||
con.commit()
|
||
con.close()
|
||
|
||
# ---- T1:release 布局 + 启动失败回退 + 期望态恢复 ----
|
||
|
||
def test_U2_layout_switch_and_rollback_on_start_failure(self):
|
||
# Both deployments intentionally share the same clock tick: a failed
|
||
# candidate must never overwrite the version needed for rollback.
|
||
clock_bin = self.root / "fixed-clock"
|
||
clock_bin.mkdir()
|
||
real_date = shutil.which("date")
|
||
clock = clock_bin / "date"
|
||
clock.write_text(
|
||
'#!/bin/bash\n'
|
||
'if [[ "$*" == *"+%Y%m%dT%H%M%SZ"* ]]; then\n'
|
||
' echo 20261012T120000Z\n'
|
||
'else\n'
|
||
f' exec {shlex.quote(real_date)} "$@"\n'
|
||
'fi\n')
|
||
clock.chmod(0o755)
|
||
self.env["PATH"] = str(clock_bin) + os.pathsep + self.env["PATH"]
|
||
self.make_src_repo() # 预克隆 srccache(本用例走 --from local,仅保环境一致)
|
||
self.seed_agent("u2a", 9461)
|
||
self.start_ok("u2a") # legacy 运行中(期望态=running)
|
||
self.assertTrue(self.running("u2a"))
|
||
|
||
# v1 部署:legacy 领养 → current 切换 → 经 current 启动
|
||
self.set_makefile(self.stub)
|
||
result = self.svc("u2a", "deploy", "--from", "local")
|
||
self.assertIn("已切换 release", result.stdout)
|
||
self.assertIn("领养 legacy", result.stdout)
|
||
cur1 = self.cur_release("u2a")
|
||
self.assertTrue(cur1 and cur1.startswith("local-"), cur1)
|
||
self.assertTrue(self.active_bin("u2a").exists())
|
||
releases = sorted((self.runtime("u2a") / "releases").iterdir())
|
||
self.assertEqual(len(releases), 2, "legacy + v1 两个 release")
|
||
pid = self.pid_of("u2a")
|
||
self.assertIn("current/bin/agent-system", self.cmdline_of(pid),
|
||
"进程 cmdline 须走 current 未解析路径(pidcheck 跨版本稳定)")
|
||
self.assertIn("current/bin", self.kv("u2a", "pidcheck") or "")
|
||
v1_bin = self.active_bin("u2a").read_bytes()
|
||
self.assertEqual(v1_bin, Path(self.stub).read_bytes(), "v1 制品 = 可用桩")
|
||
self.assertIsNone(self.journal("u2a"), "成功后 journal 清理")
|
||
|
||
# v2 部署:serve 即退 → 回退 v1 并恢复运行
|
||
self.set_makefile(self.die_stub())
|
||
result = self.svc("u2a", "deploy", "--from", "local", check=False)
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("已回退上一版本", result.stdout)
|
||
self.assertTrue(self.running("u2a"), "回退须按原期望态恢复运行")
|
||
pid2 = self.pid_of("u2a")
|
||
self.assertNotEqual(pid2, pid)
|
||
self.assertIn("current/bin/agent-system", self.cmdline_of(pid2))
|
||
self.assertEqual(self.active_bin("u2a").read_bytes(), v1_bin, "回退后制品=v1")
|
||
hist = (self.runtime("u2a") / "history.jsonl").read_text()
|
||
self.assertIn('"event":"deploy"', hist)
|
||
self.assertIn("rollback", hist)
|
||
self.assertIsNone(self.journal("u2a"))
|
||
self.svc("u2a", "stop")
|
||
|
||
# ---- T2:构建失败 → 旧服务保持运行、current 不动 ----
|
||
|
||
def test_U2_build_failure_keeps_old_running(self):
|
||
self.seed_agent("u2b", 9462)
|
||
self.set_makefile(self.stub)
|
||
self.svc("u2b", "deploy", "--from", "local")
|
||
cur1 = self.cur_release("u2b")
|
||
pid = self.pid_of("u2b")
|
||
(self.root / "Makefile").write_text("build:\n\texit 1\n")
|
||
result = self.svc("u2b", "deploy", "--from", "local", check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
self.assertIn("构建失败", result.stdout)
|
||
self.assertTrue(self.running("u2b"), "构建失败旧服务必须保持运行")
|
||
self.assertEqual(self.pid_of("u2b"), pid)
|
||
self.assertEqual(self.cur_release("u2b"), cur1, "current 指针不动")
|
||
self.svc("u2b", "stop")
|
||
|
||
# ---- T3:中断切换(journal switched)→ start 入口回退并恢复 ----
|
||
|
||
def test_U2_journal_interrupt_resolved_on_start(self):
|
||
self.seed_agent("u2c", 9463)
|
||
self.set_makefile(self.stub)
|
||
self.svc("u2c", "deploy", "--from", "local")
|
||
v1 = self.cur_release("u2c")
|
||
self.svc("u2c", "stop")
|
||
# 伪造中断于「指针已切」:current → 假 release,journal switched
|
||
fake = self.runtime("u2c") / "releases" / "fake-interrupt"
|
||
(fake / "bin").mkdir(parents=True)
|
||
shutil.copy2(self.die_stub(), fake / "bin" / "agent-system")
|
||
(self.runtime("u2c") / "current").unlink()
|
||
(self.runtime("u2c") / "current").symlink_to("releases/fake-interrupt")
|
||
(self.runtime("u2c") / "deploy.journal").write_text(json.dumps({
|
||
"phase": "switched", "new": "fake-interrupt", "prev": v1,
|
||
"desired": "running", "ts": "2026-10-12T00:00:00Z"}))
|
||
|
||
result = self.svc("u2c", "start")
|
||
self.assertIn("中断的切换", result.stdout)
|
||
self.assertIn("已回退上一版本", result.stdout)
|
||
self.assertTrue(self.running("u2c"))
|
||
self.assertEqual(self.cur_release("u2c"), v1)
|
||
self.assertIsNone(self.journal("u2c"))
|
||
self.svc("u2c", "stop")
|
||
|
||
# ---- T4:schema 前进 → 回退阻断,不触碰业务库 ----
|
||
|
||
def test_U2_schema_advance_blocks_rollback(self):
|
||
self.seed_agent("u2d", 9464)
|
||
self.set_makefile(self.stub)
|
||
self.svc("u2d", "deploy", "--from", "local")
|
||
v1 = self.cur_release("u2d")
|
||
self.svc("u2d", "stop")
|
||
# v1 提交时点 schema=3;库被「新制品」推进到 5
|
||
rf = self.runtime("u2d") / "releases" / v1 / "release.json"
|
||
data = json.loads(rf.read_text())
|
||
data["schema_at_commit"] = "3"
|
||
rf.write_text(json.dumps(data))
|
||
self.bump_schema("u2d", 5)
|
||
fake = self.runtime("u2d") / "releases" / "fake-advance"
|
||
(fake / "bin").mkdir(parents=True)
|
||
shutil.copy2(self.die_stub(), fake / "bin" / "agent-system")
|
||
(self.runtime("u2d") / "current").unlink()
|
||
(self.runtime("u2d") / "current").symlink_to("releases/fake-advance")
|
||
(self.runtime("u2d") / "deploy.journal").write_text(json.dumps({
|
||
"phase": "switched", "new": "fake-advance", "prev": v1,
|
||
"desired": "running", "ts": "2026-10-12T00:00:00Z"}))
|
||
|
||
result = self.svc("u2d", "start", check=False)
|
||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||
self.assertIn("禁止自动回退", result.stdout)
|
||
self.assertFalse(self.running("u2d"), "阻断后保持停止")
|
||
j = self.journal("u2d")
|
||
self.assertEqual(j["phase"], "blocked")
|
||
# 业务库不被回写:schema 仍是 5
|
||
import sqlite3
|
||
con = sqlite3.connect(str(self.runtime("u2d") / "data" / "agent-system.db"))
|
||
self.assertEqual(
|
||
con.execute("select max(id) from schema_migrations").fetchone()[0], 5)
|
||
con.close()
|
||
# blocked 下普通动作被拒;deploy 是解除路径(此处仅验证拒绝语义)
|
||
result = self.svc("u2d", "restart", check=False)
|
||
self.assertNotEqual(result.returncode, 0)
|
||
self.assertIn("切换被阻断", result.stdout)
|
||
|
||
# ---- T5:legacy 布局未迁移 → 裸 start/restart 仍走原路径(兼容) ----
|
||
|
||
def test_U2_legacy_layout_bare_lifecycle_still_works(self):
|
||
self.seed_agent("u2e", 9465)
|
||
self.start_ok("u2e")
|
||
self.assertFalse((self.runtime("u2e") / "current").exists())
|
||
self.assertTrue((self.runtime("u2e") / "bin" / "agent-system").exists())
|
||
self.svc("u2e", "stop")
|
||
self.start_ok("u2e")
|
||
self.assertTrue(self.running("u2e"))
|
||
self.svc("u2e", "stop")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|