diff --git a/modules/lab/package.json b/modules/lab/package.json index a3138222..4988d957 100644 --- a/modules/lab/package.json +++ b/modules/lab/package.json @@ -4,6 +4,9 @@ "description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).", "type": "module", "private": true, + "scripts": { + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + }, "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" } } diff --git a/modules/lab/test/runs.test.ts b/modules/lab/test/runs.test.ts new file mode 100644 index 00000000..2389b122 --- /dev/null +++ b/modules/lab/test/runs.test.ts @@ -0,0 +1,100 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts"; + +// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and +// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its +// setup, in 9 seconds, before anything was built. + +function aCheckout(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "lab-checkout-")); + for (const [path, content] of Object.entries(files)) { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), content); + } + return root; +} + +test("a checkout with a lock file at its root is installed there", () => { + const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}", "node-tools/package.json": "{}" }); + assert.deepEqual(installPlan(root), { install: [root], unlocked: [] }); + rmSync(root, { recursive: true, force: true }); +}); + +test("a checkout whose package lives one directory down is installed there, as mesh-tools' node-tools", () => { + const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}", "Dockerfile": "" }); + assert.deepEqual(installPlan(root), { install: [join(root, "node-tools")], unlocked: [] }); + rmSync(root, { recursive: true, force: true }); +}); + +test("a package.json with no lock file is named, never installed", () => { + const root = aCheckout({ "package.json": "{}" }); + assert.deepEqual(installPlan(root), { install: [], unlocked: [root] }); + rmSync(root, { recursive: true, force: true }); +}); + +// The shell a run executes follows the same rule: npm is asked where installPlan says, and a checkout +// with nothing installable fails the run loudly, naming it. +function runStep(root: string, build: boolean): { ok: boolean; said: string; asked: string[] } { + const bin = mkdtempSync(join(tmpdir(), "lab-npm-")); + const record = join(bin, "asked"); + writeFileSync(join(bin, "npm"), `#!/bin/sh\necho "$(pwd) $*" >> ${JSON.stringify(record)}\n`); + chmodSync(join(bin, "npm"), 0o755); + const ran = spawnSync("bash", ["-c", `set -euo pipefail\n${installStep(root, build)}`], { + encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }); + let asked: string[] = []; + try { + asked = readFileSync(record, "utf8").trim().split("\n").filter(Boolean); + } catch { /* nothing asked */ } + rmSync(bin, { recursive: true, force: true }); + return { ok: ran.status === 0, said: `${ran.stdout}${ran.stderr}`, asked }; +} + +test("a run installs and builds mesh-tools in node-tools, where its lock file is", () => { + const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}" }); + const r = runStep(root, true); + assert.ok(r.ok, r.said); + assert.deepEqual(r.asked, [ + `${join(root, "node-tools")} ci --no-audit --no-fund --loglevel=error`, + `${join(root, "node-tools")} run build --if-present`, + ]); + rmSync(root, { recursive: true, force: true }); +}); + +test("a run installs a checkout with its lock file at the root there, and builds only when asked", () => { + const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}" }); + const r = runStep(root, false); + assert.ok(r.ok, r.said); + assert.deepEqual(r.asked, [`${root} ci --no-audit --no-fund --loglevel=error`]); + rmSync(root, { recursive: true, force: true }); +}); + +test("a run fails at its setup, naming the checkout, when nothing of it can be installed", () => { + const layouts: Record[] = [{ "package.json": "{}" }, { "Dockerfile": "" }, { "sub/package.json": "{}" }]; + for (const files of layouts) { + const root = aCheckout(files); + const r = runStep(root, false); + assert.equal(r.ok, false, `installed ${JSON.stringify(files)}`); + assert.match(r.said, /issue 624/); + assert.deepEqual(r.asked, []); + rmSync(root, { recursive: true, force: true }); + } +}); + +// A run checks out mesh-tools-go too (novox/hq issue 616): genesis builds the Go toolchain from the +// repository beside mesh-tools, and a bed that raises genesis serves it. And it tells the beds where the +// checkouts are. +test("a run checks out mesh-tools-go and says where its checkouts are", () => { + assert.ok(REPOSITORIES.includes("mesh-tools-go")); + const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {}); + assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/); + assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/); + assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/); + for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo); +}); diff --git a/modules/lab/tools/index.ts b/modules/lab/tools/index.ts index 53dbd754..718a271f 100644 --- a/modules/lab/tools/index.ts +++ b/modules/lab/tools/index.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js"; +import { installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js"; export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs"; @@ -26,7 +26,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { if (clone.status !== 0) return { ok: false, output: clone.stderr }; spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" }); const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" }); - return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() }; + // **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every + // repository it clones answers on the forge, and every one it installs has a lock file where the run + // installs from. + const problems = setupProblems(forge, `${work}/check-setup`); + const output = [`${check.stdout}${check.stderr}`.trim(), ...problems].filter(Boolean).join("\n"); + return { ok: check.status === 0 && problems.length === 0, output }; }, }, { @@ -89,6 +94,38 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { ]; } +/** + * What would stop a run at its setup, in sentences: a repository the run clones that the forge does not + * answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup + * would go through. + */ +export function setupProblems(forge: string, dir: string): string[] { + const problems: string[] = []; + spawnSync("rm", ["-rf", dir]); + for (const repo of REPOSITORIES) { + const url = `${forge}/novox/${repo}.git`; + const remote = spawnSync("git", ["ls-remote", "--heads", url, "main"], { encoding: "utf8" }); + if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) { + problems.push(`a run checks out ${repo}, and ${url} answers no main branch`); + continue; + } + if (!NPM_REPOSITORIES.includes(repo)) continue; + const at = `${dir}/${repo}`; + const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", url, at], { encoding: "utf8" }); + if (clone.status !== 0) { + problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`); + continue; + } + const plan = installPlan(at); + for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`); + if (plan.install.length === 0 && plan.unlocked.length === 0) { + problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`); + } + } + spawnSync("rm", ["-rf", dir]); + return problems; +} + /** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */ export function wordIn(file: string | undefined, word: string): string { if (!file) return ""; diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index d7862b59..834605fb 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -10,8 +10,55 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */ -export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"]; +/** + * The repositories a lab run checks out, side by side, as the lab expects its siblings. mesh-tools-go is + * one since genesis builds the Go toolchain at its base step, from the repository beside mesh-tools + * (novox/hq issue 616): a bed that raises genesis serves it from its forge. + */ +export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-tools-go", "mesh-sdk"]; + +/** The checkouts whose JavaScript dependencies a run installs: the lab, the SDK and the base. */ +export const NPM_REPOSITORIES = ["mesh-sdk", "mesh-tools", "mesh-lab"]; + +/** + * Where a checkout's JavaScript dependencies are installed (novox/hq issue 624): its root when the root + * holds a lock file, otherwise each directory one level down that holds one. `npm ci` installs only from a + * lock file, and a repository that moved its package into a directory of its own (mesh-tools' node-tools) + * has none at its root; the run asked for one there and failed before it built anything. A package.json + * with no lock file beside it cannot be installed reproducibly, and is named rather than installed. + */ +export function installPlan(root: string): { install: string[]; unlocked: string[] } { + const has = (dir: string, file: string) => existsSync(join(dir, file)); + if (has(root, "package-lock.json")) return { install: [root], unlocked: [] }; + const install: string[] = []; + const unlocked: string[] = has(root, "package.json") ? [root] : []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; + const dir = join(root, entry.name); + if (has(dir, "package-lock.json")) install.push(dir); + else if (has(dir, "package.json")) unlocked.push(dir); + } + return { install: install.sort(), unlocked: unlocked.sort() }; +} + +/** + * installStep is the shell a run installs one checkout with: the same rule as installPlan, so what + * lab_check says a run would install is what it installs. Building runs where the package says how. + */ +export function installStep(root: string, build: boolean): string { + const q = shellQuote(root); + const install = `(cd "$d" && npm ci --no-audit --no-fund --loglevel=error${build ? " && npm run build --if-present" : ""})`; + return `if [ -f ${q}/package-lock.json ]; then d=${q}; ${install}; else + [ ! -f ${q}/package.json ] || { echo "${root}: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; } + found=0 + for d in ${q}/*/; do + d="\${d%/}"; [ "$(basename "$d")" = node_modules ] && continue + if [ -f "$d/package-lock.json" ]; then found=1; ${install} + elif [ -f "$d/package.json" ]; then echo "$d: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; fi + done + [ "$found" = 1 ] || { echo "${root}: no package-lock.json at its root or one directory down, so nothing of it can be installed (novox/hq issue 624)" >&2; exit 1; } +fi`; +} export interface RunStatus { id: string; @@ -104,9 +151,7 @@ ${setState("building")} # resolves it; nothing else is asked of it. printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))} export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))} -for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done -(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present) -(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present) +${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")} mkdir -p ${shellQuote(bin)} for p in postgres-provisioner objectstore-provisioner route-proxy; do (cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p) @@ -117,6 +162,8 @@ export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "m export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))} export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))} export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))} +# Where a bed that serves repositories from its own forge finds them: this run's checkouts, side by side. +export MESH_LAB_REPOS=${shellQuote(dir)} export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))} export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))} export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}