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 baf64d13..e4e40e17 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -72,6 +72,13 @@ let # would be an eval error rather than a silently missing guard. reservedOwners = import ../reserved-names.nix; + # 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 @@ -658,33 +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. ''; - } + }) + (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 new file mode 100644 index 00000000..f13b8e88 --- /dev/null +++ b/nix/reserved-hive-fragments.nix @@ -0,0 +1,19 @@ +# Words no HIVE name may CONTAIN. The substring half of `./reserved-names.nix`. +# +# 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 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. A hive named + # `foo-agent` mints exactly the id hive `foo`'s agents do. + "agent" + # 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" +] diff --git a/nix/reserved-names.nix b/nix/reserved-names.nix index f7fbb6b2..60ecefdf 100644 --- a/nix/reserved-names.nix +++ b/nix/reserved-names.nix @@ -25,6 +25,9 @@ # `from`/`to`, or a component name the collector builds pipelines from — not a # word that merely looked risky. A name in here that nothing emits is a refusal # with no failure behind it. +# +# ⚠️ Matched by EQUALITY. Words forbidden *inside* a hive name live in +# `./reserved-hive-fragments.nix` — read its header before merging the two. [ # ---- message-layer senders ------------------------------------------- # The human at the dashboard: a broker recipient (the T4LK box sends