diff --git a/MODULE.bazel b/MODULE.bazel index 67ff2d20..22e8f68e 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -210,6 +210,13 @@ use_repo( bazel_dep(name = "score_rules_imagefs", version = "0.0.3", dev_dependency = True) +# TODO: Switch back to a released version once the `ext4` rule is available in a tagged release. +git_override( + module_name = "score_rules_imagefs", + commit = "b1278766c01d9f4102c24e87b0ce9d035eaed832", + remote = "https://github.com/eclipse-score/rules_imagefs.git", +) + imagefs = use_extension("@score_rules_imagefs//extensions:imagefs.bzl", "imagefs", dev_dependency = True) imagefs.toolchain( name = "score_qnx_x86_64_ifs_toolchain", diff --git a/score/itf/plugins/qemu/__init__.py b/score/itf/plugins/qemu/__init__.py index 071b2a14..47696f6a 100644 --- a/score/itf/plugins/qemu/__init__.py +++ b/score/itf/plugins/qemu/__init__.py @@ -80,6 +80,13 @@ def pytest_addoption(parser): help="Path to a QEMU disk image (qcow2, wic, or img). " "An ephemeral overlay is created so the original image is not modified.", ) + parser.addoption( + "--qemu-disk", + action="store", + default=None, + help="Path to an additional disk image to attach to the target as a second block device. " + "A qcow2 overlay is created so the original image is not modified.", + ) @pytest.fixture(scope="session") @@ -96,6 +103,7 @@ def config(request): qemu_kernel = request.config.getoption("qemu_kernel") qemu_image = request.config.getoption("qemu_image") rootfs = request.config.getoption("qemu_rootfs") + disk = request.config.getoption("qemu_disk") if qemu_image: logger.warning( @@ -108,6 +116,7 @@ def config(request): qemu_config=load_configuration(qemu_config), qemu_kernel=qemu_kernel, qemu_rootfs=rootfs, + qemu_disk=disk, ) @@ -115,14 +124,18 @@ def config(request): def target_init(config, request, dlt): logger.info(f"Starting tests on host: {socket.gethostname()}") overlay_path = None - if config.qemu_rootfs: - overlay_path = _create_overlay(os.path.abspath(config.qemu_rootfs)) + disk_overlay_path = None try: + if config.qemu_rootfs: + overlay_path = _create_overlay(os.path.abspath(config.qemu_rootfs)) + if config.qemu_disk: + disk_overlay_path = _create_overlay(os.path.abspath(config.qemu_disk)) with qemu_target( Bunch( qemu_config=config.qemu_config, qemu_kernel=config.qemu_kernel, qemu_rootfs=overlay_path, + qemu_disk=disk_overlay_path, ) ) as qemu: pre_tests_phase(qemu) @@ -130,3 +143,5 @@ def target_init(config, request, dlt): finally: if overlay_path and os.path.exists(overlay_path): os.unlink(overlay_path) + if disk_overlay_path and os.path.exists(disk_overlay_path): + os.unlink(disk_overlay_path) diff --git a/score/itf/plugins/qemu/qemu.py b/score/itf/plugins/qemu/qemu.py index 4f06905c..49cbc75b 100644 --- a/score/itf/plugins/qemu/qemu.py +++ b/score/itf/plugins/qemu/qemu.py @@ -25,6 +25,9 @@ "network_device": "virtio-net-pci", "machine": "pc", "block_device": "virtio-blk-pci", + # virtio-blk-pci is probed by the guest in the same order the devices are + # specified on the command line. + "block_device_order": "ascending", }, "virt-aarch64": { "architecture": "aarch64", @@ -32,6 +35,9 @@ "network_device": "virtio-net-device", "machine": "virt,virtualization=true,gic-version=3", "block_device": "virtio-blk-device", + # virtio-blk-device (virtio-mmio) is probed by the guest in the reverse order + # the devices are specified on the command line. + "block_device_order": "descending", }, } @@ -55,6 +61,7 @@ def __init__( port_forwarding, rootfs, kernel_cmdline, + disk, ): """Create a QEMU instance with the specified parameters. @@ -67,6 +74,7 @@ def __init__( :param list port_forwarding: List of port forwarding configurations. :param str rootfs: Optional path to a qcow2 disk image. :param str kernel_cmdline: Optional kernel command line string. + :param str disk: Optional path to an additional qcow2 disk image. """ if machine not in _SUPPORTED_MACHINES: raise ValueError("machine must be one of: " + ", ".join(sorted(_SUPPORTED_MACHINES))) @@ -78,6 +86,7 @@ def __init__( self.__port_forwarding = port_forwarding self.__rootfs = rootfs self.__kernel_cmdline = kernel_cmdline + self.__disk = disk self.__check_qemu_is_installed() @@ -147,10 +156,10 @@ def __build_qemu_command(self): + self.__network_devices_args() + self.__port_forwarding_args() + self.__kernel_args() - + self.__rootfs_args() + + self.__disks_args() ) - def __kernel_args(self): + def __kernel_args(self) -> list[str]: if not self.__path_to_kernel_image: return [] args = ["-kernel", self.__path_to_kernel_image] @@ -158,14 +167,25 @@ def __kernel_args(self): args.extend(["-append", self.__kernel_cmdline]) return args - def __rootfs_args(self): - if not self.__rootfs: + def __disks_args(self) -> list[str]: + # Order the disks so that, regardless of the guest's probing order, the rootfs + # always ends up as the first block device (/dev/vda) in the guest. + disks = [self.__rootfs, self.__disk] + if self.__arch_config["block_device_order"] == "descending": + disks = list(reversed(disks)) + args = [] + for id, disk in enumerate(disks): + args += self.__disk_args(disk, id) + return args + + def __disk_args(self, disk: str, id: int) -> list[str]: + if not disk: return [] return [ "-device", - f"{self.__arch_config['block_device']},drive=vd0", + f"{self.__arch_config['block_device']},drive=vd{id}", "-drive", - f"if=none,format=qcow2,file={self.__rootfs},id=vd0", + f"if=none,format=qcow2,file={disk},id=vd{id}", ] def __network_devices_args(self): diff --git a/score/itf/plugins/qemu/qemu_process.py b/score/itf/plugins/qemu/qemu_process.py index 7f1221a0..765e0379 100644 --- a/score/itf/plugins/qemu/qemu_process.py +++ b/score/itf/plugins/qemu/qemu_process.py @@ -30,6 +30,7 @@ def __init__( machine, rootfs, kernel_cmdline, + disk, ): self._path_to_qemu_kernel_image = path_to_qemu_kernel_image self._available_ram = available_ram @@ -39,6 +40,7 @@ def __init__( self._machine = machine self._rootfs = rootfs self._kernel_cmdline = kernel_cmdline + self._disk = disk self._qemu = Qemu( self._path_to_qemu_kernel_image, self._available_ram, @@ -48,6 +50,7 @@ def __init__( machine=self._machine, rootfs=self._rootfs, kernel_cmdline=self._kernel_cmdline, + disk=self._disk, ) self._console = None @@ -65,6 +68,8 @@ def start(self): logger.info(f"Using QEMU kernel command line: {self._kernel_cmdline}") if self._rootfs is not None: logger.info(f"Using QEMU root filesystem image: {self._rootfs}") + if self._disk is not None: + logger.info(f"Using QEMU additional disk image: {self._disk}") subprocess_params = { "stdin": subprocess.PIPE, "stdout": subprocess.PIPE, diff --git a/score/itf/plugins/qemu/qemu_target.py b/score/itf/plugins/qemu/qemu_target.py index f5feb55d..43d845d2 100644 --- a/score/itf/plugins/qemu/qemu_target.py +++ b/score/itf/plugins/qemu/qemu_target.py @@ -293,6 +293,7 @@ def qemu_target(test_config): machine=test_config.qemu_config.qemu_machine, rootfs=test_config.qemu_rootfs, kernel_cmdline=test_config.qemu_config.qemu_kernel_cmdline, + disk=test_config.qemu_disk, ) else: process_ctx = nullcontext() diff --git a/test/integration/BUILD b/test/integration/BUILD index 66a190ec..27a0257c 100644 --- a/test/integration/BUILD +++ b/test/integration/BUILD @@ -231,6 +231,7 @@ py_itf_test( tags = [ "manual", ], + target_compatible_with = ["@platforms//os:linux"], ) py_itf_test( @@ -252,6 +253,57 @@ py_itf_test( tags = [ "manual", ], + target_compatible_with = ["@platforms//os:linux"], +) + +py_itf_test( + name = "test_ubuntu_disk", + srcs = [ + "test_qemu_disk.py", + ], + args = [ + "--qemu-config=$(location @os_images//ubuntu_x86_64:qemu_config)", + "--qemu-rootfs=$(location @os_images//ubuntu_x86_64:image)", + "--qemu-disk=$(location //test/resources:qemu_disk_image)", + ], + data = [ + "//test/resources:qemu_disk_image", + "@os_images//ubuntu_x86_64:image", + "@os_images//ubuntu_x86_64:qemu_config", + ], + plugins = [ + "//score/itf/plugins:qemu_plugin", + ], + tags = [ + "manual", + ], + target_compatible_with = ["@platforms//os:linux"], +) + +py_itf_test( + name = "test_ebclfsa_disk", + srcs = [ + "test_qemu_disk.py", + ], + args = [ + "--qemu-config=$(location @os_images//ebclfsa_aarch64:qemu_config)", + "--qemu-kernel=$(location @os_images//ebclfsa_aarch64:kernel)", + "--qemu-rootfs=$(location @os_images//ebclfsa_aarch64:image)", + "--qemu-disk=$(location //test/resources:qemu_disk_image)", + ], + data = [ + "//test/resources:qemu_disk_image", + "@os_images//ebclfsa_aarch64:image", + "@os_images//ebclfsa_aarch64:kernel", + "@os_images//ebclfsa_aarch64:qemu_config", + ], + plugins = [ + "//score/itf/plugins:qemu_plugin", + ], + tags = [ + "manual", + ], + target_compatible_with = ["@platforms//os:linux"], ) test_suite( @@ -260,7 +312,9 @@ test_suite( "manual", ], tests = [ + ":test_ebclfsa_disk", ":test_ebclfsa_ping", + ":test_ubuntu_disk", ":test_ubuntu_ping", ], ) diff --git a/test/integration/test_qemu_disk.py b/test/integration/test_qemu_disk.py new file mode 100644 index 00000000..dc7ed989 --- /dev/null +++ b/test/integration/test_qemu_disk.py @@ -0,0 +1,48 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Verifies that a second disk, passed to the QEMU plugin via ``--qemu-disk``, +shows up in the guest, can be mounted and contains the expected content. +""" + +EXPECTED_DISK_CONTENT = "Hello from the QEMU disk image!\n" + +# The additional disk is attached as the second virtio block device. The +# rootfs occupies the first one, so the disk always shows up as /dev/vdb. +DISK_DEVICE = "/dev/vdb" +MOUNT_POINT = "/mnt/qemu_disk" + + +def test_disk_device_is_visible(target): + exit_code, _ = target.execute(f"test -b {DISK_DEVICE}") + assert exit_code == 0, f"Expected block device {DISK_DEVICE} to be present" + + +def test_disk_can_be_mounted_and_has_expected_content(target): + exit_code, _ = target.execute(f"mkdir -p {MOUNT_POINT} && mount {DISK_DEVICE} {MOUNT_POINT}") + assert exit_code == 0, "Mounting the additional disk failed" + try: + exit_code, output = target.execute(f"cat {MOUNT_POINT}/qemu_disk_content.txt") + assert exit_code == 0 + assert output.decode("utf-8") == EXPECTED_DISK_CONTENT + finally: + target.execute(f"umount {MOUNT_POINT}") + + +def test_disk_is_writable(target): + exit_code, _ = target.execute(f"mkdir -p {MOUNT_POINT} && mount {DISK_DEVICE} {MOUNT_POINT}") + assert exit_code == 0, "Mounting the additional disk failed" + try: + exit_code, _ = target.execute(f"touch {MOUNT_POINT}/should_be_writable") + assert exit_code == 0, "Writing to the disk should work" + finally: + target.execute(f"umount {MOUNT_POINT}") diff --git a/test/resources/BUILD b/test/resources/BUILD index f5700827..7667a04a 100644 --- a/test/resources/BUILD +++ b/test/resources/BUILD @@ -11,7 +11,9 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") +load("@rules_pkg//pkg:mappings.bzl", "pkg_files") load("@rules_pkg//pkg:tar.bzl", "pkg_tar") +load("@score_rules_imagefs//rules/linux:ext4.bzl", "ext4") filegroup( name = "dlt_config", @@ -99,3 +101,19 @@ oci_load( #"@score_itf_examples//:__subpackages__", ], ) + +pkg_files( + name = "qemu_disk_files", + srcs = ["qemu_disk_content.txt"], + prefix = "", +) + +ext4( + name = "qemu_disk_image", + srcs = [":qemu_disk_files"], + out = "qemu_disk.ext4", + target_compatible_with = ["@platforms//os:linux"], + visibility = [ + "//test:__subpackages__", + ], +) diff --git a/test/resources/qemu_disk_content.txt b/test/resources/qemu_disk_content.txt new file mode 100644 index 00000000..04606245 --- /dev/null +++ b/test/resources/qemu_disk_content.txt @@ -0,0 +1 @@ +Hello from the QEMU disk image! diff --git a/test/unit/test_qemu.py b/test/unit/test_qemu.py index 9610a832..9cc7747e 100644 --- a/test/unit/test_qemu.py +++ b/test/unit/test_qemu.py @@ -25,6 +25,7 @@ def _build_qemu( path_to_kernel_image=None, kernel_cmdline=None, rootfs=None, + disk=None, network_adapters=None, port_forwarding=None, ): @@ -44,6 +45,7 @@ def _build_qemu( port_forwarding=port_forwarding, rootfs=rootfs, kernel_cmdline=kernel_cmdline, + disk=disk, ) @@ -59,6 +61,7 @@ def test_invalid_machine_is_rejected(mocker): port_forwarding=[], rootfs=None, kernel_cmdline=None, + disk=None, ) @@ -99,7 +102,7 @@ def test_kernel_args_are_empty_without_kernel_image(mocker): def test_rootfs_args_include_arch_specific_block_device(mocker): qemu = _build_qemu(mocker, machine="virt-aarch64", rootfs="/tmp/rootfs.qcow2") - assert qemu._Qemu__rootfs_args() == [ + assert qemu._Qemu__disk_args("/tmp/rootfs.qcow2", 0) == [ "-device", "virtio-blk-device,drive=vd0", "-drive", @@ -110,7 +113,41 @@ def test_rootfs_args_include_arch_specific_block_device(mocker): def test_rootfs_args_are_empty_without_rootfs(mocker): qemu = _build_qemu(mocker) - assert qemu._Qemu__rootfs_args() == [] + assert qemu._Qemu__disk_args(None, 0) == [] + + +def test_disk_args_include_arch_specific_block_device(mocker): + qemu = _build_qemu(mocker, machine="virt-aarch64", disk="/tmp/disk.qcow2") + + assert qemu._Qemu__disk_args("/tmp/disk.qcow2", 1) == [ + "-device", + "virtio-blk-device,drive=vd1", + "-drive", + "if=none,format=qcow2,file=/tmp/disk.qcow2,id=vd1", + ] + + +def test_disk_args_are_empty_without_disk(mocker): + qemu = _build_qemu(mocker) + + assert qemu._Qemu__disk_args(None, 0) == [] + + +def test_rootfs_and_disk_can_be_combined(mocker): + qemu = _build_qemu(mocker, rootfs="/tmp/rootfs.qcow2", disk="/tmp/disk.qcow2") + + assert qemu._Qemu__disk_args("/tmp/rootfs.qcow2", 0) == [ + "-device", + "virtio-blk-pci,drive=vd0", + "-drive", + "if=none,format=qcow2,file=/tmp/rootfs.qcow2,id=vd0", + ] + assert qemu._Qemu__disk_args("/tmp/disk.qcow2", 1) == [ + "-device", + "virtio-blk-pci,drive=vd1", + "-drive", + "if=none,format=qcow2,file=/tmp/disk.qcow2,id=vd1", + ] def test_network_args_skip_loopback_and_use_machine_specific_device(mocker):