deploy: move grafana's datasources, plugins and socket out of swarm.grafana

`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.
This commit is contained in:
atlas 2026-09-07 13:09:55 +02:00 committed by mara
commit b6df0b4afc
4 changed files with 110 additions and 58 deletions

View file

@ -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

View file

@ -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" ]

View file

@ -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.<swarm-domain>`
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.<swarm-domain>`
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/<name>/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

View file

@ -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