- 用户决定仅用部署前手动轻量快照,不再推进对象存储、ARM/hyinda-20c 备份 - refresh-deployment-contract 新增 /etc/hyb-oa-backup-mode 显式模式: 无文件保持原自动模式;manual_snapshot 要求三个备份 timer 停用且备份 service 已退出,ops timer 仍须运行;内容/属主/符号链接异常 fail-closed - probe 部署前与 refresh 部署后均校验模式与 timer/service 一致性 - 新增 44 backup_mode_invalid / 45 backup_timer_not_disabled 分类及合同测试 - 记录 10-05 ARM 与 hyinda-20c 只读核验结果、远端切换顺序与回滚 - 远端切换未执行,需另行批准 Co-Authored-By: Claude Opus 5.5 <[email protected]>
958 行
36 KiB
JavaScript
可执行文件
958 行
36 KiB
JavaScript
可执行文件
#!/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(', ')}`);
|