/** * The module runtimes a run stocks are rebuilt by the run, like everything else it tests. * * A per-module bed stocks the module's runtime image — the tool runtime carrying that module's * code — from the workstation's image store, by tag. It was built by hand, by a script the suite * never called, and on the day this was written the images for six modules about to run dated * from two weeks before the manifests they were installed with (novox/hq 04-ISSUES/075). The * suite's own rule, *the run rebuilds what it tests*, was held for the host and the control plane * and not for these. * * So: for the beds about to run, every `mesh-runtime-:development` their scenarios stock * is compared against the source it is built from — the module in the catalogue, and the tool * runtime and SDK it is built on — and rebuilt where the image is older, missing, or the source is * uncommitted. A rebuild that fails stops the suite, the way a stale host binary would. */ import { readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { parse } from "yaml"; import type { Build } from "./rebuild.ts"; import { catalogueRoot } from "./repos.ts"; /** A runtime image a scenario stocks by tag, and the module it is built from. */ export interface StockedRuntime { tag: string; module: string; } /** * Tags whose name is not the module's. The audit logger's runtime was the first, built before the * script was generalised, and the scenario still stocks it under the module's slug. */ const NAMED_OTHERWISE: Record = { "mesh-runtime-audit": "audit-logger" }; const RUNTIME_TAG = /^(mesh-runtime-[a-z0-9-]+):development$/; /** The runtimes the scenarios of these beds stock, each named once. */ export function runtimesStockedBy(testFiles: string[], root: string = process.cwd()): StockedRuntime[] { const seen = new Map(); for (const file of testFiles) { const text = readFileSync(resolve(root, file), "utf8"); const named = /const SCENARIO = "([^"]+)"/.exec(text); if (!named) continue; const scenario = parse(readFileSync(join(root, "scenarios", `${named[1]}.yml`), "utf8")) as { images?: unknown }; for (const image of Array.isArray(scenario.images) ? scenario.images : []) { const m = RUNTIME_TAG.exec(String(image)); if (!m) continue; const repository = m[1]!; seen.set(repository, { tag: String(image), module: NAMED_OTHERWISE[repository] ?? repository.slice("mesh-runtime-".length) }); } } return [...seen.values()]; } /** When an image was made and when its source last changed, in seconds; null for no image. */ export interface Ages { image: number | null; /** Infinity where the source has uncommitted changes: what is on disk is newer than any commit. */ source: number; } /** stale is whether the image predates its source, or is not there at all. */ export function isStale(a: Ages): boolean { return a.image === null || a.image < a.source; } /** A command runner, for the two questions asked below; injected so the rules can be tested. */ export type Ask = (command: string, args: string[]) => { status: number | null; stdout: string }; const ask: Ask = (command, args) => { const ran = spawnSync(command, args, { encoding: "utf8" }); return { status: ran.status, stdout: ran.stdout ?? "" }; }; /** ageOfImage is when the local image store made this tag, or null when it holds no such image. */ export function ageOfImage(tag: string, run: Ask = ask): number | null { const ran = run("docker", ["image", "inspect", "--format", "{{.Created}}", tag]); if (ran.status !== 0) return null; const at = Date.parse(ran.stdout.trim()); return Number.isNaN(at) ? null : Math.floor(at / 1000); } /** * ageOfSource is the newest commit touching what the runtime is built from: the module's directory * in the catalogue, and the whole of each repository it is built on top of. Uncommitted changes in * any of them are newer than every commit. */ export function ageOfSource(catalogue: string, module: string, builtOn: string[], run: Ask = ask): number { let newest = 0; const at = (dir: string, path?: string): number => { const args = ["-C", dir, "log", "-1", "--format=%ct"]; if (path) args.push("--", path); const ran = run("git", args); const t = Number.parseInt(ran.stdout.trim(), 10); return ran.status === 0 && Number.isFinite(t) ? t : 0; }; const dirty = (dir: string, path?: string): boolean => { const args = ["-C", dir, "status", "--porcelain"]; if (path) args.push("--", path); const ran = run("git", args); return ran.status === 0 && ran.stdout.trim() !== ""; }; if (dirty(catalogue, `modules/${module}`)) return Infinity; newest = Math.max(newest, at(catalogue, `modules/${module}`)); for (const dir of builtOn) { if (dirty(dir)) return Infinity; newest = Math.max(newest, at(dir)); } return newest; } /** * plannedRuntimes is the runtime builds these beds need before they run, given where the run was * pointed: nothing where no catalogue was named (the beds skip), one build per stale image * otherwise. The repositories the runtime is built on are the siblings the build script itself * reads (`MESH_TOOLS`, `MESH_SDK`, or the checkouts beside this one). */ export function plannedRuntimes(testFiles: string[], env: NodeJS.ProcessEnv = process.env, root: string = process.cwd(), run: Ask = ask): Build[] { const named = env["MESH_LAB_CATALOG"]; if (!named) return []; const catalogue = catalogueRoot(named); const builtOn = [ env["MESH_TOOLS"] ?? resolve(root, "..", "mesh-tools"), env["MESH_SDK"] ?? resolve(root, "..", "mesh-sdk"), ]; const builds: Build[] = []; for (const runtime of runtimesStockedBy(testFiles, root)) { const ages: Ages = { image: ageOfImage(runtime.tag, run), source: ageOfSource(catalogue, runtime.module, builtOn, run), }; if (!isStale(ages)) continue; builds.push({ what: `runtime ${runtime.tag}`, in: root, argv: ["scripts/build-module-runtime.sh", runtime.module, join(tmpdir(), `mesh-lab-${runtime.module}.tar`)], env: { MESH_CATALOG: catalogue, RUNTIME_TAG: runtime.tag }, }); } return builds; }