/** * The one place conductor runs a command as root. * * Every host-provisioning step used to be *printed* as a `sudo …` line for the * operator to retype, which is not caution — it is an audit trail nobody keeps * and a transcription error nobody catches. This module keeps the audit and * removes the retyping: the caller composes structured steps, this renders all * of them with their exact argv, takes **one** confirm, and then runs them. * * Three invariants, each of which a convenient shortcut would break: * * - **The CLI never escalates itself.** Only the individual steps below are * privileged. `sudo omp-conductor setup host` would resolve the config, * `$OMP_CONDUCTOR_HOME`, `homedir()` and the unit's own `User=` as root and * bake the wrong account into a unit for a fleet that runs as somebody * else — a timer that goes green while writing state no worker can read. * {@link assertNoSelfExec} refuses to build such a step at all. * - **One confirm, everything named.** A confirm that authorises commands it * did not print is not consent, so the render happens first and lists the * exact argv — including which steps deliberately run *without* `sudo`. * - **A failure hands the work back.** The first non-`bestEffort` failure * stops the batch and prints the failed title, its stderr, and every * not-yet-run command verbatim, so an operator can finish by hand from the * terminal they are already looking at. */ import type { WizardUi } from "./wizard-ui.ts"; import { shellQuote } from "./shell.ts"; export interface PrivilegedStep { /** One line naming what this step accomplishes, shown in the plan. */ title: string; /** The exact argv. No shell: no quoting rules, no word splitting, no `&&`. */ argv: readonly string[]; /** * A failure here is reported and the batch continues. For steps whose failure * is not a broken install — reloading a systemd that was never running, say. */ bestEffort?: boolean; /** * Runs as the invoking account with **no** `sudo` prefix, while still * appearing in the same plan under the same confirm. * * `setup graph`'s index-only clones are the case this exists for: a * root-owned clone under the fleet user's cache is precisely the failure the * escalation guard exists to prevent, but the operator must still see the * clone and the timer install as one plan they approve once. */ unprivileged?: boolean; } /** One spawn the runner needs. Injectable so tests never touch a real `sudo`. */ export interface PrivilegedSpawn { argv: readonly string[]; /** * `sudo -v` needs the terminal for its password prompt, so its stdio is * inherited whole and nothing is captured. */ interactive: boolean; } export interface PrivilegedResult { exitCode: number; /** Captured stderr, or `""` for an interactive spawn that owned the terminal. */ stderr: string; } export interface PrivilegedDeps { spawn(request: PrivilegedSpawn): Promise; /** `undefined` on a platform without uids, which is never privileged here. */ getuid(): number | undefined; } export type PrivilegedOutcome = /** The confirm was answered no, or dismissed. Nothing ran. */ | { kind: "declined" } /** Every step ran; `bestEffort` failures are listed but did not stop the batch. */ | { kind: "completed"; softFailures: { step: PrivilegedStep; stderr: string }[] } /** A required step failed. `remaining` are the steps that never ran. */ | { kind: "failed"; step: PrivilegedStep; exitCode: number; stderr: string; remaining: readonly PrivilegedStep[]; }; export interface RunPrivilegedOptions { deps?: PrivilegedDeps; /** Confirm title. Defaults to a generic one; callers name their verb. */ title?: string; /** Stable answer-file key for this batch's confirmation. */ answerKey?: string; /** Extra lines shown above the step list — what this batch is for. */ preamble?: readonly string[]; /** * Runs after the confirm and the sudo priming, immediately before the first * step. A caller that must do something between consent and execution — the * pause/drain of a draining restart — does it here, under the same confirm, * so the operator authorises the whole batch at once. A throw propagates to * the caller with nothing restarted. */ beforeRun?: () => Promise; } /** * Shell-quoted rendering of one step, exactly as an operator would type it. * * The steps are argv precisely so nothing here is load-bearing at execution * time; this is for the plan, the failure remainder and the print-only modes. */ export function formatStep(step: PrivilegedStep, asRoot = false): string { const prefix = step.unprivileged || asRoot ? [] : ["sudo"]; return [...prefix, ...step.argv].map(shellQuote).join(" "); } /** * Refuses to build a step that re-runs conductor itself. * * Called on every step before anything is rendered, because the failure it * prevents is invisible: `sudo omp-conductor …` exits 0 and leaves a unit * naming the wrong account. Cheap, and it turns a future refactor's mistake * into a test failure rather than a broken fleet. */ function assertNoSelfExec(steps: readonly PrivilegedStep[]): void { for (const step of steps) { const offender = step.argv.find((a) => { const base = a.split("/").pop() ?? a; return base === "omp-conductor" || base === "cli.ts"; }); if (offender !== undefined) { throw new Error( `privileged step "${step.title}" would re-exec conductor (${offender}) — ` + "only individual structured steps are ever privileged", ); } } } async function spawnReal(request: PrivilegedSpawn): Promise { const proc = Bun.spawn(request.argv as string[], { // stdin is inherited even for captured steps: a `sudo` whose timestamp // expired mid-batch must be able to prompt rather than fail on a closed fd. stdin: "inherit", // stdout is inherited so a long step (`systemctl start` on a oneshot that // indexes for minutes) is visible while it runs instead of after it. stdout: "inherit", stderr: request.interactive ? "inherit" : "pipe", }); const [exitCode, stderr] = await Promise.all([ proc.exited, request.interactive ? Promise.resolve("") : new Response(proc.stderr).text(), ]); return { exitCode, stderr }; } export const DEFAULT_PRIVILEGED_DEPS: PrivilegedDeps = { spawn: spawnReal, getuid: () => process.getuid?.(), }; /** * Renders `steps`, takes one confirm, then runs them in order. * * As root, every step runs directly: there is nothing to escalate, and a `sudo` * prefix on a root-run fleet is an extra dependency for no gain. Otherwise one * `sudo -v` primes the credential with the terminal it needs, and each * privileged step then runs as `sudo -- ` — `--` so a step whose first * argument begins with a dash cannot be read as a `sudo` flag. */ export async function runPrivileged( steps: readonly PrivilegedStep[], ui: WizardUi, options: RunPrivilegedOptions = {}, ): Promise { assertNoSelfExec(steps); const deps = options.deps ?? DEFAULT_PRIVILEGED_DEPS; const asRoot = deps.getuid() === 0; const needsSudo = !asRoot && steps.some((s) => s.unprivileged !== true); ui.notify( [ ...(options.preamble ?? []), ...(options.preamble === undefined || options.preamble.length === 0 ? [] : [""]), ...steps.flatMap((step, index) => [ `${index + 1}. ${step.title}${step.unprivileged === true && !asRoot ? " (as you, not root)" : ""}`, ` ${formatStep(step, asRoot)}`, ]), "", asRoot ? "Running as root — these run directly, with no sudo." : needsSudo ? "sudo will ask for your password once, before the first step." : "None of these need root.", "Nothing has been run yet.", ].join("\n"), "info", ); const go = await ui.confirm( options.title ?? "Run these steps now?", `${steps.length} step(s), in the order shown. Anything that fails stops the rest and prints what is left.`, { key: options.answerKey ?? "run-privileged-steps" }, ); // `undefined` is a dismissal rather than a "no", but for a batch that has not // started they mean the same thing: run nothing. if (go !== true) return { kind: "declined" }; if (needsSudo) { const primed = await deps.spawn({ argv: ["sudo", "-v"], interactive: true }); if (primed.exitCode !== 0) { return { kind: "failed", step: { title: "authenticate with sudo", argv: ["sudo", "-v"] }, exitCode: primed.exitCode, stderr: primed.stderr, remaining: steps, }; } } await options.beforeRun?.(); const softFailures: { step: PrivilegedStep; stderr: string }[] = []; for (const [index, step] of steps.entries()) { ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info"); const argv = step.unprivileged === true || asRoot ? step.argv : ["sudo", "--", ...step.argv]; const result = await deps.spawn({ argv, interactive: false }); if (result.exitCode === 0) continue; if (step.bestEffort === true) { softFailures.push({ step, stderr: result.stderr }); ui.notify(` continued past a best-effort failure: ${firstLine(result.stderr)}`, "warning"); continue; } const remaining = steps.slice(index + 1); ui.notify(formatFailure(step, result, remaining, asRoot), "error"); return { kind: "failed", step, exitCode: result.exitCode, stderr: result.stderr, remaining }; } return { kind: "completed", softFailures }; } function firstLine(stderr: string): string { const line = stderr.trim().split("\n")[0]; return line === undefined || line === "" ? "no stderr" : line; } /** * What the operator is left holding when a step fails: the step, why, and the * rest of the batch verbatim so finishing by hand is a paste rather than a * reconstruction. */ export function formatFailure( step: PrivilegedStep, result: PrivilegedResult, remaining: readonly PrivilegedStep[], asRoot = false, ): string { return [ `Stopped: ${step.title} exited ${result.exitCode}.`, ` ${formatStep(step, asRoot)}`, ...(result.stderr.trim() === "" ? [" (no stderr — its output is above)"] : result.stderr.trimEnd().split("\n").map((l) => ` ${l}`)), ...(remaining.length === 0 ? ["", "Nothing was left to run."] : [ "", `${remaining.length} step(s) did NOT run. To finish by hand:`, "", ...remaining.map((s) => ` ${formatStep(s, asRoot)}`), ]), ].join("\n"); }