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); }