Skip to content

Latest commit

 

History

History
200 lines (166 loc) · 10.5 KB

File metadata and controls

200 lines (166 loc) · 10.5 KB

AGENTS.md

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.

What Hyper is

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).

Commands

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.

Layout

  • lib/ — Elixir source. Tests in test/ mirror this tree.
  • lib/unit/, lib/controls/, lib/sys/linux/proc/, lib/hyper/redist/pure cores: units algebra, EWMA/rate controls, /proc parsers, hashing. No processes, no I/O. These are where property tests pay off most.
  • native/suidhelper/ — the privileged Rust helper. Source in src/, tests in tests/ (see Rust rules below).
  • Generated, do not hand-edit: lib/hyper/firecracker/api/{operations,schemas} (regen mix firecracker.gen) and lib/hyper/grpc/v1/hyper.pb.ex (regen mix grpc.gen). Both are gitignored and rebuilt by a Mix compiler.

Testing philosophy — read this before writing any test

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.

Prefer property-based tests. Find the invariants.

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 / inverseparse(render(x)) == x, decode(encode(x)) == x, with_value(value(q)) == q. See test/unit/quantity_properties_test.exs, test/sys/linux/proc/stat_properties_test.exs, native/suidhelper/tests/util/safe_path.rs (relative_to reconstructs).
  • 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 / metamorphicf(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.

When an example test is the right tool

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/meminfo fixture, 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.

Do not write slop

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 property block whose generator is so narrow it only emits one value.

Coverage is a side effect of good tests, never the target.

Answering coverage reports

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 (From impls, 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.

Elixir specifics

  • use ExUnit.Case, async: true and use ExUnitProperties for property suites.
  • Naming convention already in the tree: *_properties_test.exs for the property suite of a module, *_test.exs for 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/0 maps scalars through Information.bytes/1 etc.) — generate valid inputs by construction rather than generating-then-filtering.
  • StreamData gotcha: an empty integer(a..b) range (when a > b) raises at generation time and aborts the whole check all, it does not re-generate. Bound the parent generator so the range is always non-empty.

Rust specifics (native/suidhelper/)

  • Tests live in tests/, never inline #[cfg(test)] in src/. The crate is split into a lib (src/lib.rs, crate hyper_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 in Cargo.toml (tests/ subdirectories are not auto-discovered) — copy an existing [[test]] block when adding one.
  • proptest is the dev-dependency; use proptest!{ #[test] fn ... } with prop_assert!. CI runs cargo nextest run --profile ci (one retry to damp flakes — do not write tests that rely on the retry).

Other conventions

  • 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:
    1. 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.
    2. 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 bare 1024 * 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), not 8 * 1024 * 1024) and use Unit.Operators for unit-aware arithmetic.
  • Add @spec to public functions. Dialyzer runs with :unmatched_returns, :extra_return, :missing_return and will fail the gate on a missing/wrong spec.
  • Zero compiler warnings — --warnings-as-errors is 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.