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" ]