diff --git a/dashcaddy-api/routes/fleet.js b/dashcaddy-api/routes/fleet.js new file mode 100644 index 0000000..4dc4cb4 --- /dev/null +++ b/dashcaddy-api/routes/fleet.js @@ -0,0 +1,184 @@ +/** + * 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() { + try { + const data = await fsp.readFile(HOSTS_FILE, 'utf8'); + return JSON.parse(data); + } catch { + return []; + } + } + + async function saveHosts(hosts) { + await fsp.mkdir(path.dirname(HOSTS_FILE), { recursive: true }); + await fsp.writeFile(HOSTS_FILE, 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; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index fb110b9..4131b39 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -67,6 +67,7 @@ const catalogRoutes = require('../routes/catalog'); const wizardRoutes = require('../routes/wizard'); const disasterRoutes = require('../routes/disaster-recovery'); const caddycodeRoutes = require('../routes/caddycode'); +const fleetRoutes = require('../routes/fleet'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -647,6 +648,12 @@ async function createApp() { apiRouter.use(caddycodeRoutes({ asyncHandler: ctx.asyncHandler, })); + + // DC-108: Multi-host fleet management + apiRouter.use(fleetRoutes({ + log: ctx.log, + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,