合并最新主线并保留官方路由锁定
合并客户端扩展、应用更新、格式化门禁和后台表查询等主线改动。 运行时设置页保留官方账号服务与安全元数据,同时接入主线扩展管理与更新能力。 DirectProject 文档合并主线第三方 MCP 支持与官方路由、受控搜索边界。 修复合并后的后台 API Key 详情 Eye 图标导入。
This commit is contained in:
+13
-12
@@ -1,6 +1,6 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {dirname, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, '..');
|
||||
@@ -14,7 +14,9 @@ const command = process.argv[2] ?? 'build';
|
||||
const extraArgs = process.argv.slice(3);
|
||||
|
||||
function usage() {
|
||||
console.error('用法: node scripts/admin-web-build.mjs <typecheck|build> [vite-build-args...]');
|
||||
console.error(
|
||||
'用法: node scripts/admin-web-build.mjs <typecheck|build> [vite-build-args...]',
|
||||
);
|
||||
}
|
||||
|
||||
function runNodeScript(label, args) {
|
||||
@@ -33,12 +35,16 @@ function runNodeScript(label, args) {
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[admin-web] ${label} failed to start: ${result.error.message}`);
|
||||
console.error(
|
||||
`[admin-web] ${label} failed to start: ${result.error.message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
console.error(`[admin-web] ${label} was terminated by signal ${result.signal}`);
|
||||
console.error(
|
||||
`[admin-web] ${label} was terminated by signal ${result.signal}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -48,12 +54,7 @@ function runNodeScript(label, args) {
|
||||
}
|
||||
|
||||
function runTypecheck() {
|
||||
runNodeScript('typecheck', [
|
||||
tscBinPath,
|
||||
'--noEmit',
|
||||
'-p',
|
||||
adminTsconfigPath,
|
||||
]);
|
||||
runNodeScript('typecheck', [tscBinPath, '--noEmit', '-p', adminTsconfigPath]);
|
||||
}
|
||||
|
||||
if (command === 'typecheck') {
|
||||
|
||||
@@ -79,7 +79,9 @@ export function buildIsolatedWorkerEnv({
|
||||
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS: String(maxRequests),
|
||||
GENARRATIVE_BGFILTER_WORKER_PORT: String(workerPort),
|
||||
GENARRATIVE_EDITOR_BGFILTER_BASE_URL: providerBaseUrl,
|
||||
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: String(SINGLE_IMAGE_ESTIMATE_MS),
|
||||
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: String(
|
||||
SINGLE_IMAGE_ESTIMATE_MS,
|
||||
),
|
||||
GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH: path.join(
|
||||
tempRoot,
|
||||
'missing-pricing-override.json',
|
||||
@@ -208,7 +210,9 @@ export async function startMockBgfilterProvider({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
reset() {
|
||||
if (stats.active !== 0) {
|
||||
throw new Error(`mock provider 仍有 ${stats.active} 个活跃请求,不能重置`);
|
||||
throw new Error(
|
||||
`mock provider 仍有 ${stats.active} 个活跃请求,不能重置`,
|
||||
);
|
||||
}
|
||||
stats = emptyProviderStats();
|
||||
},
|
||||
@@ -406,7 +410,13 @@ async function runLoadSmoke() {
|
||||
);
|
||||
}
|
||||
|
||||
async function runScenario({ provider, scenario, signal, token, workerBaseUrl }) {
|
||||
async function runScenario({
|
||||
provider,
|
||||
scenario,
|
||||
signal,
|
||||
token,
|
||||
workerBaseUrl,
|
||||
}) {
|
||||
provider.reset();
|
||||
const startedAt = Date.now();
|
||||
const responses = await Promise.all(
|
||||
@@ -443,7 +453,11 @@ async function runOverloadFaultScenario() {
|
||||
},
|
||||
async ({ provider, signal, token, workerBaseUrl }) => {
|
||||
// callBudgetMs 必须按本场景 worker 的实际 N 派生,否则被 invalid_request 拒绝。
|
||||
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-overload' };
|
||||
const scenario = {
|
||||
concurrency: 2,
|
||||
mode: 'complex',
|
||||
name: 'fault-overload',
|
||||
};
|
||||
const admitted = Array.from({ length: 4 }, (_, index) =>
|
||||
requestWorker(workerBaseUrl, token, scenario, index, {
|
||||
signal,
|
||||
@@ -521,14 +535,15 @@ async function runQueueDeadlineFaultScenario() {
|
||||
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
|
||||
},
|
||||
async ({ provider, signal, token, workerBaseUrl }) => {
|
||||
const scenario = { concurrency: 1, mode: 'complex', name: 'fault-queue-deadline' };
|
||||
const firstRequest = requestWorker(
|
||||
workerBaseUrl,
|
||||
token,
|
||||
scenario,
|
||||
0,
|
||||
{ signal, timeoutMs: 10_000 },
|
||||
);
|
||||
const scenario = {
|
||||
concurrency: 1,
|
||||
mode: 'complex',
|
||||
name: 'fault-queue-deadline',
|
||||
};
|
||||
const firstRequest = requestWorker(workerBaseUrl, token, scenario, 0, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
let faultError = null;
|
||||
let queueResponse = null;
|
||||
let providerBeforeRelease = null;
|
||||
@@ -537,17 +552,11 @@ async function runQueueDeadlineFaultScenario() {
|
||||
(stats) => stats.active === 1 && stats.requests === 1,
|
||||
{ signal, timeoutMs: 3_000 },
|
||||
);
|
||||
queueResponse = await requestWorker(
|
||||
workerBaseUrl,
|
||||
token,
|
||||
scenario,
|
||||
1,
|
||||
{
|
||||
maxQueueWaitMs: QUEUE_DEADLINE_MAX_WAIT_MS,
|
||||
signal,
|
||||
timeoutMs: 3_500,
|
||||
},
|
||||
);
|
||||
queueResponse = await requestWorker(workerBaseUrl, token, scenario, 1, {
|
||||
maxQueueWaitMs: QUEUE_DEADLINE_MAX_WAIT_MS,
|
||||
signal,
|
||||
timeoutMs: 3_500,
|
||||
});
|
||||
providerBeforeRelease = provider.snapshot();
|
||||
} catch (error) {
|
||||
faultError = error;
|
||||
@@ -608,14 +617,15 @@ async function runRetryThenSuccessFaultScenario() {
|
||||
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
|
||||
},
|
||||
async ({ provider, signal, token, workerBaseUrl }) => {
|
||||
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-retry-success' };
|
||||
const response = await requestWorker(
|
||||
workerBaseUrl,
|
||||
token,
|
||||
scenario,
|
||||
0,
|
||||
{ signal, timeoutMs: 10_000 },
|
||||
);
|
||||
const scenario = {
|
||||
concurrency: 2,
|
||||
mode: 'complex',
|
||||
name: 'fault-retry-success',
|
||||
};
|
||||
const response = await requestWorker(workerBaseUrl, token, scenario, 0, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
assertBatchResponses(scenario, [response]);
|
||||
const stats = provider.snapshot();
|
||||
assertProviderStats(
|
||||
@@ -648,14 +658,15 @@ async function runProviderExhaustedFaultScenario() {
|
||||
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
|
||||
},
|
||||
async ({ provider, signal, token, workerBaseUrl }) => {
|
||||
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-provider-exhausted' };
|
||||
const response = await requestWorker(
|
||||
workerBaseUrl,
|
||||
token,
|
||||
scenario,
|
||||
0,
|
||||
{ signal, timeoutMs: 10_000 },
|
||||
);
|
||||
const scenario = {
|
||||
concurrency: 2,
|
||||
mode: 'complex',
|
||||
name: 'fault-provider-exhausted',
|
||||
};
|
||||
const response = await requestWorker(workerBaseUrl, token, scenario, 0, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
assertWorkerErrorResponse(response, {
|
||||
attemptsStarted: 2,
|
||||
code: 'provider_exhausted',
|
||||
@@ -696,14 +707,15 @@ async function runMidBodyResetThenSuccessFaultScenario() {
|
||||
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
|
||||
},
|
||||
async ({ provider, signal, token, workerBaseUrl }) => {
|
||||
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-mid-body-reset' };
|
||||
const response = await requestWorker(
|
||||
workerBaseUrl,
|
||||
token,
|
||||
scenario,
|
||||
0,
|
||||
{ signal, timeoutMs: 10_000 },
|
||||
);
|
||||
const scenario = {
|
||||
concurrency: 2,
|
||||
mode: 'complex',
|
||||
name: 'fault-mid-body-reset',
|
||||
};
|
||||
const response = await requestWorker(workerBaseUrl, token, scenario, 0, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
assertBatchResponses(scenario, [response]);
|
||||
const stats = provider.snapshot();
|
||||
assertProviderStats(
|
||||
@@ -715,10 +727,11 @@ async function runMidBodyResetThenSuccessFaultScenario() {
|
||||
},
|
||||
stats,
|
||||
);
|
||||
assertStrictSequentialAttempts(stats, [200, 200], [
|
||||
'mid_body_reset',
|
||||
'response',
|
||||
]);
|
||||
assertStrictSequentialAttempts(
|
||||
stats,
|
||||
[200, 200],
|
||||
['mid_body_reset', 'response'],
|
||||
);
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
@@ -842,7 +855,8 @@ async function cleanupResources(resources) {
|
||||
}
|
||||
|
||||
function resolveApiServerBinary() {
|
||||
const executable = process.platform === 'win32' ? 'api-server.exe' : 'api-server';
|
||||
const executable =
|
||||
process.platform === 'win32' ? 'api-server.exe' : 'api-server';
|
||||
const explicit = String(
|
||||
process.env.GENARRATIVE_BGFILTER_SMOKE_BINARY ?? '',
|
||||
).trim();
|
||||
@@ -905,7 +919,10 @@ function redactDiagnostics(value, secrets) {
|
||||
}
|
||||
}
|
||||
return redacted
|
||||
.replace(/Authorization:\s*Bearer\s+\S+/giu, 'Authorization: Bearer [redacted]')
|
||||
.replace(
|
||||
/Authorization:\s*Bearer\s+\S+/giu,
|
||||
'Authorization: Bearer [redacted]',
|
||||
)
|
||||
.replace(/https?:\/\/[^\s"']+\?[^\s"']+/gu, '[signed-url-redacted]');
|
||||
}
|
||||
|
||||
@@ -949,12 +966,7 @@ function requestWorker(baseUrl, token, scenario, index, options = {}) {
|
||||
const requestId = `bgfilter-${scenario.name}-${String(index).padStart(3, '0')}`;
|
||||
const body = Buffer.from(
|
||||
JSON.stringify(
|
||||
buildScenarioRequest(
|
||||
scenario,
|
||||
requestId,
|
||||
index,
|
||||
options.maxQueueWaitMs,
|
||||
),
|
||||
buildScenarioRequest(scenario, requestId, index, options.maxQueueWaitMs),
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
@@ -1094,10 +1106,7 @@ function assertWorkerErrorResponse(response, expected) {
|
||||
`${expected.code} 期望 attemptsStarted=${expected.attemptsStarted},实际 ${error.attemptsStarted ?? '-'}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.hasOwn(expected, 'phase') &&
|
||||
error.phase !== expected.phase
|
||||
) {
|
||||
if (Object.hasOwn(expected, 'phase') && error.phase !== expected.phase) {
|
||||
throw new Error(
|
||||
`${expected.code} 期望 phase=${expected.phase},实际 ${error.phase ?? '-'}`,
|
||||
);
|
||||
|
||||
@@ -66,8 +66,14 @@ describe('bgfilter worker smoke harness', () => {
|
||||
assert.equal(stats.peak, 2);
|
||||
assert.equal(stats.requests, 2);
|
||||
assert.deepEqual(stats.violations, []);
|
||||
assert.equal(stats.timeline.filter((event) => event.event === 'start').length, 2);
|
||||
assert.equal(stats.timeline.filter((event) => event.event === 'finish').length, 2);
|
||||
assert.equal(
|
||||
stats.timeline.filter((event) => event.event === 'start').length,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
stats.timeline.filter((event) => event.event === 'finish').length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('provider gate 与 sequence behavior 生成无重叠 timeline', async () => {
|
||||
@@ -89,7 +95,10 @@ describe('bgfilter worker smoke harness', () => {
|
||||
assert.equal(firstSettled, false);
|
||||
gate.release();
|
||||
assert.equal((await first).statusCode, 503);
|
||||
assert.equal((await postMultipart(provider.baseUrl, request)).statusCode, 200);
|
||||
assert.equal(
|
||||
(await postMultipart(provider.baseUrl, request)).statusCode,
|
||||
200,
|
||||
);
|
||||
|
||||
const stats = provider.snapshot();
|
||||
assert.equal(stats.peak, 1);
|
||||
|
||||
+22
-14
@@ -1,8 +1,10 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const adminWebBuildPath = fileURLToPath(new URL('./admin-web-build.mjs', import.meta.url));
|
||||
const adminWebBuildPath = fileURLToPath(
|
||||
new URL('./admin-web-build.mjs', import.meta.url),
|
||||
);
|
||||
const forwardedArgs = process.argv.slice(2);
|
||||
|
||||
const results = [
|
||||
@@ -10,12 +12,18 @@ const results = [
|
||||
runBuildStep('admin-web', [adminWebBuildPath, 'build']),
|
||||
];
|
||||
|
||||
const failedResult = results.find(result => result.error || result.signal || (result.status ?? 0) !== 0);
|
||||
const failedResult = results.find(
|
||||
(result) => result.error || result.signal || (result.status ?? 0) !== 0,
|
||||
);
|
||||
if (failedResult) {
|
||||
if (failedResult.error) {
|
||||
console.error(`Build gate failed to start a build step: ${failedResult.error.message}`);
|
||||
console.error(
|
||||
`Build gate failed to start a build step: ${failedResult.error.message}`,
|
||||
);
|
||||
} else if (failedResult.signal) {
|
||||
console.error(`Build gate step was terminated by signal ${failedResult.signal}`);
|
||||
console.error(
|
||||
`Build gate step was terminated by signal ${failedResult.signal}`,
|
||||
);
|
||||
}
|
||||
process.exit(failedResult.status ?? 1);
|
||||
}
|
||||
@@ -24,7 +32,7 @@ const warningLines = results.flatMap((result) => collectWarningLines(result));
|
||||
|
||||
if (warningLines.length > 0) {
|
||||
console.error('Build gate failed because warnings were emitted:');
|
||||
[...new Set(warningLines)].forEach(line => console.error(`- ${line}`));
|
||||
[...new Set(warningLines)].forEach((line) => console.error(`- ${line}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -48,14 +56,14 @@ function runBuildStep(label, args) {
|
||||
|
||||
function collectWarningLines(result) {
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
];
|
||||
const ignoredWarningPatterns = [/ExperimentalWarning/u];
|
||||
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.filter((line) => warningPattern.test(line))
|
||||
.filter(
|
||||
(line) => !ignoredWarningPatterns.some((pattern) => pattern.test(line)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {mergeApiServerEnv} from './dev-utils.mjs';
|
||||
import { mergeApiServerEnv } from './dev-utils.mjs';
|
||||
|
||||
const REQUIRED_FOR_PUZZLE_GENERATION = [
|
||||
'VECTOR_ENGINE_BASE_URL',
|
||||
@@ -77,4 +77,6 @@ if (missing.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[api-server-env] 配置齐全。重启 npm run dev:api-server 或 npm run dev 后生效。');
|
||||
console.log(
|
||||
'[api-server-env] 配置齐全。重启 npm run dev:api-server 或 npm run dev 后生效。',
|
||||
);
|
||||
|
||||
@@ -85,7 +85,7 @@ function listFilesFromGit() {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
return output
|
||||
@@ -105,7 +105,7 @@ function loadIgnoreList() {
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '' && !line.startsWith('#'))
|
||||
.map(normalizePath)
|
||||
.map(normalizePath),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ function assertNativeShellDependencyVersionGuardrails() {
|
||||
"'eas-cli': '^20.3.0'",
|
||||
"assertPackageLockVersion('apps/mobile-shell', 'eas-cli', '20.3.0')",
|
||||
]) {
|
||||
if (!mobileShellConfigCheckSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(mobileShellConfigCheckSource, snippet)) {
|
||||
throw new Error(
|
||||
`mobile shell dependency guardrail drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -201,7 +201,7 @@ function assertNativeShellDependencyVersionGuardrails() {
|
||||
"['tauri', '2.11.2']",
|
||||
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
|
||||
]) {
|
||||
if (!desktopShellConfigCheckSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(desktopShellConfigCheckSource, snippet)) {
|
||||
throw new Error(
|
||||
`desktop shell dependency guardrail drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -2425,6 +2425,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
JSON.stringify([
|
||||
{ url: 'https://dev.genarrative.world/api/*' },
|
||||
{ url: 'https://www.genarrative.world/api/*' },
|
||||
{ url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' },
|
||||
{ url: 'https://*/api/*' },
|
||||
{ url: 'http://localhost:*/*' },
|
||||
{ url: 'http://127.0.0.1:*/*' },
|
||||
@@ -2440,7 +2441,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'if (!import.meta.env.DEV)',
|
||||
"return params.has('dev') || window.location.hash === '#dev';",
|
||||
]) {
|
||||
if (!aiGameCreatorShellAppModelSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellAppModelSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator developer mode boundary drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -2451,7 +2452,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'{devMode ? (',
|
||||
'className="developer-pane"',
|
||||
]) {
|
||||
if (!aiGameCreatorShellAppSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator user/dev UI boundary drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -2525,7 +2526,9 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"',
|
||||
'src={embeddedUrl}',
|
||||
]) {
|
||||
if (!aiGameCreatorLocalGamePreviewFrameSource.includes(snippet)) {
|
||||
if (
|
||||
!sourceIncludesSnippet(aiGameCreatorLocalGamePreviewFrameSource, snippet)
|
||||
) {
|
||||
throw new Error(
|
||||
`AI game creator embedded preview boundary drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -2535,7 +2538,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
"await invoke<LocalPreviewStatus>(\n 'activate_local_game_preview'",
|
||||
'已切换到客户端运行视图',
|
||||
]) {
|
||||
if (!aiGameCreatorShellAppSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator client preview activation drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -2545,7 +2548,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'fn activate_local_game_preview(',
|
||||
'preview_open_url(&status)?;',
|
||||
]) {
|
||||
if (!aiGameCreatorPreviewRustSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorPreviewRustSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator client preview command drifted: missing ${snippet}`,
|
||||
);
|
||||
@@ -2576,7 +2579,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'项目总控对话窗口仅在开发构建中可用',
|
||||
'index.html?supervisor-chat&projectPath=',
|
||||
]) {
|
||||
if (!aiGameCreatorShellTauriSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellTauriSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator supervisor chat window must stay developer-only: ${snippet}`,
|
||||
);
|
||||
@@ -2940,7 +2943,7 @@ function extractTsStringArray(source, exportName, seen = new Set()) {
|
||||
|
||||
const match = source.match(
|
||||
new RegExp(
|
||||
`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`,
|
||||
`export const ${exportName}[^=]*=\\s*\\[([\\s\\S]*?)\\](?: as const)?;`,
|
||||
),
|
||||
);
|
||||
if (!match) {
|
||||
@@ -3242,17 +3245,20 @@ function assertH5HostBridgePayloadBoundaries() {
|
||||
);
|
||||
}
|
||||
if (
|
||||
!h5HostBridgeSource.includes(
|
||||
!sourceIncludesSnippet(
|
||||
h5HostBridgeSource,
|
||||
'function normalizeNativeAppPageUrl(url: string)',
|
||||
) ||
|
||||
!h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") ||
|
||||
!h5HostBridgeSource.includes(
|
||||
!sourceIncludesSnippet(h5HostBridgeSource, "trimmedUrl.startsWith('//')") ||
|
||||
!sourceIncludesSnippet(
|
||||
h5HostBridgeSource,
|
||||
'nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
|
||||
) ||
|
||||
!h5HostBridgeSource.includes(
|
||||
!sourceIncludesSnippet(
|
||||
h5HostBridgeSource,
|
||||
'const normalizedUrl = normalizeNativeAppPageUrl(url);',
|
||||
) ||
|
||||
!h5HostBridgeSource.includes('{ url: normalizedUrl },')
|
||||
!sourceIncludesSnippet(h5HostBridgeSource, 'url: normalizedUrl,')
|
||||
) {
|
||||
throw new Error(
|
||||
'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage',
|
||||
@@ -3281,7 +3287,7 @@ function assertH5HostBridgePayloadBoundaries() {
|
||||
"rejects.not.toThrow(\n 'private native detail',",
|
||||
'expect(consoleError.mock.calls.flat()).not.toContain(navigationError)',
|
||||
]) {
|
||||
if (!h5HostBridgeTestSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(h5HostBridgeTestSource, snippet)) {
|
||||
throw new Error(
|
||||
`H5 HostBridge navigation failure test must include ${snippet}`,
|
||||
);
|
||||
@@ -3556,7 +3562,7 @@ function assertH5NativeAppMessageSourceBoundaries() {
|
||||
'if (!isNativeInjectedMessageEvent(event))',
|
||||
];
|
||||
for (const snippet of requiredSourceSnippets) {
|
||||
if (!nativeAppHostBridgeSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(nativeAppHostBridgeSource, snippet)) {
|
||||
throw new Error(
|
||||
`H5 native app transport must verify injected message source and origin: ${snippet}`,
|
||||
);
|
||||
@@ -3571,7 +3577,7 @@ function assertH5NativeAppMessageSourceBoundaries() {
|
||||
'expect(listener).not.toHaveBeenCalled();',
|
||||
];
|
||||
for (const snippet of requiredTestSnippets) {
|
||||
if (!nativeAppHostBridgeTestSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(nativeAppHostBridgeTestSource, snippet)) {
|
||||
throw new Error(
|
||||
`H5 native app transport source boundary test must include ${snippet}`,
|
||||
);
|
||||
@@ -3608,7 +3614,7 @@ function extractDocumentMethodTable(source) {
|
||||
throw new Error('native shell plan method table missing end marker');
|
||||
}
|
||||
|
||||
return [...source.slice(start, end).matchAll(/^\| `([^`]+)` \|/gm)].map(
|
||||
return [...source.slice(start, end).matchAll(/^\|\s*`([^`]+)`\s*\|/gm)].map(
|
||||
(entry) => entry[1],
|
||||
);
|
||||
}
|
||||
@@ -3831,7 +3837,7 @@ function assertNativeShellCapabilityPlan() {
|
||||
],
|
||||
]) {
|
||||
for (const snippet of snippets) {
|
||||
if (!source.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(source, snippet)) {
|
||||
throw new Error(
|
||||
`${label} must derive unsupported methods from shared method list`,
|
||||
);
|
||||
@@ -3844,7 +3850,7 @@ function assertNativeShellCapabilityPlan() {
|
||||
'let response = resolve_host_bridge_request(request(method));',
|
||||
'assert_eq!(error.code, "unsupported_method");',
|
||||
]) {
|
||||
if (!desktopDispatchSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(desktopDispatchSource, snippet)) {
|
||||
throw new Error(
|
||||
'desktop dispatch test must derive unsupported methods from Rust capability list',
|
||||
);
|
||||
@@ -4118,7 +4124,7 @@ function assertWechatMiniProgramRouteParity() {
|
||||
"readWebViewSourceQueryValue('clientType')",
|
||||
"readWebViewSourceQueryValue('clientRuntime')",
|
||||
]) {
|
||||
if (!webViewShellSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
|
||||
throw new Error(
|
||||
'wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY',
|
||||
);
|
||||
@@ -4136,11 +4142,22 @@ function assertWechatMiniProgramRouteParity() {
|
||||
|
||||
function assertFileIncludesSnippet(filePath, snippet, label) {
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
if (!source.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(source, snippet)) {
|
||||
throw new Error(`${label} must include ${snippet} in ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceIncludesSnippet(source, snippet) {
|
||||
const compact = (value) =>
|
||||
value
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s*([()[\]{}])\s*/g, '$1')
|
||||
.replace(/\s*,\s*/g, ',')
|
||||
.replace(/,([)}\]])/g, '$1')
|
||||
.trim();
|
||||
return source.includes(snippet) || compact(source).includes(compact(snippet));
|
||||
}
|
||||
|
||||
function assertWechatMiniProgramCapabilityFlows() {
|
||||
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
|
||||
const declaredCapabilities = protocol.WECHAT_HOST_CAPABILITIES ?? [];
|
||||
@@ -4410,7 +4427,7 @@ function assertWechatPaymentResultBoundaries() {
|
||||
"logWechatPayFailure('requestVirtualPayment unavailable')",
|
||||
"logWechatPayFailure('requestVirtualPayment failed', error)",
|
||||
]) {
|
||||
if (!paymentSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(paymentSource, snippet)) {
|
||||
throw new Error(`wechat payment bridge must include ${snippet}`);
|
||||
}
|
||||
}
|
||||
@@ -4440,7 +4457,7 @@ function assertWechatPaymentResultBoundaries() {
|
||||
"expect(console.error).toHaveBeenCalledWith(\n '[wechat-pay] requestVirtualPayment failed'",
|
||||
'expect(console.error.mock.calls.flat()).not.toContain(payError)',
|
||||
]) {
|
||||
if (!paymentTestSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(paymentTestSource, snippet)) {
|
||||
throw new Error(`wechat payment bridge test must include ${snippet}`);
|
||||
}
|
||||
}
|
||||
@@ -4461,7 +4478,7 @@ function assertWechatAuthFailureBoundaries() {
|
||||
"console.warn('[web-view] read mini program env failed')",
|
||||
'logMiniProgramEnvReadFailure(error)',
|
||||
]) {
|
||||
if (!webViewShellSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
|
||||
throw new Error(
|
||||
`wechat web-view env diagnostics must include ${snippet}`,
|
||||
);
|
||||
@@ -4497,7 +4514,7 @@ function assertWechatAuthFailureBoundaries() {
|
||||
'errorMessage: WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE',
|
||||
'errorMessage: WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE',
|
||||
]) {
|
||||
if (!webViewShellSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
|
||||
throw new Error(`wechat auth shell must include ${snippet}`);
|
||||
}
|
||||
}
|
||||
@@ -4541,7 +4558,7 @@ function assertWechatAuthFailureBoundaries() {
|
||||
"expect(console.error).toHaveBeenCalledWith(\n '[web-view] bind phone auth declined'",
|
||||
'expect(console.error.mock.calls.flat()).not.toContain(authDeclined)',
|
||||
]) {
|
||||
if (!authTestSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(authTestSource, snippet)) {
|
||||
throw new Error(`wechat auth boundary test must include ${snippet}`);
|
||||
}
|
||||
}
|
||||
@@ -4566,7 +4583,7 @@ function assertWechatWebViewPageEventBoundaries() {
|
||||
"logWebViewPageFailure('load failed', event.detail)",
|
||||
"logWebViewPageEvent('message', event.detail)",
|
||||
]) {
|
||||
if (!webViewShellSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
|
||||
throw new Error(
|
||||
`wechat web-view page event diagnostics must include ${snippet}`,
|
||||
);
|
||||
@@ -4594,7 +4611,7 @@ function assertWechatWebViewPageEventBoundaries() {
|
||||
"expect(console.info).toHaveBeenCalledWith('[web-view] message')",
|
||||
'expect(console.info.mock.calls.flat()).not.toContain(webViewDetail)',
|
||||
]) {
|
||||
if (!webViewTestSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(webViewTestSource, snippet)) {
|
||||
throw new Error(
|
||||
`wechat web-view page event boundary test must include ${snippet}`,
|
||||
);
|
||||
@@ -4621,7 +4638,7 @@ function assertWechatShareGridFailureBoundaries() {
|
||||
'reject(new Error(WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE))',
|
||||
'errorMessage: WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE',
|
||||
]) {
|
||||
if (!shareGridSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(shareGridSource, snippet)) {
|
||||
throw new Error(`wechat share-grid shell must include ${snippet}`);
|
||||
}
|
||||
}
|
||||
@@ -4648,7 +4665,7 @@ function assertWechatShareGridFailureBoundaries() {
|
||||
"expect(consoleError).toHaveBeenCalledWith('[share-grid] save failed')",
|
||||
"expect(consoleError.mock.calls.flat()).not.toContain('private native download detail')",
|
||||
]) {
|
||||
if (!shareGridTestSource.includes(snippet)) {
|
||||
if (!sourceIncludesSnippet(shareGridTestSource, snippet)) {
|
||||
throw new Error(
|
||||
`wechat share-grid boundary test must include ${snippet}`,
|
||||
);
|
||||
|
||||
@@ -227,7 +227,9 @@ function runStaticChecks(content) {
|
||||
'add_header X-Genarrative-Nginx-Handoff pingora-canary always;',
|
||||
) !== 3
|
||||
) {
|
||||
fail(`${activeSnippetPath}: handoff 响应头必须在 3 个 location 中各出现一次。`);
|
||||
fail(
|
||||
`${activeSnippetPath}: handoff 响应头必须在 3 个 location 中各出现一次。`,
|
||||
);
|
||||
}
|
||||
if (countOccurrences(content, 'deny all;') !== 3) {
|
||||
fail(`${activeSnippetPath}: deny all 必须在 3 个 location 中各出现一次。`);
|
||||
@@ -281,7 +283,9 @@ function runRealpathStaticChecks(content) {
|
||||
|
||||
const serverCount = [...content.matchAll(/^\s*server\s*\{/gmu)].length;
|
||||
if (serverCount !== 1) {
|
||||
fail(`${activeSnippetPath}: 预期只有 1 个 server,实际 ${serverCount} 个。`);
|
||||
fail(
|
||||
`${activeSnippetPath}: 预期只有 1 个 server,实际 ${serverCount} 个。`,
|
||||
);
|
||||
}
|
||||
for (const directive of ['allow 127.0.0.1;', 'allow ::1;', 'deny all;']) {
|
||||
requireIncludes(
|
||||
@@ -401,7 +405,9 @@ function runRealpathStaticChecks(content) {
|
||||
);
|
||||
}
|
||||
if (countOccurrences(content, 'X-Genarrative-Pingora-Probe') !== 1) {
|
||||
fail(`${activeSnippetPath}: probe token 只能出现在真实路径 healthz canary 路由。`);
|
||||
fail(
|
||||
`${activeSnippetPath}: probe token 只能出现在真实路径 healthz canary 路由。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,12 +173,14 @@ export function collectNpmWorkspaceErrors(rootDir) {
|
||||
`${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`,
|
||||
);
|
||||
}
|
||||
const expectedWorkspaceVersion =
|
||||
workspacePath === 'apps/ai-game-creator-shell' ? '0.1.12' : '0.1.0';
|
||||
if (manifest.version !== expectedWorkspaceVersion) {
|
||||
errors.push(
|
||||
`${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`,
|
||||
);
|
||||
if (workspacePath === 'apps/ai-game-creator-shell') {
|
||||
if (!/^\d+\.\d+\.\d+$/u.test(manifest.version ?? '')) {
|
||||
errors.push(
|
||||
`${manifestPath}: workspace version must be a three-part semver`,
|
||||
);
|
||||
}
|
||||
} else if (manifest.version !== '0.1.0') {
|
||||
errors.push(`${manifestPath}: workspace version must be 0.1.0`);
|
||||
}
|
||||
|
||||
for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) {
|
||||
|
||||
@@ -70,29 +70,40 @@ function assertScriptShape() {
|
||||
content.includes('rmSync(') ||
|
||||
content.includes('nginx -s reload')
|
||||
) {
|
||||
failures.push('canary access log parity 脚本不应写文件、删除文件或 reload Nginx。');
|
||||
failures.push(
|
||||
'canary access log parity 脚本不应写文件、删除文件或 reload Nginx。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertParitySucceeds() {
|
||||
const fixture = prepareFixture('ok');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
|
||||
nginxLine(
|
||||
'rid-api',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/api/assets/history',
|
||||
200,
|
||||
),
|
||||
], [
|
||||
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200, {
|
||||
route: 'shadow_probe',
|
||||
}),
|
||||
pingoraLine('rid-api', 'GET', '/api/assets/history', 200, {
|
||||
route: 'api_proxy',
|
||||
proxyTarget: 'api-server',
|
||||
}),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/healthz',
|
||||
200,
|
||||
),
|
||||
nginxLine(
|
||||
'rid-api',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/api/assets/history',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[
|
||||
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200, {
|
||||
route: 'shadow_probe',
|
||||
}),
|
||||
pingoraLine('rid-api', 'GET', '/api/assets/history', 200, {
|
||||
route: 'api_proxy',
|
||||
proxyTarget: 'api-server',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const result = runParity(fixture, [
|
||||
'--path',
|
||||
@@ -112,27 +123,37 @@ function assertParitySucceeds() {
|
||||
|
||||
function assertRealpathParitySucceeds() {
|
||||
const fixture = prepareFixture('realpath-ok');
|
||||
writeLogs(fixture, [
|
||||
nginxLine(
|
||||
'rid-real-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_realpath_canary/healthz',
|
||||
200,
|
||||
),
|
||||
nginxLine('rid-real-api', 'GET', '/api/assets/history', 200),
|
||||
nginxLine('rid-real-asset', 'GET', '/assets/app.js', 200),
|
||||
], [
|
||||
pingoraLine('rid-real-health', 'GET', '/__genarrative_pingora/healthz', 200, {
|
||||
route: 'shadow_probe',
|
||||
}),
|
||||
pingoraLine('rid-real-api', 'GET', '/api/assets/history', 200, {
|
||||
route: 'api_proxy',
|
||||
proxyTarget: 'api-server',
|
||||
}),
|
||||
pingoraLine('rid-real-asset', 'GET', '/assets/app.js', 200, {
|
||||
route: 'static',
|
||||
}),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-real-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_realpath_canary/healthz',
|
||||
200,
|
||||
),
|
||||
nginxLine('rid-real-api', 'GET', '/api/assets/history', 200),
|
||||
nginxLine('rid-real-asset', 'GET', '/assets/app.js', 200),
|
||||
],
|
||||
[
|
||||
pingoraLine(
|
||||
'rid-real-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora/healthz',
|
||||
200,
|
||||
{
|
||||
route: 'shadow_probe',
|
||||
},
|
||||
),
|
||||
pingoraLine('rid-real-api', 'GET', '/api/assets/history', 200, {
|
||||
route: 'api_proxy',
|
||||
proxyTarget: 'api-server',
|
||||
}),
|
||||
pingoraLine('rid-real-asset', 'GET', '/assets/app.js', 200, {
|
||||
route: 'static',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const result = runParity(fixture, [
|
||||
'--realpath',
|
||||
@@ -149,16 +170,37 @@ function assertRealpathParitySucceeds() {
|
||||
return;
|
||||
}
|
||||
const payload = parseJson(result.stdout, '真实路径日志对照 JSON 输出');
|
||||
assertEqual(payload.mode, 'realpath', '真实路径对账 JSON 必须标记 realpath 模式。');
|
||||
assertEqual(payload.summary.matchedCount, 3, '应匹配三条真实路径 canary 请求。');
|
||||
assertEqual(payload.summary.missingCount, 0, '真实路径对账不应缺少 Pingora 对应日志。');
|
||||
assertEqual(
|
||||
payload.mode,
|
||||
'realpath',
|
||||
'真实路径对账 JSON 必须标记 realpath 模式。',
|
||||
);
|
||||
assertEqual(
|
||||
payload.summary.matchedCount,
|
||||
3,
|
||||
'应匹配三条真实路径 canary 请求。',
|
||||
);
|
||||
assertEqual(
|
||||
payload.summary.missingCount,
|
||||
0,
|
||||
'真实路径对账不应缺少 Pingora 对应日志。',
|
||||
);
|
||||
}
|
||||
|
||||
function assertMissingPingoraRecordFails() {
|
||||
const fixture = prepareFixture('missing-pingora');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-missing', 'GET', '/__genarrative_pingora_canary/v1/identity', 200),
|
||||
], []);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-missing',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/v1/identity',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const result = runParity(fixture);
|
||||
assertStatus(result, 1, '缺少同 request_id Pingora 日志时必须失败。');
|
||||
assertIncludes(
|
||||
@@ -170,11 +212,18 @@ function assertMissingPingoraRecordFails() {
|
||||
|
||||
function assertStatusMismatchFails() {
|
||||
const fixture = prepareFixture('status-mismatch');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-status', 'GET', '/__genarrative_pingora_canary/api/test', 200),
|
||||
], [
|
||||
pingoraLine('rid-status', 'GET', '/api/test', 503),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-status',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/api/test',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[pingoraLine('rid-status', 'GET', '/api/test', 503)],
|
||||
);
|
||||
const result = runParity(fixture);
|
||||
assertStatus(result, 1, 'Nginx/Pingora 状态码不一致时必须失败。');
|
||||
assertIncludes(
|
||||
@@ -186,11 +235,18 @@ function assertStatusMismatchFails() {
|
||||
|
||||
function assertRequiredPathFails() {
|
||||
const fixture = prepareFixture('missing-required-path');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
|
||||
], [
|
||||
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/healthz',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200)],
|
||||
);
|
||||
const result = runParity(fixture, [
|
||||
'--path',
|
||||
'/__genarrative_pingora_canary/api/assets/history',
|
||||
@@ -283,11 +339,18 @@ function assertRejectsLogPathControlCharacters() {
|
||||
|
||||
function assertRejectsPrefixAndPathControlCharacters() {
|
||||
const fixture = prepareFixture('prefix-path-control-character');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
|
||||
], [
|
||||
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/healthz',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200)],
|
||||
);
|
||||
|
||||
const prefixResult = runParity(fixture, [
|
||||
'--prefix',
|
||||
@@ -318,11 +381,25 @@ function assertRejectsPrefixAndPathControlCharacters() {
|
||||
|
||||
function assertRejectsParsedLogPathControlCharacters() {
|
||||
const fixture = prepareFixture('parsed-log-path-control-character');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
|
||||
], [
|
||||
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz%0Aspoofed', 200),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/healthz',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[
|
||||
pingoraLine(
|
||||
'rid-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora/healthz%0Aspoofed',
|
||||
200,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const result = runParity(fixture);
|
||||
if ((result.status ?? 0) === 0) {
|
||||
@@ -337,11 +414,18 @@ function assertRejectsParsedLogPathControlCharacters() {
|
||||
|
||||
function assertRejectsInvalidSinceLines() {
|
||||
const fixture = prepareFixture('invalid-since-lines');
|
||||
writeLogs(fixture, [
|
||||
nginxLine('rid-health', 'GET', '/__genarrative_pingora_canary/healthz', 200),
|
||||
], [
|
||||
pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200),
|
||||
]);
|
||||
writeLogs(
|
||||
fixture,
|
||||
[
|
||||
nginxLine(
|
||||
'rid-health',
|
||||
'GET',
|
||||
'/__genarrative_pingora_canary/healthz',
|
||||
200,
|
||||
),
|
||||
],
|
||||
[pingoraLine('rid-health', 'GET', '/__genarrative_pingora/healthz', 200)],
|
||||
);
|
||||
|
||||
const cliResult = runParity(fixture, ['--since-lines', '0']);
|
||||
if ((cliResult.status ?? 0) === 0) {
|
||||
@@ -417,13 +501,7 @@ function nginxLine(requestId, method, uri, status) {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function pingoraLine(
|
||||
requestId,
|
||||
method,
|
||||
requestPath,
|
||||
status,
|
||||
options = {},
|
||||
) {
|
||||
function pingoraLine(requestId, method, requestPath, status, options = {}) {
|
||||
return [
|
||||
`request_id=${requestId}`,
|
||||
`method=${method}`,
|
||||
|
||||
@@ -199,7 +199,9 @@ function normalizeRequestPath(value, label = '--path') {
|
||||
if (!raw) {
|
||||
return '/';
|
||||
}
|
||||
const pathOnly = raw.includes('://') ? new URL(raw).pathname : raw.split('?')[0];
|
||||
const pathOnly = raw.includes('://')
|
||||
? new URL(raw).pathname
|
||||
: raw.split('?')[0];
|
||||
return pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`;
|
||||
}
|
||||
|
||||
@@ -333,7 +335,11 @@ function buildParity(nginxRecords, pingoraRecords) {
|
||||
pingoraPath: match.path,
|
||||
problems: [],
|
||||
};
|
||||
if (nginxRecord.method && match.method && nginxRecord.method !== match.method) {
|
||||
if (
|
||||
nginxRecord.method &&
|
||||
match.method &&
|
||||
nginxRecord.method !== match.method
|
||||
) {
|
||||
mismatch.problems.push(`method ${nginxRecord.method} != ${match.method}`);
|
||||
}
|
||||
if (nginxRecord.status !== match.status) {
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -82,7 +89,8 @@ async function main() {
|
||||
await prepareAcmeRoot(acmeRoot);
|
||||
}
|
||||
|
||||
const realUpstreams = config.realUpstreams || config.apiUpstream || config.spacetimeUpstream;
|
||||
const realUpstreams =
|
||||
config.realUpstreams || config.apiUpstream || config.spacetimeUpstream;
|
||||
if (realUpstreams && (!config.apiUpstream || !config.spacetimeUpstream)) {
|
||||
throw new Error(
|
||||
'真实上游模式必须同时提供 --api-upstream 与 --spacetime-upstream。',
|
||||
@@ -307,7 +315,9 @@ async function main() {
|
||||
'Docker Nginx canary 未把 API 代表路径交给 mock api-server',
|
||||
);
|
||||
ensure(
|
||||
spacetime.state.requests.some((request) => request.url === '/v1/identity'),
|
||||
spacetime.state.requests.some(
|
||||
(request) => request.url === '/v1/identity',
|
||||
),
|
||||
'Docker Nginx canary 未把 SpacetimeDB identity 代表路径交给 mock SpacetimeDB',
|
||||
);
|
||||
ensure(
|
||||
@@ -317,8 +327,9 @@ async function main() {
|
||||
'Docker Nginx realpath canary 未把真实 API 代表路径交给 mock api-server',
|
||||
);
|
||||
ensure(
|
||||
spacetime.state.requests.filter((request) => request.url === '/v1/identity')
|
||||
.length >= 2,
|
||||
spacetime.state.requests.filter(
|
||||
(request) => request.url === '/v1/identity',
|
||||
).length >= 2,
|
||||
'Docker Nginx realpath canary 未把真实 SpacetimeDB identity 路径交给 mock SpacetimeDB',
|
||||
);
|
||||
}
|
||||
@@ -401,7 +412,10 @@ Environment aliases:
|
||||
result.realUpstreams = true;
|
||||
break;
|
||||
case '--api-upstream':
|
||||
result.apiUpstream = normalizeHostPort(requireValue(argv, ++index, arg), arg);
|
||||
result.apiUpstream = normalizeHostPort(
|
||||
requireValue(argv, ++index, arg),
|
||||
arg,
|
||||
);
|
||||
break;
|
||||
case '--spacetime-upstream':
|
||||
result.spacetimeUpstream = normalizeHostPort(
|
||||
@@ -410,10 +424,16 @@ Environment aliases:
|
||||
);
|
||||
break;
|
||||
case '--web-root':
|
||||
result.webRoot = normalizeDirectory(requireValue(argv, ++index, arg), arg);
|
||||
result.webRoot = normalizeDirectory(
|
||||
requireValue(argv, ++index, arg),
|
||||
arg,
|
||||
);
|
||||
break;
|
||||
case '--acme-root':
|
||||
result.acmeRoot = normalizeDirectory(requireValue(argv, ++index, arg), arg);
|
||||
result.acmeRoot = normalizeDirectory(
|
||||
requireValue(argv, ++index, arg),
|
||||
arg,
|
||||
);
|
||||
break;
|
||||
case '--verbose':
|
||||
result.verbose = true;
|
||||
@@ -448,12 +468,19 @@ function normalizeHostPort(value, label) {
|
||||
throw new Error(`${label} 不能为空。`);
|
||||
}
|
||||
if (raw.includes('://') || /[\s/?#@]/u.test(raw)) {
|
||||
throw new Error(`${label} 必须是 host:port,不能包含 scheme、路径、查询、片段或空白字符。`);
|
||||
throw new Error(
|
||||
`${label} 必须是 host:port,不能包含 scheme、路径、查询、片段或空白字符。`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(`http://${raw}`);
|
||||
const port = Number.parseInt(parsed.port, 10);
|
||||
if (!parsed.hostname || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
if (
|
||||
!parsed.hostname ||
|
||||
!Number.isInteger(port) ||
|
||||
port <= 0 ||
|
||||
port > 65535
|
||||
) {
|
||||
throw new Error('invalid host:port');
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -228,8 +228,7 @@ async function main() {
|
||||
const checks = [
|
||||
{
|
||||
name: 'healthz',
|
||||
path:
|
||||
config.mode === 'realpath' ? REALPATH_HEALTHZ_PATH : '/healthz',
|
||||
path: config.mode === 'realpath' ? REALPATH_HEALTHZ_PATH : '/healthz',
|
||||
expectedStatus: 200,
|
||||
assertBody: (body) => body.includes('"gateway":"pingora-shadow"'),
|
||||
bodyReason: 'body 应包含 gateway=pingora-shadow',
|
||||
|
||||
@@ -104,7 +104,9 @@ function assertScriptShape() {
|
||||
content.includes('systemctl reload') ||
|
||||
content.includes('daemon-reload')
|
||||
) {
|
||||
failures.push('current release 自审脚本不应写文件、删除文件或 reload systemd。');
|
||||
failures.push(
|
||||
'current release 自审脚本不应写文件、删除文件或 reload systemd。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +191,9 @@ function assertChecksumMismatchFails() {
|
||||
(item) => item.path === 'pingora-gateway',
|
||||
);
|
||||
if (!checksum || checksum.status !== 'CRITICAL') {
|
||||
failures.push('checksum 不匹配时 pingora-gateway checksum 必须标记 CRITICAL。');
|
||||
failures.push(
|
||||
'checksum 不匹配时 pingora-gateway checksum 必须标记 CRITICAL。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +325,10 @@ function assertSystemdShowAcceptsCurrentSymlinkExecStart() {
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
const payload = parseJson(result.stdout, 'current symlink ExecStart 自审输出');
|
||||
const payload = parseJson(
|
||||
result.stdout,
|
||||
'current symlink ExecStart 自审输出',
|
||||
);
|
||||
assertEqual(
|
||||
payload.systemd.status,
|
||||
'OK',
|
||||
@@ -359,14 +366,10 @@ function assertRejectsRelativeReleaseRoot() {
|
||||
}
|
||||
|
||||
function assertRejectsFilesystemRootReleaseRoot() {
|
||||
const result = spawnSync(
|
||||
'node',
|
||||
[AUDIT_SCRIPT, '--release-root', '/'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
const result = spawnSync('node', [AUDIT_SCRIPT, '--release-root', '/'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if ((result.status ?? 0) === 0) {
|
||||
failures.push('current release 自审必须拒绝文件系统根目录 release root。');
|
||||
}
|
||||
@@ -430,7 +433,8 @@ function assertRejectsInvalidBoolEnv() {
|
||||
const cases = [
|
||||
{
|
||||
env: { GENARRATIVE_PINGORA_CURRENT_RELEASE_REQUIRE_GATEWAY: 'ture' },
|
||||
expected: 'GENARRATIVE_PINGORA_CURRENT_RELEASE_REQUIRE_GATEWAY 必须是布尔值',
|
||||
expected:
|
||||
'GENARRATIVE_PINGORA_CURRENT_RELEASE_REQUIRE_GATEWAY 必须是布尔值',
|
||||
reason: 'current release 自审必须拒绝拼写错误的 require gateway env。',
|
||||
},
|
||||
{
|
||||
@@ -455,7 +459,10 @@ function assertRejectsInvalidBoolEnv() {
|
||||
|
||||
function prepareFixture(name, options = {}) {
|
||||
const root = path.join(tmpRoot, name);
|
||||
const releaseRoot = path.join(root, options.releaseDirectoryName || 'current');
|
||||
const releaseRoot = path.join(
|
||||
root,
|
||||
options.releaseDirectoryName || 'current',
|
||||
);
|
||||
const fakeBin = path.join(root, 'bin');
|
||||
const commandsLog = path.join(root, 'commands.log');
|
||||
const includePingoraGateway = options.includePingoraGateway !== false;
|
||||
@@ -489,7 +496,11 @@ function prepareReleaseRoot(releaseRoot, options) {
|
||||
mkdirSync(path.join(releaseRoot, dir), { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(path.join(releaseRoot, 'api-server'), '#!/usr/bin/env bash\n', 'utf8');
|
||||
writeFileSync(
|
||||
path.join(releaseRoot, 'api-server'),
|
||||
'#!/usr/bin/env bash\n',
|
||||
'utf8',
|
||||
);
|
||||
chmodExecutable(path.join(releaseRoot, 'api-server'));
|
||||
writeChecksum(releaseRoot, 'api-server');
|
||||
|
||||
@@ -588,12 +599,7 @@ function writeFakeSystemctl(fakeBin, commandsLog, releaseRoot) {
|
||||
function runAudit(fixture, args = [], options = {}) {
|
||||
return spawnSync(
|
||||
'node',
|
||||
[
|
||||
AUDIT_SCRIPT,
|
||||
'--release-root',
|
||||
fixture.releaseRoot,
|
||||
...args,
|
||||
],
|
||||
[AUDIT_SCRIPT, '--release-root', fixture.releaseRoot, ...args],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
@@ -601,11 +607,15 @@ function runAudit(fixture, args = [], options = {}) {
|
||||
...process.env,
|
||||
PATH: `${fixture.fakeBin}:${process.env.PATH || ''}`,
|
||||
...(options.timeoutEnv
|
||||
? { GENARRATIVE_PINGORA_CURRENT_RELEASE_TIMEOUT_MS: options.timeoutEnv }
|
||||
? {
|
||||
GENARRATIVE_PINGORA_CURRENT_RELEASE_TIMEOUT_MS:
|
||||
options.timeoutEnv,
|
||||
}
|
||||
: {}),
|
||||
...(options.extraEnv || {}),
|
||||
FAKE_SYSTEMD_EXEC_START:
|
||||
options.systemdExecStart || path.join(fixture.releaseRoot, 'pingora-gateway'),
|
||||
options.systemdExecStart ||
|
||||
path.join(fixture.releaseRoot, 'pingora-gateway'),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -79,11 +79,7 @@ function assertScriptShape() {
|
||||
'command-record.json',
|
||||
'命令证据脚本必须保存结构化命令记录。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'manifest.json',
|
||||
'命令证据脚本必须写 manifest。',
|
||||
);
|
||||
assertIncludes(content, 'manifest.json', '命令证据脚本必须写 manifest。');
|
||||
assertIncludes(
|
||||
content,
|
||||
'cutoverRunId',
|
||||
@@ -185,7 +181,9 @@ function assertCommandEvidenceSucceedsAndWritesArtifacts() {
|
||||
fixture.commandScript,
|
||||
'manifest.command 必须记录真实命令绝对路径。',
|
||||
);
|
||||
const commandRecord = readJson(path.join(output.bundleDir, 'command-record.json'));
|
||||
const commandRecord = readJson(
|
||||
path.join(output.bundleDir, 'command-record.json'),
|
||||
);
|
||||
assertEqual(
|
||||
commandRecord.executable,
|
||||
fixture.commandScript,
|
||||
@@ -264,7 +262,10 @@ function assertCommandEvidenceWritesCutoverRunId() {
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
const output = parseLastJsonObject(result.stdout, 'cutover run id 命令证据 stdout');
|
||||
const output = parseLastJsonObject(
|
||||
result.stdout,
|
||||
'cutover run id 命令证据 stdout',
|
||||
);
|
||||
assertEqual(
|
||||
output.cutoverRunId,
|
||||
'cutover-20260617T020000Z',
|
||||
@@ -363,11 +364,7 @@ function assertCommandEvidenceRedactsProbeTokensFromArtifacts() {
|
||||
'secret-inline-token',
|
||||
'secret-env-token',
|
||||
]) {
|
||||
assertNotIncludes(
|
||||
content,
|
||||
secret,
|
||||
`${fileName} 不能泄露 token 原文。`,
|
||||
);
|
||||
assertNotIncludes(content, secret, `${fileName} 不能泄露 token 原文。`);
|
||||
}
|
||||
assertIncludes(content, '<redacted>', `${fileName} 必须保留脱敏占位符。`);
|
||||
}
|
||||
@@ -712,7 +709,9 @@ function assertRejectsMismatchedExpectedExecutableBeforeCommand() {
|
||||
fixture.commandScript,
|
||||
]);
|
||||
if ((result.status ?? 0) === 0) {
|
||||
failures.push('命令证据脚本必须拒绝与 --expected-executable 不一致的真实命令。');
|
||||
failures.push(
|
||||
'命令证据脚本必须拒绝与 --expected-executable 不一致的真实命令。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
`${result.stdout}\n${result.stderr}`,
|
||||
@@ -818,7 +817,11 @@ function parseLastJsonObject(text, label) {
|
||||
failures.push(`${label} 未包含 JSON 结果。`);
|
||||
return {};
|
||||
}
|
||||
for (let start = text.lastIndexOf('{', end); start >= 0; start = text.lastIndexOf('{', start - 1)) {
|
||||
for (
|
||||
let start = text.lastIndexOf('{', end);
|
||||
start >= 0;
|
||||
start = text.lastIndexOf('{', start - 1)
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
@@ -891,7 +894,9 @@ function assertStatus(result, expected, reason) {
|
||||
|
||||
function assertEqual(actual, expected, reason) {
|
||||
if (actual !== expected) {
|
||||
failures.push(`${reason} 实际 ${JSON.stringify(actual)},预期 ${JSON.stringify(expected)}。`);
|
||||
failures.push(
|
||||
`${reason} 实际 ${JSON.stringify(actual)},预期 ${JSON.stringify(expected)}。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -432,7 +432,10 @@ function assertRequirePhaseDirectLiveAccessLogFailsWhenPhaseMissing() {
|
||||
1,
|
||||
'要求 post-enable access log 摘要但 post-enable 阶段证据缺失时必须失败。',
|
||||
);
|
||||
const output = parseJson(result.stdout, '缺 post-enable 阶段 access log 审计 stdout');
|
||||
const output = parseJson(
|
||||
result.stdout,
|
||||
'缺 post-enable 阶段 access log 审计 stdout',
|
||||
);
|
||||
assertEqual(
|
||||
output.summary?.directLiveEvidence?.[0]?.phase,
|
||||
'post-enable',
|
||||
@@ -574,7 +577,10 @@ function assertRequirePhasePingoraEnvShadowFailsWhenMissing() {
|
||||
'manifest.summary.pingoraEnvShadow',
|
||||
'缺少 Pingora env shadow 摘要时必须给出 pingoraEnvShadow 诊断。',
|
||||
);
|
||||
const output = parseJson(result.stdout, '缺 Pingora env shadow 摘要审计 stdout');
|
||||
const output = parseJson(
|
||||
result.stdout,
|
||||
'缺 Pingora env shadow 摘要审计 stdout',
|
||||
);
|
||||
assertEqual(
|
||||
output.summary?.pingoraEnvShadowEvidence?.[0]?.shadow?.ok,
|
||||
false,
|
||||
@@ -673,7 +679,11 @@ function assertRequiredCommandsSucceed() {
|
||||
const output = parseJson(result.stdout, '命令证据审计 stdout');
|
||||
assertEqual(output.ok, true, '命令证据审计 stdout 必须 ok=true。');
|
||||
assertEqual(output.checkedCount, 2, '命令证据审计必须检查两项 command。');
|
||||
assertEqual(output.phases?.length, 0, '只要求 command 时不应隐式检查 phase。');
|
||||
assertEqual(
|
||||
output.phases?.length,
|
||||
0,
|
||||
'只要求 command 时不应隐式检查 phase。',
|
||||
);
|
||||
assertEqual(
|
||||
output.commands?.every((command) => command.status === 'OK'),
|
||||
true,
|
||||
@@ -947,11 +957,7 @@ function assertRelativeCommandExecutableFails() {
|
||||
'--require-command',
|
||||
'enable-apply:pingora-direct-enable-apply',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'命令证据真实 executable 为相对路径时必须失败。',
|
||||
);
|
||||
assertStatus(result, 1, '命令证据真实 executable 为相对路径时必须失败。');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.command.executable 必须是绝对路径',
|
||||
@@ -978,7 +984,8 @@ function assertMissingCommandExecutableFails() {
|
||||
const commandRecord = readJson(commandRecordPath, '命令记录 JSON');
|
||||
delete commandRecord.expectedExecutable;
|
||||
delete commandRecord.executable;
|
||||
commandRecord.command = '/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --apply';
|
||||
commandRecord.command =
|
||||
'/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --apply';
|
||||
writeJson(commandRecordPath, commandRecord);
|
||||
delete manifest.expectedExecutable;
|
||||
manifest.command = { ...commandRecord };
|
||||
@@ -1063,7 +1070,8 @@ function assertCommandRecordMismatchFails() {
|
||||
...manifest.command,
|
||||
expectedExecutable:
|
||||
'/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh',
|
||||
executable: '/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh',
|
||||
executable:
|
||||
'/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh',
|
||||
command:
|
||||
'/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh --apply',
|
||||
};
|
||||
@@ -1248,11 +1256,7 @@ function assertCommandRecordArgsControlCharacterFails() {
|
||||
'--require-command',
|
||||
'enable-apply:pingora-direct-enable-apply',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'命令证据 args 含换行控制字符时必须失败。',
|
||||
);
|
||||
assertStatus(result, 1, '命令证据 args 含换行控制字符时必须失败。');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.command.args 不能包含换行或 NUL 字符',
|
||||
@@ -1396,7 +1400,11 @@ function assertFullCutoverTimelineSucceed() {
|
||||
}
|
||||
const output = parseJson(result.stdout, '完整切换时间线审计 stdout');
|
||||
assertEqual(output.timeline?.checked, true, '完整切换时间线必须被检查。');
|
||||
assertEqual(output.timeline?.ok, true, '完整切换时间线顺序正确时 timeline.ok 必须为 true。');
|
||||
assertEqual(
|
||||
output.timeline?.ok,
|
||||
true,
|
||||
'完整切换时间线顺序正确时 timeline.ok 必须为 true。',
|
||||
);
|
||||
assertEqual(
|
||||
output.timeline?.maxSpanMs,
|
||||
86400000,
|
||||
@@ -1407,7 +1415,11 @@ function assertFullCutoverTimelineSucceed() {
|
||||
14400000,
|
||||
'完整切换时间线必须输出实际跨度。',
|
||||
);
|
||||
assertEqual(output.checkedCount, 8, '完整切换时间线总审计必须检查三阶段和五条命令。');
|
||||
assertEqual(
|
||||
output.checkedCount,
|
||||
8,
|
||||
'完整切换时间线总审计必须检查三阶段和五条命令。',
|
||||
);
|
||||
assertEqual(
|
||||
output.summary?.status,
|
||||
'OK',
|
||||
@@ -1484,7 +1496,10 @@ function assertFullCutoverTimelineFailsWhenPostEnableAccessLogMissing() {
|
||||
'manifest.summary.directLiveAccessLog',
|
||||
'完整时间线缺 post-enable access log 摘要时必须给出 directLiveAccessLog 诊断。',
|
||||
);
|
||||
const output = parseJson(result.stdout, '缺 access log 完整时间线审计 stdout');
|
||||
const output = parseJson(
|
||||
result.stdout,
|
||||
'缺 access log 完整时间线审计 stdout',
|
||||
);
|
||||
assertEqual(
|
||||
output.summary?.status,
|
||||
'CRITICAL',
|
||||
@@ -1657,13 +1672,20 @@ function assertTimelineMaxSpanOverrideSucceed() {
|
||||
postRollback: '2026-06-18T05:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = runFullCutoverAudit(root, ['--timeline-max-span-ms', '172800000']);
|
||||
const result = runFullCutoverAudit(root, [
|
||||
'--timeline-max-span-ms',
|
||||
'172800000',
|
||||
]);
|
||||
assertStatus(result, 0, '显式放宽切换时间线最大跨度后应允许长窗口证据。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
const output = parseJson(result.stdout, '放宽跨度切换时间线审计 stdout');
|
||||
assertEqual(output.timeline?.ok, true, '放宽跨度后 timeline.ok 必须为 true。');
|
||||
assertEqual(
|
||||
output.timeline?.ok,
|
||||
true,
|
||||
'放宽跨度后 timeline.ok 必须为 true。',
|
||||
);
|
||||
assertEqual(
|
||||
output.timeline?.maxSpanMs,
|
||||
172800000,
|
||||
@@ -1830,7 +1852,11 @@ function assertPartialTimelineCutoverRunIdFails() {
|
||||
|
||||
function assertEmptyCutoverRunIdFails() {
|
||||
const root = path.join(tmpRoot, 'empty-cutover-run-id');
|
||||
const bundleDir = prepareBundle(root, '20260617T010000Z-pre-cutover', 'pre-cutover');
|
||||
const bundleDir = prepareBundle(
|
||||
root,
|
||||
'20260617T010000Z-pre-cutover',
|
||||
'pre-cutover',
|
||||
);
|
||||
const manifestPath = path.join(bundleDir, 'manifest.json');
|
||||
const manifest = readJson(manifestPath, '阶段证据 manifest');
|
||||
manifest.cutoverRunId = '';
|
||||
@@ -1915,7 +1941,11 @@ function assertMissingCutoverRunIdFailsWhenRequired() {
|
||||
|
||||
function assertMissingGeneratedAtFails() {
|
||||
const root = path.join(tmpRoot, 'missing-generated-at');
|
||||
const bundleDir = prepareBundle(root, '20260617T010000Z-pre-cutover', 'pre-cutover');
|
||||
const bundleDir = prepareBundle(
|
||||
root,
|
||||
'20260617T010000Z-pre-cutover',
|
||||
'pre-cutover',
|
||||
);
|
||||
const manifestPath = path.join(bundleDir, 'manifest.json');
|
||||
const manifest = readJson(manifestPath, '阶段证据 manifest');
|
||||
delete manifest.generatedAt;
|
||||
@@ -1969,7 +1999,11 @@ function assertInvalidCommandGeneratedAtFails() {
|
||||
|
||||
function assertNonCanonicalGeneratedAtFails() {
|
||||
const root = path.join(tmpRoot, 'non-canonical-generated-at');
|
||||
const bundleDir = prepareBundle(root, '20260617T010000Z-pre-cutover', 'pre-cutover');
|
||||
const bundleDir = prepareBundle(
|
||||
root,
|
||||
'20260617T010000Z-pre-cutover',
|
||||
'pre-cutover',
|
||||
);
|
||||
const manifestPath = path.join(bundleDir, 'manifest.json');
|
||||
const manifest = readJson(manifestPath, '阶段证据 manifest');
|
||||
manifest.generatedAt = '2026-06-17T01:00:00Z';
|
||||
@@ -1981,11 +2015,7 @@ function assertNonCanonicalGeneratedAtFails() {
|
||||
'--require-phase',
|
||||
'pre-cutover',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'阶段证据 manifest.generatedAt 缺少毫秒时必须失败。',
|
||||
);
|
||||
assertStatus(result, 1, '阶段证据 manifest.generatedAt 缺少毫秒时必须失败。');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.generatedAt 必须是合法 ISO 时间',
|
||||
@@ -2012,11 +2042,7 @@ function assertNonCanonicalCommandRecordTimesFail() {
|
||||
'--require-command',
|
||||
'enable-apply:pingora-direct-enable-apply',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'命令记录时间缺少毫秒时必须失败。',
|
||||
);
|
||||
assertStatus(result, 1, '命令记录时间缺少毫秒时必须失败。');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.command.startedAt 必须是合法 ISO 时间',
|
||||
@@ -2129,7 +2155,11 @@ function assertAmbiguousLatestCommandFails() {
|
||||
|
||||
function assertMissingManifestSchemaVersionFails() {
|
||||
const root = path.join(tmpRoot, 'missing-manifest-schema-version');
|
||||
const bundleDir = prepareBundle(root, '20260617T010000Z-pre-cutover', 'pre-cutover');
|
||||
const bundleDir = prepareBundle(
|
||||
root,
|
||||
'20260617T010000Z-pre-cutover',
|
||||
'pre-cutover',
|
||||
);
|
||||
const manifestPath = path.join(bundleDir, 'manifest.json');
|
||||
const manifest = readJson(manifestPath, '阶段证据 manifest');
|
||||
delete manifest.schemaVersion;
|
||||
@@ -2174,7 +2204,11 @@ function assertCommandRecordSchemaVersionMismatchFails() {
|
||||
'--require-command',
|
||||
'enable-apply:pingora-direct-enable-apply',
|
||||
]);
|
||||
assertStatus(result, 1, 'command-record.json schemaVersion 非 1 时必须失败。');
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'command-record.json schemaVersion 非 1 时必须失败。',
|
||||
);
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'command-record.json.schemaVersion 必须是 1',
|
||||
@@ -2191,12 +2225,7 @@ function assertCanAuditSinglePhase() {
|
||||
const root = path.join(tmpRoot, 'single-phase');
|
||||
prepareBundle(root, '20260617T010000Z-pre-cutover', 'pre-cutover');
|
||||
|
||||
const result = runAudit([
|
||||
'--evidence-root',
|
||||
root,
|
||||
'--phase',
|
||||
'pre-cutover',
|
||||
]);
|
||||
const result = runAudit(['--evidence-root', root, '--phase', 'pre-cutover']);
|
||||
assertStatus(result, 0, '只要求单个已有阶段时应成功。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
@@ -2243,11 +2272,7 @@ function assertCommandBundleCannotSatisfyPhaseRequirement() {
|
||||
'--require-phase',
|
||||
'post-enable',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'命令证据不能满足同名 phase 的阶段证据要求。',
|
||||
);
|
||||
assertStatus(result, 1, '命令证据不能满足同名 phase 的阶段证据要求。');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'MISSING',
|
||||
@@ -2372,9 +2397,14 @@ function assertLatestBrokenBundleFails() {
|
||||
prepareBundle(root, '20260617T010000Z-post-enable', 'post-enable', {
|
||||
generatedAt: '2026-06-17T01:00:00.000Z',
|
||||
});
|
||||
const latest = prepareBundle(root, '20260617T020000Z-post-enable', 'post-enable', {
|
||||
generatedAt: '2026-06-17T02:00:00.000Z',
|
||||
});
|
||||
const latest = prepareBundle(
|
||||
root,
|
||||
'20260617T020000Z-post-enable',
|
||||
'post-enable',
|
||||
{
|
||||
generatedAt: '2026-06-17T02:00:00.000Z',
|
||||
},
|
||||
);
|
||||
writeFileSync(path.join(latest, 'snapshot.json'), '{"ok":false}\n', 'utf8');
|
||||
|
||||
const result = runAudit([
|
||||
@@ -2558,7 +2588,10 @@ function assertRejectsUnsafeInputs() {
|
||||
const linkPath = path.join(tmpRoot, 'unsafe-link');
|
||||
symlinkSync(root, linkPath);
|
||||
const verifyLink = path.join(tmpRoot, 'verify-link.mjs');
|
||||
symlinkSync(path.resolve('scripts/ops/pingora-cutover-evidence-verify.mjs'), verifyLink);
|
||||
symlinkSync(
|
||||
path.resolve('scripts/ops/pingora-cutover-evidence-verify.mjs'),
|
||||
verifyLink,
|
||||
);
|
||||
|
||||
const cases = [
|
||||
{
|
||||
@@ -2677,12 +2710,7 @@ function assertRejectsUnsafeInputs() {
|
||||
reason: '同一 command executable 重复绑定不同路径必须失败。',
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'--evidence-root',
|
||||
root,
|
||||
'--require-command-arg',
|
||||
'enable-apply',
|
||||
],
|
||||
args: ['--evidence-root', root, '--require-command-arg', 'enable-apply'],
|
||||
expected:
|
||||
'--require-command-arg 必须使用 <phase>:<commandName>:<arg> 格式',
|
||||
reason: 'command arg 要求缺少字段必须失败。',
|
||||
@@ -2744,7 +2772,11 @@ function assertManifestFileNameControlCharacterFails() {
|
||||
'--require-command',
|
||||
'enable-apply:pingora-direct-enable-apply',
|
||||
]);
|
||||
assertStatus(result, 1, '命令证据 manifest 文件名带控制字符时总审计必须失败。');
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'命令证据 manifest 文件名带控制字符时总审计必须失败。',
|
||||
);
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.files.commandRecord.path 只能是证据目录内的普通文件名',
|
||||
@@ -2889,8 +2921,8 @@ function prepareBundle(root, dirName, phase, options = {}) {
|
||||
options.pingoraEnvShadow === false
|
||||
? null
|
||||
: options.pingoraEnvShadow && typeof options.pingoraEnvShadow === 'object'
|
||||
? options.pingoraEnvShadow
|
||||
: createPingoraEnvShadowSummary();
|
||||
? options.pingoraEnvShadow
|
||||
: createPingoraEnvShadowSummary();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path.join(dir, 'snapshot.json'), '{"ok":true}\n', 'utf8');
|
||||
writeFileSync(path.join(dir, 'snapshot.stdout.txt'), '{"ok":true}\n', 'utf8');
|
||||
@@ -2907,12 +2939,8 @@ function prepareBundle(root, dirName, phase, options = {}) {
|
||||
...(options.cutoverRunId ? { cutoverRunId: options.cutoverRunId } : {}),
|
||||
summary: {
|
||||
status: options.summaryStatus || 'OK',
|
||||
...(directLiveAccessLog
|
||||
? { directLiveAccessLog }
|
||||
: {}),
|
||||
...(directLiveStaticHeaders
|
||||
? { directLiveStaticHeaders }
|
||||
: {}),
|
||||
...(directLiveAccessLog ? { directLiveAccessLog } : {}),
|
||||
...(directLiveStaticHeaders ? { directLiveStaticHeaders } : {}),
|
||||
...(pingoraEnvShadow ? { pingoraEnvShadow } : {}),
|
||||
},
|
||||
files: {
|
||||
@@ -2993,7 +3021,8 @@ function createPingoraEnvShadowSummary() {
|
||||
|
||||
function prepareCommandBundle(root, dirName, phase, commandName, options = {}) {
|
||||
const dir = path.join(root, dirName);
|
||||
const executable = options.executable || defaultCommandExecutable(commandName);
|
||||
const executable =
|
||||
options.executable || defaultCommandExecutable(commandName);
|
||||
const args = options.args || defaultCommandArgs(commandName);
|
||||
const generatedAt = options.generatedAt || '2026-06-17T00:00:00.000Z';
|
||||
const startedAt = options.startedAt || generatedAt;
|
||||
|
||||
@@ -119,11 +119,7 @@ function assertScriptShape() {
|
||||
'--output-root 已存在但不是目录',
|
||||
'证据包脚本必须拒绝非目录 output-root。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'snapshot.stdout.txt',
|
||||
'证据包必须保存快照 stdout。',
|
||||
);
|
||||
assertIncludes(content, 'snapshot.stdout.txt', '证据包必须保存快照 stdout。');
|
||||
assertIncludes(
|
||||
content,
|
||||
'direct-live.stdout.txt',
|
||||
@@ -144,11 +140,7 @@ function assertScriptShape() {
|
||||
'--run-direct-live',
|
||||
'证据包必须暴露 direct live 归档开关。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'manifest.json',
|
||||
'证据包必须写 manifest。',
|
||||
);
|
||||
assertIncludes(content, 'manifest.json', '证据包必须写 manifest。');
|
||||
assertIncludes(
|
||||
content,
|
||||
'SECRET_VALUE_FLAGS',
|
||||
@@ -300,7 +292,9 @@ function assertBundleSucceedsAndWritesManifest() {
|
||||
fixture.originalHealthEnvText,
|
||||
'证据包不应改写 health patrol env。',
|
||||
);
|
||||
const commandRecord = readJson(path.join(output.bundleDir, 'snapshot-command.json'));
|
||||
const commandRecord = readJson(
|
||||
path.join(output.bundleDir, 'snapshot-command.json'),
|
||||
);
|
||||
assertEqual(
|
||||
commandRecord.executable,
|
||||
'node',
|
||||
@@ -482,7 +476,10 @@ function assertBundleCanArchiveDirectLiveEvidence() {
|
||||
return;
|
||||
}
|
||||
const output = parseJson(result.stdout, 'direct live 证据包 stdout');
|
||||
assertFileExists(output.directLivePath, '证据包 stdout 必须返回 direct live JSON 路径。');
|
||||
assertFileExists(
|
||||
output.directLivePath,
|
||||
'证据包 stdout 必须返回 direct live JSON 路径。',
|
||||
);
|
||||
const manifest = readJson(path.join(output.bundleDir, 'manifest.json'));
|
||||
assertEqual(
|
||||
manifest.summary.directLiveStatus,
|
||||
@@ -730,7 +727,11 @@ function assertDirectLiveFailureStillWritesEvidenceAndFails() {
|
||||
|
||||
assertStatus(result, 1, 'direct live 失败时证据包应失败。');
|
||||
const output = parseJson(result.stdout, 'direct live 失败证据包 stdout');
|
||||
assertEqual(output.status, 'CRITICAL', 'direct live 失败时 stdout 必须记录 CRITICAL。');
|
||||
assertEqual(
|
||||
output.status,
|
||||
'CRITICAL',
|
||||
'direct live 失败时 stdout 必须记录 CRITICAL。',
|
||||
);
|
||||
assertFileExists(output.bundleDir, 'direct live 失败证据包目录必须存在。');
|
||||
if (!output.bundleDir) {
|
||||
return;
|
||||
@@ -784,13 +785,19 @@ function assertDirectLiveMissingAccessLogSummaryFails() {
|
||||
1,
|
||||
'direct live JSON 缺少 direct-access-log 时证据包应失败。',
|
||||
);
|
||||
const output = parseJson(result.stdout, 'direct live 缺 access log 证据包 stdout');
|
||||
const output = parseJson(
|
||||
result.stdout,
|
||||
'direct live 缺 access log 证据包 stdout',
|
||||
);
|
||||
assertEqual(
|
||||
output.status,
|
||||
'CRITICAL',
|
||||
'direct live 缺 access log 时 stdout 必须记录 CRITICAL。',
|
||||
);
|
||||
assertFileExists(output.bundleDir, 'direct live 缺 access log 证据包目录必须存在。');
|
||||
assertFileExists(
|
||||
output.bundleDir,
|
||||
'direct live 缺 access log 证据包目录必须存在。',
|
||||
);
|
||||
if (!output.bundleDir) {
|
||||
return;
|
||||
}
|
||||
@@ -837,7 +844,10 @@ function assertDirectLiveMissingStaticHeadersSummaryFails() {
|
||||
'CRITICAL',
|
||||
'direct live 缺静态头时 stdout 必须记录 CRITICAL。',
|
||||
);
|
||||
assertFileExists(output.bundleDir, 'direct live 缺静态头证据包目录必须存在。');
|
||||
assertFileExists(
|
||||
output.bundleDir,
|
||||
'direct live 缺静态头证据包目录必须存在。',
|
||||
);
|
||||
if (!output.bundleDir) {
|
||||
return;
|
||||
}
|
||||
@@ -878,7 +888,10 @@ function assertDirectLiveStaticHeaderDiagnosticsFails() {
|
||||
1,
|
||||
'direct live 静态响应头摘要缺少 Range / 304 证据时证据包应失败。',
|
||||
);
|
||||
const output = parseJson(result.stdout, 'direct live 静态头漂移证据包 stdout');
|
||||
const output = parseJson(
|
||||
result.stdout,
|
||||
'direct live 静态头漂移证据包 stdout',
|
||||
);
|
||||
assertEqual(
|
||||
output.status,
|
||||
'CRITICAL',
|
||||
@@ -940,7 +953,10 @@ function assertDirectLiveParseFailureWritesParseErrorEvidence() {
|
||||
output.directLiveParseErrorPath,
|
||||
'证据包 stdout 必须返回 direct live parse error 路径。',
|
||||
);
|
||||
assertFileExists(output.bundleDir, 'direct live 解析失败证据包目录必须存在。');
|
||||
assertFileExists(
|
||||
output.bundleDir,
|
||||
'direct live 解析失败证据包目录必须存在。',
|
||||
);
|
||||
if (!output.bundleDir) {
|
||||
return;
|
||||
}
|
||||
@@ -990,7 +1006,11 @@ function assertCriticalSnapshotStillWritesEvidenceAndFails() {
|
||||
|
||||
assertStatus(result, 1, 'CRITICAL 快照应保留证据并返回失败。');
|
||||
const output = parseJson(result.stdout, 'CRITICAL 证据包 stdout');
|
||||
assertEqual(output.status, 'CRITICAL', '失败证据包 stdout 必须记录 CRITICAL。');
|
||||
assertEqual(
|
||||
output.status,
|
||||
'CRITICAL',
|
||||
'失败证据包 stdout 必须记录 CRITICAL。',
|
||||
);
|
||||
assertFileExists(output.bundleDir, '失败证据包目录也必须存在。');
|
||||
if (!output.bundleDir) {
|
||||
return;
|
||||
@@ -1133,7 +1153,9 @@ function assertBundleRedactsProbeTokensFromArtifacts() {
|
||||
`${fileName} 不能泄露 direct live probe token 原文。`,
|
||||
);
|
||||
}
|
||||
const commandRecord = readJson(path.join(output.bundleDir, 'snapshot-command.json'));
|
||||
const commandRecord = readJson(
|
||||
path.join(output.bundleDir, 'snapshot-command.json'),
|
||||
);
|
||||
assertIncludes(
|
||||
commandRecord.args || [],
|
||||
'--health-patrol-env-file',
|
||||
@@ -1268,7 +1290,10 @@ function assertRejectsFilesystemRootPaths() {
|
||||
for (const [flag, expected] of [
|
||||
['--release-root', '--release-root 不能是文件系统根目录'],
|
||||
['--output-root', '--output-root 不能是文件系统根目录'],
|
||||
['--health-patrol-env-file', '--health-patrol-env-file 不能是文件系统根目录'],
|
||||
[
|
||||
'--health-patrol-env-file',
|
||||
'--health-patrol-env-file 不能是文件系统根目录',
|
||||
],
|
||||
['--pingora-env-file', '--pingora-env-file 不能是文件系统根目录'],
|
||||
['--snapshot-script', '--snapshot-script 不能是文件系统根目录'],
|
||||
]) {
|
||||
@@ -1339,7 +1364,12 @@ function assertRejectsFilesystemRootPaths() {
|
||||
|
||||
function assertRejectsUnsafePhase() {
|
||||
const fixture = prepareFixture('unsafe-phase');
|
||||
for (const phase of ['post enable', '../post-enable', 'post/enable', '回退']) {
|
||||
for (const phase of [
|
||||
'post enable',
|
||||
'../post-enable',
|
||||
'post/enable',
|
||||
'回退',
|
||||
]) {
|
||||
const result = runBundle(fixture, {
|
||||
status: 'OK',
|
||||
extraArgs: ['--phase', phase],
|
||||
@@ -1608,7 +1638,9 @@ function assertRejectsInvalidBoolEnv() {
|
||||
reason: '证据包必须拒绝拼写错误的 run health patrol env。',
|
||||
},
|
||||
{
|
||||
env: { GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY: 'maybe' },
|
||||
env: {
|
||||
GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY: 'maybe',
|
||||
},
|
||||
expected:
|
||||
'GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY 必须是布尔值',
|
||||
reason: '证据包必须拒绝非法 require pingora gateway env。',
|
||||
@@ -1728,7 +1760,9 @@ function prepareFixture(name, options = {}) {
|
||||
' pingoraEnv: { values: { ...pingoraEnvValues, hasProbeToken: Boolean(pingoraEnv.GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN) }, posture: pingoraEnvPosture },',
|
||||
' checks: [{ name: "production-health-patrol", stdout: "probe stdout <redacted>:<redacted>", stderr: "probe stderr <redacted>:<redacted>" }],',
|
||||
]
|
||||
: [' pingoraEnv: { values: pingoraEnvValues, posture: pingoraEnvPosture },']),
|
||||
: [
|
||||
' pingoraEnv: { values: pingoraEnvValues, posture: pingoraEnvPosture },',
|
||||
]),
|
||||
' args,',
|
||||
'}, null, 2));',
|
||||
'if (status === "CRITICAL" && args.includes("--fail-on-critical")) process.exit(1);',
|
||||
|
||||
@@ -65,16 +65,8 @@ function main() {
|
||||
|
||||
function assertScriptShape() {
|
||||
const content = readFileSync(VERIFY_SCRIPT, 'utf8');
|
||||
assertIncludes(
|
||||
content,
|
||||
'createHash',
|
||||
'证据校验脚本必须计算 sha256。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'sizeBytes',
|
||||
'证据校验脚本必须校验 sizeBytes。',
|
||||
);
|
||||
assertIncludes(content, 'createHash', '证据校验脚本必须计算 sha256。');
|
||||
assertIncludes(content, 'sizeBytes', '证据校验脚本必须校验 sizeBytes。');
|
||||
assertIncludes(
|
||||
content,
|
||||
'证据目录不能是符号链接',
|
||||
@@ -118,7 +110,7 @@ function assertScriptShape() {
|
||||
if (
|
||||
content.includes('writeFile') ||
|
||||
content.includes('chmod(') ||
|
||||
content.includes("spawn(") ||
|
||||
content.includes('spawn(') ||
|
||||
content.includes('execFile')
|
||||
) {
|
||||
failures.push('证据校验脚本必须保持只读,不能写文件或执行外部命令。');
|
||||
@@ -145,7 +137,10 @@ function assertValidEvidenceBundleManifest() {
|
||||
|
||||
function assertValidCommandEvidenceManifest() {
|
||||
const fixture = prepareCommandEvidenceFixture('valid-command');
|
||||
const result = runVerify(['--manifest', path.join(fixture.dir, 'manifest.json')]);
|
||||
const result = runVerify([
|
||||
'--manifest',
|
||||
path.join(fixture.dir, 'manifest.json'),
|
||||
]);
|
||||
assertStatus(result, 0, '合法命令证据 manifest 应校验通过。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
@@ -157,8 +152,16 @@ function assertValidCommandEvidenceManifest() {
|
||||
|
||||
function assertRequireSummaryOkPassesForOkManifest() {
|
||||
const fixture = prepareEvidenceBundleFixture('summary-ok');
|
||||
const result = runVerify(['--bundle-dir', fixture.dir, '--require-summary-ok']);
|
||||
assertStatus(result, 0, '要求 summary OK 且 manifest.summary.status=OK 时应通过。');
|
||||
const result = runVerify([
|
||||
'--bundle-dir',
|
||||
fixture.dir,
|
||||
'--require-summary-ok',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
0,
|
||||
'要求 summary OK 且 manifest.summary.status=OK 时应通过。',
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
@@ -182,8 +185,16 @@ function assertRequireSummaryOkFailsForCriticalManifest() {
|
||||
const manifest = readJson(manifestPath);
|
||||
manifest.summary.status = 'CRITICAL';
|
||||
writeJson(manifestPath, manifest);
|
||||
const result = runVerify(['--bundle-dir', fixture.dir, '--require-summary-ok']);
|
||||
assertStatus(result, 1, '要求 summary OK 但 manifest.summary.status=CRITICAL 时必须失败。');
|
||||
const result = runVerify([
|
||||
'--bundle-dir',
|
||||
fixture.dir,
|
||||
'--require-summary-ok',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'要求 summary OK 但 manifest.summary.status=CRITICAL 时必须失败。',
|
||||
);
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.summary.status is not OK',
|
||||
@@ -197,8 +208,16 @@ function assertRequireSummaryOkFailsWhenSummaryMissing() {
|
||||
const manifest = readJson(manifestPath);
|
||||
delete manifest.summary;
|
||||
writeJson(manifestPath, manifest);
|
||||
const result = runVerify(['--bundle-dir', fixture.dir, '--require-summary-ok']);
|
||||
assertStatus(result, 1, '要求 summary OK 但 manifest 缺少 summary 时必须失败。');
|
||||
const result = runVerify([
|
||||
'--bundle-dir',
|
||||
fixture.dir,
|
||||
'--require-summary-ok',
|
||||
]);
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'要求 summary OK 但 manifest 缺少 summary 时必须失败。',
|
||||
);
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'manifest.summary.status is not OK',
|
||||
@@ -312,7 +331,11 @@ function assertStringMetadataFailsExceptManifest() {
|
||||
|
||||
function assertExtraFileFailsByDefault() {
|
||||
const fixture = prepareEvidenceBundleFixture('extra-file');
|
||||
writeFileSync(path.join(fixture.dir, 'operator-note.txt'), 'manual note\n', 'utf8');
|
||||
writeFileSync(
|
||||
path.join(fixture.dir, 'operator-note.txt'),
|
||||
'manual note\n',
|
||||
'utf8',
|
||||
);
|
||||
const result = runVerify(['--bundle-dir', fixture.dir]);
|
||||
assertStatus(result, 1, '证据目录混入未登记普通文件时默认必须失败。');
|
||||
assertIncludes(
|
||||
@@ -345,7 +368,10 @@ function assertExtraSymlinkFailsByDefault() {
|
||||
const manifest = readJson(path.join(fixture.dir, 'manifest.json'));
|
||||
manifest.files.target = metadataFor(path.join(fixture.dir, 'target.txt'));
|
||||
writeJson(path.join(fixture.dir, 'manifest.json'), manifest);
|
||||
symlinkSync(path.join(fixture.dir, 'target.txt'), path.join(fixture.dir, 'target-link.txt'));
|
||||
symlinkSync(
|
||||
path.join(fixture.dir, 'target.txt'),
|
||||
path.join(fixture.dir, 'target-link.txt'),
|
||||
);
|
||||
const result = runVerify(['--bundle-dir', fixture.dir]);
|
||||
assertStatus(result, 1, '证据目录混入未登记符号链接时默认必须失败。');
|
||||
assertIncludes(
|
||||
@@ -357,8 +383,16 @@ function assertExtraSymlinkFailsByDefault() {
|
||||
|
||||
function assertAllowExtraFilesOverrideSucceeds() {
|
||||
const fixture = prepareEvidenceBundleFixture('allow-extra-file');
|
||||
writeFileSync(path.join(fixture.dir, 'operator-note.txt'), 'manual note\n', 'utf8');
|
||||
const result = runVerify(['--bundle-dir', fixture.dir, '--allow-extra-files']);
|
||||
writeFileSync(
|
||||
path.join(fixture.dir, 'operator-note.txt'),
|
||||
'manual note\n',
|
||||
'utf8',
|
||||
);
|
||||
const result = runVerify([
|
||||
'--bundle-dir',
|
||||
fixture.dir,
|
||||
'--allow-extra-files',
|
||||
]);
|
||||
assertStatus(result, 0, '显式允许未登记文件时 verifier 应通过。');
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
@@ -389,7 +423,10 @@ function assertRejectsRootPath() {
|
||||
|
||||
function assertRejectsEntryPathControlCharacters() {
|
||||
const fixture = prepareEvidenceBundleFixture('entry-control-character');
|
||||
const result = runVerify(['--bundle-dir', `${fixture.dir}\n--allow-extra-files`]);
|
||||
const result = runVerify([
|
||||
'--bundle-dir',
|
||||
`${fixture.dir}\n--allow-extra-files`,
|
||||
]);
|
||||
assertStatus(result, 1, '入口路径带换行控制字符时必须失败。');
|
||||
assertIncludes(
|
||||
`${result.stdout}\n${result.stderr}`,
|
||||
@@ -591,6 +628,8 @@ function assertIncludes(content, needle, reason) {
|
||||
|
||||
function assertEqual(actual, expected, reason) {
|
||||
if (actual !== expected) {
|
||||
failures.push(`${reason} 预期 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`);
|
||||
failures.push(
|
||||
`${reason} 预期 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,9 +228,7 @@ function assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot() {
|
||||
(checksum) => checksum.path === 'pingora-gateway',
|
||||
);
|
||||
if (!pingoraChecksum || pingoraChecksum.matches !== true) {
|
||||
failures.push(
|
||||
'状态快照必须保留 pingora-gateway checksum 匹配结果。',
|
||||
);
|
||||
failures.push('状态快照必须保留 pingora-gateway checksum 匹配结果。');
|
||||
}
|
||||
assertEqual(
|
||||
releaseAuditCheck.details?.releaseManifest?.status,
|
||||
@@ -239,10 +237,13 @@ function assertSnapshotSucceedsWithFakeSystemctlAndReleaseRoot() {
|
||||
);
|
||||
}
|
||||
const canaryLogParityArtifact = payload.releaseArtifacts.artifacts.find(
|
||||
(item) => item.path === 'scripts/check-pingora-canary-access-log-parity.mjs',
|
||||
(item) =>
|
||||
item.path === 'scripts/check-pingora-canary-access-log-parity.mjs',
|
||||
);
|
||||
if (!canaryLogParityArtifact || canaryLogParityArtifact.status !== 'OK') {
|
||||
failures.push('快照必须确认 current release 已包含 canary access log 对账脚本。');
|
||||
failures.push(
|
||||
'快照必须确认 current release 已包含 canary access log 对账脚本。',
|
||||
);
|
||||
}
|
||||
const rehearsalStatusArtifact = payload.releaseArtifacts.artifacts.find(
|
||||
(item) => item.path === 'scripts/ops/pingora-direct-rehearsal-status.mjs',
|
||||
@@ -405,7 +406,11 @@ function assertFailOnCriticalRejectsEnvDriftWithoutWritingEnv() {
|
||||
'--fail-on-critical',
|
||||
]);
|
||||
|
||||
assertStatus(result, 1, 'health patrol env 漂移时 fail-on-critical 必须失败。');
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'health patrol env 漂移时 fail-on-critical 必须失败。',
|
||||
);
|
||||
const payload = parseJson(result.stdout, 'env 漂移快照输出');
|
||||
assertEqual(
|
||||
payload.healthPatrolEnv.status,
|
||||
@@ -660,14 +665,10 @@ function assertRejectsPathArgsWithControlCharacters() {
|
||||
}
|
||||
|
||||
function assertRejectsFilesystemRootReleaseRoot() {
|
||||
const result = spawnSync(
|
||||
'node',
|
||||
[SNAPSHOT_SCRIPT, '--release-root', '/'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
const result = spawnSync('node', [SNAPSHOT_SCRIPT, '--release-root', '/'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if ((result.status ?? 0) === 0) {
|
||||
failures.push('状态快照必须拒绝文件系统根目录 release root。');
|
||||
}
|
||||
@@ -680,7 +681,10 @@ function assertRejectsFilesystemRootReleaseRoot() {
|
||||
|
||||
function assertRejectsFilesystemRootEnvFiles() {
|
||||
for (const [flag, expected] of [
|
||||
['--health-patrol-env-file', '--health-patrol-env-file 不能是文件系统根目录'],
|
||||
[
|
||||
'--health-patrol-env-file',
|
||||
'--health-patrol-env-file 不能是文件系统根目录',
|
||||
],
|
||||
['--pingora-env-file', '--pingora-env-file 不能是文件系统根目录'],
|
||||
]) {
|
||||
const result = spawnSync('node', [SNAPSHOT_SCRIPT, flag, '/'], {
|
||||
@@ -745,7 +749,9 @@ function assertRejectsInvalidBoolEnv() {
|
||||
reason: '状态快照必须拒绝拼写错误的 run health patrol env。',
|
||||
},
|
||||
{
|
||||
env: { GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY: 'maybe' },
|
||||
env: {
|
||||
GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY: 'maybe',
|
||||
},
|
||||
expected:
|
||||
'GENARRATIVE_PINGORA_CUTOVER_SNAPSHOT_REQUIRE_PINGORA_GATEWAY 必须是布尔值',
|
||||
reason: '状态快照必须拒绝非法 require pingora gateway env。',
|
||||
@@ -794,11 +800,7 @@ function prepareFixture(name, options) {
|
||||
'',
|
||||
].join('\n');
|
||||
writeFileSync(healthEnvFile, healthEnvText, 'utf8');
|
||||
writeFileSync(
|
||||
pingoraEnvFile,
|
||||
pingoraEnvText(options),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(pingoraEnvFile, pingoraEnvText(options), 'utf8');
|
||||
writeFileSync(
|
||||
path.join(fakeBin, 'systemctl'),
|
||||
fakeSystemctlScript(commandsLog, releaseRoot, pingoraEnvFile, options),
|
||||
@@ -944,7 +946,12 @@ function prepareReleaseRoot(releaseRoot) {
|
||||
writeReleaseManifest(releaseRoot);
|
||||
}
|
||||
|
||||
function fakeSystemctlScript(commandsLog, releaseRoot, pingoraEnvFile, options) {
|
||||
function fakeSystemctlScript(
|
||||
commandsLog,
|
||||
releaseRoot,
|
||||
pingoraEnvFile,
|
||||
options,
|
||||
) {
|
||||
const capabilityLines = options.directCapability
|
||||
? [
|
||||
'AmbientCapabilities=CAP_NET_BIND_SERVICE',
|
||||
|
||||
@@ -71,7 +71,10 @@ function main() {
|
||||
function assertDirectPreflightChecksServiceEnvironmentFile() {
|
||||
const preflightRoot = path.join(tmpRoot, 'preflight-env-file');
|
||||
const envPath = path.join(preflightRoot, 'pingora-gateway.env');
|
||||
const servicePath = path.join(preflightRoot, 'genarrative-pingora-gateway.service');
|
||||
const servicePath = path.join(
|
||||
preflightRoot,
|
||||
'genarrative-pingora-gateway.service',
|
||||
);
|
||||
const mismatchServicePath = path.join(
|
||||
preflightRoot,
|
||||
'genarrative-pingora-gateway-mismatch.service',
|
||||
@@ -80,11 +83,18 @@ function assertDirectPreflightChecksServiceEnvironmentFile() {
|
||||
preflightRoot,
|
||||
'genarrative-pingora-gateway-direct-entry.conf',
|
||||
);
|
||||
const envExamplePath = path.join(preflightRoot, 'pingora-gateway.env.example');
|
||||
const envExamplePath = path.join(
|
||||
preflightRoot,
|
||||
'pingora-gateway.env.example',
|
||||
);
|
||||
const systemctlBinary = path.join(preflightRoot, 'systemctl');
|
||||
|
||||
mkdirSync(preflightRoot, { recursive: true });
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
servicePath,
|
||||
[
|
||||
@@ -156,7 +166,9 @@ function assertDirectPreflightChecksServiceEnvironmentFile() {
|
||||
envExamplePath,
|
||||
]);
|
||||
if (serviceMismatchResult.status === 0) {
|
||||
failures.push('service 模板 EnvironmentFile 漂移时 direct preflight 必须失败。');
|
||||
failures.push(
|
||||
'service 模板 EnvironmentFile 漂移时 direct preflight 必须失败。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
serviceMismatchResult.stderr,
|
||||
@@ -186,7 +198,9 @@ function assertDirectPreflightChecksServiceEnvironmentFile() {
|
||||
PATH: `${preflightRoot}:${process.env.PATH || ''}`,
|
||||
});
|
||||
if (systemdMismatchResult.status === 0) {
|
||||
failures.push('systemctl cat EnvironmentFile 漂移时 direct preflight 必须失败。');
|
||||
failures.push(
|
||||
'systemctl cat EnvironmentFile 漂移时 direct preflight 必须失败。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
systemdMismatchResult.stderr,
|
||||
@@ -198,12 +212,18 @@ function assertDirectPreflightChecksServiceEnvironmentFile() {
|
||||
function assertDirectPreflightRejectsPublicForwardedForTrust() {
|
||||
const preflightRoot = path.join(tmpRoot, 'preflight-forwarded-for');
|
||||
const envPath = path.join(preflightRoot, 'pingora-gateway.env');
|
||||
const servicePath = path.join(preflightRoot, 'genarrative-pingora-gateway.service');
|
||||
const servicePath = path.join(
|
||||
preflightRoot,
|
||||
'genarrative-pingora-gateway.service',
|
||||
);
|
||||
const dropinPath = path.join(
|
||||
preflightRoot,
|
||||
'genarrative-pingora-gateway-direct-entry.conf',
|
||||
);
|
||||
const envExamplePath = path.join(preflightRoot, 'pingora-gateway.env.example');
|
||||
const envExamplePath = path.join(
|
||||
preflightRoot,
|
||||
'pingora-gateway.env.example',
|
||||
);
|
||||
|
||||
mkdirSync(preflightRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -263,7 +283,9 @@ function assertDirectPreflightRejectsPublicForwardedForTrust() {
|
||||
);
|
||||
const publicResult = runPreflight(baseArgs);
|
||||
if (publicResult.status === 0) {
|
||||
failures.push('公网直连开启 X-Forwarded-For 信任时 direct preflight 必须失败。');
|
||||
failures.push(
|
||||
'公网直连开启 X-Forwarded-For 信任时 direct preflight 必须失败。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
publicResult.stderr,
|
||||
@@ -308,7 +330,9 @@ function assertDirectPreflightRejectsPublicForwardedForTrust() {
|
||||
);
|
||||
const missingConfirmationResult = runPreflight(baseArgs);
|
||||
if (missingConfirmationResult.status === 0) {
|
||||
failures.push('开启 X-Forwarded-For 信任但缺少确认开关时 direct preflight 必须失败。');
|
||||
failures.push(
|
||||
'开启 X-Forwarded-For 信任但缺少确认开关时 direct preflight 必须失败。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
missingConfirmationResult.stderr,
|
||||
@@ -1199,7 +1223,9 @@ function assertApplyRequiresPortsFreePreflight() {
|
||||
}
|
||||
|
||||
function assertApplyRequiresDirectLiveArgs() {
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('direct-live-args-audit-ok');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'direct-live-args-audit-ok',
|
||||
);
|
||||
const baseArgs = [
|
||||
'--apply',
|
||||
'--preflight-env-file',
|
||||
@@ -1316,13 +1342,19 @@ function assertRejectsControlCharacterInputsBeforeApply() {
|
||||
'direct host 控制字符负例必须给出明确错误。',
|
||||
);
|
||||
if (existsSync(dropinPath)) {
|
||||
failures.push('带换行的 direct host 被拒绝后不应安装 direct-entry drop-in。');
|
||||
failures.push(
|
||||
'带换行的 direct host 被拒绝后不应安装 direct-entry drop-in。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertApplyFailsWhenCurrentReleaseAuditFailsBeforeInstall() {
|
||||
const templatePath = path.join(tmpRoot, 'audit-fail-template.conf');
|
||||
const dropinPath = path.join(tmpRoot, 'audit-fail-service.d', 'direct-entry.conf');
|
||||
const dropinPath = path.join(
|
||||
tmpRoot,
|
||||
'audit-fail-service.d',
|
||||
'direct-entry.conf',
|
||||
);
|
||||
const envPath = path.join(tmpRoot, 'audit-fail-pingora-gateway.env');
|
||||
const auditScript = path.join(tmpRoot, 'fake-current-release-audit-fail.mjs');
|
||||
|
||||
@@ -1336,7 +1368,11 @@ function assertApplyFailsWhenCurrentReleaseAuditFailsBeforeInstall() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
auditScript,
|
||||
'#!/usr/bin/env node\nconsole.error("fake current release audit failed");\nprocess.exit(21);\n',
|
||||
@@ -1385,7 +1421,9 @@ function assertApplyFailsWhenCurrentReleaseAuditFailsBeforeInstall() {
|
||||
'current release 自审失败时必须暴露自审错误。',
|
||||
);
|
||||
if (existsSync(dropinPath)) {
|
||||
failures.push('current release 自审失败发生在安装前,不应留下 direct-entry drop-in。');
|
||||
failures.push(
|
||||
'current release 自审失败发生在安装前,不应留下 direct-entry drop-in。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,8 +1439,13 @@ function assertApplyFailsWhenPreflightScriptMissingBeforeInstall() {
|
||||
tmpRoot,
|
||||
'missing-check-pingora-direct-preflight.mjs',
|
||||
);
|
||||
const directLiveScript = path.join(tmpRoot, 'fake-direct-live-preflight-missing.mjs');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('preflight-missing-audit-ok');
|
||||
const directLiveScript = path.join(
|
||||
tmpRoot,
|
||||
'fake-direct-live-preflight-missing.mjs',
|
||||
);
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'preflight-missing-audit-ok',
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
templatePath,
|
||||
@@ -1414,7 +1457,11 @@ function assertApplyFailsWhenPreflightScriptMissingBeforeInstall() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
directLiveScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-direct-live] OK");\n',
|
||||
@@ -1464,7 +1511,9 @@ function assertApplyFailsWhenPreflightScriptMissingBeforeInstall() {
|
||||
'direct preflight 脚本缺失时必须给出明确错误。',
|
||||
);
|
||||
if (existsSync(dropinPath)) {
|
||||
failures.push('direct preflight 脚本缺失发生在安装前,不应留下 direct-entry drop-in。');
|
||||
failures.push(
|
||||
'direct preflight 脚本缺失发生在安装前,不应留下 direct-entry drop-in。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,12 +1525,17 @@ function assertApplyFailsWhenDirectLiveScriptMissingBeforeInstall() {
|
||||
'direct-entry.conf',
|
||||
);
|
||||
const envPath = path.join(tmpRoot, 'direct-live-missing-pingora-gateway.env');
|
||||
const preflightScript = path.join(tmpRoot, 'fake-preflight-direct-live-missing.mjs');
|
||||
const preflightScript = path.join(
|
||||
tmpRoot,
|
||||
'fake-preflight-direct-live-missing.mjs',
|
||||
);
|
||||
const missingDirectLiveScript = path.join(
|
||||
tmpRoot,
|
||||
'missing-check-pingora-direct-live.mjs',
|
||||
);
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('direct-live-missing-audit-ok');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'direct-live-missing-audit-ok',
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
templatePath,
|
||||
@@ -1493,7 +1547,11 @@ function assertApplyFailsWhenDirectLiveScriptMissingBeforeInstall() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
preflightScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-preflight] OK");\n',
|
||||
@@ -1543,7 +1601,9 @@ function assertApplyFailsWhenDirectLiveScriptMissingBeforeInstall() {
|
||||
'direct live smoke 脚本缺失时必须给出明确错误。',
|
||||
);
|
||||
if (existsSync(dropinPath)) {
|
||||
failures.push('direct live smoke 脚本缺失发生在安装前,不应留下 direct-entry drop-in。');
|
||||
failures.push(
|
||||
'direct live smoke 脚本缺失发生在安装前,不应留下 direct-entry drop-in。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1579,7 +1639,11 @@ function assertApplyRejectsSymlinkDropinFileBeforeInstall() {
|
||||
writeFileSync(realDropinFile, 'original dropin\n', 'utf8');
|
||||
symlinkSync(realDropinFile, symlinkDropinFile);
|
||||
|
||||
const result = runEnable([...fixture.args, '--dropin-path', symlinkDropinFile]);
|
||||
const result = runEnable([
|
||||
...fixture.args,
|
||||
'--dropin-path',
|
||||
symlinkDropinFile,
|
||||
]);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('drop-in 目标是符号链接时 enable apply 必须失败。');
|
||||
@@ -1602,10 +1666,15 @@ function assertApplyFailsWhenSystemdEnvFileDiffers() {
|
||||
'systemd-env-mismatch-service.d',
|
||||
'direct-entry.conf',
|
||||
);
|
||||
const envPath = path.join(tmpRoot, 'systemd-env-mismatch-pingora-gateway.env');
|
||||
const envPath = path.join(
|
||||
tmpRoot,
|
||||
'systemd-env-mismatch-pingora-gateway.env',
|
||||
);
|
||||
const preflightScript = path.join(tmpRoot, 'fake-preflight-env-ok.mjs');
|
||||
const directLiveScript = path.join(tmpRoot, 'fake-direct-live-env-ok.mjs');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('systemd-env-mismatch-audit-ok');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'systemd-env-mismatch-audit-ok',
|
||||
);
|
||||
const systemctlBinary = path.join(tmpRoot, 'systemctl');
|
||||
|
||||
writeFileSync(
|
||||
@@ -1618,7 +1687,11 @@ function assertApplyFailsWhenSystemdEnvFileDiffers() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
preflightScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-preflight] OK");\n',
|
||||
@@ -1680,8 +1753,8 @@ function assertApplyFailsWhenSystemdEnvFileDiffers() {
|
||||
'example.com',
|
||||
'--direct-redirect-host',
|
||||
'example.com',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-spacetime-database',
|
||||
'genarrative-prod',
|
||||
'--no-status',
|
||||
@@ -1690,7 +1763,9 @@ function assertApplyFailsWhenSystemdEnvFileDiffers() {
|
||||
);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('systemctl cat 的 EnvironmentFile 漂移时 enable apply 必须失败。');
|
||||
failures.push(
|
||||
'systemctl cat 的 EnvironmentFile 漂移时 enable apply 必须失败。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
@@ -1709,7 +1784,9 @@ function assertApplyFailsWhenSystemdEnvFileOnlySharesPrefix() {
|
||||
const envPath = path.join(tmpRoot, 'systemd-env-prefix-pingora-gateway.env');
|
||||
const preflightScript = path.join(tmpRoot, 'fake-preflight-prefix-ok.mjs');
|
||||
const directLiveScript = path.join(tmpRoot, 'fake-direct-live-prefix-ok.mjs');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('systemd-env-prefix-audit-ok');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'systemd-env-prefix-audit-ok',
|
||||
);
|
||||
const systemctlBinary = path.join(tmpRoot, 'systemctl');
|
||||
|
||||
writeFileSync(
|
||||
@@ -1722,7 +1799,11 @@ function assertApplyFailsWhenSystemdEnvFileOnlySharesPrefix() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
preflightScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-preflight] OK");\n',
|
||||
@@ -1784,8 +1865,8 @@ function assertApplyFailsWhenSystemdEnvFileOnlySharesPrefix() {
|
||||
'example.com',
|
||||
'--direct-redirect-host',
|
||||
'example.com',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-spacetime-database',
|
||||
'genarrative-prod',
|
||||
'--no-status',
|
||||
@@ -1807,7 +1888,10 @@ function assertApplyFailsWhenSystemdEnvFileOnlySharesPrefix() {
|
||||
|
||||
function assertApplyFailsWhenDirectLiveSmokeFails() {
|
||||
const templatePath = path.join(tmpRoot, 'direct-live-fail-template.conf');
|
||||
const serviceUnitPath = path.join(tmpRoot, 'direct-live-fail-service.service');
|
||||
const serviceUnitPath = path.join(
|
||||
tmpRoot,
|
||||
'direct-live-fail-service.service',
|
||||
);
|
||||
const dropinPath = path.join(
|
||||
tmpRoot,
|
||||
'direct-live-fail-service.d',
|
||||
@@ -1818,7 +1902,9 @@ function assertApplyFailsWhenDirectLiveSmokeFails() {
|
||||
const keyPath = path.join(tmpRoot, 'direct-live-fail-key.pem');
|
||||
const preflightScript = path.join(tmpRoot, 'fake-preflight-ok.mjs');
|
||||
const directLiveScript = path.join(tmpRoot, 'fake-direct-live-fail.mjs');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('direct-live-fail-audit-ok');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'direct-live-fail-audit-ok',
|
||||
);
|
||||
const systemctlBinary = path.join(tmpRoot, 'systemctl');
|
||||
|
||||
writeFileSync(
|
||||
@@ -1924,8 +2010,8 @@ function assertApplyFailsWhenDirectLiveSmokeFails() {
|
||||
'example.com',
|
||||
'--direct-redirect-host',
|
||||
'example.com',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-spacetime-database',
|
||||
'genarrative-prod',
|
||||
'--no-status',
|
||||
@@ -1949,7 +2035,10 @@ function assertApplyFailsWhenDirectLiveSmokeFails() {
|
||||
}
|
||||
|
||||
function assertApplyFailsWhenDirectLiveAccessLogJsonMissing() {
|
||||
const templatePath = path.join(tmpRoot, 'direct-live-json-missing-template.conf');
|
||||
const templatePath = path.join(
|
||||
tmpRoot,
|
||||
'direct-live-json-missing-template.conf',
|
||||
);
|
||||
const serviceUnitPath = path.join(
|
||||
tmpRoot,
|
||||
'direct-live-json-missing-service.service',
|
||||
@@ -1998,7 +2087,11 @@ function assertApplyFailsWhenDirectLiveAccessLogJsonMissing() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
preflightScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-preflight] OK");\n',
|
||||
@@ -2098,17 +2191,25 @@ function assertApplyFailsWhenDirectLiveAccessLogJsonMissing() {
|
||||
}
|
||||
|
||||
function assertApplyFailsWhenSystemdExecStartDiffers() {
|
||||
const templatePath = path.join(tmpRoot, 'systemd-exec-mismatch-template.conf');
|
||||
const templatePath = path.join(
|
||||
tmpRoot,
|
||||
'systemd-exec-mismatch-template.conf',
|
||||
);
|
||||
const serviceUnitPath = path.join(tmpRoot, 'systemd-exec-mismatch.service');
|
||||
const dropinPath = path.join(
|
||||
tmpRoot,
|
||||
'systemd-exec-mismatch-service.d',
|
||||
'direct-entry.conf',
|
||||
);
|
||||
const envPath = path.join(tmpRoot, 'systemd-exec-mismatch-pingora-gateway.env');
|
||||
const envPath = path.join(
|
||||
tmpRoot,
|
||||
'systemd-exec-mismatch-pingora-gateway.env',
|
||||
);
|
||||
const preflightScript = path.join(tmpRoot, 'fake-preflight-exec-ok.mjs');
|
||||
const directLiveScript = path.join(tmpRoot, 'fake-direct-live-exec-ok.mjs');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs('systemd-exec-mismatch-audit-ok');
|
||||
const auditArgs = createFakeCurrentReleaseAuditArgs(
|
||||
'systemd-exec-mismatch-audit-ok',
|
||||
);
|
||||
const systemctlBinary = path.join(tmpRoot, 'systemctl');
|
||||
|
||||
writeFileSync(
|
||||
@@ -2133,7 +2234,11 @@ function assertApplyFailsWhenSystemdExecStartDiffers() {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
preflightScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-preflight] OK");\n',
|
||||
@@ -2201,8 +2306,8 @@ function assertApplyFailsWhenSystemdExecStartDiffers() {
|
||||
'example.com',
|
||||
'--direct-redirect-host',
|
||||
'example.com',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-spacetime-database',
|
||||
'genarrative-prod',
|
||||
'--no-status',
|
||||
@@ -2211,7 +2316,9 @@ function assertApplyFailsWhenSystemdExecStartDiffers() {
|
||||
);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('systemctl show ExecStart 指向旧 release 时 enable apply 必须失败。');
|
||||
failures.push(
|
||||
'systemctl show ExecStart 指向旧 release 时 enable apply 必须失败。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
@@ -2257,7 +2364,12 @@ function createFakeCurrentReleaseAuditArgs(name) {
|
||||
'utf8',
|
||||
);
|
||||
chmodExecutable(auditScript);
|
||||
return ['--current-release-audit-script', auditScript, '--current-release-root', tmpRoot];
|
||||
return [
|
||||
'--current-release-audit-script',
|
||||
auditScript,
|
||||
'--current-release-root',
|
||||
tmpRoot,
|
||||
];
|
||||
}
|
||||
|
||||
function createApplyPathSafetyFixture(name) {
|
||||
@@ -2279,7 +2391,11 @@ function createApplyPathSafetyFixture(name) {
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(envPath, 'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n', 'utf8');
|
||||
writeFileSync(
|
||||
envPath,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=https\n',
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(
|
||||
preflightScript,
|
||||
'#!/usr/bin/env node\nconsole.log("[fake-preflight] OK");\n',
|
||||
@@ -2319,8 +2435,8 @@ function createApplyPathSafetyFixture(name) {
|
||||
'example.com',
|
||||
'--direct-redirect-host',
|
||||
'example.com',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-pingora-access-log',
|
||||
'/var/log/genarrative/pingora-gateway.access.log',
|
||||
'--direct-spacetime-database',
|
||||
'genarrative-prod',
|
||||
'--no-status',
|
||||
@@ -2376,7 +2492,9 @@ function allocateLoopbackPorts(count) {
|
||||
}
|
||||
}
|
||||
if (ports.length !== count) {
|
||||
throw new Error('无法为 Pingora direct enable 自测分配空闲 loopback 端口。');
|
||||
throw new Error(
|
||||
'无法为 Pingora direct enable 自测分配空闲 loopback 端口。',
|
||||
);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
@@ -220,10 +220,7 @@ function parseArgs(argv) {
|
||||
'--pingora-access-log',
|
||||
);
|
||||
}
|
||||
validateNoControlCharacters(
|
||||
result.spacetimeDatabase,
|
||||
'--spacetime-database',
|
||||
);
|
||||
validateNoControlCharacters(result.spacetimeDatabase, '--spacetime-database');
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.spacetimeDatabase)) {
|
||||
throw new Error(
|
||||
'--spacetime-database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$',
|
||||
@@ -442,7 +439,9 @@ async function main() {
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
results.push(await runDiscoveredStaticAssetCheck(rootResult, directRequestIds));
|
||||
results.push(
|
||||
await runDiscoveredStaticAssetCheck(rootResult, directRequestIds),
|
||||
);
|
||||
results.push(await runHttp2AlpnCheck());
|
||||
if (!config.skipWss) {
|
||||
results.push(await runWssSubscribeCheck(directRequestIds));
|
||||
@@ -553,7 +552,10 @@ async function runDiscoveredStaticAssetCheck(rootResult, directRequestIds) {
|
||||
};
|
||||
const result = await runCheck(check, directRequestIds);
|
||||
const headResult = await runStaticAssetHeadCheck(assetPath, directRequestIds);
|
||||
const rangeResult = await runStaticAssetRangeCheck(assetPath, directRequestIds);
|
||||
const rangeResult = await runStaticAssetRangeCheck(
|
||||
assetPath,
|
||||
directRequestIds,
|
||||
);
|
||||
const notModifiedResult = await runStaticAssetNotModifiedChecks(
|
||||
assetPath,
|
||||
result.rawHeaders,
|
||||
@@ -579,7 +581,8 @@ function discoverStaticAssetPaths(rootResult) {
|
||||
}
|
||||
const body = String(rootResult.bodySample || '');
|
||||
const paths = [];
|
||||
const pattern = /(?:src|href)=["']([^"']*\/(?:admin\/)?assets\/[^"']+)["']/giu;
|
||||
const pattern =
|
||||
/(?:src|href)=["']([^"']*\/(?:admin\/)?assets\/[^"']+)["']/giu;
|
||||
for (const match of body.matchAll(pattern)) {
|
||||
try {
|
||||
const pathname = new URL(match[1], config.httpsBaseUrl).pathname;
|
||||
@@ -642,7 +645,11 @@ function assertFingerprintedStaticAssetHeaders(check, response) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runStaticAssetHeadCheck(assetPath, directRequestIds, options = {}) {
|
||||
async function runStaticAssetHeadCheck(
|
||||
assetPath,
|
||||
directRequestIds,
|
||||
options = {},
|
||||
) {
|
||||
const check = {
|
||||
name: options.name || 'https-static-asset-head',
|
||||
method: 'HEAD',
|
||||
@@ -681,7 +688,9 @@ async function runFingerprintedStaticAssetCheck(
|
||||
directRequestIds,
|
||||
) {
|
||||
if (!assetPath) {
|
||||
console.log('[pingora-direct-live] https-static-fingerprinted-asset skipped');
|
||||
console.log(
|
||||
'[pingora-direct-live] https-static-fingerprinted-asset skipped',
|
||||
);
|
||||
return {
|
||||
name: 'https-static-fingerprinted-asset',
|
||||
skipped: true,
|
||||
@@ -701,13 +710,21 @@ async function runFingerprintedStaticAssetCheck(
|
||||
evidenceHeaders: true,
|
||||
};
|
||||
const result = await runCheck(check, directRequestIds);
|
||||
const headResult = await runStaticAssetHeadCheck(assetPath, directRequestIds, {
|
||||
name: 'https-static-fingerprinted-asset-head',
|
||||
assertResponse: assertFingerprintedStaticAssetHeadHeaders,
|
||||
});
|
||||
const rangeResult = await runStaticAssetRangeCheck(assetPath, directRequestIds, {
|
||||
name: 'https-static-fingerprinted-asset-range',
|
||||
});
|
||||
const headResult = await runStaticAssetHeadCheck(
|
||||
assetPath,
|
||||
directRequestIds,
|
||||
{
|
||||
name: 'https-static-fingerprinted-asset-head',
|
||||
assertResponse: assertFingerprintedStaticAssetHeadHeaders,
|
||||
},
|
||||
);
|
||||
const rangeResult = await runStaticAssetRangeCheck(
|
||||
assetPath,
|
||||
directRequestIds,
|
||||
{
|
||||
name: 'https-static-fingerprinted-asset-range',
|
||||
},
|
||||
);
|
||||
const notModifiedResult = await runStaticAssetNotModifiedChecks(
|
||||
assetPath,
|
||||
result.rawHeaders,
|
||||
@@ -725,7 +742,11 @@ async function runFingerprintedStaticAssetCheck(
|
||||
};
|
||||
}
|
||||
|
||||
async function runStaticAssetRangeCheck(assetPath, directRequestIds, options = {}) {
|
||||
async function runStaticAssetRangeCheck(
|
||||
assetPath,
|
||||
directRequestIds,
|
||||
options = {},
|
||||
) {
|
||||
const check = {
|
||||
name: options.name || 'https-static-asset-range',
|
||||
url: joinUrl(config.httpsBaseUrl, assetPath),
|
||||
@@ -770,8 +791,7 @@ async function runStaticAssetNotModifiedChecks(
|
||||
result.lastModified = await runCheck(
|
||||
{
|
||||
name:
|
||||
options.lastModifiedName ||
|
||||
'https-static-asset-last-modified-304',
|
||||
options.lastModifiedName || 'https-static-asset-last-modified-304',
|
||||
url: joinUrl(config.httpsBaseUrl, assetPath),
|
||||
expectedStatus: 304,
|
||||
headers: {
|
||||
@@ -847,7 +867,9 @@ function assertStaticAssetRangeHeaders(check, response) {
|
||||
`${check.name}: 缺少 Accept-Ranges: bytes,实际 ${response.headers['accept-ranges'] || '-'}`,
|
||||
);
|
||||
}
|
||||
if (!/^bytes 0-0\/\d+$/u.test(String(response.headers['content-range'] || ''))) {
|
||||
if (
|
||||
!/^bytes 0-0\/\d+$/u.test(String(response.headers['content-range'] || ''))
|
||||
) {
|
||||
failures.push(
|
||||
`${check.name}: Content-Range 不符合 bytes 0-0/<len>,实际 ${response.headers['content-range'] || '-'}`,
|
||||
);
|
||||
@@ -986,10 +1008,7 @@ async function runDirectAccessLogCheck(directRequestIds) {
|
||||
if (mismatched.length > 0) {
|
||||
failures.push(
|
||||
`direct-access-log: method/path/status 不一致 ${mismatched
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.requestId}: ${item.reasons.join(', ')}`,
|
||||
)
|
||||
.map((item) => `${item.requestId}: ${item.reasons.join(', ')}`)
|
||||
.join('; ')}`,
|
||||
);
|
||||
}
|
||||
@@ -1018,7 +1037,9 @@ function compareDirectAccessLogEntries(directRequestIds, entries) {
|
||||
const mismatched = [];
|
||||
|
||||
for (const expected of directRequestIds) {
|
||||
const entry = entries.find((item) => item.request_id === expected.requestId);
|
||||
const entry = entries.find(
|
||||
(item) => item.request_id === expected.requestId,
|
||||
);
|
||||
if (!entry) {
|
||||
missing.push(formatExpectedAccessLogEvidence(expected));
|
||||
continue;
|
||||
@@ -1033,7 +1054,9 @@ function compareDirectAccessLogEntries(directRequestIds, entries) {
|
||||
const expectedEvidence = formatExpectedAccessLogEvidence(expected);
|
||||
const reasons = [];
|
||||
if (actual.method !== expected.method) {
|
||||
reasons.push(`method expected=${expected.method} actual=${actual.method}`);
|
||||
reasons.push(
|
||||
`method expected=${expected.method} actual=${actual.method}`,
|
||||
);
|
||||
}
|
||||
if (actual.path !== expected.path) {
|
||||
reasons.push(`path expected=${expected.path} actual=${actual.path}`);
|
||||
|
||||
@@ -552,11 +552,7 @@ function assertProtectionInstanceBoundary(env) {
|
||||
'GENARRATIVE_PINGORA_GATEWAY_SHARED_PROTECTION_CONFIRMED',
|
||||
);
|
||||
|
||||
if (
|
||||
protectionEnabled &&
|
||||
instanceCount > 1 &&
|
||||
!sharedProtectionConfirmed
|
||||
) {
|
||||
if (protectionEnabled && instanceCount > 1 && !sharedProtectionConfirmed) {
|
||||
failures.push(
|
||||
'Pingora 接流保护当前默认是进程内状态;GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT>1 且 PROTECTION_ENABLED=true 时,必须设置 GENARRATIVE_PINGORA_GATEWAY_SHARED_PROTECTION_CONFIRMED=true,或关闭网关保护并由前置层承担。',
|
||||
);
|
||||
|
||||
@@ -57,11 +57,7 @@ function assertScriptShape() {
|
||||
'systemctl',
|
||||
'rehearsal 状态脚本必须读取 systemd 状态。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'ss',
|
||||
'rehearsal 状态脚本必须读取端口监听状态。',
|
||||
);
|
||||
assertIncludes(content, 'ss', 'rehearsal 状态脚本必须读取端口监听状态。');
|
||||
assertIncludes(
|
||||
content,
|
||||
'pingora-current-release-audit.mjs',
|
||||
@@ -90,7 +86,11 @@ function assertNginxPublicRealpathRehearsalPasses() {
|
||||
'--fail-on-critical',
|
||||
]);
|
||||
|
||||
assertStatus(result, 0, 'Nginx 接公网 + Pingora shadow + realpath canary 应通过。');
|
||||
assertStatus(
|
||||
result,
|
||||
0,
|
||||
'Nginx 接公网 + Pingora shadow + realpath canary 应通过。',
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
return;
|
||||
}
|
||||
@@ -160,7 +160,11 @@ function assertFailsWhenPublicPortsAlreadyOwnedByPingora() {
|
||||
'--fail-on-critical',
|
||||
]);
|
||||
|
||||
assertStatus(result, 1, '期望 Nginx 接公网但 80/443 由 Pingora 监听时必须失败。');
|
||||
assertStatus(
|
||||
result,
|
||||
1,
|
||||
'期望 Nginx 接公网但 80/443 由 Pingora 监听时必须失败。',
|
||||
);
|
||||
const payload = parseJson(result.stdout, 'Pingora 抢占公网端口状态输出');
|
||||
assertEqual(
|
||||
payload.publicBoundary.status,
|
||||
@@ -413,7 +417,10 @@ function prepareReleaseRoot(releaseRoot) {
|
||||
['scripts/ops/production-health-patrol.mjs', 'console.log("patrol");\n'],
|
||||
['scripts/check-production-health-patrol-env.mjs', 'console.log("env");\n'],
|
||||
['scripts/check-pingora-release-readiness.mjs', 'console.log("ready");\n'],
|
||||
['scripts/check-pingora-direct-preflight.mjs', 'console.log("preflight");\n'],
|
||||
[
|
||||
'scripts/check-pingora-direct-preflight.mjs',
|
||||
'console.log("preflight");\n',
|
||||
],
|
||||
['scripts/check-pingora-direct-live.mjs', 'console.log("live");\n'],
|
||||
['scripts/check-pingora-canary-live.mjs', 'console.log("canary");\n'],
|
||||
[
|
||||
@@ -421,11 +428,11 @@ function prepareReleaseRoot(releaseRoot) {
|
||||
'console.log("parity");\n',
|
||||
],
|
||||
['deploy/systemd/genarrative-pingora-gateway.service', '[Service]\n'],
|
||||
['deploy/systemd/genarrative-pingora-gateway-direct-entry.conf', '[Service]\n'],
|
||||
[
|
||||
'deploy/nginx/snippets/genarrative-pingora-canary.conf',
|
||||
'# canary\n',
|
||||
'deploy/systemd/genarrative-pingora-gateway-direct-entry.conf',
|
||||
'[Service]\n',
|
||||
],
|
||||
['deploy/nginx/snippets/genarrative-pingora-canary.conf', '# canary\n'],
|
||||
[
|
||||
'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf',
|
||||
'# realpath\n',
|
||||
@@ -504,7 +511,10 @@ function writeSha256(directory, fileName) {
|
||||
const hash = createHash('sha256')
|
||||
.update(readFileSync(path.join(directory, fileName)))
|
||||
.digest('hex');
|
||||
writeFileSync(path.join(directory, `${fileName}.sha256`), `${hash} ${fileName}\n`);
|
||||
writeFileSync(
|
||||
path.join(directory, `${fileName}.sha256`),
|
||||
`${hash} ${fileName}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function runStatus(fixture, args, extraEnv = {}) {
|
||||
|
||||
@@ -481,7 +481,9 @@ function assertDryRunKeepsDropin() {
|
||||
'dry-run 必须展示 Pingora shadow probe 命令且隐藏 token。',
|
||||
);
|
||||
if (shadowProbeResult.stdout.includes('test-shadow-probe-token')) {
|
||||
failures.push('dry-run Pingora shadow probe 命令不应输出 probe token 原文。');
|
||||
failures.push(
|
||||
'dry-run Pingora shadow probe 命令不应输出 probe token 原文。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
shadowProbeResult.stdout,
|
||||
@@ -560,7 +562,10 @@ function assertRelativeDropinRejected() {
|
||||
}
|
||||
|
||||
function assertRelativeServiceUnitPathRejected() {
|
||||
const dropinPath = path.join(tmpRoot, 'relative-service-unit-direct-entry.conf');
|
||||
const dropinPath = path.join(
|
||||
tmpRoot,
|
||||
'relative-service-unit-direct-entry.conf',
|
||||
);
|
||||
writeFileSync(
|
||||
dropinPath,
|
||||
'[Service]\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n',
|
||||
@@ -588,7 +593,10 @@ function assertRelativeServiceUnitPathRejected() {
|
||||
}
|
||||
|
||||
function assertRejectsRelativeNginxBinaryPath() {
|
||||
const dropinPath = path.join(tmpRoot, 'relative-nginx-binary-direct-entry.conf');
|
||||
const dropinPath = path.join(
|
||||
tmpRoot,
|
||||
'relative-nginx-binary-direct-entry.conf',
|
||||
);
|
||||
writeFileSync(
|
||||
dropinPath,
|
||||
'[Service]\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n',
|
||||
@@ -622,7 +630,10 @@ function assertRejectsRelativeNginxBinaryPath() {
|
||||
}
|
||||
|
||||
function assertRejectsRelativeCurlBinaryPath() {
|
||||
const dropinPath = path.join(tmpRoot, 'relative-curl-binary-direct-entry.conf');
|
||||
const dropinPath = path.join(
|
||||
tmpRoot,
|
||||
'relative-curl-binary-direct-entry.conf',
|
||||
);
|
||||
writeFileSync(
|
||||
dropinPath,
|
||||
'[Service]\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n',
|
||||
@@ -718,11 +729,19 @@ function assertRejectsFilesystemRootPathsBeforeApply() {
|
||||
'--health-patrol-env-file 不能是文件系统根目录',
|
||||
'文件系统根目录 health patrol env 路径负例必须给出明确错误。',
|
||||
);
|
||||
if (rootHealthPatrolEnvResult.stderr.includes('nginx should not run for root path')) {
|
||||
failures.push('文件系统根目录 health patrol env 被拒绝后不应继续执行 nginx -t。');
|
||||
if (
|
||||
rootHealthPatrolEnvResult.stderr.includes(
|
||||
'nginx should not run for root path',
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
'文件系统根目录 health patrol env 被拒绝后不应继续执行 nginx -t。',
|
||||
);
|
||||
}
|
||||
if (!existsSync(dropinPath)) {
|
||||
failures.push('文件系统根目录 health patrol env 被拒绝后不应删除 drop-in。');
|
||||
failures.push(
|
||||
'文件系统根目录 health patrol env 被拒绝后不应删除 drop-in。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1176,7 +1195,10 @@ function assertRejectsInvalidPingoraShadowProbeUrl() {
|
||||
|
||||
function assertRejectsControlCharacterInputsBeforeApply() {
|
||||
const dropinPath = path.join(tmpRoot, 'control-character-direct-entry.conf');
|
||||
const nginxBinary = path.join(tmpRoot, 'fake-nginx-should-not-run-control.sh');
|
||||
const nginxBinary = path.join(
|
||||
tmpRoot,
|
||||
'fake-nginx-should-not-run-control.sh',
|
||||
);
|
||||
writeFileSync(
|
||||
dropinPath,
|
||||
'[Service]\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n',
|
||||
@@ -1204,7 +1226,9 @@ function assertRejectsControlCharacterInputsBeforeApply() {
|
||||
]);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('带换行的 Nginx smoke Host 必须在 rollback apply 修改系统前被拒绝。');
|
||||
failures.push(
|
||||
'带换行的 Nginx smoke Host 必须在 rollback apply 修改系统前被拒绝。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
@@ -1861,10 +1885,7 @@ function assertApplyFailsWhenSystemdExecStartDiffers() {
|
||||
tmpRoot,
|
||||
'systemd-exec-mismatch-direct-entry.conf',
|
||||
);
|
||||
const serviceUnitPath = path.join(
|
||||
tmpRoot,
|
||||
'systemd-exec-mismatch.service',
|
||||
);
|
||||
const serviceUnitPath = path.join(tmpRoot, 'systemd-exec-mismatch.service');
|
||||
const nginxBinary = path.join(tmpRoot, 'fake-nginx-exec-ok.sh');
|
||||
const systemctlBinary = path.join(tmpRoot, 'systemctl');
|
||||
const curlBinary = path.join(tmpRoot, 'fake-curl-exec-ok.sh');
|
||||
|
||||
@@ -57,27 +57,27 @@ function assertScriptShape() {
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_LISTEN: \'127.0.0.1:18081\'',
|
||||
"GENARRATIVE_PINGORA_GATEWAY_LISTEN: '127.0.0.1:18081'",
|
||||
'切换脚本必须固定恢复 Pingora shadow 高端口监听。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: \'\'',
|
||||
"GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: ''",
|
||||
'切换脚本必须清空 TLS 低端口监听。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: \'\'',
|
||||
"GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: ''",
|
||||
'切换脚本必须清空 HTTP redirect 低端口监听。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: \'\'',
|
||||
"GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: ''",
|
||||
'切换脚本必须清空 TLS 证书链路径,避免无 TLS_LISTEN 但残留 cert。',
|
||||
);
|
||||
assertIncludes(
|
||||
content,
|
||||
'GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: \'\'',
|
||||
"GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: ''",
|
||||
'切换脚本必须清空 TLS 私钥路径,避免无 TLS_LISTEN 但残留 key。',
|
||||
);
|
||||
assertIncludes(
|
||||
@@ -112,8 +112,10 @@ function assertDryRunDoesNotModifyEnv() {
|
||||
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443',
|
||||
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE:
|
||||
'/etc/genarrative/pingora-tls/example/fullchain.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE:
|
||||
'/etc/genarrative/pingora-tls/example/privkey.pem',
|
||||
});
|
||||
const before = readFileSync(envFile, 'utf8');
|
||||
const result = runSwitch(['--env-file', envFile]);
|
||||
@@ -137,8 +139,10 @@ function assertApplyRestoresShadowAndPreservesOtherKeys() {
|
||||
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443',
|
||||
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE:
|
||||
'/etc/genarrative/pingora-tls/example/fullchain.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE:
|
||||
'/etc/genarrative/pingora-tls/example/privkey.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_PROTECTION_ENABLED: 'true',
|
||||
});
|
||||
const result = runSwitch(['--env-file', envFile, '--apply']);
|
||||
@@ -187,8 +191,10 @@ function assertApplyPreservesEnvFileMode() {
|
||||
GENARRATIVE_PINGORA_GATEWAY_LISTEN: '0.0.0.0:443',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN: '0.0.0.0:443',
|
||||
GENARRATIVE_PINGORA_GATEWAY_HTTP_REDIRECT_LISTEN: '0.0.0.0:80',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE: '/etc/genarrative/pingora-tls/example/fullchain.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE: '/etc/genarrative/pingora-tls/example/privkey.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE:
|
||||
'/etc/genarrative/pingora-tls/example/fullchain.pem',
|
||||
GENARRATIVE_PINGORA_GATEWAY_TLS_KEY_FILE:
|
||||
'/etc/genarrative/pingora-tls/example/privkey.pem',
|
||||
});
|
||||
chmodSync(envFile, 0o640);
|
||||
const before = statSync(envFile);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user