nix: split module-eval into per-subsystem checks
The single module-eval derivation forced ~62 full nixosSystem fixtures live at once to compute its cases list: 10.6GB peak RSS / 5m25s to evaluate, by far the dominant cost in nix flake check. Splits it into 21 independent checks.module-eval-* derivations (1-7 fixtures each) sharing builders/helpers via module-eval/lib.nix, so no single derivation needs more than a handful of fixtures live at once. A few cases spanning two clusters carry a small duplicated fixture rather than threading shared state through lib.nix.
This commit is contained in:
parent
69b70a9c6f
commit
dc418a5223
24 changed files with 3760 additions and 2997 deletions
202
nix/module-eval/lib.nix
Normal file
202
nix/module-eval/lib.nix
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# `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 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 swarmServiceEnables;
|
||||
inherit runGroup;
|
||||
}
|
||||
Loading…
Reference in a new issue