diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index a1129e00..ce7ec349 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -104,12 +104,21 @@ opening a popover of links to other swarm-wide services. Backed by `services.hyperhive.swarm.controller.links` (a `listOf { label, icon, url }`, same shape as the per-agent `services.hyperhive.agent.dashboardLinks`). -Each service's own module contributes its entry when it's enabled on the -controller's host — `swarm-authelia.nix`, `hive-matrix.nix`, -`hive-forge/default.nix`, `swarm-grafana.nix`, `swarm-victorialogs.nix`, and -`swarm-ui.nix` for this UI's own API docs. Adding a link for a new service is -a nix-only change to that service's module, or an operator adding an entry -directly. An empty list hides the button. +Links to swarm services come from swarm-level options, so the list is the +same whichever host runs each service: + +| link | URL built from | +| -------- | ------------------------------------------------- | +| Authelia | `services.hyperhive.swarm.authelia.domain` | +| Grafana | `services.hyperhive.swarm.grafana.domain` | +| Metrics | `services.hyperhive.swarm.victoriametrics.domain` | +| Logs | `services.hyperhive.swarm.victorialogs.domain` | + +`nix/host-modules/swarm-controller.nix` builds these entries. The +Matrix and Forge entries, and `swarm-ui.nix`'s entry for this UI's own API +docs, come from those services' own modules, and only when the controller's +host runs that service. An operator can add entries directly. An +empty list hides the button. The **Matrix** entry opens the swarm's matrix web client (fluffychat, `services.hyperhive.deploy.matrix.gui.package`) at the homeserver's diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index f42a971f..c5b2ee5c 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -700,18 +700,6 @@ in 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 — - # see `services.hyperhive.swarm.controller.links`'s description for - # the contribute-your-own-entry idiom). - services.hyperhive.swarm.controller.links = [ - { - label = "Authelia"; - icon = "🔑"; - url = "https://${cfg.domain}/"; - } - ]; - # `server_name = authelia.domain`, all of `/` → authelia. # # ⚠️ The server name must be exactly `cfg.domain`, not a near-miss: diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index dda27489..c97a81e6 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -18,6 +18,40 @@ let deployCfg = config.services.hyperhive.deploy; autheliaCfg = config.services.hyperhive.swarm.authelia; natsCfg = config.services.hyperhive.swarm.nats; + swarmCfg = config.services.hyperhive.swarm; + + # Quick links to swarm services, built from `swarm.*` options alone: every + # host evaluates the same entries, whichever host runs each service. One + # entry per service; reading a `deploy.*` option here would tie a link to + # where its service runs. + swarmServiceLinks = + map + (s: { + inherit (s) label icon; + url = "https://${s.domain}/"; + }) + [ + { + label = "Authelia"; + icon = "🔑"; + inherit (swarmCfg.authelia) domain; + } + { + label = "Grafana"; + icon = "📊"; + inherit (swarmCfg.grafana) domain; + } + { + label = "Metrics"; + icon = "📈"; + inherit (swarmCfg.victoriametrics) domain; + } + { + label = "Logs"; + icon = "📜"; + inherit (swarmCfg.victorialogs) domain; + } + ]; # Where the secret store is, and whether this host holds the controller's # own leaf for it. ⚠️ The controller's pair, NOT `deploy.bao.clientCertFile` @@ -411,21 +445,18 @@ in links menu (`GET /api/links`). Same shape and same zero-code-change-to-extend idea as `hyperhive.dashboardLinks` (`nix/agent-modules/dashboard-links.nix`), one level up: rather - than one central hardcoded list, each service's own module - contributes its own entry when it is actually enabled on this - host — `swarm-authelia.nix`, `hive-matrix.nix` and - `hive-forge/default.nix` all do — the same list-merge idiom - `services.hyperhive.gateway.localNames` already uses. A future - service module can push its own entry the same way, and an - operator can add arbitrary extra entries here directly; neither - needs a swarm-controller or swarm-ui change. + than one fixed list, definitions merge with the list-merge idiom + `services.hyperhive.gateway.localNames` uses, so an operator can + add arbitrary extra entries here directly with no swarm-controller + or swarm-ui change. - Only meaningful on the host that actually runs the controller — - entries contributed on any other host are computed but never - read. In a swarm that splits `swarm-authelia`/`hive-matrix`/ - `hive-forge` across hosts other than the controller's, this list - only reflects what is enabled locally; see each contributing - module's own activation condition. + The Authelia, Grafana, Metrics and Logs entries come from + `services.hyperhive.swarm..domain`, so they are present + whichever host runs each service. `hive-matrix.nix`, + `hive-forge/default.nix` and `swarm-ui.nix` contribute their own + entries, and only where they are enabled on this host. + + Read only on the host that runs the controller. ''; }; @@ -643,6 +674,8 @@ in }; config = lib.mkIf deployCfg.swarm-controller.enable { + services.hyperhive.swarm.controller.links = swarmServiceLinks; + users.users.swarm-controller = { isSystemUser = true; group = "swarm-controller"; diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 547ed043..2de1b2b3 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -399,14 +399,6 @@ in services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true; - services.hyperhive.swarm.controller.links = [ - { - label = "Grafana"; - icon = "📊"; - url = "https://${cfg.domain}/"; - } - ]; - # Registering the client is NOT here any more: it has to happen on the # host that runs authelia, and this whole block is gated on the host that # runs Grafana. ./glue-grafana-oidc-client.nix is where it moved to. diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index a295fd21..713f2e4d 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -112,14 +112,6 @@ in services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true; - services.hyperhive.swarm.controller.links = [ - { - label = "Logs"; - icon = "📜"; - url = "https://${cfg.domain}/"; - } - ]; - # Authenticated front door onto the loopback-only store — see the # file-top comment for why this is safe to add without touching the # store's own (still unauthenticated, still loopback) listener at all. diff --git a/nix/host-modules/swarm-victoriametrics.nix b/nix/host-modules/swarm-victoriametrics.nix index de8501db..7fbf9b22 100644 --- a/nix/host-modules/swarm-victoriametrics.nix +++ b/nix/host-modules/swarm-victoriametrics.nix @@ -71,14 +71,6 @@ in services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true; - services.hyperhive.swarm.controller.links = [ - { - label = "Metrics"; - icon = "📈"; - url = "https://${cfg.domain}/"; - } - ]; - # This store publishes its own health as prometheus metrics on the same # listener it serves queries on, so the swarm's collector can scrape it # with no exporter and no extra port. diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 3d589ac8..7d68a1c2 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -95,6 +95,34 @@ let deploy.forgejo.mirrors = [ aMirror ]; }; + # The controller with every shared service on another host, and the same + # controller hosting them all: the swarm UI's links must not tell the two + # apart. + controllerAlone = hive { deploy.swarm-controller.enable = true; }; + controllerWithServices = hive { + deploy.swarm-controller.enable = true; + deploy.allSwarmServices = true; + }; + + # Each shared service with a web UI, and the link it must have: the label + # and the swarm-level domain the URL is built from. + swarmServiceLinkDomains = + let + s = controllerAlone.services.hyperhive.swarm; + in + { + Authelia = s.authelia.domain; + Grafana = s.grafana.domain; + Metrics = s.victoriametrics.domain; + Logs = s.victorialogs.domain; + # forge: added once hive-forge/default.nix drops its per-host link + # matrix: added once its GUI gate is settled + # bao: added once its UI gate is settled + }; + swarmServiceLinksOf = + cfg: + lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links; + # Every service container at once: the services-only host plus the CI # runner, the one container on a netns of its own. serviceContainersWithCi = hive { @@ -194,6 +222,23 @@ let && !(swarmServicesOnly.systemd.sockets ? hive-priv) && !(s ? swarm-bao-queue-agent); } + { + # On a controller that runs none of them, so the link cannot come from + # the service's own module. + name = "the swarm UI links every shared service with a web UI at its swarm domain"; + ok = + lib.listToAttrs (map (l: lib.nameValuePair l.label l.url) (swarmServiceLinksOf controllerAlone)) + == lib.mapAttrs (_: domain: "https://${domain}/") swarmServiceLinkDomains; + } + { + # Exact list equality also catches an entry rendered twice where the + # service runs on the controller's own host. + name = "the swarm UI's service links are the same whichever host runs the services"; + ok = + lib.length (swarmServiceLinksOf controllerAlone) + == lib.length (lib.attrNames swarmServiceLinkDomains) + && swarmServiceLinksOf controllerWithServices == swarmServiceLinksOf controllerAlone; + } { # The in-container option drives the container's firewall, and host # `false` with container `true` would let the container's