feat(08-08-production-deployment-validation): 增加生产目标与迁移只读门禁
这个提交包含在:
@@ -38,6 +38,11 @@ const deploymentRefreshHelperPath = path.join(
|
||||
'scripts/refresh-deployment-contract.sh',
|
||||
);
|
||||
const remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-remote.sh');
|
||||
const productionPreflightScriptPath = path.join(rootDir, 'deploy/trialrun/production-preflight.mjs');
|
||||
const productionPreflightRemoteScriptPath = path.join(
|
||||
rootDir,
|
||||
'deploy/trialrun/production-preflight-remote.sh',
|
||||
);
|
||||
const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile');
|
||||
const timerScriptPath = path.resolve(
|
||||
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|
||||
@@ -272,6 +277,8 @@ const deploymentPreflightScriptContent = readFile(deploymentPreflightScriptPath)
|
||||
const deploymentRefreshInstallerContent = readFile(deploymentRefreshInstallerPath);
|
||||
const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath);
|
||||
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
|
||||
const productionPreflightScriptContent = readFile(productionPreflightScriptPath);
|
||||
const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath);
|
||||
const dockerfileContent = readFile(dockerfilePath);
|
||||
const timerScriptContent = readFile(timerScriptPath);
|
||||
const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath);
|
||||
@@ -582,17 +589,33 @@ for (const requiredFragment of [
|
||||
'check-existing-deployment.sh',
|
||||
'deployment_contract_load',
|
||||
'DEPLOY_DEFER_FINAL_SUCCESS=1',
|
||||
'DEPLOY_STATUS_OUTPUT_MODE=quiet',
|
||||
'ssh "$DEPLOY_REFRESH_SERVER" refresh',
|
||||
'DEPLOYED_BUT_CONTRACT_REFRESH_FAILED',
|
||||
'DEPLOY_DATA_MODE=existing',
|
||||
'SYNC_ENV=0',
|
||||
'路径与标识不回显',
|
||||
'生产部署验证不接受未进入上游的提交',
|
||||
]) {
|
||||
if (!deployExistingScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`deploy-existing.sh 缺少既有部署合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'EXPECTED_PRODUCTION_ORIGIN',
|
||||
'EXPECTED_PRODUCTION_DB_NAME',
|
||||
'EXPECTED_PENDING_MIGRATIONS',
|
||||
'production-preflight.mjs',
|
||||
'CANDIDATE_COMMIT',
|
||||
'PRODUCTION_PREFLIGHT_REPORT_DIR',
|
||||
'未进入 deploy.sh',
|
||||
]) {
|
||||
if (!deployExistingScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`deploy-existing.sh 缺少生产迁移前置门禁: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'systemctl show',
|
||||
'DEPLOY_CONTRACT_KEYS',
|
||||
@@ -669,6 +692,68 @@ if (deployExistingScriptContent.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh')
|
||||
errors.push('deploy-existing.sh 必须在应用部署成功后刷新备份合同');
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'validateProductionOrigin',
|
||||
'validateProductionDbName',
|
||||
'collectMigrationManifest',
|
||||
'EXPECTED_PENDING_MIGRATIONS',
|
||||
'PRISMA_STATUS_OUTPUT_MISSING',
|
||||
'PRISMA_STATUS_OUTPUT_UNREADABLE',
|
||||
'sanitizePrismaStatus',
|
||||
'stableRemoteSummary',
|
||||
'PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH',
|
||||
'prisma-migrate-status.txt',
|
||||
'PRODUCTION_PREFLIGHT_REPORT_DIR',
|
||||
]) {
|
||||
if (!productionPreflightScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`production-preflight.mjs 缺少生产目标/候选迁移合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'EXPECTED_PRODUCTION_ORIGIN_SHA256',
|
||||
'EXPECTED_PRODUCTION_DB_NAME_SHA256',
|
||||
'PRISMA_STATUS_BEGIN',
|
||||
'FROM "_prisma_migrations"',
|
||||
'logged_failure',
|
||||
'PROD_MIGRATION_STATUS_UNKNOWN_PENDING',
|
||||
'PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY',
|
||||
'PROD_MIGRATION_PENDING_ALLOWLIST_MISMATCH',
|
||||
'rollbackWindow=OPEN',
|
||||
'PROD_TARGET_ORIGIN_MISMATCH',
|
||||
]) {
|
||||
if (!productionPreflightRemoteScriptContent.includes(requiredFragment)) {
|
||||
errors.push(`production-preflight-remote.sh 缺少只读生产门禁合同: ${requiredFragment}`);
|
||||
}
|
||||
}
|
||||
if (productionPreflightRemoteScriptContent.includes('migrate reset')
|
||||
|| productionPreflightRemoteScriptContent.includes('db push')
|
||||
|| productionPreflightRemoteScriptContent.includes('migrate resolve')) {
|
||||
errors.push('production-preflight-remote.sh 不得包含 reset/db push/resolve 写入路径');
|
||||
}
|
||||
if (deployExistingScriptContent.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT"')
|
||||
> deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
|
||||
errors.push('deploy-existing.sh 必须在 deploy.sh 前执行生产目标与迁移 preflight');
|
||||
}
|
||||
if (!deployScriptContent.includes('DEPLOY_STATUS_OUTPUT_MODE')) {
|
||||
errors.push('deploy.sh 必须支持受控发布 wrapper 的 quiet migration status 输出模式');
|
||||
}
|
||||
if (deployScriptContent.indexOf("DEPLOY_STATUS_OUTPUT_MODE='${DEPLOY_STATUS_OUTPUT_MODE}'") === -1) {
|
||||
errors.push('deploy.sh 必须将 DEPLOY_STATUS_OUTPUT_MODE 传入远端部署 shell');
|
||||
}
|
||||
if (deployExistingScriptContent.indexOf('EXPECTED_PENDING_MIGRATIONS=""')
|
||||
< deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) {
|
||||
errors.push('deploy-existing.sh 必须在 deploy.sh 后执行空 pending 的 post-status 复核');
|
||||
}
|
||||
if (productionPreflightRemoteScriptContent.indexOf('database_url_identity=')
|
||||
> productionPreflightRemoteScriptContent.indexOf('prisma_status_output=')) {
|
||||
errors.push('production-preflight-remote.sh 必须先确认 DATABASE_URL host/db 身份,再执行 migration status');
|
||||
}
|
||||
if (deployExistingScriptContent.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh')
|
||||
< deployExistingScriptContent.indexOf('EXPECTED_PENDING_MIGRATIONS=""')) {
|
||||
errors.push('deploy-existing.sh 必须在 post-status 复核后刷新备份合同');
|
||||
}
|
||||
|
||||
for (const requiredFragment of [
|
||||
'REMOTE_DIR 是现存仓库根目录',
|
||||
'SYNC_ENV=0 要求同步前已有普通远端 .env 文件',
|
||||
|
||||
在新工单中引用
屏蔽一个用户