Merge pull request 'Foundation modules adopted, and the mesh-controller/foundation rename' (#23) from feat/foundation-and-rename into main

This commit was merged in pull request #23.
This commit is contained in:
2026-09-16 23:22:47 +02:00
47 changed files with 762 additions and 131 deletions
+4 -4
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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"
],
+3 -3
View File
@@ -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`;
+3 -3
View File
@@ -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.
+4 -4
View File
@@ -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.
//
@@ -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.
+33
View File
@@ -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
+29 -3
View File
@@ -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"
]
}
+14 -4
View File
@@ -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"
]
}
]
@@ -1,5 +1,5 @@
{
"module": "registry",
"module": "distribution",
"version": "1",
"provides": [
{
+5 -5
View File
@@ -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"
}
}
+37
View File
@@ -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
+160
View File
@@ -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<AdminResponse> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authorization,
...(options.headers as Record<string, string> | 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<void> {
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<number> {
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<number | null> {
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<void> {
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<void> {
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<void> {
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);
}
}
+84 -4
View File
@@ -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"
}
]
}
}
+44
View File
@@ -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<void> {
// 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<void> {
await gitea.deleteUser(p.as);
},
});
+1 -1
View File
@@ -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"]
}
+41
View File
@@ -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
+37 -48
View File
@@ -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"
}
]
}
}
+25
View File
@@ -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", {});
@@ -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"
+1 -1
View File
@@ -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
@@ -1,5 +1,5 @@
{
"module": "firewall",
"module": "nftables",
"version": "1",
"capabilities": [
"firewall"
+7 -29
View File
@@ -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"
+3 -3
View File
@@ -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 <path-to>/mesh-control
# -t mesh-route-proxy:development <path-to>/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.
+2 -2
View File
@@ -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.
+13
View File
@@ -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);
+6
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
showcase
+12
View File
@@ -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" });
});
+73
View File
@@ -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"] }
]
}
+9
View File
@@ -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" }
}
+20
View File
@@ -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.
},
});
+11
View File
@@ -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");
+11
View File
@@ -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`);
+25
View File
@@ -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 ?? "",
};
},
});
+12
View File
@@ -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"]
}
+7 -1
View File
@@ -99,5 +99,11 @@
},
"binds": {
"route": "/var/lib/mesh/verdaccio/route.json"
}
},
"provides": [
{
"name": "package-registry",
"scope": "mesh"
}
]
}