Files
Catalyst/server/routes.js
josh 817fdaef13
All checks were successful
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
feat: run jobs on instance creation when run_on_create is enabled
Jobs with run_on_create=true in their config fire automatically
after a new instance is created. Runs fire-and-forget so they don't
delay the 201 response. Option exposed as a checkbox in each job's
detail panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 20:00:45 -04:00

231 lines
8.4 KiB
JavaScript

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);
}
});