# Does the NixOS module actually evaluate, and does it still render the units we decided on? # # WHY THIS FILE EXISTS. `nix flake check` does NOT check `nixosModules`. It forces the value and # asserts it is a lambda taking an open attribute set — nothing more; nix's own source carries # `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` MEASURED: a flake whose # `nixosModules.default` sets a nonexistent OPTION, references a nonexistent `pkgs` attribute AND # calls a nonexistent `lib` function passes clean, printing the thoroughly reassuring # # checking NixOS module 'nixosModules.default'... all checks passed! # # So every option name, type, `pkgs.*` reference and systemd directive in nixos-module.nix was # unverified by CI while reading as covered — on a flake whose own history is Nix regressions # reaching main invisibly (`nix build .#punktfunk-web` was broken for 553 commits). # # HOW IT CLOSES THAT. Exposed as a flake `checks` output, so `nix flake check --no-build` — the leg # CI already runs — must INSTANTIATE it, and instantiating forces the `assert` below. Every # assertion is therefore pure Nix, evaluated at instantiation: a shell script inside the derivation # would only run under a full `nix flake check`, which builds the hour-long Rust packages and is # exactly what CI cannot afford. Keep it that way — if you add a check, add it to `results`, not to # a `runCommand` body. # # STUB PACKAGES, on purpose. The real derivations would drag punktfunk-host, punktfunk-client and # (via `gamescopeHdr`) a from-source gamescope into this check's closure, making the cheap leg # expensive and coupling a module regression to a Rust build. What is under test here is the MODULE. # ⚠ The stubs must be fake DERIVATIONS, not store-path strings: `types.package` accepts anything # `isDerivation` as-is, but runs a store-path string through `builtins.storePath`, which demands the # path actually exist and fails eval with "no substituter can build it". { lib, runCommand, # The nixpkgs SOURCE. Interpolated rather than `nixpkgs + "/..."` because this is called with the # flake INPUT, which is an attribute set (coerced through its `outPath`) and not a path — the # difference only shows up as "expected a set but found a string" from whichever side is wrong. nixpkgs, system, module, }: let fakeDrv = name: { type = "derivation"; inherit name; outPath = "/pf-stub/${name}"; outputs = [ "out" ]; }; stubSelf = { packages.${system} = lib.genAttrs [ "punktfunk-host" "punktfunk-client" "punktfunk-web" "punktfunk-scripting" "punktfunk-gamescope" ] fakeDrv; }; # A machine just complete enough for eval-config, plus the scenario under test. evalWith = scenario: (import "${nixpkgs}/nixos/lib/eval-config.nix" { inherit system; modules = [ (module stubSelf) { boot.loader.grub.enable = false; fileSystems."/" = { device = "/dev/sda1"; fsType = "ext4"; }; system.stateVersion = "24.11"; nixpkgs.hostPlatform = system; # `host.users` adds group membership to an existing user; declare one so NixOS's own # "isNormalUser or isSystemUser" assertion is not what this check trips over. users.users.alice.isNormalUser = true; } scenario ]; }).config; # Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a # real (stub, here) gamescope onto the unit PATH, and nothing below is about that. desktop = evalWith { services.punktfunk.host = { enable = true; users = [ "alice" ]; gamescopeHdr = false; desktopSession = true; }; }; appliance = evalWith { services.punktfunk.host = { enable = true; autoStart = true; openFirewall = true; gamescopeHdr = false; }; }; nativeOnly = evalWith { services.punktfunk.host = { enable = true; openFirewall = true; gamestream = false; gamescopeHdr = false; }; }; clientOnly = evalWith { services.punktfunk.client.enable = true; }; unit = cfg: name: cfg.systemd.user.units."${name}.service".text; has = cfg: name: infix: lib.hasInfix infix (unit cfg name); # A module's own failed assertions, as messages. failedAssertions = cfg: map (a: a.message) (lib.filter (a: !a.assertion) cfg.assertions); results = [ # --- the module evaluates at all, in every shape an operator can ask for ------------------- { name = "desktop scenario has no failing assertions"; ok = failedAssertions desktop == [ ]; } { name = "appliance scenario has no failing assertions"; ok = failedAssertions appliance == [ ]; } { name = "client-only scenario has no failing assertions"; ok = failedAssertions clientOnly == [ ]; } # --- the KWin identification trap (packaging/arch/punktfunk-host.install) ------------------- # The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in # the process's permitted set, and the kernel then refuses KWin the /proc//exe readlink it # identifies the client by — which cost every KDE box its desktop streaming in 0.26.0-1. { name = "host ExecStart is the store binary, never a capability wrapper"; ok = has desktop "punktfunk-host" "ExecStart=/pf-stub/punktfunk-host/bin/punktfunk-host serve" && !(has desktop "punktfunk-host" "ExecStart=/run/wrappers"); } # ...while the ENCODE WORKER, which nothing ever has to identify, is pointed at the wrapper. { name = "host points PUNKTFUNK_ENCODE_WORKER at the capability wrapper"; ok = has desktop "punktfunk-host" "PUNKTFUNK_ENCODE_WORKER=/run/wrappers/bin/punktfunk-encode-worker"; } { name = "the encode-worker wrapper carries exactly cap_sys_nice=ep"; ok = desktop.security.wrappers.punktfunk-encode-worker.capabilities == "cap_sys_nice=ep"; } # --- the desktop-login route (scripts/punktfunk-host-desktop-session.conf) ----------------- # Asserted on the evaluated LISTS, not the rendered text: systemd renders `After=` as one # space-separated line, so `hasInfix "After=graphical-session.target"` silently depends on # ordering — it failed against a correct module the first time this check ran. { name = "desktopSession binds the host to graphical-session.target"; ok = let u = desktop.systemd.user.services.punktfunk-host; in lib.elem "graphical-session.target" u.after && lib.elem "graphical-session.target" u.partOf # IN ADDITION to default.target, never instead of it. && lib.elem "graphical-session.target" u.wantedBy; } { name = "desktopSession is NOT applied to the appliance route (it would stay stopped there)"; ok = let u = appliance.systemd.user.services.punktfunk-host; in !(lib.elem "graphical-session.target" u.partOf) && lib.elem "default.target" u.wantedBy; } # --- GameStream opt-out reaches both the argv and the firewall ----------------------------- { name = "gamestream=true passes --gamestream"; ok = has desktop "punktfunk-host" "serve --gamestream"; } { name = "gamestream=false drops --gamestream and its firewall ports"; ok = !(has nativeOnly "punktfunk-host" "--gamestream") && !(lib.elem 47984 nativeOnly.networking.firewall.allowedTCPPorts) && lib.elem 47990 nativeOnly.networking.firewall.allowedTCPPorts; } { name = "openFirewall opens the console AND its plugin origin"; ok = lib.elem 47992 appliance.networking.firewall.allowedTCPPorts && lib.elem 47993 appliance.networking.firewall.allowedTCPPorts; } # --- the three divergences from the shipped units, as regression guards -------------------- # Each of these was ONCE wrong here while right in scripts/*.service. Assert the decision, so a # future edit cannot quietly drift back. { # Without this, systemd's default 5-starts-per-10s against RestartSec=2 gives up permanently # after ~10 s — the exact window before the host's first `serve` writes the mgmt token. name = "web console retries indefinitely while the host writes its mgmt token"; ok = has appliance "punktfunk-web" "StartLimitIntervalSec=0"; } { # A console that exits 0 has still stopped serving. name = "web console restarts on ANY exit, not just failure"; ok = has appliance "punktfunk-web" "Restart=always" && !(has appliance "punktfunk-web" "Restart=on-failure"); } { # The one unit here that runs arbitrary operator TypeScript by design. name = "the plugin runner is sandboxed like the deb/rpm unit"; ok = has appliance "punktfunk-scripting" "NoNewPrivileges=true" && has appliance "punktfunk-scripting" "ProtectSystem=strict" && has appliance "punktfunk-scripting" "ReadWritePaths=%h" && has appliance "punktfunk-scripting" "ReadWritePaths=/tmp" && has appliance "punktfunk-scripting" "RestrictAddressFamilies=AF_UNIX"; } { # PrivateTmp is OFF on purpose (the VirtualHere field report: a private /tmp hides # /tmp/vhclient and /tmp/.X11-unix, so a plugin cannot reach the daemon it integrates with). name = "the plugin runner keeps the real /tmp"; ok = has appliance "punktfunk-scripting" "PrivateTmp=false"; } { # Since the library scanners became plugins, a runner that is off means an EMPTY LIBRARY and # no obvious reason why — which is why deb+rpm `systemctl --global enable` it. name = "the plugin runner is started by default, like every other channel"; ok = has appliance "punktfunk-scripting" "WantedBy=default.target"; } # --- the client half must not drag the host's system wiring in ----------------------------- { name = "a client-only machine defines no host/web/scripting units"; ok = !(clientOnly.systemd.user.services ? punktfunk-host) && !(clientOnly.systemd.user.services ? punktfunk-web) && !(clientOnly.systemd.user.services ? punktfunk-scripting); } ]; failures = map (r: r.name) (lib.filter (r: !r.ok) results); in # The `assert` is what makes `--no-build` sufficient: instantiating this derivation forces it. assert failures == [ ] || throw '' The punktfunk NixOS module no longer renders what packaging/nix/module-check.nix requires. Failing checks (${toString (lib.length failures)} of ${toString (lib.length results)}): - ${lib.concatStringsSep "\n - " failures} ''; runCommand "punktfunk-nixos-module-check" { # Recorded in the output so a green run says what it actually covered. passed = toString (lib.length results); } '' echo "punktfunk NixOS module: $passed checks passed at eval time" > "$out" ''