- Submodule pins: dbis_core, cross-chain-pmm-lps, mcp-proxmox (local, push may be pending), metamask-integration, smom-dbis-138 - Atomic swap + cross-chain-pmm-lops-publish, deploy-portal workflow, phoenix deploy-targets, routing/aggregator matrices - Docs, token-lists, forge proxy, phoenix API, runbooks, verify scripts Made-with: Cursor
748 lines
27 KiB
JavaScript
748 lines
27 KiB
JavaScript
#!/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=<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 (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'})`);
|
|
});
|