修复(08-08-production-deployment-execution): 明确终止迁移回滚窗口依据
这个提交包含在:
@@ -1611,6 +1611,12 @@ DEPLOY_SERVER=<approved-ssh-target> \
|
||||
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=<approved-ssh-target> \
|
||||
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=<approved-ssh-target> \
|
||||
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=<approved-ssh-target> \
|
||||
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
|
||||
|
||||
@@ -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 决策必须引用部署前实际读数,而不是部署后补查。
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户