nix: split swarm-otel into service and deploy-mode files
`swarm.otel` (what the swarm collector is to every hive: its domain, receiver ports, producer names, client id and the audiences that client may present) moves to nix/host-modules/swarm-otel-service.nix, together with the `journaldUnits` removal module and the helpers its defaults read. Everything else -- the `deploy.swarm-otel` options, the whole `config` block including `containers.swarm-otel`, and the helpers only they read -- stays in nix/host-modules/swarm-otel.nix, which default.nix now imports after the new file. The service file needs `domainBase` (for `domain`), `baoCfg` (for `storeProducerName`) and `cfg` plus `pushAudiences` (for `audience`). `swarmDomain` and `domainBase` move. `cfg`, `hyperhiveCfg`, `baoCfg`, `vmCfg`, `vlCfg`, `metricsPushUrl`, `logsPushUrl` and `pushAudiences` are read on both sides and none is an option, so each is bound in both files; the comments on the push URLs say the two copies must agree. `swarmCfg` was bound and never read, so neither file carries it. A pure move: option paths, option definitions and config are unchanged. Comments changed: the transition comment above `deploy.swarm-otel` now names the file `swarm.otel` lives in, and the push-URL and `pushAudiences` comments now describe the per-file readers and the duplicate. Three otel fixtures evaluate to the same host and container toplevel derivations, `swarm.otel.*` and `deploy.swarm-otel.*` values before and after. Refs #3742
This commit is contained in:
parent
fc8b8fa2f5
commit
3a0a7346b1
3 changed files with 345 additions and 316 deletions
|
|
@ -52,6 +52,7 @@
|
||||||
./swarm-controller.nix
|
./swarm-controller.nix
|
||||||
./swarm-grafana-service.nix
|
./swarm-grafana-service.nix
|
||||||
./swarm-grafana.nix
|
./swarm-grafana.nix
|
||||||
|
./swarm-otel-service.nix
|
||||||
./swarm-otel.nix
|
./swarm-otel.nix
|
||||||
./swarm-snapshot-store.nix
|
./swarm-snapshot-store.nix
|
||||||
./swarm-ui.nix
|
./swarm-ui.nix
|
||||||
|
|
|
||||||
335
nix/host-modules/swarm-otel-service.nix
Normal file
335
nix/host-modules/swarm-otel-service.nix
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
# The swarm's telemetry collector as every hive sees it: the name it is
|
||||||
|
# reached on, its receiver ports, the client it is registered as and the
|
||||||
|
# audiences that client may present, identical on every host. What the host
|
||||||
|
# running it decides, and the container itself, are in ./swarm-otel.nix.
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.services.hyperhive.swarm.otel;
|
||||||
|
vmCfg = config.services.hyperhive.swarm.victoriametrics;
|
||||||
|
vlCfg = config.services.hyperhive.swarm.victorialogs;
|
||||||
|
hyperhiveCfg = config.services.hyperhive;
|
||||||
|
baoCfg = hyperhiveCfg.swarm.bao;
|
||||||
|
swarmDomain = hyperhiveCfg.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;
|
||||||
|
|
||||||
|
# Each store's OTLP route, by domain: the audiences `audience` below registers
|
||||||
|
# the client for. ⚠️ ./swarm-otel.nix binds the same strings for the
|
||||||
|
# exporters that request those tokens; the two copies must agree.
|
||||||
|
metricsPushUrl = "https://${vmCfg.domain}/opentelemetry/api/v1/push";
|
||||||
|
logsPushUrl = "https://${vlCfg.domain}/insert/opentelemetry/v1/logs";
|
||||||
|
|
||||||
|
pushAudiences = {
|
||||||
|
victoriametrics = metricsPushUrl;
|
||||||
|
victorialogs = logsPushUrl;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "otel" "journaldUnits" ] ''
|
||||||
|
The swarm collector no longer takes a unit allowlist: it ships the
|
||||||
|
journal of every system unit on the host and drops only user
|
||||||
|
sessions (records under a `user-<uid>.slice`). Delete the setting;
|
||||||
|
nothing replaces it.
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
|
||||||
|
# `enable` moved to `services.hyperhive.deploy.swarm-otel.enable` — see ./deploy.nix.
|
||||||
|
# ⚠️ That is the SWARM collector. The per-hive one keeps its own
|
||||||
|
# `services.hyperhive.otel.enable` (./otel.nix) and is a different
|
||||||
|
# option entirely — every hive runs that one.
|
||||||
|
options.services.hyperhive.swarm.otel = {
|
||||||
|
|
||||||
|
machine = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = "swarm-otel";
|
||||||
|
description = ''
|
||||||
|
Name of the nixos-container this collector runs in — also the
|
||||||
|
`machinectl` name, so other modules may read it rather than
|
||||||
|
repeating the literal.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4319;
|
||||||
|
description = ''
|
||||||
|
First port of this collector's receiver range. Every hive in
|
||||||
|
{option}`services.hyperhive.swarm.hives` gets its **own**
|
||||||
|
authenticated receiver — that is what makes the `hive` label
|
||||||
|
unforgeable — so the range is one port per hive, starting here, in
|
||||||
|
sorted-name order.
|
||||||
|
|
||||||
|
⚠️ Internal. No client is ever told a port: a hive reaches its own
|
||||||
|
receiver as `https://''${domain}/<hive>`, and the gateway routes on
|
||||||
|
that path. So adding a hive, which renumbers the ones after it, is
|
||||||
|
harmless — nginx is rendered from this same evaluation and moves
|
||||||
|
with it.
|
||||||
|
|
||||||
|
⚠️ **Deliberately not 4318**, the OTLP/HTTP default, because the
|
||||||
|
hive tier already uses it (`services.hyperhive.otel.collector.port`)
|
||||||
|
and every swarm container shares the host's network namespace. Two
|
||||||
|
listeners claiming one port on one host is not a build failure —
|
||||||
|
it is a runtime coin toss over which one gets it, with nothing in
|
||||||
|
any log saying so. The same collision cost a release when grafana
|
||||||
|
and the forge both defaulted to 3000. The assertions below check
|
||||||
|
the whole derived range against every port this module and the hive
|
||||||
|
tier declare, which is as far as a module can see.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
telemetryPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8889;
|
||||||
|
description = ''
|
||||||
|
Port this collector serves its **own** metrics on — queue depth,
|
||||||
|
refused and dropped samples, exporter failures. How you find out
|
||||||
|
that telemetry is being lost, so it is worth keeping rather than
|
||||||
|
switching off.
|
||||||
|
|
||||||
|
⚠️ **Deliberately not 8889's neighbour 8888**, which is the
|
||||||
|
collector's built-in default and therefore what the hive tier
|
||||||
|
already binds. Two collectors share a network namespace whenever
|
||||||
|
they are co-located, and unlike the OTLP port this one appears
|
||||||
|
nowhere in either config — it is a default inside the binary, so
|
||||||
|
nothing that compares configured ports can see the clash. The
|
||||||
|
second collector to start simply dies with
|
||||||
|
`bind: address already in use`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
producerPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4390;
|
||||||
|
description = ''
|
||||||
|
Port the swarm tier's own OTLP/HTTP receiver for a
|
||||||
|
**swarm-level** producer (`swarm-controller`'s vcs/jobq counters
|
||||||
|
today) listens on, at `127.0.0.1`. Reached through the gateway at
|
||||||
|
`https://''${domain}/''${producerName}/`, same shape as a hive's
|
||||||
|
own receiver — a swarm-level producer may not share a host with
|
||||||
|
this collector (mara, on the swarm-controller topology: "the
|
||||||
|
swarm services dont have to run on the same host as the swarm
|
||||||
|
controller"), so loopback-only reachability is not a supported
|
||||||
|
shape here any more than it is for a hive.
|
||||||
|
|
||||||
|
Authenticated by `oidc/''${producerName}`, checking for the
|
||||||
|
audience `services.hyperhive.swarm.controller.queueClientId`
|
||||||
|
requests — not the per-hive `hiveClientPrefix` namespace, because
|
||||||
|
a swarm-level producer is explicitly not a hive (see that
|
||||||
|
option's own doc comment). The `swarm` label this receiver's
|
||||||
|
samples get is still the one the receiver's own *existence*
|
||||||
|
establishes, same as before — there is no hive identity being
|
||||||
|
forged or attributed here, just a caller proving it is the one
|
||||||
|
principal allowed to push into this pipeline.
|
||||||
|
|
||||||
|
⚠️ Deliberately NOT derived from `port + (number of hives)`: that
|
||||||
|
range grows every time a hive is added, and a fixed offset from
|
||||||
|
it would silently start colliding once the hive count caught up.
|
||||||
|
Kept as its own reserved value instead, and the assertion below
|
||||||
|
still catches a real collision (including one hive growth
|
||||||
|
eventually causes) rather than starting a collector that quietly
|
||||||
|
drops one receiver's samples.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
storeProducerPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4391;
|
||||||
|
description = ''
|
||||||
|
Port the receiver for the **secret store's own forwarder** listens
|
||||||
|
on, at `127.0.0.1`. Reached through the gateway at
|
||||||
|
`https://''${domain}/''${storeProducerName}/`, the same
|
||||||
|
path-per-producer shape a hive and the swarm-tier producer above
|
||||||
|
both use.
|
||||||
|
|
||||||
|
Its own receiver, and not a second sender into
|
||||||
|
{option}`services.hyperhive.swarm.otel.producerPort`, because an
|
||||||
|
`oidc` authenticator checks exactly ONE audience: that receiver
|
||||||
|
admits the audience `swarm-controller` registers for itself, and
|
||||||
|
the store's forwarder is a different principal with a client of its
|
||||||
|
own. Sharing the route would mean sharing that client — which is
|
||||||
|
the thing the store exists to make unnecessary.
|
||||||
|
|
||||||
|
⚠️ A reserved value rather than an offset from
|
||||||
|
{option}`services.hyperhive.swarm.otel.port`, for the reason stated
|
||||||
|
on `producerPort`: that range grows with the hive count and would
|
||||||
|
eventually walk into any fixed offset from it. The collision
|
||||||
|
assertion below covers this port too.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
storeProducerName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = baoCfg.machine;
|
||||||
|
defaultText = lib.literalExpression "services.hyperhive.swarm.bao.machine";
|
||||||
|
description = ''
|
||||||
|
Path and component name of the store forwarder's own receiver
|
||||||
|
(`otlp/''${storeProducerName}`, `oidc/''${storeProducerName}`).
|
||||||
|
Published so `swarm-bao.nix` addresses the receiver by name rather
|
||||||
|
than repeating the string, exactly as `producerName` above is
|
||||||
|
published for `swarm-controller.nix`.
|
||||||
|
|
||||||
|
Read-only and derived from the store's container name: two
|
||||||
|
spellings of a name both ends have to agree on is a 404 on a
|
||||||
|
request that authenticated perfectly.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
producerName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = "swarm";
|
||||||
|
description = ''
|
||||||
|
The swarm-tier producer's own component/path/authenticator name
|
||||||
|
— the same reserved literal this module's collector components
|
||||||
|
(`otlp/swarm`, `oidc/swarm`, `resource/swarm`, `metrics/swarm`)
|
||||||
|
are already named after internally. Published as an option so a
|
||||||
|
sibling module (`swarm-controller.nix`) can address the receiver
|
||||||
|
by name — `https://''${domain}/''${producerName}/` — instead of
|
||||||
|
repeating the string. Read-only for the same reason
|
||||||
|
`clientId`/`machine`/`domain` are: two spellings of a name that
|
||||||
|
has to match on both ends is a mismatch waiting to happen.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
domain = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "otel.${domainBase}";
|
||||||
|
defaultText = lib.literalExpression ''"otel.''${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.
|
||||||
|
|
||||||
|
This is what the **hive** tier's exporter reaches — the hive
|
||||||
|
collector is a plain producer against this name exactly like every
|
||||||
|
other client of a swarm service, resolved locally by dnsmasq on a
|
||||||
|
co-located host and over the real network otherwise. There is no
|
||||||
|
separate loopback-vs-remote knob to get wrong: everything in this
|
||||||
|
swarm addresses its siblings by name.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
scrapeTargets = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''{ forgejo = "127.0.0.1:3000"; }'';
|
||||||
|
description = ''
|
||||||
|
Prometheus exposition endpoints this collector scrapes, as
|
||||||
|
`<job name> = "<host>:<port>"`.
|
||||||
|
|
||||||
|
A path and query may follow the port —
|
||||||
|
`"127.0.0.1:8202/v1/sys/metrics?format=prometheus"` — for an
|
||||||
|
exporter that does not serve `/metrics`. Both are optional and
|
||||||
|
omitted when absent, so a bare `host:port` is scraped exactly as
|
||||||
|
before.
|
||||||
|
|
||||||
|
**A service declares its own entry, from its own module, under its
|
||||||
|
own `enable`.** Do not assemble the list here: an entry then exists
|
||||||
|
only where the service that named it runs, so a target is never
|
||||||
|
declared on a host that does not serve it.
|
||||||
|
|
||||||
|
⚠️ **That constrains the TARGET, not the SCRAPER, and the difference
|
||||||
|
is a silent gap.** Nothing here puts the collector on the same host.
|
||||||
|
Services are co-located by *default* — `swarm-required-services.nix`
|
||||||
|
derives their `enable` flags from one `lib.mkDefault` — not by
|
||||||
|
construction, and an operator may split them.
|
||||||
|
|
||||||
|
When they are split the target is simply never scraped: the
|
||||||
|
service's host declares an entry no local collector reads, and the
|
||||||
|
collector's host never enabled that service so has no entry at all.
|
||||||
|
No error, no eval failure, no warning.
|
||||||
|
|
||||||
|
**No assertion can catch this.** Two hosts are separate NixOS
|
||||||
|
evaluations with no shared context, so neither can see what the
|
||||||
|
other runs. A service that would be seriously wrong to lose should
|
||||||
|
say so in its own contribution, because saying it is the only
|
||||||
|
mechanism available.
|
||||||
|
|
||||||
|
Samples land in a swarm-level pipeline that stamps `swarm` and
|
||||||
|
**never** `hive`: a swarm service does not belong to a hive, and
|
||||||
|
`hive` stays a property of which authenticated receiver accepted a
|
||||||
|
push, not something a scrape can acquire.
|
||||||
|
|
||||||
|
Empty by default, in which case no scrape receiver, processor or
|
||||||
|
pipeline is emitted at all — an enabled scraper with nothing to
|
||||||
|
scrape is the inert configuration this option exists to avoid.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
publishedScrapeTargets = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''{ forge = "https://forge.example.com/metrics"; }'';
|
||||||
|
description = ''
|
||||||
|
Prometheus endpoints this collector scrapes **by name, with a
|
||||||
|
credential**, as `<job name> = "<url>"`.
|
||||||
|
|
||||||
|
A service module declares its own entry, from its own module, the
|
||||||
|
same way it does for `scrapeTargets` — and the collector's OAuth2
|
||||||
|
client derives its permitted audiences from these URLs, so a
|
||||||
|
target and the authorisation to reach it are **one declaration**.
|
||||||
|
Two lists that must agree would be a drift to maintain, and its
|
||||||
|
failure mode is the bad one: a target whose audience was forgotten
|
||||||
|
authenticates against nothing and reads as a scrape failure rather
|
||||||
|
than a config mistake.
|
||||||
|
|
||||||
|
⚠️ A **full URL**, not `host:port`. Authelia validates a bearer
|
||||||
|
token against the address being requested, so the string here is
|
||||||
|
also the audience the token is minted for; a near miss (a trailing
|
||||||
|
slash, `http` for `https`) presents as a valid token rejected at
|
||||||
|
the target, several layers from its cause.
|
||||||
|
|
||||||
|
⚠️ Deliberately separate from `scrapeTargets`. An entry there is
|
||||||
|
trusted because the scraper and the target share a host — that
|
||||||
|
option is loopback-and-unauthenticated by contract. An entry here
|
||||||
|
is trusted because it presents a credential. One shape for both
|
||||||
|
would leave a reader unable to tell which of those a given target
|
||||||
|
relies on.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
clientId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = "swarm-collector";
|
||||||
|
description = ''
|
||||||
|
OAuth2 client id this collector authenticates as. Published so
|
||||||
|
authelia's `access_control` rules can name it without carrying a
|
||||||
|
second copy of the string, exactly as
|
||||||
|
`services.hyperhive.swarm.authelia.hiveClientPrefix` is published
|
||||||
|
for the queue's responder.
|
||||||
|
|
||||||
|
Two spellings drifting apart is not a build failure: authelia
|
||||||
|
refuses a rule naming an unregistered client in its startup
|
||||||
|
validator, so the swarm's SSO service fails to *restart* — long
|
||||||
|
after the change that caused it evaluated cleanly.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
audience = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = lib.attrValues cfg.publishedScrapeTargets ++ lib.attrValues pushAudiences;
|
||||||
|
description = ''
|
||||||
|
Every audience this collector's OAuth2 client is permitted to
|
||||||
|
present a token for — the scrape targets it reads with a credential,
|
||||||
|
plus the stores it pushes to. Published read-only so
|
||||||
|
./glue-swarm-otel-oidc-client.nix can register the client wherever
|
||||||
|
authelia runs without restating the derivation: this option and
|
||||||
|
that registration are the same fact seen from two hosts, and a
|
||||||
|
second formula for it would be free to drift from this one.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
let
|
let
|
||||||
cfg = config.services.hyperhive.swarm.otel;
|
cfg = config.services.hyperhive.swarm.otel;
|
||||||
deployCfg = config.services.hyperhive.deploy;
|
deployCfg = config.services.hyperhive.deploy;
|
||||||
swarmCfg = config.services.hyperhive.swarm;
|
|
||||||
otelCfg = config.services.hyperhive.otel;
|
otelCfg = config.services.hyperhive.otel;
|
||||||
vmCfg = config.services.hyperhive.swarm.victoriametrics;
|
vmCfg = config.services.hyperhive.swarm.victoriametrics;
|
||||||
vlCfg = config.services.hyperhive.swarm.victorialogs;
|
vlCfg = config.services.hyperhive.swarm.victorialogs;
|
||||||
|
|
@ -25,12 +24,6 @@ let
|
||||||
gatewayCfg = hyperhiveCfg.gateway;
|
gatewayCfg = hyperhiveCfg.gateway;
|
||||||
baoCfg = hyperhiveCfg.swarm.bao;
|
baoCfg = hyperhiveCfg.swarm.bao;
|
||||||
baoDeploy = deployCfg.bao;
|
baoDeploy = deployCfg.bao;
|
||||||
swarmDomain = hyperhiveCfg.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;
|
|
||||||
|
|
||||||
# The collector names its components `<kind>/<owner>`, where `<owner>` is a
|
# The collector names its components `<kind>/<owner>`, where `<owner>` is a
|
||||||
# hive name for the per-hive pipelines and this literal for the swarm tier's
|
# hive name for the per-hive pipelines and this literal for the swarm tier's
|
||||||
|
|
@ -225,13 +218,14 @@ let
|
||||||
# Each store's OTLP route, by domain. One binding because the same string is
|
# Each store's OTLP route, by domain. One binding because the same string is
|
||||||
# both the address requested and the audience the token is minted for — two
|
# both the address requested and the audience the token is minted for — two
|
||||||
# spellings present as a valid token refused at the store.
|
# spellings present as a valid token refused at the store.
|
||||||
|
# ⚠️ ./swarm-otel-service.nix binds both again for `swarm.otel.audience`,
|
||||||
|
# the audiences the client is registered for; the two copies must agree.
|
||||||
metricsPushUrl = "https://${vmCfg.domain}/opentelemetry/api/v1/push";
|
metricsPushUrl = "https://${vmCfg.domain}/opentelemetry/api/v1/push";
|
||||||
logsPushUrl = "https://${vlCfg.domain}/insert/opentelemetry/v1/logs";
|
logsPushUrl = "https://${vlCfg.domain}/insert/opentelemetry/v1/logs";
|
||||||
|
|
||||||
# Read by the extensions block, `service.extensions`, each exporter's
|
# Read by the extensions block, `service.extensions` and each exporter's
|
||||||
# authenticator and the OIDC client's audience list. Derived rather than
|
# authenticator. Derived rather than repeated: an authenticator naming an
|
||||||
# repeated: an authenticator naming an unlisted extension starts clean and
|
# unlisted extension starts clean and authenticates nothing.
|
||||||
# authenticates nothing.
|
|
||||||
pushAudiences = {
|
pushAudiences = {
|
||||||
victoriametrics = metricsPushUrl;
|
victoriametrics = metricsPushUrl;
|
||||||
victorialogs = logsPushUrl;
|
victorialogs = logsPushUrl;
|
||||||
|
|
@ -314,311 +308,10 @@ let
|
||||||
storeRetry = import ./lib/store-retry.nix { };
|
storeRetry = import ./lib/store-retry.nix { };
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
imports = [
|
# What the collector IS to the swarm — the client it is registered as, where
|
||||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "otel" "journaldUnits" ] ''
|
# it exports — is `swarm.otel` in ./swarm-otel-service.nix. The secret is a
|
||||||
The swarm collector no longer takes a unit allowlist: it ships the
|
# path on the machine that runs it, so it hangs off the deployment. `enable`
|
||||||
journal of every system unit on the host and drops only user
|
# already lives in ./deploy.nix, which also carries the rename.
|
||||||
sessions (records under a `user-<uid>.slice`). Delete the setting;
|
|
||||||
nothing replaces it.
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
|
|
||||||
# `enable` moved to `services.hyperhive.deploy.swarm-otel.enable` — see ./deploy.nix.
|
|
||||||
# ⚠️ That is the SWARM collector. The per-hive one keeps its own
|
|
||||||
# `services.hyperhive.otel.enable` (./otel.nix) and is a different
|
|
||||||
# option entirely — every hive runs that one.
|
|
||||||
options.services.hyperhive.swarm.otel = {
|
|
||||||
|
|
||||||
machine = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = "swarm-otel";
|
|
||||||
description = ''
|
|
||||||
Name of the nixos-container this collector runs in — also the
|
|
||||||
`machinectl` name, so other modules may read it rather than
|
|
||||||
repeating the literal.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
port = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 4319;
|
|
||||||
description = ''
|
|
||||||
First port of this collector's receiver range. Every hive in
|
|
||||||
{option}`services.hyperhive.swarm.hives` gets its **own**
|
|
||||||
authenticated receiver — that is what makes the `hive` label
|
|
||||||
unforgeable — so the range is one port per hive, starting here, in
|
|
||||||
sorted-name order.
|
|
||||||
|
|
||||||
⚠️ Internal. No client is ever told a port: a hive reaches its own
|
|
||||||
receiver as `https://''${domain}/<hive>`, and the gateway routes on
|
|
||||||
that path. So adding a hive, which renumbers the ones after it, is
|
|
||||||
harmless — nginx is rendered from this same evaluation and moves
|
|
||||||
with it.
|
|
||||||
|
|
||||||
⚠️ **Deliberately not 4318**, the OTLP/HTTP default, because the
|
|
||||||
hive tier already uses it (`services.hyperhive.otel.collector.port`)
|
|
||||||
and every swarm container shares the host's network namespace. Two
|
|
||||||
listeners claiming one port on one host is not a build failure —
|
|
||||||
it is a runtime coin toss over which one gets it, with nothing in
|
|
||||||
any log saying so. The same collision cost a release when grafana
|
|
||||||
and the forge both defaulted to 3000. The assertions below check
|
|
||||||
the whole derived range against every port this module and the hive
|
|
||||||
tier declare, which is as far as a module can see.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
telemetryPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 8889;
|
|
||||||
description = ''
|
|
||||||
Port this collector serves its **own** metrics on — queue depth,
|
|
||||||
refused and dropped samples, exporter failures. How you find out
|
|
||||||
that telemetry is being lost, so it is worth keeping rather than
|
|
||||||
switching off.
|
|
||||||
|
|
||||||
⚠️ **Deliberately not 8889's neighbour 8888**, which is the
|
|
||||||
collector's built-in default and therefore what the hive tier
|
|
||||||
already binds. Two collectors share a network namespace whenever
|
|
||||||
they are co-located, and unlike the OTLP port this one appears
|
|
||||||
nowhere in either config — it is a default inside the binary, so
|
|
||||||
nothing that compares configured ports can see the clash. The
|
|
||||||
second collector to start simply dies with
|
|
||||||
`bind: address already in use`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
producerPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 4390;
|
|
||||||
description = ''
|
|
||||||
Port the swarm tier's own OTLP/HTTP receiver for a
|
|
||||||
**swarm-level** producer (`swarm-controller`'s vcs/jobq counters
|
|
||||||
today) listens on, at `127.0.0.1`. Reached through the gateway at
|
|
||||||
`https://''${domain}/''${producerName}/`, same shape as a hive's
|
|
||||||
own receiver — a swarm-level producer may not share a host with
|
|
||||||
this collector (mara, on the swarm-controller topology: "the
|
|
||||||
swarm services dont have to run on the same host as the swarm
|
|
||||||
controller"), so loopback-only reachability is not a supported
|
|
||||||
shape here any more than it is for a hive.
|
|
||||||
|
|
||||||
Authenticated by `oidc/''${producerName}`, checking for the
|
|
||||||
audience `services.hyperhive.swarm.controller.queueClientId`
|
|
||||||
requests — not the per-hive `hiveClientPrefix` namespace, because
|
|
||||||
a swarm-level producer is explicitly not a hive (see that
|
|
||||||
option's own doc comment). The `swarm` label this receiver's
|
|
||||||
samples get is still the one the receiver's own *existence*
|
|
||||||
establishes, same as before — there is no hive identity being
|
|
||||||
forged or attributed here, just a caller proving it is the one
|
|
||||||
principal allowed to push into this pipeline.
|
|
||||||
|
|
||||||
⚠️ Deliberately NOT derived from `port + (number of hives)`: that
|
|
||||||
range grows every time a hive is added, and a fixed offset from
|
|
||||||
it would silently start colliding once the hive count caught up.
|
|
||||||
Kept as its own reserved value instead, and the assertion below
|
|
||||||
still catches a real collision (including one hive growth
|
|
||||||
eventually causes) rather than starting a collector that quietly
|
|
||||||
drops one receiver's samples.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
storeProducerPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 4391;
|
|
||||||
description = ''
|
|
||||||
Port the receiver for the **secret store's own forwarder** listens
|
|
||||||
on, at `127.0.0.1`. Reached through the gateway at
|
|
||||||
`https://''${domain}/''${storeProducerName}/`, the same
|
|
||||||
path-per-producer shape a hive and the swarm-tier producer above
|
|
||||||
both use.
|
|
||||||
|
|
||||||
Its own receiver, and not a second sender into
|
|
||||||
{option}`services.hyperhive.swarm.otel.producerPort`, because an
|
|
||||||
`oidc` authenticator checks exactly ONE audience: that receiver
|
|
||||||
admits the audience `swarm-controller` registers for itself, and
|
|
||||||
the store's forwarder is a different principal with a client of its
|
|
||||||
own. Sharing the route would mean sharing that client — which is
|
|
||||||
the thing the store exists to make unnecessary.
|
|
||||||
|
|
||||||
⚠️ A reserved value rather than an offset from
|
|
||||||
{option}`services.hyperhive.swarm.otel.port`, for the reason stated
|
|
||||||
on `producerPort`: that range grows with the hive count and would
|
|
||||||
eventually walk into any fixed offset from it. The collision
|
|
||||||
assertion below covers this port too.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
storeProducerName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = baoCfg.machine;
|
|
||||||
defaultText = lib.literalExpression "services.hyperhive.swarm.bao.machine";
|
|
||||||
description = ''
|
|
||||||
Path and component name of the store forwarder's own receiver
|
|
||||||
(`otlp/''${storeProducerName}`, `oidc/''${storeProducerName}`).
|
|
||||||
Published so `swarm-bao.nix` addresses the receiver by name rather
|
|
||||||
than repeating the string, exactly as `producerName` above is
|
|
||||||
published for `swarm-controller.nix`.
|
|
||||||
|
|
||||||
Read-only and derived from the store's container name: two
|
|
||||||
spellings of a name both ends have to agree on is a 404 on a
|
|
||||||
request that authenticated perfectly.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
producerName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = "swarm";
|
|
||||||
description = ''
|
|
||||||
The swarm-tier producer's own component/path/authenticator name
|
|
||||||
— the same reserved literal this module's collector components
|
|
||||||
(`otlp/swarm`, `oidc/swarm`, `resource/swarm`, `metrics/swarm`)
|
|
||||||
are already named after internally. Published as an option so a
|
|
||||||
sibling module (`swarm-controller.nix`) can address the receiver
|
|
||||||
by name — `https://''${domain}/''${producerName}/` — instead of
|
|
||||||
repeating the string. Read-only for the same reason
|
|
||||||
`clientId`/`machine`/`domain` are: two spellings of a name that
|
|
||||||
has to match on both ends is a mismatch waiting to happen.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
domain = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "otel.${domainBase}";
|
|
||||||
defaultText = lib.literalExpression ''"otel.''${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.
|
|
||||||
|
|
||||||
This is what the **hive** tier's exporter reaches — the hive
|
|
||||||
collector is a plain producer against this name exactly like every
|
|
||||||
other client of a swarm service, resolved locally by dnsmasq on a
|
|
||||||
co-located host and over the real network otherwise. There is no
|
|
||||||
separate loopback-vs-remote knob to get wrong: everything in this
|
|
||||||
swarm addresses its siblings by name.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
scrapeTargets = lib.mkOption {
|
|
||||||
type = lib.types.attrsOf lib.types.str;
|
|
||||||
default = { };
|
|
||||||
example = lib.literalExpression ''{ forgejo = "127.0.0.1:3000"; }'';
|
|
||||||
description = ''
|
|
||||||
Prometheus exposition endpoints this collector scrapes, as
|
|
||||||
`<job name> = "<host>:<port>"`.
|
|
||||||
|
|
||||||
A path and query may follow the port —
|
|
||||||
`"127.0.0.1:8202/v1/sys/metrics?format=prometheus"` — for an
|
|
||||||
exporter that does not serve `/metrics`. Both are optional and
|
|
||||||
omitted when absent, so a bare `host:port` is scraped exactly as
|
|
||||||
before.
|
|
||||||
|
|
||||||
**A service declares its own entry, from its own module, under its
|
|
||||||
own `enable`.** Do not assemble the list here: an entry then exists
|
|
||||||
only where the service that named it runs, so a target is never
|
|
||||||
declared on a host that does not serve it.
|
|
||||||
|
|
||||||
⚠️ **That constrains the TARGET, not the SCRAPER, and the difference
|
|
||||||
is a silent gap.** Nothing here puts the collector on the same host.
|
|
||||||
Services are co-located by *default* — `swarm-required-services.nix`
|
|
||||||
derives their `enable` flags from one `lib.mkDefault` — not by
|
|
||||||
construction, and an operator may split them.
|
|
||||||
|
|
||||||
When they are split the target is simply never scraped: the
|
|
||||||
service's host declares an entry no local collector reads, and the
|
|
||||||
collector's host never enabled that service so has no entry at all.
|
|
||||||
No error, no eval failure, no warning.
|
|
||||||
|
|
||||||
**No assertion can catch this.** Two hosts are separate NixOS
|
|
||||||
evaluations with no shared context, so neither can see what the
|
|
||||||
other runs. A service that would be seriously wrong to lose should
|
|
||||||
say so in its own contribution, because saying it is the only
|
|
||||||
mechanism available.
|
|
||||||
|
|
||||||
Samples land in a swarm-level pipeline that stamps `swarm` and
|
|
||||||
**never** `hive`: a swarm service does not belong to a hive, and
|
|
||||||
`hive` stays a property of which authenticated receiver accepted a
|
|
||||||
push, not something a scrape can acquire.
|
|
||||||
|
|
||||||
Empty by default, in which case no scrape receiver, processor or
|
|
||||||
pipeline is emitted at all — an enabled scraper with nothing to
|
|
||||||
scrape is the inert configuration this option exists to avoid.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
publishedScrapeTargets = lib.mkOption {
|
|
||||||
type = lib.types.attrsOf lib.types.str;
|
|
||||||
default = { };
|
|
||||||
example = lib.literalExpression ''{ forge = "https://forge.example.com/metrics"; }'';
|
|
||||||
description = ''
|
|
||||||
Prometheus endpoints this collector scrapes **by name, with a
|
|
||||||
credential**, as `<job name> = "<url>"`.
|
|
||||||
|
|
||||||
A service module declares its own entry, from its own module, the
|
|
||||||
same way it does for `scrapeTargets` — and the collector's OAuth2
|
|
||||||
client derives its permitted audiences from these URLs, so a
|
|
||||||
target and the authorisation to reach it are **one declaration**.
|
|
||||||
Two lists that must agree would be a drift to maintain, and its
|
|
||||||
failure mode is the bad one: a target whose audience was forgotten
|
|
||||||
authenticates against nothing and reads as a scrape failure rather
|
|
||||||
than a config mistake.
|
|
||||||
|
|
||||||
⚠️ A **full URL**, not `host:port`. Authelia validates a bearer
|
|
||||||
token against the address being requested, so the string here is
|
|
||||||
also the audience the token is minted for; a near miss (a trailing
|
|
||||||
slash, `http` for `https`) presents as a valid token rejected at
|
|
||||||
the target, several layers from its cause.
|
|
||||||
|
|
||||||
⚠️ Deliberately separate from `scrapeTargets`. An entry there is
|
|
||||||
trusted because the scraper and the target share a host — that
|
|
||||||
option is loopback-and-unauthenticated by contract. An entry here
|
|
||||||
is trusted because it presents a credential. One shape for both
|
|
||||||
would leave a reader unable to tell which of those a given target
|
|
||||||
relies on.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
clientId = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = "swarm-collector";
|
|
||||||
description = ''
|
|
||||||
OAuth2 client id this collector authenticates as. Published so
|
|
||||||
authelia's `access_control` rules can name it without carrying a
|
|
||||||
second copy of the string, exactly as
|
|
||||||
`services.hyperhive.swarm.authelia.hiveClientPrefix` is published
|
|
||||||
for the queue's responder.
|
|
||||||
|
|
||||||
Two spellings drifting apart is not a build failure: authelia
|
|
||||||
refuses a rule naming an unregistered client in its startup
|
|
||||||
validator, so the swarm's SSO service fails to *restart* — long
|
|
||||||
after the change that caused it evaluated cleanly.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
audience = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = lib.attrValues cfg.publishedScrapeTargets ++ lib.attrValues pushAudiences;
|
|
||||||
description = ''
|
|
||||||
Every audience this collector's OAuth2 client is permitted to
|
|
||||||
present a token for — the scrape targets it reads with a credential,
|
|
||||||
plus the stores it pushes to. Published read-only so
|
|
||||||
./glue-swarm-otel-oidc-client.nix can register the client wherever
|
|
||||||
authelia runs without restating the derivation: this option and
|
|
||||||
that registration are the same fact seen from two hosts, and a
|
|
||||||
second formula for it would be free to drift from this one.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# What stays above is what the collector IS to the swarm — the client it is
|
|
||||||
# registered as, where it exports. The secret is a path on the machine that
|
|
||||||
# runs it, so it hangs off the deployment. `enable` already lives in
|
|
||||||
# ./deploy.nix, which also carries the rename.
|
|
||||||
options.services.hyperhive.deploy.swarm-otel = {
|
options.services.hyperhive.deploy.swarm-otel = {
|
||||||
clientSecretFile = lib.mkOption {
|
clientSecretFile = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue