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/amqp-ping/index.ts b/modules/amqp-ping/index.ts index 6f606f1..f3338c3 100644 --- a/modules/amqp-ping/index.ts +++ b/modules/amqp-ping/index.ts @@ -48,3 +48,6 @@ for (;;) { await sleep(30000); await pingOnce(); } +// 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 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" ], 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/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..671d9fb 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,11 @@ "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "AUDIT_LOG": "/trail/audit.log" - } + }, + "artifact": "runtime" } + ], + "capabilities": [ + "container-runtime" ] } diff --git a/modules/builder/module.json b/modules/builder/module.json index d061ed7..fb2c6f5 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=/var/lib/builder/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", @@ -49,11 +57,13 @@ ], "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": [ - "builder-env" + "builder-env", + "package-binding", + "needs-npm-password" ] } ] 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/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/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 93d0816..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,20 +151,56 @@ "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" ] } - ] + ], + "provides": [ + { + "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"] } 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..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,74 +59,64 @@ "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", - "image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000", - "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" ] }, { "id": "runtime", "type": "container", "name": "mesh-lavinmq", - "image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000", - "network": "lavinmq", + "artifact": "runtime", + "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" } } - ] + ], + "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/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", {}); 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/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/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" 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. 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..289cf76 --- /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": [ + { "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": { + "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}\nSHOWCASE_LISTEN=${port:8080}\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"] +} 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" + } + ] }