From d3b40da1c81abda46c657901d7244dead426a29f Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 04:05:28 +0200 Subject: [PATCH] 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