/** * Reach a module's deployed host without knowing how it came to exist. * * A suite that says `net.exec('caddy', …)` is not naming a host — it is naming a * COMPOSE SERVICE, which only exists because the test declared a machine in the * topology. The moment the same module is placed on a container service, celilo * provisions the host itself, there is no compose service by that name, and the * call fails in a way that looks like the module broke. * * That coupling is the single thing standing between the suite and the * conversions in celilo#815: every candidate suite execs on its module's host by * compose-service name. `execOnModuleHost` asks celilo where the module actually * landed and reaches it accordingly, so a suite stops caring which path placed * it — which is what lets the SAME suite cover both. */ /** Where a module's host lives, as celilo reports it. */ export interface ModuleHost { hostname: string; /** Present only for a guest celilo provisioned; absent for a pool machine. */ vmid?: number; /** * The docker container to exec into, and how to reach it. * * `compose` for a machine the topology declared — it is a service in the * project. `plain` for a guest the simulator created, which is a real * container but NOT part of the compose project, so `docker compose exec` * cannot see it. */ container: string; reach: 'compose' | 'plain'; } /** * Parse `celilo module status `'s placement section. * * The two shapes come from `formatPlacementLine`: * ` — machine (zone X)` ← machine pool * ` (vmid N) → (zone X)` ← container service * * Returns null when no placement line is present, which is a real state — the * module is imported but not deployed — and is worth distinguishing from a parse * failure, because "not deployed yet" and "I could not read the output" call for * different reactions from a test. */ export function parseModuleHost(statusOutput: string): ModuleHost | null { // Strip clack's `│ ` gutter and any ANSI before matching; CLI output carries // both, and a raw match silently finds nothing. const lines = statusOutput .split('\n') .map((line) => line.replace(/\x1b\[[0-9;]*m/g, '').replace(/^[\s│|]+/, '')); for (const line of lines) { const container = /^(\S+)\s+\(vmid\s+(\d+)\)\s+→/.exec(line); if (container?.[1] && container[2]) { const vmid = Number(container[2]); return { hostname: container[1], vmid, container: `celilo-e2e-lxc-${vmid}`, reach: 'plain', }; } const machine = /^(\S+)\s+—\s+machine\s+\(/.exec(line); if (machine?.[1]) { return { hostname: machine[1], container: machine[1], reach: 'compose' }; } } return null; } /** * The address(es) a module's deploy recorded, from `celilo module where --json`. * * The old source was `grep target_ip` over the module's `generated/` tree * (celilo#1334). D4 of control-plane-stops-building-modules makes that tree * ephemeral — a successful deploy deletes it — so the inventory is where the * address lives now: `module_systems` is written by the deploy itself and * survives it. Every current module declares exactly one system, so callers * take the first address; a module with none is an API-only module or an * undeployed one, and that distinction belongs to the caller. */ export function parseModuleWhere(whereOutput: string): string[] { // The success path prints the payload verbatim (celilo#698), but defensive // ANSI stripping costs one line and dev-mode shims have surprised us before. const stripped = whereOutput.replace(/\x1b\[[0-9;]*m/g, '').trim(); let parsed: { systems?: { ipv4_address?: string }[] }; try { parsed = JSON.parse(stripped) as { systems?: { ipv4_address?: string }[] }; } catch { // A non-JSON answer is a CLI failure (version drift, crash text) arriving // on stdout. An empty list reads as "no systems", which is a real state a // caller may legitimately handle — these are not the same problem. return []; } return (parsed.systems ?? []) .map((system) => system.ipv4_address ?? '') .filter((address) => address !== ''); }