Design Spec: Declarative Integration Testing Framework & CI Pipeline
Implementation Status
| Component / Feature | Status | Details |
|---|---|---|
| Centralized Test Generator | Fully Implemented | Core QEMU framework exists; specialArgs mocking implemented. |
| Dynamic Flake Discovery | Fully Implemented | hostTests scanning and registration into checks exists. |
| Optional Host Overrides | Fully Implemented | Supports an optional test-override.nix file for unique asserts. |
| CI Pipeline Workflow | Fully Implemented | Multi-job dynamic matrices; machines: disko.nix or comin hosts. |
1. Goal
Implement a Nix-native integration test framework to verify NixOS host configurations (system boot, SSH, and QEMU Guest Agent) in isolation, and run them dynamically and in parallel using a GitHub Actions matrix.
2. Rationale
Verifying VM configurations manually or via ad-hoc shell scripts in CI is
brittle and hard to maintain. Transitioning to a declarative testing model using
NixOS's native testing framework (nixosTest) ensures that configuration
correctness is verified inside the Nix sandbox, providing reliable and
reproducible feedback. Running these tests in a parallel matrix in CI minimizes
execution time and isolates failures to specific hosts.
3. Testing Architecture
3.1 Logical vs. Physical Configuration Split
To allow the integration tests to run without triggering physical disk partitioning (which is handled by Disko and is not suitable for standard sandboxed NixOS tests), host configurations are split:
- Logical Configuration (
configuration.nix): Contains services (SSH, QEMU Guest Agent), hostname, and other logical settings. This is what the integration test evaluates. - Physical Configuration (
default.nix): Imports the logical configuration plus hardware definitions (hardware.nix) and filesystem layouts (disko.nix). This is used for actual deployment and full physical builds.
3.2 Centralized Test Generator (config/nix/tests/make-test.nix)
We utilize a centralized, service-aware NixOS integration test generator.
Individual host subdirectories do not contain individual test wrappers. Instead,
the top-level flake directly invokes this generator, passing the path to the
host's logical configuration.nix.
The generator dynamically:
- Sets the test name to
${hostName}-testby reading the logical configuration. - Configures the required QEMU
virtio-serialhardware inside the sandbox ifservices.qemuGuestis enabled. - Constructs the Python test script dynamically based on active services:
- If
services.opensshis enabled, it asserts that the SSH daemon initializes. - If
services.qemuGuestis enabled, it asserts that/dev/virtio-ports/org.qemu.guest_agent.0is created andqemu-guest-agent.serviceactivates. - If
services.sambais enabled, it creates a mock directory for every declared share path (derived from the evaluatedservices.samba.settings, since production bind mounts do not exist in the sandbox), waits forsamba-smbd.service, and asserts that every share appears in an anonymoussmbclientenumeration. - Always asserts that
multi-user.targetis reached (successful boot).
- If
- Live Attribute Extraction: Programmatically extracts production-grade
configuration data (such as
bootstrapPublicKeys) directly from the evaluated package derivation attributes, eliminating the need for loose mock variables. - Declarative Cryptographic Auditing: Leverages
test-override.nixto perform exact filesystem text matching inside the VM, verifying that target keys exist perfectly inside the specialized NixOS declarative directory path (/etc/ssh/authorized_keys.d/root).
3.3 Flake Integration and Dynamic Test Discovery
The flake dynamically discovers and registers integration tests for all hosts:
- Scans the
./hostsdirectory for subdirectories (each representing a host). - Filters out directories that do not contain a
default.nixfile (the same marker that registers the host innixosConfigurations). - Checks for an optional
test-override.nixfile within the host directory to handle host-specific custom test script assertions or extra configuration arguments. - For each valid host, maps the host's logical
configuration.nixdirectly intomake-test.nixand formats it as a check attribute:{ name = "host-<host>-test"; value = <test-derivation>; }. - Exposes these tests in the flake's
checks.${system}output.
This allows nix flake check to automatically run all integration tests
locally. This also achieves a low-maintenance architecture: adding a new host
directory with a default.nix automatically flags it for integration testing
locally and in CI.
4. GitHub Actions Workflow Update (.github/workflows/nix.yaml)
To support scaling the home lab, the CI workflow is split into separate, optimized jobs:
-
detect-tests(Discovery):- Queries the flake directly via
nix eval .#checks.x86_64-linux --jsonand filters keys matching thehost-<host>-testpattern. - Also discovers the machine matrix (deployable hosts; see job 4) and the
image-package matrix (all entries of
packages.x86_64-linux). - Benefit: Zero-maintenance CI; adding a new host or package automatically registers it in CI.
- Queries the flake directly via
-
static-checks(Validation):- Runs standard non-test checks, such as code formatting verification
(
treefmt).
- Runs standard non-test checks, such as code formatting verification
(
-
functional-vm-tests(Parallel Test Execution):- Runs as a matrix job using the output from
detect-tests. - Enables KVM virtualization inside the GitHub Actions runner
(
enable_kvm: trueininstall-nix-action). - Executes the specific integration test for the matrix target:
nix build ".#checks.x86_64-linux.host-${MATRIX_HOST}-test" --verbose - Benefit: Isolates test failures to specific hosts and allows parallel execution, reducing total CI time.
- Runs as a matrix job using the output from
-
machine-build-tests(Production Closure Verification):- Builds the production closure
(
nixosConfigurations.<host>.config.system.build.toplevel) of every deployable host. A host qualifies through either marker:- it carries a
disko.nix(deployed vianixos-anywhere), or - it enables comin (GitOps-managed; LXC containers carry no
disko.nix, so this is what includes them).
- it carries a
- Fixture hosts (e.g.
minimal-iso,minimal-lxc) match neither marker: theirnixosConfigurationsentries have no bootloader or disk configuration, sosystem.build.toplevelis not their deployable form. What runs on real machines is the image package built from the same module set — the installer ISO boots on pristine VMs to install NixOS, and the LXC bootstrap tarball is the container template's first boot.package-build-testskeeps those artifacts buildable, and the VM tests exercise their module sets (each fixture'stest-override.niximports the corresponding package'smodulespassthrough).nix build ".#nixosConfigurations.${MATRIX_HOST}.config.system.build.toplevel" --verbose - This job is what surfaces evaluation-time failures in the production
configuration. The sandboxed VM tests cannot catch them all, because the
test harness overrides parts of the configuration (for example
proxmoxLXC.manageHostNameand the comin remotes), so a module assertion such as comin's non-empty-hostname check only fires when the production closure itself is evaluated.
- Builds the production closure
(
-
package-build-tests(Image Package Verification):- Builds each discovered flake package (the installer ISO and the LXC bootstrap template) so deployable images stay buildable.
5. Verification Plan
5.1 Automated Tests (CI)
- Push a branch to GitHub and verify the multi-job CI pipeline:
- Verify that
detect-testssuccessfully detects all hosts with adefault.nix, plus the image packages. - Verify that the
functional-vm-testsmatrix job successfully executes and passes the tests in parallel. - Verify that the
machine-build-testsmatrix job builds the production closure of every deployable host (disko.nixor comin-enabled), including hosts without adisko.nix. - Verify that the
package-build-testsmatrix job builds all image packages.
- Verify that
5.2 Manual Verification (Local/Dev)
- If Nix is available on the development machine and supports KVM, run
nix flake checkfromconfig/nixto run all discovered integration tests locally. - To run a specific host's test locally:
nix build .#checks.x86_64-linux.host-<host>-test