Files
Genarrative/scripts/preview-deployment-status.mjs
kdletters 41874ab391
Project CI / Repository checks (push) Successful in 1m16s
Project CI / Frontend tests (push) Successful in 3m1s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 17m2s
新增内网容器预览部署控制面
新增分支与指定提交的预览部署 SPA 和 Jenkins 代理服务
新增多实例 Docker 预览流水线、端口租约、实时健康状态和卸载能力
新增 /build 内网路由、systemd 部署资产和运维文档
修正容器 Nginx 健康检查探针
2026-08-15 17:18:10 +08:00

298 lines
7.6 KiB
JavaScript

import { execFileSync } from 'node:child_process';
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const input = parseArgs(process.argv.slice(2));
const state = readState(input.stateFile);
const deploymentId = input.deploymentId || state?.deploymentId || '';
const projectName = input.projectName || state?.projectName || '';
const action = input.action || 'STATUS';
const phaseOverride = input.phase || '';
const containers = projectName ? inspectProjectContainers(projectName) : [];
const serviceMap = new Map(
containers.map((container) => [container.service, container]),
);
const webPort = numberOrNull(state?.webPort);
const webHost = input.webHost || '127.0.0.1';
const webUrl = webPort ? `http://${webHost}:${webPort}` : null;
const probes = {
web: await probe(webPort ? `http://127.0.0.1:${webPort}/` : null),
spacetime: probeSpacetimeContainer(serviceMap.get('spacetimedb')),
};
const requiredServices = [
'spacetimedb',
'api-server',
'external-generation-worker',
'nginx',
'otelcol',
];
const services = requiredServices.map((service) => {
const container = serviceMap.get(service);
return (
container || {
service,
containerId: null,
state: 'missing',
health: 'missing',
}
);
});
const containersHealthy = services.every(
(service) =>
service.state === 'running' &&
(service.health === 'healthy' || service.health === 'none'),
);
const probesHealthy = probes.web.ok && probes.spacetime.ok;
const active = Boolean(state?.active);
let phase = phaseOverride || state?.phase || 'NOT_FOUND';
let healthStatus = 'UNKNOWN';
if (!state) {
phase = phaseOverride || 'NOT_FOUND';
healthStatus = phase === 'FAILED' ? 'UNHEALTHY' : 'NOT_FOUND';
} else if (phase === 'UNINSTALLED' || action === 'UNINSTALL') {
phase = 'UNINSTALLED';
healthStatus = 'UNINSTALLED';
} else if (phase === 'FAILED') {
healthStatus = 'UNHEALTHY';
} else if (active && containersHealthy && probesHealthy) {
phase = 'RUNNING';
healthStatus = 'HEALTHY';
} else if (active || containers.length > 0) {
phase = 'UNHEALTHY';
healthStatus = 'UNHEALTHY';
}
const result = {
schemaVersion: 1,
action,
deploymentId,
projectName: projectName || null,
branch: state?.sourceBranch || input.sourceBranch || null,
requestedCommit: state?.requestedCommit || null,
resolvedCommit: state?.sourceCommit || null,
status: publicStatus(phase),
health: publicHealth(healthStatus),
sourceBranch: state?.sourceBranch || input.sourceBranch || null,
sourceCommit: state?.sourceCommit || null,
phase,
healthStatus,
active: phase === 'RUNNING' || phase === 'UNHEALTHY',
webPort,
webUrl,
ports: {
web: webPort,
spacetime: null,
otlpGrpc: null,
otlpHttp: null,
},
probes,
services,
message: input.message || defaultMessage(phase),
updatedAt: new Date().toISOString(),
};
writeJsonAtomic(input.output, result);
if (input.descriptionFile) {
const description = [
deploymentId || 'unknown',
result.sourceBranch || '-',
phase,
webUrl || '-',
].join(' | ');
writeFileSync(input.descriptionFile, `${description}\n`, 'utf8');
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
function parseArgs(args) {
const parsed = {};
for (let index = 0; index < args.length; index += 1) {
const key = args[index];
if (!key.startsWith('--')) {
throw new Error(`未知参数: ${key}`);
}
const value = args[index + 1];
if (value === undefined || value.startsWith('--')) {
throw new Error(`参数缺少值: ${key}`);
}
parsed[toCamelCase(key.slice(2))] = value;
index += 1;
}
if (!parsed.output) {
throw new Error('必须提供 --output。');
}
return parsed;
}
function toCamelCase(value) {
return value.replace(/-([a-z])/gu, (_match, character) =>
character.toUpperCase(),
);
}
function readState(filePath) {
if (!filePath) {
return null;
}
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch (error) {
if (error?.code === 'ENOENT') {
return null;
}
throw error;
}
}
function inspectProjectContainers(projectName) {
let lines = '';
try {
lines = execFileSync(
'docker',
[
'ps',
'-a',
'--filter',
`label=com.docker.compose.project=${projectName}`,
'--format',
'{{.ID}}|{{.Label "com.docker.compose.service"}}|{{.State}}',
],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
);
} catch {
return [];
}
return lines
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [containerId, service, state] = line.split('|');
return {
service,
containerId,
state,
health: inspectHealth(containerId),
};
});
}
function inspectHealth(containerId) {
try {
const value = execFileSync(
'docker',
[
'inspect',
'--format',
'{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}',
containerId,
],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
).trim();
return value || 'none';
} catch {
return 'unknown';
}
}
async function probe(url) {
if (!url) {
return { ok: false, status: null, elapsedMs: null };
}
const startedAt = Date.now();
try {
const response = await fetch(url, { signal: AbortSignal.timeout(3_000) });
await response.body?.cancel();
return {
ok: response.ok,
status: response.status,
elapsedMs: Date.now() - startedAt,
};
} catch {
return { ok: false, status: null, elapsedMs: Date.now() - startedAt };
}
}
function probeSpacetimeContainer(container) {
if (!container?.containerId || container.state !== 'running') {
return { ok: false, status: null, elapsedMs: null };
}
const startedAt = Date.now();
try {
execFileSync(
'docker',
[
'exec',
container.containerId,
'spacetime',
'server',
'ping',
'http://127.0.0.1:3101',
],
{ stdio: 'ignore', timeout: 3_000 },
);
return { ok: true, status: 200, elapsedMs: Date.now() - startedAt };
} catch {
return { ok: false, status: null, elapsedMs: Date.now() - startedAt };
}
}
function numberOrNull(value) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
}
function defaultMessage(phase) {
switch (phase) {
case 'RUNNING':
return '预览容器运行正常。';
case 'UNHEALTHY':
return '预览容器已启动,但健康检查未全部通过。';
case 'UNINSTALLED':
return '预览容器已卸载。';
case 'NOT_FOUND':
return '未找到对应的预览部署。';
case 'FAILED':
return '预览部署执行失败,请查看 Jenkins 构建日志。';
default:
return `预览部署状态:${phase}`;
}
}
function publicStatus(phase) {
switch (phase) {
case 'RUNNING':
case 'UNHEALTHY':
return 'running';
case 'UNINSTALLED':
case 'NOT_FOUND':
return 'stopped';
default:
return 'failed';
}
}
function publicHealth(healthStatus) {
switch (healthStatus) {
case 'HEALTHY':
return 'healthy';
case 'UNHEALTHY':
return 'unhealthy';
default:
return 'unknown';
}
}
function writeJsonAtomic(filePath, value) {
const absolutePath = path.resolve(filePath);
const temporaryPath = `${absolutePath}.tmp-${process.pid}`;
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
renameSync(temporaryPath, absolutePath);
}