From 0b7357d4b80c5ef31b956b70094bfd264c532b38 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:22:42 +0200 Subject: [PATCH] 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