Files
proxmox/phoenix-deploy-api/server.js
defiQUG c05aa50e27
Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
Phoenix Deploy API: alert webhook, .env.example, Site24x7 and E2E docs
- PHOENIX_ALERT_WEBHOOK_URL: POST on alerts when firing
- .env.example: PROXMOX_*, PROMETHEUS_*, webhooks, partner keys
- PHOENIX_SITE24X7_API_KEYS.md: how to issue API keys for Site24x7
- PHOENIX_E2E_PORTAL_API_RAILING.md: E2E test steps and references

Made-with: Cursor
2026-03-11 13:00:53 -07:00

436 lines
17 KiB
JavaScript

#!/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=<PromQL> — 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(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Phoenix Deploy API — OpenAPI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '${base}/api-docs/spec.yaml',
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: 'BaseLayout'
});
};
</script>
</body>
</html>
`);
});
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/*');
});