From eed53a2b5916e293f8a6f5ba70c727d0f90dd3de Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 1 Oct 2026 09:30:21 +0200 Subject: [PATCH] nix: split swarm-grafana into service and deploy-mode files `swarm.grafana` (what the metrics UI is to every hive: container name, domain, metrics port, OIDC client) moves to nix/host-modules/swarm-grafana-service.nix, together with `domainBase`, the only helper it reads besides `cfg`. Everything else -- the `deploy.grafana` options, the whole `config` block including `containers.swarm-grafana`, and the helpers only they read -- stays in nix/host-modules/swarm-grafana.nix, which default.nix now imports alongside the new file. Both halves read `cfg` (`swarm.grafana.oidc.redirectUri` defaults from `cfg.domain`; the config block reads `cfg` throughout). It is an option read, so each file binds it from `config.services.hyperhive.swarm.grafana`. The service file has no `hyperhiveCfg`, so its `swarmDomain` reads `config.services.hyperhive.swarm.domain` directly, as swarm-nats-service.nix does. A pure move: option paths, option definitions and config are unchanged apart from the two comments on either side of the cut, which now name the file the other half lives in. Refs #3742 --- nix/host-modules/default.nix | 1 + nix/host-modules/swarm-grafana-service.nix | 139 +++++++++++++++++++++ nix/host-modules/swarm-grafana.nix | 136 +------------------- 3 files changed, 145 insertions(+), 131 deletions(-) create mode 100644 nix/host-modules/swarm-grafana-service.nix diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index f50157be..76b912ec 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -47,6 +47,7 @@ ./swarm-nats-service.nix ./swarm-nats.nix ./swarm-controller.nix + ./swarm-grafana-service.nix ./swarm-grafana.nix ./swarm-otel.nix ./swarm-snapshot-store.nix diff --git a/nix/host-modules/swarm-grafana-service.nix b/nix/host-modules/swarm-grafana-service.nix new file mode 100644 index 00000000..c302f231 --- /dev/null +++ b/nix/host-modules/swarm-grafana-service.nix @@ -0,0 +1,139 @@ +# The swarm's metrics UI as every hive sees it: the name it is served under, +# the port its `/metrics` is re-served on, and the OIDC client it is +# registered as, identical on every host. What the host running it decides, +# and the container itself, are in ./swarm-grafana.nix. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.swarm.grafana; + swarmDomain = config.services.hyperhive.swarm.domain; + + # Total on a null swarm domain for the same reason every sibling module is: + # the required-domain assertion in hive-network.nix should be what an + # operator sees, not a coercion error from here. + domainBase = if swarmDomain == null then "invalid" else swarmDomain; +in +{ + # `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. + # Here is what the service IS to every hive: the name it is served under, + # the port its `/metrics` is re-served on, and the OIDC client it is + # registered as. What the host running it decides — which build it runs, + # where its datasources point, which plugins are in its store path, and + # the socket directory it shares with nginx — is `deploy.grafana`, in + # ./swarm-grafana.nix. + options.services.hyperhive.swarm.grafana = { + machine = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "swarm-grafana"; + description = '' + Container name. Read-only: the name appears in host paths and in + `machinectl`, so it is a fact other modules may read rather than a + knob. + ''; + }; + + domain = lib.mkOption { + type = lib.types.str; + default = "grafana.${domainBase}"; + defaultText = lib.literalExpression ''"grafana.''${services.hyperhive.swarm.domain}"''; + description = '' + Name the gateway serves this on. A sibling of the swarm's other + service names, so the swarm-services sub-CA can issue for it — see + `hive-tls.nix` for why a service name being a sibling rather than a + child decides which CA may sign it. + + ⚠️ Changing this changes the OAuth redirect URI, which authelia + matches exactly. Both sides move together because both derive from + this option; an operator who pins one by hand breaks the login. + ''; + }; + + metricsPort = lib.mkOption { + type = lib.types.port; + default = 9095; + description = '' + Loopback port on which the gateway's nginx re-serves Grafana's + `/metrics`, and nothing else, so the swarm's collector can scrape it. + + ⚠️ **This is nginx's port, not Grafana's.** Grafana still claims none — + see {option}`services.hyperhive.deploy.grafana.socketDir` for why that + matters. A prometheus scrape target is a `host:port`, and it cannot + address a unix socket; rather than undo the socket decision, the one + endpoint a scraper needs gets a listener of its own. + + Bound to loopback and unauthenticated, which is the same posture every + other entry in + {option}`services.hyperhive.swarm.otel.scrapeTargets` has: those + targets are trusted by *proximity* rather than by credential. + Deliberately **not** the published `grafana.` vhost, which + would put an authorization decision in front of a scrape. + + The number itself is arbitrary and free today; + `state/eval-port-collisions.sh` is what keeps it that way, since a + second claim on a port in this shared namespace produces no bind error + and nothing in any log. + ''; + }; + + oidc = { + clientId = lib.mkOption { + type = lib.types.str; + default = "swarm-grafana"; + description = '' + The authelia OIDC client id. Names the application rather than + the protocol, per the convention in + {option}`services.hyperhive.swarm.authelia.oidc.clients`. + ''; + }; + + redirectUri = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "https://${cfg.domain}/login/generic_oauth"; + defaultText = lib.literalExpression ''"https://''${services.hyperhive.swarm.grafana.domain}/login/generic_oauth"''; + description = '' + OAuth callback authelia sends the browser back to, and the URI it + matches **exactly**. + + Read-only, like {option}`services.hyperhive.swarm.grafana.machine` + and for the same reason: Grafana derives it from its own + `root_url` (`/login/generic_oauth`), so it is a fact + other modules may read rather than a knob. The glue that registers + this client wherever authelia runs reads it from here instead of + restating the format — a second spelling of it is a silently + rejected login. + ''; + }; + + role = lib.mkOption { + type = lib.types.enum [ + "Viewer" + "Editor" + "Admin" + ]; + default = "Admin"; + example = "Editor"; + description = '' + Grafana org role every SSO user is assigned. + + `Admin` by default, and that is a considered default rather than + a permissive one: the login form is disabled whenever SSO is + configured, so this is the *only* way anyone reaches Grafana — + a `Viewer` default would produce a swarm nobody can administer. + Passing authelia already means being an operator of this swarm; + its user store is the small, `swarmctl`-managed one. + + Lower it if a swarm ever grows read-only operators, which is a + one-line change here. + ''; + }; + }; + + }; +} diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index bf83c08c..547ed043 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -22,7 +22,6 @@ let gatewayCfg = hyperhiveCfg.gateway; baoCfg = hyperhiveCfg.swarm.bao; baoDeploy = deployCfg.bao; - swarmDomain = hyperhiveCfg.swarm.domain; caTrust = import ./lib/hive-ca-trust.nix { inherit lib gatewayCfg; @@ -205,11 +204,6 @@ let ] ); - # Total on a null swarm domain for the same reason every sibling module is: - # the required-domain assertion in hive-network.nix should be what an - # operator sees, not a coercion error from here. - domainBase = if swarmDomain == null then "invalid" else swarmDomain; - # A reader of the store is defined by holding a certificate the store # accepts, never by standing next to it — the rule # ./glue-matrix-bao-token.nix states in full. @@ -278,131 +272,11 @@ let storeRetry = import ./lib/store-retry.nix { }; in { - # `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 to every hive: the name it is - # served under, the port its `/metrics` is re-served on, and the OIDC - # client it is registered as. What the host running it decides — which - # build it runs, where its datasources point, which plugins are in its - # store path, and the socket directory it shares with nginx — is below, - # under `deploy.grafana`. - options.services.hyperhive.swarm.grafana = { - machine = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "swarm-grafana"; - description = '' - Container name. Read-only: the name appears in host paths and in - `machinectl`, so it is a fact other modules may read rather than a - knob. - ''; - }; - - domain = lib.mkOption { - type = lib.types.str; - default = "grafana.${domainBase}"; - defaultText = lib.literalExpression ''"grafana.''${services.hyperhive.swarm.domain}"''; - description = '' - Name the gateway serves this on. A sibling of the swarm's other - service names, so the swarm-services sub-CA can issue for it — see - `hive-tls.nix` for why a service name being a sibling rather than a - child decides which CA may sign it. - - ⚠️ Changing this changes the OAuth redirect URI, which authelia - matches exactly. Both sides move together because both derive from - this option; an operator who pins one by hand breaks the login. - ''; - }; - - metricsPort = lib.mkOption { - type = lib.types.port; - default = 9095; - description = '' - Loopback port on which the gateway's nginx re-serves Grafana's - `/metrics`, and nothing else, so the swarm's collector can scrape it. - - ⚠️ **This is nginx's port, not Grafana's.** Grafana still claims none — - see {option}`services.hyperhive.deploy.grafana.socketDir` for why that - matters. A prometheus scrape target is a `host:port`, and it cannot - address a unix socket; rather than undo the socket decision, the one - endpoint a scraper needs gets a listener of its own. - - Bound to loopback and unauthenticated, which is the same posture every - other entry in - {option}`services.hyperhive.swarm.otel.scrapeTargets` has: those - targets are trusted by *proximity* rather than by credential. - Deliberately **not** the published `grafana.` vhost, which - would put an authorization decision in front of a scrape. - - The number itself is arbitrary and free today; - `state/eval-port-collisions.sh` is what keeps it that way, since a - second claim on a port in this shared namespace produces no bind error - and nothing in any log. - ''; - }; - - oidc = { - clientId = lib.mkOption { - type = lib.types.str; - default = "swarm-grafana"; - description = '' - The authelia OIDC client id. Names the application rather than - the protocol, per the convention in - {option}`services.hyperhive.swarm.authelia.oidc.clients`. - ''; - }; - - redirectUri = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "https://${cfg.domain}/login/generic_oauth"; - defaultText = lib.literalExpression ''"https://''${services.hyperhive.swarm.grafana.domain}/login/generic_oauth"''; - description = '' - OAuth callback authelia sends the browser back to, and the URI it - matches **exactly**. - - Read-only, like {option}`services.hyperhive.swarm.grafana.machine` - and for the same reason: Grafana derives it from its own - `root_url` (`/login/generic_oauth`), so it is a fact - other modules may read rather than a knob. The glue that registers - this client wherever authelia runs reads it from here instead of - restating the format — a second spelling of it is a silently - rejected login. - ''; - }; - - role = lib.mkOption { - type = lib.types.enum [ - "Viewer" - "Editor" - "Admin" - ]; - default = "Admin"; - example = "Editor"; - description = '' - Grafana org role every SSO user is assigned. - - `Admin` by default, and that is a considered default rather than - a permissive one: the login form is disabled whenever SSO is - configured, so this is the *only* way anyone reaches Grafana — - a `Viewer` default would produce a swarm nobody can administer. - Passing authelia already means being an operator of this swarm; - its user store is the small, `swarmctl`-managed one. - - Lower it if a swarm ever grows read-only operators, which is a - one-line change here. - ''; - }; - }; - - }; - - # What stays above is what the service IS to every hive. What the host - # running it decides is here: which build it runs, where its datasources - # point, which plugins sit in its store path, and the directory it shares a - # socket with nginx through. `enable` already lives in ./deploy.nix, which - # also carries the renames. + # What the service IS to every hive is `swarm.grafana` in + # ./swarm-grafana-service.nix. What the host running it decides is here: + # which build it runs, where its datasources point, which plugins sit in its + # store path, and the directory it shares a socket with nginx through. + # `enable` already lives in ./deploy.nix, which also carries the renames. # # ⚠️ Both datasource URLs are wiring and still move. A URL's scope is the # scope of what it ADDRESSES, not the fact that it is a URL: both stores