Skip to content

Commit 5394823

Browse files
daniel-nolandclaude
andcommitted
feat(n-vm): in-VM test infrastructure (testn absorption + e1000)
Absorb the `n-vm`, `n-it`, `n-vm-macros`, and `n-vm-protocol` crates from the external `testn` repository into the workspace and add the supporting infrastructure for running tests inside a QEMU/cloud-hypervisor guest. This is a squashed revival of the `n-vm-again` branch, rebased onto the current ACL/dpdk line. The branch had forked before the ACL module existed; its stale fork of `dpdk/`, `dpdk-sys/`, and `dpdk/src/acl/*` has been dropped in favour of the authoritative versions on the base, keeping only the orthogonal VM test infrastructure. Contents: - `n-vm` / `n-vm-protocol`: QEMU and cloud-hypervisor backends, dynamic vsock allocation, hugepage and NIC-model configuration, scratch-only container mode with nix-store bind mounts. - `n-vm-macros`: the `#[in_vm]` attribute and companion attributes (`#[network]`, guest/hypervisor config), compile-fail trybuild tests. - `n-it`: in-guest init system (PID 1) with mount-table-driven teardown. - `nix`: `linux-fancy` kernel built from config fragments (VFIO, IOMMU, virtio, e1000/e1000e), `testroot`/`vmroot` derivations, `merge-config.nix`. - `hardware` / `dpdk-sys`: e1000/e1000e NIC binding and `rte_net_e1000` PMD linkage; `driver()` returns `Ok(None)` for unbound devices. - `mgmt`: re-enable `test_sample_config` under `#[in_vm]`. - `justfile`: export `N_VM_TEST_ROOT`/`N_VM_VM_ROOT` for `#[in_vm]` tests. The `hardware/tests/dpdk_in_vm.rs` integration test (virtio-net/e1000/ e1000e) is intentionally NOT carried forward: it was written against the pre-rewrite dpdk device API (`StartedDev` queue handles, `RxOffloadConfig`, public `Headers` fields) and needs a port to the current API. It is preserved on the `backup/n-vm-again` branch for a clean revival. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ba7f579 commit 5394823

83 files changed

