Files
Genarrative/scripts/check-pingora-canary-docker.mjs
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

1138 lines
31 KiB
JavaScript

#!/usr/bin/env node
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 net from 'node:net';
import os from 'node:os';
import path from 'node:path';
const SNIPPET_PATH = 'deploy/nginx/snippets/genarrative-pingora-canary.conf';
const REALPATH_SNIPPET_PATH =
'deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf';
const PROBE_TOKEN_PLACEHOLDER = '__GENARRATIVE_PINGORA_PROBE_TOKEN__';
const REALPATH_CANARY_ACCESS_LOG =
'/var/log/nginx/genarrative-pingora-realpath-canary.access.log';
const REALPATH_LISTEN = 'listen 127.0.0.1:18083;';
const DEFAULT_IMAGE = 'nginx:1.27-alpine';
const repoRoot = process.cwd();
const failures = [];
const childProcesses = [];
const containers = [];
const servers = [];
const sockets = new Set();
const tempDirs = [];
let skipped = false;
const config = parseArgs(process.argv.slice(2));
try {
await main();
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
} finally {
await cleanup();
}
if (failures.length > 0) {
console.error('\n[pingora-canary-docker] 未通过:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
skipped ? '\n[pingora-canary-docker] 跳过' : '\n[pingora-canary-docker] 通过',
);
async function main() {
if (!ensureDockerReady()) {
return;
}
if (!ensureDockerImage()) {
return;
}
const tempRoot = await mkdtemp(
path.join(os.tmpdir(), 'genarrative-pingora-canary-docker-'),
);
tempDirs.push(tempRoot);
const webRoot = path.join(tempRoot, 'web');
const acmeRoot = path.join(tempRoot, 'acme');
const logsRoot = path.join(tempRoot, 'logs');
const nginxAccessLogFile = path.join(logsRoot, 'nginx.access.log');
const realpathNginxAccessLogFile = path.join(
logsRoot,
'nginx.realpath.access.log',
);
const accessLogFile = path.join(logsRoot, 'pingora.access.log');
await mkdir(logsRoot, { recursive: true });
await chmod(logsRoot, 0o777);
const gatewayWebRoot = config.webRoot || webRoot;
const gatewayAcmeRoot = config.acmeRoot || acmeRoot;
if (!config.webRoot) {
await prepareStaticWebRoot(webRoot);
}
if (!config.acmeRoot) {
await prepareAcmeRoot(acmeRoot);
}
const realUpstreams =
config.realUpstreams || config.apiUpstream || config.spacetimeUpstream;
if (realUpstreams && (!config.apiUpstream || !config.spacetimeUpstream)) {
throw new Error(
'真实上游模式必须同时提供 --api-upstream 与 --spacetime-upstream。',
);
}
const api = realUpstreams
? { upstream: config.apiUpstream, state: null }
: await startApiMock();
const spacetime = realUpstreams
? { upstream: config.spacetimeUpstream, state: null }
: await startSpacetimeMock();
if (realUpstreams) {
console.log(
`[pingora-canary-docker] 使用真实上游 api=${api.upstream} spacetime=${spacetime.upstream} webRoot=${gatewayWebRoot}`,
);
await assertRealUpstreamsReady(api.upstream, spacetime.upstream);
}
const pingoraPort = await getFreePort();
const nginxPort = await getFreePort();
const realpathNginxPort = await getFreePort();
const probeToken = `docker-${randomBytes(12).toString('hex')}`;
if (!config.skipBuild) {
runCommand('cargo', [
'build',
'-p',
'pingora-gateway',
'--manifest-path',
'server-rs/Cargo.toml',
]);
}
const gatewayBinary = resolveGatewayBinary();
const gateway = spawn(gatewayBinary, [], {
cwd: repoRoot,
env: {
...smokeEnv(),
GENARRATIVE_PINGORA_GATEWAY_LISTEN: `0.0.0.0:${pingoraPort}`,
GENARRATIVE_PINGORA_GATEWAY_API_UPSTREAM: api.upstream,
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_UPSTREAM: spacetime.upstream,
GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: gatewayWebRoot,
GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: gatewayAcmeRoot,
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: path.join(
tempRoot,
'maintenance',
'enabled',
),
GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE: accessLogFile,
GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: probeToken,
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO: 'https',
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES: '1048576',
GENARRATIVE_PINGORA_GATEWAY_API_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_API_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_API_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_ADMIN_API_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_MAX_CONCURRENT: '16',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_RATE_PER_SECOND: '100',
GENARRATIVE_PINGORA_GATEWAY_SPACETIME_BURST: '100',
GENARRATIVE_PINGORA_GATEWAY_LOG: config.verbose
? 'info,pingora=info,pingora_gateway=info'
: 'warn,pingora=warn,pingora_gateway=warn',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
childProcesses.push({ child: gateway, name: 'pingora-gateway' });
collectProcessLogs(gateway, 'pingora-gateway');
await waitForHttp(`http://127.0.0.1:${pingoraPort}/`, 200, {
label: '等待 Pingora 网关就绪',
});
const renderedSnippetPath = path.join(
tempRoot,
'genarrative-pingora-canary.conf',
);
const nginxConfigPath = path.join(tempRoot, 'nginx.conf');
await renderNginxConfig({
renderedSnippetPath,
nginxConfigPath,
nginxAccessLogFile,
realpathNginxAccessLogFile,
pingoraPort,
nginxPort,
realpathNginxPort,
probeToken,
});
const containerName = `genarrative-pingora-canary-${process.pid}-${randomBytes(4).toString('hex')}`;
containers.push(containerName);
const docker = spawn(
'docker',
[
'run',
'--rm',
'--name',
containerName,
'--network',
'host',
'-v',
`${tempRoot}:${tempRoot}`,
config.image,
'nginx',
'-c',
nginxConfigPath,
'-g',
'daemon off;',
],
{
cwd: repoRoot,
env: smokeEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
},
);
childProcesses.push({ child: docker, name: 'docker-nginx' });
collectProcessLogs(docker, 'docker-nginx');
const baseUrl = `http://127.0.0.1:${nginxPort}`;
const realpathBaseUrl = `http://127.0.0.1:${realpathNginxPort}`;
await waitForHttp(`${baseUrl}/__genarrative_pingora_canary/healthz`, 200, {
headers: { Host: 'example.test' },
label: '等待 Docker Nginx canary 就绪',
validate: (response) =>
response.headers['x-genarrative-nginx-handoff'] === 'pingora-canary' &&
response.body.includes('"gateway":"pingora-shadow"'),
});
await waitForHttp(
`${realpathBaseUrl}/__genarrative_pingora_realpath_canary/healthz`,
200,
{
headers: { Host: 'example.test' },
label: '等待 Docker Nginx realpath canary 就绪',
validate: (response) =>
response.headers['x-genarrative-nginx-handoff'] ===
'pingora-realpath-canary' &&
response.body.includes('"gateway":"pingora-shadow"'),
},
);
await runCommandAsync(process.execPath, [
'scripts/check-pingora-canary-live.mjs',
'--base-url',
baseUrl,
'--host',
'example.test',
'--timeout-ms',
'5000',
]);
await runCommandAsync(process.execPath, [
'scripts/check-pingora-canary-live.mjs',
'--realpath',
'--base-url',
realpathBaseUrl,
'--host',
'example.test',
'--timeout-ms',
'5000',
]);
await expectAccessLogContains(nginxAccessLogFile, [
'/__genarrative_pingora_canary/healthz',
'/__genarrative_pingora_canary/api/assets/history',
'/__genarrative_pingora_canary/v1/identity',
'/__genarrative_pingora_canary/assets/app.js',
]);
await expectAccessLogContains(realpathNginxAccessLogFile, [
'/__genarrative_pingora_realpath_canary/healthz',
'/api/assets/history',
'/v1/identity',
'/assets/app.js',
]);
await expectAccessLogContains(accessLogFile, [
'path=/__genarrative_pingora/healthz',
'path=/api/assets/history',
'path=/v1/identity',
'path=/assets/app.js',
]);
await runCommandAsync(process.execPath, [
'scripts/check-pingora-canary-access-log-parity.mjs',
'--nginx-log-file',
nginxAccessLogFile,
'--pingora-log-file',
accessLogFile,
'--since-lines',
'2000',
'--path',
'/__genarrative_pingora_canary/healthz',
'--path',
'/__genarrative_pingora_canary/api/assets/history',
'--path',
'/__genarrative_pingora_canary/v1/identity',
'--path',
'/__genarrative_pingora_canary/assets/app.js',
]);
await runCommandAsync(process.execPath, [
'scripts/check-pingora-canary-access-log-parity.mjs',
'--realpath',
'--nginx-log-file',
realpathNginxAccessLogFile,
'--pingora-log-file',
accessLogFile,
'--since-lines',
'2000',
'--path',
'/__genarrative_pingora_realpath_canary/healthz',
'--path',
'/api/assets/history',
'--path',
'/v1/identity',
'--path',
'/assets/app.js',
]);
if (!realUpstreams) {
ensure(
api.state.requests.some(
(request) => request.url === '/api/assets/history',
),
'Docker Nginx canary 未把 API 代表路径交给 mock api-server',
);
ensure(
spacetime.state.requests.some(
(request) => request.url === '/v1/identity',
),
'Docker Nginx canary 未把 SpacetimeDB identity 代表路径交给 mock SpacetimeDB',
);
ensure(
api.state.requests.filter(
(request) => request.url === '/api/assets/history',
).length >= 2,
'Docker Nginx realpath canary 未把真实 API 代表路径交给 mock api-server',
);
ensure(
spacetime.state.requests.filter(
(request) => request.url === '/v1/identity',
).length >= 2,
'Docker Nginx realpath canary 未把真实 SpacetimeDB identity 路径交给 mock SpacetimeDB',
);
}
}
function parseArgs(argv) {
const result = {
image: process.env.GENARRATIVE_PINGORA_CANARY_DOCKER_IMAGE || DEFAULT_IMAGE,
pull: false,
requireDocker: false,
skipBuild: false,
verbose: false,
realUpstreams: false,
apiUpstream: normalizeOptionalHostPort(
process.env.GENARRATIVE_PINGORA_CANARY_REAL_API_UPSTREAM,
'GENARRATIVE_PINGORA_CANARY_REAL_API_UPSTREAM',
),
spacetimeUpstream: normalizeOptionalHostPort(
process.env.GENARRATIVE_PINGORA_CANARY_REAL_SPACETIME_UPSTREAM,
'GENARRATIVE_PINGORA_CANARY_REAL_SPACETIME_UPSTREAM',
),
webRoot: normalizeOptionalDirectory(
process.env.GENARRATIVE_PINGORA_CANARY_REAL_WEB_ROOT,
'GENARRATIVE_PINGORA_CANARY_REAL_WEB_ROOT',
),
acmeRoot: normalizeOptionalDirectory(
process.env.GENARRATIVE_PINGORA_CANARY_REAL_ACME_ROOT,
'GENARRATIVE_PINGORA_CANARY_REAL_ACME_ROOT',
),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '-h':
case '--help':
console.log(`Usage:
node scripts/check-pingora-canary-docker.mjs [options]
Options:
--image <image> Nginx image, default ${DEFAULT_IMAGE}.
--pull Pull the image when it is not present locally.
--require-docker Treat missing Docker/image/host-network as failure instead of skip.
--skip-build Reuse an existing pingora-gateway debug binary.
--real-upstreams Require real api-server and SpacetimeDB upstream arguments.
--api-upstream <host:port>
Use a real api-server upstream instead of the built-in mock.
--spacetime-upstream <host:port>
Use a real SpacetimeDB upstream instead of the built-in mock.
--web-root <path> Serve an existing web root, for example dist/.
--acme-root <path> Serve an existing ACME root; temp root is used by default.
--verbose Print Pingora and Docker Nginx process logs.
Environment aliases:
GENARRATIVE_PINGORA_CANARY_DOCKER_IMAGE
GENARRATIVE_PINGORA_CANARY_REAL_API_UPSTREAM
GENARRATIVE_PINGORA_CANARY_REAL_SPACETIME_UPSTREAM
GENARRATIVE_PINGORA_CANARY_REAL_WEB_ROOT
GENARRATIVE_PINGORA_CANARY_REAL_ACME_ROOT
GENARRATIVE_PINGORA_GATEWAY_BINARY
默认不会拉取镜像;本机或 CI 要做强验收时建议:
node scripts/check-pingora-canary-docker.mjs --require-docker --pull
`);
process.exit(0);
break;
case '--image':
result.image = requireValue(argv, ++index, arg);
break;
case '--pull':
result.pull = true;
break;
case '--require-docker':
result.requireDocker = true;
break;
case '--skip-build':
result.skipBuild = true;
break;
case '--real-upstreams':
result.realUpstreams = true;
break;
case '--api-upstream':
result.apiUpstream = normalizeHostPort(
requireValue(argv, ++index, arg),
arg,
);
break;
case '--spacetime-upstream':
result.spacetimeUpstream = normalizeHostPort(
requireValue(argv, ++index, arg),
arg,
);
break;
case '--web-root':
result.webRoot = normalizeDirectory(
requireValue(argv, ++index, arg),
arg,
);
break;
case '--acme-root':
result.acmeRoot = normalizeDirectory(
requireValue(argv, ++index, arg),
arg,
);
break;
case '--verbose':
result.verbose = true;
break;
default:
throw new Error(`未知参数: ${arg}`);
}
}
return result;
}
function requireValue(argv, index, flag) {
const value = argv[index];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} 缺少参数值`);
}
return value;
}
function normalizeOptionalHostPort(value, label) {
if (!value) {
return '';
}
return normalizeHostPort(value, label);
}
function normalizeHostPort(value, label) {
validateNoControlCharacters(value, label);
const raw = String(value || '').trim();
if (!raw) {
throw new Error(`${label} 不能为空。`);
}
if (raw.includes('://') || /[\s/?#@]/u.test(raw)) {
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
) {
throw new Error('invalid host:port');
}
} catch {
throw new Error(`${label} 必须是合法 host:port。`);
}
return raw;
}
function normalizeOptionalDirectory(value, label) {
if (!value) {
return '';
}
return normalizeDirectory(value, label);
}
function normalizeDirectory(value, label) {
validateNoControlCharacters(value, label);
const resolved = path.resolve(repoRoot, String(value || '').trim());
if (!existsSync(resolved)) {
throw new Error(`${label} 不存在:${resolved}`);
}
return resolved;
}
function validateNoControlCharacters(value, label) {
if (/[\0\r\n]/u.test(String(value ?? ''))) {
throw new Error(`${label} 不能包含换行或 NUL 字符。`);
}
}
function ensureDockerReady() {
const result = spawnSync(
'docker',
['version', '--format', '{{.Server.Version}}'],
{
cwd: repoRoot,
env: smokeEnv(),
encoding: 'utf8',
shell: false,
stdio: 'pipe',
},
);
if (result.status === 0) {
console.log(
`[pingora-canary-docker] Docker daemon ${result.stdout.trim() || 'ready'}`,
);
return true;
}
return skipOrFail(
`Docker daemon 不可用;需要强制验证时先启动 Docker。${formatSpawnFailure(result)}`,
);
}
function ensureDockerImage() {
const inspect = spawnSync('docker', ['image', 'inspect', config.image], {
cwd: repoRoot,
env: smokeEnv(),
encoding: 'utf8',
shell: false,
stdio: 'pipe',
});
if (inspect.status === 0) {
return true;
}
if (!config.pull) {
return skipOrFail(
`本机没有 ${config.image} 镜像;可追加 --pull 拉取,或预先 docker pull ${config.image}。`,
);
}
runCommand('docker', ['pull', config.image]);
return true;
}
function skipOrFail(message) {
if (config.requireDocker) {
failures.push(message);
} else {
skipped = true;
console.log(`[pingora-canary-docker] SKIP: ${message}`);
}
return false;
}
async function prepareStaticWebRoot(webRoot) {
await mkdir(path.join(webRoot, 'admin', 'assets'), { recursive: true });
await mkdir(path.join(webRoot, 'assets'), { recursive: true });
await writeFile(path.join(webRoot, 'index.html'), '<main>site-shell</main>');
await writeFile(
path.join(webRoot, 'admin', 'index.html'),
'<main>admin-shell</main>',
);
await writeFile(
path.join(webRoot, 'assets', 'app.js'),
'console.log("site asset");',
);
await writeFile(
path.join(webRoot, 'admin', 'assets', 'admin.js'),
'console.log("admin asset");',
);
}
async function prepareAcmeRoot(acmeRoot) {
await mkdir(path.join(acmeRoot, '.well-known', 'acme-challenge'), {
recursive: true,
});
await writeFile(
path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'),
'acme-token',
);
}
async function startApiMock() {
const state = {
requests: [],
};
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on('close', () => {
sockets.delete(socket);
});
if (config.verbose) {
console.log('[api-mock] connection');
}
let raw = Buffer.alloc(0);
socket.on('data', (chunk) => {
raw = Buffer.concat([raw, chunk]);
const headerEnd = raw.indexOf('\r\n\r\n');
if (headerEnd < 0 || socket.writableEnded) {
return;
}
const request = parseRawHttpRequest(raw.slice(0, headerEnd + 4));
if (config.verbose) {
console.log(
`[api-mock] ${request.method} ${request.url} headers=${JSON.stringify(request.headers)}`,
);
}
state.requests.push(request);
const body = JSON.stringify({
ok: true,
upstream: 'api',
method: request.method,
url: request.url,
host: request.headers.host || '',
});
socket.end(
[
'HTTP/1.1 200 OK',
'Content-Type: application/json; charset=utf-8',
'X-Upstream: api',
`Content-Length: ${Buffer.byteLength(body)}`,
'Connection: close',
'',
body,
].join('\r\n'),
);
});
});
const port = await listen(server);
return { upstream: `127.0.0.1:${port}`, state };
}
async function startSpacetimeMock() {
const state = {
requests: [],
};
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on('close', () => {
sockets.delete(socket);
});
let raw = Buffer.alloc(0);
socket.on('data', (chunk) => {
raw = Buffer.concat([raw, chunk]);
const headerEnd = raw.indexOf('\r\n\r\n');
if (headerEnd < 0 || socket.writableEnded) {
return;
}
const request = parseRawHttpRequest(raw.slice(0, headerEnd + 4));
state.requests.push(request);
const body = JSON.stringify({
ok: true,
upstream: 'spacetime',
url: request.url,
});
socket.end(
[
'HTTP/1.1 200 OK',
'Content-Type: application/json; charset=utf-8',
`Content-Length: ${Buffer.byteLength(body)}`,
'Connection: close',
'',
body,
].join('\r\n'),
);
});
});
const port = await listen(server);
return { upstream: `127.0.0.1:${port}`, state };
}
async function assertRealUpstreamsReady(apiUpstream, spacetimeUpstream) {
await waitForHttp(`http://${apiUpstream}/healthz`, 200, {
label: '等待真实 api-server 就绪',
});
await waitForHttp(`http://${spacetimeUpstream}/v1/ping`, 200, {
label: '等待真实 SpacetimeDB 就绪',
});
}
async function renderNginxConfig({
renderedSnippetPath,
nginxConfigPath,
nginxAccessLogFile,
realpathNginxAccessLogFile,
pingoraPort,
nginxPort,
realpathNginxPort,
probeToken,
}) {
const snippet = await readFile(SNIPPET_PATH, 'utf8');
const renderedSnippet = snippet
.replaceAll(PROBE_TOKEN_PLACEHOLDER, probeToken)
.replaceAll('http://127.0.0.1:18081', `http://127.0.0.1:${pingoraPort}`);
const renderedRealpathSnippetPath = path.join(
path.dirname(renderedSnippetPath),
'genarrative-pingora-realpath-canary.conf',
);
const realpathSnippet = await readFile(REALPATH_SNIPPET_PATH, 'utf8');
const renderedRealpathSnippet = realpathSnippet
.replaceAll(PROBE_TOKEN_PLACEHOLDER, probeToken)
.replaceAll('http://127.0.0.1:18081', `http://127.0.0.1:${pingoraPort}`)
.replace(REALPATH_LISTEN, `listen 127.0.0.1:${realpathNginxPort};`)
.replace(REALPATH_CANARY_ACCESS_LOG, realpathNginxAccessLogFile);
const nginxConfig = `
pid /tmp/genarrative-pingora-canary-docker.pid;
error_log stderr notice;
events {
worker_connections 128;
}
http {
log_format genarrative_upstream
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
'upstream_status=$upstream_status request_id=$request_id';
server {
listen 127.0.0.1:${nginxPort};
server_name example.test;
access_log ${nginxAccessLogFile} genarrative_upstream;
include ${renderedSnippetPath};
}
include ${renderedRealpathSnippetPath};
}
`;
await writeFile(renderedSnippetPath, renderedSnippet, 'utf8');
await writeFile(renderedRealpathSnippetPath, renderedRealpathSnippet, 'utf8');
await writeFile(nginxConfigPath, nginxConfig, 'utf8');
}
async function expectAccessLogContains(accessLogFile, needles) {
await waitForCondition(async () => {
try {
const content = await readFile(accessLogFile, 'utf8');
return needles.every((needle) => content.includes(needle));
} catch {
return false;
}
}, 5000);
}
function requestHttp(url, options = {}) {
return new Promise((resolve, reject) => {
const target = new URL(url);
const socket = net.connect(
{ host: target.hostname, port: Number(target.port) },
() => {
const headers = {
Host: target.host,
Connection: 'close',
...(options.headers || {}),
};
const headerLines = Object.entries(headers).map(
([name, value]) => `${name}: ${value}`,
);
socket.write(
[
`${options.method || 'GET'} ${target.pathname}${target.search} HTTP/1.1`,
...headerLines,
'',
'',
].join('\r\n'),
);
},
);
let raw = Buffer.alloc(0);
const timeout = setTimeout(() => {
socket.destroy(new Error(`HTTP 请求超时:${url}`));
}, options.timeoutMs || 5000);
socket.on('data', (chunk) => {
raw = Buffer.concat([raw, chunk]);
const response = tryParseRawHttpResponse(raw);
if (response) {
clearTimeout(timeout);
socket.destroy();
resolve(response);
}
});
socket.on('error', reject);
socket.on('close', () => {
clearTimeout(timeout);
const response = tryParseRawHttpResponse(raw, { final: true });
if (response) {
resolve(response);
}
});
});
}
function parseRawHttpRequest(raw) {
const text = raw.toString('latin1');
const [requestLine, ...headerLines] = text.trimEnd().split('\r\n');
const [method = '', url = ''] = requestLine.split(' ');
const headers = {};
for (const line of headerLines) {
const separator = line.indexOf(':');
if (separator <= 0) {
continue;
}
headers[line.slice(0, separator).trim().toLowerCase()] = line
.slice(separator + 1)
.trim();
}
return { method, url, headers };
}
function tryParseRawHttpResponse(raw, options = {}) {
const headerEnd = raw.indexOf('\r\n\r\n');
if (headerEnd < 0) {
return null;
}
const headerText = raw.slice(0, headerEnd).toString('latin1');
const [statusLine, ...headerLines] = headerText.split('\r\n');
const status = Number.parseInt(statusLine.split(' ')[1] || '0', 10);
const headers = {};
for (const line of headerLines) {
const separator = line.indexOf(':');
if (separator <= 0) {
continue;
}
headers[line.slice(0, separator).trim().toLowerCase()] = line
.slice(separator + 1)
.trim();
}
const bodyStart = headerEnd + 4;
const body = raw.slice(bodyStart);
const contentLength = Number.parseInt(headers['content-length'] || '', 10);
if (Number.isFinite(contentLength)) {
if (body.length < contentLength) {
return null;
}
return {
status: status,
headers,
body: body.slice(0, contentLength).toString('utf8'),
};
}
if (!options.final) {
return null;
}
return {
status,
headers,
body: body.toString('utf8'),
};
}
function listen(server) {
return new Promise((resolve, reject) => {
server.on('error', reject);
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => {
sockets.delete(socket);
});
});
server.listen(0, '127.0.0.1', () => {
servers.push(server);
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('无法读取 mock server 端口'));
return;
}
resolve(address.port);
});
});
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('无法分配临时端口'));
return;
}
const { port } = address;
server.close(() => resolve(port));
});
});
}
async function waitForHttp(url, expectedStatus, options = {}) {
let lastError = '';
await waitForCondition(async () => {
try {
const response = await requestHttp(url, {
headers: options.headers,
timeoutMs: 2000,
});
if (
response.status === expectedStatus &&
(!options.validate || options.validate(response))
) {
return true;
}
lastError = `HTTP ${response.status}`;
return false;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
return false;
}
}, 15000).catch(() => {
throw new Error(`${options.label || '等待 HTTP 就绪'}超时:${lastError}`);
});
}
function runCommand(command, args) {
console.log(`[pingora-canary-docker] ${command} ${args.join(' ')}`);
const result = spawnSync(command, args, {
cwd: repoRoot,
env: smokeEnv(),
shell: false,
stdio: 'inherit',
});
if (result.error) {
throw new Error(`${command} 启动失败:${result.error.message}`);
}
if (result.signal) {
throw new Error(`${command} 被信号终止:${result.signal}`);
}
if ((result.status ?? 0) !== 0) {
throw new Error(`${command} ${args.join(' ')} 退出码 ${result.status}`);
}
}
function runCommandAsync(command, args) {
console.log(`[pingora-canary-docker] ${command} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: repoRoot,
env: smokeEnv(),
shell: false,
stdio: 'inherit',
});
child.on('error', (error) => {
reject(new Error(`${command} 启动失败:${error.message}`));
});
child.on('exit', (status, signal) => {
if (signal) {
reject(new Error(`${command} 被信号终止:${signal}`));
return;
}
if ((status ?? 0) !== 0) {
reject(new Error(`${command} ${args.join(' ')} 退出码 ${status}`));
return;
}
resolve();
});
});
}
function resolveGatewayBinary() {
const explicit = process.env.GENARRATIVE_PINGORA_GATEWAY_BINARY;
if (explicit) {
return explicit;
}
const candidates = [
path.join(repoRoot, 'server-rs', 'target', 'debug', 'pingora-gateway'),
path.join(repoRoot, 'target', 'debug', 'pingora-gateway'),
];
const found = candidates.find((candidate) => existsSync(candidate));
if (!found) {
throw new Error(
`未找到 pingora-gateway debug 二进制,请先构建或设置 GENARRATIVE_PINGORA_GATEWAY_BINARY。候选:${candidates.join(', ')}`,
);
}
return found;
}
function smokeEnv() {
return {
...process.env,
PATH: `${path.join(os.homedir(), '.local', 'bin')}:${process.env.PATH || ''}`,
};
}
function collectProcessLogs(child, name) {
child.stdout?.on('data', (chunk) => {
if (config.verbose) {
process.stdout.write(`[${name}] ${chunk}`);
}
});
child.stderr?.on('data', (chunk) => {
if (config.verbose) {
process.stderr.write(`[${name}] ${chunk}`);
}
});
}
function formatSpawnFailure(result) {
const chunks = [];
if (result.error) {
chunks.push(result.error.message);
}
if (result.stderr) {
chunks.push(result.stderr.trim());
}
if (result.stdout) {
chunks.push(result.stdout.trim());
}
return chunks.length > 0 ? ` ${chunks.join(' ')}` : '';
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCondition(predicate, timeoutMs = 2000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await predicate()) {
return;
}
await delay(50);
}
throw new Error('等待 smoke 条件达成超时');
}
function ensure(condition, message) {
if (!condition) {
failures.push(message);
}
}
async function cleanup() {
for (const container of containers.reverse()) {
spawnSync('docker', ['rm', '-f', container], {
cwd: repoRoot,
env: smokeEnv(),
encoding: 'utf8',
shell: false,
stdio: config.verbose ? 'inherit' : 'pipe',
});
}
for (const { child, name } of childProcesses.reverse()) {
await stopChild(child, name);
}
for (const socket of sockets) {
socket.destroy();
}
sockets.clear();
await Promise.all(
servers.reverse().map(
(server) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, 1000);
server.close(() => {
clearTimeout(timer);
resolve();
});
}),
),
);
await Promise.all(
tempDirs.reverse().map((dir) => rm(dir, { recursive: true, force: true })),
);
}
async function stopChild(child, name) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill('SIGTERM');
const exited = await waitForExit(child, 3000);
if (!exited) {
child.kill('SIGKILL');
const killed = await waitForExit(child, 3000);
if (!killed) {
failures.push(`${name} 未能退出`);
}
}
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve(true);
return;
}
const timer = setTimeout(() => {
child.off('exit', onExit);
resolve(false);
}, timeoutMs);
function onExit() {
clearTimeout(timer);
resolve(true);
}
child.once('exit', onExit);
});
}