From 680b91546cf0740e5cf2db45e6176921cb08f5ba Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 12:58:30 +0200 Subject: [PATCH 01/18] Migrate audit-logger: it builds itself now The first module moved onto the new build process. It named a placeholder digest nothing could produce, so it only ever worked where somebody had pre-built its image by hand. It names the two shared bases instead, and the mesh builds it. Chosen first deliberately: it requires nothing, nothing requires it, and an audit trail of every event on the mesh is the thing most worth having while modules are being moved one at a time. --- modules/audit-logger/Dockerfile | 33 ++++++++++++++++++++++++++++++++ modules/audit-logger/module.json | 29 +++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 modules/audit-logger/Dockerfile diff --git a/modules/audit-logger/Dockerfile b/modules/audit-logger/Dockerfile new file mode 100644 index 0000000..f95c9ed --- /dev/null +++ b/modules/audit-logger/Dockerfile @@ -0,0 +1,33 @@ +# audit-logger's runtime: the shared runtime image, carrying this module's compiled code. +# +# **Built from this module's own directory and nothing else.** The toolkit is in the base image, so +# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a +# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have +# the siblings laid out beside it. + +# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in. +# They are different images on purpose — the first carries a compiler and the second must not, or +# every running container would carry one it never invokes. The mesh answers both with the copies it +# holds, because a fingerprint written here would name one particular copy and no other mesh has it +# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build +# nobody told stops here and says which module to build first. +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own +# node_modules — the module is compiled against exactly the toolkit it will run against. +WORKDIR /app/modules/audit-logger +COPY . . +# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are +# symlinks to a launcher that requires its library relatively — resolved away when the base image +# was assembled. +RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist +# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it +# imports anything — `run` exists for a step that works offline and exits, and would leave this +# with nothing to subscribe to. +ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 509cbee..1b8bfa8 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -2,10 +2,33 @@ "module": "audit-logger", "version": "1", "slug": "audit", - "consumes": ["#"], + "consumes": [ + "#" + ], "own-secrets": { "broker": "/var/lib/audit-logger/broker" }, + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + }, "resources": [ { "id": "state", @@ -23,7 +46,6 @@ "id": "run", "type": "container", "name": "mesh-audit-logger", - "image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "host", "volumes": [ "/var/lib/audit-logger/broker:/run/secrets/broker:ro", @@ -32,7 +54,8 @@ "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "AUDIT_LOG": "/trail/audit.log" - } + }, + "artifact": "runtime" } ] } From 030509558c9c50c6bbba7545ae7b82801761f757 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:00:48 +0200 Subject: [PATCH 02/18] Name the registry where every machine can reach it, not where the builder stands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder's environment said MESH_REGISTRY=127.0.0.1:${bound:artifact-store:port} — the port taken from the binding, the host pinned to loopback. So every artifact the mesh builds was recorded under an address that means something only on the machine holding the registry, and nothing else in the mesh could resolve it. Loopback is correct for exactly one reader and the builder is not special: it already requires artifact-store, and the binding states where the provider is on the private network. It now uses both halves of what it was given. Invisible with one machine, which is the only shape this had been proven in. The registry module declares that every machine pulls from it and opens its port to the mesh for that reason, so the reference it is handed has to be one a second machine can use. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/builder/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index d061ed7..0444466 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -37,7 +37,7 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" }, { "id": "server", From c61c7f74f99f366724f751ff8c8f20010a6ce3aa Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:05:10 +0200 Subject: [PATCH 03/18] Convert lavinmq: it is not only a broker, so it has to be built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh refused to place it — two of its three containers named mesh-runtime-lavinmq@sha256:000…0, "a placeholder digest, which is never a real image". That refusal was right, and the belief behind the placeholder was that lavinmq needs no building because its broker is an upstream image. The broker is upstream. The module is not the broker. It carries a run-once bootstrap that writes the broker's configuration before it first starts, a provisioner that grants each consumer its own vhost and user, a set of tools and an event consumer — all of it this module's own TypeScript, and none of it producible by naming somebody else's image. So it gets what every module with code of its own gets: a Dockerfile standing on the shared toolchain and runtime bases, a build block naming them, and containers that name the artifact rather than a digest nothing can produce. Same recipe as postgres, which is the converted module closest in shape — it has a provisioner too. Noted and deliberately not changed: postgres runs its provisioner from its container's args, and lavinmq's equivalent container names none, so on this manifest the provisioner is never started. That may be why, or may be a second fault; it is left alone so the next run says which. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/lavinmq/Dockerfile | 41 +++++++++++++++++++++++++++++++++++++ modules/lavinmq/module.json | 27 +++++++++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 modules/lavinmq/Dockerfile diff --git a/modules/lavinmq/Dockerfile b/modules/lavinmq/Dockerfile new file mode 100644 index 0000000..2c022d9 --- /dev/null +++ b/modules/lavinmq/Dockerfile @@ -0,0 +1,41 @@ +# lavinmq's runtime: the tool runtime, carrying this module's compiled bootstrap, provisioner, +# tools and event consumer. +# +# **Built from this module's own directory and nothing else.** The sdk is in the base image, so +# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a +# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have +# the siblings. +# +# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in. +# They are different images on purpose — the first carries a compiler and the second must not, or +# every running container would carry one it never invokes. The mesh answers both with the copies it +# holds, because a fingerprint written here would name one particular copy and no other mesh has it +# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build +# nobody told stops here and says which module to build first. +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own +# node_modules — the module is compiled against exactly the sdk it will run against. +WORKDIR /app/modules/lavinmq +COPY . . +# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are +# symlinks to a launcher that requires its library relatively — resolved away when the base image +# was assembled. +# +# Four entrypoints and a client, because this module is four things: a run-once bootstrap that +# writes the broker's configuration before it first starts, a provisioner that grants consumers +# their own vhost and user, a set of tools, and an event consumer. +RUN node /app/node_modules/typescript/bin/tsc \ + client.ts index.ts bootstrap/index.ts provisioner/index.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/lavinmq/dist /app/modules/lavinmq/dist +# What a tool host should load from this module: its event consumer and its tools, which are +# separate entrypoints because they are loaded by different things. The bootstrap and the +# provisioner are not listed here — the declaration names each in its container's `args`, because +# they are what this module's own containers run. One image, because they are one module and share +# a client. +ENV MESH_TOOL_MODULES=/app/modules/lavinmq/dist/index.js,/app/modules/lavinmq/dist/tools/index.js diff --git a/modules/lavinmq/module.json b/modules/lavinmq/module.json index ad94b4d..7a533aa 100644 --- a/modules/lavinmq/module.json +++ b/modules/lavinmq/module.json @@ -75,7 +75,7 @@ "id": "bootstrap", "type": "container", "name": "lavinmq-bootstrap", - "image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "artifact": "runtime", "run-once": true, "volumes": [ "/var/lib/lavinmq-module:/var/lib/lavinmq-module", @@ -114,7 +114,7 @@ "id": "runtime", "type": "container", "name": "mesh-lavinmq", - "image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "artifact": "runtime", "network": "lavinmq", "volumes": [ "/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro", @@ -129,5 +129,26 @@ "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" } } - ] + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } From af1e3afa495bac11e74c2d76cb2cf7a78167f2f6 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:20:48 +0200 Subject: [PATCH 04/18] amqp-ping declares a broker secret it never mounts Its runtime is a tool host: it connects to the mesh's broker before it does anything else. The module declares own-secrets.broker, and then its container neither mounts that file nor names it, so the runtime started and said there was no broker to reach, forever, in a restart loop. lavinmq's runtime container does both, and is the shape this follows. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/amqp-ping/module.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/amqp-ping/module.json b/modules/amqp-ping/module.json index 73a69ba..2f09f84 100644 --- a/modules/amqp-ping/module.json +++ b/modules/amqp-ping/module.json @@ -48,6 +48,12 @@ "type": "container", "name": "amqp-ping", "network": "amqp-ping", + "volumes": [ + "/var/lib/mesh/amqp-ping/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + }, "env-file": [ "/var/lib/amqp-ping/amqp.env" ], From e0c92195d4240841bfcf4b4a9ef869d5afeca331 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:38:05 +0200 Subject: [PATCH 05/18] The builder names the registry by loopback until there is a certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming it from the binding was right and arrived too early. The moment the machine had a name, the builder pushed to .internal:5000 and the runtime refused it: "http: server gave HTTP response to HTTPS client". The registry serves plaintext, and anything that is not loopback is required to be HTTPS. So there are two phases, and this is the first. Before the mesh has a certificate authority of its own, loopback is the only trusted path that is honest — it is trusted because it cannot leave the machine, not because anyone checked anything. The mesh-reachable name belongs to the second phase, with TLS from the mesh's own CA, and the binding expression returns then. Not a revert of the reasoning: novox/hq issue 048 stays open and this is why. The same one-line change lands again once a certificate module is running. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/builder/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index 0444466..d061ed7 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -37,7 +37,7 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" }, { "id": "server", From 4aa54fbbe05d25124834ccdbdf47a0594062a50d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 23:42:52 +0200 Subject: [PATCH 06/18] Move amqp-ping's source, so the mesh has something to notice --- modules/amqp-ping/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/amqp-ping/index.ts b/modules/amqp-ping/index.ts index 6f606f1..0b5f8b8 100644 --- a/modules/amqp-ping/index.ts +++ b/modules/amqp-ping/index.ts @@ -48,3 +48,4 @@ for (;;) { await sleep(30000); await pingOnce(); } +// changed by the one-node test at build 66e54af151df From d2dce347163982f55a56158a5577c6123df64db0 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 01:26:58 +0200 Subject: [PATCH 07/18] The catalogue asks on start, and registers a replay as history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A replayed build is registered exactly as any other and announced to nobody. A module that moved months ago is not something anything should act on now: emitting `upgraded` would have the control plane decide about a rollout, and `rebuild-needed` would ask for builds of things already current. Asked on every start rather than only the first, because a catalogue cannot tell whether it has a gap — and the answer is idempotent, so asking when there is none costs a message. Asked after subscribing, so a build arriving during the replay is not lost between the two. Closes novox/hq 04-ISSUES/050 with mesh-control. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/mesh-catalog/index.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index 20c64f0..4181fcb 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -36,6 +36,15 @@ interface Built { * produced it. Turning that into an edge between module-versions is this module's job. */ against?: string[]; + /** + * This is history, not news — the mesh re-announcing a build this catalogue was not there for. + * + * Registered exactly as any other, and announced as nothing. A module that moved months ago is + * not something anything should act on now: emitting `upgraded` would have the control plane + * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already + * current. + */ + replay?: boolean; } await on("module.builder.built", async (event) => { @@ -56,6 +65,10 @@ await on("module.builder.built", async (event) => { manifest: body.manifest ?? {}, }, body.made ?? [], body.against ?? []); + // **A replay is registered and announced to nobody.** See `replay` above: the graph gains what + // it was missing, and the mesh is told nothing happened, because nothing did. + if (body.replay) return; + await emit("module.mesh-catalog.registered", { module: body.module, commit: body.commit, upgraded, }); @@ -77,3 +90,15 @@ await on("module.builder.built", async (event) => { }); } }); + +// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). +// +// The queue above is durable, so nothing is missed once this is running. What it cannot have is +// what was announced before it first ran — and on a fresh mesh that is never arbitrary: the shared +// base, the store this runs on, and this module itself are each necessarily built BEFORE a +// catalogue exists to hear about them. The graph's foundation is the part it never sees. +// +// Asked on every start, not only the first. A catalogue cannot tell whether it has a gap, and the +// answer is idempotent: registering a build already held changes nothing and announces nothing. +// Asked AFTER subscribing, so a build arriving during the replay is not lost between the two. +await emit("module.mesh-catalog.catching-up", {}); From c4e3ebee8660ec0f4ba678cf9bf6927ad4591c5e Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 01:42:57 +0200 Subject: [PATCH 08/18] Move amqp-ping's source, so the mesh has something to notice --- modules/amqp-ping/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/amqp-ping/index.ts b/modules/amqp-ping/index.ts index 0b5f8b8..45b5c03 100644 --- a/modules/amqp-ping/index.ts +++ b/modules/amqp-ping/index.ts @@ -49,3 +49,4 @@ for (;;) { await pingOnce(); } // changed by the one-node test at build 66e54af151df +// changed by the one-node test at build 4fb41636cffd From 9a41add136d6eb70ac3fbeba437015d153637d46 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 12:58:30 +0200 Subject: [PATCH 09/18] showcase: a module that exercises everything a module can be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written so the module system has something that proves itself rather than a claim about what it supports, and guarded by a test in the catalogue's own suite so it cannot quietly stop exercising things. Nine of the host's eleven resource kinds, all three artifact kinds including the one that compiles, all three ways a module's code can run, and all four things that code can be: tools, an event consumer, a provisioner, and processes. Two absences that are findings rather than gaps. `action` is refused to modules outright — the link may not carry a command to run (ADR 0005), so a module that needs something done ships a program that reconciles, which is what a run-once process is. `service` puts an EXISTING unit into a state and installs none, which is right for software shipping its own; code the mesh built has no unit until the mesh writes one, and that is a process. And it no longer picks its own port. ADR 0038 says a module cannot know what else is on the machine it was assigned to, and names exactly the trap this fell into: the number written three times — listens, serves, a container's ports — agreeing only because one person wrote all three, with nothing checking. So it says what it needs and the mesh assigns the number. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/showcase/daemon/index.ts | 13 +++++ modules/showcase/files/README.md | 6 +++ modules/showcase/files/marker.txt | 1 + modules/showcase/index.ts | 12 +++++ modules/showcase/module.json | 73 +++++++++++++++++++++++++++ modules/showcase/package.json | 9 ++++ modules/showcase/provisioner/index.ts | 20 ++++++++ modules/showcase/report/index.ts | 11 ++++ modules/showcase/step/index.ts | 11 ++++ modules/showcase/tools/index.ts | 25 +++++++++ modules/showcase/tsconfig.json | 12 +++++ 11 files changed, 193 insertions(+) create mode 100644 modules/showcase/daemon/index.ts create mode 100644 modules/showcase/files/README.md create mode 100644 modules/showcase/files/marker.txt create mode 100644 modules/showcase/index.ts create mode 100644 modules/showcase/module.json create mode 100644 modules/showcase/package.json create mode 100644 modules/showcase/provisioner/index.ts create mode 100644 modules/showcase/report/index.ts create mode 100644 modules/showcase/step/index.ts create mode 100644 modules/showcase/tools/index.ts create mode 100644 modules/showcase/tsconfig.json diff --git a/modules/showcase/daemon/index.ts b/modules/showcase/daemon/index.ts new file mode 100644 index 0000000..660d285 --- /dev/null +++ b/modules/showcase/daemon/index.ts @@ -0,0 +1,13 @@ +// showcase's long-running process — a `process` that stays up. +// +// **Runs on the machine rather than in a container**, which is the whole point of the process +// resource: this is the mesh's own code, it needs no isolation from the mesh, and it should not +// need an image to run. +const greeting = process.env.SHOWCASE_GREETING ?? "hello"; +const every = Number(process.env.SHOWCASE_EVERY_SECONDS ?? "30") * 1000; + +console.log(`[showcase] up, saying ${greeting} every ${every / 1000}s`); + +// A daemon that stops is not a daemon, so this does not exit. The unit restarts it if it does, +// which is the machine's job rather than this file's. +setInterval(() => console.log(`[showcase] ${greeting}`), every); diff --git a/modules/showcase/files/README.md b/modules/showcase/files/README.md new file mode 100644 index 0000000..5c369cc --- /dev/null +++ b/modules/showcase/files/README.md @@ -0,0 +1,6 @@ +# showcase's packed files + +Packed as an `archive` artifact and unpacked onto the machine by an `archive` resource. + +This exists to exercise the case inlining cannot serve: a tree of files that belongs on a machine +and would make a declaration enormous if it were carried inside one. diff --git a/modules/showcase/files/marker.txt b/modules/showcase/files/marker.txt new file mode 100644 index 0000000..0246772 --- /dev/null +++ b/modules/showcase/files/marker.txt @@ -0,0 +1 @@ +showcase diff --git a/modules/showcase/index.ts b/modules/showcase/index.ts new file mode 100644 index 0000000..02a6ad2 --- /dev/null +++ b/modules/showcase/index.ts @@ -0,0 +1,12 @@ +// showcase's event consumer — loaded by a tool host, not run on its own. +// +// **This is one of the four things a module's code can be**, and the one that is easiest to +// forget: tools are called, a provisioner is invoked, a process runs, and a consumer simply reacts. +// It is here so the module exercises the shape rather than describing it. +import { on, emit } from "@novox/mesh-sdk/events"; + +await on<{ who?: string }>("module.showcase.greeted", async (event) => { + console.log(`[showcase] greeted ${event.body.who ?? "somebody"}`); + // A consumer may emit, which is what makes an event graph rather than a list of sinks. + await emit("module.showcase.acknowledged", { who: event.body.who ?? "somebody" }); +}); diff --git a/modules/showcase/module.json b/modules/showcase/module.json new file mode 100644 index 0000000..e188ac2 --- /dev/null +++ b/modules/showcase/module.json @@ -0,0 +1,73 @@ +{ + "module": "showcase", + "version": "1", + "slug": "show", + + "capabilities": ["container-runtime"], + + "provides": [{ "name": "greeting", "scope": "mesh" }], + "serves": { "greeting": { "path": "/greeting" } }, + "requires": ["postgres-database"], + "binds": { "postgres-database": "/var/lib/showcase/database.json" }, + "secrets": { "postgres-database": "/var/lib/showcase/database.secret" }, + "own-secrets": { "broker": "/var/lib/mesh/showcase/broker" }, + + "claims": [{ "name": "the-showcase", "scope": "node" }], + + "emits": ["module.showcase.acknowledged"], + "consumes": ["module.showcase.greeted"], + + "listens": [ + { "protocol": "tcp", "from": "mesh", + "why": "the showcase daemon answers here, so the mesh can reach it" } + ], + + "build": { + "artifacts": [ + { "name": "code", "kind": "bundle", "language": "typescript", + "entrypoints": ["index.js", "tools/index.js", "provisioner/index.js", + "daemon/index.js", "step/index.js", "report/index.js"] }, + { "name": "files", "kind": "archive", "from": "files" }, + { "name": "helper", "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" } + ] + }, + + "resources": [ + { "id": "account", "type": "user", "name": "showcase", "shell": "/usr/bin/nologin", + "home": "/var/lib/showcase" }, + + { "id": "logs", "type": "access", "path": "/var/log", "mode": "0755" }, + + { "id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/showcase", "mode": "0700" }, + { "id": "state", "type": "directory", "path": "/var/lib/showcase", "mode": "0755" }, + + { "id": "settings", "type": "file", "path": "/var/lib/showcase/showcase.env", "mode": "0600", + "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\n" }, + + { "id": "packed", "type": "archive", "path": "/opt/showcase", "artifact": "files" }, + + { "id": "net", "type": "network", "name": "showcase" }, + + { "id": "tooling", "type": "package", "package": "jq" }, + + { "id": "migrate", "type": "process", "name": "showcase-migrate", "artifact": "code", + "run": ["node", "step/index.js"], "run-once": true, + "env-file": ["/var/lib/showcase/showcase.env"] }, + + { "id": "server", "type": "process", "name": "showcase", "artifact": "code", + "run": ["node", "daemon/index.js"], "user": "showcase", + "env-file": ["/var/lib/showcase/showcase.env"], + "restart-on": ["settings"] }, + + { "id": "reporting", "type": "process", "name": "showcase-report", "artifact": "code", + "run": ["node", "report/index.js"], "schedule": "0 3 * * *", + "env-file": ["/var/lib/showcase/showcase.env"] }, + + { "id": "tools", "type": "container", "name": "mesh-showcase", "artifact": "helper", + "network": "showcase", + "volumes": ["/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"], + "env": { "MESH_BROKER_FILE": "/run/secrets/broker" }, + "args": ["sleep", "infinity"] } + ] +} diff --git a/modules/showcase/package.json b/modules/showcase/package.json new file mode 100644 index 0000000..f4bb7bf --- /dev/null +++ b/modules/showcase/package.json @@ -0,0 +1,9 @@ +{ + "name": "@novox/module-showcase", + "version": "0.1.0", + "description": "showcase — a module that exercises every capability a module has, so the module system has something that proves itself rather than a claim about what it supports.", + "type": "module", + "private": true, + "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, + "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" } +} diff --git a/modules/showcase/provisioner/index.ts b/modules/showcase/provisioner/index.ts new file mode 100644 index 0000000..5e4b9bf --- /dev/null +++ b/modules/showcase/provisioner/index.ts @@ -0,0 +1,20 @@ +// showcase's provisioner — how a consumer is given an instance of what this module provides. +// +// **A provider ships the provisioner that creates instances of the resource it offers** (ADR +// 0040). The mesh asks; this adapts that request to whatever the software actually needs, and +// hands back what the consumer is given. +import { provisioner } from "@novox/mesh-sdk/provisioner"; + +await provisioner({ + provision: "greeting", + async create({ consumer }: { consumer: string }) { + // A real provider would create something here — a database, a vhost, an account. This one has + // nothing to create, so it returns what a consumer is told, which is the half that matters: + // the mesh seals it and delivers it, and the consumer never sees this code. + return { serves: { greeting: `hello ${consumer}` } }; + }, + async remove() { + // Removal is not optional. A provider that cannot take an instance back leaves the mesh unable + // to unassign a consumer without leaking whatever it was given. + }, +}); diff --git a/modules/showcase/report/index.ts b/modules/showcase/report/index.ts new file mode 100644 index 0000000..ed8cb20 --- /dev/null +++ b/modules/showcase/report/index.ts @@ -0,0 +1,11 @@ +// showcase's scheduled process — a `process` with a schedule, fired on a cadence. +// +// **Not a daemon that sleeps.** A daemon that sleeps is running between fires and holds whatever +// it held; a scheduled process starts, does its work and exits, so what it costs between fires is +// nothing. +import { appendFileSync, mkdirSync } from "node:fs"; + +const where = process.env.SHOWCASE_STATE ?? "/var/lib/showcase"; +mkdirSync(where, { recursive: true }); +appendFileSync(`${where}/report`, `${new Date().toISOString()} ran\n`); +console.log("[showcase] report written"); diff --git a/modules/showcase/step/index.ts b/modules/showcase/step/index.ts new file mode 100644 index 0000000..b2eb677 --- /dev/null +++ b/modules/showcase/step/index.ts @@ -0,0 +1,11 @@ +// showcase's run-once step — a `process` with run-once, run to completion at install. +// +// **What follows it is gated on it finishing.** A migration that did not happen must not be +// followed by the thing that needed it, which is why a step is a mode rather than a daemon that +// exits. +import { mkdirSync, writeFileSync } from "node:fs"; + +const where = process.env.SHOWCASE_STATE ?? "/var/lib/showcase"; +mkdirSync(where, { recursive: true }); +writeFileSync(`${where}/installed`, `${new Date().toISOString()}\n`); +console.log(`[showcase] step complete, wrote ${where}/installed`); diff --git a/modules/showcase/tools/index.ts b/modules/showcase/tools/index.ts new file mode 100644 index 0000000..4524319 --- /dev/null +++ b/modules/showcase/tools/index.ts @@ -0,0 +1,25 @@ +// showcase's tools — its operator-facing surface, served over the broker. +// +// Two of them, because one tool proves a tool can exist and two prove a module can have a surface. +import { tool } from "@novox/mesh-sdk/tools"; + +tool({ + name: "showcase_greet", + description: "Greet somebody, and say which machine did it.", + input: { type: "object", properties: { who: { type: "string" } } }, + async run({ who }: { who?: string }) { + return { greeting: `hello ${who ?? "world"}`, from: process.env.MESH_NODE ?? "somewhere" }; + }, +}); + +tool({ + name: "showcase_state", + description: "What this module was configured with, so a test can read it back.", + input: { type: "object", properties: {} }, + async run() { + return { + greeting: process.env.SHOWCASE_GREETING ?? "", + database: process.env.SHOWCASE_DATABASE ?? "", + }; + }, +}); diff --git a/modules/showcase/tsconfig.json b/modules/showcase/tsconfig.json new file mode 100644 index 0000000..f3fec64 --- /dev/null +++ b/modules/showcase/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["index.ts", "tools/index.ts", "provisioner/index.ts", "daemon/index.ts", "step/index.ts", "report/index.ts"] +} From bf1f67a485f31675184fd511aa69578835bb6214 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 12:59:00 +0200 Subject: [PATCH 10/18] listens names the port the software uses; serves is the mesh's to fill Over-corrected: taking the port out of listens as well as serves made the module declare it listens on nothing, and the parser said so. ADR 0038 splits it. A module names the port its own software listens on, because that is a fact about the software and it knows it. The mesh assigns the machine-side number, because only the mesh knows what else is on the machine, and it is the mesh that fills the assigned number into serves so a consumer is told one number rather than three that agree by luck. So what was wrong was writing a port into serves, not into listens. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/showcase/module.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/showcase/module.json b/modules/showcase/module.json index e188ac2..e2d2711 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -18,8 +18,8 @@ "consumes": ["module.showcase.greeted"], "listens": [ - { "protocol": "tcp", "from": "mesh", - "why": "the showcase daemon answers here, so the mesh can reach it" } + { "port": 8080, "protocol": "tcp", "from": "mesh", + "why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" } ], "build": { From 71bbc7dab0e0d64016d1216b72b33a3d8caa7ea2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 20:51:00 +0200 Subject: [PATCH 11/18] Name the two modules after their software: nftables and distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module's identity is the software it is (ADR 0040). Two were named after the job instead, and the job already had a name. firewall installs the nftables package and runs nftables.service. The seat it claims is the-packet-filter, which is correctly named for the role. Calling the module firewall named neither the software nor the provision, and promised that any firewall could sit there — the false genericity the naming rule forbids. registry runs Distribution, the OCI reference implementation, and provides artifact-store. So registry was a third name for a thing that already had two, which is how one word ended up meaning the module, the software and the concept in the same paragraph. The capability stays firewall, and correctly: a capability IS a functionality, so a node having one and fail2ban requiring one are both right. Only the module moves. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/{registry => distribution}/client.ts | 0 modules/{registry => distribution}/index.ts | 0 modules/{registry => distribution}/module.json | 2 +- modules/{registry => distribution}/package.json | 0 modules/{registry => distribution}/tools/index.ts | 0 modules/{registry => distribution}/tsconfig.json | 0 modules/{firewall => nftables}/client.ts | 0 modules/{firewall => nftables}/module.json | 2 +- modules/{firewall => nftables}/package.json | 0 modules/{firewall => nftables}/tools/index.ts | 0 modules/{firewall => nftables}/tsconfig.json | 0 modules/showcase/module.json | 2 +- 12 files changed, 3 insertions(+), 3 deletions(-) rename modules/{registry => distribution}/client.ts (100%) rename modules/{registry => distribution}/index.ts (100%) rename modules/{registry => distribution}/module.json (97%) rename modules/{registry => distribution}/package.json (100%) rename modules/{registry => distribution}/tools/index.ts (100%) rename modules/{registry => distribution}/tsconfig.json (100%) rename modules/{firewall => nftables}/client.ts (100%) rename modules/{firewall => nftables}/module.json (95%) rename modules/{firewall => nftables}/package.json (100%) rename modules/{firewall => nftables}/tools/index.ts (100%) rename modules/{firewall => nftables}/tsconfig.json (100%) diff --git a/modules/registry/client.ts b/modules/distribution/client.ts similarity index 100% rename from modules/registry/client.ts rename to modules/distribution/client.ts diff --git a/modules/registry/index.ts b/modules/distribution/index.ts similarity index 100% rename from modules/registry/index.ts rename to modules/distribution/index.ts diff --git a/modules/registry/module.json b/modules/distribution/module.json similarity index 97% rename from modules/registry/module.json rename to modules/distribution/module.json index 08cb45f..029fb04 100644 --- a/modules/registry/module.json +++ b/modules/distribution/module.json @@ -1,5 +1,5 @@ { - "module": "registry", + "module": "distribution", "version": "1", "provides": [ { diff --git a/modules/registry/package.json b/modules/distribution/package.json similarity index 100% rename from modules/registry/package.json rename to modules/distribution/package.json diff --git a/modules/registry/tools/index.ts b/modules/distribution/tools/index.ts similarity index 100% rename from modules/registry/tools/index.ts rename to modules/distribution/tools/index.ts diff --git a/modules/registry/tsconfig.json b/modules/distribution/tsconfig.json similarity index 100% rename from modules/registry/tsconfig.json rename to modules/distribution/tsconfig.json diff --git a/modules/firewall/client.ts b/modules/nftables/client.ts similarity index 100% rename from modules/firewall/client.ts rename to modules/nftables/client.ts diff --git a/modules/firewall/module.json b/modules/nftables/module.json similarity index 95% rename from modules/firewall/module.json rename to modules/nftables/module.json index e8331a2..d552d65 100644 --- a/modules/firewall/module.json +++ b/modules/nftables/module.json @@ -1,5 +1,5 @@ { - "module": "firewall", + "module": "nftables", "version": "1", "capabilities": [ "firewall" diff --git a/modules/firewall/package.json b/modules/nftables/package.json similarity index 100% rename from modules/firewall/package.json rename to modules/nftables/package.json diff --git a/modules/firewall/tools/index.ts b/modules/nftables/tools/index.ts similarity index 100% rename from modules/firewall/tools/index.ts rename to modules/nftables/tools/index.ts diff --git a/modules/firewall/tsconfig.json b/modules/nftables/tsconfig.json similarity index 100% rename from modules/firewall/tsconfig.json rename to modules/nftables/tsconfig.json diff --git a/modules/showcase/module.json b/modules/showcase/module.json index e2d2711..289cf76 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -43,7 +43,7 @@ { "id": "state", "type": "directory", "path": "/var/lib/showcase", "mode": "0755" }, { "id": "settings", "type": "file", "path": "/var/lib/showcase/showcase.env", "mode": "0600", - "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\n" }, + "content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" }, { "id": "packed", "type": "archive", "path": "/opt/showcase", "artifact": "files" }, From 1cb33732f2c274766dfaf8d3de001765ce345111 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 21:03:43 +0200 Subject: [PATCH 12/18] Move amqp-ping's source, so the mesh has something to notice --- modules/amqp-ping/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/amqp-ping/index.ts b/modules/amqp-ping/index.ts index 45b5c03..f3338c3 100644 --- a/modules/amqp-ping/index.ts +++ b/modules/amqp-ping/index.ts @@ -50,3 +50,4 @@ for (;;) { } // changed by the one-node test at build 66e54af151df // changed by the one-node test at build 4fb41636cffd +// changed by the one-node test at build a69f083bf6a6 From abcba14edd418bb7e7b51f6d5bf0f11a8ee06eb2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 22:03:33 +0200 Subject: [PATCH 13/18] Review: four manifests said something stale or nothing at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dnsmasq still required resolver-data, a provision that died with the mesh-resolver module — assigning it would refuse with "nothing provides resolver-data". It asks for the node-zones fact now, at the same path its config already reads, restarting on the fact's own id. gitea and verdaccio both provide package-registry now — ADR 0075's provision, which neither declared, so ADR 0014's "consumes from the private registry" had no provider anywhere in the catalogue. Two providers, mesh-scoped: the resolver refuses until one is assigned, and choosing is assigning, which is the designed shape. audit-logger runs a container and declared no capability, alone among the containerised modules. A machine without a runtime would have been assigned it and failed at apply rather than at assignment. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/audit-logger/module.json | 3 +++ modules/dnsmasq/module.json | 10 +++++----- modules/gitea/module.json | 6 ++++++ modules/verdaccio/module.json | 8 +++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 1b8bfa8..671d9fb 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -57,5 +57,8 @@ }, "artifact": "runtime" } + ], + "capabilities": [ + "container-runtime" ] } diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 7b2c0cd..772262e 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -1,9 +1,6 @@ { "module": "dnsmasq", "version": "1", - "requires": [ - "resolver-data" - ], "provides": [ "wildcard-resolution" ], @@ -56,8 +53,11 @@ "boot": "enabled", "restart-on": [ "config", - "mesh-resolver.nodes" + "dnsmasq.fact-node-zones" ] } - ] + ], + "facts": { + "node-zones": "/etc/mesh-resolver/nodes.conf" + } } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 93d0816..293af46 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -122,5 +122,11 @@ "runtime-config" ] } + ], + "provides": [ + { + "name": "package-registry", + "scope": "mesh" + } ] } diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index f789a5f..3c52bd8 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -99,5 +99,11 @@ }, "binds": { "route": "/var/lib/mesh/verdaccio/route.json" - } + }, + "provides": [ + { + "name": "package-registry", + "scope": "mesh" + } + ] } From 065ddd6d69066745ca973c18bcc30c162cd99767 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 10:27:26 +0200 Subject: [PATCH 14/18] gitea provides the package registry; the builder gets its npm credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitea gains the package-registry provision: serves/receives/grants, an admin own-secret, a postgres-shaped build, and a provisioner that creates a gitea user per consumer with the mesh-minted password and seals nothing (hq ADR 0048). The builder takes its registry credential as an own-secret rather than a resolved provision, because gitea-as-module needs the base to build its provisioner and so cannot resolve before the base — a cycle the own-secret avoids. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/builder/module.json | 16 ++- modules/gitea/Dockerfile | 37 +++++++ modules/gitea/client.ts | 160 +++++++++++++++++++++++++++++ modules/gitea/module.json | 82 ++++++++++++++- modules/gitea/provisioner/index.ts | 44 ++++++++ modules/gitea/tsconfig.json | 2 +- 6 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 modules/gitea/Dockerfile create mode 100644 modules/gitea/provisioner/index.ts diff --git a/modules/builder/module.json b/modules/builder/module.json index d061ed7..3d91d70 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -17,7 +17,8 @@ "module.builder.built" ], "own-secrets": { - "broker": "/var/lib/mesh/builder/broker" + "broker": "/var/lib/mesh/builder/broker", + "npm-password": "/var/lib/mesh/builder/npm-password" }, "resources": [ { @@ -37,7 +38,14 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/workspace\n" + }, + { + "id": "package-binding", + "type": "file", + "path": "/var/lib/mesh/builder/package-registry.json", + "mode": "0600", + "content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n" }, { "id": "server", @@ -53,7 +61,9 @@ "/var/run/docker.sock:/var/run/docker.sock" ], "restart-on": [ - "builder-env" + "builder-env", + "package-binding", + "needs-npm-password" ] } ] diff --git a/modules/gitea/Dockerfile b/modules/gitea/Dockerfile new file mode 100644 index 0000000..b0ac565 --- /dev/null +++ b/modules/gitea/Dockerfile @@ -0,0 +1,37 @@ +# gitea's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event +# consumer. +# +# **Built from this module's own directory and nothing else.** The sdk is in the base image, so +# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a +# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have +# the siblings. +# +# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in. +# They are different images on purpose — the first carries a compiler and the second must not, or +# every running container would carry one it never invokes. The mesh answers both with the copies it +# holds, because a fingerprint written here would name one particular copy and no other mesh has it +# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build +# nobody told stops here and says which module to build first. +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own +# node_modules — the module is compiled against exactly the sdk it will run against. +WORKDIR /app/modules/gitea +COPY . . +# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are +# symlinks to a launcher that requires its library relatively — resolved away when the base image +# was assembled. +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not +# through a CLI the way postgres drives psql — so the runtime base holds everything this needs. +COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist +# What a tool host should load from this module: its event consumer and its tools, which are +# separate entrypoints because they are loaded by different things. The provisioner is the third, +# and is not listed here — the declaration names it in the container's `args`, because it is what +# this module's own container runs. One image, because they are one module and share a client. +ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 1fa36f4..aadb4cc 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -249,3 +249,163 @@ export class GiteaClient { }; } } + +/** One raw response the admin client acts on: the status code decides idempotency (a 422/409 on + * create means "already there", a 404 on delete means "already gone"), the body carries ids. */ +interface AdminResponse { + readonly status: number; + readonly body: any; +} + +/** + * The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only + * by the provisioner (novox/hq ADR 0048/0076). + * + * The token-authenticated {@link GiteaClient} above serves the tools and the event consumer, which + * read repos and open issues. Provisioning is different: it creates and deletes *users* and manages + * org teams — admin-API operations authenticated as the mesh's gitea admin, whose password is a mesh + * own-secret. Basic auth is what the admin API takes, and keeping this separate from GiteaClient + * keeps the two credentials and their two audiences apart. + * + * Every method is idempotent: the reconcile harness calls create repeatedly, so "already exists" is + * success, not an error. + */ +export class GiteaAdmin { + readonly baseUrl: string; + private readonly authorization: string; + + constructor(url: string, user: string, password: string) { + this.baseUrl = url.replace(/\/+$/, ""); + this.authorization = "Basic " + Buffer.from(`${user}:${password}`).toString("base64"); + } + + /** + * Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the forge's + * loopback, since the provisioner shares the host's network), the admin login from + * MESH_GITEA_ADMIN_USER, and the admin password from the file MESH_GITEA_ADMIN_PASSWORD_FILE names + * — the mesh own-secret the host unsealed. Trailing newline trimmed, the way the harness trims a + * sealed secret. Throws rather than hand back a client that fails on first call. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaAdmin { + const url = env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; + const user = env.MESH_GITEA_ADMIN_USER; + if (!user) throw new Error("no Gitea admin user — set MESH_GITEA_ADMIN_USER"); + const file = env.MESH_GITEA_ADMIN_PASSWORD_FILE; + if (!file) throw new Error("no Gitea admin password file — set MESH_GITEA_ADMIN_PASSWORD_FILE"); + const password = readFileSync(file, "utf8").replace(/\n$/, ""); + return new GiteaAdmin(url, user, password); + } + + /** A single admin-API call. Unlike GiteaClient.request, this returns the status rather than + * throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an + * unexpected status becomes an error, and only where the caller says so. */ + private async request(path: string, options: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: this.authorization, + ...(options.headers as Record | undefined), + }, + }); + const text = await res.text(); + let body: any = null; + if (text) { + try { body = JSON.parse(text); } catch { body = text; } + } + return { status: res.status, body }; + } + + /** Fail with the forge's own message when a status the caller did not expect comes back. */ + private static fail(path: string, res: AdminResponse): never { + const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body); + throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`); + } + + /** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent + * create won the race) are all success. */ + async ensureOrg(name: string): Promise { + const existing = await this.request(`/orgs/${encodeURIComponent(name)}`); + if (existing.status === 200) return; + const res = await this.request("/orgs", { + method: "POST", + body: JSON.stringify({ username: name, visibility: "private" }), + }); + if (res.status === 201 || res.status === 422 || res.status === 409) return; + GiteaAdmin.fail("/orgs", res); + } + + /** Ensure the org's package team exists, granting read+write on packages, and return its id. The + * team is found by name if it is already there, created otherwise; a lost create race is resolved + * by re-listing. */ + async ensureTeam(org: string, team: string, packageWrite: boolean): Promise { + const found = await this.findTeam(org, team); + if (found !== null) return found; + const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, { + method: "POST", + body: JSON.stringify({ + name: team, + permission: "read", + // Package access is a per-unit grant; the team needs write on the packages unit and nothing + // else. includes_all_repositories keeps the team's repo view whole without widening its + // repo permission beyond read. + units_map: { "repo.packages": packageWrite ? "write" : "read" }, + includes_all_repositories: true, + can_create_org_repo: false, + }), + }); + if (res.status === 201) return Number(res.body?.id); + if (res.status === 422 || res.status === 409) { + const after = await this.findTeam(org, team); + if (after !== null) return after; + } + return GiteaAdmin.fail(`/orgs/${org}/teams`, res); + } + + private async findTeam(org: string, team: string): Promise { + const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + if (res.status !== 200) return null; + const match = (res.body as any[] | null)?.find((t) => t?.name === team); + return match ? Number(match.id) : null; + } + + /** Ensure a user exists with exactly this password. Created if absent; if already there, its + * password is patched — so the mesh minting a new secret takes on the next reconcile. */ + async ensureUser(username: string, password: string, email: string): Promise { + const res = await this.request("/admin/users", { + method: "POST", + body: JSON.stringify({ username, email, password, must_change_password: false }), + }); + if (res.status === 201) return; + if (res.status === 422 || res.status === 409) { + const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { + method: "PATCH", + // login_name is required by the admin edit endpoint; for a local user it is the username. + body: JSON.stringify({ login_name: username, password, must_change_password: false }), + }); + if (patch.status === 200) return; + GiteaAdmin.fail(`/admin/users/${username}`, patch); + } + GiteaAdmin.fail("/admin/users", res); + } + + /** Add a user to a team, which also makes them an org member. Idempotent: adding an existing + * member returns 204 again. */ + async addUserToTeam(teamId: number, username: string): Promise { + const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, { + method: "PUT", + }); + if (res.status === 204 || res.status === 200) return; + GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res); + } + + /** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not + * an error, so a re-run of remove is safe. */ + async deleteUser(username: string): Promise { + const res = await this.request(`/admin/users/${encodeURIComponent(username)}?purge=true`, { + method: "DELETE", + }); + if (res.status === 204 || res.status === 200 || res.status === 404) return; + GiteaAdmin.fail(`/admin/users/${username}`, res); + } +} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 293af46..54a9b46 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -43,8 +43,22 @@ "why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it" } ], + "serves": { + "package-registry": { + "scheme": "http", + "port": 3000, + "npm-path": "/api/packages/novox/npm/" + } + }, + "receives": { + "package-registry": "/var/lib/gitea/grants/mesh.json" + }, + "grants": { + "package-registry": "/var/lib/gitea/grants" + }, "own-secrets": { "internal-token": "/var/lib/gitea/internal-token.secret", + "admin": "/var/lib/gitea/admin.secret", "broker": "/var/lib/mesh/gitea/broker" }, "resources": [ @@ -60,6 +74,12 @@ "path": "/var/lib/gitea", "mode": "0700" }, + { + "id": "grants", + "type": "directory", + "path": "/var/lib/gitea/grants", + "mode": "0700" + }, { "id": "server-env", "type": "file", @@ -95,6 +115,30 @@ "/services/gitea/gitea:/data" ] }, + { + "id": "admin-bootstrap", + "type": "container", + "name": "mesh-gitea-admin", + "image": "gitea/gitea@sha256:dfc61e347c8b582df918f4556401bf2cecdfbdb56c5282ae9488dd76fca3e41c", + "run-once": true, + "env": { + "USER_UID": "1000", + "USER_GID": "1000", + "MESH_GITEA_ADMIN_USER": "mesh-admin" + }, + "env-file": [ + "/var/lib/gitea/server.env" + ], + "volumes": [ + "/services/gitea/gitea:/data", + "/var/lib/gitea/admin.secret:/run/secrets/admin:ro" + ], + "args": [ + "/bin/sh", + "-c", + "su-exec git gitea admin user create --admin --username \"$MESH_GITEA_ADMIN_USER\" --email mesh-admin@localhost --password \"$(cat /run/secrets/admin)\" --must-change-password=false || true" + ] + }, { "id": "runtime-config", "type": "file", @@ -107,17 +151,26 @@ "id": "runtime", "type": "container", "name": "mesh-gitea", - "image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "host", "volumes": [ "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", - "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro" + "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro", + "/var/lib/gitea/grants:/var/lib/gitea/grants:ro", + "/var/lib/gitea/admin.secret:/run/secrets/admin:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_GITEA_URL": "http://127.0.0.1:3000", - "MESH_GITEA_CONFIG_FILE": "/run/config/config.json" + "MESH_GITEA_CONFIG_FILE": "/run/config/config.json", + "MESH_GITEA_ADMIN_USER": "mesh-admin", + "MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin", + "MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json" }, + "artifact": "runtime", + "args": [ + "run", + "/app/modules/gitea/dist/provisioner/index.js" + ], "restart-on": [ "runtime-config" ] @@ -128,5 +181,26 @@ "name": "package-registry", "scope": "mesh" } - ] + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts new file mode 100644 index 0000000..36d66e2 --- /dev/null +++ b/modules/gitea/provisioner/index.ts @@ -0,0 +1,44 @@ +// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry` +// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are +// the sdk harness's; this writes only the per-service half: how gitea creates and removes a +// consumer's npm credential (novox/hq ADR 0048/0076). +// +// The `package-registry` interface: a consumer authenticates to the npm registry at +// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can +// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org +// `novox`; a consumer is a gitea *user* placed on that org's package team. +// +// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives +// the login and hands it to both ends, and mints the password. gitea creates a user under exactly +// that login and sets exactly that password every run — so a rotation takes — and seals nothing: the +// consumer already has its copy through the mesh's own channel. +// +// The admin calls run through GiteaAdmin (basic auth as the mesh's gitea admin), which is the +// module's one boundary to the forge's admin API (see client.ts). + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { GiteaAdmin } from "../client.js"; + +// The npm registry owner: a gitea org named `novox`, whose package team every consumer joins so it +// can read and write packages under the `@novox` scope (ADR 0076). +const ORG = "novox"; +const PACKAGE_TEAM = "packages"; + +const gitea = GiteaAdmin.fromEnv(); + +runProvisioner("package-registry", { + async create(p: Provision): Promise { + // The org and its package team are the same for every consumer; ensuring them per-create is + // idempotent and needs no separate bootstrap step. + await gitea.ensureOrg(ORG); + const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true); + // The user carries the consumer's login and the mesh's minted password, set every run so a + // rotation takes. Membership of the package team is what grants read+write on packages. + await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`); + await gitea.addUserToTeam(teamId, p.as); + }, + + async remove(p: { as: string }): Promise { + await gitea.deleteUser(p.as); + }, +}); diff --git a/modules/gitea/tsconfig.json b/modules/gitea/tsconfig.json index 3677859..51f4046 100644 --- a/modules/gitea/tsconfig.json +++ b/modules/gitea/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "tools/index.ts"] + "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] } From b520bd18256848cfc0566a6ee9ada2954194cfd4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 16:02:26 +0200 Subject: [PATCH 15/18] The builder's workspace is a same-path bind, so sibling builds see the clone A docker run -v from inside the builder resolves the path on the host: with the workspace mounted at a different path inside than out, the SDK publish and any bundle compile mounted an empty directory. Bind it at the same path both sides. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/builder/module.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index 3d91d70..fb2c6f5 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -38,7 +38,7 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/workspace\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/var/lib/builder/workspace\n" }, { "id": "package-binding", @@ -57,7 +57,7 @@ ], "volumes": [ "/var/lib/mesh/builder:/run/mesh:ro", - "/var/lib/builder/workspace:/workspace", + "/var/lib/builder/workspace:/var/lib/builder/workspace", "/var/run/docker.sock:/var/run/docker.sock" ], "restart-on": [ From 41637befffa24ab6b29910b1577a3e3b81925e5f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 18:40:40 +0200 Subject: [PATCH 16/18] Rename mesh-control -> mesh-controller, substrate -> foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- README.md | 8 +++--- modules/anthropic-manager/grantfile.ts | 6 ++--- modules/anthropic-manager/refresh/index.ts | 6 ++--- modules/anthropic-manager/sealedbox.ts | 8 +++--- .../anthropic-manager/test/sealedbox.test.ts | 2 +- .../module.json | 26 +++++++++---------- modules/model-usage/index.ts | 2 +- modules/route-proxy/Dockerfile | 6 ++--- modules/route-proxy/README.md | 4 +-- 9 files changed, 34 insertions(+), 34 deletions(-) rename modules/{mesh-control => mesh-controller}/module.json (57%) diff --git a/README.md b/README.md index 780aa49..2f88016 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ per module under [`modules/`](modules/). This is **data, not a control-plane concern**. The manifests describe *what a module is*: what it provides, what it requires, the seats it claims, the resources the host applies for it. The engine that reads them — parsing, eligibility resolution, sealing, declaration emission — lives -in the control plane (`novox/mesh-control`, `internal/catalogue`), which consumes this repository +in the control plane (`novox/mesh-controller`, `internal/catalogue`), which consumes this repository as a build source. The host (`novox/mesh-host`) applies the declarations the control plane emits. Neither is here. @@ -17,9 +17,9 @@ manifest names its image (pinned by digest), the resources the host owns for it files, the container, the private network it joins), what it `requires` from a provider and what it `provides` to consumers, and the sealed secrets it needs filled on the machine. -- **Core mesh components are not modules.** The node host, the substrate, the control-plane +- **Core mesh components are not modules.** The node host, the foundation, the control-plane contexts and the surfaces are the mesh itself; they ship as their own repositories - (`mesh-host`, `mesh-substrate`, `mesh-control`, `mesh-surfaces`, `mesh-sdk`), not from here. + (`mesh-host`, `mesh-foundation`, `mesh-controller`, `mesh-surfaces`, `mesh-sdk`), not from here. - **Standalone applications are not here either.** A larger application lives in its own repository with its manifest at the root, registered with the mesh as a build source (novox/hq [ADR 0010](https://git.novox.be/novox/hq)). This repository holds the modules the @@ -46,7 +46,7 @@ provider/consumer edge — is data inside the manifests, not a directory the tre The shape a manifest must satisfy is owned by the control plane's catalogue engine and is what validates a manifest before a machine ever sees it — a stray key, a consumer contributing the wrong provision field, an image that nothing builds. That validation belongs with this -repository and is being re-homed here from `mesh-control`; until it is, the pipeline is the +repository and is being re-homed here from `mesh-controller`; until it is, the pipeline is the gate — it builds each module and refuses a manifest it cannot resolve. ## Where the reasoning lives diff --git a/modules/anthropic-manager/grantfile.ts b/modules/anthropic-manager/grantfile.ts index 2b0ceda..1c5915d 100644 --- a/modules/anthropic-manager/grantfile.ts +++ b/modules/anthropic-manager/grantfile.ts @@ -1,9 +1,9 @@ // Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and -// writing a sealed refresh token in the wire shape mesh-control reads. +// writing a sealed refresh token in the wire shape mesh-controller reads. // // **The public key is delivered, not derived.** The manager module holds no node key of its own // (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it -// needs the node's PUBLIC sealing key, and mesh-control puts that in the manager holder's bound facts +// needs the node's PUBLIC sealing key, and mesh-controller puts that in the manager holder's bound facts // (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every // rotation read it from there. @@ -23,7 +23,7 @@ export function managerPublicKey(boundFile: string): string { return key; } -/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-control reads. */ +/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-controller reads. */ export function writeSealedGrant(path: string, sealed: string, managerKey: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp`; diff --git a/modules/anthropic-manager/refresh/index.ts b/modules/anthropic-manager/refresh/index.ts index e2f99de..6db3a94 100644 --- a/modules/anthropic-manager/refresh/index.ts +++ b/modules/anthropic-manager/refresh/index.ts @@ -12,13 +12,13 @@ // never the refresh token — which it seals per consumer holder and stores; // 5. poll usage with the fresh access token and record the licence-grain reading. // -// mesh-control receives the products of steps 3–4 through `licence submit-refresh` (access token + +// mesh-controller receives the products of steps 3–4 through `licence submit-refresh` (access token + // sealed box). The refresh token never leaves this process except as ciphertext, and it never had to // be opened here at all — the host did that. // // This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the -// host mounts; the submit itself (the transport to mesh-control) is done by the caller invoking -// `mesh-control licence submit-refresh`. In the lab that caller is the scenario; in production it is +// host mounts; the submit itself (the transport to mesh-controller) is done by the caller invoking +// `mesh-controller licence submit-refresh`. In the lab that caller is the scenario; in production it is // an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED // — because a cross-node authenticated command surface is out of this module's scope. diff --git a/modules/anthropic-manager/sealedbox.ts b/modules/anthropic-manager/sealedbox.ts index a8cb1ae..ae4a19f 100644 --- a/modules/anthropic-manager/sealedbox.ts +++ b/modules/anthropic-manager/sealedbox.ts @@ -5,14 +5,14 @@ // carve-out delivers the refresh token to the manager module the way the mesh delivers every other // credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host // unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host -// internal/identity/sealing.go), and mesh-control seals with `box.SealAnonymous` -// (mesh-control internal/secrets/seal.go). Both are NaCl `crypto_box_seal`: +// internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous` +// (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`: // // sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret) // nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed ) // // When the vendor rotates the refresh token, the manager module must store the new one back the -// same way — sealed to the manager node's own sealing key — so mesh-control keeps it without ever +// same way — sealed to the manager node's own sealing key — so mesh-controller keeps it without ever // reading it and the host can later unseal it to deliver the cleartext again. That reseal happens // here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format // Go's `Open` accepts, or the host would refuse the delivery. @@ -27,7 +27,7 @@ // (package.json dependencies; novox/hq ADR 0052). // // **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go -// (mesh-control internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this +// (mesh-controller internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this // `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a // refresh token silently mangled in production. // diff --git a/modules/anthropic-manager/test/sealedbox.test.ts b/modules/anthropic-manager/test/sealedbox.test.ts index a30bb30..f7c11f8 100644 --- a/modules/anthropic-manager/test/sealedbox.test.ts +++ b/modules/anthropic-manager/test/sealedbox.test.ts @@ -5,7 +5,7 @@ import { generateKeyPairSync } from "node:crypto"; import { seal } from "../sealedbox.ts"; // The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal -// and mesh-control's secrets.Seal/Open) is a cross-language test in mesh-control +// and mesh-controller's secrets.Seal/Open) is a cross-language test in mesh-controller // (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced. // These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is // randomised so a rotation that changed nothing looks nothing like one that changed everything. diff --git a/modules/mesh-control/module.json b/modules/mesh-controller/module.json similarity index 57% rename from modules/mesh-control/module.json rename to modules/mesh-controller/module.json index e5e864b..b48c435 100644 --- a/modules/mesh-control/module.json +++ b/modules/mesh-controller/module.json @@ -1,5 +1,5 @@ { - "module": "mesh-control", + "module": "mesh-controller", "version": "1", "slug": "control", "capabilities": [ @@ -7,43 +7,43 @@ ], "claims": [ { - "name": "the-control-plane", + "name": "the-controller", "scope": "mesh" } ], "own-secrets": { - "inventory": "/var/lib/mesh/mesh-control/inventory", - "identity": "/var/lib/mesh/mesh-control/identity", - "licences": "/var/lib/mesh/mesh-control/licences", - "broker": "/var/lib/mesh/mesh-control/broker", - "broker-management": "/var/lib/mesh/mesh-control/broker-management", - "broker-address": "/var/lib/mesh/mesh-control/broker-address" + "inventory": "/var/lib/mesh/mesh-controller/inventory", + "identity": "/var/lib/mesh/mesh-controller/identity", + "licences": "/var/lib/mesh/mesh-controller/licences", + "broker": "/var/lib/mesh/mesh-controller/broker", + "broker-management": "/var/lib/mesh/mesh-controller/broker-management", + "broker-address": "/var/lib/mesh/mesh-controller/broker-address" }, "resources": [ { "id": "mesh-state", "type": "directory", - "path": "/var/lib/mesh/mesh-control", + "path": "/var/lib/mesh/mesh-controller", "mode": "0700" }, { "id": "control-env", "type": "file", - "path": "/var/lib/mesh/mesh-control/control.env", + "path": "/var/lib/mesh/mesh-controller/control.env", "mode": "0600", "content": "MESH_STORE_INVENTORY=${secret:inventory}\nMESH_STORE_IDENTITY=${secret:identity}\nMESH_STORE_LICENCES=${secret:licences}\nMESH_BROKER_AMQP=${secret:broker}\nMESH_BROKER_MANAGEMENT=${secret:broker-management}\nMESH_BROKER_ADDRESS=${secret:broker-address}\n" }, { "id": "server", "type": "container", - "name": "mesh-control", - "image": "mesh-control@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "name": "mesh-controller", + "image": "mesh-controller@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "host", "args": [ "serve" ], "env-file": [ - "/var/lib/mesh/mesh-control/control.env" + "/var/lib/mesh/mesh-controller/control.env" ], "env": { "MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt" diff --git a/modules/model-usage/index.ts b/modules/model-usage/index.ts index 18b5ec4..2bcf812 100644 --- a/modules/model-usage/index.ts +++ b/modules/model-usage/index.ts @@ -1,4 +1,4 @@ -// model-usage's entrypoint — the usage context store's consumer (novox/hq ADR 0054). mesh-control is +// model-usage's entrypoint — the usage context store's consumer (novox/hq ADR 0054). mesh-controller is // a CLI and cannot consume events, so the store that keeps the latest usage reading is a MODULE: it // subscribes to `module.*.usage.*` and upserts each row. Like the audit-logger, the on(...) IS the // whole handshake — the runtime imports this once the broker is bound, and every usage event any diff --git a/modules/route-proxy/Dockerfile b/modules/route-proxy/Dockerfile index f49d1c9..a22fa35 100644 --- a/modules/route-proxy/Dockerfile +++ b/modules/route-proxy/Dockerfile @@ -1,12 +1,12 @@ # The route-proxy module's runtime image: the reference reverse proxy compiled into a container. # # **The proxy source is not vendored here.** The canonical proxy — the contract written as something -# that runs — lives in the mesh-control repository at examples/route-proxy (novox/hq 08-connectivity +# that runs — lives in the mesh-controller repository at examples/route-proxy (novox/hq 08-connectivity # §3). This module ships the *packaging*, not a second copy of the contract, so the build context is -# the mesh-control repository root, and this Dockerfile compiles ./examples/route-proxy from it. +# the mesh-controller repository root, and this Dockerfile compiles ./examples/route-proxy from it. # # docker build -f mesh-catalog/modules/route-proxy/Dockerfile \ -# -t mesh-route-proxy:development /mesh-control +# -t mesh-route-proxy:development /mesh-controller # # The mesh pins the digest of what this produces; the committed module.json carries the placeholder # digest every mesh-built image does, replaced at publish. diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md index 2075b86..639024b 100644 --- a/modules/route-proxy/README.md +++ b/modules/route-proxy/README.md @@ -30,9 +30,9 @@ an event. It only reads the file the mesh writes. (Contrast `redis`, which mints ## How it ships the Go proxy The proxy is a Go program, unlike the TypeScript tool-runtime modules. The canonical source is -**not vendored here** — it lives in the mesh-control repository at `examples/route-proxy`, the +**not vendored here** — it lives in the mesh-controller repository at `examples/route-proxy`, the contract written as something that runs. This module ships only the packaging: a multi-stage -[`Dockerfile`](Dockerfile) whose build context is the mesh-control repository root and which +[`Dockerfile`](Dockerfile) whose build context is the mesh-controller repository root and which compiles `./examples/route-proxy` into `mesh-route-proxy`. The committed `module.json` carries the placeholder digest every mesh-built image does (`@sha256:0000…`); the mesh pins the real digest at publish. From a63ef3d9547c2c16f027a4f335813ee0ee9b3157 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 20:32:12 +0200 Subject: [PATCH 17/18] Phase 3.1: the postgres module adopts mesh-store instead of raising its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server is now the container the foundation raised — same name (mesh-store), same env (POSTGRES_PASSWORD/PGDATA), ports (127.0.0.1:5432:5432), volume (mesh-store-data) and the same pinned upstream postgres image the foundation runs — so the applier adopts it in place rather than raising a second postgres. The provisioner is host-networked to reach the loopback store at 127.0.0.1:5432. The module's own network and bind-mounted data dir are gone; there is one postgres now, holding the controller's contexts and every module's database. Issue 051 (WBS 3.1). Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/postgres/module.json | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/modules/postgres/module.json b/modules/postgres/module.json index 74402c3..158e68a 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -60,56 +60,34 @@ "path": "/var/lib/postgres/grants", "mode": "0700" }, - { - "id": "superuser-env", - "type": "file", - "path": "/var/lib/postgres/superuser.env", - "mode": "0600", - "content": "POSTGRES_PASSWORD=${secret:superuser}\n" - }, - { - "id": "data", - "type": "directory", - "path": "/services/postgres/db-data", - "mode": "0700" - }, - { - "id": "net", - "type": "network", - "name": "postgres" - }, { "id": "server", "type": "container", - "name": "postgres", + "name": "mesh-store", "image": "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee", - "network": "postgres", "env": { - "POSTGRES_USER": "postgres", - "POSTGRES_DB": "postgres" + "POSTGRES_PASSWORD": "bootstrap", + "PGDATA": "/var/lib/postgresql/data/pgdata" }, - "env-file": [ - "/var/lib/postgres/superuser.env" - ], "ports": [ - "5432" + "5432:5432" ], "volumes": [ - "/services/postgres/db-data:/var/lib/postgresql/data" + "mesh-store-data:/var/lib/postgresql/data" ] }, { "id": "runtime", "type": "container", "name": "mesh-postgres", - "network": "postgres", + "network": "host", "volumes": [ "/var/lib/mesh/postgres/broker:/run/secrets/broker:ro", "/var/lib/postgres/grants:/var/lib/postgres/grants:ro", "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" ], "env": { - "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable", + "MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable", "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser", "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json" From 5e4dc3748e338050751498dc184837a06568ff1c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 21:24:13 +0200 Subject: [PATCH 18/18] Phase 3.2: the lavinmq module adopts mesh-broker instead of raising its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server is now the container the foundation raised — name mesh-broker, the same pinned upstream lavinmq image, the same TLS args, ports and volumes — so the applier adopts it in place. The second server, the lavinmq.ini bootstrap and the module's own network are gone. The provisioner is host-networked to the broker's loopback management (127.0.0.1:15672) and authenticates as lavinmq's default guest, which the foundation broker runs with; the mesh bus stays on the / vhost, a vhost-per-consumer beside it. One lavinmq now. Issue 051 (WBS 3.2). Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/lavinmq/module.json | 60 +++++++++---------------------------- 1 file changed, 14 insertions(+), 46 deletions(-) diff --git a/modules/lavinmq/module.json b/modules/lavinmq/module.json index 7a533aa..ba03a4d 100644 --- a/modules/lavinmq/module.json +++ b/modules/lavinmq/module.json @@ -30,7 +30,6 @@ "amqp": "/var/lib/lavinmq-module/grants" }, "own-secrets": { - "default": "/var/lib/lavinmq-module/default.secret", "broker": "/var/lib/mesh/lavinmq/broker" }, "listens": [ @@ -60,54 +59,24 @@ "path": "/var/lib/lavinmq-module/grants", "mode": "0700" }, - { - "id": "data", - "type": "directory", - "path": "/services/lavinmq/data", - "mode": "0700" - }, - { - "id": "net", - "type": "network", - "name": "lavinmq" - }, - { - "id": "bootstrap", - "type": "container", - "name": "lavinmq-bootstrap", - "artifact": "runtime", - "run-once": true, - "volumes": [ - "/var/lib/lavinmq-module:/var/lib/lavinmq-module", - "/var/lib/lavinmq-module/default.secret:/run/secrets/default:ro" - ], - "env": { - "MESH_PROVISION_ADMIN_USER": "mesh-admin", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default", - "MESH_LAVINMQ_CONFIG_OUT": "/var/lib/lavinmq-module/lavinmq.ini", - "MESH_LAVINMQ_DATA_DIR": "/var/lib/lavinmq" - }, - "args": [ - "run", - "/app/modules/lavinmq/dist/bootstrap/index.js" - ] - }, { "id": "server", "type": "container", - "name": "lavinmq", + "name": "mesh-broker", "image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b", - "network": "lavinmq", "ports": [ - "5672" + "5671:5671", + "5672:5672", + "127.0.0.1:15672:15672" ], "volumes": [ - "/services/lavinmq/data:/var/lib/lavinmq", - "/var/lib/lavinmq-module/lavinmq.ini:/etc/lavinmq/lavinmq.ini:ro" + "mesh-broker-data:/var/lib/lavinmq", + "mesh-broker-tls:/tls:ro" ], "args": [ - "--config", - "/etc/lavinmq/lavinmq.ini" + "--amqps-port=5671", + "--cert=/tls/tls.crt", + "--key=/tls/tls.key" ] }, { @@ -115,18 +84,17 @@ "type": "container", "name": "mesh-lavinmq", "artifact": "runtime", - "network": "lavinmq", + "network": "host", "volumes": [ "/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro", - "/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro", - "/var/lib/lavinmq-module/default.secret:/run/secrets/default:ro" + "/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json", - "MESH_PROVISION_LAVINMQ": "http://lavinmq:15672", - "MESH_PROVISION_ADMIN_USER": "mesh-admin", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" + "MESH_PROVISION_LAVINMQ": "http://127.0.0.1:15672", + "MESH_PROVISION_ADMIN_USER": "guest", + "MESH_LAVINMQ_ADMIN_PASSWORD": "guest" } } ],