Commit Graph
131 Commits
Author SHA1 Message Date
jschoubben 550393b847 Runtime config: dedicated mergeable file + restart-on so settings take effect (issue 009)
Give each settings-config runtime a mergeable config file with its own id
(runtime-config) — the previous "config" collided with modules that already own a
config directory, so ten runtimes mounted a config file no resource declared. Point
each runtime's restart-on at it, so a settings change recreates the runtime and it
re-reads the new value (needs the mesh-host container restart-on fix on
issue/009-container-restart-on). Proven: runtime-restart-on-config e2e green.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:40:07 +02:00
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00
jschoubben 7b55e834e9 sonarr, radarr: tool runtime container + self-detect API key from config.xml (ADR 0052)
Mirrors plex: a broker-bound runtime that serves the module's tools, discovering
the app's API key from its own config.xml under a read-only config-dir mount, URL
defaulting to the server on the node. Proven in the mesh-lab: assigned-sonarr green
(key detected, tools served under the scoped account, no live Sonarr needed).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 22:54:53 +02:00
jschoubben 6615b5e3b3 plex, redis: module runtime containers + declare self-consumed events (ADR 0052/0046)
plex gains a broker-bound tools/events runtime container (mesh-runtime-plex)
alongside its server, and a never-throwing plex_reachable health probe. redis and
postgres subscribe to their own lifecycle events in index.ts but declared no
consumes — so the substrate never made the queue the runtime binds and it crashed
on start (404). Declare the consume, as ADR 0046 requires. Proven end-to-end in the
mesh-lab: assigned-plex and assigned-redis both green.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 22:29:41 +02:00
jschoubben 67d8d0dcbd catalogue: one mesh-state namespace — /var/lib/mesh/<module>/ (consistency)
The review found broker own-secret paths drifting: mostly /var/lib/<module>/
broker, but grafana/redis/icecast/nextcloud carried a '-module' suffix to dodge
a collision with the service's own /var/lib/<name> data, and photos sat under
/etc. Normalized to one collision-free namespace a service never owns:
/var/lib/mesh/<module>/broker, with a mesh-state directory resource for the
parent, across 24 modules. audit-logger is grandfathered (lab-proven, referenced
by the assigned test, and it has no service to collide with). All 33 manifests
parse; no client hardcoded a path, so nothing in code moved.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 21:28:55 +02:00
jschoubben 5fd78a77ad minio: provisioner emits best-effort, so a missing broker can't fail provisioning
The review found minio's provisioner emitting with a bare await, which throws
when no broker is bound (a provisioner is not yet a runtime — ADR 0052) and so
fails create/remove. Wrapped like the other providers: the event is logged and
dropped, the bucket still made. The real fix — the provisioner carrying a broker
credential — is ADR 0052.
2026-09-04 21:25:42 +02:00
jschoubben 121b7f196b cloudflare-dns: config from settings, not static manifest env (ADR 0051)
Which zone, domain and ingress are a mesh's facts, not the module's — so they
are settings merged into a config file the mesh manages, read by fromEnv, rather
than the empty env placeholders I wrongly baked in. The token stays the one
own-secret. The module now describes a Cloudflare registrar; which zone is a
setting, so the same description serves every mesh. Typechecks; manifest parses.
2026-09-04 21:07:51 +02:00
jschoubben d2d20a76b6 cloudflare-dns: a public-dns provider (ADR 0049)
The first registrar behind the neutral public-dns interface. Provider shape
like minio: provides public-dns, a provisioner that registers a consumer's
public name at Cloudflare pointing it at the mesh's ingress, and removes it on
withdrawal. The name is derived from the consumer identity under the mesh's
domain (so stateless teardown recomputes it); the returned {fqdn,target,ttl}
is public, the Cloudflare token the only secret and it never leaves. Emits
record.created/.removed (best-effort). A cloudflare_dns_records diagnostic tool.
Config (zone, domain, ingress) is left to settings, so it fails closed until a
mesh provides them. Typechecks; manifest parses.
2026-09-04 20:55:36 +02:00
jschoubben 0e102dd350 firewall: the module that applies the mesh-computed packet filter (ADR 0050)
The missing applier. mesh-control already derives a node's whole nftables rule
set from the union of its modules' listens and writes it to /etc/nftables.conf;
this module declares filtering:{into} to receive it and loads it — the nftables
service, reloaded on 'filtering' whenever the rules change. A firewall_rules
tool reads the live table so a declared scope can be checked against what is
really enforced. Closes the loop from listens.from to a packet actually dropped.
Manifest parses; tool typechecks.
2026-09-04 20:52:26 +02:00
jschoubben d59649de0a dnsmasq: a resolver tool and event (ADR 0044/0046)
The mesh's resolver is more than config after all. Tools: dnsmasq_names (what
this node answers, from the generated wildcard file) and dnsmasq_resolve (resolve
a name through this node's own resolver — the check that wildcard-resolution
actually answers). Event: it watches the wildcard file and emits
module.dnsmasq.name.added/.removed as machines' names become resolvable here —
DNS having propagated to this node, distinct from the mesh's node.* events.
Typechecks; manifest parses.
2026-09-04 10:24:03 +02:00
jschoubben 8f8e0153b1 searxng, icecast, photos: full nox modules (ADR 0044/0046)
searxng: a search tool — tools-only, a stateless search has nothing to observe.
icecast: status tool, emits stream.started/.stopped by diffing live mountpoints.
photos: status/albums/recent tools, emits item.added (immich-shaped API, coded
defensively since the stub ships a placeholder image — flagged in the code).
Typecheck; manifests parse.
2026-09-04 02:46:24 +02:00
jschoubben 81dc74734b registry, verdaccio, portainer: full nox modules (ADR 0044/0046)
registry (docker v2): catalog/tags/delete tools, emits image.pushed (a build's
image is now pullable). verdaccio (npm): list/info tools, emits
package.published. portainer: endpoints/stacks/containers tools — tools-only,
since its only events are the underlying containers' lifecycle, which the host
owns. Typecheck; manifests parse.
2026-09-04 02:45:42 +02:00
jschoubben aca237b2e0 home-assistant, influxdb: full nox modules (ADR 0044/0046)
home-assistant: states/call-service/config tools, emits state.changed bounded
to actuator/contact domains (not attribute ticks), overridable via a watch
allowlist. influxdb: health/buckets/flux-query tools — tools-only, since a
time-series DB has no lifecycle event to emit here. Typecheck; manifests parse.
2026-09-04 02:44:17 +02:00
jschoubben eccfc70991 grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046)
grafana: status/datasources/dashboards/alerts tools, emits alert.firing.
tautulli: activity/history/stats tools, emits watch.recorded. nextcloud:
users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits
user.created/share.created. nodered: flows/nodes/deploy tools, emits
flows.deployed inline from the deploy tool. All typecheck; manifests parse.
2026-09-04 02:43:20 +02:00
jschoubben 1e2a849b90 nzbget, qbittorrent: full nox downloader modules (ADR 0044/0046)
nzbget (usenet, JSON-RPC) and qbittorrent (torrents, WebUI API with manual SID
session). Tools: status, queue/torrents, add, pause/resume, delete. Both poll
and emit module.<app>.download.added and module.<app>.download.completed — the
key plex consumes to rescan; completion is keyed off real success (nzbget
history SUCCESS, qbittorrent progress reaching 1), not mere queue disappearance,
so a failed or deleted item is not reported as done. Typecheck; manifests parse.
2026-09-04 02:40:06 +02:00
jschoubben d7ceb05e53 jackett, bazarr, ombi: full nox modules (ADR 0044/0046)
Ported their real HTTP APIs (hal carried no client for these). jackett: an
indexer proxy — tools only (list indexers, search), no events, no broker
account, because it answers queries and has no timeline to observe. bazarr:
subtitle tools + emits module.bazarr.subtitle.downloaded (poll history, diff).
ombi: request tools + emits request.created/.approved. All pure emitters
(their decisions originate here). Typecheck; manifests parse.
2026-09-04 02:39:50 +02:00
jschoubben 4d04585831 redis, postgres: full nox provider modules — client, tools, provisioner, events
redis provides redis-cache: a real admin client speaking RESP over a raw socket
(node:net, no deps); provisioner makes a keyspace-scoped ACL user per grant.
postgres provides postgres-database: admin client executing through psql (the
consistent shell-out port, like minio's mc), full DDL for create/drop database+
role, a CSV row parser, read-only query tool. Both emit
module.<x>.<thing>.provisioned/.deprovisioned from the provisioner. Both
typecheck; manifests parse.
2026-09-04 02:39:03 +02:00
jschoubben f689b7dfa6 minio: full nox module — client, tools, provisioner and events (ADR 0044/0045/0046)
Object store, an s3-bucket provider. Client ported with no npm deps: S3 data
plane over fetch + SigV4 (node:crypto), scoped access keys via the mc CLI (the
admin API needs an Argon2 payload node built-ins can't make — the honest port
hal also used). Tools: list buckets/objects, bucket info, presigned url. The
provisioner makes a bucket + scoped key per grant and emits
module.minio.bucket.created/removed (the secret stays off the bus). Typechecks;
manifest parses.

(Trimmed the generated self-consuming ledger: a provider need not subscribe to
its own emits.)
2026-09-04 02:35:51 +02:00
jschoubben fb42fb956b sonarr, radarr: full nox modules — clients, tools and events (ADR 0044/0046)
The Servarr apps (TV, movies), each self-contained from hal's shared arr
client. Tools: status, library, search, queue, calendar. Events by polling the
download queue: a new item emits module.<app>.<thing>.grabbed, an item that
leaves as completed emits module.<app>.download.completed — the exact key plex
consumes to rescan. A grab that left as failed/warning is not reported as a
completion. Both typecheck; manifests parse.
2026-09-04 02:33:32 +02:00
jschoubben 259c3721b5 gitea: full nox module — client, tools and events (ADR 0044/0046)
Git hosting. 15 tools (repos, issues, PRs, labels, api passthrough) moved out
of the shared sdk. Emits repo.created (from a light poll, catching repos born
of git push or the web UI), issue.opened and pull.merged (from the tools at the
moment of the action) — the poll owns repo.created alone so it is never
announced twice. Typechecks; manifest parses.
2026-09-04 02:30:40 +02:00
jschoubben 4d98da18a0 keycloak: full nox module — client, tools and events (ADR 0044/0046)
Identity provider. 22 admin tools (realms, users, clients + secrets, groups,
roles) over the admin API, moved out of the shared sdk. Emits user created/
deleted, password reset, client/group/role created — from the write tools
themselves, since Keycloak's value is the changes it makes, not pollable
state. Consumes nothing: it is upstream of everything that authenticates
against it. Typechecks; manifest parses.
2026-09-04 02:30:40 +02:00
jschoubben 1498a22c94 mailu: full nox module — client, tools and events (ADR 0044/0046)
Email server. Users/aliases/domains over the admin REST API, mail read via
doveadm in the imap container; ten tools. Emits user/alias created/deleted by
polling and diffing the admin API, so a change in the web admin is announced
as readily as one via a tool. Consumes nothing — deliberately, since mutating
mail accounts off another module's event could silently lose mail. Typechecks
against the sdk; manifest parses.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 02:29:38 +02:00
jschoubben 2526955712 plex: full nox module — client, tools and events (ADR 0044/0046)
Moves plex's API client and tools out of the shared hal sdk into the module,
so a Plex API change rebuilds only plex. Tools: status, search, sessions,
recently-added, refresh. And a real event design: it emits playback
started/stopped and item.added by watching the server, and consumes
module.*.download.completed to rescan so a downloader's fetch becomes a
visible item. Typechecks against the sdk; manifest parses with its emits/
consumes and broker own-secret.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 02:23:23 +02:00
jschoubben c2c26a29a9 audit-logger: the container names its image directly (a container has no artifact)
A container resource takes a digest-pinned image, not a build artifact — the
host refuses 'artifact' on a container. Verified: the mesh assigns it and the
host runs it in the lab.
2026-09-04 02:13:01 +02:00
jschoubben 8f0994fcdc audit-logger: the assigned-module manifest (ADR 0048)
Now a real assigned module, not just a handler: consumes '#', declares its
broker own-secret, and runs the runtime image as a container that mounts the
sealed credential and its trail. own-secrets:{broker} is the file the mesh
seals it (module issue); the container reads MESH_BROKER_FILE from the mount
and takes its node/module identity from the credential. Parses against the
catalogue schema.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 01:56:36 +02:00
jschoubben 43625ec03f audit-logger: record the event's own x-event-id (ADR 0047)
The trail's id is now the event's x-event-id — the handle a reader dedups
the at-least-once stream on — not the type@time placeholder the first cut
used. Carries causation/schema through when present.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 00:26:10 +02:00
jschoubben f77745d7c0 audit-logger: a module that records every event on the mesh
The universal consumer from ADR 0046 — no privilege, just a module that
consumes '#' and writes each event to an append-only trail. Its whole code
is on('#', record); the manifest declares consumes:['#'] and a log dir.
Records module.*, mesh.* and node.* events alike (ADR 0047's namespace).

Type-checks against @novox/mesh-sdk; the handler test records a module
event and a node event with their metadata; the manifest parses against
the consumes-enabled schema.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-03 23:50:55 +02:00
jschoubben e8061191c7 umami: complete, on the sdk — the first fully converted module
umami is now a whole module, not a manifest: its own API client, its
tools, and its provisioner all live in the module and build on
@novox/mesh-sdk.

- client.ts — umami's API client, moved out of the shared sdk into the
  module (ADR 0044); umami's tools and provisioner both import it.
- tools/ — umami_create_site / umami_delete_site on the sdk tool harness
  (registerModuleTools); the tool logic and client are the module's.
- provisioner/ — the adapter making umami a provider of the mesh
  'analytics' interface: a consumer contributes {domain}, receives
  {siteId, snippet, dashboard}. ~20 lines, because the watch/seal/grant
  loop is the sdk harness's.

Type-checks against the real mesh-sdk (tsc --noEmit clean); the manifest
parses against internal/catalogue. Remaining to actually run: build and
publish the module + its mesh-provision-umami-analytics image (the
placeholder digest), which is the pipeline's job.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-03 23:05:20 +02:00
jschoubben f4fc5b3be8 umami: the reference module — a provider as well as a consumer
umami was only half a module: it required postgres but did not provide
what it exists to offer. It now provides the mesh 'analytics' interface
(ADR 0045) — a webapp requires analytics and umami's provisioner creates
its site and returns the grant — mirroring the postgres provider pattern
(serves/receives/grants + a provisioner container that adapts umami's API
to the mesh contract).

Also: split the listen (one port, two surfaces — the mesh-gated dashboard
and the public collection endpoint browsers POST to, hence from:anywhere),
and an admin own-secret for the provisioner to drive umami's API.

Parses against internal/catalogue. Still to build: the provisioner image
(mesh-provision-umami-analytics — the adapter, real code like
postgres-provisioner; placeholder digest for now) and umami's tools/client
in the module (ADR 0044), which need the sdk harness.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-03 22:53:07 +02:00
jschoubben c5efbd53ca A module is a package, not a bare manifest
Each module becomes modules/<name>/ holding module.json, with room for
the rest of what a module is — its tools, health checks, provisioning,
lifecycle — which the conversion from hal still has to bring across.
The flat <name>.json was only the resource-declaration half.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-03 00:47:14 +02:00
jschoubben 86ea181c76 The catalogue moves to its own repository
Thirty modules the mesh builds, provisions and runs, as manifests — one
per module, flat under modules/. They were in mesh-control/examples/,
which framed the mesh's real modules as illustrations of a control-plane
package; they are neither examples nor the control plane's. The engine
that reads them stays in mesh-control; the data lives here, consumed as
a build source.

Answers the tier-4 question novox/hq ADR 0030 left open — where the
catalogue lives — in favour of one flat repository, which the drop of
domain grouping (seats, claims and tags instead) makes the right shape.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-02 23:51:53 +02:00