#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const childProcess = require('node:child_process'); const rootDir = path.resolve(__dirname, '..', '..'); const configPath = path.join(rootDir, 'server/src/config.js'); const envExamplePath = path.resolve( process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'), ); const recoveryClassificationPath = path.resolve( process.env.ENV_CONTRACT_RECOVERY_CLASSIFICATION_PATH || path.join(rootDir, 'deploy/trialrun/env-recovery-classification.json'), ); const composePath = path.resolve( process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'), ); const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md'); const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md'); const deployScriptPath = path.resolve( process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'), ); const deployExistingScriptPath = path.join(rootDir, 'deploy/trialrun/deploy-existing.sh'); const deploymentContractLibraryPath = path.join( rootDir, 'deploy/trialrun/deployment-contract-lib.sh', ); const deploymentPreflightScriptPath = path.join( rootDir, 'deploy/trialrun/check-existing-deployment.sh', ); const deploymentRefreshInstallerPath = path.join( rootDir, 'deploy/trialrun/install-deployment-contract-refresh.sh', ); const deploymentRefreshHelperPath = path.join( rootDir, '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 productionIdentityBootstrapScriptPath = path.join( rootDir, 'deploy/trialrun/production-identity-bootstrap.mjs', ); const productionIdentityBootstrapRemoteScriptPath = path.join( rootDir, 'deploy/trialrun/production-identity-bootstrap-remote.sh', ); const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile'); const timerScriptPath = path.resolve( process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'), ); const automaticBackupTimerScriptPath = path.join( rootDir, 'deploy/trialrun/install-automatic-backup-timer.sh', ); const offhostReceiverInstallerPath = path.join( rootDir, 'deploy/trialrun/install-offhost-backup-receiver.sh', ); const automaticBackupRunnerPath = path.join(rootDir, 'scripts/run-automatic-backup.sh'); const offhostReceiverPath = path.join(rootDir, 'scripts/receive-offhost-backup.sh'); const groupAlertHelperPath = path.join(rootDir, 'scripts/send-wecom-group-alert.py'); const offhostDeadmanPath = path.join(rootDir, 'scripts/check-offhost-backup-deadman.py'); const nginxPaths = [ path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'), path.join(rootDir, 'deploy/trialrun/nginx.conf.example'), ]; const requiredServices = ['server', 'worker', 'init']; const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']); const allowedOverrides = { server: ['NODE_ENV', 'BUILD_SHA'], worker: ['NODE_ENV', 'BUILD_SHA', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'], init: ['NODE_ENV', 'BUILD_SHA', 'DEPLOY_DATA_MODE'], }; const requiredRuntimeOverrides = { server: ['BUILD_SHA'], worker: ['BUILD_SHA'], init: ['BUILD_SHA', 'DEPLOY_DATA_MODE'], }; const requiredBuildArgs = { server: ['BUILD_SHA', 'VITE_BUILD_SHA'], worker: ['BUILD_SHA', 'VITE_BUILD_SHA'], init: ['BUILD_SHA', 'VITE_BUILD_SHA'], }; const recoveryCategories = [ 'template_rebuild', 'external_reissue', 'direct_rotate', 'downtime_reset', 'preserve_original', ]; function readFile(filePath) { return fs.readFileSync(filePath, 'utf8'); } function extractConfigEnvKeys(content) { const keys = new Set(); for (const match of content.matchAll(/process\.env\.([A-Z0-9_]+)/g)) { keys.add(match[1]); } for (const match of content.matchAll(/requiredEnv\("([A-Z0-9_]+)"\)/g)) { keys.add(match[1]); } return [...keys].sort(); } function extractEnvExampleKeys(content) { const keys = new Set(); for (const line of content.split(/\r?\n/)) { const match = line.match(/^\s*#?\s*([A-Z0-9_]+)\s*=/); if (match) { keys.add(match[1]); } } return keys; } function validateRecoveryClassification(content, envKeys) { const validationErrors = []; let parsed; try { parsed = JSON.parse(content); } catch { return { errors: ['环境恢复分类清单必须是有效 JSON'], keyCount: 0 }; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { errors: ['环境恢复分类清单顶层必须是对象'], keyCount: 0 }; } const topLevelKeys = Object.keys(parsed).sort(); if (topLevelKeys.join(',') !== 'categories,version') { validationErrors.push('环境恢复分类清单顶层只允许 version 和 categories'); } if (parsed.version !== 1) { validationErrors.push('环境恢复分类清单 version 必须为 1'); } if (!parsed.categories || typeof parsed.categories !== 'object' || Array.isArray(parsed.categories)) { return { errors: [...validationErrors, '环境恢复分类清单 categories 必须是对象'], keyCount: 0 }; } const actualCategories = Object.keys(parsed.categories).sort(); const expectedCategories = [...recoveryCategories].sort(); for (const category of expectedCategories) { if (!actualCategories.includes(category)) { validationErrors.push(`环境恢复分类清单缺少类别 ${category}`); } } for (const category of actualCategories) { if (!expectedCategories.includes(category)) { validationErrors.push(`环境恢复分类清单存在未知类别 ${category}`); } } const occurrences = new Map(); for (const category of expectedCategories) { const entries = parsed.categories[category]; if (!Array.isArray(entries)) { validationErrors.push(`环境恢复类别 ${category} 必须是键名数组`); continue; } for (const entry of entries) { if (typeof entry !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry)) { validationErrors.push(`环境恢复类别 ${category} 含非法键名或疑似值`); continue; } const categories = occurrences.get(entry) ?? []; categories.push(category); occurrences.set(entry, categories); } } for (const [key, categories] of occurrences) { if (categories.length !== 1) { validationErrors.push(`环境恢复键 ${key} 重复分类: ${categories.join(', ')}`); } if (!envKeys.has(key)) { validationErrors.push(`环境恢复分类包含 .env.example 外键 ${key}`); } } for (const key of envKeys) { if (!occurrences.has(key)) { validationErrors.push(`环境恢复分类缺少 ${key}`); } } const preserveOriginal = parsed.categories.preserve_original; if (!Array.isArray(preserveOriginal) || !preserveOriginal.includes('FIELD_ENCRYPTION_KEY')) { validationErrors.push('FIELD_ENCRYPTION_KEY 必须归入 preserve_original'); } const downtimeReset = parsed.categories.downtime_reset; if (!Array.isArray(downtimeReset) || !downtimeReset.includes('POSTGRES_PASSWORD')) { validationErrors.push('POSTGRES_PASSWORD 必须归入 downtime_reset'); } return { errors: validationErrors, keyCount: occurrences.size }; } function extractServiceBlock(composeContent, serviceName) { const lines = composeContent.split(/\r?\n/); let capture = false; const block = []; for (const line of lines) { const serviceMatch = line.match(/^ ([A-Za-z0-9_]+):\s*$/); if (serviceMatch) { if (capture) { break; } capture = serviceMatch[1] === serviceName; continue; } if (capture) { block.push(line); } } return block.length ? block.join('\n') : null; } function extractSection(serviceBlock, sectionName) { const lines = serviceBlock.split(/\r?\n/); const sectionLines = []; let capture = false; for (const line of lines) { const sectionMatch = line.match(/^\s{4}([A-Za-z0-9_]+):\s*$/); if (sectionMatch) { if (capture) { break; } capture = sectionMatch[1] === sectionName; continue; } if (capture) { sectionLines.push(line); } } return sectionLines; } function extractEnvFileEntries(serviceBlock) { return extractSection(serviceBlock, 'env_file') .map((line) => line.match(/^\s{6}-\s*(.+?)\s*$/)) .filter(Boolean) .map((item) => item[1]); } function extractEnvironmentKeys(serviceBlock) { return extractSection(serviceBlock, 'environment') .map((line) => line.match(/^\s{6}([A-Z0-9_]+):/)) .filter(Boolean) .map((item) => item[1]); } function fail(message, errors) { console.error(`[R26][FAIL] ${message}`); for (const error of errors) { console.error(`- ${error}`); } process.exit(1); } const configContent = readFile(configPath); const envExampleContent = readFile(envExamplePath); const recoveryClassificationContent = readFile(recoveryClassificationPath); const composeContent = readFile(composePath); const runbookContent = readFile(runbookPath); const checklistContent = readFile(checklistPath); const deployScriptContent = readFile(deployScriptPath); const deployExistingScriptContent = readFile(deployExistingScriptPath); const deploymentContractLibraryContent = readFile(deploymentContractLibraryPath); 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 sourceProvenanceScriptContent = readFile( path.join(rootDir, 'deploy/trialrun/check-source-provenance.mjs'), ); const productionIdentityBootstrapScriptContent = readFile(productionIdentityBootstrapScriptPath); const productionIdentityBootstrapRemoteScriptContent = readFile(productionIdentityBootstrapRemoteScriptPath); const dockerfileContent = readFile(dockerfilePath); const timerScriptContent = readFile(timerScriptPath); const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath); const offhostReceiverInstallerContent = readFile(offhostReceiverInstallerPath); const automaticBackupRunnerContent = readFile(automaticBackupRunnerPath); const offhostReceiverContent = readFile(offhostReceiverPath); const groupAlertHelperContent = readFile(groupAlertHelperPath); const offhostDeadmanContent = readFile(offhostDeadmanPath); const nginxContents = nginxPaths.map((filePath) => ({ filePath, content: readFile(filePath), })); const configKeys = extractConfigEnvKeys(configContent); const envExampleKeys = extractEnvExampleKeys(envExampleContent); const errors = []; const recoveryClassification = validateRecoveryClassification( recoveryClassificationContent, envExampleKeys, ); errors.push(...recoveryClassification.errors); for (const key of configKeys) { if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) { errors.push(`.env.example 缺少 ${key}`); } } for (const key of deploymentInjectedEnvKeys) { if (envExampleKeys.has(key)) { errors.push(`.env.example 不应持久化部署注入键 ${key}`); } } for (const key of ['APP_HOST_PORT', 'POSTGRES_DATA_DIR', 'STORAGE_HOST_DIR']) { if (!envExampleKeys.has(key)) { errors.push(`.env.example 缺少 Compose 宿主契约键 ${key}`); } } if (envExampleKeys.has('COMPOSE_PROJECT_NAME')) { errors.push('.env.example 不应持久化 COMPOSE_PROJECT_NAME,调用方必须通过 -p 显式选择项目'); } for (const key of ['ALERT_WEBHOOK_FILE', 'WECOM_WEBHOOK_URL', 'WECOM_WEBHOOK_KEY']) { if (envExampleKeys.has(key)) { errors.push(`.env.example 不应持久化企微群机器人配置 ${key}`); } } const trackedFiles = childProcess.execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: rootDir, encoding: 'utf8', }).split('\0').filter(Boolean); const literalWebhookPattern = /https:\/\/qyapi\.weixin\.qq\.com\/cgi-bin\/webhook\/send\?key=/; for (const relativePath of trackedFiles) { const filePath = path.join(rootDir, relativePath); if (!fs.existsSync(filePath)) continue; if (!fs.statSync(filePath).isFile()) continue; const content = fs.readFileSync(filePath); if (content.includes(0)) continue; if (literalWebhookPattern.test(content.toString('utf8'))) { errors.push(`${relativePath} 不得持久化完整企微群机器人 Webhook URL`); } } for (const serviceName of requiredServices) { const block = extractServiceBlock(composeContent, serviceName); if (!block) { errors.push(`docker-compose.yml 缺少服务 ${serviceName}`); continue; } const envFiles = extractEnvFileEntries(block); if (!envFiles.includes('./.env')) { errors.push(`服务 ${serviceName} 未通过 env_file 引用 ./.env`); } const environmentKeys = extractEnvironmentKeys(block); const illegalOverrides = environmentKeys.filter( (key) => !(allowedOverrides[serviceName] ?? []).includes(key), ); const missingRuntimeOverrides = (requiredRuntimeOverrides[serviceName] ?? []).filter( (key) => !environmentKeys.includes(key), ); const missingBuildArgs = (requiredBuildArgs[serviceName] ?? []).filter( (key) => !new RegExp(`^\\s{8}${key}:`, 'm').test(block), ); if (illegalOverrides.length) { errors.push(`服务 ${serviceName} 存在未授权 override: ${illegalOverrides.join(', ')}`); } if (missingRuntimeOverrides.length) { errors.push(`服务 ${serviceName} 缺少运行时 override: ${missingRuntimeOverrides.join(', ')}`); } if (missingBuildArgs.length) { errors.push(`服务 ${serviceName} 缺少构建参数: ${missingBuildArgs.join(', ')}`); } } const initBlock = extractServiceBlock(composeContent, 'init'); if (!initBlock || !initBlock.includes(' DEPLOY_DATA_MODE: ${DEPLOY_DATA_MODE:-}')) { errors.push('init 的 DEPLOY_DATA_MODE 必须显式传入,缺失时为空,禁止默认初始化'); } const serverBlock = extractServiceBlock(composeContent, 'server'); if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}:3000["']\s*$/m.test(serverBlock)) { errors.push('服务 server 必须通过 APP_HOST_PORT 将容器 3000 端口只绑定到宿主回环地址'); } const dbBlock = extractServiceBlock(composeContent, 'db'); if (!dbBlock) { errors.push('docker-compose.yml 缺少服务 db'); } else { if (/^\s{4}ports:\s*$/m.test(dbBlock)) { errors.push('服务 db 不得向宿主发布端口'); } if (!/\$\{POSTGRES_DATA_DIR:-\.\/data\/postgres\}:\/var\/lib\/postgresql\/data/.test(dbBlock)) { errors.push('服务 db 必须通过 POSTGRES_DATA_DIR 参数化宿主数据目录'); } } if (/^\s+container_name:\s*/m.test(composeContent)) { errors.push('docker-compose.yml 不得固定 container_name,容器身份必须由 Compose project 隔离'); } for (const serviceName of ['server', 'worker', 'init']) { const block = extractServiceBlock(composeContent, serviceName); if (block && !/\$\{STORAGE_HOST_DIR:-\.\/data\/storage\}:\$\{STORAGE_DIR\}/.test(block)) { errors.push(`服务 ${serviceName} 必须通过 STORAGE_HOST_DIR 参数化宿主附件目录`); } } for (const [name, content] of [ ['deploy.sh', deployScriptContent], ['install-warch3-ops-timer.sh', timerScriptContent], ]) { if (!content.includes('-p "$COMPOSE_PROJECT_NAME"')) { errors.push(`${name} 必须向 Compose 显式传入独立 project name`); } if (!content.includes('"$value" == *"//"*') || !content.includes('"/$value/" == *"/../"*') || !content.includes('"/$value/" == *"/./"*')) { errors.push(`${name} 缺少绝对数据路径的空段与点路径段门禁`); } if (content.includes('[[:space:]]*)($|/)')) { errors.push(`${name} 不得以可匹配空字符串的路径段正则拒绝全部绝对路径`); } } for (const requiredFragment of [ 'POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}"', 'STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}"', 'BUILD_SHA="${BUILD_SHA:-}"', 'VITE_BUILD_SHA="${VITE_BUILD_SHA:-}"', 'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', 'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', '"$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/*', '"$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/*', '"$POSTGRES_DATA_DIR/PG_VERSION"', '"$BUILD_SHA" =~ ^[0-9a-fA-F]{40,64}$', '"$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$', '"${BUILD_SHA:0:12}" != "$VITE_BUILD_SHA"', 'Environment=POSTGRES_DATA_DIR=${POSTGRES_DATA_DIR}', 'Environment=STORAGE_HOST_DIR=${STORAGE_HOST_DIR}', 'Environment=BUILD_SHA=${BUILD_SHA}', 'Environment=VITE_BUILD_SHA=${VITE_BUILD_SHA}', ]) { if (!timerScriptContent.includes(requiredFragment)) { errors.push(`install-warch3-ops-timer.sh 缺少活动部署环境绑定: ${requiredFragment}`); } } for (const requiredFragment of [ 'deriveCandidateBuildEnv', 'BUILD_SHA: normalized', 'VITE_BUILD_SHA: normalized.slice(0, 12)', 'buildEnv,', ]) { if (!sourceProvenanceScriptContent.includes(requiredFragment)) { errors.push(`check-source-provenance.mjs 缺少候选构建身份单一合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'Object.entries(p.buildEnv ?? {})', 'declare -A SOURCE_BUILD_ENV=()', '"${#SOURCE_BUILD_ENV[@]}" -eq 2', 'SOURCE_COMMIT="${SOURCE_BUILD_ENV[BUILD_SHA]}"', 'SOURCE_SHORT_COMMIT="${SOURCE_BUILD_ENV[VITE_BUILD_SHA]}"', ]) { if (!deployScriptContent.includes(requiredFragment)) { errors.push(`deploy.sh 未完整消费 provenance buildEnv: ${requiredFragment}`); } } if (deployScriptContent.includes('p.shortCommit') || deployScriptContent.includes('p.buildEnv.BUILD_SHA') || deployScriptContent.includes('p.buildEnv.VITE_BUILD_SHA') || deployScriptContent.includes('${BUILD_SHA:0:12}')) { errors.push('deploy.sh 不得绕过完整对象遍历单独读取候选构建字段'); } if (productionIdentityBootstrapScriptContent.indexOf('if (identityOnly === "1")') > productionIdentityBootstrapScriptContent.indexOf('const databaseIdentityHash = bootstrapDatabaseIdentityHash')) { errors.push('production-identity-bootstrap.mjs 的 identity-only 停止点必须位于数据库身份读取前'); } if (productionIdentityBootstrapScriptContent.indexOf('verifyOriginIdentity(identity, contract, expectedOrigin);') > productionIdentityBootstrapScriptContent.indexOf('const expectedRemoteDir = contract.REMOTE_DIR')) { errors.push('production-identity-bootstrap.mjs 必须在 origin 双取样后冻结 contract REMOTE_DIR'); } for (const requiredFragment of [ 'MODE="${1:-install}"', 'write_service daily run', 'write_service watchdog check-stale', 'write_service drill-reminder remind-drill', 'write_timer daily', 'write_timer watchdog', 'write_timer drill-reminder', 'Environment=COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}', 'Environment=POSTGRES_DATA_DIR=${POSTGRES_DATA_DIR}', 'Environment=STORAGE_HOST_DIR=${STORAGE_HOST_DIR}', 'Environment=BUILD_SHA=${BUILD_SHA}', 'Environment=VITE_BUILD_SHA=${VITE_BUILD_SHA}', 'Environment=AUTOMATIC_BACKUP_DIR=${AUTOMATIC_BACKUP_DIR}', 'Environment=AUTOMATIC_BACKUP_STATE_DIR=${AUTOMATIC_BACKUP_STATE_DIR}', 'Environment=OFFHOST_TARGET=${OFFHOST_TARGET}', 'Environment=OFFHOST_SSH_KEY=${OFFHOST_SSH_KEY}', 'Environment=OFFHOST_KNOWN_HOSTS=${OFFHOST_KNOWN_HOSTS}', 'Environment=ALERT_WEBHOOK_FILE=${ALERT_WEBHOOK_FILE}', 'Environment=ALERT_HELPER=${ALERT_HELPER}', 'install)\n install_units', 'enable)\n enable_units', ]) { if (!automaticBackupTimerScriptContent.includes(requiredFragment)) { errors.push(`install-automatic-backup-timer.sh 缺少自动备份安装合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'COMPOSE_CMD=(docker compose)', '-p "$COMPOSE_PROJECT_NAME"', 'flock -n 9', '"$actual_storage" == "$STORAGE_HOST_DIR"', '"$actual_pgdata" == "$POSTGRES_DATA_DIR"', '--partial --chmod=Du=rwx,Dgo=,Fu=rw,Fgo=', 'StrictHostKeyChecking=yes', 'hyb-backup-finalize $run_id $manifest_sha', 'write_success_state "$run_id" "$manifest_sha"', 'prune_local_backups "$batch_name"', 'python3 "$ALERT_HELPER"', '--webhook-file "$ALERT_WEBHOOK_FILE"', 'capture_command offhost_transfer rsync', 'last-error.log', "docker inspect -f '{{json .Mounts}}'", 'if len(matches) != 1 or not matches[0]:', ]) { if (!automaticBackupRunnerContent.includes(requiredFragment)) { errors.push(`run-automatic-backup.sh 缺少运行时安全合同: ${requiredFragment}`); } } if (automaticBackupRunnerContent.includes('println .Destination "\\t" .Source')) { errors.push('run-automatic-backup.sh 不得用人类可读 Go-template 分隔符解析真实 Docker mount'); } for (const requiredFragment of [ '"$original_command" == rsync\\ --server\\ *', '--sender([[:space:]]|$)', 'exec "$RRSYNC_BIN" -wo "$BACKUP_INBOX_DIR"', 'hyb-backup-finalize', 'batch must contain exactly three regular files', 'manifest artifact checksum mismatch', 'mv -- "$partial_dir" "$archive_dir"', 'replacement_rollback=', 'publish_weekly_checkpoint', 'prune_named_directories "$BACKUP_ARCHIVE_DIR"', 'prune_named_directories "$BACKUP_WEEKLY_DIR"', ]) { if (!offhostReceiverContent.includes(requiredFragment)) { errors.push(`receive-offhost-backup.sh 缺少受限接收合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'MODE="${1:-install}"', 'passwd -l "$BACKUP_USER"', 'command="${RECEIVER_TARGET}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc', 'BACKUP_INBOX_DIR=${inbox_dir}', 'BACKUP_ARCHIVE_DIR=${archive_dir}', 'BACKUP_WEEKLY_DIR=${weekly_dir}', 'DEADMAN_MAX_AGE_SECONDS="${DEADMAN_MAX_AGE_SECONDS:-93600}"', 'ExecStart=/usr/bin/python3 ${DEADMAN_TARGET}', 'systemctl enable --now "${DEADMAN_SERVICE}.timer"', 'archives retained', 'backup root contains an unmanaged top-level entry', 'backup root must not be inside a system-managed directory', ]) { if (!offhostReceiverInstallerContent.includes(requiredFragment)) { errors.push(`install-offhost-backup-receiver.sh 缺少受限身份安装合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'stat.S_IMODE(info.st_mode) != 0o600', 'parsed.hostname != "qyapi.weixin.qq.com"', 'HYB_WECOM_ALERT_TEST_MODE', 'should_send(previous, status, category)', '不要在群内粘贴原始日志', ]) { if (!groupAlertHelperContent.includes(requiredFragment)) { errors.push(`send-wecom-group-alert.py 缺少独立脱敏告警合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'default=93600', 'archive_manifest_invalid', 'archive_time_in_future', 'FAIL_OFFHOST_DEADMAN', 'deadman-alert-state.json', ]) { if (!offhostDeadmanContent.includes(requiredFragment)) { errors.push(`check-offhost-backup-deadman.py 缺少 ARM 独立看门狗合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'validate_absolute_path REMOTE_DIR "$REMOTE_DIR"', 'validate_absolute_path REMOTE_ENV_FILE "$REMOTE_ENV_FILE"', 'validate_absolute_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"', 'validate_absolute_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"', 'paths_overlap "$REMOTE_DIR" "$POSTGRES_DATA_DIR"', 'paths_overlap "$REMOTE_DIR" "$STORAGE_HOST_DIR"', 'RSYNC_PROTECT_ARGS+=(--filter="protect /${REMOTE_ENV_RELATIVE}")', 'RSYNC_PROTECT_ARGS+=(--exclude="/${REMOTE_ENV_RELATIVE}")', 'DEPLOY_DATA_MODE" != "existing"', 'DEPLOY_DATA_MODE" != "initialize"', '"$POSTGRES_DATA_DIR/PG_VERSION"', '>>> 3. 同步前只读校验远端部署合同', 'REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"', ]) { if (!deployScriptContent.includes(requiredFragment)) { errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`); } } if (deployScriptContent.indexOf('>>> 3. 同步前只读校验远端部署合同') > deployScriptContent.indexOf('rsync -az --delete')) { errors.push('deploy.sh 必须在 rsync 前执行远端只读 preflight'); } for (const requiredFragment of [ 'DEPLOY_REFRESH_SERVER', '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_SHA256', 'EXPECTED_PENDING_MIGRATIONS', 'EXPECTED_DEPLOY_SERVER', 'EXPECTED_REMOTE_DIR', 'EXPECTED_COMPOSE_PROJECT_NAME', 'PRODUCTION_READ_ONLY_PREFLIGHT', '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 [ 'HISTORICAL_TARGET_COMMIT', 'HISTORICAL_PROJECT_COMMIT', 'discoverRefreshTarget', 'assertContractForOriginVerification', 'verifyOriginIdentity', 'bootstrapDatabaseIdentityHash', 'PRODUCTION_IDENTITY_ONLY', 'EXPECTED_PRODUCTION_DB_NAME_SHA256', 'DEPLOY_EXISTING_SCRIPT', ]) { if (!productionIdentityBootstrapScriptContent.includes(requiredFragment)) { errors.push(`production-identity-bootstrap.mjs 缺少生产身份引导合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'EXPECTED_PRODUCTION_ORIGIN_SHA256', 'PROD_TARGET_RUNTIME_ORIGIN_MISMATCH', 'PROD_TARGET_DB_NON_PRODUCTION_IDENTITY', 'origin_identity', 'IDENTITY_BOOTSTRAP_MODE', 'DB_SHA256', ]) { if (!productionIdentityBootstrapRemoteScriptContent.includes(requiredFragment)) { errors.push(`production-identity-bootstrap-remote.sh 缺少只读身份合同: ${requiredFragment}`); } } if (productionIdentityBootstrapRemoteScriptContent.includes('prisma migrate status') || productionIdentityBootstrapRemoteScriptContent.includes('migrate deploy')) { errors.push('production-identity-bootstrap-remote.sh 不得进入 Prisma status 或迁移写入'); } for (const requiredFragment of [ 'systemctl show', 'DEPLOY_CONTRACT_KEYS', 'REMOTE_DIR', 'REMOTE_ENV_FILE', 'COMPOSE_FILE', 'COMPOSE_PROJECT_NAME', 'POSTGRES_DATA_DIR', 'STORAGE_HOST_DIR', 'deployment_contract_refresh_category', ]) { if (!deploymentContractLibraryContent.includes(requiredFragment)) { errors.push(`deployment-contract-lib.sh 缺少共享部署合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'set -uo pipefail', 'CHECK_NAMES=(', 'repository_writable', 'env_readable', 'runtime_unique', 'storage_mount', 'pgdata_mount', 'backup_build', 'root_ssh_identity', 'refresh_program', 'summary PASS=', ]) { if (!deploymentPreflightScriptContent.includes(requiredFragment)) { errors.push(`check-existing-deployment.sh 缺少聚合体检合同: ${requiredFragment}`); } } if (/^set\s+-[A-Za-z]*e[A-Za-z]*(?:\s|$)/m.test(deploymentPreflightScriptContent)) { errors.push('check-existing-deployment.sh 主流程不得启用 set -e'); } for (const requiredFragment of [ 'SSH_ORIGINAL_COMMAND', 'probe) probe', 'refresh) refresh', '20-active-build.conf', 'refresh_program_integrity_invalid', 'unit_build_not_refreshed', 'timer_not_active', 'backup_mode_invalid', 'backup_timer_not_disabled', 'manual_snapshot', 'systemctl daemon-reload', ]) { if (!deploymentRefreshHelperContent.includes(requiredFragment)) { errors.push(`refresh-deployment-contract.sh 缺少 forced-command 合同: ${requiredFragment}`); } } for (const forbiddenFragment of ['eval ', 'bash -c', 'REMOTE_DIR/']) { if (deploymentRefreshHelperContent.includes(forbiddenFragment)) { errors.push(`refresh-deployment-contract.sh 包含禁止的动态执行片段: ${forbiddenFragment}`); } } for (const requiredFragment of [ 'no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc', 'dedicated key already has unmanaged or unrestricted root authorization', 'install -o root -g root -m 0755', ]) { if (!deploymentRefreshInstallerContent.includes(requiredFragment)) { errors.push(`install-deployment-contract-refresh.sh 缺少受限安装合同: ${requiredFragment}`); } } if (deployExistingScriptContent.indexOf('check-existing-deployment.sh') > deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) { errors.push('deploy-existing.sh 必须在应用部署前执行聚合体检'); } if (deployExistingScriptContent.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh') < deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) { errors.push('deploy-existing.sh 必须在应用部署成功后刷新备份合同'); } for (const requiredFragment of [ 'DEPLOY_RUNTIME_LOG_REF="artifacts/deployment-runtime"', 'install -d -m 0700 "$DEPLOY_RUNTIME_LOG_DIR"', 'chmod 0600 "$log_file"', 'run_logged_deploy_stage "build-init"', 'run_logged_deploy_stage "run-init"', 'run_logged_deploy_stage "build-switch-server"', ]) { if (!deployScriptContent.includes(requiredFragment)) { errors.push(`deploy.sh 缺少远端阶段日志合同: ${requiredFragment}`); } } for (const requiredFragment of [ 'validateProductionOrigin', 'validateProductionDbName', 'collectMigrationManifest', 'EXPECTED_PENDING_MIGRATIONS', 'CANDIDATE_COMMIT_MISSING', 'deriveCandidateBuildEnv', 'Object.entries(candidateBuildEnv)', '([key, value]) => `${key}=${shellQuote(value)}`', '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}`); } } if (productionPreflightScriptContent.includes('candidateCommit.slice(0, 12)')) { errors.push('production-preflight.mjs 不得自行派生候选短 SHA'); } if (productionPreflightScriptContent.includes('CANDIDATE_BUILD_ENV_KEYS')) { errors.push('production-preflight.mjs 不得以独立键数组重新定义候选构建环境对象'); } if (productionPreflightScriptContent.lastIndexOf('assertRemotePreflightResult({') > productionPreflightScriptContent.lastIndexOf('validatePrismaStatusText(rawStatus);')) { errors.push('production-preflight.mjs 必须先传播远端失败,再校验 Prisma marker 内容'); } for (const requiredFragment of [ 'EXPECTED_PRODUCTION_ORIGIN_SHA256', 'EXPECTED_PRODUCTION_DB_NAME_SHA256', 'PROD_TARGET_BUILD_SHA_INVALID', 'PROD_TARGET_VITE_BUILD_SHA_INVALID', 'PROD_TARGET_BUILD_SHA_MISMATCH', 'PROD_TERMINATION_MIGRATION_NOT_IN_CANDIDATE', 'basis=migration_not_applied', 'basis=terminated_status_count', '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 (deployExistingScriptContent.indexOf('PRODUCTION_READ_ONLY_PREFLIGHT') > deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) { errors.push('deploy-existing.sh 的只读停止点必须位于 deploy.sh 前'); } 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 文件', '"$POSTGRES_DATA_DIR/PG_VERSION"', ]) { if (!remotePreflightScriptContent.includes(requiredFragment)) { errors.push(`preflight-remote.sh 缺少同步前护栏: ${requiredFragment}`); } } for (const [earlier, later] of [ ['RUN apk add --no-cache bash curl', 'ARG VITE_BUILD_SHA'], ['RUN cd client && npm ci', 'ARG VITE_BUILD_SHA'], ['RUN cd server && npm ci', 'ARG BUILD_SHA'], ]) { if (dockerfileContent.indexOf(earlier) === -1 || dockerfileContent.indexOf(later) === -1 || dockerfileContent.indexOf(earlier) > dockerfileContent.indexOf(later)) { errors.push(`Dockerfile 缓存顺序无效: ${earlier} 必须早于 ${later}`); } } for (const { filePath, content } of nginxContents) { const relativePath = path.relative(rootDir, filePath); if (content.includes('$proxy_add_x_forwarded_for')) { errors.push(`${relativePath} 不得追加客户端提供的 X-Forwarded-For`); } const forwardedForDirectives = [ ...content.matchAll(/proxy_set_header\s+X-Forwarded-For\s+([^;]+);/g), ]; if ( forwardedForDirectives.length === 0 || forwardedForDirectives.some((match) => match[1].trim() !== '$remote_addr') ) { errors.push(`${relativePath} 必须用 $remote_addr 覆盖 X-Forwarded-For`); } } const authChallengeEndpoints = [ '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/email-login/code', '/api/auth/email-login/verify', ]; for (const [name, content] of [ ['runbook.md', runbookContent], ['checklist.md', checklistContent], ]) { if (!content.includes('EXTERNAL_GATED')) { errors.push(`${name} 必须标记认证 challenge 边缘操作为 EXTERNAL_GATED`); } for (const endpoint of authChallengeEndpoints) { if (!content.includes(endpoint)) { errors.push(`${name} 缺少认证 challenge 边缘关闭端点 ${endpoint}`); } } if (!/脚本不会自动(?:修改|改)边缘/.test(content)) { errors.push(`${name} 必须声明部署脚本不会自动修改边缘配置`); } } if (errors.length) { fail('部署环境契约校验失败', errors); } console.log('[R26][PASS] 部署环境契约校验通过'); console.log(`- config.js env 键数: ${configKeys.length}`); console.log(`- .env.example env 键数: ${envExampleKeys.size}`); console.log(`- 环境恢复分类键数: ${recoveryClassification.keyCount}`); console.log(`- 已检查服务: ${requiredServices.join(', ')}`);