ADR-0008: Tiered NixOS VM tests with pinned image digests
Status: accepted · Date: 2026-10-02 · Decision makers: James Hebden
Context and Problem Statement
The Rust unit suite mocks the cluster driver throughout, so it never boots the compiled binary, never parses a real config from disk, and never goes through Cli::parse() to dispatch(). Two real bugs hid in that gap: a before_hook whose resolved config was silently discarded, and a kubectl::Client that carried a kubeconfig but never set KUBECONFIG. The project needed end-to-end coverage that’s reproducible and doesn’t depend on a developer’s Docker daemon or on network access during the build.
Decision Drivers
- Test the real binary through its real entry point
- Reproducible results from
nix build, with no network access in the Nix sandbox - A cheap tier that runs fast and a heavier tier that exercises a real cluster
- Pinned inputs, so a floating image tag can’t change a test result
Considered Options
- NixOS VM tests (
runNixOSTest) in two tiers, with the k3d images fetched as fixed-output derivations pinned by digest - Docker Compose based integration tests in CI
- Shell scripts under
integration-tests/run by hand or in CI, as the Go project did - Rely on the unit tests
Decision Outcome
Chosen option: “Two-tier NixOS VM tests with pinned digests”, because VM tests give a throwaway machine per run and fixed-output derivations let the Nix-sandboxed build load k3d images without reaching a registry. Option 3 exists for the Go binary and needs a Docker daemon and network.
Tier 1 (e2e-mock) boots a VM with only the xianyun binary and a cluster.driver: mock config, and drives version, config validate, config print, doctor, status, down, and destroy. Tier 2 (e2e-k3d) creates a real k3d cluster, checks it with kubectl get nodes, and tears it down. Checks are registered for x86_64-linux and aarch64-linux only.
Consequences
Good:
- Both bugs above were found by writing these tests
- Digest pinning makes Tier 2 reproducible
Bad:
- The first two tiers didn’t run
upwith a deployer, which is why the unregistered deployer registry went unnoticed (GAP-001 in the spec; measured 2026-10-13). Scenario tests built onnix/tests/lib.nixnow cover it (ADR-0019). - The repository has no CI job that runs
cargo test, the VM checks, orcargo audit:.gitlab-ci.ymlcontains nocargoornixinvocation (it includes a sharedstandard-buildtemplate whose scanner coverage ofCargo.lockwasn’t checked) (TM-SUP-001) - Tier 2 needs KVM and substantial memory, and there’s no
aarch64-darwinequivalent - The cluster-domain discovery Job still pulls
busybox:1.36by tag (TM-EXEC-004)
Neutral:
- The macOS build host runs the unit suite under daemonless single-user Nix; see macOS
Implementation Location
nix/tests/e2e-mock.nixnix/tests/e2e-k3d.nixbook/src/project/testing.mdflake.nix