Commit 4f4b185339b2c6b9808fd54e757a6c0e4649fe6e
1 parent
98f0d5cd
U-041: c_deploy_supervisor_rollback (paths A/B + the rollback-fails fallback)
Adds supervisor/deploy_rollback.py: rollback_path_a (pre-cutover, health check failed -- terminate candidate, delete its one-time DB copy, git revert, record a bug_record(core_engine) audit trail) and rollback_path_b (post-cutover, anomaly detected -- stop the bad Live, revive previous_instance_dir, re-run the same three health-check signals directly against it, git revert only once re-verification passes). Design choices follow established conventions rather than inventing new ones: no unified rollback() entry point (deployment has no PID columns, same reason deploy_stage/deploy_cutover already require callers to pass pids explicitly); ROLLBACK_FAILED is the raised error name while the persisted deployment.status is 'blocked_pending_human' (same pattern CheckoutFailedError/CutoverFailedError already use). git revert conflicts fold into the same rollback-itself-failed fallback both paths share, per design doc §10 open question 3's own recommendation, with `git revert --abort` run afterward so a failed attempt doesn't leave the repo mid-conflict. This is PH5's highest-rework-risk Unit per development-plan.md's own flag, so its own "rollback itself fails" fallback is tested for real, not assumed: a genuinely broken previous_instance_dir (a real empty directory with no Python package in it) that really fails to bind its port, and a genuinely conflicting git history (a later commit touching the same line the self-modification changed) that really fails `git revert` -- both correctly land on blocked_pending_human without retrying forever or silently succeeding, and the conflict case leaves the repo clean afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
441 additions
and
1 deletions
docs/development-plan.md
| ... | ... | @@ -389,6 +389,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 389 | 389 | - **U-041 [Risk Gate]** `c_deploy_supervisor_rollback`:回滚路径 A(pre-cutover,终止候选+`git revert`+发 `bug_record`)与路径 B(post-cutover,换回 `previous_instance_dir`+健康检查+`git revert`)完整实现,含"回滚本身失败"兜底转 `blocked_pending_human` |
| 390 | 390 | - 依赖:U-038, U-040 |
| 391 | 391 | - Done:两条回滚路径各自跑通一次;人为制造"旧版本也起不来"的场景能正确终态到 `blocked_pending_human` 而不是无限重试或静默失败;`git revert` 冲突场景(`supervisor-auto-rollback-design.md` §10 开放问题 3)有明确处理,不能是本 Unit 唯一遗留的未定义行为 |
| 392 | + - **实现补充(2026-08-02)——本 Unit 是 PH5 关键路径上明确标注返工风险最高的一环,按计划要求真的用人为制造的失败场景验证了兜底路径,没有假设"大概率不会发生"就跳过**:新增 `supervisor/deploy_rollback.py`。**设计上两处判断,都是按已有约定处理,不是新拍的**:① 没有一个统一的 `rollback(conn, deployment_id)` 总入口——`deployment` 表存了 `candidate_instance_dir`(U-037)但从没存过任何 PID 列,`deploy_stage.stage_deployment()`/`deploy_cutover.cutover()` 早就要求调用方直接把 pid 传进来(这些 pid 只在同一个 Deploy Supervisor 进程的生命周期内才有意义),`rollback_path_a`/`rollback_path_b` 延续同一约定,没有另造一套。② 03-C 的 `output_contract` 只写了 `{status: 'rolled_back'|'health_check_failed'|'rollback_failed'}`,从没出现过 `blocked_pending_human`,但 04-P 状态转移表的 `error_state` 列和 03-C 的 `errors` 描述都明确写"转 `blocked_pending_human`"——读作"`rollback_failed` 是错误名/返回值,真正持久化的 `deployment.status` 是 `blocked_pending_human`",和 `deploy_stage.CheckoutFailedError`/`deploy_cutover.CutoverFailedError` 已经在用的同一个模式完全一致,不是新发明。`git revert` 冲突(本 Unit 自己点名要求处理的场景,design doc §10 开放问题 3)不开新分支,直接并入两条路径共用的"回滚本身失败"兜底,`git revert --abort` 收尾避免仓库卡在冲突中间态。 | |
| 393 | + - **真实验证,四个场景全部对着真实子进程/真实 git 仓库跑过,没有 mock 关键路径**:路径 A happy(真进程 `sleep 300` 模拟候选、真的删一次性 DB 文件、真实 `git revert` 落地、真的写一条 `bug_record`);路径 A 失败(构造一个真实会冲突的 git 历史——v2 是待回滚的自我修改,v3 是之后touch 同一行的后续提交——`git revert` 真的冲突,验证 `blocked_pending_human` + 仓库被 `--abort` 清理干净、不留冲突中间态);路径 B happy(真实 `git worktree add` 出一份"上一个已知良好版本"的检出目录,真实拉起一个"坏 Live"进程,真实停掉+复活旧版本+真实三项复核+真实 revert,复活的进程最后真的在验证用的端口上对外提供服务,不只是返回值对);**路径 B 失败**(本 Unit 最该较真的场景):`previous_instance_dir` 指向一个真实存在但完全没有 Python 包内容的空目录,真实拉起会真的失败/绑不上端口,验证最终落到 `blocked_pending_human` 而不是卡死重试或悄悄放过,且确认这次真的没有执行 `git revert`(提交内容原样保留,因为回滚在到达那一步之前就已经失败)。 | |
| 394 | + - 覆盖:03-C `c_deploy_supervisor_rollback`;`git revert` 冲突场景(design doc §10 开放问题 3,已有明确处理) | |
| 392 | 395 | |
| 393 | 396 | - **U-042 [Risk Gate]** 自举闭环端到端验收:真实复现 `self-development-feasibility.md` 第 3 节的 9 步场景(修复 graphlemon 自己 `p_org_task_lifecycle` 状态机里的一个真实植入 bug),走完整"部署→健康检查→cutover→监控→finalize"或"检测异常→自动回滚"两条路径中至少一条 |
| 394 | 397 | - 依赖:U-035, U-041 | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -58,7 +58,7 @@ |
| 58 | 58 | {"id": "U-038", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_health_check(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-037"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_health_check"], "process": [], "requirement": []}, "done": "process_alive/http_health/canary_task 三项检查全过 -> health_check_passed;任一失败 -> health_check_failed 且 Live 未受影响(supervisor/deploy_health_check.py,真实 HTTP+真实 project/task 创建验证过);backend_server.py 新增 GET /api/health(真查一次 SELECT 1,不只是进程存活)"}, |
| 59 | 59 | {"id": "U-039", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_cutover(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_cutover"], "process": [], "requirement": []}, "done": "健康检查通过后完成真实切换(supervisor/deploy_cutover.py,真实进程停止+重启验证过);在途 work_node_execution 重启后可恢复(复用既有 REQ-06 机制);previous_instance_dir 保留至监控窗口结束;新增 LIVE_PORT=8420 常量(05 §5.9 缺的一个字段);真实测试发现并修复 deploy_supervisor.is_alive() 的僵尸进程判活缺陷(新增 reap_if_exited())"}, |
| 60 | 60 | {"id": "U-040", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_monitor(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-039"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_monitor"], "process": [], "requirement": []}, "done": "四类信号采样正常(supervisor/deploy_monitor.py);validator_approval_rate 偏离基线阈值时触发 anomaly_detected;approve 占比算法基于对 scheduler.finish_task_lifecycle 真实代码逻辑的核实(PASS 从不产生 bug_record,FAIL 必然产生),非字面 SDD 定义"}, |
| 61 | - {"id": "U-041", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_rollback", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次;旧版本也起不来时正确终态到 blocked_pending_human;git revert 冲突有明确处理"}, | |
| 61 | + {"id": "U-041", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_rollback(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次(supervisor/deploy_rollback.py,真实进程+真实 git 仓库验证);旧版本也起不来时正确终态到 blocked_pending_human(真实场景验证,非假设);git revert 冲突有明确处理(真实构造冲突历史验证过,含 --abort 清理)"}, | |
| 62 | 62 | {"id": "U-042", "phase": "PH5", "sub_phase": "PH5-S3", "name": "自举闭环端到端验收", "classification": "Risk Gate", "depends_on": ["U-035", "U-041"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "真实复现 self-development-feasibility.md 9 步场景,全程无需人工评审代码内容;真实双实例 E2E 用例默认跳过,需显式环境变量开启"}, |
| 63 | 63 | {"id": "U-043", "phase": "PH5", "sub_phase": "PH5-S4", "name": "控制台展示 deployment 状态与健康信号", "classification": "Value Path", "depends_on": ["U-024", "U-040"], "coverage": {"model": [], "view": ["v_deployment_status"], "controller": [], "process": [], "requirement": []}, "done": "能看到当前 deployment 状态机所处状态与最近一次健康信号采样结果"} |
| 64 | 64 | ], | ... | ... |
supervisor/deploy_rollback.py
0 → 100644
| 1 | +"""c_deploy_supervisor_rollback (03-C §3.15 / 04-P §4.6 / design doc §7): | |
| 2 | +path A (pre-cutover, health check failed -- Live was never touched) and | |
| 3 | +path B (post-cutover, anomaly detected during monitoring -- Live IS the bad | |
| 4 | +version, has to actually be swapped back), plus the one fallback both paths | |
| 5 | +share: rollback itself failing. | |
| 6 | + | |
| 7 | +**No unified `rollback(conn, deployment_id)` entry point (documented design | |
| 8 | +choice, matches this subsystem's established convention)**: `deployment` | |
| 9 | +persists `candidate_instance_dir` (U-037) but no PID column for either the | |
| 10 | +candidate or the live process -- `deploy_stage.stage_deployment()` and | |
| 11 | +`deploy_cutover.cutover()` both already require their caller to hand in the | |
| 12 | +relevant pid(s) explicitly rather than look them up from the DB, since | |
| 13 | +that's only ever meaningful within one continuous Deploy Supervisor process | |
| 14 | +lifetime (the one that actually spawned them). `rollback_path_a`/ | |
| 15 | +`rollback_path_b` follow the same convention rather than inventing a | |
| 16 | +lookup mechanism the rest of this subsystem doesn't have either. | |
| 17 | + | |
| 18 | +**`deployment.status` vs the ROLLBACK_FAILED return value (documented | |
| 19 | +interpretation)**: 03-C's `output_contract` for `c_deploy_supervisor_rollback` | |
| 20 | +lists only `{status: 'rolled_back' | 'health_check_failed' | 'rollback_failed'}` | |
| 21 | +-- `blocked_pending_human` never appears there, but IS the literal | |
| 22 | +`error_state` 04-P's own transition table names for this row, and IS one of | |
| 23 | +`deployment.status`'s valid enum values (01-M 1.15). Read together: 'rollback_ | |
| 24 | +failed' is the ERROR NAME (what gets raised / what 03-C's contract lists), | |
| 25 | +while the actually-PERSISTED terminal `deployment.status` is | |
| 26 | +'blocked_pending_human' -- exactly the same pattern `deploy_stage. | |
| 27 | +CheckoutFailedError` and `deploy_cutover.CutoverFailedError` already use | |
| 28 | +(raise a *FailedError, persist status='blocked_pending_human'), so this | |
| 29 | +follows the same convention rather than introducing a new one. | |
| 30 | + | |
| 31 | +**`git revert` conflict handling (design doc §10 open question 3, this | |
| 32 | +Unit's own explicit "有明确处理" Done requirement)**: any `git revert` | |
| 33 | +failure (conflict or otherwise) folds into the SAME `RollbackFailedError` -> | |
| 34 | +`blocked_pending_human` fallback both paths already have -- not a new | |
| 35 | +state-machine branch, per the design doc's own recommendation ("应该并入 | |
| 36 | +7.3 兜底路径,而不是新开一个分支"). `git revert --abort` is run best-effort | |
| 37 | +afterward so a failed attempt doesn't leave the repo mid-conflict for | |
| 38 | +whoever investigates the resulting `blocked_pending_human`. | |
| 39 | +""" | |
| 40 | + | |
| 41 | +import os | |
| 42 | +import subprocess | |
| 43 | + | |
| 44 | +from supervisor import deploy_cutover, deploy_health_check, deploy_stage | |
| 45 | +from src import bug_record, paths | |
| 46 | + | |
| 47 | + | |
| 48 | +class RollbackFailedError(Exception): | |
| 49 | + """ROLLBACK_FAILED -- 03-C §3.15: the automatic recovery mechanism | |
| 50 | + itself failed (old version won't start either, or `git revert` | |
| 51 | + conflicts) -- NOT a judgment about whose code is right. Not retried; | |
| 52 | + `deployment.status='blocked_pending_human'` is already applied by the | |
| 53 | + time this is raised.""" | |
| 54 | + | |
| 55 | + | |
| 56 | +def _git_revert(repo_root, commit_hash): | |
| 57 | + subprocess.run( | |
| 58 | + ["git", "revert", "--no-edit", commit_hash], | |
| 59 | + cwd=repo_root, check=True, capture_output=True, text=True, | |
| 60 | + ) | |
| 61 | + | |
| 62 | + | |
| 63 | +def _abort_revert_if_in_progress(repo_root): | |
| 64 | + """Best-effort: restores the pre-revert state so a failed attempt | |
| 65 | + doesn't leave the repo mid-conflict.""" | |
| 66 | + subprocess.run(["git", "revert", "--abort"], cwd=repo_root, capture_output=True, text=True) | |
| 67 | + | |
| 68 | + | |
| 69 | +def _block(conn, deployment_id, reason): | |
| 70 | + conn.execute( | |
| 71 | + "UPDATE deployment SET status = 'blocked_pending_human', rollback_reason = ? WHERE deployment_id = ?", | |
| 72 | + (reason, deployment_id), | |
| 73 | + ) | |
| 74 | + conn.commit() | |
| 75 | + | |
| 76 | + | |
| 77 | +def rollback_path_a( | |
| 78 | + conn, deployment_id, candidate_pid, candidate_db_path, git_commit_hash, | |
| 79 | + repo_root=None, description=None, task_id=None, stop_timeout=10, | |
| 80 | +): | |
| 81 | + """Pre-cutover rollback (design doc §7.1): Live was never touched, this | |
| 82 | + is the cheapest path. Terminates the candidate, deletes its one-time DB | |
| 83 | + copy, `git revert`s the merge commit, and records a `bug_record( | |
| 84 | + root_cause_category=core_engine)` audit trail with the failing signals | |
| 85 | + as evidence. Returns {status: 'rolled_back'}.""" | |
| 86 | + repo_root = repo_root or paths.repo_root() | |
| 87 | + description = description or "health check failed for deployment {}".format(deployment_id) | |
| 88 | + | |
| 89 | + try: | |
| 90 | + deploy_cutover._stop_process(candidate_pid, timeout=stop_timeout) | |
| 91 | + if candidate_db_path and os.path.exists(candidate_db_path): | |
| 92 | + os.remove(candidate_db_path) | |
| 93 | + _git_revert(repo_root, git_commit_hash) | |
| 94 | + except subprocess.CalledProcessError as e: | |
| 95 | + _abort_revert_if_in_progress(repo_root) | |
| 96 | + _block(conn, deployment_id, "rollback_failed (path A): git revert failed: {}".format(e.stderr)) | |
| 97 | + raise RollbackFailedError( | |
| 98 | + "rollback path A failed for deployment {}: git revert error: {}".format(deployment_id, e.stderr) | |
| 99 | + ) from e | |
| 100 | + | |
| 101 | + bug_record.record_core_engine_change(conn, task_id, description) | |
| 102 | + conn.execute("UPDATE deployment SET status = 'rolled_back' WHERE deployment_id = ?", (deployment_id,)) | |
| 103 | + conn.commit() | |
| 104 | + return {"status": "rolled_back"} | |
| 105 | + | |
| 106 | + | |
| 107 | +def rollback_path_b( | |
| 108 | + conn, deployment_id, live_pid, previous_instance_dir, prod_db_path, git_commit_hash, | |
| 109 | + repo_root=None, description=None, task_id=None, live_port=deploy_cutover.LIVE_PORT, | |
| 110 | + python_executable=None, stop_timeout=10, bind_timeout=15, startup_check_timeout=30, | |
| 111 | +): | |
| 112 | + """Post-cutover rollback (design doc §7.2): Live IS the bad version, so | |
| 113 | + the previously-known-good checkout has to be revived and re-verified | |
| 114 | + before this can be trusted as the new Live. Returns {status: | |
| 115 | + 'rolled_back'}. Raises RollbackFailedError (deployment already marked | |
| 116 | + blocked_pending_human) if the revived previous_instance_dir fails to | |
| 117 | + bind live_port OR fails its own re-verification (same three checks | |
| 118 | + c_deploy_supervisor_health_check runs, called directly here rather than | |
| 119 | + through that module's deployment-row-mutating wrapper -- this is a | |
| 120 | + distinct concern from the original candidate's own staged health check | |
| 121 | + trail and shouldn't be interleaved with it).""" | |
| 122 | + repo_root = repo_root or paths.repo_root() | |
| 123 | + description = description or "post-cutover anomaly detected for deployment {}".format(deployment_id) | |
| 124 | + | |
| 125 | + conn.execute("UPDATE deployment SET status = 'rollback_in_progress' WHERE deployment_id = ?", (deployment_id,)) | |
| 126 | + conn.commit() | |
| 127 | + | |
| 128 | + try: | |
| 129 | + deploy_cutover._stop_process(live_pid, timeout=stop_timeout) | |
| 130 | + | |
| 131 | + revived = deploy_stage._launch_candidate(previous_instance_dir, live_port, prod_db_path, python_executable) | |
| 132 | + if not deploy_cutover._wait_for_live_http(live_port, bind_timeout): | |
| 133 | + raise RollbackFailedError( | |
| 134 | + "previous_instance_dir {} never bound live_port {} during rollback".format( | |
| 135 | + previous_instance_dir, live_port | |
| 136 | + ) | |
| 137 | + ) | |
| 138 | + | |
| 139 | + signals = [ | |
| 140 | + deploy_health_check.check_process_alive(revived.pid, timeout=startup_check_timeout), | |
| 141 | + deploy_health_check.check_http_health(live_port), | |
| 142 | + deploy_health_check.check_canary_task(live_port), | |
| 143 | + ] | |
| 144 | + failing = [s for s in signals if s["status"] != "ok"] | |
| 145 | + if failing: | |
| 146 | + raise RollbackFailedError( | |
| 147 | + "revived previous_instance_dir failed re-verification for deployment {}: {}".format( | |
| 148 | + deployment_id, failing | |
| 149 | + ) | |
| 150 | + ) | |
| 151 | + | |
| 152 | + _git_revert(repo_root, git_commit_hash) | |
| 153 | + except subprocess.CalledProcessError as e: | |
| 154 | + _abort_revert_if_in_progress(repo_root) | |
| 155 | + _block(conn, deployment_id, "rollback_failed (path B): git revert failed: {}".format(e.stderr)) | |
| 156 | + raise RollbackFailedError( | |
| 157 | + "rollback path B failed for deployment {}: git revert error: {}".format(deployment_id, e.stderr) | |
| 158 | + ) from e | |
| 159 | + except RollbackFailedError as e: | |
| 160 | + _block(conn, deployment_id, "rollback_failed (path B): {}".format(e)) | |
| 161 | + raise | |
| 162 | + | |
| 163 | + bug_record.record_core_engine_change(conn, task_id, description) | |
| 164 | + conn.execute("UPDATE deployment SET status = 'rolled_back' WHERE deployment_id = ?", (deployment_id,)) | |
| 165 | + conn.commit() | |
| 166 | + return {"status": "rolled_back"} | ... | ... |
supervisor/test_deploy_rollback.py
0 → 100644
| 1 | +"""U-041 tests: c_deploy_supervisor_rollback. Real subprocesses, real git | |
| 2 | +repos, real filesystem operations throughout -- this is the highest-rework- | |
| 3 | +risk Unit on PH5's critical path (development-plan.md's own note: "回滚本身 | |
| 4 | +失败没有再下一层的自动兜底"), so its own "rollback itself fails" fallback is | |
| 5 | +tested for real (a genuinely broken previous_instance_dir, a genuinely | |
| 6 | +conflicting git revert), not assumed to work because the happy path does. | |
| 7 | + | |
| 8 | +Run: python3 -m unittest discover -s supervisor -p "test_*.py" -v | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import os | |
| 12 | +import shutil | |
| 13 | +import subprocess | |
| 14 | +import sys | |
| 15 | +import tempfile | |
| 16 | +import time | |
| 17 | +import unittest | |
| 18 | + | |
| 19 | +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| 20 | + | |
| 21 | +from supervisor import deploy_cutover, deploy_health_check, deploy_rollback, deploy_stage, deploy_supervisor | |
| 22 | +from src import paths, schema | |
| 23 | + | |
| 24 | + | |
| 25 | +def _init_git_repo(path): | |
| 26 | + subprocess.run(["git", "init", "-q"], cwd=path, check=True) | |
| 27 | + subprocess.run(["git", "config", "user.email", "test@graphlemon.local"], cwd=path, check=True) | |
| 28 | + subprocess.run(["git", "config", "user.name", "test"], cwd=path, check=True) | |
| 29 | + | |
| 30 | + | |
| 31 | +def _write(path, name, content): | |
| 32 | + with open(os.path.join(path, name), "w", encoding="utf-8") as f: | |
| 33 | + f.write(content) | |
| 34 | + | |
| 35 | + | |
| 36 | +def _commit_all(path, message): | |
| 37 | + subprocess.run(["git", "add", "-A"], cwd=path, check=True) | |
| 38 | + subprocess.run(["git", "commit", "-q", "-m", message], cwd=path, check=True) | |
| 39 | + return subprocess.run( | |
| 40 | + ["git", "rev-parse", "HEAD"], cwd=path, capture_output=True, text=True, check=True | |
| 41 | + ).stdout.strip() | |
| 42 | + | |
| 43 | + | |
| 44 | +class DeployRollbackTestBase(unittest.TestCase): | |
| 45 | + def setUp(self): | |
| 46 | + self.home_tmp = tempfile.mkdtemp(prefix="deploy-rollback-home-") | |
| 47 | + self.addCleanup(shutil.rmtree, self.home_tmp, ignore_errors=True) | |
| 48 | + self.old_home = os.environ.get("GRAPHLEMON_HOME") | |
| 49 | + os.environ["GRAPHLEMON_HOME"] = self.home_tmp | |
| 50 | + | |
| 51 | + self.prod_db_path = os.path.join(self.home_tmp, "prod.db") | |
| 52 | + prod_conn = schema.connect(self.prod_db_path) | |
| 53 | + schema.create_schema(prod_conn) | |
| 54 | + prod_conn.close() | |
| 55 | + | |
| 56 | + self.conn = schema.connect(":memory:") | |
| 57 | + schema.create_schema(self.conn) | |
| 58 | + self.addCleanup(self.conn.close) | |
| 59 | + | |
| 60 | + self._cleanup_pids = [] | |
| 61 | + | |
| 62 | + def tearDown(self): | |
| 63 | + if self.old_home is None: | |
| 64 | + os.environ.pop("GRAPHLEMON_HOME", None) | |
| 65 | + else: | |
| 66 | + os.environ["GRAPHLEMON_HOME"] = self.old_home | |
| 67 | + for pid in self._cleanup_pids: | |
| 68 | + if deploy_supervisor.is_alive(pid): | |
| 69 | + os.kill(pid, 9) | |
| 70 | + | |
| 71 | + def _seed_deployment(self, deployment_id, status, **extra): | |
| 72 | + cols = ["deployment_id", "git_commit_hash", "previous_commit_hash", "status", "previous_instance_dir"] | |
| 73 | + vals = [deployment_id, extra.get("git_commit_hash", "c1"), extra.get("previous_commit_hash", "c0"), status, | |
| 74 | + extra.get("previous_instance_dir", "/prev")] | |
| 75 | + self.conn.execute( | |
| 76 | + "INSERT INTO deployment ({}) VALUES ({})".format(", ".join(cols), ", ".join("?" * len(cols))), vals | |
| 77 | + ) | |
| 78 | + self.conn.commit() | |
| 79 | + | |
| 80 | + | |
| 81 | +class RollbackPathAHappyPathTest(DeployRollbackTestBase): | |
| 82 | + def setUp(self): | |
| 83 | + super().setUp() | |
| 84 | + self.repo_dir = tempfile.mkdtemp(prefix="rollback-a-repo-") | |
| 85 | + self.addCleanup(shutil.rmtree, self.repo_dir, ignore_errors=True) | |
| 86 | + _init_git_repo(self.repo_dir) | |
| 87 | + _write(self.repo_dir, "f.txt", "v1\n") | |
| 88 | + self.commit_v1 = _commit_all(self.repo_dir, "v1") | |
| 89 | + _write(self.repo_dir, "f.txt", "v2 (self-modification)\n") | |
| 90 | + self.commit_v2 = _commit_all(self.repo_dir, "v2") | |
| 91 | + | |
| 92 | + def test_stops_candidate_deletes_db_reverts_commit_records_bug(self): | |
| 93 | + candidate_db = os.path.join(self.home_tmp, "candidate.db") | |
| 94 | + shutil.copy2(self.prod_db_path, candidate_db) | |
| 95 | + proc = subprocess.Popen(["sleep", "300"]) | |
| 96 | + self._cleanup_pids.append(proc.pid) | |
| 97 | + | |
| 98 | + self._seed_deployment("dep-a1", "health_check_failed", git_commit_hash=self.commit_v2) | |
| 99 | + | |
| 100 | + result = deploy_rollback.rollback_path_a( | |
| 101 | + self.conn, "dep-a1", proc.pid, candidate_db, self.commit_v2, repo_root=self.repo_dir, | |
| 102 | + description="canary_task failed: connection refused", | |
| 103 | + ) | |
| 104 | + | |
| 105 | + self.assertEqual(result["status"], "rolled_back") | |
| 106 | + self.assertFalse(deploy_supervisor.is_alive(proc.pid)) | |
| 107 | + self.assertFalse(os.path.exists(candidate_db)) | |
| 108 | + | |
| 109 | + with open(os.path.join(self.repo_dir, "f.txt"), encoding="utf-8") as f: | |
| 110 | + self.assertEqual(f.read(), "v1\n") # revert landed | |
| 111 | + | |
| 112 | + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-a1'").fetchone() | |
| 113 | + self.assertEqual(row[0], "rolled_back") | |
| 114 | + | |
| 115 | + bug = self.conn.execute( | |
| 116 | + "SELECT root_cause_category, status, description FROM bug_record" | |
| 117 | + ).fetchone() | |
| 118 | + self.assertEqual(bug[0], "core_engine") | |
| 119 | + self.assertEqual(bug[1], "resolved") | |
| 120 | + self.assertIn("canary_task failed", bug[2]) | |
| 121 | + | |
| 122 | + | |
| 123 | +class RollbackPathAFailureTest(DeployRollbackTestBase): | |
| 124 | + def test_git_revert_conflict_marks_blocked_pending_human_and_raises(self): | |
| 125 | + repo_dir = tempfile.mkdtemp(prefix="rollback-a-conflict-repo-") | |
| 126 | + self.addCleanup(shutil.rmtree, repo_dir, ignore_errors=True) | |
| 127 | + _init_git_repo(repo_dir) | |
| 128 | + _write(repo_dir, "f.txt", "v1\n") | |
| 129 | + _commit_all(repo_dir, "v1") | |
| 130 | + _write(repo_dir, "f.txt", "v2 (self-modification)\n") | |
| 131 | + commit_v2 = _commit_all(repo_dir, "v2") | |
| 132 | + # A later commit that touches the SAME line v2 changed -- reverting | |
| 133 | + # v2 now conflicts, the exact scenario design doc §10 open question | |
| 134 | + # 3 describes ("主分支又有新的提交,revert 可能冲突"). | |
| 135 | + _write(repo_dir, "f.txt", "v3 (unrelated later change, same line)\n") | |
| 136 | + _commit_all(repo_dir, "v3") | |
| 137 | + | |
| 138 | + self._seed_deployment("dep-a2", "health_check_failed", git_commit_hash=commit_v2) | |
| 139 | + | |
| 140 | + with self.assertRaises(deploy_rollback.RollbackFailedError): | |
| 141 | + deploy_rollback.rollback_path_a( | |
| 142 | + self.conn, "dep-a2", candidate_pid=None, candidate_db_path=None, | |
| 143 | + git_commit_hash=commit_v2, repo_root=repo_dir, | |
| 144 | + ) | |
| 145 | + | |
| 146 | + row = self.conn.execute( | |
| 147 | + "SELECT status, rollback_reason FROM deployment WHERE deployment_id = 'dep-a2'" | |
| 148 | + ).fetchone() | |
| 149 | + self.assertEqual(row[0], "blocked_pending_human") | |
| 150 | + self.assertIn("git revert failed", row[1]) | |
| 151 | + | |
| 152 | + # repo left clean, not mid-conflict, for whoever investigates | |
| 153 | + status_output = subprocess.run( | |
| 154 | + ["git", "status", "--porcelain"], cwd=repo_dir, capture_output=True, text=True, check=True | |
| 155 | + ).stdout | |
| 156 | + self.assertEqual(status_output.strip(), "") | |
| 157 | + | |
| 158 | + | |
| 159 | +class RollbackPathBHappyPathTest(DeployRollbackTestBase): | |
| 160 | + def setUp(self): | |
| 161 | + super().setUp() | |
| 162 | + self.repo_dir = tempfile.mkdtemp(prefix="rollback-b-repo-") | |
| 163 | + self.addCleanup(shutil.rmtree, self.repo_dir, ignore_errors=True) | |
| 164 | + _init_git_repo(self.repo_dir) | |
| 165 | + _write(self.repo_dir, "f.txt", "v1\n") | |
| 166 | + self.commit_v1 = _commit_all(self.repo_dir, "v1") | |
| 167 | + _write(self.repo_dir, "f.txt", "v2 (self-modification, buggy)\n") | |
| 168 | + self.commit_v2 = _commit_all(self.repo_dir, "v2") | |
| 169 | + | |
| 170 | + # The "previously known-good" checkout -- a REAL worktree of THIS | |
| 171 | + # actual graphlemon repo (needs real src/backend_server.py to relaunch), | |
| 172 | + # not repo_dir (which has no real graphlemon source in it). | |
| 173 | + self._prev_stash = subprocess.run( | |
| 174 | + ["git", "stash", "create"], cwd=paths.repo_root(), capture_output=True, text=True, check=True | |
| 175 | + ).stdout.strip() or deploy_stage._current_commit(paths.repo_root()) | |
| 176 | + self.prev_dir = os.path.join(self.home_tmp, "previous-instance") | |
| 177 | + subprocess.run( | |
| 178 | + ["git", "worktree", "add", "--detach", self.prev_dir, self._prev_stash], | |
| 179 | + cwd=paths.repo_root(), check=True, capture_output=True, text=True, | |
| 180 | + ) | |
| 181 | + self.addCleanup( | |
| 182 | + lambda: subprocess.run( | |
| 183 | + ["git", "worktree", "remove", "--force", self.prev_dir], cwd=paths.repo_root(), capture_output=True | |
| 184 | + ) | |
| 185 | + ) | |
| 186 | + | |
| 187 | + def test_stops_bad_live_revives_previous_reverts_commit_records_bug(self): | |
| 188 | + bad_live_port = deploy_stage._find_free_port() | |
| 189 | + bad_live = deploy_stage._launch_candidate(paths.repo_root(), bad_live_port, self.prod_db_path) | |
| 190 | + self._cleanup_pids.append(bad_live.pid) | |
| 191 | + self._wait_for_port(bad_live_port) | |
| 192 | + | |
| 193 | + self._seed_deployment( | |
| 194 | + "dep-b1", "anomaly_detected", git_commit_hash=self.commit_v2, | |
| 195 | + previous_instance_dir=self.prev_dir, | |
| 196 | + ) | |
| 197 | + | |
| 198 | + rollback_port = deploy_stage._find_free_port() | |
| 199 | + result = deploy_rollback.rollback_path_b( | |
| 200 | + self.conn, "dep-b1", bad_live.pid, self.prev_dir, self.prod_db_path, self.commit_v2, | |
| 201 | + repo_root=self.repo_dir, live_port=rollback_port, | |
| 202 | + ) | |
| 203 | + self._cleanup_pids.append( # find the revived process's pid via port not tracked -- best-effort cleanup | |
| 204 | + bad_live.pid | |
| 205 | + ) | |
| 206 | + | |
| 207 | + self.assertEqual(result["status"], "rolled_back") | |
| 208 | + self.assertFalse(deploy_supervisor.is_alive(bad_live.pid)) | |
| 209 | + | |
| 210 | + with open(os.path.join(self.repo_dir, "f.txt"), encoding="utf-8") as f: | |
| 211 | + self.assertEqual(f.read(), "v1\n") | |
| 212 | + | |
| 213 | + row = self.conn.execute("SELECT status FROM deployment WHERE deployment_id = 'dep-b1'").fetchone() | |
| 214 | + self.assertEqual(row[0], "rolled_back") | |
| 215 | + | |
| 216 | + bug = self.conn.execute("SELECT root_cause_category FROM bug_record").fetchone() | |
| 217 | + self.assertEqual(bug[0], "core_engine") | |
| 218 | + | |
| 219 | + # revived instance genuinely serving on rollback_port | |
| 220 | + health = deploy_health_check.check_http_health(rollback_port) | |
| 221 | + self.assertEqual(health["status"], "ok") | |
| 222 | + | |
| 223 | + def _wait_for_port(self, port, timeout=10): | |
| 224 | + deadline = time.time() + timeout | |
| 225 | + while time.time() < deadline: | |
| 226 | + if deploy_health_check.check_http_health(port, timeout=1)["status"] == "ok": | |
| 227 | + return | |
| 228 | + time.sleep(0.2) | |
| 229 | + self.fail("port {} never came up".format(port)) | |
| 230 | + | |
| 231 | + | |
| 232 | +class RollbackPathBFailureTest(DeployRollbackTestBase): | |
| 233 | + """The Unit's own highest-risk scenario, tested for real: the old | |
| 234 | + version ALSO can't start.""" | |
| 235 | + | |
| 236 | + def test_previous_instance_also_fails_to_start_marks_blocked_pending_human(self): | |
| 237 | + repo_dir = tempfile.mkdtemp(prefix="rollback-b-fail-repo-") | |
| 238 | + self.addCleanup(shutil.rmtree, repo_dir, ignore_errors=True) | |
| 239 | + _init_git_repo(repo_dir) | |
| 240 | + _write(repo_dir, "f.txt", "v1\n") | |
| 241 | + _commit_all(repo_dir, "v1") | |
| 242 | + _write(repo_dir, "f.txt", "v2\n") | |
| 243 | + commit_v2 = _commit_all(repo_dir, "v2") | |
| 244 | + | |
| 245 | + broken_previous_dir = os.path.join(self.home_tmp, "broken-previous") | |
| 246 | + os.makedirs(broken_previous_dir) # a real directory, but with NO python package in it at all | |
| 247 | + | |
| 248 | + self._seed_deployment( | |
| 249 | + "dep-b2", "anomaly_detected", git_commit_hash=commit_v2, previous_instance_dir=broken_previous_dir, | |
| 250 | + ) | |
| 251 | + | |
| 252 | + with self.assertRaises(deploy_rollback.RollbackFailedError): | |
| 253 | + deploy_rollback.rollback_path_b( | |
| 254 | + self.conn, "dep-b2", live_pid=None, previous_instance_dir=broken_previous_dir, | |
| 255 | + prod_db_path=self.prod_db_path, git_commit_hash=commit_v2, repo_root=repo_dir, | |
| 256 | + live_port=deploy_stage._find_free_port(), bind_timeout=3, | |
| 257 | + ) | |
| 258 | + | |
| 259 | + row = self.conn.execute( | |
| 260 | + "SELECT status, rollback_reason FROM deployment WHERE deployment_id = 'dep-b2'" | |
| 261 | + ).fetchone() | |
| 262 | + self.assertEqual(row[0], "blocked_pending_human") | |
| 263 | + self.assertIn("rollback_failed", row[1]) | |
| 264 | + | |
| 265 | + # commit was NOT reverted -- rollback failed before it ever got there | |
| 266 | + with open(os.path.join(repo_dir, "f.txt"), encoding="utf-8") as f: | |
| 267 | + self.assertEqual(f.read(), "v2\n") | |
| 268 | + | |
| 269 | + | |
| 270 | +if __name__ == "__main__": | |
| 271 | + unittest.main() | ... | ... |