diff --git a/src/declaration/validate.ts b/src/declaration/validate.ts index bccf643..80df7f0 100644 --- a/src/declaration/validate.ts +++ b/src/declaration/validate.ts @@ -15,7 +15,7 @@ import type { Scenario, Segment } from "./types.ts"; import { contains, familyOf, parseAddress, parseCidr, type Cidr } from "./net.ts"; -import { isMeshBuilt } from "../pinning.ts"; +import { mustBeHandedOver } from "../pinning.ts"; /** RFC 5737 and RFC 3849. The only addresses guaranteed never to route on the real internet. */ const DOCUMENTATION_RANGES = [ @@ -327,7 +327,7 @@ export function validate(scenario: Scenario): void { `images: '${image}' is pinned by digest. Name it by tag — what a declaration uses is the ` + `ID of the image loaded onto the machine, reported when the scenario is raised`, ); - } else if (!isMeshBuilt(image)) { + } else if (!mustBeHandedOver(image)) { // **The rule that replaced the lab's registry.** Anything with somewhere to be fetched from // is fetched from there, over the machine's uplink, exactly as in production. Serving it // from inside the scenario instead is what hid the bootstrap faults this lab exists to find. diff --git a/src/lifecycle/place.ts b/src/lifecycle/place.ts index 32b4640..19f6794 100644 --- a/src/lifecycle/place.ts +++ b/src/lifecycle/place.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; import { incus, incusOk, succeeds } from "../incus/client.ts"; import { around, log, shorten } from "../log.ts"; -import { isMeshBuilt, repositoryOf, type HeldImage } from "../pinning.ts"; +import { mustBeHandedOver, repositoryOf, type HeldImage } from "../pinning.ts"; /** * What this stage can put inside a machine. @@ -448,7 +448,7 @@ export async function loadHeldImages( // Refused by the validator, so reaching here would be a validator bug — but the consequence // is a third-party image quietly loaded from the workstation instead of pulled, which is the // fiction all of this exists to remove. Cheap to check, expensive to miss. - if (!isMeshBuilt(requested)) { + if (!mustBeHandedOver(requested)) { throw new Error( `images: '${requested}' is not one of the mesh's own images. It is pulled from the ` + `internet by the machine that needs it, not loaded from this workstation.`, diff --git a/src/pinning.ts b/src/pinning.ts index 9da9752..3197cfd 100644 --- a/src/pinning.ts +++ b/src/pinning.ts @@ -67,6 +67,46 @@ export function isMeshBuilt(reference: string): boolean { return !repository.includes("/") && repository.startsWith("mesh-"); } +/** + * Registries an anonymous pull works against. + * + * Not a list of what is trusted — a list of where no account is needed. Everything else wants one, + * and a scenario machine has none. + */ +const PUBLIC_REGISTRIES = [ + "docker.io", "ghcr.io", "quay.io", "lscr.io", "gcr.io", "registry.k8s.io", + "public.ecr.aws", "mcr.microsoft.com", "docker.elastic.co", "registry.gitlab.com", +]; + +/** The registry a reference names, or "" when it names none and so means Docker Hub. */ +export function registryOf(reference: string): string { + const first = repositoryOf(reference).split("/")[0] ?? ""; + // A first segment is a registry only if it looks like a host: `novox/www` is an organisation on + // Docker Hub; `registry.example/novox/www` is somewhere else entirely. + return first.includes(".") || first.includes(":") ? first : ""; +} + +/** + * Whether the workstation has to hand this image over rather than let the machine fetch it. + * + * **Two reasons, one consequence.** An image the mesh builds for itself exists in no registry at + * all. An image in the operator's *private* registry exists in one the machines have no account + * for, and the pull fails with `no basic auth credentials` — which is not something more patience + * fixes. Either way the machine cannot get it alone, so the workstation, which does hold the + * credential, exports it and loads it. + * + * **This stands in for something, and it is worth saying what.** In a finished mesh these are built + * by the mesh's builder and published to the mesh's own store, and every machine pulls them from + * there with a credential the mesh granted it. Until that store exists there is nowhere for them to + * come from — and handing them over is the closest honest thing to it, rather than a registry the + * lab invents, which is exactly what was just removed. + */ +export function mustBeHandedOver(reference: string): boolean { + if (isMeshBuilt(reference)) return true; + const registry = registryOf(reference); + return registry !== "" && !PUBLIC_REGISTRIES.includes(registry); +} + /** * Replace every reference to one of the mesh's own images with the image the machine holds. * diff --git a/test/integration/harness.ts b/test/integration/harness.ts index b3f259d..8d82bc0 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -15,7 +15,7 @@ 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"; +import { mustBeHandedOver, pinnedInto, referenceFor, repositoryOf, type HeldImage } from "../../src/pinning.ts"; // --- the substrate bundle, and what its three images are on a real machine --------------------- @@ -107,7 +107,7 @@ const UPSTREAM = new Map([ * rather than an assertion here taking the whole bed down before it starts. */ export function onTheMachine(reference: string, held: HeldImage[]): string { - if (isMeshBuilt(reference)) { + if (mustBeHandedOver(reference)) { const found = referenceFor(held, repositoryOf(reference)); assert.ok( found, diff --git a/test/pinning.test.ts b/test/pinning.test.ts index ad49bd8..65a943a 100644 --- a/test/pinning.test.ts +++ b/test/pinning.test.ts @@ -2,7 +2,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { - isMeshBuilt, pinnedInto, referenceFor, repositoryOf, stillUnpinned, type HeldImage, + isMeshBuilt, mustBeHandedOver, pinnedInto, referenceFor, repositoryOf, stillUnpinned, + type HeldImage, } from "../src/pinning.ts"; /** @@ -134,3 +135,30 @@ test("what is still a placeholder can be named", () => { const text = `"image": "something-of-ours@sha256:${"0".repeat(64)}"`; assert.deepEqual(stillUnpinned(text), ["something-of-ours"]); }); + +/** + * An image a machine cannot fetch by itself has to be handed to it, and there are two ways to be in + * that position: built here and published nowhere, or sitting in a registry the machine has no + * account for. The second was found by deleting the lab's registry — the operator's own images + * failed with `no basic auth credentials`, which no amount of retrying improves. + */ +test("an image the machine cannot fetch by itself is handed over", () => { + for (const handed of [ + "mesh-control:development", + "mesh-runtime-plex:development", + `registry.example/novox/www@sha256:${"a".repeat(64)}`, + "registry.example:5000/novox/photos-server:latest", + ]) { + assert.ok(mustBeHandedOver(handed), `${handed} cannot be fetched and was not handed over`); + } + for (const fetched of [ + "postgres:17-alpine", + "gitea/gitea:1.22", + "ghcr.io/mailu/admin:1.9", + "quay.io/keycloak/keycloak:26", + "lscr.io/linuxserver/sonarr:latest", + "mcr.microsoft.com/mssql/server:2022-latest", + ]) { + assert.ok(!mustBeHandedOver(fetched), `${fetched} can be fetched and was handed over anyway`); + } +});