The lab raised an underlay and put nothing on it: correct, and useless, because the thing it exists to test did not exist. Tier 0 now does, so `place: [host]` works and a raised scenario finally contains something. The refusal narrows rather than disappearing. A scenario placing a host and a substrate is told which half is missing, by name — not that `place:` is unsupported when half of it now works. Placement reads back rather than assuming. A file arriving is not a host working, so the binary is run before it is trusted to answer questions, and what it reports is read from the machine (ADR 0035). The binary comes from an explicit path, because the declaration design leaves where artifacts come from open and a search would harden into the answer by accident. The integration test that matters is the one asserting the host reports the MACHINE and not the workstation that placed it. A raised VM and this workstation differ in every capability — root versus uid 1000, a clean init versus a degraded one, no docker versus docker, no wireguard versus wg0 — so a host reporting the wrong machine is obvious here and invisible anywhere else. And the placed host independently confirms ADR 0031: overlay absent on a freshly raised machine. The underlay suite already asserted that by looking for wireguard interfaces; this is a second witness rather than the same check twice. Two tests failed the moment placement worked, which is what they were for. They defended "there is nothing to place yet" while that was true; the decision changed, so they change with it rather than being deleted. Gate: 75 unit, 20 integration.
157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
/**
|
|
* `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<PlacedHost> {
|
|
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<string, string>,
|
|
log: (message: string) => void = () => {},
|
|
): Promise<PlacedHost[]> {
|
|
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;
|
|
}
|