Lines changed: 11252 additions & 255 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 250 additions & 150 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ members = [
3131
"match-action",
3232
"match-action-derive",
3333
"mgmt",
34+
"n-it",
35+
"n-vm",
36+
"n-vm-macros",
37+
"n-vm-protocol",
3438
"nat",
3539
"net",
3640
"pipeline",
@@ -96,6 +100,10 @@ lpm = { path = "./lpm", package = "dataplane-lpm", features = [] }
96100
match-action = { path = "./match-action", package = "dataplane-match-action", features = [] }
97101
match-action-derive = { path = "./match-action-derive", package = "dataplane-match-action-derive", features = [] }
98102
mgmt = { path = "./mgmt", package = "dataplane-mgmt", features = [] }
103+
n-it = { path = "./n-it", package = "dataplane-n-it", features = [] }
104+
n-vm = { path = "./n-vm", package = "dataplane-n-vm", features = [] }
105+
n-vm-macros = { path = "./n-vm-macros", package = "dataplane-n-vm-macros", features = [] }
106+
n-vm-protocol = { path = "./n-vm-protocol", package = "dataplane-n-vm-protocol", features = [] }
99107
nat = { path = "./nat", package = "dataplane-nat", features = [] }
100108
net = { path = "./net", package = "dataplane-net", features = [] }
101109
pipeline = { path = "./pipeline", package = "dataplane-pipeline", features = [] }
@@ -109,6 +117,7 @@ validator = { path = "./validator", package = "dataplane-validator", features =
109117
vpcmap = { path = "./vpcmap", package = "dataplane-vpcmap", features = [] }
110118

111119
# External
120+
112121
afpacket = { version = "0.2.3", default-features = false, features = [] }
113122
ahash = { version = "0.8.12", default-features = false, features = [] }
114123
anyhow = { version = "1.0.102", default-features = false, features = [] }
@@ -122,13 +131,17 @@ bindgen = { version = "0.72.1", default-features = false, features = [] }
122131
bitflags = { version = "2.11.1", default-features = false, features = [] }
123132
bnum = { version = "0.14.4", default-features = false, features = [] }
124133
bolero = { version = "0.13.4", default-features = false, features = [] }
134+
bollard = { version = "0.20.2", default-features = false, features = [] }
125135
bytecheck = { version = "0.8.2", default-features = false, features = [] }
126136
bytes = { version = "1.11.1", default-features = false, features = [] }
137+
capctl = { version = "0.2.4", default-features = false, features = [] }
127138
caps = { version = "0.5.6", default-features = false, features = [] }
128139
chrono = { version = "0.4.44", default-features = false, features = [] }
129140
clap = { version = "4.6.1", default-features = true, features = [] }
141+
cloud-hypervisor-client = { version = "0.3.3", default-features = false, features = [] }
130142
color-eyre = { version = "0.6.5", default-features = false, features = [] }
131143
colored = { version = "3.1.1", default-features = false, features = [] }
144+
command-fds = { version = "0.3.2", default-features = false, features = [] }
132145
criterion = { version = "0.8.2", default-features = false, features = [] }
133146
crossbeam-utils = { version = "0.8.21", default-features = false, features = [] }
134147
dashmap = { version = "6.2.1", default-features = false, features = [] }
@@ -164,7 +177,6 @@ metrics-exporter-prometheus = { version = "0.18.3", default-features = false, fe
164177
miette = { version = "7.6.0", default-features = false, features = [] }
165178
mio = { version = "1.2.1", default-features = false, features = [] }
166179
multi_index_map = { version = "0.15.1", default-features = false, features = [] }
167-
n-vm = { git = "https://github.com/githedgehog/testn.git", tag = "v0.0.10", default-features = false, features = [], package = "n-vm" }
168180
netdev = { version = "0.43.0", default-features = false, features = [] }
169181
netgauze-bgp-pkt = { version = "0.12.1", features = [] }
170182
netgauze-bmp-pkt = { version = "0.12.1", features = [] }
@@ -182,6 +194,8 @@ proc-macro-crate = { version = "3.5.0", default-features = false, features = []
182194
proc-macro2 = { version = "1.0.106", default-features = false, features = [] }
183195
procfs = { version = "0.18.0", default-features = false, features = [] }
184196
pyroscope = { version = "2.0.6", default-features = false, features = [] }
197+
qapi-qmp = { version = "0.15.0", default-features = false, features = [] }
198+
qapi-spec = { version = "0.3.2", default-features = false, features = [] }
185199
quote = { version = "1.0.45", default-features = false, features = [] }
186200
rand = { version = "0.10.1", default-features = false, features = [] }
187201
rapidhash = { version = "4.4.1", default-features = false, features = [] }
@@ -211,16 +225,20 @@ thread_local = { version = "1.1.9", default-features = false, features = [] }
211225
# per-crate would buy is divergent runtime behaviour between test binaries
212226
# and the real dataplane. Keep it global.
213227
tokio = { version = "1.52.3", default-features = false, features = ["parking_lot"] }
228+
tokio-stream = { version = "0.1.18", default-features = false, features = [] }
214229
tokio-util = { version = "0.7.18", default-features = false, features = [] }
230+
tokio-vsock = { version = "0.7.2", default-features = false, features = [] }
215231
tonic = { version = "0.14.6", default-features = false, features = [] }
216232
tracing = { version = "0.1.44", default-features = false, features = ["release_max_level_debug"] }
217233
tracing-error = { version = "0.2.1", default-features = false, features = [] }
218234
tracing-subscriber = { version = "0.3.23", default-features = false, features = [] }
219235
tracing-test = { version = "0.2.6", default-features = false, features = [] }
220236
tracing-throttle = { version = "0.4.2", default-features = false, features = [] }
237+
trybuild = { version = "1.0.116", default-features = false, features = [] }
221238
ureq = { version = "3.3.0", default-features = false, features = [] }
222239
url = { version = "2.5.8", default-features = false, features = [] }
223240
uuid = { version = "1.23.2", default-features = false, features = [] }
241+
vsock = { version = "0.5.4", default-features = false, features = [] }
224242

225243
# NOTE: panic strategy is intentionally left unset here.
226244
#

default.nix

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,40 @@ let
184184
yq
185185
]);
186186
};
187+
# Minimal derivation containing only the kernel bzImage.
188+
#
189+
# The full linux-fancy output includes modules, headers, etc. that are
190+
# not needed inside the test container — we extract just the bootable
191+
# image so that symlinkJoin produces a top-level `bzImage` entry in
192+
# testroot without pulling in the rest of the kernel tree.
193+
kernel-image = pkgs.runCommand "kernel-image" { } ''
194+
mkdir -p $out
195+
cp ${pkgs.pkgsBuildHost.linux-fancy}/bzImage $out/bzImage
196+
'';
197+
198+
# Container-tier tools for the scratch-container test infrastructure.
199+
#
200+
# This derivation provides the binaries needed inside the Docker
201+
# container that launches the test VM: the hypervisor(s), virtiofsd,
202+
# and a Linux kernel image (bzImage built from config fragments by
203+
# the linux-fancy derivation in nix/overlays/dataplane-dev.nix).
204+
#
205+
# When used with a scratch container, subdirectories of this derivation
206+
# (e.g. bin/, lib/) are volume-mounted at their standard container
207+
# paths, and top-level files (e.g. bzImage) are bind-mounted at the
208+
# container root. The container also mounts /nix/store from the host
209+
# so that the symlinks created by symlinkJoin resolve to the actual
210+
# binaries and their transitive library dependencies.
211+
#
212+
# See development/ideam.md for the design rationale.
213+
testroot = pkgs.symlinkJoin {
214+
name = "dataplane-test-root";
215+
paths = with pkgs.pkgsBuildHost; [
216+
cloud-hypervisor
217+
virtiofsd
218+
qemu_kvm
219+
] ++ [ kernel-image ];
220+
};
187221
devenv = pkgs.mkShell {
188222
name = "dataplane-dev-shell";
189223
packages = [ devroot ];
@@ -454,6 +488,92 @@ let
454488
}
455489
) package-list;
456490

