nix: attach the prometheus receiver whenever either scrape option is set

The receiver was defined for `scrapeTargets != {} || publishedScrapeTargets
!= {}`, but the swarm-tier pipeline named it only for `scrapeTargets != {}`.
A collector with published targets and no loopback ones therefore rendered
scrape configs that belonged to no pipeline: requested, parsed, delivered
nowhere, and valid enough to deploy.

Latent, not live: the module seeds `scrapeTargets.collector` under its own
`enable`, so the loopback set is never empty in a real deploy and the
disagreeing arm is unreachable today. Nothing would have noticed if that
seeding became conditional, which is why the module-eval case pins the
pipeline rather than trusting the seed to keep masking it.

Refs #4511
This commit is contained in:
atlas 2026-09-19 09:43:30 +02:00 committed by mara
commit fb9a2c0830
2 changed files with 39 additions and 1 deletions

View file

@ -1323,11 +1323,18 @@ in
# producer must always have somewhere to land, unlike
# `prometheus`, which only joins the receiver list once
# something has actually declared a scrape target.
#
# ⚠️ That gate is the SAME condition the receiver is
# defined under, and the two must stay spelled the same
# way: a receiver defined here and attached to no
# pipeline is scrape configs that render, deploy and
# deliver nothing — the silent shape this file keeps
# arguing against, one level up.
"metrics/${swarmTierName}" = {
receivers = [
"otlp/${swarmTierName}"
]
++ lib.optional (cfg.scrapeTargets != { }) "prometheus";
++ lib.optional (cfg.scrapeTargets != { } || cfg.publishedScrapeTargets != { }) "prometheus";
processors = [ "resource/${swarmTierName}" ];
exporters = exporterNames;
};

View file

@ -632,6 +632,19 @@ let
swarm.otel.clientSecretFile = "/var/lib/swarm-otel-oidc/by-hand.secret";
};
# A collector whose ONLY scrape work is published: loopback targets forced
# empty, one published job declared. Unreachable in a real deploy today —
# the module seeds `scrapeTargets.collector` under its own `enable`, so the
# loopback set is never empty on its own — which is exactly why the arm
# below needs a fixture that takes that seeding away. `mkForce` is what
# does it, and it leaves the collector itself enabled: the state under test
# is a running collector with no self-scrape, not an absent one.
otelOnlyPublished = hive {
deploy.swarm-otel.enable = true;
swarm.otel.scrapeTargets = lib.mkForce { };
swarm.otel.publishedScrapeTargets.remote = "https://remote.t.local/metrics";
};
# The log path's three hops, one fixture each. Nothing carries a journal
# record end to end at eval time, so what these defend is the part no tier
# can check for itself: each hop's output is the next hop's input, and
@ -2740,6 +2753,24 @@ let
name = "a hive with no collector declares no self-scrape target";
ok = bare.services.hyperhive.otel.scrapeTargets == { };
}
{
# Defining a receiver and attaching it are two separate lists, and the
# two gates were spelled differently: the receiver appeared for either
# scrape option, the pipeline only for the loopback one. A published-
# only collector therefore rendered scrape configs that reached no
# pipeline — requested, parsed, delivered nowhere, and valid enough to
# deploy. The receiver clause is what stops the arm passing for the
# wrong reason, by an empty `prometheus` never rendering at all.
name = "a published-only collector attaches its prometheus receiver to the swarm pipeline";
ok =
let
s = otelSettings otelOnlyPublished;
in
otelOnlyPublished.services.hyperhive.swarm.otel.scrapeTargets == { }
&& otelOnlyPublished.services.hyperhive.swarm.otel.publishedScrapeTargets != { }
&& (s.receivers ? prometheus)
&& builtins.elem "prometheus" s.service.pipelines."metrics/swarm".receivers;
}
{
# Read against the roster the fixture declares rather than against
# names spelled here: an arm naming `h1` passes on a single-hive