From 7172176b4cfdc1f54252ced0a461cfc9535673d1 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 18:30:19 +0200 Subject: [PATCH] swarm: extract the name guards, so the module just says what is forbidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her review: too much text, and an assertion helper instead of that much code where the file should just say "this option cannot contain that". `lib/name-guards.nix` gets `mustNotEqual` / `mustNotContain`; both call sites in swarm-otel become four fields each — the option, the names, the list, and the sentence explaining the failure. The message plumbing (find every offender, quote them, name the list) is one shape shared by both. Offenders are printed after their label rather than before it, because no English verb agrees with both one name and five. Converted the pre-existing equality assertion too. Leaving one hand-rolled beside one helper-built is the worse of the two outcomes, and it is the same guard. Verified by evaluating both guards directly rather than only parsing: clean rosters pass, `foo-agent`/`beehive` fail containment, `swarm` fails equality, and `forgeworks`/`operator-hq` still pass — the control that keeps the two lists from being merged. --- nix/host-modules/lib/name-guards.nix | 66 +++++++++++++++++++ nix/host-modules/swarm-otel.nix | 96 ++++++++-------------------- nix/reserved-hive-fragments.nix | 52 ++++----------- 3 files changed, 106 insertions(+), 108 deletions(-) create mode 100644 nix/host-modules/lib/name-guards.nix diff --git a/nix/host-modules/lib/name-guards.nix b/nix/host-modules/lib/name-guards.nix new file mode 100644 index 00000000..f9de705b --- /dev/null +++ b/nix/host-modules/lib/name-guards.nix @@ -0,0 +1,66 @@ +# Assertions about names an operator chooses, so a module states WHAT is +# forbidden and not how to phrase the refusal. +{ lib }: +let + # One shape for both guards. Reports EVERY offender, not the first: fixing + # one per rebuild is the slowest possible way to learn a rule. + # + # Offenders come after their label rather than before it, so one name and + # five read the same — no English verb agrees with both. + guard = + { + option, + names, + forbidden, + hit, + problem, + forbiddenLabel, + why, + }: + let + offenders = lib.filter (n: lib.any (f: hit f n) forbidden) names; + quote = xs: lib.concatMapStringsSep ", " (x: "'${x}'") xs; + in + { + assertion = offenders == [ ]; + message = '' + ${option} ${problem}: ${quote offenders} + ${forbiddenLabel}: ${quote forbidden} + + ${why} + ''; + }; +in +{ + # No name may BE one of `reserved`. + mustNotEqual = + { + option, + names, + reserved, + why, + }: + guard { + inherit option names why; + forbidden = reserved; + hit = f: n: f == n; + problem = "has reserved name(s)"; + forbiddenLabel = "Reserved"; + }; + + # No name may CONTAIN one of `fragments`. + mustNotContain = + { + option, + names, + fragments, + why, + }: + guard { + inherit option names why; + forbidden = fragments; + hit = lib.hasInfix; + problem = "has name(s) containing a reserved word"; + forbiddenLabel = "Forbidden as substrings"; + }; +} diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 99c2c37b..e4e40e17 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -72,14 +72,13 @@ let # would be an eval error rather than a silently missing guard. reservedOwners = import ../reserved-names.nix; - # The second, narrower blacklist: words no hive name may CONTAIN, because - # they are fixed segments of identifiers built FROM a hive name - # (`hive-`, `hive--agent`). Substring rather than equality is - # the whole point — a hive called `foo-agent` collides in the composed - # string while colliding with nothing in the list above. That file explains - # why it is not merged into this one. + # Words no hive name may CONTAIN, because identifiers are composed from a + # hive name. That file explains why it is not merged into the list above. reservedFragments = import ../reserved-hive-fragments.nix; + nameGuards = import ./lib/name-guards.nix { inherit lib; }; + hiveNames = lib.attrNames hyperhiveCfg.swarm.hives; + # A published target is declared as ONE url, because that url is also the # audience its token is minted for — but prometheus wants the same fact in # three fields. Split it here rather than asking a service to state it @@ -666,71 +665,30 @@ in Put it back, or give this module a different swarmTierName. ''; } - { - # A hive whose name is one this module claims for itself collides in - # the collector's component namespace, and `//` resolves it silently: - # the swarm tier's parts win, that hive's `metrics/` pipeline - # disappears, and its `resource/` stamp goes with it — so the - # hive still connects and pushes into nothing, unlabelled. - # - # Checked rather than documented for the same reason the port range - # is: the names are all known at evaluation time, and the runtime - # symptom is a hive that looks healthy and reports no metrics, with - # nothing in any log naming the cause. - assertion = !lib.any (h: lib.elem h reservedOwners) (lib.attrNames hyperhiveCfg.swarm.hives); - message = '' - services.hyperhive.swarm.hives contains ${ - lib.concatMapStringsSep ", " (h: "'${h}'") ( - lib.filter (h: lib.elem h reservedOwners) (lib.attrNames hyperhiveCfg.swarm.hives) - ) - }, which the swarm collector reserves for its own pipelines. - - The collector names components `/` and uses the hive - name as the owner, so such a hive would silently replace the - swarm tier's parts and lose its own — it would keep accepting - pushes into a pipeline that routes nowhere. - - Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}. + (nameGuards.mustNotEqual { + option = "services.hyperhive.swarm.hives"; + names = hiveNames; + reserved = reservedOwners; + why = '' + The collector names components `/` with the hive name + as owner, and `//` resolves the clash silently: the swarm tier's + parts win, that hive's pipeline and `hive=` stamp disappear, and it + keeps pushing into a route that goes nowhere. Rename the hive. ''; - } - { - # The substring half, and a genuinely different failure from the one - # above: these words are fixed SEGMENTS of identifiers composed from a - # hive name, so the collision is between two composed strings while - # the names themselves differ. `hive-foo-agent` is hive `foo-agent`'s - # own client id and hive `foo`'s AGENT client id at the same time; the - # queue's responder resolves it as the agents, so `foo-agent` silently - # receives an agent grant instead of its own — and a NATS denial - # reaches a client as a timeout, so nothing names the cause. - # - # Lives here rather than in `swarm-authelia.nix` (which composes those - # ids) because this module already owns the hive-roster check and a - # second site for "is this hive name legal" is how the two drift. - assertion = - !lib.any (h: lib.any (frag: lib.hasInfix frag h) reservedFragments) ( - lib.attrNames hyperhiveCfg.swarm.hives - ); - message = '' - services.hyperhive.swarm.hives contains ${ - lib.concatMapStringsSep ", " (h: "'${h}'") ( - lib.filter (h: lib.any (frag: lib.hasInfix frag h) reservedFragments) ( - lib.attrNames hyperhiveCfg.swarm.hives - ) - ) - }, which CONTAIN a word the swarm composes identifiers from. - - Hive-scoped identifiers are built by joining a hive name with these - words — `hive-` for a hive, `hive--agent` for its agent - containers — so a name containing one produces an identifier that is - also somebody else's. The failure is a wrong grant, not an error: - the client authenticates and receives permissions meant for another - principal. - - Rename the hive. Forbidden as substrings: ${ - lib.concatMapStringsSep ", " (n: "'${n}'") reservedFragments - }. + }) + (nameGuards.mustNotContain { + option = "services.hyperhive.swarm.hives"; + names = hiveNames; + fragments = reservedFragments; + why = '' + Hive-scoped identifiers are composed from a hive name — + `hive-`, `hive--agent` — so a name containing one of + these produces an identifier that is also somebody else's. The + failure is a wrong grant rather than an error: the client + authenticates and receives another principal's permissions, and a + NATS denial arrives as a timeout. Rename the hive. ''; - } + }) { # A published target that is not an `https://host/path` url. Without # this the split returns null and the failure surfaces as diff --git a/nix/reserved-hive-fragments.nix b/nix/reserved-hive-fragments.nix index ebf95a8e..f13b8e88 100644 --- a/nix/reserved-hive-fragments.nix +++ b/nix/reserved-hive-fragments.nix @@ -1,45 +1,19 @@ -# Words no HIVE name may CONTAIN — a substring blacklist, and the second half -# of `./reserved-names.nix`. +# Words no HIVE name may CONTAIN. The substring half of `./reserved-names.nix`. # -# Two files rather than one list, because the two differ in every dimension -# that matters and folding them would silently widen one of them: -# -# | | `reserved-names.nix` | this file | -# |---|---|---| -# | subject | agent AND hive names | hive names only | -# | match | equality | substring | -# | why | the name IS a value some component emits | the word is a fixed SEGMENT of a derived identifier | -# -# ⚠️ That last row is the whole argument. `hive-` and `hive--agent` -# are built by composing a hive name with these words, so a hive called -# `foo-agent` produces an id another hive's AGENTS also produce — the composed -# strings collide even though the names do not. Equality cannot see that; only -# containment can. -# -# ⇒ And it is why the sender words (`operator`, `system`, `forge`, …) are NOT -# here: those are compared against a whole name, never embedded in one, so -# applying containment to them would refuse `forgeworks` for no failure that -# exists. `reserved-names.nix`'s own rule — an entry must name a real failure, -# not a word that looked risky — governs this file too. -# -# Read by `host-modules/swarm-otel.nix`, which owns the hive-roster assertion. -# Deliberately NOT exported to the Rust side: `HIVE_RESERVED_NAMES` answers -# "may an AGENT be called this", which stays an equality question. +# Separate because the two differ in every dimension: subject (hive names only +# vs hive AND agent), matcher (substring vs equality), and admission rule (a +# fixed segment of a COMPOSED identifier vs a value some component emits). +# Folding them would widen the other one — containment over `forge` refuses +# `forgeworks`, which names no failure. [ - # `hive-` — every hive's own OIDC client id (`swarm-authelia.nix`'s - # `hiveClientPrefix`), and the string the queue's auth-callout responder - # strips to decide which hive a connection is. + # `hive-` — every hive's OIDC client id, and the string the queue's + # auth-callout responder strips to decide which hive a connection is. "hive" - # `hive--agent` — the client its agent containers present - # (`agentClientSuffix`). A hive named `foo-agent` mints exactly the id hive - # `foo`'s agents do, and the responder resolves it as the agents: that hive - # silently receives an agent grant instead of its own, and a NATS denial - # arrives as a timeout, so nothing names the cause. + # `hive--agent` — the client its agent containers present. A hive named + # `foo-agent` mints exactly the id hive `foo`'s agents do. "agent" - # Already in `reserved-names.nix` as an exact match, listed again here for - # the wider rule: the swarm collector names components `/` with - # the hive name as owner, and a name merely CONTAINING it is not a collision - # today — but `swarm` is the one word this swarm composes identifiers from - # everywhere, so the operator's rule covers it and the two files agree. + # Also an exact-match entry next door, for the collector's `/` + # components. Here because it is the word this swarm composes identifiers + # from everywhere. "swarm" ]