One commit rather than two because they are not independent: the UI's `enable` had the controller's as its literal default, so moving the controller alone would leave the UI's default naming an option that no longer exists. The UI keeps that derivation in its new home — it is a view onto the controller's state and reaches it over that daemon's unix socket, so the host running the controller is the host that can serve it. Three spellings had to move together for the UI, not one: the `default`, the `defaultText` shown in the options doc, and the description prose that names the old path in words. A grep for the option path finds the first two. The sweep also reached outside nix: `swarm-controller`'s crate README and its `//!` module doc both named the option, as did this repo's own CLAUDE.md and four pages under docs/. An option's name is API, and its documentation lives wherever someone thought to write it down.
153 lines
6.4 KiB
Nix
153 lines
6.4 KiB
Nix
# `checks.module-eval` — the flake check that covers **nix**.
|
|
#
|
|
# Why this exists: every other check in ./checks.nix is a Rust
|
|
# derivation, so a `.nix`-only diff moves no hash, every check is a
|
|
# cache hit, and `nix flake check` reports green **without evaluating
|
|
# what changed**. This one's derivation hash is a function of the
|
|
# evaluated *results* below, so a nix change that flips a property
|
|
# rebuilds it and the builder fails naming that property.
|
|
#
|
|
# ## What belongs here, and what does not
|
|
#
|
|
# 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 be an assertion is the **absence class** — "a hive that
|
|
# hasn't opted in renders exactly what it did before", "this unit does
|
|
# not exist unless X". Those are claims about the *rendered config*
|
|
# rather than about a config being invalid, so they need an evaluator.
|
|
#
|
|
# ⚠️ **Cases are named by the PROPERTY they defend, never by the ticket
|
|
# that prompted them.** A case named after a ticket has the ticket's
|
|
# lifetime; a case named after a property lives as long as the property.
|
|
#
|
|
# ⚠️ **This check evaluates. It does not execute.** Where the artifact is
|
|
# a command line, an HTTP request or a certificate, a value assertion
|
|
# cannot stand in — those need something that *runs* them. And a case
|
|
# that needs a **rendered file** must stub the packages that file drags
|
|
# in (`swarm.ui.package = pkgs.emptyDirectory`), or it costs a full
|
|
# frontend build to answer a question about a listen directive.
|
|
{
|
|
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;
|
|
|
|
allLocal = hive { enableAllLocalDefaults = true; };
|
|
bare = hive { };
|
|
withCi = hive { swarm.forge.ci.enable = true; };
|
|
|
|
# A priority collision is a property of the *option*, not
|
|
# 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
|
|
# both necessary and sufficient. `deepSeq` over-specifies this: it keeps
|
|
# walking *into* the resulting value after the merge already succeeded,
|
|
# and a package/derivation-shaped value's `override`/`overrideAttrs`
|
|
# self-reference sends it into nixpkgs' fixpoint machinery and blows the
|
|
# stack (measured — this is not a hypothetical).
|
|
forceCiServiceConfigs =
|
|
let
|
|
svcs = withCi.containers.hive-ci.config.systemd.services;
|
|
vals = lib.concatMap (s: builtins.attrValues (s.serviceConfig or { })) (builtins.attrValues svcs);
|
|
in
|
|
builtins.foldl' (acc: v: builtins.seq v acc) true vals;
|
|
|
|
# Each case: a name stating the property, and `ok`.
|
|
cases = [
|
|
{
|
|
name = "a hive that has not opted into all-local runs no swarm controller";
|
|
ok = !bare.services.hyperhive.deploy.controller;
|
|
}
|
|
{
|
|
name = "the all-local mode turns the swarm controller on";
|
|
ok = allLocal.services.hyperhive.deploy.controller;
|
|
}
|
|
{
|
|
# The gateway's per-name issuer choice. If this ever collapses to a
|
|
# constant, every swarm-service vhost serves a certificate its CA
|
|
# is name-constrained out of — which evaluates cleanly and fails in
|
|
# a browser.
|
|
name = "a swarm service name gets the swarm-services leaf and the default server does not";
|
|
ok =
|
|
let
|
|
l = allLocal.services.hyperhive.gateway.lib;
|
|
in
|
|
(l.tlsFor "t.local").sslCertificate != (l.tlsFor "_").sslCertificate;
|
|
}
|
|
{
|
|
# nixos asserts when a vhost declares both, so this is also a
|
|
# statement that the `removeAttrs` upstream of it still happens.
|
|
name = "the swarm UI vhost forces TLS instead of merely adding it";
|
|
ok =
|
|
let
|
|
v = allLocal.services.nginx.virtualHosts."t.local";
|
|
in
|
|
v.forceSSL && !(v.addSSL or false);
|
|
}
|
|
{
|
|
name = "a hive with matrix off serves no matrix discovery endpoint";
|
|
ok =
|
|
!(builtins.hasAttr "= /.well-known/matrix/client" bare.services.nginx.virtualHosts."_".locations);
|
|
}
|
|
{
|
|
# main got eval-borked twice by this exact class of bug (once on the
|
|
# unit's `Restart` key, once on `RestartSec`) — a nixpkgs bump to
|
|
# `gitea-actions-runner.nix` adds a plain `serviceConfig.*`
|
|
# definition that collides with one of ours, and nix refuses to
|
|
# merge two plain definitions at *host* eval. No other check
|
|
# instantiates a host with `containers.hive-ci` actually enabled, so
|
|
# the collision only surfaces on operator deploy, not in CI.
|
|
name = "the CI container's unit definitions merge without a priority collision";
|
|
ok = forceCiServiceConfigs;
|
|
}
|
|
];
|
|
|
|
bad = builtins.filter (c: !c.ok) cases;
|
|
report = lib.concatMapStringsSep "\n" (c: " echo '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" { } ''
|
|
${report}
|
|
${
|
|
if bad == [ ] then
|
|
"echo '${toString (builtins.length cases)} module properties hold' && touch $out"
|
|
else
|
|
"echo 'module-eval: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1"
|
|
}
|
|
''
|