修复(08-08-production-deployment-execution): 保留生产预检真实错误码
这个提交包含在:
@@ -1553,14 +1553,16 @@ DEPLOY_SERVER=<approved-ssh-target> \
|
|||||||
- Local orchestrator: `deploy/trialrun/production-preflight.mjs`.
|
- Local orchestrator: `deploy/trialrun/production-preflight.mjs`.
|
||||||
- Remote read-only helper: `deploy/trialrun/production-preflight-remote.sh`.
|
- Remote read-only helper: `deploy/trialrun/production-preflight-remote.sh`.
|
||||||
- Required process inputs: `EXPECTED_PRODUCTION_ORIGIN`,
|
- 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
|
### 3. Contracts
|
||||||
|
|
||||||
- Production R2 is the inverse of the local migration task: the gate must prove
|
- 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
|
the target is the expected production HTTPS origin and database identity, and
|
||||||
reject loopback, local regression, staging and trialrun identities. It must
|
reject loopback, local regression, staging, shadow and placeholder identities.
|
||||||
never infer production from an SSH alias, Compose label or directory name.
|
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;
|
- The strict production-validation wrapper rejects unpublished-commit overrides;
|
||||||
disaster-recovery exceptions use a separately approved recovery flow.
|
disaster-recovery exceptions use a separately approved recovery flow.
|
||||||
- Expected target values remain process-only. The local orchestrator sends only
|
- Expected target values remain process-only. The local orchestrator sends only
|
||||||
@@ -1583,6 +1585,12 @@ DEPLOY_SERVER=<approved-ssh-target> \
|
|||||||
insufficient.
|
insufficient.
|
||||||
- Each preflight run removes only its two known report files before SSH; a
|
- 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.
|
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
|
- When the strict wrapper invokes `deploy.sh`, it sets
|
||||||
`DEPLOY_STATUS_OUTPUT_MODE=quiet`; the legacy status check must not echo raw
|
`DEPLOY_STATUS_OUTPUT_MODE=quiet`; the legacy status check must not echo raw
|
||||||
datasource output. Detailed evidence comes from the controlled pre/post
|
datasource output. Detailed evidence comes from the controlled pre/post
|
||||||
@@ -1603,12 +1611,28 @@ DEPLOY_SERVER=<approved-ssh-target> \
|
|||||||
before `deploy.sh`.
|
before `deploy.sh`.
|
||||||
- Status output missing/unreadable, failed/rolled-back/diverged history or an
|
- Status output missing/unreadable, failed/rolled-back/diverged history or an
|
||||||
unknown pending migration -> `PAUSED`; do not reset or resolve in place.
|
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
|
- Pending set differs from the approved allowlist -> `PAUSED` and retain the
|
||||||
status artifact for human review.
|
status artifact for human review.
|
||||||
- `terminated` count greater than zero -> record `rollbackWindow=CLOSED` and
|
- `terminated` count greater than zero -> record `rollbackWindow=CLOSED` and
|
||||||
disallow compatibility rollback; this task never creates such a row.
|
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`.
|
- `node --check deploy/trialrun/production-preflight.mjs`.
|
||||||
- `bash -n deploy/trialrun/production-preflight-remote.sh`.
|
- `bash -n deploy/trialrun/production-preflight-remote.sh`.
|
||||||
@@ -1617,6 +1641,26 @@ DEPLOY_SERVER=<approved-ssh-target> \
|
|||||||
- Static ordering must prove production preflight precedes `deploy.sh`, and the
|
- Static ordering must prove production preflight precedes `deploy.sh`, and the
|
||||||
remote status marker precedes migration-history reconciliation. Fixtures must
|
remote status marker precedes migration-history reconciliation. Fixtures must
|
||||||
reject loopback/staging target identities without invoking SSH.
|
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
|
## Scenario: Environment Recovery Classification And Secret Custody
|
||||||
|
|
||||||
|
|||||||
@@ -128,3 +128,11 @@
|
|||||||
- DB hash 引导成功只证明远端 `.env` 数据库名已形成比较基线,不证明后续 `.env` / `current_database()` / runtime `DATABASE_URL` 三方一致性;远端 preflight 未输出 `target_identity_and_database_identity` PASS,故该验收项保持未通过。
|
- 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。
|
- 未获得 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。失败后未重跑、未跳过门禁。
|
- `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;取得真实远端错误码后必须立即停止并复核原因。
|
||||||
|
|||||||
@@ -234,6 +234,14 @@ export function validatePrismaStatusText(status) {
|
|||||||
return 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() {
|
function main() {
|
||||||
try {
|
try {
|
||||||
const deployServer = String(process.env.DEPLOY_SERVER ?? "").trim();
|
const deployServer = String(process.env.DEPLOY_SERVER ?? "").trim();
|
||||||
@@ -337,11 +345,12 @@ function main() {
|
|||||||
|
|
||||||
printStableRemoteSummary(stdout);
|
printStableRemoteSummary(stdout);
|
||||||
console.log(`[production-preflight] reportRef=${reportRef(reportDir)}`);
|
console.log(`[production-preflight] reportRef=${reportRef(reportDir)}`);
|
||||||
if (rawStatus == null) fail("PRISMA_STATUS_OUTPUT_MISSING");
|
assertRemotePreflightResult({
|
||||||
if (result.status !== 0) {
|
status: result.status,
|
||||||
const remoteCode = `${stdout}\n${result.stderr || ""}`.match(/code=([A-Z0-9_]+)/u)?.[1];
|
stdout,
|
||||||
fail(remoteCode || "PRODUCTION_PREFLIGHT_REMOTE_FAILED");
|
stderr: result.stderr,
|
||||||
}
|
rawStatus,
|
||||||
|
});
|
||||||
console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过");
|
console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = error instanceof ProductionPreflightError
|
const code = error instanceof ProductionPreflightError
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ describe("deployment safety contract", () => {
|
|||||||
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE");
|
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE");
|
||||||
expect(preflight).toContain("redacted datasource line");
|
expect(preflight).toContain("redacted datasource line");
|
||||||
expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH");
|
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("PRISMA_STATUS_BEGIN");
|
||||||
expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING");
|
expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING");
|
||||||
expect(remote).toContain("logged_failure");
|
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 () => {
|
it("captures a redacted pre-status evidence copy from a controlled SSH fixture", async () => {
|
||||||
const fakeBin = path.join(fixtureRoot, "bin");
|
const fakeBin = path.join(fixtureRoot, "bin");
|
||||||
const reportDir = path.join(fixtureRoot, "report");
|
const reportDir = path.join(fixtureRoot, "report");
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户