41874ab391
新增分支与指定提交的预览部署 SPA 和 Jenkins 代理服务 新增多实例 Docker 预览流水线、端口租约、实时健康状态和卸载能力 新增 /build 内网路由、systemd 部署资产和运维文档 修正容器 Nginx 健康检查探针
298 lines
7.6 KiB
JavaScript
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);
|
|
}
|