otel: evaluate the agent log forwarder in module-eval
Nothing in this suite evaluated nix/agent-modules/ at all: every fixture was a host, so a typo in a rendered container config surfaced on a real deploy and nowhere else. This adds an `agent` constructor beside `hive`, off the same `nixosModules.agent-base` the meta flake hands a container. It also adds the suite's first two-hive fixture. Every existing one declares `swarm.hives.h1` alone, so a per-hive arm written against those passes on a hardcoded literal — which is exactly what the new per-hive logs pipeline needed covering. Eight cases, each paired with the control that makes it mean something: the absence arms with a presence half, the per-hive arm with a roster length check, because `lib.all` over an empty roster holds vacuously. Each was then shown to fail. Eight mutations across the three files — `directory` back to the runtime default, a pipeline naming no receiver, an exporter aimed at a loopback literal, an exporter name that stops reading `protocol`, a missing hive-tier logs pipeline, the metrics-only processor inside it, a per-hive pipeline hardcoded to one hive, and logs pipelines exporting to the metrics store — all caught, none survived, none skipped, each run's baseline green. Part of #3940.
This commit is contained in:
parent
68711796ef
commit
07639fd364
1 changed files with 166 additions and 0 deletions
|
|
@ -58,6 +58,32 @@ let
|
||||||
];
|
];
|
||||||
}).config;
|
}).config;
|
||||||
|
|
||||||
|
# The other half of the tree. `nix/agent-modules/` is evaluated by nothing
|
||||||
|
# else in this suite — every fixture above is a host — so a rendered
|
||||||
|
# container config was only ever read by a real deploy. Same entry point
|
||||||
|
# the meta flake hands a container, so what this evaluates is what an
|
||||||
|
# agent gets.
|
||||||
|
#
|
||||||
|
# Note `hyperhive`, not `services.hyperhive`: an agent container's options
|
||||||
|
# live at the top level.
|
||||||
|
agent =
|
||||||
|
extra:
|
||||||
|
(nixosSystem {
|
||||||
|
system = pkgs.stdenv.hostPlatform.system;
|
||||||
|
modules = [
|
||||||
|
self.nixosModules.agent-base
|
||||||
|
{
|
||||||
|
fileSystems."/" = {
|
||||||
|
device = "/dev/null";
|
||||||
|
fsType = "tmpfs";
|
||||||
|
};
|
||||||
|
boot.loader.grub.enable = false;
|
||||||
|
system.stateVersion = "25.11";
|
||||||
|
hyperhive = lib.recursiveUpdate { user.name = "a1"; } extra;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}).config;
|
||||||
|
|
||||||
allLocal = hive { deploy.singleHostSwarm = true; };
|
allLocal = hive { deploy.singleHostSwarm = true; };
|
||||||
bare = hive { };
|
bare = hive { };
|
||||||
withCi = hive { deploy.forgejo.ci.enable = true; };
|
withCi = hive { deploy.forgejo.ci.enable = true; };
|
||||||
|
|
@ -373,6 +399,45 @@ let
|
||||||
swarm.otel.clientSecretFile = "/var/lib/swarm-otel-oidc/by-hand.secret";
|
swarm.otel.clientSecretFile = "/var/lib/swarm-otel-oidc/by-hand.secret";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# every mismatch between them is silent — a push accepted and routed
|
||||||
|
# nowhere, a receiver pointed at an empty directory, a pipeline that does
|
||||||
|
# not exist.
|
||||||
|
agentBridge = "http://10.42.0.1:4318";
|
||||||
|
agentOtel = agent {
|
||||||
|
otel.enable = true;
|
||||||
|
otel.endpoint = agentBridge;
|
||||||
|
};
|
||||||
|
# The same agent over the other wire protocol. An exporter's NAME is what
|
||||||
|
# selects it, so this is where a defined exporter and the pipeline's
|
||||||
|
# reference to it can drift apart.
|
||||||
|
agentOtelGrpc = agent {
|
||||||
|
otel.enable = true;
|
||||||
|
otel.endpoint = agentBridge;
|
||||||
|
otel.protocol = "grpc";
|
||||||
|
};
|
||||||
|
agentNoOtel = agent { };
|
||||||
|
agentSettings = machine: machine.services.opentelemetry-collector.settings;
|
||||||
|
|
||||||
|
# This hive's own collector, which is a HOST service — unlike the swarm
|
||||||
|
# tier's, which lives in a container and is read through `otelSettings`.
|
||||||
|
hiveOtel = hive {
|
||||||
|
otel.enable = true;
|
||||||
|
otel.clientSecretFile = "/var/lib/hive-otel-oidc/client.secret";
|
||||||
|
};
|
||||||
|
hiveOtelPipelines = hiveOtel.services.opentelemetry-collector.settings.service.pipelines;
|
||||||
|
|
||||||
|
# Two hives in the roster, which no other fixture here has: every one of
|
||||||
|
# them declares `swarm.hives.h1` alone, so a per-hive arm written against
|
||||||
|
# one of those passes on a hardcoded literal.
|
||||||
|
otelTwoHives = hive {
|
||||||
|
deploy.swarm-otel.enable = true;
|
||||||
|
deploy.authelia.enable = true;
|
||||||
|
swarm.hives.h2.domain = "h2.t.local";
|
||||||
|
};
|
||||||
|
|
||||||
# A priority collision is a property of the *option*, not
|
# A priority collision is a property of the *option*, not
|
||||||
# of the merged value's interior — nix throws the moment the value is
|
# of the merged value's interior — nix throws the moment the value is
|
||||||
# demanded at all, so `seq`-ing each `serviceConfig` value to WHNF is
|
# demanded at all, so `seq`-ing each `serviceConfig` value to WHNF is
|
||||||
|
|
@ -1022,6 +1087,107 @@ let
|
||||||
in
|
in
|
||||||
named != [ ] && lib.all (a: builtins.elem a s.service.extensions) named;
|
named != [ ] && lib.all (a: builtins.elem a s.service.extensions) named;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The journald receiver's own default directory is the RUNTIME
|
||||||
|
# journal, and a container that stores persistently leaves that
|
||||||
|
# empty. At the default the forwarder validates, starts, reports
|
||||||
|
# healthy and ships nothing, so this one literal is the difference
|
||||||
|
# between the path working and silently not.
|
||||||
|
name = "the agent forwarder reads the persistent journal, not the runtime one";
|
||||||
|
ok = ((agentSettings agentOtel).receivers.journald.directory or null) == "/var/log/journal";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The hop's two ends: what it reads, and where what it reads goes.
|
||||||
|
# The endpoint is compared against the value the fixture handed the
|
||||||
|
# option rather than a literal spelled here, so an exporter that
|
||||||
|
# stopped reading the option fails instead of matching a constant
|
||||||
|
# that travelled beside it.
|
||||||
|
name = "the agent forwarder ships the journal to the endpoint its hive gave it";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = agentSettings agentOtel;
|
||||||
|
p = s.service.pipelines.logs;
|
||||||
|
in
|
||||||
|
p.receivers == [ "journald" ]
|
||||||
|
&& p.exporters != [ ]
|
||||||
|
&& lib.all (e: (s.exporters ? ${e}) && s.exporters.${e}.endpoint == agentBridge) p.exporters;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Presence control for the two cases above: with the switch off there
|
||||||
|
# is no collector in the container at all, so their passing is about
|
||||||
|
# the wiring rather than about a unit that renders regardless.
|
||||||
|
name = "an agent that has not opted into telemetry runs no collector";
|
||||||
|
ok = !agentNoOtel.services.opentelemetry-collector.enable;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# `otlp` and `otlphttp` are different components and the protocol
|
||||||
|
# option picks which one is defined. A pipeline left naming the other
|
||||||
|
# is a startup failure; an exporter no pipeline names is silence.
|
||||||
|
name = "the agent forwarder's exporter and its pipeline agree on the protocol";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = agentSettings agentOtelGrpc;
|
||||||
|
in
|
||||||
|
(s.exporters ? otlp) && s.service.pipelines.logs.exporters == [ "otlp" ];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The tier in the middle. Its OTLP receiver takes both signals on one
|
||||||
|
# port, so without this pipeline an agent's push is answered 404 on
|
||||||
|
# `/v1/logs` — and a forwarder retrying into a 404 is indistinguish-
|
||||||
|
# able from one with nothing to send. Compared against the metrics
|
||||||
|
# pipeline's exporters rather than a name, so the two signals cannot
|
||||||
|
# drift to different destinations.
|
||||||
|
name = "the hive collector forwards logs upstream, not only metrics";
|
||||||
|
ok =
|
||||||
|
(hiveOtelPipelines ? logs)
|
||||||
|
&& hiveOtelPipelines.metrics.exporters != [ ]
|
||||||
|
&& hiveOtelPipelines.logs.receivers == [ "otlp" ]
|
||||||
|
&& hiveOtelPipelines.logs.exporters == hiveOtelPipelines.metrics.exporters;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# `deltatocumulative` is metrics-only: naming it in a logs pipeline
|
||||||
|
# kills the collector at startup rather than doing nothing. The second
|
||||||
|
# clause is the control — the metrics pipeline still names it, so a
|
||||||
|
# pass means the two processor lists differ rather than that the
|
||||||
|
# processor left the module.
|
||||||
|
name = "the hive collector keeps the metrics-only processor out of its logs pipeline";
|
||||||
|
ok =
|
||||||
|
!(builtins.elem "deltatocumulative" hiveOtelPipelines.logs.processors)
|
||||||
|
&& builtins.elem "deltatocumulative" hiveOtelPipelines.metrics.processors;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Read against the roster the fixture declares rather than against
|
||||||
|
# names spelled here: an arm naming `h1` passes on a single-hive
|
||||||
|
# config however the mapping is written. The length clause is what
|
||||||
|
# makes the `all` mean anything — over an empty roster it holds
|
||||||
|
# vacuously.
|
||||||
|
name = "the swarm collector routes every hive's logs, not just one";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
p = (otelSettings otelTwoHives).service.pipelines;
|
||||||
|
hives = lib.attrNames otelTwoHives.services.hyperhive.swarm.hives;
|
||||||
|
in
|
||||||
|
lib.length hives == 2
|
||||||
|
&& lib.all (h: (p ? "logs/${h}") && p."logs/${h}".receivers == [ "otlp/${h}" ]) hives;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The same split as the metrics case above — defining an exporter and
|
||||||
|
# naming it are two lists — plus the half one shared list cannot have:
|
||||||
|
# the metrics store's exporter renders perfectly well inside a logs
|
||||||
|
# pipeline and posts journal records at an ingest route that is not
|
||||||
|
# for them.
|
||||||
|
name = "every logs pipeline sends to the log store and to no metrics one";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = otelSettings otelTwoHives;
|
||||||
|
logPipes = lib.filterAttrs (n: _: lib.hasPrefix "logs/" n) s.service.pipelines;
|
||||||
|
used = lib.unique (lib.concatMap (p: p.exporters) (lib.attrValues logPipes));
|
||||||
|
in
|
||||||
|
logPipes != { }
|
||||||
|
&& builtins.elem "otlphttp/victorialogs" used
|
||||||
|
&& !(builtins.elem "otlphttp/victoriametrics" used)
|
||||||
|
&& lib.all (e: s.exporters ? ${e}) used;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# The store's seal is spread over six gates — the stanza, the
|
# The store's seal is spread over six gates — the stanza, the
|
||||||
# provisioning unit, two bind mounts, a device and an EnvironmentFile.
|
# provisioning unit, two bind mounts, a device and an EnvironmentFile.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue