/** * Resolving a module's host from `module status`. * * The parsing is the risky half and needs no rig, so it is tested here rather * than discovered during a 3-minute e2e run. The input shapes come from * `formatPlacementLine` in `apps/celilo/src/services/placement-reconcile.ts`; * if that function grows a case, one of these should fail. */ import { describe, expect, test } from 'bun:test'; import { parseModuleHost, parseModuleWhere } from './module-host'; /** How the CLI really prints it — clack gutter and ANSI included. */ const withGutter = (line: string) => `\x1b[1mModule: caddy\x1b[0m\n│ Placement:\n│ ${line}\n`; describe('parseModuleHost', () => { test('a provisioned guest resolves to the container the simulator created', () => { const host = parseModuleHost(withGutter('iot (vmid 2100) → pve1 (zone app)')); expect(host).toEqual({ hostname: 'iot', vmid: 2100, container: 'celilo-e2e-lxc-2100', reach: 'plain', }); }); test('a pool machine resolves to the compose service of the same name', () => { const host = parseModuleHost(withGutter('caddy — machine (zone dmz)')); expect(host).toEqual({ hostname: 'caddy', container: 'caddy', reach: 'compose' }); }); test('the two are reached DIFFERENTLY, which is the whole point', () => { // A guest the simulator created is a real container but not a compose // service, so `docker compose exec` cannot see it. Getting this backwards // fails in a way that reads as the module breaking. const guest = parseModuleHost(withGutter('iot (vmid 2100) → pve1 (zone app)')); const machine = parseModuleHost(withGutter('caddy — machine (zone dmz)')); expect(guest?.reach).toBe('plain'); expect(machine?.reach).toBe('compose'); }); test('a module that is not deployed yet resolves to nothing, not a guess', () => { // A real state — imported but never deployed — and distinguishable from a // parse failure, because a test should react differently to each. expect(parseModuleHost('Module: caddy\nState: CONFIGURED\n')).toBeNull(); }); test('reads placement even when Proxmox could not be reached', () => { // `unreachable` still names the host and vmid, and the container exists // regardless of whether celilo could confirm which node holds it. const host = parseModuleHost( withGutter('iot (vmid 2100) → node unknown — Proxmox unreachable (zone app)'), ); expect(host?.container).toBe('celilo-e2e-lxc-2100'); }); test('is not fooled by the module id appearing elsewhere in the output', () => { const output = [ 'Module: caddy', 'Source: /root/.local/share/celilo/modules/caddy', 'Placement (live from Proxmox):', ' web (vmid 305) → pve1 (zone dmz)', ].join('\n'); expect(parseModuleHost(output)?.hostname).toBe('web'); }); }); describe('parseModuleWhere', () => { test('the inventory payload yields the recorded address', () => { const payload = JSON.stringify({ module: 'caddy-internal', systems: [ { name: 'main', hostname: 'caddy-internal', ipv4_address: '10.226.30.14', zone: 'dmz', vmid: 2201, infra_type: 'container_service', placement: 'caddy-internal (vmid 2201) → pve1 (zone dmz)', reachability: "firewall-segmented (dmz) — reach via the firewall's natIp DNAT", }, ], }); expect(parseModuleWhere(payload)).toEqual(['10.226.30.14']); }); test('ANSI and whitespace around the payload do not break it', () => { // The success path prints verbatim (celilo#698), but dev-mode shims have // surprised us before, so the parse tolerates decoration. const payload = `\x1b[1m${JSON.stringify({ systems: [{ ipv4_address: '10.226.30.15' }] })}\x1b[0m\n`; expect(parseModuleWhere(payload)).toEqual(['10.226.30.15']); }); test('a module with several systems yields them all, caller picks', () => { const payload = JSON.stringify({ systems: [ { name: 'main', ipv4_address: '10.226.20.9' }, { name: 'replica', ipv4_address: '10.226.20.10' }, ], }); expect(parseModuleWhere(payload)).toEqual(['10.226.20.9', '10.226.20.10']); }); test('an API-only module yields an empty list, a real state', () => { // namecheap has no host. Empty is not a parse failure — the caller decides // whether "no recorded address" is expected for their module. expect(parseModuleWhere(JSON.stringify({ systems: [] }))).toEqual([]); }); test('a non-JSON answer yields an empty list, not a throw', () => { // Crash text on stdout is a CLI failure and the harness method attaches // the raw output to its error; the parser's contract is "addresses found", // not "reasons why not". expect(parseModuleWhere('Error: module not found: caddy\n')).toEqual([]); }); });