/** * SC3 unit tests — covers the parts of aspect-runner that don't * require live Ansible execution: * * - planAspectFanOut: zone filtering, api_only exclusion, * excludeHostnames respect, mgmt-system inclusion. * - materializeAspectAnsible: inventory + playbook generation, * role-copy, error path when the role is missing. * * runAspectFanOut (end-to-end with Ansible) gets covered by the * SC3 follow-up e2e test (modules/knot-unbound-internal/e2e/ * aspect-fanout.test.ts). */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules } from '../db/schema'; import type { BaseModuleAspect, ModuleManifest } from '../manifest/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { computeAspectScopeHash, findAspectApproval, recordAspectApproval, recordAspectConsent, } from './aspect-approvals'; import { type AspectRunResult, materializeAspectAnsible, maybeRunAspectForTrigger, planAspectFanOut, } from './aspect-runner'; import { upsertDeployedSystem } from './deployed-systems'; import { addMachine } from './machine-pool'; const baseAspect: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['dmz', 'app', 'secure', 'internal'], triggers: ['on_install'], }; async function seedMachine(opts: { hostname: string; zone: 'dmz' | 'app' | 'secure' | 'internal'; ip: string; apiOnly?: boolean; }) { const machine = await addMachine({ hostname: opts.hostname, zone: opts.zone, ipAddress: opts.ip, sshUser: 'root', sshKey: 'ssh-key-placeholder', hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 5, arch: 'amd64' }, earmarkedModule: null, }); if (opts.apiOnly) { // The DB column defaults to false; flip directly since there's // no setter API yet (D8 escape valve is set via the row, not // via a public function in Phase 1). const { getDb } = await import('../db/client'); const { machines } = await import('../db/schema'); const { eq } = await import('drizzle-orm'); await getDb().update(machines).set({ apiOnly: true }).where(eq(machines.id, machine.id)); } } /** Seed a container_service LXC (a module + its module_systems row). */ function seedLxc(opts: { moduleId: string; hostname: string; zone: 'dmz' | 'app' | 'secure' | 'internal'; ip: string; }) { const db = getDb(); db.insert(modules) .values({ id: opts.moduleId, name: opts.moduleId, version: '1.0.0', manifestData: { id: opts.moduleId, name: opts.moduleId, version: '1.0.0', celilo_contract: '1.0', }, sourcePath: `/tmp/${opts.moduleId}`, }) .run(); upsertDeployedSystem(db, opts.moduleId, { name: 'main', hostname: opts.hostname, ipv4Address: opts.ip, zone: opts.zone, infraType: 'container_service', }); } describe('aspect-runner', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-runner-test-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); describe('planAspectFanOut', () => { it('returns systems matching the aspect zones, skipping api_only', async () => { await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); await seedMachine({ hostname: 'authentik', zone: 'app', ip: '10.0.20.30' }); await seedMachine({ hostname: 'celilo-mgmt', zone: 'secure', ip: '10.0.30.5' }); await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' }); // greenwave: appliance, must be skipped await seedMachine({ hostname: 'greenwave', zone: 'internal', ip: '192.168.0.1', apiOnly: true, }); const plan = await planAspectFanOut(baseAspect); const targetHostnames = plan.targetSystems.map((m) => m.hostname).sort(); expect(targetHostnames).toEqual(['authentik', 'caddy', 'celilo-mgmt', 'knot']); expect(plan.skipped.map((s) => s.machine.hostname)).toEqual(['greenwave']); expect(plan.skipped[0]?.reason).toBe('api_only'); }); it('excludes hostnames the caller asks to skip', async () => { await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' }); await seedMachine({ hostname: 'other', zone: 'internal', ip: '192.168.0.11' }); const plan = await planAspectFanOut(baseAspect, { excludeHostnames: ['knot'] }); expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['other']); // excludeHostnames-removed systems aren't in `skipped` — the // caller's expressing a deliberate "don't include this", not // an exclusion the framework chose. expect(plan.skipped).toEqual([]); }); it('returns an empty plan when no machines match the zones', async () => { // Only a dmz machine present; aspect targets nothing else. await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); const onlySecure: BaseModuleAspect = { ...baseAspect, applicable_zones: ['secure'] }; const plan = await planAspectFanOut(onlySecure); expect(plan.targetSystems).toEqual([]); expect(plan.skipped).toEqual([]); }); it('includes the management system (secure zone, no special casing)', async () => { // Per CELILO_BASE.md D1 sub-section: mgmt is part of the fleet, // not a special-case exclusion. As long as it's in a zone the // aspect targets and isn't api_only, it gets the aspect. await seedMachine({ hostname: 'celilo-mgmt', zone: 'secure', ip: '10.0.30.5' }); const plan = await planAspectFanOut({ ...baseAspect, applicable_zones: ['secure'] }); expect(plan.targetSystems.map((m) => m.hostname)).toEqual(['celilo-mgmt']); }); it('includes container_service LXCs in the zones, not just machines (ISS-0028)', async () => { // The original machines-only fan-out skipped LXCs — caddy/celilo-registry // never got the aspect. A zone with both a machine and an LXC must yield // both targets. await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' }); seedLxc({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); seedLxc({ moduleId: 'celilo-registry', hostname: 'celilo-registry', zone: 'app', ip: '10.0.20.12', }); const plan = await planAspectFanOut(baseAspect); expect(plan.targetSystems.map((t) => t.hostname).sort()).toEqual([ 'caddy', 'celilo-registry', 'knot', ]); // The LXC targets carry no machineId (ambient-key auth); the machine does. const caddy = plan.targetSystems.find((t) => t.hostname === 'caddy'); expect(caddy?.machineId).toBeUndefined(); expect(caddy?.sshUser).toBe('root'); expect(plan.targetSystems.find((t) => t.hostname === 'knot')?.machineId).toBeDefined(); }); it('respects excludeHostnames for LXC targets too', async () => { seedLxc({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); const plan = await planAspectFanOut(baseAspect, { excludeHostnames: ['caddy'] }); expect(plan.targetSystems).toEqual([]); }); }); describe('materializeAspectAnsible', () => { function makeFakeRole(moduleSourcePath: string, roleName: string) { const taskFile = join( moduleSourcePath, 'base-module-aspect', 'ansible', 'roles', roleName, 'tasks', 'main.yml', ); mkdirSync(join(taskFile, '..'), { recursive: true }); writeFileSync(taskFile, "---\n- name: noop\n ansible.builtin.debug:\n msg: 'ok'\n"); } it('builds an inventory + playbook + role copy in a temp workspace', async () => { await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); await seedMachine({ hostname: 'authentik', zone: 'app', ip: '10.0.20.30' }); const moduleSourcePath = join(dir, 'module-src'); makeFakeRole(moduleSourcePath, 'dns-client-config'); const plan = await planAspectFanOut(baseAspect); const workDir = await materializeAspectAnsible({ aspect: baseAspect, moduleSourcePath, targetSystems: plan.targetSystems, }); // Inventory file lists both systems under the 'aspect_targets' // group plus their per-zone groups. const hostsIni = readFileSync(join(workDir, 'ansible', 'inventory', 'hosts.ini'), 'utf-8'); expect(hostsIni).toContain('[aspect_targets]'); expect(hostsIni).toContain('caddy ansible_host=10.0.10.10'); expect(hostsIni).toContain('authentik ansible_host=10.0.20.30'); expect(hostsIni).toContain('[dmz]'); expect(hostsIni).toContain('[app]'); // Per-host vars carry target_zone. const caddyVars = readFileSync( join(workDir, 'ansible', 'inventory', 'host_vars', 'caddy.yml'), 'utf-8', ); expect(caddyVars).toContain('target_zone: dmz'); const authentikVars = readFileSync( join(workDir, 'ansible', 'inventory', 'host_vars', 'authentik.yml'), 'utf-8', ); expect(authentikVars).toContain('target_zone: app'); // Synthesized playbook invokes the named role against // 'aspect_targets'. const playbook = readFileSync(join(workDir, 'ansible', 'playbook.yml'), 'utf-8'); expect(playbook).toContain('hosts: aspect_targets'); expect(playbook).toContain('role: dns-client-config'); expect(playbook).toContain('become: true'); // Role files were copied across. const copiedTask = readFileSync( join(workDir, 'ansible', 'roles', 'dns-client-config', 'tasks', 'main.yml'), 'utf-8', ); expect(copiedTask).toContain('name: noop'); // Cleanup (caller's responsibility per the contract). rmSync(workDir, { recursive: true, force: true }); }); it('emits an LXC inventory row with no ssh key file (ambient auth, ISS-0028)', async () => { await seedMachine({ hostname: 'knot', zone: 'internal', ip: '192.168.0.10' }); seedLxc({ moduleId: 'caddy', hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); const moduleSourcePath = join(dir, 'module-src-lxc'); makeFakeRole(moduleSourcePath, 'dns-client-config'); const plan = await planAspectFanOut(baseAspect); const workDir = await materializeAspectAnsible({ aspect: baseAspect, moduleSourcePath, targetSystems: plan.targetSystems, }); const hostsIni = readFileSync(join(workDir, 'ansible', 'inventory', 'hosts.ini'), 'utf-8'); // The machine row pins a key file; the LXC row does not (ambient key). const knotLine = hostsIni.split('\n').find((l) => l.startsWith('knot ')); const caddyLine = hostsIni.split('\n').find((l) => l.startsWith('caddy ')); expect(knotLine).toContain('ansible_ssh_private_key_file='); expect(caddyLine).toContain('caddy ansible_host=10.0.10.10 ansible_user=root'); expect(caddyLine).not.toContain('ansible_ssh_private_key_file='); rmSync(workDir, { recursive: true, force: true }); }); it('throws a clear error when the aspect role directory is missing', async () => { await seedMachine({ hostname: 'caddy', zone: 'dmz', ip: '10.0.10.10' }); const moduleSourcePath = join(dir, 'module-src-without-role'); mkdirSync(moduleSourcePath, { recursive: true }); const plan = await planAspectFanOut(baseAspect); await expect( materializeAspectAnsible({ aspect: baseAspect, moduleSourcePath, targetSystems: plan.targetSystems, }), ).rejects.toThrow(/Aspect role not found/); }); it('writes resolved ansible_vars to group_vars/all/aspect_vars.yml', async () => { // Seed the providing module + its target_ip so the // $self:target_ip template resolves. const { moduleConfigs, modules } = await import('../db/schema'); getDb() .insert(modules) .values({ id: 'knot-unbound-internal', name: 'knot-unbound-internal', version: '1.0.0', manifestData: { id: 'knot-unbound-internal', name: 'knot-unbound-internal', version: '1.0.0', celilo_contract: '1.0', }, sourcePath: '/tmp/knot', }) .run(); getDb() .insert(moduleConfigs) .values({ moduleId: 'knot-unbound-internal', key: 'target_ip', value: '192.168.0.10' }) .run(); await seedMachine({ hostname: 'app-host', zone: 'app', ip: '10.0.20.20' }); const moduleSourcePath = join(dir, 'module-src-with-vars'); makeFakeRole(moduleSourcePath, 'dns-client-config'); const aspectWithVars: BaseModuleAspect = { ...baseAspect, ansible_vars: { knot_server_ip: '$self:target_ip' }, }; const plan = await planAspectFanOut(aspectWithVars); const workDir = await materializeAspectAnsible({ aspect: aspectWithVars, moduleSourcePath, targetSystems: plan.targetSystems, providerModuleId: 'knot-unbound-internal', db: getDb(), }); const aspectVars = readFileSync( join(workDir, 'ansible', 'inventory', 'group_vars', 'all', 'aspect_vars.yml'), 'utf-8', ); expect(aspectVars).toContain('knot_server_ip: "192.168.0.10"'); rmSync(workDir, { recursive: true, force: true }); }); it('skips group_vars file when the aspect has no ansible_vars', async () => { await seedMachine({ hostname: 'app-host', zone: 'app', ip: '10.0.20.20' }); const moduleSourcePath = join(dir, 'module-src-no-vars'); makeFakeRole(moduleSourcePath, 'dns-client-config'); const plan = await planAspectFanOut(baseAspect); const workDir = await materializeAspectAnsible({ aspect: baseAspect, // no ansible_vars moduleSourcePath, targetSystems: plan.targetSystems, }); const groupVarsPath = join( workDir, 'ansible', 'inventory', 'group_vars', 'all', 'aspect_vars.yml', ); const { existsSync } = await import('node:fs'); expect(existsSync(groupVarsPath)).toBe(false); rmSync(workDir, { recursive: true, force: true }); }); }); describe('maybeRunAspectForTrigger', () => { const manifestWithAspect: ModuleManifest = { celilo_contract: '1.0', id: 'knot-unbound-internal', name: 'Knot Unbound Internal', version: '1.0.0', base_module_aspect: { ansible_role: 'dns-client-config', applicable_zones: ['dmz', 'app', 'secure', 'internal'], triggers: ['on_install'], }, requires: { capabilities: [] }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, secrets: { declares: [] }, hooks: {}, } as unknown as ModuleManifest; function seedModuleRow(opts: { id: string; version: string }) { getDb() .insert(modules) .values({ id: opts.id, name: opts.id, version: opts.version, manifestData: { id: opts.id, name: opts.id, version: opts.version, celilo_contract: '1.0', }, sourcePath: `/tmp/${opts.id}`, }) .run(); } /** Recording fake runner — captures the args, returns the canned result. */ function makeFakeRunner(success = true) { const calls: Array<{ moduleId: string; trigger: string; zones: string[] }> = []; const fake = async (args: { moduleId: string; aspect: BaseModuleAspect; moduleSourcePath: string; options: { trigger: string }; db: unknown; }): Promise => { calls.push({ moduleId: args.moduleId, trigger: args.options.trigger, zones: [...args.aspect.applicable_zones], }); return { success, output: success ? 'fake-output' : '', error: success ? undefined : 'fake-error', plan: { targetSystems: [], skipped: [] }, recap: [], }; }; return { fake, calls }; } it('skips when the manifest has no base_module_aspect', async () => { const manifest = { ...manifestWithAspect, base_module_aspect: undefined }; const { fake, calls } = makeFakeRunner(); const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifest as ModuleManifest, trigger: 'on_install', db: getDb(), runner: fake, }); expect(result.ran).toBe(false); expect(result.reason).toBe('no_aspect'); expect(calls).toHaveLength(0); }); it("skips when the trigger isn't declared on the aspect", async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash( manifestWithAspect.base_module_aspect as BaseModuleAspect, ), approver: null, db: getDb(), }); const { fake, calls } = makeFakeRunner(); const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_new_system_in_zone', // not in the declared triggers db: getDb(), runner: fake, }); expect(result.ran).toBe(false); expect(result.reason).toBe('trigger_not_declared'); expect(calls).toHaveLength(0); }); it('interviews when undecided and runs on consent (persists the approval)', async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); const { fake, calls } = makeFakeRunner(); const asked: string[] = []; const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_install', db: getDb(), runner: fake, requestConsent: async (a) => { asked.push(a.reason); return true; }, }); expect(asked).toEqual(['no_approval']); // it interviewed, didn't silently skip expect(result.ran).toBe(true); expect(calls).toHaveLength(1); const row = findAspectApproval('knot-unbound-internal', '1.0.0', getDb()); expect(row?.consented).toBe(true); // approval recorded }); it('interviews when undecided and skips on refusal (persists the denial)', async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); const { fake, calls } = makeFakeRunner(); const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_install', db: getDb(), runner: fake, requestConsent: async () => false, }); expect(result.ran).toBe(false); expect(result.reason).toBe('denied'); expect(calls).toHaveLength(0); const row = findAspectApproval('knot-unbound-internal', '1.0.0', getDb()); expect(row?.consented).toBe(false); // refusal recorded so it won't be re-asked }); it('does NOT re-prompt once consent was refused', async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); recordAspectConsent({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash( manifestWithAspect.base_module_aspect as BaseModuleAspect, ), approver: null, consented: false, db: getDb(), }); const { fake, calls } = makeFakeRunner(); const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_install', db: getDb(), runner: fake, requestConsent: async () => { throw new Error('must not re-prompt an already-refused aspect'); }, }); expect(result.ran).toBe(false); expect(result.reason).toBe('denied'); expect(calls).toHaveLength(0); }); it('re-interviews when the approved scope diverged, then runs on consent', async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); // Approve under a narrower scope than the manifest now declares. const oldScope: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['app'], triggers: ['on_install'], }; recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash(oldScope), approver: null, db: getDb(), }); const { fake, calls } = makeFakeRunner(); const asked: string[] = []; const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_install', db: getDb(), runner: fake, requestConsent: async (a) => { asked.push(a.reason); return true; }, }); expect(asked).toEqual(['scope_changed']); expect(result.ran).toBe(true); expect(calls).toHaveLength(1); // The single (module, version) row now reflects the new, wider scope. const row = findAspectApproval('knot-unbound-internal', '1.0.0', getDb()); expect(row?.scopeHash).toBe( computeAspectScopeHash(manifestWithAspect.base_module_aspect as BaseModuleAspect), ); }); it('dispatches to the runner when everything checks out', async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash( manifestWithAspect.base_module_aspect as BaseModuleAspect, ), approver: null, db: getDb(), }); const { fake, calls } = makeFakeRunner(); const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_install', db: getDb(), runner: fake, }); expect(result.ran).toBe(true); expect(result.success).toBe(true); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ moduleId: 'knot-unbound-internal', trigger: 'on_install', zones: ['dmz', 'app', 'secure', 'internal'], }); }); it('returns {ran:true, success:false} when the runner fails (no rollback)', async () => { seedModuleRow({ id: 'knot-unbound-internal', version: '1.0.0' }); recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash( manifestWithAspect.base_module_aspect as BaseModuleAspect, ), approver: null, db: getDb(), }); const { fake } = makeFakeRunner(false); const result = await maybeRunAspectForTrigger({ moduleId: 'knot-unbound-internal', manifest: manifestWithAspect, trigger: 'on_install', db: getDb(), runner: fake, }); expect(result.ran).toBe(true); expect(result.success).toBe(false); expect(result.runResult?.error).toBe('fake-error'); }); }); });