/** * The third plane: is the code running on the host the code we generated? * * Measured by asking Ansible, not by hashing files. celilo does not know where * Ansible puts what it writes — the destination lives in the role's * `tasks/main.yml` `dest:`, which celilo does not parse and should not start * parsing. Ansible already computes exactly this, for every task, including * templated files whose content celilo could not predict * (openspec/changes/module-integrity-rigor, D4). * * This SSHes to every system the module deploys, so it is gated behind * `--deep` and never runs in a default pass. * * The honest caveat, in the spec and not only in the code: check mode does not * evaluate a task it cannot support — it SKIPS it — so a role built from * `command:` / `shell:` tasks can finish a check run with `changed=0` having * never been applied to the host at all. So a skip is `unmeasured`, never a * pass, and a finding here is `drift`, never `blocked`. A check that cries wolf * is the disease; shipping one here would be an unusually stupid way to catch * it. */ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { ModuleManifest } from '../../manifest/schema'; import { executeAnsible, parseAnsibleRecap } from '../../services/deploy-ansible'; /** * `unmeasured` is not a hedge and must never collapse into `converged`. * Absence of a change is not evidence of convergence when nothing was assessed. */ export type HostPlaneState = 'converged' | 'drift' | 'unmeasured'; export interface HostPlaneFinding { hostname: string; state: HostPlaneState; /** Why, in operator-facing words. Always set for anything but `converged`. */ detail?: string; } export interface HostPlaneResult { findings: HostPlaneFinding[]; /** * Set when the module declared `verify.deep: false`. Carried so callers can * PRINT the opt-out — an opt-out nobody sees is a check that quietly * disappeared. */ optedOut?: { reason: string }; } /** * Classify one Ansible `PLAY RECAP` line. Pure, so the interesting decisions * are testable without an SSH round trip. */ export function classifyHostRecap( recap: { host: string; changed: number; unreachable: number; failed: number; skipped: number }, moduleId: string, ): HostPlaneFinding { if (recap.unreachable > 0 || recap.failed > 0) { return { hostname: recap.host, state: 'unmeasured', detail: 'The host could not be evaluated — it did not answer, or the play errored on it.', }; } if (recap.skipped > 0) { return { hostname: recap.host, state: 'unmeasured', detail: `${recap.skipped} task(s) check mode cannot evaluate were skipped, so convergence was not measured. Prefer check-capable Ansible modules (copy, template, lineinfile, file, package, service), or give a command/shell task an honest changed_when:.`, }; } if (recap.changed > 0) { return { hostname: recap.host, state: 'drift', detail: `The playbook would change ${recap.changed} thing(s) on this host, so what is running is not what celilo generated. Run 'celilo module deploy ${moduleId}' to converge it.`, }; } return { hostname: recap.host, state: 'converged' }; } /** * Evaluate the module's generated playbook against its systems in check mode. * * `executeAnsible(..., { check: true })` is the existing plumbing and the only * one — `--check` is one argument, not a second execution path. */ export async function verifyModuleOnHosts(args: { moduleId: string; manifest: ModuleManifest; generatedPath: string; /** Injected in tests, exactly as `verifyAspectCoverage` does it. */ execute?: typeof executeAnsible; }): Promise { const { moduleId, manifest, generatedPath } = args; if (manifest.verify?.deep === false) { return { findings: [], optedOut: { reason: manifest.verify.reason } }; } const playbookPath = join(generatedPath, 'ansible', 'playbook.yml'); if (!existsSync(playbookPath)) { return { findings: [ { hostname: '(none)', state: 'unmeasured', detail: `No generated playbook at ${playbookPath}. Run 'celilo module generate ${moduleId}' first.`, }, ], }; } const execute = args.execute ?? executeAnsible; const result = await execute(generatedPath, { check: true, noInteractive: true }); const recaps = parseAnsibleRecap(result.output ?? ''); if (recaps.length === 0) { // A run that produced no recap at all must not read as success. This is // the shape celilo#951 got wrong in another comparator: nothing measured, // rendered as nothing wrong. return { findings: [ { hostname: '(none)', state: 'unmeasured', detail: `Ansible produced no PLAY RECAP, so nothing was measured.${result.error ? ` ${result.error}` : ''}`, }, ], }; } return { findings: recaps.map((recap) => classifyHostRecap(recap, moduleId)) }; }