/** * 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; };