The route-forwarding bed installs the catalogue's authority, proxy and consumer; the whole-mesh bed installs the vault first #46

Merged
jschoubben merged 9 commits from multiple-fixes into main 2026-09-21 20:58:25 +00:00
7 changed files with 81 additions and 130 deletions
+12 -1
View File
@@ -22,7 +22,18 @@ DOCKERFILE="$MESH_CATALOG/modules/route-proxy/Dockerfile"
[ -f "$MESH_CONTROL/examples/route-proxy/main.go" ] || {
echo "no proxy source at $MESH_CONTROL/examples/route-proxy" >&2; exit 1; }
# The bases the manifest declares (novox/hq ADR 0097) are what this build starts FROM — the same
# images the mesh's builder would copy and hand the recipe, not the Dockerfile's floating defaults.
BASES=()
while IFS=$'\t' read -r arg image; do
[ -n "$arg" ] && BASES+=(--build-arg "$arg=$image")
done < <(python3 -c '
import json, sys
for on in json.load(open(sys.argv[1])).get("build", {}).get("on", []):
print(on["arg"], on["image"], sep="\t")
' "$MESH_CATALOG/modules/route-proxy/module.json")
# Context is the mesh-controller repository root: the proxy compiles against that module's go.mod and
# its examples/route-proxy package.
docker build -f "$DOCKERFILE" -t "$TAG" "$MESH_CONTROL"
docker build -f "$DOCKERFILE" "${BASES[@]}" -t "$TAG" "$MESH_CONTROL"
echo "built $TAG (from $MESH_CONTROL/examples/route-proxy)"
-2
View File
@@ -56,8 +56,6 @@ const STILL_CARRIED: Record<string, { modules: string[]; why: string }> = {
why: "BESIDE (lavinmq, with a bootstrap step and a data directory the catalogue has not got); DIFFERS (amqp-ping names its entrypoint)" },
"provider-uses-mesh-credential.test.ts": { modules: ["redis"], why: "WEARING: a credential mechanism test" },
"runtime-restart-on-config.test.ts": { modules: ["grafana"], why: "WEARING: a restart mechanism test" },
"route-forwarding.test.ts": { modules: ["route-proxy", "hello-web"],
why: "WEARING: route-proxy without its certificate authority, hello-web with the route shape ADR 0066 replaced" },
"mesh.test.ts": { modules: ["postgres", "builder", "umami"],
why: "WEARING: a postgres with no resources, a builder that builds itself, an umami that is another module of that name" },
};
+14
View File
@@ -85,3 +85,17 @@ test("a manifest the catalogue does not have is refused by name", () => {
assert.throws(() => catalogueModule("nothing", held), /no manifest for nothing/);
} finally { restore(); }
});
test("an upstream artifact resolves to the reference the manifest pins, as the machine pulls it", () => {
const web = {
module: "thing", version: "1",
resources: [{ id: "server", type: "container", name: "web", artifact: "server" }],
build: { artifacts: [{ name: "server", kind: "upstream", from: "alpine@" + digest("e") }] },
};
const restore = aCatalogueWith(web);
try {
const m = JSON.parse(catalogueModule("thing", held)) as { resources: Record<string, unknown>[] };
assert.equal(m.resources[0]!["image"], "alpine@" + digest("e"));
assert.equal(m.resources[0]!["artifact"], undefined);
} finally { restore(); }
});
+12 -2
View File
@@ -306,12 +306,22 @@ export interface ForTheLab {
export function catalogueModule(module: string, held: HeldImage[], lab: ForTheLab = {}): string {
const m = JSON.parse(readFileSync(catalogueManifest(module), "utf8")) as {
resources?: { id: string; type: string; image?: string; artifact?: string; ports?: string[]; env?: Record<string, string> }[];
build?: unknown;
build?: { artifacts?: { name: string; kind: string; from?: string }[] };
};
const artifacts: Record<string, string> = { runtime: `mesh-runtime-${module}`, ...(lab.artifacts ?? {}) };
// An upstream artifact is somebody else's image, which the mesh's builder copies into its own
// registry (ADR 0096). The lab stands in for the builder by using the reference the manifest
// pins, which the machine pulls over its uplink — the same bytes, without the copy.
const upstream = new Map<string, string>();
for (const a of m.build?.artifacts ?? []) {
if (a.kind === "upstream" && a.from) upstream.set(a.name, a.from);
}
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.artifact === "string") {
if (typeof r.artifact === "string" && upstream.has(r.artifact) && !lab.artifacts?.[r.artifact]) {
r.image = onTheMachine(upstream.get(r.artifact)!, held);
delete r.artifact;
} else if (typeof r.artifact === "string") {
const repository = artifacts[r.artifact];
assert.ok(repository,
`${module}'s container '${r.id}' names the "${r.artifact}" artifact, which the mesh would ` +
+30 -64
View File
@@ -37,7 +37,7 @@ 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, foundationBundle, onTheMachine } from "./harness.ts";
import { labIsUsable, destroyAll, foundationBundle, catalogueModule, catalogueIsPresent, deriveTheFilterOn } from "./harness.ts";
import type { HeldImage } from "../../src/pinning.ts";
const capability = await labIsUsable();
@@ -50,12 +50,14 @@ const skip = !capability.usable
? "MESH_LAB_HOST_BINARY is not set to a built mesh-host"
: !bundle || !existsSync(bundle)
? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)"
: false;
: catalogueIsPresent();
const SCENARIO = "route-forwarding";
const MACHINE = "anchor";
const NAME = "hello.example";
const PAGE = "hello from hello-web, routed by the mesh";
/** The node's public domain; hello-web's label composes under it (ADR 0066). */
const DOMAIN = "example";
let instanceId = "";
let held: HeldImage[] = [];
@@ -85,10 +87,6 @@ async function mesh(command: string, timeoutMs?: number): Promise<string> {
}
/** 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 foundation bundle: ours by the ID the machine holds, everything else upstream. */
function bundleFor(images: HeldImage[]): string {
@@ -167,64 +165,32 @@ after(async () => {
test("the mesh routes a public name through the proxy to the consumer, and withdraws it on unassign", {
skip, timeout: 1_500_000,
}, async () => {
// The PROVIDER: route-proxy in the plain-HTTP shape — provides `route`, is given every consumer as
// the file at receives.route, forwards by Host. No TLS here (that is certificates.test.ts); the
// image is pinned to what this scenario serves by digest.
const proxyManifest = JSON.stringify({
module: "route-proxy",
version: "1",
capabilities: ["container-runtime"],
provides: [{ name: "route", scope: "mesh" }],
serves: { route: {} },
receives: { route: "/var/lib/route-proxy/routes/mesh.json" },
listens: [{ port: 80, protocol: "tcp", from: "anywhere", why: "public HTTP; the route-forwarding front door" }],
resources: [
{ id: "state", type: "directory", path: "/var/lib/route-proxy", mode: "0700" },
{ id: "routes-dir", type: "directory", path: "/var/lib/route-proxy/routes", mode: "0700" },
{
id: "server", type: "container", name: "route-proxy",
image: pinned("mesh-route-proxy"), network: "host",
volumes: ["/var/lib/route-proxy/routes:/routes:ro"],
env: { ROUTES: "/routes/mesh.json", LISTEN: ":80" },
},
],
});
// The CONSUMER: hello-web requires `route` and contributes the name it wants and the port it
// listens on. It runs no code of the mesh's — a bare alpine serving a fixed page over a busybox nc
// loop stands in for a web service. `contributes` is what makes it *ask*: the grant forms from it.
const webManifest = JSON.stringify({
module: "hello-web",
slug: "hello",
version: "1",
capabilities: ["container-runtime"],
requires: ["route"],
contributes: { route: { name: NAME, port: 8080 } },
binds: { route: "/var/lib/hello-web/route.json" },
listens: [{ port: 8080, protocol: "tcp", from: "mesh", why: "the demo page; only the proxy reaches it" }],
resources: [
{ id: "state", type: "directory", path: "/var/lib/hello-web", mode: "0700" },
{ id: "page", type: "file", path: "/var/lib/hello-web/index.html", mode: "0644", content: `${PAGE}\n` },
{ id: "net", type: "network", name: "hello-web" },
{
id: "server", type: "container", name: "hello-web",
image: pinned("alpine"), network: "hello-web", ports: ["8080:8080"],
volumes: ["/var/lib/hello-web/index.html:/www/index.html:ro"],
args: ["sh", "-c",
"while true; do { printf 'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\n'; cat /www/index.html; } | nc -l -p 8080; done"],
},
],
});
await must(`printf %s ${quote(proxyManifest)} > /tmp/route-proxy.json && docker cp /tmp/route-proxy.json mesh-controller:/route-proxy.json`);
await mesh("module add /route-proxy.json");
// No `module issue`: route-proxy has no broker account and no own-secret to mint. `assign` resolves
// its plan and the provider is matchable by a consumer's route from that alone.
await mesh(`assign ${MACHINE} route-proxy`);
await must(`printf %s ${quote(webManifest)} > /tmp/hello-web.json && docker cp /tmp/hello-web.json mesh-controller:/hello-web.json`);
await mesh("module add /hello-web.json");
await mesh(`assign ${MACHINE} hello-web`);
// All three from the catalogue (novox/hq ADR 0093, issue 074): the authority the proxy requires,
// the proxy, and the consumer. The proxy's manifest names the runtime image the scenario stocks;
// the authority and the consumer's server are pulled upstream by digest. The name the consumer
// is routed under is composed from its label and the node's public domain (ADR 0066), which
// the bed sets first.
await mesh(`node public-domain ${MACHINE} ${DOMAIN}`);
// The authority certifies itself for the machine's private-network address, which is what a
// consumer on any node dials (ADR 0098); a machine raised from the bundle has none until it is
// placed on the overlay. Placed as a hub of one, the way a real first node is, and converged
// BEFORE the modules arrive: the proxy fetches the authority's roots at that address at first
// start, so the interface must exist by then — in one push the order between modules is not
// promised. The derived filter admits the hub's port, as genesis does on a control-node (ADR 0088).
await mesh(`overlay place ${MACHINE} --hub --endpoint 192.0.2.10:51820 --site lab`);
await mesh(`assign ${MACHINE} networking`);
await mesh(`push ${MACHINE}`);
await settled();
await deriveTheFilterOn({ machine: MACHINE, node: MACHINE, hubPort: 51820,
must: (_m, c, t) => must(c, t), mesh, on: (_m, c, t) => on(c, t) });
const overlay = await must(`ip -4 addr show dev mesh0 2>&1 || ip -4 addr 2>&1`);
assert.match(overlay, /inet 10\./, `${MACHINE} has no private-network address after networking converged:\n${overlay}`);
for (const name of ["step-ca", "route-proxy", "hello-web"]) {
await must(`printf %s ${quote(catalogueModule(name, held))} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`);
await mesh(`module add /${name}.json`);
if (name === "step-ca") await mesh(`module issue ${name} --node ${MACHINE}`);
await mesh(`assign ${MACHINE} ${name}`);
}
await mesh(`push ${MACHINE}`);
await settled();
+10 -61
View File
@@ -179,6 +179,9 @@ const NOVOX: Mod[] = [
{ name: "mongodb", containers: ["mongo", "mesh-mongodb"] },
{ name: "mssql", containers: ["mssql", "mesh-mssql"] },
{ name: "lavinmq", containers: ["lavinmq", "mesh-lavinmq"] },
// The vault, before everything that keeps a secret from it: gitea, umami, influxdb, mailu
// require one (novox/hq ADRs 0085, 0094).
{ name: "mesh-vault", containers: ["mesh-vault"] },
// ADR 0066: the proxy now REQUIRES an `acme-ca`, so the bed must assign a provider of one or
// route-proxy is unresolvable and takes every routed module down with it. step-ca is that
// provider, on the anchor, at mesh scope.
@@ -306,14 +309,11 @@ const CREDENTIALS: { node: string; module: string; name: string; crash: string }
{ node: "ace", module: "home-assistant", name: "token", crash: "no Home Assistant token" },
{ node: "ace", module: "nzbget", name: "password", crash: "NZBGet not configured" },
{ node: "ace", module: "qbittorrent", name: "password", crash: "qBittorrent not configured" },
{ node: "novox", module: "umami", name: "admin", crash: "admin password is not set" },
];
/** Operator secrets for the credential modules that own-secret their whole app (mailu, de-spiegel). */
/** Operator secrets for the credential modules that own-secret their whole app (de-spiegel,
* amqp-email-forwarder). mailu's and umami's are kept in the vault now (ADR 0094), not delivered. */
const OPERATOR_SECRETS: { node: string; module: string; name: string; value: string }[] = [
{ node: "novox", module: "mailu", name: "secret-key", value: "0123456789abcdef0123456789abcdef" },
{ node: "novox", module: "mailu", name: "admin", value: "MailuAdminFakePass123" },
{ node: "novox", module: "mailu", name: "api-token", value: "mailuapitokenfake0123456789abcd" },
{ node: "novox", module: "de-spiegel", name: "smtp-user", value: "despiegel-smtp-fake" },
{ node: "novox", module: "de-spiegel", name: "smtp-pass", value: "despiegel-pass-fake" },
{ node: "novox", module: "amqp-email-forwarder", name: "smtp-user", value: "eef-smtp-fake" },
@@ -410,56 +410,8 @@ async function psMapOf(node: string): Promise<Map<string, string>> {
return map;
}
/**
* ADR 0066: the internal CA is initialised FROM AN OPERATOR'S ROOT — it does not mint its own.
*
* So the bed has to be an operator. The material is made on the anchor with openssl and handed to
* the mesh through the real `secret accept` path, exactly as a person would: the mesh cannot invent
* a PEM, and the random 32 bytes it makes for an own-secret nobody supplied would leave step-ca
* crash-looping on a root key that is not a key.
*/
async function deliverCaRoot(): Promise<boolean> {
const made = await on(CONTROL, [
"set -e",
"mkdir -p /tmp/ca && cd /tmp/ca",
// No trailing newline on a password file: step-ca reads the file as the password itself.
"openssl rand -hex 16 | tr -d '\\n' > key-password",
"openssl ecparam -genkey -name prime256v1 -out root.unenc",
"openssl ec -in root.unenc -aes256 -passout file:key-password -out root.key",
"rm -f root.unenc",
"openssl req -x509 -new -key root.key -passin file:key-password -sha256 -days 3650" +
` -out root.crt -subj "/CN=Mesh Internal CA/O=Novox Mesh Lab"`,
// Readable by the control plane, which is not root. Its image is FROM scratch and runs as
// 65534, and `docker cp` keeps the ownership and mode a file had outside — openssl writes a
// private key 0600 root-owned, so the copy landed unreadable and `secret accept` failed with
// `open /ca-root-key: permission denied`. The CA then crash-looped on a root it never got.
// Chowning it inside the container is not available: there is no shell in there to do it with.
//
// Safe here and nowhere else: these three exist for the seconds between being written and
// being sealed to the machine, on a lab node, for a CA thrown away with the scenario.
"chmod 0644 /tmp/ca/root.crt /tmp/ca/root.key /tmp/ca/key-password",
"docker cp /tmp/ca/root.crt mesh-controller:/ca-root-cert",
"docker cp /tmp/ca/root.key mesh-controller:/ca-root-key",
"docker cp /tmp/ca/key-password mesh-controller:/ca-root-key-password",
].join("\n"), 180_000);
if (!made.ok) {
console.log(`CA ROOT NOT MADE on ${CONTROL}:\n${made.out.split("\n").slice(-8).join("\n")}`);
return false;
}
for (const [name, file] of [
["root-cert", "/ca-root-cert"],
["root-key", "/ca-root-key"],
["root-key-password", "/ca-root-key-password"],
] as const) {
try {
await mesh(`secret accept ${CONTROL} step-ca ${name} --from ${file}`);
} catch (err) {
console.log(`CA ROOT ACCEPT FAILED (${name}): ${(err as Error).message.split("\n").slice(0, 3).join(" | ")}`);
return false;
}
}
return true;
}
// ADR 0098: the internal authority makes its own root at first start and serves it; the proxy
// fetches it. No operator root is delivered — the mesh mints only the authority's password.
/** What a module's manifest says its route label is, or "" if it contributes no route. */
function routeLabelOf(name: string): string {
@@ -848,7 +800,9 @@ test("the full mesh forms across the access point and both server sets converge"
for (const { name } of mods) {
try {
const broker = await ensureAdded(name);
if (broker) await mesh(`module issue ${name} --node ${node}`);
// Issued when the module holds a broker account or an own-secret the mesh mints for it
// (step-ca's password, ADR 0098); a requirement kept in the vault needs no issue.
if (broker || loadManifest(name).manifest.includes('"own-secrets"')) await mesh(`module issue ${name} --node ${node}`);
await mesh(`assign ${node} ${name}`);
assigned[node]!.add(name);
} catch (err) {
@@ -889,10 +843,6 @@ test("the full mesh forms across the access point and both server sets converge"
}
}
// ADR 0066: the CA's root, before the push that would otherwise deliver a random 32 bytes for it.
const caRootDelivered = assigned["novox"]!.has("step-ca") ? await deliverCaRoot() : false;
if (!caRootDelivered) console.log("ADR 0066: no operator root delivered; step-ca cannot initialise.");
// ONE push per node (workstations first — cheap — then the heavy service nodes).
const pushError: Record<string, string> = {};
for (const node of ["shanks", "g14", "novox", "ace"]) {
@@ -1027,7 +977,6 @@ test("the full mesh forms across the access point and both server sets converge"
? (await on("novox", `curl -s -o /dev/null -w '%{http_code}' --max-time 10 -H 'Host: ${probe.name}' http://127.0.0.1/`)).out.trim()
: "";
adr.push(`\n proxy answers for ${probe?.name ?? "(nothing composed)"}: ${servedCode || "no answer"}`);
adr.push(` operator root delivered to step-ca: ${caRootDelivered}`);
adr.push(` step-ca container: ${psMaps["novox"]?.get("step-ca") ?? "MISSING"}`);
console.log(adr.join("\n"));
@@ -68,6 +68,9 @@ const NODE = "novox";
*/
const MODULES: { name: string; containers: string[]; node?: boolean }[] = [
{ name: "postgres", containers: ["mesh-store", "mesh-postgres"] },
// The vault, before everything that keeps a secret from it: redis, gitea, umami, influxdb,
// mailu require one (novox/hq ADRs 0085, 0094).
{ name: "mesh-vault", containers: ["mesh-vault"] },
{ name: "redis", containers: ["redis", "mesh-redis"] },
{ name: "minio", containers: ["minio", "mesh-minio"] },
{ name: "mongodb", containers: ["mongo", "mesh-mongodb"] },