On 10/12/25 21:26, Piyush Raj wrote:
> This patch adds the bpf-vmtest-tool subdirectory under contrib which tests
> BPF programs under a live kernel using a QEMU VM. It automatically
> builds the specified kernel version with eBPF support enabled
> and stores it under "~/.bpf-vmtest-tool", which is reused for future
> invocations.
>
> It can also compile BPF C source files or BPF bytecode objects and
> test them against the kernel verifier for errors. When a BPF program
> is rejected by the kernel verifier, the verifier logs are displayed.
>
> $ python3 main.py -k 6.15 --bpf-src assets/ebpf-programs/fail.c
> BPF program failed to load
> Verifier logs:
> btf_vmlinux is malformed
> 0: R1=ctx() R10=fp0
> 0: (81) r0 = *(s32 *)(r10 +4)
> invalid read from stack R10 off=4 size=4
> processed 1 insns (limit 1000000) max_states_per_insn 0 total_states
> 0 peak_states 0 mark_read 0
>
> See the README for more examples.
>
> The script uses vmtest (https://github.com/danobi/vmtest) to boot
> the VM and run the program. By default, it uses the host's root
> ("/") as the VM rootfs via the 9p filesystem, so only the kernel is
> replaced during testing.
>
> Tested with Python 3.9 and above.
This looks good to me, but please wait for Jose or another reviewer.
I have some usability suggestions inline below but none are blockers,
they can all wait to be addressed in the future.
>
> contrib/ChangeLog:
>
> * bpf-vmtest-tool/README: New file.
> * bpf-vmtest-tool/bpf.py: New file.
> * bpf-vmtest-tool/config.py: New file.
> * bpf-vmtest-tool/kernel.py: New file.
> * bpf-vmtest-tool/main.py: New file.
> * bpf-vmtest-tool/pyproject.toml: New file.
> * bpf-vmtest-tool/tests/test_cli.py: New file.
> * bpf-vmtest-tool/utils.py: New file.
> * bpf-vmtest-tool/vm.py: New file.
>
> Signed-off-by: Piyush Raj <[email protected]>
> ---
> contrib/bpf-vmtest-tool/README | 78 ++++++++
> contrib/bpf-vmtest-tool/bpf.py | 199 ++++++++++++++++++++
> contrib/bpf-vmtest-tool/config.py | 18 ++
> contrib/bpf-vmtest-tool/kernel.py | 209 ++++++++++++++++++++++
> contrib/bpf-vmtest-tool/main.py | 103 +++++++++++
> contrib/bpf-vmtest-tool/pyproject.toml | 36 ++++
> contrib/bpf-vmtest-tool/tests/test_cli.py | 167 +++++++++++++++++
> contrib/bpf-vmtest-tool/utils.py | 27 +++
> contrib/bpf-vmtest-tool/vm.py | 154 ++++++++++++++++
> 9 files changed, 991 insertions(+)
> create mode 100644 contrib/bpf-vmtest-tool/README
> create mode 100644 contrib/bpf-vmtest-tool/bpf.py
> create mode 100644 contrib/bpf-vmtest-tool/config.py
> create mode 100644 contrib/bpf-vmtest-tool/kernel.py
> create mode 100644 contrib/bpf-vmtest-tool/main.py
> create mode 100644 contrib/bpf-vmtest-tool/pyproject.toml
> create mode 100644 contrib/bpf-vmtest-tool/tests/test_cli.py
> create mode 100644 contrib/bpf-vmtest-tool/utils.py
> create mode 100644 contrib/bpf-vmtest-tool/vm.py
>
> diff --git a/contrib/bpf-vmtest-tool/README b/contrib/bpf-vmtest-tool/README
> new file mode 100644
> index 00000000000..599e3529aa8
> --- /dev/null
> +++ b/contrib/bpf-vmtest-tool/README
> @@ -0,0 +1,78 @@
> +This directory contains a Python script to run BPF programs or shell commands
> +under a live Linux kernel using QEMU virtual machines.
> +
> +USAGE
> +=====
> +
> +To run a shell command inside a live kernel VM:
> +
> + python main.py -k 6.15 -r / -c "uname -a"
> +
> +To run a BPF source file in the VM:
> +
> + python main.py -k 6.15 --bpf-src fail.c
> +
> +To run a precompiled BPF object file:
> +
> + python main.py -k 6.15 --bpf-obj fail.bpf.o
> +
> +The tool will download and build the specified kernel version from:
> +
> + https://www.kernel.org/pub/linux/kernel
> +
> +A prebuilt `bzImage` can be supplied using the `--kernel-image` flag.
IMO it's easy to miss the -v/--log-level flag, and that by default it
will not print any informational messages while downloading and building
the kernel.
It might be better to set --log-level=INFO by default, and turn it off
when invoking from the testsuite. That way a user running the tool
directly has by default more indication of what's going on.
In general it would be good to describe the available options and their
defaults here.
> +
> +NOTE
> +====
> +- Only x86_64 is supported
> +- Only "/" (the root filesystem) is currently supported as the VM rootfs when
> +running or testing BPF programs using `--bpf-src` or `--bpf-obj`.
> +
> +DEPENDENCIES
> +============
> +
> +- Python >= 3.9
> +- vmtest >= v0.18.0 (https://github.com/danobi/vmtest)
> + - QEMU
> + - qemu-guest-agent
> +
> +For compiling kernel
> +- https://docs.kernel.org/process/changes.html#current-minimal-requirements
> +For compiling and loading BPF programs:
> +
> +- libbpf
> +- bpftool
> +- gcc-bpf-unknown-none
> + (https://gcc.gnu.org/wiki/BPFBackEnd#Where_to_find_GCC_BPF)
> +- vmlinux.h
> + Can be generated using:
> +
> + bpftool btf dump file /sys/kernel/btf/vmlinux format c > \
> + /usr/local/include/vmlinux.h
> +
> + Or downloaded from https://github.com/libbpf/vmlinux.h/tree/main
> +
> +BUILD FLAGS
> +===========
> +You can customize compiler settings using environment variables:
> +
> +- BPF_CC: Compiler for the BPF program (default:
> bpf-unknown-none-gcc)
> +- BPF_CFLAGS: Extra flags for BPF program compilation (default: "-O2")
> +- BPF_INCLUDES: Include paths for BPF (default: -I/usr/local/include
> -I/usr/include)
> +- VMTEST_CC: Compiler for the user-space loader (default: gcc)
> +- VMTEST_CFLAGS: Flags for compiling the loader (default: -g -Wall)
> +- VMTEST_LDFLAGS: Linker flags for the loader (default: -lelf -lz -lbpf)
> +
> +Example usage:
> +
> + BPF_CFLAGS="-O3 -g" CFLAGS="-O2" python main.py -k 6.15 --bpf-src fail.c
> +
> +DEVELOPMENT
> +===========
> +
> +Development dependencies are specified in `pyproject.toml`, which can be used
> +with any suitable Python virtual environment manager.
> +
> +To run the test suite:
> +
> + python3 -m pytest
--- 8< ---
> --- /dev/null
> +++ b/contrib/bpf-vmtest-tool/vm.py
> @@ -0,0 +1,154 @@
> +import logging
> +import subprocess
> +from typing import List
> +
> +from kernel import KernelImage
> +
> +logger = logging.getLogger(__name__)
> +
> +
> +class VMConfig:
> + """Configuration container for VM settings"""
> +
> + def __init__(
> + self, kernel_image: KernelImage, rootfs_path: str, command: str,
> **kwargs
> + ):
> + self.kernel = kernel_image
> + self.kernel_path = str(kernel_image.path)
> + self.rootfs_path = rootfs_path
> + self.command = command
> + self.memory_mb = kwargs.get("memory_mb", 512)
> + self.cpu_count = kwargs.get("cpu_count", 1)
> + self.extra_args = kwargs.get("extra_args", {})
> +
> +
> +def bpf_verifier_logs(output: str) -> str:
> + start_tag = "--- Verifier log start ---"
> + end_tag = "--- Verifier log end ---"
> +
> + start_idx = output.find(start_tag)
> + end_idx = output.find(end_tag)
> +
> + if start_idx != -1 and end_idx != -1:
> + # Extract between the tags (excluding the markers themselves)
> + log_body = output[start_idx + len(start_tag) : end_idx].strip()
> + return log_body
> + else:
> + return "No verifier log found in the output."
> +
> +
> +class Vmtest:
> + """vmtest backend implementation"""
> +
> + def __init__(self):
> + pass
> +
> + def _boot_command(self, vm_config: VMConfig):
> + vmtest_command = ["vmtest"]
> + vmtest_command.extend(["-k", vm_config.kernel_path])
> + vmtest_command.extend(["-r", vm_config.rootfs_path])
> + vmtest_command.append(vm_config.command)
> + return vmtest_command
> +
> + def _remove_boot_log(self, full_output: str) -> str:
> + """
> + Filters QEMU and kernel boot logs, returning only the output after
> the
> + `===> Running command` marker.
> + """
> + marker = "===> Running command"
> + lines = full_output.splitlines()
> +
> + try:
> + start_index = next(i for i, line in enumerate(lines) if marker
> in line)
> + # Return everything after that marker (excluding the marker
> itself)
> + return "\n".join(lines[start_index + 1 :]).strip()
> + except StopIteration:
> + return full_output.strip()
> +
> + def run_command(self, vm_config):
> + vm = None
> + try:
> + logger.info(f"Booting VM with kernel: {vm_config.kernel_path}")
> + logger.info(f"Using rootfs: {vm_config.rootfs_path}")
> + vm = subprocess.run(
> + self._boot_command(vm_config),
> + check=True,
> + text=True,
> + capture_output=True,
> + shell=False,
> + )
> + vm_stdout = vm.stdout
> + logger.debug(vm_stdout)
> + return VMCommandResult(
> + vm.returncode, self._remove_boot_log(vm_stdout), None
> + )
> + except subprocess.CalledProcessError as e:
> + out = e.stdout
> + err = e.stderr
> + # when the command in the vm fails we consider it as a
> succesfull boot
> + if "===> Running command" not in out:
> + raise BootFailedError("Boot failed", out, err, e.returncode)
> + logger.debug("STDOUT: \n%s", out)
> + logger.debug("STDERR: \n%s", err)
> + return VMCommandResult(e.returncode, self._remove_boot_log(out),
> err)
> +
> +
> +class VMCommandResult:
> + def __init__(self, returncode, stdout, stderr) -> None:
> + self.returncode = returncode
> + self.stdout = stdout
> + self.stderr = stderr
> +
> +
> +class VirtualMachine:
> + """Main VM class - simple interface for end users"""
> +
> + # Registry of available hypervisors
> + _hypervisors = {
> + "vmtest": Vmtest,
> + }
> +
> + def __init__(
> + self,
> + kernel_image: KernelImage,
> + rootfs_path: str,
> + command: str,
> + hypervisor_type: str = "vmtest",
> + **kwargs,
> + ):
> + self.config = VMConfig(kernel_image, rootfs_path, command, **kwargs)
> +
> + if hypervisor_type not in self._hypervisors:
> + raise ValueError(f"Unsupported hypervisor: {hypervisor_type}")
> +
> + self.hypervisor = self._hypervisors[hypervisor_type]()
> +
> + @classmethod
> + def list_hypervisors(cls) -> List[str]:
> + """List available hypervisors"""
> + return list(cls._hypervisors.keys())
> +
> + def execute(self):
> + """Execute command in VM"""
> + return self.hypervisor.run_command(self.config)
If vmtest is not installed (or not in the PATH) this results in an
ugly and un-useful traceback through to the python stdlib subprocess.py.
It is user (me :D) error of course, but still it would be nice to add
a little bit of handling here to give a more helpful error message
if possible.
> +
> +
> +class BootFailedError(Exception):
> + """Raised when VM fails to boot properly (before command execution)."""
> +
> + def __init__(
> + self, message: str, stdout: str = "", stderr: str = "", returncode:
> int = -1
> + ):
> + super().__init__(message)
> + self.stdout = stdout
> + self.stderr = stderr
> + self.returncode = returncode
> +
> + def __str__(self):
> + base = super().__str__()
> + return (
> + f"{base}\n"
> + f"Return code: {self.returncode}\n"
> + f"--- STDOUT ---\n{self.stdout}\n"
> + f"--- STDERR ---\n{self.stderr}"
> + )