From a934db1a1417b50f3660394d7e9074ecf1cbfd67 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:34:45 -0400 Subject: [PATCH] feat: add Semaphore Sync job Fetches Semaphore project inventory via Bearer auth, parses the Ansible INI format to extract hostnames, and sets semaphore=1/0 on matching instances. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 11 +++++++---- server/db.js | 4 ++++ server/jobs.js | 39 +++++++++++++++++++++++++++++++++++++-- tests/api.test.js | 17 +++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/js/ui.js b/js/ui.js index 96aae8a..4b7d51e 100644 --- a/js/ui.js +++ b/js/ui.js @@ -483,17 +483,20 @@ function _renderJobConfigFields(key, cfg) { `; - if (key === 'patchmon_sync') return ` + if (key === 'patchmon_sync' || key === 'semaphore_sync') { + const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)'; + return `
+ value="${esc(cfg.api_url ?? '')}">
- + + value="${esc(cfg.api_token ?? '')}">
`; + } return ''; } diff --git a/server/db.js b/server/db.js index 660f654..c0d0fd5 100644 --- a/server/db.js +++ b/server/db.js @@ -125,6 +125,10 @@ function seedJobs() { upsert.run('patchmon_sync', 'Patchmon Sync', 'Syncs Patchmon host registration status to instances by matching hostnames.', 0, 60, JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: '' })); + + upsert.run('semaphore_sync', 'Semaphore Sync', + 'Syncs Semaphore inventory membership to instances by matching hostnames.', + 0, 60, JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: '' })); } // ── Queries ─────────────────────────────────────────────────────────────────── diff --git a/server/jobs.js b/server/jobs.js index cd2070b..cfcb20e 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -66,11 +66,46 @@ async function patchmonSyncHandler(cfg) { return { summary: `${updated} updated of ${instances.length}` }; } +// ── Semaphore Sync ──────────────────────────────────────────────────────────── + +async function semaphoreSyncHandler(cfg) { + const { api_url, api_token } = cfg; + if (!api_url || !api_token) throw new Error('Semaphore not configured — set API URL and token'); + + const res = await fetch(api_url, { + headers: { Authorization: `Bearer ${api_token}` }, + }); + if (!res.ok) throw new Error(`Semaphore API ${res.status}`); + + const data = await res.json(); + // Inventory is an Ansible INI string; extract bare hostnames + const hostSet = new Set( + (data.inventory ?? '').split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('[') && !l.startsWith('#') && !l.startsWith(';')) + .map(l => l.split(/[\s=]/)[0]) + .filter(Boolean) + ); + + const instances = getInstances(); + let updated = 0; + for (const inst of instances) { + const newSemaphore = hostSet.has(inst.name) ? 1 : 0; + if (newSemaphore !== inst.semaphore) { + const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst; + updateInstance(inst.vmid, { ...instData, semaphore: newSemaphore }); + updated++; + } + } + return { summary: `${updated} updated of ${instances.length}` }; +} + // ── Registry ────────────────────────────────────────────────────────────────── const HANDLERS = { - tailscale_sync: tailscaleSyncHandler, - patchmon_sync: patchmonSyncHandler, + tailscale_sync: tailscaleSyncHandler, + patchmon_sync: patchmonSyncHandler, + semaphore_sync: semaphoreSyncHandler, }; // ── Public API ──────────────────────────────────────────────────────────────── diff --git a/tests/api.test.js b/tests/api.test.js index 7654980..3cb7f1c 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -585,4 +585,21 @@ describe('POST /api/jobs/:id/run', () => { const res = await request(app).post(`/api/jobs/${id}/run`) expect(res.status).toBe(500) }) + + it('semaphore_sync: parses ansible inventory and updates instances', async () => { + const semaphoreJob = { + key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test', + enabled: 0, schedule: 60, + config: JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: 'bearer-token' }), + } + createJob(semaphoreJob) + const id = (await request(app).get('/api/jobs')).body[0].id + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ inventory: '[production]\nplex\nhomeassistant\n' }), + })) + const res = await request(app).post(`/api/jobs/${id}/run`) + expect(res.status).toBe(200) + expect(res.body.summary).toMatch(/updated of/) + }) }) -- 2.39.5