#!/usr/bin/env node /** * Phoenix Deploy API — Gitea webhook receiver, deploy stub, 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 /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 { readFileSync } 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 httpsAgent = new https.Agent({ rejectUnauthorized: process.env.PROXMOX_TLS_VERIFY !== '0' }); 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: `PVEAPIToken=${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 (sha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, sha, 'pending', 'Phoenix deployment triggered'); } // Stub: enqueue deploy; actual implementation would call Proxmox/deploy logic console.log(`[deploy-stub] Would deploy ${fullName} branch=${branch} sha=${sha}`); // Stub: when full deploy runs, call setGiteaCommitStatus(owner, repoName, sha, 'success'|'failure', ...) } } 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; if (!repo) { return res.status(400).json({ error: 'repo required' }); } const [owner, repoName] = repo.includes('/') ? repo.split('/') : ['d-bis', repo]; const commitSha = sha || ''; if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus(owner, repoName, commitSha, 'pending', 'Phoenix deployment in progress'); } console.log(`[deploy] ${repo} branch=${branch} target=${target || 'default'} sha=${commitSha}`); // Stub: no real deploy yet — report success so Gitea shows green; replace with real deploy + setGiteaCommitStatus on completion const deploySuccess = true; if (commitSha && GITEA_TOKEN) { await setGiteaCommitStatus( owner, repoName, commitSha, deploySuccess ? 'success' : 'failure', deploySuccess ? 'Deploy accepted (stub)' : 'Deploy failed (stub)' ); } res.status(202).json({ status: 'accepted', repo, branch, target: target || 'default', message: 'Deploy request queued (stub). Implement full deploy logic in Sankofa Phoenix API.', }); if (PHOENIX_WEBHOOK_URL) { const payload = { event: 'deploy.completed', repo, branch, target: target || 'default', sha: commitSha, success: deploySuccess }; 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)); } }); 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 (PARTNER_KEYS.length > 0) console.log('Partner API key auth enabled for /api/v1/*'); });