import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ModuleManifest } from '../../manifest/schema'; import { classifyHostRecap, verifyModuleOnHosts } from './host-plane'; const recap = (over: Partial[0]> = {}) => ({ host: 'vpn-manager', changed: 0, unreachable: 0, failed: 0, skipped: 0, ...over, }); describe('classifyHostRecap', () => { test('nothing would change → converged', () => { expect(classifyHostRecap(recap(), 'm').state).toBe('converged'); }); test('something would change → drift, and never blocked', () => { const finding = classifyHostRecap(recap({ changed: 3 }), 'wireguard-manager'); expect(finding.state).toBe('drift'); expect(finding.detail).toContain('celilo module deploy wireguard-manager'); }); test('unreachable → unmeasured, not converged', () => { expect(classifyHostRecap(recap({ unreachable: 1 }), 'm').state).toBe('unmeasured'); }); test('failed → unmeasured', () => { expect(classifyHostRecap(recap({ failed: 1 }), 'm').state).toBe('unmeasured'); }); test('THE case that must not read as converged: check mode skipped tasks', () => { // Ansible does not evaluate a task it cannot support — it SKIPS it. A role // of `command:` / `shell:` tasks can finish with changed=0 having never // been applied to the host at all. Two states would call that converged. const finding = classifyHostRecap(recap({ skipped: 4 }), 'm'); expect(finding.state).toBe('unmeasured'); expect(finding.detail).toContain('4 task(s)'); }); test('a skip outranks a change, because the run as a whole was not measured', () => { expect(classifyHostRecap(recap({ skipped: 1, changed: 2 }), 'm').state).toBe('unmeasured'); }); }); describe('verifyModuleOnHosts', () => { function generatedTreeWithPlaybook(): string { const root = mkdtempSync(join(tmpdir(), 'celilo-hostplane-')); mkdirSync(join(root, 'ansible'), { recursive: true }); writeFileSync(join(root, 'ansible', 'playbook.yml'), '---\n- hosts: all\n'); return root; } const manifest = { id: 'm', name: 'M', version: '1.0.0' } as unknown as ModuleManifest; test('a manifest opt-out is carried back, not silently dropped', async () => { const optedOut = { ...manifest, verify: { deep: false, reason: 'the role is command:-driven' }, } as unknown as ModuleManifest; const result = await verifyModuleOnHosts({ moduleId: 'm', manifest: optedOut, generatedPath: '/nonexistent', execute: async () => { throw new Error('must not run ansible for an opted-out module'); }, }); expect(result.optedOut?.reason).toBe('the role is command:-driven'); expect(result.findings).toEqual([]); }); test('no generated playbook is unmeasured, not clean', async () => { const result = await verifyModuleOnHosts({ moduleId: 'm', manifest, generatedPath: '/nonexistent', execute: async () => { throw new Error('must not run ansible without a playbook'); }, }); expect(result.findings.map((f) => f.state)).toEqual(['unmeasured']); }); test('a run producing no PLAY RECAP is unmeasured, not clean', async () => { // celilo#951's shape in another comparator: nothing measured, rendered as // nothing wrong. const root = generatedTreeWithPlaybook(); try { const result = await verifyModuleOnHosts({ moduleId: 'm', manifest, generatedPath: root, execute: async () => ({ success: false, output: 'ssh: connect refused', error: 'boom' }), }); expect(result.findings.map((f) => f.state)).toEqual(['unmeasured']); expect(result.findings[0]?.detail).toContain('no PLAY RECAP'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('one finding per host in the recap', async () => { const root = generatedTreeWithPlaybook(); try { const result = await verifyModuleOnHosts({ moduleId: 'm', manifest, generatedPath: root, execute: async () => ({ success: true, output: [ 'PLAY RECAP *********', 'vpn-manager : ok=10 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0', 'edge : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0', ].join('\n'), }), }); expect(result.findings).toEqual([ { hostname: 'vpn-manager', state: 'converged' }, expect.objectContaining({ hostname: 'edge', state: 'drift' }), ]); } finally { rmSync(root, { recursive: true, force: true }); } }); });