Merge pull request 'fix(deploy): 切换时重建单个 PM2 应用定义并保留回滚日志' (#4) from codex/production-48-pm2-definition into main

This commit is contained in:
WuYongJie 2026-10-30 19:16:50 +08:00
commit ae4605d479
4 changed files with 93 additions and 6 deletions

View File

@ -60,10 +60,22 @@ python scripts/production48.py rollback --release <发布ID>
`activate` 会重新核对生产版本和任务状态,拒绝在最近一小时仍有未结束生成任务时切换。
更早的历史挂起记录只写入发布回执,不会自动修改数据库中的状态。
它保存旧 PM2 配置和启动快照,然后仅更新 `ai-chatbot` 的工作目录并重启这个应用,
它保存旧 PM2 配置和启动快照,然后仅重建 `ai-chatbot` 这一项 PM2 定义,
让新版本的工作目录、可执行路径和环境变量全部生效,不改变其他应用,
因此会有短暂切换窗口。健康检查同时核对进程目录、构建 ID、首页和路由。
检查失败自动恢复旧配置并再次验活;通过后执行 `pm2 save`,让重启后使用已验收版本。
PM2 的普通 restart 会保留旧工作目录和程序路径,因此不能用它替代本流程的单应用定义重建。
若一次切换已经明确回滚并验证原版本健康,修复发布工具后,可保留已校验制品并显式重试:
```powershell
python scripts/production48.py activate --release <发布ID> --retry-rolled-back
```
只有日志阶段为 `rolled-back` 且当前工作目录/构建目录仍匹配原版本才允许重试。
旧尝试日志另外保留。运行制品的 `sourceCommit` 始终指向当时实际构建的 main 提交,
后续工具修复不改写这个来源;`operatorCommit` 单独记录执行切换的维护工具版本。
## 运行资料与边界
每个发布目录包含构建机按锁文件安装的完整运行依赖和 public 源码资源。

View File

@ -226,11 +226,13 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["status", "jobs", "build", "stage", "activate", "rollback"])
parser.add_argument("--release")
parser.add_argument("--retry-rolled-back", action="store_true")
parser.add_argument("--key", type=Path, default=Path.home() / ".ssh/45.192.99.48.pem")
parser.add_argument("--jump-key", type=Path, default=Path.home() / ".ssh/youyidian_prod_ed25519")
parser.add_argument("--known-hosts", type=Path, default=Path.home() / ".ssh/known_hosts")
parser.add_argument("--receipts", type=Path, default=ROOT / ".git/releases")
args = parser.parse_args()
require(not args.retry_rolled_back or args.action == "activate", "Retry flag only applies to activation")
if args.action in {"stage", "activate", "rollback"}:
require(args.release and re.fullmatch(r"\d{8}T\d{6}Z-[0-9a-f]{12}", args.release), "--release is required")
args.receipts = args.receipts.resolve()
@ -241,6 +243,10 @@ def main():
stage(connections, args)
else:
kwargs = {"release": args.release} if args.release else {}
if args.action == "activate":
kwargs["operator-commit"] = git("rev-parse", "HEAD")
if args.retry_rolled_back:
kwargs["retry"] = "rolled-back"
result = connections.helper(args.action, **kwargs)
if args.release:
write_json(args.receipts / args.release / (args.action + ".json"), result)

View File

