#!/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] 通过');