feat(08-08-production-deployment-validation): 增加生产目标与迁移只读门禁
这个提交包含在:
@@ -1,7 +1,9 @@
|
||||
const { execFile } = require("node:child_process");
|
||||
const { mkdtemp, mkdir, rm, symlink, writeFile } = require("node:fs/promises");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { chmod, mkdtemp, mkdir, rm, symlink, writeFile, readFile } = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -10,6 +12,8 @@ const PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/preflight-remote.sh");
|
||||
const AGGREGATE_PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/check-existing-deployment.sh");
|
||||
const REFRESH_HELPER = path.join(ROOT_DIR, "scripts/refresh-deployment-contract.sh");
|
||||
const REFRESH_INSTALLER = path.join(ROOT_DIR, "deploy/trialrun/install-deployment-contract-refresh.sh");
|
||||
const PRODUCTION_PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/production-preflight.mjs");
|
||||
const PRODUCTION_PREFLIGHT_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/production-preflight-remote.sh");
|
||||
|
||||
describe("deployment safety contract", () => {
|
||||
let fixtureRoot;
|
||||
@@ -87,10 +91,215 @@ describe("deployment safety contract", () => {
|
||||
expect(wrapper).toContain("DEPLOY_DATA_MODE=existing");
|
||||
expect(wrapper).toContain("SYNC_ENV=0");
|
||||
expect(wrapper).toContain("DEPLOY_DEFER_FINAL_SUCCESS=1");
|
||||
expect(wrapper).toContain("DEPLOY_STATUS_OUTPUT_MODE=quiet");
|
||||
expect(deploy).toContain("DEPLOY_STATUS_OUTPUT_MODE");
|
||||
expect(deploy).toContain("DEPLOY_STATUS_OUTPUT_MODE='${DEPLOY_STATUS_OUTPUT_MODE}'");
|
||||
expect(wrapper).toContain("生产部署验证不接受未进入上游的提交");
|
||||
expect(wrapper).toContain("部署后 migration status 与目标只读复核");
|
||||
expect(wrapper).toContain('EXPECTED_PENDING_MIGRATIONS=""');
|
||||
expect(wrapper.indexOf('EXPECTED_PENDING_MIGRATIONS=""')).toBeGreaterThan(
|
||||
wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'),
|
||||
);
|
||||
expect(wrapper.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh')).toBeGreaterThan(wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'));
|
||||
expect(wrapper).toContain("DEPLOYED_BUT_CONTRACT_REFRESH_FAILED");
|
||||
});
|
||||
|
||||
it("requires production target identity and pending migration allowlist before deploy.sh", async () => {
|
||||
const wrapper = await readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8");
|
||||
const preflight = await readFile(PRODUCTION_PREFLIGHT, "utf8");
|
||||
const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8");
|
||||
|
||||
expect(wrapper).toContain("EXPECTED_PRODUCTION_ORIGIN");
|
||||
expect(wrapper).toContain("EXPECTED_PRODUCTION_DB_NAME");
|
||||
expect(wrapper).toContain("EXPECTED_PENDING_MIGRATIONS");
|
||||
expect(wrapper.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT"')).toBeLessThan(
|
||||
wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'),
|
||||
);
|
||||
expect(preflight).toContain("CANDIDATE_MIGRATIONS_CSV");
|
||||
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_MISSING");
|
||||
expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE");
|
||||
expect(preflight).toContain("redacted datasource line");
|
||||
expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH");
|
||||
expect(remote).toContain("PRISMA_STATUS_BEGIN");
|
||||
expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING");
|
||||
expect(remote).toContain("logged_failure");
|
||||
expect(remote).toContain("PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY");
|
||||
expect(remote).toContain('FROM "_prisma_migrations"');
|
||||
expect(remote).toContain("rollbackWindow=OPEN");
|
||||
expect(remote).not.toMatch(/\b(prisma migrate reset|prisma db push|prisma migrate resolve)\b/);
|
||||
expect(remote.indexOf("PRISMA_STATUS_BEGIN")).toBeLessThan(remote.indexOf("_prisma_migrations"));
|
||||
});
|
||||
|
||||
it("rejects loopback/staging production identities without invoking SSH", async () => {
|
||||
const target = await import(pathToFileURL(PRODUCTION_PREFLIGHT).href);
|
||||
expect(() => target.validateProductionOrigin("https://127.0.0.1")).toThrowError(
|
||||
expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }),
|
||||
);
|
||||
expect(() => target.validateProductionOrigin("https://staging.example.com")).toThrowError(
|
||||
expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }),
|
||||
);
|
||||
expect(() => target.validateProductionOrigin("https://0.0.0.0")).toThrowError(
|
||||
expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }),
|
||||
);
|
||||
expect(() => target.validateProductionOrigin("https://[::ffff:127.0.0.1]")).toThrowError(
|
||||
expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }),
|
||||
);
|
||||
expect(() => target.validateProductionDbName("hyb_oa_regression")).toThrowError(
|
||||
expect.objectContaining({ code: "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY" }),
|
||||
);
|
||||
expect(() => target.validatePrismaStatusText("")).toThrowError(
|
||||
expect.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }),
|
||||
);
|
||||
expect(() => target.validatePrismaStatusText("unexpected output")).toThrowError(
|
||||
expect.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
const sshCapture = path.join(fixtureRoot, "ssh-command.txt");
|
||||
await mkdir(fakeBin, { recursive: true });
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(path.join(reportDir, "prisma-migrate-status.txt"), "stale PASS\n");
|
||||
const fakeSsh = path.join(fakeBin, "ssh");
|
||||
await writeFile(
|
||||
fakeSsh,
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
"require('node:fs').writeFileSync(process.env.FAKE_SSH_CAPTURE, process.argv.slice(2).join(' '));",
|
||||
"process.stdout.write('[production-preflight][PASS] target_identity\\n');",
|
||||
"process.stdout.write('[production-preflight][PRISMA_STATUS_BEGIN]\\n');",
|
||||
"process.stdout.write('Datasource \\\"db\\\": PostgreSQL database \\\"hyb_oa\\\" at \\\"db.internal:5432\\\"\\n');",
|
||||
"process.stdout.write('Database schema is up to date!\\n');",
|
||||
"process.stdout.write('[production-preflight][PRISMA_STATUS_END] exit=0\\n');",
|
||||
"process.stdout.write('[production-preflight][PASS] migration_reconciliation pending=none\\n');",
|
||||
"process.stdout.write('[production-preflight][PASS] rollbackWindow=OPEN terminatedRows=0\\n');",
|
||||
].join("\n"),
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
await chmod(fakeSsh, 0o700);
|
||||
|
||||
const candidateCommit = require("node:child_process")
|
||||
.execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT_DIR, encoding: "utf8" })
|
||||
.trim();
|
||||
const result = await execFileAsync("node", [PRODUCTION_PREFLIGHT], {
|
||||
cwd: ROOT_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
|
||||
DEPLOY_SERVER: "deploy.example.invalid",
|
||||
REMOTE_DIR: remoteDir,
|
||||
REMOTE_ENV_FILE: envFile,
|
||||
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
|
||||
COMPOSE_PROJECT_NAME: "production_app",
|
||||
POSTGRES_DATA_DIR: postgresDir,
|
||||
STORAGE_HOST_DIR: storageDir,
|
||||
EXPECTED_PRODUCTION_ORIGIN: "https://oa.hyinda.com",
|
||||
EXPECTED_PRODUCTION_DB_NAME: "hyb_oa",
|
||||
EXPECTED_PENDING_MIGRATIONS: "",
|
||||
CANDIDATE_COMMIT: candidateCommit,
|
||||
PRODUCTION_PREFLIGHT_REPORT_DIR: reportDir,
|
||||
FAKE_SSH_CAPTURE: sshCapture,
|
||||
},
|
||||
});
|
||||
const statusEvidence = await readFile(path.join(reportDir, "prisma-migrate-status.txt"), "utf8");
|
||||
const sshCommand = await readFile(sshCapture, "utf8");
|
||||
expect(result.stdout).toContain("[production-preflight][PASS]");
|
||||
expect(statusEvidence).toContain("Database schema is up to date!");
|
||||
expect(statusEvidence).toContain("[redacted datasource line]");
|
||||
expect(statusEvidence).not.toContain("hyb_oa");
|
||||
expect(statusEvidence).not.toContain("db.internal:5432");
|
||||
expect(result.stdout).not.toContain("oa.hyinda.com");
|
||||
expect(result.stdout).not.toContain("hyb_oa");
|
||||
expect(sshCommand).not.toContain("oa.hyinda.com");
|
||||
expect(sshCommand).not.toContain("hyb_oa");
|
||||
expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256");
|
||||
expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256");
|
||||
});
|
||||
|
||||
it("runs the remote gate against synthetic status and migration history", async () => {
|
||||
const fakeBin = path.join(fixtureRoot, "remote-bin");
|
||||
await mkdir(fakeBin, { recursive: true });
|
||||
await writeFile(path.join(remoteDir, "deploy/trialrun/.env"), [
|
||||
"APP_ORIGIN=https://oa.example.com",
|
||||
"POSTGRES_DB=production_db",
|
||||
"POSTGRES_USER=synthetic_user",
|
||||
"NODE_ENV=production",
|
||||
"COOKIE_SECURE=true",
|
||||
"",
|
||||
].join("\n"), { mode: 0o600 });
|
||||
await writeFile(path.join(fakeBin, "docker"), [
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"joined=\"$*\"",
|
||||
"mode=\"${FAKE_REMOTE_MODE:-happy}\"",
|
||||
"if [[ \"$1\" == compose && \"${2:-}\" == version ]]; then exit 0; fi",
|
||||
"if [[ \"$1\" == ps ]]; then",
|
||||
" if [[ \"$joined\" == *service=server* ]]; then printf 'server1\\n'; else printf 'db1\\n'; fi",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [[ \"$1\" == inspect ]]; then printf 'NODE_ENV=production\\nCOOKIE_SECURE=true\\nAPP_ORIGIN=https://oa.example.com\\n'; exit 0; fi",
|
||||
"if [[ \"$joined\" == *' config'* ]]; then exit 0; fi",
|
||||
"if [[ \"$joined\" == *' exec '* && \"$joined\" == *' sh -lc '* ]]; then",
|
||||
" case \"$mode\" in",
|
||||
" pending) printf 'Following migration have not yet been applied:\\n20260807193001_followup_migration\\n'; exit 1 ;;",
|
||||
" unknown) printf 'Following migration have not yet been applied:\\n20260807193002_unknown_migration\\n'; exit 1 ;;",
|
||||
" *) printf 'Database schema is up to date!\\n'; exit 0 ;;",
|
||||
" esac",
|
||||
"fi",
|
||||
"if [[ \"$joined\" == *' exec '* && \"$joined\" == *' node -e '* ]]; then",
|
||||
" printf 'db.internal\\nproduction_db\\n'; exit 0",
|
||||
"fi",
|
||||
"if [[ \"$joined\" == *'SELECT current_database()'* ]]; then printf 'production_db\\n'; exit 0; fi",
|
||||
"if [[ \"$joined\" == *'_prisma_migrations'* ]]; then",
|
||||
" case \"$mode\" in",
|
||||
" pending) printf '20260807193000_contract_registry_termination_workspace|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",
|
||||
"printf 'unexpected fake docker call: %s\\n' \"$joined\" >&2",
|
||||
"exit 97",
|
||||
"",
|
||||
].join("\n"), { mode: 0o700 });
|
||||
await chmod(path.join(fakeBin, "docker"), 0o700);
|
||||
|
||||
const hash = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
||||
const runRemote = (mode, expectedPending = "") => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], {
|
||||
cwd: remoteDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
|
||||
FAKE_REMOTE_MODE: mode,
|
||||
REMOTE_DIR: remoteDir,
|
||||
REMOTE_ENV_FILE: envFile,
|
||||
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
|
||||
COMPOSE_PROJECT_NAME: "production_app",
|
||||
POSTGRES_DATA_DIR: postgresDir,
|
||||
STORAGE_HOST_DIR: storageDir,
|
||||
EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"),
|
||||
EXPECTED_PRODUCTION_DB_NAME_SHA256: hash("production_db"),
|
||||
CANDIDATE_MIGRATIONS_CSV: "20260807193000_contract_registry_termination_workspace,20260807193001_followup_migration",
|
||||
EXPECTED_PENDING_MIGRATIONS_CSV: expectedPending,
|
||||
},
|
||||
});
|
||||
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");
|
||||
const pendingResult = await runRemote("pending", "20260807193001_followup_migration");
|
||||
expect(pendingResult.stdout).toContain("migration_reconciliation pending=20260807193001_followup_migration");
|
||||
await expect(runRemote("unknown")).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"),
|
||||
});
|
||||
await expect(runRemote("logs")).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining("PROD_MIGRATION_FAILED_OR_ROLLED_BACK"),
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates preflight checks and keeps the root helper self-contained", async () => {
|
||||
const aggregate = await require("node:fs/promises").readFile(AGGREGATE_PREFLIGHT, "utf8");
|
||||
const helper = await require("node:fs/promises").readFile(REFRESH_HELPER, "utf8");
|
||||
|
||||
在新工单中引用
屏蔽一个用户