# The swarm's telemetry collector as every hive sees it: the name it is # reached on, its receiver ports, the published targets it scrapes, 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 { # `enable` is `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}/`, 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.swarm.hiveOtel.collectorPort`) 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. On the host running this collector, an assertion rejects a derived range that overlaps any other port this module or the hive tier declares, 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 (the swarm services need not run on the same host as the swarm controller), so loopback-only reachability is not a supported shape here, as it is not 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 port-collision assertion still rejects 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 port-collision assertion covers this port too. ''; }; serviceProducers = lib.mkOption { internal = true; type = lib.types.attrsOf ( lib.types.submodule { options = { clientId = lib.mkOption { type = lib.types.str; description = '' The producer's OAuth2 client id: the one audience its receiver's `oidc/` authenticator accepts, and the one the forwarder requests. ''; }; port = lib.mkOption { type = lib.types.port; description = '' Loopback port of the producer's receiver on the collector's host. In the port-collision assertion with every other receiver there. ''; }; }; } ); default = { }; description = '' Swarm services whose own container forwards to this collector, keyed by the route name: each gets a receiver `otlp/`, an authenticator `oidc/` and a gateway location `https://''${domain}//`, and its records land in the swarm tier's pipelines with no `hive` label. A receiver per producer because an `oidc` authenticator checks one audience, and each service mints its token as its own client. Declared below for every host, under no `enable`: the collector's host renders the receiver whether or not the service runs there. Every name must contain a fragment of `nix/reserved-hive-fragments.nix`, so no hive can own its components. ''; }; 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 `glue-controller-env.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 (`glue-controller-env.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 ` = ":"`. 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. An entry that also needs the collector on the same host (the loopback address is only honest then) is declared from a glue module instead, which reads both switches. Every swarm service is a `publishedScrapeTargets` entry instead, which a collector on any host scrapes. ⚠️ **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 ` = ""`. **Entries are declared swarm-wide**: from this module's `config`, unconditionally, keyed on the service's `swarm..domain`, so every host evaluates the same set. Unlike `scrapeTargets`, an entry is not declared under the service's own `enable`: the collector reads this list on its host and authelia's registration reads it on authelia's host, and neither need be the service's host. A swarm that does not run the service anywhere scrapes a name that answers nothing, which shows as a failing job rather than a missing one. The collector's OAuth2 client derives its permitted audiences from these URLs, and authelia derives from each one the access rule that admits only that client to it, 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. ''; }; }; config.services.hyperhive.swarm.otel.serviceProducers = { # The forwarder in ./swarm-bao.nix. ${cfg.storeProducerName} = { clientId = baoCfg.otel.clientId; port = cfg.storeProducerPort; }; # The forwarder in ./swarm-nats.nix, which addresses this route by the # queue container's name. swarm-nats = { clientId = hyperhiveCfg.swarm.nats.clientId; port = lib.mkDefault 4392; }; # The forwarders in ./swarm-builder.nix, ./swarm-claude-login.nix and # ./swarm-review-classifier.nix, which address their routes by container # name. swarm-builder = { clientId = hyperhiveCfg.swarm.builder.clientId; port = lib.mkDefault 4393; }; swarm-claude-login = { clientId = hyperhiveCfg.swarm.claude-login.clientId; port = lib.mkDefault 4394; }; swarm-review-classifier = { clientId = hyperhiveCfg.swarm.review-classifier.clientId; port = lib.mkDefault 4395; }; # The forwarders in ./swarm-ci-runner.nix, ./swarm-grafana.nix and # ./swarm-victorialogs.nix, each authenticated as a client of its own. swarm-ci-runner = { clientId = hyperhiveCfg.swarm.ci-runner.otel.clientId; port = lib.mkDefault 4396; }; swarm-grafana = { clientId = hyperhiveCfg.swarm.grafana.otel.clientId; port = lib.mkDefault 4397; }; swarm-victorialogs = { clientId = hyperhiveCfg.swarm.victorialogs.otel.clientId; port = lib.mkDefault 4398; }; # The forwarders in ./hive-forge/default.nix and ./hive-matrix.nix, each # authenticated as a client of its own. hive-forge = { clientId = hyperhiveCfg.swarm.forge.otel.clientId; port = lib.mkDefault 4399; }; hive-matrix = { clientId = hyperhiveCfg.swarm.matrix.otel.clientId; port = lib.mkDefault 4400; }; # The forwarder in ./swarm-victoriametrics.nix, which reaches this route # through its host's nginx. swarm-victoriametrics = { clientId = hyperhiveCfg.swarm.victoriametrics.otel.clientId; port = lib.mkDefault 4401; }; # The CI run poller in ./swarm-ci-metrics.nix, a host unit that pushes # its run samples here directly. swarm-ci-metrics = { clientId = hyperhiveCfg.swarm.ci-metrics.clientId; port = lib.mkDefault 4403; }; # The forwarder in ./swarm-authelia.nix. swarm-authelia = { clientId = hyperhiveCfg.swarm.authelia.otel.clientId; port = lib.mkDefault 4402; }; }; # Not under any `enable`: see `publishedScrapeTargets` for why every host # declares every entry. # # ⚠️ Each URL must be exactly what the collector requests, because authelia # checks the token's audience against it: it has to agree with the gated # `= /metrics` location on the service's vhost. A near miss is a correctly # minted token refused at the target. config.services.hyperhive.swarm.otel.publishedScrapeTargets = { # The vhost location is in ./hive-forge/default.nix. forgejo = "https://${hyperhiveCfg.swarm.forge.domain}/metrics"; # The vhost location is in ./swarm-authelia.nix. authelia = "https://${hyperhiveCfg.swarm.authelia.domain}/metrics"; # The vhost location is in ./swarm-victoriametrics.nix. victoriametrics = "https://${vmCfg.domain}/metrics"; # The vhost location is in ./swarm-victorialogs.nix. victorialogs = "https://${hyperhiveCfg.swarm.victorialogs.domain}/metrics"; # The vhost location is in ./swarm-grafana.nix. grafana = "https://${hyperhiveCfg.swarm.grafana.domain}/metrics"; # The vhost location is in ./swarm-nats.nix. nats = "https://${hyperhiveCfg.swarm.nats.domain}/metrics"; }; }