The beds name images the way a machine would find them

Twenty-eight integration tests each carried their own copy of the same two helpers,
which pointed a manifest and the substrate bundle at whatever the lab's registry had
assigned. They now share two in the harness, and the difference is the point: ours is
rewritten to the ID the machine holds it under, and everything else is left exactly as
written so the machine pulls it.

**The substrate bundle is where the fiction was most load-bearing.** mesh-host's
`examples/substrate-first-node.lock` pins all three of its images at
`192.0.2.250:5000/…`, which is the address the lab's registry served from — it was
written for a target, and the target was the lab. Two of those are ordinary third-party
images and become the digests mesh-catalog's own postgres and lavinmq modules pin, so
the substrate's store and broker are literally the images the mesh runs. mesh-control
exists in no registry at all and becomes the ID the machine was handed. **The bundle
itself should be fixed in mesh-host and this substitution deleted with it.**

Beds that wrote a manifest by hand named an image by repository and let the rewrite
supply a digest. There is nothing to supply one now, so `onTheMachine` refuses an
unpinned reference and hands back the digest the catalogue pins — a bed runs the image
the mesh ships, and a bed that drifts from the catalogue is testing a different
postgres.

Three beds took a third-party image out of the raised list, which no longer contains
one: certificates (pebble), objectstore (minio and its client) and provisioner
(postgres) now name theirs and pull it. builds and mesh publish into the MESH's own
artifact store — the `registry` module's image, on the node, on 5000 — rather than into
scenery the lab raised. That is a different claim, and only one of them exists in
production.

New unit tests cover what a full raise would otherwise be the only way to check: the
routes an egress machine gets (that its gateway is still the path to the rest of the
scenario, that a range with no path is unreachable rather than leaked to the uplink,
that each family gets its own next hop), which machine is handed which of our images,
and the `images:` rule that refuses a third-party entry. The "shipped scenarios are
valid" test now loads every scenario rather than two of them.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-10 23:16:41 +02:00
parent 5c91c0ecd2
commit 675facdb0d
40 changed files with 898 additions and 705 deletions
+26 -31
View File
@@ -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<string> {
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 = "";