/** * Ansible Execution Service * * Executes Ansible playbooks with vault password and streaming progress */ import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getActiveDisplay, log } from '../cli/prompts'; import { getVaultPassword } from '../secrets/vault'; import { shellEscape } from '../utils/shell'; import { executeBuildWithProgress } from './build-stream'; export interface AnsibleResult { success: boolean; output: string; error?: string; } /** * Parse raw Ansible output lines into concise human-readable status. * Returns null for lines that should be suppressed (decorative separators, etc.) */ const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g; /** * Emit structured progress markers to stdout for each Ansible play/task. * Uses console.log (same channel as log.info) so the e2e runner sees them * in real-time. The runner matches [ansible:play] / [ansible:task] patterns. * * When an active ProgressDisplay is handling the output, skip emission * entirely: parseAnsibleLine is already routing the same play/task lines * through display.subEvent (which emits [progress:sub] markers for * cele2e or renders directly for an interactive terminal). Emitting * both would duplicate every task on the user's screen. */ function emitAnsibleProgress(rawChunk: string): void { if (getActiveDisplay()) return; // In non-TTY mode (e.g. inside a Docker exec) write to stdout, which is // line-buffered by stdbuf. stderr may arrive batched or out-of-order through // docker exec -T, so stdout gives the e2e runner reliable real-time delivery. const out = process.stdout.isTTY ? process.stderr : process.stdout; for (const line of rawChunk.split('\n')) { const stripped = line.replace(ANSI_ESCAPE, '').trim(); if (/^PLAY \[/.test(stripped)) { const name = stripped.replace(/^PLAY \[/, '').replace(/\].*$/, ''); out.write(`[ansible:play] ${name}\n`); } else if (/^TASK \[/.test(stripped)) { const name = stripped.replace(/^TASK \[/, '').replace(/\].*$/, ''); out.write(`[ansible:task] ${name}\n`); } else if (/^RUNNING HANDLER \[/.test(stripped)) { const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, ''); out.write(`[ansible:handler] ${name}\n`); } else if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) { out.write(`[ansible:recap] ${stripped}\n`); } } } /** * Reformat an Ansible PLAY RECAP host line into a compact summary. * * Input (raw, ~100 columns): * www : ok=11 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 * * Output (compact, ~40 columns, ✓ on success / ✗ on any failure or * unreachable host, zero-value fields dropped): * ✓ www ok=11 changed=2 */ function formatAnsibleRecap(stripped: string): string { const match = stripped.match(/^(\S+)\s*:\s*(.+)$/); if (!match) return ` ${stripped}`; const host = match[1]; const stats = match[2]; const counts: Record = {}; for (const m of stats.matchAll(/(\w+)=(\d+)/g)) { counts[m[1]] = Number.parseInt(m[2], 10); } const failures = (counts.failed ?? 0) + (counts.unreachable ?? 0); const icon = failures > 0 ? '✗' : '✓'; // Order matters: ok / changed first (good news), then anything bad. // Skipped is dropped — it's noise from `when:` clauses. const fieldOrder = ['ok', 'changed', 'failed', 'unreachable', 'rescued', 'ignored']; const parts: string[] = []; for (const f of fieldOrder) { if (counts[f]) parts.push(`${f}=${counts[f]}`); } return ` ${icon} ${host} ${parts.join(' ')}`; } function parseAnsibleLine(line: string): string | null { const stripped = line.replace(ANSI_ESCAPE, '').trim(); if (/^PLAY \[/.test(stripped)) { const name = stripped.replace(/^PLAY \[/, '').replace(/\].*$/, ''); return `▶ ${name}`; } if (/^TASK \[/.test(stripped)) { const name = stripped.replace(/^TASK \[/, '').replace(/\].*$/, ''); return ` • ${name}`; } if (/^RUNNING HANDLER \[/.test(stripped)) { const name = stripped.replace(/^RUNNING HANDLER \[/, '').replace(/\].*$/, ''); return ` ↺ handler: ${name}`; } // `ok:` and `skipping:` are pure noise — every successful task emits one // and the cele2e runner already drops them. Keep `changed`/`failed`/`fatal` // because they're the lines a user actually wants to see scrolling by. if (/^ok:/.test(stripped)) return null; if (/^skipping:/.test(stripped)) return null; if (/^changed:/.test(stripped)) return ' changed'; if (/^failed:/.test(stripped)) return ' FAILED'; if (/^fatal:/.test(stripped)) return ` FATAL: ${stripped.slice(7)}`; // The "PLAY RECAP" header is redundant — the per-host summary line // that follows already names the host and the counts. if (/^PLAY RECAP/.test(stripped)) return null; if (/^\w[\w.-]+ *:/.test(stripped) && stripped.includes('ok=')) { return formatAnsibleRecap(stripped); } // Suppress decorative separator lines (all * or = chars) if (/^[*=\s]+$/.test(stripped)) return null; return null; } /** * Execute Ansible playbook with streaming progress * Execution function - performs software deployment * * @param generatedPath - Path to generated module artifacts * @returns Ansible execution result */ export async function executeAnsible( generatedPath: string, options?: { noInteractive?: boolean; check?: boolean; tags?: string[] }, ): Promise { const ansibleDir = join(generatedPath, 'ansible'); const inventoryPath = join(ansibleDir, 'inventory', 'hosts.ini'); const playbookPath = join(ansibleDir, 'playbook.yml'); const vaultPassword = await getVaultPassword(); const tempDir = await mkdtemp(join(tmpdir(), 'celilo-vault-')); const passwordPath = join(tempDir, 'vault-pass'); await writeFile(passwordPath, vaultPassword, { mode: 0o600 }); const logPath = join(generatedPath, 'deploy.log'); log.success('Configuring host (ansible-playbook)'); try { const result = await executeBuildWithProgress({ command: 'ansible-playbook', args: [ '-i', shellEscape(inventoryPath), '--vault-password-file', shellEscape(passwordPath), // `--check` evaluates the play without changing anything, so a caller // can ask "is this already applied?" of the HOST rather than of a // stored record of the host (celilo#902 design D6). One argument, not a // second execution path — everything else about the run is identical. ...(options?.check ? ['--check'] : []), // `--tags` scopes the run to the tagged tasks. The static-content // converge uses it (capability-owned-tables D10): the full playbook // re-templates the bootstrap Caddyfile, which would overwrite the // REAL config the provider's reconcile wrote to disk and reload caddy // into serving nothing. A publish converge must touch /srv/www and // nothing else. An unscoped run (the deploy) still executes every // untagged task alongside the tagged ones. ...(options?.tags?.length ? ['--tags', options.tags.join(',')] : []), shellEscape(playbookPath), ], cwd: ansibleDir, // PYTHONUNBUFFERED=1: forces Python (Ansible) to flush stdout immediately // instead of buffering when writing to a pipe — makes progress stream in // real-time rather than appearing in one burst at the end. // ANSIBLE_SSH_PIPELINING: enables SSH pipelining for performance without ControlMaster env: { PYTHONUNBUFFERED: '1', ANSIBLE_SSH_PIPELINING: 'True', }, title: 'Deploying software', noInteractive: options?.noInteractive, filterOutput: parseAnsibleLine, onOutput: emitAnsibleProgress, }); const timestamp = new Date().toISOString(); const logHeader = `\n--- Ansible deploy ${timestamp} ---\n`; await appendFile(logPath, logHeader + result.output, 'utf-8'); if (!result.success) { log.warn(`Full deploy log: ${logPath}`); return { success: false, output: result.output, error: result.error || 'Ansible deployment failed', }; } log.success('Software deployed'); return { success: true, output: result.output, }; } finally { await rm(tempDir, { recursive: true, force: true }); } } /** * One host's line from Ansible's `PLAY RECAP`. * * web-01 : ok=5 changed=2 unreachable=0 failed=0 skipped=1 … * * `skipped` is the field that matters and the one it is easy not to look at. A * task that does NOT support check mode is not evaluated — it is skipped, and * reported as skipped rather than changed. So a role built from `command:` / * `shell:` tasks finishes a `--check` run with `changed=0` having never been * applied to the host at all. Read as a boolean that says "applied", which is a * confidently clean answer about an unconverged system. Callers must treat * `skipped > 0` as NOT MEASURED rather than as applied. */ export interface AnsibleHostRecap { host: string; ok: number; changed: number; unreachable: number; failed: number; skipped: number; } const RECAP_LINE = /^(\S+)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)\s+skipped=(\d+)/; /** * Parse every host line out of an Ansible run's `PLAY RECAP`. * * Tolerant of ANSI colour and of the recap appearing anywhere in the stream, * because the output here has been through a progress filter. Lines that are * not recap lines are ignored rather than throwing — a run that produced no * recap at all returns an empty array, which callers must not read as success. */ export function parseAnsibleRecap(output: string): AnsibleHostRecap[] { const recaps: AnsibleHostRecap[] = []; for (const raw of output.split('\n')) { const match = RECAP_LINE.exec(raw.replace(ANSI_ESCAPE, '').trim()); if (!match) continue; recaps.push({ host: match[1], ok: Number(match[2]), changed: Number(match[3]), unreachable: Number(match[4]), failed: Number(match[5]), skipped: Number(match[6]), }); } return recaps; }