Hello Piyush.
I have a few high-level comments and questions, inlined below. Thanks! > This patch adds the 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 "~/.vmtest-tool", which is reused for future > invocations. I wonder, would it be a good idea to have "bpf" as part of the name of the directory. Something like bpf-vmtest-tool? > 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.10 and above. > > contrib/ChangeLog: > > * vmtest-tool/.gitignore: New file. > * vmtest-tool/.pre-commit-config.yaml: New file. > * vmtest-tool/.python-version: New file. > * vmtest-tool/README: New file. > * vmtest-tool/__init__.py: New file. > * vmtest-tool/bpf.py: New file. > * vmtest-tool/config.py: New file. > * vmtest-tool/kernel.py: New file. > * vmtest-tool/main.py: New file. > * vmtest-tool/pyproject.toml: New file. > * vmtest-tool/requirements-dev.txt: New file. > * vmtest-tool/tests/test_cli.py: New file. > * vmtest-tool/utils.py: New file. > * vmtest-tool/uv.lock: New file. > * vmtest-tool/vm.py: New file. > > Signed-off-by: Piyush Raj <piyushraj92...@gmail.com> > --- > contrib/vmtest-tool/.gitignore | 23 ++ > contrib/vmtest-tool/.pre-commit-config.yaml | 32 ++ > contrib/vmtest-tool/.python-version | 1 + > contrib/vmtest-tool/README | 75 ++++ > contrib/vmtest-tool/__init__.py | 0 > contrib/vmtest-tool/bpf.py | 193 ++++++++++ > contrib/vmtest-tool/config.py | 11 + > contrib/vmtest-tool/kernel.py | 209 +++++++++++ > contrib/vmtest-tool/main.py | 101 ++++++ > contrib/vmtest-tool/pyproject.toml | 36 ++ > contrib/vmtest-tool/requirements-dev.txt | 198 ++++++++++ > contrib/vmtest-tool/tests/test_cli.py | 170 +++++++++ > contrib/vmtest-tool/utils.py | 26 ++ > contrib/vmtest-tool/uv.lock | 380 ++++++++++++++++++++ > contrib/vmtest-tool/vm.py | 154 ++++++++ > 15 files changed, 1609 insertions(+) > create mode 100644 contrib/vmtest-tool/.gitignore > create mode 100644 contrib/vmtest-tool/.pre-commit-config.yaml > create mode 100644 contrib/vmtest-tool/.python-version > create mode 100644 contrib/vmtest-tool/README > create mode 100644 contrib/vmtest-tool/__init__.py > create mode 100644 contrib/vmtest-tool/bpf.py > create mode 100644 contrib/vmtest-tool/config.py > create mode 100644 contrib/vmtest-tool/kernel.py > create mode 100644 contrib/vmtest-tool/main.py > create mode 100644 contrib/vmtest-tool/pyproject.toml > create mode 100644 contrib/vmtest-tool/requirements-dev.txt > create mode 100644 contrib/vmtest-tool/tests/test_cli.py > create mode 100644 contrib/vmtest-tool/utils.py > create mode 100644 contrib/vmtest-tool/uv.lock > create mode 100644 contrib/vmtest-tool/vm.py > > diff --git a/contrib/vmtest-tool/.gitignore b/contrib/vmtest-tool/.gitignore > new file mode 100644 > index 00000000000..9cec867f093 > --- /dev/null > +++ b/contrib/vmtest-tool/.gitignore > @@ -0,0 +1,23 @@ > +.gitignore_local > + > +# Byte-compiled / optimized / DLL files > +__pycache__/ > +*.py[codz] > +*$py.class > + > +# Unit test / coverage reports > +.pytest_cache/ > + > + > +# Environments > +.env > +.envrc > +.venv > +env/ > +venv/ > +ENV/ > +env.bak/ > +venv.bak/ > + > +# Ruff stuff: > +.ruff_cache/ > diff --git a/contrib/vmtest-tool/.pre-commit-config.yaml > b/contrib/vmtest-tool/.pre-commit-config.yaml > new file mode 100644 > index 00000000000..26cb68389ba > --- /dev/null > +++ b/contrib/vmtest-tool/.pre-commit-config.yaml > @@ -0,0 +1,32 @@ > +fail_fast: true > +repos: > +- repo: https://github.com/astral-sh/ruff-pre-commit > + # Ruff version. > + rev: v0.11.13 > + hooks: > + # Run the linter. > + - id: ruff-check > + args: [ --fix ] > + # Run the formatter. > + - id: ruff-format > +- repo: local > + hooks: > + - id: uv-export > + name: uv-export > + description: "update requirements-dev.txt" > + entry: uv export > + language: system > + files: ^contrib/vmtest-tool/uv\.lock$ > + args: ["--directory=contrib/vmtest-tool","--frozen", > "--output-file=requirements-dev.txt"] > + pass_filenames: false > + additional_dependencies: [] > + minimum_pre_commit_version: "2.9.2" > + - id: pytest-check > + name: pytest-check > + stages: [pre-commit] > + types: [python] > + entry: bash -c 'cd contrib/vmtest-tool && export PATH="$PWD/bin:$PATH" > && uv run pytest' > + language: system > + pass_filenames: false > + always_run: true > + verbose: true > diff --git a/contrib/vmtest-tool/.python-version > b/contrib/vmtest-tool/.python-version > new file mode 100644 > index 00000000000..24ee5b1be99 > --- /dev/null > +++ b/contrib/vmtest-tool/.python-version > @@ -0,0 +1 @@ > +3.13 > diff --git a/contrib/vmtest-tool/README b/contrib/vmtest-tool/README > new file mode 100644 > index 00000000000..550950d0b63 > --- /dev/null > +++ b/contrib/vmtest-tool/README > @@ -0,0 +1,75 @@ > +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 --kernel-image 6.15 --bpf-src fail.c > + Wouldn't --kernel-image expect the path to a kernel image? Typo? > +To run a precompiled BPF object file: > + > + python main.py --kernel-image 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. > + > +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.10 > +- 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 > + > +DEVELOPMENT > +=========== > + > +This tool uses `uv` (https://github.com/astral-sh/uv) for virtual environment > +and dependency management. > + > +To install development dependencies: > + > + uv sync > + > +To run the test suite: > + > + uv run pytest > + > +A `.pre-commit-config.yaml` is provided to assist with development. > +Pre-commit hooks will auto-generate `requirements-dev.txt` and lint python > +files. > + > +To enable pre-commit hooks: > + > + uv run pre-commit install Having uv installed would only be necessary for vmtest-tool development purposes, right? Not to run the script. I see that all the dependencies in vmtest-tool/pyproject.toml are related to testing and python linting. Is the 3.10 minimum Python version requirement associated with any of these dependencies? If so, we could maybe relax the minimum version requirement for users of the script? My Debian-like system has python 3.9.2, for example. > diff --git a/contrib/vmtest-tool/__init__.py b/contrib/vmtest-tool/__init__.py > new file mode 100644 > index 00000000000..e69de29bb2d > diff --git a/contrib/vmtest-tool/bpf.py b/contrib/vmtest-tool/bpf.py > new file mode 100644 > index 00000000000..291e251e64c > --- /dev/null > +++ b/contrib/vmtest-tool/bpf.py > @@ -0,0 +1,193 @@ > +import re > +import subprocess > +import logging > +from pathlib import Path > +import tempfile > +import utils > +import config > + > +logger = logging.getLogger(__name__) > +# https://git.sr.ht/~brianwitte/gcc-bpf-example/tree/master/item/Makefile > +BPF_INCLUDES = ["-I/usr/local/include", "-I/usr/include"] > + > + > +def generate_sanitized_name(path): > + """generate sanitized c variable name""" > + name = re.sub(r"\W", "_", path.stem) > + if name and name[0].isdigit(): > + name = "_" + name > + return name > + > + > +class BPFProgram: > + tmp_base_dir = tempfile.TemporaryDirectory(prefix="vmtest") > + tmp_base_dir_path = Path(tmp_base_dir.name) > + > + def __init__( > + self, > + source_path: Path | None = None, > + bpf_bytecode_path: Path | None = None, > + use_temp_dir: bool = False, > + ): > + path = source_path or bpf_bytecode_path > + self.name = generate_sanitized_name(path) > + self.build_dir = self.__class__.tmp_base_dir_path / "ebpf_programs" > / self.name > + > + if source_path: > + self.bpf_src = source_path > + self.bpf_obj = self.build_dir / f"{self.name}.bpf.o" > + else: > + self.bpf_obj = bpf_bytecode_path > + self.build_dir.mkdir(parents=True, exist_ok=True) > + self.bpf_skel = self.build_dir / f"{self.name}.skel.h" > + self.loader_src = self.build_dir / f"{self.name}-loader.c" > + self.output = self.build_dir / f"{self.name}.o" > + > + @classmethod > + def from_source(cls, source_path: Path): > + self = cls(source_path=source_path) > + self._compile_bpf() > + self._compile_from_bpf_bytecode() > + return self.output > + > + @classmethod > + def from_bpf_obj(cls, obj_path: Path): > + self = cls(bpf_bytecode_path=obj_path) > + self._compile_from_bpf_bytecode() > + return self.output > + > + def _compile_from_bpf_bytecode(self): > + self._generate_skeleton() > + self._compile_loader() > + > + def _compile_bpf(self): > + """Compile the eBPF program using gcc""" > + logger.info(f"Compiling eBPF source: {self.bpf_src}") > + cmd = [ > + "bpf-unknown-none-gcc", > + "-g", > + "-O2", > + "-std=gnu17", > + f"-D__TARGET_ARCH_{config.ARCH}", > + "-gbtf", > + "-Wno-error=attributes", > + "-Wno-error=address-of-packed-member", > + "-Wno-compare-distinct-pointer-types", > + *BPF_INCLUDES, > + "-c", > + str(self.bpf_src), > + "-o", > + str(self.bpf_obj), > + ] It shall definitely be possible to specify the set of compilation flags using some environment variables like BPF_CFLAGS and BPF_CPPFLAGS, or arguments to the script. The GCC testsuite will want to do torture-like testing of the bpf programs by compiling and running them using different set of optimization options (-O0, -Os, -O2, other options...) any of which may break verifiability. > + logger.debug("".join(cmd)) > + utils.run_command(cmd) > + logger.info(f"eBPF compiled: {self.bpf_obj}") > + > + def _generate_skeleton(self): > + """Generate the BPF skeleton header using bpftool""" > + logger.info(f"Generating skeleton: {self.bpf_skel}") > + cmd = ["bpftool", "gen", "skeleton", str(self.bpf_obj), "name", > self.name] > + try: > + result = utils.run_command(cmd) > + with open(self.bpf_skel, "w") as f: > + f.write(result.stdout) > + logger.info("Skeleton generated.") > + logger.debug("bpftool output:\n%s", result.stdout) > + if result.stderr: > + logger.debug("bpftool output:\n%s", result.stderr) > + except subprocess.CalledProcessError as e: > + logger.error("Failed to generate skeleton.") > + logger.error("stdout:\n%s", e.stdout) > + logger.error("stderr:\n%s", e.stderr) > + raise > + > + def _compile_loader(self): > + """Compile the C loader program""" > + self.generate_loader() > + logger.info(f"Compiling loader: {self.loader_src}") > + cmd = [ > + "gcc", > + "-g", > + "-Wall", > + "-I", > + str(self.build_dir), > + str(self.loader_src), > + "-lelf", > + "-lz", > + "-lbpf", > + "-o", > + str(self.output), > + ] > + utils.run_command(cmd) > + logger.info("Compilation complete") > + > + def generate_loader(self): > + """ > + Generate a loader C file for the given BPF skeleton. > + > + Args: > + bpf_name (str): Name of the BPF program (e.g. "prog"). > + output_path (str): Path to write loader.c. > + """ > + skeleton_header = f"{self.name}.skel.h" > + loader_code = f"""\ > + #include <stdio.h> > + #include <stdlib.h> > + #include <signal.h> > + #include <unistd.h> > + #include <bpf/libbpf.h> > + #include "{skeleton_header}" > + > + #define LOG_BUF_SIZE 1024 * 1024 > + > + static volatile sig_atomic_t stop; > + static char log_buf[LOG_BUF_SIZE]; > + > + void handle_sigint(int sig) {{ > + stop = 1; > + }} > + > + int main() {{ > + struct {self.name} *skel; > + struct bpf_program *prog; > + int err; > + > + signal(SIGINT, handle_sigint); > + > + skel = {self.name}__open(); // STEP 1: open only > + if (!skel) {{ > + fprintf(stderr, "Failed to open BPF skeleton\\n"); > + return 1; > + }} > + > + // STEP 2: Get the bpf_program object for the main program > + bpf_object__for_each_program(prog, skel->obj) {{ > + bpf_program__set_log_buf(prog, log_buf, sizeof(log_buf)); > + bpf_program__set_log_level(prog, 1); // optional: verbose > logs > + }} > + > + // STEP 3: Load the program (this will trigger verifier log > output) > + err = {self.name}__load(skel); > + fprintf( > + stderr, > + "--- Verifier log start ---\\n" > + "%s\\n" > + "--- Verifier log end ---\\n", > + log_buf Eventually, the output of the loader when the verifier rejects a program ought to be suitable for our dejagnu glue code to interpret it as a pass or a fail. > + ); > + if (err) {{ > + fprintf(stderr, "Failed to load BPF skeleton: %d\\n", err); > + {self.name}__destroy(skel); > + return 1; > + }} > + > + printf("BPF program loaded successfully.\\n"); > + > + {self.name}__destroy(skel); > + return 0; > + }} > + > + """ > + with open(self.loader_src, "w") as f: > + f.write(loader_code) > + logger.info(f"Generated loader at {self.loader_src}") > diff --git a/contrib/vmtest-tool/config.py b/contrib/vmtest-tool/config.py > new file mode 100644 > index 00000000000..ba913bce9d6 > --- /dev/null > +++ b/contrib/vmtest-tool/config.py > @@ -0,0 +1,11 @@ > +import platform > +from pathlib import Path > + > +KERNEL_TARBALL_PREFIX_URL = "https://cdn.kernel.org/pub/linux/kernel/" > +BASE_DIR = Path.home() / ".vmtest-tool" > +ARCH = platform.machine() > +KCONFIG_REL_PATHS = [ > + "tools/testing/selftests/bpf/config", > + "tools/testing/selftests/bpf/config.vm", > + f"tools/testing/selftests/bpf/config.{ARCH}", > +] > diff --git a/contrib/vmtest-tool/kernel.py b/contrib/vmtest-tool/kernel.py > new file mode 100644 > index 00000000000..2974948130e > --- /dev/null > +++ b/contrib/vmtest-tool/kernel.py > @@ -0,0 +1,209 @@ > +import logging > +import os > +import shutil > +import subprocess > +from pathlib import Path > +import re > +from urllib.parse import urljoin > +from urllib.request import urlretrieve > +from typing import Optional, List > +from dataclasses import dataclass > + > +from config import ARCH, BASE_DIR, KCONFIG_REL_PATHS, > KERNEL_TARBALL_PREFIX_URL > +import utils > + > +logger = logging.getLogger(__name__) > +KERNELS_DIR = BASE_DIR / "kernels" > + > + > +@dataclass > +class KernelSpec: > + """Immutable kernel specification""" > + > + version: str > + arch: str = ARCH > + > + def __post_init__(self): > + self.major = self.version.split(".")[0] > + > + def __str__(self): > + return f"{self.version}-{self.arch}" > + > + @property > + def bzimage_path(self) -> Path: > + return KERNELS_DIR / f"bzImage-{self}" > + > + @property > + def tarball_path(self) -> Path: > + return KERNELS_DIR / f"linux-{self.version}.tar.xz" > + > + @property > + def kernel_dir(self) -> Path: > + return KERNELS_DIR / f"linux-{self.version}" > + > + > +class KernelImage: > + """Represents a compiled kernel image""" > + > + def __init__(self, path: Path): > + if not isinstance(path, Path): > + path = Path(path) > + > + if not path.exists(): > + raise FileNotFoundError(f"Kernel image not found: {path}") > + > + self.path = path > + > + def __str__(self): > + return str(self.path) > + > + > +class KernelCompiler: > + """Handles complete kernel compilation process including download and > build""" > + > + def compile_from_version(self, spec: KernelSpec) -> KernelImage: > + """Complete compilation process from kernel version""" > + if spec.bzimage_path.exists(): > + logger.info(f"Kernel {spec} already exists, skipping > compilation") > + return KernelImage(spec.bzimage_path) > + > + try: > + self._download_source(spec) > + self._extract_source(spec) > + self._configure_kernel(spec) > + self._compile_kernel(spec) > + self._copy_bzimage(spec) > + > + logger.info(f"Successfully compiled kernel {spec}") > + return KernelImage(spec.bzimage_path) > + > + except Exception as e: > + logger.error(f"Failed to compile kernel {spec}: {e}") > + raise > + finally: > + # Always cleanup temporary files > + self._cleanup(spec) > + > + def _download_source(self, spec: KernelSpec) -> None: > + """Download kernel source tarball""" > + if spec.tarball_path.exists(): > + logger.info(f"Tarball already exists: {spec.tarball_path}") > + return > + > + url_suffix = f"v{spec.major}.x/linux-{spec.version}.tar.xz" > + url = urljoin(KERNEL_TARBALL_PREFIX_URL, url_suffix) > + > + logger.info(f"Downloading kernel from {url}") > + spec.tarball_path.parent.mkdir(parents=True, exist_ok=True) > + urlretrieve(url, spec.tarball_path) > + logger.info("Kernel source downloaded") > + > + def _extract_source(self, spec: KernelSpec) -> None: > + """Extract kernel source tarball""" > + logger.info(f"Extracting kernel source to {spec.kernel_dir}") > + spec.kernel_dir.mkdir(parents=True, exist_ok=True) > + > + utils.run_command( > + [ > + "tar", > + "-xf", > + str(spec.tarball_path), > + "-C", > + str(spec.kernel_dir), > + "--strip-components=1", > + ] > + ) > + > + def _configure_kernel(self, spec: KernelSpec) -> None: > + """Configure kernel with provided config files""" > + config_path = spec.kernel_dir / ".config" > + > + with open(config_path, "wb") as kconfig: > + for config_rel_path in KCONFIG_REL_PATHS: > + config_abs_path = spec.kernel_dir / config_rel_path > + if config_abs_path.exists(): > + with open(config_abs_path, "rb") as conf: > + kconfig.write(conf.read()) > + > + logger.info("Updated kernel configuration") > + > + def _compile_kernel(self, spec: KernelSpec) -> None: > + """Compile the kernel""" > + logger.info(f"Compiling kernel in {spec.kernel_dir}") > + old_cwd = os.getcwd() > + > + try: > + os.chdir(spec.kernel_dir) > + utils.run_command(["make", "olddefconfig"]) > + utils.run_command(["make", f"-j{os.cpu_count()}", "bzImage"]) > + except subprocess.CalledProcessError as e: > + logger.error(f"Kernel compilation failed: {e}") > + raise > + finally: > + os.chdir(old_cwd) > + > + def _copy_bzimage(self, spec: KernelSpec) -> None: > + """Copy compiled bzImage to final location""" > + src = spec.kernel_dir / "arch/x86/boot/bzImage" > + dest = spec.bzimage_path > + dest.parent.mkdir(parents=True, exist_ok=True) > + > + shutil.copy2(src, dest) > + logger.info(f"Stored bzImage at {dest}") > + > + def _cleanup(self, spec: KernelSpec) -> None: > + """Clean up temporary files""" > + if spec.tarball_path.exists(): > + spec.tarball_path.unlink() > + logger.info("Removed tarball") > + > + if spec.kernel_dir.exists(): > + shutil.rmtree(spec.kernel_dir) > + logger.info("Removed kernel source directory") > + > + > +class KernelManager: > + """Main interface for kernel management""" > + > + def __init__(self): > + self.compiler = KernelCompiler() > + > + def get_kernel_image( > + self, > + version: Optional[str] = None, > + kernel_image_path: Optional[str] = None, > + arch: str = ARCH, > + ) -> KernelImage: > + """Get kernel image from version or existing file""" > + > + # Validate inputs > + if not version and not kernel_image_path: > + raise ValueError("Must provide either 'version' or > 'kernel_image_path'") > + > + if version and kernel_image_path: > + raise ValueError("Provide only one of 'version' or > 'kernel_image_path'") > + > + # Handle existing kernel image > + if kernel_image_path: > + path = Path(kernel_image_path) > + if not path.exists(): > + raise FileNotFoundError(f"Kernel image not found: > {kernel_image_path}") > + return KernelImage(path) > + > + # Handle version-based compilation > + if version: > + spec = KernelSpec(version=version, arch=arch) > + return self.compiler.compile_from_version(spec) > + > + def list_available_kernels(self) -> List[str]: > + """List all available compiled kernels""" > + if not KERNELS_DIR.exists(): > + return [] > + > + kernels = [] > + for file in KERNELS_DIR.glob("bzImage-*"): > + match = re.match(r"bzImage-(.*)", file.name) > + if match: > + kernels.append(match.group(1)) > + > + return sorted(kernels) > diff --git a/contrib/vmtest-tool/main.py b/contrib/vmtest-tool/main.py > new file mode 100644 > index 00000000000..bd408badef1 > --- /dev/null > +++ b/contrib/vmtest-tool/main.py > @@ -0,0 +1,101 @@ > +import argparse > +import logging > +from pathlib import Path > +import textwrap > + > +import bpf > +import kernel > +import vm > + > + > +def main(): > + parser = argparse.ArgumentParser() > + kernel_group = parser.add_mutually_exclusive_group(required=True) > + kernel_group.add_argument( > + "-k", > + "--kernel", > + help="Kernel version to boot in the vm", > + metavar="VERSION", > + type=str, > + ) > + kernel_group.add_argument( > + "--kernel-image", > + help="Kernel image to boot in the vm", > + metavar="PATH", > + type=str, > + ) > + parser.add_argument( > + "-r", "--rootfs", help="rootfs to mount in the vm", default="/", > metavar="PATH" > + ) > + parser.add_argument( > + "-v", > + "--log-level", > + help="Log level", > + metavar="DEBUG|INFO|WARNING|ERROR", > + choices=["DEBUG", "INFO", "WARNING", "ERROR"], > + default="ERROR", > + ) > + command_group = parser.add_mutually_exclusive_group(required=True) > + command_group.add_argument( > + "--bpf-src", > + help="Path to BPF C source file", > + metavar="PATH", > + type=str, > + ) > + command_group.add_argument( > + "--bpf-obj", > + help="Path to bpf bytecode object", > + metavar="PATH", > + type=str, > + ) > + command_group.add_argument( > + "-c", "--command", help="command to run in the vm", metavar="COMMAND" > + ) > + command_group.add_argument( > + "-s", "--shell", help="open interactive shell in the vm", > action="store_true" > + ) > + > + args = parser.parse_args() > + > + logging.basicConfig(level=args.log_level) > + logger = logging.getLogger(__name__) > + kmanager = kernel.KernelManager() > + > + if args.kernel: > + kernel_image = kmanager.get_kernel_image(version=args.kernel) > + elif args.kernel_image: > + kernel_image = > kmanager.get_kernel_image(kernel_image_path=args.kernel_image) > + > + if args.bpf_src: > + command = bpf.BPFProgram.from_source(Path(args.bpf_src)) > + elif args.bpf_obj: > + command = bpf.BPFProgram.from_bpf_obj(Path(args.bpf_obj)) > + elif args.command: > + command = args.command > + elif args.shell: > + # todo: somehow pass to hyperwiser that you need to attach stdin as > well > + # command = "/bin/bash" > + raise NotImplementedError > + > + virtual_machine = vm.VirtualMachine(kernel_image, args.rootfs, > str(command)) > + try: > + result = virtual_machine.execute() > + except vm.BootFailedError as e: > + logger.error("VM boot failure: execution aborted. See logs for > details.") > + print(e) > + exit(e.returncode) > + if args.bpf_src or args.bpf_obj: > + if result.returncode == 0: > + print("BPF programs succesfully loaded") > + else: > + if "Failed to load BPF skeleton" in result.stdout: > + print("BPF program failed to load") > + print("Verifier logs:") > + print(textwrap.indent(vm.bpf_verifier_logs(result.stdout), > "\t")) > + elif args.command: > + print(result.stdout) > + exit(result.returncode) > + > + > +if __name__ == "__main__": > + main() > diff --git a/contrib/vmtest-tool/pyproject.toml > b/contrib/vmtest-tool/pyproject.toml > new file mode 100644 > index 00000000000..e4701ec6e8f > --- /dev/null > +++ b/contrib/vmtest-tool/pyproject.toml > @@ -0,0 +1,36 @@ > +[project] > +name = "vmtest-tool" > +version = "0.1.0" > +description = "Test BPF code against live kernels" > +readme = "README.md" > +requires-python = ">=3.10" > +dependencies = [] > + > +[dependency-groups] > +dev = [ > + "pre-commit>=4.2.0", > + "pytest>=8.4.0", > + "pytest-sugar>=1.0.0", > + "ruff>=0.11.13", > + "tox>=4.26.0", > +] > + > +[tool.pytest.ini_options] > +addopts = [ > + "--import-mode=importlib", > +] > +testpaths = ["tests"] > +pythonpath = ["."] > + > +[tool.ruff.lint] > +select = [ > + # pycodestyle > + "E", > + # Pyflakes > + "F", > +] > +# Allow fix for all enabled rules (when `--fix`) is provided. > +fixable = ["ALL"] > + > +# Allow unused variables when underscore-prefixed. > +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" > \ No newline at end of file > diff --git a/contrib/vmtest-tool/requirements-dev.txt > b/contrib/vmtest-tool/requirements-dev.txt > new file mode 100644 > index 00000000000..eec7e90fbe0 > --- /dev/null > +++ b/contrib/vmtest-tool/requirements-dev.txt > @@ -0,0 +1,198 @@ > +# This file was autogenerated by uv via the following command: > +# uv export --directory=contrib/vmtest-tool --frozen > --output-file=requirements-dev.txt > +cachetools==6.0.0 \ > + > --hash=sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e > \ > + > --hash=sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf > + # via tox > +cfgv==3.4.0 \ > + > --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 > \ > + > --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 > + # via pre-commit > +chardet==5.2.0 \ > + > --hash=sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7 > \ > + > --hash=sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970 > + # via tox > +colorama==0.4.6 \ > + > --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 > \ > + > --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 > + # via > + # pytest > + # tox > +distlib==0.3.9 \ > + > --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 > \ > + > --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 > + # via virtualenv > +exceptiongroup==1.3.0 ; python_full_version < '3.11' \ > + > --hash=sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10 > \ > + > --hash=sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88 > + # via pytest > +filelock==3.18.0 \ > + > --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 > \ > + > --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de > + # via > + # tox > + # virtualenv > +identify==2.6.12 \ > + > --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 > \ > + > --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 > + # via pre-commit > +iniconfig==2.1.0 \ > + > --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 > \ > + > --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 > + # via pytest > +nodeenv==1.9.1 \ > + > --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f > \ > + > --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 > + # via pre-commit > +packaging==25.0 \ > + > --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 > \ > + > --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f > + # via > + # pyproject-api > + # pytest > + # pytest-sugar > + # tox > +platformdirs==4.3.8 \ > + > --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc > \ > + > --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 > + # via > + # tox > + # virtualenv > +pluggy==1.6.0 \ > + > --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 > \ > + > --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 > + # via > + # pytest > + # tox > +pre-commit==4.2.0 \ > + > --hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 > \ > + > --hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd > +pygments==2.19.1 \ > + > --hash=sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f > \ > + > --hash=sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c > + # via pytest > +pyproject-api==1.9.1 \ > + > --hash=sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335 > \ > + > --hash=sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948 > + # via tox > +pytest==8.4.0 \ > + > --hash=sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6 > \ > + > --hash=sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e > + # via pytest-sugar > +pytest-sugar==1.0.0 \ > + > --hash=sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a > \ > + > --hash=sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd > +pyyaml==6.0.2 \ > + > --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 > \ > + > --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 > \ > + > --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 > \ > + > --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 > \ > + > --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 > \ > + > --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee > \ > + > --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 > \ > + > --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 > \ > + > --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf > \ > + > --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 > \ > + > --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 > \ > + > --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc > \ > + > --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 > \ > + > --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 > \ > + > --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c > \ > + > --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 > \ > + > --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 > \ > + > --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e > \ > + > --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b > \ > + > --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 > \ > + > --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 > \ > + > --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 > \ > + > --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 > \ > + > --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b > \ > + > --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 > \ > + > --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 > \ > + > --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e > \ > + > --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 > \ > + > --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab > \ > + > --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 > \ > + > --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 > \ > + > --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e > \ > + > --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 > \ > + > --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed > \ > + > --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 > \ > + > --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba > \ > + > --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 > + # via pre-commit > +ruff==0.11.13 \ > + > --hash=sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629 > \ > + > --hash=sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432 > \ > + > --hash=sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514 > \ > + > --hash=sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3 > \ > + > --hash=sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc > \ > + > --hash=sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46 > \ > + > --hash=sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9 > \ > + > --hash=sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492 > \ > + > --hash=sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b > \ > + > --hash=sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165 > \ > + > --hash=sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71 > \ > + > --hash=sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc > \ > + > --hash=sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250 > \ > + > --hash=sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a > \ > + > --hash=sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48 > \ > + > --hash=sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b > \ > + > --hash=sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7 > \ > + > --hash=sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933 > +termcolor==3.1.0 \ > + > --hash=sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa > \ > + > --hash=sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970 > + # via pytest-sugar > +tomli==2.2.1 ; python_full_version < '3.11' \ > + > --hash=sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6 > \ > + > --hash=sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd > \ > + > --hash=sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c > \ > + > --hash=sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b > \ > + > --hash=sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8 > \ > + > --hash=sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6 > \ > + > --hash=sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77 > \ > + > --hash=sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff > \ > + > --hash=sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea > \ > + > --hash=sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192 > \ > + > --hash=sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249 > \ > + > --hash=sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee > \ > + > --hash=sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4 > \ > + > --hash=sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98 > \ > + > --hash=sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8 > \ > + > --hash=sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4 > \ > + > --hash=sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281 > \ > + > --hash=sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744 > \ > + > --hash=sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69 > \ > + > --hash=sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13 > \ > + > --hash=sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140 > \ > + > --hash=sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e > \ > + > --hash=sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e > \ > + > --hash=sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc > \ > + > --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff > \ > + > --hash=sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec > \ > + > --hash=sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2 > \ > + > --hash=sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222 > \ > + > --hash=sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106 > \ > + > --hash=sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272 > \ > + > --hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a > \ > + > --hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7 > + # via > + # pyproject-api > + # pytest > + # tox > +tox==4.26.0 \ > + > --hash=sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224 > \ > + > --hash=sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca > +typing-extensions==4.14.0 ; python_full_version < '3.11' \ > + > --hash=sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4 > \ > + > --hash=sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af > + # via > + # exceptiongroup > + # tox > +virtualenv==20.31.2 \ > + > --hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 > \ > + > --hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af > + # via > + # pre-commit > + # tox > diff --git a/contrib/vmtest-tool/tests/test_cli.py > b/contrib/vmtest-tool/tests/test_cli.py > new file mode 100644 > index 00000000000..261ce4b9534 > --- /dev/null > +++ b/contrib/vmtest-tool/tests/test_cli.py > @@ -0,0 +1,170 @@ > +import sys > +from unittest import mock > +import pytest > +from bpf import BPFProgram > +import kernel > +import main > +import logging > + > +logger = logging.getLogger(__name__) > + > + > +@pytest.fixture > +def openat_bpf_source(tmp_path): > + openat_bpf = tmp_path / "openat_bpf.c" > + openat_bpf.write_text(r""" > + #include "vmlinux.h" > + #include <bpf/bpf_helpers.h> > + #include <bpf/bpf_tracing.h> > + #include <bpf/bpf_core_read.h> > + > + char LICENSE[] SEC("license") = "GPL"; > + > + int example_pid = 0; > + > + SEC("tracepoint/syscalls/sys_enter_openat") > + int handle_openat(struct trace_event_raw_sys_enter *ctx) > + { > + int pid = bpf_get_current_pid_tgid() >> 32; > + char filename[256]; // filename buffer > + bpf_probe_read_user(&filename, sizeof(filename), (void > *)ctx->args[1]); > + bpf_printk("sys_enter_openat() called from PID %d for file: %s\n", > pid, > + filename); > + > + return 0; > + } > + > + """) > + return openat_bpf > + > + > +@pytest.fixture > +def openat_bpf_obj(openat_bpf_source): > + bpf_program = BPFProgram(source_path=openat_bpf_source) > + bpf_program._compile_bpf() > + return bpf_program.bpf_obj > + > + > +@pytest.fixture > +def invalid_memory_access_bpf_source(tmp_path): > + invalid_memory_access_bpf = tmp_path / "invalid_memory_access_bpf.c" > + invalid_memory_access_bpf.write_text(r""" > + #include "vmlinux.h" > + #include <bpf/bpf_helpers.h> > + #include <bpf/bpf_tracing.h> > + > + char LICENSE[] SEC("license") = "GPL"; > + > + SEC("tracepoint/syscalls/sys_enter_openat") > + int bpf_prog(struct trace_event_raw_sys_enter *ctx) { > + int arr[4] = {1, 2, 3, 4}; > + > + // Invalid memory access: out-of-bounds > + int val = arr[5]; // This causes the verifier to fail > + > + return val; > + } > + """) > + return invalid_memory_access_bpf > + > + > +@pytest.fixture > +def invalid_memory_access_bpf_obj(invalid_memory_access_bpf_source): > + bpf_program = BPFProgram(source_path=invalid_memory_access_bpf_source) > + bpf_program._compile_bpf() > + return bpf_program.bpf_obj > + > + > +def run_main_with_args_and_capture_output(args, capsys): > + with mock.patch.object(sys, "argv", args): > + try: > + main.main() > + except SystemExit as e: > + result = capsys.readouterr() > + output = result.out.rstrip() > + error = result.err.rstrip() > + logger.debug("STDOUT:\n%s", output) > + logger.debug("STDERR:\n%s", error) > + return e.code, output, error > + > + > +kernel_image_path = kernel.KernelManager().get_kernel_image(version="6.15") > +kernel_cli_flags = [["--kernel", "6.15"], ["--kernel-image", > f"{kernel_image_path}"]] > + > + > +@pytest.mark.parametrize("kernel_args", kernel_cli_flags) > +class TestCLI: > + def test_main_with_valid_bpf(self, kernel_args, openat_bpf_source, > capsys): > + args = [ > + "main.py", > + *kernel_args, > + "--rootfs", > + "/", > + "--bpf-src", > + str(openat_bpf_source), > + ] > + code, output, _ = run_main_with_args_and_capture_output(args, capsys) > + assert code == 0 > + assert "BPF programs succesfully loaded" == output > + > + def test_main_with_valid_bpf_obj(self, kernel_args, openat_bpf_obj, > capsys): > + args = [ > + "main.py", > + *kernel_args, > + "--rootfs", > + "/", > + "--bpf-obj", > + str(openat_bpf_obj), > + ] > + code, output, _ = run_main_with_args_and_capture_output(args, capsys) > + assert code == 0 > + assert "BPF programs succesfully loaded" == output > + > + def test_main_with_invalid_bpf( > + self, kernel_args, invalid_memory_access_bpf_source, capsys > + ): > + args = [ > + "main.py", > + *kernel_args, > + "--rootfs", > + "/", > + "--bpf-src", > + str(invalid_memory_access_bpf_source), > + ] > + code, output, _ = run_main_with_args_and_capture_output(args, capsys) > + output_lines = output.splitlines() > + assert code == 1 > + assert "BPF program failed to load" == output_lines[0] > + assert "Verifier logs:" == output_lines[1] > + > + def test_main_with_invalid_bpf_obj( > + self, kernel_args, invalid_memory_access_bpf_obj, capsys > + ): > + args = [ > + "main.py", > + *kernel_args, > + "--rootfs", > + "/", > + "--bpf-obj", > + str(invalid_memory_access_bpf_obj), > + ] > + code, output, _ = run_main_with_args_and_capture_output(args, capsys) > + output_lines = output.splitlines() > + assert code == 1 > + assert "BPF program failed to load" == output_lines[0] > + assert "Verifier logs:" == output_lines[1] > + > + def test_main_with_valid_command(self, kernel_args, capsys): > + args = ["main.py", *kernel_args, "--rootfs", "/", "-c", "uname -r"] > + code, output, error = run_main_with_args_and_capture_output(args, > capsys) > + assert code == 0 > + assert "6.15.0" == output > + > + def test_main_with_invalid_command(self, kernel_args, capsys): > + args = ["main.py", *kernel_args, "--rootfs", "/", "-c", > "NotImplemented"] > + code, output, error = run_main_with_args_and_capture_output(args, > capsys) > + assert code != 0 > + assert f"Command failed with exit code: {code}" in output > + > + # def test_main_with_interupts(): > + # raise NotImplementedError > diff --git a/contrib/vmtest-tool/utils.py b/contrib/vmtest-tool/utils.py > new file mode 100644 > index 00000000000..fe80a648b21 > --- /dev/null > +++ b/contrib/vmtest-tool/utils.py > @@ -0,0 +1,26 @@ > +import subprocess > +import logging > + > +logger = logging.getLogger(__name__) > + > + > +def run_command(cmd, **kwargs): > + logger.debug(f"Running command: {' '.join(cmd)}") > + try: > + logger.debug(f"running command: {cmd}") > + result = subprocess.run( > + cmd, > + text=True, > + check=True, > + capture_output=True, > + shell=False, > + **kwargs, > + ) > + logger.debug("Command stdout:\n" + result.stdout.strip()) > + if result.stderr: > + logger.debug("Command stderr:\n" + result.stderr.strip()) > + return result > + except subprocess.CalledProcessError as e: > + logger.error("Command failed with stdout:\n" + e.stdout.strip()) > + logger.error("Command failed with stderr:\n" + e.stderr.strip()) > + raise > diff --git a/contrib/vmtest-tool/uv.lock b/contrib/vmtest-tool/uv.lock > new file mode 100644 > index 00000000000..3ee8ba8ed85 > --- /dev/null > +++ b/contrib/vmtest-tool/uv.lock > @@ -0,0 +1,380 @@ > +version = 1 > +revision = 2 > +requires-python = ">=3.10" > + > +[[package]] > +name = "cachetools" > +version = "6.0.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/c0/b0/f539a1ddff36644c28a61490056e5bae43bd7386d9f9c69beae2d7e7d6d1/cachetools-6.0.0.tar.gz", > hash = > "sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf", > size = 30160, upload-time = "2025-05-23T20:01:13.076Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/6a/c3/8bb087c903c95a570015ce84e0c23ae1d79f528c349cbc141b5c4e250293/cachetools-6.0.0-py3-none-any.whl", > hash = > "sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e", > size = 10964, upload-time = "2025-05-23T20:01:11.323Z" }, > +] > + > +[[package]] > +name = "cfgv" > +version = "3.4.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", > hash = > "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", > size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", > hash = > "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", > size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, > +] > + > +[[package]] > +name = "chardet" > +version = "5.2.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", > hash = > "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", > size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", > hash = > "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", > size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, > +] > + > +[[package]] > +name = "colorama" > +version = "0.4.6" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", > hash = > "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", > size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", > hash = > "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", > size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, > +] > + > +[[package]] > +name = "distlib" > +version = "0.3.9" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", > hash = > "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", > size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", > hash = > "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", > size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, > +] > + > +[[package]] > +name = "exceptiongroup" > +version = "1.3.0" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", > hash = > "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", > size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", > hash = > "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", > size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, > +] > + > +[[package]] > +name = "filelock" > +version = "3.18.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", > hash = > "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", > size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", > hash = > "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", > size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, > +] > + > +[[package]] > +name = "identify" > +version = "2.6.12" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", > hash = > "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", > size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", > hash = > "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", > size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, > +] > + > +[[package]] > +name = "iniconfig" > +version = "2.1.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", > hash = > "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", > size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", > hash = > "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", > size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, > +] > + > +[[package]] > +name = "nodeenv" > +version = "1.9.1" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", > hash = > "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", > size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", > hash = > "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", > size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, > +] > + > +[[package]] > +name = "packaging" > +version = "25.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", > hash = > "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", > size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", > hash = > "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", > size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, > +] > + > +[[package]] > +name = "platformdirs" > +version = "4.3.8" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", > hash = > "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", > size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", > hash = > "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", > size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, > +] > + > +[[package]] > +name = "pluggy" > +version = "1.6.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", > hash = > "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", > size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", > hash = > "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", > size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, > +] > + > +[[package]] > +name = "pre-commit" > +version = "4.2.0" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "cfgv" }, > + { name = "identify" }, > + { name = "nodeenv" }, > + { name = "pyyaml" }, > + { name = "virtualenv" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", > hash = > "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", > size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", > hash = > "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", > size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, > +] > + > +[[package]] > +name = "pygments" > +version = "2.19.1" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", > hash = > "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", > size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", > hash = > "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", > size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, > +] > + > +[[package]] > +name = "pyproject-api" > +version = "1.9.1" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "packaging" }, > + { name = "tomli", marker = "python_full_version < '3.11'" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", > hash = > "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", > size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", > hash = > "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", > size = 13158, upload-time = "2025-05-12T14:41:56.217Z" }, > +] > + > +[[package]] > +name = "pytest" > +version = "8.4.0" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "colorama", marker = "sys_platform == 'win32'" }, > + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, > + { name = "iniconfig" }, > + { name = "packaging" }, > + { name = "pluggy" }, > + { name = "pygments" }, > + { name = "tomli", marker = "python_full_version < '3.11'" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", > hash = > "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", > size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", > hash = > "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", > size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, > +] > + > +[[package]] > +name = "pytest-sugar" > +version = "1.0.0" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "packaging" }, > + { name = "pytest" }, > + { name = "termcolor" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", > hash = > "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", > size = 14992, upload-time = "2024-02-01T18:30:36.735Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", > hash = > "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", > size = 10171, upload-time = "2024-02-01T18:30:29.395Z" }, > +] > + > +[[package]] > +name = "pyyaml" > +version = "6.0.2" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", > hash = > "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", > size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", > hash = > "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", > size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, > + { url = > "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", > hash = > "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", > size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, > + { url = > "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", > size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, > + { url = > "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", > hash = > "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", > size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, > + { url = > "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", > size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, > + { url = > "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", > hash = > "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", > size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, > + { url = > "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", > hash = > "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", > size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, > + { url = > "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", > hash = > "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", > size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, > + { url = > "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", > hash = > "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", > size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, > + { url = > "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", > hash = > "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", > size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, > + { url = > "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", > hash = > "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", > size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, > + { url = > "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", > size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, > + { url = > "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", > hash = > "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", > size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, > + { url = > "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", > size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, > + { url = > "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", > hash = > "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", > size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, > + { url = > "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", > hash = > "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", > size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, > + { url = > "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", > hash = > "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", > size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, > + { url = > "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", > hash = > "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", > size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, > + { url = > "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", > hash = > "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", > size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, > + { url = > "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", > hash = > "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", > size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, > + { url = > "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", > size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, > + { url = > "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", > hash = > "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", > size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, > + { url = > "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", > size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, > + { url = > "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", > hash = > "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", > size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, > + { url = > "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", > hash = > "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", > size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, > + { url = > "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", > hash = > "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", > size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, > + { url = > "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", > hash = > "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", > size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, > + { url = > "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", > hash = > "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", > size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, > + { url = > "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", > hash = > "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", > size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, > + { url = > "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", > size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, > + { url = > "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", > hash = > "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", > size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, > + { url = > "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", > size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, > + { url = > "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", > hash = > "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", > size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, > + { url = > "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", > hash = > "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", > size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, > + { url = > "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", > hash = > "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", > size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, > + { url = > "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", > hash = > "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", > size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, > +] > + > +[[package]] > +name = "ruff" > +version = "0.11.13" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", > hash = > "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", > size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", > hash = > "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", > size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, > + { url = > "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", > hash = > "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", > size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, > + { url = > "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", > hash = > "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", > size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, > + { url = > "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", > size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, > + { url = > "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", > hash = > "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", > size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, > + { url = > "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", > hash = > "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", > size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, > + { url = > "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", > hash = > "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", > size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, > + { url = > "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", > hash = > "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", > size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, > + { url = > "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", > hash = > "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", > size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, > + { url = > "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", > size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, > + { url = > "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", > hash = > "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", > size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, > + { url = > "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", > hash = > "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", > size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, > + { url = > "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", > hash = > "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", > size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, > + { url = > "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", > hash = > "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", > size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, > + { url = > "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", > hash = > "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", > size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, > + { url = > "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", > hash = > "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", > size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, > + { url = > "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", > hash = > "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", > size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, > +] > + > +[[package]] > +name = "termcolor" > +version = "3.1.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", > hash = > "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", > size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", > hash = > "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", > size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, > +] > + > +[[package]] > +name = "tomli" > +version = "2.2.1" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", > hash = > "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", > size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", > hash = > "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", > size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, > + { url = > "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", > hash = > "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", > size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, > + { url = > "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", > size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, > + { url = > "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", > size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, > + { url = > "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", > hash = > "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", > size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, > + { url = > "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", > hash = > "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", > size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, > + { url = > "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", > hash = > "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", > size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, > + { url = > "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", > hash = > "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", > size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, > + { url = > "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", > hash = > "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", > size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, > + { url = > "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", > hash = > "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", > size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, > + { url = > "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", > hash = > "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", > size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, > + { url = > "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", > hash = > "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", > size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, > + { url = > "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", > size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, > + { url = > "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", > size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, > + { url = > "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", > hash = > "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", > size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, > + { url = > "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", > hash = > "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", > size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, > + { url = > "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", > hash = > "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", > size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, > + { url = > "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", > hash = > "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", > size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, > + { url = > "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", > hash = > "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", > size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, > + { url = > "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", > hash = > "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", > size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, > + { url = > "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", > hash = > "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", > size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, > + { url = > "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", > hash = > "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", > size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, > + { url = > "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", > hash = > "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", > size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, > + { url = > "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", > hash = > "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", > size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, > + { url = > "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", > hash = > "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", > size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, > + { url = > "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", > hash = > "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", > size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, > + { url = > "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", > hash = > "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", > size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, > + { url = > "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", > hash = > "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", > size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, > + { url = > "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", > hash = > "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", > size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, > + { url = > "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", > hash = > "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", > size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, > + { url = > "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", > hash = > "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", > size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, > +] > + > +[[package]] > +name = "tox" > +version = "4.26.0" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "cachetools" }, > + { name = "chardet" }, > + { name = "colorama" }, > + { name = "filelock" }, > + { name = "packaging" }, > + { name = "platformdirs" }, > + { name = "pluggy" }, > + { name = "pyproject-api" }, > + { name = "tomli", marker = "python_full_version < '3.11'" }, > + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, > + { name = "virtualenv" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/fd/3c/dcec0c00321a107f7f697fd00754c5112572ea6dcacb40b16d8c3eea7c37/tox-4.26.0.tar.gz", > hash = > "sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca", > size = 197260, upload-time = "2025-05-13T15:04:28.481Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/de/14/f58b4087cf248b18c795b5c838c7a8d1428dfb07cb468dad3ec7f54041ab/tox-4.26.0-py3-none-any.whl", > hash = > "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224", > size = 172761, upload-time = "2025-05-13T15:04:26.207Z" }, > +] > + > +[[package]] > +name = "typing-extensions" > +version = "4.14.0" > +source = { registry = "https://pypi.org/simple" } > +sdist = { url = > "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", > hash = > "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", > size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", > hash = > "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", > size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, > +] > + > +[[package]] > +name = "virtualenv" > +version = "20.31.2" > +source = { registry = "https://pypi.org/simple" } > +dependencies = [ > + { name = "distlib" }, > + { name = "filelock" }, > + { name = "platformdirs" }, > +] > +sdist = { url = > "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", > hash = > "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", > size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } > +wheels = [ > + { url = > "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", > hash = > "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", > size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, > +] > + > +[[package]] > +name = "vmtest-tool" > +version = "0.1.0" > +source = { virtual = "." } > + > +[package.dev-dependencies] > +dev = [ > + { name = "pre-commit" }, > + { name = "pytest" }, > + { name = "pytest-sugar" }, > + { name = "ruff" }, > + { name = "tox" }, > +] > + > +[package.metadata] > + > +[package.metadata.requires-dev] > +dev = [ > + { name = "pre-commit", specifier = ">=4.2.0" }, > + { name = "pytest", specifier = ">=8.4.0" }, > + { name = "pytest-sugar", specifier = ">=1.0.0" }, > + { name = "ruff", specifier = ">=0.11.13" }, > + { name = "tox", specifier = ">=4.26.0" }, > +] > diff --git a/contrib/vmtest-tool/vm.py b/contrib/vmtest-tool/vm.py > new file mode 100644 > index 00000000000..5d4a5747f0b > --- /dev/null > +++ b/contrib/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) > + > + > +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}" > + )