From b6df0b4afcc2936908d53307462fcc459336c662 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 7 Sep 2026 13:09:55 +0200 Subject: [PATCH] deploy: move grafana's datasources, plugins and socket out of swarm.grafana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swarm.*` is what a hive needs to be a *client* of the swarm. For Grafana that is the package, the name it is served under, the port its `/metrics` is re-served on, and the OIDC client it is registered as. Where its datasources point, which plugins sit in its store path and the directory it shares a socket with nginx through are decisions of the machine running it, so datasourceUrl, logsDatasourceUrl, plugins and socketDir move to `deploy.grafana.*`. The two URLs are the interesting half. `swarm-grafana.nix`'s own summary sentence said what stays is "its package, domain, and wiring" — and both datasource URLs ARE wiring, so that sentence is rewritten with the move rather than left asserting the opposite of what the module does. They move because a URL's scope is the scope of what it ADDRESSES, not the fact that it is a URL: docs/swarm/services.md already said datasourceUrl "defaults to the store on this host, which is the only thing it can reach", because that store binds loopback. The doc argues the move. `socketDir` was already ruled host-side: the directory is shared between the host's nginx and the container, and it is the same shape as the options that moved in earlier slices. Declared in swarm-grafana.nix under the `deploy.*` path, following swarm-nats.nix; deploy.nix carries only the renames. One reader outside the options block is prose: `metricsPort` STAYS and cross-referenced `{option}services.hyperhive.swarm.grafana.socketDir`, which the split makes a pointer to another rendered page — nixosOptionsDoc emits swarm.md and deploy.md separately. Requalified, along with the one line in docs/swarm/services.md that named a mover. module-eval configures a hive through all four OLD paths and asserts a rendered effect: the host tmpfiles rule that creates the socket directory carries the fixture's custom path. The new paths evaluate fine without the shims, so dropping them has to read as a clean tree; all four are defined in the fixture, so removing any single shim fails the eval rather than only the one the assertion reads. --- docs/swarm/services.md | 2 +- nix/host-modules/deploy.nix | 16 ++++ nix/host-modules/swarm-grafana.nix | 130 ++++++++++++++++------------- nix/module-eval.nix | 20 +++++ 4 files changed, 110 insertions(+), 58 deletions(-) diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 0208b596..d1ff3fb2 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -116,7 +116,7 @@ See `retentionPeriod` below before leaving it at its default. | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `deploy.victoriametrics.retentionPeriod` | Default `5y`. Lower it once you have measured how fast this swarm actually fills a disk — the default is deliberately generous because too-short silently discards history you cannot get back. | | `swarm.grafana.oidc.role` | Default `Admin` for everyone who logs in. Lower to `Viewer`/`Editor` if the swarm grows operators who should not be able to reconfigure Grafana. | -| `swarm.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. | +| `deploy.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. | **Logging in.** Grafana is behind swarm SSO, so the accounts are the authelia ones — there is no separate Grafana password, and the local diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 757fd42c..92ae1978 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -41,6 +41,22 @@ in [ "services" "hyperhive" "swarm" "grafana" "enable" ] [ "services" "hyperhive" "deploy" "grafana" "enable" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "grafana" "socketDir" ] + [ "services" "hyperhive" "deploy" "grafana" "socketDir" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "grafana" "datasourceUrl" ] + [ "services" "hyperhive" "deploy" "grafana" "datasourceUrl" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "grafana" "logsDatasourceUrl" ] + [ "services" "hyperhive" "deploy" "grafana" "logsDatasourceUrl" ] + ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "grafana" "plugins" ] + [ "services" "hyperhive" "deploy" "grafana" "plugins" ] + ) (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "victoriametrics" "enable" ] [ "services" "hyperhive" "deploy" "victoriametrics" "enable" ] diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 5c083c86..3ec186e0 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -92,7 +92,7 @@ let # the OIDC secret above, whose other reader is authelia's container. secretKeyPath = "/var/lib/grafana-secret/secret_key"; - socketPath = "${cfg.socketDir}/grafana.sock"; + socketPath = "${deployCfg.grafana.socketDir}/grafana.sock"; # Both static NixOS ids, and both checked rather than assumed: nginx has # `ids.gids.nginx = 60`, Grafana has `ids.uids.grafana = 196` — but there is @@ -111,8 +111,12 @@ 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: its package, domain, and - # wiring. + # What stays here is what the service IS to every hive: its package, 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 — + # 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 = { package = lib.mkOption { type = lib.types.package; @@ -148,25 +152,6 @@ in ''; }; - socketDir = lib.mkOption { - type = lib.types.str; - default = "/run/swarm-grafana"; - description = '' - Directory holding the unix socket Grafana serves on, shared between - the host (where nginx runs) and the container (where Grafana runs). - - ⚠️ **Grafana takes no TCP port at all, and that is the point.** Every - swarm service container shares the host's network namespace, so a - port is a swarm-wide resource that two modules can silently both - claim — which is exactly what happened: Grafana defaulted to - upstream's 3000, so does the forge, and `grafana.` - served the forge with no bind error and nothing in any log. - - A socket has a path, and a path collision is a build-time conflict - rather than a runtime coin toss. - ''; - }; - metricsPort = lib.mkOption { type = lib.types.port; default = 9095; @@ -175,7 +160,7 @@ in `/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.swarm.grafana.socketDir` for why that + 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. @@ -194,34 +179,6 @@ in ''; }; - datasourceUrl = lib.mkOption { - type = lib.types.str; - default = "http://127.0.0.1:${toString vmCfg.port}"; - defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victoriametrics.port}"''; - description = '' - Where the provisioned datasource points. Defaults to the metrics - store on this host, which is the only place it can be: that store - binds loopback, so a Grafana somewhere else could not reach it - anyway. Set explicitly if a deployment fronts VictoriaMetrics with - something that does listen wider. - ''; - }; - - logsDatasourceUrl = lib.mkOption { - type = lib.types.str; - default = "http://127.0.0.1:${toString vlCfg.port}"; - defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victorialogs.port}"''; - description = '' - Where the provisioned logs datasource points. Same reasoning as - {option}`services.hyperhive.swarm.grafana.datasourceUrl`: the store - binds loopback, so a Grafana elsewhere could not reach it anyway. - - Provisioned unconditionally, like the metrics datasource — the store - being off is a deployment choice rather than a reason to withhold the - connection, and an operator whose logs live elsewhere sets this. - ''; - }; - oidc = { clientId = lib.mkOption { type = lib.types.str; @@ -257,6 +214,65 @@ in }; }; + }; + + # What stays above is what the service IS to every hive. What the host + # running it decides is here: 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 + # bind loopback, so these can only ever mean "on this host". + options.services.hyperhive.deploy.grafana = { + socketDir = lib.mkOption { + type = lib.types.str; + default = "/run/swarm-grafana"; + description = '' + Directory holding the unix socket Grafana serves on, shared between + the host (where nginx runs) and the container (where Grafana runs). + + ⚠️ **Grafana takes no TCP port at all, and that is the point.** Every + swarm service container shares the host's network namespace, so a + port is a swarm-wide resource that two modules can silently both + claim — which is exactly what happened: Grafana defaulted to + upstream's 3000, so does the forge, and `grafana.` + served the forge with no bind error and nothing in any log. + + A socket has a path, and a path collision is a build-time conflict + rather than a runtime coin toss. + ''; + }; + + datasourceUrl = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:${toString vmCfg.port}"; + defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victoriametrics.port}"''; + description = '' + Where the provisioned datasource points. Defaults to the metrics + store on this host, which is the only place it can be: that store + binds loopback, so a Grafana somewhere else could not reach it + anyway. Set explicitly if a deployment fronts VictoriaMetrics with + something that does listen wider. + ''; + }; + + logsDatasourceUrl = lib.mkOption { + type = lib.types.str; + default = "http://127.0.0.1:${toString vlCfg.port}"; + defaultText = lib.literalExpression ''"http://127.0.0.1:''${toString services.hyperhive.swarm.victorialogs.port}"''; + description = '' + Where the provisioned logs datasource points. Same reasoning as + {option}`services.hyperhive.deploy.grafana.datasourceUrl`: the store + binds loopback, so a Grafana elsewhere could not reach it anyway. + + Provisioned unconditionally, like the metrics datasource — the store + being off is a deployment choice rather than a reason to withhold the + connection, and an operator whose logs live elsewhere sets this. + ''; + }; + plugins = lib.mkOption { type = lib.types.listOf lib.types.package; default = [ @@ -428,7 +444,7 @@ in # because the host has no `grafana` account — Grafana lives in the # container, and only the number crosses that boundary. systemd.tmpfiles.rules = [ - "d ${cfg.socketDir} 0750 ${toString grafanaUid} ${toString nginxGid} - -" + "d ${deployCfg.grafana.socketDir} 0750 ${toString grafanaUid} ${toString nginxGid} - -" ]; # The secret delivery. It runs on the HOST because that is the only place @@ -513,8 +529,8 @@ in # ownership) and nspawn mounts it in — the same shape the per-agent # `/run/hive-agent//web.sock` already uses. bindMounts = { - ${cfg.socketDir} = { - hostPath = cfg.socketDir; + ${deployCfg.grafana.socketDir} = { + hostPath = deployCfg.grafana.socketDir; isReadOnly = false; }; } @@ -636,7 +652,7 @@ in # hive with no plugins, manual installation is impossible anyway" # — reasoned only about plugins a PERSON installs. Upstream # installs some itself, and those are the ones that went missing. - declarativePlugins = cfg.plugins; + declarativePlugins = deployCfg.grafana.plugins; settings = { server = { @@ -716,7 +732,7 @@ in name = "VictoriaMetrics"; type = "prometheus"; uid = datasourceUid; - url = cfg.datasourceUrl; + url = deployCfg.grafana.datasourceUrl; access = "proxy"; isDefault = true; } @@ -728,7 +744,7 @@ in # fails at use, far from anything naming this file. type = "victoriametrics-logs-datasource"; uid = logsDatasourceUid; - url = cfg.logsDatasourceUrl; + url = deployCfg.grafana.logsDatasourceUrl; access = "proxy"; # Exactly one datasource may claim this, and metrics has it: # two defaults is a coin toss over which one an untyped panel diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 82326028..3c5cde46 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -118,6 +118,14 @@ let swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; }; + grafanaOldPath = hive { + deploy.grafana.enable = true; + swarm.grafana.socketDir = "/run/test-grafana-sock"; + swarm.grafana.datasourceUrl = "http://127.0.0.1:19999"; + swarm.grafana.logsDatasourceUrl = "http://127.0.0.1:19998"; + swarm.grafana.plugins = [ ]; + }; + baoPkcs11 = hive { deploy.bao.enable = true; deploy.bao.seal = "pkcs11"; @@ -293,6 +301,18 @@ let units ? swarm-nats-auth-secrets && lib.hasInfix "/run/secrets/nats-user.seed" units.swarm-nats-auth-secrets.script; } + { + # Reads the host's tmpfiles rules, not the options: the socket directory + # nginx and the container share is created there, so a rename that + # resolved but stopped reaching the module would leave the gateway + # proxying to a path nothing creates. All four old paths are defined in + # the fixture, so removing any single shim fails the eval rather than + # only the one this assertion reads. + name = "a config written against the pre-rename grafana paths still creates the socket directory"; + ok = lib.any ( + rule: lib.hasInfix "/run/test-grafana-sock" rule + ) grafanaOldPath.systemd.tmpfiles.rules; + } { # The gateway's per-name issuer choice. If this ever collapses to a # constant, every swarm-service vhost serves a certificate its CA