Skip to content

Testing

Datum changes operating systems as root, so a test suite that only proves the code compiles and the YAML parses proves very little. This page records what is tested where, what a supported distribution has been through, and what blocks a release.

Proposed design

Nothing is implemented, so nothing here is running. The levels, the matrix and the gating are proposed, and they are written now because the support matrix already promises that entries appear only after behaviour is tested, and that promise needs a definition.

The levels

Level Runs against Answers Speed
Unit Nothing external Does resolution, merging, ordering and diffing behave? Milliseconds
Integration A real filesystem and real tooling, one provider at a time Does this provider observe and apply correctly? Seconds
System A whole host, container or VM Does a full pass converge, and is it idempotent? Tens of seconds
End to end A Git remote and several hosts Does a commit reach hosts and converge them? Minutes

The level a test belongs at is decided by what it needs, not by what it is about. A test of precedence ordering needs no host and is a unit test even though precedence is a fleet concept. A test that apt reports an installed version correctly needs apt and a package database, so it is an integration test even though it covers two lines of code.

Unit tests cover everything above the provider boundary, which is most of Datum. Document parsing, matcher evaluation, layer merging, precedence, conflict detection, graph construction, cycle detection, plan ordering and action selection are all pure functions from repository content and observed state to a plan, and all of them can be tested without a host existing.

That is a deliberate consequence of the architecture. Resolution does not read the host, so the half of Datum most likely to contain subtle logic errors is the half that needs no infrastructure to test.

Integration tests cover one provider against real tooling. The apt provider against a real apt, the File provider against a real filesystem, the systemd provider against a real systemd. Mocks are not used at this level, because the differences that matter between distributions are in command output formats and failure modes, which is exactly what a mock gets wrong.

System tests run complete passes against a host and assert on the outcome, the plan, and the state afterwards. This is where idempotence, drift correction, failure containment and verification are tested.

End-to-end tests involve a Git remote and more than one host, and cover the things that only exist at that scale, such as a commit reaching hosts, revision lag appearing in metrics, and a bad commit leaving hosts on their last known good.

What containers can and cannot test

Containers are cheap, fast, and genuinely sufficient for a large part of the matrix. They are also insufficient in ways that matter for exactly the resource types Datum manages.

Works in a container Why
Package on every provider A package manager needs a filesystem and a network, both of which a container has
File and Directory Ordinary filesystem operations, including the symlink and hard link cases
User and Group /etc/passwd and /etc/group are files
Manifest validation, resolution, planning No host interaction at all
Malformed desired state Fails before the host is touched, so the host can be anything
Needs a virtual machine Why
Service under systemd systemd as PID 1 with cgroup and dbus access is awkward and unrepresentative in a container
Sysctl /proc/sys is shared with the host or read-only, and writing it either fails or affects the host
Reboots A container has nothing to reboot
Kernel package upgrades The running kernel is the host's
Network configuration Changing it inside a container does not exercise what it does on a machine
Anything asserting the state survives a restart Requires something that restarts

The dividing line is whether the resource touches the kernel or the init system. Everything that is filesystem or package manager work is faithfully testable in a container, and everything that is kernel or PID 1 work is not.

Important limitation

A container passing a Sysctl test proves nothing, because writing /proc/sys in a container either fails or changes the host running the test suite. This is the clearest case where a test that appears to pass is worse than no test, and Sysctl is therefore VM-only in the matrix regardless of how much slower that makes it.

Which runtime for what

Runtime Used for
Podman or Docker Package, File, Directory, User, Group providers across every distribution
systemd-nspawn systemd Service tests where a full VM is not warranted
Full VMs Sysctl, reboots, kernel upgrades, network configuration, and the per-distribution acceptance run

systemd-nspawn is the useful middle. It gives a real systemd as PID 1 with a real dbus, which a plain container does not, at a fraction of a VM's cost, and it covers most Service behaviour including enablement across a container restart.

It does not cover reboots of the host kernel or anything in /proc/sys, so it narrows the VM set without removing it.

The matrix

Two axes, and they are not multiplied out in full.

Distribution Versions Rationale
Debian Current stable, previous stable The apt reference
Ubuntu Current LTS, previous LTS Different enough from Debian to need its own row despite ID_LIKE
Fedora Current The dnf reference, and moves fastest
RHEL or a rebuild Current major, previous major Long-lived versions are where old tooling behaviour shows up
Alpine Current stable The apk and OpenRC reference
Arch Rolling The only row with no VERSION_ID, which is itself a case to test

Rocky Linux and AlmaLinux are represented by one RHEL-family row instead of three, because they differ from RHEL in branding and ID, not in package or service behaviour. If that assumption turns out to be wrong for a specific provider, that provider's tests gain the extra rows and the whole suite does not.

Distributions are tested against their actual released images. A minimal official image for each version, pulled and not built, because the differences that matter are in what the distribution ships by default, and a locally-built image tests the build instead of the distribution.

Architectures

amd64 and arm64. Both are ordinary targets now, and arm64 is where assumptions about paths, package naming and available versions tend to break.

Other architectures are not tested and therefore not supported. Running a full matrix on more would cost more than the coverage returns until somebody needs one, at which point that architecture gains rows instead of being assumed to work.

Testing specific behaviours

Idempotence is tested by running a pass twice and asserting the second plan is empty. That is the observable definition, not a proxy for it, and every provider gets the test for every action it supports. A provider that passes on the first run and produces a non-empty second plan has a normalisation bug, and this test is the only reliable way to find it.

Artificial drift is introduced by changing the host directly and running another pass. Delete the package, rewrite the file, stop the service, change the mode, then assert the plan contains exactly the expected action and that the pass converges. Drift is ordinary input, so a drift test is a normal pass against a modified host and nothing special.

Partial failures are produced by making one resource fail on purpose, usually by making a path unwritable, and asserting three things. The failing resource is failed, resources that require it are blocked, and unrelated resources still converge. That last assertion is the one that catches an over-eager abort.

Interrupted reconciliation is tested by killing the agent mid-pass and running another pass. The assertion is that the second pass converges, which is the property that matters, because there is no partially-applied plan to resume and the next pass observes the host and plans from what it finds. Killing the agent at different points, including between a staged write and its rename, is tested explicitly.

Agent crash recovery is the same test with the agent restarted by its supervisor instead of the harness, and it additionally asserts that the recorded last-known-good revision survived and that no lock or temporary file blocks the next pass. Leftover .datum.tmp files in target directories are the expected failure here.

An unavailable Git remote is tested by blocking access and asserting the host keeps reconciling its last known good, reports the failure to fetch, and continues correcting drift. The inverse test matters as much, and it asserts that the host advances once the remote returns.

Malformed desired state covers every row of the manifest validation table, and each asserts the same two things, which are that the error names the offending document and that the host was not touched. This suite is cheap, runs in containers, and protects the property that a bad commit costs nothing.

Package upgrades and repository failures need a controlled package repository, not the distribution's real one, so the suite hosts its own repository with two versions of a test package. That makes version pinning, upgrades, downgrades and a repository returning errors or timing out all testable without depending on the internet or on what upstream happens to be serving.

Reboots are tested in VMs by applying a change that requires one, asserting the host reports awaiting-reboot, rebooting, and asserting the state afterwards. The important assertion is that Datum did not reboot the machine itself.

Network configuration is tested in VMs with out-of-band access, so that a change which breaks networking does not disconnect the harness. A VM with a serial console or a second interface reserved for the harness makes a test that breaks connectivity survivable, which is not possible when the only access is the thing under test.

Privileged filesystem operations are tested in throwaway VMs or containers, never on a developer's machine or a shared runner. The path resolution tests deliberately create hostile conditions, including symlinks pointing at sensitive files and directories swapped mid-operation, and those tests are the ones that damage a host if the code under test is wrong.

Unsupported operations are tested by asserting the right refusal. A Service resource on Alpine with no OpenRC provider must produce skip with a reason and a degraded host, not a failure and not a silent success, and that assertion is as important as the ones covering things that work.

Provider conformance

Every provider passes a common conformance suite before it counts as existing.

The suite is written once against the provider interface and parameterised by provider, so adding a package provider means running the existing suite rather than writing new tests. It asserts the behaviour every provider owes regardless of what it manages.

Conformance requirement
Observation reports the target's actual state, including absence, and never desired values
Observation changes nothing, asserted by observing a host and diffing it against a snapshot
Every action the type defines is implemented, or declared unsupported
A second pass after a successful one produces an empty plan
Verification detects a discrepancy introduced on purpose
A failure is reported as a failure, never as success or a partial change
Fields the provider cannot observe are reported unobservable, never guessed

That suite is what makes the provider boundary real and not aspirational. A second package provider passing the same tests as the first is the actual evidence that apt assumptions did not leak upward, which is why the implementation order puts a second package provider early.

What "supported" requires

A support matrix entry needs all of the following, on that distribution at that version, against its released image.

The provider passes the full conformance suite, exercising every action the resource type defines, and the idempotence test passes. Verification is shown to catch an introduced discrepancy, failures produce reported errors rather than partial changes, and any field the provider cannot observe is documented on the resource type page.

Anything short of that is not a support entry. Work in progress, a provider that mostly works, and a distribution somebody tried by hand all read as unsupported, which is the point of writing the criteria down before there is pressure to bend them.

What runs when

Trigger Runs Budget
Pull request Unit, integration in containers, malformed-state suite, one distribution's system tests Minutes
Merge to main Everything above plus system tests across every container-testable distribution Tens of minutes
Nightly Full matrix including VMs, reboots, sysctl, network, architectures Hours
Release Full matrix, and it must be green Hours

The split is by cost and by likelihood of catching something. A pull request runs everything fast enough to keep a contributor waiting for, which is most of the logic and one distribution end to end. The VM matrix runs nightly because it is slow and because the failures it finds are usually distribution-specific instead of introduced by the change under review.

Release gating is the full matrix passing. A release with a failing VM row ships a provider that does not work on a distribution the matrix claims, and the matrix is the only thing a user has to go on.

Keeping the matrix affordable

Six distributions, ten versions, two architectures and four test levels multiplies into something nobody will wait for and nothing will pay for.

Three things keep it bounded. Most tests are container tests, which are cheap enough that the distribution axis barely matters. VM tests run only for resources that need a kernel or an init system, which is a small subset. And the full matrix runs nightly and at release, not per commit, so the expensive axis is crossed on a schedule instead of on every push.

Open question

Where the VM matrix runs is undecided and is the main cost question. Hosted runners with nested virtualisation are slow and simple, dedicated hardware is fast and needs maintaining, and cloud instances per run are fast and cost money continuously. The decision affects how often the full matrix can realistically run, which in turn affects whether nightly is achievable or whether it becomes weekly.