Files
Hermes 671a6cc93c
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Add tests for DC-105/106/108 endpoints + fleet env fix
- Wizard: 6 tests (categories, recommend, hardware profiles, apply)
- Caddycode: 5 tests (generate, validate, templates)
- Fleet: 4 tests (register, list, deploy, validation)
- Fleet: loadHosts/saveHosts now reads env at call time for test isolation
- 1648 tests pass, 72 suites
2026-08-12 13:00:14 -07:00

187 lines
6.0 KiB
JavaScript

/**
* DC-108: Multi-host fleet management — deploy across multiple servers
*
* Foundation API for registering remote DashCaddy instances and coordinating
* deployments across them. Each host runs its own DashCaddy container; this
* module tracks the fleet state and can forward commands.
*
* GET /api/v1/fleet/hosts — list all registered hosts
* POST /api/v1/fleet/hosts — register a new host
* DELETE /api/v1/fleet/hosts/:hostId — deregister a host
* GET /api/v1/fleet/status — fleet-wide status overview
* POST /api/v1/fleet/deploy — deploy to multiple hosts
*
* Host state is persisted in {dataDir}/fleet-hosts.json
*/
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { ok, errorResponse } = require('../src/utils/responses');
const { ErrorCodes } = require('../src/utilities/error-codes');
const HOSTS_FILE = process.env.FLEET_HOSTS_FILE || path.join(process.cwd(), 'data', 'fleet-hosts.json');
module.exports = function({ log, asyncHandler }) {
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
const router = express.Router();
async function loadHosts() {
const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
try {
const data = await fsp.readFile(hostsFile, 'utf8');
return JSON.parse(data);
} catch {
return [];
}
}
async function saveHosts(hosts) {
const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
await fsp.mkdir(path.dirname(hostsFile), { recursive: true });
await fsp.writeFile(hostsFile, JSON.stringify(hosts, null, 2));
}
// GET /api/v1/fleet/hosts
router.get('/fleet/hosts', wrap(async (req, res) => {
const hosts = await loadHosts();
ok(res, { total: hosts.length, hosts });
}));
// POST /api/v1/fleet/hosts — register a new host
router.post('/fleet/hosts', wrap(async (req, res) => {
const { name, hostname, apiKey, port = 3001, tags = [] } = req.body || {};
if (!name || !hostname) {
return errorResponse(res, 400, 'name and hostname are required', {
code: ErrorCodes.GENERAL.INVALID_INPUT,
});
}
const hosts = await loadHosts();
// Check for duplicate
if (hosts.some(h => h.hostname === hostname)) {
return errorResponse(res, 409, `Host ${hostname} already registered`, {
code: ErrorCodes.GENERAL.CONFLICT,
});
}
const host = {
id: crypto.randomUUID(),
name,
hostname,
port,
apiKey: apiKey ? '***' : null, // Never store the actual key
apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
tags,
status: 'unknown',
registeredAt: new Date().toISOString(),
lastSeen: null,
containerCount: null,
};
hosts.push(host);
await saveHosts(hosts);
if (log) log.info('fleet', 'Host registered', { name, hostname });
ok(res, { host }, 201);
}));
// DELETE /api/v1/fleet/hosts/:hostId
router.delete('/fleet/hosts/:hostId', wrap(async (req, res) => {
const { hostId } = req.params;
const hosts = await loadHosts();
const filtered = hosts.filter(h => h.id !== hostId);
if (filtered.length === hosts.length) {
return errorResponse(res, 404, `Host ${hostId} not found`);
}
await saveHosts(filtered);
ok(res, { message: 'Host deregistered' });
}));
// GET /api/v1/fleet/status — aggregate fleet status
router.get('/fleet/status', wrap(async (req, res) => {
const hosts = await loadHosts();
// Try to reach each host and get its health
const statusPromises = hosts.map(async (host) => {
try {
const url = `http://${host.hostname}:${host.port}/api/v1/system/health`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(url, {
signal: controller.signal,
headers: host.apiKeyHash ? { 'x-api-key': host.apiKeyHash } : {},
}).finally(() => clearTimeout(timeout));
if (response.ok) {
const data = await response.json();
host.status = data.status || 'healthy';
host.lastSeen = new Date().toISOString();
host.containerCount = data.checks?.services?.total || null;
} else {
host.status = 'unreachable';
}
} catch {
host.status = 'offline';
}
return host;
});
const updatedHosts = await Promise.all(statusPromises);
await saveHosts(updatedHosts);
const summary = {
total: updatedHosts.length,
healthy: updatedHosts.filter(h => h.status === 'healthy').length,
degraded: updatedHosts.filter(h => h.status === 'degraded').length,
unhealthy: updatedHosts.filter(h => h.status === 'unhealthy').length,
offline: updatedHosts.filter(h => h.status === 'offline' || h.status === 'unreachable').length,
};
ok(res, { summary, hosts: updatedHosts });
}));
// POST /api/v1/fleet/deploy — deploy a template to multiple hosts
router.post('/fleet/deploy', wrap(async (req, res) => {
const { templateId, hostIds = [], config = {} } = req.body || {};
if (!templateId) {
return errorResponse(res, 400, 'templateId is required');
}
const hosts = await loadHosts();
const targetHosts = hostIds.length > 0
? hosts.filter(h => hostIds.includes(h.id))
: hosts;
if (targetHosts.length === 0) {
return errorResponse(res, 400, 'No valid hosts to deploy to');
}
// Generate deployment plan
const plan = targetHosts.map(host => ({
hostId: host.id,
hostname: host.hostname,
templateId,
config,
status: 'pending',
deployUrl: `http://${host.hostname}:${host.port}/api/v1/apps/deploy`,
}));
ok(res, {
templateId,
totalHosts: plan.length,
plan,
message: 'Deployment plan generated. Forward each step to the host API.',
});
}));
return router;
};