Add fleet control: service configs, replica floors, monitor, pause/restart
Some checks are pending
offline / test (push) Waiting to run

Central fleet.json sets min/max copies. Tree-node keepFloor respawns
until three healthy unpaused replicas remain. CLI and loopback UI
pause, resume, stop, and restart instances that fail health checks.
This commit is contained in:
George Lambert 2026-09-11 13:09:55 -04:00
parent f3dc0e6eee
commit dd99ce1c64
33 changed files with 1368 additions and 0 deletions

View file

@ -0,0 +1,85 @@
/**
* Operator HTTP: list, status, pause/resume/stop/start/restart.
* Binds 127.0.0.1 only.
* @module server
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { listServices } from './load.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
function json(res, code, obj) {
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(obj, null, 2));
}
/**
* @param {import('./supervisor.js').Supervisor} sup
* @param {import('./monitor.js').Monitor} [mon]
*/
export function startControlServer(sup, mon) {
const bind = sup.loaded.control?.bind || '127.0.0.1';
const port = Number(sup.loaded.control?.port || 3850);
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${bind}:${port}`);
try {
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, { ok: true, role: 'fleet-control' });
}
if (req.method === 'GET' && url.pathname === '/api/services') {
return json(res, 200, { services: listServices(sup.loaded), fleetPath: sup.loaded.fleetPath });
}
if (req.method === 'GET' && url.pathname === '/api/status') {
return json(res, 200, { ...sup.status(), monitor: mon?.last || null });
}
if (req.method === 'POST' && url.pathname === '/api/reconcile') {
const actions = await sup.reconcile();
return json(res, 200, { actions, status: sup.status() });
}
const svc = url.pathname.match(/^\/api\/services\/([^/]+)\/(start|stop|pause|resume)$/);
if (svc && req.method === 'POST') {
const [, id, op] = svc;
const fn = {
start: () => sup.startService(id),
stop: () => sup.stopService(id),
pause: () => sup.pauseService(id),
resume: () => sup.resumeService(id),
}[op];
const result = await fn();
return json(res, 200, { op, id, result, status: sup.status().services[id] });
}
const inst = url.pathname.match(/^\/api\/instances\/([^/]+)\/(pause|resume|restart|stop|unhealthy)$/);
if (inst && req.method === 'POST') {
const [, id, op] = inst;
const fn = {
pause: () => sup.pauseInstance(id),
resume: () => sup.resumeInstance(id),
restart: () => sup.restartInstance(id),
stop: () => sup.stopInstance(id, { replace: true }),
unhealthy: () => sup.markUnhealthy(id),
}[op];
const result = await fn();
return json(res, 200, { op, id, result: result?.id || result, status: sup.status() });
}
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
const dest = path.join(PUBLIC, 'index.html');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
fs.createReadStream(dest).pipe(res);
return;
}
json(res, 404, { error: 'not found' });
} catch (err) {
json(res, 400, { error: err.message });
}
});
server.listen(port, bind, () => {
process.stdout.write(`fleet control http://${bind}:${port}/\n`);
});
return server;
}