diff --git a/test/egress-routes.test.ts b/test/egress-routes.test.ts new file mode 100644 index 0000000..463efd4 --- /dev/null +++ b/test/egress-routes.test.ts @@ -0,0 +1,128 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { parseScenario } from "../src/declaration/parse.ts"; +import { scenarioRoutesFor } from "../src/lifecycle/address.ts"; + +/** + * The uplink and the declared gateway must not fight. + * + * **This is the one decision the registry's removal turned on, and it is invisible in a raise.** + * Every machine that needs an image now has an `egress` uplink, and the uplink's DHCP offers a + * default route. So did the scenario: a machine behind a household gateway defaulted through it, a + * machine on a public segment defaulted through transit. Both of those are containers that reach + * the scenario and nothing else — no route to the real internet, by design, because they exist to + * reproduce a household router rather than to be one. + * + * A default route through either is therefore a black hole for anything outside, and it beats the + * uplink's route on metric. The machine would sit failing every pull with a routing table that + * looks perfectly reasonable. + * + * The answer is that an egress machine states the scenario's ranges explicitly and lets the uplink + * be the default. These tests are how that is checked without spending an hour raising four nodes. + */ + +const HOUSEHOLD = ` +scenario: household +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] + home: + kind: private + cidr: [192.168.1.0/24] + gateway: + to: hosting + address: [192.0.2.50] + nat: [v4] + forwardable: true +machines: + novox: + at: { segment: hosting, address: [192.0.2.20] } + egress: true + ace: + at: { segment: home, address: [192.168.1.10] } + egress: true + sealed: + at: { segment: home, address: [192.168.1.99] } +`; + +test("a machine behind a gateway still reaches the scenario through that gateway", () => { + // The whole point of the topology: home→public is a masqueraded outbound path, and the overlay + // handshake has to survive it. An egress machine that stopped using its gateway would be + // testing a flat network with extra steps. + const routes = scenarioRoutesFor(parseScenario(HOUSEHOLD), "ace"); + assert.deepEqual(routes, [{ cidr: "192.0.2.0/24", via: "192.168.1.1" }]); +}); + +test("a machine's own segment gets no route — it is already on-link", () => { + const routes = scenarioRoutesFor(parseScenario(HOUSEHOLD), "ace"); + assert.ok(!routes.some((r) => r.cidr === "192.168.1.0/24"), JSON.stringify(routes)); +}); + +/** + * **The dangerous one.** `home` is 192.168.1.0/24 — a documentation range in spirit, an ordinary + * private one in fact, and very possibly the network the workstation itself is on. + * + * With one public segment there is no transit router, so novox has no path to `home` at all. Left + * to fall through, that traffic would leave by the uplink and land on whatever the workstation can + * reach. Unreachable is both the faithful reproduction of what it had before — a default route into + * scenery that dropped it — and the only safe answer. + */ +test("a range with no path inside the scenario is unreachable, not leaked to the uplink", () => { + const routes = scenarioRoutesFor(parseScenario(HOUSEHOLD), "novox"); + assert.deepEqual(routes, [{ cidr: "192.168.1.0/24", via: null }]); +}); + +test("a machine without egress is left to its default route, and states nothing", () => { + // Not because it needs no routes — it has one, a default through its gateway, applied the old + // way. This function is only asked about machines whose default belongs to the uplink. + const scenario = parseScenario(HOUSEHOLD); + assert.equal(scenario.machines["sealed"]?.egress, undefined); +}); + +const TWO_PUBLIC = ` +scenario: two-public +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] + elsewhere: + kind: public + cidr: [198.51.100.0/24] +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10] } + egress: true +`; + +test("with a second public segment the transit router is the way across, as it always was", () => { + // Transit is raised only when there is more than one public segment, so this is exactly the + // case where pointing at it means something. + const routes = scenarioRoutesFor(parseScenario(TWO_PUBLIC), "anchor"); + assert.deepEqual(routes, [{ cidr: "198.51.100.0/24", via: "192.0.2.254" }]); +}); + +const V6 = ` +scenario: both-families +segments: + hosting: + kind: public + cidr: [192.0.2.0/24, "2001:db8:a::/48"] + elsewhere: + kind: public + cidr: [198.51.100.0/24, "2001:db8:b::/48"] +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10, "2001:db8:a::10"] } + egress: true +`; + +test("each family is routed through its own next hop", () => { + // A v6 range routed via a v4 next hop is not a route, and the reverse is not either. + const routes = scenarioRoutesFor(parseScenario(V6), "anchor"); + assert.deepEqual(routes, [ + { cidr: "198.51.100.0/24", via: "192.0.2.254" }, + { cidr: "2001:db8:b::/48", via: "2001:db8:a::fffe" }, + ]); +}); diff --git a/test/integration/anthropic-bed.test.ts b/test/integration/anthropic-bed.test.ts index 4c85c42..85a4c9a 100644 --- a/test/integration/anthropic-bed.test.ts +++ b/test/integration/anthropic-bed.test.ts @@ -49,7 +49,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -73,7 +74,7 @@ const ACCESS_TOKEN = "at-lab-access-token-minted-by-the-stub"; const ROTATED_REFRESH = "rt-lab-rotated-still-only-the-manager"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -113,20 +114,13 @@ async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { return on(`docker exec mesh-control /mesh-control ${command}`); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -177,7 +171,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/assigned-audit.test.ts b/test/integration/assigned-audit.test.ts index e94365c..9c4a412 100644 --- a/test/integration/assigned-audit.test.ts +++ b/test/integration/assigned-audit.test.ts @@ -7,7 +7,7 @@ * container that connects over amqps with that account — never the broker's own. The trail filling * is the proof the delivered, scoped credential authenticated and the subscription bound. * - * It needs the host binary, the substrate bundle, and the runtime image stocked by the scenario: + * It needs the host binary, the substrate bundle, and the runtime image the scenario loads: * * MESH_LAB_HOST_BINARY=.../mesh-host * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock @@ -22,7 +22,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -40,8 +41,8 @@ const SCENARIO = "audit-node"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -67,22 +68,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -125,7 +119,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); @@ -151,7 +145,7 @@ after(async () => { test("the mesh assigns the audit logger, and it consumes over the account the mesh delivered", { skip, timeout: 900_000, }, async () => { - // The assigned-module manifest (mesh-catalog), its runtime image the digest this registry serves. + // The assigned-module manifest (mesh-catalog), its runtime image the ID the machine holds. const manifest = JSON.stringify({ module: "audit-logger", version: "1", diff --git a/test/integration/assigned-catalogue-apps.test.ts b/test/integration/assigned-catalogue-apps.test.ts index 428006c..aa583c6 100644 --- a/test/integration/assigned-catalogue-apps.test.ts +++ b/test/integration/assigned-catalogue-apps.test.ts @@ -30,7 +30,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -48,8 +49,8 @@ const SCENARIO = "catalogue-apps"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -75,22 +76,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -133,7 +127,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-catalogue-media.test.ts b/test/integration/assigned-catalogue-media.test.ts index 79bcb4b..c75fad6 100644 --- a/test/integration/assigned-catalogue-media.test.ts +++ b/test/integration/assigned-catalogue-media.test.ts @@ -26,7 +26,8 @@ * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock * scripts/build-module-runtime.sh {sonarr,radarr} build the runtime images into the local daemon; * scenarios/catalogue-media.yml stocks them. lscr.io/linuxserver/{sonarr,radarr} must be in the - * local daemon to be stocked. Each *arr runtime is given a lab API key so its client constructs and + * local daemon; the service images are pulled from the internet. Each *arr runtime is given a lab + * API key so its client constructs and * its tools register (as plex is given a lab token) — the server need not be configured by hand. */ @@ -37,7 +38,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -55,8 +57,8 @@ const SCENARIO = "catalogue-media"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -82,22 +84,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -140,7 +135,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-catalogue-mqtt.test.ts b/test/integration/assigned-catalogue-mqtt.test.ts index fb02003..5bd307d 100644 --- a/test/integration/assigned-catalogue-mqtt.test.ts +++ b/test/integration/assigned-catalogue-mqtt.test.ts @@ -26,7 +26,7 @@ * scripts/build-module-runtime.sh mosquitto builds mesh-runtime-mosquitto:development (carrying * mosquitto_ctrl and the compiled bootstrap entrypoint) into the local daemon, which * scenarios/catalogue-mqtt.yml stocks. eclipse-mosquitto:2 must be in the local daemon to be - * stocked; the host pulls both from the scenario's own registry by digest. + * the host pulls both from the internet over its uplink, by the digests the catalogue pins. */ import { test, before, after } from "node:test"; @@ -36,7 +36,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -54,7 +55,7 @@ const SCENARIO = "catalogue-mqtt"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -80,22 +81,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -138,7 +132,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-catalogue-small.test.ts b/test/integration/assigned-catalogue-small.test.ts index 2f7972f..e32c950 100644 --- a/test/integration/assigned-catalogue-small.test.ts +++ b/test/integration/assigned-catalogue-small.test.ts @@ -21,7 +21,7 @@ * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock * scripts/build-module-runtime.sh {postgres,redis,minio,plex} build the four runtime images into the * local daemon; scenarios/catalogue-small.yml stocks them. postgres:17-alpine, redis:7-alpine and - * minio/minio:latest must be in the local daemon to be stocked. + * minio/minio:latest is pulled from the internet by the node itself. */ import { test, before, after } from "node:test"; @@ -31,7 +31,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -49,8 +50,8 @@ const SCENARIO = "catalogue-small"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -76,22 +77,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -140,7 +134,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-grafana.test.ts b/test/integration/assigned-grafana.test.ts index 0b0b37f..bea54a3 100644 --- a/test/integration/assigned-grafana.test.ts +++ b/test/integration/assigned-grafana.test.ts @@ -22,7 +22,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -40,7 +41,7 @@ const SCENARIO = "grafana-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -65,20 +66,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -121,7 +115,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/assigned-model-usage.test.ts b/test/integration/assigned-model-usage.test.ts index 255182c..abdd1cf 100644 --- a/test/integration/assigned-model-usage.test.ts +++ b/test/integration/assigned-model-usage.test.ts @@ -38,7 +38,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -58,8 +59,8 @@ const SCENARIO = "model-usage-bed"; const NODE = "laptop"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -85,22 +86,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -164,7 +158,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/assigned-plex.test.ts b/test/integration/assigned-plex.test.ts index de5b638..33c4b5c 100644 --- a/test/integration/assigned-plex.test.ts +++ b/test/integration/assigned-plex.test.ts @@ -10,7 +10,7 @@ * proof the invocation routed to the assigned runtime, ran plex's real code, and replied, all under * the scoped account and never the broker's own. * - * It needs the host binary, the substrate bundle, and the runtime image stocked by the scenario: + * It needs the host binary, the substrate bundle, and the runtime image the scenario loads: * * MESH_LAB_HOST_BINARY=.../mesh-host * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock @@ -25,7 +25,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -43,8 +44,8 @@ const SCENARIO = "plex-node"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -70,22 +71,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -128,7 +122,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-redis.test.ts b/test/integration/assigned-redis.test.ts index 574f031..647d889 100644 --- a/test/integration/assigned-redis.test.ts +++ b/test/integration/assigned-redis.test.ts @@ -12,7 +12,7 @@ * has no way yet to deliver one to a provider's runtime (04-ISSUES). The manifest here sets a * lab-local key so the mechanism can be proven; the delivery is a separate, open design question. * - * It needs the host binary, the substrate bundle, and the runtime image stocked by the scenario: + * It needs the host binary, the substrate bundle, and the runtime image the scenario loads: * * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock * scripts/build-module-runtime.sh redis builds mesh-runtime-redis:development into the local @@ -26,7 +26,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -44,7 +45,7 @@ const SCENARIO = "redis-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -69,20 +70,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -125,7 +119,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/assigned-schedule-tick.test.ts b/test/integration/assigned-schedule-tick.test.ts index dfceb1e..7cf53f8 100644 --- a/test/integration/assigned-schedule-tick.test.ts +++ b/test/integration/assigned-schedule-tick.test.ts @@ -38,7 +38,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -56,8 +57,8 @@ const SCENARIO = "schedule-tick"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -83,22 +84,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -155,7 +149,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-sonarr.test.ts b/test/integration/assigned-sonarr.test.ts index 1232d37..656f903 100644 --- a/test/integration/assigned-sonarr.test.ts +++ b/test/integration/assigned-sonarr.test.ts @@ -20,7 +20,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -38,7 +39,7 @@ const SCENARIO = "sonarr-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -63,20 +64,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -119,7 +113,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/assigned-tools-confluence.test.ts b/test/integration/assigned-tools-confluence.test.ts index 24d17c2..681ebf3 100644 --- a/test/integration/assigned-tools-confluence.test.ts +++ b/test/integration/assigned-tools-confluence.test.ts @@ -29,7 +29,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -47,8 +48,8 @@ const SCENARIO = "tools-confluence"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -74,22 +75,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -132,7 +126,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-tools-gitlab.test.ts b/test/integration/assigned-tools-gitlab.test.ts index 118178d..70fcc19 100644 --- a/test/integration/assigned-tools-gitlab.test.ts +++ b/test/integration/assigned-tools-gitlab.test.ts @@ -28,7 +28,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -46,8 +47,8 @@ const SCENARIO = "tools-gitlab"; const MACHINE = "anchor"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -73,22 +74,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -131,7 +125,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index 223108d..33ea3d0 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -44,7 +44,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -63,8 +64,8 @@ const SCENARIO = "two-node-db"; const NODE = "laptop"; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -90,22 +91,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -173,7 +167,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // The first node raises the substrate — store, broker, control — from the bundle its host carries, // its digests rewritten to the ones this scenario's own registry serves. diff --git a/test/integration/builds.test.ts b/test/integration/builds.test.ts index 674645a..539068b 100644 --- a/test/integration/builds.test.ts +++ b/test/integration/builds.test.ts @@ -20,7 +20,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; import { incus } from "../../src/incus/client.ts"; import { machineName } from "../../src/lifecycle/names.ts"; @@ -42,7 +43,22 @@ const skip = !capability.usable const SCENARIO = "first-node"; const MACHINE = "anchor"; let instanceId = ""; -let registry = ""; + +/** + * Where a build publishes to. + * + * **The mesh has a registry, and this is that one.** `mesh-catalog/modules/registry` serves the + * mesh's artifact store on port 5000; a build publishes into it. This test starts the same image + * on the machine directly rather than assigning the module, because what is under test is the + * build chain and not module delivery. + * + * It used to publish into the registry the LAB raised inside the scenario — scenery pretending to + * be upstream, which is the thing this change removed. A registry the mesh runs and a registry the + * lab runs are different claims, and only the first exists in production. + */ +const ARTIFACT_STORE = + "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"; +const registry = "127.0.0.1:5000"; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -66,28 +82,33 @@ async function mesh(command: string): Promise { return must(`docker exec mesh-control /mesh-control ${command}`); } -/** The bundle, pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const pinned of images) { - const repository = pinned.slice(pinned.indexOf("/") + 1, pinned.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), pinned); - } - return text; +/** The bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } before(async () => { if (skip) return; const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {}); instanceId = raised.instanceId; - const first = raised.images[0]; - assert.ok(first, "the scenario stocked no images, so there is no registry to publish to"); - registry = first.slice(0, first.indexOf("/")); await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`); + // The mesh's artifact store, standing where the `registry` module would. Read back rather than + // assumed: a builder publishing into a registry that never came up fails several minutes later, + // as a manifest naming a blob nobody has. + await must( + `docker run -d --name mesh-registry --restart unless-stopped ` + + `-p ${registry}:5000 ${ARTIFACT_STORE}`, + ); + let serving = false; + for (let i = 0; i < 30 && !serving; i++) { + ({ ok: serving } = await on(`curl -sf http://${registry}/v2/ >/dev/null`)); + if (!serving) await new Promise((r) => setTimeout(r, 2_000)); + } + assert.ok(serving, "the mesh's artifact store never answered, so a build has nowhere to publish"); + // A module repository on the machine. Local rather than fetched, because what is under test is // the mesh's chain and not whether the lab can reach a forge. await must(`mkdir -p /root/shell/files`); diff --git a/test/integration/certificates.test.ts b/test/integration/certificates.test.ts index 81f4d46..37aa663 100644 --- a/test/integration/certificates.test.ts +++ b/test/integration/certificates.test.ts @@ -32,6 +32,14 @@ const SCENARIO = "a-public-name"; const MACHINE = "anchor"; const NAME = "photos.example"; const ACME = "/var/lib/acme"; +/** + * The ACME server under test, pulled by the machine over its uplink. + * + * It used to be served from a registry the lab raised inside the scenario. Nothing outside the lab + * has one, so an image only reachable there was a fiction — and this test is about a certificate + * being obtained over a real path. + */ +const AUTHORITY = "ghcr.io/letsencrypt/pebble:2.5.0"; let instanceId = ""; @@ -59,8 +67,7 @@ before(async () => { const instance = await raise(scenario, {}); instanceId = instance.instanceId; - const pebble = instance.images.find((r) => r.includes("pebble")); - assert.ok(pebble, `the scenario stocked no ACME server: ${instance.images.join(", ")}`); + const pebble = AUTHORITY; await must(`mkdir -p ${ACME}/cache`); diff --git a/test/integration/events.test.ts b/test/integration/events.test.ts index 1264d47..3ed2758 100644 --- a/test/integration/events.test.ts +++ b/test/integration/events.test.ts @@ -33,7 +33,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; import { incus } from "../../src/incus/client.ts"; import { machineName } from "../../src/lifecycle/names.ts"; @@ -97,17 +98,8 @@ async function must(command: string, timeoutMs?: number): Promise { * is not this one; matching by repository and rewriting to the digest this registry assigned is * what makes it applicable (the same rewrite mesh.test.ts does). */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const pinned of images) { - const repository = pinned.slice(pinned.indexOf("/") + 1, pinned.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll( - new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), - pinned, - ); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } /** Read the trail back as parsed JSON lines. */ @@ -129,7 +121,8 @@ before(async () => { instanceId = raised.instanceId; // The node raises its substrate — store, broker and the rest — from the bundle, applied from a - // file because the digests are this registry's and are not known until it is up. + // file because the control plane's image is named by the ID this machine holds it under, + // which is not knowable until it has been handed over. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/harness.ts b/test/integration/harness.ts index 1e06547..2f6d281 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -9,11 +9,117 @@ */ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { isReachable, pools, supportedDrivers } from "../../src/incus/client.ts"; import { destroy, list } from "../../src/lifecycle/operate.ts"; import { diagramFromLive } from "../../src/diagram/from-live.ts"; import { duplicateAddresses, describeConflicts, type Held } from "../../src/lifecycle/invariants.ts"; import type { Scenario } from "../../src/declaration/types.ts"; +import { isMeshBuilt, pinnedInto, referenceFor, repositoryOf, type HeldImage } from "../../src/pinning.ts"; + +// --- the substrate bundle, and what its three images are on a real machine --------------------- + +/** + * The example bundle in mesh-host names a registry that no longer exists. + * + * `examples/substrate-first-node.lock` was written **for a target**, and the target was the lab: it + * pins `192.0.2.250:5000/…` because that is where the registry the lab used to raise served from. + * That registry is gone, so those three references name nothing. + * + * Two of them are ordinary third-party images and belong to the internet. Rather than invent + * digests here, they are the ones the mesh's own modules already pin — mesh-catalog's `postgres` + * and `lavinmq` — so the substrate's store and broker are literally the images the mesh runs. The + * third, mesh-control, exists in no registry at all and becomes the ID the machine holds it under. + * + * **The bundle itself should be fixed in mesh-host**, and this substitution deleted with it. It is + * here because the file lives in another repository and because a fixture that lies about where an + * image comes from is exactly what this change is removing. + */ +const UPSTREAM_STORE = + "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee"; +const UPSTREAM_BROKER = + "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b"; + +/** + * The substrate bundle as a machine should receive it. + * + * Third-party references become upstream ones, which the machine pulls over its uplink; ours + * become the ID the machine was handed. Nothing points inside the scenario any more, which is the + * whole of this change: what the bed proves about a bootstrap is now what would happen anywhere. + */ +export function substrateBundle(path: string, held: HeldImage[]): string { + let text = readFileSync(path, "utf8"); + text = text.replaceAll(/[A-Za-z0-9_.:-]+\/postgres@sha256:[0-9a-f]{64}/g, UPSTREAM_STORE); + text = text.replaceAll( + /[A-Za-z0-9_.:-]+\/cloudamqp\/lavinmq@sha256:[0-9a-f]{64}/g, UPSTREAM_BROKER); + return pinnedInto(text, held); +} + +/** + * The upstream reference for a third-party image, as the mesh's own catalogue pins it. + * + * A bed that writes a manifest by hand still has to name an image exactly — mesh-host refuses a + * tag, and rightly (novox/hq ADR 0006). While the lab had a registry the beds sidestepped that by + * naming a repository and letting the rewrite supply a digest; there is nothing to supply one now, + * so the digest has to be written down. + * + * These are the digests mesh-catalog's own modules pin, taken from `mesh-catalog/modules/*` — so a + * bed runs the image the mesh runs, and a bed that drifts from the catalogue is a bed testing a + * different postgres than the mesh ships. + */ +const UPSTREAM = new Map([ + ["alpine", "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"], + ["baserow/baserow", "baserow/baserow@sha256:834424a10413798567f76428f255dc259445b7f8dcec56598c05b4073bb2a124"], + ["cloudamqp/lavinmq", "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b"], + ["eclipse-mosquitto", "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797"], + ["ghcr.io/umami-software/umami", "ghcr.io/umami-software/umami@sha256:fa32d116cf20cad52cbc3fad9a63b46e7fa02299d8f967168eb453d49c476b4a"], + ["letta/letta", "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b"], + ["lscr.io/linuxserver/radarr", "lscr.io/linuxserver/radarr@sha256:119aaa4a4f7349bcd2a136c5373a0d7925b5479915c7dfe0c0ad352db2a6d438"], + ["lscr.io/linuxserver/sonarr", "lscr.io/linuxserver/sonarr@sha256:c19aa4ecdf03d73e1d5c901da33744cb7eb4d921f89bafed1ca264601d7fa224"], + ["lscr.io/linuxserver/unifi-controller", "lscr.io/linuxserver/unifi-controller@sha256:fcd5d8b13a77a588c79c1b49e5fc9ad08115aa3bb1a3576c589c64908a68845f"], + ["minio/minio", "minio/minio@sha256:aefec8a86702aff0b0dcfdd9284bd7ab7c5631cbf9be63275799e6edcb30dfa2"], + ["mongo", "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3"], + ["ollama/ollama", "ollama/ollama@sha256:32931b46719f673c05fdbaa81ccb26da18ea4a1c57590a754874ab28ba269eb2"], + ["postgres", "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee"], + ["redis", "redis@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf"], + ["registry", "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"], + ["synesthesiam/marytts", "synesthesiam/marytts@sha256:45970ecb3e21a2981c66c60563a70cf00be8e95c02565e7d74b3a73dcec7db2c"], +]); + +/** + * What a manifest's image reference becomes on the machine. + * + * Three cases, and the middle one is the whole change: + * + * - **Ours** becomes the ID the machine holds it under. Nothing serves it, and nothing needs to. + * - **Anything already pinned by digest** is returned exactly as written. The machine pulls it + * from the internet, over its uplink, which is what a real machine does and what the lab spent + * a long time serving from a registry of its own instead. + * - **A bare repository or tag** is one of ours in spirit — a bed naming an image by hand — and + * is given the digest the catalogue pins. A tag would be refused by mesh-host anyway, and + * refusing here says why rather than failing on the machine. + */ +export function onTheMachine(reference: string, held: HeldImage[]): string { + if (isMeshBuilt(reference)) { + const found = referenceFor(held, repositoryOf(reference)); + assert.ok( + found, + `nothing loaded ${reference} onto the machines. They hold:\n ` + + held.map((i) => `${i.repository} ${i.reference}`).join("\n "), + ); + return found; + } + if (reference.includes("@sha256:")) return reference; + + const upstream = UPSTREAM.get(repositoryOf(reference)); + assert.ok( + upstream, + `${reference} is not pinned and this harness does not know an upstream digest for it. ` + + `Add the one mesh-catalog pins, or write the reference out in full — a tag moves, and the ` + + `host refuses one.`, + ); + return upstream; +} export interface Capability { usable: boolean; diff --git a/test/integration/lavinmq-bed.test.ts b/test/integration/lavinmq-bed.test.ts index 44a2ab8..e2326d5 100644 --- a/test/integration/lavinmq-bed.test.ts +++ b/test/integration/lavinmq-bed.test.ts @@ -41,7 +41,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -62,7 +63,7 @@ const NODE = "laptop"; const CONSUMER_LOGIN = "mesh_laptop_ping"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -88,22 +89,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -161,7 +155,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/local-model-bed.test.ts b/test/integration/local-model-bed.test.ts index 4dfc3e7..981734c 100644 --- a/test/integration/local-model-bed.test.ts +++ b/test/integration/local-model-bed.test.ts @@ -26,7 +26,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -44,7 +45,7 @@ const SCENARIO = "local-model-bed"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -73,20 +74,13 @@ async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { return on(`docker exec mesh-control /mesh-control ${command}`); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -136,7 +130,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/mesh-grant-end-to-end.test.ts b/test/integration/mesh-grant-end-to-end.test.ts index 1543ef5..0d96850 100644 --- a/test/integration/mesh-grant-end-to-end.test.ts +++ b/test/integration/mesh-grant-end-to-end.test.ts @@ -23,7 +23,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -41,7 +42,7 @@ const SCENARIO = "redis-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -66,20 +67,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -122,7 +116,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/mesh.test.ts b/test/integration/mesh.test.ts index 5f2923f..5c249db 100644 --- a/test/integration/mesh.test.ts +++ b/test/integration/mesh.test.ts @@ -21,10 +21,10 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; -import { pinnedInto, stillUnpinned } from "../../src/pinning.ts"; +import { pinnedInto, stillUnpinned, type HeldImage } from "../../src/pinning.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; import { incus } from "../../src/incus/client.ts"; import { machineName } from "../../src/lifecycle/names.ts"; import { ready, returnTo, keep, rememberStock, warmStock } from "../../src/warm.ts"; @@ -49,23 +49,27 @@ const skip = !capability.usable const SCENARIO = "two-nodes"; let instanceId = ""; -/** The scenario's own registry, which serves the images a module may mirror. */ -let registry = ""; -/** What that registry actually serves, by repository. */ -let stocked: string[] = []; - /** - * The pinned reference for one of the scenario's images. + * The MESH's own artifact store, once the registry module is running on the anchor. * - * By digest, because the lab's registry drops tags when it stocks: `registry:2` is not there and - * asking for it fails with "not found", which reads like a missing image rather than a naming - * convention. A digest is also what a declaration pins, so this is the reference a module would - * really carry. + * Not a registry the lab raised — there is no longer any such thing. A build publishes into the + * store the mesh itself runs, which is the only registry that exists outside this repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +const registry = "127.0.0.1:5000"; +/** + * The registry module's image, pinned upstream, pulled by the machine over its uplink. + * + * The digest mesh-catalog's `registry` module pins, so the store the mesh runs here is the store + * the mesh runs anywhere. + */ +const ARTIFACT_STORE = + "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; + +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } function quote(s: string): string { @@ -179,23 +183,14 @@ async function settled(node: string, withinMs = 480_000): Promise { } /** - * The bundle, with every image reference pointed at this scenario's registry. + * The bundle, as a machine should receive it. * - * Matched by repository rather than by the whole reference, because the address and the digest - * both differ from whatever the committed bundle names — and a bundle that names the wrong - * registry is not wrong, it is built for a different target. + * The committed example was written for a target that had a registry the lab raised. Its two + * third-party images become upstream references the machine pulls itself; mesh-control, which + * exists in no registry, becomes the ID this machine was handed. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const pinned of images) { - const repository = pinned.slice(pinned.indexOf("/") + 1, pinned.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll( - new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), - pinned, - ); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } /** Take a token out of what `token issue` printed. It is the one base64url blob on its own line. */ @@ -219,7 +214,7 @@ before(async () => { if (said.use === "restore") { instanceId = said.instanceId; const seconds = await returnTo(instanceId); - stocked = warmStock(instanceId).images; + held = warmStock(instanceId).images; // **A snapshot captures disk, not memory.** Restoring reboots the machine, so everything // this suite started by hand is gone — the host most of all. Without it the mesh looks @@ -255,13 +250,11 @@ before(async () => { instanceId = raised.instanceId; // The first node raises everything from a file rather than from a bundle built into the binary, - // because the digests are this registry's and are not known until it is up. + // because the control plane's image is named by the ID this machine holds it under, which is not + // knowable until it has been handed over. + held = raised.images; await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`); - stocked = raised.images; - const first = raised.images[0]; - assert.ok(first, "the scenario stocked no images, so nothing can be mirrored"); - registry = first.slice(0, first.indexOf("/")); // A build machine, so anything here can ask the mesh to build something. Placed rather than // assumed: nothing else in this scenario would start one. @@ -279,7 +272,7 @@ before(async () => { if (warming) { // Snapshotted only now, with everything up: a state worth returning to is the one after the // part nobody wants to repeat. - await rememberStock(instanceId, stocked); + await rememberStock(instanceId, held); const warm = await keep(SCENARIO, instanceId); console.log(`warm: ${warm.instanceId} kept, against ` + Object.entries(warm.against).map(([n, c]) => `${n} ${c}`).join(", ")); @@ -608,13 +601,13 @@ test("a machine that fell behind catches up without being named", { skip, timeou }); test("the mesh runs its own artifact store", { skip, timeout: 900_000 }, async () => { - // Artifacts go to a registry, and the only registries that existed were raised by the lab or by - // the bootstrap bundle. A mesh had no way to run its own. + // Artifacts go to a registry, and a mesh had no way to run its own — the only one that existed + // was raised by the lab, which is to say it existed nowhere but here. // // **Named, not mirrored** (novox/hq 04-ISSUES/029). Mirroring publishes to the artifact store, // and the builder will not start without one — so a module that provides the store and builds // its own image asks the mesh to put an artifact into the thing that artifact is needed to - // create. It worked here only because the scenario's registry was already standing to receive + // create. It used to pass here only because the LAB's registry was already standing to receive // the push, which is exactly why a real first mesh would have found this and the lab did not. // // So the image is named by digest, the way the bundle names the three a first node starts from. @@ -626,7 +619,7 @@ test("the mesh runs its own artifact store", { skip, timeout: 900_000 }, async ( `"serves":{"artifact-store":{"port":5000}},` + `"resources":[` + `{"id":"state","type":"directory","path":"/var/lib/mesh/registry","mode":"0700"},` + - `{"id":"store","type":"container","name":"mesh-registry","image":"${pinned("registry")}",` + + `{"id":"store","type":"container","name":"mesh-registry","image":"${ARTIFACT_STORE}",` + `"ports":["5000:5000"],"volumes":["mesh-registry-data:/var/lib/registry"]}]}' ` + `> /root/registry/module.json`); // **Added, not built** — and this is the half that proves the fix. Building needs a builder, @@ -677,7 +670,7 @@ test("a machine serves its internal name with a certificate the mesh issued", { // The name it was issued for is the one the mesh gave this machine. const named = await must("anchor", `openssl x509 -in /etc/mesh/serving.crt -noout -ext subjectAltName 2>/dev/null || ` + - `docker run --rm -v /etc/mesh:/m ${pinned("registry")} sh -c ` + + `docker run --rm -v /etc/mesh:/m ${ARTIFACT_STORE} sh -c ` + `"apk add --no-cache openssl >/dev/null 2>&1; openssl x509 -in /m/serving.crt -noout -text" | grep -A1 'Alternative'`); assert.match(named, /anchor\.internal/, `the certificate is not for this machine's name:\n${named}`); @@ -1086,7 +1079,7 @@ test("a route is a grant: a workload is reached by the name it asked for", { `"listens":[{"port":8088,"from":"mesh","why":"the proxy reaches it here"}],` + `"resources":[{"id":"dir","type":"directory","path":"/etc/storefront","mode":"0755"},` + `{"id":"app","type":"container","name":"storefront",` + - `"image":"${pinned("registry")}","ports":["8088:5000"]}]}' > /tmp/storefront.json`); + `"image":"${ARTIFACT_STORE}","ports":["8088:5000"]}]}' > /tmp/storefront.json`); for (const f of ["frontdoor", "storefront"]) { await must("anchor", `docker cp /tmp/${f}.json mesh-control:/${f}.json`); await mesh(`module add /${f}.json`); @@ -1456,7 +1449,7 @@ test("a container reaches another machine by the name the mesh gave it", { await must("anchor", `printf %s '{"module":"resolves","version":"1",` + `"capabilities":["container-runtime"],` + `"resources":[{"id":"idle","type":"container","name":"resolves",` + - `"image":"${pinned("registry")}"}]}' > /tmp/resolves.json`); + `"image":"${ARTIFACT_STORE}"}]}' > /tmp/resolves.json`); await must("anchor", `docker cp /tmp/resolves.json mesh-control:/resolves.json`); await mesh("module add /resolves.json"); await mesh("assign laptop resolves"); @@ -1810,7 +1803,7 @@ test("the real modules resolve together, and compose a declaration a host accept // until it is built — so the file legitimately carries a placeholder, and composing a // declaration from it is refused (novox/hq 04-ISSUES/025). Planning what could never run is // what this test used to do. - const pinned = pinnedInto(raw, stocked); + const pinned = pinnedInto(raw, held); // What this scenario does not serve cannot be redirected, and a module still naming a // placeholder cannot be planned — the refusal is the point (novox/hq 04-ISSUES/025). Skipped // and said, rather than silently dropped: a planning test quietly covering four modules @@ -1934,8 +1927,8 @@ test("the forge runs, on a database the mesh gave it", { skip, timeout: 900_000 for (const name of ["postgres", "gitea"]) { const raw = readFileSync(`${process.env["MESH_LAB_MODULES"]}/${name}.json`, "utf8"); // An image the mesh builds has no digest until it is built, and one it does not build belongs - // to whichever registry served it. Both are answered by this scenario's own registry. - const pinned = pinnedInto(raw, stocked); + // to whichever registry served it. Only the first is rewritten; the second is pulled. + const pinned = pinnedInto(raw, held); assert.deepEqual(stillUnpinned(pinned), [], `${name} still names an image nothing serves, so it could not start`); await must("anchor", `printf %s ${quote(pinned)} > /run-${name}.json`); @@ -2021,7 +2014,7 @@ test("a consumer's cache grant means exactly its own keys", { skip, timeout: 600 // keyspace, so the grant is a pattern — and the test is that the pattern means what the // manifest said, in both directions. const raw = readFileSync(`${process.env["MESH_LAB_MODULES"]}/redis.json`, "utf8"); - const pinned = pinnedInto(raw, stocked); + const pinned = pinnedInto(raw, held); assert.deepEqual(stillUnpinned(pinned), [], "redis still names an image nothing serves, so it could not start"); await must("anchor", `printf %s ${quote(pinned)} > /run-redis.json`); diff --git a/test/integration/minio-grant-end-to-end.test.ts b/test/integration/minio-grant-end-to-end.test.ts index fbc860f..1fae1de 100644 --- a/test/integration/minio-grant-end-to-end.test.ts +++ b/test/integration/minio-grant-end-to-end.test.ts @@ -21,7 +21,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -42,7 +43,7 @@ const SCENARIO = "minio-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -67,20 +68,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -129,7 +123,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/objectstore.test.ts b/test/integration/objectstore.test.ts index 72f3adc..337025a 100644 --- a/test/integration/objectstore.test.ts +++ b/test/integration/objectstore.test.ts @@ -45,8 +45,16 @@ const ROOT_PASSWORD_FILE = "/var/lib/objectstore/root.secret"; const ENDPOINT = "http://127.0.0.1:9000"; let instanceId = ""; -/** The store's image, by digest, from the registry the scenario raised. */ -let storeImage = ""; +/** + * The store and the vendor's client, pinned upstream and pulled by the machine over its uplink. + * + * The store is the digest the mesh's own minio module pins, so this is the store the mesh runs. + * Both used to come from a registry the lab raised inside the scenario; no production mesh has + * one, so a test that could only fetch from it was proving something about the lab. + */ +const storeImage = + "minio/minio@sha256:aefec8a86702aff0b0dcfdd9284bd7ab7c5631cbf9be63275799e6edcb30dfa2"; +const clientImage = "minio/mc:RELEASE.2025-08-13T08-35-41Z"; function shellQuote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -141,18 +149,10 @@ before(async () => { const instance = await raise(scenario, {}); instanceId = instance.instanceId; - // From the registry the scenario raised, by digest. There is no route to a public registry from - // a documentation range, which is the point of the lab having its own. - const store = instance.images.find((r) => r.includes("minio/minio")); - const client = instance.images.find((r) => r.includes("minio/mc")); - assert.ok(store, `the scenario stocked no store image: ${instance.images.join(", ")}`); - assert.ok(client, `the scenario stocked no client image: ${instance.images.join(", ")}`); - storeImage = store; - // The client, taken out of the vendor's own image onto the machine. The provisioner drives it, - // so it has to be here — and taking it from the stocked image is what keeps this test off any - // public network. - await must(`docker create --name mc-source ${client}`); + // so it has to be here. The machine pulls the image itself, over its uplink, the way it pulls + // everything third-party. + await must(`docker create --name mc-source ${clientImage}`); await must(`docker cp mc-source:/usr/bin/mc /usr/local/bin/mc && chmod 755 /usr/local/bin/mc`); await must(`docker rm mc-source`); diff --git a/test/integration/openai-bed.test.ts b/test/integration/openai-bed.test.ts index f8b8494..f15d57d 100644 --- a/test/integration/openai-bed.test.ts +++ b/test/integration/openai-bed.test.ts @@ -24,7 +24,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -46,7 +47,7 @@ const MACHINE = "anchor"; const API_KEY = "sk-lab-openai-static-key-value-for-the-bed-only"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -75,20 +76,13 @@ async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { return on(`docker exec mesh-control /mesh-control ${command}`); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -139,7 +133,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/postgres-grant-end-to-end.test.ts b/test/integration/postgres-grant-end-to-end.test.ts index 240bfdb..3ee0521 100644 --- a/test/integration/postgres-grant-end-to-end.test.ts +++ b/test/integration/postgres-grant-end-to-end.test.ts @@ -21,7 +21,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -39,7 +40,7 @@ const SCENARIO = "postgres-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -64,20 +65,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -120,7 +114,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/provider-on-backend-network.test.ts b/test/integration/provider-on-backend-network.test.ts index 11fb8a8..4b3f1b8 100644 --- a/test/integration/provider-on-backend-network.test.ts +++ b/test/integration/provider-on-backend-network.test.ts @@ -20,7 +20,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -38,7 +39,7 @@ const SCENARIO = "redis-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -63,20 +64,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -119,7 +113,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/provider-uses-mesh-credential.test.ts b/test/integration/provider-uses-mesh-credential.test.ts index 8724872..1fca4ba 100644 --- a/test/integration/provider-uses-mesh-credential.test.ts +++ b/test/integration/provider-uses-mesh-credential.test.ts @@ -24,7 +24,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -42,7 +43,7 @@ const SCENARIO = "redis-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -67,20 +68,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -123,7 +117,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/provisioner.test.ts b/test/integration/provisioner.test.ts index 715fd42..853d14b 100644 --- a/test/integration/provisioner.test.ts +++ b/test/integration/provisioner.test.ts @@ -34,8 +34,15 @@ const GRANTS = "/var/lib/postgres/grants"; const SUPER = "postgres://postgres:super@127.0.0.1:5432/postgres?sslmode=disable"; let instanceId = ""; -/** The postgres image, by digest, from the registry the scenario raised. */ -let image = ""; +/** + * The database, pinned upstream and pulled by the machine over its uplink. + * + * The same digest the mesh's own postgres module pins, so this is the database the mesh runs + * rather than a lookalike. It used to come from a registry the lab raised inside the scenario; + * nothing outside the lab has one, so what that proved about fetching an image was true only here. + */ +const image = + "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee"; function shellQuote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -142,12 +149,6 @@ before(async () => { const instance = await raise(scenario, {}); instanceId = instance.instanceId; - // From the registry the scenario raised, by digest. There is no route to a public registry from - // a documentation range, which is the point of the lab having its own. - const stocked = instance.images.find((r) => r.includes("postgres")); - assert.ok(stocked, `the scenario stocked no postgres image: ${instance.images.join(", ")}`); - image = stocked; - await must( `docker run -d --name mesh-db -e POSTGRES_PASSWORD=super ` + `-p 127.0.0.1:5432:5432 ${image}`, diff --git a/test/integration/route-forwarding.test.ts b/test/integration/route-forwarding.test.ts index 33ed2ed..bb8fe23 100644 --- a/test/integration/route-forwarding.test.ts +++ b/test/integration/route-forwarding.test.ts @@ -37,7 +37,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -57,7 +58,7 @@ const NAME = "hello.example"; const PAGE = "hello from hello-web, routed by the mesh"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -83,22 +84,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The pinned reference for one of the scenario's images, by repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -147,7 +141,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // Raise the substrate — store, broker, control — from the bundle. await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/integration/runtime-restart-on-config.test.ts b/test/integration/runtime-restart-on-config.test.ts index fc6a155..6eaf500 100644 --- a/test/integration/runtime-restart-on-config.test.ts +++ b/test/integration/runtime-restart-on-config.test.ts @@ -24,7 +24,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -42,7 +43,7 @@ const SCENARIO = "grafana-node"; const MACHINE = "anchor"; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -67,20 +68,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function tokenFrom(said: string): string { @@ -133,7 +127,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); diff --git a/test/integration/whole-mesh-ace.test.ts b/test/integration/whole-mesh-ace.test.ts index 77393d1..39606aa 100644 --- a/test/integration/whole-mesh-ace.test.ts +++ b/test/integration/whole-mesh-ace.test.ts @@ -17,7 +17,7 @@ * apps unifi portainer * * Each committed module.json is LOADED from mesh-catalog (not hand-written); its container image - * references are rewritten to what this scenario's own registry serves by digest, and the co-located + * references of OURS are rewritten to the IDs the machine holds, and the co-located * host-port collisions are remapped at load time (see REMAP). * * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock @@ -31,7 +31,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -148,7 +149,7 @@ const REMAP: Record> = { }; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -173,27 +174,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function repositoryFor(reference: string): string { - const withoutDigest = reference.split("@")[0] ?? reference; - const lastColon = withoutDigest.lastIndexOf(":"); - const lastSlash = withoutDigest.lastIndexOf("/"); - return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves\n ${stocked.join("\n ")}`); - return found; -} - -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function loadManifest(name: string): { manifest: string; broker: boolean } { @@ -204,7 +191,7 @@ function loadManifest(name: string): { manifest: string; broker: boolean } { const remap = REMAP[name] ?? {}; for (const r of m.resources ?? []) { if (r.type !== "container") continue; - if (typeof r.image === "string") r.image = pinned(repositoryFor(r.image)); + if (typeof r.image === "string") r.image = pinned(r.image); if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p); } const manifest = JSON.stringify(m); @@ -259,7 +246,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 900_000); diff --git a/test/integration/whole-mesh-full.test.ts b/test/integration/whole-mesh-full.test.ts index 90f5f05..25634bd 100644 --- a/test/integration/whole-mesh-full.test.ts +++ b/test/integration/whole-mesh-full.test.ts @@ -42,7 +42,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -237,7 +238,7 @@ const OPERATOR_SECRETS: { node: string; module: string; name: string; value: str ]; let instanceId = ""; -let stocked: string[] = []; +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -263,27 +264,13 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -function repositoryFor(reference: string): string { - const withoutDigest = reference.split("@")[0] ?? reference; - const lastColon = withoutDigest.lastIndexOf(":"); - const lastSlash = withoutDigest.lastIndexOf("/"); - return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves\n ${stocked.join("\n ")}`); - return found; -} - -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } function loadManifest(name: string): { manifest: string; broker: boolean } { @@ -294,7 +281,7 @@ function loadManifest(name: string): { manifest: string; broker: boolean } { const remap = REMAP[name] ?? {}; for (const r of m.resources ?? []) { if (r.type !== "container") continue; - if (typeof r.image === "string") r.image = pinned(repositoryFor(r.image)); + if (typeof r.image === "string") r.image = pinned(r.image); if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p); } const manifest = JSON.stringify(m); @@ -426,11 +413,14 @@ before(async () => { ...(FIXED_ID ? { instanceId: FIXED_ID } : {}), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; console.log(`INSTANCE ${instanceId}${KEEP ? " (KEEP — will be left standing)" : ""}`); - // novox raises the substrate from its bundle, digests rewritten to the scenario registry's. This - // is the collapse: the substrate rides novox, not a separate anchor. The bundle hardcodes the + // novox raises the substrate from its bundle. The store and the broker keep upstream references + // and novox PULLS them, over its uplink, the way any first node does; mesh-control exists in no + // registry, so it becomes the ID novox holds it under — the whole of what changed here. + // + // The rest is the collapse: the substrate rides novox, not a separate anchor. The bundle hardcodes the // broker's advertised address as 192.0.2.10:5671 (the OLD separate-anchor address) — and a token // carries MESH_BROKER_ADDRESS verbatim as the endpoint an enrolling node dials. With the substrate // on novox that endpoint must be novox's own public address, or every node (novox included) would @@ -439,12 +429,17 @@ before(async () => { const bundleText = bundleFor(raised.images).replaceAll("192.0.2.10:5671", "192.0.2.20:5671"); await must(CONTROL, `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleText}\nMESHBUNDLE`); - // **Belt as well as braces on the registry.** `raise` now refuses to return until every machine - // can fetch a manifest from the scenario registry, so the first attempt should be the only one. - // This retry is here because of what the failure looked like when the guarantee was missing: the - // apply died on a pull, `before` threw, and the instance was left a bare shell — VMs and a - // registry, no substrate, no enrolment, nothing to read. A pull is the one step here that can - // fail for a reason that goes away by itself, so it is the one step worth attempting twice. + // **Belt as well as braces on fetching.** `raise` refuses to return until every machine with + // egress has resolved a name and reached the internet, so the first attempt should be the only + // one. This retry is here because of what the failure looked like when there was no such + // guarantee: the apply died on a pull, `before` threw, and the instance was left a bare shell — + // VMs, no substrate, no enrolment, nothing to read. + // + // And a pull is now genuinely the one step that can fail for a reason which goes away by itself: + // the store and the broker come from the internet, through a household gateway's masquerade, and + // a registry elsewhere having a bad minute is not this mesh's fault. That is the trade this bed + // accepts — it is no longer hermetic, because a real node is not either, and the faults it was + // hiding were exactly the ones that only appear when a machine has to fetch for itself. { let applied = false; let said = ""; diff --git a/test/integration/whole-mesh-novox.test.ts b/test/integration/whole-mesh-novox.test.ts index 3aa189f..edbf11c 100644 --- a/test/integration/whole-mesh-novox.test.ts +++ b/test/integration/whole-mesh-novox.test.ts @@ -42,7 +42,8 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll } from "./harness.ts"; +import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -160,8 +161,8 @@ const REMAP: Record> = { }; let instanceId = ""; -/** What the scenario's registry serves, by digest. */ -let stocked: string[] = []; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; @@ -187,30 +188,15 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } -/** The repository path a reference serves under — registry.ts's repositoryFor, mirrored. */ -function repositoryFor(reference: string): string { - const withoutDigest = reference.split("@")[0] ?? reference; - const lastColon = withoutDigest.lastIndexOf(":"); - const lastSlash = withoutDigest.lastIndexOf("/"); - return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest; -} - /** The pinned reference this scenario's registry serves for a repository. */ -function pinned(repository: string): string { - const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); - assert.ok(found, `the scenario stocks no ${repository}; it serves\n ${stocked.join("\n ")}`); - return found; +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); } -/** The substrate bundle, its image references pointed at this scenario's own registry. */ -function bundleFor(images: string[]): string { - let text = readFileSync(bundle, "utf8"); - for (const ref of images) { - const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); - const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); - text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); - } - return text; +/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return substrateBundle(bundle, images); } /** @@ -226,7 +212,7 @@ function loadManifest(name: string): { manifest: string; broker: boolean } { const remap = REMAP[name] ?? {}; for (const r of m.resources ?? []) { if (r.type !== "container") continue; - if (typeof r.image === "string") r.image = pinned(repositoryFor(r.image)); + if (typeof r.image === "string") r.image = pinned(r.image); if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p); } const manifest = JSON.stringify(m); @@ -282,7 +268,7 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - stocked = raised.images; + held = raised.images; // anchor raises the substrate from its bundle, digests rewritten to the scenario registry's. await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); diff --git a/test/pinning.test.ts b/test/pinning.test.ts index d31fb2a..ad49bd8 100644 --- a/test/pinning.test.ts +++ b/test/pinning.test.ts @@ -1,70 +1,133 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { pinnedInto, repositoryOf, stillUnpinned } from "../src/pinning.ts"; +import { + isMeshBuilt, pinnedInto, referenceFor, repositoryOf, stillUnpinned, type HeldImage, +} from "../src/pinning.ts"; -const SERVED = [ - "192.0.2.250:5000/postgres@sha256:" + "a".repeat(64), - "192.0.2.250:5000/mesh-provision-postgres@sha256:" + "b".repeat(64), - "192.0.2.250:5000/gitea/gitea@sha256:" + "c".repeat(64), - "192.0.2.250:5000/ghcr.io/mailu/admin@sha256:" + "d".repeat(64), +/** + * What a machine holds, and what it does not. + * + * The lab used to raise a registry inside the scenario and rewrite EVERY reference to it — + * third-party ones included. That registry exists in no production mesh, so what these tests + * describe now is the real division: our images are handed over and named by their own ID, + * everything else is pulled from the internet and left exactly as written. + */ +const HELD: HeldImage[] = [ + { + requested: "mesh-control:development", + repository: "mesh-control", + reference: "sha256:" + "a".repeat(64), + }, + { + requested: "mesh-runtime-postgres:development", + repository: "mesh-runtime-postgres", + reference: "sha256:" + "b".repeat(64), + }, + { + requested: "mesh-route-proxy:development", + repository: "mesh-route-proxy", + reference: "sha256:" + "c".repeat(64), + }, ]; -test("the repository is what survives being served somewhere else", () => { - assert.equal(repositoryOf(SERVED[0]!), "postgres"); - assert.equal(repositoryOf(SERVED[2]!), "gitea/gitea"); - assert.equal(repositoryOf(SERVED[3]!), "ghcr.io/mailu/admin"); +test("the repository is the reference without its tag", () => { + assert.equal(repositoryOf("mesh-runtime-postgres:development"), "mesh-runtime-postgres"); assert.equal(repositoryOf("alpine"), "alpine"); + assert.equal(repositoryOf("gitea/gitea:1.22"), "gitea/gitea"); + assert.equal(repositoryOf("ghcr.io/mailu/admin@sha256:" + "d".repeat(64)), "ghcr.io/mailu/admin"); + // A port in a hostname is a colon that is NOT a tag, and treating it as one would truncate the + // host rather than the tag. + assert.equal( + repositoryOf("registry.example:5000/novox/www:latest"), "registry.example:5000/novox/www"); +}); + +/** + * The line the whole change turns on. + * + * An image with somewhere to be fetched from is fetched from there. An image with nowhere — no + * registry host, no upstream organisation, and a `mesh-` name — is one built here and handed over. + */ +test("only what is built here and published nowhere counts as ours", () => { + for (const ours of [ + "mesh-control:development", "mesh-runtime-plex:development", "mesh-route-proxy:development", + "mesh-provision-postgres@sha256:" + "0".repeat(64), + ]) { + assert.ok(isMeshBuilt(ours), `${ours} is one of ours and was not recognised`); + } + for (const theirs of [ + "postgres:17-alpine", "gitea/gitea:1.22", "ghcr.io/mailu/admin:1.9", + "registry.example:5000/novox/www:latest", + // A registry host in front of one of our names does NOT make it ours: it says somebody + // published it, so the machine can fetch it from there like anything else. + "registry.example:5000/mesh-control:development", + ]) { + assert.ok(!isMeshBuilt(theirs), `${theirs} is not ours and was claimed`); + } }); // The case this exists for: an image the mesh builds has no digest until it is built, so a // manifest ships sixty-four zeros and would stop on the machine (novox/hq 04-ISSUES/025). -test("a placeholder for one of our own images becomes the one this scenario serves", () => { - const before = `"image": "mesh-provision-postgres@sha256:${"0".repeat(64)}"`; - const after = pinnedInto(before, SERVED); - assert.match(after, /192\.0\.2\.250:5000\/mesh-provision-postgres@sha256:b{64}/); +test("a placeholder for one of our own images becomes the image the machine holds", () => { + const before = `"image": "mesh-runtime-postgres@sha256:${"0".repeat(64)}"`; + const after = pinnedInto(before, HELD); + assert.equal(after, `"image": "sha256:${"b".repeat(64)}"`); assert.deepEqual(stillUnpinned(after), []); }); -// And a real third-party digest is replaced too — the text says which image, the scenario says -// which copy of it. -test("a real digest is redirected to this scenario's copy", () => { - const before = `"image": "gitea/gitea@sha256:${"f".repeat(64)}"`; - assert.match(pinnedInto(before, SERVED), /192\.0\.2\.250:5000\/gitea\/gitea@sha256:c{64}/); +/** + * **The heart of it.** A third-party reference is not touched. + * + * It used to be rewritten to whatever the lab's registry assigned, which meant the bed never once + * fetched an image the way a real machine does — and every bootstrap fault that depended on that + * went unfound. + */ +test("a third-party image is left exactly as the manifest wrote it", () => { + for (const reference of [ + `postgres@sha256:${"7".repeat(64)}`, + `gitea/gitea@sha256:${"8".repeat(64)}`, + `ghcr.io/mailu/admin@sha256:${"9".repeat(64)}`, + `registry.example:5000/novox/www:latest`, + ]) { + const before = `"image": "${reference}"`; + assert.equal(pinnedInto(before, HELD), before, `${reference} was rewritten`); + } }); -test("a reference that already carries a registry is still redirected", () => { - const before = `"image": "docker.io/postgres@sha256:${"e".repeat(64)}"`; - assert.match(pinnedInto(before, SERVED), /192\.0\.2\.250:5000\/postgres@sha256:a{64}/); -}); - -// **Left alone, not blanked.** A repository this scenario did not stock may be reachable some -// other way, and emptying the reference would produce the exact failure this prevents. -test("something the scenario does not serve is untouched", () => { - const before = `"image": "redis@sha256:${"9".repeat(64)}"`; - assert.equal(pinnedInto(before, SERVED), before); +test("a reference of ours that already carries a registry is still redirected", () => { + // What the committed substrate bundle looks like: written for a target that had a registry. + const before = `"image": "192.0.2.250:5000/mesh-control@sha256:${"e".repeat(64)}"`; + assert.equal(pinnedInto(before, HELD), `"image": "sha256:${"a".repeat(64)}"`); }); // A longer repository ending in a shorter one must not be half-replaced. test("a repository that ends in another one is not partly rewritten", () => { - const before = `"image": "my-postgres@sha256:${"7".repeat(64)}"`; - assert.equal(pinnedInto(before, SERVED), before, - "'my-postgres' was rewritten because it ends in 'postgres'"); + const before = `"image": "our-mesh-control@sha256:${"7".repeat(64)}"`; + assert.equal(pinnedInto(before, HELD), before, + "'our-mesh-control' was rewritten because it ends in 'mesh-control'"); }); -test("every image in a whole manifest is redirected at once", () => { +test("every image in a whole manifest is settled at once", () => { const manifest = JSON.stringify({ resources: [ { id: "db", image: `postgres@sha256:${"1".repeat(64)}` }, - { id: "prov", image: `mesh-provision-postgres@sha256:${"0".repeat(64)}` }, + { id: "runtime", image: `mesh-runtime-postgres@sha256:${"0".repeat(64)}` }, + { id: "proxy", image: `mesh-route-proxy@sha256:${"0".repeat(64)}` }, { id: "app", image: `gitea/gitea@sha256:${"2".repeat(64)}` }, ], }); - const after = pinnedInto(manifest, SERVED); + const after = pinnedInto(manifest, HELD); assert.deepEqual(stillUnpinned(after), []); - for (const want of ["a".repeat(64), "b".repeat(64), "c".repeat(64)]) { - assert.ok(after.includes(want), `missing ${want.slice(0, 6)}… in ${after}`); - } + assert.ok(after.includes(`sha256:${"b".repeat(64)}`), after); + assert.ok(after.includes(`sha256:${"c".repeat(64)}`), after); + // And the two that are not ours are still whole, digest and all. + assert.ok(after.includes(`postgres@sha256:${"1".repeat(64)}`), after); + assert.ok(after.includes(`gitea/gitea@sha256:${"2".repeat(64)}`), after); +}); + +test("what a repository is held under can be asked for, and absence is not an empty string", () => { + assert.equal(referenceFor(HELD, "mesh-control"), `sha256:${"a".repeat(64)}`); + assert.equal(referenceFor(HELD, "mesh-runtime-plex"), undefined); }); test("what is still a placeholder can be named", () => { diff --git a/test/place.test.ts b/test/place.test.ts index 009a225..ebb7da1 100644 --- a/test/place.test.ts +++ b/test/place.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseScenario } from "../src/declaration/parse.ts"; -import { planPlacements, PLACEABLE, isPlaceable } from "../src/lifecycle/place.ts"; +import { planPlacements, planHeldImages, PLACEABLE, isPlaceable } from "../src/lifecycle/place.ts"; import { assertSupported, UnsupportedError } from "../src/lifecycle/supported.ts"; /** @@ -59,6 +59,77 @@ test("placing the host is supported", () => { assert.doesNotThrow(() => assertSupported(scenario("place:\n all: [host]"))); }); +/** + * Which machine is handed which of the mesh's own images. + * + * **Not an economy — a fact.** An operator's workstation holds the images its own modules need, + * because somebody put them there, and a home server holds a different set. The lab used to serve + * everything to everyone from a registry it raised, which hid that entirely; handing every machine + * the union instead would put some thirty gigabytes of runtimes onto whole-mesh-full's + * thirty-gigabyte workstations, and the raise would die on disk with the topology looking fine. + */ +test("a machine that says nothing is handed everything the scenario has", () => { + const s = parseScenario(` +scenario: s +segments: { hosting: { kind: public, cidr: [192.0.2.0/24] } } +machines: + anchor: { at: { segment: hosting, address: [192.0.2.10] }, egress: true } +images: [mesh-control:development, mesh-runtime-redis:development] +place: { all: [host, runtime] } +`); + assert.deepEqual(planHeldImages(s), [ + { machine: "anchor", images: ["mesh-control:development", "mesh-runtime-redis:development"] }, + ]); +}); + +test("a machine that names some is handed those, and no others", () => { + const s = parseScenario(` +scenario: s +segments: { hosting: { kind: public, cidr: [192.0.2.0/24] } } +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10] } + egress: true + images: [mesh-control:development] + laptop: + at: { segment: hosting, address: [192.0.2.20] } + egress: true + images: [mesh-runtime-redis:development] +images: [mesh-control:development, mesh-runtime-redis:development] +place: { all: [host, runtime] } +`); + assert.deepEqual(planHeldImages(s), [ + { machine: "anchor", images: ["mesh-control:development"] }, + { machine: "laptop", images: ["mesh-runtime-redis:development"] }, + ]); +}); + +test("a machine that names none is handed none, and is not a machine to visit", () => { + // Absent and empty are different, and a machine running nothing of ours should be able to say + // so without the lab deciding it must have meant everything. + const s = parseScenario(` +scenario: s +segments: { hosting: { kind: public, cidr: [192.0.2.0/24] } } +machines: + anchor: { at: { segment: hosting, address: [192.0.2.10] }, egress: true } + bare: { at: { segment: hosting, address: [192.0.2.20] }, egress: true, images: [] } +images: [mesh-control:development] +place: { all: [host, runtime] } +`); + assert.deepEqual(planHeldImages(s).map((p) => p.machine), ["anchor"]); +}); + +test("a scenario with none of our images loads nothing anywhere", () => { + const s = parseScenario(` +scenario: s +segments: { hosting: { kind: public, cidr: [192.0.2.0/24] } } +machines: + anchor: { at: { segment: hosting, address: [192.0.2.10] } } +place: { all: [host] } +`); + assert.deepEqual(planHeldImages(s), []); +}); + test("a tier that does not exist is refused BY NAME", () => { // Named individually rather than refused as a whole, so a scenario placing a host and a // substrate is told exactly which half the lab cannot do — rather than being told `place:` diff --git a/test/registry.test.ts b/test/registry.test.ts deleted file mode 100644 index 36229d5..0000000 --- a/test/registry.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { digestFrom, pinnedReference, registryAddress, repositoryFor } from "../src/lifecycle/registry.ts"; - -/** - * The registry inside a scenario (novox/hq 04-ISSUES/009). - * - * These test the pure parts. The parts that need a registry are exercised by raising a - * scenario, because a fake registry would assert that the fake behaves as expected - * (novox/hq ADR 0017). - */ - -test("a digest is read from what the registry actually said", () => { - // The real shape of `docker push` output. The digest here is the REGISTRY's, not Docker - // Hub's, and that is the point: a declaration pins what this registry serves. - const output = - "The push refers to repository [localhost:5000/alpine]\n" + - "63f227048c13: Pushed\n" + - "3.20: digest: sha256:6c2a9711b0a9f32b0239d9222eb1072309cf46c6431d319ae249186d811a987c size: 528\n"; - assert.equal( - digestFrom(output), - "sha256:6c2a9711b0a9f32b0239d9222eb1072309cf46c6431d319ae249186d811a987c", - ); -}); - -test("no digest is not an empty digest", () => { - // A push that reported no digest leaves nothing for a declaration to pin, and inventing one - // would be worse than failing — the host would refuse it later, further from the cause. - assert.equal(digestFrom("The push refers to repository [localhost:5000/alpine]\n"), null); - assert.equal(digestFrom(""), null); - // Hex, but the wrong LENGTH. An earlier version used "tooshort", whose letters fall outside - // a-f — so it failed the character class and proved nothing about the length check. - assert.equal(digestFrom("digest: sha256:abc123"), null); - assert.equal(digestFrom("digest: sha256:" + "a".repeat(63)), null, "63 is not 64"); -}); - -test("the repository is the reference without its tag", () => { - assert.equal(repositoryFor("alpine:3.20"), "alpine"); - assert.equal(repositoryFor("alpine"), "alpine"); - assert.equal(repositoryFor("library/postgres:17"), "library/postgres"); - // A port in a hostname is a colon that is NOT a tag, and treating it as one would serve the - // image from a truncated path. - assert.equal(repositoryFor("localhost:5000/alpine:3.20"), "localhost:5000/alpine"); - assert.equal(repositoryFor("localhost:5000/alpine"), "localhost:5000/alpine"); -}); - -test("the registry's address is derived from its segment", () => { - assert.equal(registryAddress("192.0.2.0/24"), "192.0.2.250"); - assert.equal(registryAddress("198.51.100.0/24"), "198.51.100.250"); - // An IPv6-only segment cannot host it, and saying so beats producing an address nothing - // can be pointed at. - assert.throws(() => registryAddress("2001:db8:a::/48"), /not an IPv4 network/); -}); - -test("what a declaration pins is the registry's own digest", () => { - // Not Docker Hub's. ADR 0006 requires a reference that is exact and cannot move, and a - // digest this registry assigned is both. - const pinned = pinnedReference("192.0.2.250", { - requested: "alpine:3.20", - repository: "alpine", - digest: "sha256:" + "6".repeat(64), - }); - assert.equal(pinned, `192.0.2.250:5000/alpine@sha256:${"6".repeat(64)}`); - assert.ok(pinned.includes("@sha256:"), "the host refuses anything not pinned by digest"); - assert.ok(!pinned.includes(":3.20"), "a tag would move; the digest is what is pinned"); -}); diff --git a/test/validate.test.ts b/test/validate.test.ts index e07a309..ee54064 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readdirSync } from "node:fs"; import { parseScenario } from "../src/declaration/parse.ts"; import { loadScenario } from "../src/declaration/parse.ts"; import { planRouters } from "../src/lifecycle/router.ts"; @@ -13,8 +14,13 @@ function refuses(yaml: string, pattern: RegExp): void { } test("the shipped scenarios are valid", () => { - for (const file of ["scenarios/bootstrap-single.yml", "scenarios/the-ordinary-shape.yml"]) { - assert.doesNotThrow(() => loadScenario(file)); + // **Every one of them**, not a chosen two. Thirty-odd scenarios were rewritten in one pass when + // the lab's registry was removed, and a scenario nobody loads is a scenario nobody validates — + // which is how a bed goes unraisable for weeks and is only found when somebody wants it. + const files = readdirSync("scenarios").filter((f) => f.endsWith(".yml")); + assert.ok(files.length > 20, `only ${files.length} scenarios found — is the path right?`); + for (const file of files) { + assert.doesNotThrow(() => loadScenario(`scenarios/${file}`), `scenarios/${file}`); } }); @@ -251,6 +257,57 @@ machines: { a: { at: detached, egress: true } }`, /detached but declares egress/); }); +/** + * `images:` is the mesh's own images and nothing else. + * + * **The rule that replaced the lab's registry.** Anything with somewhere to be fetched from is + * fetched from there, by the machine, over its uplink. Serving it from inside the scenario instead + * is what hid the bootstrap faults this lab exists to find — so it is refused rather than quietly + * done, or the fiction comes back one convenient line at a time. + */ +test("a third-party image in images: is refused, because nothing loads it", () => { + for (const image of ["postgres:17-alpine", "gitea/gitea:1.22", "ghcr.io/mailu/admin:1.9"]) { + refuses(`scenario: x +segments: { net: { kind: public, cidr: [192.0.2.0/24] } } +machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } } +images: ["${image}"] +place: { all: [runtime] }`, + /is not one of the mesh's own images/); + } +}); + +test("one of ours in images: is accepted", () => { + assert.doesNotThrow(() => parseScenario(`scenario: x +segments: { net: { kind: public, cidr: [192.0.2.0/24] } } +machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } } +images: [mesh-control:development, mesh-route-proxy:development] +place: { all: [runtime] }`)); +}); + +test("images: is named by tag — an image ID is not knowable until the image is built", () => { + refuses(`scenario: x +segments: { net: { kind: public, cidr: [192.0.2.0/24] } } +machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } } +images: ["mesh-control@sha256:${"0".repeat(64)}"] +place: { all: [runtime] }`, + /is pinned by digest/); +}); + +test("a machine cannot be handed an image the scenario does not have", () => { + // Ignoring it silently would be a machine missing a runtime, failing several minutes later + // inside an apply, as a container that will not start. + refuses(`scenario: x +segments: { net: { kind: public, cidr: [192.0.2.0/24] } } +machines: + a: + at: { segment: net, address: [192.0.2.1] } + egress: true + images: [mesh-runtime-redis:development] +images: [mesh-control:development] +place: { all: [runtime] }`, + /is not in this scenario's images/); +}); + test("a segment may not be named 'uplink' — the lab claims that name for egress", () => { refuses(`scenario: x segments: { uplink: { kind: public, cidr: [192.0.2.0/24] } }