Syncs patchmon field on instances by querying the Patchmon hosts API and matching hostnames. API token masked as REDACTED in responses. seedJobs now uses INSERT OR IGNORE so new jobs are seeded on existing installs without re-running the full seed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
220 lines
7.9 KiB
JavaScript
220 lines
7.9 KiB
JavaScript
import { Router } from 'express';
|
|
import {
|
|
getInstances, getInstance, getDistinctStacks,
|
|
createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory,
|
|
getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns,
|
|
} from './db.js';
|
|
import { runJob, restartJobs } 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);
|
|
} 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 date = new Date().toISOString().slice(0, 10);
|
|
res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`);
|
|
res.json({ version: 2, exported_at: new Date().toISOString(), instances, history });
|
|
});
|
|
|
|
// POST /api/import
|
|
router.post('/import', (req, res) => {
|
|
const { instances, history = [] } = 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 : []);
|
|
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) {
|
|
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);
|
|
}
|
|
});
|