Phoenix Deploy API: OpenAPI spec, Swagger UI at /api-docs, railing and health routes
Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
Some checks failed
Deploy to Phoenix / deploy (push) Has been cancelled
- openapi.yaml, docs/index.html; GET /api-docs and /api-docs/spec.yaml - Health, Infra, VE, VM lifecycle, Prometheus proxy, webhook - docs/00-meta/SANKOFA_API_COMPLETE_TASK_LIST.md Made-with: Cursor
This commit is contained in:
@@ -8,8 +8,18 @@ Gitea webhook receiver and deploy endpoint stub for Gitea → Phoenix deployment
|
||||
|--------|------|-------------|
|
||||
| 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; stub if PROXMOX_* unset) |
|
||||
| GET | /api/v1/infra/storage | Storage pools (Proxmox; stub if unset) |
|
||||
| GET | /api/v1/ve/vms | List VMs/CTs (optional `?node=`) |
|
||||
| GET | /api/v1/ve/vms/:node/:vmid/status | VM/CT status (`?type=lxc` for containers) |
|
||||
| POST | /api/v1/ve/vms/:node/:vmid/start, stop, reboot | VM/CT lifecycle (set PHOENIX_VE_LIFECYCLE_ENABLED=1) |
|
||||
| GET | /api/v1/health/metrics | Prometheus query proxy (`?query=<PromQL>`) |
|
||||
| GET | /api/v1/health/alerts | Active alerts (optional PROMETHEUS_ALERTS_URL) |
|
||||
| GET | /api/v1/health/summary | Aggregated health for Portal |
|
||||
| GET | /health | Health check |
|
||||
|
||||
All `/api/v1/*` routes accept optional partner API key when `PHOENIX_PARTNER_KEYS` is set (`X-API-Key` or `Authorization: Bearer <key>`).
|
||||
|
||||
## Environment
|
||||
|
||||
Copy `.env.example` to `.env` and set `GITEA_TOKEN` (and optionally `PHOENIX_DEPLOY_SECRET`).
|
||||
@@ -20,6 +30,18 @@ Copy `.env.example` to `.env` and set `GITEA_TOKEN` (and optionally `PHOENIX_DEP
|
||||
| GITEA_URL | https://gitea.d-bis.org | Gitea instance URL |
|
||||
| GITEA_TOKEN | | Token for commit status API |
|
||||
| PHOENIX_DEPLOY_SECRET | | Optional secret for webhook/deploy auth |
|
||||
| PROXMOX_HOST | | Proxmox host (IP or hostname) for API Railing |
|
||||
| PROXMOX_PORT | 8006 | Proxmox API port |
|
||||
| PROXMOX_USER | root@pam | Proxmox API user |
|
||||
| PROXMOX_TOKEN_NAME | | Proxmox API token name |
|
||||
| PROXMOX_TOKEN_VALUE | | Proxmox API token secret |
|
||||
| PROXMOX_TLS_VERIFY | 1 | Set to 0 to allow self-signed Proxmox certs |
|
||||
| PHOENIX_VE_LIFECYCLE_ENABLED | 0 | Set to 1 to enable VM/CT start/stop/reboot |
|
||||
| PROMETHEUS_URL | http://localhost:9090 | Prometheus base URL for Health API |
|
||||
| PROMETHEUS_ALERTS_URL | (PROMETHEUS_URL)/api/v1/alerts | Optional; use Alertmanager URL for firing alerts |
|
||||
| PHOENIX_WEBHOOK_URL | | Outbound webhook URL; POST deploy events with X-Phoenix-Signature |
|
||||
| PHOENIX_WEBHOOK_SECRET | | Secret to sign webhook payloads (HMAC-SHA256) |
|
||||
| PHOENIX_PARTNER_KEYS | | Comma-separated API keys for /api/v1/* (optional) |
|
||||
|
||||
## Gitea Webhook Configuration
|
||||
|
||||
@@ -43,6 +65,11 @@ curl -X POST "https://phoenix-api-host/api/deploy" \
|
||||
|
||||
This service is a standalone stub. Full deployment logic should be implemented in the Sankofa Phoenix API (VMID 8600). Migrate the webhook handler and deploy logic into the Phoenix API when ready.
|
||||
|
||||
## OpenAPI / Swagger
|
||||
|
||||
- **Spec:** [openapi.yaml](openapi.yaml)
|
||||
- **HTML doc:** [docs/index.html](docs/index.html) — static Swagger UI; open locally or serve from `phoenix-deploy-api/docs/` (loads `../openapi.yaml`). To serve in-app, add `swagger-ui-express` and mount at e.g. `/api-docs`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
|
||||
26
phoenix-deploy-api/docs/index.html
Normal file
26
phoenix-deploy-api/docs/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!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: '../openapi.yaml',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: 'BaseLayout'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
256
phoenix-deploy-api/openapi.yaml
Normal file
256
phoenix-deploy-api/openapi.yaml
Normal file
@@ -0,0 +1,256 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Phoenix Deploy API / Phoenix API Railing
|
||||
description: |
|
||||
Gitea webhook, deploy stub, and Phoenix API Railing (Infra, VE, Health).
|
||||
Optional partner API key for /api/v1/* when PHOENIX_PARTNER_KEYS is set.
|
||||
version: 1.0.0
|
||||
|
||||
servers:
|
||||
- url: http://localhost:4001
|
||||
description: Default
|
||||
|
||||
tags:
|
||||
- name: Webhook
|
||||
- name: Infra
|
||||
- name: VE
|
||||
- name: Health
|
||||
- name: System
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Health check
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string }
|
||||
service: { type: string }
|
||||
|
||||
/webhook/gitea:
|
||||
post:
|
||||
tags: [Webhook]
|
||||
summary: Gitea webhook receiver
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: object }
|
||||
responses:
|
||||
'200': { description: Accepted }
|
||||
'400': { description: No payload }
|
||||
'401': { description: Invalid signature }
|
||||
|
||||
/api/deploy:
|
||||
post:
|
||||
tags: [Webhook]
|
||||
summary: Deploy request
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [repo]
|
||||
properties:
|
||||
repo: { type: string }
|
||||
branch: { type: string }
|
||||
target: { type: string }
|
||||
sha: { type: string }
|
||||
responses:
|
||||
'202': { description: Accepted }
|
||||
'401': { description: Unauthorized }
|
||||
|
||||
/api/v1/infra/nodes:
|
||||
get:
|
||||
tags: [Infra]
|
||||
summary: List cluster nodes
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
nodes: { type: array }
|
||||
stub: { type: boolean }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/infra/storage:
|
||||
get:
|
||||
tags: [Infra]
|
||||
summary: List storage pools
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
storage: { type: array }
|
||||
stub: { type: boolean }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/ve/vms:
|
||||
get:
|
||||
tags: [VE]
|
||||
summary: List VMs/CTs
|
||||
parameters:
|
||||
- name: node
|
||||
in: query
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
vms: { type: array }
|
||||
stub: { type: boolean }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/ve/vms/{node}/{vmid}/status:
|
||||
get:
|
||||
tags: [VE]
|
||||
summary: VM/CT status
|
||||
parameters:
|
||||
- name: node
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: vmid
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: type
|
||||
in: query
|
||||
schema: { type: string, enum: [qemu, lxc], default: qemu }
|
||||
responses:
|
||||
'200': { description: Status object }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/ve/vms/{node}/{vmid}/start:
|
||||
post:
|
||||
tags: [VE]
|
||||
summary: Start VM/CT
|
||||
parameters:
|
||||
- name: node
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: vmid
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: type
|
||||
in: query
|
||||
schema: { type: string, enum: [qemu, lxc], default: qemu }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
'403': { description: Lifecycle disabled }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/ve/vms/{node}/{vmid}/stop:
|
||||
post:
|
||||
tags: [VE]
|
||||
summary: Stop VM/CT
|
||||
parameters:
|
||||
- name: node
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: vmid
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: type
|
||||
in: query
|
||||
schema: { type: string, enum: [qemu, lxc], default: qemu }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
'403': { description: Lifecycle disabled }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/ve/vms/{node}/{vmid}/reboot:
|
||||
post:
|
||||
tags: [VE]
|
||||
summary: Reboot VM/CT
|
||||
parameters:
|
||||
- name: node
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: vmid
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: type
|
||||
in: query
|
||||
schema: { type: string, enum: [qemu, lxc], default: qemu }
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
'403': { description: Lifecycle disabled }
|
||||
'502': { description: Proxmox error }
|
||||
|
||||
/api/v1/health/metrics:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: Prometheus query proxy
|
||||
parameters:
|
||||
- name: query
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
description: PromQL (URL-encoded)
|
||||
responses:
|
||||
'200': { description: Prometheus response }
|
||||
'400': { description: Missing query }
|
||||
'502': { description: Prometheus unreachable }
|
||||
|
||||
/api/v1/health/alerts:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: Active alerts
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
alerts: { type: array }
|
||||
stub: { type: boolean }
|
||||
|
||||
/api/v1/health/summary:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: Aggregated health for Portal
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string }
|
||||
updated_at: { type: string, format: date-time }
|
||||
hosts: { type: array }
|
||||
alerts: { type: array }
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: PHOENIX_DEPLOY_SECRET for /api/deploy
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
description: Optional partner key (or Authorization Bearer)
|
||||
@@ -1,26 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phoenix Deploy API — Gitea webhook receiver and deploy endpoint stub
|
||||
* 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)
|
||||
* 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)
|
||||
*/
|
||||
@@ -139,6 +194,170 @@ app.post('/api/deploy', async (req, res) => {
|
||||
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)
|
||||
*/
|
||||
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 ?? [];
|
||||
res.json({ alerts: Array.isArray(alerts) ? alerts : [], stub: !process.env.PROMETHEUS_URL });
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -148,7 +367,55 @@ 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/*');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user