Guidance for AI agents (Claude Code and others) working in this repo. Human
contributors: see CONTRIBUTING.md — this file does not replace it, it sharpens
the parts agents get wrong.
A distributed orchestrator for Firecracker microVMs, written on the BEAM
(Elixir ~> 1.20, OTP 28+). A small privileged Rust setuid helper
(native/suidhelper/, crate hyper_suidhelper) performs the Linux operations
the BEAM cannot do safely (losetup, dmsetup, chroot jails, device nodes).
Postgres is the only external runtime dependency (the image database).
mix check # THE gate. Must pass before any PR. Runs, in order:
# format --check-formatted (formatting is not optional)
# compile --warnings-as-errors --force
# credo --strict
# test --warnings-as-errors
# dialyzer (strict; @specs required)
mix test # Elixir suite (needs Postgres for DB tests)
mix test test/unit test/controls # pure tests, no Postgres/Firecracker needed
mix test --only integration --only external
# live E2E: boots real Firecracker VMs. Needs a provisioned
# KVM host (docs/cookbook/install.md) + passwordless sudo.
# CI runs this in ci.yml's `integration` job on ubuntu-latest.
cargo nextest run # Rust suite (run inside native/suidhelper/)Pure tests under test/unit and test/controls need neither Postgres nor
Firecracker. DB-backed tests need mix ecto.create && mix ecto.migrate first.
The :integration suite (test/e2e/) is the live Firecracker E2E: it starts
the full supervision tree (never combine with --no-start) and drives real
VMs. :external tests pull real OCI images. Both are excluded by default and
run in CI's integration job, which provisions the GitHub-hosted runner via
.github/scripts/provision-kvm-host.sh — keep that script in lockstep with
docs/cookbook/install.md.
lib/— Elixir source. Tests intest/mirror this tree.lib/unit/,lib/controls/,lib/sys/linux/proc/,lib/hyper/redist/— pure cores: units algebra, EWMA/rate controls,/procparsers, hashing. No processes, no I/O. These are where property tests pay off most.native/suidhelper/— the privileged Rust helper. Source insrc/, tests intests/(see Rust rules below).- Generated, do not hand-edit:
lib/hyper/firecracker/api/{operations,schemas}(regenmix firecracker.gen) andlib/hyper/grpc/v1/hyper.pb.ex(regenmix grpc.gen). Both are gitignored and rebuilt by a Mix compiler.
A good test proves the spec, not that the code ran. Writing a passing test is not the goal; writing a test that would fail if the behavior were wrong is. Before adding a test, ask: "what does this module promise, and what input could break that promise?" If a test cannot fail for any realistic implementation bug, it proves nothing — delete it.
StreamData (Elixir) and proptest (Rust) are already wired in. For any pure function — parsers, codecs, algebra, validators, hashing, scheduling math — reach for a property test first. The work is identifying the invariant. Hunt for these families (the existing suites are worked examples of each):
- Round-trip / inverse —
parse(render(x)) == x,decode(encode(x)) == x,with_value(value(q)) == q. Seetest/unit/quantity_properties_test.exs,test/sys/linux/proc/stat_properties_test.exs,native/suidhelper/tests/util/safe_path.rs(relative_toreconstructs). - Algebraic laws — commutativity, associativity, identity, inverse, total
order. See the additive-group laws in
quantity_properties_test.exs. - Oracle / model — the result equals an independent reference computation
(
CpuTimes.total == Enum.sum(cols)). - Invariant preserved — a property that holds for every output regardless
of input (a confined path never contains
..; a refcount is never negative). - Idempotence / metamorphic —
f(f(x)) == f(x); or a known input change produces a known output change. - Error & refusal contracts — invalid input always raises/returns the
specific error, and is never silently accepted. This is a property too: see
"mixing two dimensions always raises" and
rejects_any_loose_component. For security-sensitive code (the setuid helper, path confinement) the refusal property is the most important test in the file.
State the laws under test in the module's @moduledoc (or a Rust //! doc),
the way the existing property suites do — it forces you to name the contract.
Property tests are not a religion. Use a plain example/smoke test when:
- the behavior has no meaningful input space to generate over (a specific
parse of one real
/proc/meminfofixture, one gRPC round-trip); - you are pinning one concrete edge case or regression;
- generating valid inputs would be more code (and more bugs) than the thing under test.
A few good examples that exercise real logic beat a generator that only ever hits the happy path. Pair them: properties for the laws, examples for the representative cases and the nasty edges.
These will be asked to be removed (per CONTRIBUTING.md):
- one-assertion-per-getter / setter tests that restate the struct definition;
- tests asserting on mocks you set up in the same test (proves the mock, not the code);
- tautologies —
assert f(x) == f(x), or recomputing the implementation inside the assertion; - snapshot/coverage-padding tests with no invariant behind them;
- a
propertyblock whose generator is so narrow it only emits one value.
Coverage is a side effect of good tests, never the target.
When codecov (or any coverage tool) flags uncovered lines, do NOT write tests to move the number. Triage each uncovered region first:
- Contract — behavior a caller or operator relies on: a refusal, a wire/decode boundary, a retry/advance classification, error propagation. Write the behavioral test. Ask: "what realistic mutation of this code should this test kill?" If you cannot name one, it is not a contract.
- Mechanical — 1:1 mappings (
Fromimpls, delegation wrappers, derive-equivalent code), CLI dispatch arms,Display/error strings nothing parses. Decline: a test would restate the code and break on refactor without catching bugs. Record the decline where the work is tracked. - Privileged / integration-only — paths needing root, a live guest, or a second node. Decline at unit level and name the integration/E2E that covers them.
A patch may legitimately merge below 100%: uncovered mechanical lines cost less than tests that pin implementation detail.
Parametrize by data, not by assertion. When two or more cases share one assertion shape and differ only in inputs/expected values, write one table-driven test (or a property) over rows — never copy-paste test bodies. If a case needs its own assertions, it is a separate test, not a row.
use ExUnit.Case, async: trueanduse ExUnitPropertiesfor property suites.- Naming convention already in the tree:
*_properties_test.exsfor the property suite of a module,*_test.exsfor its example tests (often both exist side by side, e.g.sha256_test.exs+sha256_properties_test.exs). - Build generators by composing the module's own constructors (see how
quantity/0maps scalars throughInformation.bytes/1etc.) — generate valid inputs by construction rather than generating-then-filtering. StreamDatagotcha: an emptyinteger(a..b)range (whena > b) raises at generation time and aborts the wholecheck all, it does not re-generate. Bound the parent generator so the range is always non-empty.
- Tests live in
tests/, never inline#[cfg(test)]insrc/. The crate is split into a lib (src/lib.rs, cratehyper_suidhelper) and a thin bin (src/main.rs) precisely so integration tests can reach the internals. - Each test file must be registered as a
[[test]]target inCargo.toml(tests/subdirectories are not auto-discovered) — copy an existing[[test]]block when adding one. proptestis the dev-dependency; useproptest!{ #[test] fn ... }withprop_assert!. CI runscargo nextest run --profile ci(one retry to damp flakes — do not write tests that rely on the retry).
- NEVER write section-divider banners — comments like
// --- foo ---,# === bar ===,// ---- Tests ----, or any comment whose job is to label a region of a file. They are a code smell: reaching for one is a signal that the file is doing too many things at once. When you feel the urge, do NOT write the banner — make the underlying decision instead:- Split it out. If the regions are genuinely distinct responsibilities, they belong in separate modules/files (or, for tests, separate test files or submodules). Extract them.
- Or drop the comment entirely. If the code is already cohesive, the banner adds nothing a reader can't see from the names — delete it. A blank line is sufficient separation. There is no third option where the banner stays.
- Comments earn their place. No comment that just restates what the next
line does. A comment explains a non-obvious why a reader cannot recover
from the code — a workaround, an invariant, a deliberate trade-off. Prefer
self-documenting code: a named function, a
Unit.*quantity instead of a bare1024 * 1024, a descriptive variable — over a comment narrating the mechanics. If you reach for a comment to explain what, rename the thing instead. - Don't hand-roll magic numbers for sizes/durations/bandwidth: use the
Unit.*types (Unit.Information.mib(8), not8 * 1024 * 1024) anduse Unit.Operatorsfor unit-aware arithmetic. - Add
@specto public functions. Dialyzer runs with:unmatched_returns,:extra_return,:missing_returnand will fail the gate on a missing/wrong spec. - Zero compiler warnings —
--warnings-as-errorsis enforced. - Conventional Commits, scoped to subsystem:
feat(fire_vmm): ...,fix(redist): ...,test(unit): ...,docs: .... - Do not commit generated bindings or hand-edit them; commit the source
artifact (the
.proto/ OpenAPI spec), not the output.