#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
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 bashExecutable =
process.platform === 'win32'
? 'C:\\Program Files\\Git\\bin\\bash.exe'
: 'bash';
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) {
const bashArgs = args.map((arg) =>
path.isAbsolute(arg) ? toBashPath(arg) : arg,
);
const bashEnv = Object.fromEntries(
Object.entries(env).map(([key, value]) => [
key,
path.isAbsolute(value) ? toBashPath(value) : value,
]),
);
const envAssignments = Object.entries(bashEnv).map(
([key, value]) => `${key}=${value}`,
);
const commandArgs = [
...envAssignments,
'bash',
toBashPath(scriptPath),
...bashArgs,
];
const command = `exec env ${commandArgs.map(shellQuote).join(' ')}`;
return spawnSync(bashExecutable, ['-c', command], {
cwd: repoRoot,
env: process.env,
encoding: 'utf8',
});
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
}
function toBashPath(filePath) {
const windowsDrive = /^([A-Za-z]):[\\/](.*)$/u.exec(filePath);
if (windowsDrive) {
return `/${windowsDrive[1].toLowerCase()}/${windowsDrive[2].replaceAll('\\', '/')}`;
}
return filePath;
}
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 onScriptSource = readFileSync(onScript, 'utf8');
const env = {
GENARRATIVE_MAINTENANCE_FILE: markerFile,
GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile,
};
if (!onScriptSource.includes('replace_file_atomically')) {
fail('maintenance-on 必须通过统一 helper 原子替换公告页和 marker。');
}
if (!onScriptSource.includes('install -m 0644')) {
fail('maintenance-on 安装运行态公告页时必须显式设置 0644 权限。');
}
if (/\bmv\s+-[^\s]*T\b/u.test(onScriptSource)) {
fail('maintenance-on 不得使用 GNU mv 专属的 -T 参数。');
}
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 (
process.platform !== 'win32' &&
(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 前失败。');
}
const linkedPageTarget = path.join(tempRoot, 'linked-page-target');
mkdirSync(linkedPageTarget);
symlinkSync(
linkedPageTarget,
runtimePageFile,
process.platform === 'win32' ? 'junction' : 'dir',
);
const linkedPageEnable = runScript(
onScript,
['--page-file', sourcePageFile, 'linked page target'],
env,
);
if (linkedPageEnable.status === 0) {
fail('maintenance-on 必须拒绝指向目录的公告页符号链接。');
}
if (!lstatSync(runtimePageFile).isSymbolicLink()) {
fail('拒绝公告页符号链接后不得替换链接本身。');
}
if (readdirSync(linkedPageTarget).length > 0) {
fail('拒绝公告页符号链接后不得把临时文件移入链接目标目录。');
}
if (
readdirSync(path.dirname(runtimePageFile)).some((entry) =>
entry.startsWith(`${path.basename(runtimePageFile)}.tmp.`),
)
) {
fail('公告页符号链接校验失败后不得残留 page.html.tmp.* 临时文件。');
}
if (existsSync(markerFile)) {
fail('公告页符号链接校验失败时不得创建维护 marker。');
}
unlinkSync(runtimePageFile);
const linkedMarkerTarget = path.join(tempRoot, 'linked-marker-target');
mkdirSync(linkedMarkerTarget);
symlinkSync(
linkedMarkerTarget,
markerFile,
process.platform === 'win32' ? 'junction' : 'dir',
);
const linkedMarkerEnable = runScript(
onScript,
['linked marker target'],
env,
);
if (linkedMarkerEnable.status === 0) {
fail('maintenance-on 必须拒绝指向目录的 marker 符号链接。');
}
if (!lstatSync(markerFile).isSymbolicLink()) {
fail('拒绝 marker 符号链接后不得替换链接本身。');
}
if (readdirSync(linkedMarkerTarget).length > 0) {
fail('拒绝 marker 符号链接后不得把临时文件移入链接目标目录。');
}
if (
readdirSync(path.dirname(markerFile)).some((entry) =>
entry.startsWith(`${path.basename(markerFile)}.tmp.`),
)
) {
fail('marker 符号链接校验失败后不得残留 enabled.tmp.* 临时文件。');
}
if (
linkedMarkerEnable.stdout.includes('已进入维护模式') ||
linkedMarkerEnable.stderr.includes('已进入维护模式')
) {
fail('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;',
'location = /branding/taonier-maintenance-page.png',
'try_files /branding/taonier-maintenance-page.png =404;',
'location = /branding/taonier-product-ip.png',
'try_files /branding/taonier-product-ip.png =404;',
]) {
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',
'is_maintenance_page_asset(path)',
]) {
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] 通过');