From b422367942810624caf7e4f351f8d9fd39211d6a Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:08:18 +0200 Subject: [PATCH 01/10] deploy: separate "what this host deploys" from swarm-wide truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `services.hyperhive.swarm.*` is meant to be identical on every host in a swarm — it describes the swarm, and every hive needs all of it to be a client. But it also carried the `enable` toggles, which are precisely the values that must differ per machine. The namespace that should be the same everywhere held the one thing that cannot be. Adds `services.hyperhive.deploy.*` for a host's deployment decisions, and moves the first of them (`swarm.grafana.enable` -> `deploy.grafana`) as the pattern for the rest. Flat and named for the thing deployed rather than grouped under a "swarm services" attribute: from the deploy side it does not matter what kind of thing each one is, and a grouping by service kind would re-encode the service-side taxonomy into a layer that does not care about it. Behaviour is unchanged. The move is a rename in the strict sense — same type, same meaning, new path — so `mkRenamedOptionModule` carries it and existing configs keep evaluating with one warning naming both paths. The renames live in the new module rather than the service modules, so the whole migration has a single home and a single file to delete when the deprecation window closes. --- docs/swarm/services.md | 2 +- nix/host-modules/default.nix | 1 + nix/host-modules/deploy.nix | 53 ++++++++++++++++++++ nix/host-modules/swarm-grafana.nix | 21 +++----- nix/host-modules/swarm-required-services.nix | 9 +++- nix/host-modules/swarm.nix | 3 +- 6 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 nix/host-modules/deploy.nix diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 56764f15..4668c9a3 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -75,7 +75,7 @@ empty. To run one without the other, set it directly: ```nix services.hyperhive.swarm.victoriametrics.enable = true; -services.hyperhive.swarm.grafana.enable = false; +services.hyperhive.deploy.grafana = false; ``` ⚠️ **This starts a database that grows for as long as the swarm runs.** diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index da0225c9..8c180f4f 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -12,6 +12,7 @@ { imports = [ ./hyperhive.nix + ./deploy.nix ./local-defaults.nix ./hive-c0re ./hive-ci.nix diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix new file mode 100644 index 00000000..7fdc5d73 --- /dev/null +++ b/nix/host-modules/deploy.nix @@ -0,0 +1,53 @@ +# "What does THIS host deploy?" +# +# Separated from `services.hyperhive.swarm.*` because those are two +# different kinds of fact and only one of them varies per machine: +# +# swarm.* — swarm-wide truth. The swarm's name, domain, hives, peers, +# CA, and where each service lives. **Identical on every +# host**, byte for byte; a hive needs all of it to be a +# *client* of the swarm. +# deploy.* — this machine's deployment decisions. Necessarily different +# on every host, because that is what a deployment is. +# +# The `enable` toggles used to live under `swarm.*`, which made the +# namespace that is supposed to be identical everywhere carry the one +# thing that must differ. +# +# Flat and named for the thing deployed — `deploy.forgejo`, not +# `deploy.swarmServices.forgejo`. Grouping by "swarm service" would +# re-encode the service-side taxonomy into a layer that does not care +# about it: from here, a host deploys forgejo, or a hive, or the lot, and +# what *kind* of thing each one is belongs to the service module. +# +# ⚠️ The renames below are deliberately in this one file rather than +# spread across the service modules, so the whole move has a single home +# and a single file to delete when the deprecation window closes — the +# shape ./swarm-peers-removed.nix already uses. +{ lib, ... }: +{ + imports = [ + # Same type, same meaning, new path — so a rename carries it exactly + # and existing configs keep evaluating with one warning naming both + # paths. Precedent: ./hive-forge/default.nix, ./hive-matrix.nix. + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "grafana" "enable" ] + [ "services" "hyperhive" "deploy" "grafana" ] + ) + ]; + + options.services.hyperhive.deploy = { + grafana = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm's metrics UI on this host. + + Off by default and not derived from + {option}`services.hyperhive.enable`: a swarm has one Grafana, so + running it is a decision about this host rather than about + whether hyperhive is installed. + ''; + }; + }; +} diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 1e9fde3f..919c2fa8 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -16,6 +16,7 @@ }: let cfg = config.services.hyperhive.swarm.grafana; + deployCfg = config.services.hyperhive.deploy; networkCfg = config.services.hyperhive.network; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; @@ -106,18 +107,12 @@ let in { + # `enable` moved to `services.hyperhive.deploy.grafana` — see + # ./deploy.nix. Whether this host runs the swarm's Grafana is a + # deployment decision, and `swarm.*` has to be identical on every host. + # What stays here is what the service IS: its package, domain, and + # wiring. options.services.hyperhive.swarm.grafana = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Run the swarm's metrics UI on this host. Off by default and not - derived from {option}`services.hyperhive.enable`: a swarm has one - Grafana, so enabling it is a decision about swarm topology rather - than about whether hyperhive is installed. - ''; - }; - package = lib.mkOption { type = lib.types.package; default = pkgs.grafana; @@ -312,8 +307,8 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { - # The gateway name and the quick-link, both inside `cfg.enable` — that + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.grafana) { + # The gateway name and the quick-link, both inside `deploy.grafana` — that # guard is the load-bearing part. Every hive in a swarm may know this UI # exists, but only the host that RUNS it may claim the name; a client # hive declaring the vhost would answer for a service it does not have. diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index a40b7e56..dbaa26a0 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -69,7 +69,8 @@ in # takes one and not the other from this switch. An operator who wants # exactly one still sets it directly, which `mkDefault` allows. victoriametrics.enable = lib.mkDefault swarmCfg.enableRequiredServices; - grafana.enable = lib.mkDefault swarmCfg.enableRequiredServices; + # (grafana's half of the pair derives below — it lives in `deploy.*` + # now, which is a different attribute path, not a different rule.) # The log store, deriving from the same switch for the same reason — # and deliberately in the same commit as the collector pipeline that @@ -88,4 +89,10 @@ in # The collector that feeds the pair above (note: no `swarm.` prefix, # this is ./otel.nix's existing per-hive option). config.services.hyperhive.otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; + + # Grafana, the UI half of the metrics pair. Same derivation and the same + # reasoning as `victoriametrics.enable` above; it reads differently only + # because "does this host run it" now lives in `deploy.*` (./deploy.nix) + # rather than under `swarm.*`, which has to be identical on every host. + config.services.hyperhive.deploy.grafana = lib.mkDefault swarmCfg.enableRequiredServices; } diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index aad8691a..6437b9fd 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -23,6 +23,7 @@ let cfg = config.services.hyperhive; swarmCfg = cfg.swarm; + deployCfg = cfg.deploy; # Public hostnames of the swarm's own services, in declaration order. # `serviceDomains` below is this set sorted + deduplicated. @@ -57,7 +58,7 @@ let # metrics UI and store looked fine for as long as only people opened # them; the collector's exporter — same defect, no human in the loop — # failed every POST and dropped the samples. - ++ lib.optional swarmCfg.grafana.enable swarmCfg.grafana.domain + ++ lib.optional deployCfg.grafana swarmCfg.grafana.domain ++ lib.optional swarmCfg.victoriametrics.enable swarmCfg.victoriametrics.domain ++ lib.optional swarmCfg.otel.enable swarmCfg.otel.domain # VictoriaLogs' vhost is new (was previously unpublished entirely — see From cf1f2f99cfe0133a69712dab42caf3a8d91de4e3 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:13:23 +0200 Subject: [PATCH 02/10] deploy: fix a reference the option-path sweep could not see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ssoLocal` in the grafana module read `cfg.enable`, where `cfg` is the local binding for `services.hyperhive.swarm.grafana` — the option set the previous commit removed `enable` from. Evaluation fails on the missing attribute. The sweep that missed it looked for the option *path* (`grafana.enable`). This reference is spelled through a `let` alias, so no grep for the path can find it, and a parse check cannot either: it is syntactically valid and only wrong once the module system resolves the attribute. The check that does find it: for each file, extract the local bindings that point at the option set being changed, then grep for `.`. Run against this tree it reports nothing for grafana and sixteen references for authelia, which is the next module to move. --- nix/host-modules/swarm-grafana.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 919c2fa8..922597d5 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -77,7 +77,7 @@ let # The all-local case: this host runs BOTH Grafana and the swarm's authelia, # so the minted secret can be moved without an operator. Same split the # forge and matrix modules document. - ssoLocal = cfg.enable && autheliaCfg.enable; + ssoLocal = deployCfg.grafana && autheliaCfg.enable; autheliaUrl = toString autheliaCfg.url; # Where the plaintext lands inside the container. Under /var/lib rather From 29647f963e7c6325ec9fdaf89aa1ebeac585641b Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:19:42 +0200 Subject: [PATCH 03/10] deploy: move the metrics and log store toggles Same move as grafana, and the three belong together: they derive from one switch and a store with no UI is as useless as a UI with no store. `victorialogs` is the case that shows why the option-path sweep is not enough on its own. It has **zero** references spelled `swarm.victorialogs.enable` anywhere in the tree, and four spelled through `let` aliases (`vlCfg.enable` in the collector, `cfg.enable` in its own module). A sweep for the path would have reported nothing to do and left every reader broken. Prose moved with the code rather than being left behind: the comments in swarm-required-services.nix that explained why the pair derives together now sit above the assignments that do it, instead of above the gap where they used to be. --- docs/observability.md | 2 +- docs/swarm/services.md | 2 +- nix/host-modules/deploy.nix | 36 +++++++++++++++++++ nix/host-modules/otel.nix | 2 +- nix/host-modules/swarm-otel.nix | 16 +++++---- nix/host-modules/swarm-required-services.nix | 38 ++++++++------------ nix/host-modules/swarm-victorialogs.nix | 19 ++++------ nix/host-modules/swarm-victoriametrics.nix | 19 ++++------ nix/host-modules/swarm.nix | 4 +-- 9 files changed, 77 insertions(+), 61 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 35f22bf1..8a2d627d 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -18,7 +18,7 @@ services.hyperhive.otel = { `enable` is the single gate. `endpoint` is where telemetry ends up after it leaves the swarm — optional, because the swarm's own metrics store -(`swarm.victoriametrics.enable`) is a destination in its own right. With both, +(`deploy.victoriametrics`) is a destination in its own right. With both, telemetry goes to both. See [`swarm/services.md`](swarm/services.md#metrics-victoriametrics--grafana). diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 4668c9a3..996a634c 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -74,7 +74,7 @@ together: a store with no UI is unreadable and a UI with no store is empty. To run one without the other, set it directly: ```nix -services.hyperhive.swarm.victoriametrics.enable = true; +services.hyperhive.deploy.victoriametrics = true; services.hyperhive.deploy.grafana = false; ``` diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 7fdc5d73..4a95720c 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -34,6 +34,14 @@ [ "services" "hyperhive" "swarm" "grafana" "enable" ] [ "services" "hyperhive" "deploy" "grafana" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "victoriametrics" "enable" ] + [ "services" "hyperhive" "deploy" "victoriametrics" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "victorialogs" "enable" ] + [ "services" "hyperhive" "deploy" "victorialogs" ] + ) ]; options.services.hyperhive.deploy = { @@ -49,5 +57,33 @@ whether hyperhive is installed. ''; }; + + victoriametrics = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm's metrics store on this host. + + Derives from + {option}`services.hyperhive.swarm.enableRequiredServices` together + with {option}`services.hyperhive.deploy.grafana`: a store with no + UI is unreadable and a UI with no store is empty, so there is no + sensible deployment that takes one and not the other from that + switch. Set either directly to run exactly one. + ''; + }; + + victorialogs = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm's log store on this host. + + Derives from + {option}`services.hyperhive.swarm.enableRequiredServices` for the + same reason as the metrics pair above: a hive that is not the + service host is a *client* of this store, not a second one. + ''; + }; }; } diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 40b9e1b8..b1cc2d7b 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -87,7 +87,7 @@ in never given to a container. Optional. Leave it empty and the swarm's own metrics store - ({option}`services.hyperhive.swarm.victoriametrics.enable`) is the + ({option}`services.hyperhive.deploy.victoriametrics`) is the destination; that is a complete deployment, not a degraded one. Set both and telemetry goes to both. ''; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index b39e66bd..e899ab24 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -33,6 +33,7 @@ let otelCfg = config.services.hyperhive.otel; vmCfg = config.services.hyperhive.swarm.victoriametrics; vlCfg = config.services.hyperhive.swarm.victorialogs; + deployCfg = config.services.hyperhive.deploy; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; swarmDomain = hyperhiveCfg.swarm.domain; @@ -162,13 +163,14 @@ let # exactly the same destinations as the single pipeline they replace. # Written once because "which exporters" is a property of this tier, not # of which hive a sample came from. - exporterNames = upstreamExporters ++ lib.optional vmCfg.enable "otlphttp/victoriametrics"; + exporterNames = + upstreamExporters ++ lib.optional deployCfg.victoriametrics "otlphttp/victoriametrics"; # The same fan-out for logs, and the local store is only ONE of its # destinations. A deployment that turns the swarm's log store off and keeps # an upstream endpoint still collects — the store is where logs may be kept, # not the reason to read the journal at all. - logExporterNames = upstreamExporters ++ lib.optional vlCfg.enable "otlphttp/victorialogs"; + logExporterNames = upstreamExporters ++ lib.optional deployCfg.victorialogs "otlphttp/victorialogs"; # Collect when there is anywhere to send it, and only then. A pipeline with # an empty exporter list is not a quiet no-op — the collector rejects it — @@ -608,12 +610,12 @@ in # The tier exists to hold the upstream credential and to write the # swarm's store. With neither, it is a process that receives # samples and drops them — which looks healthy and loses data. - assertion = otelCfg.endpoint != "" || vmCfg.enable; + assertion = otelCfg.endpoint != "" || deployCfg.victoriametrics; message = '' services.hyperhive.swarm.otel.enable is true but this collector has nowhere to send what it receives: services.hyperhive.otel.endpoint is empty and - services.hyperhive.swarm.victoriametrics.enable is false. + services.hyperhive.deploy.victoriametrics is false. Set the endpoint to export upstream, or enable the swarm's metrics store. @@ -780,7 +782,7 @@ in cfg.producerPort otelCfg.collector.port ] - ++ lib.optional vmCfg.enable vmCfg.port; + ++ lib.optional deployCfg.victoriametrics vmCfg.port; all = derived ++ others; in lib.length (lib.unique all) == lib.length all; @@ -1015,7 +1017,7 @@ in }; exporters = - lib.optionalAttrs vmCfg.enable { + lib.optionalAttrs deployCfg.victoriametrics { # `metrics_endpoint`, NOT `endpoint`: the latter is a # base that otlphttp appends `/v1/metrics` to, while # VictoriaMetrics serves OTLP at @@ -1038,7 +1040,7 @@ in } // lib.optionalAttrs (otelCfg.protocol == "http/json") { encoding = "json"; }; } - // lib.optionalAttrs vlCfg.enable { + // lib.optionalAttrs deployCfg.victorialogs { # `logs_endpoint`, NOT `endpoint`, for exactly the reason the # metrics exporter above spells out — and the trap is worse # here, because the two stores' OTLP routes differ. `endpoint` diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index dbaa26a0..78ebcc09 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -59,25 +59,10 @@ in # the queue they authenticate against. nats.enable = lib.mkDefault swarmCfg.enableRequiredServices; - # The metrics pair. Once per swarm and optional, so they meet the rule - # in the option's description the same way the three above do — a hive - # that is not the service host is a *client* of this Grafana, not a - # second one. - # - # They derive together on purpose: a store with no UI is unreadable and - # a UI with no store is empty, so there is no sensible deployment that - # takes one and not the other from this switch. An operator who wants - # exactly one still sets it directly, which `mkDefault` allows. - victoriametrics.enable = lib.mkDefault swarmCfg.enableRequiredServices; - # (grafana's half of the pair derives below — it lives in `deploy.*` - # now, which is a different attribute path, not a different rule.) - - # The log store, deriving from the same switch for the same reason — - # and deliberately in the same commit as the collector pipeline that - # writes to it, never before it. A store nothing writes to is worse - # than no store: it starts, answers queries, and returns nothing, so - # the first person to look concludes there were no logs. - victorialogs.enable = lib.mkDefault swarmCfg.enableRequiredServices; + # The metrics pair and the log store used to derive here too. They now + # live under `deploy.*` (below, and ./deploy.nix) because "does THIS + # host run it" is a per-host decision and `swarm.*` has to be identical + # on every host. Same switch, same rule, different attribute path. # The collector that feeds the pair above, and the only tier holding # the upstream credential. Same rule as the rest: once per swarm, @@ -90,9 +75,16 @@ in # this is ./otel.nix's existing per-hive option). config.services.hyperhive.otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; - # Grafana, the UI half of the metrics pair. Same derivation and the same - # reasoning as `victoriametrics.enable` above; it reads differently only - # because "does this host run it" now lives in `deploy.*` (./deploy.nix) - # rather than under `swarm.*`, which has to be identical on every host. + # The metrics pair, deriving together on purpose: a store with no UI is + # unreadable and a UI with no store is empty, so there is no sensible + # deployment that takes one and not the other from this switch. An + # operator who wants exactly one still sets it directly, which + # `mkDefault` allows. + # + # The log store derives from the same switch for the same reason as the + # rest: a hive that is not the service host is a *client* of it, not a + # second one. + config.services.hyperhive.deploy.victoriametrics = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.victorialogs = lib.mkDefault swarmCfg.enableRequiredServices; config.services.hyperhive.deploy.grafana = lib.mkDefault swarmCfg.enableRequiredServices; } diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index 68f87a1f..737f628e 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -29,6 +29,7 @@ }: let cfg = config.services.hyperhive.swarm.victorialogs; + deployCfg = config.services.hyperhive.deploy; networkCfg = config.services.hyperhive.network; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; @@ -52,18 +53,10 @@ let ''; in { + # `enable` moved to `services.hyperhive.deploy.victorialogs` — see + # ./deploy.nix. What stays here is what the store IS: its package, + # domain, retention and wiring. options.services.hyperhive.swarm.victorialogs = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Run the swarm's log store on this host. Off by default and not - derived from {option}`services.hyperhive.enable`: a swarm has one - log store, so enabling it is a decision about swarm topology - rather than about whether hyperhive is installed. - ''; - }; - package = lib.mkOption { type = lib.types.package; default = pkgs.victorialogs; @@ -128,7 +121,7 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victorialogs) { # This store publishes its own health as prometheus metrics on the same # listener it serves queries on, so the swarm's collector scrapes it with # no exporter and no extra port — same arrangement as the metrics store. @@ -141,7 +134,7 @@ in # hosts are separate evaluations. services.hyperhive.swarm.otel.scrapeTargets.victorialogs = "127.0.0.1:${toString cfg.port}"; - # The gateway name and the quick-link, both inside `cfg.enable` — same + # The gateway name and the quick-link, both inside `deployCfg.victorialogs` — same # "only the host that runs the service may claim the name" guard every # sibling swarm-service module uses (`swarm-grafana.nix`, # `swarm-victoriametrics.nix`). diff --git a/nix/host-modules/swarm-victoriametrics.nix b/nix/host-modules/swarm-victoriametrics.nix index a991f989..c02def6d 100644 --- a/nix/host-modules/swarm-victoriametrics.nix +++ b/nix/host-modules/swarm-victoriametrics.nix @@ -17,6 +17,7 @@ }: let cfg = config.services.hyperhive.swarm.victoriametrics; + deployCfg = config.services.hyperhive.deploy; networkCfg = config.services.hyperhive.network; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; @@ -28,18 +29,10 @@ let domainBase = if swarmDomain == null then "invalid" else swarmDomain; in { + # `enable` moved to `services.hyperhive.deploy.victoriametrics` — see + # ./deploy.nix. What stays here is what the store IS: its package, + # domain, retention and wiring. options.services.hyperhive.swarm.victoriametrics = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Run the swarm's metrics store on this host. Off by default and not - derived from {option}`services.hyperhive.enable`: a swarm has one - metrics store, so enabling it is a decision about swarm topology - rather than about whether hyperhive is installed. - ''; - }; - package = lib.mkOption { type = lib.types.package; default = pkgs.victoriametrics; @@ -97,8 +90,8 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { - # The gateway name and the quick-link, both inside `cfg.enable` — that + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victoriametrics) { + # The gateway name and the quick-link, both inside `deployCfg.victoriametrics` — that # guard is the load-bearing part. Every hive in a swarm may know this # store exists, but only the host that RUNS it may claim the name; a # client hive declaring the vhost would answer for a service it does not diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 6437b9fd..efe64565 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -59,7 +59,7 @@ let # them; the collector's exporter — same defect, no human in the loop — # failed every POST and dropped the samples. ++ lib.optional deployCfg.grafana swarmCfg.grafana.domain - ++ lib.optional swarmCfg.victoriametrics.enable swarmCfg.victoriametrics.domain + ++ lib.optional deployCfg.victoriametrics swarmCfg.victoriametrics.domain ++ lib.optional swarmCfg.otel.enable swarmCfg.otel.domain # VictoriaLogs' vhost is new (was previously unpublished entirely — see # swarm-victorialogs.nix's file-top comment) and needs the same @@ -67,7 +67,7 @@ let # this list, `gateway.lib.tlsFor` falls back to the hive leaf, which # cannot cover a name under a different apex — see the ⚠️ above this # list for what that looked like the last time a name was missed here. - ++ lib.optional swarmCfg.victorialogs.enable swarmCfg.victorialogs.domain; + ++ lib.optional deployCfg.victorialogs swarmCfg.victorialogs.domain; # Hives whose entry still carries the removed `certFingerprint`. Scanned # here, at top level, because that is the only place an assertion about a From 0b7357d4b80c5ef31b956b70094bfd264c532b38 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:22:42 +0200 Subject: [PATCH 04/10] deploy: move the controller and swarm-ui toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One commit rather than two because they are not independent: the UI's `enable` had the controller's as its literal default, so moving the controller alone would leave the UI's default naming an option that no longer exists. The UI keeps that derivation in its new home — it is a view onto the controller's state and reaches it over that daemon's unix socket, so the host running the controller is the host that can serve it. Three spellings had to move together for the UI, not one: the `default`, the `defaultText` shown in the options doc, and the description prose that names the old path in words. A grep for the option path finds the first two. The sweep also reached outside nix: `swarm-controller`'s crate README and its `//!` module doc both named the option, as did this repo's own CLAUDE.md and four pages under docs/. An option's name is API, and its documentation lives wherever someone thought to write it down. --- CLAUDE.md | 2 +- docs/gateway.md | 2 +- docs/persistence.md | 2 +- docs/setup.md | 2 +- docs/swarm/README.md | 2 +- docs/swarm/ui.md | 2 +- nix/host-modules/deploy.nix | 44 ++++++++++++++++++++++++++- nix/host-modules/hive-tls.nix | 2 +- nix/host-modules/local-defaults.nix | 2 +- nix/host-modules/swarm-authelia.nix | 3 +- nix/host-modules/swarm-controller.nix | 31 +++++++------------ nix/host-modules/swarm-ui.nix | 23 ++++---------- nix/host-modules/swarm.nix | 2 +- nix/module-eval.nix | 4 +-- swarm-controller/README.md | 2 +- swarm-controller/src/main.rs | 2 +- 16 files changed, 75 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b29051f..0039e03e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ hand-maintained per-file tree drifts out of sync with the code. - **`hive-metric/`** — small CLI to push a single labeled metric to the OTEL collector via the OpenTelemetry Rust SDK / OTLP HTTP exporter. - **`swarm-controller/`** — swarm-level daemon, opt-in per host - (`services.hyperhive.swarm.controller.enable`). Where `hive-c0re` owns + (`services.hyperhive.deploy.controller`). Where `hive-c0re` owns the agents on **one** host, this owns what is true **across** hives; a swarm runs one of them, so most hives leave it off. Serves HTTP over a unix socket the gateway's nginx proxies to — ⚠️ **the socket's diff --git a/docs/gateway.md b/docs/gateway.md index e465cce7..00ddcd7c 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -15,7 +15,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next | `chat./` | `chat.` | fluffychat-web static | `matrix.gui.enable` | | `chat./config.json` | `chat.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | | `auth./` | `auth.` | authelia (`9091`) | `swarm.authelia.enable` | -| `/` | `` | swarm-ui dist (static), behind an authelia subrequest | `swarm.ui.enable` | +| `/` | `` | swarm-ui dist (static), behind an authelia subrequest | `deploy.swarm-ui` | The authelia vhost is declared only by the host that **runs** authelia, not by every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. diff --git a/docs/persistence.md b/docs/persistence.md index b7f3ae00..a0241713 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -472,7 +472,7 @@ snapshot contains and how the store authenticates a sender. ## `/var/lib/swarm-controller/` (swarm-controller host only) Only present on the one host running -`services.hyperhive.swarm.controller.enable`. systemd `StateDirectory=`, +`services.hyperhive.deploy.controller`. systemd `StateDirectory=`, so it survives restarts and redeploys. - `webhook-secret` — the HMAC key the swarm's forge webhooks are signed diff --git a/docs/setup.md b/docs/setup.md index cdebe400..89d3f182 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -70,7 +70,7 @@ than amend — adding the group afterwards is `swarmctl user update mara Detail, including what the password is and why this stays manual: [`swarm/sso.md`](swarm/sso.md). -### 4 · Swarm UI (only when `swarm.ui.enable`, on by default with the controller) +### 4 · Swarm UI (only when `deploy.swarm-ui`, on by default with the controller) Nothing to run — it is served on the swarm apex (`https:///`) as soon as the host rebuilds. Two things diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 853a44cc..b5dc8783 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -276,7 +276,7 @@ migrating agent keeps one unbroken incremental chain. See ## Swarm controller -`services.hyperhive.swarm.controller.enable` runs the `swarm-controller` +`services.hyperhive.deploy.controller` runs the `swarm-controller` daemon on this host. **Off by default and deliberately not derived from `services.hyperhive.enable`**: a swarm has one controller, so enabling it is a statement about swarm topology, not about whether hyperhive is diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index f292993e..10bd11f3 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -9,7 +9,7 @@ answers for one host. This one is the view *across* hives. ## Enabling ```nix -services.hyperhive.swarm.ui.enable = true; # defaults to swarm.controller.enable +services.hyperhive.deploy.swarm-ui = true; # defaults to deploy.controller ``` Derived from the controller rather than from `enableRequiredServices`: diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 4a95720c..d60bf322 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -24,7 +24,10 @@ # spread across the service modules, so the whole move has a single home # and a single file to delete when the deprecation window closes — the # shape ./swarm-peers-removed.nix already uses. -{ lib, ... }: +{ lib, config, ... }: +let + deployCfg = config.services.hyperhive.deploy; +in { imports = [ # Same type, same meaning, new path — so a rename carries it exactly @@ -42,6 +45,14 @@ [ "services" "hyperhive" "swarm" "victorialogs" "enable" ] [ "services" "hyperhive" "deploy" "victorialogs" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "controller" "enable" ] + [ "services" "hyperhive" "deploy" "controller" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "ui" "enable" ] + [ "services" "hyperhive" "deploy" "swarm-ui" ] + ) ]; options.services.hyperhive.deploy = { @@ -85,5 +96,36 @@ service host is a *client* of this store, not a second one. ''; }; + + controller = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm-controller daemon on this host. + + Off by default and deliberately not derived from + {option}`services.hyperhive.enable`: a swarm has one controller, + so running it is a decision about this host rather than about + whether hyperhive is installed. + ''; + }; + + swarm-ui = lib.mkOption { + type = lib.types.bool; + default = deployCfg.controller; + defaultText = lib.literalExpression "services.hyperhive.deploy.controller"; + example = true; + description = '' + Serve the swarm UI from this host. + + Derived from {option}`services.hyperhive.deploy.controller` rather + than from + {option}`services.hyperhive.swarm.enableRequiredServices`: the UI + is a view onto the controller's state and reaches it over that + daemon's unix socket, so the host that runs the controller is the + host that can serve the UI. A hive that merely *uses* a swarm has + nothing to serve here. + ''; + }; }; } diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 34b3b109..aa5867db 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -669,6 +669,6 @@ in # in review on this PR; it evaluates and builds clean either way, which # is exactly why it needed a reviewer rather than a check. systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE = - lib.mkIf hyperhiveCfg.swarm.controller.enable "${cfg.stateDir}/trust-bundle.pem"; + lib.mkIf hyperhiveCfg.deploy.controller "${cfg.stateDir}/trust-bundle.pem"; }; } diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index aab9b180..d7a0aed8 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -33,7 +33,7 @@ in shared services (`services.hyperhive.swarm.enableRequiredServices`), the swarm CA (`services.hyperhive.swarm.ca.autoConfigure`), the swarm - controller (`services.hyperhive.swarm.controller.enable`), and the + controller (`services.hyperhive.deploy.controller`), and the host's `/etc/hosts` entries for the names this hive serves (`services.hyperhive.gateway.localHostsEntry`) — with no real DNS for those names, the operator is browsing them from the same box diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index dfbb51ab..87278455 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -42,6 +42,7 @@ let hyperhiveDomain = hyperhiveCfg.domain; swarmDomain = hyperhiveCfg.swarm.domain; uiCfg = hyperhiveCfg.swarm.ui; + deployCfg = hyperhiveCfg.deploy; forgeCfg = hyperhiveCfg.swarm.forge; # Group an account must hold to reach operator-only surfaces. Named @@ -1406,7 +1407,7 @@ in # collector is registered — see `metricsRule` above, # which is where the reasoning for both halves lives. lib.optional forgeCfg.behindGateway metricsRule - ++ lib.optional uiCfg.enable { + ++ lib.optional deployCfg.swarm-ui { domain = uiCfg.domain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index cd100f3e..0d376272 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -15,6 +15,7 @@ }: let cfg = config.services.hyperhive.swarm.controller; + deployCfg = config.services.hyperhive.deploy; autheliaCfg = config.services.hyperhive.swarm.authelia; # What `swarmctl` needs in order to act on authelia from the host. @@ -119,7 +120,7 @@ let # ⚠️ Correct ONLY while the vhost and this daemon share a host, and they do # by construction: the UI's `/api/` location proxies # `http://unix:`, a path that resolves nowhere else, and this - # daemon binds no TCP address at all (see the header). `swarm.ui.enable` + # daemon binds no TCP address at all (see the header). `deploy.swarm-ui` # therefore is not a guess about *some* host publishing the endpoint — it is # the flag that declares that vhost, on the box holding the socket. # @@ -129,7 +130,7 @@ let # the moment to add an explicit `publicUrl` option — not before, because # until then there is exactly one derivable answer and an option would only # be a second place to get it wrong. - webhookEnv = lib.optionalAttrs uiCfg.enable { + webhookEnv = lib.optionalAttrs deployCfg.swarm-ui { SWARM_CONTROLLER_PUBLIC_URL = "https://${uiCfg.domain}"; }; @@ -214,7 +215,7 @@ in name = "swarm-controller"; consumers = [ "swarm-controller" ]; hostUnit = true; - enable = cfg.enable; + enable = deployCfg.controller; }) ]; @@ -238,22 +239,12 @@ in ''; }; - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Run the swarm-controller daemon on this host. Off by default and - deliberately not derived from `services.hyperhive.enable`: a swarm - has one controller, so enabling it per hive is a decision about - swarm topology, not about whether hyperhive is installed. - - `services.hyperhive.enableAllLocalDefaults` does assert it, and - that is not an exception to the rule above — it is the rule - applied. That mode says "this box is the whole deployment", which - answers the topology question outright, where - `services.hyperhive.enable` alone never can. - ''; - }; + # `enable` moved to `services.hyperhive.deploy.controller` — see + # ./deploy.nix. `services.hyperhive.enableAllLocalDefaults` still + # asserts it, and that was never an exception to "not derived from + # services.hyperhive.enable": that mode says "this box is the whole + # deployment", which answers the topology question outright, where + # `enable` alone never can. What stays here is what the daemon IS. package = lib.mkOption { type = lib.types.package; @@ -483,7 +474,7 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.controller) { # The daemon and the oneshot that mints its credential — the second one # failing leaves the first running and unable to authenticate anywhere. services.hyperhive.swarm.otel.journaldUnits = [ diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index e92b5cec..052c32ef 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -14,6 +14,7 @@ }: let cfg = config.services.hyperhive.swarm.ui; + deployCfg = config.services.hyperhive.deploy; gatewayCfg = config.services.hyperhive.gateway; autheliaCfg = config.services.hyperhive.swarm.authelia; controllerCfg = config.services.hyperhive.swarm.controller; @@ -56,23 +57,11 @@ let hiveDomain = config.services.hyperhive.domain; in { + # `enable` moved to `services.hyperhive.deploy.swarm-ui` — see + # ./deploy.nix, where it still derives from the controller's own deploy + # toggle for the same reason. What stays here is what the UI IS: its + # domain and wiring. options.services.hyperhive.swarm.ui = { - enable = lib.mkOption { - type = lib.types.bool; - default = swarmCfg.controller.enable; - defaultText = lib.literalExpression "services.hyperhive.swarm.controller.enable"; - example = true; - description = '' - Serve the swarm UI from this host. - - Derived from `swarm.controller.enable` rather than from - `enableRequiredServices`: the UI is a view onto the controller's - state and reaches it over that daemon's unix socket, so the host - that runs the controller is the host that can serve the UI. A - hive that merely *uses* a swarm has nothing to serve here. - ''; - }; - domain = lib.mkOption { type = lib.types.str; default = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain; @@ -110,7 +99,7 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-ui) { assertions = [ { # The `_` default server already answers for the hive domain diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index efe64565..c8ac1f58 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,7 +46,7 @@ let # `forge.` is, and no CA in the hierarchy issues for it # implicitly. Left out, its vhost falls back to the hive leaf and the # swarm's front page opens with a name mismatch. - ++ lib.optional swarmCfg.ui.enable swarmCfg.ui.domain + ++ lib.optional deployCfg.swarm-ui swarmCfg.ui.domain # Every swarm service that claims a gateway name belongs here, and # these three were missing it. Membership is what `gateway.lib.tlsFor` # consults to pick the services leaf over the hive one, so a name diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 29d70fa2..94951047 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -90,11 +90,11 @@ let cases = [ { name = "a hive that has not opted into all-local runs no swarm controller"; - ok = !bare.services.hyperhive.swarm.controller.enable; + ok = !bare.services.hyperhive.deploy.controller; } { name = "the all-local mode turns the swarm controller on"; - ok = allLocal.services.hyperhive.swarm.controller.enable; + ok = allLocal.services.hyperhive.deploy.controller; } { # The gateway's per-name issuer choice. If this ever collapses to a diff --git a/swarm-controller/README.md b/swarm-controller/README.md index fe96e5f2..e484ae2a 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -4,7 +4,7 @@ The **swarm-level** daemon. Where `hive-c0re` owns the agents on one host, this owns what is true *across* hives — so a swarm runs one of them and most hives leave it off. -Opt-in per host via `services.hyperhive.swarm.controller.enable`, which is +Opt-in per host via `services.hyperhive.deploy.controller`, which is deliberately **not** derived from `services.hyperhive.enable`: turning it on is a statement about swarm topology, not about whether hyperhive is installed. diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index ea09dfc9..e3df593f 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1,6 +1,6 @@ //! Swarm-level controller daemon. Runs as the unprivileged //! `swarm-controller` user on whichever host the operator flips -//! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a +//! `services.hyperhive.deploy.controller` on, and serves HTTP over a //! unix socket that the hive-gateway's nginx proxies to. //! //! Configuration is read-only and loaded once at startup from env vars the From 37ca7676d66c3331d4c875e217fcac3a01b94ee7 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:25:42 +0200 Subject: [PATCH 05/10] deploy: move the SSO provider toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest of these moves: sixteen references spelled through `let` aliases across eight modules, plus eight more spelled as a path, plus five documentation pages. authelia is also the clearest case for why the two namespaces exist. `swarm.authelia.url` is needed by *every* hive in the swarm — it says where to send a browser to authenticate — while running the container is the business of exactly one host. The client half and the server half were sharing a namespace whose whole contract is "identical everywhere", and only one of them could honour it. `swarm.authelia.oidc.clients` stays where it is for the same reason: several modules register a client there, gated on authelia running here, and the registry itself is what the service *is* rather than a decision about this machine. One sweep note worth recording: a grep for `swarm.authelia.enable` misses `swarmCfg.authelia.enable`, because the prefix is whatever the reading file bound. Grepping the suffix `.authelia.enable` finds both, and found a reference in swarm.nix that the path-shaped pattern did not. --- docs/gateway.md | 4 +-- docs/setup.md | 2 +- docs/swarm/services.md | 2 +- docs/swarm/sso.md | 2 +- nix/host-modules/deploy.nix | 22 ++++++++++++++ nix/host-modules/hive-forge/default.nix | 3 +- nix/host-modules/hive-matrix.nix | 3 +- nix/host-modules/local-defaults.nix | 2 +- nix/host-modules/otel.nix | 9 +++--- nix/host-modules/swarm-authelia.nix | 28 ++++++------------ nix/host-modules/swarm-controller.nix | 6 ++-- nix/host-modules/swarm-grafana.nix | 2 +- nix/host-modules/swarm-nats.nix | 5 ++-- nix/host-modules/swarm-otel.nix | 4 +-- nix/host-modules/swarm-required-services.nix | 31 ++++++++++++++------ nix/host-modules/swarm.nix | 2 +- 16 files changed, 77 insertions(+), 50 deletions(-) diff --git a/docs/gateway.md b/docs/gateway.md index 00ddcd7c..6d306c7f 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -14,7 +14,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next | `chat./_matrix/*` | `chat.` | tuwunel (`8008`) | `matrix.gatewayHost != null` | | `chat./` | `chat.` | fluffychat-web static | `matrix.gui.enable` | | `chat./config.json` | `chat.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | -| `auth./` | `auth.` | authelia (`9091`) | `swarm.authelia.enable` | +| `auth./` | `auth.` | authelia (`9091`) | `deploy.authelia` | | `/` | `` | swarm-ui dist (static), behind an authelia subrequest | `deploy.swarm-ui` | The authelia vhost is declared only by the host that **runs** authelia, not by every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. @@ -64,7 +64,7 @@ Each location carries a duplicated `auth_basic` block (separate locations don't - `` → `127.0.0.1` - `forge.` → `127.0.0.1` (when forge.behindGateway) - `chat.` → `127.0.0.1` (when matrix.gatewayHost set) -- `auth.` → `127.0.0.1` (when swarm.authelia.enable) +- `auth.` → `127.0.0.1` (when deploy.authelia) `lib.unique` de-dupes if any sub-domain happens to equal another entry. Operators with real DNS leave it off. diff --git a/docs/setup.md b/docs/setup.md index 89d3f182..4af6a569 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -44,7 +44,7 @@ echo "hunter2" | hivectl gateway create-user mara --password-stdin hivectl gateway list-users ``` -### 3 · Swarm SSO (only when `swarm.authelia.enable`) +### 3 · Swarm SSO (only when `deploy.authelia`) ⚠️ **Required to finish the install, not optional.** Authelia treats an empty user store as a fatal startup error, so until this runs the diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 996a634c..8dd046ae 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -34,7 +34,7 @@ One authelia per swarm, in a `swarm-authelia` container, at provider, differentiated by roles and claims rather than by mechanism — there is one IdP and one auth path. -- **`swarm.authelia.enable`** — run the container here. Defaults from +- **`deploy.authelia`** — run the container here. Defaults from `swarm.enableRequiredServices`. - **`swarm.authelia.url`** — where clients are sent to authenticate. Present on **every** hive, defaulting to this host's own instance only diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index 53aad373..26d5c7ea 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -14,7 +14,7 @@ separate `enable` would be a second fact free to disagree with the first. authelia binds loopback only. The **gateway** on the host running it publishes it as `auth.` — vhost, dnsmasq record and TLS -name all follow `swarm.authelia.enable`, so there is nothing to turn on +name all follow `deploy.authelia`, so there is nothing to turn on separately. (Details, including why a client hive must not declare that vhost: [`../gateway.md`](../gateway.md).) diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index d60bf322..bf2e6cdb 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -53,6 +53,10 @@ in [ "services" "hyperhive" "swarm" "ui" "enable" ] [ "services" "hyperhive" "deploy" "swarm-ui" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "authelia" "enable" ] + [ "services" "hyperhive" "deploy" "authelia" ] + ) ]; options.services.hyperhive.deploy = { @@ -97,6 +101,24 @@ in ''; }; + authelia = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Run the swarm's authelia in a `swarm-authelia` container on this + host. {option}`services.hyperhive.swarm.enableRequiredServices` + turns this on — a swarm has one SSO provider, and that says it + lives here. + + With it off, this hive is a *client*: + {option}`services.hyperhive.swarm.authelia.url` still points at + whoever runs it, and no container is created. That asymmetry is + why the two live in different namespaces — every hive needs the + client half, only one runs the server half. + ''; + }; + controller = lib.mkOption { type = lib.types.bool; default = false; diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index d0fca0f6..95ad4db6 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -22,6 +22,7 @@ let # assertion below turns into an eval failure rather than a discovery # request to `null/.well-known/...`. autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; autheliaUrl = autheliaCfg.url; autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration"; @@ -29,7 +30,7 @@ let # authelia, so the secret can be moved without an operator. The other # two cases (swarm side via swarmctl, remote hive) leave # `clientSecretFile` to be set explicitly — see docs/swarm/. - ssoLocal = autheliaCfg.enable; + ssoLocal = deployCfg.authelia; # Where the plaintext lands inside the forge container. Under # /var/lib rather than /run: the forge may start before the delivery diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 3b7aa588..b51c2a20 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -25,13 +25,14 @@ let # below turns into an eval failure rather than a discovery request to # `null/.well-known/…`. autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; autheliaUrl = autheliaCfg.url; # The all-local case: this host runs BOTH the homeserver and the swarm's # authelia, so the secret can be moved without an operator. The other # two cases (swarm side, remote hive) leave `clientSecretFile` to be set # explicitly — same split the forge module documents. - ssoLocal = autheliaCfg.enable; + ssoLocal = deployCfg.authelia; # Where the plaintext lands inside the matrix container. Under /var/lib # rather than /run: the homeserver may start before the delivery unit on diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index d7a0aed8..4cff68d3 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -95,7 +95,7 @@ in # ran its first boot on — so they belong to the mode that asserts this # box is the whole deployment, not to the options' own `default`. # - # Deriving them from `swarm.nats.enable` / `swarm.authelia.enable` + # Deriving them from `swarm.nats.enable` / `deploy.authelia` # inside those defaults is the mixing this file exists to prevent: the # option would be describing a deployment shape instead of describing # itself, and "what does all-local turn on?" would stop having one diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index b1cc2d7b..56bfa2e7 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -231,9 +231,7 @@ in clientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if - config.services.hyperhive.swarm.authelia.enable && config.services.hyperhive.hiveName != null - then + if config.services.hyperhive.deploy.authelia && config.services.hyperhive.hiveName != null then "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/" + "${config.services.hyperhive.swarm.authelia.hiveClientPrefix}${config.services.hyperhive.hiveName}.secret" else @@ -284,6 +282,7 @@ in let otel = config.services.hyperhive.otel; autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; swarmOtelCfg = config.services.hyperhive.swarm.otel; hiveName = config.services.hyperhive.hiveName; listen = "${config.services.hyperhive.network.bridgeIp}:${toString otel.collector.port}"; @@ -580,8 +579,8 @@ in # Only when the minting container is on THIS host. Elsewhere the file # is operator-provided and there is no local unit to order against — # naming one that does not exist orders nothing, silently. - after = lib.optional autheliaCfg.enable "container@${autheliaCfg.machine}.service"; - requires = lib.optional autheliaCfg.enable "container@${autheliaCfg.machine}.service"; + after = lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; + requires = lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 87278455..53aea22e 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -345,22 +345,12 @@ let ''; in { + # `enable` moved to `services.hyperhive.deploy.authelia` — see + # ./deploy.nix. Whether this host runs the swarm's SSO provider is a + # deployment decision; what stays here is what authelia IS, including + # `url` and the OIDC client registry every hive needs as a *client* + # whether or not it runs the container. options.services.hyperhive.swarm.authelia = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Run the swarm's authelia in a `swarm-authelia` container on this - host. `services.hyperhive.swarm.enableRequiredServices` turns - this on — a swarm has one SSO provider, and that says it lives - here. - - With it off, this hive is a *client*: `url` below still points - at whoever runs it, and no container is created. - ''; - }; - package = lib.mkOption { type = lib.types.package; default = pkgs.authelia; @@ -437,7 +427,7 @@ in url = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if cfg.enable then "https://${cfg.domain}" else null; + default = if deployCfg.authelia then "https://${cfg.domain}" else null; defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null''; example = "https://auth.example.com"; description = '' @@ -820,7 +810,7 @@ in bridgeUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; readOnly = true; - default = if cfg.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null; + default = if deployCfg.authelia then "http://127.0.0.1:${toString cfg.bridgePort}" else null; defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null''; description = '' Where `swarm-authelia-bridge` answers, **as seen from this @@ -839,7 +829,7 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia) { # The derived half of the client list, declared the same way an # operator declares one. Everything downstream then reads a single # uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart — @@ -974,7 +964,7 @@ in # Authelia's own gateway surface: the vhost that fronts it and the # name the hive resolver answers for. Both live here rather than in - # the gateway, and both are inside `cfg.enable` — that guard is the + # the gateway, and both are inside `deployCfg.authelia` — that guard is the # load-bearing part. # # ⚠️ Every hive in a swarm knows `authelia.url`, but only the host diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 0d376272..04840c5e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -25,7 +25,7 @@ let # the right behaviour is for `swarmctl user add` to fail saying the # value is unset. A guessed path would resolve cleanly and write a file # nothing reads, which is the failure mode that costs an afternoon. - autheliaEnv = lib.optionalAttrs autheliaCfg.enable { + autheliaEnv = lib.optionalAttrs deployCfg.authelia { # The CONFIGURED authelia, not whatever is on PATH: the argon2 # parameters baked into a hash have to match the verifier's. SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia"; @@ -451,7 +451,7 @@ in authBridgeUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if autheliaCfg.enable then autheliaCfg.bridgeUrl else null; + default = if deployCfg.authelia then autheliaCfg.bridgeUrl else null; defaultText = lib.literalExpression '' authelia's own `bridgeUrl` when this host also runs `swarm-authelia`, else null @@ -499,7 +499,7 @@ in # client list would be a second source of truth for a string whose # mismatch is an opaque 401 from the token endpoint. Same shape as the # queue's own client declaration. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [ + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia [ { id = queueClientId; description = "HyperHive swarm controller"; diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 922597d5..02c7b931 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -77,7 +77,7 @@ let # The all-local case: this host runs BOTH Grafana and the swarm's authelia, # so the minted secret can be moved without an operator. Same split the # forge and matrix modules document. - ssoLocal = deployCfg.grafana && autheliaCfg.enable; + ssoLocal = deployCfg.grafana && deployCfg.authelia; autheliaUrl = toString autheliaCfg.url; # Where the plaintext lands inside the container. Under /var/lib rather diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 1e4d0b2b..aed7c984 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -7,6 +7,7 @@ let cfg = config.services.hyperhive.swarm.nats; autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; autheliaUrl = autheliaCfg.url; networkCfg = config.services.hyperhive.network; # Read even when the controller runs on a different host: what is needed @@ -514,7 +515,7 @@ in # authorization grant 'client_credentials'`. Introspection — which # is all the responder needs today — worked throughout, which is why # nothing was visibly broken while the comment was untrue. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [ + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia [ { id = cfg.clientId; description = "HyperHive swarm queue"; @@ -767,7 +768,7 @@ in # being up says nothing about whether its in-container secrets unit # has finished. The wait in the script is what actually closes it; # this only stops us spinning for the full timeout on every boot. - ++ lib.optional autheliaCfg.enable "container@${autheliaCfg.machine}.service"; + ++ lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; requires = lib.optional cfg.autoGenerateCallout "swarm-nats-callout-keys.service"; serviceConfig = { Type = "oneshot"; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index e899ab24..5dbac7ea 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -517,7 +517,7 @@ in # declaration would break every hive that runs a collector and # publishes nothing. services.hyperhive.swarm.authelia.oidc.clients = - lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { }) + lib.mkIf (deployCfg.authelia && cfg.publishedScrapeTargets != { }) [ { id = cfg.clientId; @@ -555,7 +555,7 @@ in # after it. On a fresh swarm that is a permanent stall presenting as # "metrics are broken", several layers from its cause. systemd.services.swarm-otel-oidc-secret = - lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { }) + lib.mkIf (deployCfg.authelia && cfg.publishedScrapeTargets != { }) { description = "deliver the swarm collector's OIDC client secret from authelia"; after = [ "container@${autheliaCfg.machine}.service" ]; diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 78ebcc09..6ad0d4e7 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -24,9 +24,13 @@ in description = '' Host the swarm's shared services on this hive. The services that exist once per swarm rather than once per hive and are *optional* - — the matrix homeserver, the SSO provider — have their `enable` - asserted from this, so a swarm's service host is declared in one - place. + — the matrix homeserver, the SSO provider, the queue, the metrics + and log stores — have their toggle asserted from this, so a + swarm's service host is declared in one place. + + Those toggles live in two namespaces and the split is deliberate: + {option}`services.hyperhive.deploy.*` for "does THIS host run it", + `swarm.*.enable` for the ones not yet moved. See ./deploy.nix. The forge is swarm-wide too but has nothing to assert: it is the canonical store for the meta flake and every agent's config repo, @@ -46,7 +50,6 @@ in # operator who hasn't spoken, yields to one who has. config.services.hyperhive.swarm = { matrix.enable = lib.mkDefault swarmCfg.enableRequiredServices; - authelia.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The queue. Added later than the two above and missed at the time — # this file predates the `swarm-nats` container by nine days and had # not been revisited since, so its absence was sequence rather than @@ -75,16 +78,26 @@ in # this is ./otel.nix's existing per-hive option). config.services.hyperhive.otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; + # The rest of the shared services, deriving from the same switch as the + # `swarm.*` ones above. They read differently only because "does THIS + # host run it" lives in `deploy.*` (./deploy.nix) — `swarm.*` has to be + # identical on every host, and these are exactly the values that must + # differ. + # + # authelia: a swarm has one SSO provider, and this says it lives here. + # With it off the hive is a *client* — `swarm.authelia.url` still points + # at whoever runs it. + config.services.hyperhive.deploy.authelia = lib.mkDefault swarmCfg.enableRequiredServices; + # The metrics pair, deriving together on purpose: a store with no UI is # unreadable and a UI with no store is empty, so there is no sensible # deployment that takes one and not the other from this switch. An # operator who wants exactly one still sets it directly, which # `mkDefault` allows. - # - # The log store derives from the same switch for the same reason as the - # rest: a hive that is not the service host is a *client* of it, not a - # second one. config.services.hyperhive.deploy.victoriametrics = lib.mkDefault swarmCfg.enableRequiredServices; - config.services.hyperhive.deploy.victorialogs = lib.mkDefault swarmCfg.enableRequiredServices; config.services.hyperhive.deploy.grafana = lib.mkDefault swarmCfg.enableRequiredServices; + + # The log store, from the same switch for the same reason as the rest: a + # hive that is not the service host is a *client* of it, not a second one. + config.services.hyperhive.deploy.victorialogs = lib.mkDefault swarmCfg.enableRequiredServices; } diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index c8ac1f58..5682ac8b 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -91,7 +91,7 @@ let # unrepresentable rather than merely detected: a default set is all or # nothing, and the assertion is then only ever about what an operator # typed. - queueLocal = swarmCfg.nats.enable && swarmCfg.authelia.enable && cfg.hiveName != null; + queueLocal = swarmCfg.nats.enable && deployCfg.authelia && cfg.hiveName != null; in { options.services.hyperhive.swarm.hives = lib.mkOption { From 64e8c62f188b4fd411f85cbe678d79426b86c096 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:31:32 +0200 Subject: [PATCH 06/10] deploy: move the queue toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the others. Three of the eight references were prose rather than code: two assertion messages naming the option to an operator, and a `defaultText` in the authelia module, whose `oidc.hiveIdentities` derives from whether the queue runs here. Also corrects a comment in swarm.nix that this commit's own hunk sits directly beneath. It said `enableRequiredServices` turns on matrix and authelia "but NOT nats (nats has no mode that enables it)", which stopped being true when the queue was added to that switch — before this change, not by it. The reasoning it introduces is still right and still worth keeping; only the premise had expired. --- nix/host-modules/deploy.nix | 17 ++++++++++++ nix/host-modules/local-defaults.nix | 2 +- nix/host-modules/swarm-authelia.nix | 4 +-- nix/host-modules/swarm-controller.nix | 2 +- nix/host-modules/swarm-nats.nix | 27 +++++++------------- nix/host-modules/swarm-required-services.nix | 4 ++- nix/host-modules/swarm.nix | 19 +++++++++----- 7 files changed, 45 insertions(+), 30 deletions(-) diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index bf2e6cdb..a58de18f 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -57,6 +57,10 @@ in [ "services" "hyperhive" "swarm" "authelia" "enable" ] [ "services" "hyperhive" "deploy" "authelia" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "nats" "enable" ] + [ "services" "hyperhive" "deploy" "nats" ] + ) ]; options.services.hyperhive.deploy = { @@ -119,6 +123,19 @@ in ''; }; + nats = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the swarm's message queue in a `swarm-nats` container on this + host. A swarm has one queue, so this belongs on the same host as + the rest of the shared services. + + Off by default, and off means *absent*: no container is created + and nothing else in the evaluated config changes. + ''; + }; + controller = lib.mkOption { type = lib.types.bool; default = false; diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index 4cff68d3..8f8a927c 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -95,7 +95,7 @@ in # ran its first boot on — so they belong to the mode that asserts this # box is the whole deployment, not to the options' own `default`. # - # Deriving them from `swarm.nats.enable` / `deploy.authelia` + # Deriving them from `deploy.nats` / `deploy.authelia` # inside those defaults is the mixing this file exists to prevent: the # option would be describing a deployment shape instead of describing # itself, and "what does all-local turn on?" would stop having one diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 53aea22e..a9ddf5b9 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -467,8 +467,8 @@ in oidc.hiveIdentities = lib.mkOption { type = lib.types.bool; - default = hyperhiveCfg.swarm.nats.enable; - defaultText = lib.literalExpression "services.hyperhive.swarm.nats.enable"; + default = deployCfg.nats; + defaultText = lib.literalExpression "services.hyperhive.deploy.nats"; description = '' Mint one machine client per hive in {option}`services.hyperhive.swarm.hives`, so each hive can diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 04840c5e..4c61eb9c 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -539,7 +539,7 @@ in services.hyperhive.swarm.controller.queue.natsUrl is unset. It defaults to loopback only when this host also runs the queue - (`services.hyperhive.swarm.nats.enable`). A controller on its + (`services.hyperhive.deploy.nats`). A controller on its own host has to be told where the queue is. ''; } diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index aed7c984..973f191f 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -49,7 +49,7 @@ let hostPath = name: "${secretDir}/${name}"; # The responder needs all three credentials. Gating on them rather than - # on `cfg.enable` keeps a half-configured hive at "queue up, denying + # on `deployCfg.nats` keeps a half-configured hive at "queue up, denying # everyone" instead of "unit crash-looping on a missing file". # # In auto mode the seeds are minted on this host before the container @@ -210,18 +210,9 @@ in # per-service vhost pattern do not apply. options.services.hyperhive.swarm.nats = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Run the swarm's message queue in a `swarm-nats` container on this - host. A swarm has one queue, so this belongs on the same host as - the rest of the shared services. - - Off by default, and off means *absent*: no container is created - and nothing else in the evaluated config changes. - ''; - }; + # `enable` moved to `services.hyperhive.deploy.nats` — see + # ./deploy.nix. What stays here is what the queue IS: its domain, + # ports, accounts and callout wiring. # ⚠️ Deliberately NO `package` option, unlike this module's siblings. # `services.nats` upstream does not expose one — it resolves @@ -408,7 +399,7 @@ in }; }; - config = lib.mkIf cfg.enable { + config = lib.mkIf deployCfg.nats { # The responder as well as the server: a denial reaches the client as a # timeout, so the server's own log is the only place it is an error. services.hyperhive.swarm.otel.journaldUnits = [ @@ -423,7 +414,7 @@ in # several layers from "the operator never set the issuer". assertion = cfg.autoGenerateCallout || cfg.calloutIssuerPublicKey != ""; message = '' - services.hyperhive.swarm.nats.enable requires + services.hyperhive.deploy.nats requires nats.calloutIssuerPublicKey — the public half of the account nkey that signs user JWTs for this queue. @@ -440,7 +431,7 @@ in # somebody connects. assertion = cfg.autoGenerateCallout || cfg.calloutUserPublicKey != ""; message = '' - services.hyperhive.swarm.nats.enable requires + services.hyperhive.deploy.nats requires nats.calloutUserPublicKey — the public half of the user nkey the auth-callout responder authenticates as. @@ -485,7 +476,7 @@ in { assertion = autheliaUrl != null; message = '' - services.hyperhive.swarm.nats.enable requires + services.hyperhive.deploy.nats requires services.hyperhive.swarm.authelia.url — the queue authenticates clients by validating tokens that authelia issued. @@ -684,7 +675,7 @@ in # door nobody can open, which is the deliberate interim state. # # ⚠️ It is gated on the seeds being configured rather than on - # `cfg.enable`, so a half-configured hive gets a running, + # `deployCfg.nats`, so a half-configured hive gets a running, # refusing queue instead of a unit that crash-loops on a # missing file. A queue that denies everyone is a legible # failure; a restart loop is not. diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 6ad0d4e7..545e4981 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -60,7 +60,6 @@ in # derives `nats.autoGenerateCallout` from the all-local mode, so that # mode was minting the queue's callout nkeys and then never starting # the queue they authenticate against. - nats.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The metrics pair and the log store used to derive here too. They now # live under `deploy.*` (below, and ./deploy.nix) because "does THIS @@ -89,6 +88,9 @@ in # at whoever runs it. config.services.hyperhive.deploy.authelia = lib.mkDefault swarmCfg.enableRequiredServices; + # The queue. Same rule: once per swarm, optional. + config.services.hyperhive.deploy.nats = lib.mkDefault swarmCfg.enableRequiredServices; + # The metrics pair, deriving together on purpose: a store with no UI is # unreadable and a UI with no store is empty, so there is no sensible # deployment that takes one and not the other from this switch. An diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 5682ac8b..5e8d6a5b 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -80,18 +80,23 @@ let # ONE condition for all three of them, deliberately. # # 🩸 They were three independent conditions first, and that was wrong in - # a way only an eval gate finds: `enableRequiredServices` turns on - # matrix and authelia but NOT nats (nats has no mode that enables it — - # see the auto-deploy question on the swarm-queue issue), so an ordinary - # all-local hive resolved authelia's two coordinates and not the queue - # URL. Two of three set is exactly what the assertion below rejects, so - # every `enableAllLocalDefaults` hive would have stopped evaluating. + # a way only an eval gate finds: at the time `enableRequiredServices` + # turned on matrix and authelia but NOT nats, so an ordinary all-local + # hive resolved authelia's two coordinates and not the queue URL. Two of + # three set is exactly what the assertion below rejects, so every + # `enableAllLocalDefaults` hive would have stopped evaluating. + # + # (The queue does derive from that switch now — ./swarm-required-services.nix + # — so the original asymmetry is gone. The reasoning below is why the + # single predicate stays anyway: it makes the partial state + # unrepresentable rather than merely detected, which does not depend on + # which services happen to derive together this month.) # # Deriving all three from one predicate makes the partial state # unrepresentable rather than merely detected: a default set is all or # nothing, and the assertion is then only ever about what an operator # typed. - queueLocal = swarmCfg.nats.enable && deployCfg.authelia && cfg.hiveName != null; + queueLocal = deployCfg.nats && deployCfg.authelia && cfg.hiveName != null; in { options.services.hyperhive.swarm.hives = lib.mkOption { From ba5ab313b32f9e3477ee2512707cc664d0aa2673 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:35:23 +0200 Subject: [PATCH 07/10] deploy: move the swarm collector toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one move where the suffix grep is the wrong instrument. There are two otel options a word apart — `swarm.otel.enable` (one per swarm) and `otel.enable` (one per hive, every hive runs it) — so `\.otel\.enable` matches twenty-five references of which most must not change. The module already carries a comment warning about exactly this, on a line that names `swarm.otel` in full rather than through a binding. Triaged by hand and confirmed the other way round: after the move, a grep for the per-hive option still finds it in the files that should keep it. Also worth recording what the alias sweep cannot do. `swarm.nix` reads this as `swarmCfg.otel.enable`, where `swarmCfg = cfg.swarm` and `cfg = config.services.hyperhive` — an alias bound to an alias, two hops from the option path. No syntactic sweep resolves that chain, which is why the module system's own evaluation is the only complete check here and the static sweeps are a way to narrow the work, not to finish it. --- docs/observability.md | 2 +- nix/host-modules/deploy.nix | 24 +++++++++++++++ nix/host-modules/otel.nix | 4 +-- nix/host-modules/swarm-authelia.nix | 2 +- nix/host-modules/swarm-controller.nix | 4 +-- nix/host-modules/swarm-grafana.nix | 2 +- nix/host-modules/swarm-nats.nix | 2 +- nix/host-modules/swarm-otel.nix | 32 ++++++-------------- nix/host-modules/swarm-required-services.nix | 32 +++++++------------- nix/host-modules/swarm.nix | 2 +- 10 files changed, 54 insertions(+), 52 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 8a2d627d..13b3d649 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -80,7 +80,7 @@ the host is: | | runs where | receives from | does | |---|---|---|---| | **hive tier** — `otel.enable` | every hive with agents | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential, picks no destination | -| **swarm tier** — `swarm.otel.enable` | once per swarm | every hive's collector | writes the swarm's store and exports upstream | +| **swarm tier** — `deploy.otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream | An all-local host runs both, and needs nothing said about the hop between them. diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index a58de18f..36204d58 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -61,6 +61,10 @@ in [ "services" "hyperhive" "swarm" "nats" "enable" ] [ "services" "hyperhive" "deploy" "nats" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "otel" "enable" ] + [ "services" "hyperhive" "deploy" "otel" ] + ) ]; options.services.hyperhive.deploy = { @@ -123,6 +127,26 @@ in ''; }; + otel = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Run the **swarm's** telemetry collector on this host. + + Derives from + {option}`services.hyperhive.swarm.enableRequiredServices` with the + metrics pair it feeds: a swarm has one of these, and it belongs + wherever the shared services live rather than on every hive. + + ⚠️ Not to be confused with + {option}`services.hyperhive.otel.enable`, the **hive-tier** + collector, which every hive runs and which is a different option. + A hive that does not run the swarm collector still runs its own, + and reaches this one by name at + {option}`services.hyperhive.swarm.otel.domain`. + ''; + }; + nats = lib.mkOption { type = lib.types.bool; default = false; diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 56bfa2e7..0aa1c186 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -61,7 +61,7 @@ in their own containers can reach. That collector forwards to the swarm's - ({option}`services.hyperhive.swarm.otel.enable`), which holds the + ({option}`services.hyperhive.deploy.otel`), which holds the upstream credential and writes the swarm's store. So an agent never sees the credential, and neither does this tier. @@ -81,7 +81,7 @@ in has left the swarm. Read by the swarm's collector - ({option}`services.hyperhive.swarm.otel.enable`), which is the only + ({option}`services.hyperhive.deploy.otel`), which is the only tier that holds the upstream credential. An agent is handed the *first* hop instead — this hive's own collector — so this value is never given to a container. diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index a9ddf5b9..f4728f91 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1009,7 +1009,7 @@ in # Written out in full rather than through a `let` binding so the gate and # the option it gates are visibly the same path: gating the wrong one is # not a build error, it is a target that is always declared or never is. - services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.swarm.otel.enable { + services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.otel { authelia = "127.0.0.1:${toString cfg.metricsPort}"; }; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 4c61eb9c..fd0e7165 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -174,14 +174,14 @@ let # so the two ends agree on exactly one path, `/v1/metrics`, arrived at # from opposite directions. # - # Gated on `otelSwarmCfg.enable` alone (a swarm-wide fact, not "enabled + # Gated on `deployCfg.otel` alone (a swarm-wide fact, not "enabled # HERE"): the collector is reachable by name wherever it runs, so a # controller not co-located with it now exports over https instead of # exporting nothing — the graceful-absence case left is "no swarm-otel # anywhere in this swarm at all," which `forgeEnv` already models the # same way. otelSwarmCfg = config.services.hyperhive.swarm.otel; - otelEnv = lib.optionalAttrs otelSwarmCfg.enable { + otelEnv = lib.optionalAttrs deployCfg.otel { OTEL_EXPORTER_OTLP_ENDPOINT = "https://${otelSwarmCfg.domain}/${otelSwarmCfg.producerName}"; # Checked by `swarm-otel.nix`'s `oidc/${producerName}` authenticator # against exactly this controller's own registered audience (see the diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 02c7b931..ed18b7f2 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -408,7 +408,7 @@ in # Declared here rather than in the collector's module, per that option's # rule: an entry exists where the service that named it runs. - services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.swarm.otel.enable { + services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.otel { grafana = "127.0.0.1:${toString cfg.metricsPort}"; }; diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 973f191f..8f9fb6a6 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -535,7 +535,7 @@ in # ⚠️ `swarm.otel`, not `hyperhive.otel` — two collectors one word apart, # and only this one reads `scrapeTargets`. Written in full so the gate # and the option it gates are visibly the same path. - services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.swarm.otel.enable { + services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.otel { nats = "127.0.0.1:${toString cfg.metricsPort}"; }; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 5dbac7ea..c3ec4851 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -29,11 +29,11 @@ }: let cfg = config.services.hyperhive.swarm.otel; + deployCfg = config.services.hyperhive.deploy; swarmCfg = config.services.hyperhive.swarm; otelCfg = config.services.hyperhive.otel; vmCfg = config.services.hyperhive.swarm.victoriametrics; vlCfg = config.services.hyperhive.swarm.victorialogs; - deployCfg = config.services.hyperhive.deploy; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; swarmDomain = hyperhiveCfg.swarm.domain; @@ -179,23 +179,11 @@ let collectLogs = logExporterNames != [ ]; in { + # `enable` moved to `services.hyperhive.deploy.otel` — see ./deploy.nix. + # ⚠️ That is the SWARM collector. The per-hive one keeps its own + # `services.hyperhive.otel.enable` (./otel.nix) and is a different + # option entirely — every hive runs that one. options.services.hyperhive.swarm.otel = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Run the swarm's telemetry collector on this host. - - Asserted from `swarm.enableRequiredServices` in - ./swarm-required-services.nix, with the metrics pair this - collector feeds: a swarm has one of these, and it belongs - wherever the shared services live rather than on every hive. - - A hive that does not run it still runs its own hive-tier collector - (`services.hyperhive.otel.enable`) and reaches this one by name, at - {option}`services.hyperhive.swarm.otel.domain`. - ''; - }; machine = lib.mkOption { type = lib.types.str; @@ -446,8 +434,8 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { - # The gateway name, inside `cfg.enable` — that guard is the load-bearing + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.otel) { + # The gateway name, inside `deployCfg.otel` — that guard is the load-bearing # part. Every hive in a swarm may know this collector exists, but only # the host that RUNS it may claim the name; a client hive declaring the # vhost would answer for a service it does not have. @@ -612,7 +600,7 @@ in # samples and drops them — which looks healthy and loses data. assertion = otelCfg.endpoint != "" || deployCfg.victoriametrics; message = '' - services.hyperhive.swarm.otel.enable is true but this collector + services.hyperhive.deploy.otel is true but this collector has nowhere to send what it receives: services.hyperhive.otel.endpoint is empty and services.hyperhive.deploy.victoriametrics is false. @@ -644,7 +632,7 @@ in # collector would listen on nothing while looking configured. assertion = hyperhiveCfg.swarm.hives != { }; message = '' - services.hyperhive.swarm.otel.enable is true but + services.hyperhive.deploy.otel is true but services.hyperhive.swarm.hives is empty: ingest is authenticated per hive, so an empty roster means this collector accepts nothing from anyone. @@ -756,7 +744,7 @@ in # issuer URL evaluates cleanly and refuses every hive at runtime. assertion = autheliaCfg.url != null; message = '' - services.hyperhive.swarm.otel.enable is true but + services.hyperhive.deploy.otel is true but services.hyperhive.swarm.authelia.url is null: every hive authenticates to this collector as itself, and the token comes from the swarm's identity provider. diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 545e4981..b514a56c 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -49,28 +49,13 @@ in # Same precedence reasoning as ./local-defaults.nix: fills in for an # operator who hasn't spoken, yields to one who has. config.services.hyperhive.swarm = { + # The last one still spelled `swarm.*.enable`. Everything else that + # used to derive here — the queue, the SSO provider, the collector, + # the metrics pair, the log store — now derives below under + # `deploy.*`, because "does THIS host run it" is a per-host decision + # and `swarm.*` has to be identical on every host. Same switch, same + # rule, different attribute path. matrix.enable = lib.mkDefault swarmCfg.enableRequiredServices; - # The queue. Added later than the two above and missed at the time — - # this file predates the `swarm-nats` container by nine days and had - # not been revisited since, so its absence was sequence rather than - # intent. It meets the rule in the option's own description exactly: - # once per swarm, and optional. - # - # The tell that it was an omission: `local-defaults.nix` already - # derives `nats.autoGenerateCallout` from the all-local mode, so that - # mode was minting the queue's callout nkeys and then never starting - # the queue they authenticate against. - - # The metrics pair and the log store used to derive here too. They now - # live under `deploy.*` (below, and ./deploy.nix) because "does THIS - # host run it" is a per-host decision and `swarm.*` has to be identical - # on every host. Same switch, same rule, different attribute path. - - # The collector that feeds the pair above, and the only tier holding - # the upstream credential. Same rule as the rest: once per swarm, - # optional, and a hive that is not the service host is a *client* of - # it (by name, `swarm.otel.domain`) rather than a second one. - otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; }; # The collector that feeds the pair above (note: no `swarm.` prefix, @@ -91,6 +76,11 @@ in # The queue. Same rule: once per swarm, optional. config.services.hyperhive.deploy.nats = lib.mkDefault swarmCfg.enableRequiredServices; + # The swarm collector that feeds the metrics pair, and the only tier + # holding the upstream credential. ⚠️ NOT the per-hive collector below, + # which every hive runs. + config.services.hyperhive.deploy.otel = lib.mkDefault swarmCfg.enableRequiredServices; + # The metrics pair, deriving together on purpose: a store with no UI is # unreadable and a UI with no store is empty, so there is no sensible # deployment that takes one and not the other from this switch. An diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 5e8d6a5b..29d0baac 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -60,7 +60,7 @@ let # failed every POST and dropped the samples. ++ lib.optional deployCfg.grafana swarmCfg.grafana.domain ++ lib.optional deployCfg.victoriametrics swarmCfg.victoriametrics.domain - ++ lib.optional swarmCfg.otel.enable swarmCfg.otel.domain + ++ lib.optional deployCfg.otel swarmCfg.otel.domain # VictoriaLogs' vhost is new (was previously unpublished entirely — see # swarm-victorialogs.nix's file-top comment) and needs the same # membership every gateway-published swarm service needs: absent from From d3b40da1c81abda46c657901d7244dead426a29f Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 04:05:28 +0200 Subject: [PATCH 08/10] deploy: give every option an `enable`, and name the controller one Two corrections from review, applied forward on this branch rather than by rewriting it. `deploy.` was a bare bool, which makes `deploy.forgejo = { enable; ci; }` unrepresentable -- the nested CI-runner sub-option this namespace was designed around. Every entry is now an attrset with an `enable`, so a second per-host deployment decision becomes an ordinary addition rather than a migration. `deploy.controller` is now `deploy.swarm-controller`, consistent with `deploy.swarm-ui`, which was introduced in the same commit. 89 references rewritten across 24 files -- nix, Rust, docs, and the repo's own CLAUDE.md. The prefix-anchored sweep missed exactly one, and it was live code: hive-tls.nix spells it `hyperhiveCfg.deploy.controller` -- the only `hyperhiveCfg` prefix among 45 references. A suffix grep (`\.deploy\.`) finds it; a path-anchored one cannot, because the head of a reference is whatever alias the reading file happens to bind. --- CLAUDE.md | 2 +- docs/persistence.md | 2 +- docs/swarm/README.md | 2 +- docs/swarm/services.md | 4 +- docs/swarm/ui.md | 2 +- nix/host-modules/deploy.nix | 59 +++++++++++--------- nix/host-modules/hive-forge/default.nix | 2 +- nix/host-modules/hive-matrix.nix | 2 +- nix/host-modules/hive-tls.nix | 2 +- nix/host-modules/local-defaults.nix | 2 +- nix/host-modules/otel.nix | 14 +++-- nix/host-modules/swarm-authelia.nix | 24 ++++---- nix/host-modules/swarm-controller.nix | 20 +++---- nix/host-modules/swarm-grafana.nix | 14 +++-- nix/host-modules/swarm-nats.nix | 26 +++++---- nix/host-modules/swarm-otel.nix | 31 +++++----- nix/host-modules/swarm-required-services.nix | 14 +++-- nix/host-modules/swarm-ui.nix | 4 +- nix/host-modules/swarm-victorialogs.nix | 6 +- nix/host-modules/swarm-victoriametrics.nix | 6 +- nix/host-modules/swarm.nix | 12 ++-- nix/module-eval.nix | 4 +- swarm-controller/README.md | 2 +- swarm-controller/src/main.rs | 2 +- 24 files changed, 137 insertions(+), 121 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0039e03e..768e5e4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ hand-maintained per-file tree drifts out of sync with the code. - **`hive-metric/`** — small CLI to push a single labeled metric to the OTEL collector via the OpenTelemetry Rust SDK / OTLP HTTP exporter. - **`swarm-controller/`** — swarm-level daemon, opt-in per host - (`services.hyperhive.deploy.controller`). Where `hive-c0re` owns + (`services.hyperhive.deploy.swarm-controller.enable`). Where `hive-c0re` owns the agents on **one** host, this owns what is true **across** hives; a swarm runs one of them, so most hives leave it off. Serves HTTP over a unix socket the gateway's nginx proxies to — ⚠️ **the socket's diff --git a/docs/persistence.md b/docs/persistence.md index a0241713..b95291d9 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -472,7 +472,7 @@ snapshot contains and how the store authenticates a sender. ## `/var/lib/swarm-controller/` (swarm-controller host only) Only present on the one host running -`services.hyperhive.deploy.controller`. systemd `StateDirectory=`, +`services.hyperhive.deploy.swarm-controller.enable`. systemd `StateDirectory=`, so it survives restarts and redeploys. - `webhook-secret` — the HMAC key the swarm's forge webhooks are signed diff --git a/docs/swarm/README.md b/docs/swarm/README.md index b5dc8783..3ca5c4c4 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -276,7 +276,7 @@ migrating agent keeps one unbroken incremental chain. See ## Swarm controller -`services.hyperhive.deploy.controller` runs the `swarm-controller` +`services.hyperhive.deploy.swarm-controller.enable` runs the `swarm-controller` daemon on this host. **Off by default and deliberately not derived from `services.hyperhive.enable`**: a swarm has one controller, so enabling it is a statement about swarm topology, not about whether hyperhive is diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 8dd046ae..c3de4e08 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -74,8 +74,8 @@ together: a store with no UI is unreadable and a UI with no store is empty. To run one without the other, set it directly: ```nix -services.hyperhive.deploy.victoriametrics = true; -services.hyperhive.deploy.grafana = false; +services.hyperhive.deploy.victoriametrics.enable = true; +services.hyperhive.deploy.grafana.enable = false; ``` ⚠️ **This starts a database that grows for as long as the swarm runs.** diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 10bd11f3..10bd72ee 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -9,7 +9,7 @@ answers for one host. This one is the view *across* hives. ## Enabling ```nix -services.hyperhive.deploy.swarm-ui = true; # defaults to deploy.controller +services.hyperhive.deploy.swarm-ui.enable = true; # defaults to deploy.swarm-controller.enable ``` Derived from the controller rather than from `enableRequiredServices`: diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 36204d58..a07e62d3 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -15,10 +15,14 @@ # thing that must differ. # # Flat and named for the thing deployed — `deploy.forgejo`, not -# `deploy.swarmServices.forgejo`. Grouping by "swarm service" would -# re-encode the service-side taxonomy into a layer that does not care -# about it: from here, a host deploys forgejo, or a hive, or the lot, and -# what *kind* of thing each one is belongs to the service module. +# `deploy.swarmServices.forgejo`: grouping by "swarm service" re-encodes +# the service-side taxonomy into a layer that does not care about it. +# +# ⚠️ Each entry is an attrset with an `enable`, not a bare bool, so a +# service that grows a second *deployment* decision has somewhere to put +# it — `deploy.forgejo = { enable; ci; }` is then an ordinary addition +# rather than a migration. `ci` ("does this host run the runner too") is +# exactly that shape, and a bare bool leaves it unrepresentable. # # ⚠️ The renames below are deliberately in this one file rather than # spread across the service modules, so the whole move has a single home @@ -35,40 +39,40 @@ in # paths. Precedent: ./hive-forge/default.nix, ./hive-matrix.nix. (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "grafana" "enable" ] - [ "services" "hyperhive" "deploy" "grafana" ] + [ "services" "hyperhive" "deploy" "grafana" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "victoriametrics" "enable" ] - [ "services" "hyperhive" "deploy" "victoriametrics" ] + [ "services" "hyperhive" "deploy" "victoriametrics" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "victorialogs" "enable" ] - [ "services" "hyperhive" "deploy" "victorialogs" ] + [ "services" "hyperhive" "deploy" "victorialogs" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "controller" "enable" ] - [ "services" "hyperhive" "deploy" "controller" ] + [ "services" "hyperhive" "deploy" "swarm-controller" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "ui" "enable" ] - [ "services" "hyperhive" "deploy" "swarm-ui" ] + [ "services" "hyperhive" "deploy" "swarm-ui" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "authelia" "enable" ] - [ "services" "hyperhive" "deploy" "authelia" ] + [ "services" "hyperhive" "deploy" "authelia" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "nats" "enable" ] - [ "services" "hyperhive" "deploy" "nats" ] + [ "services" "hyperhive" "deploy" "nats" "enable" ] ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "otel" "enable" ] - [ "services" "hyperhive" "deploy" "otel" ] + [ "services" "hyperhive" "deploy" "otel" "enable" ] ) ]; options.services.hyperhive.deploy = { - grafana = lib.mkOption { + grafana.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -81,7 +85,7 @@ in ''; }; - victoriametrics = lib.mkOption { + victoriametrics.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -89,14 +93,14 @@ in Derives from {option}`services.hyperhive.swarm.enableRequiredServices` together - with {option}`services.hyperhive.deploy.grafana`: a store with no - UI is unreadable and a UI with no store is empty, so there is no - sensible deployment that takes one and not the other from that - switch. Set either directly to run exactly one. + with {option}`services.hyperhive.deploy.grafana.enable`: a store + with no UI is unreadable and a UI with no store is empty, so there + is no sensible deployment that takes one and not the other from + that switch. Set either directly to run exactly one. ''; }; - victorialogs = lib.mkOption { + victorialogs.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -109,7 +113,7 @@ in ''; }; - authelia = lib.mkOption { + authelia.enable = lib.mkOption { type = lib.types.bool; default = false; example = true; @@ -127,7 +131,7 @@ in ''; }; - otel = lib.mkOption { + otel.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -147,7 +151,7 @@ in ''; }; - nats = lib.mkOption { + nats.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -160,7 +164,7 @@ in ''; }; - controller = lib.mkOption { + swarm-controller.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -173,15 +177,16 @@ in ''; }; - swarm-ui = lib.mkOption { + swarm-ui.enable = lib.mkOption { type = lib.types.bool; - default = deployCfg.controller; - defaultText = lib.literalExpression "services.hyperhive.deploy.controller"; + default = deployCfg.swarm-controller.enable; + defaultText = lib.literalExpression "services.hyperhive.deploy.swarm-controller.enable"; example = true; description = '' Serve the swarm UI from this host. - Derived from {option}`services.hyperhive.deploy.controller` rather + Derived from + {option}`services.hyperhive.deploy.swarm-controller.enable` rather than from {option}`services.hyperhive.swarm.enableRequiredServices`: the UI is a view onto the controller's state and reaches it over that diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 95ad4db6..8c0dc724 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -30,7 +30,7 @@ let # authelia, so the secret can be moved without an operator. The other # two cases (swarm side via swarmctl, remote hive) leave # `clientSecretFile` to be set explicitly — see docs/swarm/. - ssoLocal = deployCfg.authelia; + ssoLocal = deployCfg.authelia.enable; # Where the plaintext lands inside the forge container. Under # /var/lib rather than /run: the forge may start before the delivery diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index b51c2a20..8084f380 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -32,7 +32,7 @@ let # authelia, so the secret can be moved without an operator. The other # two cases (swarm side, remote hive) leave `clientSecretFile` to be set # explicitly — same split the forge module documents. - ssoLocal = deployCfg.authelia; + ssoLocal = deployCfg.authelia.enable; # Where the plaintext lands inside the matrix container. Under /var/lib # rather than /run: the homeserver may start before the delivery unit on diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index aa5867db..b8f1010d 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -669,6 +669,6 @@ in # in review on this PR; it evaluates and builds clean either way, which # is exactly why it needed a reviewer rather than a check. systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE = - lib.mkIf hyperhiveCfg.deploy.controller "${cfg.stateDir}/trust-bundle.pem"; + lib.mkIf hyperhiveCfg.deploy.swarm-controller.enable "${cfg.stateDir}/trust-bundle.pem"; }; } diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index 8f8a927c..732c43c7 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -33,7 +33,7 @@ in shared services (`services.hyperhive.swarm.enableRequiredServices`), the swarm CA (`services.hyperhive.swarm.ca.autoConfigure`), the swarm - controller (`services.hyperhive.deploy.controller`), and the + controller (`services.hyperhive.deploy.swarm-controller.enable`), and the host's `/etc/hosts` entries for the names this hive serves (`services.hyperhive.gateway.localHostsEntry`) — with no real DNS for those names, the operator is browsing them from the same box diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 0aa1c186..84fcd7f5 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -61,7 +61,7 @@ in their own containers can reach. That collector forwards to the swarm's - ({option}`services.hyperhive.deploy.otel`), which holds the + ({option}`services.hyperhive.deploy.otel.enable`), which holds the upstream credential and writes the swarm's store. So an agent never sees the credential, and neither does this tier. @@ -81,13 +81,13 @@ in has left the swarm. Read by the swarm's collector - ({option}`services.hyperhive.deploy.otel`), which is the only + ({option}`services.hyperhive.deploy.otel.enable`), which is the only tier that holds the upstream credential. An agent is handed the *first* hop instead — this hive's own collector — so this value is never given to a container. Optional. Leave it empty and the swarm's own metrics store - ({option}`services.hyperhive.deploy.victoriametrics`) is the + ({option}`services.hyperhive.deploy.victoriametrics.enable`) is the destination; that is a complete deployment, not a degraded one. Set both and telemetry goes to both. ''; @@ -231,7 +231,9 @@ in clientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if config.services.hyperhive.deploy.authelia && config.services.hyperhive.hiveName != null then + if + config.services.hyperhive.deploy.authelia.enable && config.services.hyperhive.hiveName != null + then "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/" + "${config.services.hyperhive.swarm.authelia.hiveClientPrefix}${config.services.hyperhive.hiveName}.secret" else @@ -579,8 +581,8 @@ in # Only when the minting container is on THIS host. Elsewhere the file # is operator-provided and there is no local unit to order against — # naming one that does not exist orders nothing, silently. - after = lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; - requires = lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; + after = lib.optional deployCfg.authelia.enable "container@${autheliaCfg.machine}.service"; + requires = lib.optional deployCfg.authelia.enable "container@${autheliaCfg.machine}.service"; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index f4728f91..14ca2659 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -345,7 +345,7 @@ let ''; in { - # `enable` moved to `services.hyperhive.deploy.authelia` — see + # `enable` moved to `services.hyperhive.deploy.authelia.enable` — see # ./deploy.nix. Whether this host runs the swarm's SSO provider is a # deployment decision; what stays here is what authelia IS, including # `url` and the OIDC client registry every hive needs as a *client* @@ -427,7 +427,7 @@ in url = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if deployCfg.authelia then "https://${cfg.domain}" else null; + default = if deployCfg.authelia.enable then "https://${cfg.domain}" else null; defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null''; example = "https://auth.example.com"; description = '' @@ -467,8 +467,8 @@ in oidc.hiveIdentities = lib.mkOption { type = lib.types.bool; - default = deployCfg.nats; - defaultText = lib.literalExpression "services.hyperhive.deploy.nats"; + default = deployCfg.nats.enable; + defaultText = lib.literalExpression "services.hyperhive.deploy.nats.enable"; description = '' Mint one machine client per hive in {option}`services.hyperhive.swarm.hives`, so each hive can @@ -810,7 +810,7 @@ in bridgeUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; readOnly = true; - default = if deployCfg.authelia then "http://127.0.0.1:${toString cfg.bridgePort}" else null; + default = if deployCfg.authelia.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null; defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null''; description = '' Where `swarm-authelia-bridge` answers, **as seen from this @@ -829,7 +829,7 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia) { + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { # The derived half of the client list, declared the same way an # operator declares one. Everything downstream then reads a single # uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart — @@ -964,7 +964,7 @@ in # Authelia's own gateway surface: the vhost that fronts it and the # name the hive resolver answers for. Both live here rather than in - # the gateway, and both are inside `deployCfg.authelia` — that guard is the + # the gateway, and both are inside `deployCfg.authelia.enable` — that guard is the # load-bearing part. # # ⚠️ Every hive in a swarm knows `authelia.url`, but only the host @@ -1009,9 +1009,11 @@ in # Written out in full rather than through a `let` binding so the gate and # the option it gates are visibly the same path: gating the wrong one is # not a build error, it is a target that is always declared or never is. - services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.otel { - authelia = "127.0.0.1:${toString cfg.metricsPort}"; - }; + services.hyperhive.swarm.otel.scrapeTargets = + lib.mkIf config.services.hyperhive.deploy.otel.enable + { + authelia = "127.0.0.1:${toString cfg.metricsPort}"; + }; # This swarm-ui quick-links entry, same guard as the vhost/DNS name # above (only the host actually running the container claims it — @@ -1397,7 +1399,7 @@ in # collector is registered — see `metricsRule` above, # which is where the reasoning for both halves lives. lib.optional forgeCfg.behindGateway metricsRule - ++ lib.optional deployCfg.swarm-ui { + ++ lib.optional deployCfg.swarm-ui.enable { domain = uiCfg.domain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index fd0e7165..dd80856b 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -25,7 +25,7 @@ let # the right behaviour is for `swarmctl user add` to fail saying the # value is unset. A guessed path would resolve cleanly and write a file # nothing reads, which is the failure mode that costs an afternoon. - autheliaEnv = lib.optionalAttrs deployCfg.authelia { + autheliaEnv = lib.optionalAttrs deployCfg.authelia.enable { # The CONFIGURED authelia, not whatever is on PATH: the argon2 # parameters baked into a hash have to match the verifier's. SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia"; @@ -130,7 +130,7 @@ let # the moment to add an explicit `publicUrl` option — not before, because # until then there is exactly one derivable answer and an option would only # be a second place to get it wrong. - webhookEnv = lib.optionalAttrs deployCfg.swarm-ui { + webhookEnv = lib.optionalAttrs deployCfg.swarm-ui.enable { SWARM_CONTROLLER_PUBLIC_URL = "https://${uiCfg.domain}"; }; @@ -174,14 +174,14 @@ let # so the two ends agree on exactly one path, `/v1/metrics`, arrived at # from opposite directions. # - # Gated on `deployCfg.otel` alone (a swarm-wide fact, not "enabled + # Gated on `deployCfg.otel.enable` alone (a swarm-wide fact, not "enabled # HERE"): the collector is reachable by name wherever it runs, so a # controller not co-located with it now exports over https instead of # exporting nothing — the graceful-absence case left is "no swarm-otel # anywhere in this swarm at all," which `forgeEnv` already models the # same way. otelSwarmCfg = config.services.hyperhive.swarm.otel; - otelEnv = lib.optionalAttrs deployCfg.otel { + otelEnv = lib.optionalAttrs deployCfg.otel.enable { OTEL_EXPORTER_OTLP_ENDPOINT = "https://${otelSwarmCfg.domain}/${otelSwarmCfg.producerName}"; # Checked by `swarm-otel.nix`'s `oidc/${producerName}` authenticator # against exactly this controller's own registered audience (see the @@ -215,7 +215,7 @@ in name = "swarm-controller"; consumers = [ "swarm-controller" ]; hostUnit = true; - enable = deployCfg.controller; + enable = deployCfg.swarm-controller.enable; }) ]; @@ -239,7 +239,7 @@ in ''; }; - # `enable` moved to `services.hyperhive.deploy.controller` — see + # `enable` moved to `services.hyperhive.deploy.swarm-controller.enable` — see # ./deploy.nix. `services.hyperhive.enableAllLocalDefaults` still # asserts it, and that was never an exception to "not derived from # services.hyperhive.enable": that mode says "this box is the whole @@ -451,7 +451,7 @@ in authBridgeUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if deployCfg.authelia then autheliaCfg.bridgeUrl else null; + default = if deployCfg.authelia.enable then autheliaCfg.bridgeUrl else null; defaultText = lib.literalExpression '' authelia's own `bridgeUrl` when this host also runs `swarm-authelia`, else null @@ -474,7 +474,7 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.controller) { + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-controller.enable) { # The daemon and the oneshot that mints its credential — the second one # failing leaves the first running and unable to authenticate anywhere. services.hyperhive.swarm.otel.journaldUnits = [ @@ -499,7 +499,7 @@ in # client list would be a second source of truth for a string whose # mismatch is an opaque 401 from the token endpoint. Same shape as the # queue's own client declaration. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia [ + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia.enable [ { id = queueClientId; description = "HyperHive swarm controller"; @@ -539,7 +539,7 @@ in services.hyperhive.swarm.controller.queue.natsUrl is unset. It defaults to loopback only when this host also runs the queue - (`services.hyperhive.deploy.nats`). A controller on its + (`services.hyperhive.deploy.nats.enable`). A controller on its own host has to be told where the queue is. ''; } diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index ed18b7f2..9e58922c 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -77,7 +77,7 @@ let # The all-local case: this host runs BOTH Grafana and the swarm's authelia, # so the minted secret can be moved without an operator. Same split the # forge and matrix modules document. - ssoLocal = deployCfg.grafana && deployCfg.authelia; + ssoLocal = deployCfg.grafana.enable && deployCfg.authelia.enable; autheliaUrl = toString autheliaCfg.url; # Where the plaintext lands inside the container. Under /var/lib rather @@ -107,7 +107,7 @@ let in { - # `enable` moved to `services.hyperhive.deploy.grafana` — see + # `enable` moved to `services.hyperhive.deploy.grafana.enable` — see # ./deploy.nix. Whether this host runs the swarm's Grafana is a # deployment decision, and `swarm.*` has to be identical on every host. # What stays here is what the service IS: its package, domain, and @@ -307,7 +307,7 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && deployCfg.grafana) { + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.grafana.enable) { # The gateway name and the quick-link, both inside `deploy.grafana` — that # guard is the load-bearing part. Every hive in a swarm may know this UI # exists, but only the host that RUNS it may claim the name; a client @@ -408,9 +408,11 @@ in # Declared here rather than in the collector's module, per that option's # rule: an entry exists where the service that named it runs. - services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.otel { - grafana = "127.0.0.1:${toString cfg.metricsPort}"; - }; + services.hyperhive.swarm.otel.scrapeTargets = + lib.mkIf config.services.hyperhive.deploy.otel.enable + { + grafana = "127.0.0.1:${toString cfg.metricsPort}"; + }; # Order the container after the host CA service so the bind source below # exists before nspawn sets the mount up. diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 8f9fb6a6..2c454f0e 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -49,7 +49,7 @@ let hostPath = name: "${secretDir}/${name}"; # The responder needs all three credentials. Gating on them rather than - # on `deployCfg.nats` keeps a half-configured hive at "queue up, denying + # on `deployCfg.nats.enable` keeps a half-configured hive at "queue up, denying # everyone" instead of "unit crash-looping on a missing file". # # In auto mode the seeds are minted on this host before the container @@ -210,7 +210,7 @@ in # per-service vhost pattern do not apply. options.services.hyperhive.swarm.nats = { - # `enable` moved to `services.hyperhive.deploy.nats` — see + # `enable` moved to `services.hyperhive.deploy.nats.enable` — see # ./deploy.nix. What stays here is what the queue IS: its domain, # ports, accounts and callout wiring. @@ -399,7 +399,7 @@ in }; }; - config = lib.mkIf deployCfg.nats { + config = lib.mkIf deployCfg.nats.enable { # The responder as well as the server: a denial reaches the client as a # timeout, so the server's own log is the only place it is an error. services.hyperhive.swarm.otel.journaldUnits = [ @@ -414,7 +414,7 @@ in # several layers from "the operator never set the issuer". assertion = cfg.autoGenerateCallout || cfg.calloutIssuerPublicKey != ""; message = '' - services.hyperhive.deploy.nats requires + services.hyperhive.deploy.nats.enable requires nats.calloutIssuerPublicKey — the public half of the account nkey that signs user JWTs for this queue. @@ -431,7 +431,7 @@ in # somebody connects. assertion = cfg.autoGenerateCallout || cfg.calloutUserPublicKey != ""; message = '' - services.hyperhive.deploy.nats requires + services.hyperhive.deploy.nats.enable requires nats.calloutUserPublicKey — the public half of the user nkey the auth-callout responder authenticates as. @@ -476,7 +476,7 @@ in { assertion = autheliaUrl != null; message = '' - services.hyperhive.deploy.nats requires + services.hyperhive.deploy.nats.enable requires services.hyperhive.swarm.authelia.url — the queue authenticates clients by validating tokens that authelia issued. @@ -506,7 +506,7 @@ in # authorization grant 'client_credentials'`. Introspection — which # is all the responder needs today — worked throughout, which is why # nothing was visibly broken while the comment was untrue. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia [ + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia.enable [ { id = cfg.clientId; description = "HyperHive swarm queue"; @@ -535,9 +535,11 @@ in # ⚠️ `swarm.otel`, not `hyperhive.otel` — two collectors one word apart, # and only this one reads `scrapeTargets`. Written in full so the gate # and the option it gates are visibly the same path. - services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.otel { - nats = "127.0.0.1:${toString cfg.metricsPort}"; - }; + services.hyperhive.swarm.otel.scrapeTargets = + lib.mkIf config.services.hyperhive.deploy.otel.enable + { + nats = "127.0.0.1:${toString cfg.metricsPort}"; + }; containers.swarm-nats = { autoStart = true; @@ -675,7 +677,7 @@ in # door nobody can open, which is the deliberate interim state. # # ⚠️ It is gated on the seeds being configured rather than on - # `deployCfg.nats`, so a half-configured hive gets a running, + # `deployCfg.nats.enable`, so a half-configured hive gets a running, # refusing queue instead of a unit that crash-loops on a # missing file. A queue that denies everyone is a legible # failure; a restart loop is not. @@ -759,7 +761,7 @@ in # being up says nothing about whether its in-container secrets unit # has finished. The wait in the script is what actually closes it; # this only stops us spinning for the full timeout on every boot. - ++ lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; + ++ lib.optional deployCfg.authelia.enable "container@${autheliaCfg.machine}.service"; requires = lib.optional cfg.autoGenerateCallout "swarm-nats-callout-keys.service"; serviceConfig = { Type = "oneshot"; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index c3ec4851..9c017675 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -164,13 +164,14 @@ let # Written once because "which exporters" is a property of this tier, not # of which hive a sample came from. exporterNames = - upstreamExporters ++ lib.optional deployCfg.victoriametrics "otlphttp/victoriametrics"; + upstreamExporters ++ lib.optional deployCfg.victoriametrics.enable "otlphttp/victoriametrics"; # The same fan-out for logs, and the local store is only ONE of its # destinations. A deployment that turns the swarm's log store off and keeps # an upstream endpoint still collects — the store is where logs may be kept, # not the reason to read the journal at all. - logExporterNames = upstreamExporters ++ lib.optional deployCfg.victorialogs "otlphttp/victorialogs"; + logExporterNames = + upstreamExporters ++ lib.optional deployCfg.victorialogs.enable "otlphttp/victorialogs"; # Collect when there is anywhere to send it, and only then. A pipeline with # an empty exporter list is not a quiet no-op — the collector rejects it — @@ -179,7 +180,7 @@ let collectLogs = logExporterNames != [ ]; in { - # `enable` moved to `services.hyperhive.deploy.otel` — see ./deploy.nix. + # `enable` moved to `services.hyperhive.deploy.otel.enable` — see ./deploy.nix. # ⚠️ That is the SWARM collector. The per-hive one keeps its own # `services.hyperhive.otel.enable` (./otel.nix) and is a different # option entirely — every hive runs that one. @@ -434,8 +435,8 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.otel) { - # The gateway name, inside `deployCfg.otel` — that guard is the load-bearing + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.otel.enable) { + # The gateway name, inside `deployCfg.otel.enable` — that guard is the load-bearing # part. Every hive in a swarm may know this collector exists, but only # the host that RUNS it may claim the name; a client hive declaring the # vhost would answer for a service it does not have. @@ -505,7 +506,7 @@ in # declaration would break every hive that runs a collector and # publishes nothing. services.hyperhive.swarm.authelia.oidc.clients = - lib.mkIf (deployCfg.authelia && cfg.publishedScrapeTargets != { }) + lib.mkIf (deployCfg.authelia.enable && cfg.publishedScrapeTargets != { }) [ { id = cfg.clientId; @@ -543,7 +544,7 @@ in # after it. On a fresh swarm that is a permanent stall presenting as # "metrics are broken", several layers from its cause. systemd.services.swarm-otel-oidc-secret = - lib.mkIf (deployCfg.authelia && cfg.publishedScrapeTargets != { }) + lib.mkIf (deployCfg.authelia.enable && cfg.publishedScrapeTargets != { }) { description = "deliver the swarm collector's OIDC client secret from authelia"; after = [ "container@${autheliaCfg.machine}.service" ]; @@ -598,12 +599,12 @@ in # The tier exists to hold the upstream credential and to write the # swarm's store. With neither, it is a process that receives # samples and drops them — which looks healthy and loses data. - assertion = otelCfg.endpoint != "" || deployCfg.victoriametrics; + assertion = otelCfg.endpoint != "" || deployCfg.victoriametrics.enable; message = '' - services.hyperhive.deploy.otel is true but this collector + services.hyperhive.deploy.otel.enable is true but this collector has nowhere to send what it receives: services.hyperhive.otel.endpoint is empty and - services.hyperhive.deploy.victoriametrics is false. + services.hyperhive.deploy.victoriametrics.enable is false. Set the endpoint to export upstream, or enable the swarm's metrics store. @@ -632,7 +633,7 @@ in # collector would listen on nothing while looking configured. assertion = hyperhiveCfg.swarm.hives != { }; message = '' - services.hyperhive.deploy.otel is true but + services.hyperhive.deploy.otel.enable is true but services.hyperhive.swarm.hives is empty: ingest is authenticated per hive, so an empty roster means this collector accepts nothing from anyone. @@ -744,7 +745,7 @@ in # issuer URL evaluates cleanly and refuses every hive at runtime. assertion = autheliaCfg.url != null; message = '' - services.hyperhive.deploy.otel is true but + services.hyperhive.deploy.otel.enable is true but services.hyperhive.swarm.authelia.url is null: every hive authenticates to this collector as itself, and the token comes from the swarm's identity provider. @@ -770,7 +771,7 @@ in cfg.producerPort otelCfg.collector.port ] - ++ lib.optional deployCfg.victoriametrics vmCfg.port; + ++ lib.optional deployCfg.victoriametrics.enable vmCfg.port; all = derived ++ others; in lib.length (lib.unique all) == lib.length all; @@ -1005,7 +1006,7 @@ in }; exporters = - lib.optionalAttrs deployCfg.victoriametrics { + lib.optionalAttrs deployCfg.victoriametrics.enable { # `metrics_endpoint`, NOT `endpoint`: the latter is a # base that otlphttp appends `/v1/metrics` to, while # VictoriaMetrics serves OTLP at @@ -1028,7 +1029,7 @@ in } // lib.optionalAttrs (otelCfg.protocol == "http/json") { encoding = "json"; }; } - // lib.optionalAttrs deployCfg.victorialogs { + // lib.optionalAttrs deployCfg.victorialogs.enable { # `logs_endpoint`, NOT `endpoint`, for exactly the reason the # metrics exporter above spells out — and the trap is worse # here, because the two stores' OTLP routes differ. `endpoint` diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index b514a56c..53308974 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -71,25 +71,27 @@ in # authelia: a swarm has one SSO provider, and this says it lives here. # With it off the hive is a *client* — `swarm.authelia.url` still points # at whoever runs it. - config.services.hyperhive.deploy.authelia = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.authelia.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The queue. Same rule: once per swarm, optional. - config.services.hyperhive.deploy.nats = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.nats.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The swarm collector that feeds the metrics pair, and the only tier # holding the upstream credential. ⚠️ NOT the per-hive collector below, # which every hive runs. - config.services.hyperhive.deploy.otel = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The metrics pair, deriving together on purpose: a store with no UI is # unreadable and a UI with no store is empty, so there is no sensible # deployment that takes one and not the other from this switch. An # operator who wants exactly one still sets it directly, which # `mkDefault` allows. - config.services.hyperhive.deploy.victoriametrics = lib.mkDefault swarmCfg.enableRequiredServices; - config.services.hyperhive.deploy.grafana = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.victoriametrics.enable = + lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.grafana.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The log store, from the same switch for the same reason as the rest: a # hive that is not the service host is a *client* of it, not a second one. - config.services.hyperhive.deploy.victorialogs = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.victorialogs.enable = + lib.mkDefault swarmCfg.enableRequiredServices; } diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 052c32ef..1cc72ec2 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -57,7 +57,7 @@ let hiveDomain = config.services.hyperhive.domain; in { - # `enable` moved to `services.hyperhive.deploy.swarm-ui` — see + # `enable` moved to `services.hyperhive.deploy.swarm-ui.enable` — see # ./deploy.nix, where it still derives from the controller's own deploy # toggle for the same reason. What stays here is what the UI IS: its # domain and wiring. @@ -99,7 +99,7 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-ui) { + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-ui.enable) { assertions = [ { # The `_` default server already answers for the hive domain diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index 737f628e..aadd8ec3 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -53,7 +53,7 @@ let ''; in { - # `enable` moved to `services.hyperhive.deploy.victorialogs` — see + # `enable` moved to `services.hyperhive.deploy.victorialogs.enable` — see # ./deploy.nix. What stays here is what the store IS: its package, # domain, retention and wiring. options.services.hyperhive.swarm.victorialogs = { @@ -121,7 +121,7 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victorialogs) { + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victorialogs.enable) { # This store publishes its own health as prometheus metrics on the same # listener it serves queries on, so the swarm's collector scrapes it with # no exporter and no extra port — same arrangement as the metrics store. @@ -134,7 +134,7 @@ in # hosts are separate evaluations. services.hyperhive.swarm.otel.scrapeTargets.victorialogs = "127.0.0.1:${toString cfg.port}"; - # The gateway name and the quick-link, both inside `deployCfg.victorialogs` — same + # The gateway name and the quick-link, both inside `deployCfg.victorialogs.enable` — same # "only the host that runs the service may claim the name" guard every # sibling swarm-service module uses (`swarm-grafana.nix`, # `swarm-victoriametrics.nix`). diff --git a/nix/host-modules/swarm-victoriametrics.nix b/nix/host-modules/swarm-victoriametrics.nix index c02def6d..3fa29305 100644 --- a/nix/host-modules/swarm-victoriametrics.nix +++ b/nix/host-modules/swarm-victoriametrics.nix @@ -29,7 +29,7 @@ let domainBase = if swarmDomain == null then "invalid" else swarmDomain; in { - # `enable` moved to `services.hyperhive.deploy.victoriametrics` — see + # `enable` moved to `services.hyperhive.deploy.victoriametrics.enable` — see # ./deploy.nix. What stays here is what the store IS: its package, # domain, retention and wiring. options.services.hyperhive.swarm.victoriametrics = { @@ -90,8 +90,8 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victoriametrics) { - # The gateway name and the quick-link, both inside `deployCfg.victoriametrics` — that + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.victoriametrics.enable) { + # The gateway name and the quick-link, both inside `deployCfg.victoriametrics.enable` — that # guard is the load-bearing part. Every hive in a swarm may know this # store exists, but only the host that RUNS it may claim the name; a # client hive declaring the vhost would answer for a service it does not diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 29d0baac..bd50d95d 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,7 +46,7 @@ let # `forge.` is, and no CA in the hierarchy issues for it # implicitly. Left out, its vhost falls back to the hive leaf and the # swarm's front page opens with a name mismatch. - ++ lib.optional deployCfg.swarm-ui swarmCfg.ui.domain + ++ lib.optional deployCfg.swarm-ui.enable swarmCfg.ui.domain # Every swarm service that claims a gateway name belongs here, and # these three were missing it. Membership is what `gateway.lib.tlsFor` # consults to pick the services leaf over the hive one, so a name @@ -58,16 +58,16 @@ let # metrics UI and store looked fine for as long as only people opened # them; the collector's exporter — same defect, no human in the loop — # failed every POST and dropped the samples. - ++ lib.optional deployCfg.grafana swarmCfg.grafana.domain - ++ lib.optional deployCfg.victoriametrics swarmCfg.victoriametrics.domain - ++ lib.optional deployCfg.otel swarmCfg.otel.domain + ++ lib.optional deployCfg.grafana.enable swarmCfg.grafana.domain + ++ lib.optional deployCfg.victoriametrics.enable swarmCfg.victoriametrics.domain + ++ lib.optional deployCfg.otel.enable swarmCfg.otel.domain # VictoriaLogs' vhost is new (was previously unpublished entirely — see # swarm-victorialogs.nix's file-top comment) and needs the same # membership every gateway-published swarm service needs: absent from # this list, `gateway.lib.tlsFor` falls back to the hive leaf, which # cannot cover a name under a different apex — see the ⚠️ above this # list for what that looked like the last time a name was missed here. - ++ lib.optional deployCfg.victorialogs swarmCfg.victorialogs.domain; + ++ lib.optional deployCfg.victorialogs.enable swarmCfg.victorialogs.domain; # Hives whose entry still carries the removed `certFingerprint`. Scanned # here, at top level, because that is the only place an assertion about a @@ -96,7 +96,7 @@ let # unrepresentable rather than merely detected: a default set is all or # nothing, and the assertion is then only ever about what an operator # typed. - queueLocal = deployCfg.nats && deployCfg.authelia && cfg.hiveName != null; + queueLocal = deployCfg.nats.enable && deployCfg.authelia.enable && cfg.hiveName != null; in { options.services.hyperhive.swarm.hives = lib.mkOption { diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 94951047..9f2e6435 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -90,11 +90,11 @@ let cases = [ { name = "a hive that has not opted into all-local runs no swarm controller"; - ok = !bare.services.hyperhive.deploy.controller; + ok = !bare.services.hyperhive.deploy.swarm-controller.enable; } { name = "the all-local mode turns the swarm controller on"; - ok = allLocal.services.hyperhive.deploy.controller; + ok = allLocal.services.hyperhive.deploy.swarm-controller.enable; } { # The gateway's per-name issuer choice. If this ever collapses to a diff --git a/swarm-controller/README.md b/swarm-controller/README.md index e484ae2a..da5fd893 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -4,7 +4,7 @@ The **swarm-level** daemon. Where `hive-c0re` owns the agents on one host, this owns what is true *across* hives — so a swarm runs one of them and most hives leave it off. -Opt-in per host via `services.hyperhive.deploy.controller`, which is +Opt-in per host via `services.hyperhive.deploy.swarm-controller.enable`, which is deliberately **not** derived from `services.hyperhive.enable`: turning it on is a statement about swarm topology, not about whether hyperhive is installed. diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index e3df593f..c058c696 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1,6 +1,6 @@ //! Swarm-level controller daemon. Runs as the unprivileged //! `swarm-controller` user on whichever host the operator flips -//! `services.hyperhive.deploy.controller` on, and serves HTTP over a +//! `services.hyperhive.deploy.swarm-controller.enable` on, and serves HTTP over a //! unix socket that the hive-gateway's nginx proxies to. //! //! Configuration is read-only and loaded once at startup from env vars the From 13b1b41172d9bac02f906000105cbb6fdcb3cfea Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 04:11:57 +0200 Subject: [PATCH 09/10] local-defaults: assert the controller through deploy, not the old alias Round-3 review catch: the all-local mode still set `services.hyperhive.swarm.controller.enable` -- the exact path this branch's own `mkRenamedOptionModule` deprecates. It forwards correctly, so nothing broke, but every eval with `enableAllLocalDefaults = true` printed a deprecation warning and the tree depended on the shim it is retiring. It survived the rewrite because the path is SPLIT ACROSS NESTING: `swarm = { controller.enable = ...; }`. No grep for a dotted path can match text that is not contiguous -- not the prefix-anchored pattern the rewrite used, and not the suffix-anchored one added after that missed hive-tls.nix. What finds it is the bare tail with no prefix at all (`^\s*controller\.enable\s*=`), which reports exactly one hit outside deploy.nix's declarations. The assignment moves out of the `swarm` attrset rather than staying in it: `deploy.*` is a different top-level path, so the collision the neighbouring warning describes -- two definitions of `swarm`, the nested one silently lost -- does not apply. --- nix/host-modules/local-defaults.nix | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index 732c43c7..5034eee5 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -80,15 +80,6 @@ in # reach whoever runs the responder, and minting them here would move # that hand-off somewhere less visible rather than removing it. nats.autoGenerateCallout = lib.mkDefault cfg.enableAllLocalDefaults; - # The controller is asserted by the MODE and by nothing else. Its own - # option stays `default = false` precisely because running it is a - # statement about swarm topology — but "this box is the whole - # deployment" IS that statement, and it is the one shape where the - # answer isn't ambiguous. Deriving it from `enableRequiredServices` - # instead would be wrong: a hive in a larger swarm can legitimately - # want the shared services without being the host that controls them. - controller.enable = lib.mkDefault cfg.enableAllLocalDefaults; - # The controller's queue coordinates. Co-location is what makes these # derivable at all — loopback only reaches the queue when the queue is # here, and the minted client secret only exists on the host authelia @@ -117,4 +108,19 @@ in lib.mkDefault "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/swarm-controller.secret" ); }; + + # The controller is asserted by the MODE and by nothing else. Its own + # option stays `default = false` precisely because running it is a + # statement about swarm topology — but "this box is the whole + # deployment" IS that statement, and it is the one shape where the + # answer isn't ambiguous. Deriving it from `enableRequiredServices` + # instead would be wrong: a hive in a larger swarm can legitimately + # want the shared services without being the host that controls them. + # + # Sits outside the `swarm` attrset above because it is a `deploy.*` + # option (./deploy.nix): "does THIS host run the controller" is exactly + # the per-host fact `swarm.*` may not carry. The ⚠️ collision note above + # does not apply here — that one is about two definitions of `swarm` + # itself, and this is a different top-level path. + config.services.hyperhive.deploy.swarm-controller.enable = lib.mkDefault cfg.enableAllLocalDefaults; } From 09fe94a8ca18d3e00510099604c60a7cb52e8018 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 04:14:20 +0200 Subject: [PATCH 10/10] deploy: name the swarm collector swarm-otel, not otel Per review: `deploy.otel` does not imply swarm level, and there is a hive-tier collector too -- `services.hyperhive.otel.enable`, which every hive runs. The tier is the entire distinction between the two, so the name has to carry it, matching `deploy.swarm-controller` and `deploy.swarm-ui`. 15 swarm-tier references renamed across 8 files. The hive-tier collector's 16 references are deliberately untouched, verified as a control on the same command. Three spellings needed three different patterns, all inside this one rename: the dotted path; the `mkRenamedOptionModule` target written as a nix LIST (`[ ... "deploy" "otel" "enable" ]`), which no dotted grep can match; and prose in docs/observability.md spelled `deploy.otel` with neither `.enable` nor a leading dot. Unanchored `deploy\.otel\b` is the only pattern that finds all three. --- docs/observability.md | 2 +- nix/host-modules/deploy.nix | 15 ++++++++------- nix/host-modules/otel.nix | 4 ++-- nix/host-modules/swarm-authelia.nix | 2 +- nix/host-modules/swarm-controller.nix | 4 ++-- nix/host-modules/swarm-grafana.nix | 2 +- nix/host-modules/swarm-nats.nix | 2 +- nix/host-modules/swarm-otel.nix | 12 ++++++------ nix/host-modules/swarm-required-services.nix | 2 +- nix/host-modules/swarm.nix | 2 +- 10 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 13b3d649..cb2f2b21 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -80,7 +80,7 @@ the host is: | | runs where | receives from | does | |---|---|---|---| | **hive tier** — `otel.enable` | every hive with agents | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential, picks no destination | -| **swarm tier** — `deploy.otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream | +| **swarm tier** — `deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream | An all-local host runs both, and needs nothing said about the hop between them. diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index a07e62d3..8ae9ae71 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -67,7 +67,7 @@ in ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "otel" "enable" ] - [ "services" "hyperhive" "deploy" "otel" "enable" ] + [ "services" "hyperhive" "deploy" "swarm-otel" "enable" ] ) ]; @@ -131,7 +131,7 @@ in ''; }; - otel.enable = lib.mkOption { + swarm-otel.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' @@ -142,11 +142,12 @@ in metrics pair it feeds: a swarm has one of these, and it belongs wherever the shared services live rather than on every hive. - ⚠️ Not to be confused with - {option}`services.hyperhive.otel.enable`, the **hive-tier** - collector, which every hive runs and which is a different option. - A hive that does not run the swarm collector still runs its own, - and reaches this one by name at + Named `swarm-otel` rather than `otel` because there are two + collectors and the tier is the whole distinction: + {option}`services.hyperhive.otel.enable` is the **hive-tier** one, + which every hive runs. A bare `deploy.otel` would not say which + it meant. A hive that does not run the swarm collector still runs + its own, and reaches this one by name at {option}`services.hyperhive.swarm.otel.domain`. ''; }; diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 84fcd7f5..19a44f6a 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -61,7 +61,7 @@ in their own containers can reach. That collector forwards to the swarm's - ({option}`services.hyperhive.deploy.otel.enable`), which holds the + ({option}`services.hyperhive.deploy.swarm-otel.enable`), which holds the upstream credential and writes the swarm's store. So an agent never sees the credential, and neither does this tier. @@ -81,7 +81,7 @@ in has left the swarm. Read by the swarm's collector - ({option}`services.hyperhive.deploy.otel.enable`), which is the only + ({option}`services.hyperhive.deploy.swarm-otel.enable`), which is the only tier that holds the upstream credential. An agent is handed the *first* hop instead — this hive's own collector — so this value is never given to a container. diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 14ca2659..4c8ba9e7 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1010,7 +1010,7 @@ in # the option it gates are visibly the same path: gating the wrong one is # not a build error, it is a target that is always declared or never is. services.hyperhive.swarm.otel.scrapeTargets = - lib.mkIf config.services.hyperhive.deploy.otel.enable + lib.mkIf config.services.hyperhive.deploy.swarm-otel.enable { authelia = "127.0.0.1:${toString cfg.metricsPort}"; }; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index dd80856b..fbc5a3f3 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -174,14 +174,14 @@ let # so the two ends agree on exactly one path, `/v1/metrics`, arrived at # from opposite directions. # - # Gated on `deployCfg.otel.enable` alone (a swarm-wide fact, not "enabled + # Gated on `deployCfg.swarm-otel.enable` alone (a swarm-wide fact, not "enabled # HERE"): the collector is reachable by name wherever it runs, so a # controller not co-located with it now exports over https instead of # exporting nothing — the graceful-absence case left is "no swarm-otel # anywhere in this swarm at all," which `forgeEnv` already models the # same way. otelSwarmCfg = config.services.hyperhive.swarm.otel; - otelEnv = lib.optionalAttrs deployCfg.otel.enable { + otelEnv = lib.optionalAttrs deployCfg.swarm-otel.enable { OTEL_EXPORTER_OTLP_ENDPOINT = "https://${otelSwarmCfg.domain}/${otelSwarmCfg.producerName}"; # Checked by `swarm-otel.nix`'s `oidc/${producerName}` authenticator # against exactly this controller's own registered audience (see the diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 9e58922c..4c4b159b 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -409,7 +409,7 @@ in # Declared here rather than in the collector's module, per that option's # rule: an entry exists where the service that named it runs. services.hyperhive.swarm.otel.scrapeTargets = - lib.mkIf config.services.hyperhive.deploy.otel.enable + lib.mkIf config.services.hyperhive.deploy.swarm-otel.enable { grafana = "127.0.0.1:${toString cfg.metricsPort}"; }; diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 2c454f0e..8e98cac2 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -536,7 +536,7 @@ in # and only this one reads `scrapeTargets`. Written in full so the gate # and the option it gates are visibly the same path. services.hyperhive.swarm.otel.scrapeTargets = - lib.mkIf config.services.hyperhive.deploy.otel.enable + lib.mkIf config.services.hyperhive.deploy.swarm-otel.enable { nats = "127.0.0.1:${toString cfg.metricsPort}"; }; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 9c017675..baf64d13 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -180,7 +180,7 @@ let collectLogs = logExporterNames != [ ]; in { - # `enable` moved to `services.hyperhive.deploy.otel.enable` — see ./deploy.nix. + # `enable` moved to `services.hyperhive.deploy.swarm-otel.enable` — see ./deploy.nix. # ⚠️ That is the SWARM collector. The per-hive one keeps its own # `services.hyperhive.otel.enable` (./otel.nix) and is a different # option entirely — every hive runs that one. @@ -435,8 +435,8 @@ in }; }; - config = lib.mkIf (config.services.hyperhive.enable && deployCfg.otel.enable) { - # The gateway name, inside `deployCfg.otel.enable` — that guard is the load-bearing + config = lib.mkIf (config.services.hyperhive.enable && deployCfg.swarm-otel.enable) { + # The gateway name, inside `deployCfg.swarm-otel.enable` — that guard is the load-bearing # part. Every hive in a swarm may know this collector exists, but only # the host that RUNS it may claim the name; a client hive declaring the # vhost would answer for a service it does not have. @@ -601,7 +601,7 @@ in # samples and drops them — which looks healthy and loses data. assertion = otelCfg.endpoint != "" || deployCfg.victoriametrics.enable; message = '' - services.hyperhive.deploy.otel.enable is true but this collector + services.hyperhive.deploy.swarm-otel.enable is true but this collector has nowhere to send what it receives: services.hyperhive.otel.endpoint is empty and services.hyperhive.deploy.victoriametrics.enable is false. @@ -633,7 +633,7 @@ in # collector would listen on nothing while looking configured. assertion = hyperhiveCfg.swarm.hives != { }; message = '' - services.hyperhive.deploy.otel.enable is true but + services.hyperhive.deploy.swarm-otel.enable is true but services.hyperhive.swarm.hives is empty: ingest is authenticated per hive, so an empty roster means this collector accepts nothing from anyone. @@ -745,7 +745,7 @@ in # issuer URL evaluates cleanly and refuses every hive at runtime. assertion = autheliaCfg.url != null; message = '' - services.hyperhive.deploy.otel.enable is true but + services.hyperhive.deploy.swarm-otel.enable is true but services.hyperhive.swarm.authelia.url is null: every hive authenticates to this collector as itself, and the token comes from the swarm's identity provider. diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 53308974..8e8e5fc1 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -79,7 +79,7 @@ in # The swarm collector that feeds the metrics pair, and the only tier # holding the upstream credential. ⚠️ NOT the per-hive collector below, # which every hive runs. - config.services.hyperhive.deploy.otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; + config.services.hyperhive.deploy.swarm-otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The metrics pair, deriving together on purpose: a store with no UI is # unreadable and a UI with no store is empty, so there is no sensible diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index bd50d95d..9bedc776 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -60,7 +60,7 @@ let # failed every POST and dropped the samples. ++ lib.optional deployCfg.grafana.enable swarmCfg.grafana.domain ++ lib.optional deployCfg.victoriametrics.enable swarmCfg.victoriametrics.domain - ++ lib.optional deployCfg.otel.enable swarmCfg.otel.domain + ++ lib.optional deployCfg.swarm-otel.enable swarmCfg.otel.domain # VictoriaLogs' vhost is new (was previously unpublished entirely — see # swarm-victorialogs.nix's file-top comment) and needs the same # membership every gateway-published swarm service needs: absent from