/** * Host catalog: where replicas may run. Least-loaded placement. * @module machines */ import fs from 'node:fs'; import path from 'node:path'; export function defaultMachines() { return [ { id: 'local', title: 'Control plane (this host)', kind: 'local', host: '127.0.0.1', enabled: true, capacity: 32, roles: ['*'], }, ]; } export function loadMachines(root, overlayPath) { const file = path.join(root, 'machines.json'); let list = defaultMachines(); if (fs.existsSync(file)) { const raw = JSON.parse(fs.readFileSync(file, 'utf8')); if (Array.isArray(raw.machines) && raw.machines.length) list = raw.machines; } if (overlayPath && fs.existsSync(overlayPath)) { const extra = JSON.parse(fs.readFileSync(overlayPath, 'utf8')); const incoming = Array.isArray(extra) ? extra : extra.machines || []; for (const m of incoming) upsertMachine(list, m); } const secretsPath = path.join(root, 'machines.secrets.json'); if (fs.existsSync(secretsPath)) { const secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')); const byId = secrets.machines || secrets; for (const m of list) { const extra = byId[m.id]; if (extra && typeof extra === 'object') Object.assign(m, extra); } } return list.map((m) => { const n = normalizeMachine(m); if (n.id === 'lan-134' && process.env.FLEET_ENABLE_LAN134 !== '1') n.enabled = false; return n; }); } export function normalizeMachine(m) { const kind = m.kind === 'agent' || m.kind === 'ssh' ? m.kind : 'local'; return { id: String(m.id || '').trim(), title: m.title || m.id, kind, host: m.host || '127.0.0.1', user: m.user || (kind === 'ssh' ? 'marchon' : ''), sshPort: Number(m.sshPort || 22), identityFile: m.identityFile || (kind === 'ssh' ? '~/.ssh/id_ed25519' : ''), remoteDir: m.remoteDir || '~/verae-fleet-runtime', agentPort: Number(m.agentPort || 3851), enabled: m.enabled !== false, capacity: Number(m.capacity || 8), roles: Array.isArray(m.roles) && m.roles.length ? m.roles : ['*'], notes: m.notes || '', }; } export function upsertMachine(list, spec) { const m = normalizeMachine(spec); if (!m.id) throw new Error('machine id required'); const i = list.findIndex((x) => x.id === m.id); if (i >= 0) list[i] = { ...list[i], ...m }; else list.push(m); return m; } export function canHost(machine, serviceId, role) { if (!machine?.enabled) return false; const roles = machine.roles || ['*']; if (roles.includes('*')) return true; return roles.includes(serviceId) || (role && roles.includes(role)); } /** * Pick the enabled machine with the most free capacity (fewest running / capacity). * @param {object[]} machines * @param {Array<{ machine?: string }>} instances * @param {string} serviceId * @param {string} [role] */ export function pickMachine(machines, instances, serviceId, role, exclude = [], opts = {}) { const skip = new Set(exclude); let eligible = machines.filter((m) => canHost(m, serviceId, role) && !skip.has(m.id)); if (opts.preferLocal) { const local = eligible.filter((m) => m.kind === 'local'); if (local.length) eligible = local; } if (!eligible.length) return null; const scored = eligible.map((m) => { const running = instances.filter((i) => i.machine === m.id && i.pid).length; const free = m.capacity - running; const load = m.capacity <= 0 ? 1 : running / m.capacity; return { m, running, free, load }; }); scored.sort((a, b) => a.load - b.load || a.running - b.running || a.m.id.localeCompare(b.m.id)); const best = scored.find((s) => s.free > 0); return best ? best.m : null; } export function saveOverlay(overlayPath, machines) { fs.mkdirSync(path.dirname(overlayPath), { recursive: true }); fs.writeFileSync(overlayPath, JSON.stringify({ machines }, null, 2)); }