From b94afcfab02eb50bb1a62cf143dce4c38aa90b55 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 8 Aug 2026 20:57:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(08-08-production-deployment-?= =?UTF-8?q?execution):=20=E6=98=8E=E7=A1=AE=E7=BB=88=E6=AD=A2=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=9B=9E=E6=BB=9A=E7=AA=97=E5=8F=A3=E4=BE=9D=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 27 +++++++++++++++++++ .../design.md | 10 ++++--- .../implement.md | 14 ++++++++++ .../trialrun/production-preflight-remote.sh | 20 +++++++++----- deploy/trialrun/validate-env-contract.js | 3 +++ .../deployment-safety-contract.test.js | 26 ++++++++++++++++-- 6 files changed, 88 insertions(+), 12 deletions(-) diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 8ab0c40b..d0c90d7a 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -1611,6 +1611,12 @@ DEPLOY_SERVER= \ committed row closes it. Once closed, an old application that does not know `terminated` cannot be restored and the new columns are never auto-dropped; recovery is roll-forward or a compatible hotfix. +- Window evidence has two explicit bases. If the termination migration is not + in the applied set, do not issue the status query; record + `rollbackWindow=OPEN terminatedRows=0 basis=migration_not_applied`. If it is + applied, query `contract_registry.status = 'terminated'` and record + `basis=terminated_status_count`. Any query/parse failure in the applied branch + fails closed; it must never be caught and converted into `OPEN`. - The target identity must remain unchanged between the read-only preflight and the schema write; if a controlled change freeze cannot be established, stop before `deploy.sh` rather than relying on post-status to repair a wrong-target @@ -1637,6 +1643,13 @@ DEPLOY_SERVER= \ complete pre-status result. - Pending set differs from the approved allowlist -> `PAUSED` and retain the status artifact for human review. +- Termination migration absent from the candidate manifest -> + `PROD_TERMINATION_MIGRATION_NOT_IN_CANDIDATE`; the window basis is unknown. +- Termination migration not applied -> explicit `OPEN` with + `basis=migration_not_applied`, without querying the status value. +- Termination migration applied but the count query fails or returns a + non-integer -> `PROD_TERMINATED_COUNT_UNAVAILABLE` or + `PROD_TERMINATED_COUNT_INVALID`; never default to `OPEN`. - `terminated` count greater than zero -> record `rollbackWindow=CLOSED` and disallow compatibility rollback; this task never creates such a row. @@ -1650,6 +1663,9 @@ DEPLOY_SERVER= \ a later approved deployment would activate. - Base: the remote helper exits zero with a readable Prisma marker and the wrapper continues into migration reconciliation. +- Base: the approved termination migration is pending, so reconciliation passes + and the window is explicitly `OPEN` with `basis=migration_not_applied`; no + status query is attempted. - Bad: check for a missing marker before checking the remote exit and replace the real remote safety code with `PRISMA_STATUS_OUTPUT_MISSING`. - Bad: omit build SHA interpolation, use a seven-character Git abbreviation, @@ -1673,6 +1689,10 @@ DEPLOY_SERVER= \ its exact 12-character prefix as `VITE_BUILD_SHA`; reject malformed and individually valid-but-mismatched pairs. Static checks must prove there is no local-HEAD candidate fallback. +- Cover both window bases: a pending termination migration must return explicit + `OPEN` without issuing the status query; an applied migration must query and + distinguish zero/positive counts. Force the applied-branch query to fail and + assert `PROD_TERMINATED_COUNT_UNAVAILABLE`, not `OPEN`. ### 7. Wrong vs Correct @@ -1699,6 +1719,13 @@ provenance-verified commit -> BUILD_SHA + first 12 chars as VITE_BUILD_SHA -> interpolated docker compose config ``` +```text +# Wrong: a missing column/query error happens -> catch -> assume OPEN. + +# Correct: migration not applied -> explicit OPEN without querying; +# migration applied -> required count query, with any error stopping closed. +``` + ## Scenario: Environment Recovery Classification And Secret Custody ### 1. Scope / Trigger diff --git a/.trellis/tasks/08-08-production-deployment-execution/design.md b/.trellis/tasks/08-08-production-deployment-execution/design.md index c89fa5fa..d800a3b9 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/design.md +++ b/.trellis/tasks/08-08-production-deployment-execution/design.md @@ -59,7 +59,7 @@ 3. 检查唯一 server/db 容器、运行时 `DATABASE_URL` host/database、当前数据库名、`NODE_ENV=production` 和 `COOKIE_SECURE=true`。 4. 执行 `npx prisma migrate status`,在 marker 内输出原始结果。 5. 只读查询 `_prisma_migrations`,计算 applied、异常和 pending 集合,与候选 manifest 和批准 allowlist 精确对账。 -6. 查询 `contract_registry` 的 `terminated` 行数并输出 `rollbackWindow=OPEN|CLOSED`。 +6. 先以 `_prisma_migrations` 判断 termination migration 是否已应用:未应用时不查询尚未启用的终止状态,显式输出 `rollbackWindow=OPEN ... basis=migration_not_applied`;已应用时必须真实查询 `contract_registry.status = 'terminated'` 并输出 `basis=terminated_status_count`,查询错误一律 fail-closed。 status 原文仅存在受控 SSH/执行现场;本地 helper 写入 `prisma-migrate-status.txt` 时去除 datasource、连接串和预期身份值,并将稳定摘要写入 `preflight-output.txt`。报告目录复用时只删除两个已知文件,防止旧 PASS 污染新证据。 @@ -84,10 +84,12 @@ bash deploy/trialrun/deploy-existing.sh ## 6. 回滚窗口 -部署前查询 `SELECT count(*) FROM contract_registry WHERE status = 'terminated'`: +部署前窗口按 termination migration 的 applied 状态分流: -- `0` → `rollbackWindow=OPEN`;应用健康失败可按既有发布合同回退兼容版本,但不得 drop 三列。 -- `>0` → `rollbackWindow=CLOSED`;旧应用不识别 `terminated`,只能 roll-forward 或兼容 hotfix。 +- migration 未应用 → 终止能力尚未生效,不执行状态查询,记录 `rollbackWindow=OPEN`、`terminatedRows=0`、`basis=migration_not_applied`。 +- migration 已应用且 `SELECT count(*) FROM contract_registry WHERE status = 'terminated'` 返回 `0` → 记录 `OPEN` 与 `basis=terminated_status_count`;应用健康失败可按既有发布合同回退兼容版本,但不得 drop 三列。 +- migration 已应用且返回 `>0` → 记录 `CLOSED` 与 `basis=terminated_status_count`;旧应用不识别 `terminated`,只能 roll-forward 或兼容 hotfix。 +- migration 已应用但查询失败或返回不可解析值 → 停止,不得用异常兜底为 `OPEN`。 窗口在第一条已提交记录产生的事件时刻关闭,不按时间计算。当前 task 不写入该记录,因此不主动改变窗口状态。任何 rollback 决策必须引用部署前实际读数,而不是部署后补查。 diff --git a/.trellis/tasks/08-08-production-deployment-execution/implement.md b/.trellis/tasks/08-08-production-deployment-execution/implement.md index f322285f..2b4087df 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/implement.md +++ b/.trellis/tasks/08-08-production-deployment-execution/implement.md @@ -151,3 +151,17 @@ - preflight 仅从该候选派生 `BUILD_SHA=<完整 commit>` 与 `VITE_BUILD_SHA=<前 12 位>`,并传给远端 Compose;不使用 7 位 Git 默认短 hash。远端 helper 在 `docker compose config` 前校验完整/短 SHA 格式以及 `VITE_BUILD_SHA === BUILD_SHA.slice(0, 12)`。 - 此处 `docker compose config` 验证的是候选发布后将生效的配置,不代表当前运行容器已经使用该 build SHA;运行态仍须由后续只读 runtime/SHA 检查独立证明。禁止用 `--no-interpolate` 绕过 Compose 的必填变量合同。 - 聚焦测试保持 19/19 PASS,并新增候选完整/12 位注入、7 位短 SHA 拒绝、合法但不匹配 SHA 拒绝、禁止 HEAD fallback 的覆盖;Node/shell syntax 与 env contract PASS。 + +## Empty-Allowlist Migration Discovery Evidence (2026-08-08) + +- 候选配置修复提交 `8b2ed5b` 已进入 `origin/main`;执行前 provenance 为 `published=true`、`remoteVerified=true`,工作区 clean。 +- 使用空 `EXPECTED_PENDING_MIGRATIONS` 重跑只读 pre-status:精确身份组、13 项聚合体检和 `.env` / `current_database()` / runtime `DATABASE_URL` 三方数据库身份均 PASS;脱敏 Prisma status 显示运行镜像包含 64 条 migration 且对自身为 `Database schema is up to date!`。 +- 候选 manifest 包含 65 条 migration;独立候选清单与生产 `_prisma_migrations` 差集发现唯一 pending 为 `20260807193000_contract_registry_termination_workspace`,因此稳定停止码为 `PROD_MIGRATION_PENDING_ALLOWLIST_MISMATCH`。这证明运行镜像 status 不能替代候选差集门禁。 +- 该 migration SQL 已复核为三个可空列、一个索引和一个 `ON DELETE RESTRICT` 外键,无 drop/truncate/delete 或数据改写;用户已明确批准把它加入下一次只读 pre-status 的 allowlist。 +- 空 allowlist 停止发生在窗口查询前,故 `terminated` count 未读取、rollback window 仍为 `UNKNOWN`;未进入 `deploy.sh`、rsync、migration、应用切换、refresh 或业务 API,生产 schema 写入与业务写入均为 0。 + +## Explicit Rollback-window Basis Repair Evidence (2026-08-08) + +- termination migration 尚未应用时,终止能力不可能由本轮应用生效;helper 现以 `_prisma_migrations` applied set 走显式分支,不发送 `contract_registry` 状态查询,固定输出 `rollbackWindow=OPEN terminatedRows=0 basis=migration_not_applied`。 +- migration 已应用时继续执行真实 `status = 'terminated'` 计数,输出 `basis=terminated_status_count`;查询失败或结果不可解析分别返回固定错误,不使用 catch/default 把未知状态误报为 OPEN。 +- 聚焦 fixture 覆盖:未应用且状态查询被设置为必失败仍应 OPEN(证明未查询)、已应用 0 行 OPEN、已应用 2 行 CLOSED,以及已应用查询失败必须 `PROD_TERMINATED_COUNT_UNAVAILABLE`。聚焦测试 19/19 PASS。 diff --git a/deploy/trialrun/production-preflight-remote.sh b/deploy/trialrun/production-preflight-remote.sh index 09ac2b6a..3fd283ae 100755 --- a/deploy/trialrun/production-preflight-remote.sh +++ b/deploy/trialrun/production-preflight-remote.sh @@ -4,6 +4,8 @@ # runs before deploy.sh is allowed to synchronize or execute migrate deploy. set -euo pipefail +TERMINATION_MIGRATION_ID="20260807193000_contract_registry_termination_workspace" + fail() { printf '[production-preflight][FAIL] code=%s\n' "$1" >&2 exit 1 @@ -275,13 +277,19 @@ expected_pending_csv="$(printf '%s' "$EXPECTED_PENDING_MIGRATIONS_CSV" | tr ',' || fail "PROD_MIGRATION_PENDING_ALLOWLIST_MISMATCH" pass "migration_reconciliation pending=${actual_pending_csv:-none}" -terminated_count="$(db_value "SELECT count(*) FROM contract_registry WHERE status = 'terminated';" | tr -d '\r\n')" \ - || fail "PROD_TERMINATED_COUNT_UNAVAILABLE" -[[ "$terminated_count" =~ ^[0-9]+$ ]] || fail "PROD_TERMINATED_COUNT_INVALID" -if [[ "$terminated_count" -eq 0 ]]; then - pass "rollbackWindow=OPEN terminatedRows=0" +[[ -n "${candidate_set[$TERMINATION_MIGRATION_ID]:-}" ]] \ + || fail "PROD_TERMINATION_MIGRATION_NOT_IN_CANDIDATE" +if [[ -z "${applied_set[$TERMINATION_MIGRATION_ID]:-}" ]]; then + pass "rollbackWindow=OPEN terminatedRows=0 basis=migration_not_applied" else - pass "rollbackWindow=CLOSED terminatedRows=${terminated_count}" + terminated_count="$(db_value "SELECT count(*) FROM contract_registry WHERE status = 'terminated';" | tr -d '\r\n')" \ + || fail "PROD_TERMINATED_COUNT_UNAVAILABLE" + [[ "$terminated_count" =~ ^[0-9]+$ ]] || fail "PROD_TERMINATED_COUNT_INVALID" + if [[ "$terminated_count" -eq 0 ]]; then + pass "rollbackWindow=OPEN terminatedRows=0 basis=terminated_status_count" + else + pass "rollbackWindow=CLOSED terminatedRows=${terminated_count} basis=terminated_status_count" + fi fi pass "migration_status_and_target_checks_complete" diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 6e0af578..f9c957c8 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -773,6 +773,9 @@ for (const requiredFragment of [ 'PROD_TARGET_BUILD_SHA_INVALID', 'PROD_TARGET_VITE_BUILD_SHA_INVALID', 'PROD_TARGET_BUILD_SHA_MISMATCH', + 'PROD_TERMINATION_MIGRATION_NOT_IN_CANDIDATE', + 'basis=migration_not_applied', + 'basis=terminated_status_count', 'PRISMA_STATUS_BEGIN', 'FROM "_prisma_migrations"', 'logged_failure', diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index f696ddd4..bcd5cdef 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -486,12 +486,20 @@ describe("deployment safety contract", () => { "if [[ \"$joined\" == *'_prisma_migrations'* ]]; then", " case \"$mode\" in", " pending) printf '20260807193000_contract_registry_termination_workspace|applied\\n';;", + " termination-pending) printf '20260807193001_followup_migration|applied\\n';;", " logs) printf '20260807193000_contract_registry_termination_workspace|logged_failure\\n';;", " *) printf '20260807193000_contract_registry_termination_workspace|applied\\n20260807193001_followup_migration|applied\\n';;", " esac", " exit 0", "fi", - "if [[ \"$joined\" == *'contract_registry'* ]]; then printf '0\\n'; exit 0; fi", + "if [[ \"$joined\" == *'contract_registry'* ]]; then", + " case \"$mode\" in", + " termination-pending|termination-query-failure) exit 97 ;;", + " closed) printf '2\\n' ;;", + " *) printf '0\\n' ;;", + " esac", + " exit 0", + "fi", "printf 'unexpected fake docker call: %s\\n' \"$joined\" >&2", "exit 97", "", @@ -524,9 +532,23 @@ describe("deployment safety contract", () => { const result = await runRemote("happy"); expect(result.stdout).toContain("target_identity"); expect(result.stdout).toContain("migration_reconciliation pending=none"); - expect(result.stdout).toContain("rollbackWindow=OPEN"); + expect(result.stdout).toContain("rollbackWindow=OPEN terminatedRows=0 basis=terminated_status_count"); const pendingResult = await runRemote("pending", "20260807193001_followup_migration"); expect(pendingResult.stdout).toContain("migration_reconciliation pending=20260807193001_followup_migration"); + const terminationPendingResult = await runRemote( + "termination-pending", + "20260807193000_contract_registry_termination_workspace", + ); + expect(terminationPendingResult.stdout).toContain( + "rollbackWindow=OPEN terminatedRows=0 basis=migration_not_applied", + ); + const closedResult = await runRemote("closed"); + expect(closedResult.stdout).toContain( + "rollbackWindow=CLOSED terminatedRows=2 basis=terminated_status_count", + ); + await expect(runRemote("termination-query-failure")).rejects.toMatchObject({ + stderr: expect.stringContaining("PROD_TERMINATED_COUNT_UNAVAILABLE"), + }); await expect(runRemote("unknown")).rejects.toMatchObject({ stderr: expect.stringContaining("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"), });