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"] +}