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.

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
+
+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
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),
+        ]
+        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
+               );
+            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}"
+        )
-- 
2.49.0

Reply via email to