From 9a41add136d6eb70ac3fbeba437015d153637d46 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 12:58:30 +0200 Subject: [PATCH] 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"] +}