diff --git a/lib/actions/service.js b/lib/actions/service.js new file mode 100644 index 0000000..88a3945 --- /dev/null +++ b/lib/actions/service.js @@ -0,0 +1,43 @@ +import * as aa from '../db/repos/agent_actions.js'; +import { loadActions } from './registry.js'; +import { powerGuest } from './channels/proxmox.js'; +import { restartService } from './channels/ssh.js'; + +// Single choke point. Dispatches one whitelisted action to its channel. The +// registry + channels are injectable for tests; production wiring uses defaults. +export function makeActionService({ registry = loadActions(), channels = { powerGuest, restartService } } = {}) { + async function execute(a) { + if (a.kind === 'guest_power') return channels.powerGuest({ node: a.node, vmid: a.vmid, op: a.op, kindPath: a.kindPath || 'lxc' }); + if (a.kind === 'service_restart') return channels.restartService({ ip: registry.hostIp(a.host), actionId: a.id }); + throw new Error(`unknown kind: ${a.kind}`); + } + + async function run(actionId, actor, agent_id = null) { + const a = registry.get(actionId); + if (!a) throw new Error(`unknown action: ${actionId}`); + if (a.tier === 'risky') { + const row = await aa.create({ action_id: a.id, tier: a.tier, params: {}, agent_id, requested_by: actor }); + return { queued: true, action_row_id: row.id }; + } + const result = await execute(a); + const row = await aa.create({ action_id: a.id, tier: a.tier, agent_id, requested_by: actor }); + await aa.resolve(row.id, 'executed', result, actor); + return { executed: true, result }; + } + + async function approve(rowId, owner) { + const row = await aa.getById(rowId); + if (!row || row.status !== 'pending') throw new Error('not a pending action'); + const a = registry.get(row.action_id); + if (!a) throw new Error(`unknown action: ${row.action_id}`); + try { + const result = await execute(a); + return aa.resolve(rowId, 'executed', result, owner); + } catch (e) { + return aa.resolve(rowId, 'failed', { error: String(e?.message || e) }, owner); + } + } + const reject = (rowId, owner) => aa.resolve(rowId, 'rejected', null, owner); + + return { run, approve, reject, list: () => registry.list() }; +} diff --git a/tests/actions/service.test.js b/tests/actions/service.test.js new file mode 100644 index 0000000..aba11be --- /dev/null +++ b/tests/actions/service.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import { fileURLToPath } from 'url'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as aa from '../../lib/db/repos/agent_actions.js'; +import { makeActionService } from '../../lib/actions/service.js'; +import { loadActions } from '../../lib/actions/registry.js'; + +const FIX = fileURLToPath(new URL('../fixtures/actions.test.json', import.meta.url)); +const owner = { kind: 'user', id: null }; +let svc, channels; +beforeAll(async () => { await resetDb(); await migrateUp(); }); +beforeEach(() => { + channels = { powerGuest: vi.fn(async () => ({ ok: true, upid: 'U' })), restartService: vi.fn(async () => ({ ok: true, output: 'done' })) }; + svc = makeActionService({ registry: loadActions(FIX), channels }); +}); + +describe('action service', () => { + it('safe action executes immediately + audits', async () => { + const out = await svc.run('restart-caddy-ct100', owner); + expect(out.executed).toBe(true); + expect(channels.restartService).toHaveBeenCalledOnce(); + }); + it('risky action queues, does NOT execute', async () => { + const out = await svc.run('stop-ct107', owner); + expect(out.queued).toBe(true); + expect(channels.powerGuest).not.toHaveBeenCalled(); + expect((await aa.listPending()).some(r => r.id === out.action_row_id)).toBe(true); + }); + it('approve executes the queued risky action; reject does not', async () => { + const q = await svc.run('stop-ct107', owner); + const done = await svc.approve(q.action_row_id, owner); + expect(done.status).toBe('executed'); + expect(channels.powerGuest).toHaveBeenCalledOnce(); + const q2 = await svc.run('stop-ct107', owner); + const rej = await svc.reject(q2.action_row_id, owner); + expect(rej.status).toBe('rejected'); + expect(channels.powerGuest).toHaveBeenCalledOnce(); // unchanged + }); + it('unknown action → error', async () => { + await expect(svc.run('ghost', owner)).rejects.toThrow(/unknown action/i); + }); +});