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