Both journald receivers import one PRIORITY mapping, and each tier's suite was asserting the whole table against its own receiver. That checks one file twice: invert the table and two cases fail saying the same thing, which tells you nothing about which of the two possible defects you have. Split by subject instead. The table's contents — the inverted direction, overwrite_text, parse_from/on_error — belong to the file that holds them, so they get a suite of their own reading that file directly, with no fixture at all. Each tier keeps a case, reduced to the question only it can answer: does MY receiver carry the shared mapping. Both tier cases stay. They cover different receivers over different journals — the agent container's own and the swarm collector's host journal — and one tier quietly losing its parser while the other keeps one is exactly the half-fixed state worth catching. Membership rather than equality of the whole operator list, so a tier that later grows an unrelated operator of its own still passes. Checked against seven defect scenarios: each fails exactly one case, and names the right one.
222 lines
9.4 KiB
Nix
222 lines
9.4 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;
|
|
|
|
# The PRIORITY → severity operator both journald receivers attach, read
|
|
# straight off the file that defines it. What each TIER's case then asks is
|
|
# only whether its own receiver carries this exact value — the contents are
|
|
# somebody else's case (./journald-severity.nix), asserted once.
|
|
#
|
|
# Here rather than in one of the suites because three of them want it: the
|
|
# two tiers, to say they are wired to it, and the suite that reads what it
|
|
# says. A parser rather than the whole list, so a tier that later grows an
|
|
# unrelated operator of its own still passes the wiring case.
|
|
journaldSeverityParser = lib.findFirst (o: o.type or null == "severity_parser") null (
|
|
import ../journald-severity.nix
|
|
);
|
|
|
|
# Whether a given receiver is wired to it. One expression, used by both
|
|
# tiers, so "this receiver carries the shared mapping" is spelled once and
|
|
# the two tier cases differ only in which receiver they hand it.
|
|
carriesJournaldSeverity = receiver: lib.elem journaldSeverityParser (receiver.operators or [ ]);
|
|
|
|
# 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 journaldSeverityParser;
|
|
inherit carriesJournaldSeverity;
|
|
inherit swarmServiceEnables;
|
|
inherit runGroup;
|
|
}
|