Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/module-eval/lib.nix
atlas 04cffe7957 share the flake's pkgs in host module-eval fixtures
Each `hive` fixture imported its own nixpkgs. Pass the flake's pkgs
through nixpkgs.pkgs with nixpkgs' read-only module instead. On
module-eval-swarm-services-switch this drops nixpkgs instances from 52
to 23 and eval max RSS from 4.83 GiB to 3.28 GiB, with the same drvPath.

Agent fixtures keep their own nixpkgs: agent-modules sets
nixpkgs.config.allowUnfreePredicate, which a shared pkgs can't take.
2026-10-17 12:31:57 +02:00

275 lines
12 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-controller.ui.package = pkgs.emptyDirectory`).
#
# `hive`/`hiveWithController`/`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.
#
# Every host fixture reuses the flake's `pkgs` rather than importing its own
# nixpkgs. `read-only.nix` makes any host module that defines top-level
# `nixpkgs.config`/`overlays`/`hostPlatform` fail the fixture's eval instead
# of being ignored.
hive =
extra:
(nixosSystem {
modules = [
(pkgs.path + "/nixos/modules/misc/nixpkgs/read-only.nix")
{ nixpkgs.pkgs = pkgs; }
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 {
hiveName = "h1";
swarm.domain = "t.local";
swarm.hives.h1.domain = "h1.t.local";
} extra;
}
];
}).config;
# `hive` with the hive-controller tier on (hive-c0re, the agent containers,
# `deploy.hive-controller.*`). Only a fixture whose claim reads that tier
# takes this; every other claim is about the swarm services and evaluates
# without a hive on the host.
hiveWithController =
extra: hive (lib.recursiveUpdate { deploy.hive-controller.enable = true; } extra);
# 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;
agent = extra: agentWith { services.hyperhive.agent = 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;
autheliaRules =
machine:
machine.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules;
# Whether authelia's `rules`, read first-match-wins, leave no subject-scoped
# allow open to other subjects, and leave no rule unreachable.
#
# A rule with a `subject` does not match a subject outside it, so that
# subject falls through to `default_policy` unless a later subject-less
# `deny` on the same domain, with no `resources` or the same ones, catches
# it first. Walking forward from the allow, the first subject-less rule on
# its domain that is either an allow or such a `deny` must be the `deny`.
# A subject-less `deny` on narrower `resources` refuses everyone it
# matches, so it is passed over. Rules naming a subject are passed over too.
#
# A subject-less `deny` with no `resources` matches every request on its
# domain, so no rule for that domain may follow it.
# ⚠️ True of a list with no subject rule at all; a case using it also
# asserts that the rules it reads contain the ones it means.
autheliaSubjectRulesClosed =
rules:
let
n = lib.length rules;
at = lib.elemAt rules;
after = i: map at (lib.range (i + 1) (n - 1));
anyone = r: !(r ? subject);
closes = r: d: d.policy == "deny" && (!(d ? resources) || d.resources == (r.resources or null));
closed =
i:
let
r = at i;
stop = lib.findFirst (
d: d.domain == r.domain && anyone d && (d.policy != "deny" || closes r d)
) null (after i);
in
stop != null && closes r stop;
endsDomain = d: anyone d && d.policy == "deny" && !(d ? resources);
nothingAfter = i: lib.all (r: r.domain != (at i).domain) (after i);
in
lib.all (
i:
(anyone (at i) || (at i).policy == "deny" || closed i) && (!(endsDomain (at i)) || nothingAfter i)
) (lib.range 0 (n - 1));
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 hiveWithController;
inherit agent;
inherit agentWith;
inherit baoNames;
inherit baoStream;
inherit bridgePorts;
inherit bridgePortsOrNone;
inherit baoSettings;
inherit otelSettings;
inherit autheliaRules;
inherit autheliaSubjectRulesClosed;
inherit agentHarness;
inherit journaldSeverityParser;
inherit carriesJournaldSeverity;
inherit swarmServiceEnables;
inherit runGroup;
}