/** * `place:` — putting something inside the machines. * * Until now the lab raised an underlay and put nothing on it: correct, and useless, because * the thing it exists to test did not exist (novox/hq 03-DESIGN/00-as-is/11-the-lab.md). Tier * 0 now does, so this is the seam where the lab acquires a consumer. * * Only `host` is placeable. Everything else in the placement vocabulary — the substrate, a * control plane, a forge — is still refused by name rather than ignored, because a scenario * that declares something and raises without it is the fault this lab was built to catch * (novox/hq 04-ISSUES/003). */ import type { Scenario } from "../declaration/types.ts"; import { incus, incusOk } from "../incus/client.ts"; /** What this stage can put inside a machine. */ export const PLACEABLE = ["host"] as const; export type Placeable = (typeof PLACEABLE)[number]; /** Where the host binary lives on a machine once placed. */ export const HOST_PATH = "/usr/local/bin/mesh-host"; export interface Placement { machine: string; artifacts: string[]; } /** * Resolve `place:` to one list per machine. * * `all:` applies to every machine; a per-machine entry **overrides** it rather than adding to * it, which is what the declaration design says and is worth being exact about — a scenario * naming one artifact for one machine gets that artifact and not that artifact plus the rest. */ export function planPlacements(scenario: Scenario): Placement[] { const place = scenario.place; if (!place) return []; const all = place.all ?? []; return Object.keys(scenario.machines).map((machine) => { const own = place[machine]; return { machine, artifacts: own ?? all }; }).filter((p) => p.artifacts.length > 0); } /** * The host binary to place, from the environment. * * Deliberately an explicit path rather than a search. The declaration design leaves *where * `place:` gets its artifacts from* open — before the mesh is self-hosting they come from * outside, afterwards from the mesh — and it suggests a named source rather than a path. This * is neither: it is the smallest thing that works while that stays undecided, and it refuses * loudly rather than guessing, so nothing here hardens into the answer by accident. */ export function hostBinaryPath(): string | null { return process.env["MESH_LAB_HOST_BINARY"] ?? null; } export class PlacementError extends Error { readonly machine: string; constructor(machine: string, message: string) { super(message); this.name = "PlacementError"; this.machine = machine; } } export interface PlacedHost { machine: string; /** What the host reported about the machine, read back from it. */ profile: unknown; version: string; } /** * Put the host on a machine and ask it what the machine is. * * The result is read back from the running binary, never assumed from the fact that the copy * succeeded (novox/hq ADR 0035). A file arriving is not a host working, which is the same * distinction the host itself makes about installed packages. */ export async function placeHost( instanceName: string, machine: string, binary: string, log: (message: string) => void = () => {}, ): Promise { await incus(["file", "push", binary, `${instanceName}${HOST_PATH}`, "--mode", "0755"], 180_000); // Read back that it is there and executable before trusting it to answer questions. const version = (await incusOk(["exec", instanceName, "--", HOST_PATH, "version"], 60_000))?.trim(); if (!version) { throw new PlacementError( machine, `the host binary was copied to ${machine} but does not run there. A file arriving is ` + `not a host working.`, ); } const reported = await incusOk( ["exec", instanceName, "--", HOST_PATH, "profile", "--json"], 120_000, ); if (!reported) { throw new PlacementError( machine, `the host runs on ${machine} (${version}) but reported no profile. A host that cannot ` + `say what a machine is cannot be asked to change it.`, ); } let profile: unknown; try { profile = JSON.parse(reported); } catch (err) { throw new PlacementError( machine, `the host on ${machine} reported something that is not a profile: ${(err as Error).message}`, ); } log(` placed the host on ${machine} (${version})`); return { machine, profile, version }; } /** Place everything a scenario declares. */ export async function applyPlacements( scenario: Scenario, machineNames: Map, log: (message: string) => void = () => {}, ): Promise { const placements = planPlacements(scenario); if (placements.length === 0) return []; const binary = hostBinaryPath(); if (!binary) { throw new Error( `this scenario places the host, and no host binary was given. Set ` + `MESH_LAB_HOST_BINARY to a built mesh-host. Guessing at a path would place ` + `whatever happened to be there.`, ); } const placed: PlacedHost[] = []; for (const { machine, artifacts } of placements) { const name = machineNames.get(machine); if (!name) continue; for (const artifact of artifacts) { if (artifact !== "host") continue; // refused earlier; belt and braces placed.push(await placeHost(name, machine, binary, log)); } } return placed; }