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/)
+ })
})