feat(actions): tiered action service (safe-run / risky-queue / approve)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
43
lib/actions/service.js
Normal file
43
lib/actions/service.js
Normal file
@@ -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() };
|
||||||
|
}
|
||||||
43
tests/actions/service.test.js
Normal file
43
tests/actions/service.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user