#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const failures = []; const requestedFiles = []; for (let index = 2; index < process.argv.length; index += 1) { if (process.argv[index] !== '--file' || !process.argv[index + 1]) { failures.push(`未知或不完整参数: ${process.argv[index]}`); continue; } requestedFiles.push(path.resolve(process.argv[index + 1])); index += 1; } function fail(message) { failures.push(message); } function validateDefaultPage(filePath) { if (!existsSync(filePath)) { fail(`默认维护页不存在: ${filePath}`); return; } const source = readFileSync(filePath, 'utf8'); if (!source.includes('服务维护中')) { fail(`${filePath} 必须保留无日期的“服务维护中”默认文案。`); } for (const [pattern, label] of [ [/(?:今天|今晚|明天|昨天|昨日)/u, '相对日期'], [/(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, '具体日期'], [/(?:[01]?\d|2[0-3]):[0-5]\d/u, '具体维护时间'], ]) { if (pattern.test(source)) { fail(`${filePath} 不能包含${label};临时公告必须使用运行态覆盖页。`); } } } function runScript(scriptPath, args, env) { return spawnSync('bash', [scriptPath, ...args], { cwd: repoRoot, env: { ...process.env, ...env }, encoding: 'utf8', }); } function validateRuntimePageLifecycle() { const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'genarrative-maintenance-')); const markerFile = path.join(tempRoot, 'state', 'enabled'); const runtimePageFile = path.join(tempRoot, 'state', 'page.html'); const sourcePageFile = path.join(tempRoot, 'announcement.html'); const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh'); const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh'); const env = { GENARRATIVE_MAINTENANCE_FILE: markerFile, GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile, }; try { const announcement = 'planned maintenance\n'; writeFileSync(sourcePageFile, announcement); const enable = runScript( onScript, ['--page-file', sourcePageFile, 'planned maintenance'], env, ); if (enable.status !== 0) { fail(`maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`); return; } if (!existsSync(markerFile)) { fail('maintenance-on --page-file 必须创建维护 marker。'); } if (!existsSync(runtimePageFile)) { fail('maintenance-on --page-file 必须原子安装运行态公告页。'); } else { if (readFileSync(runtimePageFile, 'utf8') !== announcement) { fail('运行态公告页内容与输入文件不一致。'); } if ((statSync(runtimePageFile).mode & 0o777) !== 0o644) { fail('运行态公告页权限必须为 0644。'); } } const nestedEnable = runScript(onScript, ['api deploy'], env); if (nestedEnable.status !== 0 || !existsSync(runtimePageFile)) { fail('同一维护窗口内的后续 maintenance-on 必须保留已安装公告页。'); } const disable = runScript(offScript, [], env); if (disable.status !== 0) { fail(`maintenance-off 执行失败: ${disable.stderr || disable.stdout}`); } if (existsSync(markerFile) || existsSync(runtimePageFile)) { fail('maintenance-off 必须同时清理 marker 和运行态公告页。'); } mkdirSync(path.dirname(runtimePageFile), { recursive: true }); writeFileSync(runtimePageFile, announcement); chmodSync(runtimePageFile, 0o644); const genericEnable = runScript(onScript, ['generic maintenance'], env); if (genericEnable.status !== 0) { fail(`通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`); } if (existsSync(runtimePageFile)) { fail('新维护窗口未提供 --page-file 时必须清理残留公告页。'); } runScript(offScript, [], env); const missingPage = runScript( onScript, ['--page-file', path.join(tempRoot, 'missing.html')], env, ); if (missingPage.status === 0 || existsSync(markerFile)) { fail('不存在的 --page-file 必须在创建 marker 前失败。'); } } finally { rmSync(tempRoot, { recursive: true, force: true }); } } function validateGatewayConfiguration() { const nginxSnippet = readFileSync( path.join(repoRoot, 'deploy/nginx/snippets/genarrative-maintenance.conf'), 'utf8', ); for (const expected of [ 'root /var/lib/genarrative/maintenance;', 'try_files /page.html @genarrative_default_maintenance;', 'location @genarrative_default_maintenance', 'root /srv/genarrative/web;', ]) { if (!nginxSnippet.includes(expected)) { fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`); } } const pingoraSource = readFileSync( path.join(repoRoot, 'server-rs/crates/pingora-gateway/src/main.rs'), 'utf8', ); for (const expected of [ 'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE', 'maintenance_page_file', ]) { if (!pingoraSource.includes(expected)) { fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`); } } const releaseBuildScript = readFileSync( path.join(repoRoot, 'scripts/build-production-release.sh'), 'utf8', ); if ( !releaseBuildScript.includes( 'node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"', ) ) { fail('生产 Web 发布包构建必须校验最终 maintenance.html 不含临时公告。'); } } for (const filePath of requestedFiles.length > 0 ? requestedFiles : [path.join(repoRoot, 'public/maintenance.html')]) { validateDefaultPage(filePath); } if (requestedFiles.length === 0) { validateRuntimePageLifecycle(); validateGatewayConfiguration(); } if (failures.length > 0) { console.error('[check:maintenance-page] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log('[check:maintenance-page] 通过');