491+
# VM guest root filesystem for the scratch-container test infrastructure.
492+
#
493+
# This derivation is shared into the VM via virtiofsd and becomes the
494+
# guest's root filesystem (mounted as virtiofs with tag "root").
495+
#
496+
# It contains:
497+
# - The n-it init system binary (runs as PID 1 in the VM).
498+
# - glibc and libgcc shared libraries (so dynamically linked test
499+
# binaries can run inside the VM).
500+
#
501+
# The test binary directory is bind-mounted by container.rs at
502+
# /vm.root/test-bin (see VM_TEST_BIN_DIR in n-vm-protocol), so it
503+
# appears at /test-bin in the VM guest. The /test-bin directory is
504+
# pre-created here so Docker can create the bind mount without needing
505+
# to mkdir on the read-only nix store path.
506+
#
507+
# See development/ideam.md for the design rationale.
508+
vmroot = pkgs.runCommand "dataplane-vm-root" { } ''
509+
mkdir -p $out/bin $out/lib $out/test-bin
510+
511+
# Essential guest directories.
512+
#
513+
# The VM root filesystem is mounted read-only via virtiofs, so the
514+
# kernel cannot create directories on demand. These empty mount
515+
# points must exist so that:
516+
#
517+
# /dev — kernel auto-mounts devtmpfs (provides /dev/console,
518+
# /dev/null, etc. needed by init and test processes)
519+
# /proc — n-it mounts procfs (needed for /proc/cmdline parsing
520+
# and general process introspection)
521+
# /sys — n-it mounts sysfs
522+
# /tmp — n-it mounts tmpfs (writable scratch space)
523+
# /run — n-it mounts tmpfs (runtime state)
524+
# /etc — some libc/nss functions expect this to exist
525+
#
526+
# Without /dev in particular, the kernel logs
527+
# "devtmpfs: error mounting -2" and init may fail with ENOEXEC (-8)
528+
# because /dev/console cannot be opened.
529+
mkdir -p $out/dev $out/proc $out/sys $out/tmp $out/run $out/etc $out/var
530+
531+
# /var/run → /run symlink.
532+
#
533+
# Many daemons (including DPDK) default to writing runtime state
534+
# under /var/run. On a conventional Linux system /var/run is
535+
# either a symlink to /run or a tmpfs in its own right. Since our
536+
# root filesystem is read-only via virtiofs, we bake the symlink
537+
# into the image so that /var/run/dpdk (and friends) resolve to
538+
# the writable /run tmpfs mounted by n-it.
539+
#
540+
# This mirrors what the dataplane container image already does
541+
# (see the `dataplane.tar` buildPhase above).
542+
ln -s /run $out/var/run
543+
544+
# n-it init system binary.
545+
# The cargo package is "dataplane-n-it" but the VM expects the
546+
# binary at /bin/n-it (see INIT_BINARY_PATH in n-vm-protocol).
547+
ln -s ${workspace."n-it"}/bin/dataplane-n-it $out/bin/n-it
548+
549+
# glibc runtime libraries -- needed by dynamically linked test
550+
# binaries running inside the VM.
551+
for f in ${pkgs.pkgsHostHost.libc.out}/lib/*.so*; do
552+
[ -e "$f" ] || continue
553+
ln -s "$f" "$out/lib/$(basename "$f")"
554+
done
555+
556+
# libgcc runtime libraries (libgcc_s.so, etc.)
557+
for f in ${pkgs.pkgsHostHost.glibc.libgcc}/lib/*.so*; do
558+
[ -e "$f" ] || continue
559+
ln -s "$f" "$out/lib/$(basename "$f")"
560+
done
561+
562+
# Create a real /nix/store directory (empty mount point).
563+
#
564+
# The container tier bind-mounts the host's /nix/store here so that
565+
# virtiofsd serves it as a real directory to the VM guest. This
566+
# replaces the previous /nix -> /nix absolute symlink, which caused
567+
# ELOOP (error -40) inside the guest: the FUSE protocol returns
568+
# symlinks to the guest kernel for resolution, and /nix -> /nix is
569+
# self-referential from the guest's VFS perspective.
570+
#
571+
# Nix-built test binaries have rpaths like
572+
# /nix/store/{hash}-glibc-X.Y/lib; with /nix/store bind-mounted
573+
# through virtiofsd, those paths resolve correctly inside the VM.
574+
mkdir -p $out/nix/store
575+
'';
576+
457577
workspace-check =
458578
{
459579
pname ? null,
@@ -1013,7 +1133,9 @@ in
10131133
pkgs
10141134
sources
10151135
sysroot
1136+
testroot
10161137
tests
1138+
vmroot
10171139
workspace
10181140
;
10191141
profile = profile';

dpdk-sys/build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ fn main() {
7676
"rte_net_virtio",
7777
"rte_net_vhost",
7878
"rte_net_i40e",
79+
"rte_net_e1000",
7980
"rte_vhost",
8081
"rte_net_mlx5",
8182
"rte_common_mlx5",

hardware/src/nic/mod.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,18 @@ impl GetDriver for PciNic {
7373
fn driver(&self) -> Result<Option<PciDriver>, DriverErr> {
7474
let device_path = self.device_path().map_err(DriverErr::Sysfs)?;
7575
info!("found device {self} under device path {:?}", device_path);
76-
let driver_path = device_path.relative("driver").map_err(DriverErr::Sysfs)?;
76+
let driver_path = match device_path.relative("driver") {
77+
Ok(p) => p,
78+
Err(SysfsErr::IoError(e)) if e.kind() == ErrorKind::NotFound => {
79+
// The `driver` symlink does not exist — no kernel driver
80+
// is currently bound to this device. This is normal for
81+
// NIC types whose kernel driver was not loaded or was
82+
// already unbound.
83+
info!("no driver symlink for {self} (no driver bound)");
84+
return Ok(None);
85+
}
86+
Err(e) => return Err(DriverErr::Sysfs(e)),
87+
};
7788
info!("{self} is using driver path {driver_path:?}");
7889
match driver_path.inner().file_name() {
7990
Some(os_str) => match os_str.to_str() {
@@ -99,6 +110,12 @@ impl std::fmt::Display for PciNic {
99110
/// Enum describing supported PCI drivers.
100111
#[derive(Debug, Copy, Clone, PartialEq, Eq, strum::EnumString, strum::IntoStaticStr)]
101112
pub enum PciDriver {
113+
/// Intel's e1000 driver (82540EM — QEMU `e1000` device).
114+
#[strum(serialize = "e1000")]
115+
E1000,
116+
/// Intel's e1000e driver (82574L — QEMU `e1000e` device).
117+
#[strum(serialize = "e1000e")]
118+
E1000E,
102119
/// Intel's i40e driver.
103120
#[strum(serialize = "i40e")]
104121
I40e,
@@ -318,14 +335,11 @@ impl BindToVfioPci for PciNic {
318335
}
319336
}
320337
Ok(None) => {
321-
let msg = format!(
322-
"device {self} is unknown to the operating system. You may need to load (modprobe) a driver"
323-
);
324-
error!("{msg}");
325-
return Err(DriverErr::Sysfs(SysfsErr::IoError(std::io::Error::new(
326-
ErrorKind::Unsupported,
327-
msg,
328-
))));
338+
// No driver is currently bound. This is normal for NIC
339+
// types whose kernel driver was not loaded (e.g. e1000e
340+
// without CONFIG_E1000E) or was already manually unbound.
341+
// Proceed directly to override + bind.
342+
info!("device {self} has no driver bound; proceeding to vfio-pci bind");
329343
}
330344
Err(err) => {
331345
error!("failed to get device driver: {:?}", err);

justfile

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,15 @@ pre-flight: (check-dependencies) (fmt "--check") (test) (lint) (doctest)
155155
echo "pre flight checks pass"
156156

157157
[script]
158-
test package="tests.all" *args: (build (if package == "tests.all" { "tests.all" } else { "tests.pkg." + package }) args)
158+
test package="tests.all" *args: (setup-roots) (build (if package == "tests.all" { "tests.all" } else { "tests.pkg." + package }) args)
159159
{{ _just_debuggable_ }}
160160
declare -r target="{{ if package == "tests.all" { "tests.all" } else { "tests.pkg." + package } }}"
161+
# Export scratch-container roots when the symlinks exist so that
162+
# #[in_vm] tests use the nix-built testroot/vmroot automatically.
163+
if [[ -e testroot && -e vmroot ]]; then
164+
export N_VM_TEST_ROOT="$(pwd)/testroot"
165+
export N_VM_VM_ROOT="$(pwd)/vmroot"
166+
fi
161167
cargo nextest run --archive-file results/${target}/*.tar.zst --workspace-remap $(pwd) {{ filter }}
162168

163169
# Build and run the criterion benches. The rte_acl benches are gated behind the
@@ -199,11 +205,11 @@ test-each *args: (build "tests.pkg" args)
199205
docs package="" *args: (build (if package == "" { "docs.all" } else { "docs.pkg." + package }) args)
200206
{{ _just_debuggable_ }}
201207

202-
# Create devroot and sysroot symlinks for local development
208+
# Create devroot, sysroot, testroot, and vmroot symlinks for local development
203209
[script]
204210
setup-roots *args:
205211
{{ _just_debuggable_ }}
206-
for root in devroot sysroot; do
212+
for root in devroot sysroot testroot vmroot; do
207213
nix build -f default.nix "${root}" \
208214
--argstr default-features '{{ default_features }}' \
209215
--argstr features '{{ features }}' \
@@ -370,6 +376,12 @@ doctest *args:
370376
coverage target="tests.all" *args: (build (if target == "tests.all" { "tests.all" } else { "tests.pkg." + target }) args)
371377
{{ _just_debuggable_ }}
372378
declare -r target="{{ if target == "tests.all" { "tests.all" } else { "tests.pkg." + target } }}"
379+
# Export scratch-container roots when the symlinks exist so that
380+
# #[in_vm] tests use the nix-built testroot/vmroot automatically.
381+
if [[ -e testroot && -e vmroot ]]; then
382+
export N_VM_TEST_ROOT="$(pwd)/testroot"
383+
export N_VM_VM_ROOT="$(pwd)/vmroot"
384+
fi
373385
export LLVM_COV="$(pwd)/devroot/bin/llvm-cov"
374386
export LLVM_PROFDATA="$(pwd)/devroot/bin/llvm-profdata"
375387
export CARGO_LLVM_COV_TARGET_DIR="$(pwd)/target/llvm-cov"

0 commit comments

Comments
 (0)