feat(semaphore): trigger Ansible tasks at booking start/end via Semaphore

- Background scheduler checks every 30s for bookings that need setup or teardown
- Per-lab Semaphore template IDs stored on the labs table
- Booking flags track which jobs have been triggered and their Semaphore job IDs
- Immediate teardown triggered when an active booking is cancelled
- Settings UI section for Semaphore API URL, token, and project ID
- Lab template form fields for setup/teardown template IDs
- BookingDetailsModal shows live Ansible job status with manual trigger buttons
This commit is contained in:
Brückner
2026-06-05 09:39:58 +02:00
parent 11eb06c5ad
commit 70399a00ec
6 changed files with 488 additions and 24 deletions

215
server.ts
View File

@ -61,7 +61,7 @@ function getMsalClient(): ConfidentialClientApplication | null {
});
}
const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret'];
const SECRET_KEYS = ['azure_client_secret', 'checkmk_api_secret', 'semaphore_api_token'];
function maskSettings(raw: Record<string, string>): Record<string, string> {
const out = { ...raw };
@ -247,7 +247,9 @@ async function startServer() {
app.put('/api/settings', requireAuth, (req, res) => {
try {
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
'azure_redirect_uri', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms'];
'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user',
'checkmk_api_secret', 'checkmk_sync_interval_ms',
'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id'];
const updates = req.body as Record<string, string>;
for (const key of allowed) {
if (key in updates && updates[key] !== '__SET__') {
@ -409,7 +411,9 @@ async function startServer() {
const labs: LabTemplate[] = rows.map(r => ({
id: r.id, name: r.name, description: r.description,
contactPerson: r.contactPerson, location: r.location,
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology)
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
}));
res.json(labs);
} catch (err: any) {
@ -419,14 +423,14 @@ async function startServer() {
app.post('/api/labs', requireAuth, (req, res) => {
try {
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
}
const id = uid("lab");
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []));
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
@ -435,7 +439,7 @@ async function startServer() {
req.user!.userId);
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) });
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@ -444,10 +448,10 @@ async function startServer() {
app.put('/api/labs/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const { name, description, contactPerson, location, deviceIds, topology } = req.body;
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ? WHERE id = ?`)
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), id);
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`)
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id);
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
@ -455,7 +459,7 @@ async function startServer() {
`Modified the active topology mapping schema for the "${name}" lab template.`, req.user!.userId);
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology) });
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '' });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@ -491,7 +495,11 @@ async function startServer() {
id: r.id, labId: r.labId, userId: r.userId,
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
notes: r.notes || '', status: r.status as any,
notified: r.notified === 1, emailSent: r.emailSent === 1
notified: r.notified === 1, emailSent: r.emailSent === 1,
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
ansibleSetupJobId: r.ansibleSetupJobId || '',
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
}));
res.json(bookings);
} catch (err: any) {
@ -530,7 +538,7 @@ async function startServer() {
}
});
app.put('/api/bookings/:id', requireAuth, (req, res) => {
app.put('/api/bookings/:id', requireAuth, async (req, res) => {
try {
const id = req.params.id;
const { status, operatorName } = req.body;
@ -541,16 +549,38 @@ async function startServer() {
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, id);
if (status === 'cancelled') {
const lab = db.prepare('SELECT name FROM labs WHERE id = ?').get(booking.labId) as { name: string } | undefined;
const lab = db.prepare('SELECT name, semaphoreTeardownTemplateId FROM labs WHERE id = ?').get(booking.labId) as { name: string; semaphoreTeardownTemplateId: string } | undefined;
const logId = uid("log");
db.prepare(`INSERT INTO logs (id, timestamp, type, message, userId) VALUES (?, ?, ?, ?, ?)`)
.run(logId, new Date().toISOString(), 'booking',
`${operatorName || 'An operator'} canceled the reservation for lab template "${lab?.name || 'Unknown'}" and released associated nodes back into the pool.`,
req.user!.userId);
// Trigger teardown if booking had already started and teardown not yet triggered
const now = new Date();
if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) {
const templateId = lab?.semaphoreTeardownTemplateId;
if (templateId) {
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId,
start_time: booking.startDateTime, end_time: booking.endDateTime,
});
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', booking.id);
}
}
}
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
res.json({ id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 });
res.json({
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
notified: r.notified === 1, emailSent: r.emailSent === 1,
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
ansibleSetupJobId: r.ansibleSetupJobId || '',
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
@ -806,6 +836,161 @@ async function startServer() {
}
});
// -------------------------------------------------------------
// ANSIBLE SEMAPHORE INTEGRATION
// Triggers Semaphore task templates at booking start (setup) and
// booking end (teardown). Uses the same self-rescheduling pattern
// as CheckMK. Template IDs are configured per lab template.
// -------------------------------------------------------------
async function triggerSemaphoreTask(templateId: number, extraVars: Record<string, string>): Promise<number | null> {
const now = new Date().toISOString();
const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id');
if (!apiUrl || !token || !projectId) {
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
.run(uid('log'), now, 'system', 'Semaphore trigger skipped — API URL, token, or project ID not configured. Check Settings.');
return null;
}
try {
const res = await fetch(`${apiUrl}/api/project/${projectId}/tasks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ template_id: templateId, environment: JSON.stringify(extraVars) }),
});
if (!res.ok) {
const hint = res.status === 401 ? 'HTTP 401 Unauthorized — check API token'
: res.status === 403 ? 'HTTP 403 Forbidden — token lacks permission'
: res.status === 404 ? 'HTTP 404 — wrong project ID or Semaphore URL'
: `HTTP ${res.status}`;
throw new Error(hint);
}
const data = await res.json() as { id?: number };
const jobId = data?.id ?? null;
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
.run(uid('log'), now, 'system', `Semaphore: triggered template #${templateId} → job #${jobId} (booking ${extraVars.booking_id}).`);
return jobId;
} catch (err: any) {
const msg = `Semaphore trigger failed for template #${templateId}${err?.message ?? err}`;
console.error('[Semaphore]', msg);
db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)')
.run(uid('log'), now, 'system', msg);
return null;
}
}
async function checkAndTriggerAnsibleTasks() {
if (getSetting('semaphore_enabled') !== 'true') return;
const now = new Date().toISOString();
// Bookings that have started but setup hasn't been triggered yet
const setupPending = db.prepare(
`SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'`
).all(now) as any[];
for (const row of setupPending) {
const templateId = row.semaphoreSetupTemplateId;
if (!templateId) {
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id);
continue;
}
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', row.id);
}
// Bookings that have ended but teardown hasn't been triggered yet
const teardownPending = db.prepare(
`SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'`
).all(now) as any[];
for (const row of teardownPending) {
const templateId = row.semaphoreTeardownTemplateId;
if (!templateId) {
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id);
continue;
}
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', row.id);
}
}
async function scheduleSemaphoreCheck() {
await checkAndTriggerAnsibleTasks();
setTimeout(scheduleSemaphoreCheck, 30_000);
}
scheduleSemaphoreCheck();
// Proxy Semaphore template list so the UI can populate dropdowns
app.get('/api/semaphore/templates', requireAuth, async (_req, res) => {
const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id');
if (!apiUrl || !token || !projectId) {
return res.status(400).json({ error: 'Semaphore not fully configured.' });
}
try {
const r = await fetch(`${apiUrl}/api/project/${projectId}/templates`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!r.ok) return res.status(r.status).json({ error: `Semaphore returned HTTP ${r.status}` });
res.json(await r.json());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Manual re-trigger for a specific booking (admin use / testing)
app.post('/api/semaphore/trigger/:bookingId', requireAuth, async (req, res) => {
try {
const { bookingId } = req.params;
const { type } = req.body as { type: 'setup' | 'teardown' };
const row = db.prepare(
`SELECT b.*, l.semaphoreSetupTemplateId, l.semaphoreTeardownTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id WHERE b.id = ?`
).get(bookingId) as any;
if (!row) return res.status(404).json({ error: 'Booking not found.' });
const templateId = type === 'setup' ? row.semaphoreSetupTemplateId : row.semaphoreTeardownTemplateId;
if (!templateId) return res.status(400).json({ error: `No Semaphore ${type} template configured for this lab.` });
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
if (type === 'setup') {
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', bookingId);
} else {
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', bookingId);
}
res.json({ ok: true, jobId });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`);
});