diff --git a/.config/nextest.toml b/.config/nextest.toml index 5b23136755..9fb55b4c66 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -136,8 +136,29 @@ store-failure-output = true # filter = 'test(flaky test name here)' # retries = 3 +# Every package that boots a guest, not just the one that owns the harness. +# +# A VM claims a 1GiB hugepage for its guest memory and the host pool is small -- two pages on the +# machine this was found on. Nothing arbitrates that pool, so tests that boot concurrently race for +# it and the losers fail with "the 1073741824-byte hugepage pool has 0 free page(s)". That is a +# resource conflict wearing the costume of a flaky test, and it made ten of `dataplane-n-vm`'s +# eighteen integration tests fail in a whole-workspace run while all eighteen passed with +# `--test-threads 1`. +# +# The list is `#[n_vm::test]`'s users. It has to be maintained by hand, which is the weakness of +# fixing this here: a package that starts booting guests and is not added to it fails in a way that +# looks like its own bug. The durable fix is for `n-vm` to acquire the pool itself and wait rather +# than fail -- a test harness that only works when the consuming repo's runner config is right is +# not one that can be handed to another repo. [[profile.default.overrides]] -filter = 'package(dataplane)' +filter = ''' + package(dataplane) + + package(dataplane-n-vm) + + package(dataplane-n-it) + + package(dataplane-hardware) + + package(dataplane-interface-manager) + + package(dataplane-mgmt) +''' platform = 'cfg(unix)' test-group = 'vm' diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f7b8529e57..cb10a609dc 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -195,6 +195,22 @@ jobs: USER: "runner" # The `just` action runs under `set -u`, so this stays defined. JUST_VARS: "" + # These runners are containers on bare metal talking to the host's + # Docker daemon, so the daemon cannot see this container's /nix. Measured + # on a runner: /nix/store holds 7386 entries here and 0 as the daemon + # sees it, while `_work` is the same block device at the same path in + # both namespaces. So the closure the container mounts is exported to a + # directory under `_work`, where the two agree. `setup-roots` fills it; + # `n-vm` reads the same variable and rewrites its bind mount sources, + # leaving the targets alone. See `n_vm_host_share` in the justfile. + # + # Beside the checkout rather than inside it. Two reasons: the workspace + # is served to the guest over virtiofs, and -- the one that actually + # bites -- `default.nix` filters `./.` as its source, and the markdown + # and C-header filters would sweep a few GiB of copied store into every + # build. `runner.temp` would be the natural home but the `runner` + # context is not available to a job-level `env`. + N_VM_HOST_SHARE_DIR: "${{ github.workspace }}/../n-vm-share" strategy: fail-fast: false # Each entry gets its own runner; this limits shared-pool occupancy. diff --git a/.gitignore b/.gitignore index c30846aa3e..cacb3458c0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ result*/** /bin sysroot devroot +testroot +vmroot + +# n-vm host share (see `n_vm_host_share` in the justfile) +.n-vm-share diff --git a/.semgrep/rules/no-std-sync-direct.yaml b/.semgrep/rules/no-std-sync-direct.yaml index 008d6b249e..99276a0121 100644 --- a/.semgrep/rules/no-std-sync-direct.yaml +++ b/.semgrep/rules/no-std-sync-direct.yaml @@ -17,6 +17,17 @@ rules: - concurrency/src/quiescent.rs - concurrency/src/slot.rs - concurrency/tests/ + # The in-VM test harness is workspace-independent by design: it + # depends on no dataplane crate, so that it can go back to + # `githedgehog/testn` without a decoupling project first. Taking the + # `concurrency` facade would be exactly that coupling, and would buy + # nothing -- none of this code is under loom or shuttle, and it is + # host-side test infrastructure rather than dataplane. + - n-vm/ + - n-vm-macros/ + - n-vm-protocol/ + - n-it/ + - n-preinit/ pattern-either: - pattern: use std::sync::Arc; - pattern: use std::sync::Weak; diff --git a/Cargo.lock b/Cargo.lock index 526b3a2b47..6a308f8a01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -330,7 +330,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.5.0", "http-body", "http-body-util", "mime", @@ -349,7 +349,7 @@ dependencies = [ "bytes", "either", "fs-err", - "http", + "http 1.5.0", "http-body", "hyper", "hyper-util", @@ -392,6 +392,16 @@ dependencies = [ "backtrace", ] +[[package]] +name = "base64" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5032d51da2741729bfdaeb2664d9b8c6d9fd1e2b90715c660b6def36628499c2" +dependencies = [ + "byteorder", + "safemem", +] + [[package]] name = "base64" version = "0.22.1" @@ -564,18 +574,18 @@ dependencies = [ [[package]] name = "bollard" -version = "0.21.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ - "base64", + "base64 0.22.1", "bollard-stubs", "bytes", "futures-core", "futures-util", "hex", "home", - "http", + "http 1.5.0", "http-body-util", "hyper", "hyper-named-pipe", @@ -601,9 +611,9 @@ dependencies = [ [[package]] name = "bollard-stubs" -version = "1.53.1-rc.29.3.1" +version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ "serde", "serde_json", @@ -655,17 +665,6 @@ dependencies = [ "serde", ] -[[package]] -name = "capctl" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6e71767585f51c2a33fed6d67147ec0343725fc3c03bf4b89fe67fede56aa5" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - [[package]] name = "caps" version = "0.5.6" @@ -696,7 +695,7 @@ dependencies = [ "serde_json", "syn 2.0.119", "tempfile", - "toml", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -835,12 +834,13 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cloud-hypervisor-client" -version = "0.6.0+api-spec-0.3.0-2026-05-19" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4bed6a95ff723212851f4f331887d6de58cc8f9ecf887accffea5d1575d9c1f" +checksum = "a135a9339a5ad2775b8843dfad19cd5057e9d8092a3c09b523c5030fcc1348ee" dependencies = [ + "base64 0.7.0", "futures", - "http", + "http 0.2.12", "http-body-util", "hyper", "hyper-util", @@ -901,7 +901,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1199,6 +1199,7 @@ dependencies = [ "dataplane-lifecycle", "dataplane-lpm", "dataplane-mgmt", + "dataplane-n-vm", "dataplane-nat", "dataplane-net", "dataplane-pipeline", @@ -1213,7 +1214,6 @@ dependencies = [ "linkme", "metrics", "metrics-exporter-prometheus", - "n-vm", "netdev", "nix 0.31.3", "once_cell", @@ -1485,11 +1485,11 @@ dependencies = [ "bytecheck", "dataplane-dpdk-sysroot-helper", "dataplane-id", + "dataplane-n-vm", "dataplane-sysfs", "dataplane-test-utils", "fixin", "hwlocality", - "n-vm", "num-derive 0.5.1", "num-traits", "pci-ids", @@ -1537,6 +1537,7 @@ dependencies = [ "bolero", "caps", "dataplane-concurrency", + "dataplane-n-vm", "dataplane-net", "dataplane-rekon", "dataplane-test-utils", @@ -1545,7 +1546,6 @@ dependencies = [ "futures", "libc", "multi_index_map", - "n-vm", "nix 0.31.3", "rtnetlink", "serde", @@ -1678,6 +1678,7 @@ dependencies = [ "dataplane-k8s-less", "dataplane-lifecycle", "dataplane-lpm", + "dataplane-n-vm", "dataplane-nat", "dataplane-net", "dataplane-pipeline", @@ -1693,7 +1694,6 @@ dependencies = [ "ipnet", "linkme", "multi_index_map", - "n-vm", "netdev", "nix 0.31.3", "pretty_assertions", @@ -1706,6 +1706,72 @@ dependencies = [ "tracing-test", ] +[[package]] +name = "dataplane-n-it" +version = "0.27.0" +dependencies = [ + "dataplane-n-vm-protocol", + "nix 0.31.3", + "parking_lot", + "thiserror", + "tokio", + "tokio-vsock", + "tracing", + "tracing-subscriber", + "vsock", +] + +[[package]] +name = "dataplane-n-preinit" +version = "0.27.0" +dependencies = [ + "dataplane-n-vm-protocol", + "nix 0.31.3", +] + +[[package]] +name = "dataplane-n-vm" +version = "0.27.0" +dependencies = [ + "bolero", + "bollard", + "cloud-hypervisor-client", + "command-fds", + "dataplane-n-vm-macros", + "dataplane-n-vm-protocol", + "futures", + "miette", + "nix 0.31.3", + "qapi-qmp", + "qapi-spec", + "rand 0.10.2", + "rtnetlink", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tokio-vsock", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dataplane-n-vm-macros" +version = "0.27.0" +dependencies = [ + "dataplane-n-vm", + "proc-macro2", + "quote", + "syn 3.0.4", + "trybuild", +] + +[[package]] +name = "dataplane-n-vm-protocol" +version = "0.27.0" + [[package]] name = "dataplane-nat" version = "0.27.0" @@ -1872,7 +1938,7 @@ name = "dataplane-sysfs" version = "0.27.0" dependencies = [ "dataplane-concurrency", - "n-vm", + "dataplane-n-vm", "nix 0.31.3", "procfs", "thiserror", @@ -2138,18 +2204,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e49983f6f9b2e40db2416bacd643ec482f29e5438bcd1bf587ff142558322b81" -[[package]] -name = "educe" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4bd92664bf78c4d3dba9b7cdafce6fa15b13ed3ed16175218196942e99168a8" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "educe" version = "0.6.0" @@ -2239,7 +2293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2630,7 +2684,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -2713,6 +2767,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.5.0" @@ -2730,7 +2795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http", + "http 1.5.0", ] [[package]] @@ -2741,7 +2806,7 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http", + "http 1.5.0", "http-body", "pin-project-lite", ] @@ -2806,7 +2871,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.5.0", "http-body", "httparse", "httpdate", @@ -2837,7 +2902,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.5.0", "hyper", "hyper-util", "log", @@ -2867,11 +2932,11 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", + "http 1.5.0", "http-body", "hyper", "ipnet", @@ -3317,7 +3382,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c6922f6afe80418dd6019818af5d0d34584c371780ff09b9752370c25b4abb" dependencies = [ - "base64", + "base64 0.22.1", "jiff", "schemars", "serde", @@ -3353,11 +3418,11 @@ version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31e940a73033a7c5c7918b5ece7851d84571b58be25e937198da37fa13f116d3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "either", "futures", - "http", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -3391,7 +3456,7 @@ checksum = "a9d2353c118cf3462c352ee0b5bd5b0cf17990af456dfd8662d136ff9812eeb4" dependencies = [ "derive_more", "form_urlencoded", - "http", + "http 1.5.0", "jiff", "json-patch", "k8s-openapi", @@ -3426,7 +3491,7 @@ dependencies = [ "async-broadcast", "async-stream", "backon", - "educe 0.6.0", + "educe", "futures", "hashbrown 0.16.1", "hostname", @@ -3658,7 +3723,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" dependencies = [ - "base64", + "base64 0.22.1", "evmap", "indexmap", "metrics", @@ -3800,38 +3865,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "n-vm" -version = "0.0.10" -source = "git+https://github.com/githedgehog/testn.git?tag=v0.0.10#e49aba8400beb2cb117a3f542b114080cf572283" -dependencies = [ - "bollard", - "capctl", - "cloud-hypervisor-client", - "command-fds", - "n-vm-macros", - "nix 0.31.3", - "serde", - "serde_json", - "tokio", - "tokio-serde", - "tokio-stream", - "tokio-util", - "tokio-vsock", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "n-vm-macros" -version = "0.0.10" -source = "git+https://github.com/githedgehog/testn.git?tag=v0.0.10#e49aba8400beb2cb117a3f542b114080cf572283" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "netdev" version = "0.46.2" @@ -4056,7 +4089,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4284,7 +4317,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -4412,7 +4445,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "quick-xml", "serde", @@ -4657,6 +4690,47 @@ dependencies = [ "uuid", ] +[[package]] +name = "qapi-codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb959fed63a69baa2e3ae57224d885e686bc3f56c9bb3b03406969980ea57a44" +dependencies = [ + "qapi-parser", +] + +[[package]] +name = "qapi-parser" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b37f643cfdf67a409a9323334138a11636a5db5d56cedcc780d7a82a7fb7659" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "qapi-qmp" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45303cac879d89361cad0287ae15f9ae1e7799b904b474152414aeece39b9875" +dependencies = [ + "qapi-codegen", + "qapi-spec", + "serde", +] + +[[package]] +name = "qapi-spec" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e6bdbbe5d13015b21a49a778a29ae3cee9c450c3154e1648aed670d57fe5ba" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "quanta" version = "0.12.6" @@ -4922,12 +4996,12 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -5054,7 +5128,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5111,7 +5185,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5153,6 +5227,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safemem" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27a8b19b835f7aea908818e871f5cc3a5a186550c30773be987e155e8163d8f" + [[package]] name = "same-file" version = "1.0.6" @@ -5264,7 +5344,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets 0.12.16", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -5767,6 +5847,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + [[package]] name = "tempfile" version = "3.27.0" @@ -5774,10 +5860,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.5.0", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -5787,7 +5882,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5932,21 +6027,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-serde" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf600e7036b17782571dd44fa0a5cea3c82f60db5137f774a325a76a0d6852b" -dependencies = [ - "bytes", - "educe 0.5.11", - "futures-core", - "futures-sink", - "pin-project", - "serde", - "serde_json", -] - [[package]] name = "tokio-stream" version = "0.1.19" @@ -6002,6 +6082,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -6087,11 +6182,11 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.1", "bytes", "futures-util", - "http", + "http 1.5.0", "http-body", "mime", "pin-project-lite", @@ -6213,6 +6308,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.5+spec-1.1.0", +] + [[package]] name = "twox-hash" version = "2.1.4" @@ -6490,7 +6600,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9b1f7c9405..30a03098a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,11 @@ members = [ "match-action", "match-action-derive", "mgmt", + "n-it", + "n-preinit", + "n-vm", + "n-vm-macros", + "n-vm-protocol", "nat", "net", "pipeline", @@ -98,6 +103,11 @@ lpm = { path = "./lpm", package = "dataplane-lpm", features = [] } match-action = { path = "./match-action", package = "dataplane-match-action", features = [] } match-action-derive = { path = "./match-action-derive", package = "dataplane-match-action-derive", features = [] } mgmt = { path = "./mgmt", package = "dataplane-mgmt", features = [] } +n-it = { path = "./n-it", package = "dataplane-n-it", features = [] } +n-preinit = { path = "./n-preinit", package = "dataplane-n-preinit", features = [] } +n-vm = { path = "./n-vm", package = "dataplane-n-vm", features = [] } +n-vm-macros = { path = "./n-vm-macros", package = "dataplane-n-vm-macros", features = [] } +n-vm-protocol = { path = "./n-vm-protocol", package = "dataplane-n-vm-protocol", features = [] } nat = { path = "./nat", package = "dataplane-nat", features = [] } net = { path = "./net", package = "dataplane-net", features = [] } pipeline = { path = "./pipeline", package = "dataplane-pipeline", features = [] } @@ -126,13 +136,16 @@ bnum = { version = "0.14.4", default-features = false, features = [] } # The fork treats an unresolvable optional corpus as absent, allowing remapped # and archived tests to run. Pin the revision so branch changes cannot alter it. bolero = { git = "https://github.com/githedgehog/bolero.git", rev = "2fa595633a72e9b30721f9d37f0014a6ae8f77d4", default-features = false, features = [] } +bollard = { version = "0.20.2", default-features = false, features = [] } bytecheck = { version = "0.8.3", default-features = false, features = [] } bytes = { version = "1.12.1", default-features = false, features = [] } caps = { version = "0.5.6", default-features = false, features = [] } chrono = { version = "0.4.45", default-features = false, features = [] } clap = { version = "4.6.6", default-features = true, features = [] } +cloud-hypervisor-client = { version = "0.3.3", default-features = false, features = [] } color-eyre = { version = "0.6.5", default-features = false, features = [] } colored = { version = "3.1.1", default-features = false, features = [] } +command-fds = { version = "0.3.2", default-features = false, features = [] } criterion = { version = "0.8.2", default-features = false, features = [ "cargo_bench_support", "html_reports", @@ -173,7 +186,6 @@ metrics-exporter-prometheus = { version = "0.18.3", default-features = false, fe miette = { version = "7.6.0", default-features = false, features = [] } mio = { version = "1.2.2", default-features = false, features = [] } multi_index_map = { version = "0.15.1", default-features = false, features = [] } -n-vm = { git = "https://github.com/githedgehog/testn.git", tag = "v0.0.10", default-features = false, features = [], package = "n-vm" } netdev = { version = "0.46.2", default-features = false, features = [] } netgauze-bgp-pkt = { version = "0.13.0", features = [] } netgauze-bmp-pkt = { version = "0.13.0", features = [] } @@ -191,7 +203,9 @@ proc-macro-crate = { version = "3.5.0", default-features = false, features = [] proc-macro2 = { version = "1.0.107", default-features = false, features = [] } procfs = { version = "0.18.0", default-features = false, features = [] } pyroscope = { version = "2.1.1", default-features = false, features = [] } -quote = { version = "1.0.47", default-features = false, features = [] } +qapi-qmp = { version = "0.15.0", default-features = false, features = [] } +qapi-spec = { version = "0.3.2", default-features = false, features = [] } +quote ={ version = "1.0.47", default-features = false, features = [] } rand = { version = "0.10.2", default-features = false, features = [] } rapidhash = { version = "4.5.1", default-features = false, features = [] } reedline = { version = "0.51.0", default-features = false, features = [] } @@ -221,15 +235,19 @@ thread_local = { version = "1.1.10", default-features = false, features = [] } # per-crate would buy is divergent runtime behaviour between test binaries # and the real dataplane. Keep it global. tokio = { version = "1.53.1", default-features = false, features = ["parking_lot"] } +tokio-stream = { version = "0.1.18", default-features = false, features = [] } tokio-util = { version = "0.7.19", default-features = false, features = [] } -tonic = { version = "0.14.6", default-features = false, features = [] } +tokio-vsock = { version = "0.7.2", default-features = false, features = [] } +tonic ={ version = "0.14.6", default-features = false, features = [] } tracing = { version = "0.1.44", default-features = false, features = ["release_max_level_debug"] } tracing-error = { version = "0.2.1", default-features = false, features = [] } tracing-subscriber = { version = "0.3.23", default-features = false, features = [] } tracing-test = { version = "0.2.6", default-features = false, features = [] } +trybuild = { version = "1.0.116", default-features = false, features = [] } ureq = { version = "3.4.0", default-features = false, features = [] } url = { version = "2.5.8", default-features = false, features = [] } uuid = { version = "1.26.0", default-features = false, features = [] } +vsock = { version = "0.5.4", default-features = false, features = [] } # NOTE: panic strategy is intentionally left unset here. # @@ -256,6 +274,7 @@ overflow-checks = false codegen-units = 1 rpath = true + [profile.checked] inherits = "release" opt-level = 2 @@ -408,6 +427,34 @@ package = "dataplane-nat" miri = true wasm = false # split +[workspace.metadata.package.n-it] +package = "dataplane-n-it" +miri = false # hopeless + pointless +wasm = false # hopeless + pointless + +[workspace.metadata.package.n-preinit] +package = "dataplane-n-preinit" +miri = false # hopeless + pointless +wasm = false # hopeless + pointless + +[workspace.metadata.package.n-vm] +package = "dataplane-n-vm" +miri = false # hopeless + pointless +wasm = false # hopeless + pointless + +[workspace.metadata.package.n-vm-macros] +package = "dataplane-n-vm-macros" +# Proc-macro crate: runs at the host toolchain, not the miri target. +miri = false # hopeless + pointless +wasm = false # hopeless + pointless + +[workspace.metadata.package.n-vm-protocol] +package = "dataplane-n-vm-protocol" +# Pure wire types, so miri has something to say about them; it is the crates that +# drive guests that it cannot follow. +miri = true +wasm = false # hopeless + pointless + [workspace.metadata.package.pipeline] package = "dataplane-pipeline" miri = true diff --git a/default.nix b/default.nix index f02de46771..95de178f72 100644 --- a/default.nix +++ b/default.nix @@ -45,6 +45,27 @@ let ; inherit (platform') arch; }; + # The same flag table with the sanitizer left out, which is how + # `sanitizer-rustflags` below works out what the sanitizer added. Cheap: + # `profiles.nix` is a pure attrset of flag lists and builds nothing. + profile-unsanitized' = import ./nix/profiles.nix { + inherit + instrumentations + profile + cargo-features + host-arch + ; + sanitizers = [ ]; + inherit (platform') arch; + }; + # Exactly the `RUSTFLAGS` the selected sanitizers contribute, by difference. + # + # Subtraction rather than a substring test because there is no substring that + # catches them all: `-Clink-arg=-static-libasan` shares none with + # `-Zsanitizer=address` or `-Zexternal-clangrt`, and each sanitizer spells its + # runtime differently. Doing it by difference also means a flag added to + # `profiles.nix` later is handled without anyone remembering this exists. + sanitizer-rustflags = lib.subtractLists profile-unsanitized'.RUSTFLAGS profile'.RUSTFLAGS; # Test archives run on the host (e.g. `cargo nextest run --archive-file`) # rather than in the nix build sandbox, so panics in fixtures must # unwind for cleanup (netns / caps) to run. See `test-utils/src/lib.rs`. @@ -206,6 +227,542 @@ let zizmor ]); }; + # Whether the guest architecture (= the test binary's target arch, i.e. + # the nix host platform) differs from the build machine's arch. When it + # does, the test VM is software-emulated (TCG) rather than KVM-accelerated. + is-cross-guest = platform'.arch != host-arch; + + # The bootable kernel image filename as linux-fancy emits it. + # x86_64 produces a `bzImage`; aarch64 produces a raw `Image`. Both are + # installed into the manifest as `vmlinuz`, so nothing downstream has to + # care which one this build produced. + kernel-image-name = if platform'.arch == "aarch64" then "Image" else "bzImage"; + + # Guest architecture as spelled in the kernel manifest. Must match + # `n_vm::Arch::manifest_name`. + kernel-manifest-arch = if platform'.arch == "aarch64" then "aarch64" else "x86_64"; + + # Directory holding the kernel built from our own config fragments. + # + # "union" because it carries the union of what the whole test suite needs + # -- today from the hand-maintained fragment list in + # nix/overlays/dataplane-dev.nix, later checked against the requirements + # the tests declare. Named separately from the profiles because several + # profiles share one kernel: the artifacts are installed once and + # referenced by each. + union-kernel-dir = "union"; + + # Directory for the deliberately-modular kernel (see modular.config). + modular-kernel-dir = "modular"; + + # Directory for the pinned Flatcar release -- the kernel we actually ship + # on, and the reason the modular path exists at all. + flatcar-kernel-dir = "flatcar"; + + # Directory for the pinned Ubuntu kernel. A second distro, to test that + # the modular path generalises rather than fitting Flatcar in particular. + ubuntu-kernel-dir = "ubuntu"; + + # Every guest kernel that gets installed, keyed by its artifact directory. + # + # Keyed by *kernel* rather than by profile because several profiles can + # share one: the image is installed once and referenced by each. `boot` + # is a property of the kernel, not of the profile -- it follows from + # whether that kernel can reach its own root. + guest-kernels = { + ${union-kernel-dir} = { + image = "${pkgs.linux-fancy}/${kernel-image-name}"; + configfile = pkgs.linux-fancy.configfile; + boot = "direct"; + initramfs = null; + modules = null; + modDirVersion = null; + }; + ${modular-kernel-dir} = { + image = "${pkgs.linux-fancy-modular}/${kernel-image-name}"; + configfile = pkgs.linux-fancy-modular.configfile; + boot = "initramfs"; + initramfs = initramfs-modular; + modules = pkgs.linux-fancy-modular.modules; + inherit (pkgs.linux-fancy-modular) modDirVersion; + }; + } + # Flatcar is pinned to an `amd64-usr` release, so the profile simply does + # not exist for another guest architecture. Omitted rather than pointed + # at the x86_64 artifacts: the manifest's `arch` is derived from the build + # platform, so including it would claim an aarch64 kernel and hand over an + # x86_64 one -- a mismatch `check_arch` cannot catch because the manifest + # would be lying rather than disagreeing. + # + # Flatcar does publish arm64 releases; wiring one up is a matter of a + # second pin, not new mechanism. + // lib.optionalAttrs (kernel-manifest-arch == "x86_64") { + ${flatcar-kernel-dir} = { + inherit (flatcar-kernel-adapted) + image + configfile + modules + modDirVersion + ; + boot = "initramfs"; + initramfs = initramfs-flatcar; + }; + # Ubuntu publishes arm64 kernels too, so unlike Flatcar this is not + # inherently x86_64-only; it is pinned to an `amd64` .deb today and + # gated for the same reason -- claiming an aarch64 kernel while handing + # over an x86_64 one is a lie `check_arch` cannot catch. Lifting it is + # a second set of pins, not new mechanism, but the arm64 `vmlinuz` is a + # compressed `Image` and wants checking against QEMU's `-kernel` first. + ${ubuntu-kernel-dir} = { + inherit (ubuntu-kernel-adapted) + image + configfile + modules + modDirVersion + ; + boot = "initramfs"; + initramfs = initramfs-ubuntu; + }; + }; + + # The environments a test can run in. + # + # A profile is a (kernel, hypervisor) pair. Today they differ only in + # hypervisor, which is exactly the axis `n-vm/tests/integration.rs` + # currently sweeps *by hand* -- `test_which_runs_in_vm_with_iommu` and + # `..._with_qemu_iommu` are the same test written twice. Making it a + # profile is what lets those collapse. + # + # Profile names are Rust identifiers because each becomes a module name in + # the generated test tree (`some_test::qemu`), so nextest can filter on + # one environment. + kernel-profiles = { + cloud_hypervisor = { + hypervisor = "cloud_hypervisor"; + kernel-dir = union-kernel-dir; + }; + qemu = { + hypervisor = "qemu"; + kernel-dir = union-kernel-dir; + }; + # The modular kernel, whose virtiofs is a module, so it can only be + # reached through an initramfs. QEMU because it is the backend whose + # initrd handling we exercise first; a cloud-hypervisor variant is a + # one-line addition once that is proven. + modular = { + hypervisor = "qemu"; + kernel-dir = modular-kernel-dir; + }; + } + // lib.optionalAttrs (kernel-manifest-arch == "x86_64") { + # The kernel the dataplane actually ships on. The whole point: our own + # kernel is built from a config we chose, so it cannot tell us whether + # the code works on the one we deploy. x86_64 only -- see + # `guest-kernels`. + flatcar = { + hypervisor = "qemu"; + kernel-dir = flatcar-kernel-dir; + }; + # A distro kernel that is *not* the one we ship on, which is the point: + # Flatcar passing tells us the code works where we deploy, but only a + # second distro can tell us whether the harness itself generalises. + ubuntu = { + hypervisor = "qemu"; + kernel-dir = ubuntu-kernel-dir; + }; + }; + + # The profile used when a test does not name one. + default-kernel-profile = "cloud_hypervisor"; + + # nix's declaration of which guest kernels exist, read by the container + # tier (see `n_vm::kernel_manifest`). + # + # This is the seam that keeps `cargo` from ever having to invoke `nix`: + # the artifacts are materialized first (`just setup-roots`), and the tests + # only read them. Paths are container-absolute because the container tier + # is what consumes them -- every first-level `testroot` entry is + # bind-mounted at the container root, so `kernels/` lands at `/kernels`. + kernel-manifest = builtins.toJSON { + default = default-kernel-profile; + profiles = lib.mapAttrs ( + _name: profile: + let + k = guest-kernels.${profile.kernel-dir}; + dir = "/kernels/${profile.kernel-dir}"; + in + { + arch = kernel-manifest-arch; + inherit (profile) hypervisor; + # `boot` follows from the kernel, not from the profile: it is + # "direct" when the kernel can mount its own root, and "initramfs" + # when the transport that reaches the root is itself a module. + inherit (k) boot; + kernel = "${dir}/vmlinuz"; + config = "${dir}/config"; + } + // lib.optionalAttrs (k.initramfs != null) { initramfs = "${dir}/initramfs"; } + // lib.optionalAttrs (k.modules != null) { + modules = "${dir}/modules/${k.modDirVersion}"; + } + ) kernel-profiles; + }; + + # Minimal derivation containing the bootable kernel image and the + # manifest describing it. + # + # The full linux-fancy output includes modules, headers, etc. that are + # not needed inside the test container -- we extract just the bootable + # image so that symlinkJoin produces the `kernels/` tree in testroot + # without pulling in the rest of the kernel tree. + # + # IMPORTANT: this is `pkgs.linux-fancy` (the *host*-platform kernel), not + # `pkgs.pkgsBuildHost.linux-fancy` (the *build*-platform kernel). The + # guest kernel must match the guest (= test binary) architecture. For a + # native build the two package sets coincide, so this is a no-op for + # x86_64; for a cross build it selects the aarch64 kernel. + # The `config` is the kernel's own resolved `.config`, recorded so that a + # test's declared kernel requirements can be checked against what this + # kernel actually provides -- before booting, with a message naming the + # missing symbol rather than a mysterious runtime failure. + # + # `linux-fancy.configfile` is the merged, dependency-resolved output of + # nix/pkgs/linux/merge-config.nix, which is what `linuxManualConfig` built + # from. A *foreign* kernel has no such derivation and its config is + # recovered from the image with `extract-ikconfig` instead; both land here + # under the same name, so nothing downstream has to care which it was. + kernel-image = pkgs.runCommand "kernel-image" { } ( + '' + mkdir -p $out + cp ${pkgs.writeText "n-vm-manifest.json" kernel-manifest} \ + $out/n-vm-manifest.json + '' + + lib.concatStrings ( + lib.mapAttrsToList (dir: k: '' + mkdir -p $out/kernels/${dir} + cp ${k.image} $out/kernels/${dir}/vmlinuz + cp ${k.configfile} $out/kernels/${dir}/config + ${lib.optionalString (k.initramfs != null) '' + cp ${k.initramfs}/initramfs $out/kernels/${dir}/initramfs + ''} + ${lib.optionalString (k.modules != null) '' + mkdir -p $out/kernels/${dir}/modules + cp -r ${k.modules}/lib/modules/${k.modDirVersion} \ + $out/kernels/${dir}/modules/${k.modDirVersion} + chmod -R u+w $out/kernels/${dir}/modules + ''} + '') guest-kernels + ) + ); + + # Builds the initramfs for a kernel whose boot-critical drivers are + # modules. + # + # Only needed when the root filesystem transport is `=m`: mounting the + # workspace needs virtiofs, virtiofs is a module, and the module tree + # lives on the workspace. The initramfs is the only channel that escapes + # that, because the kernel unpacks it itself, from memory, before any + # driver loads. + # + # The dependency closure and load order are resolved *here*, by the real + # `modprobe` against the real module tree, rather than in the guest. We + # know the answer at build time, so the pre-init should not be + # rediscovering it at boot: it reads an ordered list and calls + # `finit_module` down it. No `modules.dep` parsing, no dependency + # resolution, no uevent handling in the VM. + # + # `boot-modules` are the modules needed to reach the root. Feature + # modules a test asks for are loaded later by `n-it`, from the mounted + # tree, and do not belong here. + mk-initramfs = + { + kernel, + pre-init, + boot-modules ? [ "virtiofs" ], + }: + pkgs.runCommand "n-vm-initramfs" + { + nativeBuildInputs = with pkgs.pkgsBuildHost; [ + cpio + kmod + xz + zstd + ]; + } + '' + root=$(mktemp -d) + mkdir -p "$root/modules" "$root/newroot" + + # Ask modprobe for the closure, in load order. `--show-depends` + # prints one `insmod ` line per module, dependencies first. + for m in ${pkgs.lib.escapeShellArgs boot-modules}; do + modprobe --dirname ${kernel.modules} \ + --set-version ${kernel.modDirVersion} \ + --show-depends "$m" \ + || { echo "no such module in the tree: $m" >&2; exit 1; } + done | awk '$1 == "insmod" { print $2 }' > "$NIX_BUILD_TOP/ordered" + + # Dedupe while preserving order: a shared dependency (fuse, here) + # appears once per dependent, and loading it twice is an error. + : > "$root/modules.load" + declare -A seen + while read -r ko; do + [ -n "$ko" ] || continue + base=$(basename "$ko") + # Decompress on the way in. A distro tree ships `.ko.xz`, and + # `finit_module` cannot read that unless the kernel was built + # with CONFIG_MODULE_DECOMPRESS -- which is not something we can + # rely on for someone else's kernel. Doing it here means the + # pre-init never needs a decompressor. + case "$base" in + *.ko.xz) base=''${base%.xz}; xz -dc "$ko" > "$root/modules/$base" ;; + *.ko.zst) base=''${base%.zst}; zstd -dc "$ko" > "$root/modules/$base" ;; + *.ko) cp "$ko" "$root/modules/$base" ;; + *) echo "unrecognised module file: $ko" >&2; exit 1 ;; + esac + [ -n "''${seen[$base]:-}" ] && continue + seen[$base]=1 + echo "/modules/$base" >> "$root/modules.load" + done < "$NIX_BUILD_TOP/ordered" + + cp ${pre-init} "$root/init" + chmod +x "$root/init" + + mkdir -p $out + + # The kernel sniffs the initramfs format from its magic bytes, so + # the filename carries no information and the compressor is chosen + # from what *this* kernel can actually decompress. Read from the + # config at build time rather than at eval time, which keeps this + # free of import-from-derivation. + # + # Not assumable: our kernel has CONFIG_RD_GZIP=n and + # CONFIG_RD_ZSTD=y, while Flatcar ships a `.cpio.gz`. Guessing + # would produce a kernel panic with no useful message. + cpio_out=$out/initramfs + # `$NIX_BUILD_TOP`, not `/tmp`. A fixed path under `/tmp` is private + # to the build only while the sandbox is on; without one -- an + # unprivileged container, where nix cannot build the sandbox and + # `sandbox-fallback` quietly drops it -- every build shares the host's + # `/tmp`, and the three kernels' initramfs derivations build in + # parallel. Two `cpio` runs then write one file while a third `zstd` + # reads it, which surfaces as `zstd: error 11 : Src size is incorrect` + # from whichever one lost. `$root` was always safe: `mktemp -d` + # answers under `TMPDIR`, which nix does set per build. + cpio=$NIX_BUILD_TOP/initramfs.cpio + + # Three things otherwise vary between two builds of the same inputs, + # and `cpio` records all three, so the compressed image differs every + # time and `nix build --rebuild` calls the derivation non-reproducible: + # + # - mtime. `cp` and the decompressors above stamp the current time. + # - uid/gid. A multi-user nix build runs as whichever `nixbld` user + # was free, so the archive named e.g. `nixbld1`. Root is also the + # right answer on its own merits: this is a root filesystem. + # - entry order. `find` walks in readdir order, which is a property + # of the filesystem rather than of the tree being packed. + # + # `--reproducible` covers the device and inode numbers as well. + find "$root" -exec touch -h -d @1 {} + + (cd "$root" && find . -print0 | sort -z \ + | cpio --null -o -H newc --quiet --reproducible --owner 0:0) > "$cpio" + + if grep -q '^CONFIG_RD_ZSTD=y' ${kernel.configfile}; then + zstd -19 -T0 -q -o "$cpio_out" "$cpio" + elif grep -q '^CONFIG_RD_GZIP=y' ${kernel.configfile}; then + gzip -9 -c "$cpio" > "$cpio_out" + elif grep -q '^CONFIG_RD_XZ=y' ${kernel.configfile}; then + xz -9 -c --check=crc32 "$cpio" > "$cpio_out" + else + # Always supported, and a few hundred KB is not worth a panic. + cp "$cpio" "$cpio_out" + fi + + cp "$root/modules.load" $out/modules.load + ''; + + # The pre-init, linked statically. + # + # It runs as PID 1 before /nix/store is mounted, so it cannot be + # dynamically linked: its ELF interpreter would name a path that does not + # exist yet, and the kernel would fail to exec it. + # + # Two things are needed and neither is the default. `+crt-static` asks + # for a static link; glibc's static archives then have to be found, and + # they live in a *separate output* that is not part of the sysroot -- so + # without the library path the link fails on `-lc` and friends. + # + # `overrideAttrs` rather than an argument to `workspace-builder` because + # `args` is merged with `//`, which would replace the whole `env` attrset + # rather than adding to it, discarding the sysroot and toolchain settings + # every other crate depends on. + n-preinit-static = workspace."n-preinit".overrideAttrs (orig: { + env = orig.env // { + # The search path goes through `-Clink-arg=-L`, not `LIBRARY_PATH`. + # + # `LIBRARY_PATH` is honoured by the *native* cc-wrapper, so it worked + # for an x86_64 build and silently did nothing for a cross one: the + # target-prefixed wrapper ignores it, and the link failed on `-lc` + # with the archives sitting right there in the store. `-Clink-arg` + # reaches the linker either way, which is why the sysroot is already + # passed that way in nix/profiles.nix. + # + # The sanitizer comes back out. rustc refuses the combination outright + # -- "sanitizer is incompatible with statically linked libc" -- so a + # sanitized build otherwise cannot get as far as producing a guest + # image, and `just sanitize=address test` dies in `setup-roots` rather + # than in anything it was trying to measure. Dropping it here rather + # than dropping in-VM testing from sanitized builds is the right trade: + # the pre-init is the scaffolding that execs the code under test, not + # the code under test. It costs a rebuild of two small crates, its + # only dependencies being `n-vm-protocol` and a feature-trimmed `nix`. + RUSTFLAGS = lib.concatStringsSep " " ( + (lib.subtractLists sanitizer-rustflags ( + lib.filter (flag: flag != "") (lib.splitString " " orig.env.RUSTFLAGS) + )) + ++ [ + "-Ctarget-feature=+crt-static" + "-Clink-arg=-L${pkgs.pkgsHostHost.glibc.static}/lib" + ] + ); + }; + }); + + # Flatcar's repackaged kernel, adapted to the shape `mk-initramfs` and the + # kernel-image installer expect of a kernel derivation. + # + # The adapter exists because those two consumers were written against a + # nixpkgs kernel: they ask for `.modules`, `.modDirVersion` and + # `.configfile`. Flatcar's is not built here, so it has none of them -- + # it has a directory layout instead. Mapping it here keeps the consumers + # ignorant of which kind of kernel they were handed, which is the point of + # the normalized output shape. + # Written as a function because there are two of these now: the mapping is + # a property of the *layout* the distro packages produce, not of any one + # distro, and both `pkgs/flatcar` and `pkgs/ubuntu` deliberately produce + # the same one. + adapt-distro-kernel = k: { + drv = k; + configfile = "${k}/config"; + # Discovered by the package rather than restated here; a version bump + # would otherwise leave the modules under a directory nothing reads. + modDirVersion = lib.removeSuffix "\n" (builtins.readFile "${k}/mod-dir-version"); + modules = k; + image = "${k}/vmlinuz"; + }; + + flatcar-kernel-adapted = adapt-distro-kernel pkgs.flatcar-kernel; + + ubuntu-kernel-adapted = adapt-distro-kernel pkgs.ubuntu-kernel; + + # The initramfs for Flatcar's kernel. + # + # The same derivation as the modular kernel's, given a different kernel -- + # which is the point of normalising the shape. Flatcar's modules are + # `.ko.xz`, which `mk-initramfs` decompresses on the way in because + # Flatcar does not set CONFIG_MODULE_DECOMPRESS. + initramfs-flatcar = mk-initramfs { + kernel = flatcar-kernel-adapted; + pre-init = "${n-preinit-static}/bin/dataplane-n-preinit"; + boot-modules = [ + "virtiofs" + "vmw_vsock_virtio_transport" + ]; + }; + + # The initramfs for Ubuntu's kernel. + # + # Same derivation again, with two differences from Flatcar's that are + # handled without new mechanism: the modules are `.ko.zst` rather than + # `.ko.xz`, and `fuse` is built in (`CONFIG_FUSE_FS=y`) rather than + # modular, so virtiofs pulls in no dependency and the closure is one + # module shorter. + initramfs-ubuntu = mk-initramfs { + kernel = ubuntu-kernel-adapted; + pre-init = "${n-preinit-static}/bin/dataplane-n-preinit"; + boot-modules = [ + "virtiofs" + "vmw_vsock_virtio_transport" + ]; + }; + + # The initramfs for the modular kernel. + initramfs-modular = mk-initramfs { + kernel = pkgs.linux-fancy-modular; + pre-init = "${n-preinit-static}/bin/dataplane-n-preinit"; + # `virtiofs` to reach the root, `vmw_vsock_virtio_transport` because + # n-it needs the result channel the moment it starts and cannot load it + # itself -- it is the process the channel reports on. modprobe expands + # each to its own dependency closure. + boot-modules = [ + "virtiofs" + "vmw_vsock_virtio_transport" + ]; + }; + + # The QEMU system emulator for the test VM, always a build-native (host + # CI arch) binary that runs in the Docker container. + # + # The test VMs always run headless (`-nographic`), so QEMU's GUI display + # backends (gtk/sdl/vnc/spice/...) are dead weight. Left enabled they + # drag gtk4/gtk3/cairo/pango/vte/libepoxy/SDL into every test/dev root. + # `nixosTestRunner = true` is nixpkgs' headless "boot a VM" profile: it + # disables exactly those backends and its only other effect is a 9p + # uid0 patch we never exercise (we mount via vhost-user-fs, not -virtfs). + # + # - Native guest: `qemu_test` (= `qemu_kvm` + `nixosTestRunner`): the + # prebuilt, cache-hit, host-cpu-only emulator (`qemu-system-` + # with KVM). Headless, so no gtk in the common (native) devroot. + # - Cross guest: the base `qemu`, headless and restricted to just the + # targets we need: the guest `*-softmmu` we actually emulate under TCG + # (e.g. `aarch64-softmmu`) plus the build-host `*-softmmu` (so QEMU's + # `qemu-kvm` compat symlink -> `qemu-system-` resolves; omitting + # it trips the `noBrokenSymlinks` install check). A genuine-cross + # `pkgsBuildHost` qemu is not in the binary cache regardless (Hydra + # never builds that derivation), so trimming targets + dropping the GUI + # keeps that unavoidable build small. + # + # Both provide `bin/qemu-system-`, matching + # `n_vm::Arch::qemu_system_binary`. + qemu-system = + if is-cross-guest then + pkgs.pkgsBuildHost.qemu.override { + nixosTestRunner = true; + hostCpuTargets = [ + "${host-arch}-softmmu" + "${platform'.arch}-softmmu" + ]; + } + else + pkgs.pkgsBuildHost.qemu_test; + + # Container-tier tools for the scratch-container test infrastructure. + # + # This derivation provides the binaries needed inside the Docker + # container that launches the test VM: the hypervisor(s), virtiofsd, + # and a Linux kernel image (bzImage built from config fragments by + # the linux-fancy derivation in nix/overlays/dataplane-dev.nix). + # + # When used with a scratch container, subdirectories of this derivation + # (e.g. bin/, lib/) are volume-mounted at their standard container + # paths, and top-level files (e.g. bzImage) are bind-mounted at the + # container root. The container also mounts /nix/store from the host + # so that the symlinks created by symlinkJoin resolve to the actual + # binaries and their transitive library dependencies. + # + # See development/ideam.md for the design rationale. + # NOTE: cloud-hypervisor and virtiofsd stay on `pkgsBuildHost` (they run + # on the x86 container host). Only the kernel is host-arch; the qemu + # choice is arch-aware (see `qemu-system`). + testroot = pkgs.symlinkJoin { + name = "dataplane-test-root"; + paths = [ + pkgs.pkgsBuildHost.cloud-hypervisor + pkgs.pkgsBuildHost.virtiofsd + qemu-system + kernel-image + ]; + }; devenv = pkgs.mkShell { name = "dataplane-dev-shell"; packages = [ devroot ]; @@ -296,8 +853,6 @@ let "sha256-w5dK1IfqR1kJDa4ugbvEC4VIASwGlKU6oxEd9USUwMw="; "git+https://github.com/githedgehog/rtnetlink.git?branch=hh/tc-actions4#c6b8d9865858c458e7f27fa67469f2171e1644a4" = "sha256-u14ugCKWU4nwXkQdlleThJLYU4Ft/LJNTKywMUlwxPM="; - "git+https://github.com/githedgehog/testn.git?tag=v0.0.10#e49aba8400beb2cb117a3f542b114080cf572283" = - "sha256-XwEKLdc2Y7fteSKKOERgjKTdxELy7K/wOVuB/SSj3ng="; }; }; # Rename per-revision images so the CI push filter keeps them out of Cachix; @@ -592,6 +1147,108 @@ let inherit pname; } ) package-list; + # VM guest root filesystem for the scratch-container test infrastructure. + # + # This derivation is shared into the VM via virtiofsd and becomes the + # guest's root filesystem (mounted as virtiofs with tag "root"). + # + # It contains: + # - The n-it init system binary (runs as PID 1 in the VM). + # - glibc and libgcc shared libraries (so dynamically linked test + # binaries can run inside the VM). + # + # The forwarded-environment directory (test-env, see VM_ENV_DIR) is + # pre-created for the same reason: container.rs bind-mounts a host + # directory holding the NUL-separated KEY=VALUE file there. + # + # The test binary directory is bind-mounted by container.rs at + # /vm.root/test-bin (see VM_TEST_BIN_DIR in n-vm-protocol), so it + # appears at /test-bin in the VM guest. The /test-bin directory is + # pre-created here so Docker can create the bind mount without needing + # to mkdir on the read-only nix store path. + # + # See development/ideam.md for the design rationale. + vmroot = pkgs.runCommand "dataplane-vm-root" { } '' + mkdir -p $out/bin $out/lib $out/test-bin $out/test-env + + # Essential guest directories. + # + # The VM root filesystem is mounted read-only via virtiofs, so the + # kernel cannot create directories on demand. These empty mount + # points must exist so that: + # + # /dev -- kernel auto-mounts devtmpfs (provides /dev/console, + # /dev/null, etc. needed by init and test processes) + # /proc -- n-it mounts procfs (needed for /proc/cmdline parsing + # and general process introspection) + # /sys -- n-it mounts sysfs + # /tmp -- n-it mounts tmpfs (writable scratch space) + # /run -- n-it mounts tmpfs (runtime state) + # /etc -- some libc/nss functions expect this to exist + # + # Without /dev in particular, the kernel logs + # "devtmpfs: error mounting -2" and init may fail with ENOEXEC (-8) + # because /dev/console cannot be opened. + mkdir -p $out/dev $out/proc $out/sys $out/tmp $out/run $out/etc $out/var + + # /var/run -> /run symlink. + # + # Many daemons (including DPDK) default to writing runtime state + # under /var/run. On a conventional Linux system /var/run is + # either a symlink to /run or a tmpfs in its own right. Since our + # root filesystem is read-only via virtiofs, we bake the symlink + # into the image so that /var/run/dpdk (and friends) resolve to + # the writable /run tmpfs mounted by n-it. + # + # This mirrors what the dataplane container image already does + # (see the `dataplane.tar` buildPhase above). + ln -s /run $out/var/run + + # n-it init system binary. + # The cargo package is "dataplane-n-it" but the VM expects the + # binary at /bin/n-it (see INIT_BINARY_PATH in n-vm-protocol). + ln -s ${workspace."n-it"}/bin/dataplane-n-it $out/bin/n-it + + # glibc runtime libraries -- needed by dynamically linked test + # binaries running inside the VM. + for f in ${pkgs.pkgsHostHost.libc.out}/lib/*.so*; do + [ -e "$f" ] || continue + ln -s "$f" "$out/lib/$(basename "$f")" + done + + # libgcc runtime libraries (libgcc_s.so, etc.) + for f in ${pkgs.pkgsHostHost.glibc.libgcc}/lib/*.so*; do + [ -e "$f" ] || continue + ln -s "$f" "$out/lib/$(basename "$f")" + done + + # Create a real /nix/store directory (empty mount point). + # + # The container tier bind-mounts the host's /nix/store here so that + # virtiofsd serves it as a real directory to the VM guest. This + # replaces the previous /nix -> /nix absolute symlink, which caused + # ELOOP (error -40) inside the guest: the FUSE protocol returns + # symlinks to the guest kernel for resolution, and /nix -> /nix is + # self-referential from the guest's VFS perspective. + # + # Nix-built test binaries have rpaths like + # /nix/store/{hash}-glibc-X.Y/lib; with /nix/store bind-mounted + # through virtiofsd, those paths resolve correctly inside the VM. + mkdir -p $out/nix/store + + # Empty mount point for the host's cargo workspace (see + # VM_WORKSPACE_DIR in n-vm-protocol). The container tier bind-mounts + # the workspace root here and n-it makes it the test process's working + # directory, so that paths captured at compile time relative to the + # workspace root -- `file!()`, which bolero records and later + # canonicalizes to find its corpus -- resolve inside the guest. + # + # Pre-created for the same reason as /nix/store above: this derivation + # is a read-only nix store path, so Docker cannot create the mount + # point itself. + # Must match VM_WORKSPACE_DIR in n-vm-protocol. + mkdir -p $out/workspace + ''; workspace-check = { @@ -1223,12 +1880,15 @@ in devroot doctests docs + mk-initramfs package-list pkgs sources src sysroot + testroot tests + vmroot workspace ; profile = profile'; diff --git a/deny.toml b/deny.toml index 9d2f706e84..48ff46d647 100644 --- a/deny.toml +++ b/deny.toml @@ -31,6 +31,15 @@ ignore = [ # shipped artifact. That is the invariant to re-check rather than re-derive: it stops # holding the moment `iai-callgrind` appears in a normal `[dependencies]` table. "RUSTSEC-2025-0141", + # safemem is unmaintained (archived 2019). It reaches us only through + # base64 0.7 <- cloud-hypervisor-client 0.3, which is a dev-dependency of + # the n-vm test harness: it never ships in the dataplane. The advisory is + # "unmaintained", not a vulnerability, and cargo-deny reports no safe + # upgrade. cloud-hypervisor-client 0.6 does drop it, but it also drops + # `PlatformConfig::iommu_address_width`, which n-vm uses to size the guest + # vIOMMU -- so that bump is a port, not a version bump. Ignore until it is + # done. + "RUSTSEC-2023-0081", ] [licenses] diff --git a/hardware/src/scan.rs b/hardware/src/scan.rs index c0e8726432..0c68b9b1fd 100644 --- a/hardware/src/scan.rs +++ b/hardware/src/scan.rs @@ -229,8 +229,7 @@ mod test { support::{SupportedDevice, SupportedVendor}, }; - #[test] - #[n_vm::in_vm] + #[n_vm::test] fn collect_them_all_and_bind_them() { let system = Node::scan_all(); let nics: Vec<_> = system @@ -255,8 +254,7 @@ mod test { assert_eq!(nics.len(), 3, "expected exactly 3 virtio network cards"); } - #[test] - #[n_vm::in_vm] + #[n_vm::test] fn bind_fabric_nics_and_skip_mgmt_nic() { let system = Node::scan_all(); let mgmt_nic_pci_address = "0000:00:02.0".try_into().unwrap(); @@ -283,8 +281,7 @@ mod test { assert_eq!(nics.len(), 2, "expected exactly 2 virtio network cards"); } - #[test] - #[n_vm::in_vm] + #[n_vm::test] fn bind_nic_test() { let system = Node::scan_all(); let target_pci_address = "0001:00:02.0".try_into().unwrap(); diff --git a/interface-manager/src/monitor/mod.rs b/interface-manager/src/monitor/mod.rs index 700f375a96..2f62712f3f 100644 --- a/interface-manager/src/monitor/mod.rs +++ b/interface-manager/src/monitor/mod.rs @@ -178,9 +178,8 @@ mod test { handle.link().add(msg).execute().await.unwrap(); } - #[tokio::test] + #[n_vm::test] #[wrap(with_caps([Capability::CAP_NET_ADMIN]))] - #[n_vm::in_vm] #[cfg_attr(not(emulated), traced_test)] #[ignore = "disabled until nv_m support is re-enabled"] async fn test_interface_monitor() { diff --git a/justfile b/justfile index 89ce54677b..1b926dca11 100644 --- a/justfile +++ b/justfile @@ -141,6 +141,25 @@ nightly := "false" [private] docker_sock := "/var/run/docker.sock" +# Directory the Docker daemon can see, when it cannot see this checkout's +# /nix/store. Empty -- the ordinary case -- means the daemon runs on this host +# and resolves store paths itself. +# +# n-vm's container tier hands the daemon bind-mount *sources*, and the daemon +# resolves them in its own mount namespace. On a CI runner that is itself a +# container talking to the host's daemon, /nix belongs to the runner image and +# the bare metal has nothing at those paths. Because every mount asks Docker to +# create a missing mount point, the daemon answers by creating an *empty* +# directory rather than failing, so the guest root comes up empty and the first +# mount beneath it dies on a read-only filesystem. +# +# Set this to a directory on a filesystem both sides share -- under this +# workspace, which is the one path a containerised runner and its host agree on +# -- and `setup-roots` will export the roots' closure into it. `n-vm` reads the +# same variable and rewrites the mount sources; the targets stay `/nix/store`, +# so rpaths in the guest are unaffected. +n_vm_host_share := env("N_VM_HOST_SHARE_DIR", "") + # Build a nix derivation with standard build arguments [script] build target="dataplane.tar" *args: @@ -179,10 +198,30 @@ pre-flight: (check-dependencies) (fmt "--check") (test) (lint) (doctest) echo "pre flight checks pass" [script] -test package="tests.all" *args: (build (if package == "tests.all" { "tests.all" } else { "tests.pkg." + package }) args) +test package="tests.all" *args: (setup-roots) (build (if package == "tests.all" { "tests.all" } else { "tests.pkg." + package }) args) {{ _just_debuggable_ }} declare -r target="{{ if package == "tests.all" { "tests.all" } else { "tests.pkg." + package } }}" - cargo nextest run --archive-file results/${target}/*.tar.zst --workspace-remap $(pwd) {{ filter }} + # Export the scratch-container roots `setup-roots` just built, so that + # `#[n_vm::test]` tests find them. The guard is for a tree where + # `setup-roots` was skipped; the tests fail loudly rather than skip, so + # this is a clearer error, not a fallback. + if [[ -e testroot && -e vmroot ]]; then + export N_VM_TEST_ROOT="$(pwd)/testroot" + export N_VM_VM_ROOT="$(pwd)/vmroot" + fi + # `trybuild` (n-vm-macros' compile-fail suite) shells out to + # `cargo --offline` for a scratch project under `target/tests/trybuild`, + # which resolves against the local registry cache rather than the nix + # vendor directory the workspace was built from. CI builds through nix and + # never populates that cache, so the suite fails there with "no matching + # package named `proc-macro2`" while passing on any developer machine. + # `--locked` means every download is checksum-checked against Cargo.lock, + # and a warm cache makes this a no-op. + cargo fetch --locked + # `--no-tests pass`: a single-package archive whose only test(s) are + # `#[cfg_attr(emulated, ignore)]` (e.g. n-vm-macros' trybuild test under + # cross) runs zero tests; treat that as success, matching `test-each`. + cargo nextest run --archive-file results/${target}/*.tar.zst --workspace-remap $(pwd) --no-tests pass {{ filter }} # List the bolero targets `just fuzz` can run. Args go to `cargo bolero list` [script] @@ -335,8 +374,16 @@ check-each *args: (build "check" args) {{ _just_debuggable_ }} [script] -test-each *args: (build "tests.pkg" args) +test-each *args: (setup-roots) (build "tests.pkg" args) {{ _just_debuggable_ }} + # Same two requirements as `test`: the per-package archives include + # `dataplane-n-vm`'s guest-booting suite, and n-vm-macros' trybuild suite + # resolves against the local registry cache. + if [[ -e testroot && -e vmroot ]]; then + export N_VM_TEST_ROOT="$(pwd)/testroot" + export N_VM_VM_ROOT="$(pwd)/vmroot" + fi + cargo fetch --locked declare -a fail=() for test_archive in results/tests.pkg*/*.tar.zst; do if ! cargo nextest run --archive-file "${test_archive}" --workspace-remap "$(pwd)" --no-tests pass; then @@ -352,11 +399,24 @@ test-each *args: (build "tests.pkg" args) docs package="" *args: (build (if package == "" { "docs.all" } else { "docs.pkg." + package }) args) {{ _just_debuggable_ }} -# Create devroot and sysroot symlinks for local development +# Remove test containers n-vm left behind. Args go to n-vm-reap (--force, --list, --all) +[script] +reap *args: + {{ _just_debuggable_ }} + # The host tier cleans up after itself on every route it can reach, + # including SIGTERM and SIGINT, so this is only needed after a SIGKILL, an + # OOM kill, or a reboot -- none of which give a process the chance to tidy + # up. Containers whose creating process is still alive are left alone + # unless `--all` is passed, so this is safe to run while other tests are + # going. Without a tty it refuses to remove anything; CI should pass + # `--force`. + cargo run --quiet -p dataplane-n-vm --features reap --bin n-vm-reap -- {{ args }} + +# Create devroot, sysroot, testroot, and vmroot symlinks for local development [script] setup-roots *args: {{ _just_debuggable_ }} - for root in devroot sysroot; do + for root in devroot sysroot testroot vmroot; do nix build -f default.nix "${root}" \ --argstr default-features '{{ default_features }}' \ --argstr features '{{ features }}' \ @@ -372,6 +432,86 @@ setup-roots *args: {{ args }} done + if [ -n "{{ n_vm_host_share }}" ]; then + just n_vm_host_share="{{ n_vm_host_share }}" export-scratch-roots + fi + +# Copy the scratch roots' nix closure somewhere the Docker daemon can read it. +# See `n_vm_host_share`; a no-op unless that is set. +[script] +export-scratch-roots: + {{ _just_debuggable_ }} + declare -r share="{{ n_vm_host_share }}" + if [ -z "${share}" ]; then + echo "n_vm_host_share is unset; nothing to export" + exit 0 + fi + declare -r store="${share}/nix/store" + # `tmp` is the other half: the forwarded-environment directory is a bind + # source too, and a container-local /tmp is no more visible than /nix. + mkdir -p "${store}" "${share}/tmp" + + # The closure, not the whole store. `/nix/store` is mounted into the + # container whole, so the export has to cover everything the container + # resolves through it, which is two things: + # + # testroot/vmroot the host tier's qemu, cloud-hypervisor and virtiofsd, + # the guest kernel, and n-it inside the guest root + # sysroot what the *test binary* is linked against. Missing this + # is not a missing-file error: the binary is there and + # execs, and the kernel reports ENOENT for its absent + # ELF interpreter, so the container exits 127 with + # "No such file or directory" naming a path that plainly + # exists. + # + # devroot is deliberately not here. It is the toolchain -- 4.6 GiB against + # sysroot's 2.0 -- and nothing inside the container compiles. + declare -a roots=( testroot vmroot sysroot ) + declare -a resolved=() + for root in "${roots[@]}"; do + if [ ! -e "${root}" ]; then + >&2 echo "::error::${root} is missing; run setup-roots first" + exit 1 + fi + resolved+=( "$(readlink -f "${root}")" ) + done + declare -a paths + mapfile -t paths < <(nix-store --query --requisites "${resolved[@]}") + + declare -i copied=0 kept=0 + for path in "${paths[@]}"; do + declare dest="${store}/$(basename "${path}")" + # A store path is immutable, so an entry that is already here is already + # right. This is what makes the second job on a runner cheap. + if [ -e "${dest}" ]; then + kept+=1 + continue + fi + # Copy to a private name and rename, so a concurrent job never observes + # a half-copied path under a name that promises a whole one. + declare staging="${store}/.staging-$$-$(basename "${path}")" + rm -rf -- "${staging}" + cp -a --no-preserve=ownership -- "${path}" "${staging}" + # A store path's directories are r-xr-xr-x, which nix relies on to keep + # them immutable. A copy has no such contract, and inheriting the mode + # makes the export undeletable: `rm -rf` cannot unlink a child of a + # directory it cannot write. Whoever cleans this runner should not have + # to know that. + chmod -R u+w -- "${staging}" + if ! mv -T -- "${staging}" "${dest}" 2>/dev/null; then + # Lost the race; the winner's copy is as good as ours. + chmod -R u+w -- "${staging}" 2>/dev/null || true + rm -rf -- "${staging}" + fi + copied+=1 + done + # Reported from nix rather than `du`: on a copy-on-write filesystem `du` + # run straight after the copy reports blocks that are still dirty, which + # made a 1.8 GiB export read as 26 MiB. + printf 'exported %d store paths to %s (%d already present, %s closure)\n' \ + "${copied}" "${store}" "${kept}" \ + "$(nix path-info -S "${resolved[@]}" 2>/dev/null | awk '{t+=$2} END {printf "%.1f GiB", t/1024/1024/1024}')" + [private] [script] _refuse-instrumented-artifact: @@ -865,9 +1005,20 @@ doctest package="" *args: (build (if package == "" { "doctests.all" } else { "do # Run instrumented tests and report coverage. Args are forwarded to nextest; for example, # `just coverage -p dataplane-nat` scopes the run to this crate. [script] -coverage *args: +coverage *args: (setup-roots) {{ _just_debuggable_ }} export BOLERO_RANDOM_TEST_TIME_MS="{{ bolero_coverage_test_time_ms }}" + # Export the scratch-container roots `setup-roots` just built, so that + # `#[n_vm::test]` tests find them. The guard is for a tree where + # `setup-roots` was skipped; the tests fail loudly rather than skip, so + # this is a clearer error, not a fallback. + if [[ -e testroot && -e vmroot ]]; then + export N_VM_TEST_ROOT="$(pwd)/testroot" + export N_VM_VM_ROOT="$(pwd)/vmroot" + fi + # See the `test` recipe: trybuild resolves its scratch project against the + # local registry cache. + cargo fetch --locked export LLVM_COV="$(pwd)/devroot/bin/llvm-cov" export LLVM_PROFDATA="$(pwd)/devroot/bin/llvm-profdata" declare -r out="./target/nextest/coverage" @@ -955,7 +1106,7 @@ duvet-summary *args: # Use Nix-built archives so local and CI coverage report the same binaries. [script] -coverage-archive package="tests.all" *args: +coverage-archive package="tests.all" *args: (setup-roots) {{ _just_debuggable_ }} declare -r target="{{ if package == "tests.all" { "tests.all" } else { "tests.pkg." + package } }}" just \ @@ -1016,6 +1167,17 @@ coverage-archive package="tests.all" *args: # Nextest changes cwd; `%m` also pools compatible profiles across tests. export LLVM_PROFILE_FILE="${profraw}/cov-%m.profraw" + # This recipe -- not `coverage` -- is what CI runs (`ci::coverage`), and it + # runs the tests on the runner rather than in the nix sandbox, so the + # guest-booting tests need the same two things `test` gives them. + if [[ -e testroot && -e vmroot ]]; then + export N_VM_TEST_ROOT="${root}/testroot" + export N_VM_VM_ROOT="${root}/vmroot" + fi + # See the `test` recipe: trybuild resolves its scratch project against the + # local registry cache, which a nix-only CI job never populates. + cargo fetch --locked + # Report partial coverage before propagating a test failure. declare -i test_status=0 cargo nextest run \ diff --git a/mgmt/src/tests/mgmt.rs b/mgmt/src/tests/mgmt.rs index 0a93f5ac59..a73069e731 100644 --- a/mgmt/src/tests/mgmt.rs +++ b/mgmt/src/tests/mgmt.rs @@ -441,9 +441,7 @@ pub mod test { println!("{rendered}"); } - #[ignore = "temporarily disabled during vm test runner refactor"] - #[n_vm::in_vm] - #[tokio::test] + #[n_vm::test] async fn test_sample_config() { // Applying the config builds the rte_acl-backed ACL filter and flow-filter contexts, which // need the EAL up diff --git a/mgmt/tests/reconcile.rs b/mgmt/tests/reconcile.rs index c0721dafd7..46ff45a679 100644 --- a/mgmt/tests/reconcile.rs +++ b/mgmt/tests/reconcile.rs @@ -29,11 +29,15 @@ use test_utils::with_caps; use tracing::info; use tracing_test::traced_test; -#[test] -#[n_vm::in_vm] +#[n_vm::test] #[wrap(with_caps([Capability::CAP_NET_ADMIN]))] #[cfg_attr(not(emulated), traced_test)] fn reconcile_fuzz() { + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default() + .corpus(n_vm::CorpusPolicy::Fuzz) + .build(); + let runtime = tokio::runtime::Builder::new_current_thread() .enable_io() .enable_time() @@ -125,7 +129,7 @@ where /// The flannel VTEP here deliberately shares a VNI with a VPC of ours (flannel defaults to vni 1) /// while terminating on flannel's own UDP port. That is legal in the kernel, and the reconciler /// must not confuse the two. -#[test] +#[n_vm::test] #[wrap(with_caps([Capability::CAP_NET_ADMIN, Capability::CAP_SYS_ADMIN]))] #[cfg_attr(not(emulated), traced_test)] fn foreign_cni_devices_are_not_removed() { @@ -286,7 +290,7 @@ fn foreign_cni_devices_are_not_removed() { } #[allow(clippy::too_many_lines)] // this is an integration test and is expected to be long -#[tokio::test] +#[n_vm::test] #[wrap(with_caps([Capability::CAP_NET_ADMIN]))] #[cfg_attr(not(emulated), traced_test)] async fn reconcile_demo() { diff --git a/n-it/Cargo.toml b/n-it/Cargo.toml new file mode 100644 index 0000000000..d645f63d0d --- /dev/null +++ b/n-it/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "dataplane-n-it" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true + +[dependencies] + +# internal +n-vm-protocol = { workspace = true } + +# external +nix = { workspace = true, default-features = false, features = ["signal", "mount", "reboot", "fs"] } +parking_lot = { workspace = true, default-features = false, features = [] } +thiserror = { workspace = true, default-features = false, features = ["std"] } +tokio = { workspace = true, default-features = false, features = ["rt", "process", "signal", "time", "macros", "fs", "sync", "net", "io-util"] } +tokio-vsock = { workspace = true, default-features = false, features = [] } +tracing = { workspace = true, default-features = false, features = ["attributes"] } +tracing-subscriber = { workspace = true, default-features = false, features = ["fmt"] } +vsock = { workspace = true, default-features = false, features = [] } diff --git a/n-it/src/child.rs b/n-it/src/child.rs new file mode 100644 index 0000000000..90dfc46592 --- /dev/null +++ b/n-it/src/child.rs @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Process lifecycle management for the init system. +//! +//! This module handles spawning the test binary, reaping orphaned processes, +//! forwarding signals, and gracefully terminating remaining children during +//! shutdown. + +use std::io::Write; +use std::os::unix::io::{FromRawFd, IntoRawFd}; +use std::path::Path; +use std::process::Stdio; + +use n_vm_protocol::{ENV_IN_VM, ENV_MARKER_VALUE, TestResult, VM_WORKSPACE_DIR}; +use nix::errno::Errno; +use nix::sys::signal::{Signal, kill}; +use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid}; +use nix::unistd::Pid; +use tokio::process::{Child, Command}; +use tokio::time::{Duration, sleep}; +use tokio_vsock::VMADDR_CID_HOST; +use tracing::{debug, error, trace, warn}; + +use crate::error::{ + BroadcastSignalError, BroadcastSignalOutcome, ListChildrenError, ReapOutcome, SpawnError, + TerminateOutcome, +}; + +/// Converts a [`vsock::VsockStream`] into a [`Stdio`] handle by +/// transferring ownership of the underlying file descriptor. +/// +/// This encapsulates the only `unsafe` operation in this module into a +/// safe abstraction, per the project's [unsafe code guidelines]. +/// +/// [unsafe code guidelines]: ../../development/code/unsafe-code.md +fn vsock_stream_to_stdio(stream: vsock::VsockStream) -> Stdio { + // SAFETY: `VsockStream::into_raw_fd()` returns a valid, owned file + // descriptor. `Stdio::from_raw_fd()` takes ownership of it. The + // fd is not used after this point. + unsafe { Stdio::from_raw_fd(stream.into_raw_fd()) } +} + +/// Applies the environment the host tier forwarded, if any. +/// +/// The guest builds its child's environment from nothing, so anything the +/// test needs has to arrive explicitly. [`n_vm_protocol::GUEST_ENV_FILE`] is that channel: +/// a NUL-separated `KEY=VALUE` file on the read-only root share, written by +/// the host tier before the container was created. +/// +/// An absent file is the ordinary case and means "nothing to forward". A +/// file that exists but cannot be read is an error: it was put there on +/// purpose, and a test that runs without the variables it was given usually +/// does not fail -- a bolero test simply stops fuzzing and passes. +/// +/// # Errors +/// +/// Returns [`SpawnError::ForwardedEnvRead`] if the file exists but cannot be +/// read. +fn apply_forwarded_env(command: &mut Command) -> Result<(), SpawnError> { + let path = Path::new(n_vm_protocol::GUEST_ENV_FILE); + let raw = match std::fs::read(path) { + Ok(raw) => raw, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + trace!("no {} in the guest; forwarding nothing", path.display()); + return Ok(()); + } + Err(source) => { + return Err(SpawnError::ForwardedEnvRead { + path: path.to_path_buf(), + source, + }); + } + }; + + let vars = n_vm_protocol::decode_environ(&raw); + if vars.is_empty() { + warn!( + "{} exists but yielded no variables; the host tier wrote {} byte(s)", + path.display(), + raw.len(), + ); + return Ok(()); + } + + for (key, value) in &vars { + command.env(key, value); + } + // Names only: a forwarded value can carry a corpus path or engine flags + // that are noisy at this level, and the test's own output reports what + // it acted on. + debug!( + "applied {} forwarded variable(s): {}", + vars.len(), + vars.iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .join(", "), + ); + + Ok(()) +} + +/// Spawns the test binary as the main child process. +/// +/// Reads the binary path and test name from the kernel command line +/// arguments (passed via `init=`), sets `IN_VM=YES` so the `#[n_vm::test]` +/// macro executes the test body directly, and redirects stdout/stderr to +/// dedicated vsock streams ([`n_vm_protocol::VsockChannel::TEST_STDOUT`] and +/// [`n_vm_protocol::VsockChannel::TEST_STDERR`]). +/// +/// The container tier must have already bound Unix listeners at the +/// corresponding vsock listener paths before the VM booted, so these +/// connections succeed immediately. +/// +/// # Errors +/// +/// Returns a [`SpawnError`] if: +/// - No test binary was specified on the command line. +/// - A vsock connection for stdout or stderr cannot be established. +/// - The child process fails to spawn. +/// - The child exits before its PID can be read. +pub async fn spawn_main_process() -> Result { + debug!("spawning main process"); + + let mut args = std::env::args(); + if args.len() < 2 { + return Err(SpawnError::NoMainProcess); + } + + args.next().expect("argv[0] missing"); // skip self + + // Connect vsock streams for stdout and stderr. The container tier + // has already bound listeners at the dynamically-allocated ports, so + // these connections succeed immediately. + let alloc = crate::vsock_allocation(); + + let stdout_addr = vsock::VsockAddr::new(VMADDR_CID_HOST, alloc.test_stdout.port.as_raw()); + let stdout_stream = + vsock::VsockStream::connect(&stdout_addr).map_err(|e| SpawnError::VsockConnect { + channel: alloc.test_stdout, + source: e, + })?; + + let stderr_addr = vsock::VsockAddr::new(VMADDR_CID_HOST, alloc.test_stderr.port.as_raw()); + let stderr_stream = + vsock::VsockStream::connect(&stderr_addr).map_err(|e| SpawnError::VsockConnect { + channel: alloc.test_stderr, + source: e, + })?; + + let stdout_stdio = vsock_stream_to_stdio(stdout_stream); + let stderr_stdio = vsock_stream_to_stdio(stderr_stream); + + let mut command = Command::new( + args.next() + .expect("argv[1] missing: no test binary specified"), + ); + command + .args(args) + .kill_on_drop(true) + .stdin(Stdio::inherit()) + .stdout(stdout_stdio) + .stderr(stderr_stdio) + .env(ENV_IN_VM, ENV_MARKER_VALUE) + .env("PATH", "/bin") + .env("LD_LIBRARY_PATH", "/lib") + .env("RUST_BACKTRACE", "1"); + + apply_forwarded_env(&mut command)?; + + // Run from the shared cargo workspace when the container tier mounted + // one. PID 1 starts at `/`, which resolves nothing: tooling that + // captured a workspace-relative path at compile time (`file!()`, which + // bolero canonicalizes to locate its corpus) needs the workspace root + // as the working directory. Absent for an out-of-workspace caller, in + // which case `/` is left alone. + let workspace = Path::new("/").join(VM_WORKSPACE_DIR); + if workspace.is_dir() { + debug!("running main process from {}", workspace.display()); + command.current_dir(&workspace); + } else { + debug!("no {} directory; leaving cwd at /", workspace.display()); + } + + let child = command.spawn()?; + + if let Some(pid) = child.id() { + debug!("main process spawned with PID: {pid}"); + } else { + return Err(SpawnError::NoPid); + } + Ok(child) +} + +/// Reports the structured test verdict to the host over the result vsock +/// channel ([`n_vm_protocol::VsockChannel::TEST_RESULT`]). +/// +/// The container tier bound a listener on the result port before the VM +/// booted, so this connect succeeds immediately. Dropping the stream after +/// the write closes it, signalling EOF to the host's reader. +/// +/// This is **best-effort**: a failure to connect, write, or flush is logged +/// but not fatal. The host treats a missing or unparseable verdict as a +/// test failure, so a dropped report fails safe rather than falsely passing. +pub fn report_result(result: &TestResult) { + let channel = crate::vsock_allocation().result; + let addr = vsock::VsockAddr::new(VMADDR_CID_HOST, channel.port.as_raw()); + let wire = result.to_wire(); + + match vsock::VsockStream::connect(&addr) { + Ok(mut stream) => { + if let Err(e) = stream.write_all(wire.as_bytes()) { + error!("failed to write test verdict to host on {channel}: {e}"); + return; + } + if let Err(e) = stream.flush() { + error!("failed to flush test verdict to host on {channel}: {e}"); + } + } + Err(e) => { + error!("failed to connect result vsock to host on {channel}: {e}"); + } + } +} + +/// Reaps all orphaned child processes via non-blocking `waitpid`. +/// +/// Returns [`ReapOutcome::Clean`] if all reaped processes exited with +/// status 0, or [`ReapOutcome::LeakedProcesses`] if any process exited +/// with a non-zero status or was killed by a signal. +#[tracing::instrument(level = "debug")] +pub fn reap() -> ReapOutcome { + let mut clean = true; + const ANY_CHILD: Pid = Pid::from_raw(-1); + loop { + match waitpid(ANY_CHILD, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(pid, status)) => { + if status != 0 { + warn!("orphaned process {pid} exited with status {status}"); + clean = false; + } + } + Ok(WaitStatus::Signaled(pid, signal, _)) => { + warn!("orphaned process {pid} killed by signal {signal}"); + clean = false; + } + Ok(WaitStatus::StillAlive) => { + break; + } + Ok(status) => { + debug!("unexpected waitpid status in init: {status:?}"); + clean = false; + continue; + } + Err(Errno::ECHILD) => { + // No children remain to reap. `waitpid` reports this as + // ECHILD rather than `StillAlive`, and it is the normal + // terminal condition on every shutdown round, not an error. + break; + } + Err(e) => { + warn!("unexpected errno from waitpid in init: {e}"); + break; + } + } + } + if clean { + ReapOutcome::Clean + } else { + ReapOutcome::LeakedProcesses + } +} + +/// Sends a signal to all processes except init (PID 1). +/// +/// Uses `kill(-1, signal)` which targets every process the caller has +/// permission to signal. Returns [`BroadcastSignalOutcome::Delivered`] +/// if at least one process received the signal, or +/// [`BroadcastSignalOutcome::NoProcesses`] if no processes were found +/// (`ESRCH`). +/// +/// # Errors +/// +/// Returns a [`BroadcastSignalError`] on `EPERM` or unexpected errors, +/// since an init system that cannot signal its children is in an +/// unrecoverable state. +#[tracing::instrument(level = "info")] +pub fn send_signal_to_all_processes( + signal: Signal, +) -> Result { + // Using PID -1 means "all processes that the calling process has + // permission to send signals to". + match kill(Pid::from_raw(-1), signal) { + Ok(()) => { + trace!("successfully sent {signal:?} to all processes"); + Ok(BroadcastSignalOutcome::Delivered) + } + Err(Errno::ESRCH) => { + // No processes found -- this can happen if we're the only + // process left. + trace!("no processes found to send {signal:?} to"); + Ok(BroadcastSignalOutcome::NoProcesses) + } + Err(Errno::EPERM) => Err(BroadcastSignalError::PermissionDenied { signal }), + Err(e) => Err(BroadcastSignalError::Failed { signal, source: e }), + } +} + +/// Forwards a signal to a specific process, handling the case where the +/// process has already exited. +/// +/// Unlike [`send_signal_to_all_processes`], this targets a single PID and +/// treats `ESRCH` (no such process) as a non-fatal condition -- the child +/// may have exited between the time the signal was received and the time +/// we attempt to forward it. +pub fn forward_signal(pid: Pid, sig: Signal) { + if let Err(e) = kill(pid, sig) { + match e { + Errno::ESRCH => { + debug!("cannot forward {sig:?}: process {pid} already exited"); + } + other => { + error!("failed to forward {sig:?} to process {pid}: {other}"); + } + } + } +} + +/// The PID of the init process (PID 1). +/// +/// Used to filter `/proc` entries when listing direct children of init, +/// and to verify that the binary is running as PID 1 in [`crate::main`]. +pub const INIT_PID: u32 = 1; + +/// Maximum number of SIGTERM rounds before giving up. +pub const MAX_SIGTERM_ATTEMPTS: u8 = 50; + +/// Terminates all remaining child processes with SIGTERM. +/// +/// Sends up to [`MAX_SIGTERM_ATTEMPTS`] rounds of SIGTERM (with 10 ms +/// sleeps between rounds), reaping exited processes after each round. +/// +/// Returns a [`TerminateOutcome`] describing whether child processes +/// were found and whether they all terminated successfully. +#[tracing::instrument(level = "info")] +pub async fn terminate_remaining_processes() -> TerminateOutcome { + match list_child_processes().await { + Ok(children) if children.is_empty() => { + trace!("no child processes remaining"); + return TerminateOutcome::NoneRemaining; + } + Ok(_) => {} + Err(e) => { + // If we can't even list children during shutdown, log it and + // assume the worst -- try to terminate anyway. + error!("failed to list child processes during shutdown: {e}"); + } + } + + if !reap().is_clean() { + warn!("test seems to be leaking processes"); + } + + // Send SIGTERM to all processes. + let mut sigs: u8 = 0; + warn!("sending SIGTERM to all remaining processes"); + loop { + if sigs >= MAX_SIGTERM_ATTEMPTS { + break; + } + + match send_signal_to_all_processes(Signal::SIGTERM) { + Ok(BroadcastSignalOutcome::NoProcesses) => { + // Children were found at entry but are all gone now: + // termination succeeded, this is not the give-up path. + debug!("no more processes to signal"); + return TerminateOutcome::Terminated; + } + Ok(BroadcastSignalOutcome::Delivered) => {} + Err(e) => { + // Permission denied or unexpected error from PID 1 is + // genuinely unrecoverable. + fatal!("unrecoverable error during shutdown signal broadcast: {e}"); + } + } + + sigs += 1; + sleep(Duration::from_millis(10)).await; + + if !reap().is_clean() { + error!("test is leaking processes"); + } + + match list_child_processes().await { + Ok(children) if children.is_empty() => { + debug!("all child processes terminated after {sigs} SIGTERM round(s)"); + return TerminateOutcome::Terminated; + } + Ok(_) => {} + Err(e) => { + error!("failed to list child processes during termination: {e}"); + } + } + } + + error!("maximum SIGTERM attempts reached: test did not shut down correctly"); + TerminateOutcome::ExhaustedRetries +} + +/// Lists all direct child processes of init (PPID == 1) by scanning `/proc`. +/// +/// # Errors +/// +/// Returns a [`ListChildrenError`] if `/proc` cannot be read or a child +/// PID overflows `i32`. +pub async fn list_child_processes() -> Result, ListChildrenError> { + let mut child_pids = tokio::fs::read_dir("/proc") + .await + .map_err(ListChildrenError::ReadDir)?; + let mut children = vec![]; + while let Some(process) = child_pids + .next_entry() + .await + .map_err(ListChildrenError::ReadEntry)? + { + let name = process.file_name(); + let Ok(pid) = name.to_string_lossy().parse::() else { + // Non-numeric entries (e.g. /proc/self, /proc/net) are expected + // and silently skipped. + continue; + }; + let stat = tokio::fs::read_to_string(format!("/proc/{pid}/stat")).await; + let Ok(stat) = stat else { + // The process may have exited between readdir and this read. + trace!("could not read /proc/{pid}/stat (process likely exited)"); + continue; + }; + // The second field (comm) may contain spaces and parentheses, so + // field counting only becomes reliable after the *last* ')'. + // The PPID is the second field after the comm. + let Some(ppid_str) = stat + .rfind(')') + .and_then(|idx| stat[idx + 1..].split_whitespace().nth(1)) + else { + trace!("/proc/{pid}/stat has unexpected format (missing ppid field)"); + continue; + }; + let Ok(ppid) = ppid_str.parse::() else { + trace!("/proc/{pid}/stat ppid field is not a valid u32: {ppid_str:?}"); + continue; + }; + if ppid == INIT_PID { + let pid_i32 = i32::try_from(pid).map_err(|_| ListChildrenError::PidOverflow { pid })?; + children.push(Pid::from_raw(pid_i32)); + } + } + Ok(children) +} diff --git a/n-it/src/error.rs b/n-it/src/error.rs new file mode 100644 index 0000000000..8c96b3060e --- /dev/null +++ b/n-it/src/error.rs @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Dedicated error types for the `n-it` init system subsystems. +//! +//! Each subsystem ([`mount`](crate::mount), [`child`](crate::child)) +//! defines its own error type here so that failure modes are explicit in +//! function signatures rather than hidden behind [`fatal!`] or +//! [`Option<()>`]. +//! +//! The orchestrator ([`crate::init::InitSystem`]) is responsible for +//! deciding how to handle each error -- typically by logging context and +//! aborting via [`fatal!`]. + +use std::path::Path; + +use n_vm_protocol::VsockChannel; +use nix::errno::Errno; +use nix::sys::signal::Signal; + +/// An error that occurred while mounting an essential filesystem. +#[derive(Debug, thiserror::Error)] +pub enum MountError { + /// The kernel returned `EPERM` -- the init process lacks the required + /// capability (should never happen for PID 1 in a normal VM). + #[error("permission denied while mounting {}", .target.display())] + PermissionDenied { + /// The mount point path that failed. + target: &'static Path, + }, + + /// The kernel returned an unrecognised errno during mount. + #[error("unknown error while mounting {}", .target.display())] + Unknown { + /// The mount point path that failed. + target: &'static Path, + }, + + /// A mount syscall failed with a specific errno. + #[error("failed to mount {}: {source}", .target.display())] + Failed { + /// The mount point path that failed. + target: &'static Path, + /// The underlying errno. + source: Errno, + }, +} + +/// An error that occurred while unmounting filesystems during shutdown. +#[derive(Debug, thiserror::Error)] +pub enum UnmountError { + /// The mount point remained busy after exhausting all retry attempts. + #[error( + "{} still busy after {attempts} retries; \ + a leaked process is likely holding a file descriptor open", + .target.display() + )] + BusyExhausted { + /// The mount point path that could not be unmounted. + target: &'static Path, + /// The number of retry attempts made. + attempts: u32, + }, + + /// The mount point was not actually mounted, or the path is invalid. + #[error("{} not mounted or invalid", .target.display())] + NotMounted { + /// The mount point path. + target: &'static Path, + }, + + /// An unexpected errno was returned by `umount2`. + #[error("failed to unmount {}: {source}", .target.display())] + Failed { + /// The mount point path that failed. + target: &'static Path, + /// The underlying errno. + source: Errno, + }, +} + +/// An error that occurred while spawning the main test process. +#[derive(Debug, thiserror::Error)] +pub enum SpawnError { + /// No test binary was specified on the kernel command line. + #[error("no main process specified to init process (expected argv[1])")] + NoMainProcess, + + /// The forwarded environment file exists but could not be read. + /// + /// Not treated as "nothing to forward": the host tier only writes this + /// file when it has something to pass, and a test started without it + /// generally still passes -- a bolero test just stops fuzzing. + #[error("failed to read the forwarded environment file {path}: {source}")] + ForwardedEnvRead { + /// The file that could not be read. + path: std::path::PathBuf, + /// The underlying I/O error. + source: std::io::Error, + }, + + /// Failed to connect a vsock stream for child I/O redirection. + #[error("failed to connect {channel} vsock: {source}")] + VsockConnect { + /// The vsock channel that could not be connected. + channel: VsockChannel, + /// The underlying I/O error. + source: std::io::Error, + }, + + /// The `Command::spawn` call failed. + #[error("failed to spawn test process: {0}")] + Spawn(#[from] std::io::Error), + + /// The spawned child exited before we could read its PID. + #[error("unable to determine PID of spawned test process")] + NoPid, +} + +/// Outcome of reaping orphaned child processes via `waitpid`. +/// +/// This replaces the previous `Option<()>` return where `Some(())` +/// meant failure -- a pattern the project guidelines explicitly +/// discourage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReapOutcome { + /// All reaped processes (if any) exited cleanly with status 0. + Clean, + /// At least one reaped process exited with a non-zero status or was + /// killed by a signal. This typically indicates the test leaked + /// child processes. + LeakedProcesses, +} + +impl ReapOutcome { + /// Returns `true` if all reaped processes exited cleanly. + #[must_use] + pub fn is_clean(self) -> bool { + matches!(self, Self::Clean) + } +} + +/// Outcome of sending a signal to all non-init processes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BroadcastSignalOutcome { + /// At least one process received the signal. + Delivered, + /// No processes were found to signal (`ESRCH`). + NoProcesses, +} + +/// An unrecoverable error when broadcasting a signal. +/// +/// `EPERM` from an init system is genuinely fatal -- if PID 1 cannot +/// signal its children the system is in an unrecoverable state. +#[derive(Debug, thiserror::Error)] +pub enum BroadcastSignalError { + /// The init process was denied permission to signal its children. + #[error("permission denied when sending {signal:?} to all processes")] + PermissionDenied { + /// The signal that could not be delivered. + signal: Signal, + }, + + /// An unexpected errno was returned by `kill(-1, signal)`. + #[error("failed to send {signal:?} to all processes: {source}")] + Failed { + /// The signal that could not be delivered. + signal: Signal, + /// The underlying errno. + source: Errno, + }, +} + +/// Outcome of attempting to terminate all remaining child processes +/// during shutdown. +/// +/// This replaces the previous `Option<()>` return type with an explicit +/// three-state enum so callers can distinguish "nothing to do" from +/// "cleaned up" from "gave up." +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminateOutcome { + /// No child processes were remaining at the time of the call. + NoneRemaining, + /// Leaked child processes were found and all of them were cleaned up. + /// + /// Cleanup itself succeeded, but the test still left processes behind, + /// so this outcome is **not** [`is_clean`](Self::is_clean): the test + /// result is downgraded to a failure. + Terminated, + /// SIGTERM was sent [`MAX_SIGTERM_ATTEMPTS`](crate::child::MAX_SIGTERM_ATTEMPTS) + /// times but some processes still did not exit. + ExhaustedRetries, +} + +impl TerminateOutcome { + /// Returns `true` if the test leaked no child processes (the happy + /// path). + /// + /// [`Terminated`](Self::Terminated) is deliberately *not* clean: even + /// though cleanup succeeded, the leak itself is a test failure. + #[must_use] + pub fn is_clean(self) -> bool { + matches!(self, Self::NoneRemaining) + } +} + +/// An error encountered while listing child processes from `/proc`. +#[derive(Debug, thiserror::Error)] +pub enum ListChildrenError { + /// Failed to open `/proc` for reading. + #[error("failed to read /proc: {0}")] + ReadDir(std::io::Error), + + /// Failed to read an individual `/proc` directory entry. + #[error("failed to read /proc entry: {0}")] + ReadEntry(std::io::Error), + + /// A child PID value overflows `i32` (required by [`nix::unistd::Pid::from_raw`]). + #[error("child pid {pid} overflows i32")] + PidOverflow { + /// The PID value that overflowed. + pid: u32, + }, +} diff --git a/n-it/src/init.rs b/n-it/src/init.rs new file mode 100644 index 0000000000..5720a57dfc --- /dev/null +++ b/n-it/src/init.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Init system orchestrator. +//! +//! This module ties together the [`mount`], [`child`], and +//! [`signal`](crate::signal) subsystems +//! into the main init system lifecycle: +//! +//! 1. Register signal handlers ([`SignalSet`]). +//! 2. Mount essential filesystems. +//! 3. Spawn the test process. +//! 4. Enter the event loop -- forward signals and wait for exit. +//! 5. Shut down cleanly (terminate children, unmount, power off / abort). +//! +//! Each phase delegates to a focused module, so the orchestrator itself +//! requires only local reasoning about sequencing. +//! +//! **Error handling boundary**: the subsystem modules return typed +//! [`Result`] values and outcome enums. This orchestrator is the +//! outermost boundary where unrecoverable errors are converted to +//! [`fatal!`] calls (which flush I/O and abort the process). + +use std::convert::Infallible; + +use n_vm_protocol::TestResult; +use nix::sys::reboot::{RebootMode, reboot}; +use nix::unistd::Pid; +use tracing::{debug, error, info}; + +use crate::child; +use crate::error::TerminateOutcome; +use crate::mount; +use crate::signal::{SIGNAL_TABLE, SignalPolicy, SignalSet}; + +/// Minimal init system for running tests inside a cloud-hypervisor VM. +/// +/// This unit struct groups the top-level orchestration methods. It is +/// intended to run as PID 1 and delegates filesystem mounting, process +/// lifecycle management, signal forwarding, and clean shutdown to the +/// [`mount`], [`child`], and [`signal`](crate::signal) modules. +#[derive(Debug)] +#[non_exhaustive] +pub struct InitSystem; + +impl InitSystem { + /// Main entry point for the init system. + /// + /// Registers signal handlers, mounts filesystems, spawns the test + /// process, and enters the main event loop. The event loop forwards + /// signals to the test process and waits for it to exit, then + /// initiates shutdown. + /// + /// This function never returns (its return type is [`Infallible`]). + #[tracing::instrument(level = "info")] + pub async fn run() -> Infallible { + info!("starting init system"); + + debug!("registering signal handlers"); + let mut signals = SignalSet::register(SIGNAL_TABLE); + debug!("signal handlers registered"); + + match tokio::task::spawn_blocking(mount::mount_essential_filesystems).await { + Ok(Ok(())) => {} + Ok(Err(e)) => fatal!("filesystem setup failed: {e}"), + Err(e) => fatal!("mount filesystem task panicked: {e}"), + } + + let mut test_child = match child::spawn_main_process().await { + Ok(child) => child, + Err(e) => fatal!("failed to start test process: {e}"), + }; + let pid = match test_child.id() { + Some(id) => { + let id = + i32::try_from(id).unwrap_or_else(|_| fatal!("child PID {id} overflows i32")); + Pid::from_raw(id) + } + None => fatal!("unable to determine PID of spawned test process"), + }; + + // `success` may be downgraded by a failure-policy signal while we + // wait; `outcome` is bound from the break value, which is only + // reached when the test process exits (the sole break path). + let mut success = true; + + let outcome = loop { + tokio::select! { + result = test_child.wait() => { + break match result { + Ok(status) if status.success() => { + debug!("main process exited successfully with status {status}"); + format!("test process {status}") + } + Ok(status) => { + error!("main process exited with failure status {status}"); + success = false; + format!("test process {status}") + } + Err(e) => { + error!("main process error: {e}"); + success = false; + format!("error waiting on test process: {e}") + } + }; + } + spec = signals.recv() => { + match spec.policy { + SignalPolicy::Failure => { + debug!("received failure signal {}, forwarding and marking failed", spec.label); + success = false; + } + SignalPolicy::Benign => { + debug!("forwarding benign signal {}", spec.label); + } + } + child::forward_signal(pid, spec.signal); + } + } + }; + + Self::shutdown_system(success, outcome).await + } + + /// Performs a clean system shutdown. + /// + /// 1. Terminates any remaining child processes (leaked processes + /// downgrade the verdict to a failure). + /// 2. Reports the structured pass/fail verdict to the host over the + /// result vsock channel. + /// 3. Unmounts all filesystems and powers off via `reboot(RB_POWER_OFF)`. + /// + /// Unlike a conventional init, this *always* powers off cleanly: the + /// pass/fail signal is carried by the verdict reported in step 2, not by + /// crashing the guest. [`fatal!`] (which aborts and surfaces as a guest + /// panic) is reserved for `n-it`'s own unrecoverable errors -- in those + /// cases the host sees a missing verdict plus a panic and fails the test. + /// + /// This function never returns (its return type is [`Infallible`]). + #[tracing::instrument(level = "info")] + async fn shutdown_system(success: bool, outcome: String) -> Infallible { + info!("beginning system shutdown"); + + // Terminate all child processes; leaked processes downgrade success. + let terminate_outcome = child::terminate_remaining_processes().await; + let leaked = !terminate_outcome.is_clean(); + let success = !leaked && success; + + if matches!(terminate_outcome, TerminateOutcome::ExhaustedRetries) { + error!("some child processes could not be terminated"); + } + + let detail = if leaked { + format!("{outcome}; leaked child processes during shutdown") + } else { + outcome + }; + + // Report the verdict to the host before tearing anything down. This + // is best-effort: if it fails, the host observes a missing verdict, + // which it treats as a failure -- the safe default. + child::report_result(&TestResult::new(success, detail)); + + // Final sync, unmount, and power off. + match tokio::task::spawn_blocking(move || { + if let Err(e) = mount::unmount_filesystems() { + fatal!("failed to unmount filesystems: {e}"); + } + info!( + "powering off (test {})", + if success { "passed" } else { "failed" } + ); + match reboot(RebootMode::RB_POWER_OFF) { + Ok(_) => unreachable!(), + Err(e) => { + fatal!("failed to power off: {e}"); + } + } + }) + .await + { + Ok(_) => { + // Normally unreachable -- the blocking task either powers off + // or aborts. Use fatal! to ensure stdio is flushed. + fatal!("shutdown task returned unexpectedly"); + } + Err(err) => { + fatal!("failed to shutdown system: {err}"); + } + } + } +} diff --git a/n-it/src/main.rs b/n-it/src/main.rs new file mode 100644 index 0000000000..5e82f8f7ec --- /dev/null +++ b/n-it/src/main.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Minimal init system for the `n-vm` test infrastructure. +//! +//! This binary runs as **PID 1** inside a cloud-hypervisor VM booted by +//! `n_vm::run_in_vm`. Its responsibilities are: +//! +//! 1. **Mount essential filesystems** -- `/proc`, `/sys`, `/tmp`, `/run`, and +//! `/sys/fs/cgroup` with appropriate security flags. +//! 2. **Spawn the test binary** as a child process with the `IN_VM=YES` +//! environment variable, causing the `#[n_vm::test]` macro to execute the test +//! body directly. +//! 3. **Forward signals** -- benign signals (SIGHUP, SIGUSR1, etc.) are +//! forwarded to the test process; failure signals (SIGINT, SIGPIPE, etc.) +//! are forwarded and also mark the test as failed. +//! 4. **Reap orphaned processes** -- after the test exits, any remaining child +//! processes are terminated with SIGTERM. Leaked processes are treated as +//! a test failure. +//! 5. **Stream tracing data** back to the host via a vsock connection so that +//! the container tier can collect init system logs. +//! 6. **Clean shutdown** -- unmount filesystems, sync, and power off the VM +//! (or abort on failure so the hypervisor detects a guest panic). + +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +use std::convert::Infallible; +use std::process; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use n_vm_protocol::VsockAllocation; +use tokio_vsock::VMADDR_CID_HOST; + +/// The dynamically-allocated vsock resources for this VM instance. +/// +/// Populated once during early init by parsing `/proc/cmdline`. All +/// modules in this crate access the allocation through +/// [`vsock_allocation`]. +static VSOCK_ALLOCATION: OnceLock = OnceLock::new(); + +/// Guest paths at which the writable shares should be mounted, paired with +/// the share each belongs to. +/// +/// Populated during early init from the kernel command line, and empty +/// unless the test is a fuzz target -- in which case the guest has no +/// writable view of the source tree at all. +static WRITABLE_MOUNTS: OnceLock> = OnceLock::new(); + +/// Returns the writable share mount points parsed from the kernel command +/// line. +pub(crate) fn writable_mounts() -> &'static [(n_vm_protocol::WritableShare, String)] { + WRITABLE_MOUNTS.get().map_or(&[], Vec::as_slice) +} + +/// Returns the vsock allocation parsed from the kernel command line. +/// +/// # Panics +/// +/// Panics if called before [`VSOCK_ALLOCATION`] has been initialized +/// (i.e. before `main` has run its early-init phase). +pub(crate) fn vsock_allocation() -> &'static VsockAllocation { + VSOCK_ALLOCATION + .get() + .expect("vsock_allocation() called before /proc/cmdline was parsed") +} + +// NOTE: `utils` must be declared before modules that use the `fatal!` macro. +#[macro_use] +mod utils; + +mod child; +mod error; +mod mount; +mod signal; + +mod init; +mod vsock_writer; + +use init::InitSystem; +use vsock_writer::VsockWriter; + +fn main() -> Infallible { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .thread_name_fn(|| { + static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); + let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); + format!("init-{}", id) + }) + .max_blocking_threads(1) + .build() + .unwrap_or_else(|e| { + eprintln!("FATAL: failed to build tokio runtime for init system: {e}"); + std::process::abort(); + }); + runtime.block_on(async { + eprintln!("init system runtime started: parsing vsock allocation from /proc/cmdline"); + + // Read the kernel command line to discover dynamically-allocated + // vsock ports. /proc isn't mounted yet (mount_essential_filesystems + // handles the canonical mount later), so we do a temporary + // read-only mount, grab the contents, and immediately unmount. + let cmdline = { + let _ = std::fs::create_dir_all("/proc"); + let mount_result = nix::mount::mount( + Some("proc"), + "/proc", + Some("proc"), + nix::mount::MsFlags::MS_RDONLY + | nix::mount::MsFlags::MS_NOSUID + | nix::mount::MsFlags::MS_NODEV + | nix::mount::MsFlags::MS_NOEXEC, + None::<&str>, + ); + if let Err(e) = &mount_result { + eprintln!("WARNING: early /proc mount failed: {e}; will try fallback"); + } + let result = std::fs::read_to_string("/proc/cmdline").unwrap_or_default(); + if mount_result.is_ok() { + let _ = nix::mount::umount("/proc"); + } + result + }; + + let alloc = VsockAllocation::parse_kernel_cmdline(&cmdline).unwrap_or_else(|| { + eprintln!( + "WARNING: failed to parse vsock ports from /proc/cmdline, \ + falling back to static defaults. cmdline: {cmdline:?}" + ); + VsockAllocation::with_defaults() + }); + eprintln!( + "vsock allocation: trace={}, stdout={}, stderr={}, result={}", + alloc.init_trace.port, + alloc.test_stdout.port, + alloc.test_stderr.port, + alloc.result.port, + ); + VSOCK_ALLOCATION + .set(alloc) + .expect("VSOCK_ALLOCATION already initialized"); + + let mounts: Vec<_> = n_vm_protocol::WRITABLE_SHARES + .iter() + .filter_map(|share| { + let path = cmdline.split_whitespace().find_map(|tok| { + tok.strip_prefix(share.cmdline_key) + .and_then(|rest| rest.strip_prefix('=')) + })?; + eprintln!( + "writable {role} share requested at {path}", + role = share.role, + ); + Some((*share, path.to_owned())) + }) + .collect(); + WRITABLE_MOUNTS + .set(mounts) + .expect("WRITABLE_MOUNTS already initialized"); + + let alloc = vsock_allocation(); + eprintln!( + "connecting to tracing vsock on port {}", + alloc.init_trace.port + ); + let tracing_addr = vsock::VsockAddr::new(VMADDR_CID_HOST, alloc.init_trace.port.as_raw()); + let tracing_vsock = VsockWriter::new( + vsock::VsockStream::connect(&tracing_addr).unwrap_or_else(|e| { + eprintln!("FATAL: failed to connect tracing vsock to host: {e}"); + std::process::abort(); + }), + ); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_thread_ids(false) + .with_thread_names(true) + .with_line_number(true) + .with_target(false) + .with_writer(tracing_vsock) + .with_file(true) + .init(); + + let init_span = tracing::span!(tracing::Level::INFO, "init"); + let _guard = init_span.enter(); + if process::id() != child::INIT_PID { + fatal!( + "this program must be run as PID {} (init process)", + child::INIT_PID + ); + } + InitSystem::run().await + }) +} diff --git a/n-it/src/mount.rs b/n-it/src/mount.rs new file mode 100644 index 0000000000..b8e79b64da --- /dev/null +++ b/n-it/src/mount.rs @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Filesystem mount and unmount operations for the VM init process. + +use std::path::Path; + +use nix::errno::Errno; +use nix::mount::{MntFlags, MsFlags, mount}; +use nix::unistd::sync; +use std::time::Duration; +use tracing::{debug, warn}; + +use crate::error::{MountError, UnmountError}; + +/// A single entry in the essential-filesystems mount table. +struct MountEntry { + /// Filesystem source (e.g. `"proc"`, `"tmpfs"`, `"hugetlbfs"`). + source: &'static str, + /// Mount point path. + target: &'static str, + /// Filesystem type. + fstype: &'static str, + /// Optional comma-separated mount data (e.g. `"mode=0600,size=5%"`). + data: Option<&'static str>, + /// Whether to create the target directory before mounting. + create_target: bool, + /// Whether mount failure should be logged and ignored. + optional: bool, +} + +/// The filesystems that must be mounted before the test process can run. +const ESSENTIAL_MOUNTS: &[MountEntry] = &[ + MountEntry { + source: "proc", + target: "/proc", + fstype: "proc", + data: None, + create_target: false, + optional: false, + }, + MountEntry { + source: "sysfs", + target: "/sys", + fstype: "sysfs", + data: None, + create_target: false, + optional: false, + }, + MountEntry { + source: "tmpfs", + target: "/tmp", + fstype: "tmpfs", + data: Some("mode=0600,size=5%"), + create_target: false, + optional: false, + }, + MountEntry { + source: "tmpfs", + target: "/run", + fstype: "tmpfs", + data: Some("mode=0600,size=5%"), + create_target: false, + optional: false, + }, + // Hugetlbfs page sizes are optional because guest CPU support varies. + MountEntry { + source: "hugetlbfs", + target: "/run/huge/2MiB", + fstype: "hugetlbfs", + data: Some("pagesize=2M"), + create_target: true, + optional: true, + }, + MountEntry { + source: "hugetlbfs", + target: "/run/huge/1GiB", + fstype: "hugetlbfs", + data: Some("pagesize=1G"), + create_target: true, + optional: true, + }, + MountEntry { + source: "cgroup2", + target: "/sys/fs/cgroup", + fstype: "cgroup2", + data: Some("nsdelegate,memory_recursiveprot"), + create_target: false, + optional: false, + }, +]; + +/// Maximum number of `EBUSY` retries per mount point before giving up. +const UMOUNT_MAX_EBUSY_RETRIES: u32 = 1_000; + +/// Mounts the essential virtual filesystems required by the guest OS. +/// +/// # Errors +/// +/// Returns a [`MountError`] if any **non-optional** mount syscall (or +/// preparatory `mkdir`) fails. +pub fn mount_essential_filesystems() -> Result<(), MountError> { + for entry in ESSENTIAL_MOUNTS { + match secure_mount(entry) { + Ok(()) => {} + Err(e) if entry.optional => { + warn!("optional mount {} failed ({}); skipping", entry.target, e,); + } + Err(e) => return Err(e), + } + } + mount_writable_shares(); + debug!("all essential filesystems mounted successfully"); + Ok(()) +} + +/// Mounts each writable share over its read-only counterpart, when the +/// container tier provided one. +/// +/// The workspace itself arrives over the read-only virtiofs daemon, so this +/// overmounts individual directories with shares from *writable* daemons. +/// The read/write split is therefore enforced by which daemon serves which +/// path -- nothing the guest does to its own mount flags can widen it, +/// which is the point: a fuzz target is deliberately provoking misbehaviour +/// and must not be able to damage the developer's source tree. +/// +/// There are two, with opposite lifetimes: a corpus is a cache that a run +/// may deliberately start without, while a crash is a finding that must not +/// be lost. The engine puts them in unrelated trees for that reason, so one +/// share cannot cover both. +/// +/// Best-effort. A test whose shares cannot be mounted still runs; it just +/// cannot persist anything, which is far better than failing the run. +fn mount_writable_shares() { + for (share, target) in crate::writable_mounts() { + // Usually the mount point already exists, having come from the + // read-only workspace share. A corpus directed outside the + // workspace -- `FUZZ_CORPUS_ROOT=/tmp/...` -- has no such + // counterpart, but lands on a writable tmpfs, so try to make one. + if !Path::new(target).is_dir() { + let _ = std::fs::create_dir_all(target); + } + if !Path::new(target).is_dir() { + warn!( + "{role} mount point {target} does not exist in the guest and \ + could not be created; skipping", + role = share.role, + ); + continue; + } + + debug!( + "mounting writable {role} share at {target}", + role = share.role + ); + match nix::mount::mount( + Some(share.tag), + target.as_str(), + Some("virtiofs"), + nix::mount::MsFlags::MS_NOSUID | nix::mount::MsFlags::MS_NODEV, + None::<&str>, + ) { + Ok(()) => debug!( + "{role} share mounted read-write at {target}", + role = share.role, + ), + Err(e) => warn!( + "failed to mount {role} share at {target}: {e}", + role = share.role, + ), + } + } +} + +/// Performs a single mount with security flags, optionally creating the +/// target directory first. +fn secure_mount(entry: &MountEntry) -> Result<(), MountError> { + let MountEntry { + source, + target, + fstype, + data, + create_target, + optional: _, + } = entry; + let target_path: &'static Path = Path::new(*target); + + if *create_target { + debug!("creating mount point {}", target_path.display()); + std::fs::create_dir_all(*target).map_err(|e| { + let errno = e + .raw_os_error() + .map_or(Errno::UnknownErrno, Errno::from_raw); + MountError::Failed { + target: target_path, + source: errno, + } + })?; + } + + debug!("mounting {}", target_path.display()); + mount( + Some(*source), + *target, + Some(*fstype), + MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV, + *data, + ) + .map_err(|e| match e { + Errno::UnknownErrno => MountError::Unknown { + target: target_path, + }, + Errno::EPERM => MountError::PermissionDenied { + target: target_path, + }, + other => MountError::Failed { + target: target_path, + source: other, + }, + }) +} + +/// Unmounts all [`ESSENTIAL_MOUNTS`] in reverse order. +/// +/// # Errors +/// +/// Returns an [`UnmountError`] on `EINVAL`, unexpected errors, or if +/// retries are exhausted for a busy mount point. +#[tracing::instrument(level = "info")] +pub fn unmount_filesystems() -> Result<(), UnmountError> { + debug!("syncing filesystems"); + sync(); + debug!("umounting filesystems"); + for entry in ESSENTIAL_MOUNTS.iter().rev() { + let mount_point = Path::new(entry.target); + match unmount_one(mount_point) { + Ok(()) => {} + Err(UnmountError::NotMounted { .. }) if entry.optional => { + debug!( + "optional mount {} was not mounted; skipping unmount", + mount_point.display(), + ); + } + Err(e) => return Err(e), + } + } + debug!("filesystem umounting completed"); + debug!("final sync"); + sync(); + Ok(()) +} + +/// Unmounts a single mount point, retrying on `EBUSY`. +fn unmount_one(mount_point: &'static Path) -> Result<(), UnmountError> { + debug!("umounting {}", mount_point.display()); + sync(); + let mut attempts: u32 = 0; + loop { + match nix::mount::umount2( + mount_point, + MntFlags::MNT_DETACH | MntFlags::UMOUNT_NOFOLLOW, + ) { + Ok(()) => { + debug!("successfully unmounted {}", mount_point.display()); + sync(); + return Ok(()); + } + Err(Errno::EBUSY) => { + attempts += 1; + if attempts >= UMOUNT_MAX_EBUSY_RETRIES { + return Err(UnmountError::BusyExhausted { + target: mount_point, + attempts, + }); + } + if attempts.is_multiple_of(100) { + warn!( + "{} still busy after {attempts} retries", + mount_point.display(), + ); + } + sync(); + std::thread::sleep(Duration::from_millis(1)); + } + Err(Errno::EINVAL) => { + return Err(UnmountError::NotMounted { + target: mount_point, + }); + } + Err(e) => { + return Err(UnmountError::Failed { + target: mount_point, + source: e, + }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mount_targets_are_all_absolute_paths() { + for entry in ESSENTIAL_MOUNTS { + assert!( + entry.target.starts_with('/'), + "mount target should be absolute: {:?}", + entry.target, + ); + } + } + + #[test] + fn mount_targets_have_no_duplicates() { + let mut targets: Vec<&str> = ESSENTIAL_MOUNTS.iter().map(|e| e.target).collect(); + let original_len = targets.len(); + targets.sort(); + targets.dedup(); + assert_eq!( + targets.len(), + original_len, + "ESSENTIAL_MOUNTS contains duplicate targets", + ); + } + + #[test] + fn child_mount_points_appear_after_their_parents() { + for (i, entry) in ESSENTIAL_MOUNTS.iter().enumerate() { + let target = entry.target; + for (j, other) in ESSENTIAL_MOUNTS.iter().enumerate() { + if i == j { + continue; + } + let is_child = target.starts_with(other.target) + && target != other.target + && target.as_bytes().get(other.target.len()) == Some(&b'/'); + if is_child { + assert!( + j < i, + "mount target {target:?} is a child of {:?}, \ + but the parent appears at index {j} (after child at index {i})", + other.target, + ); + } + } + } + } + + #[test] + fn hugetlbfs_entries_are_optional() { + for entry in ESSENTIAL_MOUNTS.iter().filter(|e| e.fstype == "hugetlbfs") { + assert!( + entry.optional, + "hugetlbfs mount at {:?} should be optional", + entry.target, + ); + } + } + + #[test] + fn hugetlbfs_entries_require_create_target() { + for entry in ESSENTIAL_MOUNTS.iter().filter(|e| e.fstype == "hugetlbfs") { + assert!( + entry.create_target, + "hugetlbfs mount at {:?} should have create_target = true", + entry.target, + ); + } + } + + #[test] + fn create_target_entries_are_children_of_writable_mounts() { + let writable_targets: Vec<&str> = ESSENTIAL_MOUNTS + .iter() + .filter(|e| e.fstype == "tmpfs") + .map(|e| e.target) + .collect(); + + for entry in ESSENTIAL_MOUNTS.iter().filter(|e| e.create_target) { + let has_writable_parent = writable_targets.iter().any(|parent| { + entry.target.starts_with(parent) + && entry.target != *parent + && entry.target.as_bytes().get(parent.len()) == Some(&b'/') + }); + assert!( + has_writable_parent, + "mount {:?} has create_target = true but is not a child \ + of any tmpfs mount; the directory cannot be created at runtime", + entry.target, + ); + } + } + + #[test] + fn hugetlbfs_mounts_cover_both_page_sizes() { + let hugetlb: Vec<&str> = ESSENTIAL_MOUNTS + .iter() + .filter(|e| e.fstype == "hugetlbfs") + .map(|e| e.target) + .collect(); + assert!( + hugetlb.contains(&"/run/huge/2MiB"), + "missing 2 MiB hugetlbfs mount; got: {hugetlb:?}", + ); + assert!( + hugetlb.contains(&"/run/huge/1GiB"), + "missing 1 GiB hugetlbfs mount; got: {hugetlb:?}", + ); + } + + #[test] + fn hugetlbfs_pagesize_matches_mount_point() { + for entry in ESSENTIAL_MOUNTS.iter().filter(|e| e.fstype == "hugetlbfs") { + let data = entry + .data + .unwrap_or_else(|| panic!("hugetlbfs mount {:?} has no data", entry.target)); + if entry.target.contains("2MiB") { + assert!( + data.contains("pagesize=2M"), + "2MiB hugetlbfs mount should have pagesize=2M, got: {data}", + ); + } else if entry.target.contains("1GiB") { + assert!( + data.contains("pagesize=1G"), + "1GiB hugetlbfs mount should have pagesize=1G, got: {data}", + ); + } + } + } +} diff --git a/n-it/src/signal.rs b/n-it/src/signal.rs new file mode 100644 index 0000000000..8592cc6523 --- /dev/null +++ b/n-it/src/signal.rs @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Table-driven signal dispatch for the init system. +//! +//! Instead of hand-writing a `tokio::select!` arm for every Unix signal, +//! this module defines a declarative [`SIGNAL_TABLE`] that encodes: +//! +//! - which signals to intercept, +//! - what [`nix::sys::signal::Signal`] to forward to the child process, and +//! - whether receiving the signal constitutes a test failure +//! ([`SignalPolicy`]). +//! +//! [`SignalSet`] registers handlers for every entry in the table and +//! multiplexes them into a single async [`recv`](SignalSet::recv) stream, +//! reducing the event loop in [`crate::init`] to two `tokio::select!` +//! branches (child exit and signal receipt). +//! +//! # Adding a new forwarded signal +//! +//! Append a [`SignalSpec`] to [`SIGNAL_TABLE`]. Both the handler +//! registration and the dispatch logic are derived from the same table, +//! so there is exactly one place to update. + +use nix::sys::signal::Signal; +use tokio::signal::unix::{SignalKind, signal}; +use tokio::sync::mpsc; + +/// Whether receiving a signal should mark the test as failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignalPolicy { + /// Forward to the child process; the test continues normally. + /// + /// Used for signals that do not indicate a problem (e.g. `SIGHUP`, + /// `SIGUSR1`, `SIGWINCH`). + Benign, + + /// Forward to the child process **and** mark the test as failed. + /// + /// Used for signals that indicate an abnormal condition (e.g. + /// `SIGTERM`, `SIGINT`, `SIGPIPE`). + Failure, +} + +/// A signal the init system should intercept and forward to the test process. +/// +/// Each entry pairs a [`tokio::signal::unix::SignalKind`] (used to +/// register the async handler) with a [`nix::sys::signal::Signal`] (used +/// to forward via `kill(2)`), along with the forwarding [`SignalPolicy`] +/// and a human-readable label for log messages. +#[derive(Debug, Clone, Copy)] +pub struct SignalSpec { + /// The tokio signal kind used to register the async handler. + pub kind: SignalKind, + /// The nix signal value forwarded to the child process via `kill(2)`. + pub signal: Signal, + /// Whether receipt of this signal marks the test as failed. + pub policy: SignalPolicy, + /// Human-readable name for log messages (e.g. `"SIGTERM"`). + pub label: &'static str, +} + +/// The complete signal forwarding table for the init system. +/// +/// **Failure signals** are forwarded to the child *and* cause the test to +/// be marked as failed. **Benign signals** are forwarded without +/// affecting the test outcome. +/// +/// `SIGCHLD` is intentionally omitted -- leaked child processes are +/// detected after the main test process exits, during the shutdown +/// sequence. +/// +/// # Adding a new signal +/// +/// Append a [`SignalSpec`] entry below. The [`SignalSet`] automatically +/// picks it up; no other code changes are required. +pub const SIGNAL_TABLE: &[SignalSpec] = &[ + // Failure signals + SignalSpec { + kind: SignalKind::terminate(), + signal: Signal::SIGTERM, + policy: SignalPolicy::Failure, + label: "SIGTERM", + }, + SignalSpec { + kind: SignalKind::interrupt(), + signal: Signal::SIGINT, + policy: SignalPolicy::Failure, + label: "SIGINT", + }, + SignalSpec { + kind: SignalKind::alarm(), + signal: Signal::SIGALRM, + policy: SignalPolicy::Failure, + label: "SIGALRM", + }, + SignalSpec { + kind: SignalKind::pipe(), + signal: Signal::SIGPIPE, + policy: SignalPolicy::Failure, + label: "SIGPIPE", + }, + SignalSpec { + kind: SignalKind::quit(), + signal: Signal::SIGQUIT, + policy: SignalPolicy::Failure, + label: "SIGQUIT", + }, + // Benign signals + SignalSpec { + kind: SignalKind::hangup(), + signal: Signal::SIGHUP, + policy: SignalPolicy::Benign, + label: "SIGHUP", + }, + SignalSpec { + kind: SignalKind::user_defined1(), + signal: Signal::SIGUSR1, + policy: SignalPolicy::Benign, + label: "SIGUSR1", + }, + SignalSpec { + kind: SignalKind::user_defined2(), + signal: Signal::SIGUSR2, + policy: SignalPolicy::Benign, + label: "SIGUSR2", + }, + SignalSpec { + kind: SignalKind::window_change(), + signal: Signal::SIGWINCH, + policy: SignalPolicy::Benign, + label: "SIGWINCH", + }, +]; + +/// A multiplexed receiver for all signals in a [`SignalSpec`] table. +/// +/// Each registered signal gets a dedicated [`tokio::spawn`] task that +/// loops on [`tokio::signal::unix::Signal::recv`] and forwards the +/// corresponding [`SignalSpec`] through an unbounded MPSC channel. +/// The orchestrator calls [`recv`](Self::recv) to await the next signal +/// from *any* handler. +/// +/// # Examples +/// +/// ```ignore +/// let mut signals = SignalSet::register(SIGNAL_TABLE); +/// loop { +/// tokio::select! { +/// result = child.wait() => { /* handle exit */ break; } +/// spec = signals.recv() => { +/// forward_signal(pid, spec.signal); +/// if spec.policy == SignalPolicy::Failure { success = false; } +/// } +/// } +/// } +/// ``` +pub struct SignalSet { + rx: mpsc::UnboundedReceiver, +} + +impl SignalSet { + /// Registers async signal handlers for every entry in `table` and + /// returns a [`SignalSet`] that multiplexes them. + /// + /// Each handler is a [`tokio::spawn`] task that loops on + /// `Signal::recv()` and sends the spec through an internal channel. + /// This must be called from within a tokio runtime context (i.e. + /// inside [`tokio::runtime::Runtime::block_on`]). + /// + /// Calls [`fatal!`] if any signal handler fails to register, since + /// an init system that cannot intercept signals is in an + /// unrecoverable state. + pub fn register(table: &'static [SignalSpec]) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + + for spec in table { + let tx = tx.clone(); + let spec = *spec; + let label = spec.label; + let mut handler = signal(spec.kind) + .unwrap_or_else(|e| fatal!("failed to register {label} handler: {e}")); + + tokio::spawn(async move { + loop { + handler.recv().await; + // If the receiver has been dropped the orchestrator is + // shutting down -- stop forwarding. + if tx.send(spec).is_err() { + break; + } + } + }); + } + + // Drop the original sender so the channel closes when all tasks + // complete (rather than being held open by this copy). + drop(tx); + + Self { rx } + } + + /// Waits for the next signal from any registered handler. + /// + /// Returns the [`SignalSpec`] describing which signal was received. + /// Calls [`fatal!`] if the internal channel closes unexpectedly + /// (all handler tasks exited, which should not happen during normal + /// operation). + pub async fn recv(&mut self) -> SignalSpec { + self.rx + .recv() + .await + .unwrap_or_else(|| fatal!("signal dispatch channel closed unexpectedly")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn signal_table_is_non_empty() { + assert!( + !SIGNAL_TABLE.is_empty(), + "SIGNAL_TABLE should contain at least one entry", + ); + } + + #[test] + fn signal_table_has_no_duplicate_nix_signals() { + let mut seen = HashSet::new(); + for spec in SIGNAL_TABLE { + assert!( + seen.insert(spec.signal), + "duplicate nix::Signal in SIGNAL_TABLE: {:?} (label {:?})", + spec.signal, + spec.label, + ); + } + } + + #[test] + fn signal_table_has_no_duplicate_labels() { + let mut seen = HashSet::new(); + for spec in SIGNAL_TABLE { + assert!( + seen.insert(spec.label), + "duplicate label in SIGNAL_TABLE: {:?}", + spec.label, + ); + } + } + + #[test] + fn signal_table_labels_are_non_empty() { + for spec in SIGNAL_TABLE { + assert!( + !spec.label.is_empty(), + "signal table entry for {:?} has an empty label", + spec.signal, + ); + } + } + + #[test] + fn signal_table_labels_match_signal_names() { + // nix::Signal's Debug output is the signal name (e.g. "SIGTERM"). + // We verify that each label matches its signal's name so they + // don't drift out of sync. + for spec in SIGNAL_TABLE { + let signal_name = format!("{:?}", spec.signal); + assert_eq!( + spec.label, signal_name, + "label {:?} does not match signal name {:?}", + spec.label, signal_name, + ); + } + } + + #[test] + fn signal_table_contains_sigterm_as_failure() { + let sigterm = SIGNAL_TABLE + .iter() + .find(|s| s.signal == Signal::SIGTERM) + .expect("SIGNAL_TABLE should contain SIGTERM"); + assert_eq!( + sigterm.policy, + SignalPolicy::Failure, + "SIGTERM should be a failure signal", + ); + } + + #[test] + fn signal_table_contains_sigint_as_failure() { + let sigint = SIGNAL_TABLE + .iter() + .find(|s| s.signal == Signal::SIGINT) + .expect("SIGNAL_TABLE should contain SIGINT"); + assert_eq!( + sigint.policy, + SignalPolicy::Failure, + "SIGINT should be a failure signal", + ); + } + + #[test] + fn signal_table_has_at_least_one_benign_signal() { + let benign_count = SIGNAL_TABLE + .iter() + .filter(|s| s.policy == SignalPolicy::Benign) + .count(); + assert!( + benign_count > 0, + "SIGNAL_TABLE should contain at least one benign signal", + ); + } + + #[test] + fn signal_table_has_at_least_one_failure_signal() { + let failure_count = SIGNAL_TABLE + .iter() + .filter(|s| s.policy == SignalPolicy::Failure) + .count(); + assert!( + failure_count > 0, + "SIGNAL_TABLE should contain at least one failure signal", + ); + } + + /// SIGCHLD is intentionally excluded from the table -- child reaping + /// is handled after the main test process exits, not via the signal + /// dispatch loop. This test ensures it is never accidentally added. + #[test] + fn signal_table_does_not_contain_sigchld() { + let has_sigchld = SIGNAL_TABLE.iter().any(|s| s.signal == Signal::SIGCHLD); + assert!( + !has_sigchld, + "SIGNAL_TABLE should not contain SIGCHLD; \ + child reaping is handled separately during shutdown", + ); + } +} diff --git a/n-it/src/utils.rs b/n-it/src/utils.rs new file mode 100644 index 0000000000..a2dc46d084 --- /dev/null +++ b/n-it/src/utils.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The `fatal!` macro must be used instead of `panic!` throughout this crate. +// `panic!` does not reliably flush stdout/stderr in an init system context, +// and tokio's panic-forwarding can mangle the final error output. +macro_rules! fatal { + ($($arg:tt)*) => { + { + use ::std::io::Write as _; + // quick best effort flush of stdout and stderr before logging fatal error + let _ = ::std::io::stdout().flush(); + let _ = ::std::io::stderr().flush(); + // now we lock stdout and stderr to prevent the console from getting mangled when we abort + let mut stdout_lock = ::std::io::stdout().lock(); + let mut stderr_lock = ::std::io::stderr().lock(); + let _ = stdout_lock.flush(); + let _ = stderr_lock.flush(); + ::tracing::error!($($arg)*); + ::tracing::error!("NOTE: test or test fixture failed! Expect a general protection fault and a kernel panic."); + ::tracing::error!("see other logs for cause of failure. The general protection fault is expected."); + let _ = stdout_lock.flush(); + let _ = stderr_lock.flush(); + let _ = ::nix::unistd::close(0); + let _ = ::nix::unistd::close(1); + let _ = ::nix::unistd::close(2); + // Abort rather than panic to prevent tokio's panic-forwarding + // from writing to fds 0/1/2 after they have been closed (or + // worse, to unrelated file descriptors that reused those numbers). + ::std::process::abort(); + } + }; +} diff --git a/n-it/src/vsock_writer.rs b/n-it/src/vsock_writer.rs new file mode 100644 index 0000000000..cf9404a7db --- /dev/null +++ b/n-it/src/vsock_writer.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +use std::io::Write; + +use parking_lot::{Mutex, MutexGuard}; +use tracing_subscriber::fmt::MakeWriter; + +/// A [`MakeWriter`] implementation that writes tracing output to a vsock stream. +/// +/// This is used by the init system to stream structured tracing data back to +/// the host (container tier) over a vsock connection, where it is collected as +/// part of `VmTestOutput::init_trace`. +/// +/// The inner stream is protected by a [`Mutex`] so that the type is +/// naturally `Send + Sync` without requiring `unsafe`. +/// +/// This uses `parking_lot` rather than `std::sync` (which the workspace +/// clippy config disallows) directly rather than via `concurrency::sync`: +/// `n-it` is the in-guest init binary and deliberately carries no +/// dataplane-internal dependencies, and it does not participate in the +/// loom/shuttle model checking that the `concurrency` facade exists to +/// route. `concurrency::sync` re-exports `parking_lot` by default anyway. +pub struct VsockWriter(Mutex); + +impl VsockWriter { + pub fn new(stream: vsock::VsockStream) -> Self { + Self(Mutex::new(stream)) + } +} + +/// RAII guard returned by [`VsockWriter::make_writer`] that implements +/// [`std::io::Write`] by delegating to the locked vsock stream. +pub struct VsockWriterGuard<'a>(MutexGuard<'a, vsock::VsockStream>); + +impl Write for VsockWriterGuard<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.0.flush() + } +} + +impl<'a> MakeWriter<'a> for VsockWriter { + type Writer = VsockWriterGuard<'a>; + + fn make_writer(&'a self) -> Self::Writer { + VsockWriterGuard(self.0.lock()) + } +} diff --git a/n-preinit/Cargo.toml b/n-preinit/Cargo.toml new file mode 100644 index 0000000000..e17b217e1e --- /dev/null +++ b/n-preinit/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dataplane-n-preinit" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true + +[dependencies] + +# internal +n-vm-protocol = { workspace = true } + +# external +# +# Deliberately the only external dependency, and only for syscalls. This +# binary runs as PID 1 before /nix/store exists in the guest, so it has to +# be statically linked -- every crate added here is another thing that has +# to work in that environment, and another reason the link could fail. +nix = { workspace = true, default-features = false, features = [ + "fs", + "kmod", + "mount", + "process", +] } diff --git a/n-preinit/src/main.rs b/n-preinit/src/main.rs new file mode 100644 index 0000000000..ea76ac360d --- /dev/null +++ b/n-preinit/src/main.rs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![warn(missing_docs)] + +//! PID 1 inside the initramfs, for guest kernels whose root filesystem +//! transport is a module. +//! +//! # Why this exists at all +//! +//! A kernel with `CONFIG_VIRTIO_FS=y` mounts the workspace itself and boots +//! straight into [`n-it`]; no initramfs is involved and this binary never +//! runs. A kernel with virtiofs as a *module* cannot, and the reason is +//! circular: mounting the workspace needs virtiofs, virtiofs is a module, +//! and the module tree lives on the workspace. +//! +//! The initramfs is the only way out, because the kernel unpacks it itself, +//! from memory, before any driver loads. This is what runs from it. +//! +//! # Why it is separate from `n-it` +//! +//! `n-it` is dynamically linked against `/nix/store`, which is itself only +//! reachable *after* virtiofs is mounted. It therefore cannot be the thing +//! that mounts virtiofs. Splitting the job leaves this binary with a +//! dependency surface of syscalls alone, so linking it statically is +//! uncontroversial, and leaves `n-it` unconstrained -- which matters, +//! because that is where the interesting logic lives. +//! +//! # What it does *not* do +//! +//! No dependency resolution, no `modules.dep` parsing, no uevent handling. +//! The nix build already resolved the closure and its order with the real +//! `modprobe` against the real module tree, and wrote the answer to +//! [`MODULES_LOAD`]. Rediscovering it here would be reimplementing udev to +//! answer a question we know the answer to: this VM's device set is fixed, +//! because we built it. +//! +//! [`n-it`]: https://github.com/githedgehog/dataplane + +use std::ffi::CString; +use std::fs::{File, OpenOptions}; +use std::os::fd::AsRawFd; +use std::os::unix::ffi::OsStringExt; +use std::path::Path; + +use nix::kmod::{ModuleInitFlags, finit_module}; +use nix::mount::{MsFlags, mount}; +use nix::unistd::chroot; + +use n_vm_protocol::{INIT_BINARY_PATH, VIRTIOFS_ROOT_TAG}; + +/// Ordered list of modules to load, one absolute path per line. +/// +/// Written by the `mk-initramfs` derivation from `modprobe --show-depends`, +/// so dependencies already precede their dependents. +const MODULES_LOAD: &str = "/modules.load"; + +/// Where the real root is mounted before pivoting onto it. +const NEW_ROOT: &str = "/newroot"; + +fn main() { + // Errors are reported by hand rather than by returning `Result` from + // `main`, because the default formatting is `Debug` and this output is + // read on a serial console, often by someone who does not yet know + // which of the three tiers failed. The prefix makes it greppable. + if let Err(err) = run() { + eprintln!("n-preinit: FATAL: {err}"); + eprintln!("n-preinit: the kernel will now panic, because PID 1 exited"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + // Before anything that can fail interestingly, so that what follows is + // visible. Best-effort by design -- see `open_console`. + open_console(); + + load_modules(Path::new(MODULES_LOAD))?; + mount_new_root()?; + switch_root()?; + exec_init() +} + +/// Mounts `devtmpfs` and reopens stdio on the console. +/// +/// The kernel does *not* auto-mount devtmpfs on this path: `devtmpfs_mount` +/// is called from `prepare_namespace`, which is skipped when a cpio supplies +/// the root. So `/dev` starts empty, `/dev/console` does not exist, and the +/// kernel's own attempt to open an initial console has already failed with +/// "unable to open an initial console" -- leaving this process with no +/// usable stdio. +/// +/// Best-effort: if it fails there is nowhere to report that it failed, and +/// giving up here would trade a silent boot for a silent boot that also does +/// not work. The subsequent steps still run, and their failures still panic +/// the kernel, which is at least a signal. +fn open_console() { + if std::fs::create_dir_all("/dev").is_err() { + return; + } + if mount( + Some("devtmpfs"), + "/dev", + Some("devtmpfs"), + MsFlags::empty(), + None::<&str>, + ) + .is_err() + { + return; + } + + let Ok(console) = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/console") + else { + return; + }; + let fd = console.as_raw_fd(); + // SAFETY: `fd` is open for the duration of this block, and 0/1/2 are + // valid descriptor numbers. `dup2` onto an open descriptor closes it + // first, which is the intent. + for target in 0..=2 { + unsafe { + libc_dup2(fd, target); + } + } +} + +/// `dup2(2)` without taking a `libc` dependency for one call. +/// +/// # Safety +/// +/// `oldfd` must be an open descriptor. +unsafe fn libc_dup2(oldfd: i32, newfd: i32) { + unsafe extern "C" { + fn dup2(oldfd: i32, newfd: i32) -> i32; + } + unsafe { + dup2(oldfd, newfd); + } +} + +/// Loads every module named in `list`, in the order given. +/// +/// A missing list is not an error: a kernel that needs no modules to reach +/// its root can still boot through an initramfs, and refusing to would make +/// the two boot paths gratuitously different. +fn load_modules(list: &Path) -> Result<(), String> { + let contents = match std::fs::read_to_string(list) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("cannot read module list {}: {err}", list.display())), + }; + + for path in contents.lines().map(str::trim).filter(|l| !l.is_empty()) { + let module = File::open(path).map_err(|err| format!("cannot open module {path}: {err}"))?; + // `finit_module` rather than `init_module`: the kernel reads the + // image from the descriptor itself, so this never has to hold a + // module in memory. Empty parameter string -- anything needing + // parameters would belong in the list format, not hardcoded here. + finit_module(&module, c"", ModuleInitFlags::empty()) + .map_err(|err| format!("cannot load module {path}: {err}"))?; + } + Ok(()) +} + +/// Mounts the virtiofs root share at [`NEW_ROOT`]. +/// +/// Read-only, matching the direct boot path: the share is served by a +/// `--readonly` virtiofsd, and `root_filesystem_in_vm_is_read_only` asserts +/// the guest sees it that way. +fn mount_new_root() -> Result<(), String> { + std::fs::create_dir_all(NEW_ROOT).map_err(|err| format!("cannot create {NEW_ROOT}: {err}"))?; + + mount( + Some(VIRTIOFS_ROOT_TAG), + NEW_ROOT, + Some("virtiofs"), + MsFlags::MS_RDONLY, + None::<&str>, + ) + .map_err(|err| { + format!( + "cannot mount virtiofs tag `{VIRTIOFS_ROOT_TAG}` at {NEW_ROOT}: {err}; \ + is the virtiofs module in {MODULES_LOAD}?" + ) + }) +} + +/// Makes [`NEW_ROOT`] the root. +/// +/// **Not** `pivot_root`. The kernel's own documentation is explicit that it +/// cannot work from here (`Documentation/filesystems/ramfs-rootfs-initramfs.rst`): +/// +/// > initramfs is rootfs: you can neither pivot_root rootfs, nor unmount it. +/// > Instead ... overmount rootfs with the new root +/// > (`cd /newmount; mount --move . /; chroot .`) +/// +/// An earlier version did use `pivot_root` and failed at exactly this point +/// with `EINVAL`, after everything before it had worked. This is the +/// `switch_root` idiom instead, which is why that is a separate tool from +/// `pivot_root` rather than a wrapper around it. +/// +/// The initramfs contents are deliberately *not* deleted first. The docs +/// suggest it to reclaim the memory, but rootfs holds well under 1% of this +/// VM's RAM, and recursively unlinking the filesystem this process was +/// loaded from is a poor trade for that. +fn switch_root() -> Result<(), String> { + std::env::set_current_dir(NEW_ROOT) + .map_err(|err| format!("cannot chdir to {NEW_ROOT}: {err}"))?; + + // Move the new root's mount over `/`, rather than mounting something + // new there: the same filesystem, relocated, so open descriptors and + // the mount's identity survive. + mount(Some("."), "/", None::<&str>, MsFlags::MS_MOVE, None::<&str>) + .map_err(|err| format!("cannot move {NEW_ROOT} onto /: {err}"))?; + + chroot(".").map_err(|err| format!("cannot chroot into the new root: {err}"))?; + + // stdio stays attached to the console opened before the move. Those + // descriptors are already open, so they survive `chroot` even though + // the devtmpfs they came from is no longer reachable by path. + std::env::set_current_dir("/").map_err(|err| format!("cannot chdir to the new root: {err}")) +} + +/// Replaces this process with the real init. +/// +/// Forwards argv unchanged apart from `argv[0]`. That is load-bearing: the +/// kernel hands everything after `--` on its command line to init as +/// arguments, and `n-it` reads them (`n-it/src/child.rs`) to learn which +/// test binary to run and which test to select. Dropping them would boot a +/// VM that runs nothing. +/// +/// Only returns on failure; on success this process no longer exists. +fn exec_init() -> Result<(), String> { + let init = CString::new(INIT_BINARY_PATH) + .map_err(|err| format!("{INIT_BINARY_PATH} is not a valid C string: {err}"))?; + + let mut argv = Vec::new(); + argv.push(init.clone()); + for arg in std::env::args_os().skip(1) { + let bytes = arg.into_vec(); + argv.push( + CString::new(bytes) + .map_err(|err| format!("argument contains an interior NUL: {err}"))?, + ); + } + + let err = nix::unistd::execv(&init, &argv).unwrap_err(); + Err(format!("cannot exec {INIT_BINARY_PATH}: {err}")) +} diff --git a/n-vm-macros/Cargo.toml b/n-vm-macros/Cargo.toml new file mode 100644 index 0000000000..531a396460 --- /dev/null +++ b/n-vm-macros/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dataplane-n-vm-macros" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true + +[lib] +proc-macro = true + +[dependencies] + +# external +proc-macro2 = { workspace = true, default-features = true } +quote = { workspace = true, default-features = true } +syn = { workspace = true, default-features = true, features = ["full"] } + +[dev-dependencies] + +# internal +n-vm = { workspace = true } + +# external +trybuild = { workspace = true } diff --git a/n-vm-macros/src/lib.rs b/n-vm-macros/src/lib.rs new file mode 100644 index 0000000000..e9c2407a0e --- /dev/null +++ b/n-vm-macros/src/lib.rs @@ -0,0 +1,803 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![warn(missing_docs)] + +//! Attribute macros for running tests inside the `n-vm` nested test +//! environment. +//! +//! `#[n_vm::test]` rewrites a `fn()` or `async fn()` test into a three-tier +//! dispatch: +//! +//! - host: start a Docker container; +//! - container: boot the selected hypervisor backend; +//! - VM guest: run the original test body under `n-it`. +//! +//! It *is* the test attribute -- it injects `#[test]` itself, so there is no +//! companion `#[test]` or `#[tokio::test]` to write (or to get in the wrong +//! order). It takes one argument, `config = PATH`, and everything else about +//! the VM is a field of the `VmConfig` that names. +//! +//! # Configuring the VM +//! +//! The VM's shape comes from a `const VmConfig`, named by path: +//! +//! ```ignore +//! const DPDK_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() +//! .iommu(true) +//! .guest_hugepages(n_vm::GuestHugePageConfig::Allocate { +//! size: n_vm::GuestHugePageSize::Huge2M, +//! count: 256, +//! }) +//! .nic_model(n_vm::NicModel::E1000) +//! // one fabric link per model named, for a test that must tell +//! // devices apart rather than count them +//! .fabric_nic_models(&[n_vm::NicModel::VirtioNet, n_vm::NicModel::E1000E]) +//! .build(); +//! +//! #[n_vm::test(config = DPDK_VM)] +//! fn test_dpdk() {} +//! ``` +//! +//! Or inline, in the body of the test it configures: +//! +//! ```ignore +//! #[n_vm::test] +//! fn test_dpdk() { +//! #[n_vm::config] +//! const _: _ = n_vm::VmConfigBuilder::default() +//! .iommu(true) +//! .kernel_features(&[n_vm::features::VFIO_PCI]) +//! .build(); +//! +//! // the test body follows +//! } +//! ``` +//! +//! The two forms are equivalent and a test may use either, not both. A named +//! `const` is right when several tests share a machine; the inline form keeps +//! a one-off beside the test that wants it. `#[n_vm::config]` is searched for +//! only at the top level of the body, and the `const` is lifted out before +//! anything runs -- so it cannot refer to the test body, which is what the +//! host tier needs: it is evaluated in another process, before the guest +//! exists. +//! +//! This replaces the former `#[hypervisor]`, `#[guest]`, and `#[network]` +//! companion attributes. The reason is not brevity -- it is that a `const` +//! is ordinary Rust in an ordinary position, so completion, hover, and +//! go-to-definition all work on it, and the enums in `n_vm::config` enforce +//! what those attributes had to hand-check. `hugepage_count` alongside +//! `hugepage_size = "none"` needed a dedicated error only because the two +//! were independent strings; `GuestHugePageConfig::None` has no count to set. +//! +//! Being `const` also lets the generated code assert the configuration is +//! coherent at compile time, *including against the requested backend* -- so +//! a NIC only QEMU can emulate is still rejected by the build rather than by +//! a VM that fails to boot. +//! +//! An `async fn` runs on a tokio runtime in the guest, shaped by the config's +//! `runtime` field -- current-thread by default, or +//! `GuestRuntime::MultiThread { worker_threads }`. The worker count sits +//! inside the variant that has one, so a count without a pool is not +//! something that can be written. +//! +//! The hypervisor is the config's `backend`. "The same VM on both backends" +//! is therefore two configurations, which is what it is; `VmConfig::to_builder` +//! keeps the second to one line. +//! +//! # Attribute routing +//! +//! Attributes below this one decorate the *test body* and are emitted onto an +//! inner function that only the guest tier calls. That matters for anything +//! with side effects: `#[wrap(with_caps(...))]` needs privileges the guest +//! has and the host does not, so running it on the host tier would fail +//! before a VM ever booted. The exceptions are the harness-level attributes +//! (`#[cfg]`, `#[ignore]`, doc comments), which stay on the generated +//! dispatch function where libtest and rustdoc can see them. + +extern crate proc_macro; + +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{ReturnType, parse_macro_input}; + +/// Options that used to be accepted here, and where they went. +/// +/// Kept as errors rather than dropped: a stale option would otherwise read +/// as an unknown one, with no hint about the const that replaced it. +const MIGRATED_OPTIONS: &[(&str, &str)] = &[ + ("iommu", "the `iommu` field of a `const VmConfig`"), + ("qemu", "`.backend(RequestedBackend::Qemu)` on the config"), + ( + "cloud_hypervisor", + "`.backend(RequestedBackend::CloudHypervisor)` on the config", + ), + ( + "current_thread", + "`.runtime(GuestRuntime::CurrentThread)` on the config", + ), + ( + "multi_thread", + "`.runtime(GuestRuntime::MultiThread { worker_threads: None })` on the config", + ), + ( + "worker_threads", + "the `worker_threads` field of `GuestRuntime::MultiThread`", + ), +]; + +/// Companion attributes this macro used to consume, now replaced by the +/// `config = PATH` argument. +/// +/// Detected explicitly because nothing consumes them any more: left alone, +/// `#[guest(...)]` would be an inert attribute and the VM would quietly boot +/// with the default configuration instead of the one the test appears to ask +/// for. +const RETIRED_ATTRS: &[&str] = &["hypervisor", "guest", "network"]; + +#[must_use] +fn migration_hint(ident: &str) -> Option<&'static str> { + MIGRATED_OPTIONS + .iter() + .find(|(name, _)| *name == ident) + .map(|(_, hint)| *hint) +} + +/// The one thing `#[n_vm::test(...)]` still accepts in its own argument list. +/// +/// A path rather than an arbitrary expression on purpose: the value is then +/// written as a normal item, where an editor can help with it, and the +/// attribute holds nothing an editor has to parse. +/// +/// Everything else that used to be spelled here -- the hypervisor backend, +/// the guest tokio runtime -- is a field of the `VmConfig` it configures. +/// The attribute described the machine in one vocabulary while the `const` +/// described it in another, and a reader had to hold both. +fn parse_config_arg(attr: TokenStream) -> syn::Result> { + if attr.is_empty() { + return Ok(None); + } + + use syn::parse::Parser; + let parser = syn::punctuated::Punctuated::::parse_terminated; + let metas = parser.parse(attr).map_err(|_| { + syn::Error::new( + proc_macro2::Span::call_site(), + "#[n_vm::test] takes an optional `config = PATH` naming a `const VmConfig`, \ + and nothing else", + ) + })?; + + let mut config: Option = None; + for meta in metas { + let path = meta.path(); + let name = path + .get_ident() + .map_or_else(String::new, ToString::to_string); + + if let Some(hint) = migration_hint(&name) { + return Err(syn::Error::new_spanned( + path, + format!("`{name}` has moved out of #[n_vm::test(...)] -- use {hint} instead"), + )); + } + + let syn::Meta::NameValue(nv) = &meta else { + return Err(syn::Error::new_spanned(path, unknown_option_msg(&name))); + }; + if !nv.path.is_ident("config") { + return Err(syn::Error::new_spanned(&nv.path, unknown_option_msg(&name))); + } + if config.is_some() { + return Err(syn::Error::new_spanned( + &nv.path, + "duplicate `config` in #[n_vm::test]", + )); + } + // Rejecting an inline value here is what steers callers towards + // writing the config as an item -- or, for a one-off, towards the + // `#[n_vm::config]` form in the body. + let syn::Expr::Path(syn::ExprPath { path, .. }) = &nv.value else { + return Err(syn::Error::new_spanned( + &nv.value, + "`config` takes the path of a `const VmConfig`, not an \ + inline value; declare it as an item and name it here, \ + e.g.\n\n\ + const FAST_VM: n_vm::VmConfig = \ + n_vm::VmConfigBuilder::default().iommu(true).build();\n\n\ + #[n_vm::test(config = FAST_VM)]\n\n\ + or write it inline with #[n_vm::config] in the test body.", + )); + }; + config = Some(path.clone()); + } + + Ok(config) +} + +fn unknown_option_msg(name: &str) -> String { + format!( + "unknown #[n_vm::test] option `{name}`; the only argument is \ + `config = PATH`, naming a `const VmConfig`. Everything about the VM \ + -- backend, runtime, NIC, pages, kernel features -- is a field of \ + that config, set with `n_vm::VmConfigBuilder`." + ) +} + +fn is_tokio_test_attr(attr: &syn::Attribute) -> bool { + let path = attr.path(); + let segs: Vec<_> = path.segments.iter().collect(); + segs.len() == 2 && segs[0].ident == "tokio" && segs[1].ident == "test" +} + +/// Attributes that libtest (or rustdoc) must see on the generated dispatch +/// function rather than on the inner guest body. +/// +/// `cfg` gates whether the test exists at all, `ignore` is read by the test +/// harness, and doc comments belong on the item a reader navigates to. +/// Everything else is treated as decorating the *body* -- see the routing +/// comment in [`test`] for why that default matters. +/// +/// `cfg_attr` is deliberately body-level: it most often expands to a body +/// wrapper (`#[cfg_attr(not(emulated), traced_test)]`), and we cannot know +/// what it expands to from here. A conditional `ignore` therefore has to be +/// written as a plain `#[ignore]`. +const HARNESS_ATTRS: &[&str] = &["cfg", "ignore", "doc"]; + +fn is_harness_attr(attr: &syn::Attribute) -> bool { + HARNESS_ATTRS.iter().any(|name| attr.path().is_ident(name)) +} + +const KNOWN_ATTR_PREFIXES: &[&str] = &["n_vm", "n_vm_macros"]; + +fn attr_has_name(attr: &syn::Attribute, name: &str) -> bool { + let path = attr.path(); + if path.is_ident(name) { + return true; + } + let segments: Vec<_> = path.segments.iter().collect(); + segments.len() == 2 + && KNOWN_ATTR_PREFIXES + .iter() + .any(|prefix| segments[0].ident == prefix) + && segments[1].ident == name +} + +fn extract_unique_attr( + attrs: &mut Vec, + name: &str, +) -> syn::Result> { + let idx = match attrs.iter().position(|a| attr_has_name(a, name)) { + Some(i) => i, + None => return Ok(None), + }; + let attr = attrs.remove(idx); + + if let Some(dup) = attrs.iter().find(|a| attr_has_name(a, name)) { + return Err(syn::Error::new_spanned( + dup, + format!("duplicate #[{name}] attribute"), + )); + } + + Ok(Some(attr)) +} + +/// Finds a `#[n_vm::config]` const item in a test body, removes it, and +/// returns its initializer. +/// +/// A `const` item rather than a `let` for three reasons, all load-bearing. +/// It is an *item*, so a custom attribute on it is ordinary stable Rust -- +/// attributes on statements are not. It cannot capture anything from the +/// body, which is exactly the constraint the host tier needs: the +/// configuration is evaluated in another process, before the guest exists. +/// The initializer keeps the spans it was written with, so naming a local is +/// reported as E0425 *at that name in the test body* rather than somewhere +/// inside generated code. And it keeps `VmConfig::assert_valid` running at +/// compile time. +/// +/// Only the top level of the body is searched. Items nest -- inside a +/// helper `fn`, a closure, an inner block -- and a marker found at depth +/// would either be lifted out of a scope it appears to belong to or ignored +/// altogether; both are worse than not finding it. +/// +/// The type must be the placeholder `_`, and is discarded along with the +/// item -- only the initializer is used. That spelling is legal only because +/// the item is deleted before rustc sees it; in a real const item it is +/// E0121, which is exactly the signal a reader should get. +fn extract_inline_config(block: &mut syn::Block) -> syn::Result> { + let mut found: Option = None; + for (idx, stmt) in block.stmts.iter().enumerate() { + let syn::Stmt::Item(syn::Item::Const(item)) = stmt else { + continue; + }; + if !item.attrs.iter().any(|attr| attr_has_name(attr, "config")) { + continue; + } + if found.is_some() { + return Err(syn::Error::new_spanned( + item, + "duplicate #[n_vm::config] in this test body; a test describes one VM", + )); + } + // The type is required to be `_`, because it is discarded. Written + // out it would look checked and would not be: the item is re-declared + // as `::n_vm::VmConfig` and only the initializer survives, so + // `const _: u32 = ...` would compile and mean nothing. `_` also makes + // the construct honest about where it works -- a placeholder is E0121 + // in a real const item, so a marker copied out of a test body says so + // immediately rather than silently configuring nothing. + if !matches!(*item.ty, syn::Type::Infer(_)) { + return Err(syn::Error::new_spanned( + &item.ty, + "#[n_vm::config] is written `const _: _ = ...`; the type is not \ + yours to state. The item is lifted out of the body and \ + re-declared as `n_vm::VmConfig`, so a type written here would be \ + discarded rather than checked.", + )); + } + found = Some(idx); + } + + let Some(idx) = found else { + return Ok(None); + }; + let syn::Stmt::Item(syn::Item::Const(item)) = block.stmts.remove(idx) else { + unreachable!("the index came from a matched const item"); + }; + Ok(Some(*item.expr)) +} + +/// Declares a test that runs inside an ephemeral VM. +/// +/// This *is* the test attribute -- it injects `#[test]` itself, so do not +/// add one. A `fn` runs its body directly in the guest; an `async fn` +/// runs on a tokio runtime whose shape comes from the config's `runtime` +/// field (current-thread by default). +/// +/// ```ignore +/// #[n_vm::test] // cloud-hypervisor, sync +/// fn plain() {} +/// +/// const FANCY_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() +/// .iommu(true) +/// .backend(n_vm::RequestedBackend::Qemu) +/// .runtime(n_vm::GuestRuntime::MultiThread { worker_threads: Some(4) }) +/// .build(); +/// +/// #[n_vm::test(config = FANCY_VM)] +/// async fn fancy() {} +/// ``` +/// +/// The decorated function must take no parameters and return `()`. The VM +/// is configured either by `config = PATH`, naming a `const VmConfig`, or by +/// a `#[n_vm::config]` `const` in the body; with neither it uses +/// `VmConfig::DEFAULT`. A writable corpus directory is granted by +/// `.corpus(CorpusPolicy::Fuzz)` on that configuration. +#[proc_macro_attribute] +pub fn test(attr: TokenStream, input: TokenStream) -> TokenStream { + let config = match parse_config_arg(attr) { + Ok(config) => config, + Err(err) => return err.to_compile_error().into(), + }; + + let mut func = parse_macro_input!(input as syn::ItemFn); + + // This macro owns the test attribute, so a user-written `#[test]` or + // `#[tokio::test]` is always a mistake -- and a silent one if we just + // dropped it, because `#[tokio::test]` would have carried runtime + // options we no longer read. Reject both with the migration. + if let Some(bad) = func + .attrs + .iter() + .find(|a| a.path().is_ident("test") || is_tokio_test_attr(a)) + { + let is_tokio = is_tokio_test_attr(bad); + let hint = if is_tokio { + "#[n_vm::test] already provides the test harness and the guest \ + tokio runtime: remove #[tokio::test] and move its options into \ + #[n_vm::test(...)] (e.g. `#[n_vm::test(multi_thread, \ + worker_threads = 4)]`)" + } else { + "#[n_vm::test] already provides the test harness: remove the \ + #[test] attribute" + }; + return syn::Error::new_spanned(bad, hint).to_compile_error().into(); + } + + // `#[should_panic]` cannot compose with `#[n_vm::test]`: the test body runs + // in a separate VM-guest process, and the generated function is run by + // libtest at all three dispatch tiers (host, container, guest). A + // panic is absorbed at whichever tier produces it, so `should_panic` + // semantics are incoherent across tiers (and depend on whether the + // guest panic unwinds cleanly). Reject it with a clear message rather + // than miscompile. + if let Some(attr) = func + .attrs + .iter() + .find(|a| a.path().is_ident("should_panic")) + { + return syn::Error::new_spanned( + attr, + "#[should_panic] is not supported with #[n_vm::test]: the test body runs \ + in a separate VM-guest process across three dispatch tiers, so panic \ + semantics do not compose. Assert the failure condition inside the \ + test body instead (e.g. `assert!(result.is_err())`).", + ) + .to_compile_error() + .into(); + } + + let is_async = func.sig.asyncness.is_some(); + + if !func.sig.inputs.is_empty() { + return syn::Error::new_spanned( + &func.sig.inputs, + "#[n_vm::test] functions must take no parameters; \ + the function is re-invoked by name as `fn()` inside the VM guest", + ) + .to_compile_error() + .into(); + } + + if !matches!(func.sig.output, ReturnType::Default) { + return syn::Error::new_spanned( + &func.sig.output, + "#[n_vm::test] functions must return `()`; \ + the generated dispatch branches use bare `return;` statements", + ) + .to_compile_error() + .into(); + } + + // A leftover companion attribute is now inert rather than wrong-looking, + // so it has to be caught explicitly: left alone the VM would quietly boot + // with the default configuration instead of the one the test appears to + // ask for. + if let Some(stale) = func + .attrs + .iter() + .find(|a| RETIRED_ATTRS.iter().any(|name| attr_has_name(a, name))) + { + let name = stale + .path() + .segments + .last() + .map_or_else(String::new, |s| s.ident.to_string()); + return syn::Error::new_spanned( + stale, + format!( + "#[{name}] has been replaced by a `const VmConfig`; declare one \ + and name it with `config = ...`, e.g.\n\n\ + const MY_VM: n_vm::VmConfig = \ + n_vm::VmConfig {{ iommu: true, ..n_vm::VmConfig::DEFAULT }};\n\n\ + #[n_vm::test(config = MY_VM)]", + ), + ) + .to_compile_error() + .into(); + } + + // `#[corpus]` is now `.corpus(CorpusPolicy::Fuzz)` on the config. It is + // rejected here rather than silently ignored, because a fuzz target that + // quietly lost its writable share does not fail: it runs, generates + // inputs, and saves none of them. + if let Ok(Some(attr)) = extract_unique_attr(&mut func.attrs, "corpus") { + return syn::Error::new_spanned( + attr, + "#[corpus] has been replaced by `.corpus(CorpusPolicy::Fuzz)` on \ + the config, e.g.\n\n\ + #[n_vm::config]\n\ + const _: _ = n_vm::VmConfigBuilder::default()\n\ + \u{20} .corpus(n_vm::CorpusPolicy::Fuzz)\n\ + \u{20} .build();", + ) + .to_compile_error() + .into(); + } + + // `CARGO_MANIFEST_DIR` rides along because `file!()` is not reliably + // workspace-relative here: this workspace builds with + // `--remap-path-prefix==${src}`, which rewrites it to an absolute nix + // store path. The crate directory is the anchor that recovers the + // workspace-relative tail (see `VmConfig::corpus_rel_dir`). Both are + // expanded at the call site rather than here, because a proc macro sees + // only tokens -- rustc is what knows which file it is compiling. + // + // Injected unconditionally now that the fuzz decision lives in the + // config: this macro cannot read a `const`, so it can no longer tell + // whether the file will be wanted. It costs two `&'static str`s in a + // struct that is already `const`. + let source_file = quote! { + ::core::option::Option::Some(( + ::core::file!(), + ::core::env!("CARGO_MANIFEST_DIR"), + )) + }; + + // The inline configuration, if the body declares one. Removed from the + // block here, so no tier sees it as part of the test. + let inline_config = match extract_inline_config(&mut func.block) { + Ok(found) => found, + Err(err) => return err.to_compile_error().into(), + }; + if let (Some(path), Some(expr)) = (&config, &inline_config) { + return syn::Error::new_spanned( + expr, + format!( + "this test is configured twice: `config = {path}` names one \ + `const VmConfig` and #[n_vm::config] declares another. Keep one.", + path = quote! { #path }, + ), + ) + .to_compile_error() + .into(); + } + + // Split the remaining attributes by which tier they belong to. + // + // Anything left on the generated dispatch function runs at *every* + // tier -- host, container, and guest. That is wrong for the common + // case: `#[wrap(with_caps([CAP_NET_ADMIN]))]` exists precisely because + // the body needs privileges it can only have inside the guest, and + // running it on the unprivileged host tier fails with EPERM before a VM + // is ever started. So only harness-level attributes (the ones libtest + // itself must see) stay outside; everything else moves onto an inner + // function that only the guest branch calls. + let (harness_attrs, body_attrs): (Vec<_>, Vec<_>) = + func.attrs.iter().cloned().partition(is_harness_attr); + + let block = &func.block; + let vis = &func.vis; + let ident = &func.sig.ident; + + let mut sig = func.sig.clone(); + sig.asyncness = None; + + // Named here because the discovery shim below branches on its value. + let config_ident = format_ident!("__N_VM_CONFIG_{}", ident); + + // Tier 0, and only for a fuzz target. + // + // `cargo bolero list` runs this binary with `CARGO_BOLERO_SELECT=all` and collects a line that + // each `bolero::check!` prints *as it executes*. The body never executes on the host -- that is + // what this attribute is for -- so without this an in-VM fuzz target is invisible to the + // coverage-guided runner and can never be named to `cargo bolero test`. + // + // Answering here rather than by running the body is the point: a body may open netlink sockets, + // bind devices or assume it is root, none of which may happen on a developer's workstation + // merely because something asked what tests exist. + // + // Emitted for every test but *entered* only by a fuzz target. Announcing every tiered test + // would put forty-odd entries containing no `check!` into a list whose whole purpose is naming + // things that can be fuzzed. + // + // The guard is a `const fn` on a `const`, so rustc folds it: an ordinary test compiles to + // nothing at all here. It has to be a runtime-shaped branch rather than a macro-level one + // because the decision now lives in the configuration, and a proc macro sees tokens -- given + // `config = SOME_VM` it cannot know what `SOME_VM` holds. Moving the branch from the macro to + // the value is the whole reason this reads as a branch. + // + // `should_run` is `true` whenever `CARGO_BOLERO_SELECT` is unset, so an ordinary run falls + // straight through. `__item_path__!` must expand at the call site or it names a path inside + // `n-vm`; what it yields here is the *outer* test's path, which is the name libtest accepts as + // a filter when `cargo bolero test` selects it. + let discovery = quote! { + if #config_ident.is_fuzz_target() { + let __n_vm_bolero_location = ::n_vm::bolero::TargetLocation { + package_name: ::core::env!("CARGO_PKG_NAME"), + manifest_dir: ::core::env!("CARGO_MANIFEST_DIR"), + module_path: ::core::module_path!(), + file: ::core::file!(), + line: ::core::line!(), + item_path: ::n_vm::bolero::__item_path__!(), + test_name: ::core::option::Option::None, + }; + if !__n_vm_bolero_location.should_run() { + return; + } + } + }; + + // The requested backend is resolved against the host architecture at + // The base configuration: whatever the test named, or the default. This + // macro never inspects it -- it is a path to a `const` whose value only + // rustc can know -- which is exactly why the checks that used to live + // here are now `const fn` assertions on the value itself. + let base_config = match (&config, &inline_config) { + (Some(path), _) => quote! { #path }, + (None, Some(expr)) => quote! { #expr }, + (None, None) => quote! { ::n_vm::VmConfig::DEFAULT }, + }; + + // The config and its assertion are emitted beside the test function + // rather than inside it, because `#[test]` items are stripped in a + // non-test build -- an assertion in the body would vanish with them, and + // could never be exercised by a compile-fail test. At module scope it + // is checked in every build. + // + // Only `#[cfg]` carries over: a config for a test that does not exist + // would fail to compile if it referenced cfg'd-out items. `#[ignore]` + // and doc comments are meaningless on a const. + let cfg_attrs: Vec<_> = harness_attrs + .iter() + .filter(|a| a.path().is_ident("cfg")) + .collect(); + + // The guest body becomes a nested function so that body-level + // attributes (`#[wrap(...)]`, `#[traced_test]`, ...) apply to it and + // nowhere else. + // + // The wrapper is deliberately a plain `fn` even for an `async` test: the + // runtime is driven *inside* it, so a routed attribute sees a function + // that returns `()`. Routing them onto an `async fn` instead would hand + // every such attribute a future, which is not what any of them expect -- + // `fixin::wrap` expands to `with_caps(..)(__n_vm_guest_body)` in the + // wrapper's own tail position, so an async inner function fails to + // compile with "expected `()`, found future". This also matches what + // these attributes saw before this macro existed, where `#[tokio::test]` + // expanded first and left them a synchronous function. + // + // Only wrap when there is something to route, because the wrapper is + // observable: anything deriving a name from its own call site sees the + // extra frame. `bolero` builds its on-disk corpus directory from + // `type_name` of a probe function declared at the `check!()` site, so an + // unconditional wrapper would bake `__n_vm_guest_body` into that path + // and make it churn whenever this macro's internals are renamed. + let wrap_body = !body_attrs.is_empty(); + // The runtime shape is a value in the config, not a token this macro can + // read, so it is passed through rather than branched on. A const, so the + // match inside `block_on_in_guest_with` folds away. + let drive_async_block = || { + quote! { + ::n_vm::block_on_in_guest_with(#config_ident.runtime, async #block); + } + }; + + let tier3_body = if wrap_body { + let wrapped_block = if is_async { + let drive = drive_async_block(); + quote! { { #drive } } + } else { + quote! { #block } + }; + quote! { + #(#body_attrs)* + fn __n_vm_guest_body() #wrapped_block + __n_vm_guest_body(); + } + } else if is_async { + // No attributes to route, so drive the body directly and leave the + // call site's apparent path unchanged. + drive_async_block() + } else { + quote! { #block } + }; + + quote! { + // Built once; both tiers need it (VmConfig is Copy). Tier 1 uses it + // to resolve capability/ISA skips; tier 2 to configure the VM. + // + // `source_file` is always overridden rather than taken from + // the base config, because it must name *this* test's file: + // `file!()` expands where it is written, so a shared const would name + // the const's own file and put the corpus directory beside the wrong + // source. + #(#cfg_attrs)* + #[allow(non_upper_case_globals)] + const #config_ident: ::n_vm::VmConfig = ::n_vm::VmConfig { + source_file: #source_file, + ..(#base_config) + }; + + // The backend/NIC check that used to run inside this macro runs here + // instead. A macro sees tokens, so it can never evaluate a config + // named by path; rustc can, and a `const fn` assertion keeps the + // failure at build time rather than deferring it to a VM that will + // not boot. + #(#cfg_attrs)* + const _: () = #config_ident.assert_valid(); + + #[test] + #(#harness_attrs)* + #vis #sig { + #discovery + // Tier 3: VM guest + if ::n_vm::is_in_vm() { + { #tier3_body } + return; + } + + // Tier 2: Docker container -> VM. The backend and acceleration + // mode were resolved by tier 1 and passed via the environment. + if ::n_vm::is_in_test_container() { + ::n_vm::run_container_tier(#ident, #config_ident); + return; + } + + // Tier 1: Host -> Docker container. Resolves the requested + // backend + capabilities against the host arch / Docker daemon. + ::n_vm::run_host_tier(#ident, #config_ident); + } + } + .into() +} + +/// Marks the `const` in a test body that describes the VM, consumed by +/// [`test`]. +/// +/// ```ignore +/// #[n_vm::test] +/// fn drives_a_nic() { +/// #[n_vm::config] +/// const _: _ = n_vm::VmConfigBuilder::default() +/// .iommu(true) +/// .build(); +/// +/// // the test body follows +/// } +/// ``` +/// +/// [`test`] removes this before rustc resolves it, so the definition here +/// only ever runs when the marker is used somewhere [`test`] does not reach +/// -- which is the entire reason it exists rather than being left +/// unresolvable. +#[proc_macro_attribute] +pub fn config(_attr: TokenStream, input: TokenStream) -> TokenStream { + let error = syn::Error::new( + proc_macro2::Span::call_site(), + "#[n_vm::config] marks a `const` inside the body of a #[n_vm::test] \ + function, and is consumed by it; e.g.\n\n\ + #[n_vm::test]\n\ + fn my_test() {\n\ + \x20 #[n_vm::config]\n\ + \x20 const _: _ = n_vm::VmConfigBuilder::default().iommu(true).build();\n\n\ + \x20 // the test body follows\n\ + }\n\n\ + Only the top level of the body is searched, so a marker nested inside \ + an inner function, closure or block is not found.", + ) + .to_compile_error(); + + let input2: proc_macro2::TokenStream = input.into(); + quote! { + #error + #input2 + } + .into() +} + +/// Superseded by `.corpus(CorpusPolicy::Fuzz)` on the configuration. +/// +/// Kept only so that the old spelling gets a compile error naming its +/// replacement. It moved for the reason everything else moved out of this +/// macro: the grant now has to be visible to the *rest* of the +/// configuration -- a fuzz target declines the hugepage reservation, which +/// could not be decided while one half lived in an attribute and the other +/// in a `const`. +/// +/// The grant is still opt-in and still spelled out at the call site. The +/// guest is otherwise entirely read-only, which is much of the reason to +/// run a test in a VM at all: a fuzz target is deliberately trying to make +/// code misbehave against a real kernel, and it must not be able to damage +/// the developer's working tree. +#[proc_macro_attribute] +pub fn corpus(_attr: TokenStream, input: TokenStream) -> TokenStream { + let error = syn::Error::new( + proc_macro2::Span::call_site(), + "#[corpus] has been replaced by `.corpus(CorpusPolicy::Fuzz)` on \ + the config, e.g.\n\n\ + #[n_vm::config]\n\ + const _: _ = n_vm::VmConfigBuilder::default()\n\ + \u{20} .corpus(n_vm::CorpusPolicy::Fuzz)\n\ + \u{20} .build();", + ) + .to_compile_error(); + + let input2: proc_macro2::TokenStream = input.into(); + quote! { + #error + #input2 + } + .into() +} diff --git a/n-vm-macros/tests/compile_fail/config_declared_twice.rs b/n-vm-macros/tests/compile_fail/config_declared_twice.rs new file mode 100644 index 0000000000..b1e0693103 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/config_declared_twice.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +const NAMED: n_vm::VmConfig = n_vm::VmConfigBuilder::default().iommu(true).build(); + +#[n_vm::test(config = NAMED)] +fn config_declared_twice() { + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default().build(); +} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/config_declared_twice.stderr b/n-vm-macros/tests/compile_fail/config_declared_twice.stderr new file mode 100644 index 0000000000..54c57875da --- /dev/null +++ b/n-vm-macros/tests/compile_fail/config_declared_twice.stderr @@ -0,0 +1,5 @@ +error: this test is configured twice: `config = NAMED` names one `const VmConfig` and #[n_vm::config] declares another. Keep one. + --> tests/compile_fail/config_declared_twice.rs:9:18 + | +9 | const _: _ = n_vm::VmConfigBuilder::default().build(); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/config_marker_outside_a_test.rs b/n-vm-macros/tests/compile_fail/config_marker_outside_a_test.rs new file mode 100644 index 0000000000..0c69b68b1e --- /dev/null +++ b/n-vm-macros/tests/compile_fail/config_marker_outside_a_test.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The marker is consumed by `#[n_vm::test]`. Used anywhere it cannot reach, +// the registered attribute is what reports that, rather than the config being +// silently ignored and the VM booting with the default. +#[n_vm::config] +const _: n_vm::VmConfig = n_vm::VmConfigBuilder::default().iommu(true).build(); + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/config_marker_outside_a_test.stderr b/n-vm-macros/tests/compile_fail/config_marker_outside_a_test.stderr new file mode 100644 index 0000000000..3e32d57ffd --- /dev/null +++ b/n-vm-macros/tests/compile_fail/config_marker_outside_a_test.stderr @@ -0,0 +1,17 @@ +error: #[n_vm::config] marks a `const` inside the body of a #[n_vm::test] function, and is consumed by it; e.g. + + #[n_vm::test] + fn my_test() { + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default().iommu(true).build(); + + // the test body follows + } + + Only the top level of the body is searched, so a marker nested inside an inner function, closure or block is not found. + --> tests/compile_fail/config_marker_outside_a_test.rs:7:1 + | +7 | #[n_vm::config] + | ^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `n_vm::config` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/n-vm-macros/tests/compile_fail/config_must_be_a_path.rs b/n-vm-macros/tests/compile_fail/config_must_be_a_path.rs new file mode 100644 index 0000000000..aabf731289 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/config_must_be_a_path.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! `config` takes the path of a `const`, not an inline value. +//! +//! Accepting an expression here would work, but it would put the +//! configuration back inside an attribute -- which is the one place an +//! editor cannot offer completion, hover, or go-to-definition on it. +//! Requiring a named item is what keeps the value in ordinary Rust. + +#[n_vm::test(config = 42)] +fn config_must_be_a_path() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/config_must_be_a_path.stderr b/n-vm-macros/tests/compile_fail/config_must_be_a_path.stderr new file mode 100644 index 0000000000..6210ae9fbf --- /dev/null +++ b/n-vm-macros/tests/compile_fail/config_must_be_a_path.stderr @@ -0,0 +1,11 @@ +error: `config` takes the path of a `const VmConfig`, not an inline value; declare it as an item and name it here, e.g. + + const FAST_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default().iommu(true).build(); + + #[n_vm::test(config = FAST_VM)] + + or write it inline with #[n_vm::config] in the test body. + --> tests/compile_fail/config_must_be_a_path.rs:11:23 + | +11 | #[n_vm::test(config = 42)] + | ^^ diff --git a/n-vm-macros/tests/compile_fail/duplicate_inline_config.rs b/n-vm-macros/tests/compile_fail/duplicate_inline_config.rs new file mode 100644 index 0000000000..2e9256a4e2 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/duplicate_inline_config.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#[n_vm::test] +fn duplicate_inline_config() { + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default().build(); + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default().iommu(true).build(); +} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/duplicate_inline_config.stderr b/n-vm-macros/tests/compile_fail/duplicate_inline_config.stderr new file mode 100644 index 0000000000..2326b425d7 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/duplicate_inline_config.stderr @@ -0,0 +1,6 @@ +error: duplicate #[n_vm::config] in this test body; a test describes one VM + --> tests/compile_fail/duplicate_inline_config.rs:8:5 + | +8 | / #[n_vm::config] +9 | | const _: _ = n_vm::VmConfigBuilder::default().iommu(true).build(); + | |______________________________________________________________________^ diff --git a/n-vm-macros/tests/compile_fail/e1000_on_cloud_hypervisor.rs b/n-vm-macros/tests/compile_fail/e1000_on_cloud_hypervisor.rs new file mode 100644 index 0000000000..dd0ec0bbd0 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/e1000_on_cloud_hypervisor.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The NIC/backend coherence check moved into `VmConfig::check` when the +// backend became a field. It must still be a build error: a NIC the pinned +// hypervisor cannot emulate is a contradiction in the test as written, true on +// every host, and it should not take a VM boot to discover. +const E1000_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + .nic_model(n_vm::NicModel::E1000) + .backend(n_vm::RequestedBackend::CloudHypervisor) + .build(); + +#[n_vm::test(config = E1000_VM)] +fn e1000_on_cloud_hypervisor() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/e1000_on_cloud_hypervisor.stderr b/n-vm-macros/tests/compile_fail/e1000_on_cloud_hypervisor.stderr new file mode 100644 index 0000000000..2a60615108 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/e1000_on_cloud_hypervisor.stderr @@ -0,0 +1,42 @@ +error[E0080]: evaluation panicked: this NIC model is emulated only by QEMU, but the configuration pinned cloud-hypervisor; leave the backend at RequestedBackend::Default to let the harness pick QEMU, or ask for RequestedBackend::Qemu + --> tests/compile_fail/e1000_on_cloud_hypervisor.rs:8:34 + | + 8 | const E1000_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + | __________________________________^ + 9 | | .nic_model(n_vm::NicModel::E1000) +10 | | .backend(n_vm::RequestedBackend::CloudHypervisor) +11 | | .build(); + | |____________^ evaluation of `E1000_VM` failed inside this call + | +note: inside `VmConfigBuilder::build` + --> $WORKSPACE/n-vm/src/config.rs + | + | self.0.assert_valid(); + | ^^^^^^^^^^^^^^^^^^^^^ +note: inside `VmConfig::assert_valid` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/n-vm/src/config.rs + | + | Err(ConfigProblem::NicRequiresQemu) => panic!( + | ____________________________________________________- + | | "this NIC model is emulated only by QEMU, but the configuration pinned \ + | | cloud-hypervisor; leave the backend at RequestedBackend::Default to let \ + | | the harness pick QEMU, or ask for RequestedBackend::Qemu" + | | ), + | |_____________- in this macro invocation + +note: erroneous constant encountered + --> tests/compile_fail/e1000_on_cloud_hypervisor.rs:13:1 + | +13 | #[n_vm::test(config = E1000_VM)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +note: erroneous constant encountered + --> tests/compile_fail/e1000_on_cloud_hypervisor.rs:14:4 + | +14 | fn e1000_on_cloud_hypervisor() {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/e1000e_on_cloud_hypervisor.rs b/n-vm-macros/tests/compile_fail/e1000e_on_cloud_hypervisor.rs new file mode 100644 index 0000000000..15032f8a05 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/e1000e_on_cloud_hypervisor.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The NIC/backend coherence check moved into `VmConfig::check` when the +// backend became a field. It must still be a build error: a NIC the pinned +// hypervisor cannot emulate is a contradiction in the test as written, true on +// every host, and it should not take a VM boot to discover. +const E1000E_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + .nic_model(n_vm::NicModel::E1000E) + .backend(n_vm::RequestedBackend::CloudHypervisor) + .build(); + +#[n_vm::test(config = E1000E_VM)] +fn e1000e_on_cloud_hypervisor() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/e1000e_on_cloud_hypervisor.stderr b/n-vm-macros/tests/compile_fail/e1000e_on_cloud_hypervisor.stderr new file mode 100644 index 0000000000..221297245a --- /dev/null +++ b/n-vm-macros/tests/compile_fail/e1000e_on_cloud_hypervisor.stderr @@ -0,0 +1,42 @@ +error[E0080]: evaluation panicked: this NIC model is emulated only by QEMU, but the configuration pinned cloud-hypervisor; leave the backend at RequestedBackend::Default to let the harness pick QEMU, or ask for RequestedBackend::Qemu + --> tests/compile_fail/e1000e_on_cloud_hypervisor.rs:8:35 + | + 8 | const E1000E_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + | ___________________________________^ + 9 | | .nic_model(n_vm::NicModel::E1000E) +10 | | .backend(n_vm::RequestedBackend::CloudHypervisor) +11 | | .build(); + | |____________^ evaluation of `E1000E_VM` failed inside this call + | +note: inside `VmConfigBuilder::build` + --> $WORKSPACE/n-vm/src/config.rs + | + | self.0.assert_valid(); + | ^^^^^^^^^^^^^^^^^^^^^ +note: inside `VmConfig::assert_valid` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/n-vm/src/config.rs + | + | Err(ConfigProblem::NicRequiresQemu) => panic!( + | ____________________________________________________- + | | "this NIC model is emulated only by QEMU, but the configuration pinned \ + | | cloud-hypervisor; leave the backend at RequestedBackend::Default to let \ + | | the harness pick QEMU, or ask for RequestedBackend::Qemu" + | | ), + | |_____________- in this macro invocation + +note: erroneous constant encountered + --> tests/compile_fail/e1000e_on_cloud_hypervisor.rs:13:1 + | +13 | #[n_vm::test(config = E1000E_VM)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +note: erroneous constant encountered + --> tests/compile_fail/e1000e_on_cloud_hypervisor.rs:14:4 + | +14 | fn e1000e_on_cloud_hypervisor() {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/fn_with_params.rs b/n-vm-macros/tests/compile_fail/fn_with_params.rs new file mode 100644 index 0000000000..db4c8c0c07 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/fn_with_params.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#[n_vm::test] +fn fn_with_params(_x: u32) {} + +fn main() {} \ No newline at end of file diff --git a/n-vm-macros/tests/compile_fail/fn_with_params.stderr b/n-vm-macros/tests/compile_fail/fn_with_params.stderr new file mode 100644 index 0000000000..201c7d6e04 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/fn_with_params.stderr @@ -0,0 +1,5 @@ +error: #[n_vm::test] functions must take no parameters; the function is re-invoked by name as `fn()` inside the VM guest + --> tests/compile_fail/fn_with_params.rs:5:19 + | +5 | fn fn_with_params(_x: u32) {} + | ^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/inline_config_captures_a_local.rs b/n-vm-macros/tests/compile_fail/inline_config_captures_a_local.rs new file mode 100644 index 0000000000..3a49489d06 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/inline_config_captures_a_local.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The configuration is evaluated on the host tier, in another process, before +// the guest exists -- so it cannot see the body. `const` is what enforces +// that, and the initializer's spans are what put the error on the offending +// name rather than inside generated code. +#[n_vm::test] +fn inline_config_captures_a_local() { + let wanted = true; + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default().iommu(wanted).build(); +} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/inline_config_captures_a_local.stderr b/n-vm-macros/tests/compile_fail/inline_config_captures_a_local.stderr new file mode 100644 index 0000000000..1429bdcc6c --- /dev/null +++ b/n-vm-macros/tests/compile_fail/inline_config_captures_a_local.stderr @@ -0,0 +1,5 @@ +error[E0425]: cannot find value `wanted` in this scope + --> tests/compile_fail/inline_config_captures_a_local.rs:12:57 + | +12 | const _: _ = n_vm::VmConfigBuilder::default().iommu(wanted).build(); + | ^^^^^^ not found in this scope diff --git a/n-vm-macros/tests/compile_fail/inline_config_hugepages_exceed_memory.rs b/n-vm-macros/tests/compile_fail/inline_config_hugepages_exceed_memory.rs new file mode 100644 index 0000000000..79bfae5282 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/inline_config_hugepages_exceed_memory.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The compile-time half of `the_builder_checks_what_it_builds`: a +// contradictory configuration must still fail the build, which is the property +// a non-const builder would have cost. +#[n_vm::test] +fn inline_config_hugepages_exceed_memory() { + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default() + .guest_hugepages(n_vm::GuestHugePageConfig::Allocate { + size: n_vm::GuestHugePageSize::Huge1G, + count: 2, + }) + .build(); +} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/inline_config_hugepages_exceed_memory.stderr b/n-vm-macros/tests/compile_fail/inline_config_hugepages_exceed_memory.stderr new file mode 100644 index 0000000000..54c982d02f --- /dev/null +++ b/n-vm-macros/tests/compile_fail/inline_config_hugepages_exceed_memory.stderr @@ -0,0 +1,38 @@ +error[E0080]: evaluation panicked: the guest hugepage reservation does not leave the guest kernel room; reduce hugepage_count, use a smaller hugepage size, or set guest_hugepages to GuestHugePageConfig::None + --> tests/compile_fail/inline_config_hugepages_exceed_memory.rs:10:18 + | +10 | const _: _ = n_vm::VmConfigBuilder::default() + | __________________^ +11 | | .guest_hugepages(n_vm::GuestHugePageConfig::Allocate { +12 | | size: n_vm::GuestHugePageSize::Huge1G, +13 | | count: 2, +14 | | }) +15 | | .build(); + | |________________^ evaluation of `__N_VM_CONFIG_inline_config_hugepages_exceed_memory` failed inside this call + | +note: inside `VmConfigBuilder::build` + --> $WORKSPACE/n-vm/src/config.rs + | + | self.0.assert_valid(); + | ^^^^^^^^^^^^^^^^^^^^^ +note: inside `VmConfig::assert_valid` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/n-vm/src/config.rs + | + | Err(ConfigProblem::HugepagesExceedMemory) => panic!( + | __________________________________________________________- + | | "the guest hugepage reservation does not leave the guest kernel room; \ + | | reduce hugepage_count, use a smaller hugepage size, or set \ + | | guest_hugepages to GuestHugePageConfig::None" + | | ), + | |_____________- in this macro invocation + +note: erroneous constant encountered + --> tests/compile_fail/inline_config_hugepages_exceed_memory.rs:8:4 + | +8 | fn inline_config_hugepages_exceed_memory() { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/inline_config_states_a_type.rs b/n-vm-macros/tests/compile_fail/inline_config_states_a_type.rs new file mode 100644 index 0000000000..3cc59fcc2d --- /dev/null +++ b/n-vm-macros/tests/compile_fail/inline_config_states_a_type.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The type is discarded, so writing one would look checked and would not be. +#[n_vm::test] +fn inline_config_states_a_type() { + #[n_vm::config] + const _: n_vm::VmConfig = n_vm::VmConfigBuilder::default().build(); +} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/inline_config_states_a_type.stderr b/n-vm-macros/tests/compile_fail/inline_config_states_a_type.stderr new file mode 100644 index 0000000000..4cf00a0e9e --- /dev/null +++ b/n-vm-macros/tests/compile_fail/inline_config_states_a_type.stderr @@ -0,0 +1,5 @@ +error: #[n_vm::config] is written `const _: _ = ...`; the type is not yours to state. The item is lifted out of the body and re-declared as `n_vm::VmConfig`, so a type written here would be discarded rather than checked. + --> tests/compile_fail/inline_config_states_a_type.rs:8:14 + | +8 | const _: n_vm::VmConfig = n_vm::VmConfigBuilder::default().build(); + | ^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/migrated_backend_option.rs b/n-vm-macros/tests/compile_fail/migrated_backend_option.rs new file mode 100644 index 0000000000..ca3106ae31 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_backend_option.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The backend is part of the machine now. Kept as an error with a hint rather +// than dropped: left alone it would read as an unknown option, with nothing +// pointing at the field that replaced it. +#[n_vm::test(qemu)] +fn migrated_backend_option() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/migrated_backend_option.stderr b/n-vm-macros/tests/compile_fail/migrated_backend_option.stderr new file mode 100644 index 0000000000..b0e6f76119 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_backend_option.stderr @@ -0,0 +1,5 @@ +error: `qemu` has moved out of #[n_vm::test(...)] -- use `.backend(RequestedBackend::Qemu)` on the config instead + --> tests/compile_fail/migrated_backend_option.rs:7:14 + | +7 | #[n_vm::test(qemu)] + | ^^^^ diff --git a/n-vm-macros/tests/compile_fail/migrated_corpus_attribute.rs b/n-vm-macros/tests/compile_fail/migrated_corpus_attribute.rs new file mode 100644 index 0000000000..eba0b2b407 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_corpus_attribute.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The corpus grant is part of the machine now. Rejected rather than ignored: +// a fuzz target that quietly lost its writable share does not fail, it runs, +// generates inputs, and saves none of them. +#[n_vm::test] +#[n_vm::corpus] +fn migrated_corpus_attribute() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/migrated_corpus_attribute.stderr b/n-vm-macros/tests/compile_fail/migrated_corpus_attribute.stderr new file mode 100644 index 0000000000..0cf420dde2 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_corpus_attribute.stderr @@ -0,0 +1,10 @@ +error: #[corpus] has been replaced by `.corpus(CorpusPolicy::Fuzz)` on the config, e.g. + + #[n_vm::config] + const _: _ = n_vm::VmConfigBuilder::default() + .corpus(n_vm::CorpusPolicy::Fuzz) + .build(); + --> tests/compile_fail/migrated_corpus_attribute.rs:8:1 + | +8 | #[n_vm::corpus] + | ^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/migrated_option.rs b/n-vm-macros/tests/compile_fail/migrated_option.rs new file mode 100644 index 0000000000..f1ccd4b570 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_option.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#[n_vm::test(iommu)] +fn migrated_option() {} + +fn main() {} \ No newline at end of file diff --git a/n-vm-macros/tests/compile_fail/migrated_option.stderr b/n-vm-macros/tests/compile_fail/migrated_option.stderr new file mode 100644 index 0000000000..cf8089df1b --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_option.stderr @@ -0,0 +1,5 @@ +error: `iommu` has moved out of #[n_vm::test(...)] -- use the `iommu` field of a `const VmConfig` instead + --> tests/compile_fail/migrated_option.rs:4:14 + | +4 | #[n_vm::test(iommu)] + | ^^^^^ diff --git a/n-vm-macros/tests/compile_fail/migrated_runtime_option.rs b/n-vm-macros/tests/compile_fail/migrated_runtime_option.rs new file mode 100644 index 0000000000..f16171d685 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_runtime_option.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#[n_vm::test(multi_thread)] +async fn migrated_runtime_option() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/migrated_runtime_option.stderr b/n-vm-macros/tests/compile_fail/migrated_runtime_option.stderr new file mode 100644 index 0000000000..8948a562cc --- /dev/null +++ b/n-vm-macros/tests/compile_fail/migrated_runtime_option.stderr @@ -0,0 +1,5 @@ +error: `multi_thread` has moved out of #[n_vm::test(...)] -- use `.runtime(GuestRuntime::MultiThread { worker_threads: None })` on the config instead + --> tests/compile_fail/migrated_runtime_option.rs:4:14 + | +4 | #[n_vm::test(multi_thread)] + | ^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/mixed_fabric_on_cloud_hypervisor.rs b/n-vm-macros/tests/compile_fail/mixed_fabric_on_cloud_hypervisor.rs new file mode 100644 index 0000000000..e0afead160 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/mixed_fabric_on_cloud_hypervisor.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// A fabric that names its models one by one reaches the same coherence check +// as `nic_model` does. Worth its own case: the check reads a slice here rather +// than a single field, so "no emulated NIC in this VM" is a different question +// than it was, and getting it wrong would fail at boot instead of at build. +const MIXED_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + .fabric_nic_models(&[n_vm::NicModel::VirtioNet, n_vm::NicModel::E1000]) + .backend(n_vm::RequestedBackend::CloudHypervisor) + .build(); + +#[n_vm::test(config = MIXED_VM)] +fn mixed_fabric_on_cloud_hypervisor() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/mixed_fabric_on_cloud_hypervisor.stderr b/n-vm-macros/tests/compile_fail/mixed_fabric_on_cloud_hypervisor.stderr new file mode 100644 index 0000000000..e9c19966af --- /dev/null +++ b/n-vm-macros/tests/compile_fail/mixed_fabric_on_cloud_hypervisor.stderr @@ -0,0 +1,42 @@ +error[E0080]: evaluation panicked: this NIC model is emulated only by QEMU, but the configuration pinned cloud-hypervisor; leave the backend at RequestedBackend::Default to let the harness pick QEMU, or ask for RequestedBackend::Qemu + --> tests/compile_fail/mixed_fabric_on_cloud_hypervisor.rs:8:34 + | + 8 | const MIXED_VM: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + | __________________________________^ + 9 | | .fabric_nic_models(&[n_vm::NicModel::VirtioNet, n_vm::NicModel::E1000]) +10 | | .backend(n_vm::RequestedBackend::CloudHypervisor) +11 | | .build(); + | |____________^ evaluation of `MIXED_VM` failed inside this call + | +note: inside `VmConfigBuilder::build` + --> $WORKSPACE/n-vm/src/config.rs + | + | self.0.assert_valid(); + | ^^^^^^^^^^^^^^^^^^^^^ +note: inside `VmConfig::assert_valid` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/n-vm/src/config.rs + | + | Err(ConfigProblem::NicRequiresQemu) => panic!( + | ____________________________________________________- + | | "this NIC model is emulated only by QEMU, but the configuration pinned \ + | | cloud-hypervisor; leave the backend at RequestedBackend::Default to let \ + | | the harness pick QEMU, or ask for RequestedBackend::Qemu" + | | ), + | |_____________- in this macro invocation + +note: erroneous constant encountered + --> tests/compile_fail/mixed_fabric_on_cloud_hypervisor.rs:13:1 + | +13 | #[n_vm::test(config = MIXED_VM)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +note: erroneous constant encountered + --> tests/compile_fail/mixed_fabric_on_cloud_hypervisor.rs:14:4 + | +14 | fn mixed_fabric_on_cloud_hypervisor() {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/module_param_reserved_namespace.rs b/n-vm-macros/tests/compile_fail/module_param_reserved_namespace.rs new file mode 100644 index 0000000000..38049f4d83 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/module_param_reserved_namespace.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// `n_it` is the namespace the init system reads its own boot parameters from. +// Setting it would redirect the guest's init protocol, which surfaces as a +// hang rather than as an error -- so it is refused at build time. +const _: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + .module_params(&[n_vm::ModuleParam::new("n_it", "result_port", "9")]) + .build(); + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/module_param_reserved_namespace.stderr b/n-vm-macros/tests/compile_fail/module_param_reserved_namespace.stderr new file mode 100644 index 0000000000..ef23389c98 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/module_param_reserved_namespace.stderr @@ -0,0 +1,26 @@ +error[E0080]: evaluation panicked: that module name is the namespace `n-it` reads its own boot parameters from; setting it would redirect the guest's init protocol rather than configure a module + --> tests/compile_fail/module_param_reserved_namespace.rs:8:22 + | +8 | .module_params(&[n_vm::ModuleParam::new("n_it", "result_port", "9")]) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `_` failed inside this call + | +note: inside `ModuleParam::new` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/n-vm/src/config.rs + | + | / assert!( + | | !str_eq(module, n_vm_protocol::CMDLINE_NAMESPACE), + | | "that module name is the namespace `n-it` reads its own boot parameters from; \ + | | setting it would redirect the guest's init protocol rather than configure a module" + | | ); + | |_________- in this macro invocation + +note: erroneous constant encountered + --> tests/compile_fail/module_param_reserved_namespace.rs:8:20 + | +8 | .module_params(&[n_vm::ModuleParam::new("n_it", "result_port", "9")]) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/module_param_value_with_whitespace.rs b/n-vm-macros/tests/compile_fail/module_param_value_with_whitespace.rs new file mode 100644 index 0000000000..1457866c65 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/module_param_value_with_whitespace.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The kernel command line is split on whitespace, so this would silently +// become two parameters. +const _: n_vm::VmConfig = n_vm::VmConfigBuilder::default() + .module_params(&[n_vm::ModuleParam::new("mlx5_core", "prof_sel", "2 3")]) + .build(); + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/module_param_value_with_whitespace.stderr b/n-vm-macros/tests/compile_fail/module_param_value_with_whitespace.stderr new file mode 100644 index 0000000000..bab311cf52 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/module_param_value_with_whitespace.stderr @@ -0,0 +1,27 @@ +error[E0080]: evaluation panicked: a module parameter value must be non-empty and contain no whitespace; the kernel command line is split on whitespace, so one that does would silently become two parameters + --> tests/compile_fail/module_param_value_with_whitespace.rs:7:22 + | +7 | .module_params(&[n_vm::ModuleParam::new("mlx5_core", "prof_sel", "2 3")]) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `_` failed inside this call + | +note: inside `ModuleParam::new` + --> $RUST/core/src/panic.rs + | + | $crate::panicking::panic_fmt($crate::const_format_args!($($t)+)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the failure occurred here + | + ::: $WORKSPACE/n-vm/src/config.rs + | + | / assert!( + | | is_cmdline_value(value), + | | "a module parameter value must be non-empty and contain no whitespace; \ + | | the kernel command line is split on whitespace, so one that does \ + | | would silently become two parameters" + | | ); + | |_________- in this macro invocation + +note: erroneous constant encountered + --> tests/compile_fail/module_param_value_with_whitespace.rs:7:20 + | +7 | .module_params(&[n_vm::ModuleParam::new("mlx5_core", "prof_sel", "2 3")]) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/non_unit_return.rs b/n-vm-macros/tests/compile_fail/non_unit_return.rs new file mode 100644 index 0000000000..1e7953bbc7 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/non_unit_return.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#[n_vm::test] +fn non_unit_return() -> i32 { + 42 +} + +fn main() {} \ No newline at end of file diff --git a/n-vm-macros/tests/compile_fail/non_unit_return.stderr b/n-vm-macros/tests/compile_fail/non_unit_return.stderr new file mode 100644 index 0000000000..215edf0e47 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/non_unit_return.stderr @@ -0,0 +1,5 @@ +error: #[n_vm::test] functions must return `()`; the generated dispatch branches use bare `return;` statements + --> tests/compile_fail/non_unit_return.rs:5:22 + | +5 | fn non_unit_return() -> i32 { + | ^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/redundant_test_attr.rs b/n-vm-macros/tests/compile_fail/redundant_test_attr.rs new file mode 100644 index 0000000000..427912c8dd --- /dev/null +++ b/n-vm-macros/tests/compile_fail/redundant_test_attr.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// #[n_vm::test] injects #[test] itself; a hand-written one is redundant and +// would silently double up the harness attribute. +#[n_vm::test] +#[test] +fn redundant_test_attr() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/redundant_test_attr.stderr b/n-vm-macros/tests/compile_fail/redundant_test_attr.stderr new file mode 100644 index 0000000000..08bd21b429 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/redundant_test_attr.stderr @@ -0,0 +1,5 @@ +error: #[n_vm::test] already provides the test harness: remove the #[test] attribute + --> tests/compile_fail/redundant_test_attr.rs:7:1 + | +7 | #[test] + | ^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/redundant_tokio_test_attr.rs b/n-vm-macros/tests/compile_fail/redundant_tokio_test_attr.rs new file mode 100644 index 0000000000..84986b9587 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/redundant_tokio_test_attr.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// #[tokio::test] is rejected rather than ignored: it used to carry the +// runtime flavor, which now lives in #[n_vm::test(...)]'s own arguments. +// Silently dropping it would silently change which scheduler the test ran on. +#[n_vm::test] +#[tokio::test(flavor = "multi_thread")] +async fn redundant_tokio_test_attr() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/redundant_tokio_test_attr.stderr b/n-vm-macros/tests/compile_fail/redundant_tokio_test_attr.stderr new file mode 100644 index 0000000000..91d463b3fd --- /dev/null +++ b/n-vm-macros/tests/compile_fail/redundant_tokio_test_attr.stderr @@ -0,0 +1,5 @@ +error: #[n_vm::test] already provides the test harness and the guest tokio runtime: remove #[tokio::test] and move its options into #[n_vm::test(...)] (e.g. `#[n_vm::test(multi_thread, worker_threads = 4)]`) + --> tests/compile_fail/redundant_tokio_test_attr.rs:8:1 + | +8 | #[tokio::test(flavor = "multi_thread")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/retired_companion_attribute.rs b/n-vm-macros/tests/compile_fail/retired_companion_attribute.rs new file mode 100644 index 0000000000..34e12ee689 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/retired_companion_attribute.rs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! A leftover `#[hypervisor]` must be rejected, not ignored. +//! +//! Nothing consumes these attributes any more, so without an explicit check +//! this compiles and the VM quietly boots with the default configuration -- +//! the test would appear to ask for an IOMMU and silently not get one. That +//! is the worst possible outcome for a migration, so it is a hard error that +//! names the replacement. + +#[n_vm::test] +#[hypervisor(iommu)] +fn retired_companion_attribute() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/retired_companion_attribute.stderr b/n-vm-macros/tests/compile_fail/retired_companion_attribute.stderr new file mode 100644 index 0000000000..f368cfc00c --- /dev/null +++ b/n-vm-macros/tests/compile_fail/retired_companion_attribute.stderr @@ -0,0 +1,9 @@ +error: #[hypervisor] has been replaced by a `const VmConfig`; declare one and name it with `config = ...`, e.g. + + const MY_VM: n_vm::VmConfig = n_vm::VmConfig { iommu: true, ..n_vm::VmConfig::DEFAULT }; + + #[n_vm::test(config = MY_VM)] + --> tests/compile_fail/retired_companion_attribute.rs:13:1 + | +13 | #[hypervisor(iommu)] + | ^^^^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/should_panic_unsupported.rs b/n-vm-macros/tests/compile_fail/should_panic_unsupported.rs new file mode 100644 index 0000000000..cc4b74369b --- /dev/null +++ b/n-vm-macros/tests/compile_fail/should_panic_unsupported.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#[n_vm::test] +#[should_panic] +fn should_panic_rejected() { + panic!("the body runs in the VM guest; should_panic cannot compose"); +} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/should_panic_unsupported.stderr b/n-vm-macros/tests/compile_fail/should_panic_unsupported.stderr new file mode 100644 index 0000000000..0998ebaad6 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/should_panic_unsupported.stderr @@ -0,0 +1,5 @@ +error: #[should_panic] is not supported with #[n_vm::test]: the test body runs in a separate VM-guest process across three dispatch tiers, so panic semantics do not compose. Assert the failure condition inside the test body instead (e.g. `assert!(result.is_err())`). + --> tests/compile_fail/should_panic_unsupported.rs:5:1 + | +5 | #[should_panic] + | ^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_fail/unknown_option.rs b/n-vm-macros/tests/compile_fail/unknown_option.rs new file mode 100644 index 0000000000..5e4602a327 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/unknown_option.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// The attribute takes one argument. Anything else is a typo or a leftover. +#[n_vm::test(not_a_real_option)] +fn unknown_option() {} + +fn main() {} diff --git a/n-vm-macros/tests/compile_fail/unknown_option.stderr b/n-vm-macros/tests/compile_fail/unknown_option.stderr new file mode 100644 index 0000000000..be9a00a176 --- /dev/null +++ b/n-vm-macros/tests/compile_fail/unknown_option.stderr @@ -0,0 +1,5 @@ +error: unknown #[n_vm::test] option `not_a_real_option`; the only argument is `config = PATH`, naming a `const VmConfig`. Everything about the VM -- backend, runtime, NIC, pages, kernel features -- is a field of that config, set with `n_vm::VmConfigBuilder`. + --> tests/compile_fail/unknown_option.rs:5:14 + | +5 | #[n_vm::test(not_a_real_option)] + | ^^^^^^^^^^^^^^^^^ diff --git a/n-vm-macros/tests/compile_tests.rs b/n-vm-macros/tests/compile_tests.rs new file mode 100644 index 0000000000..def89e7799 --- /dev/null +++ b/n-vm-macros/tests/compile_tests.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +// trybuild shells out to `cargo` at run time to compile each case for the +// build target. Under cross emulation (`--cfg emulated`, set by +// `nix/profiles.nix` when the test arch != host arch) the test binary runs +// via qemu-user and that build target's `std`/`core` is not available, so +// the cases fail with E0463 ("can't find crate for `core`") rather than the +// diagnostics they assert. The macro's compile-time errors are +// arch-independent, so the native (non-emulated) run is full coverage. +#[test] +#[cfg_attr( + emulated, + ignore = "trybuild compiles host-side; cross target has no std/core" +)] +fn compile_fail() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/compile_fail/*.rs"); +} diff --git a/n-vm-protocol/Cargo.toml b/n-vm-protocol/Cargo.toml new file mode 100644 index 0000000000..2149192f3e --- /dev/null +++ b/n-vm-protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "dataplane-n-vm-protocol" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true + +[lib] +name = "n_vm_protocol" + +[dependencies] \ No newline at end of file diff --git a/n-vm-protocol/src/lib.rs b/n-vm-protocol/src/lib.rs new file mode 100644 index 0000000000..e5af853aaf --- /dev/null +++ b/n-vm-protocol/src/lib.rs @@ -0,0 +1,1951 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Shared paths, environment variables, and vsock identifiers for the +//! nested VM test environment. + +use std::path::PathBuf; +use std::time::Duration; + +/// Platform string passed to the Docker engine when creating the container. +pub const CONTAINER_PLATFORM: &str = "linux/amd64"; + +/// Environment variable pointing to the resolved `testroot` directory. +pub const ENV_TEST_ROOT: &str = "N_VM_TEST_ROOT"; + +/// Environment variable pointing to the resolved `vmroot` directory. +pub const ENV_VM_ROOT: &str = "N_VM_VM_ROOT"; + +/// Environment variable naming a directory that both this process and the +/// Docker daemon can see, under which the daemon-visible copies live. +/// +/// Every path in a bind mount's `source` is resolved by the *daemon*, in the +/// daemon's mount namespace -- not by this process. The two agree when the +/// daemon runs on the same host. They do not when the tests run inside a +/// container that talks to a daemon outside it, which is how the dataplane's +/// CI runners are built: `/nix` there belongs to the runner's own image, and +/// the bare metal has nothing at those paths. +/// +/// That failure is silent by construction. Every mount sets +/// `create_mountpoint`, so a source the daemon cannot find is *created* as an +/// empty directory rather than reported -- the guest root mounts empty, and +/// the first mount beneath it fails on a read-only filesystem, naming a +/// directory that does exist in the real `vmroot`. +/// +/// When this is set, mount sources are rewritten to point inside it: +/// `/nix/store/...` becomes `/nix/store/...`, and the forwarded +/// environment is written under `/tmp` rather than [`std::env::temp_dir`]. +/// The mount *targets* are unchanged, so `/nix/store` rpaths still resolve in +/// the guest. Unset -- the ordinary case, a daemon on this host -- nothing is +/// rewritten. +/// +/// Populating the directory is the caller's job; see the `setup-roots` recipe. +pub const ENV_HOST_SHARE: &str = "N_VM_HOST_SHARE_DIR"; + +/// The store subdirectory of [`ENV_HOST_SHARE`], mirroring `/nix/store`. +pub const HOST_SHARE_STORE_SUBDIR: &str = "nix/store"; + +/// The scratch subdirectory of [`ENV_HOST_SHARE`], standing in for `/tmp`. +pub const HOST_SHARE_TMP_SUBDIR: &str = "tmp"; + +/// The store prefix that [`ENV_HOST_SHARE`] redirects. +pub const NIX_STORE_DIR: &str = "/nix/store"; + +/// Rewrites a path the Docker daemon must resolve so that it lands inside +/// [`ENV_HOST_SHARE`], when one is configured. +/// +/// Only a `/nix/store` path is rewritten. Anything else is returned unchanged: +/// the workspace and the nextest archive already live on a filesystem both +/// namespaces share, which is the whole reason the share directory is placed +/// under the workspace. +#[must_use] +pub fn host_visible_path(path: &str) -> String { + host_visible_path_in(host_share_dir().as_deref(), path) +} + +/// [`host_visible_path`] with the share directory given rather than read from +/// the environment, so that it can be tested without touching global state. +#[must_use] +pub fn host_visible_path_in(share: Option<&str>, path: &str) -> String { + let Some(share) = share else { + return path.to_owned(); + }; + // A prefix match on the string alone would rewrite `/nix/storage`, which + // is not a store path. Only the directory itself and its children. + let rest = if path == NIX_STORE_DIR { + "" + } else if let Some(rest) = path.strip_prefix(&format!("{NIX_STORE_DIR}/")) { + rest + } else { + return path.to_owned(); + }; + if rest.is_empty() { + format!("{share}/{HOST_SHARE_STORE_SUBDIR}") + } else { + format!("{share}/{HOST_SHARE_STORE_SUBDIR}/{rest}") + } +} + +/// The configured [`ENV_HOST_SHARE`], with a trailing slash removed and the +/// empty value treated as unset. +#[must_use] +pub fn host_share_dir() -> Option { + let raw = std::env::var(ENV_HOST_SHARE).ok()?; + let trimmed = raw.trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + // Canonicalised because the result becomes a bind mount `source`, and the + // Docker API takes that as an opaque absolute path rather than resolving + // it: a `..` or a symlink that this process reads without noticing would + // reach the daemon unresolved. Best effort -- a directory that does not + // exist yet is passed through, and the mount then fails on its own terms. + match std::fs::canonicalize(trimmed) { + Ok(path) => path.to_str().map(str::to_owned), + Err(_) => Some(trimmed.to_owned()), + } +} + +/// Resolved root directories for the test container infrastructure. +#[derive(Debug, Clone)] +pub struct ScratchRoots { + /// Absolute path to the `testroot` directory (container-tier tools). + pub test_root: PathBuf, + /// Absolute path to the `vmroot` directory (VM guest root filesystem). + pub vm_root: PathBuf, +} + +impl ScratchRoots { + /// Resolves the `testroot` and `vmroot` directories. + /// + /// # Errors + /// + /// - [`ScratchRootError::InvalidPath`] if an environment variable is + /// set but the path cannot be canonicalized. + /// - [`ScratchRootError::NotFound`] if neither detection method + /// locates both roots. + pub fn resolve() -> Result { + if let Some(roots) = Self::from_env()? { + return Ok(roots); + } + let cwd = std::env::current_dir().map_err(|_| ScratchRootError::NotFound)?; + if let Some(roots) = Self::from_ancestors_of(&cwd) { + return Ok(roots); + } + Err(ScratchRootError::NotFound) + } + + /// Tries to resolve roots from [`ENV_TEST_ROOT`] and [`ENV_VM_ROOT`]. + fn from_env() -> Result, ScratchRootError> { + let test_root_raw = match std::env::var(ENV_TEST_ROOT) { + Ok(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let vm_root_raw = match std::env::var(ENV_VM_ROOT) { + Ok(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + + let test_root = std::fs::canonicalize(&test_root_raw).map_err(|source| { + ScratchRootError::InvalidPath { + var: ENV_TEST_ROOT, + path: PathBuf::from(&test_root_raw), + source, + } + })?; + let vm_root = std::fs::canonicalize(&vm_root_raw).map_err(|source| { + ScratchRootError::InvalidPath { + var: ENV_VM_ROOT, + path: PathBuf::from(&vm_root_raw), + source, + } + })?; + + Ok(Some(Self { test_root, vm_root })) + } + + /// Tries to find `testroot` and `vmroot` at `start` or above it. + /// + /// The walk is the point. `just setup-roots` puts both at the + /// workspace root, but cargo runs a test with the working directory set + /// to the *package* root -- so anything that invokes cargo directly + /// rather than through `just` (an IDE's test runner, a debugger, a plain + /// `cargo test` in a subdirectory) starts one or more levels below them. + /// `workspace_root` in the host tier walks for the same reason; this was + /// the one place that did not. + /// + /// Both roots must be found at the *same* ancestor. Taking `testroot` + /// from one level and `vmroot` from another would pair a container + /// image with a guest filesystem that was never built alongside it. + /// + /// Takes the starting directory rather than reading it, so that a test + /// of the walk does not have to change the process's working directory + /// -- which is global, and would race every other test in the binary. + fn from_ancestors_of(start: &std::path::Path) -> Option { + start.ancestors().find_map(|dir| { + let test_root = std::fs::canonicalize(dir.join("testroot")).ok()?; + let vm_root = std::fs::canonicalize(dir.join("vmroot")).ok()?; + Some(Self { test_root, vm_root }) + }) + } +} + +/// Error resolving the test container root directories. +#[derive(Debug)] +pub enum ScratchRootError { + /// An environment variable path cannot be canonicalized. + InvalidPath { + /// The environment variable name. + var: &'static str, + /// The raw path value from the environment. + path: PathBuf, + /// The underlying I/O error. + source: std::io::Error, + }, + /// Neither environment variables nor CWD detection found both roots. + NotFound, +} + +impl std::fmt::Display for ScratchRootError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidPath { var, path, .. } => { + write!(f, "scratch root {var} = {path:?} is not accessible") + } + Self::NotFound => { + write!( + f, + "could not find testroot/vmroot in the working directory \ + or any parent, and {ENV_TEST_ROOT}/{ENV_VM_ROOT} are \ + not set; run `just setup-roots` from the workspace root" + ) + } + } + } +} + +impl std::error::Error for ScratchRootError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidPath { source, .. } => Some(source), + Self::NotFound => None, + } + } +} + +/// Environment variable set by the init system (`n-it`) inside the VM guest. +pub const ENV_IN_VM: &str = "IN_VM"; + +/// Environment variable set by the container tier (`n-vm::run_test_in_vm`). +pub const ENV_IN_TEST_CONTAINER: &str = "IN_TEST_CONTAINER"; + +/// The value used to mark both [`ENV_IN_VM`] and [`ENV_IN_TEST_CONTAINER`] +/// as active. +pub const ENV_MARKER_VALUE: &str = "YES"; + +/// Environment variable carrying the effective hypervisor backend the +/// container tier should boot (`"qemu"` or `"cloud_hypervisor"`). +/// +/// Set by the host tier once it has resolved the backend against the +/// Docker daemon's architecture (see the host-tier dispatch in `n-vm`); +/// read by the container tier so it can dispatch to the right backend +/// without baking the choice in at compile time. +pub const ENV_BACKEND: &str = "N_VM_BACKEND"; + +/// Environment variable carrying the effective acceleration mode for the +/// container tier (`"kvm"` or `"tcg"`). +/// +/// `kvm` when the Docker daemon architecture matches the test binary's +/// target architecture; `tcg` (software emulation) for a cross-arch +/// guest. Set by the host tier, read by the QEMU backend. +pub const ENV_ACCEL: &str = "N_VM_ACCEL"; + +/// Docker label marking a container as one this crate created. +/// +/// Set on every test container so that a container can be recognised as +/// ours without matching on image or name, neither of which is reliable: +/// the scratch image is shared, and names are assigned by the daemon. +/// +/// The reaper (`n-vm-reap`) selects on this label alone, which is what +/// makes bulk removal safe to offer at all -- it can never match a +/// container some other tool on the machine created. +pub const LABEL_OWNER: &str = "dev.githedgehog.n-vm"; + +/// Value of [`LABEL_OWNER`]. Presence is what matters; the value is fixed +/// so the label can be matched as `key=value` rather than by existence. +pub const LABEL_OWNER_VALUE: &str = "1"; + +/// Docker label carrying the fully-qualified name of the test the container +/// was launched for. +/// +/// Purely diagnostic: a leaked container is far easier to act on when it +/// says which test produced it. +pub const LABEL_TEST: &str = "dev.githedgehog.n-vm.test"; + +/// Docker label carrying the PID of the host-tier process that created the +/// container. +/// +/// This is what lets the reaper distinguish a genuine orphan from a +/// container belonging to a run that is still going: if the recorded PID is +/// gone, nothing is left to collect the container's result. Treated as a +/// hint rather than proof, since PIDs are reused. +pub const LABEL_HOST_PID: &str = "dev.githedgehog.n-vm.host-pid"; + +/// A vsock port number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct VsockPort(u32); + +impl VsockPort { + /// The smallest port suitable for dynamic allocation. + pub const DYNAMIC_MIN: Self = Self(1024); + + /// The largest port suitable for dynamic allocation. + pub const DYNAMIC_MAX: Self = Self(u32::MAX - 1); + + /// Creates a new [`VsockPort`] from a raw port number. + /// + /// # Panics + /// + /// Panics if `port` is `u32::MAX` (`VMADDR_PORT_ANY`), which has + /// special kernel semantics (wildcard / "assign any port") and must + /// not be used as a concrete port number. + #[must_use] + pub const fn new(port: u32) -> Self { + assert!( + port != u32::MAX, + "VMADDR_PORT_ANY (u32::MAX) cannot be used as a concrete vsock port" + ); + Self(port) + } + + /// Returns the raw `u32` port number. + #[must_use] + pub const fn as_raw(self) -> u32 { + self.0 + } +} + +impl std::fmt::Display for VsockPort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A vsock context identifier (CID). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct VsockCid(u64); + +impl VsockCid { + /// The hypervisor's CID (`VMADDR_CID_HYPERVISOR`). + pub const HYPERVISOR: Self = Self(0); + + /// Loopback CID (`VMADDR_CID_LOCAL`), analogous to `127.0.0.1`. + pub const LOCAL: Self = Self(1); + + /// The host CID (`VMADDR_CID_HOST`). + pub const HOST: Self = Self(2); + + /// The first CID available for guest use. + pub const GUEST_MIN: Self = Self(3); + + /// The largest CID available for guest use. + pub const GUEST_MAX: Self = Self(u32::MAX as u64 - 1); + + /// Creates a new [`VsockCid`] from a raw CID value. + /// + /// # Panics + /// + /// Panics if `cid` is 0 (`VMADDR_CID_HYPERVISOR`), 1 + /// (`VMADDR_CID_LOCAL`), or 2 (`VMADDR_CID_HOST`). These CIDs have + /// fixed kernel-level semantics and must not be used as arbitrary guest + /// identifiers -- use the named constants [`Self::HYPERVISOR`], + /// [`Self::LOCAL`], or [`Self::HOST`] instead. + #[must_use] + pub const fn new(cid: u64) -> Self { + assert!( + cid >= 3, + "CIDs 0 (hypervisor), 1 (local), and 2 (host) are reserved; use the named constants instead" + ); + Self(cid) + } + + /// Returns the raw `u64` CID value. + #[must_use] + pub const fn as_raw(self) -> u64 { + self.0 + } +} + +impl std::fmt::Display for VsockCid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A typed vsock communication channel from VM guest to container host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VsockChannel { + /// The vsock port number for this channel. + pub port: VsockPort, + /// A human-readable label used in log messages and error reports. + pub label: &'static str, +} + +impl VsockChannel { + /// Channel for the init system's tracing data. + pub const INIT_TRACE: Self = Self { + port: VsockPort::new(123_456), + label: "init-trace", + }; + + /// Channel for the test process's **stdout**. + pub const TEST_STDOUT: Self = Self { + port: VsockPort::new(123_457), + label: "test-stdout", + }; + + /// Channel for the test process's **stderr**. + pub const TEST_STDERR: Self = Self { + port: VsockPort::new(123_458), + label: "test-stderr", + }; + + /// Channel for the structured pass/fail verdict reported by the init + /// system once the test process has exited. + pub const TEST_RESULT: Self = Self { + port: VsockPort::new(123_459), + label: "test-result", + }; + + /// Returns the Unix socket path the container tier must bind for this + /// channel. + pub fn listener_path(&self) -> PathBuf { + PathBuf::from(format!("{VHOST_VSOCK_SOCKET_PATH}_{}", self.port.as_raw())) + } +} + +impl std::fmt::Display for VsockChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (vsock port {})", self.label, self.port.as_raw()) + } +} + +/// The structured pass/fail verdict the guest init system reports to the +/// host over [`VsockChannel::TEST_RESULT`]. +/// +/// This replaces scraping the test process's stdout for a libtest summary +/// line. The verdict is computed inside the guest from the test process's +/// exit status (plus the init system's leaked-process / signal policy) and +/// transmitted explicitly, so the host never has to infer pass/fail from +/// free-form output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestResult { + /// `true` if and only if the test is considered to have passed. + pub passed: bool, + /// Human-readable detail (exit code, signal, or reason) for diagnostics. + pub detail: String, +} + +impl TestResult { + /// Marker prefix identifying a result line on the wire. + /// + /// Using a prefix lets the host scan the (possibly noisy) stream for the + /// verdict line without being confused by other output. + pub const WIRE_PREFIX: &str = "n-it-result"; + + /// Creates a new [`TestResult`]. + #[must_use] + pub fn new(passed: bool, detail: impl Into) -> Self { + Self { + passed, + detail: detail.into(), + } + } + + /// Serializes the verdict to its single-line wire form. + /// + /// The detail is flattened to a single line; the trailing newline marks + /// the end of the record for the reader. + #[must_use] + pub fn to_wire(&self) -> String { + let tag = if self.passed { "pass" } else { "fail" }; + // Trim so the detail round-trips through `parse`, which trims the + // reconstructed detail. + let detail = self.detail.replace(['\n', '\r'], " "); + let detail = detail.trim(); + format!("{prefix} {tag} {detail}\n", prefix = Self::WIRE_PREFIX) + } + + /// Parses a verdict from a raw stream, scanning for the first line + /// carrying [`Self::WIRE_PREFIX`]. + /// + /// Returns `None` if no well-formed result line is present. Callers + /// **must** treat `None` as a failure: an absent or garbled verdict + /// means the guest never reported success. + #[must_use] + pub fn parse(raw: &str) -> Option { + let body = raw.lines().find_map(|line| { + // The prefix must start the line (no leading whitespace) and be + // followed by whitespace separating it from the tag. Without + // these boundaries a line like `n-it-resultpass` -- or an + // indented echo of a result line inside other output -- could + // falsely strip to a `pass` verdict; a spurious pass is the + // dangerous direction for a verdict parser. A trailing `\r` + // (CRLF console transport) is tolerated via the closing trim. + let rest = line.strip_prefix(Self::WIRE_PREFIX)?; + rest.strip_prefix(char::is_whitespace).map(str::trim_start) + })?; + let (tag, detail) = match body.split_once(char::is_whitespace) { + Some((tag, detail)) => (tag, detail.trim()), + None => (body, ""), + }; + let passed = match tag { + "pass" => true, + "fail" => false, + _ => return None, + }; + Some(Self::new(passed, detail)) + } +} + +/// Legacy static vsock CID for single-VM tests. +pub const VM_GUEST_CID: VsockCid = VsockCid::new(3); + +// Vsock CIDs and AF_VSOCK port bindings are host-global: they are NOT +// namespaced by containers, network namespaces, or cgroups. When +// multiple test containers launch QEMU in parallel, each VM must use a +// unique CID and unique listener ports to avoid EADDRINUSE collisions. + +/// Kernel command-line parameter: init-trace vsock port. +pub const CMDLINE_TRACE_PORT: &str = "n_it.trace_port"; + +/// Kernel command-line parameter: test-stdout vsock port. +pub const CMDLINE_STDOUT_PORT: &str = "n_it.stdout_port"; + +/// Kernel command-line parameter: test-stderr vsock port. +pub const CMDLINE_STDERR_PORT: &str = "n_it.stderr_port"; + +/// Kernel command-line parameter: test-result vsock port. +pub const CMDLINE_RESULT_PORT: &str = "n_it.result_port"; + +/// Dynamically allocated vsock resources for one VM instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VsockAllocation { + /// The guest CID passed to the hypervisor's vsock device. + pub cid: VsockCid, + /// Channel for the init system's tracing output. + pub init_trace: VsockChannel, + /// Channel for the test process's stdout. + pub test_stdout: VsockChannel, + /// Channel for the test process's stderr. + pub test_stderr: VsockChannel, + /// Channel for the structured pass/fail verdict. + pub result: VsockChannel, +} + +impl VsockAllocation { + /// Creates an allocation using the legacy static values. + pub const fn with_defaults() -> Self { + Self { + cid: VM_GUEST_CID, + init_trace: VsockChannel::INIT_TRACE, + test_stdout: VsockChannel::TEST_STDOUT, + test_stderr: VsockChannel::TEST_STDERR, + result: VsockChannel::TEST_RESULT, + } + } + + /// Formats the vsock port assignments as kernel command-line parameters. + pub fn kernel_cmdline_fragment(&self) -> String { + format!( + "{CMDLINE_TRACE_PORT}={} {CMDLINE_STDOUT_PORT}={} {CMDLINE_STDERR_PORT}={} \ + {CMDLINE_RESULT_PORT}={}", + self.init_trace.port.as_raw(), + self.test_stdout.port.as_raw(), + self.test_stderr.port.as_raw(), + self.result.port.as_raw(), + ) + } + + /// Parses vsock port assignments from a kernel command-line string. + /// + /// Returns `None` if any of the three port parameters are missing, + /// cannot be parsed as `u32`, or would equal `VMADDR_PORT_ANY`. + pub fn parse_kernel_cmdline(cmdline: &str) -> Option { + let mut trace_port: Option = None; + let mut stdout_port: Option = None; + let mut stderr_port: Option = None; + let mut result_port: Option = None; + + for token in cmdline.split_whitespace() { + if let Some((key, value)) = token.split_once('=') { + match key { + k if k == CMDLINE_TRACE_PORT => { + trace_port = value.parse().ok(); + } + k if k == CMDLINE_STDOUT_PORT => { + stdout_port = value.parse().ok(); + } + k if k == CMDLINE_STDERR_PORT => { + stderr_port = value.parse().ok(); + } + k if k == CMDLINE_RESULT_PORT => { + result_port = value.parse().ok(); + } + _ => {} + } + } + } + + // Filter out VMADDR_PORT_ANY before constructing VsockPort. + let trace_port = trace_port.filter(|&p| p != u32::MAX)?; + let stdout_port = stdout_port.filter(|&p| p != u32::MAX)?; + let stderr_port = stderr_port.filter(|&p| p != u32::MAX)?; + let result_port = result_port.filter(|&p| p != u32::MAX)?; + + Some(Self { + cid: VM_GUEST_CID, + init_trace: VsockChannel { + port: VsockPort::new(trace_port), + label: "init-trace", + }, + test_stdout: VsockChannel { + port: VsockPort::new(stdout_port), + label: "test-stdout", + }, + test_stderr: VsockChannel { + port: VsockPort::new(stderr_port), + label: "test-stderr", + }, + result: VsockChannel { + port: VsockPort::new(result_port), + label: "test-result", + }, + }) + } +} + +impl std::fmt::Display for VsockAllocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "cid={}, trace={}, stdout={}, stderr={}, result={}", + self.cid, + self.init_trace.port, + self.test_stdout.port, + self.test_stderr.port, + self.result.port, + ) + } +} + +/// Base directory for VM runtime artifacts (sockets, logs, etc.). +pub const VM_RUN_DIR: &str = "/vm"; + +/// Path to the virtiofsd Unix socket. +pub const VIRTIOFSD_SOCKET_PATH: &str = "/vm/virtiofsd.sock"; + +/// Path to the vhost-vsock Unix socket used by cloud-hypervisor. +pub const VHOST_VSOCK_SOCKET_PATH: &str = "/vm/vhost.vsock"; + +/// Path to the hypervisor control-plane Unix socket. +pub const HYPERVISOR_API_SOCKET_PATH: &str = "/vm/hypervisor.sock"; + +/// Path to the serial/kernel console Unix socket. +pub const KERNEL_CONSOLE_SOCKET_PATH: &str = "/vm/kernel.sock"; + +/// Root filesystem share path exposed to the VM via virtiofs. +pub const VM_ROOT_SHARE_PATH: &str = "/vm.root"; + +/// The virtiofs tag used to identify the root filesystem inside the guest. +pub const VIRTIOFS_ROOT_TAG: &str = "root"; + +/// Path to the Unix socket of the *writable* virtiofs daemon. +/// +/// A second daemon exists so the writable window is enforced by the +/// server rather than by guest cooperation. The root daemon keeps +/// `--readonly`, so it cannot write anywhere no matter what the guest +/// does with its mount flags; this one has no `--readonly` but its +/// `--shared-dir` *is* the corpus directory, so it cannot see anything +/// else. +/// +/// The threat model is the point: the reason to fuzz inside a VM is that +/// the test is deliberately trying to make code malfunction against a real +/// kernel. A guest-side `mount -o remount,rw` must not be able to reach +/// the developer's source tree. +pub const VIRTIOFSD_CORPUS_SOCKET_PATH: &str = "/vm/virtiofsd-corpus.sock"; + +/// The virtiofs tag identifying the writable corpus share in the guest. +pub const VIRTIOFS_CORPUS_TAG: &str = "corpus"; + +/// Container path at which the host corpus directory is bind-mounted, so +/// that the writable virtiofs daemon can serve it. +pub const CORPUS_SHARE_PATH: &str = "/vm.corpus"; + +/// Directory name, relative to a test's source directory, that holds +/// generated fuzz corpora and crash artifacts. +/// +/// This is `bolero`'s layout: it writes under +/// `/__fuzz__/`. Granularity is this directory +/// rather than the per-test subdirectory beneath it, because the per-test +/// name comes from `bolero`'s own `fuzz_dir()` derivation (which strips +/// `test_`/`fuzz_` affixes and is computed from the call site's +/// `type_name`). Depending on that would couple the mount layout to +/// `bolero` internals; a `__fuzz__` directory exists only to hold corpora, +/// so it is already a tight enough blast radius. +pub const CORPUS_DIR_NAME: &str = "__fuzz__"; + +/// The kernel command-line namespace `n-it` reads its own boot parameters from. +/// +/// Every key this crate defines is `{CMDLINE_NAMESPACE}.`, which is +/// also the shape of a kernel *module* parameter. `ModuleParam` rejects this +/// name for that reason: a test that set it would be redirecting the guest's +/// init protocol, not configuring a module, and would report that as a hang. +/// +/// The keys below still spell it out literally; folding them onto this +/// constant is worth doing and is not what it was added for. +pub const CMDLINE_NAMESPACE: &str = "n_it"; + +/// Kernel command-line key carrying the guest path at which the writable +/// corpus share should be mounted. +/// +/// Absent when the test declared no corpus, in which case `n-it` mounts +/// nothing and the guest stays entirely read-only. +pub const CMDLINE_CORPUS_MOUNT: &str = "n_it.corpus_mount"; + +// == The crashes share == +// +// A second writable window, with the same plumbing as the corpus one and +// the opposite lifetime. See [`FuzzDirs`] for why the engine needs two. + +/// Path to the Unix socket of the virtiofs daemon serving crash artifacts. +pub const VIRTIOFSD_CRASHES_SOCKET_PATH: &str = "/vm/virtiofsd-crashes.sock"; + +/// The virtiofs tag identifying the writable crashes share in the guest. +pub const VIRTIOFS_CRASHES_TAG: &str = "crashes"; + +/// Container path at which the host crashes directory is bind-mounted. +pub const CRASHES_SHARE_PATH: &str = "/vm.crashes"; + +/// Kernel command-line key carrying the guest path at which the writable +/// crashes share should be mounted. +/// +/// Absent when the engine named no separate artifact directory -- including +/// every run without an engine at all, where the corpus share is the only +/// writable window. +pub const CMDLINE_CRASHES_MOUNT: &str = "n_it.crashes_mount"; + +/// Container-tier environment variable carrying the guest path at which +/// the corpus share is mounted. +pub const ENV_CORPUS_MOUNT: &str = "N_VM_CORPUS_MOUNT"; + +/// Container-tier environment variable carrying the guest path at which +/// the crashes share is mounted. +pub const ENV_CRASHES_MOUNT: &str = "N_VM_CRASHES_MOUNT"; + +/// One writable window into the guest, described end to end. +/// +/// The two shares differ only in *which host directory backs them*; every +/// step between -- bind mount, daemon, tag, kernel command line, guest +/// mount -- is identical. Grouping the four constants that spell one share +/// lets each tier iterate [`WRITABLE_SHARES`] instead of carrying a second +/// copy of the same five-line sequence, which is how the first share's +/// pieces drifted apart in the first place. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WritableShare { + /// What this share is for, for logs and error messages. + pub role: &'static str, + /// virtiofs tag the guest mounts by. + pub tag: &'static str, + /// Container path the host directory is bind-mounted at. + pub container_path: &'static str, + /// Unix socket the daemon serving it listens on. + pub socket_path: &'static str, + /// Kernel command-line key carrying the guest mount point. + pub cmdline_key: &'static str, + /// Container-tier environment variable carrying the guest mount point. + /// + /// The guest path is a *remapped host* path, so only the host tier can + /// compute it -- the container has never seen the host's workspace. + /// This carries it inward, the same way [`ENV_BACKEND`] and + /// [`ENV_ENGINE_TIME_LIMIT`] carry the other facts a container cannot + /// discover for itself. + pub env_key: &'static str, +} + +/// The share holding generated inputs. +pub const CORPUS_SHARE: WritableShare = WritableShare { + role: "corpus", + tag: VIRTIOFS_CORPUS_TAG, + container_path: CORPUS_SHARE_PATH, + socket_path: VIRTIOFSD_CORPUS_SOCKET_PATH, + cmdline_key: CMDLINE_CORPUS_MOUNT, + env_key: ENV_CORPUS_MOUNT, +}; + +/// The share holding crash artifacts. +pub const CRASHES_SHARE: WritableShare = WritableShare { + role: "crashes", + tag: VIRTIOFS_CRASHES_TAG, + container_path: CRASHES_SHARE_PATH, + socket_path: VIRTIOFSD_CRASHES_SOCKET_PATH, + cmdline_key: CMDLINE_CRASHES_MOUNT, + env_key: ENV_CRASHES_MOUNT, +}; + +/// Every writable window a guest can be given, in a fixed order. +/// +/// Fixed and small on purpose: each entry costs a daemon, a socket and a +/// pre-created mount point, so this is a closed set rather than something +/// a test can extend. +pub const WRITABLE_SHARES: [WritableShare; 2] = [CORPUS_SHARE, CRASHES_SHARE]; + +/// Well-known directory inside the VM guest where the test binary +/// directory is mounted. +/// +/// The `vmroot` nix derivation pre-creates this directory so that Docker +/// can bind-mount the host-side binary directory at +/// `{VM_ROOT_SHARE_PATH}/{VM_TEST_BIN_DIR}` without needing to create +/// intermediate directories on the (read-only) nix store path. +/// +/// Inside the VM guest, the test binary is executed as +/// `/{VM_TEST_BIN_DIR}/{binary_name}`. +pub const VM_TEST_BIN_DIR: &str = "test-bin"; + +/// Well-known directory inside the VM guest where the host's cargo +/// workspace root is mounted read-write, and which `n-it` makes the test +/// process's working directory. +/// +/// This exists for tooling that resolves paths captured at compile time. +/// `bolero` is the motivating case: `bolero::check!()` records `file!()` +/// (which cargo makes *workspace-root* relative, e.g. +/// `mgmt/tests/reconcile.rs`) and later canonicalizes it to locate a +/// corpus directory. Nothing resolves in a guest whose working directory +/// is `/` and which cannot see the source tree, so the test aborts before +/// generating a single input. +/// +/// Mounting the workspace at a *fixed* guest path and running the test +/// from it is enough: the relative `file!()` then canonicalizes against +/// this directory. Matching the host's absolute workspace path inside the +/// guest would also work -- `bolero` falls back to walking +/// `CARGO_MANIFEST_DIR`'s ancestors -- but that path varies per developer +/// and per CI runner, so it cannot be baked into the `vmroot` derivation +/// that has to pre-create the mount point. +/// +/// Currently mounted **read-only**: virtiofsd serves the whole root share +/// with `--readonly`, so the guest cannot write here even though the +/// directory appears as its own mount (`--announce-submounts` makes it +/// one). That is sufficient to read an existing corpus, but generated +/// inputs and crash artifacts cannot yet persist back to the host tree. +pub const VM_WORKSPACE_DIR: &str = "workspace"; + +/// Environment variable naming the host cargo workspace root to mount at +/// [`VM_WORKSPACE_DIR`]. +/// +/// When unset, the workspace root is discovered by walking up from the +/// current directory looking for a `Cargo.toml` that declares +/// `[workspace]` -- cargo runs tests with the working directory set to the +/// *package* root, not the workspace root, so the walk is necessary. +pub const ENV_WORKSPACE: &str = "N_VM_WORKSPACE"; + +// == Forwarded environment == + +/// Well-known guest directory holding the forwarded environment file. +/// +/// The host tier writes [`ENV_FILE_NAME`] into a directory it owns and +/// bind-mounts that directory here, read-only, alongside +/// [`VM_TEST_BIN_DIR`]. Like `test-bin`, the mount point is pre-created by +/// the `vmroot` derivation, because Docker cannot `mkdir` inside a +/// read-only nix store path. +/// +/// A file rather than the kernel command line: the values are arbitrary +/// (`BOLERO_LIBFUZZER_ARGS` is a space-separated list), and the cmdline has +/// both a length cap and no escaping convention that the guest and the host +/// could be relied on to agree about. +pub const VM_ENV_DIR: &str = "test-env"; + +/// Name of the forwarded environment file within [`VM_ENV_DIR`]. +pub const ENV_FILE_NAME: &str = "environ"; + +/// Absolute path of the forwarded environment file inside the guest. +pub const GUEST_ENV_FILE: &str = "/test-env/environ"; + +/// Environment variable prefixes forwarded from the host tier to the guest. +/// +/// `BOLERO_*` is the motivating case: a fuzz supervisor configures the +/// engine entirely through the environment (`BOLERO_LIBFUZZER_ARGS`, +/// `BOLERO_TEST_NAME`, `BOLERO_LIBTEST_HARNESS`), and bolero falls back to +/// its brief random driver when it does not see them. In a guest that +/// received no environment at all, that fallback is indistinguishable from +/// a successful fuzzing run -- it passes, quickly, having fuzzed nothing. +pub const FORWARDED_ENV_PREFIXES: &[&str] = &["BOLERO_"]; + +/// Comma-separated extra variable names to forward, beyond +/// [`FORWARDED_ENV_PREFIXES`]. +pub const ENV_FORWARD: &str = "N_VM_FORWARD_ENV"; + +/// Whether a variable name should be carried into the guest. +/// +/// `extra` is the raw value of [`ENV_FORWARD`], if set. +#[must_use] +pub fn is_forwarded(name: &str, extra: Option<&str>) -> bool { + if FORWARDED_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) { + return true; + } + extra.is_some_and(|list| { + list.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .any(|s| s == name) + }) +} + +/// Rewrite host workspace paths in a forwarded value so they resolve in the guest. +/// +/// The workspace is mounted at [`VM_WORKSPACE_DIR`] rather than at its host path, because that +/// path varies per developer and per CI runner and so cannot be baked into the `vmroot` +/// derivation. Anything forwarded that *names* a host path therefore points nowhere once it +/// arrives. +/// +/// `BOLERO_LIBFUZZER_ARGS` is why this exists. `cargo-bolero` computes the corpus and +/// artifact directories on the host and passes them as absolute paths: +/// +/// ```text +/// -artifact_prefix=/home/you/src/dataplane/mgmt/tests/__fuzz__/reconcile/crashes/ +/// ``` +/// +/// The guest has that tree at `/workspace/mgmt/tests/__fuzz__/...`, and it is writable there -- +/// see the `#[corpus]` share. Without the rewrite the fuzzer ran, found inputs, and wrote them +/// to a directory that did not exist, so nothing ever came back. +/// +/// Substring replacement rather than path parsing, because the value is an opaque +/// space-separated argument list in which paths appear both alone and glued to a flag by `=`. +/// A trailing separator on `host_root` is ignored so that `/a/b` and `/a/b/` behave alike. +#[must_use] +pub fn remap_workspace_paths(value: &str, host_root: &str) -> String { + let host_root = host_root.trim_end_matches('/'); + if host_root.is_empty() { + return value.to_owned(); + } + value.replace(host_root, &format!("/{VM_WORKSPACE_DIR}")) +} + +/// The variable through which a fuzz supervisor hands libfuzzer its command line. +/// +/// Named rather than spelled out at each use because it is the one forwarded value this crate +/// interprets rather than merely carries -- see [`strip_multiprocess_flags`]. +pub const ENV_LIBFUZZER_ARGS: &str = "BOLERO_LIBFUZZER_ARGS"; + +/// libfuzzer flags under which the fuzzer supervises copies of itself. +/// +/// Flag *names*, matched against the token up to its `=`, so that `-fork_corpus_groups=1` is left +/// alone. A libfuzzer flag has no bare form: the parser only recognises `-name=value`. +const MULTIPROCESS_FLAGS: &[&str] = &["jobs", "workers", "fork"]; + +/// Remove the libfuzzer flags that would have the guest fuzzer spawn workers. +/// +/// Under `-jobs`/`-workers` (`RunInMultipleProcesses`) or `-fork` (`FuzzWithFork`), libfuzzer stops +/// fuzzing and becomes a supervisor: it re-executes its own `argv[0]` once per job and reports what +/// the copies did. It launches them with `system(3)`, so each one needs `/bin/sh`, and the guest +/// root is the `vmroot` derivation, whose `/bin` holds `n-it` and nothing else. Every job therefore +/// exits 127 -- `system(3)`'s code for "could not exec the shell" -- and the supervisor, which +/// never ran a single input itself, reports failure. +/// +/// Dropping them rather than translating them to something the guest could satisfy. A worker is a +/// process, and the number of them worth running is bounded by memory rather than by cores (see +/// `development/code/running-tests.md`): `just fuzz` derives `-jobs` from the *host's* `nproc`, +/// while the whole guest has a gigabyte in total. More parallelism in a guest has to come from +/// more guests, not from more processes inside one -- the VM is what isolates a fuzz target from +/// the developer's machine, and a supervisor that shells out gains nothing while giving that up. +/// +/// The `fuzz-.log` each job would be redirected into is a second, independent wall: libfuzzer +/// writes it relative to the working directory, which in the guest is the workspace share, and +/// virtiofsd serves that `--readonly`. +#[must_use] +pub fn strip_multiprocess_flags(value: &str) -> String { + value + .split_whitespace() + .filter(|arg| { + let Some(flag) = arg.strip_prefix('-') else { + return true; + }; + let name = flag.split_once('=').map_or(flag, |(name, _)| name); + !MULTIPROCESS_FLAGS.contains(&name) + }) + .collect::>() + .join(" ") +} + +/// The libfuzzer flag bounding the engine's resident set, in mebibytes. +const RSS_LIMIT_FLAG: &str = "-rss_limit_mb"; + +/// Bound the guest engine's resident set to what the guest actually has. +/// +/// Appended rather than substituted: a command line that already names +/// `-rss_limit_mb` said so deliberately -- `just fuzz` forwards `-E=` flags +/// verbatim for exactly this -- and libfuzzer takes the *last* occurrence, +/// so appending unconditionally would silently overrule it. +/// +/// The caller supplies the limit because only the host tier knows the size +/// of the VM the engine will run in; see `VmConfig::fuzz_rss_limit_mib` for +/// how it is derived and why it is never zero. +#[must_use] +pub fn with_rss_limit(value: &str, limit_mib: u32) -> String { + let already_bounded = value.split_whitespace().any(|arg| { + arg.split_once('=') + .is_some_and(|(name, _)| name == RSS_LIMIT_FLAG) + }); + if already_bounded { + return value.to_owned(); + } + // A trailing separator on an empty command line would be a leading one, + // and libfuzzer treats an empty argument as a positional -- i.e. as a + // corpus directory named "". + if value.trim().is_empty() { + return format!("{RSS_LIMIT_FLAG}={limit_mib}"); + } + format!("{value} {RSS_LIMIT_FLAG}={limit_mib}") +} + +/// The libfuzzer flag naming where crash artifacts are written. +const ARTIFACT_PREFIX_FLAG: &str = "-artifact_prefix="; + +/// The two host directories a libfuzzer command line says the engine will +/// write to. +/// +/// Both are borrowed out of the command line rather than owned, because the +/// only caller is the host tier deciding what to bind-mount and it has the +/// string in hand. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FuzzDirs<'a> { + /// Where newly-generated inputs are saved. + /// + /// libfuzzer's first positional argument: the one corpus directory it + /// treats as writable. Later positionals are seed corpora it only + /// reads, so they need no writable share. + pub corpus: Option<&'a str>, + /// Where crash artifacts are written. + /// + /// [`None`] when it would duplicate [`corpus`](Self::corpus): two + /// virtiofs daemons serving one directory with `cache=always` is a + /// coherence hazard, and one share already covers it. + pub crashes: Option<&'a str>, +} + +/// Reads the directories a libfuzzer command line will write to. +/// +/// These are what a fuzz target actually needs write access to, and asking +/// the engine is the only way to know them: `cargo-bolero` computes them on +/// the host, from `--corpus-dir` and from its own `fuzz_dir()` derivation, +/// and the guest sees only the result. Deriving them independently in +/// `n-vm` would mean reimplementing that derivation and drifting from it. +/// +/// The two are separate trees, and deliberately so -- a corpus is a cache +/// that a run may want to start without, while a crash is a finding that +/// must not be lost -- which is why this returns two directories rather than +/// the one enclosing `__fuzz__` that the earlier single share assumed: +/// +/// ```text +/// /.fuzz-corpus/reconcile_fuzz <- corpus, out of tree +/// /mgmt/tests/__fuzz__/reconcile/crashes <- crashes, beside the test +/// ``` +/// +/// `-artifact_prefix` is a *prefix*, not a directory: libfuzzer forms an +/// artifact path by concatenating it with `crash-`. `cargo-bolero` +/// always ends it with `/`, making it a directory, but a hand-written +/// `-E=-artifact_prefix=/tmp/run-` is legal and means `/tmp`. +#[must_use] +pub fn fuzz_dirs(value: &str) -> FuzzDirs<'_> { + let corpus = value.split_whitespace().find(|arg| !arg.starts_with('-')); + + let crashes = value + .split_whitespace() + .filter_map(|arg| arg.strip_prefix(ARTIFACT_PREFIX_FLAG)) + .filter(|prefix| !prefix.is_empty()) + .map(|prefix| match prefix.strip_suffix('/') { + Some(dir) => dir, + // Not a directory but a filename stem; the directory is its parent. + None => prefix.rsplit_once('/').map_or("", |(dir, _)| dir), + }) + // Last wins, matching libfuzzer's own parser. + .rfind(|dir| !dir.is_empty()); + + FuzzDirs { + corpus, + crashes: crashes.filter(|dir| Some(*dir) != corpus), + } +} + +/// Container-tier environment variable carrying how long the guest's work +/// was declared to take, in whole seconds. +/// +/// Set by the host tier, read by the container tier, alongside +/// [`ENV_BACKEND`] and [`ENV_ACCEL`] and for the same reason: it is a fact +/// about *this run* that the container tier cannot discover for itself. A +/// fuzz campaign's length is chosen by whoever invoked `cargo bolero`, so it +/// reaches the test binary through the environment and nothing in the +/// compiled configuration knows it. +/// +/// Absent when no engine declared one, which is the ordinary case. +pub const ENV_ENGINE_TIME_LIMIT: &str = "N_VM_ENGINE_TIME_LIMIT"; + +/// The libfuzzer flag naming how long a campaign should run. +const MAX_TOTAL_TIME_FLAG: &str = "-max_total_time="; + +/// How long a libfuzzer command line says the campaign will run. +/// +/// This is the guest's *work*, which the VM has to outlive: a VM budget that +/// merely equalled it would kill the fuzzer somewhere in its last second, +/// before `DeathCallback` could write out anything it had found. +/// +/// `None` when the campaign is bounded some other way. `-runs=N` is the case +/// that matters, and it is deliberately not translated: how long a number of +/// executions takes is a property of the target, not of the flag, so guessing +/// would produce a budget with nothing behind it. +#[must_use] +pub fn max_total_time(value: &str) -> Option { + value + .split_whitespace() + .filter_map(|arg| arg.strip_prefix(MAX_TOTAL_TIME_FLAG)) + .filter_map(|secs| secs.parse::().ok()) + .map(Duration::from_secs) + // Last wins, matching libfuzzer's own parser, which overwrites a flag + // each time it sees it rather than rejecting the repeat. + .next_back() +} + +/// Encode variables as NUL-separated `KEY=VALUE` records. +/// +/// The same shape as `/proc/self/environ`, and for the same reason: NUL is +/// the one byte that cannot appear in an environment variable, so this +/// needs no escaping and cannot be confused by a value containing spaces, +/// newlines, or quotes. +#[must_use] +pub fn encode_environ<'a, I>(vars: I) -> Vec +where + I: IntoIterator, +{ + let mut out = Vec::new(); + for (key, value) in vars { + out.extend_from_slice(key.as_bytes()); + out.push(b'='); + out.extend_from_slice(value.as_bytes()); + out.push(0); + } + out +} + +/// Decode what [`encode_environ`] wrote. +/// +/// Records that are empty, non-UTF-8, or missing a `=` are skipped rather +/// than aborting the boot: the caller logs what did arrive, which is more +/// useful than failing a VM over one malformed record. +#[must_use] +pub fn decode_environ(bytes: &[u8]) -> Vec<(String, String)> { + bytes + .split(|b| *b == 0) + .filter(|record| !record.is_empty()) + .filter_map(|record| { + let text = core::str::from_utf8(record).ok()?; + let (key, value) = text.split_once('=')?; + if key.is_empty() { + return None; + } + Some((key.to_owned(), value.to_owned())) + }) + .collect() +} + +#[cfg(test)] +mod remap_tests { + use super::*; + + #[test] + fn a_host_path_becomes_the_guest_mount() { + let got = remap_workspace_paths( + "-artifact_prefix=/home/you/src/dp/mgmt/tests/__fuzz__/crashes/", + "/home/you/src/dp", + ); + assert_eq!( + got, + "-artifact_prefix=/workspace/mgmt/tests/__fuzz__/crashes/" + ); + } + + #[test] + fn every_occurrence_is_rewritten() { + let got = remap_workspace_paths("/w/corpus /w/crashes -x=/w/c/", "/w"); + assert_eq!(got, "/workspace/corpus /workspace/crashes -x=/workspace/c/"); + } + + #[test] + fn a_trailing_separator_on_the_root_changes_nothing() { + assert_eq!( + remap_workspace_paths("/w/corpus", "/w/"), + remap_workspace_paths("/w/corpus", "/w"), + ); + } + + /// An out-of-workspace caller has no `/workspace`, and an empty root would otherwise splice + /// the mount point between every character. + #[test] + fn an_empty_root_is_left_alone() { + assert_eq!(remap_workspace_paths("/w/corpus", ""), "/w/corpus"); + } + + #[test] + fn a_value_naming_no_host_path_is_untouched() { + assert_eq!( + remap_workspace_paths("-timeout=10 -jobs=1", "/w"), + "-timeout=10 -jobs=1" + ); + } +} + +#[cfg(test)] +mod multiprocess_tests { + use super::*; + + /// The shape `just fuzz` produces: positional corpus and crashes directories, then flags. + #[test] + fn the_job_flags_go_and_everything_else_stays() { + let got = strip_multiprocess_flags( + "/corpus /crashes -artifact_prefix=/crashes/ -timeout=10 \ + -max_total_time=60 -jobs=32 -len_control=0", + ); + assert_eq!( + got, + "/corpus /crashes -artifact_prefix=/crashes/ -timeout=10 -max_total_time=60 -len_control=0", + ); + } + + #[test] + fn every_flag_that_shells_out_is_removed() { + assert_eq!(strip_multiprocess_flags("-jobs=4 -workers=2 -fork=1"), ""); + } + + #[test] + fn an_engine_that_named_no_bound_is_given_the_guests() { + assert_eq!( + with_rss_limit("/corpus -artifact_prefix=/crashes/ -timeout=10", 896), + "/corpus -artifact_prefix=/crashes/ -timeout=10 -rss_limit_mb=896", + ); + } + + /// libfuzzer takes the last occurrence, so appending to a command line + /// that already names one would silently overrule a deliberate choice. + #[test] + fn an_engine_that_named_its_own_bound_keeps_it() { + let asked = "/corpus -rss_limit_mb=64 -timeout=10"; + assert_eq!(with_rss_limit(asked, 896), asked); + } + + /// An empty command line must not gain a leading separator: libfuzzer + /// reads an empty argument as a positional, i.e. as a corpus directory + /// with no name. + #[test] + fn an_empty_command_line_gains_only_the_bound() { + assert_eq!(with_rss_limit("", 896), "-rss_limit_mb=896"); + } + + /// Prefix matching would take this one too, and it names an in-process corpus strategy that + /// only `-fork` ever reads -- so removing it would be silently changing a setting rather than + /// removing a mode the guest cannot run. + #[test] + fn a_flag_merely_starting_with_a_stripped_name_survives() { + let args = "-fork_corpus_groups=1 -jobs_are_not_a_flag"; + assert_eq!(strip_multiprocess_flags(args), args); + } + + /// A positional path is not a flag, however it is spelled. + #[test] + fn positional_arguments_are_never_matched() { + let args = "/corpus/jobs /crashes/fork"; + assert_eq!(strip_multiprocess_flags(args), args); + } + + /// `bolero` splits this value on a single space rather than on whitespace, so a run of two + /// spaces reaches libfuzzer as an empty `argv` entry. Removing a flag from the middle of the + /// list must not leave one behind. + #[test] + fn removal_leaves_no_empty_argument_behind() { + let got = strip_multiprocess_flags("/corpus -jobs=4 -timeout=10"); + assert!( + !got.split(' ').any(str::is_empty), + "empty argv entry in {got:?}", + ); + assert_eq!(got, "/corpus -timeout=10"); + } + + #[test] + fn a_command_line_with_nothing_to_strip_is_unchanged() { + let args = "/corpus /crashes -timeout=10"; + assert_eq!(strip_multiprocess_flags(args), args); + } +} + +#[cfg(test)] +mod scratch_root_tests { + use super::*; + use std::path::{Path, PathBuf}; + + /// A directory tree that removes itself, so a failing assertion does + /// not leave one behind. + struct TempTree(PathBuf); + + impl TempTree { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "n-vm-scratch-{pid}-{name}", + pid = std::process::id(), + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp tree"); + Self(dir) + } + + fn make(&self, rel: &str) -> PathBuf { + let path = self.0.join(rel); + std::fs::create_dir_all(&path).expect("subdirectory"); + path + } + } + + impl Drop for TempTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// The case that sent an IDE's test runner to `NotFound`: cargo starts + /// a test in the package directory, one level below the roots. + #[test] + fn the_roots_are_found_from_a_package_subdirectory() { + let tree = TempTree::new("nested"); + tree.make("testroot"); + tree.make("vmroot"); + let pkg = tree.make("n-vm/tests"); + + let roots = ScratchRoots::from_ancestors_of(&pkg).expect("found by walking up"); + assert!(roots.test_root.ends_with("testroot")); + assert!(roots.vm_root.ends_with("vmroot")); + } + + /// The workspace root itself still resolves, which is what `just test` + /// relied on before the walk existed. + #[test] + fn the_roots_are_found_in_the_directory_that_holds_them() { + let tree = TempTree::new("here"); + tree.make("testroot"); + tree.make("vmroot"); + + assert!(ScratchRoots::from_ancestors_of(&tree.0).is_some()); + } + + /// Half a pair is not a pair. Taking `testroot` from one ancestor and + /// `vmroot` from another would match a container image against a guest + /// filesystem never built alongside it. + #[test] + fn one_root_without_the_other_is_not_a_match() { + let tree = TempTree::new("half"); + tree.make("testroot"); + let pkg = tree.make("n-vm"); + + assert!(ScratchRoots::from_ancestors_of(&pkg).is_none()); + } + + /// A tree with no roots anywhere above it still reports nothing, so the + /// error keeps naming `just setup-roots`. + #[test] + fn a_tree_without_roots_finds_nothing() { + let tree = TempTree::new("bare"); + let pkg = tree.make("some/deep/path"); + + assert!(ScratchRoots::from_ancestors_of(&pkg).is_none()); + } + + /// Resolution returns absolute, symlink-free paths: the roots are + /// symlinks into the nix store, and the container bind-mounts what they + /// point at. + #[test] + fn the_resolved_roots_are_canonical() { + let tree = TempTree::new("canon"); + tree.make("testroot"); + tree.make("vmroot"); + let pkg = tree.make("pkg"); + + let roots = ScratchRoots::from_ancestors_of(&pkg).expect("found"); + assert!(roots.test_root.is_absolute()); + assert_eq!( + roots.test_root, + std::fs::canonicalize(&roots.test_root).expect("canonical") + ); + assert!(!roots.test_root.starts_with(Path::new("pkg"))); + } +} + +#[cfg(test)] +mod fuzz_dirs_tests { + use super::*; + + /// The exact command line observed from `just fuzz reconcile_fuzz`. + /// + /// Verbatim rather than reduced: the point of this parser is to agree + /// with what `cargo-bolero` actually emits, and a hand-simplified + /// sample cannot show that the two directories live in unrelated trees. + const REAL: &str = "/ws/.fuzz-corpus/reconcile_fuzz \ + /ws/mgmt/tests/__fuzz__/reconcile/crashes \ + -artifact_prefix=/ws/mgmt/tests/__fuzz__/reconcile/crashes/ \ + -timeout=10 -max_total_time=60 -max_len=65536 -jobs=32 -len_control=0"; + + #[test] + fn the_two_directories_are_read_from_a_real_command_line() { + let dirs = fuzz_dirs(REAL); + assert_eq!(dirs.corpus, Some("/ws/.fuzz-corpus/reconcile_fuzz")); + assert_eq!( + dirs.crashes, + Some("/ws/mgmt/tests/__fuzz__/reconcile/crashes"), + ); + } + + /// Later positionals are seed corpora libfuzzer only reads. + /// + /// The crashes directory is itself passed as one, which is why "first + /// positional" and not "every positional" is what needs a writable + /// share. + #[test] + fn only_the_first_positional_is_writable() { + let dirs = fuzz_dirs("/corpus /seed-a /seed-b -artifact_prefix=/crashes/"); + assert_eq!(dirs.corpus, Some("/corpus")); + assert_eq!(dirs.crashes, Some("/crashes")); + } + + /// One directory, not two shares over it. + /// + /// Two virtiofs daemons serving the same tree with `cache=always` is + /// the coherence hazard the split has to avoid, so an engine that names + /// one directory twice gets one share. + #[test] + fn a_directory_named_twice_yields_one_share() { + let dirs = fuzz_dirs("/shared -artifact_prefix=/shared/"); + assert_eq!(dirs.corpus, Some("/shared")); + assert_eq!(dirs.crashes, None); + } + + /// `-artifact_prefix` is a prefix, so without a trailing slash the + /// directory is its parent. + #[test] + fn a_bare_artifact_prefix_names_its_parent_directory() { + let dirs = fuzz_dirs("/corpus -artifact_prefix=/tmp/run-"); + assert_eq!(dirs.crashes, Some("/tmp")); + } + + /// Nothing to mount when nothing was asked for. + #[test] + fn an_empty_command_line_names_nothing() { + let dirs = fuzz_dirs(""); + assert_eq!(dirs.corpus, None); + assert_eq!(dirs.crashes, None); + } + + /// A flags-only command line still has no corpus to write to. + #[test] + fn flags_alone_name_no_corpus() { + let dirs = fuzz_dirs("-timeout=10 -max_total_time=60"); + assert_eq!(dirs.corpus, None); + assert_eq!(dirs.crashes, None); + } + + /// The guest sees remapped paths, so the two must survive the rewrite + /// that `write_forwarded_env` applies to the same string. + #[test] + fn both_directories_survive_the_workspace_remap() { + let remapped = remap_workspace_paths(REAL, "/ws"); + let dirs = fuzz_dirs(&remapped); + assert_eq!(dirs.corpus, Some("/workspace/.fuzz-corpus/reconcile_fuzz")); + assert_eq!( + dirs.crashes, + Some("/workspace/mgmt/tests/__fuzz__/reconcile/crashes"), + ); + } +} + +#[cfg(test)] +mod campaign_time_tests { + use super::*; + + /// The shape `just fuzz` produces. + #[test] + fn the_campaign_length_is_read_from_the_command_line() { + let args = "/corpus /crashes -timeout=10 -max_total_time=600 -len_control=0"; + assert_eq!(max_total_time(args), Some(Duration::from_secs(600))); + } + + #[test] + fn a_command_line_without_one_declares_nothing() { + assert_eq!(max_total_time("/corpus /crashes -runs=1000"), None); + assert_eq!(max_total_time(""), None); + } + + /// Not `-max_total_time`, and must not be mistaken for it. + #[test] + fn the_per_input_timeout_is_a_different_flag() { + assert_eq!(max_total_time("-timeout=10"), None); + } + + #[test] + fn a_malformed_value_declares_nothing_rather_than_zero() { + assert_eq!(max_total_time("-max_total_time=soon"), None); + assert_eq!(max_total_time("-max_total_time="), None); + } + + /// libfuzzer overwrites a repeated flag rather than rejecting it, so the + /// budget must be derived from the one that will actually take effect. + #[test] + fn a_repeated_flag_resolves_the_way_libfuzzer_resolves_it() { + assert_eq!( + max_total_time("-max_total_time=60 -max_total_time=600"), + Some(Duration::from_secs(600)), + ); + } +} + +#[cfg(test)] +mod environ_test { + use super::{decode_environ, encode_environ, is_forwarded}; + + /// `BOLERO_LIBFUZZER_ARGS` is a space-separated list of libfuzzer flags, + /// which is precisely why this is a file and not the kernel cmdline. + #[test] + fn round_trips_values_with_spaces() { + let args = "/corpus /crashes -max_total_time=60 -jobs=4"; + let decoded = decode_environ(&encode_environ([("BOLERO_LIBFUZZER_ARGS", args)])); + assert_eq!( + decoded, + vec![("BOLERO_LIBFUZZER_ARGS".to_owned(), args.to_owned())] + ); + } + + #[test] + fn round_trips_awkward_values() { + let vars = [ + ("A", "has\nnewline"), + ("B", "has \"quotes\" and 'ticks'"), + ("C", ""), + ("D", "trailing="), + ]; + let decoded = decode_environ(&encode_environ(vars)); + assert_eq!(decoded.len(), 4); + assert_eq!(decoded[0].1, "has\nnewline"); + assert_eq!(decoded[1].1, "has \"quotes\" and 'ticks'"); + assert_eq!(decoded[2].1, ""); + assert_eq!(decoded[3].1, "trailing="); + } + + #[test] + fn skips_malformed_records() { + assert!(decode_environ(b"NOEQUALS\0").is_empty()); + assert!(decode_environ(b"=value\0").is_empty()); + assert!(decode_environ(b"\0\0\0").is_empty()); + } + + #[test] + fn forwards_by_prefix_and_explicit_name() { + assert!(is_forwarded("BOLERO_LIBFUZZER_ARGS", None)); + assert!(is_forwarded("BOLERO_TEST_NAME", None)); + assert!(!is_forwarded("PATH", None)); + assert!(!is_forwarded("HOME", None)); + assert!(is_forwarded("RUST_LOG", Some("RUST_LOG, MY_VAR"))); + assert!(is_forwarded("MY_VAR", Some("RUST_LOG, MY_VAR"))); + assert!(!is_forwarded("OTHER", Some("RUST_LOG, MY_VAR"))); + // An empty or degenerate list must not become "forward everything". + assert!(!is_forwarded("PATH", Some(""))); + assert!(!is_forwarded("PATH", Some(",,"))); + } +} + +// == Binary paths (inside the container) == + +// NOTE: the `qemu-system-` binary path is architecture-specific and +// lives on `n_vm::Arch::qemu_system_binary`, not here, so the aarch64 path +// can never silently resolve to an x86 default. +// +// The guest kernel image is *not* a constant at all: it is looked up in the +// kernel manifest below, because which kernels exist is a fact about the nix +// build, not about this protocol. + +/// Path to the kernel manifest inside the container. +/// +/// nix writes this file into `testroot`, and every first-level `testroot` +/// entry is bind-mounted at the container root (see +/// `n_vm::container`), so it lands here. It declares which guest kernels +/// were built and where their images are -- the single source of truth that +/// keeps the nix build and the Rust test tiers from disagreeing. +/// +/// This exists because cargo must never invoke nix: the artifacts are +/// materialized first (`just setup-roots`), and the tests only ever read +/// them. +pub const KERNEL_MANIFEST_PATH: &str = "/n-vm-manifest.json"; + +/// Selects a kernel profile by name, overriding the manifest's `default`. +/// +/// A *run mode*, not a per-test setting: it names the environment the whole +/// invocation runs in, e.g. `N_VM_PROFILE=qemu cargo test`. Tests that +/// cannot run in the selected environment skip with a reason rather than +/// failing, because "this environment does not suit this test" is a fact +/// about the pairing and not a defect in either. +/// +/// Set on the host and forwarded into the container, so both tiers agree on +/// which profile is in play. +pub const ENV_PROFILE: &str = "N_VM_PROFILE"; + +/// File to append a record to whenever a test is skipped. +/// +/// libtest has no run-time "skipped" state -- `#[ignore]` is decided at +/// compile time -- so a test that skips is counted as *passed*, and the +/// reason it printed is swallowed by output capture unless the test also +/// fails. A suite can therefore report a clean run having actually +/// exercised almost nothing, which is the failure mode this exists to +/// prevent. +/// +/// Writing to a file rather than to stderr is what makes the record +/// survive: it is outside libtest's capture, and outside the +/// process-per-test model that nextest uses, so records from a whole run +/// accumulate in one place that CI can assert on. +/// +/// One JSON object per line, appended. Unset means no record is kept, +/// which is the default: this costs nothing when nobody is looking. +pub const ENV_SKIP_LOG: &str = "N_VM_SKIP_LOG"; + +/// When set to a non-empty value, a skipped test fails instead. +/// +/// For a run that is *supposed* to exercise everything -- a release gate +/// against the production kernel, say -- where a skip is not a neutral +/// outcome but a hole in the thing being certified. +/// +/// Deliberately not the default: a skip is the correct answer to a genuine +/// mismatch, such as cloud-hypervisor being asked to emulate a foreign +/// architecture. +pub const ENV_STRICT_SKIPS: &str = "N_VM_STRICT_SKIPS"; + +/// Overrides virtiofsd's `--cache` mode for the guest's read-only share. +/// +/// Unset uses `always`, which is what makes an aarch64 guest run at all -- +/// see the rationale where virtiofsd is launched. Set to `auto` or `never` +/// to get virtiofsd's other modes back. +/// +/// Exists as a run-time knob rather than a build-time constant because the +/// guest's failure modes are sensitive to the *layout* of the guest test +/// binary: rebuilding `n-vm` to change a virtiofsd flag also changes the +/// binary under test, which confounds the comparison it was meant to make. +/// One build plus this variable keeps the cache mode the only difference +/// between two runs -- which is how `auto` was identified as the cause. +pub const ENV_VIRTIOFS_CACHE: &str = "N_VM_VIRTIOFS_CACHE"; + +/// Directory holding per-profile kernel artifacts inside the container. +/// +/// Paths in the manifest are absolute and already include this prefix; the +/// constant exists so the nix side and the tests agree on one spelling. +pub const KERNELS_DIR: &str = "/kernels"; + +/// Path to the `n-it` init system binary inside the container. +/// +/// This binary is passed as the `init=` kernel command-line argument so +/// that it runs as PID 1 inside the VM guest. +pub const INIT_BINARY_PATH: &str = "/bin/n-it"; + +/// Path to the virtiofsd binary inside the container. +/// +/// virtiofsd shares the container's filesystem into the VM via virtiofs. +pub const VIRTIOFSD_BINARY_PATH: &str = "/bin/virtiofsd"; + +/// Path to the cloud-hypervisor binary inside the container. +/// +/// **Backend-specific**: used only by the +/// [`CloudHypervisor`](../n_vm/cloud_hypervisor/struct.CloudHypervisor.html) +/// backend. +pub const CLOUD_HYPERVISOR_BINARY_PATH: &str = "/bin/cloud-hypervisor"; + +// -- Tests ------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use super::*; + + // -- VsockCid range constants ------------------------------------- + + #[test] + fn guest_min_cid_is_three() { + assert_eq!(VsockCid::GUEST_MIN.as_raw(), 3); + } + + #[test] + fn guest_max_cid_is_below_u32_max() { + assert_eq!(VsockCid::GUEST_MAX.as_raw(), u32::MAX as u64 - 1); + } + + // -- VsockPort range constants ------------------------------------ + + #[test] + fn dynamic_port_min_is_1024() { + assert_eq!(VsockPort::DYNAMIC_MIN.as_raw(), 1024); + } + + #[test] + fn dynamic_port_max_is_below_u32_max() { + assert_eq!(VsockPort::DYNAMIC_MAX.as_raw(), u32::MAX - 1); + } + + // -- VsockAllocation round-trip ----------------------------------- + + #[test] + fn kernel_cmdline_round_trip() { + let alloc = VsockAllocation { + cid: VsockCid::new(42), + init_trace: VsockChannel { + port: VsockPort::new(50_000), + label: "init-trace", + }, + test_stdout: VsockChannel { + port: VsockPort::new(50_001), + label: "test-stdout", + }, + test_stderr: VsockChannel { + port: VsockPort::new(50_002), + label: "test-stderr", + }, + result: VsockChannel { + port: VsockPort::new(50_003), + label: "test-result", + }, + }; + + let fragment = alloc.kernel_cmdline_fragment(); + assert_eq!( + fragment, + "n_it.trace_port=50000 n_it.stdout_port=50001 n_it.stderr_port=50002 \ + n_it.result_port=50003", + ); + + // Embed in a realistic kernel cmdline with other parameters. + let cmdline = format!( + "console=ttyS0 ro rootfstype=virtiofs root=root {} init=/bin/n-it -- /test my_test", + fragment, + ); + + let parsed = + VsockAllocation::parse_kernel_cmdline(&cmdline).expect("should parse successfully"); + + assert_eq!(parsed.init_trace.port, alloc.init_trace.port); + assert_eq!(parsed.test_stdout.port, alloc.test_stdout.port); + assert_eq!(parsed.test_stderr.port, alloc.test_stderr.port); + assert_eq!(parsed.result.port, alloc.result.port); + } + + #[test] + fn parse_returns_none_on_missing_params() { + let cmdline = "console=ttyS0 n_it.trace_port=50000 n_it.stdout_port=50001"; + assert!( + VsockAllocation::parse_kernel_cmdline(cmdline).is_none(), + "should fail when stderr port is missing", + ); + } + + #[test] + fn parse_returns_none_on_invalid_port() { + let cmdline = "n_it.trace_port=abc n_it.stdout_port=50001 n_it.stderr_port=50002"; + assert!( + VsockAllocation::parse_kernel_cmdline(cmdline).is_none(), + "should fail when a port is not a valid u32", + ); + } + + #[test] + fn parse_rejects_vmaddr_port_any() { + let cmdline = format!( + "n_it.trace_port={} n_it.stdout_port=50001 n_it.stderr_port=50002", + u32::MAX, + ); + assert!( + VsockAllocation::parse_kernel_cmdline(&cmdline).is_none(), + "should reject VMADDR_PORT_ANY (u32::MAX)", + ); + } + + #[test] + fn with_defaults_matches_legacy_constants() { + let alloc = VsockAllocation::with_defaults(); + assert_eq!(alloc.cid, VM_GUEST_CID); + assert_eq!(alloc.init_trace, VsockChannel::INIT_TRACE); + assert_eq!(alloc.test_stdout, VsockChannel::TEST_STDOUT); + assert_eq!(alloc.test_stderr, VsockChannel::TEST_STDERR); + assert_eq!(alloc.result, VsockChannel::TEST_RESULT); + } + + #[test] + fn display_shows_all_fields() { + let alloc = VsockAllocation::with_defaults(); + let display = format!("{alloc}"); + assert!(display.contains("cid=3"), "{display}"); + assert!(display.contains("trace=123456"), "{display}"); + assert!(display.contains("stdout=123457"), "{display}"); + assert!(display.contains("stderr=123458"), "{display}"); + assert!(display.contains("result=123459"), "{display}"); + } + + #[test] + fn parse_returns_none_when_result_port_missing() { + let cmdline = "n_it.trace_port=50000 n_it.stdout_port=50001 n_it.stderr_port=50002"; + assert!( + VsockAllocation::parse_kernel_cmdline(cmdline).is_none(), + "should fail when the result port is missing", + ); + } + + // -- TestResult wire format --------------------------------------- + + #[test] + fn test_result_round_trip_pass() { + let result = TestResult::new(true, "exit status: 0"); + let parsed = TestResult::parse(&result.to_wire()).expect("should parse"); + assert_eq!(parsed, result); + assert!(parsed.passed); + } + + #[test] + fn test_result_round_trip_fail() { + let result = TestResult::new(false, "signal: 15 (SIGTERM)"); + let parsed = TestResult::parse(&result.to_wire()).expect("should parse"); + assert_eq!(parsed, result); + assert!(!parsed.passed); + } + + #[test] + fn test_result_parse_finds_line_amid_noise() { + let raw = format!( + "spurious leading output\n{}some trailing garbage\n", + TestResult::new(true, "ok").to_wire(), + ); + let parsed = TestResult::parse(&raw).expect("should locate the marked line"); + assert!(parsed.passed); + assert_eq!(parsed.detail, "ok"); + } + + #[test] + fn test_result_parse_absent_is_none() { + assert!( + TestResult::parse("no verdict here\ntest result: ok. 1 passed\n").is_none(), + "an absent verdict must not be mistaken for a pass", + ); + } + + #[test] + fn test_result_parse_unknown_tag_is_none() { + let raw = format!("{} maybe whatever\n", TestResult::WIRE_PREFIX); + assert!(TestResult::parse(&raw).is_none()); + } + + #[test] + fn test_result_parse_requires_prefix_boundary() { + // The prefix immediately followed by a tag (no separating + // whitespace) must NOT be mistaken for a verdict. + let raw = format!("{}pass extra\n", TestResult::WIRE_PREFIX); + assert!( + TestResult::parse(&raw).is_none(), + "a prefix without a trailing boundary must not parse as a pass", + ); + } + + #[test] + fn test_result_detail_is_single_line() { + let wire = TestResult::new(false, "line one\nline two").to_wire(); + assert_eq!(wire.matches('\n').count(), 1, "wire form: {wire:?}"); + } + + #[test] + fn test_result_parse_rejects_indented_prefix() { + // A result line must start the line; an indented echo of a result + // line inside other output must not be mistaken for a verdict. + let raw = format!(" {} pass forged\n", TestResult::WIRE_PREFIX); + assert!( + TestResult::parse(&raw).is_none(), + "an indented prefix must not parse as a verdict", + ); + } + + #[test] + fn test_result_padded_detail_round_trips() { + let result = TestResult::new(true, " exit status: 0 "); + let parsed = TestResult::parse(&result.to_wire()).expect("should parse"); + assert_eq!(parsed.detail, "exit status: 0"); + assert_eq!( + parsed, + TestResult::parse(&parsed.to_wire()).expect("idempotent") + ); + } +} + +#[cfg(test)] +mod host_share_tests { + use super::{HOST_SHARE_STORE_SUBDIR, NIX_STORE_DIR, host_visible_path_in}; + + #[test] + fn without_a_share_every_path_is_left_alone() { + for path in [ + NIX_STORE_DIR, + "/nix/store/abc-vm-root", + "/home/runner/_work/dataplane/dataplane", + "/dev/hugepages", + ] { + assert_eq!( + host_visible_path_in(None, path), + path, + "a daemon on this host resolves these itself", + ); + } + } + + #[test] + fn the_store_directory_itself_is_redirected() { + assert_eq!( + host_visible_path_in(Some("/w/.share"), NIX_STORE_DIR), + format!("/w/.share/{HOST_SHARE_STORE_SUBDIR}"), + ); + } + + #[test] + fn a_store_entry_keeps_its_name_below_the_share() { + assert_eq!( + host_visible_path_in(Some("/w/.share"), "/nix/store/abc-vm-root"), + format!("/w/.share/{HOST_SHARE_STORE_SUBDIR}/abc-vm-root"), + ); + assert_eq!( + host_visible_path_in(Some("/w/.share"), "/nix/store/abc-testroot/bin/qemu"), + format!("/w/.share/{HOST_SHARE_STORE_SUBDIR}/abc-testroot/bin/qemu"), + ); + } + + #[test] + fn a_path_outside_the_store_is_left_alone() { + // The workspace and the nextest archive are already on a filesystem + // both namespaces share; rewriting them would break the mount. + for path in [ + "/home/runner/_work/dataplane/dataplane", + "/home/runner/_work/_temp/nextest-archive-x/target/debug/deps", + "/dev/hugepages", + ] { + assert_eq!(host_visible_path_in(Some("/w/.share"), path), path); + } + } + + #[test] + fn a_sibling_of_the_store_is_not_a_store_path() { + // `/nix/storage` shares a textual prefix with `/nix/store` and is not + // below it. A plain `strip_prefix` on the bare directory would rewrite + // it, and the mount would then name a path that exists nowhere. + assert_eq!( + host_visible_path_in(Some("/w/.share"), "/nix/storage/thing"), + "/nix/storage/thing", + ); + } +} diff --git a/n-vm/Cargo.toml b/n-vm/Cargo.toml new file mode 100644 index 0000000000..041cd8cdcd --- /dev/null +++ b/n-vm/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "dataplane-n-vm" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true + +[lib] +name = "n_vm" + +[features] +# Builds the `n-vm-reap` maintenance tool. Off by default so that a plain +# `cargo test -p dataplane-n-vm` does not link a binary it will never run. +reap = [] + +# Cleans up containers a SIGKILL left behind; see the binary's own docs. +# Named with dashes to match how it is invoked, and pointed at explicitly +# because the file name is not a valid Rust identifier. +# +# Gated behind `reap` because cargo builds a package's binaries whenever it +# builds that package's integration tests, and a binary is not a fuzz +# target: under the sancov RUSTFLAGS a libFuzzer build uses it picks up +# instrumentation whose runtime only the fuzz target links, and fails on +# undefined `__sanitizer_cov_*` symbols. Keeping it out of the default set +# leaves `dataplane-n-vm` buildable under those flags. +[[bin]] +name = "n-vm-reap" +path = "src/bin/n-vm-reap.rs" +required-features = ["reap"] + +[dependencies] +# internal +n-vm-macros = { workspace = true } +n-vm-protocol = { workspace = true } + +# external +cloud-hypervisor-client = { workspace = true, default-features = false, features = [] } +command-fds = { workspace = true, default-features = false, features = ["tokio"] } +bollard = { workspace = true, default-features = false, features = ["pipe", "ssl_providerless"] } +# For target discovery only. `#[n_vm::test]` answers `cargo bolero list` on the host without +# entering the test body -- see `n_vm::bolero` -- which needs bolero's own `TargetLocation`. +bolero = { workspace = true, default-features = false, features = ["std"] } +futures = { workspace = true, default-features = false, features = ["default"] } +nix = { workspace = true, default-features = false, features = ["signal", "user"] } +qapi-qmp = { workspace = true, default-features = false, features = [] } +qapi-spec = { workspace = true, default-features = false, features = [] } +miette = { workspace = true, default-features = false, features = ["derive", "fancy"] } +rand = { workspace = true, default-features = false, features = ["thread_rng"] } +rtnetlink = { workspace = true, default-features = false, features = ["tokio_socket"] } +serde = { workspace = true, default-features = false, features = ["derive"] } +serde_json = { workspace = true, default-features = false, features = ["std"] } +thiserror = { workspace = true, default-features = false, features = [] } +tokio = { workspace = true, default-features = false, features = ["rt", "process", "macros", "fs", "net", "signal", "time", "io-util", "io-std", "rt-multi-thread"] } +tokio-stream = { workspace = true, default-features = false, features = [] } +tokio-util = { workspace = true, default-features = false, features = ["codec"] } +tokio-vsock = { workspace = true, default-features = false, features = [] } +tracing = { workspace = true, default-features = false, features = [] } +tracing-subscriber = { workspace = true, default-features = false, features = ["fmt"] } diff --git a/n-vm/src/abort_on_drop.rs b/n-vm/src/abort_on_drop.rs new file mode 100644 index 0000000000..f3ddebdba0 --- /dev/null +++ b/n-vm/src/abort_on_drop.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! RAII wrapper for tasks that must be cancelled on early return. + +use tokio::task::JoinHandle; + +/// A [`JoinHandle`] wrapper that aborts the task when dropped. +#[derive(Debug)] +pub struct AbortOnDrop { + inner: Option>, +} + +impl AbortOnDrop { + /// Wraps an existing [`JoinHandle`], arming the abort-on-drop behavior. + pub fn new(handle: JoinHandle) -> Self { + Self { + inner: Some(handle), + } + } + + /// Spawns a new task and wraps the resulting handle. + /// + /// This is a convenience shorthand for `AbortOnDrop::new(tokio::spawn(fut))`. + pub fn spawn(future: impl std::future::Future + Send + 'static) -> Self + where + T: Send + 'static, + { + Self::new(tokio::spawn(future)) + } + + /// Extracts the inner [`JoinHandle`], disarming abort-on-drop. + /// + /// # Panics + /// + /// Panics if called more than once (the handle has already been taken). + pub fn into_inner(mut self) -> JoinHandle { + self.inner + .take() + .expect("AbortOnDrop::into_inner called after handle was already taken") + } +} + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + if let Some(handle) = self.inner.take() { + handle.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[tokio::test] + async fn into_inner_disarms_abort() { + let completed = Arc::new(AtomicBool::new(false)); + let completed2 = completed.clone(); + + let guard = AbortOnDrop::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + completed2.store(true, Ordering::SeqCst); + }); + + let handle = guard.into_inner(); + handle.await.expect("task should complete successfully"); + + assert!( + completed.load(Ordering::SeqCst), + "task should have completed" + ); + } + + #[tokio::test] + async fn drop_aborts_task() { + let completed = Arc::new(AtomicBool::new(false)); + let completed2 = completed.clone(); + + let guard = AbortOnDrop::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + completed2.store(true, Ordering::SeqCst); + }); + + drop(guard); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + assert!( + !completed.load(Ordering::SeqCst), + "task should have been aborted, not completed" + ); + } +} diff --git a/n-vm/src/backend.rs b/n-vm/src/backend.rs new file mode 100644 index 0000000000..1607215918 --- /dev/null +++ b/n-vm/src/backend.rs @@ -0,0 +1,518 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Shared interface for cloud-hypervisor, QEMU, and future VM backends. + +use n_vm_protocol::VsockChannel; + +use crate::abort_on_drop::AbortOnDrop; +use crate::config::Accel; +use crate::error::VmError; +use crate::vm::TestVmParams; + +/// Normalized result of a hypervisor event stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HypervisorVerdict { + /// The VM shut down cleanly. + CleanShutdown, + /// The event stream reported a panic, an error, or no clean shutdown. + Failure, +} + +impl HypervisorVerdict { + /// Returns `true` if the VM shut down cleanly. + #[must_use] + pub fn is_success(self) -> bool { + matches!(self, Self::CleanShutdown) + } +} + +/// Resources produced by a successful hypervisor launch. +pub struct LaunchedHypervisor { + /// The hypervisor child process handle. + pub(crate) child: tokio::process::Child, + + /// Background task monitoring hypervisor lifecycle events. + pub(crate) event_watcher: AbortOnDrop<(B::EventLog, HypervisorVerdict)>, + + /// Backend-specific handle for lifecycle control. + pub(crate) controller: B::Controller, +} + +/// Hypervisor-specific VM lifecycle operations. +/// +/// Implementations translate [`TestVmParams`] into backend-native config, +/// spawn the VMM, watch lifecycle events, and provide best-effort shutdown. +#[expect( + async_fn_in_trait, + reason = "this trait is only used within the crate; auto-trait bounds on the \ + returned futures are not required" +)] +pub trait HypervisorBackend: Send + Sized + 'static { + /// Human-readable backend name for logs and diagnostics. + const NAME: &str; + + /// Whether this backend can run a guest whose architecture differs + /// from the host's, via software emulation (TCG). + /// + /// KVM-only backends (cloud-hypervisor) return `false`; such tests are + /// skipped when the guest architecture does not match the host. The + /// QEMU backend returns `true` and falls back to TCG for cross-arch + /// guests. This is the per-backend capability behind + /// [`RequestedBackend::resolve`]. + const CAN_EMULATE: bool; + + /// The collected event log produced by the backend's event monitor. + type EventLog: std::fmt::Display + std::fmt::Debug + Default + Send + 'static; + + /// Backend-specific handle for VM lifecycle control. + type Controller: Send + 'static; + + /// Spawns the hypervisor process, boots the VM, and starts event monitoring. + /// + /// # Errors + /// + /// Returns [`VmError`] if any step of the launch sequence fails. + async fn launch(params: &TestVmParams<'_>) -> Result, VmError>; + + /// Performs best-effort graceful shutdown of the VM and VMM. + async fn shutdown(controller: &Self::Controller); + + /// Binds a listener for the given [`VsockChannel`] and spawns a + /// background task that accepts a single connection and reads it to + /// EOF, returning the contents as a `String`. + /// + /// # Errors + /// + /// Returns [`VmError::VsockBind`] if the listener cannot be bound. + fn spawn_vsock_reader(channel: &VsockChannel) -> Result, VmError>; +} + +/// The backend a test *requested* (via `#[n_vm::test]`), before resolving it +/// against the host architecture. +/// +/// [`Default`](Self::Default) means the test did not name a backend; it +/// prefers cloud-hypervisor but tolerates falling back to QEMU under +/// emulation. The explicit variants mean the author named that backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestedBackend { + /// No backend named: prefer cloud-hypervisor, fall back to QEMU/TCG + /// for a cross-arch guest. + Default, + /// Explicitly `#[n_vm::test(cloud_hypervisor)]`: cannot emulate, so skipped + /// for a cross-arch guest. + CloudHypervisor, + /// Explicitly `#[n_vm::test(qemu)]`: emulates a cross-arch guest via TCG. + Qemu, +} + +/// The backend the container tier will actually boot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveBackend { + /// Boot cloud-hypervisor. + CloudHypervisor, + /// Boot QEMU. + Qemu, +} + +impl EffectiveBackend { + /// The wire value used in the [`ENV_BACKEND`](n_vm_protocol::ENV_BACKEND) + /// environment variable. + #[must_use] + pub const fn as_env(self) -> &'static str { + match self { + Self::CloudHypervisor => "cloud_hypervisor", + Self::Qemu => "qemu", + } + } + + /// Whether this backend can run a guest of a foreign architecture. + /// + /// Mirrors [`HypervisorBackend::CAN_EMULATE`], reachable from the value + /// rather than only from the type, because the host tier decides this + /// before it has monomorphised anything. + #[must_use] + pub const fn can_emulate(self) -> bool { + match self { + Self::Qemu => true, + Self::CloudHypervisor => false, + } + } + + /// Parses an [`ENV_BACKEND`](n_vm_protocol::ENV_BACKEND) value, + /// defaulting to cloud-hypervisor for an absent or unrecognised value + /// (the historical default backend). + #[must_use] + pub fn from_env(value: Option<&str>) -> Self { + match value { + Some("qemu") => Self::Qemu, + _ => Self::CloudHypervisor, + } + } +} + +/// The outcome of resolving a [`RequestedBackend`] against the host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BackendResolution { + /// Boot the given backend with the given acceleration mode. + Run { + /// The backend to boot. + backend: EffectiveBackend, + /// The acceleration mode. + accel: Accel, + }, + /// Skip the test; it requires a backend that cannot run here. + Skip { + /// Human-readable explanation, logged to the developer. + reason: String, + }, +} + +impl RequestedBackend { + /// Resolves the requested backend against whether the guest is + /// cross-architecture relative to the host. + /// + /// Policy (mirrors each backend's + /// [`CAN_EMULATE`](HypervisorBackend::CAN_EMULATE)): + /// + /// - Same-arch: honour the request, use KVM. cloud-hypervisor works. + /// - Cross-arch + [`Default`](Self::Default) or [`Qemu`](Self::Qemu): + /// run under QEMU/TCG (the test still runs). + /// - Cross-arch + explicit [`CloudHypervisor`](Self::CloudHypervisor): + /// skip -- cloud-hypervisor cannot emulate. + /// + /// `needs_qemu` reports whether the test's configuration asks for + /// something only QEMU provides (today: an emulated Intel NIC). + /// + /// `profile` is the hypervisor the selected kernel profile runs on. + /// When the test expressed no preference -- [`Default`](Self::Default), + /// which is the overwhelming majority -- the profile decides. That is + /// the point: an unpinned test means "anywhere", so it should run in + /// whichever environment is selected rather than in one particular one. + /// + /// Resolving `Default` to a *fixed* backend was the reason a Flatcar run + /// reported 16 passes while booting four VMs: every unpinned test landed + /// on cloud-hypervisor, which no QEMU profile has, and skipped. + /// + /// `None` means the profile could not be determined -- an unreadable + /// manifest -- in which case this falls back to the historical fixed + /// resolution and lets the container tier report the real problem with a + /// better message than a skip would give. + #[must_use] + pub fn resolve( + self, + cross_arch: bool, + needs_qemu: bool, + profile: Option, + ) -> BackendResolution { + use BackendResolution::{Run, Skip}; + + if let Some(profile) = profile { + // What the test actually requires, if anything at all. + let required = if needs_qemu { + Some(EffectiveBackend::Qemu) + } else { + match self { + Self::Default => None, + Self::CloudHypervisor => Some(EffectiveBackend::CloudHypervisor), + Self::Qemu => Some(EffectiveBackend::Qemu), + } + }; + + if let Some(required) = required + && required != profile + { + return Skip { + reason: format!( + "test requires {required:?}, but the selected kernel profile \ + runs on {profile:?}", + ), + }; + } + + if cross_arch && !profile.can_emulate() { + return Skip { + reason: "the selected profile's hypervisor cannot emulate a \ + foreign-architecture guest" + .to_owned(), + }; + } + + return Run { + backend: profile, + accel: if cross_arch { Accel::Tcg } else { Accel::Kvm }, + }; + } + + self.resolve_without_profile(cross_arch, needs_qemu) + } + + /// The pre-profile resolution, kept for the case where the manifest + /// cannot be read. + #[must_use] + fn resolve_without_profile(self, cross_arch: bool, needs_qemu: bool) -> BackendResolution { + use BackendResolution::{Run, Skip}; + use EffectiveBackend::{CloudHypervisor, Qemu}; + + // Only QEMU can satisfy the request, whatever the architecture says. + if needs_qemu { + return match self { + Self::Default | Self::Qemu => Run { + backend: Qemu, + accel: if cross_arch { Accel::Tcg } else { Accel::Kvm }, + }, + Self::CloudHypervisor => Skip { + reason: "the configured NIC model is emulated only by QEMU, \ + but the test pinned cloud-hypervisor" + .to_owned(), + }, + }; + } + + match (self, cross_arch) { + (Self::Default | Self::CloudHypervisor, false) => Run { + backend: CloudHypervisor, + accel: Accel::Kvm, + }, + (Self::Qemu, false) => Run { + backend: Qemu, + accel: Accel::Kvm, + }, + (Self::Default | Self::Qemu, true) => Run { + backend: Qemu, + accel: Accel::Tcg, + }, + (Self::CloudHypervisor, true) => Skip { + reason: "cloud-hypervisor cannot emulate a foreign-architecture \ + guest (host arch differs from the test's target arch)" + .to_owned(), + }, + } + } +} + +/// Normalises an architecture name to a canonical form so that the Docker +/// daemon's reporting (`x86_64`, `aarch64`) and Rust's +/// [`std::env::consts::ARCH`] (`x86_64`, `aarch64`) -- and the Go-style +/// `amd64` / `arm64` some tools emit -- compare equal. +#[must_use] +fn normalize_arch(arch: &str) -> &str { + match arch { + "amd64" | "x86_64" | "x86-64" => "x86_64", + "arm64" | "aarch64" => "aarch64", + other => other, + } +} + +/// Returns `true` if the guest (`target_arch`, i.e. the test binary's +/// compile-time architecture) differs from the host the Docker daemon +/// runs on (`daemon_arch`). +/// +/// Both are normalised first. qemu-user fakes `uname`, so the daemon's +/// self-reported architecture -- not `uname` inside the emulated process +/// -- is the reliable host signal. +#[must_use] +pub fn is_cross_arch(daemon_arch: &str, target_arch: &str) -> bool { + normalize_arch(daemon_arch) != normalize_arch(target_arch) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_honours_request_with_kvm() { + for req in [ + RequestedBackend::Default, + RequestedBackend::CloudHypervisor, + RequestedBackend::Qemu, + ] { + match req.resolve(false, false, None) { + BackendResolution::Run { accel, .. } => assert_eq!(accel, Accel::Kvm), + BackendResolution::Skip { .. } => panic!("native run must not skip: {req:?}"), + } + } + } + + #[test] + fn cross_default_falls_back_to_qemu_tcg() { + assert_eq!( + RequestedBackend::Default.resolve(true, false, None), + BackendResolution::Run { + backend: EffectiveBackend::Qemu, + accel: Accel::Tcg, + }, + ); + } + + #[test] + fn cross_qemu_uses_tcg() { + assert_eq!( + RequestedBackend::Qemu.resolve(true, false, None), + BackendResolution::Run { + backend: EffectiveBackend::Qemu, + accel: Accel::Tcg, + }, + ); + } + + #[test] + fn cross_explicit_cloud_hypervisor_skips() { + assert!(matches!( + RequestedBackend::CloudHypervisor.resolve(true, false, None), + BackendResolution::Skip { .. }, + )); + } + + // -- QEMU-only configurations ------------------------------------- + + /// An unpinned test that asks for something only QEMU provides is not a + /// contradiction -- it is a test that wants QEMU. Selecting it beats + /// making the author restate the backend they already implied. + #[test] + fn unpinned_qemu_only_config_selects_qemu() { + assert_eq!( + RequestedBackend::Default.resolve(false, true, None), + BackendResolution::Run { + backend: EffectiveBackend::Qemu, + accel: Accel::Kvm, + }, + ); + } + + /// Needing QEMU must not cost hardware acceleration when the guest + /// architecture matches the host; only a cross-arch guest falls to TCG. + #[test] + fn qemu_only_config_keeps_kvm_when_not_cross_arch() { + for req in [RequestedBackend::Default, RequestedBackend::Qemu] { + match req.resolve(false, true, None) { + BackendResolution::Run { backend, accel } => { + assert_eq!(backend, EffectiveBackend::Qemu); + assert_eq!(accel, Accel::Kvm, "{req:?} should keep KVM natively"); + } + BackendResolution::Skip { .. } => panic!("{req:?} should run"), + } + } + assert_eq!( + RequestedBackend::Qemu.resolve(true, true, None), + BackendResolution::Run { + backend: EffectiveBackend::Qemu, + accel: Accel::Tcg, + }, + ); + } + + // -- Profile-driven resolution ----------------------------------- + + /// The fix for the coverage hole: a test that named no backend must run + /// on whatever the selected profile provides, not on one fixed choice. + /// + /// Resolving `Default` to cloud-hypervisor regardless was why a Flatcar + /// run reported 16 passes while booting four VMs -- every unpinned test + /// landed on a hypervisor no QEMU profile has, and skipped. + #[test] + fn unpinned_test_adopts_the_profile_hypervisor() { + for profile in [EffectiveBackend::Qemu, EffectiveBackend::CloudHypervisor] { + assert_eq!( + RequestedBackend::Default.resolve(false, false, Some(profile)), + BackendResolution::Run { + backend: profile, + accel: Accel::Kvm, + }, + "an unpinned test should run on the profile's hypervisor ({profile:?})", + ); + } + } + + /// A test that *did* name a backend still skips where it does not fit -- + /// that is a real mismatch rather than an absence of preference. + #[test] + fn pinned_test_skips_on_a_profile_it_does_not_match() { + assert!(matches!( + RequestedBackend::CloudHypervisor.resolve(false, false, Some(EffectiveBackend::Qemu)), + BackendResolution::Skip { .. }, + )); + assert!(matches!( + RequestedBackend::Qemu.resolve(false, false, Some(EffectiveBackend::CloudHypervisor)), + BackendResolution::Skip { .. }, + )); + } + + /// A config needing an emulated NIC is a requirement like any other, so + /// it skips a cloud-hypervisor profile even when the test named nothing. + #[test] + fn qemu_only_config_skips_a_cloud_hypervisor_profile() { + assert!(matches!( + RequestedBackend::Default.resolve(false, true, Some(EffectiveBackend::CloudHypervisor)), + BackendResolution::Skip { .. }, + )); + } + + /// A cross-arch guest needs an emulating hypervisor; a profile whose + /// hypervisor cannot emulate is a skip rather than a doomed boot. + #[test] + fn cross_arch_skips_a_profile_that_cannot_emulate() { + assert!(matches!( + RequestedBackend::Default.resolve(true, false, Some(EffectiveBackend::CloudHypervisor)), + BackendResolution::Skip { .. }, + )); + assert_eq!( + RequestedBackend::Default.resolve(true, false, Some(EffectiveBackend::Qemu)), + BackendResolution::Run { + backend: EffectiveBackend::Qemu, + accel: Accel::Tcg, + }, + ); + } + + /// An unreadable manifest must not masquerade as an environment + /// mismatch: it falls back to the historical resolution and lets the + /// container tier report the real problem. + #[test] + fn absent_profile_falls_back_to_the_historical_resolution() { + assert_eq!( + RequestedBackend::Default.resolve(false, false, None), + BackendResolution::Run { + backend: EffectiveBackend::CloudHypervisor, + accel: Accel::Kvm, + }, + ); + } + + /// Pinning cloud-hypervisor *and* asking for a QEMU-only NIC is a + /// contradiction that `VmConfig::assert_valid_for` rejects at compile + /// time. `resolve` still has to answer, so it skips rather than + /// silently booting a VM that cannot honour the request. + #[test] + fn pinned_cloud_hypervisor_with_qemu_only_config_skips() { + for cross in [false, true] { + assert!( + matches!( + RequestedBackend::CloudHypervisor.resolve(cross, true, None), + BackendResolution::Skip { .. }, + ), + "cross={cross} should skip", + ); + } + } + + #[test] + fn arch_comparison_normalises() { + assert!(!is_cross_arch("x86_64", "x86_64")); + assert!(!is_cross_arch("amd64", "x86_64")); + assert!(!is_cross_arch("arm64", "aarch64")); + assert!(is_cross_arch("x86_64", "aarch64")); + assert!(is_cross_arch("aarch64", "x86_64")); + } + + #[test] + fn backend_env_round_trip() { + for b in [EffectiveBackend::CloudHypervisor, EffectiveBackend::Qemu] { + assert_eq!(EffectiveBackend::from_env(Some(b.as_env())), b); + } + assert_eq!( + EffectiveBackend::from_env(None), + EffectiveBackend::CloudHypervisor, + ); + } +} diff --git a/n-vm/src/bin/n-vm-reap.rs b/n-vm/src/bin/n-vm-reap.rs new file mode 100644 index 0000000000..e791bc7b7d --- /dev/null +++ b/n-vm/src/bin/n-vm-reap.rs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Removes test containers that `n-vm` left behind. +//! +//! The host tier cleans up after itself on every route it can reach: the +//! normal path, a panic, an early return, and -- since the signal handler in +//! `container.rs` -- `SIGTERM` and `SIGINT`. `SIGKILL` is the one it cannot, +//! because nothing can catch it. A test runner that escalates to `SIGKILL`, +//! an OOM kill, or a hard reboot therefore still strands a container. +//! +//! Stranded is worse than untidy. The container is owned by the daemon +//! rather than by the process that asked for it, so it keeps running with +//! nothing left to collect its result -- for a fuzz target, for the whole +//! remaining time budget. `auto_remove` is deliberately `false`, so even the +//! exited ones persist as records. +//! +//! Selection is by [`LABEL_OWNER`] alone, which is what makes removing in +//! bulk safe to offer: it cannot match a container this crate did not create. +//! +//! # Usage +//! +//! ```shell +//! n-vm-reap # list orphans and ask before removing +//! n-vm-reap --force # remove without asking +//! n-vm-reap --list # list only, never remove +//! n-vm-reap --all # include containers whose creator is still alive +//! ``` + +use std::collections::HashMap; +use std::io::Write; +use std::process::ExitCode; + +use bollard::query_parameters::{ListContainersOptionsBuilder, RemoveContainerOptionsBuilder}; +use n_vm_protocol::{LABEL_HOST_PID, LABEL_OWNER, LABEL_OWNER_VALUE, LABEL_TEST}; + +/// What the caller asked for. +struct Args { + /// Remove without asking for confirmation. + force: bool, + /// List and exit, removing nothing. + list_only: bool, + /// Include containers whose creating process is still running. + all: bool, +} + +impl Args { + /// Parses argv. + /// + /// Hand-rolled rather than pulled from `clap`: four flags do not justify + /// adding a dependency to a crate that is otherwise test infrastructure. + fn parse() -> Result { + let mut args = Self { + force: false, + list_only: false, + all: false, + }; + + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--force" | "-f" => args.force = true, + "--list" | "-l" => args.list_only = true, + "--all" | "-a" => args.all = true, + "--help" | "-h" => { + println!("{USAGE}"); + std::process::exit(0); + } + other => return Err(format!("unknown argument `{other}`\n\n{USAGE}")), + } + } + + if args.force && args.list_only { + return Err("--force and --list contradict each other".to_owned()); + } + + Ok(args) + } +} + +const USAGE: &str = "\ +n-vm-reap -- remove test containers n-vm left behind + +USAGE: + n-vm-reap [FLAGS] + +FLAGS: + -f, --force Remove without asking for confirmation + -l, --list List what would be removed, then exit + -a, --all Include containers whose creating process is still + running. Off by default: such a container usually + belongs to a live test run, and removing it would + break that run rather than tidy up after one. + -h, --help Print this message"; + +/// A container this crate created, as the daemon reports it. +struct Orphan { + id: String, + /// Docker's human-readable status, e.g. `Exited (101) 2 minutes ago`. + status: String, + /// Test the container was launched for, from [`LABEL_TEST`]. + test: String, + /// PID recorded at creation, if it parsed. + host_pid: Option, + /// Whether the container is still running. + running: bool, +} + +impl Orphan { + /// Whether the process that created this container is still around. + /// + /// A hint, not proof: PIDs are reused, so a live PID here may be some + /// unrelated process. That asymmetry is deliberate -- guessing "alive" + /// leaves a container behind for the next run to clean up, while + /// guessing "dead" removes one out from under a test that is still + /// using it. Only the first is recoverable, so an unparseable or + /// missing PID counts as alive and is left alone. + fn creator_alive(&self) -> bool { + let Some(pid) = self.host_pid else { + return true; + }; + std::path::Path::new(&format!("/proc/{pid}")).exists() + } +} + +fn main() -> ExitCode { + let args = match Args::parse() { + Ok(args) => args, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + }; + + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(e) => { + eprintln!("error: could not start a tokio runtime: {e}"); + return ExitCode::FAILURE; + } + }; + + runtime.block_on(run(&args)) +} + +async fn run(args: &Args) -> ExitCode { + let client = match bollard::Docker::connect_with_unix_defaults() { + Ok(client) => client, + Err(e) => { + eprintln!("error: could not reach the Docker daemon: {e}"); + return ExitCode::FAILURE; + } + }; + + let found = match list_owned(&client).await { + Ok(found) => found, + Err(e) => { + eprintln!("error: could not list containers: {e}"); + return ExitCode::FAILURE; + } + }; + + if found.is_empty() { + println!("no n-vm containers found"); + return ExitCode::SUCCESS; + } + + // Partition rather than filter, so the skipped ones can be reported. + // Silently ignoring them would read as "there were none", which is the + // one message that would send someone looking in the wrong place. + let (live, orphaned): (Vec<_>, Vec<_>) = found.into_iter().partition(|c| c.creator_alive()); + + let targets = if args.all { + orphaned.into_iter().chain(live).collect::>() + } else { + if !live.is_empty() { + println!( + "skipping {} container(s) whose creating process is still alive \ + (pass --all to include them):", + live.len(), + ); + for c in &live { + println!(" {}", describe(c)); + } + println!(); + } + orphaned + }; + + if targets.is_empty() { + println!("no orphaned n-vm containers to remove"); + return ExitCode::SUCCESS; + } + + println!("{} n-vm container(s):", targets.len()); + for c in &targets { + println!(" {}", describe(c)); + } + + if args.list_only { + return ExitCode::SUCCESS; + } + + if !args.force && !confirm(targets.len()) { + println!("aborted; nothing removed"); + return ExitCode::SUCCESS; + } + + let mut failed = 0usize; + for c in &targets { + // Forced, because a container that is still running would otherwise + // fail removal with a 409 and leave exactly the mess being cleaned. + let options = RemoveContainerOptionsBuilder::default().force(true).build(); + match client.remove_container(&c.id, Some(options)).await { + Ok(()) => println!("removed {}", short(&c.id)), + Err(e) => { + failed += 1; + eprintln!("error: failed to remove {}: {e}", short(&c.id)); + } + } + } + + if failed > 0 { + eprintln!("{failed} container(s) could not be removed"); + return ExitCode::FAILURE; + } + + ExitCode::SUCCESS +} + +/// Lists every container carrying [`LABEL_OWNER`], running or not. +async fn list_owned(client: &bollard::Docker) -> Result, bollard::errors::Error> { + let mut filters = HashMap::new(); + filters.insert( + "label".to_owned(), + vec![format!("{LABEL_OWNER}={LABEL_OWNER_VALUE}")], + ); + + let options = ListContainersOptionsBuilder::default() + // Exited containers are the common case, and they are invisible + // without this. + .all(true) + .filters(&filters) + .build(); + + let containers = client.list_containers(Some(options)).await?; + + Ok(containers + .into_iter() + .map(|c| { + let labels = c.labels.unwrap_or_default(); + Orphan { + id: c.id.unwrap_or_default(), + status: c.status.unwrap_or_else(|| "unknown".to_owned()), + test: labels + .get(LABEL_TEST) + .cloned() + .unwrap_or_else(|| "".to_owned()), + host_pid: labels.get(LABEL_HOST_PID).and_then(|p| p.parse().ok()), + // Paused and restarting count as live: all three still hold + // the VM and its resources, which is what the label is for. + running: c.state.is_some_and(|s| { + use bollard::models::ContainerSummaryStateEnum as State; + matches!(s, State::RUNNING | State::RESTARTING | State::PAUSED) + }), + } + }) + .collect()) +} + +/// One line describing a container, for the listing. +fn describe(c: &Orphan) -> String { + let pid = c + .host_pid + .map_or_else(|| "pid unknown".to_owned(), |p| format!("pid {p}")); + let running = if c.running { ", RUNNING" } else { "" }; + format!( + "{} {} [{}, {}{}]", + short(&c.id), + c.test, + c.status, + pid, + running + ) +} + +/// Docker's conventional short form of a container ID. +fn short(id: &str) -> &str { + id.get(..12).unwrap_or(id) +} + +/// Asks before removing. +/// +/// A non-tty answers "no": this is meant to be run from CI as well, and a +/// prompt nobody can answer must not be read as consent. CI should pass +/// `--force`. +fn confirm(count: usize) -> bool { + use std::io::IsTerminal; + + if !std::io::stdin().is_terminal() { + eprintln!("not a terminal; refusing to remove without --force"); + return false; + } + + print!("remove {count} container(s)? [y/N] "); + if std::io::stdout().flush().is_err() { + return false; + } + + let mut answer = String::new(); + if std::io::stdin().read_line(&mut answer).is_err() { + return false; + } + + matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") +} diff --git a/n-vm/src/cloud_hypervisor/error.rs b/n-vm/src/cloud_hypervisor/error.rs new file mode 100644 index 0000000000..74db49ea06 --- /dev/null +++ b/n-vm/src/cloud_hypervisor/error.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Error types specific to the cloud-hypervisor backend. +//! +//! These errors cover failure modes unique to cloud-hypervisor's +//! architecture: +//! +//! - **Event-monitor pipe** -- cloud-hypervisor uses an `--event-monitor +//! fd=N` argument with a Unix pipe for lifecycle events. Creating the +//! pipe, converting the sender to a blocking fd, and mapping it into the +//! child process are all CH-specific operations. +//! - **REST API** -- cloud-hypervisor separates process startup from VM +//! boot: the VMM process starts first, then the container tier issues +//! `create_vm` and `boot_vm` REST calls. QEMU boots the VM immediately +//! on process start, so these steps do not apply. +//! +//! Generic errors that apply to any hypervisor backend (e.g. spawning a +//! child process, waiting for sockets, `/dev/kvm` accessibility) remain in +//! [`VmError`](crate::error::VmError). + +/// Errors specific to the cloud-hypervisor [`HypervisorBackend`](crate::backend::HypervisorBackend) +/// implementation. +/// +/// These are wrapped into [`VmError::Backend`](crate::error::VmError::Backend) +/// by the [`CloudHypervisor`](super::CloudHypervisor) launch sequence, +/// preserving the full error chain for diagnostics while keeping the +/// generic [`VmError`](crate::error::VmError) enum free of +/// cloud-hypervisor-specific variants. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum CloudHypervisorError { + /// The event-monitor pipe between the container tier and + /// cloud-hypervisor could not be created. + /// + /// Cloud-hypervisor uses `--event-monitor fd=N` to stream lifecycle + /// events (boot, shutdown, panic, etc.) over a Unix pipe. This + /// error indicates the initial `pipe()` call failed. + #[error("failed to create event monitor pipe")] + #[diagnostic( + code(n_vm::cloud_hypervisor::event_pipe), + help( + "the initial pipe() syscall for cloud-hypervisor's --event-monitor \ + fd=N failed -- check system resource limits (ulimit -n)" + ) + )] + EventPipe(#[source] std::io::Error), + + /// The event-monitor pipe sender could not be converted to a blocking + /// file descriptor for fd-mapping into the hypervisor process. + /// + /// The pipe is created as a tokio async pipe, but the child-side fd + /// must be a regular blocking fd so that cloud-hypervisor (which does + /// its own I/O) can write to it directly. + #[error("failed to convert event monitor sender to blocking fd")] + #[diagnostic(code(n_vm::cloud_hypervisor::event_sender_fd))] + EventSenderFd(#[source] std::io::Error), + + /// File-descriptor mapping for the cloud-hypervisor child process + /// failed (e.g. the `command-fds` crate detected an fd collision). + /// + /// The inner value is a stringified `command_fds::FdMappingCollision` + /// because that type does not implement [`std::error::Error`]. + #[error("failed to set up fd mappings for cloud-hypervisor: {0}")] + #[diagnostic( + code(n_vm::cloud_hypervisor::fd_mapping), + help( + "this usually means an fd collision in the command-fds mapping; \ + check that no other code has claimed the target fd" + ) + )] + FdMapping(String), + + /// The event-monitor pipe was not readable after the hypervisor + /// process started, indicating the VMM did not emit its initial event. + /// + /// After spawning the cloud-hypervisor process, the container tier + /// waits for the first event to become readable on the pipe as a + /// signal that the VMM has initialised. This error means the pipe + /// never became readable. + #[error("event monitor pipe not readable after hypervisor start")] + #[diagnostic( + code(n_vm::cloud_hypervisor::event_monitor_not_readable), + help( + "cloud-hypervisor may have crashed before emitting its first \ + lifecycle event -- check the hypervisor stderr for details" + ) + )] + EventMonitorNotReadable(#[source] std::io::Error), + + /// The cloud-hypervisor REST API rejected the `create_vm` request. + /// + /// Cloud-hypervisor separates VMM startup from VM creation: after the + /// process starts and the API socket appears, the container tier sends + /// a `create_vm` request with the full [`VmConfig`]. This error + /// indicates that request was rejected. + /// + /// [`VmConfig`]: cloud_hypervisor_client::models::VmConfig + #[error("cloud-hypervisor API rejected create_vm: {reason}")] + #[diagnostic( + code(n_vm::cloud_hypervisor::vm_create), + help( + "the cloud-hypervisor REST API refused the VM configuration -- \ + check the `reason` field and cloud-hypervisor logs for details" + ) + )] + VmCreate { + /// Stringified error from the cloud-hypervisor API client. + /// + /// The generated client crate's error types do not implement + /// [`std::error::Error`], so the error is captured as a + /// debug-formatted string. + reason: String, + }, + + /// The cloud-hypervisor REST API rejected the `boot_vm` request. + /// + /// After a successful `create_vm`, the container tier sends `boot_vm` + /// to begin guest execution. This error indicates that request was + /// rejected. + #[error("cloud-hypervisor API rejected boot_vm: {reason}")] + #[diagnostic( + code(n_vm::cloud_hypervisor::vm_boot), + help( + "create_vm succeeded but boot_vm was rejected -- this can happen \ + if the kernel image is missing, the virtio devices failed to \ + initialise, or the VM configuration is internally inconsistent" + ) + )] + VmBoot { + /// Stringified error from the cloud-hypervisor API client. + /// + /// See [`VmCreate::reason`](Self::VmCreate) for why this is a + /// `String` rather than a typed error. + reason: String, + }, +} diff --git a/n-vm/src/cloud_hypervisor/events.rs b/n-vm/src/cloud_hypervisor/events.rs new file mode 100644 index 0000000000..c727485b0c --- /dev/null +++ b/n-vm/src/cloud_hypervisor/events.rs @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Cloud-hypervisor event monitoring and JSON stream decoding. +//! +//! This module provides types for deserializing the event stream emitted by +//! [cloud-hypervisor](https://github.com/cloud-hypervisor/cloud-hypervisor) +//! via its `--event-monitor` file descriptor, along with an async codec for +//! reading those events incrementally from a pipe. +//! +//! The [`watch`] function consumes the event stream and returns a +//! [`HypervisorVerdict`] indicating whether the VM shut down cleanly. +//! +//! Both [`Source`] and [`EventType`] use `#[serde(other)]` on their +//! `Unknown` variants so that unrecognised strings emitted by newer +//! cloud-hypervisor versions do not cause deserialization failures. +//! Without this, a new event string would downgrade the +//! [`HypervisorVerdict`] to [`Failure`](HypervisorVerdict::Failure) and +//! turn an otherwise passing test into a false negative. + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::StreamDeserializer; +use tokio_stream::StreamExt; +use tokio_util::bytes::{Buf, BytesMut}; +use tracing::warn; + +pub use crate::backend::HypervisorVerdict; + +/// The component that emitted a hypervisor event. +#[derive(Debug, Copy, Clone, Deserialize)] +pub enum Source { + /// The virtual machine itself. + #[serde(rename = "vm")] + Vm, + /// The virtual machine monitor (VMM) process. + #[serde(rename = "vmm")] + Vmm, + /// The guest operating system. + #[serde(rename = "guest")] + Guest, + /// A virtio device backend. + #[serde(rename = "virtio-device")] + VirtioDevice, + /// An unrecognised source (see module-level docs on `#[serde(other)]`). + #[serde(other)] + Unknown, +} + +/// The type of hypervisor lifecycle event. +#[derive(Debug, Copy, Clone, Deserialize)] +pub enum EventType { + /// The VMM is starting up. + #[serde(rename = "starting")] + Starting, + /// The VM is booting (kernel loaded, about to execute). + #[serde(rename = "booting")] + Booting, + /// The VM has finished booting. + #[serde(rename = "booted")] + Booted, + /// A virtio device has been activated. + #[serde(rename = "activated")] + Activated, + /// The VM has been deleted. + #[serde(rename = "deleted")] + Deleted, + /// The VM or VMM has shut down cleanly. + #[serde(rename = "shutdown")] + Shutdown, + /// The guest kernel panicked. + #[serde(rename = "panic")] + Panic, + /// An unrecognised event type (see module-level docs on `#[serde(other)]`). + #[serde(other)] + Unknown, +} + +/// A single event from the cloud-hypervisor event monitor. +/// +/// Events are emitted as newline-delimited JSON objects on the file descriptor +/// passed via `--event-monitor fd=N`. +#[derive(Debug, Clone, Deserialize)] +pub struct Event { + /// Time elapsed since the VMM process started. + pub timestamp: Duration, + /// Which component emitted the event. + pub source: Source, + /// The lifecycle event that occurred. + pub event: EventType, + /// Optional key-value properties attached to the event. + #[serde(deserialize_with = "deserialize_null_default")] + pub properties: BTreeMap, +} + +/// Deserializes `null` JSON values as `T::default()` instead of failing. +fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result +where + T: Default + Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let opt = Option::deserialize(deserializer)?; + Ok(opt.unwrap_or_default()) +} + +/// Computes the [`HypervisorVerdict`] from a collected event log and a +/// flag indicating whether any stream-level deserialization errors occurred +/// during collection. +/// +/// This is a **pure function** extracted from [`watch`] so that verdict +/// logic can be unit-tested with hand-crafted event sequences without +/// needing a pipe or tokio runtime. +/// +/// The verdict is [`CleanShutdown`](HypervisorVerdict::CleanShutdown) +/// only if **all** of the following hold: +/// +/// 1. A `(Vmm, Shutdown)` event was received. +/// 2. No `(Guest, Panic)` event preceded the shutdown in the event log. +/// 3. No stream-level deserialization errors occurred (indicated by +/// `had_stream_errors`). +/// +/// Otherwise the verdict is [`Failure`](HypervisorVerdict::Failure). +pub fn compute_verdict(events: &[Event], had_stream_errors: bool) -> HypervisorVerdict { + let mut tainted = had_stream_errors; + + for event in events { + match (event.source, event.event) { + // Either source. `Vm`/`Shutdown` is the VM reporting that it stopped; `Vmm`/`Shutdown` + // is the monitor process reporting that *it* is going away. Accepting only the second + // made a clean run's verdict depend on whether that later event won a race against the + // pipe closing: the guest would run the test, exit 0, shut down, and still be failed + // because the stream ended one event too early. Seen under load, where it loses. + (Source::Vmm | Source::Vm, EventType::Shutdown) => { + return if tainted { + HypervisorVerdict::Failure + } else { + HypervisorVerdict::CleanShutdown + }; + } + (Source::Guest, EventType::Panic) => { + tainted = true; + } + _ => {} + } + } + + // Stream ended without a VMM Shutdown event. + HypervisorVerdict::Failure +} + +/// A [`tokio_util::codec::Decoder`] that incrementally deserializes +/// concatenated JSON [`Event`] values from a byte stream. +/// +/// This is used to parse the cloud-hypervisor event monitor output, which +/// consists of concatenated JSON objects written to a pipe. +/// +/// The previous implementation carried a phantom lifetime and generic type +/// parameter that were never used -- the `Decoder` impl was always +/// monomorphised for [`Event`]. This version is a simple unit struct. +#[derive(Debug, Default)] +pub struct AsyncJsonStreamDecoder; + +impl AsyncJsonStreamDecoder { + /// Creates a new decoder. + pub fn new() -> Self { + Self + } +} + +/// Errors that can occur while decoding a JSON stream. +#[derive(Debug, thiserror::Error)] +pub enum AsyncJsonStreamError { + /// A JSON deserialization error. + #[error("JSON deserialization error: {0}")] + Json(#[from] serde_json::Error), + /// An I/O error from the underlying reader. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +impl tokio_util::codec::Decoder for AsyncJsonStreamDecoder { + type Item = Event; + type Error = AsyncJsonStreamError; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + // Scope the immutable borrow of `src` (via `as_ref()`) so that we + // can call `src.advance()` afterward without a borrow conflict. + let (next, bytes_consumed) = { + let mut stream: StreamDeserializer<'_, serde_json::de::SliceRead<'_>, Event> = + serde_json::Deserializer::from_slice(src.as_ref()).into_iter::(); + let next = stream.next(); + (next, stream.byte_offset()) + }; + match next { + Some(Ok(value)) => { + src.advance(bytes_consumed); + Ok(Some(value)) + } + // An EOF error means the buffer contains a partial JSON object + // that is still being written to the pipe. Return `Ok(None)` + // to tell the framing layer to wait for more data rather than + // treating it as a fatal parse error. + Some(Err(err)) if err.classify() == serde_json::error::Category::Eof => Ok(None), + Some(Err(err)) => Err(AsyncJsonStreamError::Json(err)), + None => Ok(None), + } + } + + /// End of stream, where the provided implementation is wrong for this format. + /// + /// `Decoder::decode_eof` defaults to calling [`Self::decode`] and, if that yields nothing + /// while bytes remain, reporting "bytes remaining on stream". Every event here is newline + /// terminated, so a byte always remains -- and [`watch`] escalates any stream error to + /// [`HypervisorVerdict::Failure`]. A terminator is not a truncated event. + /// + /// The distinction is kept rather than dropped: leftover *whitespace* ends the stream, and + /// anything else is still an error, because a half-written object is real evidence that + /// events were lost. + fn decode_eof(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + if let Some(value) = self.decode(src)? { + return Ok(Some(value)); + } + if src.iter().all(u8::is_ascii_whitespace) { + src.clear(); + return Ok(None); + } + Err(AsyncJsonStreamError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + format!( + "event stream ended mid-object with {} bytes left", + src.len() + ), + ))) + } +} + +/// Drains remaining events from the stream for up to +/// [`config::POST_PANIC_DRAIN_TIMEOUT`](crate::config::POST_PANIC_DRAIN_TIMEOUT), +/// appending them to `hlog`. +/// +/// Called after a guest panic is detected so that subsequent lifecycle +/// events (e.g. VMM Shutdown, Deleted) are captured for diagnostics. +async fn drain_after_panic( + reader: &mut tokio_util::codec::FramedRead< + tokio::net::unix::pipe::Receiver, + AsyncJsonStreamDecoder, + >, + hlog: &mut Vec, +) { + let drain_deadline = tokio::time::sleep(crate::config::POST_PANIC_DRAIN_TIMEOUT); + tokio::pin!(drain_deadline); + loop { + tokio::select! { + event = reader.next() => { + match event { + Some(Ok(value)) => { + hlog.push(value); + } + Some(Err(e)) => { + warn!( + "hypervisor event error during post-panic drain: {e:#?}" + ); + } + None => break, + } + } + () = &mut drain_deadline => { + break; + } + } + } +} + +/// Consumes the hypervisor event stream and returns the collected events +/// along with a [`HypervisorVerdict`]. +/// +/// Event collection terminates when: +/// - A `(Vmm, Shutdown)` event is received (normal completion). +/// - A `(Guest, Panic)` event is received (remaining events are drained +/// for up to [`POST_PANIC_DRAIN_TIMEOUT`]). +/// - The stream ends (pipe closed). +/// +/// The verdict is computed by [`compute_verdict`] from the collected +/// events and a flag tracking whether any stream-level deserialization +/// errors occurred. +pub async fn watch(receiver: tokio::net::unix::pipe::Receiver) -> (Vec, HypervisorVerdict) { + let decoder = AsyncJsonStreamDecoder::new(); + + let mut reader = tokio_util::codec::FramedRead::new(receiver, decoder); + let mut hlog = Vec::with_capacity(32); + let mut had_stream_errors = false; + + loop { + match reader.next().await { + Some(Ok(value)) => { + let is_shutdown = matches!( + (value.source, value.event), + (Source::Vmm | Source::Vm, EventType::Shutdown) + ); + let is_panic = matches!( + (value.source, value.event), + (Source::Guest, EventType::Panic) + ); + hlog.push(value); + + if is_shutdown || is_panic { + if is_panic { + drain_after_panic(&mut reader, &mut hlog).await; + } + break; + } + } + Some(Err(e)) => { + // Deserialization errors may hide critical events (e.g. + // a guest panic encoded in a malformed JSON object), so + // they are tracked and fed into the verdict computation. + warn!("hypervisor event deserialization error (marking as failure): {e:#?}"); + had_stream_errors = true; + } + None => { + break; + } + } + } + + let verdict = compute_verdict(&hlog, had_stream_errors); + (hlog, verdict) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::codec::Decoder as _; + + /// Helper to create a minimal [`Event`] with the given source and type. + fn event(source: Source, event: EventType) -> Event { + Event { + timestamp: Duration::from_secs(0), + source, + event, + properties: BTreeMap::new(), + } + } + + /// One event as cloud-hypervisor writes it: a JSON object followed by a newline. + fn wire(source: &str, event: &str) -> BytesMut { + BytesMut::from( + format!( + "{{\"timestamp\":{{\"secs\":1,\"nanos\":0}},\"source\":\"{source}\",\ + \"event\":\"{event}\",\"properties\":null}}\n" + ) + .as_bytes(), + ) + } + + #[test] + fn an_event_is_decoded_off_the_wire() { + let mut decoder = AsyncJsonStreamDecoder::new(); + let mut buf = wire("vmm", "starting"); + let got = decoder + .decode(&mut buf) + .expect("decodes") + .expect("an event"); + assert!(matches!( + (got.source, got.event), + (Source::Vmm, EventType::Starting) + )); + } + + /// The newline after the last event is not a truncated event. + /// + /// `Decoder`'s provided `decode_eof` calls `decode` and, finding the buffer non-empty when it + /// yields nothing, reports "bytes remaining on stream". Every object here is newline + /// terminated, so that is what is always left over -- and [`watch`] turns any stream error + /// into [`HypervisorVerdict::Failure`]. + /// + /// It stays hidden because `watch` breaks on `Vmm`/`Shutdown` and never reaches EOF. A VM + /// whose pipe closes *without* a shutdown -- the VMM killed, the host under enough load to + /// reorder things -- reaches it, and is then reported as a malformed event stream rather than + /// as the missing shutdown it actually is. Found exactly that way: one guest in a + /// twelve-at-a-time run, blaming the parser for a VM that went away. + #[test] + fn a_trailing_newline_at_end_of_stream_is_not_a_broken_event() { + let mut decoder = AsyncJsonStreamDecoder::new(); + let mut buf = wire("vmm", "shutdown"); + decoder + .decode(&mut buf) + .expect("decodes") + .expect("an event"); + assert_eq!(&buf[..], b"\n", "the terminator should be what is left"); + assert!( + decoder + .decode_eof(&mut buf) + .expect("a newline is not an error") + .is_none(), + "a trailing newline should end the stream, not fail it" + ); + } + + /// A genuinely truncated object still is one. The point above is not "tolerate anything". + #[test] + fn a_half_written_event_at_end_of_stream_is_an_error() { + let mut decoder = AsyncJsonStreamDecoder::new(); + let mut buf = BytesMut::from(&b"{\"timestamp\":{\"secs\":1,"[..]); + assert!(decoder.decode_eof(&mut buf).is_err()); + } + + #[test] + fn clean_shutdown_without_errors() { + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Vmm, EventType::Booting), + event(Source::Vmm, EventType::Shutdown), + ]; + assert_eq!( + compute_verdict(&events, false), + HypervisorVerdict::CleanShutdown, + ); + } + + /// The VM's own shutdown ends the run, not just the monitor's. + /// + /// A guest that runs its test, exits 0 and stops emits `Vm`/`Shutdown`. The `Vmm`/`Shutdown` + /// that follows is the monitor process leaving, and it may not arrive before the pipe closes. + /// Requiring it made a clean run fail intermittently under load. + #[test] + fn a_vm_shutdown_is_a_clean_shutdown() { + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Vm, EventType::Booted), + event(Source::Vm, EventType::Shutdown), + ]; + assert_eq!( + compute_verdict(&events, false), + HypervisorVerdict::CleanShutdown, + ); + } + + #[test] + fn shutdown_with_stream_errors_is_failure() { + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Vmm, EventType::Shutdown), + ]; + assert_eq!(compute_verdict(&events, true), HypervisorVerdict::Failure,); + } + + #[test] + fn panic_before_shutdown_is_failure() { + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Guest, EventType::Panic), + event(Source::Vmm, EventType::Shutdown), + ]; + assert_eq!(compute_verdict(&events, false), HypervisorVerdict::Failure,); + } + + #[test] + fn panic_without_shutdown_is_failure() { + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Guest, EventType::Panic), + ]; + assert_eq!(compute_verdict(&events, false), HypervisorVerdict::Failure,); + } + + #[test] + fn stream_ended_without_shutdown_is_failure() { + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Vmm, EventType::Booting), + ]; + assert_eq!(compute_verdict(&events, false), HypervisorVerdict::Failure,); + } + + #[test] + fn empty_event_log_is_failure() { + assert_eq!(compute_verdict(&[], false), HypervisorVerdict::Failure,); + } + + #[test] + fn events_after_shutdown_are_ignored_for_verdict() { + // Events collected after the shutdown (e.g. Deleted) should not + // affect the verdict -- the shutdown event is the decision point. + let events = vec![ + event(Source::Vmm, EventType::Starting), + event(Source::Vmm, EventType::Shutdown), + event(Source::Guest, EventType::Panic), // after shutdown + ]; + assert_eq!( + compute_verdict(&events, false), + HypervisorVerdict::CleanShutdown, + ); + } +} diff --git a/n-vm/src/cloud_hypervisor/mod.rs b/n-vm/src/cloud_hypervisor/mod.rs new file mode 100644 index 0000000000..5b4e8bc55b --- /dev/null +++ b/n-vm/src/cloud_hypervisor/mod.rs @@ -0,0 +1,1017 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Cloud-hypervisor [`HypervisorBackend`] implementation. +//! +//! This module encapsulates all +//! [cloud-hypervisor](https://github.com/cloud-hypervisor/cloud-hypervisor)-specific +//! concerns: +//! +//! - **VM configuration** -- translating [`TestVmParams`] into a +//! cloud-hypervisor [`VmConfig`] via focused sub-builders. +//! - **Process spawning** -- launching the `cloud-hypervisor` binary with +//! an `--event-monitor` pipe and `--api-socket`. +//! - **Lifecycle control** -- creating and booting the VM via the REST API, +//! and performing best-effort shutdown. +//! - **Event monitoring** -- delegating to `events::watch` to consume +//! the event stream and produce a [`crate::HypervisorVerdict`]. +//! +//! Nothing in this module is used by the generic [`TestVm`](crate::vm::TestVm) +//! machinery except through the [`HypervisorBackend`] trait. +//! +//! The `events` submodule contains the cloud-hypervisor event monitor +//! JSON stream decoder and the `events::watch` function that consumes +//! the event stream. + +pub mod error; +pub(crate) mod events; + +pub use self::error::CloudHypervisorError; + +use std::os::unix::io::RawFd; +use std::process::Stdio; +use std::sync::Arc; + +use cloud_hypervisor_client::apis::DefaultApi; +use cloud_hypervisor_client::models::console_config::Mode; +use cloud_hypervisor_client::models::{ + ConsoleConfig, CpuTopology, CpusConfig, FsConfig, MemoryConfig, NetConfig, PayloadConfig, + PlatformConfig, VmConfig, VsockConfig, +}; +use command_fds::{CommandFdExt, FdMapping}; +use n_vm_protocol::{ + CLOUD_HYPERVISOR_BINARY_PATH, HYPERVISOR_API_SOCKET_PATH, KERNEL_CONSOLE_SOCKET_PATH, + VHOST_VSOCK_SOCKET_PATH, VIRTIOFS_ROOT_TAG, VIRTIOFSD_SOCKET_PATH, VsockChannel, +}; +use tracing::{debug, error}; + +use crate::abort_on_drop::AbortOnDrop; +use crate::backend::{HypervisorBackend, LaunchedHypervisor}; +use crate::config; +use crate::error::VmError; +use crate::vm::{TestVmParams, check_hugepages_accessible, check_kvm_accessible, wait_for_socket}; + +// -- Constants -------------------------------------------------------- + +/// The fd number used for the cloud-hypervisor event monitor pipe. +/// +/// This is the child-side fd that cloud-hypervisor writes events to. +/// It must match the `--event-monitor fd=N` argument. +const EVENT_MONITOR_FD: RawFd = 3; + +// -- Public types ----------------------------------------------------- + +/// Cloud-hypervisor [`HypervisorBackend`] implementation. +/// +/// Launches a cloud-hypervisor VMM process, configures and boots the VM +/// via its REST API, monitors lifecycle events through the +/// `--event-monitor` pipe, and performs shutdown via the REST API. +#[derive(Debug)] +pub struct CloudHypervisor; + +/// Lifecycle controller for a running cloud-hypervisor instance. +/// +/// Wraps the generated REST API client behind a mutex (the generated +/// client's methods take `&self` but are not `Sync`). +pub struct CloudHypervisorController { + client: Arc>, +} + +/// Collected event log from cloud-hypervisor's `--event-monitor` stream. +/// +/// This newtype wraps the raw event vector so that the generic +/// [`VmTestOutput`](crate::vm::VmTestOutput) can store and display +/// backend-specific event data through the [`Display`](std::fmt::Display) +/// bound on [`HypervisorBackend::EventLog`]. +/// +/// The [`Display`](std::fmt::Display) implementation produces one line per +/// event in a human-readable format suitable for test failure diagnostics. +#[derive(Debug, Default)] +pub struct CloudHypervisorEventLog(pub Vec); + +impl std::fmt::Display for CloudHypervisorEventLog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for event in &self.0 { + writeln!( + f, + "[{:?}] {:?} - {:?} {:?}", + event.timestamp, event.source, event.event, event.properties + )?; + } + Ok(()) + } +} + +// -- Error conversion ------------------------------------------------- + +impl From for VmError { + fn from(err: CloudHypervisorError) -> Self { + VmError::Backend(Box::new(err)) + } +} + +// -- HypervisorBackend ------------------------------------------------ + +impl HypervisorBackend for CloudHypervisor { + const NAME: &str = "cloud-hypervisor"; + const CAN_EMULATE: bool = false; + + type EventLog = CloudHypervisorEventLog; + type Controller = CloudHypervisorController; + + async fn launch(params: &TestVmParams<'_>) -> Result, VmError> { + let (child, event_receiver) = spawn_hypervisor_process( + params.vm_config.host_page_size, + params.vm_config.memory_bytes(), + ) + .await?; + + let config = build_vm_config(params); + + let client = Arc::new(tokio::sync::Mutex::new( + cloud_hypervisor_client::socket_based_api_client(HYPERVISOR_API_SOCKET_PATH), + )); + + client.lock().await.create_vm(config).await.map_err(|e| { + CloudHypervisorError::VmCreate { + reason: format!("{e:?}"), + } + })?; + + let event_watcher = AbortOnDrop::spawn(async { + let (events, verdict) = events::watch(event_receiver).await; + (CloudHypervisorEventLog(events), verdict) + }); + + client + .lock() + .await + .boot_vm() + .await + .map_err(|e| CloudHypervisorError::VmBoot { + reason: format!("{e:?}"), + })?; + + Ok(LaunchedHypervisor { + child, + event_watcher, + controller: CloudHypervisorController { client }, + }) + } + + async fn shutdown(controller: &Self::Controller) { + // In the normal path the VM has already powered off (n-it calls + // reboot(RB_POWER_OFF) or aborts), so these calls will fail + // harmlessly. But if the guest init hangs or the shutdown path + // fails, these calls break the deadlock that would otherwise occur + // when `collect` waits for the hypervisor process to exit. + if let Err(err) = controller.client.lock().await.shutdown_vm().await as Result<(), _> { + debug!("vm shutdown: {err}"); + } + if let Err(err) = controller.client.lock().await.shutdown_vmm().await as Result<(), _> { + debug!("vmm shutdown: {err}"); + } + } + + fn spawn_vsock_reader(channel: &VsockChannel) -> Result, VmError> { + let path = channel.listener_path(); + let label = channel.label; + let listen = + tokio::net::UnixListener::bind(&path).map_err(|source| VmError::VsockBind { + label, + path: path.clone(), + source, + })?; + Ok(AbortOnDrop::spawn(async move { + let connection = match listen.accept().await { + Ok((stream, _)) => stream, + Err(e) => { + error!("failed to accept {label} vsock connection: {e}"); + return format!( + "!!!{} UNAVAILABLE: accept failed: {e}!!!", + label.to_uppercase() + ); + } + }; + config::read_vsock_stream(connection, label).await + })) + } +} + +// -- Process spawning ------------------------------------------------- + +/// Creates the event-monitor pipe, verifies `/dev/kvm`, spawns the +/// cloud-hypervisor binary, and waits for the API socket to appear. +/// +/// Returns the child process handle and the event-monitor pipe receiver +/// (which is consumed by [`hypervisor::watch`]). +async fn spawn_hypervisor_process( + host_page_size: config::HostPageSize, + memory_bytes: i64, +) -> Result<(tokio::process::Child, tokio::net::unix::pipe::Receiver), VmError> { + let (event_sender, event_receiver) = + tokio::net::unix::pipe::pipe().map_err(CloudHypervisorError::EventPipe)?; + let event_sender = event_sender + .into_blocking_fd() + .map_err(CloudHypervisorError::EventSenderFd)?; + + check_kvm_accessible().await?; + check_hugepages_accessible(host_page_size, memory_bytes).await?; + + let hypervisor = tokio::process::Command::new(CLOUD_HYPERVISOR_BINARY_PATH) + .args([ + "--api-socket", + format!("path={HYPERVISOR_API_SOCKET_PATH}").as_str(), + "--event-monitor", + format!("fd={EVENT_MONITOR_FD}").as_str(), + ]) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true) + .fd_mappings(vec![FdMapping { + parent_fd: event_sender, + child_fd: EVENT_MONITOR_FD, + }]) + .map_err(|e| CloudHypervisorError::FdMapping(format!("{e:?}")))? + .spawn() + .map_err(VmError::HypervisorSpawn)?; + + // The first VMM event becoming readable indicates the hypervisor has + // started. We then poll until the API socket appears on the + // filesystem. + event_receiver + .readable() + .await + .map_err(CloudHypervisorError::EventMonitorNotReadable)?; + wait_for_socket(HYPERVISOR_API_SOCKET_PATH).await?; + + Ok((hypervisor, event_receiver)) +} + +// -- VM configuration builders ---------------------------------------- +// +// Each builder is a focused function responsible for a single aspect of +// the cloud-hypervisor `VmConfig`. They can be tested and evolved +// independently; `build_vm_config` composes them into the final config. + +/// Builds the complete cloud-hypervisor [`VmConfig`] for a test run. +/// +/// The virtio-console is disabled (`Mode::Off`) because test +/// stdout/stderr are forwarded via dedicated +/// [`VsockChannel`](n_vm_protocol::VsockChannel)s instead. +fn build_vm_config(params: &TestVmParams<'_>) -> VmConfig { + let ifaces = params.vm_config.all_ifaces(); + let ifaces = &ifaces; + // Cloud-hypervisor only supports virtio-net -- it has no emulated NIC + // models. The proc macro prevents incompatible combinations at compile + // time, so this is a belt-and-suspenders check for callers that bypass + // the macro (e.g. direct `TestVm::::launch()` calls). + debug_assert!( + params.vm_config.first_qemu_only_nic().is_none(), + "cloud-hypervisor does not support NIC model {:?}; \ + pin RequestedBackend::Qemu for emulated NIC models", + params.vm_config.first_qemu_only_nic().unwrap_or_default(), + ); + + VmConfig { + payload: build_payload_config(params), + vsock: Some(VsockConfig { + cid: params.vsock.cid.as_raw() as _, + socket: VHOST_VSOCK_SOCKET_PATH.into(), + pci_segment: Some(0), + ..Default::default() + }), + cpus: Some(build_cpu_config(params.vm_config.vcpus)), + memory: Some(build_memory_config(¶ms.vm_config)), + net: Some(build_network_configs(params.vm_config.iommu, ifaces)), + fs: Some(build_fs_config(¶ms.shares)), + // The virtio-console is disabled: test stdout/stderr travel + // over dedicated VsockChannels (TEST_STDOUT / TEST_STDERR). + console: Some(ConsoleConfig::new(Mode::Off)), + serial: Some(ConsoleConfig { + mode: Mode::Socket, + socket: Some(KERNEL_CONSOLE_SOCKET_PATH.into()), + ..Default::default() + }), + iommu: Some(params.vm_config.iommu), + watchdog: Some(true), + platform: Some(build_platform_config(params)), + pvpanic: Some(true), + // Landlock is disabled: the Docker container already provides + // filesystem isolation, and Landlock's allow-list (which only + // covers VM_RUN_DIR) would block access to /dev/net/tun and + // other device nodes that cloud-hypervisor needs. + landlock_enable: Some(false), + ..Default::default() + } +} + +/// Builds the kernel payload configuration, including the kernel command +/// line that passes the test binary path and name to the init system. +fn build_payload_config(params: &TestVmParams<'_>) -> PayloadConfig { + PayloadConfig { + firmware: None, + kernel: Some(params.kernel_image.clone()), + // Set only for a modular kernel; see the QEMU backend for why an + // unconditional initrd would change how a direct-boot kernel boots. + initramfs: params.initramfs.clone(), + cmdline: Some(config::build_kernel_cmdline( + ¶ms.vm_bin_path, + params.test_name, + ¶ms.vsock, + ¶ms.vm_config, + ¶ms.shares, + params.arch, + params.boot, + )), + ..Default::default() + } +} + +/// Builds the CPU topology for `vcpus` vCPUs. +/// +/// cloud-hypervisor rejects a topology whose levels do not multiply to +/// `boot_vcpus`, so the arrangement comes from +/// [`SmpTopology::for_vcpus`](config::SmpTopology::for_vcpus) -- the same +/// derivation QEMU's `-smp` string is built from, so the two backends +/// present the same machine. +fn build_cpu_config(vcpus: u32) -> CpusConfig { + let t = config::SmpTopology::for_vcpus(vcpus); + CpusConfig { + boot_vcpus: vcpus as i32, + max_vcpus: vcpus as i32, + topology: Some(CpuTopology { + threads_per_core: Some(t.threads as i32), + cores_per_die: Some(t.cores as i32), + dies_per_package: Some(t.dies as i32), + packages: Some(t.sockets as i32), + }), + ..Default::default() + } +} + +/// Builds the memory configuration with sharing support and optional +/// hugepage backing based on the [`HostPageSize`](config::HostPageSize). +/// +/// - [`Standard`](config::HostPageSize::Standard) -- `shared=on`, +/// `hugepages=off`, `thp=off`. No hugetlbfs mount required. +/// - [`Huge2M`](config::HostPageSize::Huge2M) / +/// [`Huge1G`](config::HostPageSize::Huge1G) -- `shared=on`, +/// `hugepages=on` with the matching page size, `thp=off`. +/// +/// `shared=on` is always set because virtiofsd (vhost-user-fs) requires +/// `MAP_SHARED` memory to access the guest address space from a +/// separate process. +/// +/// THP (transparent huge pages) and KSM merging (`mergeable`) are always +/// off. Both only apply to private anonymous memory (`shared=off`), so +/// they have no effect when `shared=on` -- and current cloud-hypervisor +/// *rejects* `mergeable=on` together with `shared=on` ("Invalid to set +/// both 'mergeable' and 'shared' for memory"). +fn build_memory_config(vm_config: &config::VmConfig) -> MemoryConfig { + let host_page_size = vm_config.host_page_size; + let (hugepages, hugepage_size) = if host_page_size.requires_hugepages() { + (Some(true), Some(host_page_size.bytes())) + } else { + (Some(false), None) + }; + MemoryConfig { + size: vm_config.memory_bytes(), + mergeable: Some(false), + shared: Some(true), + hugepages, + hugepage_size, + thp: Some(false), + ..Default::default() + } +} + +/// Builds the network interface configurations. +/// +/// One per entry in `ifaces`, which +/// [`VmConfig::all_ifaces`](config::VmConfig::all_ifaces) derives from the +/// configured fabric: **mgmt** on PCI segment 0 (1500 MTU), then +/// **fabric1**..**fabricN** on segment 1 (9500 MTU jumbo frames). +/// +/// The interface's device model is not read here. Cloud-hypervisor has +/// only virtio-net, so a VM naming any other model has already been +/// resolved to QEMU by [`config::VmConfig::first_qemu_only_nic`] before +/// this runs. +/// +/// When `iommu` is `true`, the fabric interfaces have their per-device +/// `iommu` flag set so that cloud-hypervisor places them behind the +/// virtual IOMMU. The management interface remains on PCI segment 0, +/// which is outside the IOMMU segments configured in +/// [`build_platform_config`]. +fn build_network_configs(iommu: bool, ifaces: &[config::NetIface]) -> Vec { + // Per-device IOMMU flag for devices on the IOMMU-protected PCI + // segment. `None` leaves the field at its default (no IOMMU), + // `Some(true)` opts the device into DMA remapping. + let fabric_iommu = if iommu { Some(true) } else { None }; + + ifaces + .iter() + .map(|iface| NetConfig { + tap: Some(iface.tap.clone()), + ip: Some(iface.host_ipv6.to_string()), + mask: Some("ffff:ffff:ffff:ffff::".into()), + mac: Some(iface.mac.clone()), + mtu: Some(iface.mtu), + id: Some(iface.id.clone()), + pci_segment: Some(i32::from(iface.pci_segment)), + queue_size: Some(iface.queue_size), + // Only the protected segment carries the flag; the management + // link is on segment 0, which has no IOMMU in front of it. + iommu: if iface.pci_segment == 0 { + None + } else { + fabric_iommu + }, + ..Default::default() + }) + .collect() +} + +/// Builds the virtiofs filesystem configuration for sharing the container +/// filesystem into the VM. +fn build_fs_config(active: &[config::ActiveShare]) -> Vec { + let mut shares = vec![FsConfig { + tag: VIRTIOFS_ROOT_TAG.into(), + socket: VIRTIOFSD_SOCKET_PATH.into(), + num_queues: 1, + queue_size: config::VIRTIOFS_QUEUE_SIZE as i32, + id: Some(VIRTIOFS_ROOT_TAG.into()), + ..Default::default() + }]; + + // One further share per writable window, each served by its own daemon. + shares.extend(active.iter().map(|active| FsConfig { + tag: active.share.tag.into(), + socket: active.share.socket_path.into(), + num_queues: 1, + queue_size: config::VIRTIOFS_QUEUE_SIZE as i32, + id: Some(active.share.tag.into()), + ..Default::default() + })); + + shares +} + +/// Builds the platform metadata configuration, embedding the test binary +/// name and test name in OEM strings for identification. +/// +/// When `params.iommu` is `true`, PCI segment 1 (the fabric-facing +/// segment) is placed behind the virtual IOMMU with a 48-bit address +/// width. +/// Segment 0 (management, vsock, virtiofs, serial) remains outside the +/// IOMMU so that these infrastructure devices are not subject to DMA +/// remapping overhead. +fn build_platform_config(params: &TestVmParams<'_>) -> PlatformConfig { + // Only populate IOMMU segment and address-width fields when the + // caller has requested vIOMMU support. Leaving them as `None` when + // iommu is disabled avoids sending unnecessary (and potentially + // confusing) configuration to the hypervisor. + let (iommu_segments, iommu_address_width) = if params.vm_config.iommu { + (Some(vec![1]), Some(48)) + } else { + (None, None) + }; + + PlatformConfig { + serial_number: Some("dataplane-test".into()), + uuid: Some("dff9c8dd-492d-4148-a007-7931f94db852".into()), // arbitrary uuid4 + oem_strings: Some(vec![ + format!("exe={}", params.bin_name), + format!("test={}", params.test_name), + ]), + num_pci_segments: Some(2), + iommu_segments, + iommu_address_width, + ..Default::default() + } +} + +// -- Tests ------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use crate::config::{self, FABRIC_MTU, FABRIC_QUEUE_SIZE, MGMT_MTU, MGMT_QUEUE_SIZE}; + use n_vm_protocol::INIT_BINARY_PATH; + + /// A default VM's interfaces, with `n` fabric links. + fn ifaces_of(n: u8) -> Vec { + config::VmConfig { + fabric: config::FabricNics::Uniform(n), + ..config::VmConfig::DEFAULT + } + .all_ifaces() + } + + const VIRTIOFS_QUEUE_SIZE: i32 = crate::config::VIRTIOFS_QUEUE_SIZE as i32; + + /// Builds a representative [`TestVmParams`] for use in config builder + /// tests. The values are arbitrary but realistic. + fn sample_params() -> TestVmParams<'static> { + TestVmParams { + full_bin_path: Path::new("/target/debug/deps/my_test-abc123"), + vm_bin_path: format!("/{}/my_test-abc123", n_vm_protocol::VM_TEST_BIN_DIR), + bin_name: "my_test-abc123", + test_name: "tests::my_test", + vm_config: config::VmConfig::default(), + arch: config::Arch::X86_64, + kernel_image: SAMPLE_KERNEL.to_owned(), + initramfs: None, + boot: crate::kernel_manifest::BootMode::Direct, + accel: config::Accel::Kvm, + vsock: n_vm_protocol::VsockAllocation::with_defaults(), + shares: Vec::new(), + } + } + + /// A stand-in for whatever the kernel manifest resolved to. The point + /// of threading the path through [`TestVmParams`] is that this lowering + /// no longer knows or cares which kernel it is, so the test asserts the + /// path is forwarded rather than that it has any particular value. + const SAMPLE_KERNEL: &str = "/kernels/union/vmlinuz"; + + // -- Payload config ----------------------------------------------- + + #[test] + fn payload_config_uses_resolved_kernel_image() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + assert_eq!(payload.kernel.as_deref(), Some(SAMPLE_KERNEL)); + } + + #[test] + fn payload_config_embeds_test_binary_in_cmdline() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + let cmdline = payload.cmdline.as_deref().expect("cmdline should be set"); + let expected = format!("/{}/my_test-abc123", n_vm_protocol::VM_TEST_BIN_DIR); + assert!( + cmdline.contains(&expected), + "cmdline should contain the VM-side binary path ({expected}): {cmdline}", + ); + } + + #[test] + fn payload_config_embeds_test_name_in_cmdline() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + let cmdline = payload.cmdline.as_deref().expect("cmdline should be set"); + assert!( + cmdline.contains("tests::my_test"), + "cmdline should contain the test name: {cmdline}", + ); + } + + #[test] + fn payload_config_sets_init_binary() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + let cmdline = payload.cmdline.as_deref().expect("cmdline should be set"); + assert!( + cmdline.contains(&format!("init={INIT_BINARY_PATH}")), + "cmdline should specify the init binary: {cmdline}", + ); + } + + #[test] + fn payload_config_enables_hugepages_on_cmdline() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + let cmdline = payload.cmdline.as_deref().expect("cmdline should be set"); + // Against the config, not against a literal. This asserted `hugepages=1` and + // `hugepagesz=1G`, which is a copy of the default rather than a claim about the cmdline, + // and it failed the moment the default changed for reasons that had nothing to do with + // whether the reservation reaches the kernel. + let expected = params + .vm_config + .hugepage_reservation() + .kernel_cmdline_fragment(); + assert!(!expected.is_empty(), "the sample config reserves hugepages"); + assert!( + cmdline.contains(expected.trim_end()), + "cmdline should carry the configured reservation `{expected}`: {cmdline}", + ); + } + + #[test] + fn payload_config_passes_exact_flag_to_test_harness() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + let cmdline = payload.cmdline.as_deref().expect("cmdline should be set"); + assert!( + cmdline.contains("--exact"), + "cmdline should pass --exact to the test harness: {cmdline}", + ); + assert!( + cmdline.contains("--no-capture"), + "cmdline should pass --no-capture to the test harness: {cmdline}", + ); + } + + #[test] + fn payload_config_embeds_vsock_port_parameters() { + let params = sample_params(); + let payload = build_payload_config(¶ms); + let cmdline = payload.cmdline.as_deref().expect("cmdline should be set"); + let fragment = params.vsock.kernel_cmdline_fragment(); + assert!( + cmdline.contains(&fragment), + "cmdline should contain vsock port parameters ({fragment}): {cmdline}", + ); + } + + // -- CPU config --------------------------------------------------- + + #[test] + fn cpu_config_asks_for_the_count_it_was_given() { + let cpus = build_cpu_config(4); + assert_eq!(cpus.boot_vcpus, 4); + assert_eq!(cpus.max_vcpus, 4); + } + + /// The default still lowers to the machine the hand-written constants + /// used to describe. + #[test] + fn the_default_cpu_topology_is_three_dies_by_one_core_by_two_threads() { + let cpus = build_cpu_config(config::VmConfig::DEFAULT.vcpus); + let topo = cpus.topology.expect("topology should be set"); + assert_eq!(topo.threads_per_core, Some(2)); + assert_eq!(topo.cores_per_die, Some(1)); + assert_eq!(topo.dies_per_package, Some(3)); + assert_eq!(topo.packages, Some(1)); + } + + /// cloud-hypervisor rejects a topology that does not multiply back to + /// `boot_vcpus`, so this must hold for every count, not just the + /// default. + #[test] + fn every_cpu_topology_multiplies_to_its_boot_vcpus() { + for vcpus in [1, 2, 3, 4, 6, 8, 15, 32] { + let cpus = build_cpu_config(vcpus); + let topo = cpus.topology.expect("topology should be set"); + let total = topo.threads_per_core.unwrap() + * topo.cores_per_die.unwrap() + * topo.dies_per_package.unwrap() + * topo.packages.unwrap(); + assert_eq!( + total, cpus.boot_vcpus, + "topology product ({total}) should match boot_vcpus ({})", + cpus.boot_vcpus, + ); + } + } + + // -- Memory config ------------------------------------------------ + + #[test] + fn memory_config_has_expected_size() { + let mem = build_memory_config(&config::VmConfig::DEFAULT); + assert_eq!(mem.size, config::VmConfig::DEFAULT.memory_bytes()); + } + + /// The size follows the configuration rather than a constant, which is + /// the point of the lever. + #[test] + fn memory_config_follows_the_configured_size() { + let vm_config = config::VmConfigBuilder::default().memory_mib(4096).build(); + assert_eq!(build_memory_config(&vm_config).size, 4096 * 1024 * 1024); + } + + #[test] + fn memory_config_enables_hugepages_and_sharing_for_1g() { + let mem = build_memory_config( + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Huge1G) + .build(), + ); + assert_eq!(mem.hugepages, Some(true)); + assert_eq!(mem.hugepage_size, Some(1024 * 1024 * 1024)); + assert_eq!( + mem.shared, + Some(true), + "shared memory is required for virtiofs" + ); + assert_eq!( + mem.mergeable, + Some(false), + "mergeable must be off: cloud-hypervisor rejects mergeable+shared", + ); + assert_eq!(mem.thp, Some(false)); + } + + #[test] + fn memory_config_enables_hugepages_and_sharing_for_2m() { + let mem = build_memory_config( + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Huge2M) + .build(), + ); + assert_eq!(mem.hugepages, Some(true)); + assert_eq!(mem.hugepage_size, Some(2 * 1024 * 1024)); + assert_eq!( + mem.shared, + Some(true), + "shared memory is required for virtiofs" + ); + } + + #[test] + fn memory_config_disables_hugepages_for_standard_pages() { + let mem = build_memory_config( + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Standard) + .build(), + ); + assert_eq!(mem.hugepages, Some(false)); + assert_eq!(mem.hugepage_size, None); + assert_eq!( + mem.shared, + Some(true), + "shared memory is required for virtiofs even without hugepages" + ); + assert_eq!(mem.thp, Some(false)); + } + + // -- Network config ----------------------------------------------- + + #[test] + fn network_config_has_three_interfaces() { + let nets = build_network_configs(false, &ifaces_of(2)); + assert_eq!(nets.len(), 3); + } + + #[test] + fn mgmt_interface_is_on_pci_segment_zero_with_standard_mtu() { + let nets = build_network_configs(false, &ifaces_of(2)); + let mgmt = nets + .iter() + .find(|n| n.id.as_deref() == Some("mgmt")) + .expect("should have a 'mgmt' interface"); + assert_eq!(mgmt.pci_segment, Some(0)); + assert_eq!(mgmt.mtu, Some(MGMT_MTU)); + assert_eq!(mgmt.queue_size, Some(MGMT_QUEUE_SIZE)); + } + + #[test] + fn fabric_interfaces_are_on_pci_segment_one_with_jumbo_mtu() { + let nets = build_network_configs(false, &ifaces_of(2)); + for name in &["fabric1", "fabric2"] { + let iface = nets + .iter() + .find(|n| n.id.as_deref() == Some(*name)) + .unwrap_or_else(|| panic!("should have a '{name}' interface")); + assert_eq!(iface.pci_segment, Some(1), "{name} PCI segment"); + assert_eq!(iface.mtu, Some(FABRIC_MTU), "{name} MTU"); + assert_eq!( + iface.queue_size, + Some(FABRIC_QUEUE_SIZE), + "{name} queue size" + ); + } + } + + #[test] + fn all_interfaces_have_unique_mac_addresses() { + let nets = build_network_configs(false, &ifaces_of(2)); + let macs: Vec<_> = nets.iter().filter_map(|n| n.mac.as_deref()).collect(); + assert_eq!(macs.len(), 3, "all interfaces should have MAC addresses"); + let mut deduped = macs.clone(); + deduped.sort(); + deduped.dedup(); + assert_eq!( + macs.len(), + deduped.len(), + "all MAC addresses should be unique" + ); + } + + #[test] + fn all_interfaces_have_unique_tap_names() { + let nets = build_network_configs(false, &ifaces_of(2)); + let taps: Vec<_> = nets.iter().filter_map(|n| n.tap.as_deref()).collect(); + assert_eq!(taps.len(), 3, "all interfaces should have tap names"); + let mut deduped = taps.clone(); + deduped.sort(); + deduped.dedup(); + assert_eq!(taps.len(), deduped.len(), "all tap names should be unique"); + } + + // -- Filesystem config -------------------------------------------- + + #[test] + fn fs_config_uses_virtiofs_root_tag_and_socket() { + let fs = build_fs_config(&[]); + assert_eq!(fs.len(), 1); + let entry = &fs[0]; + assert_eq!(entry.tag, VIRTIOFS_ROOT_TAG); + assert_eq!(entry.socket, VIRTIOFSD_SOCKET_PATH); + assert_eq!(entry.queue_size, VIRTIOFS_QUEUE_SIZE); + } + + // -- Platform config ---------------------------------------------- + + #[test] + fn platform_config_embeds_binary_and_test_name_in_oem_strings() { + let params = sample_params(); + let platform = build_platform_config(¶ms); + let oem = platform.oem_strings.expect("oem_strings should be set"); + assert!( + oem.iter().any(|s| s == "exe=my_test-abc123"), + "OEM strings should contain the binary name: {oem:?}", + ); + assert!( + oem.iter().any(|s| s == "test=tests::my_test"), + "OEM strings should contain the test name: {oem:?}", + ); + } + + #[test] + fn platform_config_has_two_pci_segments() { + let params = sample_params(); + let platform = build_platform_config(¶ms); + assert_eq!(platform.num_pci_segments, Some(2)); + } + + // -- Composed VmConfig -------------------------------------------- + + #[test] + fn vm_config_disables_virtio_console() { + let params = sample_params(); + let config = build_vm_config(¶ms); + let console = config.console.expect("console should be set"); + assert_eq!(console.mode, Mode::Off); + } + + #[test] + fn vm_config_serial_uses_socket_mode() { + let params = sample_params(); + let config = build_vm_config(¶ms); + let serial = config.serial.expect("serial should be set"); + assert_eq!(serial.mode, Mode::Socket); + assert_eq!(serial.socket.as_deref(), Some(KERNEL_CONSOLE_SOCKET_PATH)); + } + + #[test] + fn vm_config_vsock_uses_guest_cid() { + let params = sample_params(); + let config = build_vm_config(¶ms); + let vsock = config.vsock.expect("vsock should be set"); + assert_eq!(vsock.cid, params.vsock.cid.as_raw() as i64); + assert_eq!(vsock.socket, VHOST_VSOCK_SOCKET_PATH); + } + + #[test] + fn vm_config_enables_safety_features() { + let params = sample_params(); + let config = build_vm_config(¶ms); + assert_eq!(config.watchdog, Some(true), "watchdog should be enabled"); + assert_eq!(config.pvpanic, Some(true), "pvpanic should be enabled"); + assert_eq!( + config.iommu, + Some(false), + "iommu should be disabled when not requested" + ); + } + + // -- vIOMMU configuration ----------------------------------------- + + /// Helper that returns [`TestVmParams`] with vIOMMU enabled. + fn sample_params_iommu() -> TestVmParams<'static> { + let mut params = sample_params(); + params.vm_config.iommu = true; + params + } + + #[test] + fn vm_config_enables_iommu_when_requested() { + let params = sample_params_iommu(); + let config = build_vm_config(¶ms); + assert_eq!( + config.iommu, + Some(true), + "iommu should be enabled when requested" + ); + } + + #[test] + fn platform_config_has_iommu_segments_when_enabled() { + let params = sample_params_iommu(); + let platform = build_platform_config(¶ms); + assert_eq!( + platform.iommu_segments, + Some(vec![1]), + "PCI segment 1 (fabric) should be behind the vIOMMU" + ); + assert_eq!( + platform.iommu_address_width, + Some(48), + "IOMMU address width should be 48 bits" + ); + } + + #[test] + fn platform_config_has_no_iommu_segments_when_disabled() { + let params = sample_params(); + let platform = build_platform_config(¶ms); + assert_eq!( + platform.iommu_segments, None, + "iommu_segments should be None when iommu is disabled" + ); + assert_eq!( + platform.iommu_address_width, None, + "iommu_address_width should be None when iommu is disabled" + ); + } + + #[test] + fn fabric_interfaces_have_iommu_when_enabled() { + let nets = build_network_configs(true, &ifaces_of(2)); + let fabric1 = &nets[1]; + let fabric2 = &nets[2]; + assert_eq!( + fabric1.iommu, + Some(true), + "fabric1 should have per-device iommu enabled" + ); + assert_eq!( + fabric2.iommu, + Some(true), + "fabric2 should have per-device iommu enabled" + ); + } + + #[test] + fn mgmt_interface_has_no_iommu_even_when_enabled() { + let nets = build_network_configs(true, &ifaces_of(2)); + let mgmt = &nets[0]; + assert_eq!( + mgmt.iommu, None, + "mgmt interface on segment 0 should not have per-device iommu" + ); + } + + #[test] + fn fabric_interfaces_have_no_iommu_when_disabled() { + let nets = build_network_configs(false, &ifaces_of(2)); + let fabric1 = &nets[1]; + let fabric2 = &nets[2]; + assert_eq!( + fabric1.iommu, None, + "fabric1 should not have per-device iommu when disabled" + ); + assert_eq!( + fabric2.iommu, None, + "fabric2 should not have per-device iommu when disabled" + ); + } + + #[test] + fn vm_config_disables_landlock() { + let params = sample_params(); + let config = build_vm_config(¶ms); + // Landlock is disabled because the Docker container already + // provides filesystem isolation, and the allow-list would block + // access to /dev/net/tun and other device nodes. + assert_eq!(config.landlock_enable, Some(false)); + } + + // -- Event log display -------------------------------------------- + + #[test] + fn empty_event_log_displays_nothing() { + let log = CloudHypervisorEventLog(vec![]); + assert_eq!(log.to_string(), ""); + } + + #[test] + fn event_log_displays_one_line_per_event() { + use std::collections::BTreeMap; + use std::time::Duration; + + let log = CloudHypervisorEventLog(vec![ + events::Event { + timestamp: Duration::from_secs(0), + source: events::Source::Vmm, + event: events::EventType::Starting, + properties: BTreeMap::new(), + }, + events::Event { + timestamp: Duration::from_secs(1), + source: events::Source::Vmm, + event: events::EventType::Shutdown, + properties: BTreeMap::new(), + }, + ]); + let output = log.to_string(); + let lines: Vec<_> = output.lines().collect(); + assert_eq!(lines.len(), 2, "should have one line per event: {output}"); + } +} diff --git a/n-vm/src/config.rs b/n-vm/src/config.rs new file mode 100644 index 0000000000..a2caae8839 --- /dev/null +++ b/n-vm/src/config.rs @@ -0,0 +1,3244 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Shared VM configuration used by all hypervisor backends. + +use std::net::Ipv6Addr; +use std::time::Duration; + +use n_vm_protocol::{INIT_BINARY_PATH, VsockAllocation}; +use tokio::io::AsyncReadExt; +use tracing::{error, warn}; + +/// VM acceleration mode. +/// +/// Chosen at run time by the host tier: [`Kvm`](Self::Kvm) when the host +/// and guest architectures match, [`Tcg`](Self::Tcg) (software emulation) +/// for a cross-architecture guest. Only the QEMU backend honours +/// [`Tcg`](Self::Tcg); cloud-hypervisor is KVM-only and is never selected +/// for a cross-arch guest. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Accel { + /// Hardware-accelerated via KVM (host arch == guest arch). + #[default] + Kvm, + /// Software emulation via TCG (cross-arch guest). + Tcg, +} + +impl Accel { + /// The wire value used in the [`ENV_ACCEL`](n_vm_protocol::ENV_ACCEL) + /// environment variable. + #[must_use] + pub const fn as_env(self) -> &'static str { + match self { + Self::Kvm => "kvm", + Self::Tcg => "tcg", + } + } + + /// Parses an [`ENV_ACCEL`](n_vm_protocol::ENV_ACCEL) value, defaulting + /// to [`Kvm`](Self::Kvm) for an absent or unrecognised value. + #[must_use] + pub fn from_env(value: Option<&str>) -> Self { + match value { + Some("tcg") => Self::Tcg, + _ => Self::Kvm, + } + } +} + +/// The per-ISA realization of a virtual IOMMU. +/// +/// One object capturing every piece of "how a vIOMMU is wired up on this +/// architecture", so the pieces can't drift apart or be half-applied. +/// Returned by [`Arch::virtual_iommu`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VIommuLowering { + /// QEMU `-device` string for the vIOMMU, if it is realized as a device. + /// `None` when the vIOMMU is a `-machine` option instead (aarch64's + /// SMMUv3 is `-machine iommu=smmuv3`, not a device). Emitted only when + /// a test requests `iommu = true`. + pub device: Option<&'static str>, + /// Extra `-machine` options the vIOMMU requires: the x86 Intel IOMMU + /// needs `kernel-irqchip=split` for interrupt remapping; the aarch64 + /// SMMUv3 *is* `iommu=smmuv3`. Applied alongside the device; empty if + /// none. + pub machine_opts: &'static str, + /// Guest kernel command-line parameters enabling IOMMU support. These + /// are emitted whenever the ISA *has* a vIOMMU (harmless without a + /// device present), so one kernel serves both iommu and non-iommu + /// tests. Empty when the IOMMU is auto-probed (the arm64 SMMUv3 is + /// described in the device tree and needs no command-line opt-in). + pub kernel_params: &'static str, +} + +/// Guest CPU architecture. +/// +/// Equal to the test binary's compile-time `target_arch` (the binary *is* +/// the guest payload, so its architecture is the guest's). Selected at +/// run time via [`Arch::current`] so the arg builders can be unit-tested +/// for both architectures on a single host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arch { + /// x86_64 (`q35` machine, `ttyS0` console, ISA pvpanic). + X86_64, + /// aarch64 (`virt` machine, `ttyAMA0` console, PCI pvpanic). + Aarch64, +} + +impl Arch { + /// The architecture of the running test binary, i.e. the guest arch. + /// + /// Unrecognised architectures fall back to [`X86_64`](Self::X86_64); + /// only x86_64 and aarch64 guests are supported. + #[must_use] + pub fn current() -> Self { + match std::env::consts::ARCH { + "aarch64" => Self::Aarch64, + _ => Self::X86_64, + } + } + + /// Path to the `qemu-system-` binary inside the container. + /// + /// For a cross-arch guest this is a build-native (host-arch) emulator + /// that the nix `testroot`/`vmroot` derivations install (step 4). + #[must_use] + pub const fn qemu_system_binary(self) -> &'static str { + match self { + Self::X86_64 => "/bin/qemu-system-x86_64", + Self::Aarch64 => "/bin/qemu-system-aarch64", + } + } + + /// This architecture's name as spelled in the kernel manifest. + /// + /// The guest kernel image is *not* derived from the architecture any + /// more -- it is looked up in the manifest nix writes into `testroot` + /// (see [`kernel_manifest`](crate::kernel_manifest)), because which + /// kernels exist is a fact about the nix build. This is what lets the + /// manifest's claimed architecture be checked against the guest's. + #[must_use] + pub const fn manifest_name(self) -> &'static str { + match self { + Self::X86_64 => "x86_64", + Self::Aarch64 => "aarch64", + } + } + + /// The QEMU `-machine` base type (before accel / IOMMU options). + #[must_use] + pub const fn qemu_machine_base(self) -> &'static str { + match self { + Self::X86_64 => "q35", + // `gic-version=max` selects the best interrupt controller the + // accelerator supports (GICv3 under TCG). + Self::Aarch64 => "virt,gic-version=max", + } + } + + /// QEMU `-smp` topology string for `vcpus` total vCPUs. + /// + /// The `dies=` level is x86-only; on aarch64 it is folded into `cores`. + #[must_use] + pub fn smp_topology(self, vcpus: u32) -> String { + let t = SmpTopology::for_vcpus(vcpus); + match self { + Self::X86_64 => format!( + "{vcpus},sockets={sockets},dies={dies},cores={cores},threads={threads}", + sockets = t.sockets, + dies = t.dies, + cores = t.cores, + threads = t.threads, + ), + Self::Aarch64 => format!( + "{vcpus},sockets={sockets},cores={cores},threads={threads}", + sockets = t.sockets, + cores = t.dies * t.cores, + threads = t.threads, + ), + } + } + + /// QEMU guest-panic device for this architecture. + #[must_use] + pub const fn pvpanic_device(self) -> &'static str { + match self { + Self::X86_64 => "pvpanic", + Self::Aarch64 => "pvpanic-pci", + } + } + + /// Kernel command-line console parameters for this architecture's + /// default serial port. + #[must_use] + pub const fn console_kernel_params(self) -> &'static str { + match self { + Self::X86_64 => "earlyprintk=ttyS0 console=ttyS0", + Self::Aarch64 => "earlycon console=ttyAMA0", + } + } + + /// The complete virtual-IOMMU lowering for this ISA, or `None` if no + /// vIOMMU is wired up. + /// + /// This is the single source of truth for "how a virtual IOMMU is + /// realized on this architecture" -- the QEMU device, the extra + /// `-machine` options it needs, and the guest kernel parameters, as one + /// object. Adding a new ISA's vIOMMU (e.g. aarch64 SMMUv3) means + /// filling in one [`VIommuLowering`] rather than touching several + /// scattered methods. `None` (currently aarch64) means an + /// `iommu = true` request is resolved to a skip in the host tier rather + /// than producing a wrong or partial config. + #[must_use] + pub const fn virtual_iommu(self) -> Option { + match self { + Self::X86_64 => Some(VIommuLowering { + device: Some("intel-iommu,intremap=on,device-iotlb=on,caching-mode=on"), + // Intel IOMMU interrupt remapping requires split irqchip. + machine_opts: "kernel-irqchip=split", + kernel_params: "iommu=on intel_iommu=on amd_iommu=on", + }), + // aarch64: QEMU's `virt` SMMUv3 is a machine option, not a + // device, and is auto-probed from the device tree (no kernel + // command-line opt-in). Validated under TCG: PCI devices + // (incl. e1000) land in IOMMU groups, so vfio-pci works. + Self::Aarch64 => Some(VIommuLowering { + device: None, + machine_opts: "iommu=smmuv3", + kernel_params: "", + }), + } + } + + /// Whether the virtual-IOMMU (`iommu = true`) configuration is + /// supported on this architecture -- i.e. whether there is a + /// [`virtual_iommu`](Self::virtual_iommu) lowering. + #[must_use] + pub const fn supports_virtual_iommu(self) -> bool { + self.virtual_iommu().is_some() + } +} + +/// Network interface card model presented to the VM guest. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum NicModel { + /// Paravirtualised virtio-net (default). + #[default] + VirtioNet, + + /// Intel 82540EM Gigabit Ethernet (QEMU `e1000` device). + E1000, + + /// Intel 82574L Gigabit Ethernet (QEMU `e1000e` device). + E1000E, +} + +impl NicModel { + /// Returns `true` if this NIC model is virtio-based. + #[must_use] + pub const fn is_virtio(self) -> bool { + matches!(self, Self::VirtioNet) + } + + /// Returns `true` if this NIC model requires QEMU. + #[must_use] + pub const fn requires_qemu(self) -> bool { + matches!(self, Self::E1000 | Self::E1000E) + } +} + +/// Page size used by the hypervisor to back VM memory on the host. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum HostPageSize { + /// Standard 4 KiB pages. No hugepage mount required on the host. + Standard, + /// 2 MiB huge pages. + Huge2M, + /// 1 GiB huge pages. + #[default] + Huge1G, +} + +impl HostPageSize { + /// Size in bytes of a single page at this page size. + #[must_use] + pub const fn bytes(self) -> i64 { + match self { + Self::Standard => 4 * 1024, + Self::Huge2M => 2 * 1024 * 1024, + Self::Huge1G => 1024 * 1024 * 1024, + } + } + + /// Whether this page size draws from a hugepage pool. + /// + /// Renamed in spirit from "requires a hugetlbfs mount": neither backend + /// mounts anything any more. Both allocate through `memfd` with the + /// `MFD_HUGE_*` flags, which draws straight from the kernel's pool for + /// that size. + #[must_use] + pub const fn requires_hugepages(self) -> bool { + match self { + Self::Standard => false, + Self::Huge2M | Self::Huge1G => true, + } + } + + /// QEMU's `hugetlbsize=` spelling for this page size. + /// + /// Needed because QEMU's `memory-backend-memfd` takes the size as an + /// option, unlike `memory-backend-file`, which infers it from whatever + /// the mount happens to be -- see [`Self::pool_dir`]. + #[must_use] + pub const fn qemu_hugetlbsize(self) -> &'static str { + match self { + // Not reachable for a non-huge page size, which uses a plain + // memfd with no `hugetlb=on`. + Self::Standard => "", + Self::Huge2M => "2M", + Self::Huge1G => "1G", + } + } + + /// The sysfs directory for this size's hugepage pool. + /// + /// `None` for [`Standard`](Self::Standard), which has no pool. + /// + /// This is what a pre-flight check should look at. The previous check + /// tested whether `/dev/hugepages` *existed*, which passed happily while + /// the pool it stands for was empty -- and an empty pool is exactly the + /// failure it was meant to catch. + #[must_use] + pub const fn pool_dir(self) -> Option<&'static str> { + match self { + Self::Standard => None, + Self::Huge2M => Some("/sys/kernel/mm/hugepages/hugepages-2048kB"), + Self::Huge1G => Some("/sys/kernel/mm/hugepages/hugepages-1048576kB"), + } + } +} + +/// Hugepage size for guest kernel command-line reservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestHugePageSize { + /// 2 MiB guest hugepages. + Huge2M, + /// 1 GiB guest hugepages. + Huge1G, +} + +impl GuestHugePageSize { + /// The kernel command-line size suffix (e.g. `"2M"`, `"1G"`). + #[must_use] + pub const fn kernel_suffix(self) -> &'static str { + match self { + Self::Huge2M => "2M", + Self::Huge1G => "1G", + } + } + + /// Size in bytes of a single hugepage at this granularity. + #[must_use] + pub const fn bytes(self) -> i64 { + match self { + Self::Huge2M => 2 * 1024 * 1024, + Self::Huge1G => 1024 * 1024 * 1024, + } + } +} + +/// Guest hugepage reservation passed on the kernel command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestHugePageConfig { + /// Let the harness decide, from what the test is for. + /// + /// A reservation is memory the guest kernel hands to hugetlbfs and can + /// never hand back, so it is only worth taking when something is going + /// to claim it. The two roles want opposite answers: + /// + /// * an ordinary guest test gets [`DEFAULT_RESERVATION`], because the + /// thing these VMs mostly exist to run is DPDK, and DPDK without + /// hugepages is a different program; + /// * a fuzz target gets [`None`], because a coverage-guided engine + /// claims ordinary heap and nothing else. Reserving for it is + /// strictly a subtraction: it halved the usable memory of a 1 GiB + /// guest, and the engine's own `-rss_limit_mb` was left describing + /// memory the kernel had already given away. + /// + /// This is a default, not a rule. Naming either variant explicitly + /// wins, including on a fuzz target -- fuzzing a hugepage-dependent path + /// is a coherent thing to want. + /// + /// [`DEFAULT_RESERVATION`]: Self::DEFAULT_RESERVATION + /// [`None`]: Self::None + Auto, + /// No guest hugepages. DPDK must use `--no-huge`. + None, + /// Reserve hugepages of the given size and count. + Allocate { + /// Hugepage granularity. + size: GuestHugePageSize, + /// Number of hugepages to reserve. + count: u32, + }, +} + +impl Default for GuestHugePageConfig { + /// Returns [`Auto`](Self::Auto): defer, rather than guess in the dark. + /// + /// This used to return one 1 GiB page while [`VmConfig::DEFAULT`] used + /// 256 2 MiB pages -- two "defaults" that had already drifted apart + /// because nothing forced them to agree. Deferring is the only answer + /// that cannot drift. + fn default() -> Self { + Self::Auto + } +} + +impl Default for VmConfig { + /// Delegates to [`VmConfig::DEFAULT`]. + /// + /// Written out rather than derived so the const and the trait cannot + /// drift: a derived `Default` would independently consult each field's + /// own `Default`, and nothing would notice if the two answers diverged. + fn default() -> Self { + Self::DEFAULT + } +} + +impl GuestHugePageConfig { + /// What [`Auto`](Self::Auto) means for a test that is not a fuzz target. + /// + /// 512 MiB of a 1 GiB guest, in 2 MiB pages. Small enough to leave the + /// guest kernel room (see [`VmConfig::check`]), and 2 MiB rather than + /// 1 GiB because a guest cannot count on being handed a contiguous + /// gigabyte. + pub const DEFAULT_RESERVATION: Self = Self::Allocate { + size: GuestHugePageSize::Huge2M, + count: 256, + }; + + /// Builds the kernel command-line fragment for hugepage reservation. + /// + /// Call this on a *resolved* reservation -- + /// [`VmConfig::hugepage_reservation`] -- never on the raw field. + /// [`Auto`](Self::Auto) is a request to decide, and this type does not + /// hold what the decision is made from. + pub(crate) fn kernel_cmdline_fragment(&self) -> String { + match self { + Self::Auto => unreachable!( + "Auto must be resolved by VmConfig::hugepage_reservation before rendering" + ), + Self::None => String::new(), + Self::Allocate { size, count } => { + let sz = size.kernel_suffix(); + format!("default_hugepagesz={sz} hugepagesz={sz} hugepages={count} ") + } + } + } +} + +/// A kernel module parameter, set on the guest command line as +/// `.=`. +/// +/// Structured rather than a free-form command-line string on purpose. The +/// kernel command line is a flat namespace in which `root=`, `init=` and +/// `n_it.result_port=` sit beside module parameters, and a test that +/// overwrote one of those would not report an error -- it would hang, or +/// boot a guest whose init protocol had been quietly redirected. A value of +/// this type can only ever render as a module parameter, so the only thing +/// left to guard is the module name. +/// +/// Everything this checks is checked in [`new`](Self::new), which is `const`, +/// so a malformed parameter is a build error rather than a console line forty +/// lines into a boot dump. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModuleParam { + module: &'static str, + key: &'static str, + value: &'static str, +} + +/// Whether `s` is a legal module or parameter name. +/// +/// Kernel module and parameter names are C identifiers, with `-` also +/// appearing in module names (`vfio-pci`). Anything else -- a `.`, an `=`, a +/// space -- would change where the kernel splits the token, so it is rejected +/// rather than escaped. +const fn is_module_ident(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return false; + } + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-'; + if !ok { + return false; + } + i += 1; + } + true +} + +/// Whether `s` can appear as a command-line value. +/// +/// The command line is split on whitespace, so a value containing any would +/// silently become two parameters. +const fn is_cmdline_value(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return false; + } + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_whitespace() { + return false; + } + i += 1; + } + true +} + +/// `const`-callable string equality; `PartialEq` is not `const`. +const fn str_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +impl ModuleParam { + /// Declares `.=` on the guest kernel command line. + /// + /// # Panics + /// + /// Panics if `module` or `key` is not a legal identifier, if `value` is + /// empty or contains whitespace, or if `module` is the namespace `n-it` + /// reads its own parameters from. In a `const` context each panic *is* + /// the compile error. + #[must_use] + pub const fn new(module: &'static str, key: &'static str, value: &'static str) -> Self { + assert!( + is_module_ident(module), + "a module name must be a kernel module identifier: letters, digits, `_` or `-`" + ); + assert!( + is_module_ident(key), + "a module parameter name must be an identifier: letters, digits, `_` or `-`" + ); + assert!( + is_cmdline_value(value), + "a module parameter value must be non-empty and contain no whitespace; \ + the kernel command line is split on whitespace, so one that does \ + would silently become two parameters" + ); + assert!( + !str_eq(module, n_vm_protocol::CMDLINE_NAMESPACE), + "that module name is the namespace `n-it` reads its own boot parameters from; \ + setting it would redirect the guest's init protocol rather than configure a module" + ); + Self { module, key, value } + } + + /// Renders as it appears on the kernel command line. + #[must_use] + pub fn render(&self) -> String { + format!("{}.{}={}", self.module, self.key, self.value) + } +} + +/// Complete VM configuration passed through the dispatch chain. +/// +/// Written at the call site as a `const`, so that a test's configuration is +/// ordinary Rust in an ordinary position -- completion, hover, and +/// go-to-definition all work on it, which is not true of anything spelled +/// inside an attribute: +/// +/// ```ignore +/// const FAST_VM: VmConfig = VmConfig { iommu: true, ..VmConfig::DEFAULT }; +/// +/// #[n_vm::test(config = FAST_VM)] +/// fn my_test() { ... } +/// ``` +/// +/// Being `const` also means [`assert_valid`](Self::assert_valid) can run at +/// compile time, turning what were runtime launch failures into build +/// errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VmConfig { + /// Whether to present a virtual IOMMU device to the guest. + pub iommu: bool, + /// Page size backing the VM's memory on the host. + pub host_page_size: HostPageSize, + /// Guest hugepage reservation for the kernel command line. + /// + /// A *request*, which [`GuestHugePageConfig::Auto`] leaves open; read + /// [`hugepage_reservation`](Self::hugepage_reservation) for the answer. + pub guest_hugepages: GuestHugePageConfig, + /// NIC model for the management interface, and for fabric links that + /// do not name their own. + /// + /// A [`FabricNics::Mixed`] fabric names each of its links, and this is + /// then the management link's model alone -- which is the one a test + /// of device identification wants held fixed, since it is how the + /// harness reaches the guest. + pub nic_model: NicModel, + /// Kernel features this test depends on. + /// + /// Checked against the kernel's own config before launch, so a missing + /// feature is reported by name rather than surfacing as whatever it + /// breaks deep inside the test body. See + /// [`kernel_feature`](crate::kernel_feature) for why these are verified + /// rather than used to generate the kernel's config. + /// + /// ```ignore + /// const TC_VM: VmConfig = VmConfig { + /// kernel_features: &[features::NET_CLS_FLOWER, features::NET_CLS_ACT], + /// ..VmConfig::DEFAULT + /// }; + /// ``` + pub kernel_features: &'static [crate::kernel_feature::KernelFeature], + /// The hypervisor the test asked for. + /// + /// Part of the machine rather than of the attribute that declares the + /// test, so that "the same VM on both backends" is written as two + /// configurations -- which is what it is. Derive the second from the + /// first with [`to_builder`](Self::to_builder). + pub backend: crate::backend::RequestedBackend, + /// The tokio runtime an `async` body is driven on in the guest. + /// + /// Ignored by a synchronous test, which has no runtime to shape. + pub runtime: GuestRuntime, + /// How long the test body may take, before the VM's own overhead. + /// + /// The VM is given this *plus* room to boot, load a corpus and shut down + /// -- the container cannot hold exactly what it contains, or the guest is + /// killed somewhere inside its last second and whatever it was about to + /// report is lost. + /// + /// `None` means the body gets the same allowance an ordinary test does, + /// which is what nearly every test wants. Set it for work that is + /// deliberately long-lived: a fuzz campaign's length arrives separately, + /// from the engine, and the larger of the two wins. + pub guest_time_limit: Option, + /// Kernel module parameters to set on the guest command line. + /// + /// Rendered as `.=` into the kernel section of the + /// command line. A module does not have to be built in for this to + /// apply: the kernel stores parameters for modules that are not loaded + /// yet and applies them at load time, so this composes with a + /// [`KernelFeature`](crate::kernel_feature::KernelFeature) declared + /// `modular`. + pub module_params: &'static [ModuleParam], + /// The kernel profile to boot, by name. + /// + /// A profile is a *(kernel, hypervisor)* pair from the manifest nix + /// materialises into `testroot` -- `flatcar` is a distribution kernel + /// with its own module tree booted through an initramfs, `qemu` and + /// `cloud_hypervisor` are the union kernel this repo builds and boots + /// directly. See [`crate::kernel_feature::kernel_profiles`] for the names. + /// + /// `None` leaves the choice to the run: `N_VM_PROFILE` if it is set, + /// otherwise the manifest's default. Naming one here outranks both, + /// because a test that says which kernel it needs is saying what it is + /// *for* -- a module-loading test that got swept onto a + /// built-in-only kernel by an environment variable would not be + /// testing anything. + /// + /// Because a profile names a hypervisor, this can contradict + /// [`backend`](Self::backend). That cannot be checked here -- which + /// hypervisor a profile uses is in a file, not in this type -- so it is + /// caught at launch, by name, rather than silently booting one of them. + pub kernel_profile: Option<&'static str>, + /// Number of fabric-facing network interfaces. + /// + /// The management interface is always present and is not counted here, + /// so the default of 2 gives the three the VM has always had. + /// + /// Raise it to test what only more than one link can show -- failover, + /// ECMP, a bond losing a member. Lower it to nothing for a test that + /// never touches the network: each interface is a TAP device, a virtio + /// device and a queue pair, all of which are set up before the guest + /// starts running. + /// + /// [`FabricNics::Mixed`] names a model per link instead of a count. + /// That is what a test of the startup sequence wants: the failure it + /// is looking for is a program that unbinds a device it did not mean + /// to, and on a machine where every NIC is the same device there is no + /// wrong one to pick. + pub fabric: FabricNics, + /// Total guest memory, in MiB. + /// + /// A whole number of [`host_page_size`](Self::host_page_size) pages, or + /// the VM cannot be backed at all -- see [`ConfigProblem::MemoryNotAligned`]. + /// + /// Raise it for work that needs room: a fuzz engine's `-rss_limit_mb` + /// is derived from this by [`fuzz_rss_limit_mib`](Self::fuzz_rss_limit_mib), + /// and the default gigabyte is a tight fit for a coverage-guided + /// campaign. Every MiB is taken from the host + /// for the VM's whole life, so this is also what bounds how many VMs + /// can run at once. + pub memory_mib: u32, + /// Number of vCPUs. + /// + /// Arranged into a socket/die/core/thread topology by + /// `SmpTopology::for_vcpus`, which is what both hypervisors actually + /// want. Like [`memory_mib`](Self::memory_mib) this bounds + /// concurrency: vCPUs, not memory, is what usually limits how many of + /// these VMs a host can run. + pub vcpus: u32, + /// What writable storage this test gets, and whether it is a fuzz + /// target. See [`CorpusPolicy`]. + pub corpus: CorpusPolicy, + /// The test's own `(file!(), CARGO_MANIFEST_DIR)`, injected by the + /// attribute macro; `None` on a configuration built by hand. + /// + /// Carried as raw compile-time strings rather than a pre-computed + /// directory so that both tiers derive the path identically from one + /// constant. + /// + /// Both are needed because `file!()` alone is not reliably + /// workspace-relative. It is relative under a plain `cargo test`, but + /// this workspace builds with `--remap-path-prefix==${src}` + /// (default.nix), an empty-FROM mapping that deliberately rewrites + /// source paths *to* the nix store so debuggers can find them. Under + /// that flag `file!()` is `/nix/store/-source/n-vm/tests/foo.rs`, + /// and treating it as relative produced a corpus path of + /// `/workspace//nix/store/...` in the guest and a host path that escaped + /// the workspace entirely -- so `#[corpus]` silently got no writable + /// directory. The manifest dir supplies the anchor needed to recover + /// the workspace-relative tail; see [`Self::corpus_rel_dir`]. + pub source_file: Option<(&'static str, &'static str)>, +} + +/// What writable storage a test is given, and whether it is a fuzz target. +/// +/// One value rather than a `bool` because the two things it decides are the +/// same decision: a coverage-guided target is exactly the thing that needs +/// somewhere to save an input, and `cargo bolero list` exists to name the +/// things that can be fuzzed. Splitting them would let a test be announced +/// with nowhere to write, or given a writable share it never uses. +/// +/// An enum rather than a `bool` for the usual reason -- it reads at the call +/// site, and a third answer can be added without breaking the second -- and +/// for a specific one: everything a run might want to vary about a corpus +/// (where it lives, whether it carries over, whether to start clean) is a +/// property of the *invocation*, not of the test. Those levers belong to +/// `just fuzz`; see [`n_vm_protocol::FuzzDirs`]. What is left for the test +/// to declare is only whether it needs them at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CorpusPolicy { + /// No writable storage. The guest is read-only throughout, and the + /// test is not announced to `cargo bolero list`. + #[default] + None, + /// A coverage-guided fuzz target. + /// + /// Gets a writable corpus share, a writable crashes share when the + /// engine names a separate one, and an entry in `cargo bolero list`. + Fuzz, +} + +/// The tokio runtime an `async` test body is driven on inside the guest. +/// +/// A worker count that only means something on the multi-threaded scheduler +/// is *inside* the variant that has one, so `worker_threads` without +/// `multi_thread` cannot be written down. It used to be two independent +/// attribute options and a hand-written check that rejected the combination; +/// there is now nothing to reject. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestRuntime { + /// The current-thread scheduler. The default: a test that does not say + /// otherwise gets no worker pool. + CurrentThread, + /// The multi-threaded scheduler. + MultiThread { + /// Worker threads, or `None` for tokio's default (one per core). + worker_threads: Option, + }, +} + +/// Builds a [`VmConfig`] by naming only what differs from +/// [`VmConfig::DEFAULT`]. +/// +/// Every method is `const`, which is the whole point rather than an +/// optimisation. A test's configuration is evaluated on the *host* tier, in +/// another process, before the guest exists, so it must not be able to +/// observe anything in the test body -- and `const` is what enforces that: +/// naming a local in one cannot resolve, and is reported at that name rather +/// than somewhere inside generated code. It is also what keeps +/// [`assert_valid`](VmConfig::assert_valid) running at compile time, so a +/// contradictory configuration stays a build error. +/// +/// This is why the builder is written out rather than derived. +/// `derive_builder` produces `build(&self) -> Result<_, _>`, and neither the +/// method nor the `unwrap` that follows it can appear in a `const`. +/// +/// ```ignore +/// #[n_vm::test] +/// fn drives_a_nic() { +/// #[n_vm::config] +/// const _: _ = VmConfigBuilder::default() +/// .iommu(true) +/// .kernel_features(&[features::VFIO_PCI]) +/// .build(); +/// +/// // the test body follows +/// } +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct VmConfigBuilder(VmConfig); + +impl VmConfigBuilder { + /// Starts from [`VmConfig::DEFAULT`]. + /// + /// An inherent method rather than the `Default` trait because + /// `Default::default` is not `const`, and a builder that cannot be used + /// in a `const` would defeat the purpose -- see the type's own docs. + #[must_use] + #[allow(clippy::should_implement_trait)] + pub const fn default() -> Self { + Self(VmConfig::DEFAULT) + } + + /// Presents a virtual IOMMU to the guest. + #[must_use] + pub const fn iommu(mut self, iommu: bool) -> Self { + self.0.iommu = iommu; + self + } + + /// Sets the host page size backing guest memory. + /// + /// Anything other than [`HostPageSize::Standard`] draws on a host + /// hugepage pool that nothing arbitrates; see [`VmConfig::DEFAULT`] for + /// why the default declines to. + #[must_use] + pub const fn host_page_size(mut self, size: HostPageSize) -> Self { + self.0.host_page_size = size; + self + } + + /// Names the kernel profile to boot. + /// + /// Use a constant from [`crate::kernel_feature::kernel_profiles`] rather than a bare string, + /// so a rename shows up as a build error instead of an "unknown + /// profile" at launch. + #[must_use] + pub const fn kernel_profile(mut self, name: &'static str) -> Self { + self.0.kernel_profile = Some(name); + self + } + + /// Sets how many fabric-facing interfaces the VM gets, all of the same + /// model. + /// + /// The management interface is always present and is not counted. + #[must_use] + pub const fn fabric_nics(mut self, count: u8) -> Self { + self.0.fabric = FabricNics::Uniform(count); + self + } + + /// Gives the VM one fabric interface per model named, in order. + /// + /// Replaces any earlier [`fabric_nics`](Self::fabric_nics): the count + /// and the models are one field, so they cannot disagree. + /// + /// ```ignore + /// .fabric_nic_models(&[NicModel::VirtioNet, NicModel::E1000, NicModel::E1000E]) + /// ``` + /// + /// An emulated model needs QEMU, so a mixed fabric that names one also + /// pins the backend -- see [`ConfigProblem::NicRequiresQemu`]. + #[must_use] + pub const fn fabric_nic_models(mut self, models: &'static [NicModel]) -> Self { + self.0.fabric = FabricNics::Mixed(models); + self + } + + /// Sets the guest's total memory, in MiB. + /// + /// Must stay a whole number of host pages; with + /// [`HostPageSize::Huge1G`] that means a whole number of gibibytes. + #[must_use] + pub const fn memory_mib(mut self, mib: u32) -> Self { + self.0.memory_mib = mib; + self + } + + /// Sets the guest's vCPU count. + #[must_use] + pub const fn vcpus(mut self, vcpus: u32) -> Self { + self.0.vcpus = vcpus; + self + } + + /// Declares what writable storage the test needs. + /// + /// [`CorpusPolicy::Fuzz`] is what replaced the old `#[n_vm::corpus]` + /// attribute. It is a configuration value rather than an attribute + /// because it decides things the other configuration decides -- notably + /// the hugepage reservation, which a fuzz target declines -- and those + /// could not see each other while one lived in the macro. + #[must_use] + pub const fn corpus(mut self, policy: CorpusPolicy) -> Self { + self.0.corpus = policy; + self + } + + /// Sets the guest's own hugepage reservation. + /// + /// Overrides [`GuestHugePageConfig::Auto`], including on a fuzz target, + /// which otherwise reserves nothing. + #[must_use] + pub const fn guest_hugepages(mut self, hugepages: GuestHugePageConfig) -> Self { + self.0.guest_hugepages = hugepages; + self + } + + /// Sets the NIC model for every interface in the VM. + #[must_use] + pub const fn nic_model(mut self, model: NicModel) -> Self { + self.0.nic_model = model; + self + } + + /// Declares the kernel features the test depends on. + /// + /// Prefer the curated constants in + /// [`features`](crate::kernel_feature::features) over spelling a symbol + /// out: the table exists so that a typo is a compile error and shows up + /// in completion, and a hand-written `KernelFeature` gets neither. + #[must_use] + pub const fn kernel_features( + mut self, + features: &'static [crate::kernel_feature::KernelFeature], + ) -> Self { + self.0.kernel_features = features; + self + } + + /// Pins the hypervisor. + /// + /// Leaving it at [`RequestedBackend::Default`](crate::backend::RequestedBackend::Default) + /// is not the same as naming cloud-hypervisor: the default tolerates + /// falling back to QEMU for a cross-architecture guest, where a pinned + /// cloud-hypervisor test is skipped instead. + #[must_use] + pub const fn backend(mut self, backend: crate::backend::RequestedBackend) -> Self { + self.0.backend = backend; + self + } + + /// Sets the tokio runtime an `async` body is driven on in the guest. + #[must_use] + pub const fn runtime(mut self, runtime: GuestRuntime) -> Self { + self.0.runtime = runtime; + self + } + + /// Gives the test body longer than an ordinary test gets. + /// + /// Bounds the *body*, not the VM: boot, corpus load and shutdown are + /// added on top, so a test that asks for ten minutes gets a VM that + /// outlives ten minutes of work. + #[must_use] + pub const fn guest_time_limit(mut self, limit: Duration) -> Self { + self.0.guest_time_limit = Some(limit); + self + } + + /// Sets kernel module parameters on the guest command line. + #[must_use] + pub const fn module_params(mut self, params: &'static [ModuleParam]) -> Self { + self.0.module_params = params; + self + } + + /// Produces the configuration, checking it. + /// + /// # Panics + /// + /// Panics via [`assert_valid`](VmConfig::assert_valid) if the + /// combination is contradictory. In a `const` context that panic *is* + /// the compile error, which is why this returns a [`VmConfig`] rather + /// than a `Result`: `Result::unwrap` is not callable in a `const`. + #[must_use] + pub const fn build(self) -> VmConfig { + self.0.assert_valid(); + self.0 + } +} + +/// A way a [`VmConfig`] can be wrong. +/// +/// Carried as a variant rather than a message so that one `const fn` check +/// serves both the compile-time assertion (which can only panic with a +/// literal) and the runtime path (which can afford to format the actual +/// numbers into the message). Otherwise the two would each need their own +/// copy of the conditions, and would drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigProblem { + /// VM memory is not a whole number of host pages. + MemoryNotAligned, + /// The guest hugepage reservation is larger than the VM's memory. + HugepagesExceedMemory, + /// The NIC model is emulated only by QEMU, but cloud-hypervisor is pinned. + NicRequiresQemu, + /// The VM was given no memory. + NoMemory, + /// The VM was given no vCPUs. + NoVcpus, + /// More fabric interfaces were asked for than have distinct addresses. + TooManyNics, + /// A fuzz target whose VM has no memory left for the engine to use. + NoRoomToFuzz, +} + +impl VmConfig { + /// The default configuration: 4 KiB host pages, 512 MiB of 2 MiB guest hugepages, virtio-net, + /// no IOMMU. + /// + /// # Why the host pages are small and the guest's are not + /// + /// These two settings look alike and are not. The guest hugepage is reserved *inside* the + /// guest, on its kernel command line, and a guest that wants one gets one however the host + /// backs the memory underneath. The host page size decides only whether that backing is + /// physically contiguous, which matters to exactly one thing: DPDK driving a real device + /// through an IOMMU. A test that boots a guest and does not do that cannot tell the + /// difference. + /// + /// So the host pool is a resource this default should not spend. It is small -- two 1 GiB + /// pages on the machine where this was found -- and nothing arbitrates it, so a default of + /// [`HostPageSize::Huge1G`] made every test that never overrode it contend for a page it had + /// no use for. Ten of eighteen integration tests failed that way in a parallel run, reporting + /// "the 1073741824-byte hugepage pool has 0 free page(s)", which reads as flakiness and is a + /// default asking for something it does not need. + /// + /// Tests that *do* drive DPDK through an IOMMU ask for [`HostPageSize::Huge1G`] explicitly, + /// and pay for it honestly. + /// + /// # Why the guest's reservation is 512 MiB of 2 MiB pages + /// + /// It used to be a single 1 GiB page, out of a guest with exactly 1 GiB of RAM, which the + /// guest kernel cannot satisfy -- it has already taken memory by the time it reserves. That + /// was invisible while the host handed over a contiguous 1 GiB page and became intermittent + /// as soon as it did not. [`VmConfig::check`] now requires the headroom, so this is a build + /// error rather than a console line forty lines into a failure dump. + /// + /// Spell overrides against this with struct update syntax, which works + /// in a `const`: + /// + /// ```ignore + /// const NO_HUGE: VmConfig = VmConfig { + /// guest_hugepages: GuestHugePageConfig::None, + /// ..VmConfig::DEFAULT + /// }; + /// ``` + pub const DEFAULT: Self = Self { + iommu: false, + host_page_size: HostPageSize::Standard, + guest_hugepages: GuestHugePageConfig::Auto, + nic_model: NicModel::VirtioNet, + kernel_features: &[], + backend: crate::backend::RequestedBackend::Default, + runtime: GuestRuntime::CurrentThread, + guest_time_limit: None, + module_params: &[], + kernel_profile: None, + fabric: FabricNics::Uniform(2), + memory_mib: 1024, + vcpus: 6, + corpus: CorpusPolicy::None, + source_file: None, + }; + + /// Reopens this configuration as a builder, for deriving a variant. + /// + /// The `const`-callable equivalent of `..BASE` struct update syntax, and + /// the answer to "the same VM on the other hypervisor": + /// + /// ```ignore + /// const IOMMU_VM: VmConfig = VmConfigBuilder::default().iommu(true).build(); + /// const IOMMU_VM_QEMU: VmConfig = IOMMU_VM.to_builder() + /// .backend(RequestedBackend::Qemu) + /// .build(); + /// ``` + #[must_use] + pub const fn to_builder(self) -> VmConfigBuilder { + VmConfigBuilder(self) + } + + /// Whether this test is a coverage-guided fuzz target. + /// + /// Read through this rather than off the field, so the meaning has one + /// name. It is also what the generated harness branches on to decide + /// whether to announce itself to `cargo bolero list`; being `const`, that + /// branch folds away entirely in an ordinary test. + #[must_use] + pub const fn is_fuzz_target(&self) -> bool { + matches!(self.corpus, CorpusPolicy::Fuzz) + } + + /// Total guest memory in bytes. + /// + /// `i64` because both hypervisors' memory sizes are signed, and the + /// hugepage arithmetic in [`check`](Self::check) is done in the same + /// type to keep it `const`. + #[must_use] + pub const fn memory_bytes(&self) -> i64 { + (self.memory_mib as i64) * 1024 * 1024 + } + + /// The hugepage reservation this VM actually boots with. + /// + /// Resolves [`GuestHugePageConfig::Auto`] against the test's role; any + /// other value is returned unchanged. Every consumer -- the validity + /// check, the kernel command line, the launcher -- must go through here, + /// because the raw field is a *request* and `Auto` is a request to + /// decide. + #[must_use] + pub const fn hugepage_reservation(&self) -> GuestHugePageConfig { + match self.guest_hugepages { + GuestHugePageConfig::Auto => { + if self.is_fuzz_target() { + GuestHugePageConfig::None + } else { + GuestHugePageConfig::DEFAULT_RESERVATION + } + } + explicit => explicit, + } + } + + /// Every interface presented to this VM, in device order. + /// + /// The management interface is always first and always present: it is + /// the standard-MTU link on the unprotected PCI segment, and the fabric + /// links are defined by contrast with it. + /// + /// A method rather than a free function taking a count, because a link + /// now carries a device model as well as an index, and both come from + /// the configuration. Reading them from one place is what keeps the + /// two backends lowering the same list. + #[must_use] + pub fn all_ifaces(&self) -> Vec { + let mgmt = NetIface { + id: "mgmt".to_owned(), + tap: "mgmt".to_owned(), + mac: "02:DE:AD:BE:EF:01".to_owned(), + host_ipv6: Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0xffff, 1), + mtu: MGMT_MTU, + queue_size: MGMT_QUEUE_SIZE, + pci_segment: 0, + model: self.nic_model, + }; + + // Clamped rather than trusted. `check` rejects a longer fabric, but + // a configuration can reach the launcher without having been + // checked, and past 254 the index no longer fits the last octet of + // the MAC -- two links would come up sharing an address, which is + // the symptom a network test would then be trying to explain. + let count = MAX_FABRIC_NICS.min(u8::try_from(self.fabric.len()).unwrap_or(u8::MAX)); + + std::iter::once(mgmt) + .chain((1..=count).map(|n| NetIface { + id: format!("fabric{n}"), + tap: format!("fabric{n}"), + mac: format!("02:CA:FE:BA:BE:{n:02X}"), + host_ipv6: Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, u16::from(n)), + mtu: FABRIC_MTU, + queue_size: FABRIC_QUEUE_SIZE, + pci_segment: 1, + model: self.fabric.model(usize::from(n) - 1, self.nic_model), + })) + .collect() + } + + /// The first interface in this VM that only QEMU can emulate, if any. + /// + /// Covers the management link as well as the fabric, so that + /// `nic_model` alone is enough to pin the backend even on a VM with no + /// fabric links at all. + #[must_use] + pub const fn first_qemu_only_nic(&self) -> Option { + if self.nic_model.requires_qemu() { + return Some(self.nic_model); + } + self.fabric.first_qemu_only(self.nic_model) + } + + /// The `-rss_limit_mb` a fuzz engine running in this VM should be held + /// to, or [`None`] if this VM has no room to fuzz at all. + /// + /// libfuzzer's own default is 2048 MB, which is twice the default guest + /// and was never reached: the guest kernel ran out of memory first and + /// killed the engine, and an engine killed from outside writes no + /// artifact. The input that grew the heap is then gone, so the one + /// finding a memory bug is supposed to produce is exactly what the + /// failure destroys. Under the limit, libfuzzer notices the growth + /// itself, saves the input, and says which one it was. + /// + /// The derivation is what is left after the two claims the engine + /// cannot touch: a hugepage reservation, which the guest kernel hands + /// to hugetlbfs and never hands back, and + /// `GUEST_KERNEL_HEADROOM_BYTES` for the kernel and `n-it` + /// themselves. That is a bound, not a measurement -- an engine that + /// stays under it can still be killed by a guest that was busy + /// elsewhere -- but every part of it is memory that provably is not + /// available to the engine. + /// + /// [`None`] rather than a floor because libfuzzer reads + /// `-rss_limit_mb=0` as *no limit*. Saturating at zero would turn a VM + /// too small to fuzz into one whose engine is unbounded, which is the + /// opposite of what this is for. [`check`](Self::check) rejects a fuzz + /// target that lands here, so the [`None`] arm is only reachable for a + /// configuration that never declared itself one. + #[must_use] + pub const fn fuzz_rss_limit_mib(&self) -> Option { + let reserved_bytes = match self.hugepage_reservation() { + // `as i64` rather than `i64::from`: trait methods are not + // callable in a const fn. + GuestHugePageConfig::Allocate { size, count } => size.bytes() * (count as i64), + // `Auto` is unreachable -- `hugepage_reservation` resolves it -- + // and `None` reserves nothing. + _ => 0, + }; + let available_mib = + (self.memory_bytes() - reserved_bytes - GUEST_KERNEL_HEADROOM_BYTES) / (1024 * 1024); + // Rounded down first, then tested: a remainder under a mebibyte is + // not a limit anybody can express, and `0` would mean the opposite + // of one. + if available_mib <= 0 { + return None; + } + Some(available_mib as u32) + } + + /// Checks the configuration for internal contradictions. + /// + /// `const` so the same check can run at compile time; see + /// [`assert_valid`](Self::assert_valid). + /// + /// # Errors + /// + /// Returns the first [`ConfigProblem`] found. + pub const fn check(&self) -> Result<(), ConfigProblem> { + // Zero first, because every check below passes vacuously on it: + // `0 % anything` is 0, so an empty VM would read as well-aligned + // and then fail at launch with whatever the hypervisor says about + // a machine with nothing in it. + if self.memory_mib == 0 { + return Err(ConfigProblem::NoMemory); + } + if self.vcpus == 0 { + return Err(ConfigProblem::NoVcpus); + } + if self.fabric.len() > MAX_FABRIC_NICS as usize { + return Err(ConfigProblem::TooManyNics); + } + let memory_bytes = self.memory_bytes(); + let page_bytes = self.host_page_size.bytes(); + if memory_bytes % page_bytes != 0 { + return Err(ConfigProblem::MemoryNotAligned); + } + // The *resolved* reservation, not the raw field: `Auto` is what + // nearly every config carries, and checking the field would check + // nothing at all for them. + if let GuestHugePageConfig::Allocate { size, count } = self.hugepage_reservation() { + // `as i64` rather than `i64::from`: trait methods are not + // callable in a const fn, and this check must stay const. + let required = size.bytes() * (count as i64); + // Strictly less, not `<=`. The guest kernel has already claimed memory by the time it + // reserves hugepages, so a reservation of *all* of RAM cannot be satisfied -- it + // reports "HugeTLB: allocating 1 of page size 1.00 GiB failed. Only allocated 0 + // hugepages." on the console and boots without them, which surfaces much later as a + // test failure with nothing pointing here. + // + // `<=` accepted exactly that, and the default was exactly that: one 1 GiB page out of + // 1 GiB. It only ever worked because 1 GiB host pages handed the guest a contiguous + // block; it became intermittent the moment the host default stopped doing so. + if required + GUEST_KERNEL_HEADROOM_BYTES > memory_bytes { + return Err(ConfigProblem::HugepagesExceedMemory); + } + } + // A fuzz target with nowhere to fuzz. Checked here rather than + // clamped at the point of use because the clamp would have to be + // zero, and libfuzzer reads zero as "no limit" -- see + // `fuzz_rss_limit_mib`. + if self.is_fuzz_target() && self.fuzz_rss_limit_mib().is_none() { + return Err(ConfigProblem::NoRoomToFuzz); + } + // Now that the backend is part of the configuration this is an + // internal contradiction like the others, rather than something only + // a caller holding both halves could check. + // + // `RequestedBackend::Default` stays permissive: an unpinned test that + // asks for an emulated NIC is not a contradiction, it is a test that + // wants QEMU, and `RequestedBackend::resolve` selects it. + if self.first_qemu_only_nic().is_some() + && matches!( + self.backend, + crate::backend::RequestedBackend::CloudHypervisor + ) + { + return Err(ConfigProblem::NicRequiresQemu); + } + Ok(()) + } + + /// Rejects an invalid configuration at compile time. + /// + /// The generated test harness emits `const _: () = CONFIG.assert_valid();` + /// so that a contradiction is a build error rather than a VM that fails + /// to launch several tiers later. + /// + /// # Panics + /// + /// Panics if [`check`](Self::check) fails. In a `const` context that + /// panic *is* the compile error. + pub const fn assert_valid(&self) { + match self.check() { + Ok(()) => {} + Err(ConfigProblem::MemoryNotAligned) => panic!( + "VM memory is not a whole number of host pages; \ + pick a host_page_size that divides the VM's memory" + ), + Err(ConfigProblem::HugepagesExceedMemory) => panic!( + "the guest hugepage reservation does not leave the guest kernel room; \ + reduce hugepage_count, use a smaller hugepage size, or set \ + guest_hugepages to GuestHugePageConfig::None" + ), + Err(ConfigProblem::NoMemory) => { + panic!("a VM needs memory; set memory_mib to a non-zero whole number of host pages") + } + Err(ConfigProblem::NoVcpus) => panic!("a VM needs at least one vCPU; set vcpus"), + Err(ConfigProblem::TooManyNics) => panic!( + "too many fabric interfaces; beyond 254 two of them would share a \ + MAC address and a link-local address" + ), + Err(ConfigProblem::NoRoomToFuzz) => panic!( + "this fuzz target's VM has no memory left for the engine once the guest \ + kernel and the hugepage reservation have taken theirs; raise memory_mib, \ + or set guest_hugepages to GuestHugePageConfig::None" + ), + Err(ConfigProblem::NicRequiresQemu) => panic!( + "this NIC model is emulated only by QEMU, but the configuration pinned \ + cloud-hypervisor; leave the backend at RequestedBackend::Default to let \ + the harness pick QEMU, or ask for RequestedBackend::Qemu" + ), + } + } + + /// The corpus directory for this test, relative to the workspace root. + /// + /// Guaranteed relative, which the callers rely on: the host tier joins + /// it onto the workspace root (an absolute path would silently *replace* + /// the root rather than extend it) and the guest path is built by + /// concatenation. + /// + /// A relative `file!()` is used as-is. An absolute one is the + /// `--remap-path-prefix==${src}` case described on + /// [`Self::source_file`]: the remap prepends the workspace's + /// store path, so the workspace-relative tail is recovered by cutting at + /// the crate directory's own name, which is where the manifest dir and + /// the source path necessarily agree. `rposition` because the anchor is + /// the *last* such component -- a store hash like `abc-n-vm-source` + /// would otherwise match ahead of the real crate directory. + /// + /// This is the *fallback* location, used when no engine named one. + /// Under `cargo bolero test` the directories come from the engine's own + /// command line instead (see [`n_vm_protocol::fuzz_dirs`]), because + /// `cargo-bolero` computes them from `--corpus-dir` and from its own + /// `fuzz_dir()` derivation -- recomputing them here would mean + /// reimplementing that derivation and drifting from it. + /// + /// `None` when the test is not a fuzz target, when the path has no + /// parent to hang `__fuzz__` off, or when the anchor is absent (a crate + /// sitting at the workspace root, whose directory name the remapped + /// prefix does not preserve). Callers must treat `None` on a fuzz + /// target as an error: a missing corpus mount otherwise surfaces as a + /// confusing read-only failure inside the guest. + #[must_use] + pub fn corpus_rel_dir(&self) -> Option { + use std::path::{Component, Path, PathBuf}; + + if !self.is_fuzz_target() { + return None; + } + let (file, crate_dir) = self.source_file?; + let file = Path::new(file); + + let relative: PathBuf = if file.is_relative() { + file.to_path_buf() + } else { + let anchor = Path::new(crate_dir).file_name()?; + let components: Vec> = file.components().collect(); + let start = components.iter().rposition(|c| c.as_os_str() == anchor)?; + components[start..].iter().collect() + }; + + Some(relative.parent()?.join(n_vm_protocol::CORPUS_DIR_NAME)) + } + + /// The absolute path at which the corpus directory appears *inside the + /// guest*, given that the workspace is mounted at + /// `/{VM_WORKSPACE_DIR}`. + /// + /// Both the host tier (which bind-mounts the directory) and the + /// container tier (which puts this on the kernel command line) compute + /// it from the same compile-time constant, so they cannot disagree. + #[must_use] + pub fn corpus_guest_path(&self) -> Option { + let rel = self.corpus_rel_dir()?; + Some(format!( + "/{workspace}/{rel}", + workspace = n_vm_protocol::VM_WORKSPACE_DIR, + rel = rel.display(), + )) + } +} + +impl VmConfig { + /// Checks that the VM memory is properly aligned for the host page + /// size and that guest hugepage reservations fit within VM memory. + /// + /// # Errors + /// + /// Returns a human-readable error string if validation fails. + /// + /// [`TestVm::launch`]: crate::vm::TestVm::launch + pub fn validate_memory_alignment(&self) -> Result<(), String> { + // Same conditions as `check`, but with the numbers formatted in. + // This path survives because a config can reach the launcher without + // having gone through `assert_valid` -- it is built at run time in + // tests, for one -- and because "1024 MiB is not a multiple of 1 GiB" + // is a more useful thing to read than the static message a const + // panic is limited to. + match self.check() { + Ok(()) => Ok(()), + Err(ConfigProblem::NoMemory) => Err("memory_mib is 0; a VM needs memory".to_owned()), + Err(ConfigProblem::NoVcpus) => Err("vcpus is 0; a VM needs at least one".to_owned()), + Err(ConfigProblem::TooManyNics) => Err(format!( + "fabric_nics ({n}) exceeds {MAX_FABRIC_NICS}; the MAC and link-local \ + addresses are derived from the index and would collide", + n = self.fabric.len(), + )), + Err(ConfigProblem::MemoryNotAligned) => Err(format!( + "guest memory ({mib} MiB = {bytes} bytes) is not a whole number of \ + host pages ({page_bytes} bytes)", + mib = self.memory_mib, + bytes = self.memory_bytes(), + page_bytes = self.host_page_size.bytes(), + )), + Err(ConfigProblem::HugepagesExceedMemory) => { + let GuestHugePageConfig::Allocate { size, count } = self.hugepage_reservation() + else { + unreachable!( + "HugepagesExceedMemory is only reachable when hugepages are allocated" + ) + }; + let required = size.bytes() * i64::from(count); + Err(format!( + "guest hugepage reservation ({count} x {} = {required} bytes) \ + exceeds VM memory ({bytes} bytes)", + size.bytes(), + bytes = self.memory_bytes(), + )) + } + Err(ConfigProblem::NoRoomToFuzz) => Err(format!( + "this is a fuzz target, but its VM ({mib} MiB) has nothing left for the \ + engine once the guest kernel and the hugepage reservation have taken \ + theirs; raise memory_mib or drop the reservation", + mib = self.memory_mib, + )), + Err(ConfigProblem::NicRequiresQemu) => { + let Some(nic) = self.first_qemu_only_nic() else { + unreachable!("NicRequiresQemu is only reachable when some NIC needs QEMU") + }; + Err(format!( + "{nic:?} is emulated only by QEMU, but this configuration pinned \ + cloud-hypervisor", + )) + } + } + } +} + +/// Guest memory a hugepage reservation must leave for the kernel that performs it. +/// +/// Not a measurement -- a floor. The kernel reserves hugepages very early, but not before it +/// exists, so a reservation may not name the whole of RAM. 128 MiB is enough for the kernels this +/// boots and small enough not to constrain a reservation anybody would actually want. +pub(crate) const GUEST_KERNEL_HEADROOM_BYTES: i64 = 128 * 1024 * 1024; + +/// A CPU topology whose levels multiply to a given vCPU count. +/// +/// Both hypervisors want the four levels rather than a total, and both +/// reject a topology that does not multiply back -- so this is derived in +/// one place and used by both, rather than each backend guessing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SmpTopology { + /// Sockets (packages). + pub sockets: u32, + /// Dies per socket. x86-only; aarch64 folds this into `cores`. + pub dies: u32, + /// Cores per die. + pub cores: u32, + /// Hardware threads per core. + pub threads: u32, +} + +impl SmpTopology { + /// Arranges `vcpus` into levels whose product is exactly `vcpus`. + /// + /// An even count keeps SMT, because that is the shape a real host has + /// and the guest is code that inspects its own topology: DPDK reads + /// `/sys` to lay lcores out, and a machine that claims no hyperthreads + /// exercises a different path from every machine this code runs on in + /// production. An odd count cannot be split that way, so it falls back + /// to flat cores rather than rounding the request. + /// + /// At the default of 6 this reproduces the topology that used to be + /// four hand-written constants -- 1 socket, 3 dies, 1 core, 2 threads -- + /// so making the count a lever changes nothing for a test that does not + /// set it. + pub(crate) const fn for_vcpus(vcpus: u32) -> Self { + if vcpus.is_multiple_of(2) { + Self { + sockets: 1, + dies: vcpus / 2, + cores: 1, + threads: 2, + } + } else { + Self { + sockets: 1, + dies: 1, + cores: vcpus, + threads: 1, + } + } + } +} + +/// Describes a network interface shared across all hypervisor backends. +/// +/// Owned rather than `&'static`, because the fabric interfaces are +/// generated from a count. Carrying the MTU, queue depth and PCI segment +/// here rather than branching on "is this the management one" in each +/// backend is what lets both of them lower an arbitrary list identically. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetIface { + /// Unique identifier used in device configuration (e.g. `"mgmt"`, + /// `"fabric1"`). + pub id: String, + /// TAP device name on the host. + pub tap: String, + /// MAC address in `XX:XX:XX:XX:XX:XX` format. + pub mac: String, + /// IPv6 link-local address assigned to the host-side TAP. + pub host_ipv6: Ipv6Addr, + /// Link MTU. + pub mtu: i32, + /// Virtio queue depth. + pub queue_size: i32, + /// PCI segment the device is placed on. + /// + /// Segment 1 is the one placed behind the virtual IOMMU, so this is + /// also what decides whether a device gets DMA remapping. + pub pci_segment: u16, + /// The device model the guest sees, and therefore which driver binds + /// it. + /// + /// Per-interface rather than per-VM because a machine whose links are + /// all the same device cannot exercise the thing that goes wrong: a + /// program that picks a NIC by ordinal, or by whatever `/sys` lists + /// first, looks correct on a uniform machine and unbinds the wrong + /// device on a mixed one. + pub model: NicModel, +} + +/// The largest fabric NIC index the address derivation can represent. +/// +/// The last octet of the MAC and the last group of the IPv6 address are +/// both the interface's index, so 254 is where two NICs would start +/// sharing an address. That does not fail: the guest brings both up, and +/// forwarding silently goes to whichever answered -- which is precisely +/// the symptom a failover test would then be trying to explain. +pub(crate) const MAX_FABRIC_NICS: u8 = 254; + +/// The fabric-facing interfaces a VM is given. +/// +/// Two spellings of one list, because the two things a test wants to say +/// about its fabric are different questions. Most tests care only how +/// many links there are; a test of device *identification* cares what each +/// one is, and needs them to differ. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FabricNics { + /// `n` links, every one of [`VmConfig::nic_model`]. + Uniform(u8), + /// One link per entry, each of the model named. + /// + /// A `&'static` slice rather than an array so this stays writable in + /// the `const` the builder produces -- the same reason + /// [`VmConfig::kernel_features`] is one. + Mixed(&'static [NicModel]), +} + +impl Default for FabricNics { + fn default() -> Self { + VmConfig::DEFAULT.fabric + } +} + +impl FabricNics { + /// How many fabric links this describes. + /// + /// `usize` rather than `u8` so that an over-long [`Mixed`](Self::Mixed) + /// is a number [`VmConfig::check`] can compare against + /// `MAX_FABRIC_NICS`, instead of one that has already wrapped past + /// it. + #[must_use] + pub const fn len(&self) -> usize { + match self { + Self::Uniform(count) => *count as usize, + Self::Mixed(models) => models.len(), + } + } + + /// Whether this VM has no fabric links at all, which is what a test + /// that never touches the network should ask for. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The model of the `index`-th link, counting from zero. + /// + /// `default` is what a [`Uniform`](Self::Uniform) fabric is made of; + /// it is the VM's own [`nic_model`](VmConfig::nic_model), which is + /// also what the management link uses. It is returned for an index + /// past the end of a [`Mixed`](Self::Mixed) fabric too, which + /// [`VmConfig::all_ifaces`] never asks for -- it iterates + /// [`len`](Self::len). + #[must_use] + pub const fn model(&self, index: usize, default: NicModel) -> NicModel { + match self { + Self::Uniform(_) => default, + Self::Mixed(models) => { + if index < models.len() { + models[index] + } else { + default + } + } + } + } + + /// The first model here that only QEMU emulates, if any. + /// + /// Returned rather than a bare `bool` so the error can name the device + /// that forced the choice -- on a mixed fabric, "some NIC in this VM + /// needs QEMU" is not enough to act on. + #[must_use] + pub const fn first_qemu_only(&self, default: NicModel) -> Option { + match self { + Self::Uniform(count) => { + if *count > 0 && default.requires_qemu() { + Some(default) + } else { + None + } + } + Self::Mixed(models) => { + // A `while` rather than an iterator: this must stay `const`, + // because it is what `check` asks. + let mut i = 0; + while i < models.len() { + if models[i].requires_qemu() { + return Some(models[i]); + } + i += 1; + } + None + } + } + } +} + +/// IPv6 prefix length for host-side TAP addresses (link-local /64). +pub(crate) const TAP_IPV6_PREFIX_LEN: u8 = 64; + +/// MTU for the management network interface (standard Ethernet). +pub(crate) const MGMT_MTU: i32 = 1500; + +/// MTU for fabric-facing network interfaces (jumbo frames). +pub(crate) const FABRIC_MTU: i32 = 9500; + +/// Virtio queue depth for the management network interface. +pub(crate) const MGMT_QUEUE_SIZE: i32 = 512; + +/// Virtio queue depth for fabric-facing network interfaces. +pub(crate) const FABRIC_QUEUE_SIZE: i32 = 8192; + +/// Virtio queue depth for the virtiofs filesystem device. +pub(crate) const VIRTIOFS_QUEUE_SIZE: u32 = 1024; + +/// Initial buffer capacity for vsock reader tasks. +pub(crate) const VSOCK_READER_CAPACITY: usize = 32_768; + +/// Duration to continue draining hypervisor events after a guest panic +/// is detected. +pub(crate) const POST_PANIC_DRAIN_TIMEOUT: Duration = Duration::from_millis(500); + +/// A writable share that this run actually has, and where it lands. +/// +/// Separate from [`n_vm_protocol::WritableShare`], which is the static +/// description of a window that *could* exist. Which windows are open, and +/// what guest path each covers, is decided per run: the engine names its own +/// directories, and their guest paths are host paths put through the +/// workspace remap. Resolving that once and threading the result keeps the +/// backends' argument lowering a pure function of its inputs, the same way +/// `arch` and `kernel_image` are. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveShare { + /// Which window this is. + pub share: n_vm_protocol::WritableShare, + /// Absolute path the guest mounts it at. + pub guest_path: String, +} + +impl ActiveShare { + /// The shares this container has, in [`n_vm_protocol::WRITABLE_SHARES`] + /// order. + /// + /// A share is present when *both* halves are: the host tier bind-mounted + /// a directory at the container path, and it named the guest path in the + /// environment. Requiring both is what keeps a virtio device from being + /// added with no daemon behind it, which does not fail -- it hangs the + /// hypervisor waiting on a vhost-user socket that will never be served. + #[must_use] + pub fn resolve() -> Vec { + n_vm_protocol::WRITABLE_SHARES + .iter() + .filter(|share| std::path::Path::new(share.container_path).is_dir()) + .filter_map(|share| { + let guest_path = std::env::var(share.env_key).ok()?; + (!guest_path.is_empty()).then_some(Self { + share: *share, + guest_path, + }) + }) + .collect() + } +} + +/// Builds the guest kernel command line. +pub(crate) fn build_kernel_cmdline( + vm_bin_path: &str, + test_name: &str, + vsock: &VsockAllocation, + // Grouped rather than passed field by field: `iommu`, the hugepage + // reservation and the module parameters all come from here, and + // threading them separately made the signature grow every time the + // config did. + vm_config: &VmConfig, + // Not from the config, because which windows are open is a fact about + // this run rather than about the test -- see [`ActiveShare`]. + shares: &[ActiveShare], + arch: Arch, + boot: crate::kernel_manifest::BootMode, +) -> String { + let vsock_cmdline = vsock.kernel_cmdline_fragment(); + let iommu = vm_config.iommu; + let guest_hugepages = vm_config.hugepage_reservation(); + + // Without a vIOMMU, allow DPDK to bind devices via vfio-pci. + let noiommu_fragment = if iommu { + "" + } else { + "vfio.enable_unsafe_noiommu_mode=1 " + }; + + let hugepage_fragment = guest_hugepages.kernel_cmdline_fragment(); + + // Module parameters, in the kernel section (before `--`). Each was + // checked for shape when it was declared, so this only has to join them. + let module_param_fragment = + vm_config + .module_params + .iter() + .fold(String::new(), |mut acc, param| { + acc.push_str(¶m.render()); + acc.push(' '); + acc + }); + + // The IOMMU and console parameters are lowered per guest ISA + // (x86 ttyS0 vs aarch64 ttyAMA0); `arch` is passed in explicitly so + // this is testable for every ISA on any build host. The IOMMU kernel + // params come from the vIOMMU lowering and are present whenever the + // ISA has one (empty otherwise) -- independent of the per-test flag. + let iommu_params = arch.virtual_iommu().map_or("", |l| l.kernel_params); + let console_params = arch.console_kernel_params(); + + // Where `n-it` should mount each writable share. Empty for an ordinary + // test, which never sees a writable filesystem at all. + let corpus_fragment = shares.iter().fold(String::new(), |mut acc, active| { + use std::fmt::Write as _; + let _ = write!( + acc, + "{key}={path} ", + key = active.share.cmdline_key, + path = active.guest_path, + ); + acc + }); + + // `sysctl.debug.exception-trace=1` makes the kernel report a userspace + // fault -- faulting PC, SP and address -- instead of killing the process + // silently. It defaults to 0 (`show_unhandled_signals` in + // arch/arm64/kernel/traps.c), which meant a test binary taking SIGSEGV + // in the guest produced no diagnostic at all: `n-it` could say only + // "main process exited with failure status signal: 11". A VM that + // exists to run tests should say why one died. + // + // How the kernel is told to find its root. + // + // A direct boot names the virtiofs share and the init to exec once it + // is mounted. An initramfs boot reaches none of that code: + // `prepare_namespace` is skipped entirely when a cpio supplies the + // root, so `root=`/`rootfstype=` would be read by nothing, and `init=` + // applies only after a switch_root the pre-init does not perform. + // Naming them anyway would be inert *and* misleading about how the + // guest actually boots. + // + // `rdinit=` is spelled explicitly even though `/init` is the kernel's + // default, because an external initramfs layered onto a foreign + // kernel's embedded one (as Flatcar would need) resolves `/init` to + // whichever cpio was unpacked last. Naming the path does not depend on + // that ordering. + let root_params = match boot { + crate::kernel_manifest::BootMode::Direct => { + format!("rootfstype=virtiofs root=root init={INIT_BINARY_PATH}") + } + crate::kernel_manifest::BootMode::Initramfs => "rdinit=/init".to_owned(), + }; + + format!( + "{iommu_params} \ + {noiommu_fragment}\ + {console_params} \ + sysctl.debug.exception-trace=1 \ + ro \ + {root_params} \ + {hugepage_fragment}\ + {module_param_fragment}\ + {corpus_fragment}\ + {vsock_cmdline} \ + -- {vm_bin_path} {test_name} --exact --no-capture --format=terse", + ) +} + +/// Reads an async byte stream to EOF and returns its contents as a +/// UTF-8 string. +pub(crate) async fn read_vsock_stream( + mut stream: impl tokio::io::AsyncRead + Unpin, + label: &str, +) -> String { + let mut buf = Vec::with_capacity(VSOCK_READER_CAPACITY); + loop { + match stream.read_buf(&mut buf).await { + Ok(0) => break, + Ok(_) => {} + Err(e) => { + error!("error reading {label} vsock stream: {e}"); + break; + } + } + } + String::from_utf8_lossy(&buf).into_owned() +} + +/// Best-effort capture of a child process's stderr, logged at +/// appropriate levels. +pub(crate) async fn drain_child_stderr(child: &mut tokio::process::Child, label: &str) { + // Give the child a moment to flush its output. + tokio::time::sleep(Duration::from_millis(100)).await; + + let Some(mut stderr) = child.stderr.take() else { + return; + }; + + let mut buf = String::with_capacity(4096); + match tokio::time::timeout(Duration::from_secs(2), stderr.read_to_string(&mut buf)).await { + Ok(Ok(_)) if !buf.is_empty() => { + error!("{label} stderr (captured after launch failure):\n{buf}"); + } + Ok(Ok(_)) => { + warn!("{label} stderr was empty after launch failure"); + } + Ok(Err(e)) => { + warn!("failed to read {label} stderr: {e}"); + } + Err(_) => { + warn!("timed out reading {label} stderr"); + if !buf.is_empty() { + error!("{label} stderr (partial, timed out):\n{buf}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The default VM with `n` fabric links, all of the default model. + const fn uniform_fabric(n: u8) -> VmConfig { + VmConfig { + fabric: FabricNics::Uniform(n), + ..VmConfig::DEFAULT + } + } + + const DEFAULT_HP: GuestHugePageConfig = GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + count: 1, + }; + + // -- Writable shares ---------------------------------------------- + + fn active(share: n_vm_protocol::WritableShare, guest_path: &str) -> ActiveShare { + ActiveShare { + share, + guest_path: guest_path.to_owned(), + } + } + + fn cmdline_with(shares: &[ActiveShare]) -> String { + build_kernel_cmdline( + "/test/bin", + "my::test", + &n_vm_protocol::VsockAllocation::with_defaults(), + &VmConfig::DEFAULT, + shares, + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ) + } + + /// Both windows reach `n-it`, each under its own key. + /// + /// One key could not carry two paths, and the guest has to mount them + /// separately -- they are in unrelated trees. + #[test] + fn each_writable_share_gets_its_own_cmdline_key() { + let cmdline = cmdline_with(&[ + active(n_vm_protocol::CORPUS_SHARE, "/workspace/.fuzz-corpus/t"), + active( + n_vm_protocol::CRASHES_SHARE, + "/workspace/m/__fuzz__/t/crashes", + ), + ]); + assert!( + cmdline.contains("n_it.corpus_mount=/workspace/.fuzz-corpus/t"), + "{cmdline}", + ); + assert!( + cmdline.contains("n_it.crashes_mount=/workspace/m/__fuzz__/t/crashes"), + "{cmdline}", + ); + } + + /// An ordinary test is told about no writable filesystem at all. + #[test] + fn a_test_with_no_shares_names_no_mounts() { + let cmdline = cmdline_with(&[]); + assert!(!cmdline.contains("_mount="), "{cmdline}"); + } + + /// The keys land in the kernel section, before the `--` that separates + /// it from the test binary's own argv. A parameter on the wrong side + /// of that split does not fail: `n-it` never sees it, and the guest + /// silently has no writable share. + #[test] + fn the_mount_keys_precede_the_argv_separator() { + let cmdline = cmdline_with(&[active(n_vm_protocol::CORPUS_SHARE, "/workspace/c")]); + let key = cmdline.find("n_it.corpus_mount=").expect("key is present"); + let split = cmdline.find(" -- ").expect("separator is present"); + assert!(key < split, "{cmdline}"); + } + + // -- Hugepage defaulting ------------------------------------------ + + /// The reservation an ordinary guest test still gets: the same 512 MiB + /// it got when `VmConfig::DEFAULT` spelled it out. + #[test] + fn an_ordinary_test_reserves_what_it_always_reserved() { + assert_eq!( + VmConfig::DEFAULT.hugepage_reservation(), + GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge2M, + count: 256, + }, + ); + } + + /// A fuzz target reserves nothing: a coverage-guided engine claims + /// ordinary heap, and the reservation was pure subtraction from the + /// memory it could use. + #[test] + fn a_fuzz_target_reserves_nothing() { + let config = with_corpus("n-vm/tests/integration.rs", "/home/dev/dataplane/n-vm"); + assert_eq!(config.guest_hugepages, GuestHugePageConfig::Auto); + assert_eq!(config.hugepage_reservation(), GuestHugePageConfig::None); + } + + /// `Auto` is a default, not a rule. Fuzzing a hugepage-dependent path + /// is a coherent thing to want, and saying so wins. + #[test] + fn a_fuzz_target_may_still_ask_for_hugepages() { + let asked = GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge2M, + count: 64, + }; + let config = VmConfig { + guest_hugepages: asked, + ..with_corpus("n-vm/tests/integration.rs", "/home/dev/dataplane/n-vm") + }; + assert_eq!(config.hugepage_reservation(), asked); + } + + /// And an explicit `None` on an ordinary test still means none, rather + /// than falling through to the reservation `Auto` would have picked. + #[test] + fn an_ordinary_test_may_still_decline_hugepages() { + let config = VmConfig { + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT + }; + assert_eq!(config.hugepage_reservation(), GuestHugePageConfig::None); + } + + /// The end of the chain: what the guest kernel is actually told. + /// + /// Asserted here as well as on the resolved value because the command + /// line is the only thing the guest sees, and `build_kernel_cmdline` + /// reading the raw field instead of the resolved one is exactly the + /// mistake this defaulting invites. + #[test] + fn a_fuzz_target_gets_no_hugepage_reservation_on_the_cmdline() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &with_corpus("n-vm/tests/integration.rs", "/home/dev/dataplane/n-vm"), + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + !cmdline.contains("hugepages"), + "a fuzz target should be told nothing about hugepages: {cmdline}", + ); + } + + // -- Corpus paths ------------------------------------------------- + + fn with_corpus(file: &'static str, crate_dir: &'static str) -> VmConfig { + VmConfig { + corpus: CorpusPolicy::Fuzz, + source_file: Some((file, crate_dir)), + ..VmConfig::DEFAULT + } + } + + /// A plain `cargo test` gives a workspace-relative `file!()`. + #[test] + fn corpus_dir_from_a_relative_source_path() { + let config = with_corpus("n-vm/tests/integration.rs", "/home/dev/dataplane/n-vm"); + assert_eq!( + config.corpus_rel_dir().expect("resolvable"), + std::path::Path::new("n-vm/tests/__fuzz__"), + ); + } + + /// The `--remap-path-prefix==${src}` build gives an absolute store path. + /// + /// Treating it as relative is what broke `#[corpus]`: the host tier's + /// `workspace.join(rel)` discarded the workspace, and the guest was told + /// to look under `/workspace//nix/store/...`. + #[test] + fn corpus_dir_from_a_remapped_absolute_source_path() { + let config = with_corpus( + "/nix/store/7qpx1j7sqw6zklc13w0v338vwwykjzpl-source/n-vm/tests/integration.rs", + "/build/source/n-vm", + ); + let rel = config.corpus_rel_dir().expect("resolvable"); + assert!(rel.is_relative(), "must be relative, got {}", rel.display()); + assert_eq!(rel, std::path::Path::new("n-vm/tests/__fuzz__")); + } + + /// The guest path must not contain a doubled root. + #[test] + fn corpus_guest_path_is_a_single_rooted_path() { + let config = with_corpus( + "/nix/store/7qpx1j7sqw6zklc13w0v338vwwykjzpl-source/n-vm/tests/integration.rs", + "/build/source/n-vm", + ); + assert_eq!( + config.corpus_guest_path().expect("resolvable"), + "/workspace/n-vm/tests/__fuzz__", + ); + } + + /// The anchor is the *last* matching component, so a store hash that + /// happens to contain the crate name does not win. + #[test] + fn corpus_dir_anchors_on_the_last_matching_component() { + let config = with_corpus( + "/nix/store/abcd-n-vm-source/n-vm/tests/integration.rs", + "/build/source/n-vm", + ); + assert_eq!( + config.corpus_rel_dir().expect("resolvable"), + std::path::Path::new("n-vm/tests/__fuzz__"), + ); + } + + /// Unresolvable rather than silently wrong when there is no anchor; the + /// caller turns this into an error instead of a read-only corpus. + #[test] + fn corpus_dir_is_unresolvable_without_an_anchor() { + let config = with_corpus( + "/nix/store/abcd-source/tests/integration.rs", + "/build/source", + ); + assert_eq!(config.corpus_rel_dir(), None); + } + + /// A test that never opted in has no corpus and no error. + #[test] + fn no_corpus_opt_in_means_no_corpus_dir() { + assert_eq!(VmConfig::DEFAULT.corpus_rel_dir(), None); + assert_eq!(VmConfig::DEFAULT.corpus_guest_path(), None); + } + + // -- Arch profiles (both arches exercised on a single host) ------- + + #[test] + fn virtual_iommu_lowering_is_coherent_per_arch() { + // The point of folding the vIOMMU into one object: a lowering must + // describe at least one way to instantiate the IOMMU -- a `-device` + // (x86 intel-iommu) or a `-machine` option (aarch64 iommu=smmuv3) + // -- so it can't be present-but-inert. `supports_virtual_iommu` + // tracks `is_some` exactly. + for arch in [Arch::X86_64, Arch::Aarch64] { + match arch.virtual_iommu() { + Some(l) => { + assert!( + l.device.is_some() || !l.machine_opts.is_empty(), + "{arch:?}: lowering must have a device or a machine option", + ); + assert!(arch.supports_virtual_iommu()); + } + None => assert!(!arch.supports_virtual_iommu()), + } + } + } + + #[test] + fn arch_x86_64_profile() { + let a = Arch::X86_64; + assert_eq!(a.qemu_system_binary(), "/bin/qemu-system-x86_64"); + assert_eq!(a.manifest_name(), "x86_64"); + assert_eq!(a.qemu_machine_base(), "q35"); + assert_eq!(a.pvpanic_device(), "pvpanic"); + assert!(a.console_kernel_params().contains("ttyS0")); + let viommu = a.virtual_iommu().expect("x86 has a vIOMMU lowering"); + assert!(viommu.device.is_some_and(|d| d.starts_with("intel-iommu"))); + assert_eq!(viommu.machine_opts, "kernel-irqchip=split"); + assert!(viommu.kernel_params.contains("intel_iommu=on")); + assert!(a.supports_virtual_iommu()); + assert!(a.smp_topology(6).contains("dies=")); + } + + #[test] + fn arch_aarch64_profile() { + let a = Arch::Aarch64; + assert_eq!(a.qemu_system_binary(), "/bin/qemu-system-aarch64"); + assert_eq!(a.manifest_name(), "aarch64"); + assert!(a.qemu_machine_base().starts_with("virt")); + assert_eq!(a.pvpanic_device(), "pvpanic-pci"); + assert!(a.console_kernel_params().contains("ttyAMA0")); + // aarch64's SMMUv3 is a machine option, not a device, and is + // auto-probed (no kernel command-line params). + let viommu = a.virtual_iommu().expect("aarch64 has an SMMUv3 lowering"); + assert_eq!(viommu.device, None); + assert_eq!(viommu.machine_opts, "iommu=smmuv3"); + assert!(viommu.kernel_params.is_empty()); + assert!(a.supports_virtual_iommu()); + assert!( + !a.smp_topology(6).contains("dies="), + "aarch64 -smp must not use the x86-only dies= level: {}", + a.smp_topology(6), + ); + } + + /// Both hypervisors reject a topology whose levels do not multiply to + /// the vCPU count, so this has to hold for every count a test can ask + /// for -- not just the default it used to be checked at. + #[test] + fn smp_topology_preserves_vcpu_count_on_both_arches() { + for vcpus in [1, 2, 3, 4, 6, 7, 8, 16, 31, 64] { + for arch in [Arch::X86_64, Arch::Aarch64] { + let smp = arch.smp_topology(vcpus); + assert!( + smp.starts_with(&format!("{vcpus},")), + "{arch:?} -smp must declare {vcpus} vCPUs: {smp}", + ); + // sockets * (dies) * cores * threads == vcpus + let product: u32 = smp + .split(',') + .skip(1) + .filter_map(|kv| kv.split('=').nth(1)) + .filter_map(|v| v.parse::().ok()) + .product(); + assert_eq!( + product, vcpus, + "{arch:?} topology must multiply to {vcpus}: {smp}" + ); + } + } + } + + /// The default still produces the machine the four hand-written + /// constants used to: making the count a lever must not silently change + /// what a test that never sets it boots on. + #[test] + fn the_default_vcpu_count_lowers_to_the_topology_it_always_did() { + assert_eq!( + Arch::X86_64.smp_topology(VmConfig::DEFAULT.vcpus), + "6,sockets=1,dies=3,cores=1,threads=2", + ); + } + + #[test] + fn kernel_cmdline_includes_hugepage_reservation_for_1g() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let hp = GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + count: 1, + }; + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: hp, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("hugepages=1"), + "cmdline should configure hugepage count: {cmdline}", + ); + assert!( + cmdline.contains("hugepagesz=1G"), + "cmdline should configure hugepage size: {cmdline}", + ); + assert!( + cmdline.contains("default_hugepagesz=1G"), + "cmdline should set default hugepage size: {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_includes_hugepage_reservation_for_2m() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let hp = GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge2M, + count: 512, + }; + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: hp, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("hugepages=512"), + "cmdline should configure hugepage count: {cmdline}", + ); + assert!( + cmdline.contains("hugepagesz=2M"), + "cmdline should configure 2M hugepage size: {cmdline}", + ); + assert!( + cmdline.contains("default_hugepagesz=2M"), + "cmdline should set default hugepage size: {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_omits_hugepages_when_none() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + !cmdline.contains("hugepagesz"), + "cmdline should not contain hugepagesz: {cmdline}", + ); + assert!( + !cmdline.contains("default_hugepagesz"), + "cmdline should not contain default_hugepagesz: {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_includes_init_binary() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains(&format!("init={INIT_BINARY_PATH}")), + "cmdline should set init binary: {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_passes_test_binary_and_name() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("-- /test/bin my::test --exact"), + "cmdline should pass test binary and name after '--': {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_includes_vsock_parameters() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let fragment = vsock.kernel_cmdline_fragment(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains(&fragment), + "cmdline should contain vsock port parameters ({fragment}): {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_enables_noiommu_mode_when_iommu_disabled() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("vfio.enable_unsafe_noiommu_mode=1"), + "cmdline should enable no-IOMMU mode when iommu is disabled: {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_omits_noiommu_mode_when_iommu_enabled() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: true, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + !cmdline.contains("noiommu"), + "cmdline should NOT enable no-IOMMU mode when iommu is enabled: {cmdline}", + ); + } + + #[test] + fn kernel_cmdline_iommu_kernel_params_match_arch() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + + // x86_64 always carries the Intel/AMD IOMMU kernel hints (whether or + // not a vIOMMU device is present); aarch64 carries none. Asserting + // both ISAs here -- on a single build -- is the point of threading + // `Arch` instead of reading `Arch::current()`. + for iommu in [false, true] { + let x86 = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!(x86.contains("intel_iommu=on"), "x86 (iommu={iommu}): {x86}"); + + let arm = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::Aarch64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + !arm.contains("intel_iommu"), + "aarch64 must not carry x86 IOMMU kernel params (iommu={iommu}): {arm}", + ); + } + } + + #[test] + fn kernel_cmdline_console_matches_arch() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let x86 = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!(x86.contains("console=ttyS0"), "x86: {x86}"); + + let arm = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::Aarch64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!(arm.contains("console=ttyAMA0"), "aarch64: {arm}"); + assert!(!arm.contains("ttyS0"), "aarch64 must not use ttyS0: {arm}"); + } + + // -- Module parameters and the const builder ---------------------- + + #[test] + fn a_module_param_renders_as_the_kernel_expects() { + assert_eq!( + ModuleParam::new("vfio-pci", "disable_idle_d3", "1").render(), + "vfio-pci.disable_idle_d3=1", + ); + } + + #[test] + fn declared_module_params_reach_the_kernel_cmdline() { + const PARAMS: &[ModuleParam] = &[ + ModuleParam::new("mlx5_core", "prof_sel", "2"), + ModuleParam::new("vfio-pci", "disable_idle_d3", "1"), + ]; + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + module_params: PARAMS, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("mlx5_core.prof_sel=2"), + "cmdline: {cmdline}" + ); + assert!( + cmdline.contains("vfio-pci.disable_idle_d3=1"), + "cmdline: {cmdline}", + ); + // In the kernel section: everything after `--` is argv for the test + // binary, where a module parameter would be read by libtest instead. + let (kernel, _init) = cmdline + .split_once(" -- ") + .expect("cmdline has an init separator"); + assert!( + kernel.contains("mlx5_core.prof_sel=2"), + "kernel section: {kernel}" + ); + } + + /// The default carries none, so no test that never asks for one pays a + /// stray command-line token for the feature existing. + #[test] + fn no_module_params_adds_nothing_to_the_cmdline() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig::DEFAULT, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!(!cmdline.contains(" "), "double space in {cmdline:?}"); + } + + /// The builder is `..VmConfig::DEFAULT` in fluent spelling, so the two + /// must agree -- otherwise a test converted from one form to the other + /// would silently boot a different machine. + #[test] + fn the_builder_agrees_with_struct_update_syntax() { + const BUILT: VmConfig = VmConfigBuilder::default() + .iommu(true) + .guest_hugepages(GuestHugePageConfig::None) + .build(); + const WRITTEN: VmConfig = VmConfig { + iommu: true, + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT + }; + assert_eq!(BUILT, WRITTEN); + } + + /// The pairs in the integration suite depend on this: a derived config + /// must differ in exactly what was named and nowhere else, or "both + /// backends present the same guest" stops being what those tests check. + #[test] + fn to_builder_changes_only_what_is_named() { + const BASE: VmConfig = VmConfigBuilder::default().iommu(true).build(); + const DERIVED: VmConfig = BASE + .to_builder() + .backend(crate::backend::RequestedBackend::Qemu) + .build(); + assert_eq!(DERIVED.backend, crate::backend::RequestedBackend::Qemu); + assert_eq!( + DERIVED.to_builder().backend(BASE.backend).build(), + BASE, + "putting the backend back should recover the base exactly", + ); + } + + /// The check that used to need the backend passed in alongside the value. + #[test] + fn a_qemu_only_nic_contradicts_a_pinned_cloud_hypervisor() { + let pinned = VmConfig { + nic_model: NicModel::E1000, + backend: crate::backend::RequestedBackend::CloudHypervisor, + ..VmConfig::DEFAULT + }; + assert_eq!(pinned.check(), Err(ConfigProblem::NicRequiresQemu)); + + // Unpinned is not a contradiction: it is a test that wants QEMU, and + // `RequestedBackend::resolve` is what gives it one. + let unpinned = VmConfig { + backend: crate::backend::RequestedBackend::Default, + ..pinned + }; + assert_eq!(unpinned.check(), Ok(())); + } + + #[test] + fn the_default_runtime_is_current_thread() { + assert_eq!(VmConfig::DEFAULT.runtime, GuestRuntime::CurrentThread); + } + + #[test] + fn an_untouched_builder_is_the_default() { + const BUILT: VmConfig = VmConfigBuilder::default().build(); + assert_eq!(BUILT, VmConfig::DEFAULT); + } + + /// `build()` runs the same check the generated harness does, so a + /// contradiction is caught at the builder rather than at launch. This is + /// the runtime half; the compile-time half is a `compile_fail` case in + /// `n-vm-macros`, because a `const` panic cannot be caught here. + #[test] + fn the_builder_checks_what_it_builds() { + let problem = VmConfig { + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + count: 2, + }, + ..VmConfig::DEFAULT + } + .check(); + assert_eq!(problem, Err(ConfigProblem::HugepagesExceedMemory)); + } + + #[test] + fn kernel_cmdline_uses_virtiofs_root() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("rootfstype=virtiofs"), + "cmdline: {cmdline}", + ); + assert!(cmdline.contains("root=root"), "cmdline: {cmdline}"); + } + + #[test] + fn kernel_cmdline_passes_no_capture_and_terse_format() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let cmdline = build_kernel_cmdline( + "/test/bin", + "my::test", + &vsock, + &VmConfig { + iommu: false, + guest_hugepages: DEFAULT_HP, + source_file: None, + ..VmConfig::DEFAULT + }, + &[], + Arch::X86_64, + crate::kernel_manifest::BootMode::Direct, + ); + assert!( + cmdline.contains("--no-capture"), + "cmdline should pass --no-capture: {cmdline}", + ); + assert!( + cmdline.contains("--format=terse"), + "cmdline should pass --format=terse: {cmdline}", + ); + } + + /// Uniqueness across *every* count, not just the default three. + /// + /// The MAC, tap name, link-local address and device id are all derived + /// from the interface index now, so a collision is a bug in the + /// derivation rather than a typo in a table -- and it would not fail + /// loudly: the guest brings both links up and forwards out of whichever + /// answered. + #[test] + fn every_interface_is_distinct_at_every_count() { + for fabric_nics in [0, 1, 2, 3, 8, 64, MAX_FABRIC_NICS] { + let ifaces = uniform_fabric(fabric_nics).all_ifaces(); + assert_eq!( + ifaces.len(), + usize::from(fabric_nics) + 1, + "expected {fabric_nics} fabric interfaces plus mgmt", + ); + + for (label, mut values) in [ + ( + "MAC", + ifaces.iter().map(|i| i.mac.clone()).collect::>(), + ), + ("TAP", ifaces.iter().map(|i| i.tap.clone()).collect()), + ("id", ifaces.iter().map(|i| i.id.clone()).collect()), + ( + "link-local address", + ifaces.iter().map(|i| i.host_ipv6.to_string()).collect(), + ), + ] { + let count = values.len(); + values.sort(); + values.dedup(); + assert_eq!( + count, + values.len(), + "{label}s must be unique at {fabric_nics} fabric NICs", + ); + } + } + } + + /// The bound the engine is given is the memory it can actually reach: + /// the whole VM, less what the kernel reserves before the engine runs. + #[test] + fn a_fuzz_targets_heap_is_bounded_by_the_memory_it_can_reach() { + const FUZZ: VmConfig = VmConfig { + corpus: CorpusPolicy::Fuzz, + ..VmConfig::DEFAULT + }; + // A fuzz target reserves no hugepages, so only the kernel headroom + // comes off the top. + assert_eq!(FUZZ.fuzz_rss_limit_mib(), Some(1024 - 128)); + } + + /// A reservation is memory the guest kernel has given away, so it is + /// not memory the engine may grow into -- even though nothing stops it + /// trying. + #[test] + fn a_hugepage_reservation_comes_off_the_engines_bound() { + const FUZZ: VmConfig = VmConfig { + corpus: CorpusPolicy::Fuzz, + guest_hugepages: GuestHugePageConfig::DEFAULT_RESERVATION, + ..VmConfig::DEFAULT + }; + assert_eq!(FUZZ.fuzz_rss_limit_mib(), Some(1024 - 512 - 128)); + } + + /// Zero would mean "unlimited" to libfuzzer, so a VM with nothing left + /// reports having nothing left. + #[test] + fn a_vm_with_no_room_reports_none_rather_than_an_unlimited_engine() { + const TINY: VmConfig = VmConfig { + guest_hugepages: GuestHugePageConfig::None, + memory_mib: 128, + ..VmConfig::DEFAULT + }; + assert_eq!(TINY.fuzz_rss_limit_mib(), None); + // Not a contradiction on its own: this VM is fine, it just is not + // one anybody can fuzz in. + assert_eq!(TINY.check(), Ok(())); + } + + /// ...and declaring such a VM a fuzz target is a contradiction, caught + /// where every other one is. + #[test] + fn a_fuzz_target_with_no_room_is_rejected() { + const TINY_FUZZ: VmConfig = VmConfig { + corpus: CorpusPolicy::Fuzz, + guest_hugepages: GuestHugePageConfig::None, + memory_mib: 128, + ..VmConfig::DEFAULT + }; + assert_eq!(TINY_FUZZ.check(), Err(ConfigProblem::NoRoomToFuzz)); + assert!( + TINY_FUZZ + .validate_memory_alignment() + .is_err_and(|why| why.contains("fuzz target")), + ); + } + + /// The default still produces the three interfaces, with the exact + /// addresses, that used to be written out by hand. + #[test] + fn the_default_interfaces_are_the_ones_that_were_hand_written() { + let ifaces = VmConfig::DEFAULT.all_ifaces(); + let described: Vec<_> = ifaces + .iter() + .map(|i| { + ( + i.id.as_str(), + i.mac.as_str(), + i.host_ipv6.to_string(), + i.mtu, + ) + }) + .collect(); + assert_eq!( + described, + vec![ + ("mgmt", "02:DE:AD:BE:EF:01", "fe80::ffff:1".to_owned(), 1500), + ("fabric1", "02:CA:FE:BA:BE:01", "fe80::1".to_owned(), 9500), + ("fabric2", "02:CA:FE:BA:BE:02", "fe80::2".to_owned(), 9500), + ], + ); + } + + /// A mixed fabric is what a startup-sequence test needs: the links + /// differ, so "the second NIC" and "the virtio NIC" are different + /// devices and picking the wrong rule is visible. + #[test] + fn each_link_in_a_mixed_fabric_is_the_model_it_named() { + const MIXED: VmConfig = VmConfig { + fabric: FabricNics::Mixed(&[NicModel::VirtioNet, NicModel::E1000, NicModel::E1000E]), + ..VmConfig::DEFAULT + }; + let models: Vec<_> = MIXED.all_ifaces().iter().map(|i| i.model).collect(); + assert_eq!( + models, + vec![ + // The management link keeps the VM's own model: it is how + // the harness reaches the guest, so it is the one thing a + // test of device identification should hold fixed. + NicModel::VirtioNet, + NicModel::VirtioNet, + NicModel::E1000, + NicModel::E1000E, + ], + ); + } + + /// The count spelling still means what it meant: every link the same. + #[test] + fn a_uniform_fabric_takes_the_vms_own_model() { + const UNIFORM: VmConfig = VmConfig { + nic_model: NicModel::E1000, + fabric: FabricNics::Uniform(2), + backend: crate::backend::RequestedBackend::Qemu, + ..VmConfig::DEFAULT + }; + assert!( + UNIFORM + .all_ifaces() + .iter() + .all(|i| i.model == NicModel::E1000), + ); + } + + /// One emulated link anywhere in the machine decides the hypervisor for + /// the whole machine -- there is no per-device backend. + #[test] + fn an_emulated_link_anywhere_pins_qemu() { + const MIXED: VmConfig = VmConfig { + fabric: FabricNics::Mixed(&[NicModel::VirtioNet, NicModel::E1000E]), + ..VmConfig::DEFAULT + }; + assert_eq!(MIXED.first_qemu_only_nic(), Some(NicModel::E1000E)); + + const PINNED: VmConfig = VmConfig { + backend: crate::backend::RequestedBackend::CloudHypervisor, + ..MIXED + }; + assert_eq!(PINNED.check(), Err(ConfigProblem::NicRequiresQemu)); + } + + /// An all-virtio mixed fabric is not a QEMU-only machine. Worth + /// pinning: reading "mixed" as "emulated" would quietly take every + /// mixed test off the default backend. + #[test] + fn a_mixed_fabric_of_virtio_links_still_runs_anywhere() { + const MIXED: VmConfig = VmConfig { + fabric: FabricNics::Mixed(&[NicModel::VirtioNet, NicModel::VirtioNet]), + backend: crate::backend::RequestedBackend::CloudHypervisor, + ..VmConfig::DEFAULT + }; + assert_eq!(MIXED.first_qemu_only_nic(), None); + assert_eq!(MIXED.check(), Ok(())); + } + + /// The management link counts too, so a VM with no fabric at all can + /// still be one only QEMU can run. + #[test] + fn an_emulated_management_link_pins_qemu_with_no_fabric_at_all() { + const MGMT_ONLY: VmConfig = VmConfig { + nic_model: NicModel::E1000, + fabric: FabricNics::Uniform(0), + backend: crate::backend::RequestedBackend::CloudHypervisor, + ..VmConfig::DEFAULT + }; + assert_eq!(MGMT_ONLY.first_qemu_only_nic(), Some(NicModel::E1000)); + assert_eq!(MGMT_ONLY.check(), Err(ConfigProblem::NicRequiresQemu)); + } + + /// Only the fabric links sit on the segment the vIOMMU protects. + #[test] + fn management_stays_off_the_protected_segment() { + let ifaces = uniform_fabric(4).all_ifaces(); + assert_eq!(ifaces[0].pci_segment, 0, "mgmt is unprotected"); + assert!( + ifaces[1..].iter().all(|i| i.pci_segment == 1), + "every fabric link belongs behind the vIOMMU", + ); + } + + /// A test that never touches the network can ask for no fabric links, + /// and still gets a machine. + #[test] + fn a_vm_can_have_no_fabric_interfaces() { + let ifaces = uniform_fabric(0).all_ifaces(); + assert_eq!(ifaces.len(), 1); + assert_eq!(ifaces[0].id, "mgmt"); + } + + /// Beyond the derivation's range the configuration is rejected rather + /// than silently issuing a duplicate address. + #[test] + fn more_fabric_interfaces_than_addresses_is_rejected() { + assert_eq!(uniform_fabric(MAX_FABRIC_NICS).check(), Ok(())); + assert_eq!( + uniform_fabric(MAX_FABRIC_NICS + 1).check(), + Err(ConfigProblem::TooManyNics), + ); + // A named-model fabric is counted the same way, and by its length + // rather than by a number somebody wrote next to it. + const TOO_MANY: &[NicModel] = &[NicModel::VirtioNet; MAX_FABRIC_NICS as usize + 1]; + const MIXED: VmConfig = VmConfig { + fabric: FabricNics::Mixed(TOO_MANY), + ..VmConfig::DEFAULT + }; + assert_eq!(MIXED.check(), Err(ConfigProblem::TooManyNics)); + } + + #[test] + fn topology_multiplies_to_vcpu_count() { + for vcpus in 1..=64 { + let t = SmpTopology::for_vcpus(vcpus); + assert_eq!( + t.sockets * t.dies * t.cores * t.threads, + vcpus, + "topology for {vcpus} vCPUs does not multiply back: {t:?}", + ); + } + } + + /// An even count keeps SMT, because that is the shape of every machine + /// this code runs on in production and the guest inspects its own + /// topology to lay lcores out. + #[test] + fn an_even_vcpu_count_keeps_hyperthreads() { + assert_eq!(SmpTopology::for_vcpus(8).threads, 2); + assert_eq!(SmpTopology::for_vcpus(1).threads, 1); + assert_eq!(SmpTopology::for_vcpus(7).threads, 1); + } + + #[test] + fn default_config_passes_memory_alignment_validation() { + VmConfig::default() + .validate_memory_alignment() + .expect("default VmConfig should pass memory alignment validation"); + } + + // -- Const configuration ------------------------------------------ + + /// `Default` delegates to `DEFAULT`, so the two cannot drift. A derived + /// `Default` would consult each field's own `Default` independently and + /// nothing would notice if the answers diverged. + #[test] + fn default_trait_matches_the_default_const() { + assert_eq!(VmConfig::default(), VmConfig::DEFAULT); + } + + /// The point of `assert_valid` is that it runs at compile time. This + /// item *is* the assertion: if `assert_valid` ever stopped being + /// const-evaluable, this would fail to compile rather than fail a test. + const _: () = VmConfig::DEFAULT.assert_valid(); + + /// Struct update syntax has to work in a `const`, since that is how + /// every call site is expected to spell an override. + const OVERRIDDEN: VmConfig = VmConfig { + iommu: true, + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT + }; + const _: () = OVERRIDDEN.assert_valid(); + + // The overridden fields are checked at compile time -- there is nothing + // to run, and asserting on a const at run time only defers the answer. + // `matches!` rather than `==` because `PartialEq` is a trait, and trait + // methods are not callable in a const. + const _: () = assert!(OVERRIDDEN.iommu); + const _: () = assert!(matches!( + OVERRIDDEN.guest_hugepages, + GuestHugePageConfig::None + )); + + /// Fields the update did not name must keep the default. This one stays + /// a runtime test precisely so it can compare *against* + /// `VmConfig::DEFAULT` rather than restating its values -- which would + /// pass even if the defaults changed underneath it. + #[test] + fn struct_update_leaves_unnamed_fields_at_the_default() { + assert_eq!(OVERRIDDEN.host_page_size, VmConfig::DEFAULT.host_page_size); + assert_eq!(OVERRIDDEN.nic_model, VmConfig::DEFAULT.nic_model); + assert_eq!(OVERRIDDEN.source_file, VmConfig::DEFAULT.source_file); + } + + #[test] + fn check_rejects_hugepages_larger_than_vm_memory() { + let config = VmConfig { + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + // VM memory is 1 GiB, so two 1 GiB pages cannot fit. + count: 2, + }, + ..VmConfig::DEFAULT + }; + assert_eq!( + config.check(), + Err(ConfigProblem::HugepagesExceedMemory), + "reserving more hugepages than the VM has memory must be rejected", + ); + } + + /// The runtime formatter and the const check must agree on *whether* a + /// config is valid; they differ only in how much detail the message can + /// carry. Sharing one `check` is what keeps them from drifting. + #[test] + fn runtime_validation_agrees_with_the_const_check() { + let configs = [ + VmConfig::DEFAULT, + OVERRIDDEN, + VmConfig { + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + count: 2, + }, + ..VmConfig::DEFAULT + }, + VmConfig { + host_page_size: HostPageSize::Standard, + ..VmConfig::DEFAULT + }, + ]; + for config in configs { + assert_eq!( + config.check().is_ok(), + config.validate_memory_alignment().is_ok(), + "const check and runtime validation disagree on {config:?}", + ); + } + } + + /// The runtime path exists to say more than a const panic can, so it + /// should actually name the numbers involved. + #[test] + fn runtime_validation_message_names_the_numbers() { + let config = VmConfig { + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + count: 2, + }, + ..VmConfig::DEFAULT + }; + let err = config + .validate_memory_alignment() + .expect_err("two 1 GiB hugepages exceed 1 GiB of VM memory"); + assert!( + err.contains('2') && err.contains(&VmConfig::DEFAULT.memory_bytes().to_string()), + "message should carry the reservation and the VM memory: {err}", + ); + } + + #[test] + fn all_host_page_sizes_are_memory_aligned() { + for host_page_size in [ + HostPageSize::Standard, + HostPageSize::Huge2M, + HostPageSize::Huge1G, + ] { + let config = VmConfig { + host_page_size, + ..VmConfig::default() + }; + config + .validate_memory_alignment() + .unwrap_or_else(|e| panic!("{host_page_size:?}: {e}")); + } + } + + #[test] + fn guest_hugepages_exceeding_memory_fails_validation() { + let config = VmConfig { + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge1G, + count: 100, + }, + ..VmConfig::default() + }; + assert!( + config.validate_memory_alignment().is_err(), + "100 x 1G hugepages should exceed VM memory", + ); + } + + #[test] + fn guest_hugepages_none_passes_validation() { + let config = VmConfig { + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::default() + }; + config + .validate_memory_alignment() + .expect("GuestHugePageConfig::None should always pass validation"); + } + + #[test] + fn memory_bytes_follows_memory_mib() { + for mib in [1, 512, 1024, 4096, 65536] { + let config = VmConfig { + memory_mib: mib, + ..VmConfig::DEFAULT + }; + assert_eq!(config.memory_bytes(), i64::from(mib) * 1024 * 1024); + } + } + + /// Shrinking the VM below its own hugepage reservation is caught, and + /// caught at build time. + /// + /// The two levers are independent to write and not independent in + /// effect: `guest_hugepages` defaults to 512 MiB, so asking for a + /// smaller VM without also lowering it describes a guest whose kernel + /// has no room to boot. Left unchecked the guest reports + /// "HugeTLB: allocating ... failed" on a console nobody reads and boots + /// without them, which surfaces much later as a test failure with + /// nothing pointing here. + #[test] + fn a_vm_too_small_for_its_own_hugepages_is_rejected() { + let config = VmConfig { + memory_mib: 256, + ..VmConfig::DEFAULT + }; + assert_eq!(config.check(), Err(ConfigProblem::HugepagesExceedMemory)); + } + + /// ...and lowering the reservation with it is accepted. + #[test] + fn a_small_vm_that_declines_hugepages_is_fine() { + let config = VmConfig { + memory_mib: 256, + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT + }; + assert_eq!(config.check(), Ok(())); + } + + /// A VM with nothing in it is rejected before any check that would + /// pass vacuously on it. + #[test] + fn an_empty_vm_is_rejected() { + let no_memory = VmConfig { + memory_mib: 0, + ..VmConfig::DEFAULT + }; + assert_eq!(no_memory.check(), Err(ConfigProblem::NoMemory)); + + let no_cpus = VmConfig { + vcpus: 0, + ..VmConfig::DEFAULT + }; + assert_eq!(no_cpus.check(), Err(ConfigProblem::NoVcpus)); + } + + /// Memory must still be a whole number of host pages, now measured + /// against the configured size rather than a constant. + #[test] + fn memory_must_be_a_whole_number_of_host_pages() { + let unaligned = VmConfig { + memory_mib: 1536, + host_page_size: HostPageSize::Huge1G, + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT + }; + assert_eq!(unaligned.check(), Err(ConfigProblem::MemoryNotAligned)); + + let aligned = VmConfig { + memory_mib: 2048, + ..unaligned + }; + assert_eq!(aligned.check(), Ok(())); + } +} diff --git a/n-vm/src/container.rs b/n-vm/src/container.rs new file mode 100644 index 0000000000..d1ba79eae7 --- /dev/null +++ b/n-vm/src/container.rs @@ -0,0 +1,2280 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Docker container management for the host tier of `#[n_vm::test]` tests. + +use std::path::{Path, PathBuf}; + +use bollard::models::{ + ContainerCreateBody, DeviceMapping, HostConfig, MountBindOptions, RestartPolicy, + RestartPolicyNameEnum, +}; +use bollard::query_parameters::{ + CreateContainerOptions, InspectContainerOptions, RemoveContainerOptionsBuilder, + StartContainerOptions, +}; +use n_vm_protocol::{ + CONTAINER_PLATFORM, ENV_ACCEL, ENV_BACKEND, ENV_IN_TEST_CONTAINER, ENV_MARKER_VALUE, + ENV_WORKSPACE, LABEL_HOST_PID, LABEL_OWNER, LABEL_OWNER_VALUE, LABEL_TEST, ScratchRoots, + VM_ENV_DIR, VM_ROOT_SHARE_PATH, VM_RUN_DIR, VM_TEST_BIN_DIR, VM_WORKSPACE_DIR, +}; +use tokio::sync::oneshot; +use tokio_stream::StreamExt; +use tracing::warn; + +use crate::backend::{BackendResolution, EffectiveBackend, is_cross_arch}; +use crate::config::Accel; +use crate::error::ContainerError; + +/// Resolves the host cargo workspace root to share with the guest. +/// +/// Prefers [`ENV_WORKSPACE`]; otherwise walks up from the current directory +/// for a `Cargo.toml` declaring `[workspace]`. The walk is required because +/// cargo runs a test with the working directory set to the *package* root, +/// while `file!()` is recorded relative to the *workspace* root. +/// +/// Returns `None` when no workspace is found, which is not an error: a +/// caller outside a cargo workspace simply gets no `/workspace` in the +/// guest. +/// How long the fuzzing engine says this run's campaign will take, in whole +/// seconds. +/// +/// Derived from the forwarded engine arguments rather than declared, because +/// nothing compiled into the test knows it: `cargo bolero test -T 10min` +/// chooses it at the command line, and the test binary learns it from +/// `BOLERO_LIBFUZZER_ARGS`. +/// +/// The workspace remap does not apply -- this reads a duration, not a path -- +/// so it deliberately looks at the raw value rather than at what +/// `write_forwarded_env` will carry into the guest. +fn engine_time_limit() -> Option { + let args = std::env::var(n_vm_protocol::ENV_LIBFUZZER_ARGS).ok()?; + n_vm_protocol::max_total_time(&args).map(|d| d.as_secs()) +} + +/// A writable share the host tier has decided to open. +/// +/// The host half of [`crate::config::ActiveShare`]: this side knows the host +/// directory to bind-mount, the other side knows only what arrived. +struct ResolvedShare { + share: n_vm_protocol::WritableShare, + host_dir: PathBuf, + guest_path: String, +} + +/// Chooses which host directories a fuzz target may write to. +/// +/// The engine is asked first, and answers for both windows. `cargo-bolero` +/// computes them from `--corpus-dir` and from its own `fuzz_dir()` +/// derivation, and puts them on the command line it hands to libfuzzer -- +/// so they are already decided by the time this tier runs, and deriving +/// them independently here would mean reimplementing that derivation and +/// drifting from it. That drift is exactly what broke persistence: `n-vm` +/// made `__fuzz__` writable while `just fuzz` pointed the corpus at +/// `.fuzz-corpus/`, which the read-only share then served, so every +/// campaign started from `0 files found` and saved nothing. +/// +/// Without an engine there is no command line to read, and the corpus falls +/// back to `bolero`'s own default beside the test. There is no crashes +/// window in that case: the fallback directory encloses both. +/// +/// `engine` is passed in rather than read here so this stays a pure +/// function of its inputs, matching [`Accel::from_env`]'s convention -- a +/// test of it should not have to mutate the process environment. +/// +/// # Errors +/// +/// A test that asked for a corpus and cannot be given one is an error, not +/// a warning. The guest has no way to report "I had no writable corpus" -- +/// the write just lands on the read-only root share and surfaces as a bare +/// `ReadOnlyFilesystem` several tiers from the cause, which is how the +/// remapped-`file!()` bug hid. +fn plan_writable_shares( + vm_config: &crate::config::VmConfig, + engine: &str, + workspace: &Path, +) -> Result, ContainerError> { + if !vm_config.is_fuzz_target() { + return Ok(Vec::new()); + } + let dirs = n_vm_protocol::fuzz_dirs(engine); + + // A relative directory is resolved against the workspace, which is the + // guest's working directory too -- so the same string names the same + // place on both sides. + let absolute = |dir: &str| { + let path = Path::new(dir); + if path.is_absolute() { + path.to_path_buf() + } else { + workspace.join(path) + } + }; + + let corpus = match dirs.corpus { + Some(dir) => absolute(dir), + None => { + let (file, crate_dir) = vm_config.source_file.unwrap_or(("", "")); + let rel = vm_config.corpus_rel_dir().ok_or_else(|| { + ContainerError::CorpusDirUnresolvable { + file: file.to_owned(), + crate_dir: crate_dir.to_owned(), + } + })?; + workspace.join(rel) + } + }; + + // `WRITABLE_SHARES` order, which every tier relies on: the container + // resolves in it, and the QEMU backend numbers chardevs by it. + Ok([ + (n_vm_protocol::CORPUS_SHARE, Some(corpus)), + (n_vm_protocol::CRASHES_SHARE, dirs.crashes.map(absolute)), + ] + .into_iter() + .filter_map(|(share, dir)| dir.map(|dir| (share, dir))) + .collect()) +} + +/// Creates the planned directories and works out where the guest sees them. +/// +/// # Errors +/// +/// Propagates [`plan_writable_shares`], and reports a directory that cannot +/// be created. +fn resolve_writable_shares( + vm_config: &crate::config::VmConfig, +) -> Result, ContainerError> { + if !vm_config.is_fuzz_target() { + return Ok(Vec::new()); + } + let workspace = workspace_root().ok_or(ContainerError::CorpusWithoutWorkspace)?; + let host_root = workspace.to_str().unwrap_or_default(); + + // The raw value, before `write_forwarded_env` rewrites it: these are + // host paths, and this tier is the only one that can act on them. + let engine = std::env::var(n_vm_protocol::ENV_LIBFUZZER_ARGS).unwrap_or_default(); + + plan_writable_shares(vm_config, &engine, &workspace)? + .into_iter() + .map(|(share, host_dir)| { + // Created here, as the invoking user: the guest sees the + // workspace read-only and so cannot create it, and creating it + // here keeps ownership right without relying on virtiofsd's uid + // squashing for the directory itself. + std::fs::create_dir_all(&host_dir).map_err(|source| { + ContainerError::CorpusDirCreate { + path: host_dir.clone(), + source, + } + })?; + let guest_path = n_vm_protocol::remap_workspace_paths( + host_dir.to_str().unwrap_or_default(), + host_root, + ); + Ok(ResolvedShare { + share, + host_dir, + guest_path, + }) + }) + .collect() +} + +fn workspace_root() -> Option { + if let Ok(dir) = std::env::var(ENV_WORKSPACE) { + let path = PathBuf::from(dir); + return path.canonicalize().ok().or(Some(path)); + } + + let cwd = std::env::current_dir().ok()?; + cwd.ancestors() + .find(|dir| { + let manifest = dir.join("Cargo.toml"); + std::fs::read_to_string(&manifest).is_ok_and(|text| { + text.lines() + .any(|line| line.trim_start().starts_with("[workspace")) + }) + }) + .map(Path::to_path_buf) +} + +/// Docker image tag for the locally-created empty container image. +/// +/// Created on-demand by [`ensure_scratch_image`] if it does not already +/// exist. Not pulled from a registry. +const SCRATCH_IMAGE_TAG: &str = "dataplane-test-scratch:local"; + +/// Linux capabilities required inside the test container. +const REQUIRED_CAPS: [&str; 16] = [ + "SETPCAP", // modify own capability bounding set (capset(2)) + "SETUID", // virtiofsd UID mapping (--translate-uid) + "SETGID", // drop supplemental groups (setgroups(2)) + "CHOWN", // serve chown/fchown FUSE ops + "DAC_OVERRIDE", // bypass file read/write/execute permission checks + "DAC_READ_SEARCH", // bypass directory read and execute permission checks + "FOWNER", // bypass checks requiring file UID == process UID + "FSETID", // preserve set-user-ID / set-group-ID bits + "MKNOD", // serve mknod FUSE ops (device special files) + "SETFCAP", // serve file-capability xattrs + "SYS_RESOURCE", // override RLIMIT_NOFILE (--rlimit-nofile=0) + "SYS_RAWIO", // raw I/O port access (af-packet, DPDK) + "IPC_LOCK", // mlock hugepage-backed guest memory + "NET_ADMIN", // tap device creation, interface configuration + "NET_RAW", // raw socket access in network tests + "NET_BIND_SERVICE", // vsock listeners +]; + +/// Device nodes that must be mapped into the container. +const REQUIRED_DEVICES: [&str; 4] = [ + "/dev/kvm", // to launch VMs + "/dev/vhost-vsock", // for vsock communication with the VM + "/dev/vhost-net", // for vhost-net backed network interfaces + "/dev/net/tun", // for tap device creation +]; + +/// The result of running a test inside a Docker container. +#[derive(Debug)] +pub struct ContainerTestResult { + /// The exit code of the container's main process, if available. + pub exit_code: Option, +} + +/// The outcome of the host tier: the test ran in a container, or it was +/// skipped because the requested backend cannot run on this host. +#[derive(Debug)] +pub enum ContainerOutcome { + /// The test ran; carries the container's exit status. + Ran(ContainerTestResult), + /// The test was skipped (e.g. cloud-hypervisor requested for a + /// cross-architecture guest). `reason` is shown to the developer. + Skipped { + /// Human-readable explanation for the skip. + reason: String, + }, +} + +/// Parameters that vary per test invocation. +struct ContainerParams { + /// Full path to the test binary (e.g. `/path/to/deps/my_test-abc123`). + bin_path: PathBuf, + /// Canonicalized directory that contains the test binary. + bin_dir: PathBuf, + /// Fully-qualified test name (e.g. `module::test_name`). + test_name: String, + /// Effective UID of the calling process. + uid: nix::unistd::Uid, + /// Effective GID of the calling process. + gid: nix::unistd::Gid, + /// Groups owning required device nodes and the Docker socket. + device_groups: Vec, + /// Resolved `testroot` and `vmroot` directories. + scratch_roots: ScratchRoots, +} + +impl ContainerParams { + /// Resolves all parameters needed to configure the test container. + /// + /// # Errors + /// + /// Returns a [`ContainerError`] if any filesystem lookup or validation + /// step fails. + fn resolve() -> Result { + let identity = crate::test_identity::TestIdentity::resolve::(); + let test_name = identity.test_name; + + let bin_path = + std::fs::read_link("/proc/self/exe").map_err(ContainerError::BinaryPathRead)?; + + let bin_parent = bin_path + .parent() + .ok_or_else(|| ContainerError::NoParentDirectory { + path: bin_path.clone(), + })?; + + let bin_dir = + std::fs::canonicalize(bin_parent).map_err(ContainerError::BinaryPathCanonicalize)?; + + // Docker mount sources, targets, and commands require UTF-8 strings. + if bin_dir.to_str().is_none() { + return Err(ContainerError::NonUtf8Path { path: bin_dir }); + } + if bin_path.to_str().is_none() { + return Err(ContainerError::NonUtf8Path { path: bin_path }); + } + + let device_groups = Self::resolve_device_groups()?; + + let scratch_roots = ScratchRoots::resolve().map_err(ContainerError::ScratchRootResolve)?; + + Ok(Self { + bin_path, + bin_dir, + test_name: test_name.to_owned(), + uid: nix::unistd::getuid(), + gid: nix::unistd::getgid(), + device_groups, + scratch_roots, + }) + } + + /// Resolves the groups that own [`REQUIRED_DEVICES`] and the Docker socket. + /// + /// # Errors + /// + /// Returns [`ContainerError::DeviceNotAccessible`] if any required + /// device or the Docker socket cannot be `stat`'d. + fn resolve_device_groups() -> Result, ContainerError> { + use std::os::unix::fs::MetadataExt; + + // Non-Unix Docker endpoints have no local socket group to add. + let docker_socket_path: Option = match std::env::var("DOCKER_HOST") { + Ok(host) => match host.strip_prefix("unix://") { + Some(path) => Some(path.to_string()), + // Non-Unix schemes (e.g. tcp://) have no local socket. + None if host.contains("://") => None, + // Bare path with no scheme -- treat as a Unix socket path. + None => Some(host), + }, + Err(_) => Some("/var/run/docker.sock".into()), + }; + + let required_files: Vec = REQUIRED_DEVICES + .iter() + .map(|&s| s.to_string()) + .chain(docker_socket_path) + .collect(); + + let mut groups: Vec = required_files + .iter() + .map(|path| { + std::fs::metadata(path) + .map(|m| nix::unistd::Gid::from_raw(m.gid())) + .map_err(|source| ContainerError::DeviceNotAccessible { + path: PathBuf::from(path), + source, + }) + }) + .collect::, _>>()?; + + groups.sort_unstable_by_key(|g| g.as_raw()); + groups.dedup_by_key(|g| g.as_raw()); + Ok(groups) + } + + /// Returns the test binary path as a UTF-8 string slice. + fn bin_path_str(&self) -> &str { + self.bin_path + .to_str() + .expect("validated as UTF-8 in resolve()") + } + + /// Returns the test binary directory as a UTF-8 string slice. + fn bin_dir_str(&self) -> &str { + self.bin_dir + .to_str() + .expect("validated as UTF-8 in resolve()") + } + + /// Returns the Docker image tag for the test container. + fn container_image(&self) -> &'static str { + SCRATCH_IMAGE_TAG + } + + /// Builds the [`ContainerCreateBody`] for this test invocation. + /// + /// `backend` and `accel` are the host-tier-resolved choices, passed to + /// the container tier via [`ENV_BACKEND`] / [`ENV_ACCEL`] so it can + /// dispatch to the right hypervisor without a compile-time pick. + fn build_config( + &self, + backend: EffectiveBackend, + accel: Accel, + qemu_user: Option<&str>, + shares: &[ResolvedShare], + env_host_dir: Option<&Path>, + ) -> ContainerCreateBody { + ContainerCreateBody { + entrypoint: None, + cmd: Some(self.build_test_command(qemu_user)), + image: Some(self.container_image().to_owned()), + network_disabled: Some(true), + // Marks the container as ours so `n-vm-reap` can find it later. + // A SIGKILL leaves no chance to clean up in-process, so something + // has to be able to identify the remains after the fact; matching + // on image or name would not do, since the scratch image is + // shared and names come from the daemon. + labels: Some( + [ + (LABEL_OWNER.to_owned(), LABEL_OWNER_VALUE.to_owned()), + (LABEL_TEST.to_owned(), self.test_name.clone()), + (LABEL_HOST_PID.to_owned(), std::process::id().to_string()), + ] + .into_iter() + .collect(), + ), + env: Some( + vec![ + format!("{ENV_IN_TEST_CONTAINER}={ENV_MARKER_VALUE}"), + format!("{ENV_BACKEND}={}", backend.as_env()), + format!("{ENV_ACCEL}={}", accel.as_env()), + "RUST_BACKTRACE=1".into(), + ] + .into_iter() + // Forwarded only when set: an absent variable must leave the + // container tier on the manifest's default rather than on an + // empty string that names no profile. + .chain( + std::env::var(n_vm_protocol::ENV_PROFILE) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| format!("{}={v}", n_vm_protocol::ENV_PROFILE)), + ) + // How long the engine says the guest's work will take. Read + // from *this* tier's environment because that is where the + // fuzz supervisor set it; the container tier never sees the + // invocation that chose it. Absent when nothing declared + // one, which leaves the VM budget exactly where it was. + .chain( + engine_time_limit() + .map(|secs| format!("{}={secs}", n_vm_protocol::ENV_ENGINE_TIME_LIMIT)), + ) + // Where each writable window lands in the guest. Only the + // host tier can know this: the guest path is a host path put + // through the workspace remap, and the container has never + // seen the host's workspace. + .chain( + shares + .iter() + .map(|active| format!("{}={}", active.share.env_key, active.guest_path)), + ) + // Same "only when set" discipline: virtiofsd runs in this + // tier, so the override has to reach it here. + .chain( + std::env::var(n_vm_protocol::ENV_VIRTIOFS_CACHE) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| format!("{}={v}", n_vm_protocol::ENV_VIRTIOFS_CACHE)), + ) + .collect(), + ), + user: Some("0:0".into()), + host_config: Some(HostConfig { + devices: Some(Self::build_device_mappings()), + group_add: Some( + self.device_groups + .iter() + .map(|g| g.as_raw().to_string()) + .collect(), + ), + init: Some(true), + network_mode: Some("none".into()), + restart_policy: Some(RestartPolicy { + name: Some(RestartPolicyNameEnum::NO), + ..Default::default() + }), + auto_remove: Some(false), + readonly_rootfs: Some(true), + mounts: Some(self.build_mounts(shares, env_host_dir)), + tmpfs: Some(self.build_tmpfs()), + privileged: Some(false), + cap_add: Some(REQUIRED_CAPS.iter().map(|&c| c.into()).collect()), + cap_drop: Some(vec!["ALL".into()]), + // QEMU needs AF_VSOCK sockets, and virtiofsd needs FUSE + // operations Docker's default seccomp/AppArmor profiles block. + security_opt: Some(vec![ + "seccomp=unconfined".into(), + "apparmor=unconfined".into(), + ]), + ..Default::default() + }), + ..Default::default() + } + } + + /// Builds Docker device mappings from [`REQUIRED_DEVICES`]. + fn build_device_mappings() -> Vec { + REQUIRED_DEVICES + .iter() + .map(|&path| DeviceMapping { + path_on_host: Some(path.into()), + path_in_container: Some(path.into()), + cgroup_permissions: Some("rwm".into()), + }) + .collect() + } + + /// Builds the test binary command line for the container entrypoint. + /// + /// When `qemu_user` is `Some`, the binary is a foreign architecture + /// relative to the container, so it is run under that user-mode QEMU + /// interpreter -- mirroring how `scripts/test-runner.sh` wraps the + /// host-tier invocation (`qemu- ...`). The interpreter is + /// an absolute `/nix/store` path, available in the container via the + /// bind-mounted store, so no host `binfmt_misc` registration is needed. + fn build_test_command(&self, qemu_user: Option<&str>) -> Vec { + let mut cmd = Vec::new(); + if let Some(interp) = qemu_user { + cmd.push(interp.to_owned()); + } + cmd.extend([ + self.bin_path_str().to_owned(), + self.test_name.clone(), + "--exact".into(), + "--no-capture".into(), + "--format=terse".into(), + ]); + cmd + } + + /// Builds the bind mounts for the test binary directory. + fn build_mounts( + &self, + shares: &[ResolvedShare], + env_host_dir: Option<&Path>, + ) -> Vec { + Self::build_mounts_in( + n_vm_protocol::host_share_dir().as_deref(), + self, + shares, + env_host_dir, + ) + } + + /// [`build_mounts`](Self::build_mounts) with the host share given rather + /// than read from the environment. + /// + /// Split out because the share is process-wide state that changes every + /// mount source: a test asserting a source cannot be correct both with and + /// without it, and CI sets it for the whole job. + fn build_mounts_in( + share: Option<&str>, + params: &Self, + shares: &[ResolvedShare], + env_host_dir: Option<&Path>, + ) -> Vec { + let this = params; + let bin_dir = this.bin_dir_str(); + let mut mounts = vec![ + Self::read_only_bind_mount(bin_dir, bin_dir.to_owned()), + Self::read_only_bind_mount(bin_dir, format!("{VM_ROOT_SHARE_PATH}/{VM_TEST_BIN_DIR}")), + ]; + + // The forwarded environment, read-only: the guest only reads it, and + // the root share is served `--readonly` regardless. A directory + // rather than the file itself, because the `vmroot` derivation can + // only pre-create a directory as a mount point. + if let Some(env_dir) = env_host_dir + && let Some(env_dir) = env_dir.to_str() + { + mounts.push(Self::read_only_bind_mount( + env_dir, + format!("{VM_ROOT_SHARE_PATH}/{VM_ENV_DIR}"), + )); + } + + mounts.extend(Self::build_scratch_mounts_in( + share, + &this.scratch_roots, + shares, + )); + + mounts + } + + /// Writes the variables this tier should carry into the guest, returning + /// the host directory to bind-mount at [`VM_ENV_DIR`]. + /// + /// `Ok(None)` when there is nothing to forward, so the ordinary case + /// adds no file and no mount. + /// + /// # Errors + /// + /// Returns [`ContainerError::EnvFileWrite`] if the file cannot be + /// written. Deliberately an error rather than a warning that carries + /// on, per `development/code/error-handling.md`: the guest cannot report + /// "I was given no environment", and a bolero test that loses + /// `BOLERO_LIBFUZZER_ARGS` does not fail -- it quietly stops fuzzing and + /// still passes. This is the only place the loss is visible. + /// The directory to write a forwarded-environment directory into. + /// + /// `/tmp` when a host share is configured, else the ordinary + /// temporary directory. + fn env_parent_dir() -> PathBuf { + match n_vm_protocol::host_share_dir() { + Some(share) => PathBuf::from(share).join(n_vm_protocol::HOST_SHARE_TMP_SUBDIR), + None => std::env::temp_dir(), + } + } + + fn write_forwarded_env( + &self, + rss_limit_mib: Option, + ) -> Result, ContainerError> { + let extra = std::env::var(n_vm_protocol::ENV_FORWARD).ok(); + // Values are rewritten, not just carried. A forwarded variable that names a host path + // points nowhere in the guest, where the workspace lives at `/workspace` rather than at + // whatever it is called here -- see `remap_workspace_paths`, and `BOLERO_LIBFUZZER_ARGS` + // for why it matters. + let host_root = workspace_root() + .and_then(|root| root.to_str().map(str::to_owned)) + .unwrap_or_default(); + let mut vars: Vec<(String, String)> = std::env::vars() + .filter(|(name, _)| n_vm_protocol::is_forwarded(name, extra.as_deref())) + .map(|(name, value)| { + let value = n_vm_protocol::remap_workspace_paths(&value, &host_root); + // The one forwarded value this tier reads rather than carries. A libfuzzer + // command line can ask the fuzzer to supervise copies of itself, which in the + // guest means `system(3)` against a root that has no shell -- see + // `strip_multiprocess_flags`. + let value = if name == n_vm_protocol::ENV_LIBFUZZER_ARGS { + let value = n_vm_protocol::strip_multiprocess_flags(&value); + // The other thing this tier decides rather than carries. + // libfuzzer's default `-rss_limit_mb` is twice the + // default guest, so it is the guest kernel that notices + // the growth first -- and it kills the engine, which + // loses the input that caused it. + match rss_limit_mib { + Some(limit) => n_vm_protocol::with_rss_limit(&value, limit), + None => value, + } + } else { + value + }; + (name, value) + }) + .collect(); + if vars.is_empty() { + return Ok(None); + } + // Stable order, so the same environment produces the same bytes and + // this stays out of the way when diffing a failing run against a + // passing one. + vars.sort(); + + // Keyed by pid and test name: nextest runs each test in its own + // process and several containers are created in parallel, so a + // shared path would race. + // Under the share directory when there is one: this path is a bind + // mount source, so the daemon has to be able to resolve it, and a + // container-local /tmp is exactly what it cannot. Getting this wrong + // is silent in the worst way -- the daemon would create an empty + // directory and the guest would come up with no environment at all, + // which is the loss the doc comment above says nothing else can + // report. + let dir = Self::env_parent_dir().join(format!( + "n-vm-env-{}-{}", + std::process::id(), + self.test_name.replace("::", "_"), + )); + let path = dir.join(n_vm_protocol::ENV_FILE_NAME); + + let encoded = + n_vm_protocol::encode_environ(vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + + std::fs::create_dir_all(&dir) + .and_then(|()| std::fs::write(&path, &encoded)) + .map_err(|source| ContainerError::EnvFileWrite { + path: path.clone(), + source, + })?; + + tracing::debug!( + "forwarding {} variable(s) to the guest via {}: {}", + vars.len(), + path.display(), + vars.iter() + .map(|(k, _)| k.as_str()) + .collect::>() + .join(", "), + ); + + Ok(Some(dir)) + } + + /// Builds the additional bind mounts required in scratch mode. + fn build_scratch_mounts_in( + share: Option<&str>, + roots: &ScratchRoots, + shares: &[ResolvedShare], + ) -> Vec { + let visible = |path: &str| n_vm_protocol::host_visible_path_in(share, path); + let vm_root = roots + .vm_root + .to_str() + .expect("vm_root validated as canonicalized path"); + + let mut mounts = Vec::new(); + + // The *source* of every mount below is resolved by the daemon, which + // may be outside this container; the target is not. So sources go + // through `host_visible_path` and targets stay as the guest expects. + mounts.push(Self::read_only_bind_mount( + &visible(n_vm_protocol::NIX_STORE_DIR), + n_vm_protocol::NIX_STORE_DIR.to_owned(), + )); + + // Mount each first-level testroot entry at the container root. + if let Ok(entries) = std::fs::read_dir(&roots.test_root) { + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let path = entry.path(); + // Resolved, because a first-level entry may be a symlink into + // another store path -- `n-vm-manifest.json` points at the + // kernel image. The daemon resolves a mount source in its own + // namespace, where an absolute /nix/store target means nothing, + // and answers a source it cannot stat by trying to create it: + // "mkdir .../n-vm-manifest.json: file exists". + let Some(entry_source) = std::fs::canonicalize(&path) + .unwrap_or(path.clone()) + .to_str() + .map(str::to_owned) + else { + continue; + }; + if path.is_dir() || path.is_file() { + mounts.push(Self::read_only_bind_mount( + &visible(&entry_source), + format!("/{name}"), + )); + } + } + } + + mounts.push(Self::read_only_bind_mount( + &visible(vm_root), + VM_ROOT_SHARE_PATH.to_owned(), + )); + + // Guest binaries keep /nix/store rpaths; expose the real store via virtiofs. + mounts.push(Self::read_only_bind_mount( + &visible(n_vm_protocol::NIX_STORE_DIR), + format!("{VM_ROOT_SHARE_PATH}/nix/store"), + )); + + // QEMU and cloud-hypervisor allocate hugepage-backed memory here. + mounts.push(Self::rw_bind_mount( + "/dev/hugepages", + "/dev/hugepages".to_owned(), + )); + + // The cargo workspace, so that compile-time-captured relative paths + // (`file!()`) resolve in the guest -- see `VM_WORKSPACE_DIR`. + // + // Read-only, matching what the guest actually gets: virtiofsd serves + // the whole share with `--readonly`, so a read-write bind mount here + // would claim an access the guest does not have. Enough for reading + // an existing corpus; persisting newly-generated inputs back to the + // host tree needs the virtiofsd flag relaxed as well. + // + // Absent for an out-of-workspace caller, in which case the guest + // simply has no /workspace and `n-it` leaves the working directory + // alone. + if let Some(workspace) = workspace_root() + && let Some(workspace) = workspace.to_str() + { + mounts.push(Self::read_only_bind_mount( + workspace, + format!("{VM_ROOT_SHARE_PATH}/{VM_WORKSPACE_DIR}"), + )); + } + + // The writable windows, one bind mount each. Mounted *outside* the + // root share so that the read-only virtiofs daemon never serves + // them: a separate daemon shares each path and only that path, + // which keeps the read/write split enforced by the server rather + // than by the guest's mount flags. + for active in shares { + if let Some(host) = active.host_dir.to_str() { + mounts.push(Self::rw_bind_mount( + host, + active.share.container_path.to_owned(), + )); + } + } + + mounts + } + + /// Builds the tmpfs mounts for the container. + fn build_tmpfs(&self) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + map.insert( + VM_RUN_DIR.into(), + format!( + "nodev,noexec,nosuid,uid={uid},gid={gid}", + uid = self.uid.as_raw(), + gid = self.gid.as_raw(), + ), + ); + map + } + + /// Creates a read-only private bind mount from `source` to `target`. + fn read_only_bind_mount(source: &str, target: String) -> bollard::models::Mount { + bollard::models::Mount { + source: Some(source.into()), + target: Some(target), + typ: Some(bollard::models::MountTypeEnum::BIND), + read_only: Some(true), + bind_options: Some(MountBindOptions { + propagation: Some(bollard::models::MountBindOptionsPropagationEnum::PRIVATE), + non_recursive: Some(true), + create_mountpoint: Some(true), + ..Default::default() + }), + ..Default::default() + } + } + + /// Creates a read-write private bind mount from `source` to `target`. + fn rw_bind_mount(source: &str, target: String) -> bollard::models::Mount { + bollard::models::Mount { + source: Some(source.into()), + target: Some(target), + typ: Some(bollard::models::MountTypeEnum::BIND), + read_only: Some(false), + bind_options: Some(MountBindOptions { + propagation: Some(bollard::models::MountBindOptionsPropagationEnum::PRIVATE), + non_recursive: Some(true), + create_mountpoint: Some(true), + ..Default::default() + }), + ..Default::default() + } + } +} + +/// Ensures the scratch Docker image exists locally. +/// +/// # Errors +/// +/// Returns [`ContainerError::ScratchImageCreate`] if the image does not +/// exist and cannot be created. +async fn ensure_scratch_image(client: &bollard::Docker) -> Result<(), ContainerError> { + if client.inspect_image(SCRATCH_IMAGE_TAG).await.is_ok() { + return Ok(()); + } + + tracing::info!( + image = SCRATCH_IMAGE_TAG, + "creating scratch Docker image for test infrastructure", + ); + + // A valid empty tar archive is two 512-byte end-of-archive records + // (1024 zero bytes total). Importing this produces a Docker image + // with a single empty layer. + let mut child = tokio::process::Command::new("docker") + .args(["import", "-", SCRATCH_IMAGE_TAG]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| { + ContainerError::ScratchImageCreate(format!("failed to spawn `docker import`: {e}")) + })?; + + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + if let Err(e) = stdin.write_all(&[0u8; 1024]).await { + // Keep going: the exit-status check below still catches the + // failure; this just preserves the underlying I/O cause. + tracing::warn!("failed to write empty tar to `docker import` stdin: {e}"); + } + // Dropping stdin closes the pipe, signaling EOF to `docker import`. + } + + let output = child.wait_with_output().await.map_err(|e| { + ContainerError::ScratchImageCreate(format!("failed to wait for `docker import`: {e}")) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(ContainerError::ScratchImageCreate(format!( + "`docker import` exited with {}: {stderr}", + output.status, + ))); + } + + Ok(()) +} + +/// A dedicated thread that stands ready to perform emergency container +/// cleanup when the [`ContainerGuard`] is dropped without explicit cleanup. +/// +/// The thread blocks on a [`oneshot::Receiver`]. There are two outcomes: +/// +/// - **Normal path**: The sender is dropped without sending (via +/// [`defuse`](Self::defuse)). The receiver returns `Err`, the thread +/// exits immediately, and no cleanup is performed. +/// - **Emergency path**: The [`ContainerGuard::drop`] impl sends the +/// container ID through the channel. The thread receives it, builds a +/// minimal tokio runtime, and force-removes the container via the Docker +/// API. +/// +/// # Why `std::thread` instead of `tokio::task`? +/// +/// [`run_test_in_vm`] uses a single-threaded tokio runtime. During panic +/// unwinding, the runtime may be shutting down, so a `tokio::task::spawn` +/// from [`Drop`] is unreliable. A dedicated OS thread with its own +/// runtime is fully decoupled from the caller's async context. +struct CleanupThread { + /// Send the container ID to request emergency cleanup. + /// Drop without sending to signal "all clear." + tx: Option>, + /// Handle to the cleanup thread. Joined on defuse; detached on + /// emergency trigger (so that [`Drop`] does not block). + thread: Option>, +} + +impl CleanupThread { + /// Spawns the cleanup thread with its own clone of the Docker client. + /// + /// The thread blocks immediately on the [`oneshot::Receiver`] and does + /// no work until either [`trigger`](Self::trigger) or + /// [`defuse`](Self::defuse) is called (or the sender is dropped). + fn spawn(client: bollard::Docker) -> Self { + let (tx, rx) = oneshot::channel::(); + + let thread = std::thread::Builder::new() + .name("container-cleanup".into()) + .spawn(move || { + // Block until we know whether cleanup is needed. + let container_id = match rx.blocking_recv() { + Ok(id) => id, + // Sender dropped without sending -- explicit cleanup + // already happened, nothing to do. + Err(_) => return, + }; + + // `eprintln!` rather than `tracing` throughout: this thread + // belongs to the host tier, and `init_tracing` runs in the + // container tier only, so a `tracing` event here reaches + // nobody. These lines are the sole record that a container + // was left behind and dealt with -- the one place where + // going unread is worse than being ugly. + eprintln!("n-vm: performing emergency cleanup of container {container_id}"); + + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!( + "n-vm: failed to build emergency cleanup runtime ({e}); \ + manual removal needed (e.g. `docker rm -f {container_id}`)", + ); + return; + } + }; + + rt.block_on(async { + let opts = RemoveContainerOptionsBuilder::default().force(true).build(); + match client.remove_container(&container_id, Some(opts)).await { + Ok(()) => eprintln!( + "n-vm: emergency cleanup of container {container_id} succeeded", + ), + Err(e) => eprintln!( + "n-vm: emergency cleanup of container {container_id} failed ({e}); \ + manual removal may be needed \ + (e.g. `docker rm -f {container_id}`)", + ), + } + }); + }) + .expect("failed to spawn container cleanup thread"); + + Self { + tx: Some(tx), + thread: Some(thread), + } + } + + /// Signal that explicit cleanup was performed; the thread will exit + /// without doing anything. + /// + /// Drops the sender (so the receiver sees `RecvError`) and joins the + /// thread, which should return almost immediately. + fn defuse(&mut self) { + // Drop the sender without sending -- the receiver unblocks with + // Err(RecvError) and the thread exits. + self.tx.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + + /// Send the container ID to trigger emergency cleanup. + /// + /// The thread is *detached* (not joined) so that [`Drop`] does not + /// block waiting for the Docker API call. The cleanup proceeds in the + /// background. + fn trigger(&mut self, container_id: String) { + if let Some(tx) = self.tx.take() { + // The only way send() fails is if the receiver was already + // dropped (thread exited), in which case there is nothing to + // do. + let _ = tx.send(container_id); + } + // Detach the thread -- don't block Drop on the Docker API call. + self.thread.take(); + } +} + +/// RAII guard that owns a running Docker container and provides lifecycle +/// methods. +/// +/// The expected usage is: +/// +/// 1. [`create_and_start`](Self::create_and_start) -- create the container +/// and return an armed guard. +/// 2. [`stream_logs`](Self::stream_logs) -- forward container +/// stdout/stderr to the host. +/// 3. [`into_result`](Self::into_result) -- inspect the exit status, +/// remove the container, and defuse the guard. +/// +/// If the guard is dropped *without* calling `into_result` (e.g. due to a +/// panic or an early return inserted by a future refactor), the [`Drop`] +/// impl sends the container ID to a [`CleanupThread`] which force-removes +/// the container via the Docker API. +/// +/// # Async cleanup via sync Drop +/// +/// Rust does not support async `Drop`. This guard bridges the gap by +/// using a [`tokio::sync::oneshot`] channel whose +/// [`Sender::send`](oneshot::Sender::send) is synchronous (not async), +/// making it safe to call from [`Drop`]. A dedicated [`std::thread`] +/// receives the message and performs the async Docker API call in its own +/// minimal tokio runtime -- fully decoupled from whatever runtime (if any) +/// the caller is using. +struct ContainerGuard<'a> { + client: &'a bollard::Docker, + container_id: String, + /// Background thread that will force-remove the container if we send + /// it the container ID. Defused on the normal path. + cleanup: CleanupThread, + /// Set to `true` once explicit cleanup has been performed via + /// [`into_result`](Self::into_result). + defused: bool, +} + +impl<'a> ContainerGuard<'a> { + /// Creates a Docker container from the given configuration, starts it, + /// and returns an armed guard. + /// + /// This combines container creation, guard construction, and starting + /// into a single step so that the container _never_ exists without a + /// guard to clean it up -- even if the start request fails after the + /// container was created. + /// + /// A [`CleanupThread`] is spawned that will stand by to force-remove + /// the container if this guard is dropped without calling + /// [`into_result`](Self::into_result). + /// + /// # Errors + /// + /// Returns [`ContainerError::ContainerCreate`] or + /// [`ContainerError::ContainerStart`] if the Docker daemon rejects the + /// request. + async fn create_and_start( + client: &'a bollard::Docker, + config: ContainerCreateBody, + ) -> Result, ContainerError> { + let container = client + .create_container( + Some(CreateContainerOptions { + name: None, + platform: CONTAINER_PLATFORM.into(), + }), + config, + ) + .await + .map_err(ContainerError::ContainerCreate)?; + + // Arm the guard as soon as the container exists. If the start + // below fails, the guard drops with `defused == false` and the + // cleanup thread force-removes the created-but-never-started + // container instead of leaking it. + let cleanup = CleanupThread::spawn(client.clone()); + let guard = Self { + client, + container_id: container.id, + cleanup, + defused: false, + }; + + guard + .client + .start_container(&guard.container_id, None::) + .await + .map_err(ContainerError::ContainerStart)?; + + Ok(guard) + } + + /// Streams container stdout/stderr to the host's stdout/stderr until + /// the container exits. + /// + /// # Errors + /// + /// Returns [`ContainerError::LogStream`] if the log stream encounters + /// an error from the Docker daemon. + async fn stream_logs(&self) -> Result<(), ContainerError> { + let mut logs = self.client.logs( + &self.container_id, + Some(bollard::query_parameters::LogsOptions { + follow: true, + stdout: true, + stderr: true, + tail: "all".into(), + ..Default::default() + }), + ); + + while let Some(log) = logs.next().await { + match log { + Ok(msg) => match msg { + bollard::container::LogOutput::StdErr { message } => { + eprint!("{}", String::from_utf8_lossy(&message)); + } + bollard::container::LogOutput::StdOut { message } + | bollard::container::LogOutput::Console { message } => { + print!("{}", String::from_utf8_lossy(&message)); + } + bollard::container::LogOutput::StdIn { .. } => { + warn!("unexpected StdIn log entry from Docker"); + } + }, + Err(e) => { + return Err(ContainerError::LogStream(e)); + } + } + } + + Ok(()) + } + + /// Performs the explicit inspect + remove lifecycle. + /// + /// This defuses the [`CleanupThread`] (so its background thread exits + /// without doing anything) and marks the guard so that its [`Drop`] + /// impl is a no-op. Returns the container's exit status on success. + async fn into_result(mut self) -> Result { + let result = self.collect_and_cleanup().await?; + // Disarm the safety nets only after the container is actually + // removed. If `collect_and_cleanup` returned early (inspect + // failure or missing state) the `?` above propagates while + // `defused` is still false, so `Drop` triggers emergency removal + // rather than leaking the container. + self.defused = true; + self.cleanup.defuse(); + Ok(result) + } + + /// Inspects the container's exit status and removes it. + /// + /// # Errors + /// + /// Returns a [`ContainerError`] if the container cannot be inspected + /// or removed, or if the inspection response is missing the container + /// state. + async fn collect_and_cleanup(&self) -> Result { + let state = self + .client + .inspect_container(&self.container_id, None::) + .await + .map_err(ContainerError::ContainerInspect)? + .state + .ok_or(ContainerError::MissingState)?; + + // Force removal: if we got here with the container still running + // (e.g. the log stream died before the container exited), a plain + // remove would fail with HTTP 409 and obscure the real error. + self.client + .remove_container( + &self.container_id, + Some(RemoveContainerOptionsBuilder::default().force(true).build()), + ) + .await + .map_err(ContainerError::ContainerRemove)?; + + Ok(ContainerTestResult { + exit_code: state.exit_code, + }) + } + + /// Force-removes the container in response to a termination signal, then + /// ends this process. Never returns. + /// + /// Every other cleanup route here runs from [`Drop`] -- the guard's own + /// impl, and the [`CleanupThread`] it dispatches to. That covers panics + /// and early returns, because both unwind. A signal does not: the + /// default disposition for `SIGTERM` terminates the process outright, so + /// no destructor runs and neither safety net fires. + /// + /// Two things leak without this, and the smaller one is the one that + /// looks worse. The container *record* survives, because + /// `auto_remove` is deliberately `false`, and accumulates one entry per + /// killed run. More importantly the container keeps *running*: it is + /// owned by the daemon, not by this process, so it plays the test out to + /// the end with nothing left to collect the result. For a test that is + /// a few seconds; for a fuzz target it is the whole `-max_total_time` + /// budget, which means a runner's own timeout would not actually stop + /// the work it just gave up waiting for. + /// + /// Removal is forced, since the container is by definition still running. + /// Reports through `eprintln!` rather than `tracing` because this tier + /// has no subscriber: [`init_tracing`](crate::dispatch) runs in the + /// container, not on the host, which is why the surrounding host-tier + /// code prints directly too. A cleanup notice that goes nowhere is + /// worse than none, since it is the only record that the container was + /// dealt with. + async fn abort_on_signal(&mut self, signal: &str) -> ! { + eprintln!( + "n-vm: {signal} received; force-removing container {}", + self.container_id, + ); + + match self + .client + .remove_container( + &self.container_id, + Some(RemoveContainerOptionsBuilder::default().force(true).build()), + ) + .await + { + Ok(()) => { + // Disarm both nets: there is nothing left to remove, and the + // process is about to end anyway. + self.defused = true; + self.cleanup.defuse(); + } + Err(e) => eprintln!( + "n-vm: force-removal of container {} after {signal} failed: {e}; \ + manual removal may be needed (e.g. `docker rm -f {}`)", + self.container_id, self.container_id, + ), + } + + // `128 + signo`, the shell's convention for a signal death. Chosen + // over resetting the disposition and re-raising because that needs + // `unsafe`, and nothing downstream distinguishes the two: a runner + // sees a non-zero status either way. + std::process::exit(128 + signal_number(signal)); + } +} + +/// The signal number behind one of the names [`await_termination`] reports. +/// +/// Only those two names are ever passed; an unrecognised one still yields a +/// failing status rather than pretending the run succeeded. +fn signal_number(signal: &str) -> i32 { + match signal { + "SIGINT" => nix::sys::signal::Signal::SIGINT as i32, + "SIGTERM" => nix::sys::signal::Signal::SIGTERM as i32, + _ => nix::sys::signal::Signal::SIGTERM as i32, + } +} + +/// Handlers for the signals that should end a run early. +/// +/// `SIGINT` is watched alongside `SIGTERM` because an interactive Ctrl-C +/// leaks exactly the same way a runner's timeout does. +struct TerminationSignals { + sigterm: tokio::signal::unix::Signal, + sigint: tokio::signal::unix::Signal, +} + +impl TerminationSignals { + /// Registers both handlers. + /// + /// Separated from [`recv`](Self::recv) so that registration -- the only + /// fallible part -- happens before the race it feeds, rather than inside + /// it where a failure would have to unwind a log stream already underway. + /// + /// # Errors + /// + /// Returns [`ContainerError::SignalHandler`] if either handler cannot be + /// registered. + fn install() -> Result { + use tokio::signal::unix::{SignalKind, signal}; + + Ok(Self { + sigterm: signal(SignalKind::terminate()).map_err(ContainerError::SignalHandler)?, + sigint: signal(SignalKind::interrupt()).map_err(ContainerError::SignalHandler)?, + }) + } + + /// Resolves when either signal arrives, naming it. + async fn recv(&mut self) -> &'static str { + tokio::select! { + _ = self.sigterm.recv() => "SIGTERM", + _ = self.sigint.recv() => "SIGINT", + } + } +} + +impl Drop for ContainerGuard<'_> { + fn drop(&mut self) { + if !self.defused { + tracing::error!( + container_id = %self.container_id, + "ContainerGuard dropped without explicit cleanup; \ + dispatching emergency container removal", + ); + self.cleanup.trigger(self.container_id.clone()); + } + } +} + +/// The hypervisor the selected kernel profile runs on. +/// +/// `None` when it cannot be determined -- an unreadable or malformed +/// manifest -- which is deliberately *not* treated as a mismatch. The +/// container tier reads the same file and reports that failure with a far +/// better message; skipping here would disguise a broken `testroot` as a +/// routine environment mismatch. +/// +/// Read from `testroot` on the host because this is the last tier where a +/// skip can still be expressed; inside the container the only outcomes left +/// are pass and fail. +fn profile_backend( + roots: &n_vm_protocol::ScratchRoots, + declared: Option<&str>, + emulation_required: bool, +) -> Option { + let path = roots + .test_root + .join(n_vm_protocol::KERNEL_MANIFEST_PATH.trim_start_matches('/')); + let manifest = crate::kernel_manifest::KernelManifest::load_from(&path).ok()?; + let (name, profile) = manifest.selected(declared, emulation_required).ok()?; + profile.backend(name).ok() +} + +/// Reports a test that named a kernel profile and a backend that disagree. +/// +/// A failure rather than a skip, which is what +/// [`RequestedBackend::resolve`](crate::backend::RequestedBackend::resolve) +/// would produce. Skipping is right when the profile came from +/// `N_VM_PROFILE`: the run asked for an environment this test cannot use. +/// It is wrong when the test wrote both halves itself, because a skip is +/// reported as a pass -- so the test would go green having run nothing, +/// and the contradiction would never be seen. +/// +/// `None` when the test named no profile (nothing to contradict), pinned no +/// backend (nothing contradicts it), or the two agree. +fn profile_backend_conflict( + declared_profile: Option<&str>, + requested: crate::backend::RequestedBackend, + profile: Option, +) -> Option { + use crate::backend::RequestedBackend; + + let name = declared_profile?; + let profile_backend = profile?; + let requested = match requested { + RequestedBackend::Default => return None, + RequestedBackend::Qemu => EffectiveBackend::Qemu, + RequestedBackend::CloudHypervisor => EffectiveBackend::CloudHypervisor, + }; + (requested != profile_backend).then(|| ContainerError::ProfileContradictsBackend { + profile: name.to_owned(), + profile_backend, + requested, + }) +} + +/// Launches a Docker container and re-runs the current test binary inside it. +/// +/// This is the **host-tier** entry point, called from the code generated by +/// `#[n_vm::test]` when neither `IN_VM` nor `IN_TEST_CONTAINER` is set (i.e. a +/// normal `cargo test` invocation). It: +/// +/// 1. Resolves the test identity, binary paths, and device group ownership +/// via `ContainerParams::resolve`. +/// 2. Builds the Docker container configuration via +/// `ContainerParams::build_config`. +/// 3. Creates and starts the container via +/// `ContainerGuard::create_and_start`. +/// 4. Streams container stdout/stderr to the host via +/// `ContainerGuard::stream_logs`. +/// 5. Collects the exit status and removes the container via +/// `ContainerGuard::into_result`. +/// +/// The type parameter `F` is used only to derive the test name via +/// [`std::any::type_name`]; the function itself is never called in this tier. +/// +/// # Errors +/// +/// Returns [`ContainerError`] if any part of the container lifecycle fails +/// (Docker connection, container creation/start, log streaming, inspection, +/// or cleanup). +pub fn run_test_in_vm( + _test_fn: F, + vm_config: crate::config::VmConfig, +) -> Result { + let requested = vm_config.backend; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime for test container"); + + runtime.block_on(async { + let params = ContainerParams::resolve::()?; + + let client = + bollard::Docker::connect_with_unix_defaults().map_err(ContainerError::DockerConnect)?; + + // Resolve the backend and acceleration mode against the Docker + // daemon's real architecture. qemu-user fakes `uname`, so the + // daemon's self-reported arch -- not the in-process `uname` -- is + // the reliable host signal. + let daemon_arch = query_daemon_arch(&client).await?; + let cross = is_cross_arch(&daemon_arch, std::env::consts::ARCH); + let needs_qemu = vm_config.first_qemu_only_nic().is_some(); + // The selected profile decides the hypervisor unless the test asked + // for a specific one. Read here rather than in the container tier + // because this is the last place a skip can be expressed. + let profile = profile_backend(¶ms.scratch_roots, vm_config.kernel_profile, cross); + + if let Some(conflict) = + profile_backend_conflict(vm_config.kernel_profile, vm_config.backend, profile) + { + return Err(conflict); + } + + let (backend, accel) = match requested.resolve(cross, needs_qemu, profile) { + BackendResolution::Run { backend, accel } => (backend, accel), + BackendResolution::Skip { reason } => { + return Ok(ContainerOutcome::Skipped { reason }); + } + }; + + // Skip a test that requests a capability the guest ISA can't + // provide (rather than panicking deep in launch). The guest ISA is + // this binary's target arch. Currently the only such capability is + // the virtual IOMMU (no aarch64 SMMUv3 lowering yet); as more ISA- + // divergent capabilities are added, their support checks belong + // here alongside it. + let guest_arch = crate::config::Arch::current(); + if vm_config.iommu && !guest_arch.supports_virtual_iommu() { + return Ok(ContainerOutcome::Skipped { + reason: format!("virtual IOMMU (iommu = true) is not supported on {guest_arch:?}"), + }); + } + + // For a cross-arch guest, the container (daemon arch) cannot exec + // the foreign test binary directly, so run it under user-mode QEMU + // -- the same `qemu-` interpreter `scripts/test-runner.sh` + // uses for the host tier. Resolved to an absolute /nix/store path + // (reachable in the container via the bind-mounted store), which + // avoids any host binfmt_misc dependency. + let qemu_user = if cross { + let name = format!("qemu-{}", std::env::consts::ARCH); + Some(find_on_path(&name).ok_or(ContainerError::QemuUserNotFound { name })?) + } else { + None + }; + tracing::info!( + daemon_arch = %daemon_arch, + target_arch = std::env::consts::ARCH, + ?backend, + ?accel, + qemu_user = ?qemu_user, + "resolved hypervisor backend for this host", + ); + + // Ensure the empty Docker image exists before building the + // container config (which references it by tag). + ensure_scratch_image(&client).await?; + + // Which windows this run opens, and where each is backed. See + // `resolve_writable_shares`. + let shares = resolve_writable_shares(&vm_config)?; + + // Written before the container is created, so a failure here stops + // the run rather than producing a guest that silently lost its + // fuzzing configuration. + let env_host_dir = params.write_forwarded_env(vm_config.fuzz_rss_limit_mib())?; + + let config = params.build_config( + backend, + accel, + qemu_user.as_deref(), + &shares, + env_host_dir.as_deref(), + ); + + // The guard is armed at creation -- if anything between here and + // the explicit cleanup panics or returns early, the CleanupThread + // will force-remove the container. + let mut guard = ContainerGuard::create_and_start(&client, config).await?; + + // Streaming the logs is where this tier spends the test's whole + // lifetime, so it is also where a termination signal arrives. Racing + // the two is what gives a signal any path to cleanup at all; see + // [`ContainerGuard::abort_on_signal`]. + let log_result = match TerminationSignals::install() { + Ok(mut signals) => tokio::select! { + result = guard.stream_logs() => result, + signal = signals.recv() => guard.abort_on_signal(signal).await, + }, + // Losing the handlers costs cleanup on a kill, which is worth a + // warning and not worth failing a run over. + Err(e) => { + eprintln!( + "n-vm: could not install termination handlers ({e}); \ + a signal will leak the container", + ); + guard.stream_logs().await + } + }; + + // Explicit cleanup -- inspects the exit status and removes the + // container. This defuses the guard so its Drop is a no-op. + let cleanup_result = guard.into_result().await; + + // Propagate the log streaming error first if it occurred -- it is + // the root cause. But if cleanup also failed, log that error so + // the container leak is visible even though we cannot return both + // errors. + if let (Err(log_err), Err(cleanup_err)) = (&log_result, &cleanup_result) { + tracing::error!( + %log_err, + %cleanup_err, + "both log streaming and container cleanup failed; \ + propagating log error, but the container may have leaked", + ); + } + log_result?; + cleanup_result.map(ContainerOutcome::Ran) + }) +} + +/// Queries the Docker daemon's architecture (e.g. `"x86_64"`, `"aarch64"`). +/// +/// The daemon runs natively on the host, so this is reliable even when the +/// caller is an emulated (qemu-user) foreign-arch binary. +/// +/// # Errors +/// +/// Returns [`ContainerError::DockerInfo`] if the query fails, or +/// [`ContainerError::DockerArchUnknown`] if the daemon does not report an +/// architecture. +async fn query_daemon_arch(client: &bollard::Docker) -> Result { + client + .info() + .await + .map_err(ContainerError::DockerInfo)? + .architecture + .ok_or(ContainerError::DockerArchUnknown) +} + +/// Resolves an executable to its absolute path by searching `$PATH`. +/// +/// Used to find the `qemu-` user-mode interpreter for cross-arch +/// tests; the resolved `/nix/store` path is reachable inside the container +/// via the bind-mounted store. +fn find_on_path(program: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(program)) + .find(|candidate| candidate.is_file()) + // Canonicalized, because the result is executed *inside the + // container*, where only `/nix/store` and the `testroot` entries are + // mounted. A `PATH` hit is typically `devroot/bin/`, a + // symlink in the developer's working tree that does not exist in + // there -- so handing it over unresolved produces exit code 127 from + // a binary that is plainly present on the host. + .and_then(|candidate| std::fs::canonicalize(candidate).ok()) + .and_then(|p| p.to_str().map(ToOwned::to_owned)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- Profile / backend agreement ---------------------------------- + + use crate::backend::RequestedBackend; + + /// The mistake this exists to catch: both halves named, and they + /// describe a machine that does not exist. + #[test] + fn a_declared_profile_contradicting_a_pinned_backend_is_an_error() { + let conflict = profile_backend_conflict( + Some("flatcar"), + RequestedBackend::CloudHypervisor, + Some(EffectiveBackend::Qemu), + ); + assert!(matches!( + conflict, + Some(ContainerError::ProfileContradictsBackend { .. }) + )); + } + + /// Agreement is not a conflict, even though both were named. + #[test] + fn a_declared_profile_agreeing_with_its_backend_is_fine() { + assert!( + profile_backend_conflict( + Some("flatcar"), + RequestedBackend::Qemu, + Some(EffectiveBackend::Qemu), + ) + .is_none() + ); + } + + /// An unpinned backend cannot contradict anything -- the profile + /// chooses, which is the ordinary way to use the lever. + #[test] + fn a_declared_profile_alone_chooses_the_backend() { + assert!( + profile_backend_conflict( + Some("flatcar"), + RequestedBackend::Default, + Some(EffectiveBackend::Qemu), + ) + .is_none() + ); + } + + /// A profile the *environment* chose is not this function's business: + /// `resolve` skips that case, and skipping is right because the run, + /// not the test, asked for an environment the test cannot use. + #[test] + fn an_environment_chosen_profile_is_left_to_resolve() { + assert!( + profile_backend_conflict( + None, + RequestedBackend::CloudHypervisor, + Some(EffectiveBackend::Qemu), + ) + .is_none() + ); + } + + // -- Writable shares ---------------------------------------------- + + /// The exact command line observed from `just fuzz reconcile_fuzz`, + /// with the workspace shortened. + const ENGINE: &str = "/ws/.fuzz-corpus/reconcile_fuzz \ + /ws/mgmt/tests/__fuzz__/reconcile/crashes \ + -artifact_prefix=/ws/mgmt/tests/__fuzz__/reconcile/crashes/ \ + -timeout=10 -max_total_time=60"; + + fn fuzz_config() -> crate::config::VmConfig { + crate::config::VmConfig { + corpus: crate::config::CorpusPolicy::Fuzz, + source_file: Some(("mgmt/tests/reconcile.rs", "/ws/mgmt")), + ..crate::config::VmConfig::DEFAULT + } + } + + /// An ordinary test opens no window at all, which is the property the + /// whole read-only guest rests on. + #[test] + fn a_test_that_is_not_a_fuzz_target_gets_no_writable_share() { + let plan = + plan_writable_shares(&crate::config::VmConfig::DEFAULT, ENGINE, Path::new("/ws")) + .expect("an ordinary test cannot fail to plan"); + assert!(plan.is_empty()); + } + + /// Two windows, in two unrelated trees, taken from the engine. + /// + /// The corpus is the one that used to be served read-only, so this is + /// the case that failed: `0 files found`, and nothing saved. + #[test] + fn the_engine_names_both_windows() { + let plan = plan_writable_shares(&fuzz_config(), ENGINE, Path::new("/ws")) + .expect("both directories are named"); + let got: Vec<_> = plan + .iter() + .map(|(share, dir)| (share.role, dir.to_str().expect("utf-8"))) + .collect(); + assert_eq!( + got, + vec![ + ("corpus", "/ws/.fuzz-corpus/reconcile_fuzz"), + ("crashes", "/ws/mgmt/tests/__fuzz__/reconcile/crashes"), + ], + ); + } + + /// Without an engine there is no command line to read, so the corpus + /// falls back beside the test -- and one window covers both, because + /// `__fuzz__` encloses the crashes directory too. + #[test] + fn without_an_engine_the_corpus_falls_back_beside_the_test() { + let plan = plan_writable_shares(&fuzz_config(), "", Path::new("/ws")) + .expect("the fallback is derivable from the source file"); + let got: Vec<_> = plan + .iter() + .map(|(share, dir)| (share.role, dir.to_str().expect("utf-8"))) + .collect(); + assert_eq!(got, vec![("corpus", "/ws/mgmt/tests/__fuzz__")]); + } + + /// A fuzz target whose corpus cannot be derived is an error rather than + /// a run with nowhere to write: the guest cannot report the difference. + #[test] + fn a_fuzz_target_without_a_derivable_corpus_is_an_error() { + let config = crate::config::VmConfig { + corpus: crate::config::CorpusPolicy::Fuzz, + source_file: None, + ..crate::config::VmConfig::DEFAULT + }; + let err = plan_writable_shares(&config, "", Path::new("/ws")) + .expect_err("nothing names a corpus directory"); + assert!(matches!(err, ContainerError::CorpusDirUnresolvable { .. })); + } + + /// `FUZZ_CORPUS_ROOT` may point anywhere, including outside the + /// workspace. The plan takes it as given; `n-it` creates the mount + /// point in the guest because there is no read-only counterpart to + /// overmount. + #[test] + fn a_corpus_outside_the_workspace_is_taken_as_given() { + let plan = plan_writable_shares(&fuzz_config(), "/tmp/scratch-corpus", Path::new("/ws")) + .expect("an absolute path needs no anchor"); + assert_eq!(plan[0].1.to_str().expect("utf-8"), "/tmp/scratch-corpus",); + } + + /// Builds a representative [`ContainerParams`] for use in config + /// builder tests without hitting the filesystem or process table. + fn sample_params() -> ContainerParams { + ContainerParams { + bin_path: PathBuf::from("/target/debug/deps/my_test-abc123"), + bin_dir: PathBuf::from("/target/debug/deps"), + test_name: "tests::my_test".into(), + uid: nix::unistd::Uid::from_raw(1000), + gid: nix::unistd::Gid::from_raw(1000), + device_groups: vec![ + nix::unistd::Gid::from_raw(36), + nix::unistd::Gid::from_raw(108), + ], + scratch_roots: ScratchRoots { + test_root: PathBuf::from("/nix/store/fake-test-root"), + vm_root: PathBuf::from("/nix/store/fake-vm-root"), + }, + } + } + + #[test] + fn config_uses_scratch_image() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + assert_eq!(config.image.as_deref(), Some(SCRATCH_IMAGE_TAG)); + } + + #[test] + fn test_command_wraps_with_qemu_when_cross() { + let p = sample_params(); + let native = p.build_test_command(None); + assert_eq!( + native[0], + p.bin_path_str(), + "native runs the binary directly" + ); + + let interp = "/nix/store/x-qemu-user/bin/qemu-aarch64"; + let cross = p.build_test_command(Some(interp)); + assert_eq!( + cross[0], interp, + "cross prepends the user-mode QEMU interpreter" + ); + assert_eq!(cross[1], p.bin_path_str(), "binary follows the interpreter"); + // The remaining args (test name, --exact, ...) are identical. + assert_eq!(cross[2..], native[1..]); + } + + #[test] + fn config_propagates_backend_and_accel_env() { + let config = + sample_params().build_config(EffectiveBackend::Qemu, Accel::Tcg, None, &[], None); + let env = config.env.as_ref().expect("env"); + assert!( + env.iter().any(|e| e == "N_VM_BACKEND=qemu"), + "expected N_VM_BACKEND=qemu in {env:?}", + ); + assert!( + env.iter().any(|e| e == "N_VM_ACCEL=tcg"), + "expected N_VM_ACCEL=tcg in {env:?}", + ); + } + + #[test] + fn config_disables_networking() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + assert_eq!(config.network_disabled, Some(true)); + let host = config.host_config.as_ref().expect("host_config"); + assert_eq!(host.network_mode.as_deref(), Some("none")); + } + + #[test] + fn config_sets_environment_variables() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + let env = config.env.as_ref().expect("env should be set"); + let expected = format!("{ENV_IN_TEST_CONTAINER}={ENV_MARKER_VALUE}"); + assert!( + env.contains(&expected), + "env should contain {expected}: {env:?}", + ); + assert!( + env.iter().any(|e| e == "RUST_BACKTRACE=1"), + "env should enable RUST_BACKTRACE: {env:?}", + ); + } + + #[test] + fn config_runs_as_root() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + // The container runs as root so that capabilities in the + // bounding set are effective without ambient-cap gymnastics. + assert_eq!(config.user.as_deref(), Some("0:0")); + } + + #[test] + fn config_passes_device_groups() { + let params = sample_params(); + let config = params.build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + let host = config.host_config.as_ref().expect("host_config"); + let groups = host.group_add.as_ref().expect("group_add"); + // The sample_params use GIDs 36 and 108. + assert!(groups.contains(&"36".to_string())); + assert!(groups.contains(&"108".to_string())); + } + + #[test] + fn config_is_unprivileged_with_minimal_caps() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + let host = config.host_config.as_ref().expect("host_config"); + assert_eq!(host.privileged, Some(false)); + + // All default caps are dropped; only REQUIRED_CAPS are added back. + let drop = host.cap_drop.as_ref().expect("cap_drop"); + assert_eq!(drop, &["ALL"], "cap_drop should drop ALL capabilities"); + + let caps = host.cap_add.as_ref().expect("cap_add"); + for required in &REQUIRED_CAPS { + assert!( + caps.iter().any(|c| c == *required), + "missing required capability: {required}", + ); + } + + // Seccomp must be disabled so that AF_VSOCK sockets (family 40) + // are not blocked by Docker's default seccomp profile. + let security = host.security_opt.as_ref().expect("security_opt"); + assert!( + security.iter().any(|s| s == "seccomp=unconfined"), + "security_opt should contain seccomp=unconfined: {security:?}", + ); + + // AppArmor must be disabled so that virtiofsd can initialise + // its FUSE filesystem server. Docker's `docker-default` + // AppArmor profile restricts operations that virtiofsd needs, + // causing it to exit before creating its Unix socket. + assert!( + security.iter().any(|s| s == "apparmor=unconfined"), + "security_opt should contain apparmor=unconfined: {security:?}", + ); + } + + #[test] + fn config_has_readonly_rootfs() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + let host = config.host_config.as_ref().expect("host_config"); + assert_eq!(host.readonly_rootfs, Some(true)); + } + + #[test] + fn config_does_not_auto_remove_and_never_restarts() { + let config = sample_params().build_config( + EffectiveBackend::CloudHypervisor, + Accel::Kvm, + None, + &[], + None, + ); + let host = config.host_config.as_ref().expect("host_config"); + assert_eq!(host.auto_remove, Some(false)); + let restart = host.restart_policy.as_ref().expect("restart_policy"); + assert_eq!(restart.name, Some(RestartPolicyNameEnum::NO)); + } + + #[test] + fn device_mappings_cover_all_required_devices() { + let mappings = ContainerParams::build_device_mappings(); + assert_eq!(mappings.len(), REQUIRED_DEVICES.len()); + for device in &REQUIRED_DEVICES { + let found = mappings.iter().any(|m| { + m.path_on_host.as_deref() == Some(*device) + && m.path_in_container.as_deref() == Some(*device) + }); + assert!(found, "missing device mapping for {device}"); + } + } + + #[test] + fn device_mappings_have_full_permissions() { + let mappings = ContainerParams::build_device_mappings(); + for mapping in &mappings { + assert_eq!( + mapping.cgroup_permissions.as_deref(), + Some("rwm"), + "device {:?} should have rwm permissions", + mapping.path_on_host, + ); + } + } + + #[test] + fn test_command_starts_with_binary_path() { + let params = sample_params(); + let cmd = params.build_test_command(None); + assert_eq!(cmd[0], "/target/debug/deps/my_test-abc123"); + } + + #[test] + fn test_command_passes_test_name_with_exact() { + let params = sample_params(); + let cmd = params.build_test_command(None); + assert_eq!(cmd[1], "tests::my_test"); + assert!(cmd.contains(&"--exact".to_string())); + assert!(cmd.contains(&"--no-capture".to_string())); + assert!(cmd.contains(&"--format=terse".to_string())); + } + + #[test] + fn mounts_include_bin_dir_at_original_path() { + let params = sample_params(); + let mounts = ContainerParams::build_mounts_in(None, ¶ms, &[], None); + let direct = mounts + .iter() + .find(|m| m.target.as_deref() == Some("/target/debug/deps")); + assert!( + direct.is_some(), + "should mount bin_dir at its original path" + ); + let direct = direct.unwrap(); + assert_eq!(direct.source.as_deref(), Some("/target/debug/deps")); + assert_eq!(direct.read_only, Some(true)); + } + + /// The guest builds its child's environment from nothing, so this mount + /// is the only way anything reaches it. Without it a bolero test in the + /// guest silently drops to its random driver and still passes. + #[test] + fn mounts_include_the_forwarded_env_dir_when_there_is_one() { + let params = sample_params(); + let env_dir = std::path::Path::new("/tmp/n-vm-env-1-tests_my_test"); + let mounts = ContainerParams::build_mounts_in(None, ¶ms, &[], Some(env_dir)); + let expected_target = format!("{VM_ROOT_SHARE_PATH}/{VM_ENV_DIR}"); + let mount = mounts + .iter() + .find(|m| m.target.as_deref() == Some(expected_target.as_str())) + .expect("forwarded env dir should be mounted into the root share"); + assert_eq!( + mount.source.as_deref(), + Some("/tmp/n-vm-env-1-tests_my_test") + ); + // Read-only: the guest only reads this, and virtiofsd serves the + // root share `--readonly` regardless, so a read-write mount would + // claim an access the guest does not have. + assert_eq!(mount.read_only, Some(true)); + } + + /// Nothing to forward must add no mount at all, so an ordinary test is + /// unaffected by this path existing. + #[test] + fn no_env_dir_means_no_env_mount() { + let mounts = ContainerParams::build_mounts_in(None, &sample_params(), &[], None); + let unexpected = format!("{VM_ROOT_SHARE_PATH}/{VM_ENV_DIR}"); + assert!( + !mounts + .iter() + .any(|m| m.target.as_deref() == Some(unexpected.as_str())) + ); + } + + #[test] + fn mounts_include_bin_dir_at_vm_test_bin_dir() { + let params = sample_params(); + let mounts = ContainerParams::build_mounts_in(None, ¶ms, &[], None); + let expected_target = format!("{VM_ROOT_SHARE_PATH}/{VM_TEST_BIN_DIR}"); + let mirror = mounts + .iter() + .find(|m| m.target.as_deref() == Some(expected_target.as_str())); + assert!( + mirror.is_some(), + "should mount bin_dir at {expected_target}", + ); + let mirror = mirror.unwrap(); + assert_eq!(mirror.source.as_deref(), Some("/target/debug/deps")); + assert_eq!(mirror.read_only, Some(true)); + } + + #[test] + fn scratch_mounts_include_nix_store() { + let roots = ScratchRoots { + test_root: PathBuf::from("/nix/store/fake-test-root"), + vm_root: PathBuf::from("/nix/store/fake-vm-root"), + }; + let mounts = ContainerParams::build_scratch_mounts_in(None, &roots, &[]); + let nix_mount = mounts + .iter() + .find(|m| m.target.as_deref() == Some("/nix/store")); + assert!( + nix_mount.is_some(), + "scratch mounts should include /nix/store", + ); + let nix_mount = nix_mount.unwrap(); + assert_eq!(nix_mount.source.as_deref(), Some("/nix/store")); + assert_eq!(nix_mount.read_only, Some(true)); + } + + #[test] + fn scratch_mounts_include_vm_root() { + let roots = ScratchRoots { + test_root: PathBuf::from("/nix/store/fake-test-root"), + vm_root: PathBuf::from("/nix/store/fake-vm-root"), + }; + let mounts = ContainerParams::build_scratch_mounts_in(None, &roots, &[]); + let vm_mount = mounts + .iter() + .find(|m| m.target.as_deref() == Some(VM_ROOT_SHARE_PATH)); + assert!( + vm_mount.is_some(), + "scratch mounts should include {VM_ROOT_SHARE_PATH}", + ); + let vm_mount = vm_mount.unwrap(); + assert_eq!(vm_mount.source.as_deref(), Some("/nix/store/fake-vm-root"),); + assert_eq!(vm_mount.read_only, Some(true)); + } + + #[test] + fn a_host_share_redirects_every_store_source_and_no_target() { + // The regression this exists for: CI sets N_VM_HOST_SHARE_DIR for the + // whole job, so `build_scratch_mounts` reading it directly made the two + // tests above fail there and pass here. Assert both modes explicitly + // instead, and assert the thing that actually has to hold -- sources + // move, targets do not. + let roots = ScratchRoots { + test_root: PathBuf::from("/nix/store/fake-test-root"), + vm_root: PathBuf::from("/nix/store/fake-vm-root"), + }; + let plain = ContainerParams::build_scratch_mounts_in(None, &roots, &[]); + let shared = ContainerParams::build_scratch_mounts_in(Some("/w/.share"), &roots, &[]); + + assert_eq!( + plain.len(), + shared.len(), + "a share changes where mounts come from, never how many there are", + ); + for (plain, shared) in plain.iter().zip(shared.iter()) { + assert_eq!( + plain.target, shared.target, + "targets are resolved inside the container and must not move", + ); + let before = plain.source.as_deref().unwrap_or_default(); + let after = shared.source.as_deref().unwrap_or_default(); + if let Some(rest) = before.strip_prefix("/nix/store") { + assert_eq!( + after, + format!("/w/.share/nix/store{rest}"), + "a store source belongs under the share", + ); + } else { + assert_eq!( + before, after, + "{before} is not in the store and must be left alone", + ); + } + } + } + + #[test] + fn scratch_mounts_are_bind_mounts_with_expected_permissions() { + let roots = ScratchRoots { + test_root: PathBuf::from("/nix/store/fake-test-root"), + vm_root: PathBuf::from("/nix/store/fake-vm-root"), + }; + let mounts = ContainerParams::build_scratch_mounts_in(None, &roots, &[]); + // At minimum we expect /nix/store, /vm.root, and /dev/hugepages. + // testroot subdirectory mounts depend on what's on disk, so + // we can't assert an exact count, but we can verify invariants + // on whatever mounts are returned. + assert!( + mounts.len() >= 3, + "scratch mounts should have at least /nix/store, /vm.root, and /dev/hugepages, got {}", + mounts.len(), + ); + // /dev/hugepages is the only read-write mount; everything else + // should be read-only. + for mount in &mounts { + assert_eq!( + mount.typ, + Some(bollard::models::MountTypeEnum::BIND), + "all scratch mounts should be bind mounts", + ); + let target = mount.target.as_deref().unwrap_or(""); + if target == "/dev/hugepages" { + assert_eq!( + mount.read_only, + Some(false), + "/dev/hugepages must be read-write for hugepage allocation", + ); + } else { + assert_eq!( + mount.read_only, + Some(true), + "scratch mount {target} should be read-only", + ); + } + } + } + + #[test] + fn scratch_mounts_include_hugepages() { + let roots = ScratchRoots { + test_root: PathBuf::from("/nix/store/fake-test-root"), + vm_root: PathBuf::from("/nix/store/fake-vm-root"), + }; + let mounts = ContainerParams::build_scratch_mounts_in(None, &roots, &[]); + let hp_mount = mounts + .iter() + .find(|m| m.target.as_deref() == Some("/dev/hugepages")); + assert!( + hp_mount.is_some(), + "scratch mounts should include /dev/hugepages", + ); + let hp_mount = hp_mount.unwrap(); + assert_eq!(hp_mount.source.as_deref(), Some("/dev/hugepages")); + assert_eq!( + hp_mount.read_only, + Some(false), + "/dev/hugepages must be read-write", + ); + } + + #[test] + fn all_mounts_are_private_non_recursive_bind_mounts() { + let params = sample_params(); + let mounts = ContainerParams::build_mounts_in(None, ¶ms, &[], None); + for mount in &mounts { + assert_eq!(mount.typ, Some(bollard::models::MountTypeEnum::BIND),); + let opts = mount.bind_options.as_ref().expect("bind_options"); + assert_eq!( + opts.propagation, + Some(bollard::models::MountBindOptionsPropagationEnum::PRIVATE), + ); + assert_eq!(opts.non_recursive, Some(true)); + assert_eq!(opts.create_mountpoint, Some(true)); + } + } + + #[test] + fn tmpfs_mounts_vm_run_dir_with_security_flags() { + let params = sample_params(); + let tmpfs = params.build_tmpfs(); + assert_eq!(tmpfs.len(), 1); + let opts = tmpfs.get(VM_RUN_DIR).expect("should have VM_RUN_DIR entry"); + assert!(opts.contains("nodev"), "tmpfs should be nodev: {opts}"); + assert!(opts.contains("noexec"), "tmpfs should be noexec: {opts}"); + assert!(opts.contains("nosuid"), "tmpfs should be nosuid: {opts}"); + assert!(opts.contains("uid=1000"), "tmpfs should set uid: {opts}"); + assert!(opts.contains("gid=1000"), "tmpfs should set gid: {opts}"); + } + + #[test] + fn read_only_bind_mount_sets_expected_fields() { + let mount = ContainerParams::read_only_bind_mount("/src/dir", "/dst/dir".to_string()); + assert_eq!(mount.source.as_deref(), Some("/src/dir")); + assert_eq!(mount.target.as_deref(), Some("/dst/dir")); + assert_eq!(mount.read_only, Some(true)); + assert_eq!(mount.typ, Some(bollard::models::MountTypeEnum::BIND)); + } + + #[test] + fn required_caps_has_no_duplicates() { + let mut sorted = REQUIRED_CAPS.to_vec(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + REQUIRED_CAPS.len(), + "REQUIRED_CAPS contains duplicates", + ); + } + + #[test] + fn required_devices_has_no_duplicates() { + let mut sorted = REQUIRED_DEVICES.to_vec(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + REQUIRED_DEVICES.len(), + "REQUIRED_DEVICES contains duplicates", + ); + } + + #[test] + fn required_devices_are_all_absolute_paths() { + for device in &REQUIRED_DEVICES { + assert!( + device.starts_with('/'), + "device path should be absolute: {device}", + ); + } + } +} diff --git a/n-vm/src/dispatch.rs b/n-vm/src/dispatch.rs new file mode 100644 index 0000000000..060e7d2fc3 --- /dev/null +++ b/n-vm/src/dispatch.rs @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Runtime helpers called by code generated from `#[n_vm::test]`. +//! +//! The macro keeps only tier selection in generated code. Container launch, +//! VM launch, runtime setup, and error formatting live here as normal Rust. + +use std::future::Future; + +use crate::backend::{EffectiveBackend, HypervisorBackend}; +use crate::config::{Accel, GuestRuntime, VmConfig}; +use crate::container::ContainerOutcome; +use n_vm_protocol::{ENV_ACCEL, ENV_BACKEND, ENV_IN_TEST_CONTAINER, ENV_IN_VM, ENV_MARKER_VALUE}; + +/// Returns `true` when running inside the VM guest. +#[inline] +pub fn is_in_vm() -> bool { + std::env::var(ENV_IN_VM).as_deref() == Ok(ENV_MARKER_VALUE) +} + +/// Returns `true` when running inside the Docker container tier. +#[inline] +pub fn is_in_test_container() -> bool { + std::env::var(ENV_IN_TEST_CONTAINER).as_deref() == Ok(ENV_MARKER_VALUE) +} + +fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_thread_names(true) + .without_time() + .with_test_writer() + .with_line_number(true) + .with_target(true) + .with_file(true) + .try_init(); +} + +/// Runs an async test body on the runtime its configuration asked for. +/// +/// One entry point rather than one per scheduler, because the choice now +/// lives in a `const` the macro cannot read: a proc macro can branch on a +/// token but not on a value. The shape is a [`GuestRuntime`], so the match +/// happens here, in ordinary code, where it can be tested. +/// +/// # Panics +/// +/// Panics if the tokio runtime cannot be created. +pub fn block_on_in_guest_with>(runtime: GuestRuntime, f: F) { + let mut builder = match runtime { + GuestRuntime::CurrentThread => tokio::runtime::Builder::new_current_thread(), + GuestRuntime::MultiThread { .. } => tokio::runtime::Builder::new_multi_thread(), + }; + builder.enable_all(); + if let GuestRuntime::MultiThread { + worker_threads: Some(n), + } = runtime + { + builder.worker_threads(n); + } + builder + .build() + .expect("failed to build tokio runtime for async #[n_vm::test] test body") + .block_on(f); +} + +/// Container-tier dispatch: boot a VM and re-execute the test inside it. +/// +/// The backend and acceleration mode were resolved by the host tier and +/// passed in via [`ENV_BACKEND`] / [`ENV_ACCEL`]; this reads them and +/// dispatches to the right backend so the choice is not baked in at +/// compile time. An absent/unrecognised backend defaults to +/// cloud-hypervisor (the historical default). +/// +/// # Panics +/// +/// Panics if: +/// - The tokio runtime cannot be created. +/// - The VM infrastructure returns an error. +/// - The test running inside the VM reports failure. +pub fn run_container_tier(test_fn: F, vm_config: VmConfig) { + let backend = EffectiveBackend::from_env(std::env::var(ENV_BACKEND).ok().as_deref()); + let accel = Accel::from_env(std::env::var(ENV_ACCEL).ok().as_deref()); + + match backend { + EffectiveBackend::Qemu => { + run_container_tier_for::(test_fn, vm_config, accel); + } + EffectiveBackend::CloudHypervisor => { + run_container_tier_for::(test_fn, vm_config, accel); + } + } +} + +/// Monomorphised container-tier body for a single backend. +fn run_container_tier_for( + test_fn: F, + vm_config: VmConfig, + accel: Accel, +) { + // Invariant: a TCG (cross-arch) run must use an emulation-capable + // backend. The host tier never selects a non-emulating backend for + // TCG, but assert it here so a future regression surfaces loudly. + debug_assert!( + B::CAN_EMULATE || accel == Accel::Kvm, + "backend `{}` cannot emulate, but TCG acceleration was selected", + B::NAME, + ); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .expect("failed to build tokio runtime for #[n_vm::test] container tier"); + + let _guard = runtime.enter(); + + runtime.block_on(async { + init_tracing(); + + let init_span = tracing::span!(tracing::Level::INFO, "hypervisor"); + let _guard = init_span.enter(); + + let output = crate::run_in_vm::(test_fn, vm_config, accel) + .await + .unwrap_or_else(|err| { + panic!("VM infrastructure error:\n{:?}", miette::Report::new(err)) + }); + + eprintln!("{output}"); + assert!(output.success, "VM test failed (see output above)"); + }); +} + +/// Host-tier dispatch: launch a Docker container and re-run the test inside it. +/// +/// The backend the test asked for comes from `vm_config`. It is +/// resolved against the Docker daemon's architecture and the requested +/// capabilities: a cross-arch guest runs under QEMU/TCG, a test that +/// *explicitly* requires cloud-hypervisor on a cross-arch host is skipped, +/// and a test requesting a capability the guest ISA can't provide (e.g. +/// a virtual IOMMU on aarch64) is skipped. +/// +/// Skips are reported by returning normally with a `SKIPPED:` log line -- +/// libtest/nextest have no runtime "ignored" state, so a skipped test +/// counts as passed. This keeps cross-arch runs honest by minimising +/// skips: only the unsupported combinations are affected; everything else +/// runs under emulation. +/// +/// # Panics +/// +/// Panics if: +/// - The Docker container infrastructure returns an error. +/// - The container exits with a non-zero code. +/// - The container does not report an exit code at all. +pub fn run_host_tier(test_fn: F, vm_config: VmConfig) { + eprintln!("===== BEGIN NESTED TEST ENVIRONMENT ====="); + + let outcome = crate::run_test_in_vm(test_fn, vm_config).unwrap_or_else(|err| { + panic!( + "test container infrastructure error:\n{:?}", + miette::Report::new(err) + ) + }); + + eprintln!("===== END NESTED TEST ENVIRONMENT ====="); + + match outcome { + ContainerOutcome::Skipped { reason } => { + report_skip( + crate::test_identity::TestIdentity::resolve::().test_name, + &reason, + ); + } + ContainerOutcome::Ran(state) => match state.exit_code { + Some(0) => {} + Some(code) => { + panic!("test container exited with code {code}"); + } + None => { + panic!("test container did not return an exit code"); + } + }, + } +} + +/// Records a skipped test, and fails it when the run does not tolerate +/// skips. +/// +/// libtest has no run-time "skipped" state: `#[ignore]` is a compile-time +/// decision, so a test that skips here is counted as **passed**, and the +/// reason it printed is swallowed by output capture unless the test also +/// fails. A profile can therefore skip nearly everything and still report +/// a clean run -- which is exactly what a Flatcar run does today, where 12 +/// of 16 "passes" never boot a VM. +/// +/// So the reason is written somewhere that survives: a file named by +/// [`ENV_SKIP_LOG`], outside libtest's capture and outside the +/// process-per-test model, so a whole run's skips accumulate in one place +/// CI can assert on. +/// +/// [`ENV_STRICT_SKIPS`] turns a skip into a failure, for a run that is +/// meant to exercise everything and where a skip is a hole in what is being +/// certified rather than a neutral outcome. +fn report_skip(test_name: &str, reason: &str) { + eprintln!("SKIPPED: {reason}"); + + if let Ok(path) = std::env::var(n_vm_protocol::ENV_SKIP_LOG) + && !path.is_empty() + { + // Appended, not rewritten: nextest runs each test in its own + // process, so the records of one run arrive from many writers. + // `O_APPEND` keeps single short writes from interleaving. + use std::io::Write as _; + let record = format!( + "{{\"test\":{},\"profile\":{},\"reason\":{}}}\n", + json_string(test_name), + json_string(&std::env::var(n_vm_protocol::ENV_PROFILE).unwrap_or_default()), + json_string(reason), + ); + // Best-effort: failing to record a skip must not turn a skip into a + // failure, which would be a worse outcome than the missing record. + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = f.write_all(record.as_bytes()); + } + } + + if std::env::var(n_vm_protocol::ENV_STRICT_SKIPS).is_ok_and(|v| !v.is_empty()) { + panic!( + "test skipped, and {} is set: {reason}", + n_vm_protocol::ENV_STRICT_SKIPS, + ); + } +} + +/// Minimal JSON string escaping, to avoid a serde dependency in a path +/// that writes at most a few dozen short records per run. +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod test { + use super::json_string; + + /// A skip reason is free text that ends up inside a JSON record, and + /// the reasons already contain backticks and quotes. Escaping wrong + /// would produce a log that no parser can read -- silently, since + /// nothing reads it during the run that wrote it. + #[test] + fn escapes_quotes_and_backslashes() { + assert_eq!(json_string(r#"a "quoted" word"#), r#""a \"quoted\" word""#); + assert_eq!(json_string(r"back\slash"), r#""back\\slash""#); + } + + #[test] + fn escapes_control_characters() { + assert_eq!(json_string("line\nbreak"), r#""line\nbreak""#); + assert_eq!(json_string("tab\there"), r#""tab\there""#); + // Anything else below 0x20 has no short form and must be \uXXXX. + assert_eq!(json_string("\u{1}"), r#""\u0001""#); + } + + /// Reason strings routinely carry backticks and paths; those are + /// ordinary characters and must survive untouched. + #[test] + fn leaves_ordinary_text_alone() { + let reason = "kernel profile `flatcar` runs on qemu; use N_VM_PROFILE="; + assert_eq!(json_string(reason), format!("\"{reason}\"")); + } +} diff --git a/n-vm/src/error.rs b/n-vm/src/error.rs new file mode 100644 index 0000000000..e749870d36 --- /dev/null +++ b/n-vm/src/error.rs @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Dedicated error types for the `n-vm` test infrastructure. +//! +//! These replace the bare `.expect()` / `panic!()` calls that previously +//! made every failure path unrecoverable, giving callers the option to +//! handle errors via [`Result`] instead. +//! +//! # Design notes +//! +//! Each tier of the nested test environment has its own error enum: +//! +//! - [`VmError`] -- failures in the **container -> VM** tier +//! ([`run_in_vm`](crate::run_in_vm) / [`TestVm`](crate::vm::TestVm)). +//! - [`ContainerError`] -- failures in the **host -> container** tier +//! ([`run_test_in_vm`](crate::run_test_in_vm)). +//! +//! `VmError` contains only variants that are common to every hypervisor +//! backend (process spawning, socket polling, vsock, virtiofsd, etc.). +//! Backend-specific errors (e.g. cloud-hypervisor's event-monitor pipe or +//! REST API failures) are represented by the [`Backend`](VmError::Backend) +//! variant, which wraps a `Box`. Each backend module defines +//! its own error enum (e.g. +//! [`CloudHypervisorError`](crate::cloud_hypervisor::error::CloudHypervisorError)) +//! that is boxed into this variant at the [`HypervisorBackend::launch`] +//! call site. +//! +//! # Diagnostics +//! +//! Both error enums derive [`miette::Diagnostic`] in addition to +//! [`thiserror::Error`]. This gives each variant a stable error code +//! (e.g. `n_vm::kvm_not_accessible`) and, where applicable, an +//! actionable `help` hint that is rendered by miette's fancy reporter. +//! The `thiserror` Display messages and `#[source]` chains are unchanged +//! -- miette layers on top without replacing anything. +//! +//! [`HypervisorBackend::launch`]: crate::backend::HypervisorBackend::launch + +use std::path::PathBuf; +use std::time::Duration; + +/// Errors that can occur while launching or managing a VM in the +/// container tier. +/// +/// This enum covers failure modes common to **all** hypervisor backends: +/// binary-path resolution, virtiofsd spawning, vsock listener binding, +/// KVM accessibility, hypervisor process spawning, and socket polling. +/// +/// Backend-specific errors are wrapped in the [`Backend`](Self::Backend) +/// variant so that [`VmError`] does not need to know about any particular +/// hypervisor's internals. +/// +/// Returned by [`TestVm::launch`](crate::vm::TestVm::launch) and +/// [`run_in_vm`](crate::run_in_vm). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum VmError { + /// The [`VmConfig`](crate::config::VmConfig) failed validation before + /// launch (e.g. guest memory not aligned to the hugepage size). + #[error("invalid VM configuration: {reason}")] + #[diagnostic( + code(n_vm::invalid_config), + help( + "check the `const VmConfig` the test names with `config = ...`; \ + guest memory must be a multiple of the configured hugepage size" + ) + )] + InvalidConfig { + /// Why the configuration was rejected. + reason: String, + }, + + /// The guest kernel does not provide features the test declared it + /// needs. + /// + /// A hard failure rather than a skip because the only kernels that exist + /// today are ones *we* build from our own fragments: if such a kernel + /// lacks a required symbol, the fragment list is wrong and silently + /// skipping would hide that. Once a profile can name a kernel we did + /// not build, an unmet requirement there is a finding about that kernel + /// rather than a bug in our config, and should be reported as a skip + /// instead. + #[error( + "guest kernel is missing {} required feature(s): {}", + missing.len(), + missing.iter().map(|s| format!("CONFIG_{s}")).collect::>().join(", "), + )] + #[diagnostic( + code(n_vm::kernel_features_unmet), + help( + "add the symbol to the kernel config fragments in \ + nix/overlays/dataplane-dev.nix and re-run `just setup-roots`, or \ + drop it from the test's `kernel_features` if it is not actually \ + needed" + ) + )] + KernelFeaturesUnmet { + /// Kconfig symbols the kernel does not provide, without the + /// `CONFIG_` prefix. + missing: Vec<&'static str>, + }, + + /// The guest kernel's config could not be read. + #[error(transparent)] + #[diagnostic(transparent)] + KernelConfig(#[from] crate::kernel_config::KernelConfigError), + + /// The kernel manifest could not be read, or does not describe a kernel + /// usable for this guest. + /// + /// Forwarded verbatim: the manifest errors already carry the diagnostic + /// the developer needs (usually "run `just setup-roots`"), and wrapping + /// them in a second layer of prose would bury it. + #[error(transparent)] + #[diagnostic(transparent)] + KernelManifest(#[from] crate::kernel_manifest::KernelManifestError), + + /// `argv[0]` was not available, so the test binary path could not be + /// determined. + /// + /// This can happen if the process was spawned without arguments (e.g. + /// via a bare `execve` with an empty argv array). + #[error("argv[0] missing: cannot determine test binary path")] + #[diagnostic( + code(n_vm::missing_argv), + help( + "the process was spawned without arguments -- this usually indicates \ + a bare execve with an empty argv array" + ) + )] + MissingArgv, + + /// The test binary path (from `argv[0]`) does not contain a `'/'` + /// separator, so the binary name cannot be extracted. + /// + /// This can happen if the binary was invoked via `PATH` lookup without + /// a directory component (e.g. `my_test` instead of `./my_test`). + #[error("test binary path does not contain a '/' separator: {path:?}")] + #[diagnostic( + code(n_vm::invalid_binary_path), + help( + "invoke the test binary with a directory component \ + (e.g. `./my_test` instead of `my_test`)" + ) + )] + InvalidBinaryPath { + /// The argv\[0\] value that could not be split. + path: PathBuf, + }, + + /// virtiofsd failed to start. + #[error("failed to spawn virtiofsd")] + #[diagnostic( + code(n_vm::virtiofsd_spawn), + help( + "is virtiofsd installed at the expected path? \ + check that the binary exists and is executable" + ) + )] + VirtiofsdSpawn(#[source] std::io::Error), + + /// A vsock listener socket could not be bound. + /// + /// The container tier must bind Unix sockets for each + /// [`VsockChannel`](n_vm_protocol::VsockChannel) *before* the VM boots. + /// This error indicates one of those binds failed. + #[error("failed to bind vsock listener for channel `{label}` at {path:?}")] + #[diagnostic( + code(n_vm::vsock_bind), + help( + "check that the socket's parent directory exists and is writable, \ + and that no stale socket file is left over from a previous run" + ) + )] + VsockBind { + /// Human-readable channel label (e.g. `"test-stdout"`). + label: &'static str, + /// Filesystem path that was passed to `bind()`. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// `/dev/kvm` is missing or inaccessible inside the container. + /// + /// Both cloud-hypervisor and QEMU require KVM for hardware-accelerated + /// virtualisation. This error is raised during the pre-flight check + /// before the hypervisor process is spawned. + #[error("/dev/kvm is not accessible")] + #[diagnostic( + code(n_vm::kvm_not_accessible), + help( + "ensure /dev/kvm exists on the host and is passed into the container \ + (--device /dev/kvm). on the host, verify with: \ + `ls -la /dev/kvm` and check group membership with `stat /dev/kvm`" + ) + )] + KvmNotAccessible(#[source] std::io::Error), + + /// `/dev/hugepages` is missing or inaccessible inside the container. + /// + /// Both cloud-hypervisor and QEMU require hugepage-backed memory for + /// the VM guest (cloud-hypervisor via `MemoryConfig.hugepages`, QEMU + /// via `-object memory-backend-file,mem-path=/dev/hugepages`). + /// + /// In scratch-mode containers, `/dev/hugepages` must be available as + /// a hugetlbfs mount. Privileged containers normally inherit this + /// from the host, but if the host kernel does not have hugetlbfs + /// mounted at `/dev/hugepages` or the mount is not propagated into + /// the container, QEMU/cloud-hypervisor will crash immediately with + /// an opaque error. + /// + /// This pre-flight check runs alongside [`Self::KvmNotAccessible`] to + /// surface the problem early with a clear message. + #[error("hugepage pool unavailable")] + #[diagnostic( + code(n_vm::hugepages_not_accessible), + help( + "reserve pages of the size this VM asks for, e.g. \ + `echo 16 > /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages`. \ + no hugetlbfs mount is needed -- both backends allocate through memfd. \ + note the pool is shared by every VM running at once, so a parallel \ + test run needs roughly one page per concurrent test; \ + `--test-threads=N` bounds that" + ) + )] + HugepagesNotAccessible(#[source] std::io::Error), + + /// The hypervisor binary could not be spawned. + /// + /// This is the `Command::spawn()` call for whatever hypervisor binary + /// the active backend uses (e.g. `cloud-hypervisor`, `qemu-system-x86_64`). + #[error("failed to spawn hypervisor process")] + #[diagnostic( + code(n_vm::hypervisor_spawn), + help( + "is the hypervisor binary installed and on PATH? \ + check that the binary exists and is executable" + ) + )] + HypervisorSpawn(#[source] std::io::Error), + + /// A required socket did not appear on the filesystem within the + /// polling timeout. + /// + /// Several sockets (API socket, virtiofsd socket, etc.) are created + /// asynchronously by child processes. This error means the polling + /// loop in `wait_for_socket` exhausted + /// its retry budget without finding the socket. + #[error("timed out waiting for socket {path:?} after {timeout:?}")] + #[diagnostic( + code(n_vm::socket_timeout), + help( + "the process responsible for creating the socket may have crashed \ + before it could do so -- check the hypervisor and virtiofsd \ + stderr output above for clues" + ) + )] + SocketTimeout { + /// The socket path that was being polled. + path: PathBuf, + /// Total time spent polling. + timeout: Duration, + }, + + /// An I/O error occurred while polling for a socket's existence. + #[error("I/O error while waiting for socket {path:?}")] + #[diagnostic(code(n_vm::socket_poll))] + SocketPoll { + /// The socket path that was being polled. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// A backend-specific error occurred during the hypervisor launch + /// sequence. + /// + /// Each [`HypervisorBackend`](crate::backend::HypervisorBackend) + /// implementation defines its own error type covering failure modes + /// unique to that hypervisor (e.g. cloud-hypervisor's event-monitor + /// pipe setup, REST API calls; QEMU's QMP handshake, etc.). Those + /// errors are boxed into this variant so that [`VmError`] remains + /// backend-agnostic. + /// + /// The full error chain is preserved through the + /// [`source()`](std::error::Error::source) method on the inner error, + /// so miette's reporter will render the complete "caused by" chain. + #[error(transparent)] + #[diagnostic(code(n_vm::backend))] + Backend(#[from] Box), +} + +/// Errors that can occur while launching or managing a Docker container +/// in the host tier. +/// +/// Returned by [`run_test_in_vm`](crate::run_test_in_vm). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum ContainerError { + /// Could not read `/proc/self/exe` to determine the test binary path. + #[error("failed to read /proc/self/exe")] + #[diagnostic(code(n_vm::container::binary_path_read))] + BinaryPathRead(#[source] std::io::Error), + + /// A `#[corpus]` test's corpus directory could not be derived from its + /// compile-time source path. + /// + /// Reported rather than skipped: the guest cannot say "I had no writable + /// corpus", so the write lands on the read-only root share and surfaces + /// as a bare `ReadOnlyFilesystem` far from the cause. + #[error("cannot derive the corpus directory for a #[corpus] test")] + #[diagnostic( + code(n_vm::container::corpus_dir_unresolvable), + help( + "`file!()` is `{file}` and the crate directory is `{crate_dir}`, \ + which share no component to anchor on. A crate at the workspace \ + root cannot be anchored this way, because \ + `--remap-path-prefix==${{src}}` does not preserve its directory \ + name." + ) + )] + CorpusDirUnresolvable { + /// The test's `file!()`, as recorded at compile time. + file: String, + /// The test crate's `CARGO_MANIFEST_DIR`. + crate_dir: String, + }, + + /// The forwarded environment file could not be written on the host. + /// + /// Fatal rather than best-effort: the guest cannot report that it was + /// started without the environment it needed, and the failure is + /// otherwise silent -- a bolero test that loses `BOLERO_LIBFUZZER_ARGS` + /// still passes, having quietly stopped fuzzing. + #[error("failed to write the forwarded environment file {path}")] + #[diagnostic(code(n_vm::container::env_file_write))] + EnvFileWrite { + /// The file that could not be written. + path: PathBuf, + /// The underlying filesystem error. + #[source] + source: std::io::Error, + }, + + /// The corpus directory could not be created on the host. + #[error("failed to create the corpus directory {path}")] + #[diagnostic(code(n_vm::container::corpus_dir_create))] + CorpusDirCreate { + /// The directory that could not be created. + path: PathBuf, + /// The underlying filesystem error. + #[source] + source: std::io::Error, + }, + + /// A `#[corpus]` test ran outside any cargo workspace. + /// + /// The corpus lives beside the test's source, so without a workspace + /// root there is nowhere to put it. + #[error("a #[corpus] test requires a cargo workspace, but none was found")] + #[diagnostic( + code(n_vm::container::corpus_without_workspace), + help("set N_VM_WORKSPACE to the workspace root") + )] + CorpusWithoutWorkspace, + + /// The test named a kernel profile and a backend that disagree. + /// + /// A failure rather than a skip. A profile the *environment* chose can + /// legitimately not suit a test -- that is what `N_VM_PROFILE` sweeps + /// are for, and skipping is right. But a test that writes both halves + /// itself has described a machine that does not exist, and a skip is + /// reported as a pass, so it would go green having run nothing. + #[error( + "this test names kernel profile `{profile}`, which runs on {profile_backend:?}, \ + but also pins {requested:?}" + )] + #[diagnostic( + code(n_vm::container::profile_contradicts_backend), + help( + "a profile is a (kernel, hypervisor) pair; drop the backend, or name a \ + profile that runs on it" + ) + )] + ProfileContradictsBackend { + /// The profile the test named. + profile: String, + /// The hypervisor that profile runs on. + profile_backend: crate::backend::EffectiveBackend, + /// The hypervisor the test pinned. + requested: crate::backend::EffectiveBackend, + }, + + /// Could not canonicalize the test binary's parent directory. + #[error("failed to canonicalize test binary directory")] + #[diagnostic(code(n_vm::container::binary_path_canonicalize))] + BinaryPathCanonicalize(#[source] std::io::Error), + + /// The test binary path (from `/proc/self/exe`) has no parent + /// directory component. + /// + /// This is unexpected for a path returned by `readlink`, which should + /// always be absolute. + #[error("test binary path has no parent directory: {path}")] + #[diagnostic(code(n_vm::container::no_parent_directory))] + NoParentDirectory { + /// The path that had no parent. + path: PathBuf, + }, + + /// A filesystem path required for the container configuration is not + /// valid UTF-8. + /// + /// Docker and the container runtime APIs require UTF-8 strings for + /// mount paths and command arguments. + #[error("path is not valid UTF-8: {path:?}")] + #[diagnostic( + code(n_vm::container::non_utf8_path), + help( + "Docker requires UTF-8 mount paths and command arguments; \ + rename or move the file to a path containing only valid UTF-8" + ) + )] + NonUtf8Path { + /// The path that could not be converted to a UTF-8 string. + path: PathBuf, + }, + + /// A required device node (e.g. `/dev/kvm`) is not accessible on the + /// host. + #[error("required device {path:?} is not accessible")] + #[diagnostic( + code(n_vm::container::device_not_accessible), + help( + "ensure the device node exists on the host and has the correct \ + permissions -- check with `ls -la /dev/`" + ) + )] + DeviceNotAccessible { + /// The device path that could not be stat'd. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// Could not connect to the Docker daemon. + #[error("failed to connect to Docker daemon")] + #[diagnostic( + code(n_vm::container::docker_connect), + help( + "is the Docker daemon running? check with: \ + `systemctl status docker` or `docker info`. \ + also verify the current user is in the `docker` group" + ) + )] + DockerConnect(#[source] bollard::errors::Error), + + /// The Docker daemon `info` query failed. + #[error("failed to query Docker daemon info")] + #[diagnostic(code(n_vm::container::docker_info))] + DockerInfo(#[source] bollard::errors::Error), + + /// The Docker daemon did not report its architecture, so the host + /// architecture cannot be compared against the test's target arch. + #[error("Docker daemon did not report its architecture")] + #[diagnostic(code(n_vm::container::docker_arch_unknown))] + DockerArchUnknown, + + /// The user-mode QEMU interpreter needed to run a cross-architecture + /// test binary inside the container could not be found on `$PATH`. + #[error("user-mode QEMU interpreter `{name}` not found on PATH")] + #[diagnostic( + code(n_vm::container::qemu_user_not_found), + help( + "cross-arch in-VM tests run the foreign test binary under \ + user-mode QEMU in the container; ensure `{name}` is on PATH \ + (it is provided by the dev shell)" + ) + )] + QemuUserNotFound { + /// The interpreter name searched for (e.g. `qemu-aarch64`). + name: String, + }, + + /// Docker refused to create the container. + #[error("failed to create Docker container")] + #[diagnostic(code(n_vm::container::container_create))] + ContainerCreate(#[source] bollard::errors::Error), + + /// Docker refused to start the container. + #[error("failed to start Docker container")] + #[diagnostic(code(n_vm::container::container_start))] + ContainerStart(#[source] bollard::errors::Error), + + /// An error occurred while streaming container logs. + #[error("error reading container log stream")] + #[diagnostic(code(n_vm::container::log_stream))] + LogStream(#[source] bollard::errors::Error), + + /// The container inspection after exit did not include a + /// [`ContainerState`](bollard::models::ContainerState). + #[error("container returned no state on inspection")] + #[diagnostic(code(n_vm::container::missing_state))] + MissingState, + + /// Docker refused the post-exit container inspection. + #[error("failed to inspect container after exit")] + #[diagnostic(code(n_vm::container::container_inspect))] + ContainerInspect(#[source] bollard::errors::Error), + + /// Docker refused to remove the container. + #[error("failed to remove container")] + #[diagnostic(code(n_vm::container::container_remove))] + ContainerRemove(#[source] bollard::errors::Error), + + /// A termination-signal handler could not be registered. + /// + /// Not fatal on its own: the run continues without the signal race, and + /// only loses the ability to clean up when killed. + #[error("failed to install termination signal handler")] + #[diagnostic( + code(n_vm::container::signal_handler), + help("the container will leak if this process is signalled") + )] + SignalHandler(#[source] std::io::Error), + + /// A scratch-mode root directory environment variable is set but the + /// path it references cannot be resolved. + #[error("failed to resolve scratch root directory")] + #[diagnostic( + code(n_vm::container::scratch_root_resolve), + help( + "check that the scratch root environment variables point to \ + existing, accessible directories" + ) + )] + ScratchRootResolve(#[source] n_vm_protocol::ScratchRootError), + + /// The scratch Docker image could not be created locally. + /// + /// In scratch mode, a truly empty Docker image is created on-demand + /// by importing an empty tar archive. This error indicates that + /// the import failed. + #[error("failed to create scratch Docker image: {0}")] + #[diagnostic( + code(n_vm::container::scratch_image_create), + help( + "the Docker daemon could not import the empty tar archive used \ + to create the scratch image -- check Docker daemon logs for details" + ) + )] + ScratchImageCreate(String), +} diff --git a/n-vm/src/kernel_config.rs b/n-vm/src/kernel_config.rs new file mode 100644 index 0000000000..9dfa384aa5 --- /dev/null +++ b/n-vm/src/kernel_config.rs @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Reading a kernel's own `.config`, so that what a test *requires* can be +//! checked against what its kernel actually *provides*. +//! +//! # Why this exists +//! +//! The set of features a kernel provides is declared independently of the +//! tests -- by config fragments for a kernel we build, by someone else's +//! build entirely for a distro kernel. Nothing stops a test from needing a +//! symbol its kernel does not have. +//! +//! Without a check, that failure surfaces as whatever the missing feature +//! breaks: a socket option returning `ENOPROTOOPT`, a `tc` filter that will +//! not attach, a mount that fails for no stated reason -- deep inside a test +//! body, in a VM, with no hint that the kernel is the cause. With one, it +//! surfaces before boot as the name of the missing symbol. +//! +//! That difference matters most for the case this is all aimed at: running +//! the suite against the production kernel we ship on. A skipped test with +//! *"requires `CONFIG_NET_CLS_FLOWER`, kernel has it `n`"* is a finding +//! about production. The same test failing obscurely is noise, and noise in +//! that position gets muted. +//! +//! # What a value means +//! +//! Kconfig has three states, and the distinction between two of them is the +//! whole reason foreign kernels are hard: +//! +//! - `y` -- built into the image, available the instant it boots; +//! - `m` -- a separate `.ko` that something must load first; +//! - absent (or `# CONFIG_X is not set`) -- not there at all. +//! +//! A requirement is satisfied by `y` *or* `m`, but `m` additionally implies +//! a module to load, which is why [`FeatureState`] keeps them apart rather +//! than collapsing to a boolean. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Whether a kernel provides a feature, and how. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeatureState { + /// Compiled into the image (`=y`). Usable immediately at boot. + BuiltIn, + /// Built as a loadable module (`=m`). Present, but something has to + /// `insmod` it before the feature works. + Module, + /// Not configured, or explicitly `# CONFIG_X is not set`. + Absent, +} + +impl FeatureState { + /// Whether the feature is present at all, in either form. + #[must_use] + pub const fn is_available(self) -> bool { + matches!(self, Self::BuiltIn | Self::Module) + } + + /// Whether using this feature requires loading a module first. + #[must_use] + pub const fn needs_module_load(self) -> bool { + matches!(self, Self::Module) + } +} + +/// A parsed kernel `.config`. +/// +/// Only tristate symbols are retained. String and integer options +/// (`CONFIG_LOCALVERSION="..."`, `CONFIG_HZ=250`) are parsed but recorded as +/// [`FeatureState::BuiltIn`], since for the purpose of "does this kernel +/// have X" a symbol with a value is present. +#[derive(Debug, Clone, Default)] +pub struct KernelConfig { + /// Symbol name *without* the `CONFIG_` prefix, mapped to its state. + symbols: BTreeMap, +} + +impl KernelConfig { + /// Reads and parses a kernel config file. + /// + /// # Errors + /// + /// Returns [`KernelConfigError::Read`] if the file cannot be read. + pub fn load(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|source| KernelConfigError::Read { + path: path.to_owned(), + source, + })?; + Ok(Self::parse(&raw)) + } + + /// Parses a kernel config from its text. + /// + /// Unparseable lines are ignored rather than rejected. A `.config` is + /// generated, not hand-written, and it carries banner comments and blank + /// lines throughout; refusing to read one because of an unrecognized + /// line would fail closed on a file that is almost certainly fine. + #[must_use] + pub fn parse(raw: &str) -> Self { + let mut symbols = BTreeMap::new(); + + for line in raw.lines() { + let line = line.trim(); + + // `# CONFIG_X is not set` is how Kconfig spells an explicit + // "no". It is a comment, so it has to be matched before + // comments are skipped -- and it is worth capturing rather than + // treating as absent-by-omission, because it distinguishes + // "considered and disabled" from "this symbol does not exist in + // this kernel version at all". + if let Some(rest) = line.strip_prefix("# CONFIG_") { + if let Some(name) = rest.strip_suffix(" is not set") { + symbols.insert(name.to_owned(), FeatureState::Absent); + } + continue; + } + + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some(rest) = line.strip_prefix("CONFIG_") else { + continue; + }; + let Some((name, value)) = rest.split_once('=') else { + continue; + }; + + let state = match value { + "y" => FeatureState::BuiltIn, + "m" => FeatureState::Module, + "n" => FeatureState::Absent, + // A string or integer option: present, with a value. + _ => FeatureState::BuiltIn, + }; + symbols.insert(name.to_owned(), state); + } + + Self { symbols } + } + + /// The state of one symbol, named *without* the `CONFIG_` prefix. + /// + /// A symbol the config never mentions is [`FeatureState::Absent`]: an + /// unset Kconfig symbol and one that does not exist are + /// indistinguishable to a running kernel. + #[must_use] + pub fn state(&self, symbol: &str) -> FeatureState { + self.symbols + .get(symbol) + .copied() + .unwrap_or(FeatureState::Absent) + } + + /// Whether the kernel provides this symbol as either `y` or `m`. + #[must_use] + pub fn provides(&self, symbol: &str) -> bool { + self.state(symbol).is_available() + } + + /// How many symbols were parsed. Useful to sanity-check that a config + /// was actually read rather than silently empty. + #[must_use] + pub fn len(&self) -> usize { + self.symbols.len() + } + + /// Whether the config is empty, which almost always means the file was + /// not a kernel config at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.symbols.is_empty() + } +} + +/// Errors reading a kernel config. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum KernelConfigError { + /// The config file could not be read. + #[error("cannot read kernel config at {path:?}")] + #[diagnostic( + code(n_vm::kernel_config_read), + help( + "the config is recorded beside the kernel image by the nix build; \ + re-run `just setup-roots` from the workspace root" + ) + )] + Read { + /// The path that could not be read. + path: PathBuf, + /// The underlying I/O error. + source: std::io::Error, + }, +} + +#[cfg(test)] +mod test { + use super::*; + + const SAMPLE: &str = r#" +# +# Automatically generated file; DO NOT EDIT. +# Linux/x86 6.18.20 Kernel Configuration +# +CONFIG_VIRTIO_FS=y +CONFIG_FUSE_FS=y +CONFIG_NET_CLS_FLOWER=m +# CONFIG_MLX5_CORE is not set +CONFIG_LOCALVERSION="-fancy" +CONFIG_HZ=250 +"#; + + #[test] + fn distinguishes_builtin_module_and_absent() { + let config = KernelConfig::parse(SAMPLE); + assert_eq!(config.state("VIRTIO_FS"), FeatureState::BuiltIn); + assert_eq!(config.state("NET_CLS_FLOWER"), FeatureState::Module); + assert_eq!(config.state("MLX5_CORE"), FeatureState::Absent); + } + + /// `y` and `m` both satisfy a requirement, but only `m` implies there is + /// a module to load -- which is the distinction the whole foreign-kernel + /// path turns on. + #[test] + fn module_is_available_but_needs_loading() { + let config = KernelConfig::parse(SAMPLE); + assert!(config.provides("NET_CLS_FLOWER")); + assert!(config.state("NET_CLS_FLOWER").needs_module_load()); + assert!(config.provides("VIRTIO_FS")); + assert!(!config.state("VIRTIO_FS").needs_module_load()); + } + + /// A symbol the config never mentions must read as absent, not panic or + /// default to present: an unset symbol and a nonexistent one are the + /// same to a running kernel. + #[test] + fn unmentioned_symbol_is_absent() { + let config = KernelConfig::parse(SAMPLE); + assert_eq!( + config.state("SOME_SYMBOL_THAT_DOES_NOT_EXIST"), + FeatureState::Absent + ); + assert!(!config.provides("SOME_SYMBOL_THAT_DOES_NOT_EXIST")); + } + + /// `# CONFIG_X is not set` is a comment, so it must be matched before + /// comments are skipped. Getting this backwards silently loses every + /// explicit disable in the file. + #[test] + fn explicit_not_set_is_recorded_not_skipped_as_comment() { + let config = KernelConfig::parse(SAMPLE); + assert_eq!(config.state("MLX5_CORE"), FeatureState::Absent); + assert!( + config.len() >= 5, + "banner comments should not be parsed as symbols, but real \ + entries should survive; got {} symbols", + config.len(), + ); + } + + /// String and integer options are not tristates, but for "does this + /// kernel have X" a symbol with a value is present. + #[test] + fn valued_options_count_as_present() { + let config = KernelConfig::parse(SAMPLE); + assert!(config.provides("LOCALVERSION")); + assert!(config.provides("HZ")); + } + + /// A `.config` is generated and full of banners and blank lines, so + /// parsing must tolerate them rather than fail closed on a good file. + #[test] + fn tolerates_comments_and_blank_lines() { + let config = KernelConfig::parse("\n\n# a comment\n\nCONFIG_A=y\n \nCONFIG_B=m\n"); + assert_eq!(config.len(), 2); + assert_eq!(config.state("A"), FeatureState::BuiltIn); + assert_eq!(config.state("B"), FeatureState::Module); + } + + /// The `CONFIG_` prefix is stripped on the way in, so callers name + /// symbols one way only. Passing a prefixed name is a mistake that + /// would otherwise silently read as absent. + #[test] + fn symbols_are_stored_without_the_config_prefix() { + let config = KernelConfig::parse("CONFIG_VIRTIO_FS=y\n"); + assert!(config.provides("VIRTIO_FS")); + assert!(!config.provides("CONFIG_VIRTIO_FS")); + } + + #[test] + fn empty_input_yields_an_empty_config() { + let config = KernelConfig::parse(""); + assert!(config.is_empty()); + assert_eq!(config.len(), 0); + } +} diff --git a/n-vm/src/kernel_feature.rs b/n-vm/src/kernel_feature.rs new file mode 100644 index 0000000000..03d8f672d7 --- /dev/null +++ b/n-vm/src/kernel_feature.rs @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Kernel features a test depends on, and checking them against the kernel +//! it is about to run on. +//! +//! # Why declare them +//! +//! The set of features a kernel *provides* is decided independently of the +//! tests: by config fragments for a kernel we build, by someone else's build +//! entirely for a distro kernel. Nothing stops a test from needing a symbol +//! its kernel does not have. +//! +//! Declaring the dependency makes that a stated fact rather than an +//! accident. A test that needs `NET_CLS_FLOWER` and does not say so passes +//! only because the fragment list happens to enable it -- and keeps passing +//! until someone trims the list, at which point it fails somewhere far from +//! the cause. +//! +//! This is also the check that makes running against a production kernel +//! worth anything. "requires `CONFIG_NET_CLS_FLOWER`, kernel has it `n`" is +//! a finding about the kernel we ship on. The same test failing obscurely +//! several tiers down is noise, and noise in that position gets muted. +//! +//! # Why not generate the fragments from these +//! +//! It looks like these declarations should *produce* the kernel config +//! rather than be checked against it. They cannot: nix builds the kernel +//! before cargo builds the tests, so a union derived from compiled Rust +//! would have to exist before the thing it is derived from. Scraping the +//! sources instead would work, but would make the kernel derivation depend +//! on every `.rs` file in the workspace -- so any code edit would trigger a +//! full kernel rebuild. +//! +//! So the provided set stays declared independently, and these are verified +//! against it. Almost nothing is lost: the value was in the check, not in +//! the generation. + +use crate::kernel_config::{FeatureState, KernelConfig}; + +/// A kernel feature a test can depend on. +/// +/// Carries both the Kconfig symbol and the module name because the two are +/// used by different consumers and are not mechanically related -- +/// `CONFIG_NET_CLS_FLOWER` builds `cls_flower.ko`. The symbol answers "does +/// this kernel have it"; the module name answers "what has to be loaded +/// before it works". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KernelFeature { + /// Kconfig symbol, *without* the `CONFIG_` prefix. + symbol: &'static str, + /// Module that provides it when built as `=m`, if it can be modular. + module: Option<&'static str>, +} + +impl KernelFeature { + /// Declares a feature that can be built as a module. + #[must_use] + pub const fn modular(symbol: &'static str, module: &'static str) -> Self { + Self { + symbol, + module: Some(module), + } + } + + /// Declares a feature that can only be built in. + /// + /// Kept distinct from [`modular`](Self::modular) so that finding such a + /// symbol set to `=m` is recognisable as a bug in this table rather than + /// silently producing a module name that does not exist. + #[must_use] + pub const fn builtin_only(symbol: &'static str) -> Self { + Self { + symbol, + module: None, + } + } + + /// The Kconfig symbol, without the `CONFIG_` prefix. + #[must_use] + pub const fn symbol(&self) -> &'static str { + self.symbol + } + + /// The module providing this feature when it is built as `=m`. + #[must_use] + pub const fn module(&self) -> Option<&'static str> { + self.module + } +} + +/// Features this workspace's tests depend on. +/// +/// A curated table rather than free-form strings so that a typo is a +/// compile error and shows up in completion, per +/// `development/code/avoid-global-reasoning.md` ("use static typing to +/// enforce validity constraints where possible"). It is a module of +/// `const`s rather than an enum so a consuming project can declare its own +/// without editing this one. +pub mod features { + use super::KernelFeature; + + /// Paravirtualised network device. + pub const VIRTIO_NET: KernelFeature = KernelFeature::modular("VIRTIO_NET", "virtio_net"); + /// Shared-filesystem transport used for the workspace mount. + pub const VIRTIO_FS: KernelFeature = KernelFeature::modular("VIRTIO_FS", "virtiofs"); + /// FUSE, which `virtiofs` is built on. + pub const FUSE_FS: KernelFeature = KernelFeature::modular("FUSE_FS", "fuse"); + /// vsock transport used for the result channel. + pub const VIRTIO_VSOCKETS: KernelFeature = + KernelFeature::modular("VIRTIO_VSOCKETS", "vmw_vsock_virtio_transport"); + + /// hugetlbfs. Cannot be modular. + pub const HUGETLBFS: KernelFeature = KernelFeature::builtin_only("HUGETLBFS"); + + /// `tc` flower classifier. + pub const NET_CLS_FLOWER: KernelFeature = + KernelFeature::modular("NET_CLS_FLOWER", "cls_flower"); + /// `tc` action support. Cannot be modular. + pub const NET_CLS_ACT: KernelFeature = KernelFeature::builtin_only("NET_CLS_ACT"); + + /// Userspace device passthrough. + pub const VFIO: KernelFeature = KernelFeature::modular("VFIO", "vfio"); + /// PCI passthrough via VFIO. + pub const VFIO_PCI: KernelFeature = KernelFeature::modular("VFIO_PCI", "vfio-pci"); + + /// Mellanox ConnectX core driver. + pub const MLX5_CORE: KernelFeature = KernelFeature::modular("MLX5_CORE", "mlx5_core"); + + /// Intel 82540EM, the emulated NIC QEMU calls `e1000`. + pub const E1000: KernelFeature = KernelFeature::modular("E1000", "e1000"); + /// Intel 82574L, the emulated NIC QEMU calls `e1000e`. + /// + /// Worth declaring alongside [`VIRTIO_NET`] on a test that mixes + /// models: a kernel without the driver does not fail, it simply never + /// brings the interface up, and the test then reads as "the device was + /// not presented" when the device was presented and nothing could bind + /// it. + pub const E1000E: KernelFeature = KernelFeature::modular("E1000E", "e1000e"); +} + +/// A feature a kernel does not provide. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnmetRequirement { + /// The Kconfig symbol that is missing. + pub symbol: &'static str, +} + +/// Checks a test's declared features against a kernel's config. +/// +/// A feature is satisfied by `=y` *or* `=m`: a module is present, it just +/// has to be loaded first. Loading is a separate concern from availability, +/// which is why this does not reject `=m`. +/// +/// Returns every unmet requirement rather than the first, because a kernel +/// missing one feature usually misses several related ones, and reporting +/// them one boot at a time is a poor way to find that out. +#[must_use] +pub fn unmet_requirements( + required: &[KernelFeature], + config: &KernelConfig, +) -> Vec { + required + .iter() + .filter(|feature| !config.provides(feature.symbol())) + .map(|feature| UnmetRequirement { + symbol: feature.symbol(), + }) + .collect() +} + +/// The modules that must be loaded before the declared features work. +/// +/// Only features the kernel built as `=m` appear: a built-in needs no +/// loading, and an absent one is a failed requirement rather than something +/// to load. Unused until the guest can load modules, but derived here so +/// the rule lives next to the table it reads. +#[must_use] +pub fn modules_to_load(required: &[KernelFeature], config: &KernelConfig) -> Vec<&'static str> { + required + .iter() + .filter(|feature| config.state(feature.symbol()) == FeatureState::Module) + .filter_map(KernelFeature::module) + .collect() +} + +#[cfg(test)] +mod test { + use super::*; + + fn config() -> KernelConfig { + KernelConfig::parse( + "CONFIG_VIRTIO_FS=y\n\ + CONFIG_FUSE_FS=y\n\ + CONFIG_NET_CLS_FLOWER=m\n\ + CONFIG_VFIO=m\n\ + # CONFIG_MLX5_CORE is not set\n", + ) + } + + #[test] + fn builtin_and_module_both_satisfy_a_requirement() { + let unmet = unmet_requirements(&[features::VIRTIO_FS, features::NET_CLS_FLOWER], &config()); + assert!( + unmet.is_empty(), + "`=y` and `=m` should both satisfy: {unmet:?}", + ); + } + + #[test] + fn absent_feature_is_reported() { + let unmet = unmet_requirements(&[features::MLX5_CORE], &config()); + assert_eq!( + unmet, + vec![UnmetRequirement { + symbol: "MLX5_CORE" + }], + ); + } + + /// A kernel missing one feature usually misses several; reporting them + /// one boot at a time would be a poor way to discover that. + #[test] + fn every_unmet_requirement_is_reported_not_just_the_first() { + let unmet = unmet_requirements( + &[ + features::MLX5_CORE, + features::VIRTIO_FS, + features::HUGETLBFS, + ], + &config(), + ); + let symbols: Vec<_> = unmet.iter().map(|u| u.symbol).collect(); + assert_eq!(symbols, vec!["MLX5_CORE", "HUGETLBFS"]); + } + + /// Only `=m` features need loading. A built-in is already there, and an + /// absent one is a failed requirement rather than something to load. + #[test] + fn only_modular_features_need_loading() { + let modules = modules_to_load( + &[ + features::VIRTIO_FS, // =y, already present + features::NET_CLS_FLOWER, // =m, needs loading + features::VFIO, // =m, needs loading + features::MLX5_CORE, // absent + ], + &config(), + ); + assert_eq!(modules, vec!["cls_flower", "vfio"]); + } + + /// The symbol and the module name are not mechanically related, so both + /// have to be carried. `CONFIG_NET_CLS_FLOWER` builds `cls_flower.ko`. + #[test] + fn symbol_and_module_name_differ() { + assert_eq!(features::NET_CLS_FLOWER.symbol(), "NET_CLS_FLOWER"); + assert_eq!(features::NET_CLS_FLOWER.module(), Some("cls_flower")); + } + + /// A feature that cannot be modular has no module name, so nothing can + /// try to load one that does not exist. + #[test] + fn builtin_only_features_have_no_module() { + assert_eq!(features::HUGETLBFS.module(), None); + let modules = modules_to_load( + &[features::HUGETLBFS], + &KernelConfig::parse("CONFIG_HUGETLBFS=y\n"), + ); + assert!(modules.is_empty()); + } + + #[test] + fn no_requirements_is_trivially_satisfied() { + assert!(unmet_requirements(&[], &config()).is_empty()); + assert!(modules_to_load(&[], &config()).is_empty()); + } +} + +/// Names of the kernel profiles the manifest defines. +/// +/// A profile is a *(kernel, hypervisor)* pair, and which ones exist is a +/// fact about the nix build rather than about this crate -- so these are +/// names checked against the manifest at launch, not a closed enum. They +/// are spelled out here for the same reason [`features`] is: a constant +/// gets completion and a rename becomes a build error, where a bare string +/// gets neither. +/// +/// A name that is not in the manifest is reported at launch, listing the +/// ones that are. +pub mod kernel_profiles { + /// The kernel this repo builds, booted directly under cloud-hypervisor. + /// + /// The manifest's default, and what a test that names no profile gets. + pub const CLOUD_HYPERVISOR: &str = "cloud_hypervisor"; + + /// The same kernel, booted directly under QEMU. + /// + /// Prefer [`RequestedBackend::Qemu`](crate::RequestedBackend::Qemu) to + /// change only the hypervisor; this exists so the pair can be named as + /// one thing when that is what is meant. + pub const QEMU: &str = "qemu"; + + /// Flatcar's distribution kernel, booted through an initramfs on QEMU. + /// + /// A *modular* kernel with its own module tree, which is what makes it + /// worth having: the union kernel builds in everything, so nothing that + /// depends on a module being loaded -- or on failing to load -- can be + /// tested against it. + pub const FLATCAR: &str = "flatcar"; + + /// Ubuntu's distribution kernel, booted through an initramfs on QEMU. + pub const UBUNTU: &str = "ubuntu"; + + /// This repo's kernel built modular, booted through an initramfs on + /// QEMU. + /// + /// The one to reach for when a test needs modules *and* a kernel whose + /// configuration this repo controls. + pub const MODULAR: &str = "modular"; +} diff --git a/n-vm/src/kernel_manifest.rs b/n-vm/src/kernel_manifest.rs new file mode 100644 index 0000000000..b94ebae856 --- /dev/null +++ b/n-vm/src/kernel_manifest.rs @@ -0,0 +1,795 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! The kernel manifest: nix's declaration of which guest kernels exist. +//! +//! # Why a manifest at all +//! +//! `cargo` must never invoke `nix`. Nix does not handle that recursion +//! well, so the build is strictly staged: +//! +//! 1. nix builds or fetches every artifact that is not a test -- kernels, +//! module trees, virtiofsd, the guest rootfs -- and materializes them +//! (`just setup-roots` writes the `testroot` symlink); +//! 2. cargo builds the tests; +//! 3. the tests *read* those artifacts, and never build them. +//! +//! That staging means the set of available kernels is a fact about the nix +//! build, discovered at run time rather than hardcoded in Rust. Hardcoding +//! it -- as `Arch::kernel_image_path` used to, with `/bzImage` and `/Image` +//! -- works only while there is exactly one kernel per architecture, which +//! stops being true as soon as we want to run the same test against both a +//! kernel built from our own config fragments and a distro's production +//! kernel. +//! +//! # Contract +//! +//! nix writes the manifest into `testroot`; every first-level `testroot` +//! entry is bind-mounted at the container root, so the container tier reads +//! it from [`KERNEL_MANIFEST_PATH`]. Paths inside it are +//! container-absolute, because the container tier is what consumes them. +//! +//! A missing or malformed manifest is a hard error naming the fix, never a +//! silent fallback: a wrong kernel path fails much later and far less +//! legibly than a missing one. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use n_vm_protocol::KERNEL_MANIFEST_PATH; + +use crate::backend::EffectiveBackend; +use crate::config::Arch; + +/// How a profile's kernel reaches its root filesystem. +/// +/// A kernel with `CONFIG_VIRTIO_FS=y` can mount the workspace itself and +/// needs no initramfs. A kernel that has virtiofs as a *module* cannot: +/// the module lives in the module tree, which is reached over virtiofs. +/// Breaking that deadlock needs an initramfs carrying the boot-critical +/// modules, which is the only channel guaranteed to be available before any +/// driver loads. +/// +/// This is derived by the nix build from the kernel's own config rather +/// than configured by hand, so the two cannot disagree. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BootMode { + /// Boot the kernel straight into the virtiofs root. + #[default] + Direct, + /// Boot through an initramfs that loads boot-critical modules first. + Initramfs, +} + +/// One named guest kernel and its artifacts. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct KernelProfile { + /// Guest architecture this kernel is built for (`x86_64`, `aarch64`). + /// + /// Checked against the test binary's own target arch before launch: an + /// x86_64 `bzImage` is useless under an aarch64 emulator, and catching + /// that here beats debugging a VM that never prints anything. + pub arch: String, + /// Hypervisor this profile runs on (`cloud_hypervisor`, `qemu`). + /// + /// A profile is a (kernel, hypervisor) pair, so the hypervisor is a + /// property of the *environment* rather than of the test. That is what + /// lets one test run under several hypervisors instead of being written + /// out once per backend. + pub hypervisor: String, + /// How this kernel reaches its root filesystem. + #[serde(default)] + pub boot: BootMode, + /// Container-absolute path to the bootable kernel image. + pub kernel: String, + /// Container-absolute path to the kernel's own `.config`, when nix was + /// able to record it. + /// + /// Unused today; this is what a later requirement-verification pass + /// reads to answer "does this kernel actually provide what the test + /// declared it needs". + #[serde(default)] + pub config: Option, + /// Container-absolute path to the initramfs, when `boot` is + /// [`BootMode::Initramfs`]. + #[serde(default)] + pub initramfs: Option, + /// Container-absolute path to the module tree, for modular kernels. + #[serde(default)] + pub modules: Option, +} + +impl KernelProfile { + /// The hypervisor this profile runs on. + /// + /// # Errors + /// + /// Returns [`KernelManifestError::UnknownHypervisor`] if the manifest + /// names one this build does not have a backend for. Failing loudly + /// beats defaulting, which would run the test somewhere other than + /// where the profile said. + pub fn backend(&self, name: &str) -> Result { + match self.hypervisor.as_str() { + "cloud_hypervisor" => Ok(EffectiveBackend::CloudHypervisor), + "qemu" => Ok(EffectiveBackend::Qemu), + other => Err(KernelManifestError::UnknownHypervisor { + profile: name.to_owned(), + hypervisor: other.to_owned(), + }), + } + } + + /// Checks that this kernel matches the guest architecture. + /// + /// # Errors + /// + /// Returns [`KernelManifestError::ArchMismatch`] if it does not. + pub fn check_arch(&self, name: &str, arch: Arch) -> Result<(), KernelManifestError> { + if self.arch == arch.manifest_name() { + return Ok(()); + } + Err(KernelManifestError::ArchMismatch { + profile: name.to_owned(), + manifest_arch: self.arch.clone(), + guest_arch: arch.manifest_name(), + }) + } +} + +/// The set of guest kernels nix built, and which one to use by default. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct KernelManifest { + /// Name of the profile to use when a test does not name one. + pub default: String, + /// Every available profile, keyed by name. + pub profiles: BTreeMap, +} + +impl KernelManifest { + /// Reads the manifest from the container's well-known location. + /// + /// # Errors + /// + /// See [`KernelManifestError`]. + pub fn load() -> Result { + Self::load_from(Path::new(KERNEL_MANIFEST_PATH)) + } + + /// Reads a manifest from an explicit path. + /// + /// # Errors + /// + /// See [`KernelManifestError`]. + pub fn load_from(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|source| KernelManifestError::Read { + path: path.to_owned(), + source, + })?; + Self::parse(&raw, path) + } + + /// Parses a manifest from JSON, validating internal consistency. + /// + /// # Errors + /// + /// See [`KernelManifestError`]. + pub fn parse(raw: &str, path: &Path) -> Result { + let manifest: Self = + serde_json::from_str(raw).map_err(|source| KernelManifestError::Parse { + path: path.to_owned(), + source, + })?; + + // A `default` naming a profile that does not exist would otherwise + // surface as a confusing "unknown profile" at launch, pointing at + // the test rather than at the manifest that is actually wrong. + if !manifest.profiles.contains_key(&manifest.default) { + return Err(KernelManifestError::UnknownProfile { + name: manifest.default.clone(), + available: manifest.profile_names(), + }); + } + + Ok(manifest) + } + + /// The available profile names, for error messages. + #[must_use] + pub fn profile_names(&self) -> Vec { + self.profiles.keys().cloned().collect() + } + + /// Looks up a profile by name. + /// + /// # Errors + /// + /// Returns [`KernelManifestError::UnknownProfile`] if there is no such + /// profile, listing the ones that do exist. + pub fn profile(&self, name: &str) -> Result<&KernelProfile, KernelManifestError> { + self.profiles + .get(name) + .ok_or_else(|| KernelManifestError::UnknownProfile { + name: name.to_owned(), + available: self.profile_names(), + }) + } + + /// The profile this invocation should use. + /// + /// Three inputs, in order. `declared` is what the test's own + /// configuration asked for and wins outright. Failing that, + /// [`n_vm_protocol::ENV_PROFILE`] points a whole run at a different environment + /// (`N_VM_PROFILE=qemu cargo test`) without editing any test. Failing + /// that, the manifest's `default`. + /// + /// `emulation_required` says the guest cannot run natively on this host, + /// so the chosen profile's hypervisor has to be able to emulate. It + /// only affects the fallback -- see + /// `default_emulating_profile`. + /// Callers must derive it from the same fact in every tier: the host + /// tier from the Docker daemon's architecture, later tiers from the + /// [`ENV_ACCEL`](n_vm_protocol::ENV_ACCEL) it forwards. Two tiers + /// disagreeing here would boot a different kernel than the one whose + /// hypervisor was resolved. + /// + /// # Errors + /// + /// Returns [`KernelManifestError::UnknownProfile`] if the variable names + /// a profile that does not exist -- a typo there would otherwise + /// silently run the default environment while appearing to select + /// another, which is the one outcome worth failing over. + pub fn selected( + &self, + declared: Option<&str>, + emulation_required: bool, + ) -> Result<(&str, &KernelProfile), KernelManifestError> { + // A test that names a profile means it, so it outranks the + // environment. Same rule as a pinned `RequestedBackend`, and for + // the same reason: `N_VM_PROFILE` exists to point tests that have + // *no* opinion at a different environment, and a test that declares + // one is declaring what it is for -- a modular-kernel test asking + // for `flatcar` is not asking to be swept along with the rest. + let from_env = std::env::var(n_vm_protocol::ENV_PROFILE).ok(); + self.selected_with(declared, from_env.as_deref(), emulation_required) + } + + /// [`selected`](Self::selected) with the environment passed in. + /// + /// Separated so the precedence can be tested without mutating the + /// process environment, which is global and would race every other test + /// in the binary. + /// + /// # Errors + /// + /// As [`selected`](Self::selected). + pub(crate) fn selected_with( + &self, + declared: Option<&str>, + from_env: Option<&str>, + emulation_required: bool, + ) -> Result<(&str, &KernelProfile), KernelManifestError> { + let named = declared + .filter(|name| !name.is_empty()) + .or(from_env.filter(|name| !name.is_empty())) + .map(str::to_owned); + + match named { + Some(name) => { + let profile = self.profile(&name)?; + let key = self + .profiles + .get_key_value(&name) + .map(|(k, _)| k.as_str()) + .expect("profile() succeeded, so the key is present"); + Ok((key, profile)) + } + None if emulation_required => Ok(self.default_emulating_profile()), + None => self.default_profile(), + } + } + + /// The profile to use by default when the guest must be *emulated*. + /// + /// Falls back to [`default_profile`](Self::default_profile) when that + /// profile's hypervisor can emulate, or when no profile can. + /// + /// This exists because the manifest's `default` is a fact about the nix + /// build, not about the machine the tests run on, and a cross-arch run + /// is the case where those diverge: `default` is `cloud_hypervisor`, + /// which cannot emulate a foreign guest at all. Left alone, every + /// unpinned test skipped -- and a skip is reported as a pass, so an + /// aarch64 run went green in 1.5s having executed 2 of 19 in_vm tests. + /// A harness that cannot run the guest must not look like one that did. + /// + /// Only for the *unset* case. An explicit `N_VM_PROFILE` naming a + /// profile that cannot emulate is honoured and skips, because "run this + /// environment" is a request worth failing to satisfy visibly rather + /// than silently substituting another. + /// + /// Among the profiles that can emulate, prefers one whose `boot` matches + /// the default's, so the substitute differs from the default in its + /// hypervisor and nothing else; ties break on name order, which + /// [`BTreeMap`] makes deterministic. + fn default_emulating_profile(&self) -> (&str, &KernelProfile) { + let default = self.default_profile(); + let Ok((default_name, default_profile)) = default else { + // `parse` rejects a `default` naming a missing profile, so this + // is a hand-built manifest; let the caller's own lookup report + // it rather than guessing here. + return self + .profiles + .iter() + .next() + .map(|(k, v)| (k.as_str(), v)) + .unwrap_or_else(|| unreachable!("a manifest with no profiles cannot parse")); + }; + + let can_emulate = |p: &KernelProfile, name: &str| { + p.backend(name).is_ok_and(EffectiveBackend::can_emulate) + }; + if can_emulate(default_profile, default_name) { + return (default_name, default_profile); + } + + let candidates = || { + self.profiles + .iter() + .map(|(k, v)| (k.as_str(), v)) + .filter(|(name, p)| can_emulate(p, name)) + }; + candidates() + .find(|(_, p)| p.boot == default_profile.boot) + .or_else(|| candidates().next()) + .unwrap_or((default_name, default_profile)) + } + + /// The default profile and its name. + /// + /// # Errors + /// + /// Returns [`KernelManifestError::UnknownProfile`] if `default` names a + /// profile that is not present. [`parse`](Self::parse) rejects that up + /// front, so this can only fire on a hand-built manifest. + pub fn default_profile(&self) -> Result<(&str, &KernelProfile), KernelManifestError> { + let profile = self.profile(&self.default)?; + Ok((&self.default, profile)) + } +} + +/// Errors reading or interpreting the kernel manifest. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum KernelManifestError { + /// The manifest file could not be read. + #[error("cannot read kernel manifest at {path:?}")] + #[diagnostic( + code(n_vm::kernel_manifest_read), + help( + "the manifest is materialized by nix into `testroot` -- run \ + `just setup-roots` from the workspace root, and re-run it after \ + changing anything about the guest kernels" + ) + )] + Read { + /// The path that could not be read. + path: PathBuf, + /// The underlying I/O error. + source: std::io::Error, + }, + + /// The manifest is not valid JSON, or does not match the expected shape. + #[error("cannot parse kernel manifest at {path:?}")] + #[diagnostic( + code(n_vm::kernel_manifest_parse), + help( + "the manifest is generated by the nix build; a parse failure means \ + the generator and this reader have drifted apart" + ) + )] + Parse { + /// The path that failed to parse. + path: PathBuf, + /// The underlying deserialization error. + source: serde_json::Error, + }, + + /// A profile was requested that the manifest does not declare. + #[error("no kernel profile named `{name}` (available: {})", available.join(", "))] + #[diagnostic( + code(n_vm::kernel_manifest_unknown_profile), + help("add the profile to the nix build, then re-run `just setup-roots`") + )] + UnknownProfile { + /// The profile that was requested. + name: String, + /// The profiles that do exist. + available: Vec, + }, + + /// The profile's kernel is built for a different architecture than the + /// test binary. + #[error("kernel profile `{profile}` is for {manifest_arch}, but the guest is {guest_arch}")] + #[diagnostic( + code(n_vm::kernel_manifest_arch_mismatch), + help( + "a kernel image only boots its own architecture; rebuild the roots \ + for this target (`just platform= setup-roots`)" + ) + )] + ArchMismatch { + /// The offending profile's name. + profile: String, + /// The architecture the manifest claims. + manifest_arch: String, + /// The architecture the test binary targets. + guest_arch: &'static str, + }, + + /// The manifest names a hypervisor this build has no backend for. + #[error("kernel profile `{profile}` names unknown hypervisor `{hypervisor}`")] + #[diagnostic( + code(n_vm::kernel_manifest_unknown_hypervisor), + help("valid hypervisors are `cloud_hypervisor` and `qemu`") + )] + UnknownHypervisor { + /// The offending profile's name. + profile: String, + /// The hypervisor the manifest claimed. + hypervisor: String, + }, +} + +#[cfg(test)] +mod test { + use super::*; + + const SAMPLE: &str = r#"{ + "default": "union", + "profiles": { + "union": { + "arch": "x86_64", + "hypervisor": "cloud_hypervisor", + "boot": "direct", + "kernel": "/kernels/union/vmlinuz" + } + } + }"#; + + fn parse(raw: &str) -> Result { + KernelManifest::parse(raw, Path::new("")) + } + + /// The shape nix writes for a cross build: the default cannot emulate, + /// and two profiles that can. + const CROSS: &str = r#"{ + "default": "cloud_hypervisor", + "profiles": { + "cloud_hypervisor": { + "arch": "aarch64", + "hypervisor": "cloud_hypervisor", + "boot": "direct", + "kernel": "/kernels/union/vmlinuz" + }, + "modular": { + "arch": "aarch64", + "hypervisor": "qemu", + "boot": "initramfs", + "kernel": "/kernels/modular/vmlinuz" + }, + "qemu": { + "arch": "aarch64", + "hypervisor": "qemu", + "boot": "direct", + "kernel": "/kernels/union/vmlinuz" + } + } + }"#; + + /// An emulated guest must not default to a hypervisor that cannot + /// emulate it. + /// + /// Every unpinned test then skips, and a skip counts as a pass, so the + /// run goes green having executed almost nothing -- the failure this + /// whole fallback exists to prevent. Asserted on the private resolver + /// rather than through `selected`, which reads the environment and would + /// make the test depend on the ambient `N_VM_PROFILE`. + #[test] + fn an_emulated_guest_does_not_default_to_a_non_emulating_hypervisor() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, profile) = manifest.default_emulating_profile(); + assert_ne!(name, "cloud_hypervisor", "cannot emulate a foreign guest"); + assert!( + profile + .backend(name) + .expect("known hypervisor") + .can_emulate(), + "substituted profile `{name}` must be able to emulate", + ); + } + + // -- Which profile a run gets ------------------------------------- + + /// A test that names a profile gets it, even under a sweep that named + /// something else. + /// + /// The point of the lever: a test declares which kernel it is *for*. + /// A modular-kernel test swept onto a built-in-only kernel by an + /// environment variable would not be testing anything, and would say so + /// only by failing somewhere unrelated. + #[test] + fn a_declared_profile_outranks_the_environment() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest + .selected_with(Some("modular"), Some("qemu"), false) + .expect("declared profile resolves"); + assert_eq!(name, "modular"); + } + + /// With nothing declared, the environment still points a whole run at + /// another environment -- which is what it was for. + #[test] + fn the_environment_still_steers_a_test_with_no_opinion() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest + .selected_with(None, Some("qemu"), false) + .expect("environment profile resolves"); + assert_eq!(name, "qemu"); + } + + /// With neither, the manifest's default. + #[test] + fn nothing_declared_and_nothing_set_is_the_default() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest + .selected_with(None, None, false) + .expect("default resolves"); + assert_eq!(name, "cloud_hypervisor"); + } + + /// An empty value is not a choice, from either source. + #[test] + fn an_empty_name_falls_through() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest + .selected_with(Some(""), Some(""), false) + .expect("falls through to the default"); + assert_eq!(name, "cloud_hypervisor"); + } + + /// A declared profile that does not exist is an error naming the ones + /// that do -- never a silent fallback to the default, which would run + /// the wrong kernel while appearing to run the requested one. + #[test] + fn a_declared_profile_that_does_not_exist_is_an_error() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let err = manifest + .selected_with(Some("flatcra"), None, false) + .expect_err("a typo must not fall back"); + assert!( + matches!(err, KernelManifestError::UnknownProfile { .. }), + "expected UnknownProfile, got {err:?}", + ); + } + + /// A declared profile is honoured even when the guest must be emulated + /// and it cannot emulate: the caller then skips with its own reason. + /// Substituting silently is only right for a run with no opinion. + #[test] + fn a_declared_profile_is_not_substituted_when_emulating() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest + .selected_with(Some("cloud_hypervisor"), None, true) + .expect("declared profile resolves"); + assert_eq!(name, "cloud_hypervisor"); + } + + /// The substitute differs from the default in its hypervisor and nothing + /// else, so switching to it does not quietly also switch boot path. + #[test] + fn the_substituted_profile_keeps_the_defaults_boot_mode() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest.default_emulating_profile(); + assert_eq!( + name, "qemu", + "`qemu` boots directly like the default; `modular` would also \ + change the boot path", + ); + } + + /// The fallback applies only on the emulation path: a native run still + /// gets the manifest's own default, even when it cannot emulate. + /// + /// Asserted via `default_profile` rather than `selected(false)` because + /// `selected` consults `N_VM_PROFILE`, which would make the result depend + /// on the environment the suite happens to run under -- as it did, when + /// this test was first written that way and failed under + /// `N_VM_PROFILE=qemu`. + #[test] + fn a_native_guest_keeps_the_manifest_default() { + let manifest = parse(CROSS).expect("cross manifest should parse"); + let (name, _) = manifest + .default_profile() + .expect("default must resolve for a native guest"); + assert_eq!( + name, "cloud_hypervisor", + "a native guest runs the default even though it cannot emulate", + ); + } + + /// With nothing able to emulate, the default is returned unchanged so + /// the caller skips with its own specific reason rather than this code + /// inventing a profile that cannot work either. + #[test] + fn no_emulating_profile_leaves_the_default_alone() { + let manifest = parse(SAMPLE).expect("sample manifest should parse"); + let (name, _) = manifest.default_emulating_profile(); + assert_eq!(name, "union"); + } + + #[test] + fn parses_a_minimal_manifest() { + let manifest = parse(SAMPLE).expect("sample manifest should parse"); + let (name, profile) = manifest.default_profile().expect("default must resolve"); + assert_eq!(name, "union"); + assert_eq!(profile.kernel, "/kernels/union/vmlinuz"); + assert_eq!(profile.boot, BootMode::Direct); + } + + /// Optional artifacts default to absent rather than failing to parse, + /// so a direct-boot profile need not spell out fields that only apply + /// to modular kernels. + #[test] + fn optional_artifacts_default_to_absent() { + let manifest = parse(SAMPLE).expect("sample manifest should parse"); + let profile = manifest.profile("union").expect("profile exists"); + assert_eq!(profile.config, None); + assert_eq!(profile.initramfs, None); + assert_eq!(profile.modules, None); + } + + #[test] + fn reads_an_initramfs_profile() { + let raw = r#"{ + "default": "flatcar", + "profiles": { + "flatcar": { + "arch": "x86_64", + "hypervisor": "qemu", + "boot": "initramfs", + "kernel": "/kernels/flatcar/vmlinuz", + "config": "/kernels/flatcar/config", + "initramfs": "/kernels/flatcar/initramfs.cpio.gz", + "modules": "/kernels/flatcar/modules/6.12.95-flatcar" + } + } + }"#; + let manifest = parse(raw).expect("initramfs manifest should parse"); + let profile = manifest.profile("flatcar").expect("profile exists"); + assert_eq!(profile.boot, BootMode::Initramfs); + assert_eq!( + profile.modules.as_deref(), + Some("/kernels/flatcar/modules/6.12.95-flatcar") + ); + } + + /// A `default` pointing at a missing profile is the manifest's bug, so + /// it is rejected at parse time rather than at launch, where the error + /// would appear to blame the test. + #[test] + fn rejects_default_naming_a_missing_profile() { + let raw = r#"{ + "default": "nope", + "profiles": { + "union": { "arch": "x86_64", "hypervisor": "qemu", + "kernel": "/kernels/union/vmlinuz" } + } + }"#; + let err = parse(raw).expect_err("dangling default must be rejected"); + assert!( + matches!(&err, KernelManifestError::UnknownProfile { name, .. } if name == "nope"), + "expected UnknownProfile, got {err:?}", + ); + } + + /// The error names what *is* available, because the common cause is a + /// typo or a stale `testroot`, and both are diagnosed by seeing the list. + #[test] + fn unknown_profile_error_lists_the_alternatives() { + let manifest = parse(SAMPLE).expect("sample manifest should parse"); + let err = manifest + .profile("flatcar") + .expect_err("missing profile must be rejected"); + assert!( + format!("{err}").contains("union"), + "error should list available profiles, got: {err}", + ); + } + + // -- Multiple profiles -------------------------------------------- + + /// The shape the nix build actually emits: several profiles sharing one + /// kernel and differing only in hypervisor. That is the axis the + /// integration suite currently sweeps by hand. + const TWO_HYPERVISORS: &str = r#"{ + "default": "cloud_hypervisor", + "profiles": { + "cloud_hypervisor": { + "arch": "x86_64", "hypervisor": "cloud_hypervisor", "boot": "direct", + "kernel": "/kernels/union/vmlinuz", "config": "/kernels/union/config" + }, + "qemu": { + "arch": "x86_64", "hypervisor": "qemu", "boot": "direct", + "kernel": "/kernels/union/vmlinuz", "config": "/kernels/union/config" + } + } + }"#; + + #[test] + fn profiles_may_share_a_kernel_and_differ_only_in_hypervisor() { + let manifest = parse(TWO_HYPERVISORS).expect("two-profile manifest should parse"); + assert_eq!(manifest.profile_names(), vec!["cloud_hypervisor", "qemu"]); + + let chv = manifest.profile("cloud_hypervisor").expect("exists"); + let qemu = manifest.profile("qemu").expect("exists"); + assert_eq!( + chv.kernel, qemu.kernel, + "both profiles should reference the same kernel image", + ); + assert_eq!( + chv.backend("cloud_hypervisor").expect("known hypervisor"), + EffectiveBackend::CloudHypervisor, + ); + assert_eq!( + qemu.backend("qemu").expect("known hypervisor"), + EffectiveBackend::Qemu, + ); + } + + #[test] + fn default_selects_one_of_several_profiles() { + let manifest = parse(TWO_HYPERVISORS).expect("two-profile manifest should parse"); + let (name, _) = manifest.default_profile().expect("default must resolve"); + assert_eq!(name, "cloud_hypervisor"); + } + + /// Defaulting an unrecognised hypervisor would run the test somewhere + /// other than where the profile said, which is worse than not running it. + #[test] + fn unknown_hypervisor_is_rejected_rather_than_defaulted() { + let raw = r#"{ + "default": "weird", + "profiles": { + "weird": { + "arch": "x86_64", "hypervisor": "firecracker", + "kernel": "/kernels/union/vmlinuz" + } + } + }"#; + let manifest = parse(raw).expect("manifest itself is well-formed"); + let err = manifest + .profile("weird") + .expect("exists") + .backend("weird") + .expect_err("unknown hypervisor must be rejected"); + assert!( + matches!(err, KernelManifestError::UnknownHypervisor { .. }), + "expected UnknownHypervisor, got {err:?}", + ); + } + + #[test] + fn arch_mismatch_is_rejected() { + let manifest = parse(SAMPLE).expect("sample manifest should parse"); + let profile = manifest.profile("union").expect("profile exists"); + profile + .check_arch("union", Arch::X86_64) + .expect("matching arch must be accepted"); + let err = profile + .check_arch("union", Arch::Aarch64) + .expect_err("mismatched arch must be rejected"); + assert!( + matches!(err, KernelManifestError::ArchMismatch { .. }), + "expected ArchMismatch, got {err:?}", + ); + } +} diff --git a/n-vm/src/lib.rs b/n-vm/src/lib.rs new file mode 100644 index 0000000000..019e9d2787 --- /dev/null +++ b/n-vm/src/lib.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +//! Runtime support for `#[n_vm::test]` tests. +//! +//! The host tier starts a Docker container, the container tier boots a VM +//! through a [`HypervisorBackend`], and the guest tier runs under `n-it`. +//! This crate also re-exports the macro attributes and protocol constants +//! used by generated code. + +pub mod backend; +pub mod cloud_hypervisor; +pub mod config; +pub mod dispatch; +pub mod error; +pub mod kernel_config; +pub mod kernel_feature; +pub mod kernel_manifest; +pub mod qemu; + +pub mod abort_on_drop; +mod container; +mod test_identity; +mod vm; + +pub use abort_on_drop::AbortOnDrop; +pub use backend::{ + BackendResolution, EffectiveBackend, HypervisorBackend, HypervisorVerdict, LaunchedHypervisor, + RequestedBackend, is_cross_arch, +}; +/// Re-exported so `#[n_vm::test]` can answer fuzz-target discovery. +/// +/// `cargo bolero list` runs the test binary with `CARGO_BOLERO_SELECT=all` and reads a line that +/// each `bolero::check!` prints *when it executes*. A tiered test never executes its body on the +/// host -- that is the whole contract -- so an in-VM fuzz target was invisible to the +/// coverage-guided runner and could not be named to `cargo bolero test`. +/// +/// The generated harness therefore constructs bolero's own [`bolero::TargetLocation`] and asks it, +/// before any tier dispatch. Bolero prints, and does the printing itself so the wire format cannot +/// drift from a copy here. +pub use bolero; +pub use cloud_hypervisor::CloudHypervisor; +pub use config::{ + Accel, ConfigProblem, CorpusPolicy, FabricNics, GuestHugePageConfig, GuestHugePageSize, + GuestRuntime, HostPageSize, ModuleParam, NicModel, VmConfig, VmConfigBuilder, +}; +pub use container::{ContainerOutcome, ContainerTestResult, run_test_in_vm}; +pub use dispatch::{ + block_on_in_guest_with, is_in_test_container, is_in_vm, run_container_tier, run_host_tier, +}; +pub use error::{ContainerError, VmError}; +pub use kernel_feature::{KernelFeature, features, kernel_profiles}; +pub use n_vm_macros::{config, corpus, test}; +pub use n_vm_protocol::{ + CLOUD_HYPERVISOR_BINARY_PATH, CONTAINER_PLATFORM, ENV_IN_TEST_CONTAINER, ENV_IN_VM, + ENV_MARKER_VALUE, ENV_TEST_ROOT, ENV_VM_ROOT, HYPERVISOR_API_SOCKET_PATH, INIT_BINARY_PATH, + KERNEL_CONSOLE_SOCKET_PATH, ScratchRootError, ScratchRoots, VHOST_VSOCK_SOCKET_PATH, + VIRTIOFS_ROOT_TAG, VIRTIOFSD_BINARY_PATH, VIRTIOFSD_SOCKET_PATH, VM_GUEST_CID, + VM_ROOT_SHARE_PATH, VM_RUN_DIR, VM_TEST_BIN_DIR, VsockAllocation, VsockChannel, VsockCid, + VsockPort, +}; +pub use qemu::Qemu; +pub use vm::{ProcessOutput, TestVm, TestVmParams, VmTestOutput, run_in_vm}; diff --git a/n-vm/src/qemu/error.rs b/n-vm/src/qemu/error.rs new file mode 100644 index 0000000000..85d7404844 --- /dev/null +++ b/n-vm/src/qemu/error.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Error types specific to the QEMU backend. +//! +//! These errors cover failure modes unique to QEMU's architecture: +//! +//! - **QMP protocol** -- QEMU uses the QEMU Machine Protocol (QMP), a +//! JSON-based protocol over a Unix socket, for lifecycle control and +//! event monitoring. Connecting to the socket, receiving the initial +//! greeting, negotiating capabilities, and issuing commands are all +//! QEMU-specific operations that can fail independently. +//! - **Process spawning** -- QEMU boots the VM immediately on process +//! start (unlike cloud-hypervisor which separates VMM startup from VM +//! boot), so there is no separate "create VM" or "boot VM" step. +//! However, QEMU's command-line argument assembly can fail if the +//! configuration is invalid. +//! +//! Generic errors that apply to any hypervisor backend (e.g. KVM +//! accessibility, socket polling, vsock listener binding) remain in +//! [`VmError`](crate::error::VmError). + +/// Errors specific to the QEMU [`HypervisorBackend`](crate::backend::HypervisorBackend) +/// implementation. +/// +/// These are wrapped into [`VmError::Backend`](crate::error::VmError::Backend) +/// by the [`Qemu`](super::Qemu) launch and shutdown sequences, preserving +/// the full error chain for diagnostics while keeping the generic +/// [`VmError`](crate::error::VmError) enum free of QEMU-specific variants. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum QemuError { + /// Failed to connect to the QMP Unix socket. + /// + /// After QEMU starts, it creates a QMP control socket at the path + /// specified by `-chardev socket,path=...`. The container tier + /// connects to this socket to issue lifecycle commands and receive + /// async events. This error means the connection attempt failed + /// after the socket appeared on the filesystem. + #[error("failed to connect to QMP socket")] + #[diagnostic( + code(n_vm::qemu::qmp_connect), + help( + "QEMU may have exited before creating the QMP socket -- \ + check the hypervisor stderr for early startup failures" + ) + )] + QmpConnect(#[source] std::io::Error), + + /// The QMP greeting was not received or could not be parsed. + /// + /// Upon connection, QEMU sends a JSON greeting message that is + /// deserialized as [`qapi_qmp::QapiCapabilities`]. This error + /// indicates the greeting was absent, malformed, or could not be + /// deserialized into that type. + #[error("QMP greeting not received or malformed: {reason}")] + #[diagnostic( + code(n_vm::qemu::qmp_greeting), + help( + "QEMU should send a QapiCapabilities JSON greeting immediately \ + on connection -- a malformed or missing greeting usually means \ + the QEMU version is incompatible or the socket is not a QMP socket" + ) + )] + QmpGreeting { + /// Description of what went wrong with the greeting. + reason: String, + }, + + /// QMP capabilities negotiation failed. + /// + /// After receiving the greeting, the client must send + /// `{"execute": "qmp_capabilities"}` to enter command mode. This + /// error indicates QEMU rejected the negotiation request. + #[error("QMP capabilities negotiation failed: {reason}")] + #[diagnostic( + code(n_vm::qemu::qmp_negotiate), + help( + "the qmp_capabilities handshake was rejected -- this can \ + indicate a QEMU version mismatch or a protocol error" + ) + )] + QmpNegotiate { + /// The error message or description from the QMP response. + reason: String, + }, + + /// A QMP command failed. + /// + /// This covers any command sent after successful negotiation (e.g. + /// `query-status`, `system_powerdown`, `quit`) that QEMU rejected + /// with an error response. + #[error("QMP command `{command}` failed: {reason}")] + #[diagnostic( + code(n_vm::qemu::qmp_command), + help( + "a QMP command was rejected by QEMU -- check the `reason` \ + field for details; common causes include invalid arguments \ + or issuing commands in an unexpected VM state" + ) + )] + QmpCommand { + /// The QMP command that was sent (e.g. `"system_powerdown"`). + command: String, + /// The error message from QEMU's response. + reason: String, + }, + + /// An I/O error occurred while communicating over the QMP socket. + /// + /// This covers read/write failures on the QMP Unix stream after a + /// successful connection, such as unexpected disconnection or pipe + /// errors mid-conversation. + #[error("QMP I/O error")] + #[diagnostic( + code(n_vm::qemu::qmp_io), + help( + "the QMP socket connection was lost mid-conversation -- \ + QEMU may have crashed or been killed externally" + ) + )] + QmpIo(#[source] std::io::Error), + + /// A QMP response could not be deserialized from JSON. + /// + /// QEMU sends responses and async events as newline-delimited JSON. + /// This error indicates a response was received but could not be + /// parsed into the expected structure. + #[error("failed to deserialize QMP response")] + #[diagnostic( + code(n_vm::qemu::qmp_deserialize), + help( + "a QMP JSON response could not be parsed -- this may indicate \ + a QEMU version mismatch or an unexpected async event format" + ) + )] + QmpDeserialize(#[source] serde_json::Error), + + /// Host-side TAP configuration failed. + /// + /// After QEMU creates TAP devices via `-netdev tap`, the QEMU backend + /// uses rtnetlink to bring them UP and assign IPv6 link-local + /// addresses. This error indicates one of those netlink operations + /// failed. + #[error("failed to configure host TAP `{tap}`: {reason}")] + #[diagnostic( + code(n_vm::qemu::tap_setup), + help( + "QEMU creates TAPs with `-netdev tap,script=no`, leaving them \ + DOWN and address-less. The QEMU backend configures them via \ + rtnetlink after QEMU starts. Check that the container has \ + NET_ADMIN capability and that the TAP devices exist." + ) + )] + TapSetup { + /// The TAP device name that could not be configured. + tap: String, + /// Description of what went wrong. + reason: String, + }, +} diff --git a/n-vm/src/qemu/mod.rs b/n-vm/src/qemu/mod.rs new file mode 100644 index 0000000000..69c500cc96 --- /dev/null +++ b/n-vm/src/qemu/mod.rs @@ -0,0 +1,1937 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! QEMU [`HypervisorBackend`] implementation. +//! +//! This module encapsulates all +//! [QEMU](https://www.qemu.org/)-specific concerns: +//! +//! - **VM configuration** -- translating [`TestVmParams`] into QEMU +//! command-line arguments via focused sub-builders. +//! - **Process spawning** -- launching `qemu-system-x86_64` with the +//! assembled arguments; QEMU boots the VM immediately on process start +//! (unlike cloud-hypervisor, which separates VMM startup from VM boot). +//! - **Lifecycle control** -- connecting to the QMP (QEMU Machine +//! Protocol) socket for shutdown commands. +//! - **Event monitoring** -- consuming async QMP events (`SHUTDOWN`, +//! `GUEST_PANICKED`, etc.) and producing a [`HypervisorVerdict`]. +//! +//! Nothing in this module is used by the generic [`TestVm`](crate::vm::TestVm) +//! machinery except through the [`HypervisorBackend`] trait. +//! +//! # Architecture differences from cloud-hypervisor +//! +//! | Concern | cloud-hypervisor | QEMU | +//! |------------|------------------------------------|---------------------------------| +//! | Boot model | `create_vm` + `boot_vm` REST | Boots on process start | +//! | Control | REST API over Unix socket | QMP (JSON-RPC) over Unix socket | +//! | Events | `--event-monitor fd=N` pipe | QMP async events | +//! | Shutdown | `shutdown_vm()` + `shutdown_vmm()` | `system_powerdown` + `quit` | +//! | Config | JSON `VmConfig` body | Command-line arguments | +//! +//! # vsock bridging +//! +//! Cloud-hypervisor has a built-in vhost-user-vsock implementation that +//! transparently maps guest vsock connections to host-side Unix sockets +//! at `$VHOST_SOCKET_$PORT`. Its +//! [`spawn_vsock_reader`](crate::backend::HypervisorBackend::spawn_vsock_reader) +//! implementation binds [`UnixListener`](tokio::net::UnixListener)s at +//! those paths. +//! +//! QEMU's `vhost-vsock-pci` device uses the kernel's vhost-vsock module +//! instead, which surfaces guest connections as `AF_VSOCK` sockets on +//! the host. This backend's +//! [`spawn_vsock_reader`](Qemu::spawn_vsock_reader) implementation uses +//! [`tokio_vsock::VsockListener`] bound to `VMADDR_CID_ANY` on the +//! channel's port, so the kernel routes guest vsock connections directly +//! to the listener without any intermediate Unix socket mapping. +//! +//! The `qmp` submodule contains the QMP protocol client and wire types. + +pub mod error; +pub(crate) mod qmp; + +pub use self::error::QemuError; + +use std::process::Stdio; +use std::sync::Arc; + +use n_vm_protocol::{ + HYPERVISOR_API_SOCKET_PATH, KERNEL_CONSOLE_SOCKET_PATH, VIRTIOFS_ROOT_TAG, + VIRTIOFSD_SOCKET_PATH, VsockAllocation, VsockChannel, +}; +use tracing::{debug, error, warn}; + +use crate::abort_on_drop::AbortOnDrop; +use crate::backend::{HypervisorBackend, HypervisorVerdict, LaunchedHypervisor}; +use crate::config; +use crate::error::VmError; +use crate::vm::{TestVmParams, check_hugepages_accessible, check_kvm_accessible, wait_for_socket}; + +use self::qmp::{EventDisplay, QmpCommandName, QmpConnection, QmpEventStream, QmpWriter}; + +// -- Public types ----------------------------------------------------- + +/// QEMU [`HypervisorBackend`] implementation. +/// +/// Launches a `qemu-system-x86_64` process that boots the VM immediately, +/// monitors lifecycle events through the QMP socket, and performs shutdown +/// via QMP commands. +#[derive(Debug)] +pub struct Qemu; + +/// Lifecycle controller for a running QEMU instance. +/// +/// Wraps a `QmpWriter` behind a mutex for interior mutability, since +/// the [`HypervisorBackend::shutdown`] method takes `&Self::Controller`. +pub struct QemuController { + writer: Arc>, +} + +/// Collected QMP event log from a QEMU VM's lifetime. +/// +/// This newtype wraps the raw event vector so that the generic +/// [`VmTestOutput`](crate::vm::VmTestOutput) can store and display +/// backend-specific event data through the [`Display`](std::fmt::Display) +/// bound on [`HypervisorBackend::EventLog`]. +/// +/// The [`Display`](std::fmt::Display) implementation produces one line per +/// event in a human-readable format suitable for test failure diagnostics. +#[derive(Debug, Default)] +pub struct QemuEventLog(pub Vec); + +impl std::fmt::Display for QemuEventLog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for event in &self.0 { + let ts = event.timestamp(); + write!(f, "[{ts:?}] ")?; + writeln!(f, "{}", EventDisplay(event))?; + } + Ok(()) + } +} + +// -- Error conversion ------------------------------------------------- + +impl From for VmError { + fn from(err: QemuError) -> Self { + VmError::Backend(Box::new(err)) + } +} + +// -- HypervisorBackend ------------------------------------------------ + +impl HypervisorBackend for Qemu { + const NAME: &str = "qemu"; + const CAN_EMULATE: bool = true; + + type EventLog = QemuEventLog; + type Controller = QemuController; + + async fn launch(params: &TestVmParams<'_>) -> Result, VmError> { + let (child, qmp_conn) = spawn_qemu_process(params).await?; + + // QEMU's `-netdev tap,script=no` creates the TAPs but leaves them + // DOWN with no addresses. Bring them UP and assign IPv6 link-local + // addresses so that NDP traffic flows and rx tests have something + // to receive. + configure_host_taps(¶ms.vm_config.all_ifaces()).await?; + + let (writer, event_stream) = qmp_conn.into_split(); + + let event_watcher = AbortOnDrop::spawn(async { + let (events, verdict) = watch_events(event_stream).await; + (QemuEventLog(events), verdict) + }); + + Ok(LaunchedHypervisor { + child, + event_watcher, + controller: QemuController { + writer: Arc::new(tokio::sync::Mutex::new(writer)), + }, + }) + } + + async fn shutdown(controller: &Self::Controller) { + // In the normal path the VM has already powered off (n-it calls + // reboot(RB_POWER_OFF) or aborts), and QEMU is paused due to + // -no-shutdown. These commands break that pause and exit QEMU. + // + // If the guest init hangs, `system_powerdown` sends an ACPI power + // button event, and `quit` forcefully terminates the VMM. + let mut writer = controller.writer.lock().await; + writer + .send_command_fire_and_forget(QmpCommandName::SystemPowerdown) + .await; + writer + .send_command_fire_and_forget(QmpCommandName::Quit) + .await; + } + + fn spawn_vsock_reader(channel: &VsockChannel) -> Result, VmError> { + let port = channel.port.as_raw(); + let label = channel.label; + + // Bind an AF_VSOCK listener on VMADDR_CID_ANY so the kernel's + // vhost-vsock module will route guest connections to us. + let addr = tokio_vsock::VsockAddr::new(tokio_vsock::VMADDR_CID_ANY, port); + let listener = + tokio_vsock::VsockListener::bind(addr).map_err(|source| VmError::VsockBind { + label, + path: format!("vsock://any:{port}").into(), + source, + })?; + + Ok(AbortOnDrop::spawn(async move { + let connection = match listener.accept().await { + Ok((stream, _)) => stream, + Err(e) => { + error!("failed to accept {label} vsock connection: {e}"); + return format!( + "!!!{} UNAVAILABLE: accept failed: {e}!!!", + label.to_uppercase() + ); + } + }; + config::read_vsock_stream(connection, label).await + })) + } +} + +// -- Event monitoring ------------------------------------------------- + +/// Consumes the QMP event stream and returns the collected events along +/// with a [`HypervisorVerdict`]. +/// +/// Event collection terminates when: +/// - A `SHUTDOWN` event is received (normal completion). +/// - A `GUEST_PANICKED` event is received (remaining events are drained +/// for up to [`POST_PANIC_DRAIN_TIMEOUT`]). +/// - The stream ends (socket closed / QEMU exited). +/// +/// The verdict is computed by [`compute_verdict`] from the collected +/// events and a flag tracking whether any stream-level errors occurred. +async fn watch_events(mut stream: QmpEventStream) -> (Vec, HypervisorVerdict) { + /// Bail out after this many consecutive stream errors. + /// + /// A persistent error stream (e.g. a QEMU whose event schema + /// `qapi_qmp` cannot deserialize) would otherwise keep the watcher + /// alive -- and the verdict unresolved -- until the socket closes. + const MAX_CONSECUTIVE_ERRORS: u32 = 32; + + let mut log = Vec::with_capacity(16); + let mut had_errors = false; + let mut consecutive_errors = 0u32; + + loop { + match stream.next_event().await { + Ok(Some(event)) => { + consecutive_errors = 0; + let is_shutdown = matches!(event, qapi_qmp::Event::SHUTDOWN { .. }); + let is_panic = matches!(event, qapi_qmp::Event::GUEST_PANICKED { .. }); + log.push(event); + + if is_shutdown || is_panic { + if is_panic { + drain_after_panic(&mut stream, &mut log).await; + } + break; + } + } + Ok(None) => { + // Stream closed -- QEMU exited. + break; + } + Err(err) => { + warn!("QMP event stream error (marking as failure): {err:#?}"); + had_errors = true; + consecutive_errors += 1; + if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { + warn!( + "{MAX_CONSECUTIVE_ERRORS} consecutive QMP event stream errors; \ + giving up on the event stream", + ); + break; + } + } + } + } + + let verdict = compute_verdict(&log, had_errors); + (log, verdict) +} + +/// Drains remaining QMP events for up to [`POST_PANIC_DRAIN_TIMEOUT`] +/// after a guest panic, appending them to `log`. +/// +/// This gives QEMU time to emit subsequent events (e.g. `SHUTDOWN`) that +/// aid diagnosis. +async fn drain_after_panic(stream: &mut QmpEventStream, log: &mut Vec) { + let deadline = tokio::time::sleep(config::POST_PANIC_DRAIN_TIMEOUT); + tokio::pin!(deadline); + loop { + tokio::select! { + result = stream.next_event() => { + match result { + Ok(Some(event)) => log.push(event), + Ok(None) => break, + Err(err) => { + warn!("QMP event error during post-panic drain: {err:#?}"); + } + } + } + () = &mut deadline => break, + } + } +} + +/// Computes the [`HypervisorVerdict`] from collected QMP events and a +/// flag indicating whether any stream-level errors occurred. +/// +/// This is a **pure function** extracted from `watch_events` so that +/// verdict logic can be unit-tested with hand-crafted event sequences +/// without needing a socket or tokio runtime. +/// +/// The verdict is [`CleanShutdown`](HypervisorVerdict::CleanShutdown) +/// only if **all** of the following hold: +/// +/// 1. A `SHUTDOWN` event was received. +/// 2. No `GUEST_PANICKED` event preceded the shutdown in the event log. +/// 3. No stream-level errors occurred (indicated by `had_stream_errors`). +/// +/// Otherwise the verdict is [`Failure`](HypervisorVerdict::Failure). +pub fn compute_verdict(events: &[qapi_qmp::Event], had_stream_errors: bool) -> HypervisorVerdict { + let mut tainted = had_stream_errors; + + for event in events { + match event { + qapi_qmp::Event::SHUTDOWN { .. } => { + return if tainted { + HypervisorVerdict::Failure + } else { + HypervisorVerdict::CleanShutdown + }; + } + qapi_qmp::Event::GUEST_PANICKED { .. } => { + tainted = true; + } + _ => {} + } + } + + // Stream ended without a SHUTDOWN event. + HypervisorVerdict::Failure +} + +// -- Host-side TAP configuration --------------------------------------- + +/// Prefix length for the IPv6 link-local addresses assigned to TAPs. +const TAP_IPV6_PREFIX_LEN: u8 = config::TAP_IPV6_PREFIX_LEN; + +/// Configures host-side TAP interfaces after QEMU creates them. +/// +/// Cloud-hypervisor performs this automatically via `NetConfig.ip` / +/// `NetConfig.mask`, but QEMU's `-netdev tap` only creates the TAP +/// device -- it does not assign addresses or bring the link up. +/// +/// This function uses rtnetlink to: +/// +/// 1. Look up each TAP by name to obtain its interface index. +/// 2. Bring the link administratively UP. +/// 3. Assign the configured IPv6 link-local address with a /64 prefix. +/// +/// These addresses generate NDP traffic (Neighbor Solicitation / +/// Neighbor Advertisement) on the TAPs, which is essential for Phase 1 +/// rx validation tests -- without traffic on the host side, the DPDK +/// guest has nothing to receive. +async fn configure_host_taps(ifaces: &[config::NetIface]) -> Result<(), QemuError> { + let (connection, handle, _) = rtnetlink::new_connection().map_err(|e| QemuError::TapSetup { + tap: "".into(), + reason: format!("failed to open netlink connection: {e}"), + })?; + + // Spawn the netlink connection handler as a background task. + // It runs until all Handle clones are dropped. + tokio::spawn(connection); + + for iface in ifaces { + let tap_name = iface.tap.as_str(); + + // Look up the TAP by name to get its interface index. + let mut links = handle + .link() + .get() + .match_name(tap_name.to_string()) + .execute(); + + use futures::TryStreamExt; + let link = links.try_next().await.map_err(|e| QemuError::TapSetup { + tap: tap_name.into(), + reason: format!("failed to look up TAP device: {e}"), + })?; + + let link = link.ok_or_else(|| QemuError::TapSetup { + tap: tap_name.into(), + reason: "TAP device not found (QEMU may not have created it yet)".into(), + })?; + + let index = link.header.index; + + // Bring the TAP up. + handle + .link() + .set(rtnetlink::LinkUnspec::new_with_index(index).up().build()) + .execute() + .await + .map_err(|e| QemuError::TapSetup { + tap: tap_name.into(), + reason: format!("failed to bring TAP up: {e}"), + })?; + + // Assign the IPv6 link-local address. + handle + .address() + .add( + index, + std::net::IpAddr::V6(iface.host_ipv6), + TAP_IPV6_PREFIX_LEN, + ) + .execute() + .await + .map_err(|e| QemuError::TapSetup { + tap: tap_name.into(), + reason: format!( + "failed to add IPv6 address {}/{}: {e}", + iface.host_ipv6, TAP_IPV6_PREFIX_LEN, + ), + })?; + + debug!( + tap = tap_name, + index, + ipv6 = %iface.host_ipv6, + prefix_len = TAP_IPV6_PREFIX_LEN, + "configured host-side TAP", + ); + } + + Ok(()) +} + +// -- Process spawning ------------------------------------------------- + +/// Verifies KVM and hugepage accessibility, spawns the QEMU process, +/// waits for the QMP socket, and establishes the QMP connection. +/// +/// QEMU boots the VM immediately on process start (no separate +/// `create_vm` / `boot_vm` calls), so by the time the QMP connection is +/// established the VM is either running or has already failed to boot. +/// +/// If the QMP socket appears but the connection or negotiation fails +/// (e.g. QEMU crashes during early init), this function attempts to +/// drain the child's stderr and log it before returning the error. +/// Without this, the QEMU error output would be silently lost because +/// the `dispatch` layer panics on [`VmError`] before the normal +/// [`collect`](crate::vm::TestVm::collect) phase runs. +async fn spawn_qemu_process( + params: &TestVmParams<'_>, +) -> Result<(tokio::process::Child, QmpConnection), VmError> { + // KVM is only needed under hardware acceleration; a TCG (cross-arch) + // guest does not touch /dev/kvm. + if params.accel == config::Accel::Kvm { + check_kvm_accessible().await?; + } + check_hugepages_accessible( + params.vm_config.host_page_size, + params.vm_config.memory_bytes(), + ) + .await?; + + let args = build_qemu_args(params); + + let qemu_binary = params.arch.qemu_system_binary(); + debug!("spawning QEMU: {qemu_binary} {}", args.join(" ")); + + let mut child = tokio::process::Command::new(qemu_binary) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(VmError::HypervisorSpawn)?; + + // Wait for QEMU to create the QMP socket, then connect and negotiate. + // If either step fails, try to capture QEMU's stderr so the developer + // can see why QEMU crashed rather than just "Connection reset by peer". + let socket_result = wait_for_socket(HYPERVISOR_API_SOCKET_PATH).await; + if let Err(err) = socket_result { + config::drain_child_stderr(&mut child, "QEMU").await; + return Err(err); + } + + match QmpConnection::connect(HYPERVISOR_API_SOCKET_PATH).await { + Ok(qmp) => Ok((child, qmp)), + Err(qmp_err) => { + config::drain_child_stderr(&mut child, "QEMU").await; + Err(qmp_err.into()) + } + } +} + +// -- CLI argument builders -------------------------------------------- +// +// Each builder is a focused function responsible for a single aspect of +// the QEMU command line. They can be tested and evolved independently; +// `build_qemu_args` composes them into the final argument vector. +// +// Arguments are pushed onto a `Vec` rather than returned, so +// callers can compose multiple builders without intermediate allocation. + +/// Builds the complete QEMU argument vector for a test run. +fn build_qemu_args(params: &TestVmParams<'_>) -> Vec { + let iommu = params.vm_config.iommu; + let arch = params.arch; + let mut args = Vec::with_capacity(64); + push_machine_args(&mut args, iommu, params.accel, arch); + push_cpu_args(&mut args, arch, params.vm_config.vcpus); + push_memory_args(&mut args, ¶ms.vm_config); + push_iommu_args(&mut args, iommu, arch); + push_kernel_args(&mut args, params); + push_fs_args(&mut args, ¶ms.shares); + push_vsock_args(&mut args, ¶ms.vsock, iommu); + push_network_args(&mut args, iommu, ¶ms.vm_config.all_ifaces()); + push_serial_args(&mut args); + push_qmp_args(&mut args); + push_platform_args(&mut args, params); + push_misc_args(&mut args, arch); + args +} + +/// Machine type and acceleration. +/// +/// When `iommu` is `true`, adds `kernel-irqchip=split` to the machine +/// options. This is required for the Intel IOMMU's interrupt remapping +/// to function: in split irqchip mode the in-kernel PIC/IOAPIC is +/// disabled so that interrupt routing goes through the emulated IOMMU. +/// +/// Under [`Accel::Kvm`] the machine uses `accel=kvm` with `-enable-kvm` +/// and `-cpu host`. Under [`Accel::Tcg`] (cross-arch guest) it uses +/// `accel=tcg` with `-cpu max` and omits `-enable-kvm`. +/// +/// The machine type, IOMMU irqchip mode, and CPU model are lowered per +/// guest ISA via `arch`, which is passed in explicitly (not read from +/// `Arch::current()`) so this is testable for every ISA on any build host. +fn push_machine_args( + args: &mut Vec, + iommu: bool, + accel: config::Accel, + arch: config::Arch, +) { + let accel_opt = match accel { + config::Accel::Kvm => "accel=kvm", + config::Accel::Tcg => "accel=tcg", + }; + let mut machine = format!("{base},{accel_opt}", base = arch.qemu_machine_base()); + // The vIOMMU may require extra machine options (e.g. the x86 Intel + // IOMMU needs kernel-irqchip=split); applied only when a vIOMMU is + // requested and the ISA has a lowering for it. + let machine_opts = arch + .virtual_iommu() + .filter(|_| iommu) + .map_or("", |l| l.machine_opts); + if !machine_opts.is_empty() { + machine = format!("{machine},{machine_opts}"); + } + if accel == config::Accel::Kvm { + args.push("-enable-kvm".into()); + } + args.extend([ + "-machine".into(), + machine, + "-cpu".into(), + // `host` requires KVM; `max` is the richest TCG-emulable CPU model. + match accel { + config::Accel::Kvm => "host".into(), + config::Accel::Tcg => "max".into(), + }, + ]); +} + +/// CPU count and topology. +/// +/// Matches the cloud-hypervisor backend: both arrange the count with +/// [`SmpTopology::for_vcpus`](config::SmpTopology::for_vcpus), so the two +/// present the same machine. +fn push_cpu_args(args: &mut Vec, arch: config::Arch, vcpus: u32) { + // The `-smp dies=` level is x86-specific; `smp_topology` omits it on + // aarch64 while preserving the total vCPU count. + args.extend(["-smp".into(), arch.smp_topology(vcpus)]); +} + +/// Memory configuration with hugepage backing and sharing. +/// Shared memory backend with optional hugepage backing. +/// +/// - [`Standard`](config::HostPageSize::Standard) -- uses +/// `memory-backend-memfd` with `share=on`. No hugetlbfs mount +/// required. +/// - [`Huge2M`](config::HostPageSize::Huge2M) / +/// [`Huge1G`](config::HostPageSize::Huge1G) -- uses +/// `memory-backend-file` backed by `/dev/hugepages` with `share=on` +/// and `prealloc=on` (ensures hugepages are allocated at VM start +/// rather than on first access). +/// +/// `share=on` is always set because virtiofsd (vhost-user-fs-pci) +/// requires `MAP_SHARED` memory to access the guest address space from +/// a separate process. +/// +/// The `-numa node,memdev=mem0` argument assigns the memory backend to +/// a NUMA node, which is how QEMU associates a memory backend with the +/// guest's address space. +fn push_memory_args(args: &mut Vec, vm_config: &config::VmConfig) { + let host_page_size = vm_config.host_page_size; + let mib = vm_config.memory_mib; + // `memory-backend-memfd` with `hugetlb=on`, not `memory-backend-file` + // with `mem-path=/dev/hugepages`. + // + // `memory-backend-file` infers the page size from the mount it is + // pointed at, so it produced byte-identical arguments for Huge2M and + // Huge1G and silently allocated whatever `/dev/hugepages` happened to + // be -- 2 MiB pages on a typical host, while the 1 GiB pool sat + // untouched. A test asking for 1 GiB pages was never getting them, and + // a 1 GiB VM consumed 512 pages of the wrong pool. + // + // memfd takes the size as an option and draws from that pool directly, + // which is what cloud-hypervisor has always done, so the two backends + // now mean the same thing by `HostPageSize`. It also needs no + // hugetlbfs mount at all. + let backend = if host_page_size.requires_hugepages() { + format!( + "memory-backend-memfd,id=mem0,size={mib}M,share=on,\ + hugetlb=on,hugetlbsize={size},prealloc=on", + size = host_page_size.qemu_hugetlbsize(), + ) + } else { + format!("memory-backend-memfd,id=mem0,size={mib}M,share=on") + }; + args.extend([ + "-m".into(), + format!("{mib}M"), + "-object".into(), + backend, + "-numa".into(), + "node,memdev=mem0".into(), + ]); +} + +/// Intel IOMMU device for DMA remapping. +/// +/// When `iommu` is `true`, adds an `intel-iommu` device with: +/// +/// - **`intremap=on`** -- interrupt remapping, required for proper MSI +/// isolation between devices. +/// - **`device-iotlb=on`** -- device-side IOTLB for Address Translation +/// Services (ATS), enabling virtio devices with `ats=on` to cache +/// IOMMU translations on the device side. +/// - **`caching-mode=on`** -- required for vhost-based devices (e.g. +/// `vhost-user-fs-pci`) that perform DMA from a separate process +/// without going through QEMU's emulated IOMMU data path. +/// +/// The machine type must use `kernel-irqchip=split` (see +/// [`push_machine_args`]) for interrupt remapping to function. +/// +/// Unlike cloud-hypervisor's per-segment IOMMU model, QEMU's Intel +/// IOMMU covers the entire PCI topology. Individual virtio devices +/// opt in via `iommu_platform=on,ats=on` on their device strings (see +/// [`push_network_args`], [`push_vsock_args`]). +/// +/// The `vhost-user-fs-pci` device does **not** support +/// `iommu_platform`; vhost-user devices perform DMA from a separate +/// userspace process rather than through QEMU's emulated IOMMU data +/// path. `caching-mode=on` on the Intel IOMMU handles this case. +fn push_iommu_args(args: &mut Vec, iommu: bool, arch: config::Arch) { + // The vIOMMU device is part of the per-ISA lowering on `Arch` + // (x86 -> intel-iommu; aarch64 -> None until SMMUv3 is wired up). An + // `iommu = true` request on an ISA with no lowering is resolved to a + // skip in the host tier, so reaching here with `None` should not + // happen -- emit nothing rather than a wrong device if it does. + // The vIOMMU may be realized as a device (x86 intel-iommu) or purely + // as a machine option (aarch64 iommu=smmuv3, handled in + // `push_machine_args`); only emit a device when the lowering has one. + if let Some(device) = arch + .virtual_iommu() + .filter(|_| iommu) + .and_then(|l| l.device) + { + args.extend(["-device".into(), device.into()]); + } +} + +/// Kernel image and command line. +/// +/// The kernel command line is built by [`config::build_kernel_cmdline`], +/// shared with the cloud-hypervisor backend to ensure both backends +/// present an identical guest environment. +/// +/// When `params.vm_config.iommu` is `true`, the VFIO no-IOMMU escape +/// hatch is omitted so that VFIO is forced to use the virtual IOMMU +/// for DMA remapping -- which is the purpose of the vIOMMU test +/// configuration. +fn push_kernel_args(args: &mut Vec, params: &TestVmParams<'_>) { + let cmdline = config::build_kernel_cmdline( + ¶ms.vm_bin_path, + params.test_name, + ¶ms.vsock, + ¶ms.vm_config, + ¶ms.shares, + params.arch, + params.boot, + ); + + args.extend([ + "-kernel".into(), + params.kernel_image.clone(), + "-append".into(), + cmdline, + ]); + + // Only for a kernel that cannot reach its own root. Passing an initrd + // to a kernel that does not need one is not harmless: it would be + // unpacked into rootfs and its `/init` run in preference to the + // `root=` the direct path relies on. + if let Some(initramfs) = ¶ms.initramfs { + args.extend(["-initrd".into(), initramfs.clone()]); + } +} + +/// Virtiofs filesystem device for sharing the container filesystem. +/// +/// Uses `vhost-user-fs-pci` with a chardev pointing at the virtiofsd +/// socket, matching the cloud-hypervisor backend's filesystem +/// configuration. +/// +/// The `vhost-user-fs-pci` device does **not** support +/// `iommu_platform=on` because vhost-user devices perform DMA from a +/// separate userspace process (virtiofsd) rather than through QEMU's +/// emulated IOMMU data path. The Intel IOMMU's `caching-mode=on` +/// (set in [`push_iommu_args`]) covers this case instead. +fn push_fs_args(args: &mut Vec, shares: &[config::ActiveShare]) { + args.extend([ + "-chardev".into(), + format!("socket,id=virtiofs0,path={VIRTIOFSD_SOCKET_PATH}"), + "-device".into(), + format!( + "vhost-user-fs-pci,queue-size={qs},\ + chardev=virtiofs0,tag={VIRTIOFS_ROOT_TAG}", + qs = config::VIRTIOFS_QUEUE_SIZE, + ), + ]); + + // One further share per writable window, each served by its own + // daemon. Indices continue from the root share's `virtiofs0`, and the + // order is `WRITABLE_SHARES` order, so a given window gets the same + // chardev id on both backends and in every run. + for (index, active) in shares.iter().enumerate() { + let id = format!("virtiofs{}", index + 1); + args.extend([ + "-chardev".into(), + format!( + "socket,id={id},path={path}", + path = active.share.socket_path + ), + "-device".into(), + format!( + "vhost-user-fs-pci,queue-size={qs},chardev={id},tag={tag}", + qs = config::VIRTIOFS_QUEUE_SIZE, + tag = active.share.tag, + ), + ]); + } +} + +/// Vsock device for guest-to-host communication. +/// +/// Always uses `vhost-vsock-pci-non-transitional` (virtio 1.0+ only) +/// because the test environment targets modern kernels and there is no +/// need to exercise the legacy virtio transport path. +/// +/// When `iommu` is `true`, adds `iommu_platform=on,ats=on` so that +/// vsock I/O is routed through the virtual IOMMU. +/// +/// # Limitations +/// +/// See the [module-level documentation](self) for the vsock bridging +/// limitation: this device uses kernel vhost-vsock (AF_VSOCK on the +/// host), while the [`TestVm`](crate::vm::TestVm) infrastructure +/// expects Unix sockets at `$VHOST_SOCKET_$PORT` paths. +fn push_vsock_args(args: &mut Vec, vsock: &VsockAllocation, iommu: bool) { + let iommu_suffix = if iommu { + ",iommu_platform=on,ats=on" + } else { + "" + }; + args.extend([ + "-device".into(), + format!( + "vhost-vsock-pci-non-transitional,guest-cid={}{iommu_suffix}", + vsock.cid.as_raw() + ), + ]); +} + +/// Network interfaces. +/// +/// Creates three TAP-backed virtio-net-pci devices matching the +/// cloud-hypervisor backend: +/// +/// - **mgmt** -- management network. +/// - **fabric1** / **fabric2** -- fabric-facing interfaces. +/// +/// Note: QEMU's virtio-net-pci device does not support an MTU property +/// on the command line. TAP MTU must be configured separately (e.g. via +/// `ip link set`) if non-default MTU is required. The cloud-hypervisor +/// backend sets MTU in the device configuration, which cloud-hypervisor +/// applies to the TAP devices automatically. +/// +/// # NIC model selection +/// +/// Each interface names its own device type, so one VM can present several +/// at once -- which is the point: a program that identifies a NIC by +/// ordinal rather than by what it is looks correct on a uniform machine and +/// picks the wrong one on a mixed one. +/// +/// - [`VirtioNet`](config::NicModel::VirtioNet) -- +/// `virtio-net-pci-non-transitional` (virtio 1.0+ only). When +/// `iommu` is `true`, adds `iommu_platform=on,ats=on` so that +/// network I/O is routed through the virtual IOMMU. +/// +/// - [`E1000`](config::NicModel::E1000) -- Intel 82540EM (`e1000`). +/// This is a fully emulated legacy NIC. It does not support +/// `iommu_platform` or ATS (not a virtio device), but DMA is still +/// remapped by the Intel IOMMU when present because the IOMMU covers +/// the entire PCI topology. +/// +/// - [`E1000E`](config::NicModel::E1000E) -- Intel 82574L (`e1000e`). +/// A newer emulated Intel GbE NIC with improved feature support +/// (MSI-X, hardware offloads). Like `e1000`, it does not support +/// `iommu_platform` or ATS but sits behind the Intel IOMMU on the +/// PCI bus. +fn push_network_args(args: &mut Vec, iommu: bool, ifaces: &[config::NetIface]) { + for iface in ifaces { + // The TAP netdev is the same regardless of the front-end device + // model -- it just bridges a host TAP interface into the guest. + args.extend([ + "-netdev".into(), + format!( + "tap,id=nd-{id},ifname={tap},script=no,downscript=no", + id = iface.id, + tap = iface.tap, + ), + ]); + + // The front-end device string depends on the NIC model. + let device_str = match iface.model { + config::NicModel::VirtioNet => { + let iommu_suffix = if iommu { + ",iommu_platform=on,ats=on" + } else { + "" + }; + format!( + "virtio-net-pci-non-transitional,netdev=nd-{id},mac={mac}{iommu_suffix}", + id = iface.id, + mac = iface.mac, + ) + } + config::NicModel::E1000 => { + // e1000 is a legacy emulated NIC -- no iommu_platform or + // ATS support. The Intel IOMMU still intercepts DMA from + // this device when present on the PCI bus. + format!( + "e1000,netdev=nd-{id},mac={mac}", + id = iface.id, + mac = iface.mac, + ) + } + config::NicModel::E1000E => { + // e1000e (Intel 82574L) is a newer emulated NIC with + // MSI-X and hardware offloads. Same IOMMU story as + // e1000: no iommu_platform/ATS, but DMA is remapped + // by the Intel IOMMU when present. + format!( + "e1000e,netdev=nd-{id},mac={mac}", + id = iface.id, + mac = iface.mac, + ) + } + }; + + args.extend(["-device".into(), device_str]); + } +} + +/// Serial console on a Unix socket. +/// +/// QEMU creates the socket in server mode (`server=on`) and does not +/// block waiting for a client (`wait=off`). Console output is buffered +/// until the container tier's kernel-log reader connects. +fn push_serial_args(args: &mut Vec) { + args.extend([ + "-serial".into(), + format!("unix:{KERNEL_CONSOLE_SOCKET_PATH},server=on,wait=off"), + ]); +} + +/// QMP control socket for lifecycle commands and event monitoring. +/// +/// Creates a chardev socket in server mode and attaches a QMP monitor +/// to it. The container tier connects to this socket after the QEMU +/// process starts. +fn push_qmp_args(args: &mut Vec) { + args.extend([ + "-chardev".into(), + format!("socket,id=qmp0,path={HYPERVISOR_API_SOCKET_PATH},server=on,wait=off"), + "-mon".into(), + "chardev=qmp0,mode=control".into(), + ]); +} + +/// SMBIOS tables for test identification and miscellaneous platform +/// settings. +/// +/// Embeds the test binary name and test name in SMBIOS OEM strings +/// (type 11), matching the cloud-hypervisor backend's +/// `PlatformConfig.oem_strings`. Also sets a serial number and UUID +/// in the system information table (type 1). +fn push_platform_args(args: &mut Vec, params: &TestVmParams<'_>) { + // QEMU's option parser splits on commas; a literal comma in a value is + // escaped by doubling it. Test and binary names normally contain no + // commas, but a stray one must not corrupt the argument parse. + let bin_name = params.bin_name.replace(',', ",,"); + let test_name = params.test_name.replace(',', ",,"); + args.extend([ + "-smbios".into(), + "type=1,serial=dataplane-test,uuid=dff9c8dd-492d-4148-a007-7931f94db852".into(), + "-smbios".into(), + format!("type=11,value=exe={bin_name},value=test={test_name}"), + ]); +} + +/// Miscellaneous flags. +/// +/// - `-display none` -- suppress graphical output. +/// - `-no-reboot` -- exit on guest reboot rather than restarting. +/// - `-no-shutdown` -- pause on guest shutdown rather than exiting, so +/// the QMP event watcher has time to capture the `SHUTDOWN` event +/// before the socket closes. The [`shutdown`](Qemu::shutdown) method +/// sends `quit` to terminate the paused VMM. +/// - `-device pvpanic` -- enable guest panic detection via the pvpanic +/// PCI device. When the guest kernel panics, QEMU emits a +/// `GUEST_PANICKED` QMP event. +fn push_misc_args(args: &mut Vec, arch: config::Arch) { + args.extend([ + "-display".into(), + "none".into(), + "-no-reboot".into(), + "-no-shutdown".into(), + "-device".into(), + arch.pvpanic_device().into(), + ]); +} + +// -- Tests ------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + use n_vm_protocol::INIT_BINARY_PATH; + + /// A default VM's interfaces, with `n` fabric links all of `model`. + /// + /// The model reaches the lowering through the interface list now, so a + /// test that used to pass it alongside builds the list that carries it. + fn ifaces_of(model: config::NicModel, n: u8) -> Vec { + config::VmConfig { + nic_model: model, + fabric: config::FabricNics::Uniform(n), + ..config::VmConfig::DEFAULT + } + .all_ifaces() + } + + const VIRTIOFS_QUEUE_SIZE: u32 = crate::config::VIRTIOFS_QUEUE_SIZE; + + /// Builds a representative [`TestVmParams`] for use in CLI builder + /// tests. The values are arbitrary but realistic. + fn sample_params() -> TestVmParams<'static> { + TestVmParams { + full_bin_path: Path::new("/deps/my_test-abc123"), + vm_bin_path: format!("/{}/my_test-abc123", n_vm_protocol::VM_TEST_BIN_DIR), + bin_name: "my_test-abc123", + test_name: "module::test_name", + vm_config: config::VmConfig::default(), + arch: config::Arch::X86_64, + kernel_image: SAMPLE_KERNEL.to_owned(), + initramfs: None, + boot: crate::kernel_manifest::BootMode::Direct, + accel: config::Accel::Kvm, + vsock: n_vm_protocol::VsockAllocation::with_defaults(), + shares: Vec::new(), + } + } + + /// A stand-in for whatever the kernel manifest resolved to. The point + /// of threading the path through [`TestVmParams`] is that this lowering + /// no longer knows or cares which kernel it is, so the test asserts the + /// path is forwarded rather than that it has any particular value. + const SAMPLE_KERNEL: &str = "/kernels/union/vmlinuz"; + + // -- Machine and CPU ---------------------------------------------- + + #[test] + fn machine_args_enable_kvm_with_q35() { + let mut args = Vec::new(); + push_machine_args(&mut args, false, config::Accel::Kvm, config::Arch::X86_64); + assert!(args.contains(&"-enable-kvm".to_string())); + assert!(args.contains(&"q35,accel=kvm".to_string())); + assert!(args.contains(&"host".to_string())); + } + + #[test] + fn machine_args_use_tcg_without_kvm() { + let mut args = Vec::new(); + push_machine_args(&mut args, false, config::Accel::Tcg, config::Arch::X86_64); + assert!( + !args.contains(&"-enable-kvm".to_string()), + "TCG must not pass -enable-kvm: {args:?}", + ); + assert!(args.contains(&"q35,accel=tcg".to_string()), "{args:?}"); + assert!( + args.contains(&"max".to_string()), + "TCG should use -cpu max, not host: {args:?}", + ); + assert!( + !args.contains(&"host".to_string()), + "TCG must not use -cpu host: {args:?}", + ); + } + + #[test] + fn machine_and_iommu_args_lower_per_arch() { + // The builders are pure functions of (config, accel, arch), so both + // ISAs' lowering is asserted here on a single build host -- this is + // the property that decouples these tests from the build target. + + // aarch64: `virt` machine, no x86 kernel-irqchip, no vIOMMU device. + let mut machine = Vec::new(); + push_machine_args( + &mut machine, + true, + config::Accel::Tcg, + config::Arch::Aarch64, + ); + assert!( + machine.iter().any(|a| a.starts_with("virt")), + "aarch64 uses the virt machine: {machine:?}", + ); + assert!( + !machine.iter().any(|a| a.contains("kernel-irqchip")), + "aarch64 has no x86 split-irqchip: {machine:?}", + ); + // aarch64's vIOMMU is the SMMUv3 machine option, not a device. + assert!( + machine.iter().any(|a| a.contains("iommu=smmuv3")), + "aarch64 iommu lowers to the smmuv3 machine option: {machine:?}", + ); + + let mut arm_iommu = Vec::new(); + push_iommu_args(&mut arm_iommu, true, config::Arch::Aarch64); + assert!( + arm_iommu.is_empty(), + "aarch64 has no vIOMMU device lowering: {arm_iommu:?}", + ); + + // x86: the intel-iommu device is present. + let mut x86_iommu = Vec::new(); + push_iommu_args(&mut x86_iommu, true, config::Arch::X86_64); + assert!( + x86_iommu.iter().any(|a| a.starts_with("intel-iommu")), + "x86 emits the intel-iommu device: {x86_iommu:?}", + ); + } + + #[test] + fn cpu_args_have_six_vcpus() { + let mut args = Vec::new(); + push_cpu_args(&mut args, config::Arch::X86_64, 6); + let smp = &args[1]; + assert!(smp.starts_with("6,"), "expected 6 vCPUs: {smp}"); + } + + #[test] + fn cpu_topology_matches_cloud_hypervisor() { + let mut args = Vec::new(); + push_cpu_args(&mut args, config::Arch::X86_64, 6); + let smp = &args[1]; + assert!(smp.contains("sockets=1"), "{smp}"); + assert!(smp.contains("dies=3"), "{smp}"); + assert!(smp.contains("cores=1"), "{smp}"); + assert!(smp.contains("threads=2"), "{smp}"); + } + + // -- Memory ------------------------------------------------------- + + #[test] + fn memory_args_set_ram_size() { + let mut args = Vec::new(); + push_memory_args(&mut args, &config::VmConfig::DEFAULT); + let idx = args.iter().position(|a| a == "-m").unwrap(); + assert_eq!( + args[idx + 1], + format!("{mib}M", mib = config::VmConfig::DEFAULT.memory_mib), + ); + } + + /// The size follows the configuration rather than a constant, which is + /// the point of the lever. Both the `-m` flag and the backend object + /// have to move together, or QEMU is told two different sizes. + #[test] + fn memory_args_follow_the_configured_size() { + let mut args = Vec::new(); + let vm_config = config::VmConfigBuilder::default().memory_mib(4096).build(); + push_memory_args(&mut args, &vm_config); + let idx = args.iter().position(|a| a == "-m").unwrap(); + assert_eq!(args[idx + 1], "4096M"); + let obj = args + .iter() + .find(|a| a.starts_with("memory-backend")) + .expect("a memory backend object"); + assert!(obj.contains("size=4096M"), "{obj}"); + } + + #[test] + fn memory_args_use_hugepages_with_sharing_for_1g() { + let mut args = Vec::new(); + push_memory_args( + &mut args, + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Huge1G) + .build(), + ); + let obj = args + .iter() + .find(|a| a.starts_with("memory-backend-memfd")) + .unwrap(); + assert!(obj.contains("size=1024M"), "{obj}"); + assert!(obj.contains("hugetlb=on"), "{obj}"); + assert!(obj.contains("hugetlbsize=1G"), "{obj}"); + assert!(obj.contains("share=on"), "{obj}"); + assert!(obj.contains("prealloc=on"), "{obj}"); + } + + /// The test that would have caught the bug: the two huge sizes must + /// produce *different* arguments. + /// + /// They previously did not. `memory-backend-file` infers the page size + /// from the mount it is pointed at, so `Huge2M` and `Huge1G` emitted + /// byte-identical strings and QEMU silently allocated whatever + /// `/dev/hugepages` happened to be -- 2 MiB pages -- while the 1 GiB + /// pool went untouched. Every existing assertion still passed. + #[test] + fn each_huge_page_size_asks_for_that_size() { + let arg_for = |size| { + let mut args = Vec::new(); + let vm_config = config::VmConfigBuilder::default() + .host_page_size(size) + .build(); + push_memory_args(&mut args, &vm_config); + args.iter() + .find(|a| a.starts_with("memory-backend")) + .expect("a memory backend object") + .clone() + }; + + let two_m = arg_for(config::HostPageSize::Huge2M); + let one_g = arg_for(config::HostPageSize::Huge1G); + + assert!(two_m.contains("hugetlbsize=2M"), "{two_m}"); + assert!(one_g.contains("hugetlbsize=1G"), "{one_g}"); + assert_ne!( + two_m, one_g, + "a 2 MiB and a 1 GiB request must not produce the same arguments", + ); + } + + /// Standard pages take a plain memfd: no pool to draw from, and asking + /// for `hugetlb=on` would fail on a host with no hugepages reserved. + #[test] + fn standard_pages_use_a_plain_memfd() { + let mut args = Vec::new(); + push_memory_args( + &mut args, + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Standard) + .build(), + ); + let obj = args + .iter() + .find(|a| a.starts_with("memory-backend-memfd")) + .unwrap(); + assert!(!obj.contains("hugetlb"), "{obj}"); + } + + #[test] + fn memory_args_use_hugepages_with_sharing_for_2m() { + let mut args = Vec::new(); + push_memory_args( + &mut args, + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Huge2M) + .build(), + ); + let obj = args + .iter() + .find(|a| a.starts_with("memory-backend-memfd")) + .unwrap(); + assert!(obj.contains("hugetlb=on"), "{obj}"); + assert!(obj.contains("hugetlbsize=2M"), "{obj}"); + assert!(obj.contains("share=on"), "{obj}"); + assert!(obj.contains("prealloc=on"), "{obj}"); + } + + #[test] + fn memory_args_use_memfd_for_standard_pages() { + let mut args = Vec::new(); + push_memory_args( + &mut args, + &config::VmConfigBuilder::default() + .host_page_size(config::HostPageSize::Standard) + .build(), + ); + let obj = args + .iter() + .find(|a| a.starts_with("memory-backend-memfd")) + .expect("standard pages should use memory-backend-memfd"); + assert!(obj.contains("share=on"), "{obj}"); + assert!( + !obj.contains("hugepages"), + "standard pages should not reference hugepages: {obj}" + ); + } + + #[test] + fn memory_args_include_numa_node() { + let mut args = Vec::new(); + push_memory_args(&mut args, &config::VmConfig::DEFAULT); + assert!(args.contains(&"node,memdev=mem0".to_string())); + } + + // -- Kernel ------------------------------------------------------- + + #[test] + fn kernel_args_use_resolved_kernel_image() { + let mut args = Vec::new(); + push_kernel_args(&mut args, &sample_params()); + let idx = args.iter().position(|a| a == "-kernel").unwrap(); + assert_eq!(args[idx + 1], SAMPLE_KERNEL); + } + + #[test] + fn kernel_cmdline_embeds_test_binary_and_name() { + let mut args = Vec::new(); + push_kernel_args(&mut args, &sample_params()); + let idx = args.iter().position(|a| a == "-append").unwrap(); + let cmdline = &args[idx + 1]; + let expected = format!("/{}/my_test-abc123", n_vm_protocol::VM_TEST_BIN_DIR); + assert!( + cmdline.contains(&expected), + "cmdline should contain the VM-side binary path ({expected}): {cmdline}", + ); + assert!(cmdline.contains("module::test_name"), "{cmdline}"); + } + + #[test] + fn kernel_cmdline_sets_init_binary() { + let mut args = Vec::new(); + push_kernel_args(&mut args, &sample_params()); + let idx = args.iter().position(|a| a == "-append").unwrap(); + let cmdline = &args[idx + 1]; + assert!( + cmdline.contains(&format!("init={INIT_BINARY_PATH}")), + "{cmdline}" + ); + } + + #[test] + fn kernel_cmdline_enables_hugepages() { + let mut args = Vec::new(); + let params = sample_params(); + push_kernel_args(&mut args, ¶ms); + let idx = args.iter().position(|a| a == "-append").unwrap(); + let cmdline = &args[idx + 1]; + // Against the config, not a literal -- see the twin of this test in `cloud_hypervisor`. + let expected = params + .vm_config + .hugepage_reservation() + .kernel_cmdline_fragment(); + assert!(!expected.is_empty(), "the sample config reserves hugepages"); + assert!(cmdline.contains(expected.trim_end()), "{cmdline}"); + } + + #[test] + fn kernel_cmdline_passes_exact_flag() { + let mut args = Vec::new(); + push_kernel_args(&mut args, &sample_params()); + let idx = args.iter().position(|a| a == "-append").unwrap(); + let cmdline = &args[idx + 1]; + assert!(cmdline.contains("--exact"), "{cmdline}"); + assert!(cmdline.contains("--no-capture"), "{cmdline}"); + assert!(cmdline.contains("--format=terse"), "{cmdline}"); + } + + #[test] + fn kernel_cmdline_embeds_vsock_port_parameters() { + let params = sample_params(); + let mut args = Vec::new(); + push_kernel_args(&mut args, ¶ms); + let idx = args.iter().position(|a| a == "-append").unwrap(); + let cmdline = &args[idx + 1]; + let fragment = params.vsock.kernel_cmdline_fragment(); + assert!( + cmdline.contains(&fragment), + "kernel cmdline should contain vsock port parameters ({fragment}): {cmdline}", + ); + } + + // -- Filesystem --------------------------------------------------- + + #[test] + fn fs_args_use_virtiofs_tag_and_socket() { + let mut args = Vec::new(); + push_fs_args(&mut args, &[]); + let chardev = args + .iter() + .find(|a| a.starts_with("socket,id=virtiofs0")) + .unwrap(); + assert!(chardev.contains(VIRTIOFSD_SOCKET_PATH), "{chardev}"); + let device = args + .iter() + .find(|a| a.starts_with("vhost-user-fs-pci")) + .unwrap(); + assert!(device.contains(VIRTIOFS_ROOT_TAG), "{device}"); + assert!( + device.contains(&format!("queue-size={VIRTIOFS_QUEUE_SIZE}")), + "{device}" + ); + } + + // -- vsock -------------------------------------------------------- + + #[test] + fn vsock_args_use_guest_cid() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let mut args = Vec::new(); + push_vsock_args(&mut args, &vsock, false); + let device = args + .iter() + .find(|a| a.starts_with("vhost-vsock-pci")) + .unwrap(); + assert!( + device.contains(&format!("guest-cid={}", vsock.cid.as_raw())), + "{device}" + ); + } + + // -- Network ------------------------------------------------------ + + #[test] + fn network_args_have_three_interfaces() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::VirtioNet, 2)); + let netdev_count = args.iter().filter(|a| a.starts_with("tap,")).count(); + let device_count = args + .iter() + .filter(|a| a.starts_with("virtio-net-pci-non-transitional,")) + .count(); + assert_eq!(netdev_count, 3); + assert_eq!(device_count, 3); + } + + #[test] + fn all_interfaces_have_unique_mac_addresses() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::VirtioNet, 2)); + let macs: Vec<&str> = args + .iter() + .filter_map(|a| { + a.split(',') + .find(|part| part.starts_with("mac=")) + .map(|p| &p[4..]) + }) + .collect(); + assert_eq!(macs.len(), 3); + let mut unique = macs.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), 3, "MAC addresses must be unique: {macs:?}"); + } + + #[test] + fn all_interfaces_have_unique_tap_names() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::VirtioNet, 2)); + let taps: Vec<&str> = args + .iter() + .filter_map(|a| { + a.split(',') + .find(|part| part.starts_with("ifname=")) + .map(|p| &p[7..]) + }) + .collect(); + assert_eq!(taps.len(), 3); + let mut unique = taps.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), 3, "TAP names must be unique: {taps:?}"); + } + + // -- Serial ------------------------------------------------------- + + #[test] + fn serial_args_use_socket_mode() { + let mut args = Vec::new(); + push_serial_args(&mut args); + let serial = args.iter().find(|a| a.starts_with("unix:")).unwrap(); + assert!(serial.contains(KERNEL_CONSOLE_SOCKET_PATH), "{serial}"); + assert!(serial.contains("server=on"), "{serial}"); + assert!(serial.contains("wait=off"), "{serial}"); + } + + // -- QMP ---------------------------------------------------------- + + #[test] + fn qmp_args_create_control_socket() { + let mut args = Vec::new(); + push_qmp_args(&mut args); + let chardev = args + .iter() + .find(|a| a.starts_with("socket,id=qmp0")) + .unwrap(); + assert!(chardev.contains(HYPERVISOR_API_SOCKET_PATH), "{chardev}"); + assert!(chardev.contains("server=on"), "{chardev}"); + assert!(chardev.contains("wait=off"), "{chardev}"); + assert!(args.contains(&"chardev=qmp0,mode=control".to_string())); + } + + // -- Platform / SMBIOS -------------------------------------------- + + #[test] + fn platform_args_embed_binary_and_test_name() { + let mut args = Vec::new(); + push_platform_args(&mut args, &sample_params()); + let oem = args.iter().find(|a| a.starts_with("type=11,")).unwrap(); + assert!(oem.contains("exe=my_test-abc123"), "{oem}"); + assert!(oem.contains("test=module::test_name"), "{oem}"); + } + + #[test] + fn platform_args_set_serial_and_uuid() { + let mut args = Vec::new(); + push_platform_args(&mut args, &sample_params()); + let sys = args.iter().find(|a| a.starts_with("type=1,")).unwrap(); + assert!(sys.contains("serial=dataplane-test"), "{sys}"); + assert!(sys.contains("uuid="), "{sys}"); + } + + // -- Misc --------------------------------------------------------- + + #[test] + fn misc_args_disable_display() { + let mut args = Vec::new(); + push_misc_args(&mut args, config::Arch::X86_64); + assert!(args.contains(&"none".to_string())); + } + + #[test] + fn misc_args_enable_no_reboot_and_no_shutdown() { + let mut args = Vec::new(); + push_misc_args(&mut args, config::Arch::X86_64); + assert!(args.contains(&"-no-reboot".to_string())); + assert!(args.contains(&"-no-shutdown".to_string())); + } + + #[test] + fn misc_args_enable_pvpanic() { + let mut args = Vec::new(); + push_misc_args(&mut args, config::Arch::X86_64); + assert!(args.contains(&"pvpanic".to_string())); + } + + // -- Full arg vector ---------------------------------------------- + + #[test] + fn build_qemu_args_is_nonempty() { + let args = build_qemu_args(&sample_params()); + assert!(!args.is_empty()); + } + + // -- vIOMMU configuration ----------------------------------------- + + /// Helper that returns [`TestVmParams`] with vIOMMU enabled. + fn sample_params_iommu() -> TestVmParams<'static> { + let mut params = sample_params(); + params.vm_config.iommu = true; + params + } + + #[test] + fn machine_args_use_irqchip_split_when_iommu_enabled() { + let mut args = Vec::new(); + push_machine_args(&mut args, true, config::Accel::Kvm, config::Arch::X86_64); + assert!( + args.contains(&"q35,accel=kvm,kernel-irqchip=split".to_string()), + "iommu requires kernel-irqchip=split: {args:?}", + ); + } + + #[test] + fn machine_args_omit_irqchip_split_when_iommu_disabled() { + let mut args = Vec::new(); + push_machine_args(&mut args, false, config::Accel::Kvm, config::Arch::X86_64); + assert!( + args.contains(&"q35,accel=kvm".to_string()), + "no kernel-irqchip=split without iommu: {args:?}", + ); + assert!( + !args.iter().any(|a| a.contains("kernel-irqchip")), + "should not mention kernel-irqchip when iommu is disabled", + ); + } + + #[test] + fn iommu_args_present_when_enabled() { + let mut args = Vec::new(); + push_iommu_args(&mut args, true, config::Arch::X86_64); + let device = args + .iter() + .find(|a| a.starts_with("intel-iommu")) + .expect("should have an intel-iommu device"); + assert!(device.contains("intremap=on"), "{device}"); + assert!(device.contains("device-iotlb=on"), "{device}"); + assert!(device.contains("caching-mode=on"), "{device}"); + } + + #[test] + fn iommu_args_absent_when_disabled() { + let mut args = Vec::new(); + push_iommu_args(&mut args, false, config::Arch::X86_64); + assert!(args.is_empty(), "no IOMMU args when disabled"); + } + + #[test] + fn network_devices_have_iommu_platform_when_enabled() { + let mut args = Vec::new(); + push_network_args(&mut args, true, &ifaces_of(config::NicModel::VirtioNet, 2)); + let devices: Vec<&String> = args + .iter() + .filter(|a| a.starts_with("virtio-net-pci-non-transitional,")) + .collect(); + assert_eq!(devices.len(), 3); + for dev in &devices { + assert!( + dev.contains("iommu_platform=on"), + "device should have iommu_platform=on: {dev}", + ); + assert!(dev.contains("ats=on"), "device should have ats=on: {dev}",); + } + } + + #[test] + fn network_devices_omit_iommu_platform_when_disabled() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::VirtioNet, 2)); + for arg in &args { + assert!( + !arg.contains("iommu_platform"), + "should not contain iommu_platform when disabled: {arg}", + ); + } + } + + /// One VM, three device models, in the order the fabric named them. + /// + /// This is the lowering a startup-sequence test depends on: if the + /// model were still per-VM, every device string here would be the + /// same and the test above it would be asserting nothing. + #[test] + fn a_mixed_fabric_lowers_to_a_different_device_per_link() { + let mixed = config::VmConfig { + fabric: config::FabricNics::Mixed(&[config::NicModel::E1000, config::NicModel::E1000E]), + ..config::VmConfig::DEFAULT + }; + let mut args = Vec::new(); + push_network_args(&mut args, false, &mixed.all_ifaces()); + + let devices: Vec<&str> = args + .iter() + .filter(|a| a.contains("netdev=nd-")) + .map(|a| a.split(',').next().unwrap_or_default()) + .collect(); + assert_eq!( + devices, + vec!["virtio-net-pci-non-transitional", "e1000", "e1000e"], + ); + + // Every link still gets its own TAP, whatever it is presented as: + // the back end is the same host device either way. + assert_eq!(args.iter().filter(|a| a.starts_with("tap,")).count(), 3); + } + + // -- e1000 NIC model ---------------------------------------------- + + #[test] + fn e1000_default_devices_as_virtio() { + // e1000 requires_qemu but is not virtio -- sanity check. + assert!(!config::NicModel::E1000.is_virtio()); + assert!(config::NicModel::E1000.requires_qemu()); + } + + #[test] + fn e1000_network_args_have_three_interfaces() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::E1000, 2)); + let netdev_count = args.iter().filter(|a| a.starts_with("tap,")).count(); + let device_count = args.iter().filter(|a| a.starts_with("e1000,")).count(); + assert_eq!(netdev_count, 3); + assert_eq!(device_count, 3); + } + + #[test] + fn e1000_devices_have_no_iommu_platform_even_when_enabled() { + let mut args = Vec::new(); + push_network_args(&mut args, true, &ifaces_of(config::NicModel::E1000, 2)); + let devices: Vec<&String> = args.iter().filter(|a| a.starts_with("e1000,")).collect(); + assert_eq!(devices.len(), 3); + for dev in &devices { + assert!( + !dev.contains("iommu_platform"), + "e1000 should not have iommu_platform: {dev}", + ); + assert!(!dev.contains("ats="), "e1000 should not have ats: {dev}",); + } + } + + #[test] + fn e1000_devices_have_correct_mac_addresses() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::E1000, 2)); + let macs: Vec<&str> = args + .iter() + .filter_map(|a| { + a.split(',') + .find(|part| part.starts_with("mac=")) + .map(|p| &p[4..]) + }) + .collect(); + assert_eq!(macs.len(), 3); + let mut unique = macs.clone(); + unique.sort(); + unique.dedup(); + assert_eq!( + unique.len(), + 3, + "e1000 MAC addresses must be unique: {macs:?}" + ); + } + + #[test] + fn e1000_network_args_use_same_tap_devices_as_virtio() { + let mut virtio_args = Vec::new(); + push_network_args( + &mut virtio_args, + false, + &ifaces_of(config::NicModel::VirtioNet, 2), + ); + let mut e1000_args = Vec::new(); + push_network_args( + &mut e1000_args, + false, + &ifaces_of(config::NicModel::E1000, 2), + ); + + let virtio_taps: Vec<&String> = virtio_args + .iter() + .filter(|a| a.starts_with("tap,")) + .collect(); + let e1000_taps: Vec<&String> = e1000_args + .iter() + .filter(|a| a.starts_with("tap,")) + .collect(); + assert_eq!( + virtio_taps, e1000_taps, + "TAP netdevs should be identical regardless of NIC model", + ); + } + + // -- e1000e NIC model --------------------------------------------- + + #[test] + fn e1000e_default_devices_as_virtio() { + // e1000e requires_qemu but is not virtio -- sanity check. + assert!(!config::NicModel::E1000E.is_virtio()); + assert!(config::NicModel::E1000E.requires_qemu()); + } + + #[test] + fn e1000e_network_args_have_three_interfaces() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::E1000E, 2)); + let netdev_count = args.iter().filter(|a| a.starts_with("tap,")).count(); + let device_count = args.iter().filter(|a| a.starts_with("e1000e,")).count(); + assert_eq!(netdev_count, 3); + assert_eq!(device_count, 3); + } + + #[test] + fn e1000e_devices_have_no_iommu_platform_even_when_enabled() { + let mut args = Vec::new(); + push_network_args(&mut args, true, &ifaces_of(config::NicModel::E1000E, 2)); + let devices: Vec<&String> = args.iter().filter(|a| a.starts_with("e1000e,")).collect(); + assert_eq!(devices.len(), 3); + for dev in &devices { + assert!( + !dev.contains("iommu_platform"), + "e1000e should not have iommu_platform: {dev}", + ); + assert!(!dev.contains("ats="), "e1000e should not have ats: {dev}",); + } + } + + #[test] + fn e1000e_devices_have_correct_mac_addresses() { + let mut args = Vec::new(); + push_network_args(&mut args, false, &ifaces_of(config::NicModel::E1000E, 2)); + let macs: Vec<&str> = args + .iter() + .filter_map(|a| { + a.split(',') + .find(|part| part.starts_with("mac=")) + .map(|p| &p[4..]) + }) + .collect(); + assert_eq!(macs.len(), 3); + let mut unique = macs.clone(); + unique.sort(); + unique.dedup(); + assert_eq!( + unique.len(), + 3, + "e1000e MAC addresses must be unique: {macs:?}" + ); + } + + #[test] + fn e1000e_network_args_use_same_tap_devices_as_virtio() { + let mut virtio_args = Vec::new(); + push_network_args( + &mut virtio_args, + false, + &ifaces_of(config::NicModel::VirtioNet, 2), + ); + let mut e1000e_args = Vec::new(); + push_network_args( + &mut e1000e_args, + false, + &ifaces_of(config::NicModel::E1000E, 2), + ); + + let virtio_taps: Vec<&String> = virtio_args + .iter() + .filter(|a| a.starts_with("tap,")) + .collect(); + let e1000e_taps: Vec<&String> = e1000e_args + .iter() + .filter(|a| a.starts_with("tap,")) + .collect(); + assert_eq!( + virtio_taps, e1000e_taps, + "TAP netdevs should be identical regardless of NIC model", + ); + } + + #[test] + fn fs_device_never_has_iommu_platform() { + let mut args = Vec::new(); + push_fs_args(&mut args, &[]); + let device = args + .iter() + .find(|a| a.starts_with("vhost-user-fs-pci")) + .unwrap(); + assert!( + !device.contains("iommu_platform"), + "vhost-user-fs-pci does not support iommu_platform: {device}", + ); + } + + #[test] + fn vsock_device_has_iommu_platform_when_enabled() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let mut args = Vec::new(); + push_vsock_args(&mut args, &vsock, true); + let device = args + .iter() + .find(|a| a.starts_with("vhost-vsock-pci-non-transitional")) + .unwrap(); + assert!( + device.contains("iommu_platform=on,ats=on"), + "vsock device should have iommu_platform: {device}", + ); + } + + #[test] + fn vsock_device_omits_iommu_platform_when_disabled() { + let vsock = n_vm_protocol::VsockAllocation::with_defaults(); + let mut args = Vec::new(); + push_vsock_args(&mut args, &vsock, false); + let device = args + .iter() + .find(|a| a.starts_with("vhost-vsock-pci-non-transitional,")) + .unwrap(); + assert!( + !device.contains("iommu_platform"), + "vsock device should not have iommu_platform when disabled: {device}", + ); + } + + #[test] + fn full_args_with_iommu_contain_intel_iommu_device() { + let args = build_qemu_args(&sample_params_iommu()); + assert!( + args.iter().any(|a| a.starts_with("intel-iommu")), + "full arg vector should contain intel-iommu device when iommu=true", + ); + } + + #[test] + fn full_args_without_iommu_omit_intel_iommu_device() { + let args = build_qemu_args(&sample_params()); + assert!( + !args.iter().any(|a| a.contains("intel-iommu")), + "full arg vector should not contain intel-iommu when iommu=false", + ); + } + + // -- Event log display -------------------------------------------- + + #[test] + fn empty_event_log_displays_nothing() { + let log = QemuEventLog(vec![]); + assert_eq!(format!("{log}"), ""); + } + + #[test] + fn event_log_displays_one_line_per_event() { + let log = QemuEventLog(vec![ + qapi_qmp::Event::SHUTDOWN { + data: qapi_qmp::SHUTDOWN { + guest: true, + reason: qapi_qmp::ShutdownCause::guest_shutdown, + }, + timestamp: serde_json::from_str(r#"{"seconds": 1, "microseconds": 0}"#).unwrap(), + }, + qapi_qmp::Event::STOP { + data: qapi_qmp::STOP {}, + timestamp: serde_json::from_str(r#"{"seconds": 2, "microseconds": 0}"#).unwrap(), + }, + ]); + let output = format!("{log}"); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 2, "expected 2 lines:\n{output}"); + assert!(lines[0].contains("SHUTDOWN"), "{}", lines[0]); + assert!(lines[1].contains("STOP"), "{}", lines[1]); + } + + // -- Verdict computation ------------------------------------------ + + /// A zero-valued timestamp for use in test events. + fn ts() -> qapi_spec::Timestamp { + serde_json::from_str(r#"{"seconds": 0, "microseconds": 0}"#).unwrap() + } + + fn resume_event() -> qapi_qmp::Event { + qapi_qmp::Event::RESUME { + data: qapi_qmp::RESUME {}, + timestamp: ts(), + } + } + + fn shutdown_event() -> qapi_qmp::Event { + qapi_qmp::Event::SHUTDOWN { + data: qapi_qmp::SHUTDOWN { + guest: true, + reason: qapi_qmp::ShutdownCause::guest_shutdown, + }, + timestamp: ts(), + } + } + + fn panic_event() -> qapi_qmp::Event { + qapi_qmp::Event::GUEST_PANICKED { + data: qapi_qmp::GUEST_PANICKED { + action: qapi_qmp::GuestPanicAction::pause, + info: None, + }, + timestamp: ts(), + } + } + + #[test] + fn clean_shutdown_without_errors() { + let events = vec![resume_event(), shutdown_event()]; + assert_eq!( + compute_verdict(&events, false), + HypervisorVerdict::CleanShutdown, + ); + } + + #[test] + fn shutdown_with_stream_errors_is_failure() { + let events = vec![resume_event(), shutdown_event()]; + assert_eq!(compute_verdict(&events, true), HypervisorVerdict::Failure); + } + + #[test] + fn panic_before_shutdown_is_failure() { + let events = vec![resume_event(), panic_event(), shutdown_event()]; + assert_eq!(compute_verdict(&events, false), HypervisorVerdict::Failure); + } + + #[test] + fn panic_without_shutdown_is_failure() { + let events = vec![resume_event(), panic_event()]; + assert_eq!(compute_verdict(&events, false), HypervisorVerdict::Failure); + } + + #[test] + fn stream_ended_without_shutdown_is_failure() { + let events = vec![resume_event()]; + assert_eq!(compute_verdict(&events, false), HypervisorVerdict::Failure); + } + + #[test] + fn empty_event_log_is_failure() { + assert_eq!(compute_verdict(&[], false), HypervisorVerdict::Failure); + } + + #[test] + fn events_after_shutdown_are_ignored_for_verdict() { + let events = vec![resume_event(), shutdown_event(), panic_event()]; + assert_eq!( + compute_verdict(&events, false), + HypervisorVerdict::CleanShutdown, + ); + } +} diff --git a/n-vm/src/qemu/qmp.rs b/n-vm/src/qemu/qmp.rs new file mode 100644 index 0000000000..70505951b7 --- /dev/null +++ b/n-vm/src/qemu/qmp.rs @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! QEMU Machine Protocol (QMP) client backed by [`qapi-rs`] type +//! definitions. +//! +//! QMP is a JSON-based protocol that QEMU exposes over a Unix socket for +//! machine lifecycle control and event monitoring. This module provides +//! a purpose-built client covering only the operations needed by the +//! [`Qemu`](super::Qemu) hypervisor backend: +//! +//! 1. **Connection and negotiation** -- connect to the QMP socket, receive +//! the greeting, and enter command mode via `qmp_capabilities`. +//! 2. **Command execution** -- send commands (fire-and-forget) for +//! best-effort shutdown (`system_powerdown`, `quit`). +//! 3. **Event monitoring** -- read and deserialize async QMP events for +//! the event watcher task. +//! +//! Wire types (events, error classes, version info, greeting structure) +//! are provided by the [`qapi_qmp`] crate, which is code-generated from +//! the upstream QEMU QAPI schema. This gives us: +//! +//! - **Typed events** -- [`qapi_qmp::Event`] is an enum with variants +//! like `SHUTDOWN`, `GUEST_PANICKED`, `RESUME`, etc., each carrying +//! its schema-defined data payload. Verdict computation can use +//! pattern matching instead of string comparison. +//! - **Typed error classes** -- [`qapi_spec::ErrorClass`] enumerates the +//! QMP error categories (`GenericError`, `CommandNotFound`, etc.). +//! - **Version information** -- [`qapi_qmp::VersionInfo`] and +//! [`qapi_qmp::VersionTriple`] provide structured QEMU version data +//! from the greeting. +//! +//! # Protocol overview +//! +//! ```mermaid +//! sequenceDiagram +//! participant Client +//! participant QEMU +//! +//! QEMU->>Client: {"QMP": {"version": ...}} (greeting) +//! Client->>QEMU: {"execute": "qmp_capabilities"} (negotiate) +//! QEMU->>Client: {"return": {}} (success) +//! +//! note over Client,QEMU: command mode active +//! +//! QEMU->>Client: {"event": "SHUTDOWN", ...} (async event) +//! Client->>QEMU: {"execute": "quit"} (command) +//! QEMU->>Client: {"return": {}} (response) +//! ``` +//! +//! After negotiation, the socket carries a mix of **responses** (to +//! commands) and **async events** (lifecycle transitions). Since the +//! test infrastructure's shutdown path is best-effort and runs after the +//! event watcher has finished, the [`QmpWriter`] sends commands without +//! waiting for responses. +//! +//! # Socket split +//! +//! After negotiation, [`QmpConnection::into_split`] produces: +//! +//! - A [`QmpWriter`] that goes into the +//! [`QemuController`](super::QemuController) for lifecycle commands. +//! - A [`QmpEventStream`] that goes into the background event-watcher +//! task. +//! +//! The writer sends commands fire-and-forget (no response reading). The +//! event stream consumes everything from the read half, discarding +//! command responses and yielding only [`qapi_qmp::Event`]s. This +//! avoids the need for a multiplexer while keeping the API simple. +//! +//! [`qapi-rs`]: https://github.com/arcnmx/qapi-rs + +use std::path::Path; + +use qapi_qmp::QmpMessage; +use serde::Serialize; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tracing::{debug, trace, warn}; + +use super::error::QemuError; + +// -- Event display ---------------------------------------------------- + +/// Wrapper for human-readable [`Display`](std::fmt::Display) of a +/// [`qapi_qmp::Event`]. +/// +/// Produces a concise one-line representation showing the event name +/// followed by its data payload (if non-empty), suitable for diagnostic +/// output in test failure reports. +/// +/// # Examples +/// +/// An event with payload displays as `SHUTDOWN {"guest":true,"reason":"guest-shutdown"}`. +/// An event with an empty data struct displays as just `STOP`. +pub struct EventDisplay<'a>(pub &'a qapi_qmp::Event); + +impl std::fmt::Display for EventDisplay<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Serialize to JSON to extract the event name and data fields. + // `qapi_qmp::Event` is `#[serde(tag = "event")]`, so the JSON + // object always contains an `"event"` key with the variant name. + let Ok(json) = serde_json::to_value(self.0) else { + // Fallback to Debug if serialization somehow fails. + return write!(f, "{:?}", self.0); + }; + + let name = json + .get("event") + .and_then(|v| v.as_str()) + .unwrap_or("UNKNOWN"); + write!(f, "{name}")?; + + // Append the data payload unless it is an empty object (which + // is the serialized form of events that carry no payload, e.g. + // `STOP`, `RESUME`). + if let Some(data) = json.get("data") + && !data.as_object().is_some_and(serde_json::Map::is_empty) + { + write!(f, " {data}")?; + } + + Ok(()) + } +} + +// -- QMP command (outbound) ------------------------------------------- + +/// Enumerates the QMP commands used by this backend. +/// +/// Each variant serializes to the wire-format command name that QEMU +/// expects in the `"execute"` field of a QMP command message. +/// Only argument-free commands are needed; the shutdown path uses +/// [`SystemPowerdown`](Self::SystemPowerdown) and [`Quit`](Self::Quit), +/// while connection setup uses +/// [`QmpCapabilities`](Self::QmpCapabilities). +#[derive(Debug, Clone, Copy, Serialize)] +pub(crate) enum QmpCommandName { + /// Enter command mode after the initial greeting. + #[serde(rename = "qmp_capabilities")] + QmpCapabilities, + /// Send an ACPI power-button event to the guest. + #[serde(rename = "system_powerdown")] + SystemPowerdown, + /// Immediately terminate the QEMU process. + #[serde(rename = "quit")] + Quit, +} + +impl std::fmt::Display for QmpCommandName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::QmpCapabilities => f.write_str("qmp_capabilities"), + Self::SystemPowerdown => f.write_str("system_powerdown"), + Self::Quit => f.write_str("quit"), + } + } +} + +/// A QMP command message to send to QEMU. +/// +/// Serializes to `{"execute": ""}`, which is the format +/// QEMU expects for commands without arguments. +#[derive(Debug, Serialize)] +struct QmpCommand { + execute: QmpCommandName, +} + +// -- QMP connection --------------------------------------------------- + +/// An established QMP connection that has completed capability +/// negotiation and is ready for command mode. +/// +/// Created by [`QmpConnection::connect`], this type is consumed by +/// [`into_split`](Self::into_split) to produce a [`QmpWriter`] (for +/// sending commands) and a [`QmpEventStream`] (for reading events). +pub(crate) struct QmpConnection { + reader: BufReader, + writer: OwnedWriteHalf, +} + +impl QmpConnection { + /// Connects to the QMP socket at `path`, reads the greeting, and + /// negotiates capabilities. + /// + /// The greeting is deserialized as [`qapi_qmp::QapiCapabilities`], + /// which provides typed access to the QEMU version and advertised + /// capabilities. + /// + /// After this returns successfully, the connection is in command mode + /// and ready to send commands or read events. + /// + /// # Errors + /// + /// Returns [`QemuError`] if the connection, greeting, or negotiation + /// fails. + pub async fn connect(path: impl AsRef) -> Result { + let stream = UnixStream::connect(path.as_ref()) + .await + .map_err(QemuError::QmpConnect)?; + + let (read_half, write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + let mut writer = write_half; + + // -- Phase 1: read the QMP greeting --------------------------- + let greeting = + read_line_json::(&mut reader, || QemuError::QmpGreeting { + reason: "connection closed before greeting received".into(), + }) + .await?; + + let v = &greeting.QMP.version; + debug!( + "QMP greeting: QEMU {}.{}.{} (package: {:?})", + v.qemu.major, v.qemu.minor, v.qemu.micro, v.package, + ); + + // -- Phase 2: negotiate capabilities -------------------------- + send_command(&mut writer, QmpCommandName::QmpCapabilities).await?; + + let msg = + read_line_json::(&mut reader, || QemuError::QmpNegotiate { + reason: "connection closed before capabilities response received".into(), + }) + .await?; + match msg { + QmpMessage::Response(resp) => match resp.result() { + Ok(_) => { + debug!("QMP capabilities negotiated successfully"); + } + Err(error) => { + return Err(QemuError::QmpNegotiate { + reason: format!("{:?}: {}", error.class, error.desc), + }); + } + }, + QmpMessage::Event(event) => { + // Events during negotiation are unexpected but not + // impossible (e.g. a race with early device init). + warn!( + "unexpected QMP event during negotiation: {}", + EventDisplay(&event), + ); + return Err(QemuError::QmpNegotiate { + reason: format!( + "unexpected event during negotiation: {}", + EventDisplay(&event), + ), + }); + } + } + + Ok(Self { reader, writer }) + } + + /// Splits the connection into a writer (for sending commands) and an + /// event stream (for reading events in a background task). + /// + /// The writer goes into the [`QemuController`](super::QemuController) + /// and the event stream goes into the background event-watcher task + /// spawned during [`launch`](super::Qemu::launch). + pub fn into_split(self) -> (QmpWriter, QmpEventStream) { + ( + QmpWriter { + writer: self.writer, + }, + QmpEventStream { + reader: self.reader, + }, + ) + } +} + +// -- QmpWriter -------------------------------------------------------- + +/// Write half of a QMP connection, used for sending lifecycle commands. +/// +/// Commands are sent fire-and-forget: the response (if any) will be +/// consumed and discarded by the [`QmpEventStream`] on the read half, +/// or simply lost if QEMU has already exited. +/// +/// This design is appropriate because: +/// +/// - **During normal operation**, the event stream task owns the read +/// half and will discard any command responses it encounters. +/// - **During shutdown** (which runs after the event stream task +/// completes), the VM has usually already exited, so writes may fail +/// with a broken pipe. The best-effort semantics mean these failures +/// are harmless. +pub struct QmpWriter { + writer: OwnedWriteHalf, +} + +impl QmpWriter { + /// Sends a QMP command without waiting for a response. + /// + /// This is suitable for best-effort operations like shutdown where + /// the caller does not need to know whether the command succeeded. + /// Errors are logged at debug level but not propagated. + pub async fn send_command_fire_and_forget(&mut self, command: QmpCommandName) { + if let Err(err) = send_command(&mut self.writer, command).await { + debug!("QMP command `{command}` send failed (best-effort): {err}"); + } + } +} + +// -- QmpEventStream --------------------------------------------------- + +/// Read half of a QMP connection, used for consuming events in a +/// background task. +/// +/// Reads newline-delimited JSON messages from the QMP socket and yields +/// [`qapi_qmp::Event`]s. Command responses that arrive on the stream +/// are logged and discarded, since the writer sends commands +/// fire-and-forget. +pub(crate) struct QmpEventStream { + reader: BufReader, +} + +impl QmpEventStream { + /// Reads the next QMP event from the stream. + /// + /// Skips over command responses (which may arrive if the writer sent + /// a fire-and-forget command while the event stream was active). + /// + /// Returns `Ok(None)` when the stream is closed (QEMU exited and + /// the socket was shut down). + /// + /// # Errors + /// + /// Returns [`QemuError`] on I/O or deserialization errors. + pub async fn next_event(&mut self) -> Result, QemuError> { + loop { + let mut line = String::new(); + let bytes_read = self + .reader + .read_line(&mut line) + .await + .map_err(QemuError::QmpIo)?; + + if bytes_read == 0 { + // EOF -- QEMU exited and the socket was closed. + return Ok(None); + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + trace!("QMP recv: {trimmed}"); + + let msg: qapi_qmp::QmpMessageAny = + serde_json::from_str(trimmed).map_err(QemuError::QmpDeserialize)?; + + match msg { + QmpMessage::Event(event) => return Ok(Some(event)), + QmpMessage::Response(resp) => match resp.result() { + Ok(_) => { + // Discard command responses -- the writer + // doesn't wait for them. + trace!("QMP: discarding command success response"); + } + Err(error) => { + // Log command errors but don't propagate -- the + // writer sent fire-and-forget. + debug!( + "QMP: discarding command error response: {:?}: {}", + error.class, error.desc, + ); + } + }, + } + } + } +} + +// -- Internal helpers ------------------------------------------------- + +/// Reads a single newline-delimited JSON message from the buffered +/// reader and deserializes it into `T`. +/// +/// `on_eof` supplies the error for a connection closed before a full +/// message arrived, so each negotiation phase reports an error naming +/// the phase that actually failed. +async fn read_line_json( + reader: &mut BufReader, + on_eof: impl FnOnce() -> QemuError, +) -> Result { + let mut line = String::new(); + let bytes_read = reader + .read_line(&mut line) + .await + .map_err(QemuError::QmpIo)?; + if bytes_read == 0 { + return Err(on_eof()); + } + let trimmed = line.trim(); + trace!("QMP recv: {trimmed}"); + serde_json::from_str(trimmed).map_err(QemuError::QmpDeserialize) +} + +/// Serializes and sends a QMP command as a newline-terminated JSON +/// message. +async fn send_command( + writer: &mut OwnedWriteHalf, + command: QmpCommandName, +) -> Result<(), QemuError> { + let cmd = QmpCommand { execute: command }; + let mut payload = serde_json::to_string(&cmd).map_err(|e| QemuError::QmpCommand { + command: command.to_string(), + reason: format!("serialization failed: {e}"), + })?; + payload.push('\n'); + trace!("QMP send: {}", payload.trim()); + writer + .write_all(payload.as_bytes()) + .await + .map_err(QemuError::QmpIo)?; + writer.flush().await.map_err(QemuError::QmpIo)?; + Ok(()) +} + +// -- Tests ------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use super::*; + + // -- Greeting deserialization ------------------------------------- + + #[test] + fn deserialize_greeting() { + let json = r#"{"QMP": {"version": {"qemu": {"micro": 0, "minor": 2, "major": 9}, "package": "v9.2.0"}, "capabilities": ["oob"]}}"#; + let greeting: qapi_qmp::QapiCapabilities = serde_json::from_str(json).unwrap(); + assert_eq!(greeting.QMP.version.qemu.major, 9); + assert_eq!(greeting.QMP.version.qemu.minor, 2); + assert_eq!(greeting.QMP.version.qemu.micro, 0); + } + + #[test] + fn deserialize_greeting_without_capabilities() { + let json = r#"{"QMP": {"version": {"qemu": {"micro": 1, "minor": 0, "major": 8}, "package": ""}, "capabilities": []}}"#; + let greeting: qapi_qmp::QapiCapabilities = serde_json::from_str(json).unwrap(); + assert_eq!(greeting.QMP.version.qemu.major, 8); + assert!(greeting.QMP.capabilities.is_empty()); + } + + // -- Response deserialization ------------------------------------- + + #[test] + fn deserialize_return_response() { + let json = r#"{"return": {}}"#; + let msg: qapi_qmp::QmpMessageAny = serde_json::from_str(json).unwrap(); + match msg { + QmpMessage::Response(resp) => { + resp.result().expect("expected successful response"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + #[test] + fn deserialize_return_with_data() { + let json = r#"{"return": {"status": "running", "singlestep": false}}"#; + let msg: qapi_qmp::QmpMessageAny = serde_json::from_str(json).unwrap(); + match msg { + QmpMessage::Response(resp) => { + resp.result().expect("expected successful response"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + #[test] + fn deserialize_error_response() { + let json = r#"{"error": {"class": "GenericError", "desc": "something went wrong"}}"#; + let msg: qapi_qmp::QmpMessageAny = serde_json::from_str(json).unwrap(); + match msg { + QmpMessage::Response(resp) => match resp.result() { + Err(error) => { + assert_eq!(error.class, qapi_spec::ErrorClass::GenericError,); + assert_eq!(error.desc, "something went wrong"); + } + Ok(_) => panic!("expected error response, got success"), + }, + QmpMessage::Event(e) => panic!("expected response, got event: {e:?}"), + } + } + + // -- Event deserialization ---------------------------------------- + + #[test] + fn deserialize_shutdown_event() { + let json = r#"{"event": "SHUTDOWN", "data": {"guest": true, "reason": "guest-shutdown"}, "timestamp": {"seconds": 1234, "microseconds": 5678}}"#; + let msg: qapi_qmp::QmpMessageAny = serde_json::from_str(json).unwrap(); + match msg { + QmpMessage::Event(qapi_qmp::Event::SHUTDOWN { data, .. }) => { + assert!(data.guest); + assert_eq!(data.reason, qapi_qmp::ShutdownCause::guest_shutdown,); + } + other => panic!("expected SHUTDOWN event, got {other:?}"), + } + } + + #[test] + fn deserialize_guest_panicked_event() { + let json = r#"{"event": "GUEST_PANICKED", "data": {"action": "pause"}, "timestamp": {"seconds": 42, "microseconds": 0}}"#; + let msg: qapi_qmp::QmpMessageAny = serde_json::from_str(json).unwrap(); + match msg { + QmpMessage::Event(qapi_qmp::Event::GUEST_PANICKED { data, .. }) => { + assert_eq!(data.action, qapi_qmp::GuestPanicAction::pause); + } + other => panic!("expected GUEST_PANICKED event, got {other:?}"), + } + } + + #[test] + fn deserialize_event_without_data() { + let json = r#"{"event": "STOP", "timestamp": {"seconds": 10, "microseconds": 0}}"#; + let msg: qapi_qmp::QmpMessageAny = serde_json::from_str(json).unwrap(); + assert!( + matches!(msg, QmpMessage::Event(qapi_qmp::Event::STOP { .. })), + "expected STOP event, got {msg:?}", + ); + } + + // -- Event display ------------------------------------------------ + + #[test] + fn event_display_with_data() { + let event: qapi_qmp::Event = serde_json::from_str( + r#"{"event": "SHUTDOWN", "data": {"guest": true, "reason": "guest-shutdown"}, "timestamp": {"seconds": 0, "microseconds": 0}}"#, + ) + .unwrap(); + let display = format!("{}", EventDisplay(&event)); + assert!( + display.starts_with("SHUTDOWN"), + "expected display to start with SHUTDOWN, got: {display}", + ); + // The typed data includes both `guest` and `reason` fields. + assert!( + display.contains("guest"), + "expected display to contain guest data, got: {display}", + ); + } + + #[test] + fn event_display_without_data() { + let event: qapi_qmp::Event = serde_json::from_str( + r#"{"event": "STOP", "timestamp": {"seconds": 0, "microseconds": 0}}"#, + ) + .unwrap(); + // STOP carries no payload, so the display should be just the + // event name with no trailing data. + assert_eq!(format!("{}", EventDisplay(&event)), "STOP"); + } + + // -- Message disambiguation --------------------------------------- + + #[test] + fn messages_deserialize_unambiguously() { + // Verify that each message type deserializes to the correct + // variant and does not accidentally match another variant. + let return_json = r#"{"return": {"id": 1}}"#; + let error_json = r#"{"error": {"class": "GenericError", "desc": "Y"}}"#; + let event_json = r#"{"event": "RESET", "data": {"guest": false, "reason": "host-qmp-system-reset"}, "timestamp": {"seconds": 0, "microseconds": 0}}"#; + + match serde_json::from_str::(return_json).unwrap() { + QmpMessage::Response(r) => { + r.result() + .expect("return_json should be a success response"); + } + other => panic!("expected Response for return_json, got {other:?}"), + } + match serde_json::from_str::(error_json).unwrap() { + QmpMessage::Response(r) => { + r.result() + .expect_err("error_json should be an error response"); + } + other => panic!("expected Response for error_json, got {other:?}"), + } + assert!(matches!( + serde_json::from_str::(event_json).unwrap(), + QmpMessage::Event(qapi_qmp::Event::RESET { .. }), + )); + } +} diff --git a/n-vm/src/test_identity.rs b/n-vm/src/test_identity.rs new file mode 100644 index 0000000000..6cf32a9bc1 --- /dev/null +++ b/n-vm/src/test_identity.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! Shared test-name extraction for host and container tiers. + +/// Resolved identity for a test function. +#[derive(Debug, Clone, Copy)] +pub(crate) struct TestIdentity { + /// The fully-qualified type name after `&`-stripping. + #[allow(dead_code)] + pub full_type_name: &'static str, + + /// The portion passed to the Rust test harness with `--exact`. + pub test_name: &'static str, +} + +impl TestIdentity { + /// Resolves the test identity from a function type parameter. + /// + /// Function item type names are expected to be fully qualified + /// (`crate::module::test_fn`); the leading crate segment is stripped + /// to produce the `--exact` test name. `type_name` is documented as + /// best-effort with no format guarantee, so a name without `::` is + /// used as-is rather than treated as unreachable -- a wrong-but- + /// diagnosable test name beats a panic in the harness. + pub fn resolve() -> Self { + let full_type_name = std::any::type_name::().trim_start_matches('&'); + let test_name = full_type_name + .split_once("::") + .map_or(full_type_name, |(_, rest)| rest); + Self { + full_type_name, + test_name, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_test_function() {} + + fn resolve_for(_: F) -> TestIdentity { + TestIdentity::resolve::() + } + + #[test] + fn resolve_produces_expected_test_name() { + let id = resolve_for(dummy_test_function); + assert!( + id.full_type_name.contains("::"), + "full_type_name should contain '::': {:?}", + id.full_type_name, + ); + assert!( + !id.full_type_name.starts_with('&'), + "full_type_name should not start with '&': {:?}", + id.full_type_name, + ); + assert!( + id.full_type_name.ends_with(id.test_name), + "full_type_name {:?} should end with test_name {:?}", + id.full_type_name, + id.test_name, + ); + } + + #[test] + fn resolve_with_concrete_function_item() { + let id = resolve_for(dummy_test_function); + assert!( + id.test_name.ends_with("dummy_test_function"), + "test_name should end with 'dummy_test_function': {:?}", + id.test_name, + ); + } +} diff --git a/n-vm/src/vm.rs b/n-vm/src/vm.rs new file mode 100644 index 0000000000..791cd8d890 --- /dev/null +++ b/n-vm/src/vm.rs @@ -0,0 +1,1041 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +//! VM lifecycle management for the container tier. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use n_vm_protocol::{ + KERNEL_CONSOLE_SOCKET_PATH, TestResult, VIRTIOFS_ROOT_TAG, VIRTIOFSD_BINARY_PATH, + VIRTIOFSD_SOCKET_PATH, VM_GUEST_CID, VM_ROOT_SHARE_PATH, VsockAllocation, VsockChannel, + VsockCid, VsockPort, +}; +use rand::RngExt; +use tokio::io::AsyncReadExt; +use tokio::task::JoinHandle; +use tracing::{error, info, warn}; + +use crate::abort_on_drop::AbortOnDrop; +use crate::backend::{HypervisorBackend, HypervisorVerdict}; +use crate::config; +use crate::error::VmError; + +/// Maximum number of poll iterations before giving up on a socket. +const SOCKET_POLL_MAX_ATTEMPTS: u32 = 100; + +/// Interval between socket existence checks. +const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(5); + +/// What a KVM-accelerated VM gets on top of the work it was asked to do. +/// +/// Boot, the guest's own start-up, corpus load, shutdown and drain -- plus an +/// ordinary test body, which declares nothing and is expected to fit here. +const VM_OVERHEAD_ALLOWANCE_KVM: Duration = Duration::from_secs(60); + +/// The same for TCG (software-emulated, cross-arch), which is far slower -- +/// a guest kernel boot alone can take tens of seconds. +const VM_OVERHEAD_ALLOWANCE_TCG: Duration = Duration::from_secs(300); + +/// The VM's overhead allowance for the given acceleration mode. +const fn vm_overhead_allowance(accel: config::Accel) -> Duration { + match accel { + config::Accel::Kvm => VM_OVERHEAD_ALLOWANCE_KVM, + config::Accel::Tcg => VM_OVERHEAD_ALLOWANCE_TCG, + } +} + +/// How long the VM may run before it is shut down by force. +/// +/// The container has to be bigger than what it contains. A budget that +/// merely equalled the work would kill the guest inside its last second, +/// which for a fuzz campaign means losing the crash it was in the middle of +/// writing out -- libfuzzer saves artifacts from `DeathCallback`, after the +/// abort, so a VM killed at the wire reports nothing at all. +/// +/// So the allowance is added to the declared work rather than competing with +/// it. `guest_budget` of zero -- nothing declared, which is nearly every +/// test -- leaves this exactly where it has always been. +fn vm_test_timeout(accel: config::Accel, guest_budget: Duration) -> Duration { + vm_overhead_allowance(accel).saturating_add(guest_budget) +} + +/// The longest the guest's work was declared to take. +/// +/// Two sources, and the VM has to outlast both: a test can declare a limit in +/// its `VmConfig`, and a fuzzing engine can declare a campaign length that +/// only exists at run time, arriving via +/// [`ENV_ENGINE_TIME_LIMIT`](n_vm_protocol::ENV_ENGINE_TIME_LIMIT) from the +/// host tier. +/// +/// The two are alternatives rather than addends: they describe one stretch of +/// work, so the longer wins. +/// +/// `engine_limit` is passed in rather than read here, matching +/// [`Accel::from_env`](config::Accel::from_env) and for the same reason -- +/// the environment is the caller's to look at, and a pure function of its +/// value is one a test can exercise without mutating the process. +fn guest_budget(vm_config: &config::VmConfig, engine_limit: Option<&str>) -> Duration { + let declared = vm_config.guest_time_limit.unwrap_or(Duration::ZERO); + let from_engine = engine_limit + .and_then(|secs| secs.parse::().ok()) + .map_or(Duration::ZERO, Duration::from_secs); + declared.max(from_engine) +} + +/// The complete argument list for a virtiofsd serving one share. +/// +/// A pure function of its inputs, matching the QEMU argument builders, so the +/// policy below is asserted by unit tests instead of only being observable by +/// booting a VM and seeing whether the guest survives. +/// +/// # Why `--cache=always` on the read-only share +/// +/// virtiofsd's default is `auto`, and `auto` corrupts the guest's +/// file-backed pages. Every binary the guest runs -- `ld.so`, `libc`, the +/// test binary itself -- is mmapped from this share, and there is no DAX +/// window (neither this virtiofsd nor QEMU 11's `vhost-user-fs-pci` supports +/// one), so those mappings are served out of the guest page cache over FUSE. +/// An aarch64 guest then executes and dereferences stale or partially-filled +/// pages: garbage relocations, pointers with their low word zeroed, jumps +/// into `.rodata`. It reproduced 5/5 and was independent of the guest kernel +/// config. +/// +/// `always` is not a preference among several working settings; it is the +/// only one that works. Swept on aarch64, one test per policy, everything +/// else held fixed: +/// +/// | policy | aarch64 | x86_64 | +/// |------------|--------------------|--------| +/// | `auto` | guest faults | passes | +/// | `always` | passes | passes | +/// | `never` | guest faults | passes | +/// | `metadata` | guest faults | passes | +/// +/// So the trigger is not specifically `auto`'s timeout-driven revalidation: +/// `never` and `metadata` do not cache file contents in the guest at all and +/// fail the same way, and adding `--allow-mmap` does not rescue either. What +/// the three failing policies share is that a file page can have to be +/// fetched more than once. `always` tells the guest its page cache is +/// authoritative, so a page is read once and never refilled -- and refilling +/// is what goes wrong. +/// +/// Sound here because the share is read-only for the guest *and* immutable on +/// the host: a /nix/store closure plus the workspace, neither of which +/// changes while a VM is up. +/// +/// Every policy passes on x86_64, which is why this went unnoticed for so +/// long, and is the trap for anyone tempted to loosen it: the setting reads +/// like a performance tunable and relaxing it looks fine locally while +/// breaking only the emulated guest. Do not change it without re-running +/// that sweep on aarch64, which +/// [`N_VM_VIRTIOFS_CACHE`](n_vm_protocol::ENV_VIRTIOFS_CACHE) exists to make +/// cheap. +/// +/// The writable corpus share keeps virtiofsd's default: it must stay coherent +/// with the host that reads results back afterwards. +/// +/// `cache_override` is the caller's +/// [`N_VM_VIRTIOFS_CACHE`](n_vm_protocol::ENV_VIRTIOFS_CACHE), read at the +/// call site rather than here so this stays a pure function: reading the +/// environment inside it would make its own tests depend on whatever the +/// suite happens to run under. +fn virtiofsd_args( + path: &Path, + tag: &str, + socket: &str, + writable: bool, + cache_override: Option<&str>, +) -> Vec { + let uid = nix::unistd::getuid().as_raw(); + let gid = nix::unistd::getgid().as_raw(); + + let mut args = vec!["--shared-dir".to_owned(), path.display().to_string()]; + + if !writable { + args.push("--readonly".to_owned()); + + let cache = cache_override.unwrap_or("always"); + // `metadata` and `never` refuse to mmap a shared file unless asked, + // and the guest cannot execute a binary it cannot mmap. Passed only + // for those two, so the default path is unchanged and a sweep + // compares each policy at its best rather than failing one on a flag + // it needed. + if cache == "metadata" || cache == "never" { + args.push("--allow-mmap".to_owned()); + } + args.push(format!("--cache={cache}")); + } + + args.extend([ + "--tag".to_owned(), + tag.to_owned(), + "--socket-path".to_owned(), + socket.to_owned(), + "--announce-submounts".to_owned(), + "--sandbox=none".to_owned(), + "--rlimit-nofile=0".to_owned(), + format!("--translate-uid=squash-host:0:{uid}:{MAX}", MAX = u32::MAX), + format!("--translate-gid=squash-host:0:{gid}:{MAX}", MAX = u32::MAX), + ]); + + args +} + +/// Polls the filesystem until `path` exists, returning an error on timeout +/// or I/O failure. +pub(crate) async fn wait_for_socket(path: impl AsRef) -> Result<(), VmError> { + let path = path.as_ref(); + for _ in 0..SOCKET_POLL_MAX_ATTEMPTS { + match tokio::fs::try_exists(path).await { + Ok(true) => return Ok(()), + Ok(false) => { + tokio::time::sleep(SOCKET_POLL_INTERVAL).await; + } + Err(err) => { + return Err(VmError::SocketPoll { + path: path.to_path_buf(), + source: err, + }); + } + } + } + Err(VmError::SocketTimeout { + path: path.to_path_buf(), + timeout: SOCKET_POLL_INTERVAL.saturating_mul(SOCKET_POLL_MAX_ATTEMPTS), + }) +} + +/// Verifies that `/dev/kvm` is accessible inside the container. +/// +/// # Errors +/// +/// Returns [`VmError::KvmNotAccessible`] if `/dev/kvm` does not exist or +/// cannot be stat'd. +pub(crate) async fn check_kvm_accessible() -> Result<(), VmError> { + match tokio::fs::try_exists("/dev/kvm").await { + Ok(true) => Ok(()), + Ok(false) => Err(VmError::KvmNotAccessible(std::io::Error::new( + std::io::ErrorKind::NotFound, + "/dev/kvm does not exist", + ))), + Err(err) => Err(VmError::KvmNotAccessible(err)), + } +} + +/// Verifies that `/dev/hugepages` is accessible when host hugepages are needed. +/// +/// # Errors +/// +/// Returns [`VmError::HugepagesNotAccessible`] if `/dev/hugepages` does +/// not exist or cannot be stat'd and the host page size requires it. +pub(crate) async fn check_hugepages_accessible( + host_page_size: config::HostPageSize, + memory_bytes: i64, +) -> Result<(), VmError> { + let Some(pool) = host_page_size.pool_dir() else { + return Ok(()); + }; + + // The *pool*, not `/dev/hugepages`. + // + // Both backends allocate through `memfd` with `MFD_HUGE_*` now, so no + // hugetlbfs mount is involved at all. The old check tested whether that + // mount existed, which passed happily on a host whose pool was empty -- + // precisely the case it was supposed to catch, and one that surfaced + // instead as `unable to map backing store for guest RAM` from deep + // inside QEMU. + let free_path = format!("{pool}/free_hugepages"); + let free = match tokio::fs::read_to_string(&free_path).await { + Ok(contents) => contents.trim().parse::().unwrap_or(0), + Err(err) => { + return Err(VmError::HugepagesNotAccessible(std::io::Error::new( + err.kind(), + format!( + "cannot read {free_path}: {err}; this kernel may not support \ + {size}-byte hugepages", + size = host_page_size.bytes(), + ), + ))); + } + }; + + let page = host_page_size.bytes(); + let needed = (memory_bytes + page - 1) / page; + if (free as i64) < needed { + return Err(VmError::HugepagesNotAccessible(std::io::Error::other( + format!( + "the {size}-byte hugepage pool has {free} free page(s); this VM needs \ + {needed}. Reserve them on the host, e.g. \ + `echo {needed} > {pool}/nr_hugepages`", + size = host_page_size.bytes(), + ), + ))); + } + Ok(()) +} + +/// Collected stdout and stderr from a child process. +pub struct ProcessOutput { + /// Whether the process exited successfully (status code 0). + pub success: bool, + /// Captured stdout as a lossy UTF-8 string. + pub stdout: String, + /// Captured stderr as a lossy UTF-8 string. + pub stderr: String, +} + +impl ProcessOutput { + /// Waits for a child process to exit and collects its stdout/stderr as + /// UTF-8 strings. + async fn from_child(child: tokio::process::Child, label: &str) -> Self { + match child.wait_with_output().await { + Ok(output) => Self { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }, + Err(err) => { + error!("failed to collect {label} output: {err}"); + Self { + success: false, + stdout: String::new(), + stderr: format!("!!!OUTPUT UNAVAILABLE: {err}!!!"), + } + } + } + } + + /// Awaits a [`JoinHandle`], returning a fallback message on failure. + async fn join_task_or_fallback(handle: JoinHandle, label: &str) -> String { + match handle.await { + Ok(output) => output, + Err(err) => { + error!("failed to join {label} task: {err}"); + format!("!!!{} UNAVAILABLE: {err}!!!", label.to_uppercase()) + } + } + } + + /// Formats stdout and stderr sections with the given label prefix. + fn fmt_sections(&self, f: &mut std::fmt::Formatter<'_>, label: &str) -> std::fmt::Result { + writeln!(f, "--------------- {label} stdout ---------------")?; + write_tagged(f, &format!("{label}.out"), &self.stdout)?; + writeln!(f, "--------------- {label} stderr ---------------")?; + write_tagged(f, &format!("{label}.err"), &self.stderr) + } +} + +/// Write a captured stream with every line naming where it came from. +/// +/// The section headers below are not enough on their own. This whole report is one string, +/// printed after the guest has exited, while the host tier keeps writing to the same file +/// descriptor -- so a line appearing between two headers is not evidence that it came from +/// between them. That is not hypothetical: a libfuzzer banner belonging to a *host-side* target +/// was read as the guest's, and the mistake survived several rounds of looking at it, because +/// position inside the markers was the only evidence available. +/// +/// Every channel here already arrives separately -- the guest's stdout, stderr, `n-it` trace and +/// result each have their own vsock port, and the console and hypervisor are separate captures. +/// They were only ever merged at this last step. Tagging costs one prefix per line and makes the +/// merge reversible by anyone reading it, including with `grep`. +fn write_tagged(f: &mut std::fmt::Formatter<'_>, tag: &str, body: &str) -> std::fmt::Result { + if body.is_empty() { + return Ok(()); + } + for line in body.lines() { + writeln!(f, "[{tag}] {line}")?; + } + Ok(()) +} + +/// Parameters that vary per test invocation. +pub struct TestVmParams<'a> { + /// Full path to the test binary (e.g. `/path/to/deps/my_test-abc123`). + pub full_bin_path: &'a Path, + /// Path to the test binary as seen by the VM guest. + pub vm_bin_path: String, + /// Short binary name (filename component only, e.g. `my_test-abc123`). + pub bin_name: &'a str, + /// Fully-qualified test name (e.g. `module::test_name`). + pub test_name: &'a str, + /// VM configuration controlling memory, hugepages, IOMMU, and NICs. + pub vm_config: config::VmConfig, + /// Guest CPU architecture (= the test binary's target arch). Threaded + /// explicitly so the arg lowering is a pure function of (config, arch, + /// accel) and testable for every ISA on any build host. + pub arch: config::Arch, + /// Container-absolute path to the initramfs, when the profile's kernel + /// cannot reach its own root. + /// + /// `None` for a direct boot, which is the case whenever the root + /// filesystem transport is built in. + pub initramfs: Option, + /// How this kernel reaches its root filesystem. + /// + /// Threaded through because it changes the kernel command line, not + /// just which files are passed: an initramfs boot skips + /// `prepare_namespace` entirely, so `root=` and `rootfstype=` are never + /// read and naming them would be misleading. + pub boot: crate::kernel_manifest::BootMode, + /// Container-absolute path to the guest kernel image, resolved from the + /// kernel manifest before launch. + /// + /// Threaded through as a resolved value rather than looked up in the + /// backends for the same reason `arch` is: it keeps the argument + /// lowering a pure function of its inputs, so both backends can be + /// tested for any kernel on any host without a manifest on disk. + pub kernel_image: String, + /// Acceleration mode (KVM for same-arch, TCG for a cross-arch guest). + pub accel: config::Accel, + /// Dynamically-allocated vsock resources for this VM instance. + pub vsock: VsockAllocation, + /// The writable shares this run has, and where the guest mounts them. + /// + /// Resolved once before launch rather than probed here, so that the + /// devices the hypervisor is given and the daemons that back them come + /// from one answer. A device without its daemon does not fail: the + /// hypervisor blocks forever on a vhost-user socket nothing serves. + pub shares: Vec, +} + +/// Collected output from a test that ran inside a VM. +pub struct VmTestOutput { + /// Whether the test passed and all infrastructure exited successfully. + pub success: bool, + /// Captured stdout and stderr from the test process (via vsock). + pub test: ProcessOutput, + /// Kernel serial console output (from the guest's `ttyS0`). + pub console: String, + /// Tracing output from the `n-it` init system, streamed via vsock. + pub init_trace: String, + /// Captured stdout, stderr, and exit status of the hypervisor process. + pub hypervisor: ProcessOutput, + /// Hypervisor lifecycle events collected during the VM's lifetime. + pub hypervisor_events: B::EventLog, + /// Captured stdout, stderr, and exit status of the virtiofsd process. + pub virtiofsd: ProcessOutput, +} + +impl std::fmt::Display for VmTestOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "=============== n_vm::test RESULTS ===============")?; + writeln!(f, "--------------- {} events ---------------", B::NAME)?; + write!(f, "{}", self.hypervisor_events)?; + self.hypervisor.fmt_sections(f, B::NAME)?; + self.virtiofsd.fmt_sections(f, "virtiofsd")?; + writeln!(f, "--------------- linux console ---------------")?; + write_tagged(f, "console", &self.console)?; + writeln!(f, "--------------- init system ---------------")?; + write_tagged(f, "n-it", &self.init_trace)?; + self.test.fmt_sections(f, "guest")?; + Ok(()) + } +} + +/// Owns all long-lived resources for a running test VM. +pub struct TestVm { + /// The hypervisor child process. + hypervisor: tokio::process::Child, + /// The virtiofsd child process serving the read-only root share. + virtiofsd: tokio::process::Child, + /// One virtiofsd child process per writable share, in + /// [`n_vm_protocol::WRITABLE_SHARES`] order. + /// + /// Empty for an ordinary test. Held only so that `kill_on_drop` tears + /// the daemons down with the VM. + _share_virtiofsd: Vec, + /// Backend-specific handle for lifecycle control. + controller: B::Controller, + /// Background task watching hypervisor lifecycle events. + event_watcher: AbortOnDrop<(B::EventLog, HypervisorVerdict)>, + /// Background task collecting init system tracing output via vsock. + init_trace: AbortOnDrop, + /// Background task collecting test process stdout via vsock. + test_stdout: AbortOnDrop, + /// Background task collecting test process stderr via vsock. + test_stderr: AbortOnDrop, + /// Background task collecting the structured pass/fail verdict via vsock. + test_result: AbortOnDrop, + /// Background task collecting kernel serial console output. + kernel_log: AbortOnDrop, + /// Acceleration mode, used to scale the test timeout (TCG is slower). + accel: config::Accel, + /// How long the guest's work was declared to take, resolved at launch. + guest_budget: Duration, +} + +impl TestVm { + /// Spawns a virtiofs daemon for one share. + /// + /// `writable` is the security boundary for the corpus share, and it is + /// deliberately enforced here rather than by the guest's mount flags: a + /// fuzz target exists to drive code into misbehaving against a real + /// kernel, so a guest-side `mount -o remount,rw` must not be able to + /// reach the developer's source tree. The root daemon keeps + /// `--readonly` and therefore cannot write anywhere at all; the corpus + /// daemon can write, but its `--shared-dir` is a single `__fuzz__` + /// directory, so there is nothing else for it to reach. + async fn launch_virtiofsd( + path: impl AsRef, + tag: &str, + socket: &str, + writable: bool, + ) -> Result { + let cache_override = std::env::var(n_vm_protocol::ENV_VIRTIOFS_CACHE) + .ok() + .filter(|v| !v.is_empty()); + let mut command = tokio::process::Command::new(VIRTIOFSD_BINARY_PATH); + command + .args(virtiofsd_args( + path.as_ref(), + tag, + socket, + writable, + cache_override.as_deref(), + )) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true); + command.spawn().map_err(VmError::VirtiofsdSpawn) + } + + /// Spawns a background task that reads the kernel serial console. + fn spawn_kernel_log_reader() -> AbortOnDrop { + AbortOnDrop::spawn(async move { + if let Err(e) = wait_for_socket(KERNEL_CONSOLE_SOCKET_PATH).await { + return format!("!!!KERNEL LOG UNAVAILABLE: socket not ready: {e}!!!"); + } + match tokio::net::UnixStream::connect(KERNEL_CONSOLE_SOCKET_PATH).await { + Ok(mut stream) => { + let mut log = String::with_capacity(16_384); + if let Err(e) = stream.read_to_string(&mut log).await { + warn!("error reading kernel console: {e}"); + } + log + } + Err(e) => format!("!!!KERNEL LOG UNAVAILABLE: connect failed: {e}!!!"), + } + }) + } + + /// Prepares the environment and boots the VM. + pub async fn launch(params: &TestVmParams<'_>) -> Result { + params + .vm_config + .validate_memory_alignment() + .map_err(|reason| VmError::InvalidConfig { reason })?; + + // Unsupported capability/ISA combinations (e.g. vIOMMU on aarch64) + // are resolved to a graceful skip in the host tier before we ever + // reach launch -- see `run_test_in_vm`. A debug assert documents + // the invariant without re-introducing a hard runtime failure. + debug_assert!( + !params.vm_config.iommu || params.arch.supports_virtual_iommu(), + "vIOMMU requested on {:?}, which has no vIOMMU lowering; the host \ + tier should have skipped this test", + params.arch, + ); + + let mut virtiofsd = Self::launch_virtiofsd( + VM_ROOT_SHARE_PATH, + VIRTIOFS_ROOT_TAG, + VIRTIOFSD_SOCKET_PATH, + false, + ) + .await?; + + // virtiofsd creates its socket asynchronously after process start. + if let Err(err) = wait_for_socket(VIRTIOFSD_SOCKET_PATH).await { + config::drain_child_stderr(&mut virtiofsd, "virtiofsd").await; + return Err(err); + } + + // One writable daemon per share the host tier opened. Driven by + // `params.shares` rather than by probing the filesystem again: the + // hypervisor is about to be given exactly one device per entry, and + // a device whose daemon is missing hangs the boot on a vhost-user + // socket that never appears. + let mut share_virtiofsd = Vec::with_capacity(params.shares.len()); + for active in ¶ms.shares { + let mut child = Self::launch_virtiofsd( + active.share.container_path, + active.share.tag, + active.share.socket_path, + true, + ) + .await?; + if let Err(err) = wait_for_socket(active.share.socket_path).await { + let label = format!("virtiofsd-{role}", role = active.share.role); + config::drain_child_stderr(&mut child, &label).await; + return Err(err); + } + share_virtiofsd.push(child); + } + + // Bind readers before boot so guest-side vsock connects succeed. + let init_trace = B::spawn_vsock_reader(¶ms.vsock.init_trace)?; + let test_stdout = B::spawn_vsock_reader(¶ms.vsock.test_stdout)?; + let test_stderr = B::spawn_vsock_reader(¶ms.vsock.test_stderr)?; + let test_result = B::spawn_vsock_reader(¶ms.vsock.result)?; + + let launched = B::launch(params).await?; + + let kernel_log = Self::spawn_kernel_log_reader(); + + Ok(Self { + hypervisor: launched.child, + virtiofsd, + _share_virtiofsd: share_virtiofsd, + controller: launched.controller, + event_watcher: launched.event_watcher, + init_trace, + test_stdout, + test_stderr, + test_result, + kernel_log, + accel: params.accel, + guest_budget: guest_budget( + ¶ms.vm_config, + std::env::var(n_vm_protocol::ENV_ENGINE_TIME_LIMIT) + .ok() + .as_deref(), + ), + }) + } + + /// Waits for the test to finish and collects output from all subsystems. + pub async fn collect(self) -> VmTestOutput { + let Self { + hypervisor, + virtiofsd, + // Dropped here, which kills the writable daemons now that the + // guest is finished with them. Their output is not collected: + // each serves a single directory and has no verdict to report. + _share_virtiofsd, + controller, + event_watcher, + init_trace, + test_stdout, + test_stderr, + test_result, + kernel_log, + accel, + guest_budget, + } = self; + + let event_watcher = event_watcher.into_inner(); + let init_trace = init_trace.into_inner(); + let test_stdout = test_stdout.into_inner(); + let test_stderr = test_stderr.into_inner(); + let test_result = test_result.into_inner(); + let kernel_log = kernel_log.into_inner(); + + // Wait for a terminal event, or force shutdown on timeout. The + // budget is the work the guest was given plus the allowance for the + // acceleration mode, which also covers boot -- TCG (cross-arch + // emulation) is much slower than KVM. + let timeout = vm_test_timeout(accel, guest_budget); + let (hypervisor_events, hypervisor_verdict) = tokio::select! { + biased; + result = event_watcher => { + match result { + Ok(r) => r, + Err(err) => { + error!("hypervisor event watcher task failed: {err}"); + (B::EventLog::default(), HypervisorVerdict::Failure) + } + } + } + _ = tokio::time::sleep(timeout) => { + warn!( + "VM test did not complete within {timeout:?} ({accel:?} \ + allowance {allowance:?} + declared work {guest_budget:?}); \ + forcing hypervisor shutdown to collect diagnostics", + allowance = vm_overhead_allowance(accel), + ); + (B::EventLog::default(), HypervisorVerdict::Failure) + } + }; + + B::shutdown(&controller).await; + + const DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + + let init_trace = drain_or_fallback(init_trace, "init system trace", DRAIN_TIMEOUT).await; + let test_stdout = drain_or_fallback(test_stdout, "test stdout", DRAIN_TIMEOUT).await; + let test_stderr = drain_or_fallback(test_stderr, "test stderr", DRAIN_TIMEOUT).await; + let test_result = drain_or_fallback(test_result, "test result", DRAIN_TIMEOUT).await; + + let hypervisor_output = ProcessOutput::from_child(hypervisor, B::NAME).await; + + let kernel_log = drain_or_fallback(kernel_log, "kernel log", DRAIN_TIMEOUT).await; + + let virtiofsd_output = ProcessOutput::from_child(virtiofsd, "virtiofsd").await; + + // The guest init system reports the verdict explicitly over the + // result channel. An absent or unparseable verdict is a FAILURE: + // the guest never confirmed success, so we must not pass. + let test_passed = match TestResult::parse(&test_result) { + Some(result) => { + if !result.passed { + warn!("guest reported test failure: {}", result.detail); + } + result.passed + } + None => { + error!( + "no parseable test verdict from guest (channel contents: {test_result:?}); \ + treating as failure" + ); + false + } + }; + + let test_output = ProcessOutput { + success: test_passed, + stdout: test_stdout, + stderr: test_stderr, + }; + + VmTestOutput { + success: test_output.success + && virtiofsd_output.success + && hypervisor_verdict.is_success() + && hypervisor_output.success, + test: test_output, + console: kernel_log, + init_trace, + hypervisor: hypervisor_output, + hypervisor_events, + virtiofsd: virtiofsd_output, + } + } +} + +/// Awaits a string-producing task with a timeout and fallback message. +async fn drain_or_fallback(handle: JoinHandle, label: &str, timeout: Duration) -> String { + match tokio::time::timeout(timeout, ProcessOutput::join_task_or_fallback(handle, label)).await { + Ok(output) => output, + Err(_) => { + warn!("{label} did not complete within {timeout:?} after shutdown"); + format!( + "!!!{} UNAVAILABLE: timed out after shutdown!!!", + label.to_uppercase() + ) + } + } +} + +/// Allocates a random CID and four consecutive vsock ports. +fn allocate_vsock_resources() -> VsockAllocation { + let mut rng = rand::rng(); + + // CIDs are host-global; skip VM_GUEST_CID (== GUEST_MIN) so dynamic + // allocations never collide with the legacy static CID used by + // `VsockAllocation::with_defaults()`. + let cid_min = VM_GUEST_CID.as_raw() + 1; + let cid = rng.random_range(cid_min..=VsockCid::GUEST_MAX.as_raw()); + + // Reserve trace, stdout, stderr, and result. + let port_max = VsockPort::DYNAMIC_MAX.as_raw() - 3; + let port_base = rng.random_range(VsockPort::DYNAMIC_MIN.as_raw()..=port_max); + + VsockAllocation { + cid: VsockCid::new(cid), + init_trace: VsockChannel { + port: VsockPort::new(port_base), + label: "init-trace", + }, + test_stdout: VsockChannel { + port: VsockPort::new(port_base + 1), + label: "test-stdout", + }, + test_stderr: VsockChannel { + port: VsockPort::new(port_base + 2), + label: "test-stderr", + }, + result: VsockChannel { + port: VsockPort::new(port_base + 3), + label: "test-result", + }, + } +} + +/// Launches a VM, runs the test, and collects output. +/// +/// # Errors +/// +/// Returns [`VmError`] if any part of the VM launch sequence fails. +/// Output collection is best-effort and never fails -- see +/// [`TestVm::collect`]. +pub async fn run_in_vm( + _: F, + vm_config: config::VmConfig, + accel: config::Accel, +) -> Result, VmError> { + let identity = crate::test_identity::TestIdentity::resolve::(); + let test_name = identity.test_name; + + let full_bin_path = std::env::args().next().ok_or(VmError::MissingArgv)?; + let (_, bin_name) = + full_bin_path + .rsplit_once("/") + .ok_or_else(|| VmError::InvalidBinaryPath { + path: PathBuf::from(&full_bin_path), + })?; + + let vm_bin_path = format!("/{}/{bin_name}", n_vm_protocol::VM_TEST_BIN_DIR); + + let vsock = allocate_vsock_resources(); + info!("allocated vsock resources: {vsock}"); + + // The guest arch is this binary's target arch. + let arch = config::Arch::current(); + + // Which kernels exist is a fact about the nix build, so it is read from + // the manifest nix materialized into `testroot` rather than hardcoded + // here. Resolved once, before launch, so a bad manifest fails with a + // manifest error instead of a VM that boots nothing. + let manifest = crate::kernel_manifest::KernelManifest::load()?; + let (profile_name, profile) = + manifest.selected(vm_config.kernel_profile, accel == config::Accel::Tcg)?; + profile.check_arch(profile_name, arch)?; + info!( + "using kernel profile `{profile_name}` ({hypervisor}, {kernel})", + hypervisor = profile.hypervisor, + kernel = profile.kernel, + ); + + // Check what the test says it needs against what this kernel actually + // has, before booting. A missing symbol otherwise surfaces as whatever + // the feature's absence breaks -- an ioctl returning ENOTTY, a filter + // that will not attach -- several tiers away from the cause. + // + // Skipped when the manifest records no config: that is a gap in the + // profile, not a licence to ignore what the test asked for, so it is + // logged rather than passing quietly. + if !vm_config.kernel_features.is_empty() { + match &profile.config { + Some(path) => { + let kernel_config = + crate::kernel_config::KernelConfig::load(std::path::Path::new(path))?; + let unmet = crate::kernel_feature::unmet_requirements( + vm_config.kernel_features, + &kernel_config, + ); + if !unmet.is_empty() { + return Err(VmError::KernelFeaturesUnmet { + missing: unmet.into_iter().map(|u| u.symbol).collect(), + }); + } + } + None => warn!( + "kernel profile `{profile_name}` records no config, so the \ + {n} feature(s) this test requires cannot be verified", + n = vm_config.kernel_features.len(), + ), + } + } + + let params = TestVmParams { + full_bin_path: Path::new(&full_bin_path), + vm_bin_path, + bin_name, + test_name, + vm_config, + arch, + kernel_image: profile.kernel.clone(), + initramfs: profile.initramfs.clone(), + boot: profile.boot, + accel, + vsock, + shares: config::ActiveShare::resolve(), + }; + + let vm = TestVm::::launch(¶ms).await?; + Ok(vm.collect().await) +} + +#[cfg(test)] +mod timeout_tests { + use super::*; + + /// Nothing declared is the ordinary case, and it must leave the budget + /// exactly where it was before any of this existed. + #[test] + fn an_ordinary_test_gets_what_it_always_got() { + assert_eq!( + vm_test_timeout(config::Accel::Kvm, Duration::ZERO), + VM_OVERHEAD_ALLOWANCE_KVM, + ); + assert_eq!( + vm_test_timeout(config::Accel::Tcg, Duration::ZERO), + VM_OVERHEAD_ALLOWANCE_TCG, + ); + } + + /// The container has to be bigger than what it contains. A ten-minute + /// campaign in a ten-minute VM is the bug this exists to prevent. + #[test] + fn the_budget_always_exceeds_the_work() { + for secs in [1, 60, 600, 36_000] { + let work = Duration::from_secs(secs); + for accel in [config::Accel::Kvm, config::Accel::Tcg] { + assert!( + vm_test_timeout(accel, work) > work, + "{accel:?}: {secs}s of work must not get a {secs}s VM", + ); + } + } + } + + #[test] + fn declared_work_is_added_to_the_allowance() { + assert_eq!( + vm_test_timeout(config::Accel::Kvm, Duration::from_secs(600)), + VM_OVERHEAD_ALLOWANCE_KVM + Duration::from_secs(600), + ); + } + + /// The two sources are alternatives, not addends: a campaign running + /// inside a test that also declared a limit takes the longer of the two, + /// because it is one stretch of work described twice. + #[test] + fn the_longer_of_the_two_declarations_wins() { + let declared = config::VmConfig { + guest_time_limit: Some(Duration::from_secs(600)), + ..config::VmConfig::DEFAULT + }; + assert_eq!( + guest_budget(&declared, Some("60")), + Duration::from_secs(600), + "a short campaign must not shrink a longer declared limit", + ); + assert_eq!( + guest_budget(&declared, Some("900")), + Duration::from_secs(900), + "a long campaign must not be capped by a shorter declared limit", + ); + assert_eq!(guest_budget(&declared, None), Duration::from_secs(600)); + } + + #[test] + fn a_test_that_declares_nothing_declares_nothing() { + let default = config::VmConfig::DEFAULT; + assert_eq!(guest_budget(&default, None), Duration::ZERO); + assert_eq!( + guest_budget(&default, Some("600")), + Duration::from_secs(600), + "a campaign alone is enough to extend the budget", + ); + } + + /// A malformed value must not silently read as "no work declared" for a + /// campaign that is genuinely running -- but it is the host tier that + /// writes it, from a parsed `Duration`, so this is only reachable if + /// something else set it. + #[test] + fn a_malformed_engine_limit_falls_back_to_declaring_nothing() { + assert_eq!( + guest_budget(&config::VmConfig::DEFAULT, Some("ages")), + Duration::ZERO, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(writable: bool, cache: Option<&str>) -> Vec { + virtiofsd_args( + Path::new("/share"), + "root", + "/run/virtiofsd.sock", + writable, + cache, + ) + } + + /// The read-only share must be served with `--cache=always`. + /// + /// The one assertion standing between us and a repeat of the bug that + /// made every aarch64 test fail. Worth a unit test rather than only the + /// in-guest coverage, because the in-guest symptom is the guest dying in + /// `ld.so` before any test body runs -- which reads as "the VM is broken" + /// and sends the next person looking at kernel configs, as it did. + #[test] + fn read_only_share_is_served_with_cache_always() { + let args = args(false, None); + assert!( + args.contains(&"--cache=always".to_owned()), + "read-only share must be `--cache=always`; see the sweep in \ + `virtiofsd_args`: {args:?}", + ); + assert!(args.contains(&"--readonly".to_owned()), "{args:?}"); + } + + /// The writable corpus share keeps virtiofsd's default. + /// + /// `always` would tell the guest its cache is authoritative for a + /// directory the host reads back afterwards. It is also how the corpus + /// share was first broken: applying the override to both daemons made the + /// corpus read-only to the guest. + #[test] + fn writable_share_gets_no_cache_policy() { + let args = args(true, None); + assert!( + !args.iter().any(|a| a.starts_with("--cache")), + "writable share must keep virtiofsd's default: {args:?}", + ); + assert!( + !args.contains(&"--readonly".to_owned()), + "the corpus share is writable: {args:?}", + ); + } + + /// The override reaches virtiofsd, which is what makes a policy sweep a + /// single build plus an environment variable. + #[test] + fn cache_override_is_honoured() { + assert!(args(false, Some("auto")).contains(&"--cache=auto".to_owned())); + assert!(args(false, Some("never")).contains(&"--cache=never".to_owned())); + } + + /// The restrictive policies get `--allow-mmap`, and only they do. + /// + /// Without it they refuse to mmap a shared file and the guest cannot + /// execute anything, so a sweep would fail them on a missing flag rather + /// than on the property being measured. (It does not save them: both + /// still fault on aarch64.) + #[test] + fn only_restrictive_policies_allow_mmap() { + for policy in ["metadata", "never"] { + assert!( + args(false, Some(policy)).contains(&"--allow-mmap".to_owned()), + "{policy} needs --allow-mmap to serve an executable", + ); + } + for policy in ["always", "auto"] { + assert!( + !args(false, Some(policy)).contains(&"--allow-mmap".to_owned()), + "{policy} caches contents and does not need --allow-mmap", + ); + } + } + + /// The corpus share's isolation is `--shared-dir`, not guest mount flags: + /// a fuzz target that remounts rw must still reach only `__fuzz__`. + #[test] + fn each_share_is_scoped_to_its_own_directory() { + let args = args(true, None); + let i = args + .iter() + .position(|a| a == "--shared-dir") + .expect("present"); + assert_eq!(args[i + 1], "/share", "{args:?}"); + } +} diff --git a/n-vm/tests/integration.rs b/n-vm/tests/integration.rs new file mode 100644 index 0000000000..d84101d8d2 --- /dev/null +++ b/n-vm/tests/integration.rs @@ -0,0 +1,684 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Open Network Fabric Authors + +use n_vm::{ + CorpusPolicy, GuestHugePageConfig, GuestHugePageSize, GuestRuntime, HostPageSize, ModuleParam, + RequestedBackend, VmConfig, VmConfigBuilder, features, +}; + +/// Reads the first `len` bytes of `path`, or the whole file if it is shorter. +fn read_prefix(path: &str, len: usize) -> Vec { + use std::io::Read; + + let mut file = std::fs::File::open(path).unwrap_or_else(|e| panic!("cannot open {path}: {e}")); + let mut buf = Vec::with_capacity(len); + file.by_ref() + .take(len as u64) + .read_to_end(&mut buf) + .unwrap_or_else(|e| panic!("cannot read {path}: {e}")); + assert!(!buf.is_empty(), "{path} is empty"); + buf +} + +fn hugepages_total() -> u64 { + std::fs::read_to_string("/proc/meminfo") + .unwrap() + .lines() + .find(|l| l.starts_with("HugePages_Total:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +// -- Configurations under test ---------------------------------------- +// +// Named once and shared, which is most of the point: the same VM shape can +// be exercised on both backends without restating it, and the names say what +// each one is for at the call site. + +/// A virtual IOMMU. Both backends support one; only the aarch64 *guest* +/// lacks a lowering, which the container tier resolves to a skip. +const IOMMU_VM: VmConfig = VmConfig { + iommu: true, + ..VmConfig::DEFAULT +}; + +/// 1 GiB host pages, which is the one thing the default deliberately does not ask for. +/// +/// The default backs guest memory with 4 KiB pages because the host pool is small, unarbitrated, +/// and irrelevant to a guest that is not driving DPDK through an IOMMU. That makes this the only +/// test of the hugetlbfs path, and it is here so that flipping the default did not silently +/// delete the coverage it used to get for free. +const HOST_1G_VM: VmConfig = VmConfig { + host_page_size: HostPageSize::Huge1G, + ..VmConfig::DEFAULT +}; + +/// The same VMs on QEMU. +/// +/// Two configurations rather than one config and a backend argument, because +/// that is what they are: the pair exists to check that both hypervisors +/// present the same guest, which is only a claim worth making if each side +/// names the machine it booted. `to_builder` keeps the derivation to one +/// line, so the shape is still stated once. +const IOMMU_VM_QEMU: VmConfig = IOMMU_VM + .to_builder() + .backend(RequestedBackend::Qemu) + .build(); +const HOST_1G_VM_QEMU: VmConfig = HOST_1G_VM + .to_builder() + .backend(RequestedBackend::Qemu) + .build(); + +/// QEMU, otherwise default. Pinned because these assert on the initramfs +/// boot path, which only QEMU takes under the `modular` profile. +const QEMU_VM: VmConfig = VmConfigBuilder::default() + .backend(RequestedBackend::Qemu) + .build(); + +/// No guest hugepage reservation at all. +const NO_GUEST_HUGEPAGES_VM: VmConfig = VmConfig { + guest_hugepages: GuestHugePageConfig::None, + ..VmConfig::DEFAULT +}; + +/// 64 x 2 MiB guest hugepages. +const GUEST_2M_HUGEPAGES_VM: VmConfig = VmConfig { + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge2M, + count: 64, + }, + ..VmConfig::DEFAULT +}; + +/// 4 KiB host pages *and* 64 x 2 MiB guest hugepages -- the guest can back +/// hugepages that the host is not itself backing with hugepages. +const HOST_4K_GUEST_2M_VM: VmConfig = VmConfig { + host_page_size: HostPageSize::Standard, + guest_hugepages: GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge2M, + count: 64, + }, + iommu: true, + ..VmConfig::DEFAULT +}; + +/// The same, on QEMU. +const HOST_4K_GUEST_2M_VM_QEMU: VmConfig = HOST_4K_GUEST_2M_VM + .to_builder() + .backend(RequestedBackend::Qemu) + .build(); + +/// The multi-threaded guest runtime, with a pinned worker count. +const MULTI_THREAD_VM: VmConfig = VmConfigBuilder::default() + .runtime(GuestRuntime::MultiThread { + worker_threads: Some(2), + }) + .build(); + +/// Declares the kernel features it actually leans on. +/// +/// `hugetlbfs` and the `tc` flower classifier are both checked against the +/// kernel's own config before the VM boots, so a fragment list that stopped +/// providing them would fail here by name rather than somewhere far from the +/// cause. +const TC_VM: VmConfig = VmConfig { + kernel_features: &[features::HUGETLBFS, features::NET_CLS_FLOWER], + // 4 KiB host pages so this runs on a host with no hugepage reservation. + host_page_size: HostPageSize::Standard, + ..VmConfig::DEFAULT +}; + +#[n_vm::test] +fn test_which_runs_in_vm() { + assert_eq!(2 + 2, 4); +} + +/// The declared-requirement path, end to end: these features are verified +/// against `kernels//config` before launch, and the VM boots. +#[n_vm::test(config = TC_VM)] +fn vm_boots_with_declared_kernel_features() { + // `tc` flower needs both the classifier and action support; if the + // pre-boot check passed, the kernel really does have them. + assert!(std::path::Path::new("/proc/net").exists()); +} + +// NOTE: there is deliberately no `#[n_vm::test] #[should_panic]` negative +// control here. `#[should_panic]` does not compose with `#[n_vm::test]` (the +// body runs in a separate VM-guest process across three dispatch tiers, so +// the panic is absorbed inconsistently) and the macro now rejects it. The +// "does the harness actually detect failures" property is covered at the +// unit level by the verdict-decoding tests (`cloud_hypervisor::events`, +// `n_vm_protocol::TestResult` parse tests: an absent/failed verdict -> +// failure), not by a panicking end-to-end test. + +/// A file on the read-only share reads back the same after its pages are +/// dropped and refetched. +/// +/// This is the property the harness silently depended on and never checked, +/// and getting it wrong cost a lot: every aarch64 test failed for a whole +/// debugging session, presenting as guest userspace corruption -- garbage ELF +/// relocations, pointers with their low word zeroed, jumps into `.rodata` -- +/// which looked convincingly like a guest kernel misconfiguration and was +/// chased as one through many kernel configs before virtiofsd's cache policy +/// turned out to be the cause. +/// +/// Refetching is the specific thing to exercise. Sweeping virtiofsd's four +/// policies on aarch64 showed `auto`, `never` and `metadata` all faulting and +/// only `always` surviving, and what the three failures share is that a file +/// page can have to be fetched more than once. So this drops the guest page +/// cache between two reads to force exactly that, rather than reading twice +/// and being served the same cached copy both times. +/// +/// `drop_caches` frees only clean, unreferenced pages, so it cannot evict the +/// running binary's own mapped text -- the process stays alive to make the +/// assertion, which a broader eviction would not allow. +/// +/// Deliberately not a hash against a value baked in at build time: the share +/// serves the developer's live workspace, so any uncommitted edit would fail +/// such a test for a reason that has nothing to do with virtiofs. +#[n_vm::test] +fn file_on_read_only_share_survives_a_page_cache_drop() { + // The test binary itself: guaranteed present, served by the same + // virtiofsd as everything else the guest executes, and far larger than + // one page, so the comparison spans many. + const SLICE: usize = 4 * 1024 * 1024; + let exe = std::fs::read_link("/proc/self/exe") + .expect("the guest should be able to resolve /proc/self/exe"); + let exe = exe.to_str().expect("test binary path should be UTF-8"); + + let before = read_prefix(exe, SLICE); + + // 3 = page cache + reclaimable slab. A write to this is what forces the + // next read to come from the host again instead of the guest's cache. + std::fs::write("/proc/sys/vm/drop_caches", b"3\n") + .expect("the guest should be able to drop its page cache"); + + let after = read_prefix(exe, SLICE); + + assert_eq!( + before.len(), + after.len(), + "read {} bytes before dropping caches and {} after", + before.len(), + after.len(), + ); + // Compared by position rather than with `assert_eq!` on the buffers, + // because a mismatch dumping 4 MiB of bytes is unreadable and the offset + // is the useful part: it says which page came back wrong. + if let Some(at) = (0..before.len()).find(|&i| before[i] != after[i]) { + panic!( + "{exe} changed across a page cache drop at offset {at} \ + (0x{at:x}, page {page}): {b:#04x} -> {a:#04x}. The guest \ + refetched this file and got different bytes, so mappings of it \ + -- including executable ones -- cannot be trusted.", + page = at / 4096, + b = before[at], + a = after[at], + ); + } +} + +#[n_vm::test] +fn root_filesystem_in_vm_is_read_only() { + let error = std::fs::File::create_new("/some.file").unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::ReadOnlyFilesystem); +} + +#[n_vm::test] +fn run_filesystem_in_vm_is_read_write() { + std::fs::File::create_new("/run/some.file").unwrap(); +} + +#[n_vm::test] +fn tmp_filesystem_in_vm_is_read_write() { + std::fs::File::create_new("/tmp/some.file").unwrap(); +} + +#[n_vm::test(config = IOMMU_VM)] +fn test_which_runs_in_vm_with_iommu() { + assert_eq!(2 + 2, 4); +} + +#[n_vm::test(config = IOMMU_VM_QEMU)] +fn test_which_runs_in_vm_with_qemu_iommu() { + assert_eq!(2 + 2, 4); +} + +#[n_vm::test(config = HOST_1G_VM)] +#[ignore = "needs a 1 GiB host hugepage; CI runners reserve none (boot parameter required)"] +fn vm_boots_with_host_hugepages() { + assert!(std::path::Path::new("/proc/meminfo").exists()); +} + +#[n_vm::test(config = HOST_1G_VM_QEMU)] +#[ignore = "needs a 1 GiB host hugepage; CI runners reserve none (boot parameter required)"] +fn vm_boots_with_host_hugepages_on_qemu() { + assert!(std::path::Path::new("/proc/meminfo").exists()); +} + +#[n_vm::test(config = NO_GUEST_HUGEPAGES_VM)] +fn vm_boots_without_guest_hugepages() { + assert_eq!( + hugepages_total(), + 0, + "expected no guest hugepages when hugepage_size = none" + ); +} + +#[n_vm::test(config = GUEST_2M_HUGEPAGES_VM)] +fn vm_boots_with_2m_guest_hugepages() { + assert_eq!( + hugepages_total(), + 64, + "expected 64 guest hugepages from kernel reservation" + ); +} + +#[n_vm::test(config = HOST_4K_GUEST_2M_VM_QEMU)] +async fn vm_boots_with_4k_host_and_2m_guest_hugepages_on_qemu() { + assert_eq!( + hugepages_total(), + 64, + "expected 64 guest hugepages with 4K host backing" + ); +} + +#[n_vm::test] +async fn tokio_test_current_thread_default() { + let contents = tokio::fs::read_to_string("/proc/version").await.unwrap(); + assert!(contents.contains("Linux")); +} + +#[n_vm::test(config = MULTI_THREAD_VM)] +async fn tokio_test_multi_thread() { + let handle = tokio::spawn(async { tokio::fs::read_to_string("/proc/version").await.unwrap() }); + let contents = handle.await.unwrap(); + assert!(contents.contains("Linux")); +} + +/// Everything [`CorpusPolicy::Fuzz`] changes about a guest, in one boot. +/// +/// Two claims, kept together because the second costs nothing once the VM +/// is up and a fuzz target of its own would be announced to +/// `cargo bolero list` as one containing no `check!`. +/// +/// **The writable window is exactly one directory wide.** +/// +/// This is the security boundary that makes a writable share acceptable at +/// all: a fuzz target is deliberately provoking misbehaviour, so it must be +/// able to save inputs without being able to damage the rest of the +/// developer's source tree. The split is enforced by *which virtiofs daemon +/// serves which path* -- the root daemon runs `--readonly` and the corpus +/// daemon's `--shared-dir` is the corpus directory alone -- so it holds +/// regardless of what the guest does with its own mount flags. +/// +/// This run has no engine, so the corpus falls back to `bolero`'s own +/// `__fuzz__` beside this file. Under `cargo bolero test` the directories +/// come from the engine instead, and there are two of them. +/// +/// **The guest reserves no hugepages.** +/// +/// The policy alone decides this -- the config says nothing about +/// hugepages, so `guest_hugepages` is [`GuestHugePageConfig::Auto`]. See +/// `VmConfig::hugepage_reservation` for why the two roles want opposite +/// answers. +#[n_vm::test] +fn corpus_is_writable_and_rest_of_workspace_is_not() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .corpus(CorpusPolicy::Fuzz) + .build(); + + assert_eq!( + hugepages_total(), + 0, + "a fuzz target should boot with no hugepage reservation", + ); + + let cwd = std::env::current_dir().expect("workspace should be the working directory"); + + let corpus = cwd.join("n-vm/tests/__fuzz__"); + let probe = corpus.join(".write_probe"); + std::fs::write(&probe, b"probe").expect("the corpus directory must be writable"); + std::fs::remove_file(&probe).expect("the corpus probe must be removable"); + + // Anything else under the workspace is served by the read-only daemon. + for path in [cwd.join(".write_probe"), cwd.join("n-vm/.write_probe")] { + let err = + std::fs::write(&path, b"probe").expect_err("only the corpus directory may be writable"); + assert_eq!( + err.kind(), + std::io::ErrorKind::ReadOnlyFilesystem, + "writing {path:?} should fail as read-only, got {err}", + ); + } +} + +// -- The initramfs boot path ------------------------------------------ +// +// These pin QEMU so they can run under the `modular` profile, whose kernel +// has virtiofs as a module and so can only reach its root through an +// initramfs (`N_VM_PROFILE=modular`). Under a direct-boot profile they +// still run and assert the same invariants, which is the point: the guest +// is supposed to look identical either way, and only the route to it +// differs. + +/// The root must be the read-only virtiofs share, not the writable rootfs +/// the initramfs started in. +/// +/// This is the check that the `switch_root` actually happened. If the +/// pre-init failed to move the new root over `/`, the guest would still be +/// sitting in a perfectly functional tmpfs and almost everything else would +/// keep working -- so a test that merely boots proves much less than it +/// appears to. +#[n_vm::test(config = QEMU_VM)] +fn root_is_read_only_after_switch_root() { + let err = std::fs::File::create_new("/some.file").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::ReadOnlyFilesystem); +} + +/// `n-it`'s own mounts land on the new root, not the abandoned one. +#[n_vm::test(config = QEMU_VM)] +fn n_it_mounts_survive_switch_root() { + std::fs::File::create_new("/run/probe").expect("/run should be a writable tmpfs"); + assert!( + std::path::Path::new("/proc/self").exists(), + "procfs mounted" + ); + assert!( + std::path::Path::new("/sys/kernel").exists(), + "sysfs mounted" + ); +} + +// -- The inline `#[n_vm::config]` form -------------------------------- +// +// `config = PATH` still works and every test above still uses it. These two +// exist to pin what the inline form adds: a configuration written at the call +// site, in ordinary Rust, that still reaches the guest. + +/// A module parameter declared inline reaches the guest's command line. +/// +/// Asserts on `/proc/cmdline` rather than on the config value. The value is +/// what the host tier *asked* for; the command line is what the guest was +/// actually booted with, and everything this form changed sits between the +/// two -- the const is lifted out of the body by the macro, folded into the +/// generated `VmConfig`, and rendered by `build_kernel_cmdline`. +/// +/// The type is spelled `_` on purpose: the placeholder is illegal in a real +/// const item (E0121), and this passes only because the macro deletes the +/// item before rustc ever resolves it. +#[n_vm::test] +fn an_inline_config_reaches_the_guest_cmdline() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .guest_hugepages(GuestHugePageConfig::None) + .module_params(&[ModuleParam::new("vfio-pci", "disable_idle_d3", "1")]) + .build(); + + let cmdline = std::fs::read_to_string("/proc/cmdline").expect("procfs should be mounted"); + assert!( + cmdline.contains("vfio-pci.disable_idle_d3=1"), + "declared module parameter is missing from {cmdline:?}", + ); + assert_eq!( + hugepages_total(), + 0, + "inline guest_hugepages(None) should have reached the guest", + ); +} + +/// The same VM as `GUEST_2M_HUGEPAGES_VM`, written inline. +/// +/// Kept alongside the `config = PATH` test it mirrors +/// (`vm_boots_with_2m_guest_hugepages`) so that the two forms are known to +/// produce the same guest rather than assumed to. Spelled with an explicit +/// type, which is the form that also compiles outside this macro. +#[n_vm::test] +fn an_inline_config_matches_the_named_const_it_mirrors() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .guest_hugepages(GuestHugePageConfig::Allocate { + size: GuestHugePageSize::Huge2M, + count: 64, + }) + .kernel_features(&[features::HUGETLBFS]) + .build(); + + assert_eq!( + hugepages_total(), + 64, + "expected 64 guest hugepages from the inline reservation" + ); +} + +/// No `#[n_vm::config]`, no `config = PATH`: the plain form still boots. +/// +/// The path the other two are measured against, and the one every test +/// written before either form existed takes. Kept explicit because "we did +/// not break the default" is otherwise only ever asserted incidentally, by +/// tests that are checking something else. +#[n_vm::test] +fn a_default_vm() { + assert!( + std::path::Path::new("/proc/self").exists(), + "a default VM should still boot a guest with procfs mounted", + ); +} + +// -- What the machine is made of -------------------------------------- +// +// Each of these asserts from inside the booted guest rather than against +// the arguments the harness produced. An argument records what was asked +// for; a hypervisor that silently declined it -- or accepted it and built +// something else -- leaves every unit test green. + +/// Total memory as the guest sees it, in KiB. +fn mem_total_kib() -> u64 { + std::fs::read_to_string("/proc/meminfo") + .expect("guest has /proc/meminfo") + .lines() + .find_map(|l| l.strip_prefix("MemTotal:")) + .and_then(|v| v.split_whitespace().next()) + .and_then(|v| v.parse().ok()) + .expect("MemTotal is a number") +} + +/// How many CPUs the guest brought online. +fn online_cpus() -> usize { + std::fs::read_to_string("/proc/cpuinfo") + .expect("guest has /proc/cpuinfo") + .lines() + .filter(|l| l.starts_with("processor")) + .count() +} + +/// Every interface the guest enumerated, as `(name, MAC)`, sorted. +/// +/// Loopback is dropped: it is not a NIC and it has no MAC worth naming. +fn guest_links() -> Vec<(String, String)> { + let mut links: Vec<(String, String)> = std::fs::read_dir("/sys/class/net") + .expect("guest has /sys/class/net") + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| name != "lo") + .map(|name| { + let mac = std::fs::read_to_string(format!("/sys/class/net/{name}/address")) + .unwrap_or_default() + .trim() + .to_ascii_uppercase(); + (name, mac) + }) + .collect(); + links.sort(); + links +} + +/// The kernel driver bound to each interface, sorted. +/// +/// This, rather than the MAC, is what says the guest saw a *different +/// device*: the harness derives every MAC the same way whatever the model +/// is, so a machine that presented one device three times would still show +/// three distinct addresses. +fn guest_nic_drivers() -> Vec { + let mut drivers: Vec = guest_links() + .into_iter() + .filter_map(|(name, _)| { + std::fs::read_link(format!("/sys/class/net/{name}/device/driver")) + .ok()? + .file_name() + .map(|d| d.to_string_lossy().into_owned()) + }) + .collect(); + drivers.sort(); + drivers +} + +/// The VM is the size it asked for, not the size this crate used to hard-code. +#[n_vm::test] +fn a_vm_is_the_size_it_asked_for() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default().memory_mib(2048).vcpus(2).build(); + + assert_eq!(online_cpus(), 2, "the guest should have brought up 2 vCPUs"); + + // A band, not an equality: the guest kernel's own reservations come off + // MemTotal before userspace ever sees it, so the exact figure is a + // property of the kernel rather than of the lever under test. What + // matters is that it is the 2 GiB that was asked for and not the 1 GiB + // default. + let mib = mem_total_kib() / 1024; + assert!( + (1536..=2048).contains(&mib), + "a 2048 MiB VM reported {mib} MiB of RAM", + ); +} + +/// The same lever, on the other hypervisor. +#[n_vm::test] +fn a_vm_is_the_size_it_asked_for_on_qemu() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .kernel_profile(n_vm::kernel_profiles::QEMU) + .memory_mib(2048) + .vcpus(2) + .build(); + + assert_eq!(online_cpus(), 2); + let mib = mem_total_kib() / 1024; + assert!( + (1536..=2048).contains(&mib), + "a 2048 MiB VM reported {mib} MiB of RAM", + ); +} + +/// The fabric is as wide as the test said. +/// +/// **Pinned to QEMU, and not because the lever is.** A fabric link does not +/// reach a cloud-hypervisor guest at all: they sit on PCI segment 1 and that +/// guest enumerates nothing there, with or without the vIOMMU. That +/// predates this lever -- the two links the VM has always had were equally +/// invisible -- so this names the profile that shows the interfaces rather +/// than asserting something known to be false. +#[n_vm::test] +fn a_vm_gets_the_fabric_links_it_asked_for() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .kernel_profile(n_vm::kernel_profiles::QEMU) + .fabric_nics(4) + .build(); + + // Sorted by address rather than left in device order: which interface + // the kernel names `eth0` is up to the kernel, and this is asserting + // which links exist, not what they were called. + let mut macs: Vec = guest_links().into_iter().map(|(_, mac)| mac).collect(); + macs.sort(); + assert_eq!( + macs, + vec![ + "02:CA:FE:BA:BE:01", + "02:CA:FE:BA:BE:02", + "02:CA:FE:BA:BE:03", + "02:CA:FE:BA:BE:04", + "02:DE:AD:BE:EF:01", + ], + "expected four fabric links plus management", + ); +} + +/// A test that never touches the network can decline the fabric entirely. +/// +/// Each link is a TAP device, a virtio device and a queue pair, all set up +/// before the guest runs, so this is the cheapest VM the harness can build. +#[n_vm::test] +fn a_vm_can_decline_its_fabric_links() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .kernel_profile(n_vm::kernel_profiles::QEMU) + .fabric_nics(0) + .build(); + + let links = guest_links(); + assert_eq!(links.len(), 1, "management only, but found {links:?}"); + assert_eq!(links[0].1, "02:DE:AD:BE:EF:01"); +} + +/// One VM, three device models, so "the second NIC" and "the virtio NIC" +/// name different devices. +/// +/// This is the machine a startup-sequence test needs. The failure it is +/// looking for is a program that identifies a NIC by ordinal, or by whatever +/// `/sys` happens to list first, and unbinds a device it did not mean to -- +/// on this system, that is how the management link gets taken away and the +/// host needs a physical reboot. On a machine where every NIC is the same +/// device there is no wrong one to pick, so a uniform fabric cannot see it. +/// +/// No backend is pinned: an emulated model is one only QEMU has, so the +/// configuration selects it on its own. +#[n_vm::test] +fn a_vm_can_present_several_nic_models_at_once() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .fabric_nic_models(&[ + n_vm::NicModel::VirtioNet, + n_vm::NicModel::E1000, + n_vm::NicModel::E1000E, + ]) + // Declared, because a kernel missing one of these does not fail -- + // it presents the device and binds nothing, which reads here as + // "the model never reached the guest" when it did. + .kernel_features(&[features::VIRTIO_NET, features::E1000, features::E1000E]) + // Named rather than left to `RequestedBackend::Qemu`. A pinned + // backend that the run's profile does not offer resolves to a + // *skip*, and a skip is reported as a pass -- so this test would + // have quietly asserted nothing on every default run. Naming the + // profile says what the test is for, and outranks `N_VM_PROFILE`. + .kernel_profile(n_vm::kernel_profiles::QEMU) + .build(); + + assert_eq!( + guest_nic_drivers(), + vec!["e1000", "e1000e", "virtio_net", "virtio_net"], + "the guest should have bound three different drivers", + ); +} + +/// The kernel a test names is the kernel it gets. +/// +/// `N_VM_PROFILE` points a suite that has no opinion at another +/// environment; a test that depends on a *modular* kernel has an opinion, +/// and this is how it says so. +#[n_vm::test] +fn a_vm_boots_the_kernel_profile_it_named() { + #[n_vm::config] + const _: _ = VmConfigBuilder::default() + .kernel_profile(n_vm::kernel_profiles::FLATCAR) + .build(); + + let version = std::fs::read_to_string("/proc/version").expect("guest has /proc/version"); + assert!( + version.to_ascii_lowercase().contains("flatcar"), + "expected a flatcar kernel, got {version}", + ); +} diff --git a/nix/overlays/dataplane-dev.nix b/nix/overlays/dataplane-dev.nix index 2edaed6b49..bc3300474e 100644 --- a/nix/overlays/dataplane-dev.nix +++ b/nix/overlays/dataplane-dev.nix @@ -71,4 +71,123 @@ in "--disable-source-highlight" # breaks static compile ]; }); + + # Builds a guest kernel from the shared fragment list plus whatever else + # the caller asks for. + # + # Parameterised because profiles need more than one kernel: the default is + # fully static, while exercising the initramfs boot path needs one whose + # virtiofs is a module. `extraFragments` are merged *last*, after the + # arch fragments and after disable.config, so a caller can override any + # earlier setting -- which is the whole point for `modular.config`, whose + # job is to turn `=y` into `=m`. + mkLinuxFancy = + { + extraFragments ? [ ], + }: + let + version = "6.18.20"; + # True only when the kernel's target arch differs from the builder. + isCross = final.stdenv.hostPlatform.system != final.stdenv.buildPlatform.system; + # Cross stdenv: builds the (possibly aarch64) kernel itself. + crossStdenv = final.llvmPackages'.stdenv; + # Stdenv/toolchain that runs the .config codegen, which must execute + # on the builder. For a native build keep the original (so the + # output is byte-identical); for a cross build switch to the + # build-platform toolchain so the setup tools actually run. + buildStdenv = if isCross then final.pkgsBuildHost.llvmPackages'.stdenv else crossStdenv; + buildLlvm = if isCross then final.pkgsBuildHost.llvmPackages' else final.llvmPackages'; + # Target kernel ARCH, only set when cross-compiling (null leaves a + # native build's config output byte-identical). + kernelArch = if isCross then final.stdenv.hostPlatform.linuxArch else null; + src = fetchTarball { + url = "https://cdn.kernel.org/pub/linux/kernel/v${final.lib.versions.major version}.x/linux-${version}.tar.xz"; + sha256 = "sha256:1sbidvi0zi1a8nlzrdjmk3yq50gdc5qjvcf4n4ah70pis25912ba"; + }; + # Fragments are merged left-to-right; later entries override earlier ones. + # Place broad settings first and targeted overrides (especially disables) last. + # + # The shared list is arch-neutral in intent: x86-only symbols + # (CONFIG_X86_*, 8250, x86 PARAVIRT) that don't exist on arm64 are + # warned-and-dropped by merge_config.sh, harmlessly. The aarch64 + # `virt`-machine essentials (GIC, PL011, PSCI, arch timer, generic + # PCI host) are appended via an arch-specific fragment. + sharedFragments = [ + "base.config" + "serial-console.config" + "kvm-guest.config" + "virtio.config" + "hugepages.config" + "cgroups-ns.config" + "filesystems.config" + "crypto.config" + "net-core.config" + "net-tc-qos.config" + "net-virt-devices.config" + "intel-e1000.config" + "mlx5-sriov.config" + # "debug-fuzz.config" + "disable.config" + ]; + # Appended last so its enables win over earlier fragments/disables. + archFragments = final.lib.optionals final.stdenv.hostPlatform.isAarch64 [ + "aarch64-virt.config" + ]; + fragments = map (f: ../pkgs/linux/fragments + "/${f}") ( + sharedFragments ++ archFragments ++ extraFragments + ); + configfile = final.callPackage ../pkgs/linux/merge-config.nix { + inherit src version fragments kernelArch; + stdenv = buildStdenv; + llvmPackages = buildLlvm; + }; + in + final.linuxManualConfig { + inherit version src configfile; + stdenv = crossStdenv; + # nixpkgs decides at *eval* time whether this kernel has modules, and + # that decision creates a whole extra output (`modules`) plus the + # `modules_install` step. It normally learns this by reading the + # configfile -- but only when the configfile is a literal path or + # `allowImportFromDerivation` is set. Ours is a derivation + # (merge-config.nix), so without help nixpkgs sees an empty config, + # concludes the kernel is not modular, and silently ships a kernel + # whose `.ko` files were never installed anywhere. + # + # Answered by reading our own fragments, which *are* paths, so no + # import-from-derivation is involved. IFD would be the obvious + # alternative but it forces the config derivation to build during + # evaluation and is unavailable under restricted eval; a hand-set flag + # would be a second source of truth that could drift from the + # fragments it is supposed to describe. + config = final.lib.optionalAttrs ( + final.lib.any (f: final.lib.hasInfix "CONFIG_MODULES=y" (builtins.readFile f)) fragments + ) { CONFIG_MODULES = "y"; }; + }; + + # A pinned Flatcar release, repackaged into the layout the kernel + # manifest expects. This is the kernel the dataplane actually ships on, + # which is the whole reason for running tests against it. + flatcar-kernel = final.callPackage ../pkgs/flatcar { + # `extract-ikconfig` is version-agnostic -- it scans an image for the + # embedded IKCFG_ST block -- so our own kernel source's copy reads + # Flatcar's image fine, and this avoids a second kernel source fetch. + extractIkconfig = "${final.linux-fancy.src}/scripts/extract-ikconfig"; + }; + + # A pinned Ubuntu kernel, repackaged into the same layout. Not a second + # copy of the Flatcar test: this one exists to find out whether the harness + # is distro-agnostic or merely Flatcar-shaped. Needs no `extractIkconfig` + # -- Ubuntu does not set `CONFIG_IKCONFIG`, so its config comes from a + # separate package instead of from the image. + ubuntu-kernel = final.callPackage ../pkgs/ubuntu { }; + + # The default guest kernel: everything built in, no modules at all. + linux-fancy = final.mkLinuxFancy { }; + + # Same kernel with virtiofs and fuse demoted to modules, reproducing the + # bootstrap deadlock a distro kernel presents (see modular.config). + linux-fancy-modular = final.mkLinuxFancy { + extraFragments = [ "modular.config" ]; + }; } diff --git a/nix/pkgs/flatcar/default.nix b/nix/pkgs/flatcar/default.nix new file mode 100644 index 0000000000..f49517c778 --- /dev/null +++ b/nix/pkgs/flatcar/default.nix @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Open Network Fabric Authors + +# Fetches a pinned Flatcar release and repackages it into the layout the +# kernel manifest expects. +# +# The point of running tests against this kernel is that our own is built +# from a minimal config we chose, so it cannot tell us whether the code +# works on the kernel we actually ship. This one can. +# +# # Why the PXE cpio rather than the disk image +# +# The modules live inside the `/usr` filesystem, and there are two published +# artifacts carrying it. The disk image is a GPT-partitioned, +# dm-verity-protected ext4 volume: reading it means slicing the partition +# out by offset and coping with the verity hash tree, and mounting is not an +# option because nix builds run unprivileged and cannot `mount -o loop`. +# +# The PXE cpio contains exactly three entries -- `.`, `etc`, and +# `usr.squashfs` -- so the whole extraction is `cpio -i` then `unsquashfs`, +# both of which are ordinary userspace tools needing no privileges. It is +# also the smaller download. +# +# # Why the fetches are separate derivations +# +# So that the ~400 MB of upstream artifacts are inputs to *this* derivation +# rather than part of its output. Nix substituters match on the output +# hash, so a consumer whose pin is unchanged gets the repackaged result from +# the binary cache and never realises the fetches at all. The download +# happens once, wherever this is first built. +{ + lib, + stdenvNoCC, + fetchurl, + cpio, + squashfsTools, + gzip, + # `scripts/extract-ikconfig` from any kernel source tree. The script is + # version-agnostic: it scans an image for the embedded `IKCFG_ST` block, + # so our own kernel's copy reads Flatcar's image perfectly well. + extractIkconfig, + + # Flatcar release to pin. Both artifacts must come from the same one: + # modules are vermagic-matched to their kernel and will refuse to load + # against a different build. + channel ? "stable", + version ? "4593.2.4", + # Digests are published as SHA512 in the `.DIGESTS` files beside each + # artifact; there is no SHA256 to use instead. + vmlinuzHash ? "sha512-nwL0g+WSR60foPWju9IyuGAF5xv2qDnNBdn5MXO3UUNXFa8/DKYbgzlkJ0FrOQ0PRqP+bX2zZaWzboIHQQ8dHg==", + pxeImageHash ? "sha512-iALhDGwMNfvjbmD7sXJWqQs7zLhxD9bque93PXPTqVAMfSVYpqtRuX/HEy3PKepDmb2rm05xYEzqKnjppCjv0w==", +}: +let + # The versioned CDN path, not `.../current/`. `current` moves at every + # release, so a pin against it would keep a valid hash while silently + # coming to mean a different kernel. + base = "https://flatcar.cdn.cncf.io/${channel}/amd64-usr/${version}"; + + vmlinuz = fetchurl { + url = "${base}/flatcar_production_pxe.vmlinuz"; + hash = vmlinuzHash; + }; + + pxeImage = fetchurl { + url = "${base}/flatcar_production_pxe_image.cpio.gz"; + hash = pxeImageHash; + }; +in +stdenvNoCC.mkDerivation { + pname = "flatcar-kernel"; + inherit version; + + dontUnpack = true; + dontConfigure = true; + dontFixup = true; + + nativeBuildInputs = [ + cpio + gzip + squashfsTools + ]; + + buildPhase = '' + runHook preBuild + + # The cpio holds one large file; stream it rather than materialising + # the archive twice. + mkdir -p extracted + ( cd extracted && gzip -dc ${pxeImage} | cpio -idm --quiet usr.squashfs ) + + # Selective extraction: the squashfs is the whole of /usr (~393 MB) and + # the module tree is the only part any of this needs. + # `-no-xattrs` because the tree carries `security.selinux` attributes + # that only root may set, and a nix build is unprivileged. They are of + # no use to us either way: the guest never enforces SELinux, and the + # modules only have to be readable. + unsquashfs -quiet -no-progress -no-xattrs \ + -dest usr extracted/usr.squashfs 'lib/modules' + rm -rf extracted + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp ${vmlinuz} $out/vmlinuz + + # The kernel version is discovered rather than assumed: it is a property + # of the release, and hardcoding it would break silently on a version + # bump -- the modules would be present but under a directory nothing + # looks in. + modDir=$(ls usr/lib/modules) + if [ "$(printf '%s\n' "$modDir" | wc -l)" != 1 ]; then + echo "expected exactly one module directory, found: $modDir" >&2 + exit 1 + fi + echo "$modDir" > $out/mod-dir-version + + # `lib/modules/`, matching both nixpkgs' `modules` output and what + # `modprobe --dirname` expects, so neither consumer needs to know which + # kind of kernel produced the tree. + mkdir -p $out/lib/modules + cp -r usr/lib/modules/"$modDir" $out/lib/modules/"$modDir" + chmod -R u+w $out/lib/modules + + # `CONFIG_IKCONFIG=y` means the complete, post-resolution config is + # embedded in the image, so it can be recovered here rather than at boot + # -- which is what lets a test's declared kernel requirements be checked + # before a VM is started. + ${extractIkconfig} $out/vmlinuz > $out/config + if ! grep -q '^CONFIG_' $out/config; then + echo "extract-ikconfig produced no config; is CONFIG_IKCONFIG set?" >&2 + exit 1 + fi + + runHook postInstall + ''; + + meta = { + description = "Flatcar ${channel} ${version} kernel, modules and config"; + # The kernel and its modules are GPL-2.0-only; the surrounding release + # is Apache-2.0. Only the kernel parts are repackaged here. + license = lib.licenses.gpl2Only; + platforms = [ "x86_64-linux" ]; + }; +} diff --git a/nix/pkgs/linux/fragments/aarch64-virt.config b/nix/pkgs/linux/fragments/aarch64-virt.config new file mode 100644 index 0000000000..a44dcf6ab1 --- /dev/null +++ b/nix/pkgs/linux/fragments/aarch64-virt.config @@ -0,0 +1,54 @@ +# aarch64 QEMU `virt` machine essentials. +# +# Appended (last) to the shared fragment list when building the guest +# kernel for an aarch64 test VM. Supplies the arm64-specific platform +# support the x86 fragments do not: interrupt controller, arch timer, +# PSCI, the PL011 console (ttyAMA0), and the generic ECAM PCI host that +# QEMU's `virt` machine presents. The arch-neutral feature symbols +# (virtio, virtio-vsock, e1000, ext4, hugetlbfs, pvpanic, ...) come from +# the shared fragments. +# +# Boot-validated: the aarch64 guest boots on QEMU `virt` under TCG and the +# full `dataplane-n-vm` in_vm suite passes. The build uses an allnoconfig +# base, so every needed symbol (and its `depends on` chain) must be listed +# explicitly; `merge_config.sh` warns for any requested symbol dropped for +# unmet dependencies. To iterate on this config standalone: +# scripts/n-vm-aarch64-boot-spike.sh (boot + vsock + NIC drivers) +# scripts/n-vm-aarch64-smmu-spike.sh (SMMUv3 / IOMMU groups) + +# Interrupt controller: GICv3 (QEMU virt with gic-version=max). +CONFIG_ARM_GIC_V3=y +CONFIG_ARM_GIC_V3_ITS=y + +# Architected timer (mandatory clocksource on virt). +CONFIG_ARM_ARCH_TIMER=y + +# PSCI firmware interface -- QEMU virt uses it for SMP bring-up and for +# power-off/reset (n-it calls reboot(RB_POWER_OFF)). +CONFIG_ARM_PSCI_FW=y +CONFIG_POWER_RESET=y +CONFIG_POWER_RESET_SYSCON=y + +# PL011 AMBA UART -> ttyAMA0, the serial console on virt. +CONFIG_SERIAL_AMBA_PL011=y +CONFIG_SERIAL_AMBA_PL011_CONSOLE=y +CONFIG_SERIAL_EARLYCON_ARM_SEMIHOST=n + +# Generic ECAM PCI host controller that QEMU virt exposes (virtio-pci, +# vhost-vsock-pci, e1000 all sit on it). +CONFIG_PCI_HOST_GENERIC=y +CONFIG_PCI_HOST_COMMON=y + +# Device tree is how virt describes itself to the guest. +CONFIG_OF=y + +# RTC on virt is a PL031. +CONFIG_RTC_DRV_PL031=y + +# ARM System MMU v3, which QEMU's `virt` machine exposes via +# `iommu=smmuv3` (the aarch64 virtual IOMMU, used by `#[hypervisor(iommu)]` +# tests for VFIO/DPDK passthrough). IOMMU_SUPPORT / VFIO* / VIRTIO_IOMMU +# come from the shared mlx5-sriov fragment; this is the arm64 SMMU driver +# that backs them. Validated under TCG: PCI devices land in IOMMU groups. +CONFIG_ARM_SMMU_V3=y +CONFIG_IOMMU_DMA=y diff --git a/nix/pkgs/linux/fragments/base.config b/nix/pkgs/linux/fragments/base.config new file mode 100644 index 0000000000..55745b5990 --- /dev/null +++ b/nix/pkgs/linux/fragments/base.config @@ -0,0 +1,40 @@ +# Base architecture, CPU, and general setup +# +# 64BIT is critical: without it, allnoconfig produces an x86-32 kernel. +# This must come before any option that depends on X86_64. +CONFIG_64BIT=y + +# ELF binary format support. +# +# With allnoconfig (-n), BINFMT_ELF defaults to n even though +# Kconfig marks it `default y` -- that default only applies in +# interactive config and defconfig, not allnoconfig. Without this the +# kernel has no registered binary format handler and *every* execve() +# returns ENOEXEC (-8). +CONFIG_BINFMT_ELF=y + +CONFIG_WERROR=y +CONFIG_LOCALVERSION_AUTO=y +CONFIG_KERNEL_ZSTD=y +CONFIG_DEFAULT_HOSTNAME="gateway" +CONFIG_SYSVIPC=y +CONFIG_POSIX_MQUEUE=y +CONFIG_SMP=y +CONFIG_X86_X2APIC=y +CONFIG_NR_CPUS=16 +CONFIG_HZ_1000=y +CONFIG_MICROCODE=y +CONFIG_X86_MSR=y +CONFIG_X86_CPUID=y +CONFIG_PREEMPT_DYNAMIC=y +CONFIG_BLK_DEV_INITRD=y +CONFIG_RD_ZSTD=y +CONFIG_HIGH_RES_TIMERS=y +CONFIG_NO_HZ_IDLE=y +CONFIG_JUMP_LABEL=y +CONFIG_ACPI=y +CONFIG_ACPI_PROCESSOR=y +CONFIG_PCI=y +CONFIG_PCI_MSI=y +CONFIG_RTC_CLASS=y +CONFIG_LTO_CLANG_FULL=y diff --git a/nix/pkgs/linux/fragments/cgroups-ns.config b/nix/pkgs/linux/fragments/cgroups-ns.config new file mode 100644 index 0000000000..a56397b108 --- /dev/null +++ b/nix/pkgs/linux/fragments/cgroups-ns.config @@ -0,0 +1,20 @@ +# Cgroups and namespaces (used by DPDK tooling and netns tests) +CONFIG_CGROUPS=y +CONFIG_MEMCG=y +CONFIG_CGROUP_SCHED=y +CONFIG_FAIR_GROUP_SCHED=y +CONFIG_CFS_BANDWIDTH=y +CONFIG_RT_GROUP_SCHED=y +CONFIG_CGROUP_PIDS=y +CONFIG_CGROUP_RDMA=y +CONFIG_CGROUP_HUGETLB=y +CONFIG_CPUSETS=y +CONFIG_CGROUP_DEVICE=y +CONFIG_CGROUP_CPUACCT=y +CONFIG_CGROUP_PERF=y +CONFIG_CGROUP_MISC=y +CONFIG_NAMESPACES=y +CONFIG_USER_NS=y +CONFIG_NET_NS=y +CONFIG_CGROUP_NET_PRIO=y +CONFIG_CGROUP_NET_CLASSID=y \ No newline at end of file diff --git a/nix/pkgs/linux/fragments/crypto.config b/nix/pkgs/linux/fragments/crypto.config new file mode 100644 index 0000000000..e76b4bfe14 --- /dev/null +++ b/nix/pkgs/linux/fragments/crypto.config @@ -0,0 +1,8 @@ +# Cryptographic algorithms (minimum for MACsec AES-GCM) +# +# CRYPTO is the top-level menuconfig gate for the entire cryptographic +# subsystem. All crypto algorithm options live inside it. +CONFIG_CRYPTO=y +CONFIG_CRYPTO_AES=y +CONFIG_CRYPTO_GCM=y +CONFIG_CRYPTO_GHASH=y \ No newline at end of file diff --git a/nix/pkgs/linux/fragments/debug-fuzz.config b/nix/pkgs/linux/fragments/debug-fuzz.config new file mode 100644 index 0000000000..99ba13ecda --- /dev/null +++ b/nix/pkgs/linux/fragments/debug-fuzz.config @@ -0,0 +1,47 @@ +# Debug/fuzz testing (optional, crash-on-corruption for catching kernel bugs) +# Designed to make the kernel noisy and crash-happy when something is wrong + +# Crash on oops instead of limping along +CONFIG_PANIC_ON_OOPS=y +CONFIG_PANIC_TIMEOUT=1 + +# Stack and memory corruption detection +CONFIG_STACKPROTECTOR=y +CONFIG_STACKPROTECTOR_STRONG=y +CONFIG_VMAP_STACK=y +CONFIG_INIT_STACK_ALL_ZERO=y +CONFIG_RANDOMIZE_KSTACK_OFFSET=y +CONFIG_FORTIFY_SOURCE=y +CONFIG_HARDENED_USERCOPY=y +CONFIG_SLAB_FREELIST_HARDENED=y +CONFIG_LIST_HARDENED=y +CONFIG_BUG_ON_DATA_CORRUPTION=y + +# CFI (catch indirect call target corruption) +CONFIG_CFI_CLANG=y +CONFIG_CFI_ICALL_NORMALIZE_INTEGERS=y +CONFIG_X86_KERNEL_IBT=y + +# KFENCE (sampling-based memory error detector, low overhead) +CONFIG_DEBUG_KERNEL=y +CONFIG_KFENCE=y + +# Seccomp (if fuzz test harness uses sandboxing) +CONFIG_SECCOMP=y +CONFIG_SECCOMP_FILTER=y + +# Lock validation (catches lock ordering bugs) +CONFIG_PROVE_LOCKING=y +CONFIG_DEBUG_ATOMIC_SLEEP=y +CONFIG_DEBUG_MUTEXES=y +CONFIG_DEBUG_SPINLOCK=y + +# SLUB debug (red zones, poisoning) +CONFIG_SLUB_DEBUG=y + +# Verbose warnings +CONFIG_DEBUG_BUGVERBOSE=y + +# Memory debugging +CONFIG_DEBUG_MEMORY_INIT=y +CONFIG_PAGE_POISONING=y diff --git a/nix/pkgs/linux/fragments/disable.config b/nix/pkgs/linux/fragments/disable.config new file mode 100644 index 0000000000..f404605f93 --- /dev/null +++ b/nix/pkgs/linux/fragments/disable.config @@ -0,0 +1,30 @@ +# Explicit disables (override defaults, strip unnecessary subsystems) +# CONFIG_MODULES is not set +# CONFIG_SWAP is not set +# CONFIG_EFI is not set +# CONFIG_CPU_MITIGATIONS is not set +# CONFIG_SUSPEND is not set +# CONFIG_PM is not set +# CONFIG_VIRTUALIZATION is not set +# CONFIG_BPF_SYSCALL is not set +# CONFIG_RELOCATABLE is not set +# CONFIG_IA32_EMULATION is not set +# CONFIG_FTRACE is not set +# CONFIG_MAGIC_SYSRQ is not set +# CONFIG_DEBUG_FS is not set +# CONFIG_INPUT_KEYBOARD is not set +# CONFIG_INPUT_MOUSE is not set +# CONFIG_SERIO is not set +# CONFIG_USB_SUPPORT is not set +# CONFIG_SOUND is not set +# CONFIG_HID_SUPPORT is not set +# CONFIG_WIRELESS is not set +# CONFIG_NETFILTER is not set +# CONFIG_SCSI is not set +# CONFIG_ATA is not set +# CONFIG_DRM is not set +# CONFIG_FB is not set +# CONFIG_I2C is not set +# CONFIG_NETWORK_FILESYSTEMS is not set +# CONFIG_CPU_FREQ is not set +# CONFIG_PROFILING is not set \ No newline at end of file diff --git a/nix/pkgs/linux/fragments/filesystems.config b/nix/pkgs/linux/fragments/filesystems.config new file mode 100644 index 0000000000..46c93d56e4 --- /dev/null +++ b/nix/pkgs/linux/fragments/filesystems.config @@ -0,0 +1,14 @@ +# Filesystem support +# +# INOTIFY_USER is not optional in practice: the allnoconfig base leaves it +# off, and anything using a file watcher fails at startup in the guest with +# a bare "Failed to init inotify" (the dataplane router does exactly this). +CONFIG_INOTIFY_USER=y +CONFIG_FUSE_FS=y +CONFIG_FUSE_PASSTHROUGH=y +CONFIG_PROC_FS=y +CONFIG_TMPFS=y +CONFIG_TMPFS_POSIX_ACL=y +CONFIG_TMPFS_XATTR=y +CONFIG_CONFIGFS_FS=y +CONFIG_NLS_UTF8=y diff --git a/nix/pkgs/linux/fragments/hugepages.config b/nix/pkgs/linux/fragments/hugepages.config new file mode 100644 index 0000000000..daa6a20b5d --- /dev/null +++ b/nix/pkgs/linux/fragments/hugepages.config @@ -0,0 +1,5 @@ +# Hugepage support (required for DPDK) +CONFIG_TRANSPARENT_HUGEPAGE=y +CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS=y +CONFIG_HUGETLBFS=y +CONFIG_HUGETLB_PAGE_OPTIMIZE_VMEMMAP_DEFAULT_ON=y diff --git a/nix/pkgs/linux/fragments/intel-e1000.config b/nix/pkgs/linux/fragments/intel-e1000.config new file mode 100644 index 0000000000..860a0c0b03 --- /dev/null +++ b/nix/pkgs/linux/fragments/intel-e1000.config @@ -0,0 +1,24 @@ +# Intel e1000 / e1000e emulated NIC drivers (for QEMU VM tests) +# +# QEMU can emulate two Intel Gigabit Ethernet controllers: +# +# - e1000 (Intel 82540EM) -- CONFIG_E1000 +# - e1000e (Intel 82574L) -- CONFIG_E1000E +# +# The kernel drivers are needed so that the devices appear on the PCI +# bus with a bound driver that VFIO can unbind-and-rebind to vfio-pci. +# Without these, the emulated NICs show up as unclaimed PCI devices +# and the bind_to_vfio_pci() path may not work correctly. +# +# Requires: net-core (NETDEVICES, ETHERNET), mlx5-sriov (VFIO, VFIO_PCI) + +# Vendor menu gate. E1000/E1000E live under `if NET_VENDOR_INTEL`, which +# is `default y` but forced off by our allnoconfig base -- so without this +# both drivers silently drop out of the final .config (on every arch). +CONFIG_NET_VENDOR_INTEL=y + +# Intel PRO/1000 legacy (82540EM) -- QEMU `-device e1000` +CONFIG_E1000=y + +# Intel PRO/1000 PCIe (82574L) -- QEMU `-device e1000e` +CONFIG_E1000E=y \ No newline at end of file diff --git a/nix/pkgs/linux/fragments/kvm-guest.config b/nix/pkgs/linux/fragments/kvm-guest.config new file mode 100644 index 0000000000..3a71194e9b --- /dev/null +++ b/nix/pkgs/linux/fragments/kvm-guest.config @@ -0,0 +1,8 @@ +# KVM guest paravirtualization +CONFIG_HYPERVISOR_GUEST=y +CONFIG_PARAVIRT=y +CONFIG_PARAVIRT_SPINLOCKS=y +CONFIG_KVM_GUEST=y +CONFIG_PVPANIC=y +CONFIG_PVPANIC_PCI=y +# CONFIG_PVPANIC_MMIO is not set diff --git a/nix/pkgs/linux/fragments/mlx5-sriov.config b/nix/pkgs/linux/fragments/mlx5-sriov.config new file mode 100644 index 0000000000..b6a60e6187 --- /dev/null +++ b/nix/pkgs/linux/fragments/mlx5-sriov.config @@ -0,0 +1,75 @@ +# MLX5 SR-IOV passthrough and RDMA (optional, for hardware tests) +# Requires: base, net-core, cgroups-ns, hugepages + +# PCI passthrough prerequisites +CONFIG_VFIO=y +# VFIO_CONTAINER creates /dev/vfio/vfio -- the character device that DPDK +# opens to set up DMA mappings. In Linux 6.x this was split from VFIO +# core into its own Kconfig symbol; with allnoconfig it defaults to n, +# so VFIO binds work but DPDK fails with "Cannot open VFIO container". +CONFIG_VFIO_CONTAINER=y +CONFIG_VFIO_DEVICE_CDEV=y +CONFIG_VFIO_GROUP=y +CONFIG_VFIO_NOIOMMU=y +CONFIG_VFIO_PCI=y +CONFIG_PCI_IOV=y +CONFIG_PCI_PRI=y +CONFIG_PCI_PASID=y +CONFIG_PCI_PF_STUB=y + +# IOMMU (needed for VFIO passthrough) +# IOMMU_SUPPORT is the top-level menuconfig gate for the entire IOMMU +# subsystem. With allnoconfig it defaults to n, which silently drops +# every option below it (INTEL_IOMMU, AMD_IOMMU, IRQ_REMAP, ...). +CONFIG_IOMMU_SUPPORT=y +CONFIG_AMD_IOMMU=y +CONFIG_INTEL_IOMMU=y +CONFIG_INTEL_IOMMU_SVM=y +CONFIG_INTEL_IOMMU_DEFAULT_ON=y +CONFIG_INTEL_IOMMU_SCALABLE_MODE_DEFAULT_ON=y +CONFIG_IOMMUFD=y +CONFIG_IRQ_REMAP=y +CONFIG_VIRTIO_IOMMU=y + +# MLX5 driver +# NET_VENDOR_MELLANOX is the `if` gate wrapping every Mellanox Kconfig +# source (drivers/net/ethernet/mellanox/Kconfig). It is `default y`, but +# allnoconfig overrides defaults to n, and a `depends on` -- unlike a +# `select` -- is never auto-satisfied. Without this line all 17 MLX5 +# symbols below are silently dropped: merge_config.sh warns, but the build +# succeeds and produces a kernel with no mlx5 support at all. +CONFIG_NET_VENDOR_MELLANOX=y +CONFIG_MLX5_CORE=y +CONFIG_MLX5_CORE_EN=y +CONFIG_MLX5_ESWITCH=y +CONFIG_MLX5_BRIDGE=y +# MLX5_CLS_ACT depends on NET_TC_SKB_EXT ("TC recirculation support"), +# which is off under allnoconfig. It selects SKB_EXTENSIONS itself, so +# this one line is enough. +CONFIG_NET_TC_SKB_EXT=y +CONFIG_MLX5_CLS_ACT=y +CONFIG_MLX5_TC_SAMPLE=y +CONFIG_MLX5_CORE_IPOIB=y +CONFIG_MLX5_MACSEC=y +CONFIG_MLX5_SW_STEERING=y +CONFIG_MLX5_HW_STEERING=y +CONFIG_MLX5_SF=y +CONFIG_MLX5_SF_MANAGER=y +CONFIG_MLX5_DPLL=y +CONFIG_MLX5_VFIO_PCI=y +# CONFIG_VDPA gates CONFIG_MLX5_VDPA; without it the two options below +# are silently dropped by merge_config.sh. +CONFIG_VDPA=y +CONFIG_MLX5_VDPA=y +CONFIG_MLX5_VDPA_NET=y + +# InfiniBand / RDMA +CONFIG_INFINIBAND=y +CONFIG_INFINIBAND_USER_MAD=y +CONFIG_INFINIBAND_USER_ACCESS=y +CONFIG_INFINIBAND_ON_DEMAND_PAGING=y +CONFIG_MLX5_INFINIBAND=y +CONFIG_RDMA_RXE=y + +# NUMA (MLX5 benefits from NUMA awareness) +CONFIG_NUMA=y diff --git a/nix/pkgs/linux/fragments/modular.config b/nix/pkgs/linux/fragments/modular.config new file mode 100644 index 0000000000..22687e3530 --- /dev/null +++ b/nix/pkgs/linux/fragments/modular.config @@ -0,0 +1,63 @@ +# Deliberately modular kernel, for exercising the initramfs boot path. +# +# Our default kernel is entirely static -- zero `=m` symbols -- which is the +# simplification the original n-vm design leaned on: virtiofs is built in, +# so the kernel mounts its own root and no initramfs is needed. +# +# A distro kernel is not like that. Flatcar 4593.2.4 has CONFIG_VIRTIO_FS=m +# and CONFIG_FUSE_FS=m, which creates a bootstrap deadlock: mounting the +# workspace needs virtiofs, virtiofs is a module, and the module tree lives +# on the workspace. The only way out is an initramfs carrying those modules, +# because it is the one channel the kernel unpacks itself before any driver +# loads. +# +# This fragment reproduces that shape with a kernel we control, so the +# initramfs and pre-init can be developed against a config we can change when +# something is wrong -- rather than against someone else's artifacts, where a +# failure could equally be a bad fetch, a bad extraction, or a bad boot. +# +# Merged last, so these override the `=y` settings in filesystems.config. + +# Loadable module support. Absent entirely from the default kernel: it has +# no `=m` symbols, so it never needed the machinery. +CONFIG_MODULES=y +CONFIG_MODULE_UNLOAD=y + +# Compressed, as Flatcar's are. +# +# This is what makes the cpio's decompression step load-bearing rather than +# incidental: `finit_module` is handed a file descriptor and the kernel +# reads the image from it directly, so a `.ko.xz` fails outright unless +# something decompressed it first. `mk-initramfs` does, at build time, +# which is why the pre-init needs no decompressor. +# +# CONFIG_MODULE_DECOMPRESS is deliberately left unset. With it the kernel +# would decompress modules itself and the whole question would go away -- +# but Flatcar does not set it either, and relying on it would mean the path +# we actually depend on for a foreign kernel never gets exercised. +CONFIG_MODULE_COMPRESS=y +CONFIG_MODULE_COMPRESS_XZ=y +# `modules_install` compresses only under this third gate +# (scripts/Makefile.modinst: `ifdef CONFIG_MODULE_COMPRESS_ALL`). It is +# `default y`, which is exactly why it needs stating: allnoconfig overrides +# defaults to n, so without this the modules build compressed-capable and +# install uncompressed. Flatcar does not set it either -- their build +# starts from a defconfig where the default applies, which is why their +# tree ships `.ko.xz` and ours did not. Same trap as NET_VENDOR_MELLANOX. +CONFIG_MODULE_COMPRESS_ALL=y +# CONFIG_MODULE_DECOMPRESS is not set + +# The deadlock itself: the root filesystem transport is a module. +CONFIG_FUSE_FS=m +CONFIG_VIRTIO_FS=m + +# The result channel, also a module -- as it is on Flatcar. +# +# Not part of the deadlock: nothing needs vsock to *reach* the root. It is +# here because n-it needs it the moment it starts, and because it makes the +# pre-init load two independent module chains rather than one, which is the +# shape a real distro kernel presents. Keeping it built in meant a failure +# in module loading could still report itself over vsock -- useful while the +# boot path was unproven, and worth giving up now that it works. +CONFIG_VSOCKETS=m +CONFIG_VIRTIO_VSOCKETS=m diff --git a/nix/pkgs/linux/fragments/net-core.config b/nix/pkgs/linux/fragments/net-core.config new file mode 100644 index 0000000000..562c8cddd2 --- /dev/null +++ b/nix/pkgs/linux/fragments/net-core.config @@ -0,0 +1,46 @@ +# Core networking and protocols +# +# The NET option is the top-level menuconfig gate for the entire networking +# subsystem. Nearly every other networking option (including PACKET, UNIX, +# INET, VSOCKETS, NET_NS, cgroup net options, and all of net/sched) lives +# inside `if NET` in the kernel Kconfig tree. +CONFIG_NET=y + +# NETDEVICES is the menuconfig gate for all network device drivers +# (virtual and physical). Depends on NET. +CONFIG_NETDEVICES=y +CONFIG_NET_CORE=y +CONFIG_ETHERNET=y + +# Socket types +CONFIG_PACKET=y +CONFIG_UNIX=y + +# IPv4 +CONFIG_INET=y +CONFIG_IP_ADVANCED_ROUTER=y +CONFIG_IP_MULTIPLE_TABLES=y +CONFIG_IP_ROUTE_MULTIPATH=y +CONFIG_IP_ROUTE_VERBOSE=y +CONFIG_SYN_COOKIES=y +CONFIG_TCP_CONG_CUBIC=y + +# IPv6 +CONFIG_IPV6=y +CONFIG_IPV6_ROUTER_PREF=y +CONFIG_IPV6_ROUTE_INFO=y +CONFIG_IPV6_OPTIMISTIC_DAD=y +CONFIG_IPV6_MULTIPLE_TABLES=y + +# L2 +CONFIG_BRIDGE=y +CONFIG_BRIDGE_VLAN_FILTERING=y +CONFIG_VLAN_8021Q=y + +# Misc +CONFIG_VSOCKETS=y +CONFIG_NETLINK_DIAG=y +CONFIG_NET_SWITCHDEV=y +CONFIG_NET_L3_MASTER_DEV=y +CONFIG_ETHTOOL_NETLINK=y +CONFIG_PSAMPLE=y \ No newline at end of file diff --git a/nix/pkgs/linux/fragments/net-tc-qos.config b/nix/pkgs/linux/fragments/net-tc-qos.config new file mode 100644 index 0000000000..b6bb6eefde --- /dev/null +++ b/nix/pkgs/linux/fragments/net-tc-qos.config @@ -0,0 +1,25 @@ +# Traffic control and classification +CONFIG_NET_SCHED=y +CONFIG_NET_SCH_HTB=y +CONFIG_NET_SCH_RED=y +CONFIG_NET_SCH_INGRESS=y +CONFIG_NET_CLS_FW=y +CONFIG_NET_CLS_CGROUP=y +CONFIG_NET_CLS_FLOWER=y +CONFIG_NET_CLS_MATCHALL=y +CONFIG_NET_EMATCH=y +CONFIG_NET_EMATCH_META=y +CONFIG_NET_CLS_ACT=y +CONFIG_NET_ACT_POLICE=y +CONFIG_NET_ACT_GACT=y +CONFIG_GACT_PROB=y +CONFIG_NET_ACT_MIRRED=y +CONFIG_NET_ACT_SAMPLE=y +CONFIG_NET_ACT_NAT=y +CONFIG_NET_ACT_PEDIT=y +CONFIG_NET_ACT_SKBEDIT=y +CONFIG_NET_ACT_CSUM=y +CONFIG_NET_ACT_MPLS=y +CONFIG_NET_ACT_VLAN=y +CONFIG_NET_ACT_SKBMOD=y +CONFIG_NET_ACT_TUNNEL_KEY=y diff --git a/nix/pkgs/linux/fragments/net-virt-devices.config b/nix/pkgs/linux/fragments/net-virt-devices.config new file mode 100644 index 0000000000..5df0e9b15a --- /dev/null +++ b/nix/pkgs/linux/fragments/net-virt-devices.config @@ -0,0 +1,11 @@ +# Virtual network devices (used by interface manager) +CONFIG_MACVLAN=y +CONFIG_MACVTAP=y +CONFIG_IPVLAN=y +CONFIG_IPVTAP=y +CONFIG_VXLAN=y +CONFIG_GENEVE=y +CONFIG_MACSEC=y +CONFIG_TUN=y +CONFIG_VETH=y +CONFIG_NET_VRF=y diff --git a/nix/pkgs/linux/fragments/serial-console.config b/nix/pkgs/linux/fragments/serial-console.config new file mode 100644 index 0000000000..ab6916f788 --- /dev/null +++ b/nix/pkgs/linux/fragments/serial-console.config @@ -0,0 +1,12 @@ +# Serial, TTY, console, and boot output +CONFIG_SERIAL_8250=y +CONFIG_SERIAL_8250_CONSOLE=y +CONFIG_SERIAL_8250_NR_UARTS=4 +CONFIG_VT=y +CONFIG_VT_CONSOLE=y +CONFIG_NULL_TTY=y +CONFIG_DEVTMPFS=y +CONFIG_DEVTMPFS_MOUNT=y +CONFIG_DEVTMPFS_SAFE=y +CONFIG_EARLY_PRINTK=y +CONFIG_PRINTK_TIME=y diff --git a/nix/pkgs/linux/fragments/virtio.config b/nix/pkgs/linux/fragments/virtio.config new file mode 100644 index 0000000000..94ca6251a8 --- /dev/null +++ b/nix/pkgs/linux/fragments/virtio.config @@ -0,0 +1,14 @@ +# Virtio guest devices +# +# VIRTIO_MENU is the menuconfig gate for all virtio drivers. +# TTY is required by VIRTIO_CONSOLE (depends on TTY). +CONFIG_VIRTIO_MENU=y +CONFIG_TTY=y + +CONFIG_VIRTIO_PCI=y +CONFIG_VIRTIO_NET=y +CONFIG_VIRTIO_CONSOLE=y +CONFIG_VIRTIO_MMIO=y +CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES=y +CONFIG_VIRTIO_FS=y +CONFIG_VIRTIO_VSOCKETS=y \ No newline at end of file diff --git a/nix/pkgs/linux/merge-config.nix b/nix/pkgs/linux/merge-config.nix new file mode 100644 index 0000000000..b6288b1b44 --- /dev/null +++ b/nix/pkgs/linux/merge-config.nix @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Open Network Fabric Authors + +# Merge Linux kernel config fragments into a complete .config file. +# +# Uses the kernel's own scripts/kconfig/merge_config.sh to combine fragments +# left-to-right (later fragments override earlier ones) on top of an +# allnoconfig base, then resolves Kconfig dependencies to produce a fully +# consistent configuration. +# +# The allnoconfig base means every option starts as "n" -- only values +# explicitly requested by the fragments (and options pulled in via Kconfig +# `select` statements) will be enabled. This keeps the resulting kernel +# minimal, but it also means fragments must specify the full `depends on` +# chain for every option they request. merge_config.sh prints warnings for +# any requested value that did not survive dependency resolution, which makes +# it straightforward to identify missing dependencies. +# +# The output is a single file suitable for use as the `configfile` argument +# to `linuxManualConfig`. +{ + stdenv, + lib, + src, + version, + fragments, + flex, + bison, + bc, + perl, + python3, + llvmPackages ? null, + # Target kernel architecture (`make ARCH=`), e.g. "arm64". Leave null + # to let the kernel default to the build host's arch -- correct for a + # native build, but a cross build MUST set it so kconfig reads the + # target arch's Kconfig (and the merged .config has the right symbols). + # + # IMPORTANT: this derivation generates the .config on the *build* host, + # so `stdenv` must be a build-platform stdenv (its coreutils etc. must + # execute on the builder), even though the kernel it configures is + # cross-compiled. Passing a host(target)-platform stdenv fails with + # "Exec format error" in the setup phase. + kernelArch ? null, +}: + +assert lib.assertMsg (fragments != [ ]) "merge-config: at least one config fragment is required"; +assert lib.assertMsg (builtins.isList fragments) "merge-config: fragments must be a list of paths"; + +stdenv.mkDerivation ({ + pname = "linux-merged-config"; + inherit version src; + + nativeBuildInputs = [ + flex + bison + bc + perl + python3 + ] + # When building with the LLVM stdenv, ld.lld must be on PATH for the + # kernel's Kconfig probing (scripts/Kconfig.include checks for $(LD) by name, + # and LLVM=1 sets LD=ld.lld). The stdenv's cc.bintools is GNU ld -- we need + # the LLVM bintools wrapper which ships ld.lld. + ++ lib.optionals stdenv.cc.isClang + [ (assert llvmPackages != null; llvmPackages.bintools) ] + # On a cross build the bintools wrapper does not expose an *unprefixed* + # `ld.lld` (which LLVM=1 probes for), so add the raw lld package. Gated + # on kernelArch so a native build's derivation stays byte-identical. + ++ lib.optionals (kernelArch != null && stdenv.cc.isClang) [ llvmPackages.lld ]; + + # We only generate a config file -- skip build and fixup entirely. + dontBuild = true; + dontFixup = true; + + configurePhase = + let + # Copy each fragment into a local writable directory. + # merge_config.sh touches the first file in-place, so Nix store paths + # (which are read-only) cannot be used directly. + copyFragment = i: f: + "cp ${f} fragments/${toString i}-${baseNameOf (toString f)}"; + copyCommands = lib.concatStringsSep "\n" + (lib.imap0 copyFragment fragments); + in + '' + runHook preConfigure + + # Use the LLVM toolchain when the stdenv provides clang. + ${lib.optionalString stdenv.cc.isClang "export LLVM=1"} + + # Point kconfig host-tool builds at the stdenv compilers. + export HOSTCC=$CC + export HOSTCXX=$CXX + export HOSTLD=$LD + export HOSTAR=$AR + + mkdir -p fragments + ${copyCommands} + chmod u+w fragments/* + + echo "Merging ${toString (builtins.length fragments)} config fragment(s) (allnoconfig base)..." + # -n: use allnoconfig instead of alldefconfig -- every option starts as + # "n" so the kernel contains only what fragments explicitly request. + bash scripts/kconfig/merge_config.sh -n $(ls -v fragments/*) + + runHook postConfigure + ''; + + installPhase = '' + runHook preInstall + cp .config $out + runHook postInstall + ''; +} +# Set the target kernel ARCH as a build env var (so the kconfig `make` +# invocations read the right arch//Kconfig). Added only when cross- +# compiling, so a native build's derivation is byte-identical. +// lib.optionalAttrs (kernelArch != null) { ARCH = kernelArch; }) \ No newline at end of file diff --git a/nix/pkgs/ubuntu/default.nix b/nix/pkgs/ubuntu/default.nix new file mode 100644 index 0000000000..b79a5d262c --- /dev/null +++ b/nix/pkgs/ubuntu/default.nix @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Open Network Fabric Authors + +# Fetches a pinned Ubuntu kernel and repackages it into the layout the +# kernel manifest expects -- the same output shape as `pkgs/flatcar`. +# +# A second distro kernel is not redundant with the first. Flatcar is the +# kernel we ship on, so it answers "does this work where we deploy". This +# one answers a different question: whether the harness is actually +# distro-agnostic, or merely Flatcar-shaped. Three of its assumptions turn +# out not to survive contact with a second distro -- see below -- and each +# was found by trying rather than by reading. +# +# # Why three packages +# +# Ubuntu splits what Flatcar ships together: +# +# - `linux-image-unsigned-*` holds literally three files, one of which is +# the kernel. Not the signed `linux-image-*`: the signature wraps the +# image for Secure Boot and buys nothing here, since QEMU is told to boot +# it directly. +# - `linux-modules-*` holds the module tree (~6900 modules). +# - `linux-buildinfo-*` holds the config, and is the only place it exists. +# +# That last one is the interesting break. Flatcar's package recovers the +# config *from the image* with `scripts/extract-ikconfig`, which works +# because Flatcar sets `CONFIG_IKCONFIG=y`. Ubuntu does not +# (`# CONFIG_IKCONFIG is not set`), so that mechanism fails outright -- and +# the config is not optional here, because it is what a test's declared +# kernel requirements are checked against before a VM is started. +# +# # Why depmod runs here +# +# Ubuntu ships no `modules.dep`: it runs `depmod` from the package's +# postinst, on the installed system. There is no postinst in a nix build, +# so the tree arrives unindexed and `modprobe --show-depends` -- which is +# how the initramfs discovers the module load order -- has nothing to read. +# Flatcar ships a fully indexed tree, so nothing needed this before. +# +# # Why the fetches are separate derivations +# +# So that the ~180 MB of upstream artifacts are inputs to *this* derivation +# rather than part of its output, and a consumer whose pin is unchanged gets +# the repackaged result from a substituter without realising the fetches. +# Same reasoning as `pkgs/flatcar`. +{ + lib, + stdenvNoCC, + fetchurl, + dpkg, + kmod, + + # Ubuntu kernel to pin. All three artifacts must come from the same + # build: modules are vermagic-matched to their kernel and will refuse to + # load against a different one, and a config from another build would + # describe a kernel we are not running. + # + # `abi` is the ABI-and-upload version that appears in the file name + # (`7.0.0-29.29`); `release` is the part that also names the module + # directory (`7.0.0-29-generic`). + abi ? "7.0.0-29.29", + series ? "7.0.0-29", + flavour ? "generic", + # Ubuntu publishes SHA256 in the archive's `Packages` indices. + imageHash ? "sha256-xXQCJLovE8qfgnseCyNMgzhctaX/hDdee2/tg9FD36U=", + modulesHash ? "sha256-WxZFhzHQeUm6D2ZLDCB3hkiQqQFTh31fuiQpXHwxp8s=", + buildinfoHash ? "sha256-zo4+1wQLA8bdeBGVqzYnT803f4qcy9eLxT2/kQsZhFo=", +}: +let + release = "${series}-${flavour}"; + + # The versioned pool path. The pool retains many kernel versions, but not + # indefinitely; when a pin here stops resolving, the durable source is + # `https://snapshot.ubuntu.com/ubuntu//...`, which serves the + # archive as it stood at a point in time. + base = "https://archive.ubuntu.com/ubuntu/pool/main/l/linux"; + + image = fetchurl { + url = "${base}/linux-image-unsigned-${release}_${abi}_amd64.deb"; + hash = imageHash; + }; + + modules = fetchurl { + url = "${base}/linux-modules-${release}_${abi}_amd64.deb"; + hash = modulesHash; + }; + + buildinfo = fetchurl { + url = "${base}/linux-buildinfo-${release}_${abi}_amd64.deb"; + hash = buildinfoHash; + }; +in +stdenvNoCC.mkDerivation { + pname = "ubuntu-kernel"; + version = abi; + + dontUnpack = true; + dontConfigure = true; + # The module tree is someone else's build output: stripping or patching + # ELF in it would invalidate the signatures and the vermagic. + dontFixup = true; + + nativeBuildInputs = [ + dpkg + kmod + ]; + + buildPhase = '' + runHook preBuild + + # `dpkg-deb -x` needs no privileges, which is what makes a .deb an + # easier upstream artifact than Flatcar's dm-verity-protected disk + # image. + mkdir -p stage + for deb in ${image} ${modules} ${buildinfo}; do + dpkg-deb -x "$deb" stage + done + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + + cp stage/boot/vmlinuz-${release} $out/vmlinuz + + # Ubuntu is usrmerged, so the tree arrives under `usr/lib`. It is + # published at `lib/modules/` to match both nixpkgs' `modules` + # output and what `modprobe --dirname` expects, so no consumer has to + # know which distro produced the tree. + mkdir -p $out/lib/modules + cp -r stage/usr/lib/modules/${release} $out/lib/modules/${release} + chmod -R u+w $out/lib/modules + + # The config exists only in the buildinfo package; there is no + # `CONFIG_IKCONFIG` to recover it from the image. + cp stage/usr/lib/linux/${release}/config $out/config + if ! grep -q '^CONFIG_' $out/config; then + echo "buildinfo config looks wrong: no CONFIG_ lines" >&2 + exit 1 + fi + + # Discovered rather than restated, so a version bump cannot leave the + # modules under a directory nothing reads. + modDir=$(ls $out/lib/modules) + if [ "$(printf '%s\n' "$modDir" | wc -l)" != 1 ]; then + echo "expected exactly one module directory, found: $modDir" >&2 + exit 1 + fi + echo "$modDir" > $out/mod-dir-version + + # Build the index Ubuntu leaves to its postinst. Without it, + # `modprobe --show-depends` cannot answer, and the initramfs would be + # assembled with no modules and panic looking for its root. + depmod --basedir $out "$modDir" + if [ ! -s $out/lib/modules/"$modDir"/modules.dep ]; then + echo "depmod produced no modules.dep" >&2 + exit 1 + fi + + rm -rf stage + + runHook postInstall + ''; + + meta = { + description = "Ubuntu ${abi} ${flavour} kernel, modules and config"; + # The kernel and its modules are GPL-2.0-only. + license = lib.licenses.gpl2Only; + platforms = [ "x86_64-linux" ]; + }; +}