Merge pull request 'lab: install each checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (issue 624)' (#268) from fix/624-lab-run-installs-what-each-checkout-has into main
This commit was merged in pull request #268.
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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, string>): 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<string, string>[] = [{ "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);
|
||||
});
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
Reference in New Issue
Block a user