341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
#!/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 = '<!doctype html><title>planned maintenance</title>\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] 通过');
|