diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index c87c0320..5a20473f 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -12,7 +12,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo | URL | upstream | declared by, when | | --- | --- | --- | -| `/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` | +| `/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-controller.enable` | | `auth./` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` | | `auth./bridge/*` | swarm-authelia-bridge (`9092`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` | | `forge./` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` | diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index fac28025..09926687 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -334,10 +334,10 @@ Where each credential lives and who reads it: ## Enabling ```nix -services.hyperhive.deploy.swarm-ui.enable = true; # defaults to deploy.swarm-controller.enable +services.hyperhive.deploy.swarm-controller.enable = true; # the UI comes with it ``` -Derived from the controller rather than from `allSwarmServices`: +The UI has no switch of its own, and doesn't follow `allSwarmServices`: the UI is a view onto the controller's state and reaches it over that daemon's 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. diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 972c5079..bfa5a83c 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -28,9 +28,13 @@ # spread across the service modules, so all of them have a single home # and a single file to delete when the deprecation window closes — the # shape ./swarm-peers-removed.nix already uses. -{ lib, config, ... }: +{ lib, ... }: let - deployCfg = config.services.hyperhive.deploy; + # Shared by the two removed spellings of the swarm UI's own switch below. + swarmUiSwitchRemoved = '' + swarm-ui now runs wherever swarm-controller runs. Remove this + definition; services.hyperhive.deploy.swarm-controller.enable decides it. + ''; # Shared by the two removed spellings of the status-publishing secret below. statusSecretRemoved = '' @@ -94,10 +98,20 @@ in be deleted. '' ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "ui" "enable" ] - [ "services" "hyperhive" "deploy" "swarm-ui" "enable" ] - ) + (lib.mkRemovedOptionModule [ + "services" + "hyperhive" + "swarm" + "ui" + "enable" + ] swarmUiSwitchRemoved) + (lib.mkRemovedOptionModule [ + "services" + "hyperhive" + "deploy" + "swarm-ui" + "enable" + ] swarmUiSwitchRemoved) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "authelia" "enable" ] [ "services" "hyperhive" "deploy" "authelia" "enable" ] @@ -436,9 +450,8 @@ in [ "services" "hyperhive" "swarm" "victoriametrics" "package" ] [ "services" "hyperhive" "deploy" "victoriametrics" "package" ] ) - # With this one `services.hyperhive.swarm.ui` holds nothing at all — its - # two options are both renamed, so the namespace exists only as this - # entry and the `enable` one above it. + # With this one `services.hyperhive.swarm.ui` holds nothing at all: its + # `enable` is removed above and its `package` is renamed here. (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "ui" "package" ] [ "services" "hyperhive" "deploy" "swarm-ui" "package" ] @@ -688,23 +701,11 @@ in {option}`services.hyperhive.deploy.hive-controller.enable`: a swarm has one controller, so running it is a decision about this host rather than about whether it runs a hive. - ''; - }; - swarm-ui.enable = lib.mkOption { - type = lib.types.bool; - 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.swarm-controller.enable`: 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. + Also serves the swarm UI from this host. 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 only host that can + serve it. ''; }; }; diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 206093a6..684f5415 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -623,7 +623,7 @@ in # swarm's host was the missing one, and every swarm-controller # delivery was refused for it. # - # Not gated on `deploy.swarm-ui.enable`: in a spread deployment the + # Not gated on `deploy.swarm-controller.enable`: in a spread deployment the # forge and the swarm host are different machines, so that # per-host flag is false exactly where this entry is needed. webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," ( diff --git a/nix/host-modules/hive-network.nix b/nix/host-modules/hive-network.nix index f5f42072..1cc02e37 100644 --- a/nix/host-modules/hive-network.nix +++ b/nix/host-modules/hive-network.nix @@ -40,7 +40,6 @@ let lib.any lib.id [ d.hive-controller.enable d.swarm-controller.enable - d.swarm-ui.enable d.forgejo.enable d.ci-runner.enable d.matrix.enable diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 2c465c9b..014bc10f 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1107,7 +1107,7 @@ in [ metricsRule ] # Also guarded on the domain being set: without it a null # apex would render a rule matching the string "null". - ++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) { + ++ lib.optional (deployCfg.swarm-controller.enable && swarmDomain != null) { domain = swarmDomain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index ec5e8f82..f1e2d1de 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -204,9 +204,9 @@ let }; # How the forge must address this controller to deliver a swarm-wide - # webhook. Gated on the swarm UI being served *here*, because that module - # is what declares the vhost and the `/webhook/forge/` location inside it: - # absent, nothing outside this host can reach the endpoint. + # webhook. ./swarm-ui.nix declares the vhost and the `/webhook/forge/` + # location inside it on every host that runs this daemon, so the only gate + # left is a swarm domain to name it by. # # 🔑 The gate is the point, not a detail. The daemon registers hooks only # when this is set, and a hook whose `target_url` nothing answers is worse @@ -216,17 +216,15 @@ 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). `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. + # daemon binds no TCP address at all (see the header). # # ⇒ The day this daemon grows a TCP listener so another host can front it, - # THIS LINE SILENTLY STOPS BEING RIGHT: the env goes unset on the controller - # host, registration quietly does not happen, and nothing errors. That is + # THIS LINE SILENTLY STOPS BEING RIGHT: hooks get registered against a vhost + # that may no longer be the one fronting the daemon, and nothing errors. That is # 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.enable && swarmDomain != null) { + webhookEnv = lib.optionalAttrs (swarmDomain != null) { SWARM_CONTROLLER_PUBLIC_URL = "https://${swarmDomain}"; }; @@ -469,8 +467,8 @@ in `services.hyperhive.swarm.bao.ui.domain`, and the Matrix entry from `services.hyperhive.swarm.matrix.gatewayHost` when it is set, so they are present whichever host runs each service. - `swarm-ui.nix` contributes its own API-docs entry wherever the - swarm UI is enabled on this host. + `swarm-ui.nix` contributes its own API-docs entry, since the + swarm UI runs wherever the controller does. Read only on the host that runs the controller. ''; diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 7cba8a8d..998286f7 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -66,10 +66,10 @@ let apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain; in { - # `enable` is `services.hyperhive.deploy.swarm-ui.enable` — see - # ./deploy.nix, where it derives from the controller's own deploy - # toggle for the same reason. There is no `domain` option: the UI answers - # on the swarm apex, which the swarm already declares once. + # No `enable` of its own: the UI is on wherever + # `services.hyperhive.deploy.swarm-controller.enable` is, because it reaches + # that daemon over its unix socket. There is no `domain` option: the UI + # answers on the swarm apex, which the swarm already declares once. imports = [ (lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "ui" "domain" ] '' The swarm UI answers on services.hyperhive.swarm.domain and nothing @@ -83,9 +83,9 @@ in '') ]; - # ⚠️ `services.hyperhive.swarm.ui` is not a namespace. Its two options, - # `enable` and `package`, live under `deploy.swarm-ui`, and the `swarm.ui` - # paths survive purely as renames in ./deploy.nix. + # ⚠️ `services.hyperhive.swarm.ui` is not a namespace. Its `package` lives + # under `deploy.swarm-ui`, and the `swarm.ui` paths survive purely as + # rename/removal entries in ./deploy.nix. options.services.hyperhive.deploy.swarm-ui.package = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui"; @@ -102,7 +102,7 @@ in ''; }; - config = lib.mkIf deployCfg.swarm-ui.enable { + config = lib.mkIf deployCfg.swarm-controller.enable { assertions = [ { # The `_` default server already answers for the hive domain diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 1a030fac..09d3bdf1 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -631,11 +631,11 @@ let ok = !(bare.containers ? swarm-otel) && !(centralToggleOff.containers ? swarm-otel); } { - # `deploy.swarm-ui.enable`, which defaults to the controller's toggle — - # derived from a sibling deployment decision, still not from the hive - # toggle. `t.local` is the fixtures' swarm domain, which is the apex the - # UI claims; the arm below is what proves this vhost renders at all. - name = "the swarm UI vhost is absent unless this host is told to serve it"; + # The swarm UI follows `deploy.swarm-controller.enable` — a sibling + # deployment decision, not the hive toggle. `t.local` is the fixtures' + # swarm domain, which is the apex the UI claims; the arm below is what + # proves this vhost renders at all. + name = "the swarm UI vhost is absent unless this host runs the swarm controller"; ok = !(bare.services.nginx.virtualHosts ? "t.local") && !(centralToggleOff.services.nginx.virtualHosts ? "t.local"); diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 1b9d9b21..d7be255b 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -70,13 +70,19 @@ let network.exposeHostPorts = [ 5432 ]; }; - # The swarm UI where its own toggle is on — the control the absence arm + # The swarm UI on the controller's host — the control the absence arm # needs, since nothing else in this suite renders this vhost and an arm # saying "it is not there" would hold just as well if it were never there. # Package stubbed per this file's header: the vhost roots at it. + # + # Also sets both removed UI switches to `false`: the vhost must render + # regardless, and each must fail evaluation through its assertion. Neither + # is read anywhere else, so they change nothing the other arms see. swarmUiHere = hive { - deploy.swarm-ui.enable = true; + deploy.swarm-controller.enable = true; deploy.swarm-ui.package = pkgs.emptyDirectory; + deploy.swarm-ui.enable = false; + swarm.ui.enable = false; }; # The swarm's shared services hosted HERE without the all-local mode — a @@ -224,7 +230,7 @@ let name = "hosting the swarm's shared services does not make a hive the swarm's control plane"; ok = !swarmServicesHere.services.hyperhive.deploy.swarm-controller.enable - && !swarmServicesHere.services.hyperhive.deploy.swarm-ui.enable; + && !(swarmServicesHere.services.nginx.virtualHosts ? "t.local"); } { # Control for the arm above, and the one place this slice is not inert: @@ -235,9 +241,29 @@ let ok = builtins.elem 5432 (bridgePortsOrNone exposedPortsNoHive); } { - name = "the swarm UI claims the swarm apex where this host serves it"; + name = "the swarm UI claims the swarm apex wherever this host runs the swarm controller"; ok = swarmUiHere.services.nginx.virtualHosts ? "t.local"; } + { + # Control: `swarmServicesHere` sets neither removed switch. + name = "a definition of a removed swarm UI switch fails evaluation, naming the option and the controller"; + ok = + let + refused = + m: path: + lib.any ( + a: + !a.assertion + && lib.hasInfix "services.hyperhive.${path}" a.message + && lib.hasInfix "swarm-ui now runs wherever swarm-controller runs" a.message + ) m.assertions; + paths = [ + "deploy.swarm-ui.enable" + "swarm.ui.enable" + ]; + in + lib.all (refused swarmUiHere) paths && !(lib.any (refused swarmServicesHere) paths); + } { # The swarm-services toggle enables them explicitly, from its own # module rather than from any of their defaults.