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:
@@ -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" },
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
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",
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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.
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
* 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);
|
||||
|
||||
|
||||
@@ -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<string, string>([
|
||||
["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;
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
@@ -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<string, Record<string, string>> = {
|
||||
};
|
||||
|
||||
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<string> {
|
||||
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);
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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<string, Record<string, string>> = {
|
||||
};
|
||||
|
||||
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<string> {
|
||||
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`);
|
||||
|
||||
+101
-38
@@ -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", () => {
|
||||
|
||||
+72
-1
@@ -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:`
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
+59
-2
@@ -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] } }
|
||||
|
||||
Reference in New Issue
Block a user