文件
hyb-oa/deploy/trialrun/validate-env-contract.js
T

542 行
19 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 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 remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-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'],
};
const requiredRuntimeOverrides = {
server: ['BUILD_SHA'],
worker: ['BUILD_SHA'],
init: ['BUILD_SHA'],
};
const requiredBuildArgs = {
server: ['BUILD_SHA', 'VITE_BUILD_SHA'],
worker: ['BUILD_SHA', 'VITE_BUILD_SHA'],
init: ['BUILD_SHA', 'VITE_BUILD_SHA'],
};
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 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 composeContent = readFile(composePath);
const runbookContent = readFile(runbookPath);
const checklistContent = readFile(checklistPath);
const deployScriptContent = readFile(deployScriptPath);
const deployExistingScriptContent = readFile(deployExistingScriptPath);
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
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 = [];
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.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 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 [
'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 [
'systemctl show',
'DEPLOY_DATA_MODE=existing',
'SYNC_ENV=0',
'路径与标识不回显',
]) {
if (!deployExistingScriptContent.includes(requiredFragment)) {
errors.push(`deploy-existing.sh 缺少既有部署合同: ${requiredFragment}`);
}
}
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(`- 已检查服务: ${requiredServices.join(', ')}`);