From 6c09ddb528d028298dda1ff194265f3ec4813a4f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 11 Sep 2026 01:11:18 +0200 Subject: [PATCH] An image the machine has no account for is handed over, not fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the lab's registry left the operator's own images to be pulled like anything else, and they cannot be: their registry wants an account and a scenario machine has none. The pull fails with 'no basic auth credentials', which is not something more patience fixes. So the test is no longer 'did the mesh build it' but 'can the machine get it at all'. Two ways to fail that — published nowhere, or published somewhere the machine cannot authenticate to — and one consequence: the workstation, which does hold the credential, exports it and loads it. Worth saying what this stands in for. In a finished mesh these are built by the builder and published to the mesh's own store, and every machine pulls them from there with a credential the mesh granted. Until that store exists there is nowhere for them to come from, and handing them over is the closest honest thing — not a registry the lab invents, which is what was just removed. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- src/declaration/validate.ts | 4 ++-- src/lifecycle/place.ts | 4 ++-- src/pinning.ts | 40 +++++++++++++++++++++++++++++++++++++ test/integration/harness.ts | 4 ++-- test/pinning.test.ts | 30 +++++++++++++++++++++++++++- 5 files changed, 75 insertions(+), 7 deletions(-) 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`); + } +});