From 9b063a77b2d042927d53011b1cb1bb65af57f2b4 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 19:34:14 +0200 Subject: [PATCH 1/7] nats: the module, and an image that reloads in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.1 and 1.2 of novox/hq ADR 0116. The server is a built artifact rather than the upstream image directly, because it needs an entrypoint of its own: the host can only recreate a container, and recreating the bus for every permission change drops every connection and every in-flight ack. nats-server reloads on SIGHUP by itself, so the config is mounted as a directory (not digest-tracked, hq issue 103) and the entrypoint watches the one file. Verified against the real server, not assumed: a user added to the config connects, a revoked one is refused, both within one poll interval, with the container's PID and restart count unchanged and "Reloaded: accounts" in its log. Two corrections found by checking rather than reading: - the seat delivers nothing now (hq ADR 0117), and the controller's parser refused the manifest until it did — "nats claims mesh-broker, whose holder answers for amqp, and nats does not provide amqp" - pinned to the multi-arch index digest; the first pin was the amd64 manifest, which builds here and fails on any other architecture --- modules/nats/Dockerfile | 18 ++++++++++ modules/nats/entrypoint.sh | 61 ++++++++++++++++++++++++++++++++ modules/nats/module.json | 71 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 modules/nats/Dockerfile create mode 100644 modules/nats/entrypoint.sh create mode 100644 modules/nats/module.json diff --git a/modules/nats/Dockerfile b/modules/nats/Dockerfile new file mode 100644 index 0000000..b362d7c --- /dev/null +++ b/modules/nats/Dockerfile @@ -0,0 +1,18 @@ +# nats's server image: the upstream server, plus an entrypoint that reloads it in place when the +# mesh rewrites its configuration. See entrypoint.sh for why that belongs here and not in the host. +# +# **Pinned to the multi-architecture index digest, not a platform's.** `docker manifest inspect` +# reports a platform manifest per architecture and the index that lists them; pinning a platform's +# digest builds on this workstation and fails on any node of another architecture, with an error +# that names a manifest rather than the mistake. This is the index — `docker pull` reports the same +# one, and `RepoDigests` confirms it. +# +# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image: +# the module's code is the server, which upstream already built. There is no BUILD_BASE here on +# purpose — nothing is compiled. +FROM nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 + +COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint +RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint + +ENTRYPOINT ["/usr/local/bin/mesh-nats-entrypoint"] diff --git a/modules/nats/entrypoint.sh b/modules/nats/entrypoint.sh new file mode 100644 index 0000000..f117cc1 --- /dev/null +++ b/modules/nats/entrypoint.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# nats's entrypoint: run the server, and reload it in place when the mesh rewrites its +# configuration. +# +# **Why this exists inside the module** (novox/hq design 25 §5). The controller composes every +# account and permission into one configuration file, and that file changes whenever a module is +# added, reassigned, or a person's access is granted or revoked — which is often, and on the one +# server everything else depends on. The host has no way to say "reload this container": a +# container resource has `restart-on` and nothing else, and a container's `restart-on` means +# *recreate* — every connection dropped and every in-flight JetStream ack lost, mid-flight, for a +# permission change. `reload-on` is real but it is a *service* field, not a container's. +# +# nats-server already reloads its own configuration on SIGHUP — accounts, permissions, everything +# the mesh composes — without dropping a connection. That is the server's own documented +# capability, not something built for the mesh. So the configuration is mounted as a directory +# (a directory's contents are not digest-tracked the way a directly-mounted file's are, novox/hq +# issue 103), and this watches the one file inside it and signals the server itself. The host's +# only job is what it already does for any directory: keep the file's content current. Nothing +# here is declared `restart-on` or `reload-on`. +set -eu + +CONF="${MESH_NATS_CONF:-/etc/nats/nats.conf}" +POLL="${MESH_NATS_CONF_POLL_SECONDS:-5}" + +# The controller writes the configuration as part of the same declaration that creates this +# container, but the two are not ordered against each other. Waiting is correct and starting +# without one is not: nats-server would come up with its compiled-in defaults — no TLS, no +# accounts, every subject open to anyone who can reach the port — and then be reloaded into +# correctness a moment later. A bus that is briefly open to everything is not a bus that is +# briefly wrong; it is an open bus. +while [ ! -s "$CONF" ]; do + echo "[nats] waiting for the mesh to compose $CONF" + sleep 1 +done + +digest() { sha256sum "$CONF" 2>/dev/null | cut -d' ' -f1; } + +nats-server --config "$CONF" "$@" & +server=$! + +# Forward a stop to the server and let it drain, rather than dying and leaving it orphaned as +# PID 1's child. +stop() { kill -TERM "$server" 2>/dev/null || true; } +trap stop TERM INT + +last=$(digest) +while kill -0 "$server" 2>/dev/null; do + sleep "$POLL" + now=$(digest) + # An empty digest means the file is mid-write or briefly gone. Reloading on that would hand the + # server a truncated configuration; the next tick sees the finished one. + [ -n "$now" ] || continue + if [ "$now" != "$last" ]; then + last=$now + echo "[nats] configuration changed; reloading in place" + kill -HUP "$server" || true + fi +done + +# `wait` on an already-exited child still yields its status, which becomes this container's. +wait "$server" diff --git a/modules/nats/module.json b/modules/nats/module.json new file mode 100644 index 0000000..ed34306 --- /dev/null +++ b/modules/nats/module.json @@ -0,0 +1,71 @@ +{ + "module": "nats", + "version": "1", + "provides": [], + "claims": [ + { + "name": "mesh-broker", + "scope": "mesh" + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [], + "consumes": [], + "listens": [ + { + "port": 4222, + "protocol": "tcp", + "from": "mesh", + "why": "the mesh bus \u2014 every link the mesh has, over TLS, reached across the overlay" + } + ], + "guards": [ + 8222 + ], + "resources": [ + { + "id": "jetstream-data", + "type": "directory", + "path": "/var/lib/mesh-broker-nats", + "mode": "0700" + }, + { + "id": "conf-dir", + "type": "directory", + "path": "/var/lib/nats-module/conf", + "mode": "0700" + }, + { + "id": "server", + "type": "container", + "name": "mesh-broker-nats", + "ports": [ + "4222:4222", + "127.0.0.1:8222:8222" + ], + "volumes": [ + "/var/lib/mesh-broker-nats:/data", + "/var/lib/nats-module/conf:/etc/nats:ro", + "/var/lib/mesh-broker-nats-tls:/tls:ro" + ], + "artifact": "server" + } + ], + "accesses": [ + { + "path": "/var/lib/mesh-broker-nats-tls", + "mode": "read" + } + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile" + } + ] + } +} From ea7f6796e83232a7a672d5f5f48f1b589d4bbc96 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:08:40 +0200 Subject: [PATCH 2/7] lavinmq is a provider, not foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It claims no seat: mesh-broker is the NATS server's (novox/hq ADR 0119). The amqp interface stays exactly as it is — a backing service a module may require, like a database. --- modules/lavinmq/module.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/lavinmq/module.json b/modules/lavinmq/module.json index 3304cb7..2f40b65 100644 --- a/modules/lavinmq/module.json +++ b/modules/lavinmq/module.json @@ -7,12 +7,6 @@ "scope": "mesh" } ], - "claims": [ - { - "name": "mesh-broker", - "scope": "mesh" - } - ], "capabilities": [ "container-runtime" ], From 86882cacd4e6107aecfde55b3f71f2d3b10a32e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:17:21 +0200 Subject: [PATCH 3/7] nats provides mesh-bus (novox/hq ADR 0120) --- modules/nats/module.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/nats/module.json b/modules/nats/module.json index ed34306..44adaff 100644 --- a/modules/nats/module.json +++ b/modules/nats/module.json @@ -1,7 +1,12 @@ { "module": "nats", "version": "1", - "provides": [], + "provides": [ + { + "name": "mesh-bus", + "scope": "mesh" + } + ], "claims": [ { "name": "mesh-broker", From ae99204a8cb64c7e83cc2b7f6c15edf164b7b6b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:07:09 +0200 Subject: [PATCH 4/7] Claim the renamed seats (novox/hq ADR 0118) Ten manifests claim mesh-* names now. What they PROVIDE is unchanged: gitea still provides git and npm-package-registry, and a consumer requires the interface, not the seat. --- modules/builder/module.json | 2 +- modules/distribution/module.json | 2 +- modules/dnsmasq/module.json | 6 +- modules/fail2ban/module.json | 2 +- modules/gitea/module.json | 4 +- modules/mesh-catalog/module.json | 2 +- modules/nftables/module.json | 4 +- modules/resolv-conf/module.json | 22 ++- modules/resolved-split-dns/module.json | 44 +++-- modules/showcase/module.json | 235 ++++++++++++++++++------- 10 files changed, 235 insertions(+), 88 deletions(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index 9d38701..68b3e9e 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "the-build-machine", + "name": "mesh-build-machine", "scope": "node" } ], diff --git a/modules/distribution/module.json b/modules/distribution/module.json index da0cfef..dfa7c9c 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -9,7 +9,7 @@ ], "claims": [ { - "name": "the-artifact-store", + "name": "mesh-artifact-store", "scope": "mesh" } ], diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 3442b37..3e3ae78 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -16,7 +16,7 @@ }, "claims": [ { - "name": "the-dns-port", + "name": "mesh-dns-port", "scope": "node" } ], @@ -25,7 +25,7 @@ "port": 53, "protocol": "udp", "from": "mesh", - "why": "every name for this machine and what it runs — the mesh's own answered here, the rest forwarded", + "why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded", "fixed": true } ], @@ -46,7 +46,7 @@ "type": "file", "path": "/etc/dnsmasq.conf", "mode": "0644", - "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n\n# What the mesh computed: one wildcard per machine — its name and everything\n# under it — and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask — including\n# this machine's containers. This module writes the runtime's\n# `dns` key into its own configuration file, beside whatever the\n# machine had there (novox/hq ADR 0102), naming this address: a\n# container cannot reach the machine's loopback, and a runtime\n# whose host resolves at loopback falls back to a public resolver\n# and never sees a mesh name. The runtime reads that key when it\n# starts and not on a reload, and a restart stops every container\n# on the machine, so this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\n# 127.0.0.1 this machine's own use. The predecessor's resolver answered\n# here, and the resolv.conf it wrote on every machine says so;\n# that file stays in force on an adopted machine until the mesh's\n# module for it is taken, so the resolver has to answer where the\n# machine already asks or the machine loses DNS the moment this\n# module is taken. Not .53 or .54: systemd-resolved holds BOTH —\n# .53 is its stub and .54 its proxy stub — and neither is .1, so\n# the two coexist on a machine that runs it. This module used to\n# answer on 127.0.0.55 instead: a convention of its own, beside\n# the one every machine already followed. One address, this one,\n# and the modules that point a machine at the mesh name the same.\n#\n# Whatever address it listens on, it takes the machine's DNS port.\n# That is why this module claims `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\n\n# **It must never read resolv.conf to find out where to forward.** Whatever\n# points this machine at the mesh writes this resolver's own address there — so\n# a resolver that read it for upstreams would find itself, and every query it\n# could not answer locally would loop until its receive queue filled. That is\n# not theoretical: it filled with 15KB of queries and every lookup on the\n# machine hung. no-resolv is what makes that loop impossible: the upstreams are\n# the two lines below, and nothing on the machine can redirect them.\n#\n# It forwards, because it is now asked for everything. The module that points\n# this machine at the mesh names this resolver alone — as the predecessor's\n# did — so the host and every container resolve the world through it. The\n# upstreams are the ones the predecessor's module shipped as its defaults. The\n# mesh's own names never reach them: the local= line in the file above stops\n# them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# A name without a dot is never forwarded — a bare hostname is answered from\n# /etc/hosts or not at all — and reverse lookups of private ranges are answered\n# here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n" + "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n\n# What the mesh computed: one wildcard per machine \u2014 its name and everything\n# under it \u2014 and the mesh's own suffix as a local domain, so a name under it is\n# answered here or not at all and is never asked upstream. Rewritten whenever a\n# machine joins or leaves, which is why the service below restarts on it: a\n# reload makes dnsmasq re-read hosts files, not its configuration, and a\n# wildcard is configuration.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it can ask \u2014 including\n# this machine's containers. This module writes the runtime's\n# `dns` key into its own configuration file, beside whatever the\n# machine had there (novox/hq ADR 0102), naming this address: a\n# container cannot reach the machine's loopback, and a runtime\n# whose host resolves at loopback falls back to a public resolver\n# and never sees a mesh name. The runtime reads that key when it\n# starts and not on a reload, and a restart stops every container\n# on the machine, so this module orders neither: the key holds for\n# every container created after the runtime next starts. On the\n# machine this replaces the predecessor wrote the same value, so\n# nothing there is waiting on it.\n# 127.0.0.1 this machine's own use. The predecessor's resolver answered\n# here, and the resolv.conf it wrote on every machine says so;\n# that file stays in force on an adopted machine until the mesh's\n# module for it is taken, so the resolver has to answer where the\n# machine already asks or the machine loses DNS the moment this\n# module is taken. Not .53 or .54: systemd-resolved holds BOTH \u2014\n# .53 is its stub and .54 its proxy stub \u2014 and neither is .1, so\n# the two coexist on a machine that runs it. This module used to\n# answer on 127.0.0.55 instead: a convention of its own, beside\n# the one every machine already followed. One address, this one,\n# and the modules that point a machine at the mesh name the same.\n#\n# Whatever address it listens on, it takes the machine's DNS port.\n# That is why this module claims `the-dns-port`.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.1\n\n# **It must never read resolv.conf to find out where to forward.** Whatever\n# points this machine at the mesh writes this resolver's own address there \u2014 so\n# a resolver that read it for upstreams would find itself, and every query it\n# could not answer locally would loop until its receive queue filled. That is\n# not theoretical: it filled with 15KB of queries and every lookup on the\n# machine hung. no-resolv is what makes that loop impossible: the upstreams are\n# the two lines below, and nothing on the machine can redirect them.\n#\n# It forwards, because it is now asked for everything. The module that points\n# this machine at the mesh names this resolver alone \u2014 as the predecessor's\n# did \u2014 so the host and every container resolve the world through it. The\n# upstreams are the ones the predecessor's module shipped as its defaults. The\n# mesh's own names never reach them: the local= line in the file above stops\n# them here, answered or refused.\nno-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n\n# A name without a dot is never forwarded \u2014 a bare hostname is answered from\n# /etc/hosts or not at all \u2014 and reverse lookups of private ranges are answered\n# here rather than asking the world who 10.x is.\ndomain-needed\nbogus-priv\n" }, { "id": "runtime-dns", diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 8b94092..208e204 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "the-intrusion-prevention", + "name": "mesh-intrusion-prevention", "scope": "node" } ], diff --git a/modules/gitea/module.json b/modules/gitea/module.json index d80f2ee..a5b9843 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -75,11 +75,11 @@ }, "claims": [ { - "name": "npm-package-registry", + "name": "mesh-npm-package-registry", "scope": "mesh" }, { - "name": "git", + "name": "mesh-git", "scope": "mesh" } ], diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 097368a..4c52d90 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -7,7 +7,7 @@ ], "claims": [ { - "name": "the-catalogue", + "name": "mesh-catalog", "scope": "mesh" } ], diff --git a/modules/nftables/module.json b/modules/nftables/module.json index 11a6be6..2eadabb 100644 --- a/modules/nftables/module.json +++ b/modules/nftables/module.json @@ -6,7 +6,7 @@ ], "claims": [ { - "name": "the-packet-filter", + "name": "mesh-packet-filter", "scope": "node" } ], @@ -30,7 +30,7 @@ "id": "stock-unit-stop", "type": "file", "path": "/etc/systemd/system/nftables.service.d/mesh.conf", - "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", + "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", "mode": "0644" }, { diff --git a/modules/resolv-conf/module.json b/modules/resolv-conf/module.json index 520e864..4a4a7e9 100644 --- a/modules/resolv-conf/module.json +++ b/modules/resolv-conf/module.json @@ -2,12 +2,22 @@ "module": "resolv-conf", "version": "1", "slug": "resolv", - - "requires": ["wildcard-resolution"], - "claims": [{"name": "the-resolver-configuration", "scope": "node"}], - + "requires": [ + "wildcard-resolution" + ], + "claims": [ + { + "name": "mesh-resolver-configuration", + "scope": "node" + } + ], "resources": [ - {"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it — the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know — a resolver's second line is asked only when the first does not\n# answer at all — and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"} + { + "id": "resolv", + "type": "file", + "path": "/etc/resolv.conf", + "mode": "0644", + "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n" + } ] } diff --git a/modules/resolved-split-dns/module.json b/modules/resolved-split-dns/module.json index 246ba83..b8efa28 100644 --- a/modules/resolved-split-dns/module.json +++ b/modules/resolved-split-dns/module.json @@ -2,18 +2,38 @@ "module": "resolved-split-dns", "version": "1", "slug": "splitdns", - - "requires": ["wildcard-resolution"], - "claims": [{"name": "the-resolver-configuration", "scope": "node"}], - + "requires": [ + "wildcard-resolution" + ], + "claims": [ + { + "name": "mesh-resolver-configuration", + "scope": "node" + } + ], "resources": [ - {"id": "drop-in", "type": "directory", "path": "/etc/systemd/resolved.conf.d", "mode": "0755"}, - - {"id": "route", "type": "file", - "path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine — a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"}, - - {"id": "resolved", "type": "service", "unit": "systemd-resolved.service", - "state": "running", "boot": "enabled", "restart-on": ["route"]} + { + "id": "drop-in", + "type": "directory", + "path": "/etc/systemd/resolved.conf.d", + "mode": "0755" + }, + { + "id": "route", + "type": "file", + "path": "/etc/systemd/resolved.conf.d/mesh.conf", + "mode": "0644", + "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n" + }, + { + "id": "resolved", + "type": "service", + "unit": "systemd-resolved.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "route" + ] + } ] } diff --git a/modules/showcase/module.json b/modules/showcase/module.json index 289cf76..a72c2e3 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -2,72 +2,189 @@ "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"], - + "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": "mesh-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)" } + { + "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" } + { + "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", + { + "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"] } + "volumes": [ + "/var/lib/mesh/showcase/broker:/run/secrets/broker:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "args": [ + "sleep", + "infinity" + ] + } ] } From a093c88c32b09e557956aaa666bd610c8f46c08c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 02:50:35 +0200 Subject: [PATCH 5/7] nats declares its own server settings, and where the mesh's users go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split the controller now makes, from this side. The module's own configuration — ports, TLS, JetStream — is a declared file resource, because those are properties of this container and change when its image does. `bus-users` names where the mesh writes every account and permission, in the same directory, and the module's configuration includes it. **Both files in one directory because they have to be.** An absolute include path is resolved relative to the including file's directory: nats-server given `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server, and recorded in the configuration itself where somebody moving a file will read it. **`verify: true` is gone, and it was refusing every connection in the mesh.** It makes the server demand a client certificate; a host pins this server's exact certificate and authenticates with the password the mesh minted, and presents none. Found by building this image and connecting to it as a host would. The entrypoint now waits for both files and watches the mesh's half: the module's own does not change without a new declaration, and that recreates the container anyway. Verified end to end against this image — the mesh's user list rewritten, the module noticing and reloading the server itself with no signal from outside, and the connection the mesh already had still working afterwards. --- modules/nats/entrypoint.sh | 36 ++++++++++++++++++++++-------------- modules/nats/module.json | 8 ++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/modules/nats/entrypoint.sh b/modules/nats/entrypoint.sh index f117cc1..8523f7b 100644 --- a/modules/nats/entrypoint.sh +++ b/modules/nats/entrypoint.sh @@ -3,9 +3,9 @@ # configuration. # # **Why this exists inside the module** (novox/hq design 25 §5). The controller composes every -# account and permission into one configuration file, and that file changes whenever a module is -# added, reassigned, or a person's access is granted or revoked — which is often, and on the one -# server everything else depends on. The host has no way to say "reload this container": a +# account and permission into one file, and that file changes whenever a module is added, +# reassigned, or a person's access is granted or revoked — which is often, and on the one server +# everything else depends on. The host has no way to say "reload this container": a # container resource has `restart-on` and nothing else, and a container's `restart-on` means # *recreate* — every connection dropped and every in-flight JetStream ack lost, mid-flight, for a # permission change. `reload-on` is real but it is a *service* field, not a container's. @@ -19,21 +19,29 @@ # here is declared `restart-on` or `reload-on`. set -eu +# **Two files, and only one of them is the mesh's** (novox/hq design 25 §4, task 1.7). CONF is this +# module's own — ports, TLS, JetStream — declared in its manifest, because those are properties of +# the container this module raises. USERS is every account and permission, composed by the +# controller, and CONF includes it. So what is watched here is the mesh's half: the module's own +# does not change without a new declaration, and that recreates the container anyway. CONF="${MESH_NATS_CONF:-/etc/nats/nats.conf}" +USERS="${MESH_NATS_USERS:-/etc/nats/accounts.conf}" POLL="${MESH_NATS_CONF_POLL_SECONDS:-5}" -# The controller writes the configuration as part of the same declaration that creates this -# container, but the two are not ordered against each other. Waiting is correct and starting -# without one is not: nats-server would come up with its compiled-in defaults — no TLS, no -# accounts, every subject open to anyone who can reach the port — and then be reloaded into -# correctness a moment later. A bus that is briefly open to everything is not a bus that is -# briefly wrong; it is an open bus. -while [ ! -s "$CONF" ]; do - echo "[nats] waiting for the mesh to compose $CONF" - sleep 1 +# Both are written as part of the same declaration that creates this container, but none of the +# three are ordered against each other. Waiting is correct and starting without them is not: +# nats-server given a configuration whose include is missing refuses to start, and one given no +# configuration at all comes up with its compiled-in defaults — no TLS, no accounts, every subject +# open to anyone who can reach the port. A bus that is briefly open to everything is not a bus that +# is briefly wrong; it is an open bus. +for needed in "$CONF" "$USERS"; do + while [ ! -s "$needed" ]; do + echo "[nats] waiting for the mesh to write $needed" + sleep 1 + done done -digest() { sha256sum "$CONF" 2>/dev/null | cut -d' ' -f1; } +digest() { sha256sum "$USERS" 2>/dev/null | cut -d' ' -f1; } nats-server --config "$CONF" "$@" & server=$! @@ -52,7 +60,7 @@ while kill -0 "$server" 2>/dev/null; do [ -n "$now" ] || continue if [ "$now" != "$last" ]; then last=$now - echo "[nats] configuration changed; reloading in place" + echo "[nats] the mesh's user list changed; reloading in place" kill -HUP "$server" || true fi done diff --git a/modules/nats/module.json b/modules/nats/module.json index 44adaff..5a0f695 100644 --- a/modules/nats/module.json +++ b/modules/nats/module.json @@ -13,6 +13,7 @@ "scope": "mesh" } ], + "bus-users": "/var/lib/nats-module/conf/accounts.conf", "capabilities": [ "container-runtime" ], @@ -42,6 +43,13 @@ "path": "/var/lib/nats-module/conf", "mode": "0700" }, + { + "id": "server-conf", + "type": "file", + "path": "/var/lib/nats-module/conf/nats.conf", + "content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n", + "mode": "0644" + }, { "id": "server", "type": "container", From 7b06a7a408cc6f590582152c197d72915c51a374 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 14:42:28 +0200 Subject: [PATCH 6/7] Event names are local now, in the manifests and in the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every module named its events the way the old bus spelled a routing key — `module..`. Design 29 says a module names an event locally and the mesh works out where it lands, so all 37 were stale against a rule already decided. On the new bus that derives into a namespace belonging to a module called "module", so no cross-module subscription in the mesh matched anything: nothing failed, nothing reacted (novox/hq 04-ISSUES/127). 36 manifests converted, and 43 files of module code with them. The code mattered as much as the manifests: the runtime builds the subject from what `emit()` is handed, so a converted manifest with unconverted code would have had the permission and the subject disagree. Three things the new check found on the way: - `photos` emitted an event its manifest never declared, which the new bus refuses outright. Declared. - `showcase` waited for an event nothing emits, so its demo could never be triggered — only `showcase` may publish under its own name. It emits both halves now. - `distribution` declared an event named after a different module. It emits `image.pushed` under its own name. An event about a *role* belongs on the seat, where the name outlives whoever holds it, but the sdk has no way to publish on a seat yet, so that stays recorded rather than declared. The audit logger's "everything" pattern is `**` rather than the old bus's `#`. --- modules/anthropic-consumer/module.json | 2 +- modules/anthropic-consumer/usage/index.ts | 2 +- modules/anthropic-manager/module.json | 2 +- modules/anthropic-manager/refresh/index.ts | 2 +- modules/audit-logger/module.json | 2 +- modules/audit-logger/test/audit.test.ts | 6 +++--- modules/bazarr/index.ts | 2 +- modules/bazarr/module.json | 2 +- modules/bookshelf/index.ts | 4 ++-- modules/bookshelf/module.json | 4 ++-- modules/builder/module.json | 2 +- modules/cloudflare-dns/module.json | 4 ++-- modules/cloudflare-dns/provisioner/index.ts | 4 ++-- modules/distribution/index.ts | 2 +- modules/distribution/module.json | 2 +- modules/dnsmasq/index.ts | 4 ++-- modules/dnsmasq/module.json | 4 ++-- modules/gitea/index.ts | 2 +- modules/gitea/module.json | 6 +++--- modules/gitea/tools/index.ts | 4 ++-- modules/grafana/index.ts | 2 +- modules/grafana/module.json | 2 +- modules/home-assistant/index.ts | 2 +- modules/home-assistant/module.json | 2 +- modules/icecast/index.ts | 4 ++-- modules/icecast/module.json | 4 ++-- modules/keycloak/index.ts | 12 ++++++------ modules/keycloak/module.json | 12 ++++++------ modules/lavinmq/index.ts | 4 ++-- modules/lavinmq/module.json | 8 ++++---- modules/lavinmq/provisioner/index.ts | 4 ++-- modules/lidarr/index.ts | 4 ++-- modules/lidarr/module.json | 4 ++-- modules/mailu/index.ts | 4 ++-- modules/mailu/module.json | 8 ++++---- modules/mesh-catalog/index.ts | 10 +++++----- modules/mesh-catalog/module.json | 8 ++++---- modules/mesh-vault/index.ts | 6 +++--- modules/mesh-vault/module.json | 12 ++++++------ modules/mesh-vault/provisioner/index.ts | 2 +- modules/minio/module.json | 4 ++-- modules/minio/provisioner/index.ts | 4 ++-- modules/model-usage/index.ts | 2 +- modules/model-usage/module.json | 2 +- modules/mongodb/index.ts | 4 ++-- modules/mongodb/module.json | 8 ++++---- modules/mongodb/provisioner/index.ts | 4 ++-- modules/mosquitto/index.ts | 4 ++-- modules/mosquitto/module.json | 8 ++++---- modules/mosquitto/provisioner/index.ts | 4 ++-- modules/mssql/index.ts | 4 ++-- modules/mssql/module.json | 8 ++++---- modules/mssql/provisioner/index.ts | 4 ++-- modules/nextcloud/index.ts | 4 ++-- modules/nextcloud/module.json | 4 ++-- modules/nodered/module.json | 2 +- modules/nodered/tools/index.ts | 2 +- modules/nzbget/index.ts | 4 ++-- modules/nzbget/module.json | 4 ++-- modules/ombi/index.ts | 4 ++-- modules/ombi/module.json | 4 ++-- modules/photos/index.ts | 2 +- modules/photos/module.json | 3 +++ modules/plex/index.ts | 8 ++++---- modules/plex/module.json | 8 ++++---- modules/postgres/index.ts | 4 ++-- modules/postgres/module.json | 8 ++++---- modules/postgres/provisioner/index.ts | 4 ++-- modules/qbittorrent/index.ts | 4 ++-- modules/qbittorrent/module.json | 4 ++-- modules/radarr/index.ts | 4 ++-- modules/radarr/module.json | 4 ++-- modules/redis/index.ts | 4 ++-- modules/redis/module.json | 8 ++++---- modules/redis/provisioner/index.ts | 4 ++-- modules/showcase/index.ts | 4 ++-- modules/showcase/module.json | 5 +++-- modules/sonarr/index.ts | 4 ++-- modules/sonarr/module.json | 4 ++-- modules/tautulli/index.ts | 2 +- modules/tautulli/module.json | 2 +- modules/verdaccio/index.ts | 2 +- modules/verdaccio/module.json | 2 +- 83 files changed, 181 insertions(+), 177 deletions(-) diff --git a/modules/anthropic-consumer/module.json b/modules/anthropic-consumer/module.json index 981199d..55754a7 100644 --- a/modules/anthropic-consumer/module.json +++ b/modules/anthropic-consumer/module.json @@ -18,7 +18,7 @@ "broker": "/var/lib/mesh/anthropic-consumer/broker" }, "emits": [ - "module.anthropic-consumer.usage.session" + "usage.session" ], "resources": [ { diff --git a/modules/anthropic-consumer/usage/index.ts b/modules/anthropic-consumer/usage/index.ts index f874547..9dae62d 100644 --- a/modules/anthropic-consumer/usage/index.ts +++ b/modules/anthropic-consumer/usage/index.ts @@ -123,7 +123,7 @@ async function emitUsage(body: Record): Promise { await new Promise((resolve) => { const child = spawn( process.execPath, - [main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)], + [main, "emit", "usage.session", JSON.stringify(body)], { stdio: "inherit" }, ); child.on("exit", () => resolve()); diff --git a/modules/anthropic-manager/module.json b/modules/anthropic-manager/module.json index b0f9d55..a82b24d 100644 --- a/modules/anthropic-manager/module.json +++ b/modules/anthropic-manager/module.json @@ -18,7 +18,7 @@ "broker": "/var/lib/mesh/anthropic-manager/broker" }, "emits": [ - "module.anthropic-manager.usage.read" + "usage.read" ], "resources": [ { diff --git a/modules/anthropic-manager/refresh/index.ts b/modules/anthropic-manager/refresh/index.ts index 6db3a94..3749233 100644 --- a/modules/anthropic-manager/refresh/index.ts +++ b/modules/anthropic-manager/refresh/index.ts @@ -143,7 +143,7 @@ async function emitUsage(body: Record): Promise { const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; const { spawn } = await import("node:child_process"); await new Promise((resolve) => { - const child = spawn(process.execPath, [main, "emit", "module.anthropic-manager.usage.read", JSON.stringify(body)], { + const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], { stdio: "inherit", }); child.on("exit", () => resolve()); diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json index 671d9fb..0fbae45 100644 --- a/modules/audit-logger/module.json +++ b/modules/audit-logger/module.json @@ -3,7 +3,7 @@ "version": "1", "slug": "audit", "consumes": [ - "#" + "**" ], "own-secrets": { "broker": "/var/lib/audit-logger/broker" diff --git a/modules/audit-logger/test/audit.test.ts b/modules/audit-logger/test/audit.test.ts index 2762934..db09111 100644 --- a/modules/audit-logger/test/audit.test.ts +++ b/modules/audit-logger/test/audit.test.ts @@ -15,16 +15,16 @@ test("audit-logger records every event to the trail as one line each", async () const path = join(dir, "audit.log"); // The audit-logger's whole behaviour: consume everything, record it. - await on("#", async (event) => record(event, path)); + await on("**", async (event) => record(event, path)); process.env.MESH_MODULE = "umami"; process.env.MESH_NODE = "anchor"; - await emit("module.umami.site.created", { domain: "my-app" }); + await emit("site.created", { domain: "my-app" }); await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l)); assert.equal(lines.length, 2); - assert.deepEqual(lines.map((l) => l.type), ["module.umami.site.created", "node.anchor.joined"]); + assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]); assert.equal(lines[0].source, "umami"); assert.equal(lines[0].node, "anchor"); assert.equal(lines[0].body.domain, "my-app"); diff --git a/modules/bazarr/index.ts b/modules/bazarr/index.ts index 42bbe4f..6c07d0c 100644 --- a/modules/bazarr/index.ts +++ b/modules/bazarr/index.ts @@ -24,7 +24,7 @@ async function pollHistory(): Promise { for (const entry of entries) { if (seen.has(entry.id)) continue; if (primed) { - await emit("module.bazarr.subtitle.downloaded", { + await emit("subtitle.downloaded", { kind: entry.kind, title: entry.title, language: entry.language, diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index fb95070..00bd0dc 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -5,7 +5,7 @@ "container-runtime" ], "emits": [ - "module.bazarr.subtitle.downloaded" + "subtitle.downloaded" ], "own-secrets": { "broker": "/var/lib/mesh/bazarr/broker", diff --git a/modules/bookshelf/index.ts b/modules/bookshelf/index.ts index 6b870d6..a7543fa 100644 --- a/modules/bookshelf/index.ts +++ b/modules/bookshelf/index.ts @@ -45,12 +45,12 @@ async function pollQueue(bookshelf: BookshelfClient): Promise { if (primed) { // Entered the queue since last look — Bookshelf grabbed a release. for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("module.bookshelf.book.grabbed", { title: item.title, status: item.status }); + if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status }); } // Left the queue — imported and done, unless it was last seen failing. for (const [id, item] of inQueue) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("module.bookshelf.download.completed", { title: item.title }); + await emit("download.completed", { title: item.title }); } } } diff --git a/modules/bookshelf/module.json b/modules/bookshelf/module.json index 9361d77..17a96b0 100644 --- a/modules/bookshelf/module.json +++ b/modules/bookshelf/module.json @@ -6,8 +6,8 @@ "container-runtime" ], "emits": [ - "module.bookshelf.book.grabbed", - "module.bookshelf.download.completed" + "book.grabbed", + "download.completed" ], "consumes": [], "own-secrets": { diff --git a/modules/builder/module.json b/modules/builder/module.json index 68b3e9e..de6ea36 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -21,7 +21,7 @@ "npm-package-registry": "/var/lib/mesh/builder/package-registry.secret" }, "emits": [ - "module.builder.built" + "built" ], "own-secrets": { "broker": "/var/lib/mesh/builder/broker" diff --git a/modules/cloudflare-dns/module.json b/modules/cloudflare-dns/module.json index 5a52670..9ce387f 100644 --- a/modules/cloudflare-dns/module.json +++ b/modules/cloudflare-dns/module.json @@ -22,8 +22,8 @@ "broker": "/var/lib/mesh/cloudflare-dns/broker" }, "emits": [ - "module.cloudflare-dns.record.created", - "module.cloudflare-dns.record.removed" + "record.created", + "record.removed" ], "resources": [ { diff --git a/modules/cloudflare-dns/provisioner/index.ts b/modules/cloudflare-dns/provisioner/index.ts index e192843..80bfcaf 100644 --- a/modules/cloudflare-dns/provisioner/index.ts +++ b/modules/cloudflare-dns/provisioner/index.ts @@ -20,7 +20,7 @@ runProvisioner("public-dns", { async create(p: Provision): Promise { const fqdn = cloudflare.nameFor(p.as); await cloudflare.upsert(fqdn); - await announce("module.cloudflare-dns.record.created", { + await announce("record.created", { name: fqdn, target: cloudflare.ingress, consumer: p.consumer ?? "", @@ -30,7 +30,7 @@ runProvisioner("public-dns", { async remove(p: { as: string }): Promise { const fqdn = cloudflare.nameFor(p.as); await cloudflare.remove(fqdn); - await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as }); + await announce("record.removed", { name: fqdn, consumer: p.as }); }, }); diff --git a/modules/distribution/index.ts b/modules/distribution/index.ts index 86748b7..949f1f9 100644 --- a/modules/distribution/index.ts +++ b/modules/distribution/index.ts @@ -33,7 +33,7 @@ async function pollCatalog(): Promise { for (const tag of tags) { const id = `${repo}:${tag}`; if (!seen.has(id)) { - if (primed) await emit("module.registry.image.pushed", { repo, tag }); + if (primed) await emit("image.pushed", { repo, tag }); seen.add(id); } } diff --git a/modules/distribution/module.json b/modules/distribution/module.json index dfa7c9c..ea28f11 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -17,7 +17,7 @@ "container-runtime" ], "emits": [ - "module.registry.image.pushed" + "image.pushed" ], "own-secrets": { "broker": "/var/lib/mesh/registry/broker" diff --git a/modules/dnsmasq/index.ts b/modules/dnsmasq/index.ts index 2c428c8..d8327bd 100644 --- a/modules/dnsmasq/index.ts +++ b/modules/dnsmasq/index.ts @@ -20,10 +20,10 @@ async function poll(): Promise { const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); if (primed) { for (const [name, address] of now) { - if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address }); + if (!known.has(name)) await emit("name.added", { name, address }); } for (const [name] of known) { - if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name }); + if (!now.has(name)) await emit("name.removed", { name }); } } known.clear(); diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 3e3ae78..8f8af5e 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -8,8 +8,8 @@ "mesh-addressing" ], "emits": [ - "module.dnsmasq.name.added", - "module.dnsmasq.name.removed" + "name.added", + "name.removed" ], "own-secrets": { "broker": "/var/lib/mesh/dnsmasq/broker" diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index f7847bb..eb1d565 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -35,7 +35,7 @@ async function pollRepos(client: GiteaClient): Promise { for (const repo of repos) { if (!seen.has(repo.full_name)) { if (primed) { - await emit("module.gitea.repo.created", { + await emit("repo.created", { full_name: repo.full_name, owner: repo.owner, name: repo.name, diff --git a/modules/gitea/module.json b/modules/gitea/module.json index a5b9843..c44cabe 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -38,9 +38,9 @@ "container-runtime" ], "emits": [ - "module.gitea.repo.created", - "module.gitea.issue.opened", - "module.gitea.pull.merged" + "repo.created", + "issue.opened", + "pull.merged" ], "listens": [ { diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 7e77484..e910f8b 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -124,7 +124,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { labels: labelIds, }); // The mesh just opened an issue — announce it the moment it exists. - await emit("module.gitea.issue.opened", { + await emit("issue.opened", { owner, repo, number: issue.number, @@ -231,7 +231,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { // Read the PR first, so the merged event carries a title and branches, not just a number. const pull = await gitea.getPullRequest(owner, repo, number); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); - await emit("module.gitea.pull.merged", { + await emit("pull.merged", { owner, repo, number, diff --git a/modules/grafana/index.ts b/modules/grafana/index.ts index 03e8814..6e174b2 100644 --- a/modules/grafana/index.ts +++ b/modules/grafana/index.ts @@ -40,7 +40,7 @@ async function pollAlerts(client: GrafanaClient): Promise { for (const key of now) { if (!firing.has(key)) { const a = byKey.get(key)!; - await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt }); + await emit("alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt }); } } } diff --git a/modules/grafana/module.json b/modules/grafana/module.json index 8ae941f..fe877ff 100644 --- a/modules/grafana/module.json +++ b/modules/grafana/module.json @@ -2,7 +2,7 @@ "module": "grafana", "version": "1", "emits": [ - "module.grafana.alert.firing" + "alert.firing" ], "own-secrets": { "admin": "/var/lib/grafana-module/admin.secret", diff --git a/modules/home-assistant/index.ts b/modules/home-assistant/index.ts index aba7949..d59a5c2 100644 --- a/modules/home-assistant/index.ts +++ b/modules/home-assistant/index.ts @@ -44,7 +44,7 @@ async function pollStates(): Promise { for (const s of states) { const prev = lastState.get(s.entity_id); if (primed && prev !== undefined && prev !== s.state) { - await emit("module.home-assistant.state.changed", { + await emit("state.changed", { entity: s.entity_id, name: nameOf(s), from: prev, diff --git a/modules/home-assistant/module.json b/modules/home-assistant/module.json index 4b8bcb9..e860c1f 100644 --- a/modules/home-assistant/module.json +++ b/modules/home-assistant/module.json @@ -6,7 +6,7 @@ "container-runtime" ], "emits": [ - "module.home-assistant.state.changed" + "state.changed" ], "own-secrets": { "broker": "/var/lib/mesh/home-assistant/broker", diff --git a/modules/icecast/index.ts b/modules/icecast/index.ts index 62523c3..2e121f0 100644 --- a/modules/icecast/index.ts +++ b/modules/icecast/index.ts @@ -23,7 +23,7 @@ async function pollMounts(): Promise { if (primed) { for (const [mount, m] of now) { if (!live.has(mount)) { - await emit("module.icecast.stream.started", { + await emit("stream.started", { mount, name: m.name, description: m.description, @@ -33,7 +33,7 @@ async function pollMounts(): Promise { } for (const [mount, m] of live) { if (!now.has(mount)) { - await emit("module.icecast.stream.stopped", { mount, name: m.name }); + await emit("stream.stopped", { mount, name: m.name }); } } } diff --git a/modules/icecast/module.json b/modules/icecast/module.json index cdbef64..51c6296 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -5,8 +5,8 @@ "container-runtime" ], "emits": [ - "module.icecast.stream.started", - "module.icecast.stream.stopped" + "stream.started", + "stream.stopped" ], "own-secrets": { "broker": "/var/lib/mesh/icecast/broker" diff --git a/modules/keycloak/index.ts b/modules/keycloak/index.ts index 451464c..dc25c8a 100644 --- a/modules/keycloak/index.ts +++ b/modules/keycloak/index.ts @@ -28,17 +28,17 @@ async function announce(type: string, body: Record): Promise - announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }), + announce("user.created", { realm, username, ...(email ? { email } : {}) }), userDeleted: (realm: string, userId: string) => - announce("module.keycloak.user.deleted", { realm, userId }), + announce("user.deleted", { realm, userId }), passwordReset: (realm: string, userId: string) => - announce("module.keycloak.password.reset", { realm, userId }), + announce("password.reset", { realm, userId }), clientCreated: (realm: string, clientId: string, name?: string) => - announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }), + announce("client.created", { realm, clientId, ...(name ? { name } : {}) }), groupCreated: (realm: string, name: string) => - announce("module.keycloak.group.created", { realm, name }), + announce("group.created", { realm, name }), roleCreated: (realm: string, name: string) => - announce("module.keycloak.role.created", { realm, name }), + announce("role.created", { realm, name }), }; console.log("[keycloak] event surface ready — identity, client, group and role changes are announced"); diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 44e0d44..ae6f921 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -25,12 +25,12 @@ "container-runtime" ], "emits": [ - "module.keycloak.user.created", - "module.keycloak.user.deleted", - "module.keycloak.password.reset", - "module.keycloak.client.created", - "module.keycloak.group.created", - "module.keycloak.role.created" + "user.created", + "user.deleted", + "password.reset", + "client.created", + "group.created", + "role.created" ], "listens": [ { diff --git a/modules/lavinmq/index.ts b/modules/lavinmq/index.ts index d440014..76f5128 100644 --- a/modules/lavinmq/index.ts +++ b/modules/lavinmq/index.ts @@ -14,11 +14,11 @@ interface AmqpEvent { vhost?: string; } -await on("module.lavinmq.amqp.provisioned", async (e) => { +await on("amqp.provisioned", async (e) => { console.log(`[lavinmq] broker provisioned for ${e.body.consumer ?? "?"} (user ${e.body.user}, vhost ${e.body.vhost})`); }); -await on("module.lavinmq.amqp.deprovisioned", async (e) => { +await on("amqp.deprovisioned", async (e) => { console.log(`[lavinmq] broker deprovisioned (user ${e.body.user})`); }); diff --git a/modules/lavinmq/module.json b/modules/lavinmq/module.json index 2f40b65..31f76e4 100644 --- a/modules/lavinmq/module.json +++ b/modules/lavinmq/module.json @@ -11,12 +11,12 @@ "container-runtime" ], "emits": [ - "module.lavinmq.amqp.provisioned", - "module.lavinmq.amqp.deprovisioned" + "amqp.provisioned", + "amqp.deprovisioned" ], "consumes": [ - "module.lavinmq.amqp.provisioned", - "module.lavinmq.amqp.deprovisioned" + "lavinmq.amqp.provisioned", + "lavinmq.amqp.deprovisioned" ], "serves": { "amqp": { diff --git a/modules/lavinmq/provisioner/index.ts b/modules/lavinmq/provisioner/index.ts index 7cea27f..acd1d97 100644 --- a/modules/lavinmq/provisioner/index.ts +++ b/modules/lavinmq/provisioner/index.ts @@ -37,7 +37,7 @@ runProvisioner("amqp", { // The vhost and the user share the consumer's login, so one cannot reach another's broker. await lavinmq.waitReady(); await lavinmq.createConsumer(p.as, p.password); - await announce("module.lavinmq.amqp.provisioned", { + await announce("amqp.provisioned", { consumer: p.consumer ?? "", user: p.as, vhost: p.as, @@ -46,7 +46,7 @@ runProvisioner("amqp", { async remove(p: { as: string }): Promise { await lavinmq.removeConsumer(p.as); - await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as }); + await announce("amqp.deprovisioned", { user: p.as, vhost: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/lidarr/index.ts b/modules/lidarr/index.ts index f286e2c..a19c88b 100644 --- a/modules/lidarr/index.ts +++ b/modules/lidarr/index.ts @@ -40,12 +40,12 @@ async function pollQueue(lidarr: LidarrClient): Promise { if (primed) { // Entered the queue since last look — Lidarr grabbed a release. for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("module.lidarr.album.grabbed", { title: item.title, status: item.status }); + if (!inQueue.has(id)) await emit("album.grabbed", { title: item.title, status: item.status }); } // Left the queue — imported and done, unless it was last seen failing. for (const [id, item] of inQueue) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("module.lidarr.download.completed", { title: item.title }); + await emit("download.completed", { title: item.title }); } } } diff --git a/modules/lidarr/module.json b/modules/lidarr/module.json index ebb2b0b..6d424b6 100644 --- a/modules/lidarr/module.json +++ b/modules/lidarr/module.json @@ -5,8 +5,8 @@ "container-runtime" ], "emits": [ - "module.lidarr.album.grabbed", - "module.lidarr.download.completed" + "album.grabbed", + "download.completed" ], "consumes": [], "own-secrets": { diff --git a/modules/mailu/index.ts b/modules/mailu/index.ts index 6c891b0..b756e5f 100644 --- a/modules/mailu/index.ts +++ b/modules/mailu/index.ts @@ -36,8 +36,8 @@ function watcher(created: string, deleted: string): (keys: string[]) => Promise< }; } -const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted"); -const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted"); +const watchUsers = watcher("user.created", "user.deleted"); +const watchAliases = watcher("alias.created", "alias.deleted"); async function pollUsers(): Promise { await watchUsers((await mailu.listUsers()).map((u) => u.email)); diff --git a/modules/mailu/module.json b/modules/mailu/module.json index 7ee64b3..d4808fb 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -53,10 +53,10 @@ } }, "emits": [ - "module.mailu.user.created", - "module.mailu.user.deleted", - "module.mailu.alias.created", - "module.mailu.alias.deleted" + "user.created", + "user.deleted", + "alias.created", + "alias.deleted" ], "listens": [ { diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index 4181fcb..bb68529 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -47,7 +47,7 @@ interface Built { replay?: boolean; } -await on("module.builder.built", async (event) => { +await on("builder.built", async (event) => { const body = event.body as Built; if (!body.module || !body.commit) { // Said rather than dropped: a build that announced itself without saying what it built is a @@ -69,7 +69,7 @@ await on("module.builder.built", async (event) => { // 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("registered", { module: body.module, commit: body.commit, upgraded, }); @@ -77,13 +77,13 @@ await on("module.builder.built", async (event) => { // through modules that did not change, forever (ADR 0072). if (!upgraded) return; - await emit("module.mesh-catalog.upgraded", { + await emit("upgraded", { module: body.module, commit: body.commit, previous, }); // What can be built now — stale, and waiting on nothing that is itself stale. for (const next of await graph.buildable()) { - await emit("module.mesh-catalog.rebuild-needed", { + await emit("rebuild-needed", { module: next.module, builtAt: next.commit, because: next.because, @@ -101,4 +101,4 @@ await on("module.builder.built", async (event) => { // 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", {}); +await emit("catching-up", {}); diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 4c52d90..e940df4 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -29,12 +29,12 @@ "broker": "/var/lib/mesh/mesh-catalog/broker" }, "consumes": [ - "module.builder.built" + "builder.built" ], "emits": [ - "module.mesh-catalog.registered", - "module.mesh-catalog.upgraded", - "module.mesh-catalog.rebuild-needed" + "registered", + "upgraded", + "rebuild-needed" ], "resources": [ { diff --git a/modules/mesh-vault/index.ts b/modules/mesh-vault/index.ts index 6ef05e3..540a780 100644 --- a/modules/mesh-vault/index.ts +++ b/modules/mesh-vault/index.ts @@ -16,15 +16,15 @@ interface SecretEvent { rotations?: number; } -await on("module.mesh-vault.secret.provisioned", async (e) => { +await on("secret.provisioned", async (e) => { console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`); }); -await on("module.mesh-vault.secret.rotated", async (e) => { +await on("secret.rotated", async (e) => { console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`); }); -await on("module.mesh-vault.secret.deprovisioned", async (e) => { +await on("secret.deprovisioned", async (e) => { console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`); }); diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index a875fbe..70577e5 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -11,14 +11,14 @@ "container-runtime" ], "emits": [ - "module.mesh-vault.secret.provisioned", - "module.mesh-vault.secret.rotated", - "module.mesh-vault.secret.deprovisioned" + "secret.provisioned", + "secret.rotated", + "secret.deprovisioned" ], "consumes": [ - "module.mesh-vault.secret.provisioned", - "module.mesh-vault.secret.rotated", - "module.mesh-vault.secret.deprovisioned" + "mesh-vault.secret.provisioned", + "mesh-vault.secret.rotated", + "mesh-vault.secret.deprovisioned" ], "receives": { "secret": "/var/lib/mesh-vault/grants/mesh.json" diff --git a/modules/mesh-vault/provisioner/index.ts b/modules/mesh-vault/provisioner/index.ts index 010d78e..ad0872c 100644 --- a/modules/mesh-vault/provisioner/index.ts +++ b/modules/mesh-vault/provisioner/index.ts @@ -43,6 +43,6 @@ runProvisioner("secret", { async remove(p: { as: string }): Promise { if (!ledger.withdraw(p.as)) return; console.log(`[mesh-vault] withdrawn: ${p.as}`); - await announce("module.mesh-vault.secret.deprovisioned", { as: p.as }); + await announce("secret.deprovisioned", { as: p.as }); }, }); diff --git a/modules/minio/module.json b/modules/minio/module.json index 6e005b9..56c933d 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -26,8 +26,8 @@ "container-runtime" ], "emits": [ - "module.minio.bucket.created", - "module.minio.bucket.removed" + "bucket.created", + "bucket.removed" ], "listens": [ { diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index 5e15c01..4c34201 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -30,7 +30,7 @@ runProvisioner("s3-bucket", { try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } await minio.createAccessKey(bucket, accessKeyId, p.password); - await announce("module.minio.bucket.created", { + await announce("bucket.created", { bucket, consumer: p.consumer ?? "", accessKey: accessKeyId, @@ -51,7 +51,7 @@ runProvisioner("s3-bucket", { console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); } - await announce("module.minio.bucket.removed", { bucket, accessKey: p.as }); + await announce("bucket.removed", { bucket, accessKey: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as diff --git a/modules/model-usage/index.ts b/modules/model-usage/index.ts index 2bcf812..bdde20e 100644 --- a/modules/model-usage/index.ts +++ b/modules/model-usage/index.ts @@ -22,7 +22,7 @@ const store = UsageStore.fromEnv(); // to reach the provider over the overlay would block the very apply that brings the overlay up. await store.migrate(); -await on("module.*.usage.*", async (event) => { +await on("*.usage.*", async (event) => { const body = event.body as { rows?: UsageRow[]; raw?: unknown }; for (const row of body.rows ?? []) { try { diff --git a/modules/model-usage/module.json b/modules/model-usage/module.json index 96fc842..60ec754 100644 --- a/modules/model-usage/module.json +++ b/modules/model-usage/module.json @@ -20,7 +20,7 @@ "postgres-database": "/var/lib/model-usage/database.secret" }, "consumes": [ - "module.*.usage.*" + "*.usage.*" ], "own-secrets": { "broker": "/var/lib/mesh/model-usage/broker" diff --git a/modules/mongodb/index.ts b/modules/mongodb/index.ts index c3bd40b..ebca8d5 100644 --- a/modules/mongodb/index.ts +++ b/modules/mongodb/index.ts @@ -14,11 +14,11 @@ interface DatabaseEvent { user?: string; } -await on("module.mongodb.database.provisioned", async (e) => { +await on("database.provisioned", async (e) => { console.log(`[mongodb] database provisioned for ${e.body.consumer} (db ${e.body.database})`); }); -await on("module.mongodb.database.deprovisioned", async (e) => { +await on("database.deprovisioned", async (e) => { console.log(`[mongodb] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`); }); diff --git a/modules/mongodb/module.json b/modules/mongodb/module.json index f831eb4..f93fc34 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -11,12 +11,12 @@ "container-runtime" ], "emits": [ - "module.mongodb.database.provisioned", - "module.mongodb.database.deprovisioned" + "database.provisioned", + "database.deprovisioned" ], "consumes": [ - "module.mongodb.database.provisioned", - "module.mongodb.database.deprovisioned" + "mongodb.database.provisioned", + "mongodb.database.deprovisioned" ], "listens": [ { diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index e532b62..aaa279d 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -34,7 +34,7 @@ runProvisioner("mongodb-database", { // Database and owning user share the consumer's login, so the consumer owns exactly its own. const database = p.as; await mongo.createDatabaseAndUser(database, p.as, p.password); - await announce("module.mongodb.database.provisioned", { + await announce("database.provisioned", { consumer: p.consumer ?? "", database, user: p.as, @@ -43,7 +43,7 @@ runProvisioner("mongodb-database", { async remove(p: { as: string }): Promise { await mongo.dropDatabaseAndUser(p.as, p.as); - await announce("module.mongodb.database.deprovisioned", { database: p.as }); + await announce("database.deprovisioned", { database: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/mosquitto/index.ts b/modules/mosquitto/index.ts index d91356c..af26f4b 100644 --- a/modules/mosquitto/index.ts +++ b/modules/mosquitto/index.ts @@ -14,11 +14,11 @@ interface TopicEvent { topicPrefix?: string; } -await on("module.mosquitto.topic.provisioned", async (e) => { +await on("topic.provisioned", async (e) => { console.log(`[mosquitto] topic provisioned for ${e.body.consumer} (client ${e.body.username})`); }); -await on("module.mosquitto.topic.deprovisioned", async (e) => { +await on("topic.deprovisioned", async (e) => { console.log(`[mosquitto] topic deprovisioned for ${e.body.consumer} (client ${e.body.username})`); }); diff --git a/modules/mosquitto/module.json b/modules/mosquitto/module.json index 774231f..267c24f 100644 --- a/modules/mosquitto/module.json +++ b/modules/mosquitto/module.json @@ -12,12 +12,12 @@ "container-runtime" ], "emits": [ - "module.mosquitto.topic.provisioned", - "module.mosquitto.topic.deprovisioned" + "topic.provisioned", + "topic.deprovisioned" ], "consumes": [ - "module.mosquitto.topic.provisioned", - "module.mosquitto.topic.deprovisioned" + "mosquitto.topic.provisioned", + "mosquitto.topic.deprovisioned" ], "serves": { "mqtt-topic": {} diff --git a/modules/mosquitto/provisioner/index.ts b/modules/mosquitto/provisioner/index.ts index 60f9ed6..8a5fa08 100644 --- a/modules/mosquitto/provisioner/index.ts +++ b/modules/mosquitto/provisioner/index.ts @@ -32,7 +32,7 @@ runProvisioner("mqtt-topic", { // The topic subtree is scoped to the consumer's own login, so one cannot read another's topics. const topicPrefix = p.as; await mosquitto.createScopedClient(p.as, p.password, topicPrefix); - await announce("module.mosquitto.topic.provisioned", { + await announce("topic.provisioned", { consumer: p.consumer ?? "", username: p.as, topicPrefix, @@ -41,7 +41,7 @@ runProvisioner("mqtt-topic", { async remove(p: { as: string }): Promise { await mosquitto.deleteScopedClient(p.as); - await announce("module.mosquitto.topic.deprovisioned", { username: p.as }); + await announce("topic.deprovisioned", { username: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/mssql/index.ts b/modules/mssql/index.ts index 873b569..0232587 100644 --- a/modules/mssql/index.ts +++ b/modules/mssql/index.ts @@ -14,11 +14,11 @@ interface DatabaseEvent { user?: string; } -await on("module.mssql.database.provisioned", async (e) => { +await on("database.provisioned", async (e) => { console.log(`[mssql] database provisioned for ${e.body.consumer} (db ${e.body.database})`); }); -await on("module.mssql.database.deprovisioned", async (e) => { +await on("database.deprovisioned", async (e) => { console.log(`[mssql] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`); }); diff --git a/modules/mssql/module.json b/modules/mssql/module.json index 5fa2b01..2de6678 100644 --- a/modules/mssql/module.json +++ b/modules/mssql/module.json @@ -11,12 +11,12 @@ "container-runtime" ], "emits": [ - "module.mssql.database.provisioned", - "module.mssql.database.deprovisioned" + "database.provisioned", + "database.deprovisioned" ], "consumes": [ - "module.mssql.database.provisioned", - "module.mssql.database.deprovisioned" + "mssql.database.provisioned", + "mssql.database.deprovisioned" ], "listens": [ { diff --git a/modules/mssql/provisioner/index.ts b/modules/mssql/provisioner/index.ts index 16d0907..9ef31aa 100644 --- a/modules/mssql/provisioner/index.ts +++ b/modules/mssql/provisioner/index.ts @@ -33,7 +33,7 @@ runProvisioner("mssql-database", { // Database, login and user share the consumer's name, so the consumer owns exactly its own. const database = p.as; await mssql.createDatabaseAndLogin(database, p.as, p.password); - await announce("module.mssql.database.provisioned", { + await announce("database.provisioned", { consumer: p.consumer ?? "", database, user: p.as, @@ -42,7 +42,7 @@ runProvisioner("mssql-database", { async remove(p: { as: string }): Promise { await mssql.dropDatabaseAndLogin(p.as, p.as); - await announce("module.mssql.database.deprovisioned", { database: p.as }); + await announce("database.deprovisioned", { database: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/nextcloud/index.ts b/modules/nextcloud/index.ts index aeaf61d..9fcf6d5 100644 --- a/modules/nextcloud/index.ts +++ b/modules/nextcloud/index.ts @@ -26,7 +26,7 @@ async function pollUsers(client: NextcloudClient): Promise { const users = client.listUsers(); for (const u of users) { if (knownUsers.has(u.uid)) continue; - if (usersPrimed) await emit("module.nextcloud.user.created", { uid: u.uid, displayName: u.displayName }); + if (usersPrimed) await emit("user.created", { uid: u.uid, displayName: u.displayName }); knownUsers.add(u.uid); } usersPrimed = true; @@ -38,7 +38,7 @@ async function pollShares(client: NextcloudClient): Promise { const shares = await client.listShares(); for (const s of shares) { if (knownShares.has(s.id)) continue; - if (sharesPrimed) await emit("module.nextcloud.share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner }); + if (sharesPrimed) await emit("share.created", { id: s.id, path: s.path, shareType: s.shareType, shareWith: s.shareWith, owner: s.owner }); knownShares.add(s.id); } sharesPrimed = true; diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 7df1e84..fc82b7b 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -26,8 +26,8 @@ "s3-bucket": "${dir:state}/store.secret" }, "emits": [ - "module.nextcloud.user.created", - "module.nextcloud.share.created" + "user.created", + "share.created" ], "own-secrets": { "admin": "${dir:state}/admin.secret", diff --git a/modules/nodered/module.json b/modules/nodered/module.json index 73217b4..cd8e801 100644 --- a/modules/nodered/module.json +++ b/modules/nodered/module.json @@ -2,7 +2,7 @@ "module": "nodered", "version": "1", "emits": [ - "module.nodered.flows.deployed" + "flows.deployed" ], "own-secrets": { "broker": "/var/lib/mesh/nodered/broker" diff --git a/modules/nodered/tools/index.ts b/modules/nodered/tools/index.ts index e19b6dc..4a7b45d 100644 --- a/modules/nodered/tools/index.ts +++ b/modules/nodered/tools/index.ts @@ -43,7 +43,7 @@ export function getNodeRedTools(nodered: NodeRedClient): ToolDefinition[] { const result = await nodered.deployFlows(flows, type); // Best-effort announcement — a deploy must not fail because the broker is unbound here. try { - await emit("module.nodered.flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type }); + await emit("flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type }); } catch (err) { console.error(`[nodered] deployed but could not emit: ${err}`); } diff --git a/modules/nzbget/index.ts b/modules/nzbget/index.ts index 16bfee3..fa14e0b 100644 --- a/modules/nzbget/index.ts +++ b/modules/nzbget/index.ts @@ -27,7 +27,7 @@ async function pollQueue(): Promise { if (queuePrimed) { for (const item of items) { if (!inQueue.has(item.id)) { - await emit("module.nzbget.download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB }); + await emit("download.added", { name: item.name, category: item.category, sizeMB: item.sizeMB }); } } } @@ -45,7 +45,7 @@ async function pollHistory(): Promise { // A newly-appeared history entry is a completion only if it actually succeeded; a failure or // a manual delete lands in history too, and neither is a "download.completed". if (historyPrimed && item.success) { - await emit("module.nzbget.download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB }); + await emit("download.completed", { name: item.name, category: item.category, sizeMB: item.sizeMB }); } seenHistory.add(item.id); } diff --git a/modules/nzbget/module.json b/modules/nzbget/module.json index 94f0f87..87f2cf6 100644 --- a/modules/nzbget/module.json +++ b/modules/nzbget/module.json @@ -5,8 +5,8 @@ "container-runtime" ], "emits": [ - "module.nzbget.download.added", - "module.nzbget.download.completed" + "download.added", + "download.completed" ], "consumes": [], "own-secrets": { diff --git a/modules/ombi/index.ts b/modules/ombi/index.ts index 0c38b0f..479c08c 100644 --- a/modules/ombi/index.ts +++ b/modules/ombi/index.ts @@ -31,7 +31,7 @@ async function pollRequests(): Promise { const key = keyOf(r); const known = approvedState.has(key); if (primed && !known) { - await emit("module.ombi.request.created", { + await emit("request.created", { kind: r.kind, id: r.id, title: r.title, @@ -41,7 +41,7 @@ async function pollRequests(): Promise { } // Approval: the flag went from false to true for a request we already knew about. if (primed && known && r.approved && approvedState.get(key) === false) { - await emit("module.ombi.request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId }); + await emit("request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId }); } approvedState.set(key, r.approved); } diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 27536d7..4f73efa 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -5,8 +5,8 @@ "container-runtime" ], "emits": [ - "module.ombi.request.created", - "module.ombi.request.approved" + "request.created", + "request.approved" ], "own-secrets": { "broker": "/var/lib/mesh/ombi/broker", diff --git a/modules/photos/index.ts b/modules/photos/index.ts index b2f457a..747b171 100644 --- a/modules/photos/index.ts +++ b/modules/photos/index.ts @@ -20,7 +20,7 @@ async function pollRecent(): Promise { for (const asset of items) { if (!seen.has(asset.id)) { if (primed) { - await emit("module.photos.item.added", { + await emit("item.added", { id: asset.id, fileName: asset.fileName, kind: asset.type, diff --git a/modules/photos/module.json b/modules/photos/module.json index b804f99..235ddbc 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -4,6 +4,9 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "item.added" + ], "requires": [ "s3-bucket", "mongodb-database", diff --git a/modules/plex/index.ts b/modules/plex/index.ts index d4d48de..66aebdb 100644 --- a/modules/plex/index.ts +++ b/modules/plex/index.ts @@ -24,10 +24,10 @@ async function pollSessions(): Promise { const now = new Map(sessions.map((s) => [s.key, s])); if (playbackPrimed) { for (const [key, s] of now) { - if (!active.has(key)) await emit("module.plex.playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type }); + if (!active.has(key)) await emit("playback.started", { title: s.title, user: s.user, player: s.player, kind: s.type }); } for (const [key, s] of active) { - if (!now.has(key)) await emit("module.plex.playback.stopped", { title: s.title, user: s.user, player: s.player }); + if (!now.has(key)) await emit("playback.stopped", { title: s.title, user: s.user, player: s.player }); } } active.clear(); @@ -44,7 +44,7 @@ async function pollRecent(): Promise { for (const item of items) { const id = `${item.title}@${item.addedAt ?? ""}`; if (!seen.has(id)) { - if (itemsPrimed) await emit("module.plex.item.added", item); + if (itemsPrimed) await emit("item.added", item); seen.add(id); } } @@ -53,7 +53,7 @@ async function pollRecent(): Promise { // A downloader finished somewhere on the mesh: rescan, so what it fetched becomes a visible item // rather than a file Plex has not noticed. Idempotent — a rescan too many costs a little disk I/O. -await on("module.*.download.completed", async () => { +await on("*.download.completed", async () => { await plex.refreshAll(); }); diff --git a/modules/plex/module.json b/modules/plex/module.json index b5c56f0..ae2c1bd 100644 --- a/modules/plex/module.json +++ b/modules/plex/module.json @@ -5,12 +5,12 @@ "container-runtime" ], "emits": [ - "module.plex.playback.started", - "module.plex.playback.stopped", - "module.plex.item.added" + "playback.started", + "playback.stopped", + "item.added" ], "consumes": [ - "module.*.download.completed" + "*.download.completed" ], "own-secrets": { "broker": "/var/lib/mesh/plex/broker", diff --git a/modules/postgres/index.ts b/modules/postgres/index.ts index 61b1cff..5129f4e 100644 --- a/modules/postgres/index.ts +++ b/modules/postgres/index.ts @@ -14,11 +14,11 @@ interface DatabaseEvent { user?: string; } -await on("module.postgres.database.provisioned", async (e) => { +await on("database.provisioned", async (e) => { console.log(`[postgres] database provisioned for ${e.body.consumer} (db ${e.body.database})`); }); -await on("module.postgres.database.deprovisioned", async (e) => { +await on("database.deprovisioned", async (e) => { console.log(`[postgres] database deprovisioned for ${e.body.consumer} (db ${e.body.database})`); }); diff --git a/modules/postgres/module.json b/modules/postgres/module.json index b543e5f..68f3259 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -17,12 +17,12 @@ "container-runtime" ], "emits": [ - "module.postgres.database.provisioned", - "module.postgres.database.deprovisioned" + "database.provisioned", + "database.deprovisioned" ], "consumes": [ - "module.postgres.database.provisioned", - "module.postgres.database.deprovisioned" + "postgres.database.provisioned", + "postgres.database.deprovisioned" ], "listens": [ { diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 825cd98..6c3916f 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -34,7 +34,7 @@ runProvisioner("postgres-database", { // Database and owning role share the consumer's login, so the consumer owns exactly its own. const database = p.as; await postgres.createDatabaseAndRole(database, p.as, p.password); - await announce("module.postgres.database.provisioned", { + await announce("database.provisioned", { consumer: p.consumer ?? "", database, user: p.as, @@ -43,7 +43,7 @@ runProvisioner("postgres-database", { async remove(p: { as: string }): Promise { await postgres.dropDatabaseAndRole(p.as, p.as); - await announce("module.postgres.database.deprovisioned", { database: p.as }); + await announce("database.deprovisioned", { database: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/qbittorrent/index.ts b/modules/qbittorrent/index.ts index ae737fb..5f136d1 100644 --- a/modules/qbittorrent/index.ts +++ b/modules/qbittorrent/index.ts @@ -30,9 +30,9 @@ async function pollTorrents(): Promise { for (const [hash, t] of now) { const before = progressByHash.get(hash); if (before === undefined) { - await emit("module.qbittorrent.download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); + await emit("download.added", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); } else if (before < 1 && t.progress >= 1) { - await emit("module.qbittorrent.download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); + await emit("download.completed", { name: t.name, category: t.category, sizeBytes: t.sizeBytes }); } } } diff --git a/modules/qbittorrent/module.json b/modules/qbittorrent/module.json index 1820649..0cea981 100644 --- a/modules/qbittorrent/module.json +++ b/modules/qbittorrent/module.json @@ -6,8 +6,8 @@ "container-runtime" ], "emits": [ - "module.qbittorrent.download.added", - "module.qbittorrent.download.completed" + "download.added", + "download.completed" ], "consumes": [], "own-secrets": { diff --git a/modules/radarr/index.ts b/modules/radarr/index.ts index 03c8fc2..801e727 100644 --- a/modules/radarr/index.ts +++ b/modules/radarr/index.ts @@ -40,12 +40,12 @@ async function pollQueue(radarr: RadarrClient): Promise { if (primed) { // Entered the queue since last look — Radarr grabbed a release. for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("module.radarr.movie.grabbed", { title: item.title, status: item.status }); + if (!inQueue.has(id)) await emit("movie.grabbed", { title: item.title, status: item.status }); } // Left the queue — imported and done, unless it was last seen failing. for (const [id, item] of inQueue) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("module.radarr.download.completed", { title: item.title }); + await emit("download.completed", { title: item.title }); } } } diff --git a/modules/radarr/module.json b/modules/radarr/module.json index 4b39284..76701d4 100644 --- a/modules/radarr/module.json +++ b/modules/radarr/module.json @@ -5,8 +5,8 @@ "container-runtime" ], "emits": [ - "module.radarr.movie.grabbed", - "module.radarr.download.completed" + "movie.grabbed", + "download.completed" ], "consumes": [], "own-secrets": { diff --git a/modules/redis/index.ts b/modules/redis/index.ts index d15c8ac..c4a42ea 100644 --- a/modules/redis/index.ts +++ b/modules/redis/index.ts @@ -14,11 +14,11 @@ interface CacheEvent { keyspacePrefix?: string; } -await on("module.redis.cache.provisioned", async (e) => { +await on("cache.provisioned", async (e) => { console.log(`[redis] cache provisioned for ${e.body.consumer} (user ${e.body.username})`); }); -await on("module.redis.cache.deprovisioned", async (e) => { +await on("cache.deprovisioned", async (e) => { console.log(`[redis] cache deprovisioned for ${e.body.consumer} (user ${e.body.username})`); }); diff --git a/modules/redis/module.json b/modules/redis/module.json index 907db43..c4d77d0 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -14,12 +14,12 @@ "container-runtime" ], "emits": [ - "module.redis.cache.provisioned", - "module.redis.cache.deprovisioned" + "cache.provisioned", + "cache.deprovisioned" ], "consumes": [ - "module.redis.cache.provisioned", - "module.redis.cache.deprovisioned" + "redis.cache.provisioned", + "redis.cache.deprovisioned" ], "serves": { "redis-cache": { diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index 84aea66..0e51476 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -32,7 +32,7 @@ runProvisioner("redis-cache", { // The keyspace is scoped to the consumer's own login, so one cannot read another's keys. const keyspacePrefix = p.as; await redis.createAclUser(p.as, p.password, keyspacePrefix); - await announce("module.redis.cache.provisioned", { + await announce("cache.provisioned", { consumer: p.consumer ?? "", username: p.as, keyspacePrefix, @@ -41,7 +41,7 @@ runProvisioner("redis-cache", { async remove(p: { as: string }): Promise { await redis.deleteAclUser(p.as); - await announce("module.redis.cache.deprovisioned", { username: p.as }); + await announce("cache.deprovisioned", { username: p.as }); }, // This server keeps its ACL users in memory only, so a restart of it forgets every consumer while diff --git a/modules/showcase/index.ts b/modules/showcase/index.ts index 02a6ad2..8cd83fe 100644 --- a/modules/showcase/index.ts +++ b/modules/showcase/index.ts @@ -5,8 +5,8 @@ // 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) => { +await on<{ who?: string }>("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" }); + await emit("acknowledged", { who: event.body.who ?? "somebody" }); }); diff --git a/modules/showcase/module.json b/modules/showcase/module.json index a72c2e3..8d9412f 100644 --- a/modules/showcase/module.json +++ b/modules/showcase/module.json @@ -35,10 +35,11 @@ } ], "emits": [ - "module.showcase.acknowledged" + "greeted", + "acknowledged" ], "consumes": [ - "module.showcase.greeted" + "showcase.greeted" ], "listens": [ { diff --git a/modules/sonarr/index.ts b/modules/sonarr/index.ts index 67c7b6d..e062ddf 100644 --- a/modules/sonarr/index.ts +++ b/modules/sonarr/index.ts @@ -40,12 +40,12 @@ async function pollQueue(sonarr: SonarrClient): Promise { if (primed) { // Entered the queue since last look — Sonarr grabbed a release. for (const [id, item] of now) { - if (!inQueue.has(id)) await emit("module.sonarr.episode.grabbed", { title: item.title, status: item.status }); + if (!inQueue.has(id)) await emit("episode.grabbed", { title: item.title, status: item.status }); } // Left the queue — imported and done, unless it was last seen failing. for (const [id, item] of inQueue) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { - await emit("module.sonarr.download.completed", { title: item.title }); + await emit("download.completed", { title: item.title }); } } } diff --git a/modules/sonarr/module.json b/modules/sonarr/module.json index 32ecce9..1806886 100644 --- a/modules/sonarr/module.json +++ b/modules/sonarr/module.json @@ -5,8 +5,8 @@ "container-runtime" ], "emits": [ - "module.sonarr.episode.grabbed", - "module.sonarr.download.completed" + "episode.grabbed", + "download.completed" ], "consumes": [], "own-secrets": { diff --git a/modules/tautulli/index.ts b/modules/tautulli/index.ts index afbfc21..40aba14 100644 --- a/modules/tautulli/index.ts +++ b/modules/tautulli/index.ts @@ -33,7 +33,7 @@ async function pollHistory(client: TautulliClient): Promise { } async function emitWatch(w: TautulliWatch): Promise { - await emit("module.tautulli.watch.recorded", { + await emit("watch.recorded", { title: w.title, user: w.user, mediaType: w.mediaType, watchedStatus: w.watchedStatus, percentComplete: w.percentComplete, at: w.date, }); diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index 8752c70..ce94a77 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -2,7 +2,7 @@ "module": "tautulli", "version": "1", "emits": [ - "module.tautulli.watch.recorded" + "watch.recorded" ], "own-secrets": { "broker": "/var/lib/mesh/tautulli/broker" diff --git a/modules/verdaccio/index.ts b/modules/verdaccio/index.ts index 0c23d9e..7a13da0 100644 --- a/modules/verdaccio/index.ts +++ b/modules/verdaccio/index.ts @@ -28,7 +28,7 @@ async function pollPackages(): Promise { const known = latest.get(pkg.name); if (known !== pkg.version) { // A name we have not seen, or a name whose latest version moved — both are a publish. - if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version }); + if (primed) await emit("package.published", { name: pkg.name, version: pkg.version }); latest.set(pkg.name, pkg.version); } } diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index 5a161d4..b9d12bf 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -6,7 +6,7 @@ "container-runtime" ], "emits": [ - "module.verdaccio.package.published" + "package.published" ], "own-secrets": { "broker": "/var/lib/mesh/verdaccio/broker" From b58a3b487de817d4c706252bd1e34305fa710384 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 15:37:21 +0200 Subject: [PATCH 7/7] A build's outcome belongs to the role, not to the module holding it ADR 0121. The builder declared `built` as its own event, so every consumer depended on which module happens to be the build machine today. It is the build-machine role's event now: the builder declares none of its own, and the catalogue listens for `mesh-build-machine.built` rather than `builder.built`. Nothing changes about what reaches the catalogue. What changes is that it survives the build machine being a different module, which is the whole reason the mesh has a word for a role. --- modules/builder/module.json | 3 --- modules/mesh-catalog/index.ts | 4 ++-- modules/mesh-catalog/module.json | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index de6ea36..a6cf4a1 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -20,9 +20,6 @@ "secrets": { "npm-package-registry": "/var/lib/mesh/builder/package-registry.secret" }, - "emits": [ - "built" - ], "own-secrets": { "broker": "/var/lib/mesh/builder/broker" }, diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index bb68529..0c703a2 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -1,6 +1,6 @@ // mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072). // -// The builder announces what it built; this places it in the graph and announces what that means. +// The build-machine role announces what it built; this places it in the graph and announces what that means. // The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so // it never has to interpret an artifact or ask this module anything. // @@ -47,7 +47,7 @@ interface Built { replay?: boolean; } -await on("builder.built", async (event) => { +await on("mesh-build-machine.built", async (event) => { const body = event.body as Built; if (!body.module || !body.commit) { // Said rather than dropped: a build that announced itself without saying what it built is a diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index e940df4..e396055 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -29,7 +29,7 @@ "broker": "/var/lib/mesh/mesh-catalog/broker" }, "consumes": [ - "builder.built" + "mesh-build-machine.built" ], "emits": [ "registered",