From 47d990b33ad3e0bb85cff1728285cc08e75e5bbe Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 02:43:19 +0200 Subject: [PATCH] Prove a route reaches the workload, and does not outlive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request goes to the name, across the private network, and returns the workload's own answer. Then the module is unassigned and the same request must stop working — a stale public name pointing at nothing fails more visibly than a stale grant. The workload declares its port as well as its route, because they are different questions and the earlier test leaves this machine filtering: a module that asked for a route and not for the port would be unreachable by the proxy it just asked for. --- scenarios/two-nodes.yml | 2 + test/integration/mesh.test.ts | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/scenarios/two-nodes.yml b/scenarios/two-nodes.yml index 0912483..42858e5 100644 --- a/scenarios/two-nodes.yml +++ b/scenarios/two-nodes.yml @@ -34,6 +34,8 @@ images: # And the provisioner, which is what makes a sealed credential true on a machine — the mesh # discarded the plaintext and cannot tell a database to start accepting it. - mesh-provision-postgres:development + # And the proxy, which is what turns a route grant into traffic actually arriving. + - mesh-route-proxy:development place: all: [host, runtime] diff --git a/test/integration/mesh.test.ts b/test/integration/mesh.test.ts index e657e6d..fd40bf4 100644 --- a/test/integration/mesh.test.ts +++ b/test/integration/mesh.test.ts @@ -851,3 +851,89 @@ test("rotating a credential moves both ends, and the old one stops working", { assert.ok(!(await login(first)).ok, "the password that was rotated away still authenticates, so nothing was rotated"); }); + +test("a route is a grant: a workload is reached by the name it asked for", { + skip, timeout: 900_000, +}, async () => { + // novox/hq 08-connectivity §3. The mirror of a database grant: there the consumer supplies a + // name and receives credentials; here it supplies a target and receives a name. Nothing new in + // the vocabulary — a route is a provision like any other. + // + // The workload is the registry image, because it is an HTTP server this scenario already has. + // What is being tested is the mesh's arrangement, not the workload. + await must("anchor", `printf %s '{"module":"frontdoor","version":"1",` + + `"provides":[{"name":"route","scope":"mesh"}],` + + `"capabilities":["container-runtime"],` + + `"receives":{"route":"/etc/frontdoor/routes.json"},` + + `"serves":{"route":{"domain":"mesh.test"}},` + + `"listens":[{"port":8081,"from":"mesh","why":"the front door"}],` + + `"resources":[{"id":"dir","type":"directory","path":"/etc/frontdoor","mode":"0755"},` + + `{"id":"proxy","type":"container","name":"front-door",` + + `"image":"${pinned("mesh-route-proxy")}","network":"host",` + + `"volumes":["/etc/frontdoor:/etc/frontdoor:ro"],` + + `"env":{"ROUTES":"/etc/frontdoor/routes.json","LISTEN":":8081"}}]}' ` + + `> /tmp/frontdoor.json`); + // The workload declares the port it listens on as well as the route it wants. Both, because + // they are different questions: one says who may reach it, the other says by what name — and + // the earlier test left this machine filtering, so a module that asked for a route and not for + // the port would be unreachable by the proxy it just asked for. + await must("anchor", `printf %s '{"module":"storefront","version":"1",` + + `"requires":["route"],"capabilities":["container-runtime"],` + + `"contributes":{"route":{"name":"shop.mesh.test","port":8088}},` + + `"binds":{"route":"/etc/storefront/route.json"},` + + `"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`); + for (const f of ["frontdoor", "storefront"]) { + await must("anchor", `docker cp /tmp/${f}.json mesh-control:/${f}.json`); + await mesh(`module add /${f}.json`); + } + await mesh("assign anchor frontdoor"); + await mesh("assign laptop storefront"); + await mesh("push"); + await new Promise((r) => setTimeout(r, 25_000)); + + // The provider was told who asked, and where that machine is — which it needs in order to + // reach back, and which it must not have to derive from a naming convention. + const routes = await must("anchor", `cat /etc/frontdoor/routes.json`); + assert.match(routes, /shop\.mesh\.test/, `the proxy was not told about the route:\n${routes}`); + assert.match(routes, /"at": *"laptop\.internal"/, + `the proxy was not told where the consumer is, so it cannot reach it:\n${routes}`); + + // And the consumer was told what the provider serves, which is how it knows its own name. + const bound = await must("laptop", `cat /etc/storefront/route.json`); + assert.match(bound, /mesh\.test/, `the consumer was not told the public name:\n${bound}`); + + // The whole point: a request for the name reaches the workload, across the private network. + let reached = false; + let said = ""; + for (let i = 0; i < 20 && !reached; i++) { + const answer = await on("anchor", + `curl -sf -H 'Host: shop.mesh.test' http://127.0.0.1:8081/v2/ -o /dev/null -w '%{http_code}'`); + said = answer.out; + reached = answer.ok && said.trim() === "200"; + if (!reached) await new Promise((r) => setTimeout(r, 4000)); + } + assert.ok(reached, `a request for the name did not reach the workload (${said}):\n` + + `${(await on("anchor", `docker logs front-door 2>&1 | tail -20`)).out}`); + + // Withdrawal, which 08-connectivity lists as open: a stale public name pointing at nothing + // fails more visibly than a stale grant, so it must not survive the module leaving. + await mesh("unassign laptop storefront"); + await mesh("push"); + await new Promise((r) => setTimeout(r, 20_000)); + + const after = await must("anchor", `cat /etc/frontdoor/routes.json`); + assert.doesNotMatch(after, /shop\.mesh\.test/, + `the route outlived the module that asked for it:\n${after}`); + + let gone = false; + for (let i = 0; i < 15 && !gone; i++) { + const answer = await on("anchor", + `curl -s -H 'Host: shop.mesh.test' http://127.0.0.1:8081/v2/ -o /dev/null -w '%{http_code}'`); + gone = answer.out.trim() === "404"; + if (!gone) await new Promise((r) => setTimeout(r, 3000)); + } + assert.ok(gone, "the proxy still serves a name whose module was unassigned"); +});