fix: 将迁移审计从常规发布中剥离
This commit is contained in:
parent
440b1a7f60
commit
2c7df1765b
@ -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` 的迁移批次,缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。
|
||||
|
||||
@ -152,6 +152,7 @@ $bootstrap = @{
|
||||
-Commit $bootstrap.TargetCommit `
|
||||
-MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-<UTC时间>.json' `
|
||||
-MigrationAuditSha256 '<审计文件的64位小写SHA-256>' `
|
||||
-RequireMigrationAudit `
|
||||
-KeepOldImages
|
||||
```
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'\)/)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user