From 83af7544c4ec848855ec47e285c20c2d02c50f5b Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 8 Aug 2026 20:14:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(08-08-production-deployment-?= =?UTF-8?q?execution):=20=E4=BF=9D=E7=95=99=E7=94=9F=E4=BA=A7=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E7=9C=9F=E5=AE=9E=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 52 +++++++++++++++++-- .../implement.md | 8 +++ deploy/trialrun/production-preflight.mjs | 19 +++++-- .../deployment-safety-contract.test.js | 46 ++++++++++++++++ 4 files changed, 116 insertions(+), 9 deletions(-) diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 926c85ab..f13673eb 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -1553,14 +1553,16 @@ DEPLOY_SERVER= \ - Local orchestrator: `deploy/trialrun/production-preflight.mjs`. - Remote read-only helper: `deploy/trialrun/production-preflight-remote.sh`. - Required process inputs: `EXPECTED_PRODUCTION_ORIGIN`, - `EXPECTED_PRODUCTION_DB_NAME`, and `EXPECTED_PENDING_MIGRATIONS`. + `EXPECTED_PRODUCTION_DB_NAME_SHA256`, `EXPECTED_PENDING_MIGRATIONS`, and the + exact deployment target/directory/Compose identity values. ### 3. Contracts - Production R2 is the inverse of the local migration task: the gate must prove the target is the expected production HTTPS origin and database identity, and - reject loopback, local regression, staging and trialrun identities. It must - never infer production from an SSH alias, Compose label or directory name. + reject loopback, local regression, staging, shadow and placeholder identities. + A legacy `trialrun` token is neutral; the gate must never infer production or + non-production from an SSH alias, Compose label or directory name alone. - The strict production-validation wrapper rejects unpublished-commit overrides; disaster-recovery exceptions use a separately approved recovery flow. - Expected target values remain process-only. The local orchestrator sends only @@ -1583,6 +1585,12 @@ DEPLOY_SERVER= \ insufficient. - Each preflight run removes only its two known report files before SSH; a reused report directory must never leave an old PASS status artifact in place. +- After the stable remote summary and report reference are emitted, a non-zero + remote exit is evaluated before Prisma marker completeness. A parsed remote + `code=...` remains the primary stop reason; a non-zero exit without a safe + code becomes `PRODUCTION_PREFLIGHT_REMOTE_FAILED`. Only a zero remote exit + without the required marker becomes `PRISMA_STATUS_OUTPUT_MISSING`. This + ordering preserves both the original safety diagnosis and failure evidence. - When the strict wrapper invokes `deploy.sh`, it sets `DEPLOY_STATUS_OUTPUT_MODE=quiet`; the legacy status check must not echo raw datasource output. Detailed evidence comes from the controlled pre/post @@ -1603,12 +1611,28 @@ DEPLOY_SERVER= \ before `deploy.sh`. - Status output missing/unreadable, failed/rolled-back/diverged history or an unknown pending migration -> `PAUSED`; do not reset or resolve in place. +- Non-zero remote exit with a safe `code=...`, with or without a Prisma marker + -> propagate that code and stop. Non-zero exit without a parseable code -> + `PRODUCTION_PREFLIGHT_REMOTE_FAILED`; never continue to marker validation. +- Zero remote exit without the Prisma marker -> + `PRISMA_STATUS_OUTPUT_MISSING`; do not treat the transport success as a + complete pre-status result. - Pending set differs from the approved allowlist -> `PAUSED` and retain the status artifact for human review. - `terminated` count greater than zero -> record `rollbackWindow=CLOSED` and disallow compatibility rollback; this task never creates such a row. -### 5. Tests Required +### 5. Good/Base/Bad Cases + +- Good: the remote helper exits non-zero with a stable database-identity code; + the wrapper writes the stable evidence summary, returns that exact code and + stops before migration or deployment. +- Base: the remote helper exits zero with a readable Prisma marker and the + wrapper continues into migration reconciliation. +- Bad: check for a missing marker before checking the remote exit and replace + the real remote safety code with `PRISMA_STATUS_OUTPUT_MISSING`. + +### 6. Tests Required - `node --check deploy/trialrun/production-preflight.mjs`. - `bash -n deploy/trialrun/production-preflight-remote.sh`. @@ -1617,6 +1641,26 @@ DEPLOY_SERVER= \ - Static ordering must prove production preflight precedes `deploy.sh`, and the remote status marker precedes migration-history reconciliation. Fixtures must reject loopback/staging target identities without invoking SSH. +- Cover all remote-result precedence combinations: non-zero with a safe code + and missing marker; non-zero with a safe code and present marker; non-zero + without a safe code; zero with a missing marker. Also assert the stable + summary and report reference are emitted before either failure check. + +### 7. Wrong vs Correct + +#### Wrong + +```text +missing Prisma marker -> PRISMA_STATUS_OUTPUT_MISSING +-> inspect remote non-zero exit afterward +``` + +#### Correct + +```text +write stable evidence -> inspect remote non-zero exit and propagate/fallback +-> only after remote success require the Prisma marker +``` ## Scenario: Environment Recovery Classification And Secret Custody diff --git a/.trellis/tasks/08-08-production-deployment-execution/implement.md b/.trellis/tasks/08-08-production-deployment-execution/implement.md index ec78b84a..7f81966e 100644 --- a/.trellis/tasks/08-08-production-deployment-execution/implement.md +++ b/.trellis/tasks/08-08-production-deployment-execution/implement.md @@ -128,3 +128,11 @@ - DB hash 引导成功只证明远端 `.env` 数据库名已形成比较基线,不证明后续 `.env` / `current_database()` / runtime `DATABASE_URL` 三方一致性;远端 preflight 未输出 `target_identity_and_database_identity` PASS,故该验收项保持未通过。 - 未获得 migration reconciliation、pending allowlist 对账或 `terminated` count;rollback window 仍为 `UNKNOWN`,不得写作 OPEN/CLOSED。 - `PRODUCTION_READ_ONLY_PREFLIGHT=1` 未进入 `deploy.sh`;未执行 rsync、`migrate deploy`、应用切换、contract refresh 或业务 API,生产 schema 写入与业务写入均为 0。失败后未重跑、未跳过门禁。 + +## Pre-status Diagnostic Wrapper Repair Evidence (2026-08-08) + +- 已确认外层 `production-preflight.mjs` 在远端非零退出且 Prisma marker 缺失时,先抛出 `PRISMA_STATUS_OUTPUT_MISSING`,遮蔽了远端 helper 已输出的稳定安全码;该结论来自源码顺序与既有脱敏 evidence,不反推或猜测真实远端原因。 +- 最小修复保持稳定摘要与 `reportRef` 在失败判断前输出,并把远端非零退出判断提前:可解析 `code=...` 时传播原码,不可解析时固定失败为 `PRODUCTION_PREFLIGHT_REMOTE_FAILED`;只有远端退出 0 且 marker 缺失时才返回 `PRISMA_STATUS_OUTPUT_MISSING`。 +- 聚焦回归覆盖四种组合:非零/有安全码/marker 缺失,非零/有安全码/marker 存在,非零/无可解析码/marker 缺失,以及退出 0/marker 缺失;`deployment-safety-contract.test.js` 为 19/19 PASS。 +- 本地 Node/shell syntax、env contract、task validate、process contract、docs audit、Trellis governance、sensitive scan 与 `git diff --check` 均通过;docs audit 仅保留仓库既有的未注册文档 warning。 +- 本节修复阶段未连接生产、未运行 target/status、未执行 rsync、migration、应用切换或业务 API;生产 schema 写入与业务写入仍为 0。候选提交进入上游且 provenance 重新通过后,才允许重跑只读 pre-status;取得真实远端错误码后必须立即停止并复核原因。 diff --git a/deploy/trialrun/production-preflight.mjs b/deploy/trialrun/production-preflight.mjs index d2111562..8f34fd10 100755 --- a/deploy/trialrun/production-preflight.mjs +++ b/deploy/trialrun/production-preflight.mjs @@ -234,6 +234,14 @@ export function validatePrismaStatusText(status) { return status; } +export function assertRemotePreflightResult({ status, stdout, stderr, rawStatus }) { + if (status !== 0) { + const remoteCode = `${stdout}\n${stderr || ""}`.match(/code=([A-Z0-9_]+)/u)?.[1]; + fail(remoteCode || "PRODUCTION_PREFLIGHT_REMOTE_FAILED"); + } + if (rawStatus == null) fail("PRISMA_STATUS_OUTPUT_MISSING"); +} + function main() { try { const deployServer = String(process.env.DEPLOY_SERVER ?? "").trim(); @@ -337,11 +345,12 @@ function main() { printStableRemoteSummary(stdout); console.log(`[production-preflight] reportRef=${reportRef(reportDir)}`); - if (rawStatus == null) fail("PRISMA_STATUS_OUTPUT_MISSING"); - if (result.status !== 0) { - const remoteCode = `${stdout}\n${result.stderr || ""}`.match(/code=([A-Z0-9_]+)/u)?.[1]; - fail(remoteCode || "PRODUCTION_PREFLIGHT_REMOTE_FAILED"); - } + assertRemotePreflightResult({ + status: result.status, + stdout, + stderr: result.stderr, + rawStatus, + }); console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过"); } catch (error) { const code = error instanceof ProductionPreflightError diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index 1aa27489..75501703 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -130,6 +130,12 @@ describe("deployment safety contract", () => { expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE"); expect(preflight).toContain("redacted datasource line"); expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH"); + expect(preflight.lastIndexOf("printStableRemoteSummary(stdout)")).toBeLessThan( + preflight.lastIndexOf("assertRemotePreflightResult({"), + ); + expect(preflight.indexOf("reportRef=${reportRef(reportDir)}")).toBeLessThan( + preflight.lastIndexOf("assertRemotePreflightResult({"), + ); expect(remote).toContain("PRISMA_STATUS_BEGIN"); expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"); expect(remote).toContain("logged_failure"); @@ -227,6 +233,46 @@ describe("deployment safety contract", () => { ); }); + it.each([ + { + name: "prefers a remote safety code when the marker is missing", + status: 1, + stdout: "", + stderr: "[production-preflight][PAUSED] code=PROD_DATABASE_IDENTITY_MISMATCH", + rawStatus: null, + expectedCode: "PROD_DATABASE_IDENTITY_MISMATCH", + }, + { + name: "prefers a remote safety code when the marker exists", + status: 1, + stdout: "[production-preflight][PAUSED] code=PROD_MIGRATION_STATUS_FAILED", + stderr: "", + rawStatus: "Database schema is up to date!", + expectedCode: "PROD_MIGRATION_STATUS_FAILED", + }, + { + name: "fails closed when a remote failure has no parseable code", + status: 1, + stdout: "remote command failed", + stderr: "ssh exited unexpectedly", + rawStatus: null, + expectedCode: "PRODUCTION_PREFLIGHT_REMOTE_FAILED", + }, + { + name: "requires the Prisma marker after a successful remote exit", + status: 0, + stdout: "[production-preflight][PASS] target_identity", + stderr: "", + rawStatus: null, + expectedCode: "PRISMA_STATUS_OUTPUT_MISSING", + }, + ])("$name", async ({ status, stdout, stderr, rawStatus, expectedCode }) => { + const target = await import(pathToFileURL(PRODUCTION_PREFLIGHT).href); + expect(() => target.assertRemotePreflightResult({ status, stdout, stderr, rawStatus })).toThrowError( + expect.objectContaining({ code: expectedCode }), + ); + }); + it("captures a redacted pre-status evidence copy from a controlled SSH fixture", async () => { const fakeBin = path.join(fixtureRoot, "bin"); const reportDir = path.join(fixtureRoot, "report");