import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, } from './db.js'; export const router = Router(); // ── Validation ──────────────────────────────────────────────────────────────── const VALID_STATES = ['deployed', 'testing', 'degraded']; const VALID_STACKS = ['production', 'development']; const SERVICE_KEYS = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda']; function validate(body) { const errors = []; if (!body.name || typeof body.name !== 'string' || !body.name.trim()) errors.push('name is required'); if (!Number.isInteger(body.vmid) || body.vmid < 1) errors.push('vmid must be a positive integer'); if (!VALID_STATES.includes(body.state)) errors.push(`state must be one of: ${VALID_STATES.join(', ')}`); if (!VALID_STACKS.includes(body.stack)) errors.push(`stack must be one of: ${VALID_STACKS.join(', ')}`); const ip = (body.tailscale_ip ?? '').trim(); if (ip && !/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) errors.push('tailscale_ip must be a valid IPv4 address or empty'); return errors; } function normalise(body) { const row = { name: body.name.trim(), state: body.state, stack: body.stack, vmid: body.vmid, tailscale_ip: (body.tailscale_ip ?? '').trim(), hardware_acceleration: body.hardware_acceleration ? 1 : 0, }; for (const svc of SERVICE_KEYS) row[svc] = body[svc] ? 1 : 0; return row; } // ── Routes ──────────────────────────────────────────────────────────────────── // GET /api/instances/stacks — must be declared before /:vmid router.get('/instances/stacks', (_req, res) => { res.json(getDistinctStacks()); }); // GET /api/instances router.get('/instances', (req, res) => { const { search, state, stack } = req.query; res.json(getInstances({ search, state, stack })); }); // GET /api/instances/:vmid router.get('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); const instance = getInstance(vmid); if (!instance) return res.status(404).json({ error: 'instance not found' }); res.json(instance); }); // POST /api/instances router.post('/instances', (req, res) => { const errors = validate(req.body); if (errors.length) return res.status(400).json({ errors }); try { const data = normalise(req.body); createInstance(data); const created = getInstance(data.vmid); res.status(201).json(created); } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); console.error('POST /api/instances', e); res.status(500).json({ error: 'internal server error' }); } }); // PUT /api/instances/:vmid router.put('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); if (!getInstance(vmid)) return res.status(404).json({ error: 'instance not found' }); const errors = validate(req.body); if (errors.length) return res.status(400).json({ errors }); try { const data = normalise(req.body); updateInstance(vmid, data); res.json(getInstance(data.vmid)); } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); console.error('PUT /api/instances/:vmid', e); res.status(500).json({ error: 'internal server error' }); } }); // GET /api/export router.get('/export', (_req, res) => { const instances = getInstances(); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); res.json({ version: 1, exported_at: new Date().toISOString(), instances }); }); // POST /api/import router.post('/import', (req, res) => { const { instances } = req.body ?? {}; if (!Array.isArray(instances)) { return res.status(400).json({ error: 'body must contain an instances array' }); } const errors = []; for (const [i, row] of instances.entries()) { const errs = validate(normalise(row)); if (errs.length) errors.push({ index: i, errors: errs }); } if (errors.length) return res.status(400).json({ errors }); try { importInstances(instances.map(normalise)); res.json({ imported: instances.length }); } catch (e) { console.error('POST /api/import', e); res.status(500).json({ error: 'internal server error' }); } }); // DELETE /api/instances/:vmid router.delete('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); const instance = getInstance(vmid); if (!instance) return res.status(404).json({ error: 'instance not found' }); if (instance.stack !== 'development') return res.status(422).json({ error: 'only development instances can be deleted' }); try { deleteInstance(vmid); res.status(204).end(); } catch (e) { console.error('DELETE /api/instances/:vmid', e); res.status(500).json({ error: 'internal server error' }); } });