#!/usr/bin/env node /** * Phoenix Deploy API — Gitea webhook receiver, deploy execution API, and Phoenix API Railing (Infra/VE) * * Endpoints: * POST /webhook/gitea — Receives Gitea push/tag/PR webhooks * POST /api/deploy — Deploy request (repo, branch, target) * GET /api/v1/infra/nodes — Cluster nodes (Proxmox or stub) * GET /api/v1/infra/storage — Storage pools (Proxmox or stub) * GET /api/v1/ve/vms — List VMs/CTs (Proxmox or stub) * GET /api/v1/ve/vms/:node/:vmid/status — VM/CT status * GET /api/v1/public-sector/programs — Public-sector / eIDAS program manifest (JSON) * GET /health — Health check * * Env: PORT, GITEA_URL, GITEA_TOKEN, PHOENIX_DEPLOY_SECRET * PROXMOX_HOST, PROXMOX_PORT, PROXMOX_USER, PROXMOX_TOKEN_NAME, PROXMOX_TOKEN_VALUE (optional, for railing) */ import crypto from 'crypto'; import https from 'https'; import path from 'path'; import { promisify } from 'util'; import { execFile as execFileCallback } from 'child_process'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import express from 'express'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || '4001', 10); const GITEA_URL = (process.env.GITEA_URL || 'https://gitea.d-bis.org').replace(/\/$/, ''); const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; const WEBHOOK_SECRET = process.env.PHOENIX_DEPLOY_SECRET || ''; const PROXMOX_HOST = process.env.PROXMOX_HOST || ''; const PROXMOX_PORT = parseInt(process.env.PROXMOX_PORT || '8006', 10); const PROXMOX_USER = process.env.PROXMOX_USER || 'root@pam'; const PROXMOX_TOKEN_NAME = process.env.PROXMOX_TOKEN_NAME || ''; const PROXMOX_TOKEN_VALUE = process.env.PROXMOX_TOKEN_VALUE || ''; const hasProxmox = PROXMOX_HOST && PROXMOX_TOKEN_NAME && PROXMOX_TOKEN_VALUE; const VE_LIFECYCLE_ENABLED = process.env.PHOENIX_VE_LIFECYCLE_ENABLED === '1' || process.env.PHOENIX_VE_LIFECYCLE_ENABLED === 'true'; const PROMETHEUS_URL = (process.env.PROMETHEUS_URL || 'http://localhost:9090').replace(/\/$/, ''); const PHOENIX_WEBHOOK_URL = process.env.PHOENIX_WEBHOOK_URL || ''; const PHOENIX_WEBHOOK_SECRET = process.env.PHOENIX_WEBHOOK_SECRET || ''; const PARTNER_KEYS = (process.env.PHOENIX_PARTNER_KEYS || '').split(',').map((k) => k.trim()).filter(Boolean); const WEBHOOK_DEPLOY_ENABLED = process.env.PHOENIX_WEBHOOK_DEPLOY_ENABLED === '1' || process.env.PHOENIX_WEBHOOK_DEPLOY_ENABLED === 'true'; const execFile = promisify(execFileCallback); function expandEnvTokens(value) { if (typeof value !== 'string') return value; return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, key) => process.env[key] || ''); } /** * Manifest resolution order: * 1) PUBLIC_SECTOR_MANIFEST_PATH (explicit file) * 2) public-sector-program-manifest.json next to server.js (systemd install copies this) * 3) PHOENIX_REPO_ROOT or PROXMOX_REPO_PATH + config/public-sector-program-manifest.json * 4) ../config/... (running from phoenix-deploy-api inside full proxmox clone) */ function resolvePublicSectorManifestPath() { const override = (process.env.PUBLIC_SECTOR_MANIFEST_PATH || '').trim(); if (override && existsSync(override)) return override; const bundled = path.join(__dirname, 'public-sector-program-manifest.json'); if (existsSync(bundled)) return bundled; const repoRoot = (process.env.PHOENIX_REPO_ROOT || process.env.PROXMOX_REPO_PATH || '').trim().replace(/\/$/, ''); if (repoRoot) { const fromRepo = path.join(repoRoot, 'config', 'public-sector-program-manifest.json'); if (existsSync(fromRepo)) return fromRepo; } return path.join(__dirname, '..', 'config', 'public-sector-program-manifest.json'); } function resolveDeployTargetsPath() { const override = (process.env.DEPLOY_TARGETS_PATH || '').trim(); if (override && existsSync(override)) return override; const bundled = path.join(__dirname, 'deploy-targets.json'); if (existsSync(bundled)) return bundled; return bundled; } function loadDeployTargetsConfig() { const configPath = resolveDeployTargetsPath(); if (!existsSync(configPath)) { return { path: configPath, defaults: {}, targets: [], }; } const raw = readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw); return { path: configPath, defaults: parsed.defaults || {}, targets: Array.isArray(parsed.targets) ? parsed.targets : [], }; } function findDeployTarget(repo, branch, requestedTarget) { const config = loadDeployTargetsConfig(); const wantedTarget = requestedTarget || 'default'; const match = config.targets.find((entry) => { if (entry.repo !== repo) return false; if ((entry.branch || 'main') !== branch) return false; return (entry.target || 'default') === wantedTarget; }); return { config, match, wantedTarget }; } async function sleep(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } async function verifyHealthCheck(healthcheck) { if (!healthcheck || !healthcheck.url) return null; const attempts = Math.max(1, Number(healthcheck.attempts || 1)); const delayMs = Math.max(0, Number(healthcheck.delay_ms || 0)); const timeoutMs = Math.max(1000, Number(healthcheck.timeout_ms || 10000)); const expectedStatus = Number(healthcheck.expect_status || 200); const expectBodyIncludes = healthcheck.expect_body_includes || ''; let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const res = await fetch(healthcheck.url, { signal: controller.signal }); const body = await res.text(); clearTimeout(timeout); if (res.status !== expectedStatus) { throw new Error(`Expected HTTP ${expectedStatus}, got ${res.status}`); } if (expectBodyIncludes && !body.includes(expectBodyIncludes)) { throw new Error(`Health body missing expected text: ${expectBodyIncludes}`); } return { ok: true, url: healthcheck.url, status: res.status, attempt, }; } catch (err) { lastError = err; if (attempt < attempts && delayMs > 0) { await sleep(delayMs); } } } throw new Error(`Health check failed for ${healthcheck.url}: ${lastError?.message || 'unknown error'}`); } async function runDeployTarget(definition, configDefaults, context) { if (!Array.isArray(definition.command) || definition.command.length === 0) { throw new Error('Deploy target is missing a command array'); } const cwd = expandEnvTokens(definition.cwd || configDefaults.cwd || process.cwd()); const timeoutSeconds = Number(definition.timeout_sec || configDefaults.timeout_sec || 1800); const timeout = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 1800 * 1000; const command = definition.command.map((part) => expandEnvTokens(part)); const missingEnv = (definition.required_env || []).filter((key) => !process.env[key]); if (missingEnv.length > 0) { throw new Error(`Missing required env for deploy target: ${missingEnv.join(', ')}`); } if (!existsSync(cwd)) { throw new Error(`Deploy working directory does not exist: ${cwd}`); } const childEnv = { ...process.env, PHOENIX_DEPLOY_REPO: context.repo, PHOENIX_DEPLOY_BRANCH: context.branch, PHOENIX_DEPLOY_SHA: context.sha || '', PHOENIX_DEPLOY_TARGET: context.target, PHOENIX_DEPLOY_TRIGGER: context.trigger, }; const { stdout, stderr } = await execFile(command[0], command.slice(1), { cwd, env: childEnv, timeout, maxBuffer: 10 * 1024 * 1024, }); const healthcheck = await verifyHealthCheck(definition.healthcheck || configDefaults.healthcheck || null); return { cwd, command, stdout: stdout || '', stderr: stderr || '', timeout_sec: timeoutSeconds, healthcheck, }; } async function executeDeploy({ repo, branch = 'main', target = 'default', sha = '', trigger = 'api' }) { if (!repo) { const error = new Error('repo required'); error.statusCode = 400; error.payload = { error: error.message }; throw error; } const [owner, repoName] = repo.includes('/') ? repo.split('/') : ['d-bis', repo]; const commitSha = sha || ''; const requestedTarget = target || 'default'; const { config, match, wantedTarget } = findDeployTarget(repo, branch, requestedTarget); if (!match) { const error = new Error('Deploy target not configured'); error.statusCode = 404; error.payload = { error: error.message, repo, branch, target: wantedTarget, config_path: config.path, }; if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, commitSha, 'failure', `No deploy target for ${repo} ${branch} ${wantedTarget}`); } throw error; } if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, commitSha, 'pending', 'Phoenix deployment in progress'); } console.log(`[deploy] ${repo} branch=${branch} target=${wantedTarget} sha=${commitSha} trigger=${trigger}`); let deployResult = null; let deployError = null; try { deployResult = await runDeployTarget(match, config.defaults, { repo, branch, sha: commitSha, target: wantedTarget, trigger, }); if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, commitSha, 'success', `Deployed to ${wantedTarget}`); } return { status: 'completed', repo, branch, target: wantedTarget, config_path: config.path, command: deployResult.command, cwd: deployResult.cwd, stdout: deployResult.stdout, stderr: deployResult.stderr, healthcheck: deployResult.healthcheck, }; } catch (err) { deployError = err; if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, commitSha, 'failure', `Deploy failed: ${err.message.slice(0, 120)}`); } err.statusCode = err.statusCode || 500; err.payload = err.payload || { error: err.message, repo, branch, target: wantedTarget, config_path: config.path, }; throw err; } finally { if (PHOENIX_WEBHOOK_URL) { const payload = { event: 'deploy.completed', repo, branch, target: wantedTarget, sha: commitSha, success: Boolean(deployResult), command: deployResult?.command, cwd: deployResult?.cwd, error: deployError?.message || null, }; const body = JSON.stringify(payload); const sig = crypto.createHmac('sha256', PHOENIX_WEBHOOK_SECRET || '').update(body).digest('hex'); fetch(PHOENIX_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Phoenix-Signature': `sha256=${sig}` }, body, }).catch((e) => console.error('[webhook] outbound failed', e.message)); } } } const httpsAgent = new https.Agent({ rejectUnauthorized: process.env.PROXMOX_TLS_VERIFY !== '0' }); function formatProxmoxAuthHeader(user, tokenName, tokenValue) { if (tokenName.includes('!')) { return `PVEAPIToken=${tokenName}=${tokenValue}`; } return `PVEAPIToken=${user}!${tokenName}=${tokenValue}`; } async function proxmoxRequest(endpoint, method = 'GET', body = null) { const baseUrl = `https://${PROXMOX_HOST}:${PROXMOX_PORT}/api2/json`; const url = `${baseUrl}${endpoint}`; const options = { method, headers: { Authorization: formatProxmoxAuthHeader(PROXMOX_USER, PROXMOX_TOKEN_NAME, PROXMOX_TOKEN_VALUE), 'Content-Type': 'application/json', }, agent: httpsAgent, }; if (body && method !== 'GET') options.body = JSON.stringify(body); const res = await fetch(url, options); if (!res.ok) throw new Error(`Proxmox API ${res.status}: ${await res.text()}`); const data = await res.json(); return data.data; } const app = express(); // Keep raw body for webhook HMAC verification (Gitea uses HMAC-SHA256 of body) app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); /** Optional: require partner API key for /api/v1/* read-only routes when PHOENIX_PARTNER_KEYS is set */ function partnerKeyMiddleware(req, res, next) { if (PARTNER_KEYS.length === 0) return next(); const key = req.headers['x-api-key'] || (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (!key || !PARTNER_KEYS.includes(key)) { return res.status(401).json({ error: 'Missing or invalid API key' }); } next(); } /** * Update Gitea commit status (pending/success/failure) */ async function setGiteaCommitStatus(owner, repo, sha, state, description, targetUrl = '') { if (!GITEA_TOKEN) return; const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/statuses/${sha}`; const body = { state, description, context: 'phoenix-deploy', target_url: targetUrl || undefined }; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `token ${GITEA_TOKEN}`, }, body: JSON.stringify(body), }); if (!res.ok) { console.error(`Gitea status failed: ${res.status} ${await res.text()}`); } } /** * POST /webhook/gitea — Gitea webhook receiver * Supports: push, tag, pull_request */ app.post('/webhook/gitea', async (req, res) => { const payload = req.body; if (!payload) { return res.status(400).json({ error: 'No payload' }); } // Validate X-Gitea-Signature or X-Gogs-Signature (HMAC-SHA256 of raw body, hex) if (WEBHOOK_SECRET) { const sig = req.headers['x-gitea-signature'] || req.headers['x-gogs-signature']; if (!sig) { return res.status(401).json({ error: 'Missing webhook signature' }); } const raw = req.rawBody || Buffer.from(JSON.stringify(payload)); const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex'); const sigNormalized = String(sig).replace(/^sha256=/, '').trim(); const expectedBuf = Buffer.from(expected, 'hex'); const sigBuf = Buffer.from(sigNormalized, 'hex'); if (expectedBuf.length !== sigBuf.length || !crypto.timingSafeEqual(expectedBuf, sigBuf)) { return res.status(401).json({ error: 'Invalid webhook signature' }); } } const action = payload.action || (payload.ref ? 'push' : null); const ref = payload.ref || ''; const repo = payload.repository; if (!repo) { return res.status(400).json({ error: 'No repository in payload' }); } const ownerObj = repo.owner || {}; const fullName = repo.full_name || `${ownerObj.username || ownerObj.login || 'unknown'}/${repo.name || 'repo'}`; const [owner, repoName] = fullName.split('/'); const branch = ref.replace('refs/heads/', '').replace('refs/tags/', ''); const pr = payload.pull_request || {}; const head = pr.head || {}; const sha = payload.after || (payload.sender && payload.sender.sha) || head.sha || ''; console.log(`[webhook] ${action || 'push'} ${fullName} ${branch} ${sha}`); if (action === 'push' || (action === 'synchronize' && payload.pull_request)) { if (branch === 'main' || branch === 'master' || ref.startsWith('refs/tags/')) { if (!WEBHOOK_DEPLOY_ENABLED) { return res.status(200).json({ received: true, repo: fullName, branch, sha, deployed: false, message: 'Webhook accepted; set PHOENIX_WEBHOOK_DEPLOY_ENABLED=1 to execute deploys from webhook events.', }); } try { const result = await executeDeploy({ repo: fullName, branch, sha, target: 'default', trigger: 'webhook', }); return res.status(200).json({ received: true, repo: fullName, branch, sha, deployed: true, result, }); } catch (err) { return res.status(200).json({ received: true, repo: fullName, branch, sha, deployed: false, error: err.message, details: err.payload || null, }); } } } res.status(200).json({ received: true, repo: fullName, branch, sha }); }); /** * POST /api/deploy — Deploy endpoint * Body: { repo, branch?, target?, sha? } */ app.post('/api/deploy', async (req, res) => { const auth = req.headers.authorization; if (WEBHOOK_SECRET && auth !== `Bearer ${WEBHOOK_SECRET}`) { return res.status(401).json({ error: 'Unauthorized' }); } const { repo, branch = 'main', target, sha } = req.body; try { const result = await executeDeploy({ repo, branch, sha, target, trigger: 'api', }); res.status(200).json(result); } catch (err) { res.status(err.statusCode || 500).json(err.payload || { error: err.message }); } }); app.get('/api/deploy-targets', (req, res) => { const config = loadDeployTargetsConfig(); const targets = config.targets.map((entry) => ({ repo: entry.repo, branch: entry.branch || 'main', target: entry.target || 'default', description: entry.description || '', cwd: entry.cwd || config.defaults.cwd || '', command: entry.command || [], has_healthcheck: Boolean(entry.healthcheck || config.defaults.healthcheck), })); res.json({ config_path: config.path, count: targets.length, targets, }); }); /** * GET /api/v1/public-sector/programs — Program registry for operators / Phoenix UI (non-secret). * Registered before partnerKeyMiddleware so callers do not need X-API-Key. */ app.get('/api/v1/public-sector/programs', (req, res) => { const manifestPath = resolvePublicSectorManifestPath(); try { if (!existsSync(manifestPath)) { return res.status(503).json({ error: 'Manifest not found', path: manifestPath, hint: 'Set PUBLIC_SECTOR_MANIFEST_PATH or deploy repo with config/public-sector-program-manifest.json next to phoenix-deploy-api/', }); } const raw = readFileSync(manifestPath, 'utf8'); const data = JSON.parse(raw); res.type('application/json').json(data); } catch (err) { res.status(500).json({ error: err.message, path: manifestPath }); } }); app.use('/api/v1', partnerKeyMiddleware); /** * GET /api/v1/infra/nodes — Cluster nodes (Phoenix API Railing) */ app.get('/api/v1/infra/nodes', async (req, res) => { try { if (!hasProxmox) { return res.json({ nodes: [], stub: true, message: 'Set PROXMOX_HOST, PROXMOX_TOKEN_NAME, PROXMOX_TOKEN_VALUE for live data' }); } const nodes = await proxmoxRequest('/cluster/resources?type=node'); res.json({ nodes: nodes || [], stub: false }); } catch (err) { res.status(502).json({ error: err.message, stub: false }); } }); /** * GET /api/v1/infra/storage — Storage pools per node (Phoenix API Railing) */ app.get('/api/v1/infra/storage', async (req, res) => { try { if (!hasProxmox) { return res.json({ storage: [], stub: true, message: 'Set PROXMOX_* env for live data' }); } const storage = await proxmoxRequest('/storage'); res.json({ storage: storage || [], stub: false }); } catch (err) { res.status(502).json({ error: err.message, stub: false }); } }); /** * GET /api/v1/ve/vms — List VMs and CTs (Phoenix API Railing). Query: node (optional) */ app.get('/api/v1/ve/vms', async (req, res) => { try { if (!hasProxmox) { return res.json({ vms: [], stub: true, message: 'Set PROXMOX_* env for live data' }); } const resources = await proxmoxRequest('/cluster/resources?type=vm'); const node = (req.query.node || '').toString(); let list = Array.isArray(resources) ? resources : []; if (node) list = list.filter((v) => v.node === node); res.json({ vms: list, stub: false }); } catch (err) { res.status(502).json({ error: err.message, stub: false }); } }); /** * GET /api/v1/ve/vms/:node/:vmid/status — VM/CT status (Phoenix API Railing) */ app.get('/api/v1/ve/vms/:node/:vmid/status', async (req, res) => { const { node, vmid } = req.params; try { if (!hasProxmox) { return res.json({ node, vmid, status: 'unknown', stub: true }); } const type = (req.query.type || 'qemu').toString(); const path = type === 'lxc' ? `/nodes/${node}/lxc/${vmid}/status/current` : `/nodes/${node}/qemu/${vmid}/status/current`; const status = await proxmoxRequest(path); res.json({ node, vmid, type, ...status, stub: false }); } catch (err) { res.status(502).json({ error: err.message, node, vmid, stub: false }); } }); /** * POST /api/v1/ve/vms/:node/:vmid/start|stop|reboot — VM/CT lifecycle (optional; set PHOENIX_VE_LIFECYCLE_ENABLED=1) */ ['start', 'stop', 'reboot'].forEach((action) => { app.post(`/api/v1/ve/vms/:node/:vmid/${action}`, async (req, res) => { if (!VE_LIFECYCLE_ENABLED) { return res.status(403).json({ error: 'VM lifecycle is disabled (set PHOENIX_VE_LIFECYCLE_ENABLED=1)' }); } const { node, vmid } = req.params; const type = (req.query.type || 'qemu').toString(); try { if (!hasProxmox) { return res.status(502).json({ error: 'Proxmox not configured' }); } const path = type === 'lxc' ? `/nodes/${node}/lxc/${vmid}/status/${action}` : `/nodes/${node}/qemu/${vmid}/status/${action}`; await proxmoxRequest(path, 'POST'); res.json({ node, vmid, type, action, ok: true }); } catch (err) { res.status(502).json({ error: err.message, node, vmid, action }); } }); }); /** * GET /api/v1/health/metrics?query= — Proxy to Prometheus instant query */ app.get('/api/v1/health/metrics', async (req, res) => { const query = (req.query.query || '').toString(); if (!query) { return res.status(400).json({ error: 'query parameter required' }); } try { const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`; const data = await fetch(url).then((r) => r.json()); res.json(data); } catch (err) { res.status(502).json({ error: err.message }); } }); /** * GET /api/v1/health/alerts — Active alerts (stub or Alertmanager; optional PROMETHEUS_ALERTS_URL) * Optional: POST to PHOENIX_ALERT_WEBHOOK_URL when alerts exist (partner notification). */ const PHOENIX_ALERT_WEBHOOK_URL = process.env.PHOENIX_ALERT_WEBHOOK_URL || ''; const PHOENIX_ALERT_WEBHOOK_SECRET = process.env.PHOENIX_ALERT_WEBHOOK_SECRET || ''; app.get('/api/v1/health/alerts', async (req, res) => { const alertsUrl = process.env.PROMETHEUS_ALERTS_URL || `${PROMETHEUS_URL}/api/v1/alerts`; try { const data = await fetch(alertsUrl).then((r) => r.json()).catch(() => ({ data: { alerts: [] } })); const alerts = data.data?.alerts ?? data.alerts ?? []; const payload = { alerts: Array.isArray(alerts) ? alerts : [], stub: !process.env.PROMETHEUS_URL }; res.json(payload); if (PHOENIX_ALERT_WEBHOOK_URL && payload.alerts.length > 0) { const body = JSON.stringify({ event: 'alerts.fired', alerts: payload.alerts, at: new Date().toISOString() }); const sig = PHOENIX_ALERT_WEBHOOK_SECRET ? crypto.createHmac('sha256', PHOENIX_ALERT_WEBHOOK_SECRET).update(body).digest('hex') : ''; fetch(PHOENIX_ALERT_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(sig && { 'X-Phoenix-Signature': `sha256=${sig}` }) }, body, }).catch((e) => console.error('[alert-webhook]', e.message)); } } catch (err) { res.json({ alerts: [], stub: true, message: err.message }); } }); /** * GET /api/v1/health/summary — Aggregated health for Portal */ app.get('/api/v1/health/summary', async (req, res) => { const summary = { status: 'healthy', updated_at: new Date().toISOString(), hosts: [], alerts: [] }; try { if (hasProxmox) { const nodes = await proxmoxRequest('/cluster/resources?type=node').catch(() => []); summary.hosts = (nodes || []).map((n) => ({ instance: n.node, status: n.status, cpu: n.cpu ? Number(n.cpu) * 100 : null, mem: n.mem ? Number(n.mem) * 100 : null, })); } const alertsUrl = process.env.PROMETHEUS_ALERTS_URL || `${PROMETHEUS_URL}/api/v1/alerts`; const alertsRes = await fetch(alertsUrl).then((r) => r.ok ? r.json() : {}).catch(() => ({})); const alerts = alertsRes.data?.alerts ?? alertsRes.alerts ?? []; summary.alerts = (alerts || []).slice(0, 20).map((a) => ({ name: a.labels?.alertname, severity: a.labels?.severity, instance: a.labels?.instance })); if (summary.alerts.some((a) => a.severity === 'critical')) summary.status = 'critical'; else if (summary.alerts.length > 0) summary.status = 'degraded'; res.json(summary); } catch (err) { summary.status = 'unknown'; summary.message = err.message; res.json(summary); } }); /** * GET /health — Health check */ app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'phoenix-deploy-api' }); }); /** * GET /api-docs/spec.yaml — OpenAPI spec for Swagger UI */ app.get('/api-docs/spec.yaml', (req, res) => { try { const specPath = path.join(__dirname, 'openapi.yaml'); res.type('application/yaml').send(readFileSync(specPath, 'utf8')); } catch (e) { res.status(500).send('openapi.yaml not found'); } }); /** * GET /api-docs — Swagger UI (interactive API docs) */ app.get('/api-docs', (req, res) => { const base = `${req.protocol}://${req.get('host')}`; res.type('text/html').send(` Phoenix Deploy API — OpenAPI
`); }); app.listen(PORT, () => { console.log(`Phoenix Deploy API listening on port ${PORT}`); console.log(`Swagger UI: http://localhost:${PORT}/api-docs`); if (!GITEA_TOKEN) console.warn('GITEA_TOKEN not set — commit status updates disabled'); if (!hasProxmox) console.warn('PROXMOX_* not set — Infra/VE API returns stub data'); if (PHOENIX_WEBHOOK_URL) console.log('Outbound webhook enabled:', PHOENIX_WEBHOOK_URL); if (WEBHOOK_DEPLOY_ENABLED) console.log('Inbound webhook deploy execution enabled'); if (PARTNER_KEYS.length > 0) console.log('Partner API key auth enabled for /api/v1/* (except GET /api/v1/public-sector/programs)'); const mpath = resolvePublicSectorManifestPath(); const dpath = resolveDeployTargetsPath(); console.log(`Public-sector manifest: ${mpath} (${existsSync(mpath) ? 'ok' : 'missing'})`); console.log(`Deploy targets: ${dpath} (${existsSync(dpath) ? 'ok' : 'missing'})`); });