From f03647d60585e81ffc8697bd9fad160ae70acae2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 6 Sep 2026 15:07:23 +0200 Subject: [PATCH] Add route-forwarding lab bed proving the route grant end to end A scenario and integration test assign route-proxy (provider) and hello-web (consumer) on one node, then assert a request to the consumer's name -- sent to the proxy -- is forwarded to the workload and returns its answer, and that unassigning the consumer withdraws the route so the same request stops working (the proxy replaces its table rather than merging). Modeled on mesh-grant-end-to-end and schedule-tick: module add, assign, one push, settled, with no module issue (route-proxy needs no scoped account). build-route-proxy-image.sh compiles the Go proxy from mesh-control/examples/route-proxy into mesh-route-proxy:development for the scenario to stock. This bed proves route-forwarding over plain HTTP; public-ACME TLS is proven separately by certificates.test.ts against a real ACME server. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- scenarios/route-forwarding.yml | 49 ++++ scripts/build-route-proxy-image.sh | 28 +++ test/integration/route-forwarding.test.ts | 285 ++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 scenarios/route-forwarding.yml create mode 100755 scripts/build-route-proxy-image.sh create mode 100644 test/integration/route-forwarding.test.ts diff --git a/scenarios/route-forwarding.yml b/scenarios/route-forwarding.yml new file mode 100644 index 0000000..ae25cb5 --- /dev/null +++ b/scenarios/route-forwarding.yml @@ -0,0 +1,49 @@ +# One machine that becomes a mesh and is then assigned the route-proxy provider and a hello-web +# consumer — the bed that proves route-forwarding end to end (novox/hq ADR 0007, +# 08-connectivity §3 Exposure). +# +# **A route is a grant.** hello-web `requires: route` and contributes the name it wants and the port +# it listens on; route-proxy `provides: route`, is given every consumer as a file the mesh writes +# (`receives.route`), and forwards by the request's `Host` header to where the mesh says that +# consumer is. The sharp point this bed proves, straight from 08-connectivity §3's "Checked in the +# lab": a request to the consumer's name, sent to the proxy, reaches the workload and returns the +# workload's own answer — then unassigning the consumer withdraws the route and the same request +# stops working (the proxy replaces its table rather than merging). +# +# This bed proves the ROUTE-FORWARDING half over plain HTTP. The public-ACME/TLS half — ordering a +# publicly-trusted certificate and answering an HTTP-01 challenge at the name — is proven separately +# by certificates.test.ts against a real ACME server (Pebble), driving the same proxy binary. +# +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# scripts/build-route-proxy-image.sh builds mesh-route-proxy:development into the local daemon +# (from mesh-control/examples/route-proxy, via mesh-catalog/modules/route-proxy/Dockerfile). +# alpine:latest must be in the local daemon — hello-web's backend is a bare alpine that serves a +# fixed page over a busybox nc loop. Both images are stocked and served by digest. +scenario: route-forwarding + +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] + +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10] } + inbound: allow + memory: 3GiB + cpus: 2 + +images: + # The first-node substrate: store, broker, control. + - postgres:17-alpine + - cloudamqp/lavinmq:latest + - mesh-control:development + # The route-proxy's image, built from the canonical Go proxy in mesh-control by + # scripts/build-route-proxy-image.sh, and hello-web's backend, a bare alpine nc loop. + - mesh-route-proxy:development + - alpine:latest + +place: + # Only the host — neither module carries a mesh-runtime. Both service images are served by the + # scenario's registry and pulled by the host, not placed inside the machine. + all: [host] diff --git a/scripts/build-route-proxy-image.sh b/scripts/build-route-proxy-image.sh new file mode 100755 index 0000000..ef5985e --- /dev/null +++ b/scripts/build-route-proxy-image.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Build the route-proxy module's runtime image: the reference reverse proxy (novox/hq +# 08-connectivity §3), compiled from its canonical Go source in mesh-control into a container the +# lab can stock and serve by digest. +# +# Unlike the TypeScript modules (built by build-module-runtime.sh into a node tool runtime), the +# proxy is a Go program. The module ships only the packaging — a Dockerfile in mesh-catalog whose +# build context is the mesh-control repository root — and this script runs that build into the local +# docker daemon, which a scenario's registry then stocks and serves by digest. +# +# build-route-proxy-image.sh +# -> tags mesh-route-proxy:development in the local daemon +set -euo pipefail + +HERE="$(cd "$(dirname "$0")/.." && pwd)"; ROOT="$(cd "$HERE/.." && pwd)" +MESH_CONTROL="${MESH_CONTROL:-$ROOT/mesh-control}" +MESH_CATALOG="${MESH_CATALOG:-$ROOT/mesh-catalog}" +TAG="${ROUTE_PROXY_TAG:-mesh-route-proxy:development}" +DOCKERFILE="$MESH_CATALOG/modules/route-proxy/Dockerfile" + +[ -f "$DOCKERFILE" ] || { echo "no Dockerfile at $DOCKERFILE" >&2; exit 1; } +[ -f "$MESH_CONTROL/examples/route-proxy/main.go" ] || { + echo "no proxy source at $MESH_CONTROL/examples/route-proxy" >&2; exit 1; } + +# Context is the mesh-control 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" +echo "built $TAG (from $MESH_CONTROL/examples/route-proxy)" diff --git a/test/integration/route-forwarding.test.ts b/test/integration/route-forwarding.test.ts new file mode 100644 index 0000000..5b5573f --- /dev/null +++ b/test/integration/route-forwarding.test.ts @@ -0,0 +1,285 @@ +/** + * The mesh assigns the route-proxy provider and a hello-web consumer, and a request to the + * consumer's name — sent to the proxy — is forwarded to the workload and returns the workload's own + * answer. Then the consumer is unassigned and the same request stops working. This is the + * route-forwarding half of exposure, mesh-driven end to end (novox/hq ADR 0007, 08-connectivity + * §3), with nothing hand-written. + * + * **A route is a grant.** hello-web `requires: route` and contributes `{name, port}`; route-proxy + * `provides: route`, and the *mesh* writes it the contributions file (`receives.route`) — the test + * places nothing. The proxy reads that file as $ROUTES and forwards by the `Host` header to + * `http://:`, where `at` is where the mesh says the consumer is (empty here — co-located — + * so 127.0.0.1, the published backend port). The two claims this bed proves, straight from + * 08-connectivity §3's "Checked in the lab": + * + * - a request to the consumer's name reaches the workload across the proxy and returns its answer; + * - unassigning the module withdraws the route and the same request stops working — the proxy + * replaces its table rather than merging, so a name whose module left is no longer served. + * + * route-proxy carries no broker account, no own-secrets and no provisioner: it neither mints a + * credential nor emits an event, it only reads the file the mesh writes. So `module add` → `assign` + * → ONE `push` is the whole sequence for each of the two modules, with no `module issue` (as + * schedule-tick and the run-once bed established for a module that needs no scoped account). + * + * This bed proves ROUTE-FORWARDING over plain HTTP. The public-ACME/TLS half — ordering a + * publicly-trusted certificate and answering an HTTP-01 challenge at the name — is proven separately + * by certificates.test.ts, which drives the same proxy binary against a real ACME server (Pebble). + * + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * scripts/build-route-proxy-image.sh builds mesh-route-proxy:development into the local daemon; + * scenarios/route-forwarding.yml stocks it and alpine:latest, and serves both by digest. + */ + +import { test, before, after } from "node:test"; +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 { destroy, exec } from "../../src/lifecycle/operate.ts"; +import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { labIsUsable, destroyAll } from "./harness.ts"; + +const capability = await labIsUsable(); +const binary = hostBinaryPath(); +const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; + +const skip = !capability.usable + ? `lab not usable: ${capability.why}` + : !binary || !existsSync(binary) + ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" + : !bundle || !existsSync(bundle) + ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + : false; + +const SCENARIO = "route-forwarding"; +const MACHINE = "anchor"; +const NAME = "hello.example"; +const PAGE = "hello from hello-web, routed by the mesh"; + +let instanceId = ""; +let stocked: string[] = []; + +function quote(s: string): string { + return `'${s.replaceAll("'", `'\\''`)}'`; +} + +async function on(command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> { + const { stdout } = await exec(instanceId, MACHINE, [ + "sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`, + ], timeoutMs); + const marker = stdout.lastIndexOf("__exit="); + if (marker < 0) return { out: stdout, ok: false }; + return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" }; +} + +async function must(command: string, timeoutMs?: number): Promise { + const { out, ok } = await on(command, timeoutMs); + if (!ok) throw new Error(`${MACHINE}: ${command}\n${out}`); + return out; +} + +/** The control plane, a container on the node. */ +async function mesh(command: string, timeoutMs?: number): Promise { + 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 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; +} + +function tokenFrom(said: string): string { + const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" ")); + assert.ok(found, `no token in:\n${said}`); + return found; +} + +async function settled(withinMs = 600_000): Promise { + const until = Date.now() + withinMs; + let last = ""; + while (Date.now() < until) { + const asked = await on(`docker exec mesh-control /mesh-control status --json`); + if (asked.ok) { + try { + const state = JSON.parse(asked.out) as { + wrong: { node: string; outcome: string }[]; + waiting: { node: string }[]; + reported: { node: string; outcome: string; current: boolean }[]; + }; + const bad = state.wrong.find((w) => w.node === MACHINE); + if (bad) throw new Error(`${MACHINE} did not apply what it was sent: ${bad.outcome}\n${asked.out}`); + const word = state.reported.find((r) => r.node === MACHINE); + if (!state.waiting.some((w) => w.node === MACHINE) && word?.outcome === "applied" && word.current) return; + last = asked.out; + } catch (err) { + if (err instanceof Error && err.message.includes("did not apply")) throw err; + last = asked.out; + } + } + await new Promise((r) => setTimeout(r, 5000)); + } + throw new Error(`${MACHINE} never caught up within ${Math.round(withinMs / 1000)}s. Last:\n${last}`); +} + +/** A request to the proxy carrying the consumer's public name. The proxy binds :80 on the machine's + * own network (network: host), so it is reached at 127.0.0.1 from inside the machine. */ +async function throughProxy(host: string): Promise<{ out: string; ok: boolean }> { + return on(`curl -s --max-time 5 -H ${quote(`Host: ${host}`)} http://127.0.0.1/`); +} + +before(async () => { + if (skip) return; + + const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { + onProgress: (m) => console.log(`raise: ${m}`), + }); + instanceId = raised.instanceId; + stocked = raised.images; + + // Raise the substrate — store, broker, control — from the bundle. + await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + const up = await must(`docker ps --format '{{.Names}}'`); + for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { + assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + } + + // The node joins its own mesh, so it is a node the mesh can assign to, and the host runs so it + // applies what it is pushed. + await mesh(`node add ${MACHINE}`); + const token = tokenFrom(await mesh(`token issue --node ${MACHINE}`)); + await must(`${HOST_PATH} enrol --token ${quote(token)}`); + await must(`nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`); +}, { timeout: 1_800_000 }); + +after(async () => { + if (instanceId) await destroy(instanceId); + await destroyAll(`${SCENARIO}-`); +}, { timeout: 600_000 }); + +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", + 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-control:/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-control:/hello-web.json`); + await mesh("module add /hello-web.json"); + await mesh(`assign ${MACHINE} hello-web`); + + await mesh(`push ${MACHINE}`); + await settled(); + + // The mesh matched the two and wrote route-proxy its contributions file — the test wrote nothing. + const routes = await must(`cat /var/lib/route-proxy/routes/mesh.json`); + assert.match(routes, new RegExp(NAME.replace(/\./g, "\\.")), + `the mesh did not write route-proxy the route for ${NAME}:\n${routes}`); + assert.match(routes, /"port": *8080/, `the route did not carry the consumer's port:\n${routes}`); + + // --- THE PROOF: a request to the name, sent to the proxy, returns the workload's own answer ------ + // The proxy polls its routes file every couple of seconds and the backend takes a moment to bind, + // so this is retried. It is the exact shape of 08-connectivity §3's lab check. + let served = { out: "", ok: false }; + const untilServed = Date.now() + 120_000; + while (Date.now() < untilServed) { + served = await throughProxy(NAME); + if (served.ok && served.out.includes(PAGE)) break; + await new Promise((r) => setTimeout(r, 3000)); + } + assert.ok(served.out.includes(PAGE), + `the name ${NAME} was never routed to the workload:\n${served.out}\n` + + `---routes---\n${routes}\n` + + `---proxy log---\n${(await on(`docker logs route-proxy 2>&1 | tail -20`)).out}\n` + + `---host log---\n${(await on(`tail -40 /var/log/mesh-host.log`)).out}`); + + // --- a name the proxy does not serve is refused by name, not with a bare 404 -------------------- + const unknown = await throughProxy("nobody-asked-for-this.example"); + assert.doesNotMatch(unknown.out, new RegExp(PAGE), + `an unrouted name reached the workload:\n${unknown.out}`); + assert.match(unknown.out, /no route for/, + `the proxy did not name what it serves for an unknown host:\n${unknown.out}`); + + // --- WITHDRAWAL: unassigning the consumer removes the route, and the same request stops working -- + // The file the proxy is given is the whole truth about who has a route, so the proxy replaces its + // table rather than merging — a name whose module left is no longer served (08-connectivity §3). + await mesh(`unassign ${MACHINE} hello-web`); + await mesh(`push ${MACHINE}`); + await settled(); + + let gone = { out: "", ok: false }; + const untilGone = Date.now() + 60_000; + while (Date.now() < untilGone) { + gone = await throughProxy(NAME); + if (!gone.out.includes(PAGE)) break; + await new Promise((r) => setTimeout(r, 3000)); + } + assert.doesNotMatch(gone.out, new RegExp(PAGE), + `the route outlived the module that asked for it — withdrawal did not take:\n${gone.out}\n` + + `---routes---\n${(await on(`cat /var/lib/route-proxy/routes/mesh.json`)).out}`); + assert.match(gone.out, /no route for/, + `after withdrawal the proxy did not report ${NAME} as unserved:\n${gone.out}`); +});