Files
punktfunk/packaging/nix/module-check.nix
T
enricobuehler f8cde0adaf feat(nix): actually check the NixOS module in CI, and close the sweep's open issues
THE CI GAP. `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 module sets a nonexistent OPTION, references a
nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean,
printing `checking NixOS module 'nixosModules.default'... all checks passed!`.
nix.yml's header claimed that leg covered the module; it never did, for the
module's whole life — on a flake whose history is Nix regressions reaching main
invisibly.

Closed with `checks.<system>.nixos-module` (packaging/nix/module-check.nix): it
evaluates the module against real nixpkgs in four scenarios (desktop, appliance,
native-only, client-only) and asserts on the rendered systemd units. The
assertions are PURE NIX so instantiating the check runs them — which means the
eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built.
Stub fake-derivation packages keep it independent of punktfunk-host/-client and
the from-source gamescope; crane and bun2nix are provably not needed (they are
`throw`s in the wiring test and it still instantiates).

17 checks, including regression guards for every divergence the sweep found and
for the KWin identification trap (host ExecStart must stay on the plain store
path, never a capability wrapper, while the encode worker points AT the wrapper).
Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected,
baseline green. The suite already earned it once — its first run failed a correct
module because systemd renders `After=` as one space-separated line, so those
assertions now read the evaluated lists instead of the text.

Also closed from the sweep:

  * services.punktfunk.host.desktopSession (new, default false) — binds the host
    to graphical-session.target, the declarative form of the
    punktfunk-host-desktop-session.conf drop-in. Without it a Plasma/GNOME
    restart leaves the host holding a Wayland socket and portal D-Bus connection
    that died with the old compositor: it still listens, still answers, and every
    session it then serves fails at capture. Off by default because an appliance
    may never reach that target and would be left permanently stopped.

  * scripting.autoStart now defaults ON, matching the deb postinst and RPM %post,
    which both `systemctl --global enable` the runner. It was opt-in here on the
    reasoning that the runner is inert until you add automation — which stopped
    being true when the game-library scanners became plugins. A NixOS host came up
    with an empty library and no obvious reason why. The module and README carried
    the superseded rationale verbatim; both updated.

  * A warning when the host is enabled and xdg.portal is not. A warning rather
    than `xdg.portal.enable = mkDefault true`, because enabling the portal service
    with no `extraPortals` backend is its own broken state and only the operator
    knows which backend their compositor needs.

  * punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the
    critical path of every host build (`gamescopeHdr` defaults true) yet nothing
    compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change
    of ours — is what breaks it, and the first to find out would be an operator
    whose system rebuild fails.

All .nix files reformatted with the flake's own declared formatter
(nixfmt-rfc-style from the PINNED nixpkgs, not a channel's).
2026-08-10 19:57:10 +02:00

264 lines
11 KiB
Nix

# 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/<pid>/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"
''