import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, getAllJobs, getAllJobRuns, importJobs, } from './db.js'; import { runJob, restartJobs, runJobsOnCreate } from './jobs.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']; const REDACTED = '**REDACTED**'; function maskJob(job) { const cfg = JSON.parse(job.config || '{}'); if (cfg.api_key) cfg.api_key = REDACTED; if (cfg.api_token) cfg.api_token = REDACTED; return { ...job, config: cfg }; } 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 handleDbError(context, e, res) { 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(context, e); res.status(500).json({ error: 'internal server error' }); } 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/history router.get('/instances/:vmid/history', (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' }); res.json(getInstanceHistory(vmid)); }); // 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); runJobsOnCreate().catch(() => {}); } catch (e) { handleDbError('POST /api/instances', e, res); } }); // 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) { handleDbError('PUT /api/instances/:vmid', e, res); } }); // GET /api/export router.get('/export', (_req, res) => { const instances = getInstances(); const history = getAllHistory(); const jobs = getAllJobs(); const job_runs = getAllJobRuns(); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); res.json({ version: 3, exported_at: new Date().toISOString(), instances, history, jobs, job_runs }); }); // POST /api/import router.post('/import', (req, res) => { const { instances, history = [], jobs, job_runs } = 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), Array.isArray(history) ? history : []); if (Array.isArray(jobs)) { importJobs(jobs, Array.isArray(job_runs) ? job_runs : []); try { restartJobs(); } catch (e) { console.error('POST /api/import restartJobs', e); } } res.json({ imported: instances.length, imported_jobs: Array.isArray(jobs) ? jobs.length : undefined, }); } 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) { handleDbError('DELETE /api/instances/:vmid', e, res); } }); // GET /api/jobs router.get('/jobs', (_req, res) => { res.json(getJobs().map(maskJob)); }); // GET /api/jobs/:id router.get('/jobs/:id', (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'invalid id' }); const job = getJob(id); if (!job) return res.status(404).json({ error: 'job not found' }); res.json({ ...maskJob(job), runs: getJobRuns(id) }); }); // PUT /api/jobs/:id router.put('/jobs/:id', (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'invalid id' }); const job = getJob(id); if (!job) return res.status(404).json({ error: 'job not found' }); const { enabled, schedule, config: newCfg } = req.body ?? {}; const existingCfg = JSON.parse(job.config || '{}'); const mergedCfg = { ...existingCfg, ...(newCfg ?? {}) }; if (newCfg?.api_key === REDACTED) mergedCfg.api_key = existingCfg.api_key; if (newCfg?.api_token === REDACTED) mergedCfg.api_token = existingCfg.api_token; updateJob(id, { enabled: enabled != null ? (enabled ? 1 : 0) : job.enabled, schedule: schedule != null ? (parseInt(schedule, 10) || 15) : job.schedule, config: JSON.stringify(mergedCfg), }); try { restartJobs(); } catch (e) { console.error('PUT /api/jobs/:id restartJobs', e); } res.json(maskJob(getJob(id))); }); // POST /api/jobs/:id/run router.post('/jobs/:id/run', async (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'invalid id' }); if (!getJob(id)) return res.status(404).json({ error: 'job not found' }); try { res.json(await runJob(id)); } catch (e) { handleDbError('POST /api/jobs/:id/run', e, res); } });