chore: sync workspace — configs, docs, scripts, CI, pnpm, submodules
- 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
This commit is contained in:
@@ -4,6 +4,7 @@ PORT=4001
|
||||
GITEA_URL=https://gitea.d-bis.org
|
||||
GITEA_TOKEN=
|
||||
PHOENIX_DEPLOY_SECRET=
|
||||
PHOENIX_WEBHOOK_DEPLOY_ENABLED=0
|
||||
|
||||
# Proxmox (for Infra/VE API; omit for stub responses)
|
||||
PROXMOX_HOST=
|
||||
@@ -32,4 +33,6 @@ PHOENIX_PARTNER_KEYS=
|
||||
# Optional: override path for GET /api/v1/public-sector/programs (else bundled copy, repo config/, or ../config/)
|
||||
PUBLIC_SECTOR_MANIFEST_PATH=
|
||||
# Optional: proxmox repo root on host (manifest = $PHOENIX_REPO_ROOT/config/public-sector-program-manifest.json)
|
||||
PHOENIX_REPO_ROOT=
|
||||
PHOENIX_REPO_ROOT=/home/intlc/projects/proxmox
|
||||
# Optional: deploy target config file (defaults to phoenix-deploy-api/deploy-targets.json)
|
||||
DEPLOY_TARGETS_PATH=
|
||||
|
||||
@@ -28,7 +28,9 @@ npm start
|
||||
|
||||
```bash
|
||||
sudo nano /opt/phoenix-deploy-api/.env
|
||||
# Set GITEA_TOKEN=... and optionally PHOENIX_DEPLOY_SECRET
|
||||
# Set GITEA_TOKEN=..., PHOENIX_DEPLOY_SECRET=..., PHOENIX_REPO_ROOT=...
|
||||
# Optional: PHOENIX_WEBHOOK_DEPLOY_ENABLED=1 only for repos using webhook-triggered deploys
|
||||
# Optional: SANKOFA_PORTAL_SRC=/home/intlc/projects/Sankofa/portal for the portal-live target
|
||||
sudo systemctl restart phoenix-deploy-api
|
||||
```
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Phoenix Deploy API
|
||||
|
||||
Gitea webhook receiver and deploy endpoint stub for Gitea → Phoenix deployment integration.
|
||||
Gitea webhook receiver and deploy endpoint for Gitea → Phoenix / Proxmox deployment integration.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | /webhook/gitea | Receives Gitea push/tag/PR webhooks |
|
||||
| POST | /api/deploy | Deploy request (repo, branch, target) |
|
||||
| POST | /webhook/gitea | Receives Gitea push/tag/PR webhooks; executes deploys only when `PHOENIX_WEBHOOK_DEPLOY_ENABLED=1` |
|
||||
| POST | /api/deploy | Deploy request (repo, branch, target); resolves a target from `deploy-targets.json` and runs its command |
|
||||
| GET | /api/deploy-targets | Lists configured deploy targets and whether each has a health check |
|
||||
| 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=`) |
|
||||
@@ -31,10 +32,11 @@ 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 |
|
||||
| PHOENIX_WEBHOOK_DEPLOY_ENABLED | 0 | Set to 1 to allow `/webhook/gitea` to execute the default target on matching pushes |
|
||||
| PROXMOX_HOST | proxmox-api.d-bis.org | 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_NAME | | Proxmox API token name; bare token name preferred, full token id also accepted |
|
||||
| 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 |
|
||||
@@ -45,6 +47,7 @@ Copy `.env.example` to `.env` and set `GITEA_TOKEN` (and optionally `PHOENIX_DEP
|
||||
| PHOENIX_PARTNER_KEYS | | Comma-separated API keys for /api/v1/* (optional) |
|
||||
| PUBLIC_SECTOR_MANIFEST_PATH | | Override JSON path for `/api/v1/public-sector/programs` |
|
||||
| PHOENIX_REPO_ROOT | | Proxmox repo root; loads `config/public-sector-program-manifest.json` if present |
|
||||
| DEPLOY_TARGETS_PATH | | Override deploy target file; default is `phoenix-deploy-api/deploy-targets.json` |
|
||||
|
||||
**Program manifest:** From a full repo checkout, the file is `config/public-sector-program-manifest.json`. `scripts/install-systemd.sh` copies it next to `server.js` on `/opt/phoenix-deploy-api` so the endpoint works without a full tree.
|
||||
|
||||
@@ -52,11 +55,13 @@ Copy `.env.example` to `.env` and set `GITEA_TOKEN` (and optionally `PHOENIX_DEP
|
||||
|
||||
In Gitea: Repository → Settings → Webhooks → Add Webhook
|
||||
|
||||
- **URL:** `https://phoenix-api-host/api/webhook/gitea` (or your Phoenix API URL)
|
||||
- **URL:** `https://phoenix-api-host/webhook/gitea` (or your Phoenix API URL)
|
||||
- **Content type:** application/json
|
||||
- **Events:** Push events, Tag creation (and optionally Pull requests)
|
||||
- **Secret:** Optional, set PHOENIX_DEPLOY_SECRET to match
|
||||
|
||||
Use webhook-triggered deploys only for repos that are not already deploying through Gitea Actions, unless you intentionally want both paths.
|
||||
|
||||
## Deploy API (Trigger from Gitea Actions)
|
||||
|
||||
```bash
|
||||
@@ -66,9 +71,56 @@ curl -X POST "https://phoenix-api-host/api/deploy" \
|
||||
-d '{"repo":"d-bis/proxmox","branch":"main","sha":"abc123","target":"default"}'
|
||||
```
|
||||
|
||||
The API returns `404` when no matching deploy target exists for `{repo, branch, target}` and `500` when the target command fails.
|
||||
If a target defines `healthcheck`, the deploy is only marked successful after the post-deploy URL check passes.
|
||||
|
||||
## Deploy target configuration
|
||||
|
||||
Targets live in [`deploy-targets.json`](deploy-targets.json). Each entry maps a `{repo, branch, target}` tuple to a command array and working directory.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"repo": "d-bis/proxmox",
|
||||
"branch": "main",
|
||||
"target": "default",
|
||||
"cwd": "${PHOENIX_REPO_ROOT}",
|
||||
"command": ["bash", "scripts/deployment/deploy-phoenix-deploy-api-to-dev-vm.sh", "--apply", "--start-ct"],
|
||||
"required_env": ["PHOENIX_REPO_ROOT"]
|
||||
}
|
||||
```
|
||||
|
||||
Use this to promote different repos to different VMIDs or CTs by adding more entries.
|
||||
|
||||
Optional per-target health check:
|
||||
|
||||
```json
|
||||
{
|
||||
"healthcheck": {
|
||||
"url": "http://192.168.11.59:4001/health",
|
||||
"expect_status": 200,
|
||||
"expect_body_includes": "phoenix-deploy-api",
|
||||
"attempts": 8,
|
||||
"delay_ms": 3000,
|
||||
"timeout_ms": 10000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Manual smoke trigger
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
bash scripts/dev-vm/trigger-phoenix-deploy.sh
|
||||
```
|
||||
|
||||
This calls `/api/deploy` directly using `PHOENIX_DEPLOY_URL` and `PHOENIX_DEPLOY_TOKEN` from root `.env`.
|
||||
|
||||
## Integration with Sankofa Phoenix
|
||||
|
||||
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.
|
||||
This service is still standalone, but it now executes real target commands. If you later fold it into the Sankofa Phoenix API (VMID 8600), keep the same target-file pattern so repo-to-VM routing stays declarative.
|
||||
|
||||
## OpenAPI / Swagger
|
||||
|
||||
|
||||
101
phoenix-deploy-api/deploy-targets.json
Normal file
101
phoenix-deploy-api/deploy-targets.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"defaults": {
|
||||
"timeout_sec": 1800
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"repo": "d-bis/proxmox",
|
||||
"branch": "main",
|
||||
"target": "default",
|
||||
"description": "Deploy the Phoenix deploy API bundle to the dev VM on Proxmox.",
|
||||
"cwd": "${PHOENIX_REPO_ROOT}",
|
||||
"command": [
|
||||
"bash",
|
||||
"scripts/deployment/deploy-phoenix-deploy-api-to-dev-vm.sh",
|
||||
"--apply",
|
||||
"--start-ct"
|
||||
],
|
||||
"required_env": [
|
||||
"PHOENIX_REPO_ROOT"
|
||||
],
|
||||
"healthcheck": {
|
||||
"url": "http://192.168.11.59:4001/health",
|
||||
"expect_status": 200,
|
||||
"expect_body_includes": "phoenix-deploy-api",
|
||||
"attempts": 8,
|
||||
"delay_ms": 3000,
|
||||
"timeout_ms": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"repo": "d-bis/proxmox",
|
||||
"branch": "main",
|
||||
"target": "portal-live",
|
||||
"description": "Deploy the Sankofa portal to CT 7801 on Proxmox.",
|
||||
"cwd": "${PHOENIX_REPO_ROOT}",
|
||||
"command": [
|
||||
"bash",
|
||||
"scripts/deployment/sync-sankofa-portal-7801.sh"
|
||||
],
|
||||
"required_env": [
|
||||
"PHOENIX_REPO_ROOT",
|
||||
"SANKOFA_PORTAL_SRC"
|
||||
],
|
||||
"healthcheck": {
|
||||
"url": "http://192.168.11.51:3000/",
|
||||
"expect_status": 200,
|
||||
"expect_body_includes": "<html",
|
||||
"attempts": 10,
|
||||
"delay_ms": 5000,
|
||||
"timeout_ms": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"repo": "d-bis/proxmox",
|
||||
"branch": "master",
|
||||
"target": "default",
|
||||
"description": "Deploy the Phoenix deploy API bundle to the dev VM on Proxmox.",
|
||||
"cwd": "${PHOENIX_REPO_ROOT}",
|
||||
"command": [
|
||||
"bash",
|
||||
"scripts/deployment/deploy-phoenix-deploy-api-to-dev-vm.sh",
|
||||
"--apply",
|
||||
"--start-ct"
|
||||
],
|
||||
"required_env": [
|
||||
"PHOENIX_REPO_ROOT"
|
||||
],
|
||||
"healthcheck": {
|
||||
"url": "http://192.168.11.59:4001/health",
|
||||
"expect_status": 200,
|
||||
"expect_body_includes": "phoenix-deploy-api",
|
||||
"attempts": 8,
|
||||
"delay_ms": 3000,
|
||||
"timeout_ms": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"repo": "d-bis/proxmox",
|
||||
"branch": "master",
|
||||
"target": "portal-live",
|
||||
"description": "Deploy the Sankofa portal to CT 7801 on Proxmox.",
|
||||
"cwd": "${PHOENIX_REPO_ROOT}",
|
||||
"command": [
|
||||
"bash",
|
||||
"scripts/deployment/sync-sankofa-portal-7801.sh"
|
||||
],
|
||||
"required_env": [
|
||||
"PHOENIX_REPO_ROOT",
|
||||
"SANKOFA_PORTAL_SRC"
|
||||
],
|
||||
"healthcheck": {
|
||||
"url": "http://192.168.11.51:3000/",
|
||||
"expect_status": 200,
|
||||
"expect_body_includes": "<html",
|
||||
"attempts": 10,
|
||||
"delay_ms": 5000,
|
||||
"timeout_ms": 10000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Phoenix Deploy API / Phoenix API Railing
|
||||
description: |
|
||||
Gitea webhook, deploy stub, and Phoenix API Railing (Infra, VE, Health).
|
||||
Gitea webhook, deploy execution API, and Phoenix API Railing (Infra, VE, Health).
|
||||
Optional partner API key for /api/v1/* when PHOENIX_PARTNER_KEYS is set.
|
||||
version: 1.0.0
|
||||
|
||||
@@ -38,13 +38,16 @@ paths:
|
||||
post:
|
||||
tags: [Webhook]
|
||||
summary: Gitea webhook receiver
|
||||
description: |
|
||||
Validates webhook signatures when `PHOENIX_DEPLOY_SECRET` is set.
|
||||
Executes the default deploy target only when `PHOENIX_WEBHOOK_DEPLOY_ENABLED=1`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: object }
|
||||
responses:
|
||||
'200': { description: Accepted }
|
||||
'200': { description: Accepted or executed depending on webhook deploy mode }
|
||||
'400': { description: No payload }
|
||||
'401': { description: Invalid signature }
|
||||
|
||||
@@ -64,8 +67,17 @@ paths:
|
||||
target: { type: string }
|
||||
sha: { type: string }
|
||||
responses:
|
||||
'202': { description: Accepted }
|
||||
'200': { description: Deploy completed successfully }
|
||||
'401': { description: Unauthorized }
|
||||
'404': { description: No matching deploy target }
|
||||
'500': { description: Deploy command or health check failed }
|
||||
|
||||
/api/deploy-targets:
|
||||
get:
|
||||
tags: [Webhook]
|
||||
summary: List configured deploy targets
|
||||
responses:
|
||||
'200': { description: Target list }
|
||||
|
||||
/api/v1/public-sector/programs:
|
||||
get:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "phoenix-deploy-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Phoenix deploy API stub and Gitea webhook receiver for Gitea→Phoenix deployment integration",
|
||||
"description": "Phoenix deploy API and Gitea webhook receiver for Gitea to Phoenix and Proxmox deployment integration",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,7 +17,8 @@ fi
|
||||
|
||||
echo "Installing Phoenix Deploy API to $TARGET ..."
|
||||
mkdir -p "$TARGET"
|
||||
cp -a "$APP_DIR/server.js" "$APP_DIR/package.json" "$APP_DIR/package-lock.json" "$TARGET/" 2>/dev/null || cp -a "$APP_DIR/server.js" "$APP_DIR/package.json" "$TARGET/"
|
||||
cp -a "$APP_DIR/server.js" "$APP_DIR/package.json" "$APP_DIR/package-lock.json" "$APP_DIR/deploy-targets.json" "$TARGET/" 2>/dev/null || \
|
||||
cp -a "$APP_DIR/server.js" "$APP_DIR/package.json" "$APP_DIR/deploy-targets.json" "$TARGET/"
|
||||
# Program manifest for GET /api/v1/public-sector/programs (server loads from cwd-adjacent copy on /opt)
|
||||
if [[ -f "$REPO_ROOT/config/public-sector-program-manifest.json" ]]; then
|
||||
cp -a "$REPO_ROOT/config/public-sector-program-manifest.json" "$TARGET/public-sector-program-manifest.json"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phoenix Deploy API — Gitea webhook receiver, deploy stub, and Phoenix API Railing (Infra/VE)
|
||||
* 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
|
||||
@@ -19,6 +19,8 @@
|
||||
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';
|
||||
@@ -42,6 +44,13 @@ const PROMETHEUS_URL = (process.env.PROMETHEUS_URL || 'http://localhost:9090').r
|
||||
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:
|
||||
@@ -63,15 +72,249 @@ function resolvePublicSectorManifestPath() {
|
||||
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: `PVEAPIToken=${PROXMOX_USER}!${PROXMOX_TOKEN_NAME}=${PROXMOX_TOKEN_VALUE}`,
|
||||
Authorization: formatProxmoxAuthHeader(PROXMOX_USER, PROXMOX_TOKEN_NAME, PROXMOX_TOKEN_VALUE),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
agent: httpsAgent,
|
||||
@@ -162,12 +405,44 @@ app.post('/webhook/gitea', async (req, res) => {
|
||||
|
||||
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');
|
||||
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,
|
||||
});
|
||||
}
|
||||
// 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', ...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,47 +460,36 @@ app.post('/api/deploy', async (req, res) => {
|
||||
}
|
||||
|
||||
const { repo, branch = 'main', target, sha } = req.body;
|
||||
if (!repo) {
|
||||
return res.status(400).json({ error: 'repo required' });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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.',
|
||||
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,
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -474,7 +738,10 @@ app.listen(PORT, () => {
|
||||
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'})`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user