hyperhive/nix/module-eval/lib.nix
atlas 7fa13b592f module-eval: pin the severity mapping's direction and the panel
The direction is the part a reviewer cannot check by looking, so it is
asserted at both ends of the table and in both tiers' groups: an inverted
mapping still maps every value to something, and a case that only asks
whether a severity parser exists passes on the exact defect. The reader
that turns a rendered operator list back into a PRIORITY -> name function
lives in lib.nix, since both tiers need it.

The panel is asserted on its query rather than its title, because a panel
that keeps the title and loses the expression renders an empty graph that
looks exactly like zero prioless lines.
2026-09-20 14:23:56 +02:00

232 lines
9.8 KiB
Nix

# `checks.module-eval-*` — the flake checks that cover **nix**.
#
# Split from one monolithic `module-eval` derivation into small
# independent checks, one per subsystem cluster, so that evaluating any
# single derivation only has to hold a handful of full `nixosSystem`
# fixtures live at once instead of all of them together — the original
# combined ~62 fixtures into one derivation and measured 10.6GB peak RSS /
# 5m25s to evaluate. Every other check in ./checks.nix is a Rust
# derivation, so a `.nix`-only diff moves no hash and `nix flake check`
# goes green **without evaluating what changed**; these derivations' hash
# is a function of their cases' results, so a change that flips a
# property rebuilds the owning check and fails naming it.
#
# Anything expressible as a module `assertion` **should be one instead** —
# an assertion fires at deploy time for a real operator, not only in CI.
# What cannot is the **absence class**: "a hive that hasn't opted in
# renders what it did before", "this unit does not exist unless X" — claims
# about the rendered config rather than about a config being invalid.
#
# ⚠️ **Name a case for the PROPERTY it defends, never the ticket that
# prompted it**; a case named after a ticket has the ticket's lifetime.
#
# ⚠️ **This evaluates, it does not execute** — a command line, a request or
# a certificate needs something that *runs* it. A case needing a rendered
# file must stub what it drags in (`deploy.swarm-ui.package = pkgs.emptyDirectory`).
#
# `hive`/`agent`/`agentWith` builders, the shared cross-group helpers, and
# the `runGroup` derivation builder live in ./lib.nix; each file below
# defines only the fixtures and cases its own cluster needs.
{
pkgs,
lib,
self,
nixosSystem,
}:
let
# Stub host, same shape ./docs/default.nix already uses: enough for a
# `nixosSystem` to evaluate, nothing that pulls a real disk or
# bootloader in.
hive =
extra:
(nixosSystem {
system = pkgs.stdenv.hostPlatform.system;
modules = [
self.nixosModules.default
{
fileSystems."/" = {
device = "/dev/null";
fsType = "tmpfs";
};
boot.loader.grub.enable = false;
system.stateVersion = "25.11";
# `recursiveUpdate`, not `//`: a plain `//` only merges the
# *top-level* keys of `extra` in, so an `extra` that touches a
# nested attr under an existing top-level key (e.g. `swarm.*`)
# silently drops every sibling under that key instead of merging
# into it — the same class of bug as the `services.hyperhive`
# double-nesting mistake this stub already had to dodge once,
# just one level down. `recursiveUpdate` merges nested attrsets
# all the way down instead.
services.hyperhive = lib.recursiveUpdate {
enable = true;
hiveName = "h1";
swarm.domain = "t.local";
swarm.hives.h1.domain = "h1.t.local";
} extra;
}
];
}).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.
#
# Takes a whole module rather than a settings attrset, so a fixture can
# reach either spelling of the agent tier. `user.name` is the one option
# with no usable default, set here at its real path and at `mkDefault` so a
# fixture naming its own agent still wins.
agentWith =
module:
(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";
services.hyperhive.agent.user.name = lib.mkDefault "a1";
}
module
];
}).config;
# Note `hyperhive`, not `services.hyperhive`: the agent tier's options moved
# under `services.hyperhive.agent`, and ../agent-modules/renamed-options.nix
# keeps the top-level spelling reaching them. ⚠️ That shim covers the
# options that existed when the tier moved and nothing since, so a fixture
# for an option added afterwards has to go through [`agentWith`] and name
# the real path.
agent = extra: agentWith { hyperhive = extra; };
baoNames = machine: machine.services.hyperhive.gateway.localNames;
# What nginx is handed for its `stream {}` block. Deliberately not
# `virtualHosts`: a vhost is the terminating shape ./host-modules/
# swarm-bao.nix's header refuses, and this is the passthrough that is not.
baoStream = machine: machine.services.nginx.streamConfig;
# The bridge-interface firewall — the list `network.exposeHostPorts` merges
# into, and the only place a port is opened for agents. The host's own
# `allowedTCPPorts` is a different list and a different exposure.
bridgePorts =
machine:
machine.networking.firewall.interfaces.${machine.services.hyperhive.network.bridgeName}.allowedTCPPorts;
# Same list, on a host that may not declare the interface at all: the
# `[ 80 443 ]` block is what creates the attr, and it is off where the hive
# is. Reading it through `bridgePorts` would throw rather than report an
# empty exposure, which is precisely the state the cases below assert.
bridgePortsOrNone =
machine:
(machine.networking.firewall.interfaces.${machine.services.hyperhive.network.bridgeName} or {
allowedTCPPorts = [ ];
}
).allowedTCPPorts;
# The config file openbao parses, not the nix that produces it: a setting it
# requires is absent here without anything in the module system minding, so
# the daemon's own startup is otherwise the first reader.
baoSettings = machine: machine.containers.swarm-bao.config.services.openbao.settings;
otelSettings =
machine: machine.containers.swarm-otel.config.services.opentelemetry-collector.settings;
agentHarness = machine: machine.systemd.services.hive-agent;
# Reads a journald receiver's rendered operator list back as a plain
# `PRIORITY -> severity-name` function, so a case can ask what a given
# priority actually maps to rather than matching on the shape of the config
# that produces it. Shared because two tiers run that receiver — the agent
# container over its own journal, the swarm collector over the host's — from
# one imported mapping, and the case that says so has to reach both.
# `null` for a receiver carrying no severity parser at all, which is the
# state that mapping replaced.
journaldSeverityOf =
receiver: priority:
let
parser = lib.findFirst (o: o.type or null == "severity_parser") null (receiver.operators or [ ]);
# The mapping is `severity-name -> value or list of values`; invert it
# into `value -> severity-name` so a lookup is by priority.
hits = lib.attrNames (lib.filterAttrs (_: v: lib.elem priority (lib.toList v)) parser.mapping);
in
if parser == null || hits == [ ] then null else lib.head hits;
# Whether that same parser rewrites the severity TEXT. Its own reader,
# because it is a separate way for the mapping to be there and useless: see
# the case that asserts it.
journaldSeverityOverwritesText =
receiver:
let
parser = lib.findFirst (o: o.type or null == "severity_parser") { } (receiver.operators or [ ]);
in
parser.overwrite_text or false;
# The enables that switch owns. ⚠️ `otel` is the per-hive collector's own
# option and is NOT under `deploy` — spelled at the wrong path it would be
# undeclared rather than false, and a roster that quietly loses a member is
# what the count guard in the cases below exists to catch. Its membership
# here is deliberate and was the fix for a gap, not an oversight: the hive
# tier lands wherever the swarm services do.
swarmServiceEnables =
machine:
let
h = machine.services.hyperhive;
in
{
matrix = h.deploy.matrix.enable;
otel = h.otel.enable;
authelia = h.deploy.authelia.enable;
nats = h.deploy.nats.enable;
swarm-otel = h.deploy.swarm-otel.enable;
victoriametrics = h.deploy.victoriametrics.enable;
grafana = h.deploy.grafana.enable;
victorialogs = h.deploy.victorialogs.enable;
bao = h.deploy.bao.enable;
};
runGroup =
name: cases:
let
bad = builtins.filter (c: !c.ok) cases;
# Escaped, because a name is prose and prose contains apostrophes. Hand-quoting
# broke the builder mid-report on the first such name that failed — and only
# ever on failure, so every green run agreed the reporter was fine.
report = lib.concatMapStringsSep "\n" (
c: " echo ${lib.escapeShellArg "FAILED: ${c.name}"} >&2"
) bad;
in
# The results are embedded in the builder text on purpose: that is what
# makes this derivation's hash depend on them, so a nix-only change that
# flips a case cannot be answered from cache.
pkgs.runCommand "hyperhive-module-eval-${name}" { } ''
${report}
${
if bad == [ ] then
"echo '${toString (builtins.length cases)} module properties hold in ${name}' && touch $out"
else
"echo 'module-eval-${name}: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1"
}
'';
in
{
inherit hive;
inherit agent;
inherit agentWith;
inherit baoNames;
inherit baoStream;
inherit bridgePorts;
inherit bridgePortsOrNone;
inherit baoSettings;
inherit otelSettings;
inherit agentHarness;
inherit journaldSeverityOf;
inherit journaldSeverityOverwritesText;
inherit swarmServiceEnables;
inherit runGroup;
}