refactor(swarm-otel): one declaration for a published scrape target

`collectorAudiences` was a list of URLs services contributed so the
collector's client could be registered for them. Slice C needs the same
URLs as scrape jobs, and a job needs a name the audience list has no
room for — so services would have contributed to two options that must
agree.

They now contribute `publishedScrapeTargets` once, as `<job> = "<url>"`,
and the client's audiences derive from it. The drift that would have
needed maintaining is gone, and its failure mode was the quiet one: a
target whose audience was forgotten authenticates against nothing and
reads as a broken scrape rather than a missing registration.

Adds an assertion for the one collision the module system cannot catch.
Two definitions of the same key within one option are already refused
(measured); across the two scrape options nothing arbitrates, and both
entries would render into a single scrape_configs list under one
job_name.
This commit is contained in:
atlas 2026-08-24 13:36:32 +02:00
commit 94ef8428e0
2 changed files with 80 additions and 41 deletions

View file

@ -442,17 +442,20 @@ in
url = "https://${cfg.domain}/"; url = "https://${cfg.domain}/";
}; };
# The address a scraper has to hold a token for. Same `behindGateway` # The metrics endpoint, declared once: the collector both scrapes this
# guard, and for a stronger reason than the two above: with it off # URL and derives from it the audience its token is minted for. Same
# there is no `= /metrics` location and no `auth_request` in front of # `behindGateway` guard, and for a stronger reason than the two above:
# it, so the URL this names does not exist to be authorised. # with it off there is no `= /metrics` location and no `auth_request`
# in front of it, so the URL this names does not exist to be scraped
# or authorised.
# #
# ⚠️ Written as the exact URL a collector requests, because that is # ⚠️ Written as the exact URL a collector requests, because that is
# what authelia compares against — this string agreeing with the # what authelia compares against — this string agreeing with the
# `location` block above it is the whole mechanism. A near miss is a # `location` block above it is the whole mechanism. A near miss is a
# correctly minted token refused at the target. # correctly minted token refused at the target.
services.hyperhive.swarm.otel.collectorAudiences = services.hyperhive.swarm.otel.publishedScrapeTargets = lib.optionalAttrs cfg.behindGateway {
lib.optional cfg.behindGateway "https://${cfg.domain}/metrics"; forgejo = "https://${cfg.domain}/metrics";
};
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for # `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB

View file

@ -224,36 +224,35 @@ in
''; '';
}; };
collectorAudiences = lib.mkOption { publishedScrapeTargets = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.attrsOf lib.types.str;
default = [ ]; default = { };
example = lib.literalExpression ''[ "https://forge.example.com/metrics" ]''; example = lib.literalExpression ''{ forge = "https://forge.example.com/metrics"; }'';
description = '' description = ''
URLs this collector may mint an access token for, contributed by Prometheus endpoints this collector scrapes **by name, with a
the modules that publish them. credential**, as `<job name> = "<url>"`.
A service module says **which URL it exposes**; this module turns A service module declares its own entry, from its own module, the
the set into the collector's single OAuth2 client the same split same way it does for `scrapeTargets` and the collector's OAuth2
as `services.hyperhive.gateway.localNames`, and for the same client derives its permitted audiences from these URLs, so a
reason. The collector has no business knowing another service's target and the authorisation to reach it are **one declaration**.
routes, and a service cannot reach into the client list to add Two lists that must agree would be a drift to maintain, and its
itself: client definitions concatenate rather than merging into a failure mode is the bad one: a target whose audience was forgotten
shared entry, so the entry has to be built in one place from a authenticates against nothing and reads as a scrape failure rather
list many modules can write. than a config mistake.
An audience is a **URL**, not a label. Authelia validates a A **full URL**, not `host:port`. Authelia validates a bearer
bearer token against the address being requested, so a token token against the address being requested, so the string here is
minted for one target is refused at another and a client may only also the audience the token is minted for; a near miss (a trailing
request an audience it is registered for registration is the slash, `http` for `https`) presents as a valid token rejected at
authorisation. A near miss (a trailing slash, `http` for `https`) the target, several layers from its cause.
presents as a valid token rejected at the target, several layers
from its cause.
Deliberately not part of `scrapeTargets`. An entry there is Deliberately separate from `scrapeTargets`. An entry there is
trusted because the scraper and the target share a host; an entry trusted because the scraper and the target share a host that
here is trusted because it presents a credential. Folding the two option is loopback-and-unauthenticated by contract. An entry here
together would leave a reader unable to tell which of those a is trusted because it presents a credential. One shape for both
given target relies on. would leave a reader unable to tell which of those a given target
relies on.
''; '';
}; };
@ -328,14 +327,14 @@ in
# render nothing while reading as done; a swarm whose authelia lives # render nothing while reading as done; a swarm whose authelia lives
# elsewhere registers it there. # elsewhere registers it there.
# #
# ⚠️ Also conditional on the audience set being non-empty, and that is # ⚠️ Also conditional on there being a published target, and that is
# the shipped case rather than an edge — nothing contributes a URL # the shipped case rather than an edge — nothing declares one until
# until some service publishes one behind the gateway. Authelia # some service publishes behind the gateway. Authelia refuses a
# refuses a bearer-authz client with no audience, so an unconditional # bearer-authz client with no audience, so an unconditional
# declaration would break every hive that runs a collector and # declaration would break every hive that runs a collector and
# publishes nothing. # publishes nothing.
services.hyperhive.swarm.authelia.oidc.clients = services.hyperhive.swarm.authelia.oidc.clients =
lib.mkIf (autheliaCfg.enable && cfg.collectorAudiences != [ ]) lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { })
[ [
{ {
id = cfg.clientId; id = cfg.clientId;
@ -345,7 +344,13 @@ in
# endpoint refuses an otherwise valid token and blames the # endpoint refuses an otherwise valid token and blames the
# token rather than the missing grant. # token rather than the missing grant.
bearerAuthz = true; bearerAuthz = true;
audience = cfg.collectorAudiences; # DERIVED from the targets rather than contributed alongside
# them. A service declares a URL once and this is the
# permission to reach it; two lists that had to agree would be
# a drift to maintain, and the failure mode is the quiet one —
# a target whose audience was forgotten authenticates against
# nothing and looks like a broken scrape.
audience = lib.attrValues cfg.publishedScrapeTargets;
# Stated rather than left on authelia's default, because the # Stated rather than left on authelia's default, because the
# two agreeing today is not the same as this being the # two agreeing today is not the same as this being the
# required value: authelia permits only basic / JWT methods # required value: authelia permits only basic / JWT methods
@ -416,6 +421,37 @@ in
Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}. Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}.
''; '';
} }
{
# A job name used by BOTH scrape options. Within one option this
# cannot happen — the module system refuses two definitions of the
# same key with different values, measured — but the two options
# are separate, so nothing arbitrates between them, and they
# render into a single `scrape_configs` LIST where nothing
# overwrites anything: both entries ship under one `job_name`.
#
# The message names which side each collision came from, which is
# the part a rendered-config error could not tell an operator.
assertion =
lib.intersectLists (lib.attrNames cfg.scrapeTargets) (lib.attrNames cfg.publishedScrapeTargets)
== [ ];
message = ''
services.hyperhive.swarm.otel: ${
lib.concatMapStringsSep ", " (j: "'${j}'") (
lib.intersectLists (lib.attrNames cfg.scrapeTargets) (lib.attrNames cfg.publishedScrapeTargets)
)
} is declared as both a loopback
scrapeTargets job and a publishedScrapeTargets job.
They render into one prometheus scrape_configs list, so both
entries would ship under the same job_name nothing overwrites
anything, and the two are scraped by different rules with
different trust.
Rename one. A target is either reachable on loopback because it
shares this host, or published and reached with a credential; it
should not be described as both.
'';
}
{ {
# A hive proves who it is with a token this provider mints, so # A hive proves who it is with a token this provider mints, so
# there is no version of this collector that runs without one. # there is no version of this collector that runs without one.