UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

253 lines (235 loc) 8.96 kB
/** * Runtime status contract and the in-pod internal status endpoint. * * Single source of truth for the Underpost runtime readiness signal (Phase 2 of * the two-phase deployment monitor). The runtime publishes its lifecycle here; * the CD-side monitor (`src/cli/monitor.js`) reads it over HTTP via * `kubectl port-forward`. Kubernetes pod readiness (Phase 1) is owned by kubelet * and is intentionally not modeled in this module. * * Cross-process contract: * - In-pod, the canonical value lives in the underpost root env key * `container-status`, written by `start.js`. For non-error phases it carries * the namespaced form `<deployId>-<env>-<phase>`; a fatal fault collapses to * the bare value `error`. * - The internal HTTP server exposes that value (normalized to the bare * contract phase) and never exposes secrets, env dumps, or configuration. * * @module src/server/runtime-status.js * @namespace RuntimeStatus */ import http from 'node:http'; import fs from 'fs-extra'; import dotenv from 'dotenv'; import Underpost from '../index.js'; import { loggerFactory } from './logger.js'; const logger = loggerFactory(import.meta); /** * Allowed runtime status contract values. These are the only Phase-2 signals * the monitor reasons about. * @memberof RuntimeStatus */ const RUNTIME_STATUS = { BUILD: 'build-deployment', INIT: 'initializing-deployment', RUNNING: 'running-deployment', ERROR: 'error', }; const CONTAINER_STATUS_KEY = 'container-status'; const START_CONTAINER_STATUS_KEY = 'start-container-status'; const INTERNAL_STATUS_PATH = '/_internal/status'; const INTERNAL_READY_PATH = '/_internal/ready'; const INTERNAL_HEALTH_PATH = '/_internal/health'; /** * Resolves the internal status port. Defaults to the deployment base `PORT` * (app instances bind `PORT + 1` upward, so the base port is free inside the * pod). An explicit `UNDERPOST_INTERNAL_PORT` override wins. * @memberof RuntimeStatus * @returns {number|undefined} */ const resolveInternalStatusPort = () => { const raw = process.env.UNDERPOST_INTERNAL_PORT || process.env.PORT; const port = parseInt(raw); return Number.isNaN(port) ? undefined : port; }; /** * Single source of truth for the internal status port of a specific deployment, * used identically by the in-pod server bind (`start.js`) and the CD-side * monitor target (`monitor.js`) so the two can never disagree. * * Resolution order: `UNDERPOST_INTERNAL_PORT` override → the deployment's * `.env.<env>` `PORT` → the ambient `PORT`. * * @memberof RuntimeStatus * @param {string} deployId * @param {string} env * @returns {number|undefined} */ const deployStatusPort = (deployId, env) => { const override = parseInt(process.env.UNDERPOST_INTERNAL_PORT); if (!Number.isNaN(override)) return override; try { const envPath = `./engine-private/conf/${deployId}/.env.${env}`; if (fs.existsSync(envPath)) { const port = parseInt(dotenv.parse(fs.readFileSync(envPath, 'utf8')).PORT); if (!Number.isNaN(port)) return port; } } catch (_) { /* fall through to ambient resolution */ } return resolveInternalStatusPort(); }; /** * Builds the `container-status` env value for a lifecycle phase. * @memberof RuntimeStatus */ const containerStatusValue = (deployId, env, phase) => phase === RUNTIME_STATUS.ERROR ? RUNTIME_STATUS.ERROR : `${deployId}-${env}-${phase}`; /** * Normalizes a raw `container-status` value to a bare contract phase. * Strips the `<deployId>-<env>-` prefix; `error` and unknown/empty values are * passed through (empty → undefined). * @memberof RuntimeStatus * @param {string} raw * @returns {string|undefined} */ const normalizeContainerStatus = (raw) => { if (!raw || typeof raw !== 'string') return undefined; const value = raw.trim(); if (!value || value === 'undefined' || value.toLowerCase().includes('empty')) return undefined; if (value === RUNTIME_STATUS.ERROR) return RUNTIME_STATUS.ERROR; for (const phase of [RUNTIME_STATUS.BUILD, RUNTIME_STATUS.INIT, RUNTIME_STATUS.RUNNING]) if (value.endsWith(`-${phase}`)) return phase; return value; }; /** * Reads the current normalized runtime status from the env file. * @memberof RuntimeStatus * @returns {string|undefined} */ const getRuntimeStatus = () => normalizeContainerStatus(Underpost.env.get(CONTAINER_STATUS_KEY, undefined, { disableLog: true })); /** * Reads the start-container-status env key — an insulated marker set by the * start pipeline after it completes the running phase. Unlike container-status * (which external scripts / backup failures may clobber), this key is written * once and survives globalSecretClean. Used exclusively by the readinessProbe * endpoint so K8s pod readiness is never derailed by lifecycle noise. * @memberof RuntimeStatus * @returns {string|undefined} */ const getStartContainerStatus = () => { const raw = Underpost.env.get(START_CONTAINER_STATUS_KEY, undefined, { disableLog: true }); return raw && typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; }; /** * Minimal, secret-free payload served by the internal status endpoint and used * by the monitor for failure classification and observability. * @memberof RuntimeStatus * @returns {{status: (string|null), deployId: (string|null), env: (string|null)}} */ const runtimeStatusPayload = () => ({ status: getRuntimeStatus() ?? null, deployId: process.env.DEPLOY_ID ?? null, env: process.env.NODE_ENV ?? null, }); /** * Emits a structured, secret-free deployment transition event. * @memberof RuntimeStatus */ const emitRuntimeEvent = ({ deployId, env, phase }) => { logger.info('runtime-status', { deployId, env, phase: 'runtime', status: phase, timestamp: new Date().toISOString(), }); }; /** * Publishes a runtime lifecycle phase to the cross-process contract. * @memberof RuntimeStatus * @param {string} deployId * @param {string} env * @param {string} phase - One of {@link RUNTIME_STATUS}. */ const setRuntimeStatus = (deployId, env, phase) => { Underpost.env.set(CONTAINER_STATUS_KEY, containerStatusValue(deployId, env, phase)); emitRuntimeEvent({ deployId, env, phase }); }; let internalServer; /** * Starts the in-pod internal status server. Idempotent: repeated calls return * the already-listening server. Exposes only the three internal endpoints and * never serves secrets or configuration. * * GET /_internal/status → 200, `{status, deployId, env}` (monitor transport) * GET /_internal/ready → 200 iff running-deployment, else 503 (readinessProbe) * GET /_internal/health → 200 while the process is alive (livenessProbe) * * @memberof RuntimeStatus * @param {number} [port] * @returns {import('node:http').Server|undefined} */ const startInternalStatusServer = (port = resolveInternalStatusPort()) => { if (internalServer) return internalServer; if (!port) { logger.warn('Internal status server not started: no resolvable port'); return undefined; } const server = http.createServer((req, res) => { const url = (req.url || '').split('?')[0]; const sendJson = (code, body) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }; if (req.method !== 'GET') return sendJson(405, { error: 'method_not_allowed' }); switch (url) { case INTERNAL_HEALTH_PATH: return sendJson(200, { status: 'ok' }); case INTERNAL_READY_PATH: return getStartContainerStatus() ? sendJson(200, { status: RUNTIME_STATUS.RUNNING }) : sendJson(503, { status: getRuntimeStatus() ?? null }); case INTERNAL_STATUS_PATH: return sendJson(200, runtimeStatusPayload()); default: return sendJson(404, { error: 'not_found' }); } }); server.on('error', (error) => logger.error('internal status server error', error?.message ?? error)); server.listen(port, () => logger.info(`Internal status endpoint listening on :${port}${INTERNAL_STATUS_PATH}`)); internalServer = server; return internalServer; }; /** * Stops the internal status server if running. Returns a promise that resolves * once the listener is closed. Primarily a test/teardown hook. * @memberof RuntimeStatus * @returns {Promise<void>} */ const stopInternalStatusServer = () => new Promise((resolve) => { if (!internalServer) return resolve(); const server = internalServer; internalServer = undefined; server.close(() => resolve()); }); export { RUNTIME_STATUS, CONTAINER_STATUS_KEY, START_CONTAINER_STATUS_KEY, INTERNAL_STATUS_PATH, INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH, resolveInternalStatusPort, deployStatusPort, containerStatusValue, normalizeContainerStatus, getRuntimeStatus, getStartContainerStatus, runtimeStatusPayload, setRuntimeStatus, startInternalStatusServer, stopInternalStatusServer, };