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