fix: 将迁移审计从常规发布中剥离

This commit is contained in:
chenpei 2026-08-15 18:14:24 +08:00
parent 440b1a7f60
commit 2c7df1765b
5 changed files with 41 additions and 12 deletions

View File

@ -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-<UTC时间>.json' `
-MigrationAuditSha256 '<该文件的 64 位小写 SHA-256>' `
-RequireMigrationAudit `
-KeepOldImages
```
脚本会在候选创建前核对审计文件的路径、普通文件属性、`0600` 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。
脚本会在候选创建前核对审计文件的路径、普通文件属性、`0600` 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。对于显式带 `-RequireMigrationAudit` 的迁移批次,缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。

View File

@ -152,6 +152,7 @@ $bootstrap = @{
-Commit $bootstrap.TargetCommit `
-MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-<UTC时间>.json' `
-MigrationAuditSha256 '<审计文件的64位小写SHA-256>' `
-RequireMigrationAudit `
-KeepOldImages
```

View File

@ -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')

View File

@ -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/<batch>/applied-<UTC>.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

View File

@ -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'\)/)