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 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 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 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. as a build source. The host (`novox/mesh-host`) applies the declarations the control plane emits.
Neither is here. 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 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. 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 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 - **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 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 (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 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 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 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. gate — it builds each module and refuses a manifest it cannot resolve.
## Where the reasoning lives ## Where the reasoning lives
+3
View File
@@ -48,3 +48,6 @@ for (;;) {
await sleep(30000); await sleep(30000);
await pingOnce(); 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", "type": "container",
"name": "amqp-ping", "name": "amqp-ping",
"network": "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": [ "env-file": [
"/var/lib/amqp-ping/amqp.env" "/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 // 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 // **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 // (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 // (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every
// rotation read it from there. // rotation read it from there.
@@ -23,7 +23,7 @@ export function managerPublicKey(boundFile: string): string {
return key; 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 { export function writeSealedGrant(path: string, sealed: string, managerKey: string): void {
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`; const tmp = `${path}.tmp`;
+3 -3
View File
@@ -12,13 +12,13 @@
// never the refresh token — which it seals per consumer holder and stores; // 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. // 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 // 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. // 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 // 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 // host mounts; the submit itself (the transport to mesh-controller) is done by the caller invoking
// `mesh-control licence submit-refresh`. In the lab that caller is the scenario; in production it is // `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 // 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. // — 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 // 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 // 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 // unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
// internal/identity/sealing.go), and mesh-control seals with `box.SealAnonymous` // internal/identity/sealing.go), and mesh-controller seals with `box.SealAnonymous`
// (mesh-control internal/secrets/seal.go). Both are NaCl `crypto_box_seal`: // (mesh-controller internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
// //
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret) // sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed ) // nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
// //
// When the vendor rotates the refresh token, the manager module must store the new one back the // 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 // 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 // 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. // Go's `Open` accepts, or the host would refuse the delivery.
@@ -27,7 +27,7 @@
// (package.json dependencies; novox/hq ADR 0052). // (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 // **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 // `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. // refresh token silently mangled in production.
// //
@@ -5,7 +5,7 @@ import { generateKeyPairSync } from "node:crypto";
import { seal } from "../sealedbox.ts"; import { seal } from "../sealedbox.ts";
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal // 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. // (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 // 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. // 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", "module": "audit-logger",
"version": "1", "version": "1",
"slug": "audit", "slug": "audit",
"consumes": ["#"], "consumes": [
"#"
],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/audit-logger/broker" "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": [ "resources": [
{ {
"id": "state", "id": "state",
@@ -23,7 +46,6 @@
"id": "run", "id": "run",
"type": "container", "type": "container",
"name": "mesh-audit-logger", "name": "mesh-audit-logger",
"image": "mesh-runtime-audit@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/audit-logger/broker:/run/secrets/broker:ro", "/var/lib/audit-logger/broker:/run/secrets/broker:ro",
@@ -32,7 +54,11 @@
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"AUDIT_LOG": "/trail/audit.log" "AUDIT_LOG": "/trail/audit.log"
} },
"artifact": "runtime"
} }
],
"capabilities": [
"container-runtime"
] ]
} }
+14 -4
View File
@@ -17,7 +17,8 @@
"module.builder.built" "module.builder.built"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/builder/broker" "broker": "/var/lib/mesh/builder/broker",
"npm-password": "/var/lib/mesh/builder/npm-password"
}, },
"resources": [ "resources": [
{ {
@@ -37,7 +38,14 @@
"type": "file", "type": "file",
"path": "/var/lib/mesh/builder/builder.env", "path": "/var/lib/mesh/builder/builder.env",
"mode": "0600", "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", "id": "server",
@@ -49,11 +57,13 @@
], ],
"volumes": [ "volumes": [
"/var/lib/mesh/builder:/run/mesh:ro", "/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" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
"builder-env" "builder-env",
"package-binding",
"needs-npm-password"
] ]
} }
] ]
@@ -1,5 +1,5 @@
{ {
"module": "registry", "module": "distribution",
"version": "1", "version": "1",
"provides": [ "provides": [
{ {
+5 -5
View File
@@ -1,9 +1,6 @@
{ {
"module": "dnsmasq", "module": "dnsmasq",
"version": "1", "version": "1",
"requires": [
"resolver-data"
],
"provides": [ "provides": [
"wildcard-resolution" "wildcard-resolution"
], ],
@@ -56,8 +53,11 @@
"boot": "enabled", "boot": "enabled",
"restart-on": [ "restart-on": [
"config", "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" "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": { "own-secrets": {
"internal-token": "/var/lib/gitea/internal-token.secret", "internal-token": "/var/lib/gitea/internal-token.secret",
"admin": "/var/lib/gitea/admin.secret",
"broker": "/var/lib/mesh/gitea/broker" "broker": "/var/lib/mesh/gitea/broker"
}, },
"resources": [ "resources": [
@@ -60,6 +74,12 @@
"path": "/var/lib/gitea", "path": "/var/lib/gitea",
"mode": "0700" "mode": "0700"
}, },
{
"id": "grants",
"type": "directory",
"path": "/var/lib/gitea/grants",
"mode": "0700"
},
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
@@ -95,6 +115,30 @@
"/services/gitea/gitea:/data" "/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", "id": "runtime-config",
"type": "file", "type": "file",
@@ -107,20 +151,56 @@
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
"name": "mesh-gitea", "name": "mesh-gitea",
"image": "mesh-runtime-gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", "/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": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GITEA_URL": "http://127.0.0.1:3000", "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": [ "restart-on": [
"runtime-config" "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, "skipLibCheck": true,
"noEmit": 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" "amqp": "/var/lib/lavinmq-module/grants"
}, },
"own-secrets": { "own-secrets": {
"default": "/var/lib/lavinmq-module/default.secret",
"broker": "/var/lib/mesh/lavinmq/broker" "broker": "/var/lib/mesh/lavinmq/broker"
}, },
"listens": [ "listens": [
@@ -60,74 +59,64 @@
"path": "/var/lib/lavinmq-module/grants", "path": "/var/lib/lavinmq-module/grants",
"mode": "0700" "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", "id": "server",
"type": "container", "type": "container",
"name": "lavinmq", "name": "mesh-broker",
"image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b", "image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b",
"network": "lavinmq",
"ports": [ "ports": [
"5672" "5671:5671",
"5672:5672",
"127.0.0.1:15672:15672"
], ],
"volumes": [ "volumes": [
"/services/lavinmq/data:/var/lib/lavinmq", "mesh-broker-data:/var/lib/lavinmq",
"/var/lib/lavinmq-module/lavinmq.ini:/etc/lavinmq/lavinmq.ini:ro" "mesh-broker-tls:/tls:ro"
], ],
"args": [ "args": [
"--config", "--amqps-port=5671",
"/etc/lavinmq/lavinmq.ini" "--cert=/tls/tls.crt",
"--key=/tls/tls.key"
] ]
}, },
{ {
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
"name": "mesh-lavinmq", "name": "mesh-lavinmq",
"image": "mesh-runtime-lavinmq@sha256:0000000000000000000000000000000000000000000000000000000000000000", "artifact": "runtime",
"network": "lavinmq", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro", "/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro",
"/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro", "/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro"
"/var/lib/lavinmq-module/default.secret:/run/secrets/default:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json", "MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json",
"MESH_PROVISION_LAVINMQ": "http://lavinmq:15672", "MESH_PROVISION_LAVINMQ": "http://127.0.0.1:15672",
"MESH_PROVISION_ADMIN_USER": "mesh-admin", "MESH_PROVISION_ADMIN_USER": "guest",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/default" "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. * produced it. Turning that into an edge between module-versions is this module's job.
*/ */
against?: string[]; 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) => { await on("module.builder.built", async (event) => {
@@ -56,6 +65,10 @@ await on("module.builder.built", async (event) => {
manifest: body.manifest ?? {}, manifest: body.manifest ?? {},
}, body.made ?? [], body.against ?? []); }, 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", { await emit("module.mesh-catalog.registered", {
module: body.module, commit: body.commit, upgraded, 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", "version": "1",
"slug": "control", "slug": "control",
"capabilities": [ "capabilities": [
@@ -7,43 +7,43 @@
], ],
"claims": [ "claims": [
{ {
"name": "the-control-plane", "name": "the-controller",
"scope": "mesh" "scope": "mesh"
} }
], ],
"own-secrets": { "own-secrets": {
"inventory": "/var/lib/mesh/mesh-control/inventory", "inventory": "/var/lib/mesh/mesh-controller/inventory",
"identity": "/var/lib/mesh/mesh-control/identity", "identity": "/var/lib/mesh/mesh-controller/identity",
"licences": "/var/lib/mesh/mesh-control/licences", "licences": "/var/lib/mesh/mesh-controller/licences",
"broker": "/var/lib/mesh/mesh-control/broker", "broker": "/var/lib/mesh/mesh-controller/broker",
"broker-management": "/var/lib/mesh/mesh-control/broker-management", "broker-management": "/var/lib/mesh/mesh-controller/broker-management",
"broker-address": "/var/lib/mesh/mesh-control/broker-address" "broker-address": "/var/lib/mesh/mesh-controller/broker-address"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/mesh-control", "path": "/var/lib/mesh/mesh-controller",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "control-env", "id": "control-env",
"type": "file", "type": "file",
"path": "/var/lib/mesh/mesh-control/control.env", "path": "/var/lib/mesh/mesh-controller/control.env",
"mode": "0600", "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" "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", "id": "server",
"type": "container", "type": "container",
"name": "mesh-control", "name": "mesh-controller",
"image": "mesh-control@sha256:0000000000000000000000000000000000000000000000000000000000000000", "image": "mesh-controller@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "host", "network": "host",
"args": [ "args": [
"serve" "serve"
], ],
"env-file": [ "env-file": [
"/var/lib/mesh/mesh-control/control.env" "/var/lib/mesh/mesh-controller/control.env"
], ],
"env": { "env": {
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt" "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 // 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 // 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 // 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", "version": "1",
"capabilities": [ "capabilities": [
"firewall" "firewall"
+7 -29
View File
@@ -60,56 +60,34 @@
"path": "/var/lib/postgres/grants", "path": "/var/lib/postgres/grants",
"mode": "0700" "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", "id": "server",
"type": "container", "type": "container",
"name": "postgres", "name": "mesh-store",
"image": "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee", "image": "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee",
"network": "postgres",
"env": { "env": {
"POSTGRES_USER": "postgres", "POSTGRES_PASSWORD": "bootstrap",
"POSTGRES_DB": "postgres" "PGDATA": "/var/lib/postgresql/data/pgdata"
}, },
"env-file": [
"/var/lib/postgres/superuser.env"
],
"ports": [ "ports": [
"5432" "5432:5432"
], ],
"volumes": [ "volumes": [
"/services/postgres/db-data:/var/lib/postgresql/data" "mesh-store-data:/var/lib/postgresql/data"
] ]
}, },
{ {
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
"name": "mesh-postgres", "name": "mesh-postgres",
"network": "postgres", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/postgres/broker:/run/secrets/broker:ro", "/var/lib/mesh/postgres/broker:/run/secrets/broker:ro",
"/var/lib/postgres/grants:/var/lib/postgres/grants:ro", "/var/lib/postgres/grants:/var/lib/postgres/grants:ro",
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
], ],
"env": { "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_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json" "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 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 # **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 # §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 \ # 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 # 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. # 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 ## How it ships the Go proxy
The proxy is a Go program, unlike the TypeScript tool-runtime modules. The canonical source is 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 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 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 placeholder digest every mesh-built image does (`@sha256:0000…`); the mesh pins the real digest at
publish. 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": { "binds": {
"route": "/var/lib/mesh/verdaccio/route.json" "route": "/var/lib/mesh/verdaccio/route.json"
} },
"provides": [
{
"name": "package-registry",
"scope": "mesh"
}
]
} }