diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 8512e79..927fc21 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -355,7 +355,7 @@ docker run --rm \ 工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对七条 migration 的数据库 checksum、完成状态、关键人员组织表/字段类型与可空性、租约索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。 -apply 成功后立即固定审计文件本身;后续普通部署只能读取这一个文件,不能手工重写: +apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写: ```bash test -f "$MIGRATION_AUDIT_FILE" && test ! -L "$MIGRATION_AUDIT_FILE" @@ -377,9 +377,11 @@ test -n "$MIGRATION_AUDIT_SHA256" 任意一项不成立:不部署应用、不手工修改 `_prisma_migrations`、不批量改业务状态;先保留现场并依据备份制定恢复方案。 -## 6. 用同一 SHA 执行普通蓝绿部署 +## 6. 用同一 SHA 执行迁移批次的蓝绿部署 -保持 bootstrap 维护入口和全局部署锁不变,使用第 1 步固定的 runner,并把第 4 步真实生成的审计文件与 SHA-256 显式交给普通部署脚本: +数据库迁移与日常应用发布是两条独立流程。只有本节这种“刚完成迁移、要上线依赖该 schema 的代码”的批次,才向部署脚本传入 `-RequireMigrationAudit` 和审计凭证。日常代码、前端与配置发布不传这些参数,不会创建 dump、恢复隔离库或要求历史审计文件。 + +保持 bootstrap 维护入口和全局部署锁不变,使用第 1 步固定的 runner,并把第 4 步真实生成的审计文件与 SHA-256 显式交给迁移批次部署脚本: ```powershell .\scripts\git-incremental-deploy.ps1 ` @@ -387,7 +389,8 @@ test -n "$MIGRATION_AUDIT_SHA256" -Commit '<与 runner/migrator 完全相同的 40 位 SHA>' ` -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-.json' ` -MigrationAuditSha256 '<该文件的 64 位小写 SHA-256>' ` + -RequireMigrationAudit ` -KeepOldImages ``` -脚本会在候选创建前核对审计文件的路径、普通文件属性、`0600` 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。 +脚本会在候选创建前核对审计文件的路径、普通文件属性、`0600` 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。对于显式带 `-RequireMigrationAudit` 的迁移批次,缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。 diff --git a/docs/视频预览任务中断事故复盘-2026-08-11.md b/docs/视频预览任务中断事故复盘-2026-08-11.md index b9ff436..57be838 100644 --- a/docs/视频预览任务中断事故复盘-2026-08-11.md +++ b/docs/视频预览任务中断事故复盘-2026-08-11.md @@ -152,6 +152,7 @@ $bootstrap = @{ -Commit $bootstrap.TargetCommit ` -MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-.json' ` -MigrationAuditSha256 '<审计文件的64位小写SHA-256>' ` + -RequireMigrationAudit ` -KeepOldImages ``` diff --git a/lib/__tests__/video-transcode-deploy-drain.test.ts b/lib/__tests__/video-transcode-deploy-drain.test.ts index 5db176c..d1e95bd 100644 --- a/lib/__tests__/video-transcode-deploy-drain.test.ts +++ b/lib/__tests__/video-transcode-deploy-drain.test.ts @@ -908,13 +908,15 @@ docker() { } }, 30_000) - test('迁移审计由隔离 root 只读解析,并在候选创建前绑定数据库实际身份', () => { + test('迁移审计仅在显式迁移批次启用,并在候选创建前绑定数据库实际身份', () => { const audit = script.indexOf('verify_migration_audit_evidence') + const optIn = script.indexOf('[switch]$RequireMigrationAudit') const rootParser = script.indexOf('--read-only --user 0:0 --cap-drop ALL --security-opt no-new-privileges', audit) const identity = script.indexOf('verify_candidate_database_identity "$NEXT_CONTAINER"', rootParser) const switchTraffic = script.indexOf('if ! switch_proxy; then', identity) expect(audit).toBeGreaterThan(0) + expect(optIn).toBeGreaterThan(0) expect(rootParser).toBeGreaterThan(audit) expect(identity).toBeGreaterThan(rootParser) expect(switchTraffic).toBeGreaterThan(identity) @@ -922,7 +924,7 @@ docker() { expect(script).not.toContain('process.stdin.on("end", async () => {') }) - test('纯代码发布只可复用祖先提交且 Prisma 定义完全一致的迁移审计', () => { + test('只有显式迁移批次才校验审计来源提交与 Prisma 定义', () => { const gitSync = script.indexOf('COMMIT_SHA="$(git rev-parse HEAD)"') const ancestor = script.indexOf('git merge-base --is-ancestor "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA"', gitSync) const schemaDiff = script.indexOf('git diff --quiet "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA" -- prisma/schema.prisma prisma/migrations', ancestor) @@ -932,11 +934,20 @@ docker() { expect(ancestor).toBeGreaterThan(gitSync) expect(schemaDiff).toBeGreaterThan(ancestor) expect(build).toBeGreaterThan(schemaDiff) + expect(script).toContain('if [ "$REQUIRE_MIGRATION_AUDIT" = "1" ] && [ "$SKIP_DEPLOY" != "1" ]; then') expect(script).toContain('Database schema or migrations changed after the approved audit; create a fresh verified migration audit') }) - test('迁移凭证绑定显式审计源提交,未指定时仍默认绑定当前部署提交', () => { - expect(script).toContain('$MigrationAuditSourceCommit = $Commit') + test('日常应用发布默认不消费迁移审计,也不会因此进入迁移流程', () => { + expect(script).toContain("'__REQUIRE_MIGRATION_AUDIT__'") + expect(script).toContain('REQUIRE_MIGRATION_AUDIT=__REQUIRE_MIGRATION_AUDIT__') + expect(script).toContain('if [ "$REQUIRE_MIGRATION_AUDIT" != "1" ]; then') + expect(script).toContain('Migration audit is not required for this normal application release') + expect(script).toContain('[ "$REQUIRE_MIGRATION_AUDIT" = "1" ] || return 0') + }) + + test('迁移凭证仅在显式迁移批次绑定审计源提交', () => { + expect(script).toContain('if ($RequireMigrationAudit -and [string]::IsNullOrWhiteSpace($MigrationAuditSourceCommit))') expect(script).toContain('-e MIGRATION_AUDIT_SOURCE_COMMIT="$MIGRATION_AUDIT_SOURCE_COMMIT"') expect(script).toContain('audit.sourceCommit !== process.env.MIGRATION_AUDIT_SOURCE_COMMIT') expect(script).not.toContain('audit.sourceCommit !== process.env.COMMIT_SHA') diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index ebe6752..6219cfe 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -46,6 +46,10 @@ param( [string]$MigrationAuditFile = $env:GONGXIANGP2_MIGRATION_AUDIT_FILE, [string]$MigrationAuditSha256 = $env:GONGXIANGP2_MIGRATION_AUDIT_SHA256, [string]$MigrationAuditSourceCommit = $env:GONGXIANGP2_MIGRATION_AUDIT_SOURCE_COMMIT, + # Normal application releases do not run the database migration workflow. + # This explicit switch is only for a migration batch that already has an + # independently created and verified audit receipt. + [switch]$RequireMigrationAudit, [string]$DownloadTokenSecret = $env:DOWNLOAD_TOKEN_SECRET, @@ -76,11 +80,11 @@ if (-not $DryRun -and $Commit -cnotmatch '^[0-9a-f]{40}$') { throw 'HTTP Git transport requires -Commit with the exact 40-character lowercase SHA selected on the trusted workstation.' } -if ([string]::IsNullOrWhiteSpace($MigrationAuditSourceCommit)) { +if ($RequireMigrationAudit -and [string]::IsNullOrWhiteSpace($MigrationAuditSourceCommit)) { $MigrationAuditSourceCommit = $Commit } -if (-not $DryRun -and -not $SkipDeploy) { +if ($RequireMigrationAudit -and -not $DryRun -and -not $SkipDeploy) { if ($MigrationAuditFile -cnotmatch '^/root/gongxiangp2-migration-audit/[A-Za-z0-9._-]+/applied-[0-9]{8}T[0-9]{6}Z\.json$') { throw 'Production deployment requires the immutable apply audit file under /root/gongxiangp2-migration-audit//applied-.json.' } @@ -694,6 +698,7 @@ $tokens = @{ '__MIGRATION_AUDIT_FILE__' = ConvertTo-ShellSingleQuoted $MigrationAuditFile '__MIGRATION_AUDIT_SHA256__' = ConvertTo-ShellSingleQuoted $MigrationAuditSha256 '__MIGRATION_AUDIT_SOURCE_COMMIT__' = ConvertTo-ShellSingleQuoted $MigrationAuditSourceCommit + '__REQUIRE_MIGRATION_AUDIT__' = ConvertTo-ShellSingleQuoted ($(if ($RequireMigrationAudit) { '1' } else { '0' })) '__CONTAINER_NAME__' = ConvertTo-ShellSingleQuoted $cfg.ContainerName '__PORT__' = ConvertTo-ShellSingleQuoted ([string]$cfg.Port) '__ENV_FILE__' = ConvertTo-ShellSingleQuoted $cfg.EnvFile @@ -733,6 +738,7 @@ UPLOADS_MOUNT=__UPLOADS_MOUNT__ MIGRATION_AUDIT_FILE=__MIGRATION_AUDIT_FILE__ MIGRATION_AUDIT_SHA256=__MIGRATION_AUDIT_SHA256__ MIGRATION_AUDIT_SOURCE_COMMIT=__MIGRATION_AUDIT_SOURCE_COMMIT__ +REQUIRE_MIGRATION_AUDIT=__REQUIRE_MIGRATION_AUDIT__ CONTAINER_NAME=__CONTAINER_NAME__ PORT=__PORT__ ENV_FILE=__ENV_FILE__ @@ -882,7 +888,7 @@ git clean -fdx COMMIT_SHA="$(git rev-parse HEAD)" [ "$COMMIT_SHA" = "$COMMIT_INPUT" ] || fail "Git sync did not land on the exact preflighted commit" -if [ "$SKIP_DEPLOY" != "1" ]; then +if [ "$REQUIRE_MIGRATION_AUDIT" = "1" ] && [ "$SKIP_DEPLOY" != "1" ]; then git cat-file -e "${MIGRATION_AUDIT_SOURCE_COMMIT}^{commit}" 2>/dev/null || \ fail "Migration audit source commit is unavailable in the trusted build worktree" git merge-base --is-ancestor "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA" || \ @@ -1035,6 +1041,7 @@ MIGRATION_AUDIT_FILE="$9" shift 9 MIGRATION_AUDIT_SHA256="$1" MIGRATION_AUDIT_SOURCE_COMMIT="$2" +REQUIRE_MIGRATION_AUDIT="$3" log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } @@ -1399,6 +1406,10 @@ exec 9>"$DEPLOY_LOCK" flock -w 30 9 || fail "Another production deployment or legacy bootstrap transition is already running" verify_migration_audit_evidence() { + if [ "$REQUIRE_MIGRATION_AUDIT" != "1" ]; then + ok "Migration audit is not required for this normal application release" + return 0 + fi [ "$TARGET" = "production" ] || return 0 case "$MIGRATION_AUDIT_FILE" in /root/gongxiangp2-migration-audit/*/applied-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z.json) ;; @@ -1449,6 +1460,7 @@ NODE verify_migration_audit_evidence verify_candidate_database_identity() { + [ "$REQUIRE_MIGRATION_AUDIT" = "1" ] || return 0 local name="$1" docker exec -i "$name" node -e ' const { PrismaClient } = require("@prisma/client"); @@ -3499,7 +3511,7 @@ PROD_SCRIPT "$DINGTALK_CALLBACK_ORIGIN" "$DINGTALK_INTERNAL_RETURN_ORIGINS" "$S3_PUBLIC_DOMAIN" \ "$DOWNLOAD_TOKEN_SECRET" "$PROXY_RECONCILE_SCRIPT_B64" 'youyidian-3d_app-network' \ "$UPLOADS_MOUNT" "$ROLLBACK_ON_FAILURE" "$MIGRATION_AUDIT_FILE" "$MIGRATION_AUDIT_SHA256" \ - "$MIGRATION_AUDIT_SOURCE_COMMIT" + "$MIGRATION_AUDIT_SOURCE_COMMIT" "$REQUIRE_MIGRATION_AUDIT" ok "Production deployment command completed" if [ "$TARGET" = "production" ]; then diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 3f6ed1d..1dbea0d 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -529,9 +529,11 @@ test('container supply chain and migrator image are immutable while SOP keeps co assert.match(sop, /docker image inspect --format '\{\{\.Id\}\}' "\$image"/) assert.match(sop, /MigrationAuditFile/) assert.match(sop, /MigrationAuditSha256/) + assert.match(sop, /RequireMigrationAudit/) assert.match(incidentSop, /migrator 必须按《生产数据库安全增量迁移 SOP/) assert.match(incidentSop, /-MigrationAuditFile/) assert.match(incidentSop, /-MigrationAuditSha256/) + assert.match(incidentSop, /-RequireMigrationAudit/) assert.match(sop, /receiptNames\.length !== 1 \|\| dumpNames\.length !== 1 \|\| entries\.length !== 2/) assert.match(sop, /entry\.isFile\(\) && entry\.name\.endsWith\('\.receipt\.json'\)/) assert.match(sop, /entry\.isFile\(\) && entry\.name\.endsWith\('\.dump'\)/)