diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index ed7df081..3e5271f6 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -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; }; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index fd5b5126..5b3da706 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -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