@ -384,21 +384,34 @@ def healthy(cwd, build_id):
def restart(config):
run(["pm2", "startOrRestart", str(config), "--only", NAME, "--update-env"], timeout=150)
apps = json.loads(config.read_text())["apps"]
require(len(apps) == 1 and apps[0]["name"] == NAME, "Refusing a multi-application PM2 definition")
current = [app for app in json.loads(run(["pm2", "jlist"])) if app["name"] == NAME]
require(len(current) <= 1, "Duplicate ai-chatbot definitions; operator required")
# PM2 restart updates env but retains the old executable/cwd. Recreate only
# this name so the new immutable release's process definition really applies.
if current:
run(["pm2", "delete", NAME], timeout=150)
run(["pm2", "start", str(config), "--only", NAME], timeout=150)
def activate(release_id):
def activate(release_id, retry=False, operator_commit=None):
destination = release_path(release_id)
manifest = json.loads((destination / "release.json").read_text())
staged = json.loads((destination / "staged.json").read_text())
app, current, dist = app_state()
require(str(current) == staged["previousCwd"] and dist == staged["previousDistDir"],
"Production changed after staging")
require(not (destination / "activation.json").exists(), "Already activated; inspect status")
journal = destination / "activation.json"
prior = json.loads(journal.read_text()) if journal.exists() else None
require(not prior or (retry and prior.get("phase") == "rolled-back"),
"Already activated; inspect status before explicitly retrying a rolled-back attempt")
verify_runtime(manifest, destination)
jobs = job_status()
require(not any(row["recent"] for rows in jobs.values() for row in rows),
"Recent active generation jobs: defer activation")
if prior:
json_write(destination / f"activation-history-{time.time_ns()}.json", prior)
before = ecosystem(app, current, dist)
previous_build = (current / dist / "BUILD_ID").read_text().strip()
json_write(destination / "previous.pm2.json", before)
@ -410,7 +423,7 @@ def activate(release_id):
"previousBuildId": previous_build, "previousCwd": str(current),
"activatedAtUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"historicalTaskStates": jobs, "artifactSha256": staged["artifactSha256"],
"phase": "activating"}
"phase": "activating", "operatorCommit": operator_commit}
json_write(destination / "activation.json", receipt)
try:
restart(destination / "candidate.pm2.json")
@ -453,6 +466,8 @@ def main():
parser.add_argument("--release")
parser.add_argument("--digest")
parser.add_argument("--workspace", type=Path)
parser.add_argument("--retry", choices=["rolled-back"])
parser.add_argument("--operator-commit")
args = parser.parse_args()
os.umask(0o077)
if args.action in {"pack", "smoke"}:
@ -469,7 +484,8 @@ def main():
if args.action == "stage":
result = stage(args.release, args.digest)
elif args.action == "activate":
result = activate(args.release)
result = activate(args.release, retry=args.retry == "rolled-back",
operator_commit=args.operator_commit)
else:
result = rollback(args.release)
print(json.dumps(result))

View File

@ -222,6 +222,59 @@ class ActivationTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "Rollback health check failed"):
host.activate(self.release_id)
def test_explicit_retry_preserves_the_failed_attempt(self):
host.json_write(self.target / "activation.json", {"phase": "rolled-back", "previousBuildId": "old-build"})
with patch.object(host, "healthy", return_value=True):
result = host.activate(self.release_id, retry=True, operator_commit="tool-commit")
self.assertEqual(result["phase"], "active")
self.assertEqual(result["operatorCommit"], "tool-commit")
history = list(self.target.glob("activation-history-*.json"))
self.assertEqual(len(history), 1)
self.assertEqual(json.loads(history[0].read_text())["phase"], "rolled-back")
def test_retry_cannot_replace_an_active_attempt(self):
host.json_write(self.target / "activation.json", {"phase": "active"})
with self.assertRaisesRegex(RuntimeError, "Already activated"):
host.activate(self.release_id, retry=True)
self.restart.assert_not_called()
def test_failed_process_start_also_restores_previous_definition(self):
self.restart.side_effect = [RuntimeError("candidate start failed"), None]
with patch.object(host, "healthy", return_value=True):
with self.assertRaisesRegex(RuntimeError, "candidate start failed"):
host.activate(self.release_id)
self.assertEqual(self.restart.call_args_list[-1].args[0].name, "previous.pm2.json")
class ProcessDefinitionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.config = Path(self.temporary.name) / "candidate.json"
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot", "cwd": "/new/release"}]}))
def tearDown(self):
self.temporary.cleanup()
def test_recreates_only_target_application(self):
with patch.object(host, "run", side_effect=[json.dumps([{"name": "ai-chatbot"}, {"name": "other-service"}]), "", ""]) as run:
host.restart(self.config)
self.assertEqual([call.args[0] for call in run.call_args_list], [
["pm2", "jlist"], ["pm2", "delete", "ai-chatbot"],
["pm2", "start", str(self.config), "--only", "ai-chatbot"]])
def test_can_restore_after_failed_candidate_left_no_process(self):
with patch.object(host, "run", side_effect=["[]", ""]) as run:
host.restart(self.config)
self.assertEqual(len(run.call_args_list), 2)
self.assertEqual(run.call_args_list[-1].args[0][1], "start")
def test_refuses_definitions_that_include_other_applications(self):
self.config.write_text(json.dumps({"apps": [{"name": "ai-chatbot"}, {"name": "other"}]}))
with patch.object(host, "run") as run:
with self.assertRaisesRegex(RuntimeError, "multi-application"):
host.restart(self.config)
run.assert_not_called()
class HelperTests(unittest.TestCase):
def test_production_e2e_switch_blocks_release(self):