diff --git a/modules/lab/files/lab.env b/modules/lab/files/lab.env index 35aa1a29..a94f435b 100644 --- a/modules/lab/files/lab.env +++ b/modules/lab/files/lab.env @@ -1,3 +1,4 @@ MESH_LAB_FORGE=${config:forge} +MESH_LAB_FORGE_OWNER=${config:forge-owner} MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret diff --git a/modules/lab/module.json b/modules/lab/module.json index ef82b87c..856f328b 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -37,6 +37,12 @@ "description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).", "trusted": true, "required": true + }, + "forge-owner": { + "type": "string", + "description": "The owner on the forge the mesh's repositories and npm packages are under, such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).", + "trusted": true, + "required": true } }, "resources": [ diff --git a/modules/lab/test/base.test.ts b/modules/lab/test/base.test.ts index 93a6f8ec..7a7462e6 100644 --- a/modules/lab/test/base.test.ts +++ b/modules/lab/test/base.test.ts @@ -60,8 +60,8 @@ test("no run starts while the base image is rebuilt", () => { }); test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => { - const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials"); - assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/); + const s = baseScript("/work/base-x", "https://forge.invalid", "mesh-org", "/work/.base-x.git-credentials"); + assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/mesh-org\/mesh-lab.git'/); assert.match(s, /src\/cli.ts base build/); assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone"); }); diff --git a/modules/lab/test/owner.test.ts b/modules/lab/test/owner.test.ts new file mode 100644 index 00000000..c0497c7a --- /dev/null +++ b/modules/lab/test/owner.test.ts @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// The forge's owner differs between meshes (novox/hq ADR 0507 §9, issue 1838): the lab takes it as a config value +// the operator gives, with no default, and no file of the lab names it. +// 1838/AC4 + +const here = join(import.meta.dirname, ".."); + +test("the forge's owner is a required config value with no default, and reaches the lab's env-file", () => { + const manifest = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { config: Record> }; + const owner = manifest.config["forge-owner"]; + assert.ok(owner, "the manifest declares no forge-owner"); + assert.equal(owner.required, true); + assert.ok(!("default" in owner), `forge-owner has the default ${JSON.stringify(owner.default)}`); + assert.match(readFileSync(join(here, "files", "lab.env"), "utf8"), /^MESH_LAB_FORGE_OWNER=\$\{config:forge-owner\}$/m); +}); + +test("no file of the lab's code names an owner on the forge", () => { + for (const f of readdirSync(join(here, "tools")).filter((f) => f.endsWith(".ts"))) { + const src = readFileSync(join(here, "tools", f), "utf8"); + assert.doesNotMatch(src, /\}\/[a-z][\w.-]*\/\$\{repo\}|\}\/[a-z][\w.-]*\/mesh-lab\.git|\/api\/packages\/[a-z][\w.-]*\/npm\//, f); + } +}); diff --git a/modules/lab/test/runs.test.ts b/modules/lab/test/runs.test.ts index b2071208..5c478b0d 100644 --- a/modules/lab/test/runs.test.ts +++ b/modules/lab/test/runs.test.ts @@ -92,8 +92,8 @@ test("a run fails at its setup, naming the checkout, when nothing of it can be i // 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'/); + const s = script("/work", "run-x", "http://forge", "mesh-org", ["test/integration/genesis-keeps-its-bus.test.ts"], {}); + assert.match(s, /mesh-org\/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); @@ -113,22 +113,22 @@ function aBinding(as: string, secret: string): { binding: string; secret: string } test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => { - const b = aBinding("lab-shanks", "s3cret-of-forty-characters"); - assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret), - "http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000"); - assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined); + const b = aBinding("lab-node-d", "s3cret-of-forty-characters"); + assert.equal(forgeCredential("http://node-c.internal:20000", b.binding, b.secret), + "http://lab-node-d:s3cret-of-forty-characters@node-c.internal:20000"); + assert.equal(forgeCredential("http://node-c.internal:20000/", b.binding, ""), undefined); assert.equal(forgeCredential("", b.binding, b.secret), undefined); - assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined); + assert.equal(forgeCredential("http://node-c.internal:20000", "/nowhere", b.secret), undefined); }); test("a run clones through git's credential store, and its script carries no secret", () => { - const b = aBinding("lab-shanks", "s3cret-of-forty-characters"); - const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret); + const b = aBinding("lab-node-d", "s3cret-of-forty-characters"); + const credential = forgeCredential("http://node-c.internal:20000", b.binding, b.secret); const path = credentialPath("/work", "run-x"); - const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path); + const s = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, path); assert.ok(credential); assert.doesNotMatch(s, /s3cret/); - assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/); + assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/node-c\.internal:20000\/mesh-org\/mesh-tools-go\.git'/); assert.match(s, /export GIT_TERMINAL_PROMPT=0/); assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory"); assert.equal(credentialArgs(undefined), ""); @@ -139,10 +139,10 @@ test("a run clones through git's credential store, and its script carries no sec test("git presents the stored credential to the forge, and only when one is given", () => { const dir = mkdtempSync(join(tmpdir(), "lab-git-")); const store = join(dir, "git-credentials"); - writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 }); + writeFileSync(store, "http://lab-node-d:pw@forge.invalid:20000\n", { mode: 0o600 }); const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`], { encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); - assert.match(fill.stdout, /username=lab-shanks/); + assert.match(fill.stdout, /username=lab-node-d/); assert.match(fill.stdout, /password=pw/); const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`], { encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); @@ -168,7 +168,7 @@ test("a stopped script removes the credential, whatever ends it", () => { }); test("the script removes the credential as soon as its clones are done", () => { - const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials"); + const s = script("/work", "run-x", "http://forge", "mesh-org", ["t.test.ts"], {}, "/work/.run-x.git-credentials"); const lastClone = s.lastIndexOf("clone --quiet"); const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone); const building = s.indexOf("npm ci"); @@ -252,9 +252,9 @@ test("the lab's tools bundle names its bound credential and binding in its env", assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]); }); -// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused +// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on node-d): npm 12 refused // mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's -// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball +// address (http://node-c.internal:20000/…), the lock file holds the address the forge writes every tarball // URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry. // A run names the registry by the address the registry names itself by. import { npmRegistry } from "../tools/runs.ts"; @@ -270,54 +270,54 @@ function aRegistry(answer: () => Response | Promise): { get: typeof fe const packument = (tarball: string) => new Response(JSON.stringify({ name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" }, - versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } }, + versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/mesh-org/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } }, }), { status: 200, headers: { "content-type": "application/json" } }); test("a run names the package registry by the address the registry gives its tarballs", async () => { - const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")); - const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/"); - assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" }); - assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]); + const r = aRegistry(() => packument("https://forge.example.org/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")); + const got = await npmRegistry("http://forge.example.org:3000/", "mesh-org", r.get, "https://forge.example.org/api/packages/mesh-org/npm/"); + assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/mesh-org/npm/" }); + assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/mesh-org/npm/@novox%2fmesh-sdk"]); // The forge's own scheme is taken too, with its port. - const plain = await npmRegistry("http://forge.example.org:3000", - aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get); - assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" }); + const plain = await npmRegistry("http://forge.example.org:3000", "mesh-org", + aRegistry(() => packument("http://forge.example.org:3000/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get); + assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/mesh-org/npm/" }); }); // What the registry answers may come over plain HTTP, so it never decides where packages come from: only // the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after // it (review of mesh-catalog #275, ADR 0346). test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => { - const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"; + const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"; for (const [answer, why] of [ [() => new Response("not found", { status: 404 }), /it answered 404/], [() => new Response("{}", { status: 200 }), /no tarball/], [() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/], - [() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//], + [() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/mesh-org\/npm\//], [() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/], - [() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/], - [() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/], - [() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/], - [() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/], - [() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/], - [() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/], - [() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/], - [() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/], + [() => packument(`https://attacker.example.net/x?u=https://node-c.internal${sdk}`), /query or a fragment/], + [() => packument(`https://node-c.internal${sdk}?x=1`), /query or a fragment/], + [() => packument(`https://node-c.internal${sdk}#frag`), /query or a fragment/], + [() => packument(`https://attacker.example.net/#https://node-c.internal${sdk}`), /query or a fragment/], + [() => packument(`ftp://node-c.internal${sdk}`), /at ftp:\/\/node-c\.internal, which is neither/], + [() => packument(`file:///api/packages/mesh-org/npm/x.tgz`), /which is neither/], + [() => packument(`https://node-c.internal/api/packages/mesh-org/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/], + [() => packument(`https://user:pw@node-c.internal${sdk}`), /user or password/], ] as [() => Response, RegExp][]) { - const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get); - assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why)); + const got = await npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(answer).get); + assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", String(why)); assert.match(got.problem ?? "", /did not give an address the lab takes/); assert.match(got.problem ?? "", why); } }); test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => { - const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined, - "https://forge.example.org/api/packages/novox/npm/"); - assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/); - assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/); - const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}); - assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/); + const given = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, undefined, + "https://forge.example.org/api/packages/mesh-org/npm/"); + assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/mesh-org\/npm\/' > '\/work\/run-x\/\.npmrc'\n/); + assert.doesNotMatch(given, /@novox:registry=http:\/\/node-c\.internal/); + const none = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}); + assert.match(none, /'@novox:registry=http:\/\/node-c\.internal:20000\/api\/packages\/mesh-org\/npm\/'/); }); // The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a @@ -325,20 +325,20 @@ test("a run's npm configuration names the registry it was given, and the forge's import { publicRootOf } from "../tools/runs.ts"; test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => { - const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"; - const root = "https://git.example.org/api/packages/novox/npm/"; + const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"; + const root = "https://git.example.org/api/packages/mesh-org/npm/"; const at = (tarball: string, publicRoot?: string) => - npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot); + npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(() => packument(tarball)).get, publicRoot); assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root }); for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) { const got = await at(tarball, root); - assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball); + assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", tarball); assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball); } // Without a public root, the forge's public name is not trusted on its own word. assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/); // And the forge's own address only at its own port. - assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/); + assert.match((await at(`http://node-c.internal${sdk}`, root)).problem ?? "", /neither/); }); test("the public root is read from the binding, and only an https root at the npm path is taken", () => { @@ -346,39 +346,39 @@ test("the public root is read from the binding, and only an https root at the np const binding = (root: unknown) => { const f = join(dir, `b-${Math.random()}.json`); writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000, - "npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } })); + "npm-path": "/api/packages/mesh-org/npm/", ...(root === undefined ? {} : { "public-root": root }) } })); return f; }; - assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")), - { root: "https://git.example.org/api/packages/novox/npm/" }); - assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")), - { root: "https://git.example.org:8443/api/packages/novox/npm/" }); + assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/mesh-org/npm/"), "mesh-org"), + { root: "https://git.example.org/api/packages/mesh-org/npm/" }); + assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/mesh-org/npm/"), "mesh-org"), + { root: "https://git.example.org:8443/api/packages/mesh-org/npm/" }); for (const [root, why] of [ [undefined, /serves no public-root/], - ["http://git.example.org/api/packages/novox/npm/", /is not https:\/\//], + ["http://git.example.org/api/packages/mesh-org/npm/", /is not https:\/\//], ["https://git.example.org/elsewhere/", /is not https:\/\//], - ["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\//], - ["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\//], - ["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/], - ["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/], + ["https://git.example.org/api/packages/mesh-org/npm/?x=1", /is not https:\/\//], + ["https://u:p@git.example.org/api/packages/mesh-org/npm/", /is not https:\/\//], + ["https://git.example.org/api/packages/mesh-org/npm/\nallow-remote=all", /whitespace or a control character/], + ["https://${bound:route:name-web}/api/packages/mesh-org/npm/", /not a URL|is not https/], ] as [unknown, RegExp][]) { - const got = publicRootOf(binding(root)); + const got = publicRootOf(binding(root), "mesh-org"); assert.equal(got.root, undefined, String(root)); assert.match(got.problem ?? "", why, String(root)); } - assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/); - assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/); + assert.match(publicRootOf(join(dir, "none.json"), "mesh-org").problem ?? "", /could not be read/); + assert.match(publicRootOf(undefined, "mesh-org").problem ?? "", /no npm-package-registry binding/); rmSync(dir, { recursive: true, force: true }); }); // A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin // is "null", as is a `file:` forge's, and the two must not match. test("a forge setting that is not http(s) trusts no origin of its own", async () => { - const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get); + const got = await npmRegistry("file:///srv/forge", "mesh-org", aRegistry(() => packument("file:///api/packages/mesh-org/npm/x.tgz")).get); assert.match(got.problem ?? "", /neither the forge's public root nor its own address/); - const root = "https://git.example.org/api/packages/novox/npm/"; - const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() => - packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root); + const root = "https://git.example.org/api/packages/mesh-org/npm/"; + const viaRoot = await npmRegistry("ftp://node-c.internal", "mesh-org", aRegistry(() => + packument("ftp://node-c.internal/api/packages/mesh-org/npm/x.tgz")).get, root); assert.match(viaRoot.problem ?? "", /neither/); }); @@ -401,7 +401,7 @@ test("every file takes its text from a template in files/", () => { }); // Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0, -// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it. +// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on node-d wrote it. const oldLog = [ "STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.", "\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:", diff --git a/modules/lab/tools/index.ts b/modules/lab/tools/index.ts index 63c7c41f..bd18ec0f 100644 --- a/modules/lab/tools/index.ts +++ b/modules/lab/tools/index.ts @@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start, + checkDir, repositoryURL, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start, stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun, launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem, } from "./runs.js"; @@ -19,13 +19,17 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { // ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used // without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance. const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, ""); + // The owner on the forge the mesh's repositories and packages are under, the operator's too (novox/hq ADR 0507 + // §9), read the same way. + const ownerOf = (): string => (env.MESH_LAB_FORGE_OWNER ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_OWNER")).trim(); + const noOwner = "the lab's forge owner is not set: config for lab, {\"forge-owner\": \"\"}"; // The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call. // The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call. const bindingOf = (): string | undefined => env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined); - const registryOf = async (forge: string) => { - const root = publicRootOf(bindingOf()); - const registry = await npmRegistry(forge, fetch, root.root); + const registryOf = async (forge: string, owner: string) => { + const root = publicRootOf(bindingOf(), owner); + const registry = await npmRegistry(forge, owner, fetch, root.root); return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry; }; const credentialOf = (forge: string): string | undefined => forgeCredential(forge, @@ -39,6 +43,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { run: async () => { const forge = forgeOf(); if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"\"}" }; + const owner = ownerOf(); + if (!owner) return { ok: false, output: noOwner }; // What earlier checks left is cleared, and what could not be is said; this check works in a fresh // directory of its own either way, and removes it when it is done. const leftovers = clearChecks(work); @@ -46,13 +52,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { let check: ReturnType; let problems: string[]; try { - const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" }); + const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", repositoryURL(forge, owner, "mesh-lab"), dir], { encoding: "utf8" }); if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") }; spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" }); check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" }); - problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge)); + problems = setupProblems(forge, owner, `${dir}/setup`, credentialOf(forge)); // And the address a run names the package registry by (npmRegistry): said when the registry does not say it. - const registry = await registryOf(forge); + const registry = await registryOf(forge, owner); if (registry.problem) problems.push(registry.problem); } finally { rmSync(dir, { recursive: true, force: true }); @@ -91,6 +97,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { run: async (args) => { const forge = forgeOf(); if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"\"}" }; + const owner = ownerOf(); + if (!owner) return { started: false, reason: noOwner }; const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean); if (tests.length === 0) return { started: false, reason: "name at least one bed test file" }; let refs: Record = {}; @@ -105,8 +113,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` }; // **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698, // review 256): the registry asked first, then the check and the start with nothing awaited between. - const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() }, - (registry) => start(work, forge, tests, refs, credentialOf(forge), registry)); + const launched = await launchRun(work, { registry: () => registryOf(forge, owner), base: () => readBaseImage() }, + (registry) => start(work, forge, owner, tests, refs, credentialOf(forge), registry)); if (!launched.started) return launched; return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry, ...(launched.problem ? { problem: launched.problem } : {}) }; @@ -134,10 +142,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { if (args.rebuild !== true) return reading; const forge = forgeOf(); if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"\"}" }; + const owner = ownerOf(); + if (!owner) return { ...reading, started: false, reason: noOwner }; // Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with // nothing awaited between (review 256). - const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before }, - (registry) => startBaseBuild(work, forge, credentialOf(forge), registry)); + const launched = await launchRebuild(work, { registry: () => registryOf(forge, owner), base: () => before }, + (registry) => startBaseBuild(work, forge, owner, credentialOf(forge), registry)); if (launched.started === false) return { ...reading, started: false, reason: launched.reason }; return { ...reading, started: true, build: launched.job }; }, @@ -174,7 +184,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { * 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, credential?: string): string[] { +export function setupProblems(forge: string, owner: string, dir: string, credential?: string): string[] { const problems: string[] = []; spawnSync("rm", ["-rf", dir]); // The credential a run would offer, in a file of its own outside the check's clones, removed after. @@ -186,7 +196,7 @@ export function setupProblems(forge: string, dir: string, credential?: string): }); try { for (const repo of REPOSITORIES) { - const url = `${forge}/novox/${repo}.git`; + const url = repositoryURL(forge, owner, repo); const remote = git(["ls-remote", "--heads", url, "main"]); if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) { const said = remote.stderr.trim().split("\n")[0] ?? ""; diff --git a/modules/lab/tools/runs.ts b/modules/lab/tools/runs.ts index c65bfb41..76095ff2 100644 --- a/modules/lab/tools/runs.ts +++ b/modules/lab/tools/runs.ts @@ -60,8 +60,11 @@ export function installStep(root: string, build: boolean): string { fi`; } -/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */ -export const NPM_PATH = "/api/packages/novox/npm/"; +/** + * Where the forge serves the mesh's npm packages, under its own address: gitea's npm registry for the owner the + * mesh's repositories are under, the operator's config value (novox/hq ADR 0507 §9). + */ +export const npmPath = (owner: string): string => `/api/packages/${encodeURIComponent(owner)}/npm/`; /** * The registry's public root, as the forge serves it in the lab's npm-package-registry binding @@ -70,7 +73,8 @@ export const NPM_PATH = "/api/packages/novox/npm/"; * nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a * machine with no public domain serves none (ADR 0346 §3). */ -export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } { +export function publicRootOf(bindingFile: string | undefined, owner: string): { root?: string; problem?: string } { + const NPM_PATH = npmPath(owner); if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" }; let said: unknown; try { @@ -112,8 +116,9 @@ export function publicRootOf(bindingFile: string | undefined): { root?: string; * reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the * forge's own address and says why, so a run still starts and lab_check names it. */ -export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string): +export async function npmRegistry(forge: string, owner: string, get: typeof fetch = fetch, publicRoot?: string): Promise<{ registry: string; problem?: string }> { + const NPM_PATH = npmPath(owner); const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`; const url = `${asked}@novox%2fmesh-sdk`; const fallback = (why: string) => ({ @@ -453,6 +458,10 @@ export function running(work: string): RunStatus | undefined { return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state)); } +/** A repository's clone address on the forge: under the owner the mesh's repositories are under (ADR 0507 §9). */ +export const repositoryURL = (forge: string, owner: string, repo: string): string => + `${forge}/${encodeURIComponent(owner)}/${repo}.git`; + const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; /** @@ -461,8 +470,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; * The environment is the one the lab's README describes for a run against sibling checkouts, pointed * at this run's own tree, so what is built and claimed is exactly what was checked out. */ -export function script(work: string, id: string, forge: string, tests: string[], refs: Record, - credential?: string, registry = `${forge}${NPM_PATH}`): string { +export function script(work: string, id: string, forge: string, owner: string, tests: string[], refs: Record, + credential?: string, registry = `${forge}${npmPath(owner)}`): string { const dir = runDir(work, id); const setState = (state: string) => `node -e ${shellQuote( @@ -471,7 +480,7 @@ export function script(work: string, id: string, forge: string, tests: string[], const clones = REPOSITORIES.map((repo) => { const ref = refs[repo] ?? "main"; return [ - `git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`, + `git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(repositoryURL(forge, owner, repo))} ${shellQuote(join(dir, repo))}`, `echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`, ].join("\n"); }).join("\n"); @@ -515,7 +524,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join(" } /** start begins a run and returns at once with its status. */ -export function start(work: string, forge: string, tests: string[], refs: Record, +export function start(work: string, forge: string, owner: string, tests: string[], refs: Record, credential?: string, registry?: string): RunStatus { const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`; const dir = runDir(work, id); @@ -525,7 +534,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record // What a crashed or rebooted run left behind goes before this run writes its own. cleanCredentials(work); const credentials = writeCredential(credentialPath(work, id), credential); - writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 }); + writeFileSync(join(dir, "run.sh"), script(work, id, forge, owner, tests, refs, credentials, registry), { mode: 0o700 }); // The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a // run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172). @@ -715,13 +724,13 @@ export function baseBuildLog(work: string, id: string, lines = 40): string { * baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it, * then its `base build`, which replaces the image and reads back that it works before publishing it. */ -export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string { +export function baseScript(dir: string, forge: string, owner: string, credential?: string, registry = `${forge}${npmPath(owner)}`): string { const lab = join(dir, "mesh-lab"); return `set -euo pipefail export GIT_TERMINAL_PROMPT=0 ${credentialGuard(credential)} cd ${shellQuote(dir)} -git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)} +git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(repositoryURL(forge, owner, "mesh-lab"))} ${shellQuote(lab)} ${credential ? `rm -f ${shellQuote(credential)}` : ""} echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)" printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))} @@ -733,7 +742,7 @@ node --experimental-strip-types src/cli.ts base build } /** startBaseBuild rebuilds the base image in the background and answers at once with the build. */ -export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild { +export function startBaseBuild(work: string, forge: string, owner: string, credential?: string, registry?: string): BaseBuild { const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`; const dir = join(work, id); mkdirSync(dir, { recursive: true }); @@ -742,7 +751,7 @@ export function startBaseBuild(work: string, forge: string, credential?: string, // What a crashed or rebooted run or rebuild left behind goes before this one writes its own. cleanCredentials(work); const credentials = writeCredential(credentialPath(work, id), credential); - writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 }); + writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, owner, credentials, registry), { mode: 0o700 }); const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$? node -e ${shellQuote( `const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,