diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/conftest.py | 36 | ||||
| -rw-r--r-- | tests/pytest.ini | 1 | ||||
| -rw-r--r-- | tests/requirements.txt | 3 | ||||
| -rw-r--r-- | tests/test_container_cross_install.py | 450 | ||||
| -rw-r--r-- | tests/test_container_registry_script.py | 28 | ||||
| -rw-r--r-- | tests/test_containerd_runtime.py | 358 | ||||
| -rw-r--r-- | tests/test_incus_runtime.py | 64 | ||||
| -rw-r--r-- | tests/test_k3s_runtime.py | 92 | ||||
| -rw-r--r-- | tests/test_libvirt.py | 468 | ||||
| -rw-r--r-- | tests/test_lxc_runtime.py | 290 | ||||
| -rw-r--r-- | tests/test_vcontainer_auth_config.py | 642 | ||||
| -rw-r--r-- | tests/test_vcontainer_distro.py | 532 | ||||
| -rw-r--r-- | tests/test_vdkr_registry.py | 19 | ||||
| -rw-r--r-- | tests/test_xen_runtime.py | 62 |
14 files changed, 2744 insertions, 301 deletions
diff --git a/tests/conftest.py b/tests/conftest.py index 22c47d17..6258611b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py | |||
| @@ -259,6 +259,13 @@ def pytest_addoption(parser): | |||
| 259 | default=False, | 259 | default=False, |
| 260 | help="Run secure registry tests (requires openssl, htpasswd)", | 260 | help="Run secure registry tests (requires openssl, htpasswd)", |
| 261 | ) | 261 | ) |
| 262 | # Container cross-install coverage options | ||
| 263 | parser.addoption( | ||
| 264 | "--container-profiles", | ||
| 265 | action="store", | ||
| 266 | default="docker,podman", | ||
| 267 | help="Comma-separated container profiles to test (default: docker,podman)", | ||
| 268 | ) | ||
| 262 | 269 | ||
| 263 | 270 | ||
| 264 | def _cleanup_stale_test_state(): | 271 | def _cleanup_stale_test_state(): |
| @@ -501,10 +508,17 @@ class VdkrRunner: | |||
| 501 | result = self.memres_status() | 508 | result = self.memres_status() |
| 502 | return result.returncode == 0 and "running" in result.stdout.lower() | 509 | return result.returncode == 0 and "running" in result.stdout.lower() |
| 503 | 510 | ||
| 504 | def ensure_memres(self, timeout=180): | 511 | def ensure_memres(self, timeout=180, no_registry=True): |
| 505 | """Ensure memres is running, starting it if needed.""" | 512 | """Ensure memres is running, starting it if needed. |
| 513 | |||
| 514 | Args: | ||
| 515 | timeout: Timeout for memres start | ||
| 516 | no_registry: Disable baked-in registry (default True for tests | ||
| 517 | so that pulled images use short names like alpine:latest | ||
| 518 | rather than registry-prefixed names) | ||
| 519 | """ | ||
| 506 | if not self.is_memres_running(): | 520 | if not self.is_memres_running(): |
| 507 | result = self.memres_start(timeout=timeout) | 521 | result = self.memres_start(timeout=timeout, no_registry=no_registry) |
| 508 | if result.returncode != 0: | 522 | if result.returncode != 0: |
| 509 | raise RuntimeError(f"Failed to start memres: {result.stderr}") | 523 | raise RuntimeError(f"Failed to start memres: {result.stderr}") |
| 510 | 524 | ||
| @@ -640,6 +654,9 @@ def pytest_configure(config): | |||
| 640 | "markers", "boot: marks tests that boot a QEMU image (requires built image)" | 654 | "markers", "boot: marks tests that boot a QEMU image (requires built image)" |
| 641 | ) | 655 | ) |
| 642 | config.addinivalue_line( | 656 | config.addinivalue_line( |
| 657 | "markers", "container_profile: marks tests parametrized over container profiles" | ||
| 658 | ) | ||
| 659 | config.addinivalue_line( | ||
| 643 | "markers", "k3s: marks k3s runtime tests" | 660 | "markers", "k3s: marks k3s runtime tests" |
| 644 | ) | 661 | ) |
| 645 | config.addinivalue_line( | 662 | config.addinivalue_line( |
| @@ -790,10 +807,17 @@ class VpdmnRunner: | |||
| 790 | result = self.memres_status() | 807 | result = self.memres_status() |
| 791 | return result.returncode == 0 and "running" in result.stdout.lower() | 808 | return result.returncode == 0 and "running" in result.stdout.lower() |
| 792 | 809 | ||
| 793 | def ensure_memres(self, timeout=180): | 810 | def ensure_memres(self, timeout=180, no_registry=True): |
| 794 | """Ensure memres is running, starting it if needed.""" | 811 | """Ensure memres is running, starting it if needed. |
| 812 | |||
| 813 | Args: | ||
| 814 | timeout: Timeout for memres start | ||
| 815 | no_registry: Disable baked-in registry (default True for tests | ||
| 816 | so that pulled images use short names like alpine:latest | ||
| 817 | rather than registry-prefixed names) | ||
| 818 | """ | ||
| 795 | if not self.is_memres_running(): | 819 | if not self.is_memres_running(): |
| 796 | result = self.memres_start(timeout=timeout) | 820 | result = self.memres_start(timeout=timeout, no_registry=no_registry) |
| 797 | if result.returncode != 0: | 821 | if result.returncode != 0: |
| 798 | raise RuntimeError(f"Failed to start memres: {result.stderr}") | 822 | raise RuntimeError(f"Failed to start memres: {result.stderr}") |
| 799 | 823 | ||
diff --git a/tests/pytest.ini b/tests/pytest.ini index 6d756a28..6e72e447 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini | |||
| @@ -12,6 +12,7 @@ markers = | |||
| 12 | boot: marks tests that boot a full QEMU image (requires pexpect) | 12 | boot: marks tests that boot a full QEMU image (requires pexpect) |
| 13 | incus: marks incus runtime tests | 13 | incus: marks incus runtime tests |
| 14 | k3s: marks k3s runtime tests | 14 | k3s: marks k3s runtime tests |
| 15 | lxc: marks lxc runtime tests | ||
| 15 | 16 | ||
| 16 | # Default options - include junit xml for CI and detailed output | 17 | # Default options - include junit xml for CI and detailed output |
| 17 | addopts = -v --tb=short --junitxml=/tmp/pytest-results.xml | 18 | addopts = -v --tb=short --junitxml=/tmp/pytest-results.xml |
diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..b0ac705f --- /dev/null +++ b/tests/requirements.txt | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | pytest>=9.0.3,<10 | ||
| 2 | pytest-timeout>=2.4.0,<3 | ||
| 3 | pexpect>=4.9.0,<5 | ||
diff --git a/tests/test_container_cross_install.py b/tests/test_container_cross_install.py index ceb8b874..52eceb0d 100644 --- a/tests/test_container_cross_install.py +++ b/tests/test_container_cross_install.py | |||
| @@ -8,17 +8,22 @@ These tests verify that container-cross-install correctly bundles | |||
| 8 | OCI containers into Yocto images. | 8 | OCI containers into Yocto images. |
| 9 | 9 | ||
| 10 | Run with: | 10 | Run with: |
| 11 | pytest tests/test_container_cross_install.py -v | 11 | pytest tests/test_container_cross_install.py -v --poky-dir /opt/bruce/poky |
| 12 | 12 | ||
| 13 | Run boot tests (requires built image): | 13 | Full coverage (builds + boots with both docker and podman profiles): |
| 14 | pytest tests/test_container_cross_install.py::TestBundledContainersBoot -v | 14 | pytest tests/test_container_cross_install.py -v --poky-dir /opt/bruce/poky --container-profiles docker,podman |
| 15 | |||
| 16 | Single profile only: | ||
| 17 | pytest tests/test_container_cross_install.py -v --poky-dir /opt/bruce/poky --container-profiles docker | ||
| 15 | 18 | ||
| 16 | Environment variables: | 19 | Environment variables: |
| 17 | POKY_DIR: Path to poky directory (default: /opt/bruce/poky) | 20 | POKY_DIR: Path to poky directory (default: /opt/bruce/poky) |
| 18 | BUILD_DIR: Path to build directory (default: $POKY_DIR/build) | 21 | BUILD_DIR: Path to build directory (default: $POKY_DIR/build) |
| 19 | MACHINE: Target machine (default: qemux86-64) | 22 | MACHINE: Target machine (default: qemux86-64) |
| 20 | 23 | ||
| 21 | Note: These tests require a configured Yocto build environment. | 24 | Note: Boot tests (TestBundledContainersBoot, TestCustomServiceFileBoot) build |
| 25 | container-image-host with each --container-profiles profile, boot it, and | ||
| 26 | verify containers are present and runnable. No local.conf editing required. | ||
| 22 | """ | 27 | """ |
| 23 | 28 | ||
| 24 | import os | 29 | import os |
| @@ -87,26 +92,55 @@ def meta_virt_dir(poky_dir): | |||
| 87 | return path | 92 | return path |
| 88 | 93 | ||
| 89 | 94 | ||
| 90 | def run_bitbake(build_dir, recipe, task=None, extra_args=None, timeout=1800): | 95 | def run_bitbake(build_dir, recipe, task=None, extra_args=None, extra_vars=None, timeout=1800): |
| 91 | """Run a bitbake command.""" | 96 | """Run a bitbake command within the Yocto environment. |
| 92 | cmd = ["bitbake"] | 97 | |
| 98 | Args: | ||
| 99 | build_dir: Path to build directory | ||
| 100 | recipe: Recipe or mc:target to build | ||
| 101 | task: Optional task (e.g., 'rootfs') | ||
| 102 | extra_args: Optional list of extra bitbake arguments | ||
| 103 | extra_vars: Optional dict of variable overrides (written to a | ||
| 104 | temporary conf file and passed via -R) | ||
| 105 | timeout: Command timeout in seconds | ||
| 106 | """ | ||
| 107 | import tempfile | ||
| 108 | |||
| 109 | bb_cmd = "bitbake" | ||
| 93 | if task: | 110 | if task: |
| 94 | cmd.extend(["-c", task]) | 111 | bb_cmd += f" -c {task}" |
| 95 | cmd.append(recipe) | 112 | |
| 113 | # Write variable overrides to a temporary conf file | ||
| 114 | conf_file = None | ||
| 115 | if extra_vars: | ||
| 116 | conf_file = tempfile.NamedTemporaryFile( | ||
| 117 | mode='w', suffix='.conf', prefix='pytest-bbvars-', | ||
| 118 | dir=str(build_dir / "conf"), delete=False) | ||
| 119 | for var, val in extra_vars.items(): | ||
| 120 | conf_file.write(f'{var} = "{val}"\n') | ||
| 121 | conf_file.close() | ||
| 122 | bb_cmd += f" -R {conf_file.name}" | ||
| 123 | |||
| 124 | bb_cmd += f" {recipe}" | ||
| 96 | if extra_args: | 125 | if extra_args: |
| 97 | cmd.extend(extra_args) | 126 | bb_cmd += " " + " ".join(extra_args) |
| 98 | 127 | ||
| 99 | env = os.environ.copy() | 128 | poky_dir = build_dir.parent |
| 100 | env["BUILDDIR"] = str(build_dir) | 129 | full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" |
| 101 | 130 | ||
| 102 | result = subprocess.run( | 131 | try: |
| 103 | cmd, | 132 | result = subprocess.run( |
| 104 | cwd=build_dir, | 133 | full_cmd, |
| 105 | env=env, | 134 | shell=True, |
| 106 | timeout=timeout, | 135 | cwd=build_dir, |
| 107 | capture_output=True, | 136 | timeout=timeout, |
| 108 | text=True, | 137 | capture_output=True, |
| 109 | ) | 138 | text=True, |
| 139 | ) | ||
| 140 | finally: | ||
| 141 | if conf_file: | ||
| 142 | os.unlink(conf_file.name) | ||
| 143 | |||
| 110 | return result | 144 | return result |
| 111 | 145 | ||
| 112 | 146 | ||
| @@ -298,13 +332,13 @@ class TestVdkrRecipes: | |||
| 298 | assert len(installers) > 0, f"No SDK installer found in {sdk_deploy}" | 332 | assert len(installers) > 0, f"No SDK installer found in {sdk_deploy}" |
| 299 | 333 | ||
| 300 | def test_vdkr_initramfs_create(self, build_dir): | 334 | def test_vdkr_initramfs_create(self, build_dir): |
| 301 | """Test vdkr-initramfs-create builds.""" | 335 | """Test vdkr-initramfs-create builds via multiconfig.""" |
| 302 | result = run_bitbake(build_dir, "vdkr-initramfs-create") | 336 | result = run_bitbake(build_dir, "mc:vruntime-x86-64:vdkr-initramfs-create") |
| 303 | assert result.returncode == 0, f"Build failed: {result.stderr}" | 337 | assert result.returncode == 0, f"Build failed: {result.stderr}" |
| 304 | 338 | ||
| 305 | def test_vpdmn_initramfs_create(self, build_dir): | 339 | def test_vpdmn_initramfs_create(self, build_dir): |
| 306 | """Test vpdmn-initramfs-create builds.""" | 340 | """Test vpdmn-initramfs-create builds via multiconfig.""" |
| 307 | result = run_bitbake(build_dir, "vpdmn-initramfs-create") | 341 | result = run_bitbake(build_dir, "mc:vruntime-x86-64:vpdmn-initramfs-create") |
| 308 | assert result.returncode == 0, f"Build failed: {result.stderr}" | 342 | assert result.returncode == 0, f"Build failed: {result.stderr}" |
| 309 | 343 | ||
| 310 | 344 | ||
| @@ -439,6 +473,10 @@ class RunqemuSession: | |||
| 439 | # Get everything before the prompt | 473 | # Get everything before the prompt |
| 440 | raw_output = self.child.before | 474 | raw_output = self.child.before |
| 441 | 475 | ||
| 476 | # Strip OSC and other escape sequences from output | ||
| 477 | raw_output = re.sub(r'\x1b\]3008;[^\x07\x1b]*(?:\x07|\x1b\\)', '', raw_output) | ||
| 478 | raw_output = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', raw_output) | ||
| 479 | |||
| 442 | # Parse: split by newlines, skip command echo (first line), take the rest | 480 | # Parse: split by newlines, skip command echo (first line), take the rest |
| 443 | lines = raw_output.replace('\r', '').split('\n') | 481 | lines = raw_output.replace('\r', '').split('\n') |
| 444 | 482 | ||
| @@ -589,6 +627,64 @@ def runqemu_session(request, poky_dir, build_dir, machine, check_rootfs_freshnes | |||
| 589 | session.stop() | 627 | session.stop() |
| 590 | 628 | ||
| 591 | 629 | ||
| 630 | def _get_container_profiles(request): | ||
| 631 | """Parse --container-profiles option into a list.""" | ||
| 632 | return [p.strip() for p in request.config.getoption("--container-profiles").split(",")] | ||
| 633 | |||
| 634 | |||
| 635 | @pytest.fixture(scope="class", params=["docker", "podman"]) | ||
| 636 | def profiled_session(request, poky_dir, build_dir, machine): | ||
| 637 | """ | ||
| 638 | Fixture that builds container-image-host with a specific CONTAINER_PROFILE, | ||
| 639 | boots it, and provides a session for testing. | ||
| 640 | |||
| 641 | Parametrized over profiles from --container-profiles (default: docker,podman). | ||
| 642 | Each profile gets a full build → boot → test cycle. | ||
| 643 | """ | ||
| 644 | profile = request.param | ||
| 645 | profiles = _get_container_profiles(request) | ||
| 646 | if profile not in profiles: | ||
| 647 | pytest.skip(f"Profile {profile} not in --container-profiles={','.join(profiles)}") | ||
| 648 | |||
| 649 | if not PEXPECT_AVAILABLE: | ||
| 650 | pytest.skip("pexpect not installed. Run: pip install pexpect") | ||
| 651 | |||
| 652 | image = request.config.getoption("--image") | ||
| 653 | fstype = request.config.getoption("--image-fstype") | ||
| 654 | timeout = request.config.getoption("--boot-timeout") | ||
| 655 | use_kvm = not request.config.getoption("--no-kvm") | ||
| 656 | |||
| 657 | # Build the image with this profile | ||
| 658 | result = run_bitbake( | ||
| 659 | build_dir, image, | ||
| 660 | extra_vars={"CONTAINER_PROFILE": profile}, | ||
| 661 | timeout=3600, | ||
| 662 | ) | ||
| 663 | if result.returncode != 0: | ||
| 664 | pytest.fail(f"Build failed for profile {profile}: {result.stderr}") | ||
| 665 | |||
| 666 | session = RunqemuSession(poky_dir, build_dir, machine, image, | ||
| 667 | fstype=fstype, use_kvm=use_kvm, timeout=timeout) | ||
| 668 | session.container_profile = profile | ||
| 669 | |||
| 670 | try: | ||
| 671 | session.start() | ||
| 672 | yield session | ||
| 673 | finally: | ||
| 674 | session.stop() | ||
| 675 | |||
| 676 | |||
| 677 | @pytest.fixture(scope="class") | ||
| 678 | def profiled_containers(profiled_session, build_dir, machine): | ||
| 679 | """Detect containers in the booted image for the active profile.""" | ||
| 680 | image = "container-image-host" | ||
| 681 | result = _detect_containers_from_rootfs(build_dir, machine, image) | ||
| 682 | if not result: | ||
| 683 | result = {'docker': [], 'podman': []} | ||
| 684 | result['profile'] = profiled_session.container_profile | ||
| 685 | return result | ||
| 686 | |||
| 687 | |||
| 592 | def _detect_containers_from_rootfs(build_dir, machine, image): | 688 | def _detect_containers_from_rootfs(build_dir, machine, image): |
| 593 | """ | 689 | """ |
| 594 | Detect bundled containers by checking storage directories in the rootfs. | 690 | Detect bundled containers by checking storage directories in the rootfs. |
| @@ -775,135 +871,75 @@ class TestBundledContainersBoot: | |||
| 775 | """ | 871 | """ |
| 776 | Boot tests to verify bundled containers are visible. | 872 | Boot tests to verify bundled containers are visible. |
| 777 | 873 | ||
| 778 | These tests boot the actual Yocto image and verify that | 874 | Parametrized over container profiles (--container-profiles, default: docker,podman). |
| 779 | `docker images` or `podman images` shows the bundled containers. | 875 | For each profile, builds container-image-host with that CONTAINER_PROFILE, |
| 780 | 876 | boots the image, and verifies the profile's containers are present and runnable. | |
| 781 | Prerequisites: | ||
| 782 | - Image must be built with BUNDLED_CONTAINERS configured | ||
| 783 | - pexpect must be installed: pip install pexpect | ||
| 784 | 877 | ||
| 785 | Run with: | 878 | Run with: |
| 786 | pytest tests/test_container_cross_install.py::TestBundledContainersBoot -v | 879 | pytest tests/test_container_cross_install.py::TestBundledContainersBoot -v |
| 787 | 880 | ||
| 788 | Options: | 881 | Options: |
| 789 | --image IMAGE Image name to boot (default: core-image-minimal) | 882 | --container-profiles Profiles to test (default: docker,podman) |
| 883 | --image IMAGE Image name to boot (default: container-image-host) | ||
| 790 | --machine MACHINE Machine to use (default: qemux86-64) | 884 | --machine MACHINE Machine to use (default: qemux86-64) |
| 791 | --boot-timeout SECS Timeout for boot (default: 120) | 885 | --boot-timeout SECS Timeout for boot (default: 120) |
| 792 | """ | 886 | """ |
| 793 | 887 | ||
| 794 | @pytest.mark.slow | 888 | @pytest.mark.slow |
| 795 | @pytest.mark.boot | 889 | @pytest.mark.boot |
| 796 | def test_system_boots(self, runqemu_session): | 890 | @pytest.mark.container_profile |
| 891 | def test_system_boots(self, profiled_session): | ||
| 797 | """Test that the system boots successfully.""" | 892 | """Test that the system boots successfully.""" |
| 798 | assert runqemu_session.booted, "System failed to boot" | 893 | assert profiled_session.booted, \ |
| 894 | f"System failed to boot with profile {profiled_session.container_profile}" | ||
| 799 | 895 | ||
| 800 | # Basic sanity check | 896 | output = profiled_session.run_command('uname -a') |
| 801 | output = runqemu_session.run_command('uname -a') | ||
| 802 | assert 'Linux' in output, f"Unexpected uname output: {output}" | 897 | assert 'Linux' in output, f"Unexpected uname output: {output}" |
| 803 | 898 | ||
| 804 | @pytest.mark.slow | 899 | @pytest.mark.slow |
| 805 | @pytest.mark.boot | 900 | @pytest.mark.boot |
| 806 | def test_docker_images_visible(self, runqemu_session, bundled_containers_config): | 901 | @pytest.mark.container_profile |
| 807 | """Test that bundled Docker containers are visible.""" | 902 | def test_containers_visible(self, profiled_session, profiled_containers): |
| 808 | expected = bundled_containers_config['docker'] | 903 | """Test that bundled containers are visible for the active profile.""" |
| 809 | if not expected: | 904 | profile = profiled_session.container_profile |
| 810 | pytest.skip("No Docker containers in bundle packages or BUNDLED_CONTAINERS") | 905 | runtime = profile # docker or podman |
| 811 | 906 | ||
| 812 | # Check if docker is available | 907 | output = profiled_session.run_command(f'which {runtime}') |
| 813 | output = runqemu_session.run_command('which docker') | 908 | assert f'/{runtime}' in output, \ |
| 814 | if '/docker' not in output: | 909 | f"{runtime} not installed in image (profile={profile})" |
| 815 | pytest.skip("docker not installed in image") | ||
| 816 | 910 | ||
| 817 | # Get docker images | 911 | output = profiled_session.run_command(f'{runtime} images', timeout=30) |
| 818 | output = runqemu_session.run_command('docker images', timeout=30) | 912 | print(f"{runtime} images output ({profile}):\n{output}") |
| 819 | print(f"docker images output:\n{output}") | ||
| 820 | 913 | ||
| 821 | # Verify each expected container is present | 914 | expected = profiled_containers.get(runtime, []) |
| 822 | missing = [] | 915 | assert expected, \ |
| 823 | for container in expected: | 916 | f"No {runtime} containers detected in rootfs (profile={profile})" |
| 824 | if container not in output: | ||
| 825 | missing.append(container) | ||
| 826 | 917 | ||
| 827 | assert not missing, f"Missing Docker containers: {missing}\nOutput:\n{output}" | 918 | missing = [c for c in expected if c not in output] |
| 919 | assert not missing, \ | ||
| 920 | f"Missing {runtime} containers: {missing}\nOutput:\n{output}" | ||
| 828 | 921 | ||
| 829 | @pytest.mark.slow | 922 | @pytest.mark.slow |
| 830 | @pytest.mark.boot | 923 | @pytest.mark.boot |
| 831 | def test_podman_images_visible(self, runqemu_session, bundled_containers_config): | 924 | @pytest.mark.container_profile |
| 832 | """Test that bundled Podman containers are visible.""" | 925 | def test_run_bundled_container(self, profiled_session, profiled_containers): |
| 833 | expected = bundled_containers_config['podman'] | 926 | """Test that a bundled container can actually run.""" |
| 834 | if not expected: | 927 | profile = profiled_session.container_profile |
| 835 | pytest.skip("No Podman containers in bundle packages or BUNDLED_CONTAINERS") | 928 | runtime = profile |
| 836 | |||
| 837 | # Check if podman is available | ||
| 838 | output = runqemu_session.run_command('which podman') | ||
| 839 | if '/podman' not in output: | ||
| 840 | pytest.skip("podman not installed in image") | ||
| 841 | |||
| 842 | # Get podman images | ||
| 843 | output = runqemu_session.run_command('podman images', timeout=30) | ||
| 844 | print(f"podman images output:\n{output}") | ||
| 845 | |||
| 846 | # Verify each expected container is present | ||
| 847 | missing = [] | ||
| 848 | for container in expected: | ||
| 849 | if container not in output: | ||
| 850 | missing.append(container) | ||
| 851 | |||
| 852 | assert not missing, f"Missing Podman containers: {missing}\nOutput:\n{output}" | ||
| 853 | |||
| 854 | @pytest.mark.slow | ||
| 855 | @pytest.mark.boot | ||
| 856 | def test_docker_run_bundled_container(self, runqemu_session, bundled_containers_config): | ||
| 857 | """Test that a bundled Docker container can actually run.""" | ||
| 858 | expected = bundled_containers_config['docker'] | ||
| 859 | if not expected: | ||
| 860 | pytest.skip("No Docker containers configured") | ||
| 861 | |||
| 862 | # Check if docker is available | ||
| 863 | output = runqemu_session.run_command('which docker') | ||
| 864 | if '/docker' not in output: | ||
| 865 | pytest.skip("docker not installed in image") | ||
| 866 | |||
| 867 | # Try to run the first bundled container with a simple command | ||
| 868 | # Use --entrypoint to override any image entrypoint, otherwise | ||
| 869 | # images with entrypoint like ["sh"] would interpret the command | ||
| 870 | # as a script argument rather than executing the binary directly | ||
| 871 | container = expected[0] | ||
| 872 | output = runqemu_session.run_command( | ||
| 873 | f'docker run --rm --entrypoint /bin/echo {container}:latest "CONTAINER_WORKS"', | ||
| 874 | timeout=60 | ||
| 875 | ) | ||
| 876 | print(f"docker run output:\n{output}") | ||
| 877 | 929 | ||
| 878 | assert 'CONTAINER_WORKS' in output, \ | 930 | expected = profiled_containers.get(runtime, []) |
| 879 | f"Container {container} failed to run.\nOutput:\n{output}" | 931 | assert expected, \ |
| 932 | f"No {runtime} containers detected in rootfs (profile={profile})" | ||
| 880 | 933 | ||
| 881 | @pytest.mark.slow | ||
| 882 | @pytest.mark.boot | ||
| 883 | def test_podman_run_bundled_container(self, runqemu_session, bundled_containers_config): | ||
| 884 | """Test that a bundled Podman container can actually run.""" | ||
| 885 | expected = bundled_containers_config['podman'] | ||
| 886 | if not expected: | ||
| 887 | pytest.skip("No Podman containers in bundle packages or BUNDLED_CONTAINERS") | ||
| 888 | |||
| 889 | # Check if podman is available | ||
| 890 | output = runqemu_session.run_command('which podman') | ||
| 891 | if '/podman' not in output: | ||
| 892 | pytest.skip("podman not installed in image") | ||
| 893 | |||
| 894 | # Try to run the first bundled container with a simple command | ||
| 895 | # Use --entrypoint to override any image entrypoint, otherwise | ||
| 896 | # images with entrypoint like ["sh"] would interpret the command | ||
| 897 | # as a script argument rather than executing the binary directly | ||
| 898 | container = expected[0] | 934 | container = expected[0] |
| 899 | output = runqemu_session.run_command( | 935 | output = profiled_session.run_command( |
| 900 | f'podman run --rm --entrypoint /bin/echo {container}:latest "CONTAINER_WORKS"', | 936 | f'{runtime} run --rm --entrypoint /bin/echo {container}:latest "CONTAINER_WORKS"', |
| 901 | timeout=60 | 937 | timeout=60 |
| 902 | ) | 938 | ) |
| 903 | print(f"podman run output:\n{output}") | 939 | print(f"{runtime} run output ({profile}):\n{output}") |
| 904 | 940 | ||
| 905 | assert 'CONTAINER_WORKS' in output, \ | 941 | assert 'CONTAINER_WORKS' in output, \ |
| 906 | f"Container {container} failed to run.\nOutput:\n{output}" | 942 | f"Container {container} failed to run with {runtime}.\nOutput:\n{output}" |
| 907 | 943 | ||
| 908 | 944 | ||
| 909 | # ============================================================================ | 945 | # ============================================================================ |
| @@ -933,6 +969,22 @@ class TestCustomServiceFileSupport: | |||
| 933 | assert "install_custom_service" in content, \ | 969 | assert "install_custom_service" in content, \ |
| 934 | "install_custom_service function not found" | 970 | "install_custom_service function not found" |
| 935 | 971 | ||
| 972 | def test_bundle_class_unpack_enabled(self, meta_virt_dir): | ||
| 973 | """Test that do_unpack is NOT disabled in container-bundle.bbclass. | ||
| 974 | |||
| 975 | Custom service files use SRC_URI file:// entries which require | ||
| 976 | do_unpack to run. If do_unpack[noexec] = "1" is set, the files | ||
| 977 | never reach UNPACKDIR and CONTAINER_SERVICE_FILE references fail. | ||
| 978 | """ | ||
| 979 | class_file = meta_virt_dir / "classes" / "container-bundle.bbclass" | ||
| 980 | content = class_file.read_text() | ||
| 981 | for line in content.splitlines(): | ||
| 982 | stripped = line.strip() | ||
| 983 | if stripped.startswith("#"): | ||
| 984 | continue | ||
| 985 | assert 'do_unpack[noexec]' not in stripped, \ | ||
| 986 | "do_unpack must not be noexec — custom service files need SRC_URI unpacking" | ||
| 987 | |||
| 936 | def test_bundle_class_has_service_file_support(self, meta_virt_dir): | 988 | def test_bundle_class_has_service_file_support(self, meta_virt_dir): |
| 937 | """Test that container-bundle.bbclass includes CONTAINER_SERVICE_FILE support.""" | 989 | """Test that container-bundle.bbclass includes CONTAINER_SERVICE_FILE support.""" |
| 938 | class_file = meta_virt_dir / "classes" / "container-bundle.bbclass" | 990 | class_file = meta_virt_dir / "classes" / "container-bundle.bbclass" |
| @@ -983,118 +1035,74 @@ class TestCustomServiceFileBoot: | |||
| 983 | """ | 1035 | """ |
| 984 | Boot tests for custom service files. | 1036 | Boot tests for custom service files. |
| 985 | 1037 | ||
| 986 | These tests verify that custom service files are properly installed | 1038 | Parametrized over container profiles — verifies that autostart services |
| 987 | and enabled in the booted system. | 1039 | (Docker systemd units or Podman Quadlet files) are correctly installed |
| 1040 | and enabled for each profile. | ||
| 988 | """ | 1041 | """ |
| 989 | 1042 | ||
| 990 | @pytest.mark.slow | 1043 | @pytest.mark.slow |
| 991 | @pytest.mark.boot | 1044 | @pytest.mark.boot |
| 992 | def test_systemd_services_directory_exists(self, runqemu_session): | 1045 | @pytest.mark.container_profile |
| 1046 | def test_systemd_services_directory_exists(self, profiled_session): | ||
| 993 | """Test that systemd service directories exist.""" | 1047 | """Test that systemd service directories exist.""" |
| 994 | output = runqemu_session.run_command('ls -la /lib/systemd/system/ | head -5') | 1048 | output = profiled_session.run_command('ls -la /lib/systemd/system/ | head -n 5') |
| 995 | assert 'systemd' in output or 'total' in output, \ | 1049 | assert 'systemd' in output or 'total' in output, \ |
| 996 | "Systemd system directory not accessible" | 1050 | "Systemd system directory not accessible" |
| 997 | 1051 | ||
| 998 | @pytest.mark.slow | 1052 | @pytest.mark.slow |
| 999 | @pytest.mark.boot | 1053 | @pytest.mark.boot |
| 1000 | def test_container_services_present(self, runqemu_session, bundled_containers_config): | 1054 | @pytest.mark.container_profile |
| 1001 | """Test that container service files are present (custom or generated).""" | 1055 | def test_autostart_service_present(self, profiled_session): |
| 1002 | docker_containers = bundled_containers_config.get('docker', []) | 1056 | """Test that autostart service files are present for the active profile.""" |
| 1003 | 1057 | profile = profiled_session.container_profile | |
| 1004 | if not docker_containers: | 1058 | |
| 1005 | pytest.skip("No Docker containers configured") | 1059 | if profile == 'docker': |
| 1006 | 1060 | output = profiled_session.run_command( | |
| 1007 | # Check if docker is available | 1061 | 'ls /lib/systemd/system/container-*.service 2>/dev/null || echo "NONE"') |
| 1008 | output = runqemu_session.run_command('which docker') | 1062 | assert 'NONE' not in output and '.service' in output, \ |
| 1009 | if '/docker' not in output: | 1063 | f"No Docker autostart service files found (profile={profile})" |
| 1010 | pytest.skip("docker not installed in image") | 1064 | |
| 1011 | 1065 | elif profile == 'podman': | |
| 1012 | # Check for container service files | 1066 | output = profiled_session.run_command( |
| 1013 | output = runqemu_session.run_command('ls /lib/systemd/system/container-*.service 2>/dev/null || echo "NONE"') | 1067 | 'ls /etc/containers/systemd/*.container 2>/dev/null || echo "NONE"') |
| 1014 | 1068 | assert 'NONE' not in output and '.container' in output, \ | |
| 1015 | if 'NONE' in output: | 1069 | f"No Podman Quadlet files found (profile={profile})" |
| 1016 | # No autostart services - check if any containers have autostart | ||
| 1017 | pytest.skip("No container autostart services found (containers may not have autostart enabled)") | ||
| 1018 | |||
| 1019 | # Verify at least one service file exists | ||
| 1020 | assert '.service' in output, \ | ||
| 1021 | f"No container service files found. Output: {output}" | ||
| 1022 | |||
| 1023 | @pytest.mark.slow | ||
| 1024 | @pytest.mark.boot | ||
| 1025 | def test_container_service_enabled(self, runqemu_session, bundled_containers_config): | ||
| 1026 | """Test that container services are enabled (linked in wants directory).""" | ||
| 1027 | docker_containers = bundled_containers_config.get('docker', []) | ||
| 1028 | |||
| 1029 | if not docker_containers: | ||
| 1030 | pytest.skip("No Docker containers configured") | ||
| 1031 | |||
| 1032 | # Check for enabled services in multi-user.target.wants | ||
| 1033 | output = runqemu_session.run_command( | ||
| 1034 | 'ls /etc/systemd/system/multi-user.target.wants/container-*.service 2>/dev/null || echo "NONE"' | ||
| 1035 | ) | ||
| 1036 | |||
| 1037 | if 'NONE' in output: | ||
| 1038 | pytest.skip("No container autostart services enabled") | ||
| 1039 | |||
| 1040 | # Verify services are symlinked | ||
| 1041 | assert '.service' in output, \ | ||
| 1042 | f"No enabled container services found. Output: {output}" | ||
| 1043 | 1070 | ||
| 1044 | @pytest.mark.slow | 1071 | @pytest.mark.slow |
| 1045 | @pytest.mark.boot | 1072 | @pytest.mark.boot |
| 1046 | def test_custom_service_content(self, runqemu_session, bundled_containers_config): | 1073 | @pytest.mark.container_profile |
| 1047 | """Test that custom service files have expected content markers.""" | 1074 | def test_autostart_service_running(self, profiled_session): |
| 1048 | docker_containers = bundled_containers_config.get('docker', []) | 1075 | """Test that the autostart container is actually running.""" |
| 1049 | 1076 | profile = profiled_session.container_profile | |
| 1050 | if not docker_containers: | 1077 | runtime = profile |
| 1051 | pytest.skip("No Docker containers configured") | ||
| 1052 | 1078 | ||
| 1053 | # Find a container service file | 1079 | output = profiled_session.run_command(f'{runtime} ps', timeout=30) |
| 1054 | output = runqemu_session.run_command( | 1080 | print(f"{runtime} ps output ({profile}):\n{output}") |
| 1055 | 'ls /lib/systemd/system/container-*.service 2>/dev/null | head -1' | ||
| 1056 | ) | ||
| 1057 | |||
| 1058 | if not output or 'container-' not in output: | ||
| 1059 | pytest.skip("No container service files found") | ||
| 1060 | |||
| 1061 | service_file = output.strip().split('\n')[0] | ||
| 1062 | 1081 | ||
| 1063 | # Read the service file content | 1082 | assert 'autostart-test-container' in output, \ |
| 1064 | content = runqemu_session.run_command(f'cat {service_file}') | 1083 | f"autostart-test-container not running under {runtime} (profile={profile})\nOutput:\n{output}" |
| 1065 | |||
| 1066 | # Verify it has expected systemd service structure | ||
| 1067 | assert '[Unit]' in content, f"Service file missing [Unit] section: {service_file}" | ||
| 1068 | assert '[Service]' in content, f"Service file missing [Service] section: {service_file}" | ||
| 1069 | assert '[Install]' in content, f"Service file missing [Install] section: {service_file}" | ||
| 1070 | |||
| 1071 | # Check for docker-related content | ||
| 1072 | assert 'docker' in content.lower(), \ | ||
| 1073 | f"Service file doesn't reference docker: {content}" | ||
| 1074 | 1084 | ||
| 1075 | @pytest.mark.slow | 1085 | @pytest.mark.slow |
| 1076 | @pytest.mark.boot | 1086 | @pytest.mark.boot |
| 1077 | def test_podman_quadlet_directory(self, runqemu_session, bundled_containers_config): | 1087 | @pytest.mark.container_profile |
| 1078 | """Test Podman Quadlet directory exists for Podman containers.""" | 1088 | def test_autostart_service_content(self, profiled_session): |
| 1079 | podman_containers = bundled_containers_config.get('podman', []) | 1089 | """Test that autostart service files have expected content.""" |
| 1080 | 1090 | profile = profiled_session.container_profile | |
| 1081 | if not podman_containers: | 1091 | |
| 1082 | pytest.skip("No Podman containers configured") | 1092 | if profile == 'docker': |
| 1083 | 1093 | content = profiled_session.run_command( | |
| 1084 | # Check if podman is available | 1094 | 'cat /lib/systemd/system/container-autostart-test-container.service') |
| 1085 | output = runqemu_session.run_command('which podman') | 1095 | assert '[Unit]' in content, \ |
| 1086 | if '/podman' not in output: | 1096 | f"Missing [Unit] section in docker service file.\nContent:\n{content}" |
| 1087 | pytest.skip("podman not installed in image") | 1097 | assert '[Service]' in content, \ |
| 1088 | 1098 | f"Missing [Service] section in docker service file" | |
| 1089 | # Check for Quadlet directory | 1099 | assert 'docker' in content.lower(), \ |
| 1090 | output = runqemu_session.run_command('ls -la /etc/containers/systemd/ 2>/dev/null || echo "NONE"') | 1100 | f"Service doesn't reference docker" |
| 1091 | 1101 | ||
| 1092 | if 'NONE' in output: | 1102 | elif profile == 'podman': |
| 1093 | pytest.skip("Quadlet directory not found (containers may not have autostart enabled)") | 1103 | content = profiled_session.run_command( |
| 1094 | 1104 | 'cat /etc/containers/systemd/container-autostart-test-container.container') | |
| 1095 | # Check for .container files | 1105 | assert '[Container]' in content, \ |
| 1096 | output = runqemu_session.run_command('ls /etc/containers/systemd/*.container 2>/dev/null || echo "NONE"') | 1106 | f"Missing [Container] section in podman quadlet file.\nContent:\n{content}" |
| 1097 | 1107 | assert 'Image=' in content, \ | |
| 1098 | if 'NONE' not in output: | 1108 | f"Missing Image= directive in podman quadlet file" |
| 1099 | assert '.container' in output, \ | ||
| 1100 | f"No Quadlet container files found. Output: {output}" | ||
diff --git a/tests/test_container_registry_script.py b/tests/test_container_registry_script.py index 3089e870..18efb851 100644 --- a/tests/test_container_registry_script.py +++ b/tests/test_container_registry_script.py | |||
| @@ -1497,7 +1497,7 @@ class TestVcontainerSecureRegistry: | |||
| 1497 | "Should not base64 encode CA cert for kernel cmdline" | 1497 | "Should not base64 encode CA cert for kernel cmdline" |
| 1498 | 1498 | ||
| 1499 | def test_vrunner_daemon_sets_9p(self): | 1499 | def test_vrunner_daemon_sets_9p(self): |
| 1500 | """Test that daemon mode sets _9p=1 in kernel cmdline.""" | 1500 | """Test that daemon mode sets up virtio-9p share and _9p=1.""" |
| 1501 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" | 1501 | script = Path("/opt/bruce/poky/meta-virtualization/recipes-containers/" |
| 1502 | "vcontainer/files/vrunner.sh") | 1502 | "vcontainer/files/vrunner.sh") |
| 1503 | if not script.exists(): | 1503 | if not script.exists(): |
| @@ -1505,26 +1505,24 @@ class TestVcontainerSecureRegistry: | |||
| 1505 | 1505 | ||
| 1506 | content = script.read_text() | 1506 | content = script.read_text() |
| 1507 | 1507 | ||
| 1508 | # Find the daemon mode block and check for _9p=1 | 1508 | # Find the daemon mode block and check for 9p setup |
| 1509 | # The daemon block should contain both virtfs and _9p=1 | ||
| 1510 | lines = content.split('\n') | 1509 | lines = content.split('\n') |
| 1511 | in_daemon_block = False | 1510 | in_daemon_block = False |
| 1512 | daemon_has_virtfs = False | 1511 | daemon_has_9p_opts = False |
| 1513 | daemon_has_9p = False | 1512 | daemon_has_9p_flag = False |
| 1514 | for line in lines: | 1513 | for line in lines: |
| 1515 | if 'DAEMON_MODE" = "start"' in line or "DAEMON_MODE\" = \"start\"" in line: | 1514 | if 'DAEMON_MODE" = "start"' in line or 'DAEMON_MODE = "start"' in line: |
| 1516 | in_daemon_block = True | 1515 | in_daemon_block = True |
| 1517 | if in_daemon_block: | 1516 | if in_daemon_block: |
| 1518 | if "virtfs" in line and "DAEMON_SHARE_DIR" in line: | 1517 | if "hv_build_9p_opts" in line and "DAEMON_SHARE_DIR" in line: |
| 1519 | daemon_has_virtfs = True | 1518 | daemon_has_9p_opts = True |
| 1520 | if "_9p=1" in line: | 1519 | if "_9p=1" in line: |
| 1521 | daemon_has_9p = True | 1520 | daemon_has_9p_flag = True |
| 1522 | # Detect end of the if block (next top-level statement) | 1521 | if line.startswith("fi") and in_daemon_block and daemon_has_9p_opts: |
| 1523 | if line.startswith("fi") and in_daemon_block and daemon_has_virtfs: | ||
| 1524 | break | 1522 | break |
| 1525 | 1523 | ||
| 1526 | assert daemon_has_virtfs, "Daemon mode should set up virtio-9p share" | 1524 | assert daemon_has_9p_opts, "Daemon mode should set up virtio-9p share via hv_build_9p_opts" |
| 1527 | assert daemon_has_9p, "Daemon mode should set _9p=1 in kernel cmdline" | 1525 | assert daemon_has_9p_flag, "Daemon mode should set _9p=1 in kernel cmdline" |
| 1528 | 1526 | ||
| 1529 | def test_vrunner_nondaemon_ca_cert_virtio9p(self): | 1527 | def test_vrunner_nondaemon_ca_cert_virtio9p(self): |
| 1530 | """Test that non-daemon mode creates virtio-9p share for CA cert.""" | 1528 | """Test that non-daemon mode creates virtio-9p share for CA cert.""" |
| @@ -1536,8 +1534,8 @@ class TestVcontainerSecureRegistry: | |||
| 1536 | content = script.read_text() | 1534 | content = script.read_text() |
| 1537 | assert "CA_SHARE_DIR" in content, \ | 1535 | assert "CA_SHARE_DIR" in content, \ |
| 1538 | "Non-daemon mode should create CA_SHARE_DIR for virtio-9p" | 1536 | "Non-daemon mode should create CA_SHARE_DIR for virtio-9p" |
| 1539 | assert "cashare" in content, \ | 1537 | assert "hv_build_9p_opts" in content and "CA_SHARE_DIR" in content, \ |
| 1540 | "Should add virtio-9p device for CA cert sharing" | 1538 | "Should set up virtio-9p device for CA cert sharing via hv_build_9p_opts" |
| 1541 | 1539 | ||
| 1542 | def test_vdkr_init_reads_ca_from_share(self): | 1540 | def test_vdkr_init_reads_ca_from_share(self): |
| 1543 | """Test that vdkr-init.sh reads CA cert from /mnt/share/ca.crt.""" | 1541 | """Test that vdkr-init.sh reads CA cert from /mnt/share/ca.crt.""" |
diff --git a/tests/test_containerd_runtime.py b/tests/test_containerd_runtime.py new file mode 100644 index 00000000..470ac3ba --- /dev/null +++ b/tests/test_containerd_runtime.py | |||
| @@ -0,0 +1,358 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2026 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Containerd runtime tests — recipe checks, build, and boot verification. | ||
| 6 | |||
| 7 | The tests automatically build container-image-host with | ||
| 8 | CONTAINER_PROFILE=containerd before booting. No local.conf changes needed. | ||
| 9 | |||
| 10 | Run: | ||
| 11 | # Static tests only | ||
| 12 | pytest tests/test_containerd_runtime.py -v -m "not slow and not boot" --poky-dir /opt/bruce/poky | ||
| 13 | |||
| 14 | # All tests | ||
| 15 | pytest tests/test_containerd_runtime.py -v --poky-dir /opt/bruce/poky | ||
| 16 | """ | ||
| 17 | |||
| 18 | import os | ||
| 19 | import re | ||
| 20 | import subprocess | ||
| 21 | import tempfile | ||
| 22 | import time | ||
| 23 | import pytest | ||
| 24 | from pathlib import Path | ||
| 25 | |||
| 26 | try: | ||
| 27 | import pexpect | ||
| 28 | PEXPECT_AVAILABLE = True | ||
| 29 | except ImportError: | ||
| 30 | PEXPECT_AVAILABLE = False | ||
| 31 | |||
| 32 | |||
| 33 | def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 34 | """Run bitbake with optional variable overrides via -R conf file.""" | ||
| 35 | bb_cmd = "bitbake" | ||
| 36 | conf_file = None | ||
| 37 | if extra_vars: | ||
| 38 | conf_file = tempfile.NamedTemporaryFile( | ||
| 39 | mode='w', suffix='.conf', prefix='pytest-containerd-', | ||
| 40 | dir=str(build_dir / "conf"), delete=False) | ||
| 41 | for var, val in extra_vars.items(): | ||
| 42 | conf_file.write(f'{var} = "{val}"\n') | ||
| 43 | conf_file.close() | ||
| 44 | bb_cmd += f" -R {conf_file.name}" | ||
| 45 | bb_cmd += f" {recipe}" | ||
| 46 | poky_dir = build_dir.parent | ||
| 47 | full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 48 | try: | ||
| 49 | return subprocess.run(full_cmd, shell=True, cwd=build_dir, | ||
| 50 | timeout=timeout, capture_output=True, text=True) | ||
| 51 | finally: | ||
| 52 | if conf_file: | ||
| 53 | os.unlink(conf_file.name) | ||
| 54 | |||
| 55 | |||
| 56 | # ============================================================================ | ||
| 57 | # Fixtures | ||
| 58 | # ============================================================================ | ||
| 59 | |||
| 60 | @pytest.fixture(scope="module") | ||
| 61 | def poky_dir(request): | ||
| 62 | path = Path(request.config.getoption("--poky-dir")) | ||
| 63 | if not path.exists(): | ||
| 64 | pytest.skip(f"Poky directory not found: {path}") | ||
| 65 | return path | ||
| 66 | |||
| 67 | |||
| 68 | @pytest.fixture(scope="module") | ||
| 69 | def build_dir(request, poky_dir): | ||
| 70 | bd = request.config.getoption("--build-dir") | ||
| 71 | path = Path(bd) if bd else poky_dir / "build" | ||
| 72 | if not path.exists(): | ||
| 73 | pytest.skip(f"Build directory not found: {path}") | ||
| 74 | return path | ||
| 75 | |||
| 76 | |||
| 77 | @pytest.fixture(scope="module") | ||
| 78 | def meta_virt_dir(poky_dir): | ||
| 79 | path = poky_dir / "meta-virtualization" | ||
| 80 | if not path.exists(): | ||
| 81 | pytest.skip(f"meta-virtualization not found: {path}") | ||
| 82 | return path | ||
| 83 | |||
| 84 | |||
| 85 | @pytest.fixture(scope="module") | ||
| 86 | def containerd_image(build_dir): | ||
| 87 | """Build container-image-host with containerd profile.""" | ||
| 88 | result = _run_bitbake( | ||
| 89 | build_dir, "container-image-host", | ||
| 90 | extra_vars={"CONTAINER_PROFILE": "containerd"}, | ||
| 91 | ) | ||
| 92 | if result.returncode != 0: | ||
| 93 | pytest.fail(f"Containerd image build failed: {result.stderr}") | ||
| 94 | |||
| 95 | |||
| 96 | @pytest.fixture(scope="module") | ||
| 97 | def containerd_session(request, poky_dir, build_dir, containerd_image): | ||
| 98 | """Boot container-image-host with containerd and provide pexpect session.""" | ||
| 99 | if not PEXPECT_AVAILABLE: | ||
| 100 | pytest.skip("pexpect not installed") | ||
| 101 | |||
| 102 | machine = request.config.getoption("--machine", default="qemux86-64") | ||
| 103 | timeout = int(request.config.getoption("--boot-timeout", default="120")) | ||
| 104 | no_kvm = request.config.getoption("--no-kvm", default=False) | ||
| 105 | |||
| 106 | kvm_opt = "" if no_kvm else "kvm" | ||
| 107 | cmd = ( | ||
| 108 | f"bash -c 'cd {poky_dir} && " | ||
| 109 | f"source oe-init-build-env {build_dir} >/dev/null 2>&1 && " | ||
| 110 | f"runqemu {machine} container-image-host ext4 nographic slirp " | ||
| 111 | f"{kvm_opt} qemuparams=\"-m 2048\"'" | ||
| 112 | ) | ||
| 113 | |||
| 114 | child = pexpect.spawn(cmd, encoding='utf-8', timeout=timeout) | ||
| 115 | child.logfile_read = open('/tmp/runqemu-containerd-test.log', 'w') | ||
| 116 | |||
| 117 | try: | ||
| 118 | index = child.expect([r'login:', r'root@', pexpect.TIMEOUT, pexpect.EOF], | ||
| 119 | timeout=timeout) | ||
| 120 | if index == 0: | ||
| 121 | child.sendline('root') | ||
| 122 | child.expect([r'root@', r'#'], timeout=30) | ||
| 123 | elif index >= 2: | ||
| 124 | raise RuntimeError("Boot failed") | ||
| 125 | |||
| 126 | child.sendline('export TERM=dumb') | ||
| 127 | child.expect(r'root@[^:]+:[^#]+#', timeout=10) | ||
| 128 | |||
| 129 | yield child | ||
| 130 | |||
| 131 | except RuntimeError as e: | ||
| 132 | pytest.skip(f"Failed to boot: {e}") | ||
| 133 | finally: | ||
| 134 | child.sendline('poweroff') | ||
| 135 | try: | ||
| 136 | child.expect(pexpect.EOF, timeout=30) | ||
| 137 | except pexpect.TIMEOUT: | ||
| 138 | child.terminate(force=True) | ||
| 139 | |||
| 140 | |||
| 141 | def run_cmd(child, cmd, timeout=60): | ||
| 142 | """Run a command and return (output, returncode).""" | ||
| 143 | marker = f"__MARKER_{time.monotonic_ns()}__" | ||
| 144 | child.sendline(f"{cmd}; echo {marker} $?") | ||
| 145 | child.expect(marker + r" (\d+)", timeout=timeout) | ||
| 146 | raw = child.before | ||
| 147 | raw = re.sub(r'\x1b\]3008;[^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) | ||
| 148 | raw = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', raw) | ||
| 149 | output = raw.strip() | ||
| 150 | rc = int(child.match.group(1)) | ||
| 151 | child.expect(r'root@[^:]+:[^#]+#', timeout=10) | ||
| 152 | return output, rc | ||
| 153 | |||
| 154 | |||
| 155 | # ============================================================================ | ||
| 156 | # Tier 1: Static recipe assertions | ||
| 157 | # ============================================================================ | ||
| 158 | |||
| 159 | class TestContainerdRecipeStatic: | ||
| 160 | """Static checks on containerd recipe.""" | ||
| 161 | |||
| 162 | def test_recipe_exists(self, meta_virt_dir): | ||
| 163 | path = meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb" | ||
| 164 | assert path.exists() | ||
| 165 | |||
| 166 | def test_provides_virtual_containerd(self, meta_virt_dir): | ||
| 167 | content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text() | ||
| 168 | assert "virtual/containerd" in content or "virtual-containerd" in content | ||
| 169 | |||
| 170 | def test_systemd_service(self, meta_virt_dir): | ||
| 171 | content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text() | ||
| 172 | assert "containerd.service" in content | ||
| 173 | |||
| 174 | def test_rdepends_container_runtime(self, meta_virt_dir): | ||
| 175 | content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text() | ||
| 176 | assert "VIRTUAL-RUNTIME_container_runtime" in content | ||
| 177 | |||
| 178 | def test_cni_networking(self, meta_virt_dir): | ||
| 179 | content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text() | ||
| 180 | assert "cni_networking" in content | ||
| 181 | |||
| 182 | def test_installs_ctr(self, meta_virt_dir): | ||
| 183 | content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text() | ||
| 184 | assert "ctr" in content | ||
| 185 | |||
| 186 | def test_installs_shim(self, meta_virt_dir): | ||
| 187 | content = (meta_virt_dir / "recipes-containers" / "containerd" / "containerd_git.bb").read_text() | ||
| 188 | assert "containerd-shim-runc-v2" in content | ||
| 189 | |||
| 190 | |||
| 191 | class TestContainerdProfileStatic: | ||
| 192 | """Static checks on containerd profile configuration.""" | ||
| 193 | |||
| 194 | def test_profile_conf_exists(self, meta_virt_dir): | ||
| 195 | path = meta_virt_dir / "conf" / "distro" / "include" / "container-host-containerd.conf" | ||
| 196 | assert path.exists() | ||
| 197 | |||
| 198 | def test_profile_sets_containerd(self, meta_virt_dir): | ||
| 199 | content = (meta_virt_dir / "conf" / "distro" / "include" / "container-host-containerd.conf").read_text() | ||
| 200 | assert 'CONTAINER_PROFILE = "containerd"' in content | ||
| 201 | |||
| 202 | def test_profile_inc_exists(self, meta_virt_dir): | ||
| 203 | path = meta_virt_dir / "conf" / "distro" / "include" / "meta-virt-container-containerd.inc" | ||
| 204 | assert path.exists() | ||
| 205 | |||
| 206 | def test_profile_engine_is_containerd(self, meta_virt_dir): | ||
| 207 | content = (meta_virt_dir / "conf" / "distro" / "include" / "meta-virt-container-containerd.inc").read_text() | ||
| 208 | assert "containerd" in content | ||
| 209 | assert "VIRTUAL-RUNTIME_container_engine" in content | ||
| 210 | |||
| 211 | def test_packagegroup_containerd(self, meta_virt_dir): | ||
| 212 | content = (meta_virt_dir / "recipes-core" / "packagegroups" / "packagegroup-container.bb").read_text() | ||
| 213 | assert "packagegroup-containerd" in content | ||
| 214 | assert "nerdctl" in content | ||
| 215 | |||
| 216 | |||
| 217 | class TestNerdctlRecipeStatic: | ||
| 218 | """Static checks on nerdctl recipe.""" | ||
| 219 | |||
| 220 | def test_recipe_exists(self, meta_virt_dir): | ||
| 221 | recipes = list((meta_virt_dir / "recipes-containers" / "nerdctl").glob("nerdctl_*.bb")) | ||
| 222 | assert len(recipes) >= 1, "nerdctl recipe not found" | ||
| 223 | |||
| 224 | |||
| 225 | class TestCriToolsRecipeStatic: | ||
| 226 | """Static checks on cri-tools (crictl) recipe.""" | ||
| 227 | |||
| 228 | def test_recipe_exists(self, meta_virt_dir): | ||
| 229 | recipes = list((meta_virt_dir / "recipes-containers" / "cri-tools").glob("cri-tools_*.bb")) | ||
| 230 | assert len(recipes) >= 1, "cri-tools recipe not found" | ||
| 231 | |||
| 232 | |||
| 233 | class TestCriORecipeStatic: | ||
| 234 | """Static checks on CRI-O recipe.""" | ||
| 235 | |||
| 236 | def test_recipe_exists(self, meta_virt_dir): | ||
| 237 | path = meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb" | ||
| 238 | assert path.exists() | ||
| 239 | |||
| 240 | def test_requires_seccomp(self, meta_virt_dir): | ||
| 241 | content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text() | ||
| 242 | assert "seccomp" in content | ||
| 243 | assert "REQUIRED_DISTRO_FEATURES" in content | ||
| 244 | |||
| 245 | def test_systemd_service(self, meta_virt_dir): | ||
| 246 | content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text() | ||
| 247 | assert "crio.service" in content | ||
| 248 | |||
| 249 | def test_rdepends_cni(self, meta_virt_dir): | ||
| 250 | content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text() | ||
| 251 | assert "cni" in content | ||
| 252 | |||
| 253 | def test_rdepends_conmon(self, meta_virt_dir): | ||
| 254 | content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text() | ||
| 255 | assert "conmon" in content | ||
| 256 | |||
| 257 | def test_rdepends_runtime(self, meta_virt_dir): | ||
| 258 | content = (meta_virt_dir / "recipes-containers" / "cri-o" / "cri-o_git.bb").read_text() | ||
| 259 | assert "VIRTUAL-RUNTIME_container_runtime" in content | ||
| 260 | |||
| 261 | |||
| 262 | # ============================================================================ | ||
| 263 | # Tier 2: Build verification | ||
| 264 | # ============================================================================ | ||
| 265 | |||
| 266 | @pytest.mark.slow | ||
| 267 | class TestContainerdBuild: | ||
| 268 | """Build tests for containerd.""" | ||
| 269 | |||
| 270 | def test_containerd_builds(self, build_dir): | ||
| 271 | result = _run_bitbake(build_dir, "containerd") | ||
| 272 | assert result.returncode == 0, f"containerd build failed: {result.stderr}" | ||
| 273 | |||
| 274 | def test_nerdctl_builds(self, build_dir): | ||
| 275 | result = _run_bitbake(build_dir, "nerdctl") | ||
| 276 | assert result.returncode == 0, f"nerdctl build failed: {result.stderr}" | ||
| 277 | |||
| 278 | def test_containerd_image_builds(self, build_dir, containerd_image): | ||
| 279 | """container-image-host with containerd profile builds (via fixture).""" | ||
| 280 | pass | ||
| 281 | |||
| 282 | |||
| 283 | # ============================================================================ | ||
| 284 | # Tier 3: Boot tests — containerd | ||
| 285 | # ============================================================================ | ||
| 286 | |||
| 287 | @pytest.mark.slow | ||
| 288 | @pytest.mark.boot | ||
| 289 | class TestContainerdRuntime: | ||
| 290 | """Boot tests for containerd on container-image-host.""" | ||
| 291 | |||
| 292 | def test_containerd_running(self, containerd_session): | ||
| 293 | """containerd systemd service should be active.""" | ||
| 294 | output, rc = run_cmd(containerd_session, "systemctl is-active containerd") | ||
| 295 | assert "active" in output, f"containerd not active: {output}" | ||
| 296 | |||
| 297 | def test_ctr_available(self, containerd_session): | ||
| 298 | """ctr command should be available.""" | ||
| 299 | output, rc = run_cmd(containerd_session, "which ctr") | ||
| 300 | assert rc == 0, f"ctr not found: {output}" | ||
| 301 | |||
| 302 | def test_nerdctl_available(self, containerd_session): | ||
| 303 | """nerdctl command should be available.""" | ||
| 304 | output, rc = run_cmd(containerd_session, "which nerdctl") | ||
| 305 | assert rc == 0, f"nerdctl not found: {output}" | ||
| 306 | |||
| 307 | def test_containerd_version(self, containerd_session): | ||
| 308 | """containerd should report its version.""" | ||
| 309 | output, rc = run_cmd(containerd_session, "containerd --version") | ||
| 310 | assert rc == 0, f"containerd version failed: {output}" | ||
| 311 | assert "containerd" in output | ||
| 312 | |||
| 313 | def test_ctr_version(self, containerd_session): | ||
| 314 | """ctr should report its version.""" | ||
| 315 | output, rc = run_cmd(containerd_session, "ctr version") | ||
| 316 | assert rc == 0, f"ctr version failed: {output}" | ||
| 317 | |||
| 318 | def test_ctr_namespaces(self, containerd_session): | ||
| 319 | """ctr should list namespaces.""" | ||
| 320 | output, rc = run_cmd(containerd_session, "ctr namespaces list") | ||
| 321 | assert rc == 0, f"ctr namespaces list failed: {output}" | ||
| 322 | assert "NAME" in output, f"Unexpected namespaces output: {output}" | ||
| 323 | |||
| 324 | def test_runtime_available(self, containerd_session): | ||
| 325 | """Container runtime (runc/crun) should be installed.""" | ||
| 326 | output, rc = run_cmd(containerd_session, | ||
| 327 | "which crun 2>/dev/null || which runc 2>/dev/null") | ||
| 328 | assert rc == 0, f"No container runtime found: {output}" | ||
| 329 | |||
| 330 | def test_cni_plugins_installed(self, containerd_session): | ||
| 331 | """CNI plugins should be installed.""" | ||
| 332 | output, rc = run_cmd(containerd_session, "ls /opt/cni/bin/bridge") | ||
| 333 | assert rc == 0, f"CNI bridge plugin not found: {output}" | ||
| 334 | |||
| 335 | def test_nerdctl_pull(self, containerd_session): | ||
| 336 | """nerdctl should be able to pull an image.""" | ||
| 337 | output, rc = run_cmd(containerd_session, | ||
| 338 | "nerdctl pull docker.io/library/busybox:latest", | ||
| 339 | timeout=120) | ||
| 340 | assert rc == 0, f"nerdctl pull failed: {output}" | ||
| 341 | |||
| 342 | def test_nerdctl_run(self, containerd_session): | ||
| 343 | """nerdctl should run a container.""" | ||
| 344 | output, rc = run_cmd(containerd_session, | ||
| 345 | 'nerdctl run --rm busybox:latest echo CONTAINERD_WORKS', | ||
| 346 | timeout=60) | ||
| 347 | assert rc == 0, f"nerdctl run failed: {output}" | ||
| 348 | assert "CONTAINERD_WORKS" in output | ||
| 349 | |||
| 350 | def test_nerdctl_images(self, containerd_session): | ||
| 351 | """nerdctl images should show pulled images.""" | ||
| 352 | output, rc = run_cmd(containerd_session, "nerdctl images") | ||
| 353 | assert rc == 0, f"nerdctl images failed: {output}" | ||
| 354 | assert "busybox" in output | ||
| 355 | |||
| 356 | def test_nerdctl_cleanup(self, containerd_session): | ||
| 357 | """Clean up test image.""" | ||
| 358 | run_cmd(containerd_session, "nerdctl rmi busybox:latest", timeout=30) | ||
diff --git a/tests/test_incus_runtime.py b/tests/test_incus_runtime.py index 6de4bd5c..4314709b 100644 --- a/tests/test_incus_runtime.py +++ b/tests/test_incus_runtime.py | |||
| @@ -5,15 +5,11 @@ | |||
| 5 | Incus runtime tests - boot container-image-host with incus and verify | 5 | Incus runtime tests - boot container-image-host with incus and verify |
| 6 | system container management. | 6 | system container management. |
| 7 | 7 | ||
| 8 | Build prerequisites (in local.conf): | 8 | The tests automatically build container-image-host with CONTAINER_PROFILE=incus |
| 9 | require conf/distro/include/meta-virt-host.conf | 9 | before booting. No local.conf changes needed. |
| 10 | require conf/distro/include/container-host-incus.conf | ||
| 11 | MACHINE = "qemux86-64" # or qemuarm64 | ||
| 12 | |||
| 13 | bitbake container-image-host | ||
| 14 | 10 | ||
| 15 | Run: | 11 | Run: |
| 16 | pytest tests/test_incus_runtime.py -v --machine qemux86-64 | 12 | pytest tests/test_incus_runtime.py -v --poky-dir /opt/bruce/poky |
| 17 | 13 | ||
| 18 | Options: | 14 | Options: |
| 19 | --boot-timeout QEMU boot timeout (default: 120s) | 15 | --boot-timeout QEMU boot timeout (default: 120s) |
| @@ -22,8 +18,11 @@ Options: | |||
| 22 | 18 | ||
| 23 | import os | 19 | import os |
| 24 | import re | 20 | import re |
| 21 | import subprocess | ||
| 22 | import tempfile | ||
| 25 | import time | 23 | import time |
| 26 | import pytest | 24 | import pytest |
| 25 | from pathlib import Path | ||
| 27 | 26 | ||
| 28 | try: | 27 | try: |
| 29 | import pexpect | 28 | import pexpect |
| @@ -38,19 +37,60 @@ pytestmark = [ | |||
| 38 | ] | 37 | ] |
| 39 | 38 | ||
| 40 | 39 | ||
| 40 | def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 41 | """Run bitbake with optional variable overrides via -R conf file.""" | ||
| 42 | bb_cmd = "bitbake" | ||
| 43 | conf_file = None | ||
| 44 | if extra_vars: | ||
| 45 | conf_file = tempfile.NamedTemporaryFile( | ||
| 46 | mode='w', suffix='.conf', prefix='pytest-incus-', | ||
| 47 | dir=str(build_dir / "conf"), delete=False) | ||
| 48 | for var, val in extra_vars.items(): | ||
| 49 | conf_file.write(f'{var} = "{val}"\n') | ||
| 50 | conf_file.close() | ||
| 51 | bb_cmd += f" -R {conf_file.name}" | ||
| 52 | bb_cmd += f" {recipe}" | ||
| 53 | poky_dir = build_dir.parent | ||
| 54 | full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 55 | try: | ||
| 56 | return subprocess.run(full_cmd, shell=True, cwd=build_dir, | ||
| 57 | timeout=timeout, capture_output=True, text=True) | ||
| 58 | finally: | ||
| 59 | if conf_file: | ||
| 60 | os.unlink(conf_file.name) | ||
| 61 | |||
| 62 | |||
| 63 | @pytest.fixture(scope="module") | ||
| 64 | def incus_image(request): | ||
| 65 | """Build container-image-host with incus profile.""" | ||
| 66 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 67 | bd = request.config.getoption("--build-dir") | ||
| 68 | build_dir = Path(bd) if bd else poky_dir / "build" | ||
| 69 | result = _run_bitbake( | ||
| 70 | build_dir, "container-image-host", | ||
| 71 | extra_vars={ | ||
| 72 | "CONTAINER_PROFILE": "incus", | ||
| 73 | }, | ||
| 74 | ) | ||
| 75 | if result.returncode != 0: | ||
| 76 | pytest.fail(f"Incus image build failed: {result.stderr}") | ||
| 77 | |||
| 78 | |||
| 41 | @pytest.fixture(scope="module") | 79 | @pytest.fixture(scope="module") |
| 42 | def incus_qemu(request): | 80 | def incus_qemu(request, incus_image): |
| 43 | """Boot a QEMU VM with incus and return the pexpect session.""" | 81 | """Build incus image, boot a QEMU VM, and return the pexpect session.""" |
| 44 | machine = request.config.getoption("--machine", default="qemux86-64") | 82 | machine = request.config.getoption("--machine", default="qemux86-64") |
| 45 | boot_timeout = int(request.config.getoption("--boot-timeout", default="120")) | 83 | boot_timeout = int(request.config.getoption("--boot-timeout", default="120")) |
| 46 | no_kvm = request.config.getoption("--no-kvm", default=False) | 84 | no_kvm = request.config.getoption("--no-kvm", default=False) |
| 47 | 85 | ||
| 48 | builddir = os.environ.get("BUILDDIR", os.path.expanduser("~/poky/build")) | 86 | poky_dir = Path(request.config.getoption("--poky-dir")) |
| 87 | bd = request.config.getoption("--build-dir") | ||
| 88 | builddir = str(Path(bd) if bd else poky_dir / "build") | ||
| 49 | 89 | ||
| 50 | kvm_opt = "" if no_kvm else "kvm" | 90 | kvm_opt = "" if no_kvm else "kvm" |
| 51 | cmd = f"runqemu {machine} nographic slirp {kvm_opt} qemuparams=\"-m 4096\"" | 91 | cmd = f"runqemu {machine} container-image-host ext4 nographic slirp {kvm_opt} qemuparams=\"-m 4096\"" |
| 52 | 92 | ||
| 53 | child = pexpect.spawn(f"bash -c 'source {builddir}/oe-init-build-env {builddir} >/dev/null 2>&1 && {cmd}'", | 93 | child = pexpect.spawn(f"bash -c 'cd {poky_dir} && source oe-init-build-env {builddir} >/dev/null 2>&1 && {cmd}'", |
| 54 | timeout=boot_timeout, encoding="utf-8", logfile=None) | 94 | timeout=boot_timeout, encoding="utf-8", logfile=None) |
| 55 | 95 | ||
| 56 | # Wait for login prompt | 96 | # Wait for login prompt |
diff --git a/tests/test_k3s_runtime.py b/tests/test_k3s_runtime.py index aa0d443f..eb9600dd 100644 --- a/tests/test_k3s_runtime.py +++ b/tests/test_k3s_runtime.py | |||
| @@ -8,22 +8,18 @@ Single-node tests verify k3s server start, node readiness, and basic pod | |||
| 8 | deployment. Multi-node tests use QEMU socket networking to connect two VMs | 8 | deployment. Multi-node tests use QEMU socket networking to connect two VMs |
| 9 | on a shared L2 segment and verify agent join + multi-node scheduling. | 9 | on a shared L2 segment and verify agent join + multi-node scheduling. |
| 10 | 10 | ||
| 11 | Build prerequisites (in local.conf): | 11 | The tests automatically build container-image-host with CONTAINER_PROFILE=k3s-host |
| 12 | require conf/distro/include/meta-virt-host.conf | 12 | before booting. No local.conf changes needed. |
| 13 | require conf/distro/include/container-host-k3s.conf | ||
| 14 | MACHINE = "qemux86-64" # or qemuarm64 | ||
| 15 | |||
| 16 | bitbake container-image-host | ||
| 17 | 13 | ||
| 18 | Run: | 14 | Run: |
| 19 | # Single-node only | 15 | # Single-node only |
| 20 | pytest tests/test_k3s_runtime.py -v -k "not multinode" --machine qemux86-64 | 16 | pytest tests/test_k3s_runtime.py -v -k "not multinode" --poky-dir /opt/bruce/poky |
| 21 | 17 | ||
| 22 | # Multi-node only | 18 | # Multi-node only |
| 23 | pytest tests/test_k3s_runtime.py -v -k "multinode" --machine qemux86-64 | 19 | pytest tests/test_k3s_runtime.py -v -k "multinode" --poky-dir /opt/bruce/poky |
| 24 | 20 | ||
| 25 | # All tests | 21 | # All tests |
| 26 | pytest tests/test_k3s_runtime.py -v --machine qemux86-64 | 22 | pytest tests/test_k3s_runtime.py -v --poky-dir /opt/bruce/poky |
| 27 | 23 | ||
| 28 | Options: | 24 | Options: |
| 29 | --k3s-timeout Overall k3s readiness timeout (default: 300s) | 25 | --k3s-timeout Overall k3s readiness timeout (default: 300s) |
| @@ -43,6 +39,8 @@ Notes: | |||
| 43 | import os | 39 | import os |
| 44 | import re | 40 | import re |
| 45 | import shutil | 41 | import shutil |
| 42 | import subprocess | ||
| 43 | import tempfile | ||
| 46 | import time | 44 | import time |
| 47 | import pytest | 45 | import pytest |
| 48 | from pathlib import Path | 46 | from pathlib import Path |
| @@ -290,6 +288,39 @@ class K3sRunner: | |||
| 290 | # Fixtures | 288 | # Fixtures |
| 291 | # ============================================================================ | 289 | # ============================================================================ |
| 292 | 290 | ||
| 291 | def run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 292 | """Run a bitbake command within the Yocto environment.""" | ||
| 293 | import tempfile | ||
| 294 | |||
| 295 | bb_cmd = "bitbake" | ||
| 296 | |||
| 297 | conf_file = None | ||
| 298 | if extra_vars: | ||
| 299 | conf_file = tempfile.NamedTemporaryFile( | ||
| 300 | mode='w', suffix='.conf', prefix='pytest-k3s-', | ||
| 301 | dir=str(build_dir / "conf"), delete=False) | ||
| 302 | for var, val in extra_vars.items(): | ||
| 303 | conf_file.write(f'{var} = "{val}"\n') | ||
| 304 | conf_file.close() | ||
| 305 | bb_cmd += f" -R {conf_file.name}" | ||
| 306 | |||
| 307 | bb_cmd += f" {recipe}" | ||
| 308 | |||
| 309 | poky_dir = build_dir.parent | ||
| 310 | full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 311 | |||
| 312 | try: | ||
| 313 | result = subprocess.run( | ||
| 314 | full_cmd, shell=True, cwd=build_dir, | ||
| 315 | timeout=timeout, capture_output=True, text=True, | ||
| 316 | ) | ||
| 317 | finally: | ||
| 318 | if conf_file: | ||
| 319 | os.unlink(conf_file.name) | ||
| 320 | |||
| 321 | return result | ||
| 322 | |||
| 323 | |||
| 293 | @pytest.fixture(scope="module") | 324 | @pytest.fixture(scope="module") |
| 294 | def poky_dir(request): | 325 | def poky_dir(request): |
| 295 | """Path to poky directory.""" | 326 | """Path to poky directory.""" |
| @@ -325,20 +356,28 @@ def k3s_timeout(request): | |||
| 325 | 356 | ||
| 326 | 357 | ||
| 327 | @pytest.fixture(scope="module") | 358 | @pytest.fixture(scope="module") |
| 328 | def k3s_session(request, poky_dir, build_dir, machine): | 359 | def k3s_image(build_dir): |
| 360 | """Build container-image-host with k3s profile.""" | ||
| 361 | result = run_bitbake( | ||
| 362 | build_dir, "container-image-host", | ||
| 363 | extra_vars={ | ||
| 364 | "CONTAINER_PROFILE": "k3s-host", | ||
| 365 | "DISTRO_FEATURES:append": " k3s", | ||
| 366 | }, | ||
| 367 | ) | ||
| 368 | if result.returncode != 0: | ||
| 369 | pytest.fail(f"K3s image build failed: {result.stderr}") | ||
| 370 | |||
| 371 | |||
| 372 | @pytest.fixture(scope="module") | ||
| 373 | def k3s_session(request, poky_dir, build_dir, machine, k3s_image): | ||
| 329 | """ | 374 | """ |
| 330 | Module-scoped fixture that boots container-image-host once for all | 375 | Module-scoped fixture that builds container-image-host with k3s profile, |
| 331 | single-node k3s tests. Uses runqemu for single-node tests. | 376 | boots it, and provides a session for all single-node k3s tests. |
| 332 | """ | 377 | """ |
| 333 | if not PEXPECT_AVAILABLE: | 378 | if not PEXPECT_AVAILABLE: |
| 334 | pytest.skip("pexpect not installed. Run: pip install pexpect") | 379 | pytest.skip("pexpect not installed. Run: pip install pexpect") |
| 335 | 380 | ||
| 336 | deploy_dir = build_dir / "tmp" / "deploy" / "images" / machine | ||
| 337 | ext4_files = list(deploy_dir.glob("container-image-host-*.rootfs.ext4")) | ||
| 338 | if not ext4_files: | ||
| 339 | pytest.skip( | ||
| 340 | f"container-image-host ext4 image not found in {deploy_dir}") | ||
| 341 | |||
| 342 | timeout = request.config.getoption("--boot-timeout") | 381 | timeout = request.config.getoption("--boot-timeout") |
| 343 | use_kvm = not request.config.getoption("--no-kvm") | 382 | use_kvm = not request.config.getoption("--no-kvm") |
| 344 | 383 | ||
| @@ -356,10 +395,10 @@ def k3s_session(request, poky_dir, build_dir, machine): | |||
| 356 | 395 | ||
| 357 | 396 | ||
| 358 | @pytest.fixture(scope="module") | 397 | @pytest.fixture(scope="module") |
| 359 | def k3s_multinode(request, poky_dir, build_dir, machine): | 398 | def k3s_multinode(request, poky_dir, build_dir, machine, k3s_image): |
| 360 | """ | 399 | """ |
| 361 | Module-scoped fixture that boots two VMs connected via QEMU socket | 400 | Module-scoped fixture that builds with k3s profile, then boots two VMs |
| 362 | networking for multi-node k3s testing. | 401 | connected via QEMU socket networking for multi-node k3s testing. |
| 363 | 402 | ||
| 364 | Uses direct QEMU launch (not runqemu) since runqemu can only run | 403 | Uses direct QEMU launch (not runqemu) since runqemu can only run |
| 365 | one VM at a time. Creates a copy of the rootfs for the agent VM. | 404 | one VM at a time. Creates a copy of the rootfs for the agent VM. |
| @@ -380,9 +419,13 @@ def k3s_multinode(request, poky_dir, build_dir, machine): | |||
| 380 | 419 | ||
| 381 | rootfs_orig = ext4_files[-1] | 420 | rootfs_orig = ext4_files[-1] |
| 382 | 421 | ||
| 383 | # Create a copy of the rootfs for the agent VM — two VMs can't | 422 | # Both VMs need their own rootfs copy — the original may still be |
| 384 | # share the same ext4 file read-write | 423 | # locked by a previous runqemu session (single-node test), and two |
| 424 | # VMs can't share the same ext4 file read-write. | ||
| 425 | rootfs_server = Path(f"/tmp/k3s-server-rootfs-{os.getpid()}.ext4") | ||
| 385 | rootfs_agent = Path(f"/tmp/k3s-agent-rootfs-{os.getpid()}.ext4") | 426 | rootfs_agent = Path(f"/tmp/k3s-agent-rootfs-{os.getpid()}.ext4") |
| 427 | print(f"Copying rootfs for server VM: {rootfs_orig} -> {rootfs_server}") | ||
| 428 | shutil.copy2(rootfs_orig, rootfs_server) | ||
| 386 | print(f"Copying rootfs for agent VM: {rootfs_orig} -> {rootfs_agent}") | 429 | print(f"Copying rootfs for agent VM: {rootfs_orig} -> {rootfs_agent}") |
| 387 | shutil.copy2(rootfs_orig, rootfs_agent) | 430 | shutil.copy2(rootfs_orig, rootfs_agent) |
| 388 | 431 | ||
| @@ -399,7 +442,7 @@ def k3s_multinode(request, poky_dir, build_dir, machine): | |||
| 399 | use_kvm=use_kvm, timeout=timeout, | 442 | use_kvm=use_kvm, timeout=timeout, |
| 400 | extra_qemu_params=server_params, | 443 | extra_qemu_params=server_params, |
| 401 | use_runqemu=False, | 444 | use_runqemu=False, |
| 402 | rootfs_path=rootfs_orig, | 445 | rootfs_path=rootfs_server, |
| 403 | kernel_append="k3s.role=server k3s.node-ip=192.168.50.1", | 446 | kernel_append="k3s.role=server k3s.node-ip=192.168.50.1", |
| 404 | log_suffix="-server") | 447 | log_suffix="-server") |
| 405 | 448 | ||
| @@ -415,6 +458,7 @@ def k3s_multinode(request, poky_dir, build_dir, machine): | |||
| 415 | rootfs_path=rootfs_agent, | 458 | rootfs_path=rootfs_agent, |
| 416 | kernel_append="k3s.role=agent k3s.node-ip=192.168.50.2 k3s.node-name=k3s-agent", | 459 | kernel_append="k3s.role=agent k3s.node-ip=192.168.50.2 k3s.node-name=k3s-agent", |
| 417 | log_suffix="-agent") | 460 | log_suffix="-agent") |
| 461 | server._rootfs_copy = str(rootfs_server) | ||
| 418 | agent._rootfs_copy = str(rootfs_agent) | 462 | agent._rootfs_copy = str(rootfs_agent) |
| 419 | 463 | ||
| 420 | try: | 464 | try: |
diff --git a/tests/test_libvirt.py b/tests/test_libvirt.py new file mode 100644 index 00000000..442df8c3 --- /dev/null +++ b/tests/test_libvirt.py | |||
| @@ -0,0 +1,468 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2026 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Libvirt recipe and runtime tests. | ||
| 6 | |||
| 7 | Tier 1: Static assertions on recipe files (no build required) | ||
| 8 | Tier 2: Build verification (requires bitbake) | ||
| 9 | Tier 3: Boot tests on kvm-image-minimal (requires QEMU with KVM) | ||
| 10 | |||
| 11 | The tests automatically build kvm-image-minimal with the kvm | ||
| 12 | DISTRO_FEATURE before booting. No local.conf changes needed. | ||
| 13 | |||
| 14 | Run: | ||
| 15 | # Static tests only | ||
| 16 | pytest tests/test_libvirt.py -v -m "not slow and not boot" --poky-dir /opt/bruce/poky | ||
| 17 | |||
| 18 | # All tests including build and boot | ||
| 19 | pytest tests/test_libvirt.py -v --poky-dir /opt/bruce/poky | ||
| 20 | |||
| 21 | Options: | ||
| 22 | --boot-timeout QEMU boot timeout (default: 120s) | ||
| 23 | --no-kvm Disable KVM acceleration | ||
| 24 | """ | ||
| 25 | |||
| 26 | import os | ||
| 27 | import re | ||
| 28 | import subprocess | ||
| 29 | import tempfile | ||
| 30 | import time | ||
| 31 | import pytest | ||
| 32 | from pathlib import Path | ||
| 33 | |||
| 34 | try: | ||
| 35 | import pexpect | ||
| 36 | PEXPECT_AVAILABLE = True | ||
| 37 | except ImportError: | ||
| 38 | PEXPECT_AVAILABLE = False | ||
| 39 | |||
| 40 | |||
| 41 | def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 42 | """Run bitbake with optional variable overrides via -R conf file.""" | ||
| 43 | bb_cmd = "bitbake" | ||
| 44 | conf_file = None | ||
| 45 | if extra_vars: | ||
| 46 | conf_file = tempfile.NamedTemporaryFile( | ||
| 47 | mode='w', suffix='.conf', prefix='pytest-libvirt-', | ||
| 48 | dir=str(build_dir / "conf"), delete=False) | ||
| 49 | for var, val in extra_vars.items(): | ||
| 50 | conf_file.write(f'{var} = "{val}"\n') | ||
| 51 | conf_file.close() | ||
| 52 | bb_cmd += f" -R {conf_file.name}" | ||
| 53 | bb_cmd += f" {recipe}" | ||
| 54 | poky_dir = build_dir.parent | ||
| 55 | full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 56 | try: | ||
| 57 | return subprocess.run(full_cmd, shell=True, cwd=build_dir, | ||
| 58 | timeout=timeout, capture_output=True, text=True) | ||
| 59 | finally: | ||
| 60 | if conf_file: | ||
| 61 | os.unlink(conf_file.name) | ||
| 62 | |||
| 63 | |||
| 64 | # ============================================================================ | ||
| 65 | # Fixtures | ||
| 66 | # ============================================================================ | ||
| 67 | |||
| 68 | @pytest.fixture(scope="module") | ||
| 69 | def poky_dir(request): | ||
| 70 | path = Path(request.config.getoption("--poky-dir")) | ||
| 71 | if not path.exists(): | ||
| 72 | pytest.skip(f"Poky directory not found: {path}") | ||
| 73 | return path | ||
| 74 | |||
| 75 | |||
| 76 | @pytest.fixture(scope="module") | ||
| 77 | def build_dir(request, poky_dir): | ||
| 78 | bd = request.config.getoption("--build-dir") | ||
| 79 | path = Path(bd) if bd else poky_dir / "build" | ||
| 80 | if not path.exists(): | ||
| 81 | pytest.skip(f"Build directory not found: {path}") | ||
| 82 | return path | ||
| 83 | |||
| 84 | |||
| 85 | @pytest.fixture(scope="module") | ||
| 86 | def meta_virt_dir(poky_dir): | ||
| 87 | path = poky_dir / "meta-virtualization" | ||
| 88 | if not path.exists(): | ||
| 89 | pytest.skip(f"meta-virtualization not found: {path}") | ||
| 90 | return path | ||
| 91 | |||
| 92 | |||
| 93 | @pytest.fixture(scope="module") | ||
| 94 | def libvirt_recipe(meta_virt_dir): | ||
| 95 | path = meta_virt_dir / "recipes-extended" / "libvirt" / "libvirt_git.bb" | ||
| 96 | if not path.exists(): | ||
| 97 | pytest.skip(f"libvirt recipe not found: {path}") | ||
| 98 | return path | ||
| 99 | |||
| 100 | |||
| 101 | @pytest.fixture(scope="module") | ||
| 102 | def libvirt_files_dir(meta_virt_dir): | ||
| 103 | path = meta_virt_dir / "recipes-extended" / "libvirt" / "libvirt" | ||
| 104 | if not path.exists(): | ||
| 105 | pytest.skip(f"libvirt files dir not found: {path}") | ||
| 106 | return path | ||
| 107 | |||
| 108 | |||
| 109 | @pytest.fixture(scope="module") | ||
| 110 | def kvm_image(build_dir): | ||
| 111 | """Build kvm-image-minimal with kvm DISTRO_FEATURE.""" | ||
| 112 | result = _run_bitbake( | ||
| 113 | build_dir, "kvm-image-minimal", | ||
| 114 | extra_vars={ | ||
| 115 | "DISTRO_FEATURES:append": " kvm", | ||
| 116 | }, | ||
| 117 | ) | ||
| 118 | if result.returncode != 0: | ||
| 119 | pytest.fail(f"KVM image build failed: {result.stderr}") | ||
| 120 | |||
| 121 | |||
| 122 | @pytest.fixture(scope="module") | ||
| 123 | def libvirt_session(request, poky_dir, build_dir, kvm_image): | ||
| 124 | """Boot kvm-image-minimal and provide a pexpect session.""" | ||
| 125 | if not PEXPECT_AVAILABLE: | ||
| 126 | pytest.skip("pexpect not installed") | ||
| 127 | |||
| 128 | machine = request.config.getoption("--machine", default="qemux86-64") | ||
| 129 | timeout = int(request.config.getoption("--boot-timeout", default="120")) | ||
| 130 | no_kvm = request.config.getoption("--no-kvm", default=False) | ||
| 131 | |||
| 132 | kvm_opt = "" if no_kvm else "kvm" | ||
| 133 | cmd = ( | ||
| 134 | f"bash -c 'cd {poky_dir} && " | ||
| 135 | f"source oe-init-build-env {build_dir} >/dev/null 2>&1 && " | ||
| 136 | f"runqemu {machine} kvm-image-minimal ext4 nographic slirp " | ||
| 137 | f"{kvm_opt} qemuparams=\"-m 2048\"'" | ||
| 138 | ) | ||
| 139 | |||
| 140 | child = pexpect.spawn(cmd, encoding='utf-8', timeout=timeout) | ||
| 141 | child.logfile_read = open('/tmp/runqemu-libvirt-test.log', 'w') | ||
| 142 | |||
| 143 | try: | ||
| 144 | index = child.expect([r'login:', r'root@', pexpect.TIMEOUT, pexpect.EOF], | ||
| 145 | timeout=timeout) | ||
| 146 | if index == 0: | ||
| 147 | child.sendline('root') | ||
| 148 | child.expect([r'root@', r'#'], timeout=30) | ||
| 149 | elif index >= 2: | ||
| 150 | raise RuntimeError("Boot failed") | ||
| 151 | |||
| 152 | child.sendline('export TERM=dumb') | ||
| 153 | child.expect(r'root@[^:]+:[^#]+#', timeout=10) | ||
| 154 | |||
| 155 | yield child | ||
| 156 | |||
| 157 | except RuntimeError as e: | ||
| 158 | pytest.skip(f"Failed to boot: {e}") | ||
| 159 | finally: | ||
| 160 | child.sendline('poweroff') | ||
| 161 | try: | ||
| 162 | child.expect(pexpect.EOF, timeout=30) | ||
| 163 | except pexpect.TIMEOUT: | ||
| 164 | child.terminate(force=True) | ||
| 165 | |||
| 166 | |||
| 167 | def run_cmd(child, cmd, timeout=60): | ||
| 168 | """Run a command and return (output, returncode).""" | ||
| 169 | marker = f"__MARKER_{time.monotonic_ns()}__" | ||
| 170 | child.sendline(f"{cmd}; echo {marker} $?") | ||
| 171 | child.expect(marker + r" (\d+)", timeout=timeout) | ||
| 172 | raw = child.before | ||
| 173 | raw = re.sub(r'\x1b\]3008;[^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) | ||
| 174 | raw = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', raw) | ||
| 175 | output = raw.strip() | ||
| 176 | rc = int(child.match.group(1)) | ||
| 177 | child.expect(r'root@[^:]+:[^#]+#', timeout=10) | ||
| 178 | return output, rc | ||
| 179 | |||
| 180 | |||
| 181 | # ============================================================================ | ||
| 182 | # Tier 1: Static assertions (no build required) | ||
| 183 | # ============================================================================ | ||
| 184 | |||
| 185 | class TestLibvirtRecipeStatic: | ||
| 186 | """Static checks on the libvirt recipe file.""" | ||
| 187 | |||
| 188 | def test_recipe_exists(self, libvirt_recipe): | ||
| 189 | assert libvirt_recipe.exists() | ||
| 190 | |||
| 191 | def test_has_meson_inherit(self, libvirt_recipe): | ||
| 192 | content = libvirt_recipe.read_text() | ||
| 193 | assert "inherit meson" in content | ||
| 194 | |||
| 195 | def test_has_systemd_support(self, libvirt_recipe): | ||
| 196 | content = libvirt_recipe.read_text() | ||
| 197 | assert "inherit" in content and "systemd" in content | ||
| 198 | assert "SYSTEMD_SERVICE" in content | ||
| 199 | |||
| 200 | def test_systemd_services_defined(self, libvirt_recipe): | ||
| 201 | content = libvirt_recipe.read_text() | ||
| 202 | assert "libvirtd.service" in content | ||
| 203 | assert "virtlockd.service" in content | ||
| 204 | |||
| 205 | def test_has_useradd(self, libvirt_recipe): | ||
| 206 | content = libvirt_recipe.read_text() | ||
| 207 | assert "inherit" in content and "useradd" in content | ||
| 208 | assert "qemu" in content | ||
| 209 | assert "libvirt" in content | ||
| 210 | |||
| 211 | def test_qemu_packageconfig(self, libvirt_recipe): | ||
| 212 | content = libvirt_recipe.read_text() | ||
| 213 | assert "PACKAGECONFIG[qemu]" in content | ||
| 214 | assert "driver_qemu" in content | ||
| 215 | |||
| 216 | def test_lxc_packageconfig(self, libvirt_recipe): | ||
| 217 | content = libvirt_recipe.read_text() | ||
| 218 | assert "PACKAGECONFIG[lxc]" in content | ||
| 219 | assert "driver_lxc" in content | ||
| 220 | |||
| 221 | def test_xen_packageconfig_gated(self, libvirt_recipe): | ||
| 222 | """libxl PACKAGECONFIG should be gated on xen DISTRO_FEATURE.""" | ||
| 223 | content = libvirt_recipe.read_text() | ||
| 224 | assert "PACKAGECONFIG[libxl]" in content | ||
| 225 | for line in content.splitlines(): | ||
| 226 | if "libxl" in line and "DISTRO_FEATURES" in line: | ||
| 227 | assert "xen" in line | ||
| 228 | break | ||
| 229 | else: | ||
| 230 | pytest.fail("libxl PACKAGECONFIG not gated on xen DISTRO_FEATURE") | ||
| 231 | |||
| 232 | def test_nftables_rdepends(self, libvirt_recipe): | ||
| 233 | """libvirt-libvirtd should RDEPEND on nftables when configured.""" | ||
| 234 | content = libvirt_recipe.read_text() | ||
| 235 | assert "nftables" in content | ||
| 236 | |||
| 237 | def test_nftables_or_iptables_rdepends(self, libvirt_recipe): | ||
| 238 | """RDEPENDS should select nftables or iptables based on PACKAGECONFIG.""" | ||
| 239 | content = libvirt_recipe.read_text() | ||
| 240 | assert "bb.utils.contains('PACKAGECONFIG', 'nftables'" in content, \ | ||
| 241 | "RDEPENDS should conditionally select nftables or iptables" | ||
| 242 | |||
| 243 | def test_packages_split(self, libvirt_recipe): | ||
| 244 | """Recipe should split into libvirt, libvirt-libvirtd, libvirt-virsh.""" | ||
| 245 | content = libvirt_recipe.read_text() | ||
| 246 | assert "${PN}-libvirtd" in content | ||
| 247 | assert "${PN}-virsh" in content | ||
| 248 | |||
| 249 | def test_cve_status_entries(self, libvirt_recipe): | ||
| 250 | content = libvirt_recipe.read_text() | ||
| 251 | assert "CVE_STATUS" in content | ||
| 252 | cve_count = content.count("CVE_STATUS[CVE-") | ||
| 253 | assert cve_count >= 5, f"Expected at least 5 CVE entries, found {cve_count}" | ||
| 254 | |||
| 255 | |||
| 256 | class TestLibvirtHookSupport: | ||
| 257 | """Tests for hook_support.py (the file from the recent bug report).""" | ||
| 258 | |||
| 259 | def test_hook_script_exists(self, libvirt_files_dir): | ||
| 260 | path = libvirt_files_dir / "hook_support.py" | ||
| 261 | assert path.exists() | ||
| 262 | |||
| 263 | def test_hook_script_python3_shebang(self, libvirt_files_dir): | ||
| 264 | content = (libvirt_files_dir / "hook_support.py").read_text() | ||
| 265 | first_line = content.splitlines()[0] | ||
| 266 | assert "python3" in first_line or "python" in first_line | ||
| 267 | |||
| 268 | def test_hook_script_uses_text_mode(self, libvirt_files_dir): | ||
| 269 | """Popen should use text=True for python3 string compatibility.""" | ||
| 270 | content = (libvirt_files_dir / "hook_support.py").read_text() | ||
| 271 | assert "text=True" in content, \ | ||
| 272 | "Popen should use text=True for python3 string/bytes compatibility" | ||
| 273 | |||
| 274 | def test_hook_script_regex_raw_string(self, libvirt_files_dir): | ||
| 275 | """Regex should use raw string to avoid SyntaxWarning in python3.12+.""" | ||
| 276 | content = (libvirt_files_dir / "hook_support.py").read_text() | ||
| 277 | for line in content.splitlines(): | ||
| 278 | if "re.compile" in line and "\\w" in line: | ||
| 279 | assert "rf\"" in line or "r'" in line or 'r"' in line, \ | ||
| 280 | f"Regex with \\w should use raw string: {line.strip()}" | ||
| 281 | |||
| 282 | def test_hook_script_syntax_valid(self, libvirt_files_dir): | ||
| 283 | """hook_support.py should parse without syntax errors.""" | ||
| 284 | import py_compile | ||
| 285 | path = libvirt_files_dir / "hook_support.py" | ||
| 286 | try: | ||
| 287 | py_compile.compile(str(path), doraise=True) | ||
| 288 | except py_compile.PyCompileError as e: | ||
| 289 | pytest.fail(f"Syntax error in hook_support.py: {e}") | ||
| 290 | |||
| 291 | def test_hook_installs_for_all_domains(self, libvirt_recipe): | ||
| 292 | """hook_support.py should be installed for daemon, lxc, network, qemu.""" | ||
| 293 | content = libvirt_recipe.read_text() | ||
| 294 | for hook in ["daemon", "lxc", "network", "qemu"]: | ||
| 295 | assert hook in content, f"Hook not installed for domain: {hook}" | ||
| 296 | |||
| 297 | |||
| 298 | class TestLibvirtServiceFiles: | ||
| 299 | """Tests for systemd service and init script files.""" | ||
| 300 | |||
| 301 | def test_initscript_exists(self, libvirt_files_dir): | ||
| 302 | assert (libvirt_files_dir / "libvirtd.sh").exists() | ||
| 303 | |||
| 304 | def test_initscript_has_lsb_header(self, libvirt_files_dir): | ||
| 305 | content = (libvirt_files_dir / "libvirtd.sh").read_text() | ||
| 306 | assert "BEGIN INIT INFO" in content | ||
| 307 | |||
| 308 | def test_initscript_start_stop(self, libvirt_files_dir): | ||
| 309 | content = (libvirt_files_dir / "libvirtd.sh").read_text() | ||
| 310 | assert "start)" in content | ||
| 311 | assert "stop)" in content | ||
| 312 | assert "restart)" in content | ||
| 313 | |||
| 314 | def test_dnsmasq_conf_exists(self, libvirt_files_dir): | ||
| 315 | assert (libvirt_files_dir / "dnsmasq.conf").exists() | ||
| 316 | |||
| 317 | def test_libvirtd_conf_exists(self, libvirt_files_dir): | ||
| 318 | assert (libvirt_files_dir / "libvirtd.conf").exists() | ||
| 319 | |||
| 320 | def test_gnutls_helper_exists(self, libvirt_files_dir): | ||
| 321 | assert (libvirt_files_dir / "gnutls-helper.py").exists() | ||
| 322 | |||
| 323 | |||
| 324 | class TestLibvirtPatchFiles: | ||
| 325 | """Verify patches exist and reference valid upstream issues.""" | ||
| 326 | |||
| 327 | def test_patches_exist(self, libvirt_files_dir): | ||
| 328 | patches = list(libvirt_files_dir.glob("*.patch")) | ||
| 329 | assert len(patches) >= 1, "Expected at least one patch" | ||
| 330 | |||
| 331 | def test_buildpath_patches_present(self, libvirt_files_dir): | ||
| 332 | """Patches for buildpaths should exist.""" | ||
| 333 | patches = list(libvirt_files_dir.glob("*.patch")) | ||
| 334 | buildpath_patches = [p for p in patches if "build-path" in p.name | ||
| 335 | or "build_path" in p.name or "gendispatch" in p.name] | ||
| 336 | assert len(buildpath_patches) >= 1, \ | ||
| 337 | "Expected at least one buildpath-related patch" | ||
| 338 | |||
| 339 | |||
| 340 | class TestKvmImageRecipe: | ||
| 341 | """Static checks on kvm-image-minimal recipe.""" | ||
| 342 | |||
| 343 | def test_recipe_exists(self, meta_virt_dir): | ||
| 344 | path = meta_virt_dir / "recipes-extended" / "images" / "kvm-image-minimal.bb" | ||
| 345 | assert path.exists() | ||
| 346 | |||
| 347 | def test_requires_kvm_distro_feature(self, meta_virt_dir): | ||
| 348 | content = (meta_virt_dir / "recipes-extended" / "images" / "kvm-image-minimal.bb").read_text() | ||
| 349 | assert "kvm" in content | ||
| 350 | assert "REQUIRED_DISTRO_FEATURES" in content | ||
| 351 | |||
| 352 | def test_includes_libvirt(self, meta_virt_dir): | ||
| 353 | content = (meta_virt_dir / "recipes-extended" / "images" / "kvm-image-minimal.bb").read_text() | ||
| 354 | assert "libvirt" in content | ||
| 355 | assert "libvirt-libvirtd" in content | ||
| 356 | assert "libvirt-virsh" in content | ||
| 357 | |||
| 358 | def test_includes_qemu(self, meta_virt_dir): | ||
| 359 | content = (meta_virt_dir / "recipes-extended" / "images" / "kvm-image-minimal.bb").read_text() | ||
| 360 | assert "qemu" in content | ||
| 361 | |||
| 362 | def test_includes_kvm_modules(self, meta_virt_dir): | ||
| 363 | content = (meta_virt_dir / "recipes-extended" / "images" / "kvm-image-minimal.bb").read_text() | ||
| 364 | assert "kernel-module-kvm" in content | ||
| 365 | |||
| 366 | |||
| 367 | # ============================================================================ | ||
| 368 | # Tier 2: Build verification (requires bitbake) | ||
| 369 | # ============================================================================ | ||
| 370 | |||
| 371 | @pytest.mark.slow | ||
| 372 | class TestLibvirtBuild: | ||
| 373 | """Build tests for libvirt recipe.""" | ||
| 374 | |||
| 375 | def test_libvirt_builds(self, build_dir): | ||
| 376 | result = _run_bitbake(build_dir, "libvirt") | ||
| 377 | assert result.returncode == 0, f"libvirt build failed: {result.stderr}" | ||
| 378 | |||
| 379 | def test_kvm_image_builds(self, build_dir, kvm_image): | ||
| 380 | """kvm-image-minimal builds successfully (via kvm_image fixture).""" | ||
| 381 | pass | ||
| 382 | |||
| 383 | |||
| 384 | # ============================================================================ | ||
| 385 | # Tier 3: Boot tests (requires QEMU) | ||
| 386 | # ============================================================================ | ||
| 387 | |||
| 388 | # Libvirt v12 defaults to modular daemons (virtqemud, virtnetworkd, etc.) | ||
| 389 | # but kvm-image-minimal runs the monolithic libvirtd. virsh must be pointed | ||
| 390 | # at the monolithic socket explicitly. | ||
| 391 | _VIRSH = "virsh -c qemu+unix:///system?socket=/var/run/libvirt/libvirt-sock" | ||
| 392 | |||
| 393 | |||
| 394 | @pytest.mark.slow | ||
| 395 | @pytest.mark.boot | ||
| 396 | class TestLibvirtRuntime: | ||
| 397 | """Boot tests verifying libvirt on a running kvm-image-minimal.""" | ||
| 398 | |||
| 399 | def test_libvirtd_running(self, libvirt_session): | ||
| 400 | """libvirtd systemd service should be active.""" | ||
| 401 | output, rc = run_cmd(libvirt_session, "systemctl is-active libvirtd") | ||
| 402 | assert "active" in output, f"libvirtd not active: {output}" | ||
| 403 | |||
| 404 | def test_virtlockd_running(self, libvirt_session): | ||
| 405 | """virtlockd should be active (started on demand via socket).""" | ||
| 406 | output, rc = run_cmd(libvirt_session, | ||
| 407 | "systemctl is-active virtlockd.socket") | ||
| 408 | assert "active" in output, f"virtlockd.socket not active: {output}" | ||
| 409 | |||
| 410 | def test_virsh_available(self, libvirt_session): | ||
| 411 | """virsh command should be available.""" | ||
| 412 | output, rc = run_cmd(libvirt_session, "which virsh") | ||
| 413 | assert rc == 0, f"virsh not found: {output}" | ||
| 414 | |||
| 415 | def test_virsh_version(self, libvirt_session): | ||
| 416 | """virsh version should report libvirt version.""" | ||
| 417 | output, rc = run_cmd(libvirt_session, "virsh --version") | ||
| 418 | assert rc == 0, f"virsh version failed: {output}" | ||
| 419 | |||
| 420 | def test_virsh_connect(self, libvirt_session): | ||
| 421 | """virsh should connect to local libvirtd.""" | ||
| 422 | output, rc = run_cmd(libvirt_session, f"{_VIRSH} uri") | ||
| 423 | assert rc == 0, f"virsh uri failed: {output}" | ||
| 424 | |||
| 425 | def test_virsh_capabilities(self, libvirt_session): | ||
| 426 | """virsh capabilities should return valid XML.""" | ||
| 427 | output, rc = run_cmd(libvirt_session, f"{_VIRSH} capabilities") | ||
| 428 | assert rc == 0, f"virsh capabilities failed: {output}" | ||
| 429 | assert "<capabilities>" in output or "capabilities" in output | ||
| 430 | |||
| 431 | def test_virsh_nodeinfo(self, libvirt_session): | ||
| 432 | """virsh nodeinfo should report system info.""" | ||
| 433 | output, rc = run_cmd(libvirt_session, f"{_VIRSH} nodeinfo") | ||
| 434 | assert rc == 0, f"virsh nodeinfo failed: {output}" | ||
| 435 | assert "CPU model" in output or "cpu" in output.lower() | ||
| 436 | |||
| 437 | def test_virsh_list(self, libvirt_session): | ||
| 438 | """virsh list should work (even with no domains).""" | ||
| 439 | output, rc = run_cmd(libvirt_session, f"{_VIRSH} list --all") | ||
| 440 | assert rc == 0, f"virsh list failed: {output}" | ||
| 441 | |||
| 442 | def test_default_network(self, libvirt_session): | ||
| 443 | """Default network should be defined.""" | ||
| 444 | output, rc = run_cmd(libvirt_session, f"{_VIRSH} net-list --all") | ||
| 445 | assert rc == 0, f"virsh net-list failed: {output}" | ||
| 446 | |||
| 447 | def test_hook_scripts_installed(self, libvirt_session): | ||
| 448 | """Hook scripts should be installed for all domains.""" | ||
| 449 | output, rc = run_cmd(libvirt_session, "ls /etc/libvirt/hooks/") | ||
| 450 | assert rc == 0 | ||
| 451 | for hook in ["daemon", "lxc", "network", "qemu"]: | ||
| 452 | assert hook in output, f"Hook script missing: {hook}" | ||
| 453 | |||
| 454 | def test_hook_scripts_executable(self, libvirt_session): | ||
| 455 | """Hook scripts should be executable.""" | ||
| 456 | output, rc = run_cmd(libvirt_session, | ||
| 457 | "test -x /etc/libvirt/hooks/qemu && echo OK") | ||
| 458 | assert "OK" in output, "qemu hook not executable" | ||
| 459 | |||
| 460 | def test_qemu_user_exists(self, libvirt_session): | ||
| 461 | """qemu user should exist (created by useradd in recipe).""" | ||
| 462 | output, rc = run_cmd(libvirt_session, "grep ^qemu: /etc/passwd") | ||
| 463 | assert rc == 0, f"qemu user not found: {output}" | ||
| 464 | |||
| 465 | def test_libvirt_group_exists(self, libvirt_session): | ||
| 466 | """libvirt group should exist.""" | ||
| 467 | output, rc = run_cmd(libvirt_session, "grep ^libvirt: /etc/group") | ||
| 468 | assert rc == 0, f"libvirt group not found: {output}" | ||
diff --git a/tests/test_lxc_runtime.py b/tests/test_lxc_runtime.py new file mode 100644 index 00000000..695cfeb7 --- /dev/null +++ b/tests/test_lxc_runtime.py | |||
| @@ -0,0 +1,290 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2026 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | LXC runtime tests — boot container-image-host with lxc installed and | ||
| 6 | exercise the LXC command-line lifecycle (create, start, attach, stop, | ||
| 7 | destroy). | ||
| 8 | |||
| 9 | The tests build container-image-host with CONTAINER_IMAGE_HOST_EXTRA_INSTALL | ||
| 10 | including lxc. No local.conf changes needed. | ||
| 11 | |||
| 12 | The download-template regression check (TestLxcDownloadTemplate) exists | ||
| 13 | specifically to catch the class of bug reported on the meta-virt list | ||
| 14 | on 2026-06-13 (Ferry Toth: "lxc: starting a container errors out"), | ||
| 15 | where a stale local patch to templates/lxc-download.in expanded an empty | ||
| 16 | ${DOWNLOAD_TEMP} into `mktemp -p -d` and broke the download path before | ||
| 17 | any network call. The test invokes lxc-create with the download template | ||
| 18 | and asserts that the early mktemp error does not appear in the output, | ||
| 19 | even if the actual download itself fails (e.g. no network in the test | ||
| 20 | environment). That keeps the regression test useful in air-gapped CI | ||
| 21 | without requiring outbound network from the qemu guest. | ||
| 22 | |||
| 23 | Run: | ||
| 24 | pytest tests/test_lxc_runtime.py -v --poky-dir /opt/bruce/poky | ||
| 25 | |||
| 26 | Options: | ||
| 27 | --boot-timeout QEMU boot timeout (default: 120s) | ||
| 28 | --no-kvm Disable KVM acceleration | ||
| 29 | --machine QEMU MACHINE (default: qemux86-64) | ||
| 30 | """ | ||
| 31 | |||
| 32 | import os | ||
| 33 | import subprocess | ||
| 34 | import tempfile | ||
| 35 | import time | ||
| 36 | from pathlib import Path | ||
| 37 | |||
| 38 | import pytest | ||
| 39 | |||
| 40 | try: | ||
| 41 | import pexpect | ||
| 42 | PEXPECT_AVAILABLE = True | ||
| 43 | except ImportError: | ||
| 44 | PEXPECT_AVAILABLE = False | ||
| 45 | |||
| 46 | |||
| 47 | pytestmark = [ | ||
| 48 | pytest.mark.skipif(not PEXPECT_AVAILABLE, reason="pexpect not installed"), | ||
| 49 | pytest.mark.lxc, | ||
| 50 | pytest.mark.boot, | ||
| 51 | ] | ||
| 52 | |||
| 53 | |||
| 54 | # --------------------------------------------------------------------------- | ||
| 55 | # Helpers (mirror test_incus_runtime.py conventions so the suites read alike) | ||
| 56 | # --------------------------------------------------------------------------- | ||
| 57 | |||
| 58 | def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 59 | """Run bitbake with optional variable overrides via -R conf file.""" | ||
| 60 | bb_cmd = "bitbake" | ||
| 61 | conf_file = None | ||
| 62 | if extra_vars: | ||
| 63 | conf_file = tempfile.NamedTemporaryFile( | ||
| 64 | mode='w', suffix='.conf', prefix='pytest-lxc-', | ||
| 65 | dir=str(build_dir / "conf"), delete=False) | ||
| 66 | for var, val in extra_vars.items(): | ||
| 67 | conf_file.write(f'{var} = "{val}"\n') | ||
| 68 | conf_file.close() | ||
| 69 | bb_cmd += f" -R {conf_file.name}" | ||
| 70 | bb_cmd += f" {recipe}" | ||
| 71 | poky_dir = build_dir.parent | ||
| 72 | full_cmd = ( | ||
| 73 | f"bash -c 'cd {poky_dir} && " | ||
| 74 | f"source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 75 | ) | ||
| 76 | try: | ||
| 77 | return subprocess.run(full_cmd, shell=True, cwd=build_dir, | ||
| 78 | timeout=timeout, capture_output=True, text=True) | ||
| 79 | finally: | ||
| 80 | if conf_file: | ||
| 81 | os.unlink(conf_file.name) | ||
| 82 | |||
| 83 | |||
| 84 | # --------------------------------------------------------------------------- | ||
| 85 | # Fixtures | ||
| 86 | # --------------------------------------------------------------------------- | ||
| 87 | |||
| 88 | @pytest.fixture(scope="module") | ||
| 89 | def lxc_image(request): | ||
| 90 | """Build container-image-host with lxc included. | ||
| 91 | |||
| 92 | Uses CONTAINER_IMAGE_HOST_EXTRA_INSTALL to add the lxc package without | ||
| 93 | needing to touch local.conf or invent a dedicated container-host-lxc | ||
| 94 | profile fragment. | ||
| 95 | """ | ||
| 96 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 97 | bd = request.config.getoption("--build-dir") | ||
| 98 | build_dir = Path(bd) if bd else poky_dir / "build" | ||
| 99 | result = _run_bitbake( | ||
| 100 | build_dir, "container-image-host", | ||
| 101 | extra_vars={ | ||
| 102 | "CONTAINER_IMAGE_HOST_EXTRA_INSTALL": "lxc", | ||
| 103 | }, | ||
| 104 | ) | ||
| 105 | if result.returncode != 0: | ||
| 106 | pytest.fail(f"container-image-host with lxc failed to build: {result.stderr}") | ||
| 107 | |||
| 108 | |||
| 109 | @pytest.fixture(scope="module") | ||
| 110 | def lxc_qemu(request, lxc_image): | ||
| 111 | """Boot container-image-host in QEMU, return a logged-in pexpect session.""" | ||
| 112 | machine = request.config.getoption("--machine", default="qemux86-64") | ||
| 113 | boot_timeout = int(request.config.getoption("--boot-timeout", default="120")) | ||
| 114 | no_kvm = request.config.getoption("--no-kvm", default=False) | ||
| 115 | |||
| 116 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 117 | bd = request.config.getoption("--build-dir") | ||
| 118 | builddir = str(Path(bd) if bd else poky_dir / "build") | ||
| 119 | |||
| 120 | kvm_opt = "" if no_kvm else "kvm" | ||
| 121 | cmd = ( | ||
| 122 | f"runqemu {machine} container-image-host ext4 nographic slirp " | ||
| 123 | f"{kvm_opt} qemuparams=\"-m 4096\"" | ||
| 124 | ) | ||
| 125 | |||
| 126 | child = pexpect.spawn( | ||
| 127 | f"bash -c 'cd {poky_dir} && source oe-init-build-env {builddir} " | ||
| 128 | f">/dev/null 2>&1 && {cmd}'", | ||
| 129 | timeout=boot_timeout, encoding="utf-8", logfile=None, | ||
| 130 | ) | ||
| 131 | |||
| 132 | child.expect(r"login:", timeout=boot_timeout) | ||
| 133 | child.sendline("root") | ||
| 134 | child.expect(r"root@.*[:~#]", timeout=30) | ||
| 135 | |||
| 136 | # Suppress shell-integration escape sequences that interfere with | ||
| 137 | # pexpect matchers (same trick as test_incus_runtime / test_xen_runtime). | ||
| 138 | child.sendline("export TERM=dumb") | ||
| 139 | child.expect(r"root@.*[:~#]", timeout=10) | ||
| 140 | |||
| 141 | yield child | ||
| 142 | |||
| 143 | child.sendline("poweroff") | ||
| 144 | try: | ||
| 145 | child.expect(pexpect.EOF, timeout=30) | ||
| 146 | except pexpect.TIMEOUT: | ||
| 147 | child.terminate(force=True) | ||
| 148 | |||
| 149 | |||
| 150 | def run_cmd(child, cmd, timeout=60): | ||
| 151 | """Run a shell command in the guest and return (stdout, rc).""" | ||
| 152 | marker = f"__MARKER_{time.monotonic_ns()}__" | ||
| 153 | child.sendline(f"{cmd}; echo {marker} $?") | ||
| 154 | child.expect(marker + r" (\d+)", timeout=timeout) | ||
| 155 | output = child.before.strip() | ||
| 156 | rc = int(child.match.group(1)) | ||
| 157 | child.expect(r"root@.*[:~#]", timeout=10) | ||
| 158 | return output, rc | ||
| 159 | |||
| 160 | |||
| 161 | # --------------------------------------------------------------------------- | ||
| 162 | # Sanity — lxc tooling present and functional | ||
| 163 | # --------------------------------------------------------------------------- | ||
| 164 | |||
| 165 | class TestLxcInstalled: | ||
| 166 | """Confirm lxc is installed and the basic commands report a version.""" | ||
| 167 | |||
| 168 | def test_lxc_create_present(self, lxc_qemu): | ||
| 169 | output, rc = run_cmd(lxc_qemu, "command -v lxc-create") | ||
| 170 | assert rc == 0, f"lxc-create not installed: {output}" | ||
| 171 | |||
| 172 | def test_lxc_start_present(self, lxc_qemu): | ||
| 173 | output, rc = run_cmd(lxc_qemu, "command -v lxc-start") | ||
| 174 | assert rc == 0, f"lxc-start not installed: {output}" | ||
| 175 | |||
| 176 | def test_lxc_version(self, lxc_qemu): | ||
| 177 | output, rc = run_cmd(lxc_qemu, "lxc-create --version") | ||
| 178 | assert rc == 0, f"lxc-create --version failed: {output}" | ||
| 179 | |||
| 180 | |||
| 181 | # --------------------------------------------------------------------------- | ||
| 182 | # Regression: the lxc-download.in mktemp bug from list thread #11808 | ||
| 183 | # --------------------------------------------------------------------------- | ||
| 184 | |||
| 185 | class TestLxcDownloadTemplate: | ||
| 186 | """Regression for the templates-actually-create-DOWNLOAD_TEMP-directory | ||
| 187 | patch breakage. | ||
| 188 | |||
| 189 | The bug was: when ${DOWNLOAD_TEMP} is unset (the common case for | ||
| 190 | `lxc-create --template download`), the patched else branch expanded | ||
| 191 | to `mktemp -p -d`, which the shell parses as `-d` being the argument | ||
| 192 | to `-p` rather than its own flag. mktemp then reports: | ||
| 193 | |||
| 194 | mktemp: failed to create file via template '-d/tmp.XXXXXXXXXX': | ||
| 195 | No such file or directory | ||
| 196 | |||
| 197 | and lxc-create exits before any network call. | ||
| 198 | |||
| 199 | We don't care whether the download itself succeeds here — in a test | ||
| 200 | environment without outbound network, it won't, and that's fine. | ||
| 201 | We only care that the early mktemp parse never happens. If it does, | ||
| 202 | that *exact* error string surfaces, and that string failing to appear | ||
| 203 | is what we assert. | ||
| 204 | """ | ||
| 205 | |||
| 206 | BAD_MKTEMP_ERROR = "mktemp: failed to create file via template '-d" | ||
| 207 | |||
| 208 | def test_download_template_no_mktemp_error(self, lxc_qemu): | ||
| 209 | """lxc-create with the download template must not emit the broken | ||
| 210 | mktemp invocation even when the actual download fails.""" | ||
| 211 | # The specific dist/release/arch values don't matter — even an | ||
| 212 | # invalid combination still exercises the early mktemp path | ||
| 213 | # before any HTTP request. We pick a plausibly-real combo so the | ||
| 214 | # test stays meaningful if a future change adds an early | ||
| 215 | # validation step on the args. | ||
| 216 | cmd = ( | ||
| 217 | "lxc-create --name test-download --template download -- " | ||
| 218 | "--dist ubuntu --release noble --arch amd64 2>&1" | ||
| 219 | ) | ||
| 220 | output, _rc = run_cmd(lxc_qemu, cmd, timeout=120) | ||
| 221 | assert self.BAD_MKTEMP_ERROR not in output, ( | ||
| 222 | f"lxc-download.in DOWNLOAD_TEMP regression — early mktemp error " | ||
| 223 | f"surfaced.\nFull output:\n{output}" | ||
| 224 | ) | ||
| 225 | # Clean up whatever partial state lxc-create may have left behind | ||
| 226 | # so the next test starts clean. Ignore rc — there may be nothing | ||
| 227 | # to destroy. | ||
| 228 | run_cmd(lxc_qemu, "lxc-destroy --name test-download --force", timeout=30) | ||
| 229 | |||
| 230 | |||
| 231 | # --------------------------------------------------------------------------- | ||
| 232 | # Network-required path — exercise the full download+create flow | ||
| 233 | # --------------------------------------------------------------------------- | ||
| 234 | |||
| 235 | @pytest.mark.network | ||
| 236 | class TestLxcContainerLifecycle: | ||
| 237 | """End-to-end create/start/attach/stop/destroy against a real download. | ||
| 238 | |||
| 239 | Marked @network because lxc-create --template download fetches from | ||
| 240 | images.linuxcontainers.org. Skipped on offline runners. The regression | ||
| 241 | test above runs without network and is the primary guard against | ||
| 242 | Ferry's bug. | ||
| 243 | """ | ||
| 244 | |||
| 245 | NAME = "test-lxc-lifecycle" | ||
| 246 | |||
| 247 | def test_create_alpine_via_download(self, lxc_qemu): | ||
| 248 | cmd = ( | ||
| 249 | f"lxc-create --name {self.NAME} --template download -- " | ||
| 250 | f"--dist alpine --release edge --arch amd64" | ||
| 251 | ) | ||
| 252 | output, rc = run_cmd(lxc_qemu, cmd, timeout=600) | ||
| 253 | if rc != 0: | ||
| 254 | pytest.skip( | ||
| 255 | f"lxc-create download failed (likely network unreachable): " | ||
| 256 | f"{output[:400]}" | ||
| 257 | ) | ||
| 258 | |||
| 259 | def test_start(self, lxc_qemu): | ||
| 260 | output, rc = run_cmd(lxc_qemu, f"lxc-start --name {self.NAME}", | ||
| 261 | timeout=60) | ||
| 262 | assert rc == 0, f"lxc-start failed: {output}" | ||
| 263 | # Give the container a moment to come up | ||
| 264 | run_cmd(lxc_qemu, "sleep 3") | ||
| 265 | |||
| 266 | def test_running(self, lxc_qemu): | ||
| 267 | output, rc = run_cmd(lxc_qemu, f"lxc-ls --running -1") | ||
| 268 | assert self.NAME in output, f"container not running: {output}" | ||
| 269 | |||
| 270 | def test_attach_runs_command(self, lxc_qemu): | ||
| 271 | # lxc-attach returns the exit code of the inner command, so | ||
| 272 | # check the inner command's output rather than rc alone. | ||
| 273 | output, _rc = run_cmd( | ||
| 274 | lxc_qemu, | ||
| 275 | f"lxc-attach --name {self.NAME} -- cat /etc/os-release", | ||
| 276 | timeout=30, | ||
| 277 | ) | ||
| 278 | assert "alpine" in output.lower(), ( | ||
| 279 | f"expected alpine os-release inside container, got: {output}" | ||
| 280 | ) | ||
| 281 | |||
| 282 | def test_stop(self, lxc_qemu): | ||
| 283 | output, rc = run_cmd(lxc_qemu, f"lxc-stop --name {self.NAME}", | ||
| 284 | timeout=60) | ||
| 285 | assert rc == 0, f"lxc-stop failed: {output}" | ||
| 286 | |||
| 287 | def test_destroy(self, lxc_qemu): | ||
| 288 | output, rc = run_cmd(lxc_qemu, f"lxc-destroy --name {self.NAME}", | ||
| 289 | timeout=60) | ||
| 290 | assert rc == 0, f"lxc-destroy failed: {output}" | ||
diff --git a/tests/test_vcontainer_auth_config.py b/tests/test_vcontainer_auth_config.py new file mode 100644 index 00000000..2e7093aa --- /dev/null +++ b/tests/test_vcontainer_auth_config.py | |||
| @@ -0,0 +1,642 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2026 Konsulko Group | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Tests for the vcontainer registry-auth-config plumbing ("--config" / | ||
| 6 | $VDKR_CONFIG / $VPDMN_CONFIG). | ||
| 7 | |||
| 8 | These tests are split into two tiers: | ||
| 9 | |||
| 10 | Tier 1 - static/shell-level (TestAuthConfigStaticPlumbing): | ||
| 11 | Reads the shell scripts under recipes-containers/vcontainer/files/ and the | ||
| 12 | README and asserts that the expected function definitions, call sites, | ||
| 13 | kernel cmdline flags, permission modes, mount options, and documentation | ||
| 14 | blocks are present. These tests need no infrastructure and run in <1s. | ||
| 15 | |||
| 16 | Tier 2 - functional validator (TestAuthConfigValidator): | ||
| 17 | Extracts validate_auth_config() from vrunner.sh, sources it in a bash | ||
| 18 | subshell with a stubbed log() function, and drives it with a table of | ||
| 19 | inputs covering the perm / size / symlink / ownership / regular-file | ||
| 20 | rules. Also runs in <1s per case. | ||
| 21 | |||
| 22 | Tier 3 (live registry pull with --config) is intentionally NOT in this file. | ||
| 23 | It belongs alongside test_vdkr_registry.py once the registry fixture grows a | ||
| 24 | credentials-required mode. | ||
| 25 | |||
| 26 | Run with: | ||
| 27 | pytest tests/test_vcontainer_auth_config.py -v | ||
| 28 | """ | ||
| 29 | |||
| 30 | import os | ||
| 31 | import re | ||
| 32 | import stat | ||
| 33 | import subprocess | ||
| 34 | import textwrap | ||
| 35 | from pathlib import Path | ||
| 36 | |||
| 37 | import pytest | ||
| 38 | |||
| 39 | |||
| 40 | # --------------------------------------------------------------------------- | ||
| 41 | # Locate the vcontainer files/ directory. | ||
| 42 | # --------------------------------------------------------------------------- | ||
| 43 | # | ||
| 44 | # Resolution order: | ||
| 45 | # 1. VCONTAINER_FILES_DIR environment variable (explicit override) | ||
| 46 | # 2. <repo-root>/recipes-containers/vcontainer/files/ relative to this test | ||
| 47 | # (i.e. tests/../recipes-containers/vcontainer/files/) | ||
| 48 | # 3. /opt/bruce/poky/meta-virtualization/recipes-containers/vcontainer/files/ | ||
| 49 | # (matches the pattern used by test_container_registry_script.py) | ||
| 50 | # | ||
| 51 | # If none of these are present, every test in this module is skipped. | ||
| 52 | _TESTS_DIR = Path(__file__).resolve().parent | ||
| 53 | _DEFAULT_CANDIDATES = [ | ||
| 54 | _TESTS_DIR.parent / "recipes-containers" / "vcontainer" / "files", | ||
| 55 | Path("/opt/bruce/poky/meta-virtualization/recipes-containers/vcontainer/files"), | ||
| 56 | ] | ||
| 57 | |||
| 58 | |||
| 59 | def _find_files_dir() -> Path: | ||
| 60 | override = os.environ.get("VCONTAINER_FILES_DIR") | ||
| 61 | if override: | ||
| 62 | return Path(override) | ||
| 63 | for c in _DEFAULT_CANDIDATES: | ||
| 64 | if c.is_dir(): | ||
| 65 | return c | ||
| 66 | return _DEFAULT_CANDIDATES[0] # return first, skip in fixture if missing | ||
| 67 | |||
| 68 | |||
| 69 | @pytest.fixture(scope="module") | ||
| 70 | def files_dir() -> Path: | ||
| 71 | d = _find_files_dir() | ||
| 72 | if not d.is_dir(): | ||
| 73 | pytest.skip(f"vcontainer files/ dir not found: {d}") | ||
| 74 | return d | ||
| 75 | |||
| 76 | |||
| 77 | @pytest.fixture(scope="module") | ||
| 78 | def repo_root() -> Path: | ||
| 79 | # The vcontainer files live at <root>/recipes-containers/vcontainer/files, | ||
| 80 | # so the repo root is two levels up. | ||
| 81 | d = _find_files_dir() | ||
| 82 | return d.parent.parent.parent | ||
| 83 | |||
| 84 | |||
| 85 | @pytest.fixture(scope="module") | ||
| 86 | def vrunner_sh(files_dir: Path) -> str: | ||
| 87 | p = files_dir / "vrunner.sh" | ||
| 88 | if not p.is_file(): | ||
| 89 | pytest.skip(f"vrunner.sh not found: {p}") | ||
| 90 | return p.read_text() | ||
| 91 | |||
| 92 | |||
| 93 | @pytest.fixture(scope="module") | ||
| 94 | def vcontainer_common_sh(files_dir: Path) -> str: | ||
| 95 | p = files_dir / "vcontainer-common.sh" | ||
| 96 | if not p.is_file(): | ||
| 97 | pytest.skip(f"vcontainer-common.sh not found: {p}") | ||
| 98 | return p.read_text() | ||
| 99 | |||
| 100 | |||
| 101 | @pytest.fixture(scope="module") | ||
| 102 | def init_common_sh(files_dir: Path) -> str: | ||
| 103 | p = files_dir / "vcontainer-init-common.sh" | ||
| 104 | if not p.is_file(): | ||
| 105 | pytest.skip(f"vcontainer-init-common.sh not found: {p}") | ||
| 106 | return p.read_text() | ||
| 107 | |||
| 108 | |||
| 109 | @pytest.fixture(scope="module") | ||
| 110 | def vdkr_init_sh(files_dir: Path) -> str: | ||
| 111 | p = files_dir / "vdkr-init.sh" | ||
| 112 | if not p.is_file(): | ||
| 113 | pytest.skip(f"vdkr-init.sh not found: {p}") | ||
| 114 | return p.read_text() | ||
| 115 | |||
| 116 | |||
| 117 | @pytest.fixture(scope="module") | ||
| 118 | def vpdmn_init_sh(files_dir: Path) -> str: | ||
| 119 | p = files_dir / "vpdmn-init.sh" | ||
| 120 | if not p.is_file(): | ||
| 121 | pytest.skip(f"vpdmn-init.sh not found: {p}") | ||
| 122 | return p.read_text() | ||
| 123 | |||
| 124 | |||
| 125 | @pytest.fixture(scope="module") | ||
| 126 | def readme_md(repo_root: Path) -> str: | ||
| 127 | p = repo_root / "recipes-containers" / "vcontainer" / "README.md" | ||
| 128 | if not p.is_file(): | ||
| 129 | pytest.skip(f"vcontainer README.md not found: {p}") | ||
| 130 | return p.read_text() | ||
| 131 | |||
| 132 | |||
| 133 | # --------------------------------------------------------------------------- | ||
| 134 | # Tier 1: Static / shell-level plumbing assertions | ||
| 135 | # --------------------------------------------------------------------------- | ||
| 136 | |||
| 137 | |||
| 138 | class TestAuthConfigStaticPlumbing: | ||
| 139 | """Shell-script-level assertions for the --config / VDKR_CONFIG feature.""" | ||
| 140 | |||
| 141 | # --- vrunner.sh -------------------------------------------------------- | ||
| 142 | |||
| 143 | def test_vrunner_defines_auth_config_from_env(self, vrunner_sh): | ||
| 144 | """AUTH_CONFIG picks up $VDKR_CONFIG or $VPDMN_CONFIG by default.""" | ||
| 145 | assert re.search( | ||
| 146 | r'AUTH_CONFIG="\$\{VDKR_CONFIG:-\$\{VPDMN_CONFIG:-\}\}"', vrunner_sh | ||
| 147 | ), "vrunner.sh should initialise AUTH_CONFIG from VDKR_CONFIG/VPDMN_CONFIG" | ||
| 148 | |||
| 149 | def test_vrunner_accepts_config_flag(self, vrunner_sh): | ||
| 150 | """`--config <path>` is parsed and assigned to AUTH_CONFIG.""" | ||
| 151 | # The case label ("--config") plus the assignment should both exist. | ||
| 152 | # Allow interleaved comment lines between the label and the assignment. | ||
| 153 | assert re.search( | ||
| 154 | r'--config\)\s*\n(?:\s*#[^\n]*\n)*\s*AUTH_CONFIG="\$2"', | ||
| 155 | vrunner_sh, | ||
| 156 | ), "vrunner.sh should parse --config and set AUTH_CONFIG=\"$2\"" | ||
| 157 | |||
| 158 | def test_vrunner_defines_validate_auth_config(self, vrunner_sh): | ||
| 159 | assert "validate_auth_config()" in vrunner_sh, \ | ||
| 160 | "vrunner.sh should define validate_auth_config()" | ||
| 161 | |||
| 162 | def test_vrunner_defines_setup_auth_share(self, vrunner_sh): | ||
| 163 | assert "setup_auth_share()" in vrunner_sh, \ | ||
| 164 | "vrunner.sh should define setup_auth_share()" | ||
| 165 | |||
| 166 | def test_vrunner_validator_rejects_symlinks(self, vrunner_sh): | ||
| 167 | """Symlinks are rejected outright to block /proc/self/environ tricks.""" | ||
| 168 | assert re.search(r'if \[ -L "\$path" \]', vrunner_sh), \ | ||
| 169 | "validate_auth_config must reject symlinks with `[ -L $path ]`" | ||
| 170 | |||
| 171 | def test_vrunner_validator_requires_regular_file(self, vrunner_sh): | ||
| 172 | assert re.search(r'if \[ ! -f "\$path" \]', vrunner_sh), \ | ||
| 173 | "validate_auth_config must require a regular file (-f)" | ||
| 174 | |||
| 175 | def test_vrunner_validator_requires_readable(self, vrunner_sh): | ||
| 176 | assert re.search(r'if \[ ! -r "\$path" \]', vrunner_sh), \ | ||
| 177 | "validate_auth_config must require the file be readable (-r)" | ||
| 178 | |||
| 179 | def test_vrunner_validator_checks_missing(self, vrunner_sh): | ||
| 180 | assert re.search(r'if \[ ! -e "\$path" \]', vrunner_sh), \ | ||
| 181 | "validate_auth_config must detect missing files (-e)" | ||
| 182 | |||
| 183 | def test_vrunner_validator_min_size(self, vrunner_sh): | ||
| 184 | """Files smaller than 2 bytes (minimum "{}" JSON) are rejected.""" | ||
| 185 | assert re.search(r'size"?\s*-lt\s*2', vrunner_sh), \ | ||
| 186 | "validate_auth_config must reject files smaller than 2 bytes" | ||
| 187 | |||
| 188 | def test_vrunner_validator_max_size(self, vrunner_sh): | ||
| 189 | """Files larger than 1 MiB are rejected.""" | ||
| 190 | assert "1048576" in vrunner_sh, \ | ||
| 191 | "validate_auth_config must reject files larger than 1 MiB (1048576)" | ||
| 192 | |||
| 193 | def test_vrunner_validator_mode_whitelist(self, vrunner_sh): | ||
| 194 | """Permission modes are restricted to 400 / 600 / 200.""" | ||
| 195 | # We accept either a case statement or equivalent chain; the canonical | ||
| 196 | # form in the source is a case statement that matches these literals. | ||
| 197 | assert re.search(r'400\s*\|\s*600\s*\|\s*200', vrunner_sh), ( | ||
| 198 | "validate_auth_config must whitelist modes 400|600|200 only" | ||
| 199 | ) | ||
| 200 | |||
| 201 | def test_vrunner_validator_warns_on_wrong_owner(self, vrunner_sh): | ||
| 202 | """Non-owner files trigger a WARN but don't reject (documented).""" | ||
| 203 | assert re.search(r'WARN.*not owned by current user', vrunner_sh), \ | ||
| 204 | "validate_auth_config must WARN when file is not owned by current user" | ||
| 205 | |||
| 206 | def test_vrunner_setup_auth_share_permissions(self, vrunner_sh): | ||
| 207 | """Staging dir is 700 and staged file is 400.""" | ||
| 208 | assert "chmod 700" in vrunner_sh, \ | ||
| 209 | "setup_auth_share must chmod 700 the staging directory" | ||
| 210 | assert re.search(r'chmod 400[^\n]*config\.json', vrunner_sh), \ | ||
| 211 | "setup_auth_share must chmod 400 the staged config.json" | ||
| 212 | |||
| 213 | def test_vrunner_setup_auth_share_readonly_9p(self, vrunner_sh): | ||
| 214 | """The 9p share is created with readonly=on.""" | ||
| 215 | assert 'hv_build_9p_opts' in vrunner_sh and 'readonly=on' in vrunner_sh, ( | ||
| 216 | "setup_auth_share must pass readonly=on to hv_build_9p_opts" | ||
| 217 | ) | ||
| 218 | |||
| 219 | def test_vrunner_setup_auth_share_uses_dedicated_tag(self, vrunner_sh): | ||
| 220 | """Auth 9p tag is TOOL_NAME_auth (separate from the shared /mnt/share).""" | ||
| 221 | assert re.search(r'auth_tag="\$\{TOOL_NAME\}_auth"', vrunner_sh), ( | ||
| 222 | 'setup_auth_share must use a dedicated "${TOOL_NAME}_auth" 9p tag' | ||
| 223 | ) | ||
| 224 | |||
| 225 | def test_vrunner_auth_cmdline_is_flag_only(self, vrunner_sh): | ||
| 226 | """Only a boolean flag (_auth=1) is appended - never the path or contents.""" | ||
| 227 | # Flag is appended: | ||
| 228 | assert re.search( | ||
| 229 | r'KERNEL_APPEND="\$KERNEL_APPEND \$\{CMDLINE_PREFIX\}_auth=1"', | ||
| 230 | vrunner_sh, | ||
| 231 | ), "vrunner.sh must append `${CMDLINE_PREFIX}_auth=1` to KERNEL_APPEND" | ||
| 232 | |||
| 233 | # And the path / env var names must NEVER land in KERNEL_APPEND. | ||
| 234 | # Scan every line that mutates KERNEL_APPEND and prove none mention | ||
| 235 | # AUTH_CONFIG, VDKR_CONFIG, or VPDMN_CONFIG. | ||
| 236 | for ln in vrunner_sh.splitlines(): | ||
| 237 | if "KERNEL_APPEND=" in ln or "KERNEL_APPEND+=" in ln: | ||
| 238 | assert "AUTH_CONFIG" not in ln, ( | ||
| 239 | f"KERNEL_APPEND must not carry AUTH_CONFIG: {ln!r}" | ||
| 240 | ) | ||
| 241 | assert "VDKR_CONFIG" not in ln, ( | ||
| 242 | f"KERNEL_APPEND must not carry VDKR_CONFIG: {ln!r}" | ||
| 243 | ) | ||
| 244 | assert "VPDMN_CONFIG" not in ln, ( | ||
| 245 | f"KERNEL_APPEND must not carry VPDMN_CONFIG: {ln!r}" | ||
| 246 | ) | ||
| 247 | |||
| 248 | def test_vrunner_setup_auth_share_called_in_both_paths(self, vrunner_sh): | ||
| 249 | """setup_auth_share is called at least twice (daemon + non-daemon paths).""" | ||
| 250 | # Count *call sites*, not the definition. The definition line has a '(' right after. | ||
| 251 | call_sites = [ | ||
| 252 | ln for ln in vrunner_sh.splitlines() | ||
| 253 | if re.search(r'\bsetup_auth_share\b', ln) | ||
| 254 | and "()" not in ln | ||
| 255 | and not ln.lstrip().startswith("#") | ||
| 256 | ] | ||
| 257 | assert len(call_sites) >= 2, ( | ||
| 258 | f"setup_auth_share should be invoked in both daemon and non-daemon " | ||
| 259 | f"paths; found {len(call_sites)} call site(s): {call_sites}" | ||
| 260 | ) | ||
| 261 | |||
| 262 | # --- vcontainer-common.sh --------------------------------------------- | ||
| 263 | |||
| 264 | def test_common_inits_auth_config_from_env(self, vcontainer_common_sh): | ||
| 265 | assert re.search( | ||
| 266 | r'AUTH_CONFIG="\$\{VDKR_CONFIG:-\$\{VPDMN_CONFIG:-\}\}"', | ||
| 267 | vcontainer_common_sh, | ||
| 268 | ), "vcontainer-common.sh should init AUTH_CONFIG from VDKR_CONFIG/VPDMN_CONFIG" | ||
| 269 | |||
| 270 | def test_common_parses_config_flag(self, vcontainer_common_sh): | ||
| 271 | assert re.search( | ||
| 272 | r'--config\)\s*\n(?:\s*#[^\n]*\n)*\s*(?:#[^\n]*\n\s*)*AUTH_CONFIG="\$2"', | ||
| 273 | vcontainer_common_sh, | ||
| 274 | ), "vcontainer-common.sh should parse --config into AUTH_CONFIG" | ||
| 275 | |||
| 276 | def test_common_forwards_auth_config_to_runner(self, vcontainer_common_sh): | ||
| 277 | """AUTH_CONFIG is forwarded as --config to vrunner.sh.""" | ||
| 278 | assert re.search( | ||
| 279 | r'\[ -n "\$AUTH_CONFIG" \].*args\+=\("--config" "\$AUTH_CONFIG"\)', | ||
| 280 | vcontainer_common_sh, | ||
| 281 | ), "vcontainer-common.sh must forward AUTH_CONFIG via --config to vrunner" | ||
| 282 | |||
| 283 | def test_common_show_usage_documents_config(self, vcontainer_common_sh): | ||
| 284 | """--config appears in show_usage help output.""" | ||
| 285 | assert re.search(r'--config\s+<path>', vcontainer_common_sh), ( | ||
| 286 | "show_usage must document --config <path>" | ||
| 287 | ) | ||
| 288 | assert "VDKR_CONFIG" in vcontainer_common_sh, \ | ||
| 289 | "show_usage must mention VDKR_CONFIG env var" | ||
| 290 | assert "VPDMN_CONFIG" in vcontainer_common_sh, \ | ||
| 291 | "show_usage must mention VPDMN_CONFIG env var" | ||
| 292 | |||
| 293 | # --- vcontainer-init-common.sh ---------------------------------------- | ||
| 294 | |||
| 295 | def test_init_common_defaults_runtime_auth(self, init_common_sh): | ||
| 296 | assert re.search(r'RUNTIME_AUTH="0"', init_common_sh), \ | ||
| 297 | "init-common must default RUNTIME_AUTH to 0" | ||
| 298 | |||
| 299 | def test_init_common_parses_auth_flag(self, init_common_sh): | ||
| 300 | """Kernel cmdline <prefix>_auth=* is parsed into RUNTIME_AUTH.""" | ||
| 301 | assert re.search( | ||
| 302 | r'\$\{VCONTAINER_RUNTIME_PREFIX\}_auth=\*', init_common_sh | ||
| 303 | ), "init-common must parse ${VCONTAINER_RUNTIME_PREFIX}_auth=* cmdline arg" | ||
| 304 | assert re.search( | ||
| 305 | r'RUNTIME_AUTH="\$\{param#\$\{VCONTAINER_RUNTIME_PREFIX\}_auth=\}"', | ||
| 306 | init_common_sh, | ||
| 307 | ), "init-common must strip _auth= prefix into RUNTIME_AUTH" | ||
| 308 | |||
| 309 | def test_init_common_defines_mount_helpers(self, init_common_sh): | ||
| 310 | assert "mount_auth_share()" in init_common_sh, \ | ||
| 311 | "init-common must define mount_auth_share()" | ||
| 312 | assert "unmount_auth_share()" in init_common_sh, \ | ||
| 313 | "init-common must define unmount_auth_share()" | ||
| 314 | |||
| 315 | def test_init_common_mount_uses_dedicated_tag(self, init_common_sh): | ||
| 316 | """mount_auth_share uses ${VCONTAINER_RUNTIME_NAME}_auth tag.""" | ||
| 317 | assert re.search( | ||
| 318 | r'AUTH_SHARE_TAG="\$\{VCONTAINER_RUNTIME_NAME\}_auth"', | ||
| 319 | init_common_sh, | ||
| 320 | ), "mount_auth_share must use a per-runtime _auth 9p tag" | ||
| 321 | |||
| 322 | def test_init_common_mount_options_hardened(self, init_common_sh): | ||
| 323 | """Auth share is mounted ro,nosuid,nodev,noexec.""" | ||
| 324 | # All four options must be present on the mount command. | ||
| 325 | # Find the mount call to be sure we're looking at the right line. | ||
| 326 | m = re.search( | ||
| 327 | r'mount -t 9p[^\n]*\\\n[^\n]*trans=\$\{NINE_P_TRANSPORT\}[^\n]*', | ||
| 328 | init_common_sh, | ||
| 329 | ) | ||
| 330 | assert m, "mount_auth_share must issue a mount -t 9p call" | ||
| 331 | # The options are on the continuation line; grab the paragraph. | ||
| 332 | start = m.start() | ||
| 333 | end = init_common_sh.find('"$AUTH_SHARE_TAG"', start) | ||
| 334 | block = init_common_sh[start:end if end != -1 else start + 400] | ||
| 335 | for opt in ("ro", "nosuid", "nodev", "noexec"): | ||
| 336 | assert opt in block, f"mount_auth_share must include {opt} mount option" | ||
| 337 | |||
| 338 | def test_init_common_mount_guarded_by_runtime_auth(self, init_common_sh): | ||
| 339 | """mount_auth_share returns early when RUNTIME_AUTH != 1.""" | ||
| 340 | # Find "mount_auth_share()" and assert the first ~15 lines contain the guard. | ||
| 341 | idx = init_common_sh.find("mount_auth_share()") | ||
| 342 | assert idx != -1 | ||
| 343 | snippet = init_common_sh[idx:idx + 400] | ||
| 344 | assert re.search(r'if \[ "\$RUNTIME_AUTH" != "1" \]', snippet), ( | ||
| 345 | "mount_auth_share must early-return when RUNTIME_AUTH != 1" | ||
| 346 | ) | ||
| 347 | |||
| 348 | # --- vdkr-init.sh ------------------------------------------------------ | ||
| 349 | |||
| 350 | def test_vdkr_defines_install_auth_config(self, vdkr_init_sh): | ||
| 351 | assert "install_auth_config()" in vdkr_init_sh, \ | ||
| 352 | "vdkr-init.sh must define install_auth_config()" | ||
| 353 | |||
| 354 | def test_vdkr_target_path_and_modes(self, vdkr_init_sh): | ||
| 355 | """Target is /root/.docker/config.json; mode 0600; parent 0700.""" | ||
| 356 | assert "/root/.docker/config.json" in vdkr_init_sh, ( | ||
| 357 | "vdkr-init must write credentials to /root/.docker/config.json" | ||
| 358 | ) | ||
| 359 | assert "chmod 700 /root/.docker" in vdkr_init_sh, \ | ||
| 360 | "vdkr-init must chmod 700 /root/.docker" | ||
| 361 | assert "chmod 600 /root/.docker/config.json" in vdkr_init_sh, \ | ||
| 362 | "vdkr-init must chmod 600 /root/.docker/config.json" | ||
| 363 | |||
| 364 | def test_vdkr_calls_mount_and_unmount(self, vdkr_init_sh): | ||
| 365 | assert "mount_auth_share" in vdkr_init_sh | ||
| 366 | assert "unmount_auth_share" in vdkr_init_sh, ( | ||
| 367 | "vdkr-init must unmount /mnt/auth after copying" | ||
| 368 | ) | ||
| 369 | |||
| 370 | def test_vdkr_logs_precedence_note(self, vdkr_init_sh): | ||
| 371 | """When --config and --registry-user/--registry-pass are both set, log a NOTE.""" | ||
| 372 | assert re.search( | ||
| 373 | r'NOTE:\s*--config\s*takes precedence over\s*--registry-user/--registry-pass', | ||
| 374 | vdkr_init_sh, | ||
| 375 | ), "vdkr-init must log a precedence NOTE when both mechanisms are supplied" | ||
| 376 | |||
| 377 | def test_vdkr_install_auth_config_after_ca(self, vdkr_init_sh): | ||
| 378 | """install_auth_config runs after install_registry_ca in main flow.""" | ||
| 379 | # Find the call sites (not the definitions). Each name should appear | ||
| 380 | # at least once at column 0 (bare call) after the function bodies. | ||
| 381 | # A simpler, resilient check: the LAST occurrence of install_registry_ca | ||
| 382 | # should appear before the LAST occurrence of install_auth_config. | ||
| 383 | last_ca = vdkr_init_sh.rfind("install_registry_ca") | ||
| 384 | last_auth = vdkr_init_sh.rfind("install_auth_config") | ||
| 385 | assert last_ca != -1 and last_auth != -1 | ||
| 386 | assert last_ca < last_auth, ( | ||
| 387 | "install_auth_config must be called AFTER install_registry_ca " | ||
| 388 | "so --config wins on precedence" | ||
| 389 | ) | ||
| 390 | |||
| 391 | # --- vpdmn-init.sh ----------------------------------------------------- | ||
| 392 | |||
| 393 | def test_vpdmn_defines_install_auth_config(self, vpdmn_init_sh): | ||
| 394 | assert "install_auth_config()" in vpdmn_init_sh, \ | ||
| 395 | "vpdmn-init.sh must define install_auth_config()" | ||
| 396 | |||
| 397 | def test_vpdmn_target_path_and_modes(self, vpdmn_init_sh): | ||
| 398 | """Target is /run/containers/0/auth.json with 0600; dir 0700.""" | ||
| 399 | assert "/run/containers/0" in vpdmn_init_sh, \ | ||
| 400 | "vpdmn-init must write to /run/containers/0 (rootful podman default)" | ||
| 401 | assert re.search(r'auth_file="\$auth_dir/auth\.json"', vpdmn_init_sh), \ | ||
| 402 | "vpdmn-init must write to .../auth.json" | ||
| 403 | assert re.search(r'chmod 700 "\$auth_dir"', vpdmn_init_sh), \ | ||
| 404 | "vpdmn-init must chmod 700 the auth dir" | ||
| 405 | assert re.search(r'chmod 600 "\$auth_file"', vpdmn_init_sh), \ | ||
| 406 | "vpdmn-init must chmod 600 the auth.json" | ||
| 407 | |||
| 408 | def test_vpdmn_exports_registry_auth_file(self, vpdmn_init_sh): | ||
| 409 | """REGISTRY_AUTH_FILE is exported so podman finds the creds.""" | ||
| 410 | assert re.search(r'export REGISTRY_AUTH_FILE="\$auth_file"', vpdmn_init_sh), ( | ||
| 411 | "vpdmn-init must export REGISTRY_AUTH_FILE" | ||
| 412 | ) | ||
| 413 | |||
| 414 | def test_vpdmn_calls_mount_and_unmount(self, vpdmn_init_sh): | ||
| 415 | assert "mount_auth_share" in vpdmn_init_sh | ||
| 416 | assert "unmount_auth_share" in vpdmn_init_sh, ( | ||
| 417 | "vpdmn-init must unmount /mnt/auth after copying" | ||
| 418 | ) | ||
| 419 | |||
| 420 | def test_vpdmn_install_auth_config_after_verify_podman(self, vpdmn_init_sh): | ||
| 421 | """install_auth_config runs after verify_podman in the main flow.""" | ||
| 422 | last_verify = vpdmn_init_sh.rfind("verify_podman") | ||
| 423 | last_auth = vpdmn_init_sh.rfind("install_auth_config") | ||
| 424 | assert last_verify != -1 and last_auth != -1 | ||
| 425 | assert last_verify < last_auth, ( | ||
| 426 | "install_auth_config should be called AFTER verify_podman" | ||
| 427 | ) | ||
| 428 | |||
| 429 | # --- README.md --------------------------------------------------------- | ||
| 430 | |||
| 431 | def test_readme_documents_config_section(self, readme_md): | ||
| 432 | assert "Passing an existing docker/podman auth file" in readme_md, ( | ||
| 433 | "README must document the --config feature" | ||
| 434 | ) | ||
| 435 | |||
| 436 | def test_readme_lists_env_vars(self, readme_md): | ||
| 437 | assert "VDKR_CONFIG" in readme_md, "README must document VDKR_CONFIG" | ||
| 438 | assert "VPDMN_CONFIG" in readme_md, "README must document VPDMN_CONFIG" | ||
| 439 | |||
| 440 | def test_readme_lists_target_paths(self, readme_md): | ||
| 441 | """Both runtime target paths appear in the doc.""" | ||
| 442 | assert "/root/.docker/config.json" in readme_md, \ | ||
| 443 | "README must document the vdkr target path" | ||
| 444 | assert "/run/containers/0/auth.json" in readme_md, \ | ||
| 445 | "README must document the vpdmn target path" | ||
| 446 | |||
| 447 | |||
| 448 | # --------------------------------------------------------------------------- | ||
| 449 | # Tier 2: Functional validator tests (bash subshell, no QEMU). | ||
| 450 | # --------------------------------------------------------------------------- | ||
| 451 | |||
| 452 | |||
| 453 | def _extract_validate_auth_config(vrunner_text: str) -> str: | ||
| 454 | """Extract the validate_auth_config function body from vrunner.sh. | ||
| 455 | |||
| 456 | Parses from "validate_auth_config() {" to its matching top-level closing | ||
| 457 | brace. Simple brace-counting suffices because the function body only | ||
| 458 | contains shell constructs (no here-docs that start with '{'). | ||
| 459 | """ | ||
| 460 | start = vrunner_text.find("validate_auth_config()") | ||
| 461 | assert start != -1, "validate_auth_config not found in vrunner.sh" | ||
| 462 | # Jump to the opening brace of the function. | ||
| 463 | brace = vrunner_text.find("{", start) | ||
| 464 | assert brace != -1 | ||
| 465 | depth = 0 | ||
| 466 | i = brace | ||
| 467 | n = len(vrunner_text) | ||
| 468 | while i < n: | ||
| 469 | ch = vrunner_text[i] | ||
| 470 | if ch == "{": | ||
| 471 | depth += 1 | ||
| 472 | elif ch == "}": | ||
| 473 | depth -= 1 | ||
| 474 | if depth == 0: | ||
| 475 | return vrunner_text[start : i + 1] | ||
| 476 | i += 1 | ||
| 477 | raise AssertionError("Unterminated validate_auth_config definition") | ||
| 478 | |||
| 479 | |||
| 480 | @pytest.fixture(scope="module") | ||
| 481 | def validator_harness(vrunner_sh, tmp_path_factory) -> Path: | ||
| 482 | """Create a tiny bash script that sources validate_auth_config + runs it. | ||
| 483 | |||
| 484 | The harness is parameterised by $1 = path argument. It prints validator | ||
| 485 | output to stderr (as vrunner does) and exits with the validator's code. | ||
| 486 | """ | ||
| 487 | body = _extract_validate_auth_config(vrunner_sh) | ||
| 488 | harness = textwrap.dedent( | ||
| 489 | """\ | ||
| 490 | #!/usr/bin/env bash | ||
| 491 | # Test harness for validate_auth_config (extracted from vrunner.sh). | ||
| 492 | |||
| 493 | # Stub the log() helper used by validate_auth_config. Route everything | ||
| 494 | # to stderr so the test can grep on captured stderr. | ||
| 495 | log() { | ||
| 496 | local level="$1" | ||
| 497 | shift | ||
| 498 | echo "[$level] $*" 1>&2 | ||
| 499 | } | ||
| 500 | |||
| 501 | %s | ||
| 502 | |||
| 503 | validate_auth_config "$1" | ||
| 504 | exit $? | ||
| 505 | """ | ||
| 506 | ) % body | ||
| 507 | |||
| 508 | out = tmp_path_factory.mktemp("auth_validator") / "harness.sh" | ||
| 509 | out.write_text(harness) | ||
| 510 | out.chmod(0o700) | ||
| 511 | return out | ||
| 512 | |||
| 513 | |||
| 514 | def _run_validator(harness: Path, path_arg: str) -> subprocess.CompletedProcess: | ||
| 515 | return subprocess.run( | ||
| 516 | ["bash", str(harness), path_arg], | ||
| 517 | capture_output=True, | ||
| 518 | text=True, | ||
| 519 | timeout=10, | ||
| 520 | ) | ||
| 521 | |||
| 522 | |||
| 523 | class TestAuthConfigValidator: | ||
| 524 | """Functional tests for validate_auth_config() in vrunner.sh.""" | ||
| 525 | |||
| 526 | def test_accepts_valid_mode_600(self, validator_harness, tmp_path): | ||
| 527 | f = tmp_path / "config.json" | ||
| 528 | f.write_text('{"auths":{}}') | ||
| 529 | os.chmod(f, 0o600) | ||
| 530 | r = _run_validator(validator_harness, str(f)) | ||
| 531 | assert r.returncode == 0, f"expected accept, got {r.returncode}\nstderr={r.stderr}" | ||
| 532 | |||
| 533 | def test_accepts_valid_mode_400(self, validator_harness, tmp_path): | ||
| 534 | f = tmp_path / "config.json" | ||
| 535 | f.write_text('{"auths":{}}') | ||
| 536 | os.chmod(f, 0o400) | ||
| 537 | r = _run_validator(validator_harness, str(f)) | ||
| 538 | assert r.returncode == 0, f"expected accept, got {r.returncode}\nstderr={r.stderr}" | ||
| 539 | |||
| 540 | def test_accepts_minimum_two_byte_json(self, validator_harness, tmp_path): | ||
| 541 | """A 2-byte file ('{}' with no trailing newline) is the minimum valid size.""" | ||
| 542 | f = tmp_path / "config.json" | ||
| 543 | f.write_bytes(b"{}") | ||
| 544 | os.chmod(f, 0o600) | ||
| 545 | r = _run_validator(validator_harness, str(f)) | ||
| 546 | assert r.returncode == 0, ( | ||
| 547 | f"expected accept for 2-byte file, got {r.returncode}\nstderr={r.stderr}" | ||
| 548 | ) | ||
| 549 | |||
| 550 | def test_rejects_missing_file(self, validator_harness, tmp_path): | ||
| 551 | r = _run_validator(validator_harness, str(tmp_path / "no-such-file")) | ||
| 552 | assert r.returncode != 0 | ||
| 553 | assert "not found" in r.stderr | ||
| 554 | |||
| 555 | def test_rejects_symlink(self, validator_harness, tmp_path): | ||
| 556 | target = tmp_path / "real.json" | ||
| 557 | target.write_text('{"auths":{}}') | ||
| 558 | os.chmod(target, 0o600) | ||
| 559 | link = tmp_path / "link.json" | ||
| 560 | link.symlink_to(target) | ||
| 561 | r = _run_validator(validator_harness, str(link)) | ||
| 562 | assert r.returncode != 0 | ||
| 563 | assert "symlink" in r.stderr | ||
| 564 | |||
| 565 | def test_rejects_directory(self, validator_harness, tmp_path): | ||
| 566 | d = tmp_path / "adir" | ||
| 567 | d.mkdir() | ||
| 568 | r = _run_validator(validator_harness, str(d)) | ||
| 569 | assert r.returncode != 0 | ||
| 570 | # Directories trip the -L check first on some shells; either error is fine. | ||
| 571 | assert "regular file" in r.stderr or "not readable" in r.stderr or "symlink" not in r.stderr | ||
| 572 | |||
| 573 | def test_rejects_empty_file(self, validator_harness, tmp_path): | ||
| 574 | f = tmp_path / "empty.json" | ||
| 575 | f.write_bytes(b"") | ||
| 576 | os.chmod(f, 0o600) | ||
| 577 | r = _run_validator(validator_harness, str(f)) | ||
| 578 | assert r.returncode != 0 | ||
| 579 | assert "empty or too small" in r.stderr | ||
| 580 | |||
| 581 | def test_rejects_one_byte_file(self, validator_harness, tmp_path): | ||
| 582 | """A single-byte file (e.g. lone newline from 'echo > file') is rejected.""" | ||
| 583 | f = tmp_path / "tiny.json" | ||
| 584 | f.write_bytes(b"\n") | ||
| 585 | os.chmod(f, 0o600) | ||
| 586 | r = _run_validator(validator_harness, str(f)) | ||
| 587 | assert r.returncode != 0 | ||
| 588 | assert "empty or too small" in r.stderr | ||
| 589 | |||
| 590 | def test_rejects_oversize_file(self, validator_harness, tmp_path): | ||
| 591 | """Files > 1 MiB are rejected.""" | ||
| 592 | f = tmp_path / "big.json" | ||
| 593 | # 1 MiB + 1 byte. | ||
| 594 | f.write_bytes(b"{" + b"a" * (1024 * 1024) + b"}") | ||
| 595 | os.chmod(f, 0o600) | ||
| 596 | r = _run_validator(validator_harness, str(f)) | ||
| 597 | assert r.returncode != 0 | ||
| 598 | assert "too large" in r.stderr | ||
| 599 | |||
| 600 | def test_rejects_world_readable(self, validator_harness, tmp_path): | ||
| 601 | """Mode 0644 (group/other readable) is rejected.""" | ||
| 602 | f = tmp_path / "config.json" | ||
| 603 | f.write_text('{"auths":{}}') | ||
| 604 | os.chmod(f, 0o644) | ||
| 605 | r = _run_validator(validator_harness, str(f)) | ||
| 606 | assert r.returncode != 0 | ||
| 607 | assert "unsafe permissions" in r.stderr | ||
| 608 | |||
| 609 | def test_rejects_group_readable(self, validator_harness, tmp_path): | ||
| 610 | """Mode 0640 (group readable) is rejected.""" | ||
| 611 | f = tmp_path / "config.json" | ||
| 612 | f.write_text('{"auths":{}}') | ||
| 613 | os.chmod(f, 0o640) | ||
| 614 | r = _run_validator(validator_harness, str(f)) | ||
| 615 | assert r.returncode != 0 | ||
| 616 | assert "unsafe permissions" in r.stderr | ||
| 617 | |||
| 618 | def test_rejects_executable(self, validator_harness, tmp_path): | ||
| 619 | """Mode 0700 (owner-exec) is rejected - we only permit r/w combos.""" | ||
| 620 | f = tmp_path / "config.json" | ||
| 621 | f.write_text('{"auths":{}}') | ||
| 622 | os.chmod(f, 0o700) | ||
| 623 | r = _run_validator(validator_harness, str(f)) | ||
| 624 | assert r.returncode != 0 | ||
| 625 | assert "unsafe permissions" in r.stderr | ||
| 626 | |||
| 627 | def test_rejects_unreadable(self, validator_harness, tmp_path): | ||
| 628 | """A mode 0000 file cannot be read by the invoking user.""" | ||
| 629 | if os.geteuid() == 0: | ||
| 630 | pytest.skip("running as root; DAC permission checks are bypassed") | ||
| 631 | f = tmp_path / "config.json" | ||
| 632 | f.write_text('{"auths":{}}') | ||
| 633 | # 0000: no bits at all. | ||
| 634 | os.chmod(f, 0o000) | ||
| 635 | try: | ||
| 636 | r = _run_validator(validator_harness, str(f)) | ||
| 637 | assert r.returncode != 0 | ||
| 638 | # Either "not readable" wins, or the mode-check fires; accept either. | ||
| 639 | assert "not readable" in r.stderr or "unsafe permissions" in r.stderr | ||
| 640 | finally: | ||
| 641 | # Restore perms so pytest can clean up the tmp tree. | ||
| 642 | os.chmod(f, 0o600) | ||
diff --git a/tests/test_vcontainer_distro.py b/tests/test_vcontainer_distro.py new file mode 100644 index 00000000..49c6deb0 --- /dev/null +++ b/tests/test_vcontainer_distro.py | |||
| @@ -0,0 +1,532 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Tests for vcontainer distro and multi-arch OCI container build infrastructure. | ||
| 6 | |||
| 7 | Verifies: | ||
| 8 | - vruntime-base.inc shared base exists and has correct content | ||
| 9 | - vcontainer.conf distro uses shared base without BBMASK | ||
| 10 | - vruntime.conf uses shared base with BBMASK (no regression) | ||
| 11 | - Container multiconfig files exist and reference vcontainer distro | ||
| 12 | - oci-multiarch.bbclass defaults point to container-* multiconfigs | ||
| 13 | - container-base-multiarch.bb recipe wires up correctly | ||
| 14 | - meta-virt-host.conf BBMULTICONFIG includes container-* entries | ||
| 15 | - Built OCI output has valid multi-arch structure (when available) | ||
| 16 | |||
| 17 | Run with: | ||
| 18 | pytest tests/test_vcontainer_distro.py -v --poky-dir /opt/bruce/poky | ||
| 19 | |||
| 20 | Build-dependent tests (marked @pytest.mark.slow) require: | ||
| 21 | bitbake container-base-multiarch | ||
| 22 | """ | ||
| 23 | |||
| 24 | import json | ||
| 25 | import os | ||
| 26 | import pytest | ||
| 27 | from pathlib import Path | ||
| 28 | |||
| 29 | |||
| 30 | @pytest.fixture(scope="module") | ||
| 31 | def meta_virt_dir(request): | ||
| 32 | """Path to meta-virtualization layer.""" | ||
| 33 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 34 | path = poky_dir / "meta-virtualization" | ||
| 35 | if not path.exists(): | ||
| 36 | pytest.skip(f"meta-virtualization not found: {path}") | ||
| 37 | return path | ||
| 38 | |||
| 39 | |||
| 40 | @pytest.fixture(scope="module") | ||
| 41 | def build_dir(request): | ||
| 42 | """Path to build directory.""" | ||
| 43 | bd = request.config.getoption("--build-dir") | ||
| 44 | if bd: | ||
| 45 | return Path(bd) | ||
| 46 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 47 | return poky_dir / "build" | ||
| 48 | |||
| 49 | |||
| 50 | # ============================================================================= | ||
| 51 | # Tier 1: Static file assertions (no bitbake required) | ||
| 52 | # ============================================================================= | ||
| 53 | |||
| 54 | class TestVruntimeBaseInc: | ||
| 55 | """Test the shared vruntime-base.inc fragment.""" | ||
| 56 | |||
| 57 | def test_exists(self, meta_virt_dir): | ||
| 58 | path = meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc" | ||
| 59 | assert path.exists(), f"Missing: {path}" | ||
| 60 | |||
| 61 | def test_requires_poky(self, meta_virt_dir): | ||
| 62 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc").read_text() | ||
| 63 | assert "require conf/distro/poky.conf" in content | ||
| 64 | |||
| 65 | def test_sets_distro_features(self, meta_virt_dir): | ||
| 66 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc").read_text() | ||
| 67 | assert 'DISTRO_FEATURES = "' in content | ||
| 68 | assert "vcontainer" in content | ||
| 69 | assert "seccomp" in content | ||
| 70 | |||
| 71 | def test_opts_out_features(self, meta_virt_dir): | ||
| 72 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc").read_text() | ||
| 73 | assert "DISTRO_FEATURES_OPTED_OUT" in content | ||
| 74 | assert "pulseaudio" in content | ||
| 75 | assert "wayland" in content | ||
| 76 | |||
| 77 | def test_native_class_overrides(self, meta_virt_dir): | ||
| 78 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc").read_text() | ||
| 79 | assert "DISTRO_FEATURES:class-native" in content | ||
| 80 | assert "DISTRO_FEATURES:class-nativesdk" in content | ||
| 81 | |||
| 82 | def test_does_not_set_distro_name(self, meta_virt_dir): | ||
| 83 | """Shared base must NOT set DISTRO or DISTRO_NAME — consumers do that.""" | ||
| 84 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc").read_text() | ||
| 85 | for line in content.splitlines(): | ||
| 86 | stripped = line.strip() | ||
| 87 | if stripped.startswith("#"): | ||
| 88 | continue | ||
| 89 | assert not stripped.startswith("DISTRO ="), "vruntime-base.inc must not set DISTRO" | ||
| 90 | assert not stripped.startswith("DISTRO_NAME"), "vruntime-base.inc must not set DISTRO_NAME" | ||
| 91 | |||
| 92 | def test_does_not_include_bbmask(self, meta_virt_dir): | ||
| 93 | """Shared base must NOT include BBMASK files.""" | ||
| 94 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vruntime-base.inc").read_text() | ||
| 95 | assert "bbmask" not in content.lower() | ||
| 96 | |||
| 97 | |||
| 98 | class TestVcontainerConf: | ||
| 99 | """Test the vcontainer OCI builder distro.""" | ||
| 100 | |||
| 101 | def test_exists(self, meta_virt_dir): | ||
| 102 | path = meta_virt_dir / "conf" / "distro" / "vcontainer.conf" | ||
| 103 | assert path.exists(), f"Missing: {path}" | ||
| 104 | |||
| 105 | def test_requires_shared_base(self, meta_virt_dir): | ||
| 106 | content = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 107 | assert "require conf/distro/include/vruntime-base.inc" in content | ||
| 108 | |||
| 109 | def test_sets_distro(self, meta_virt_dir): | ||
| 110 | content = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 111 | assert 'DISTRO = "vcontainer"' in content | ||
| 112 | |||
| 113 | def test_has_lighter_bbmask(self, meta_virt_dir): | ||
| 114 | """vcontainer uses its own lighter BBMASK, not vruntime's.""" | ||
| 115 | content = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 116 | assert "vcontainer-bbmask.inc" in content | ||
| 117 | # Must NOT use vruntime's BBMASK (it blocks OCI tooling) | ||
| 118 | for line in content.splitlines(): | ||
| 119 | stripped = line.strip() | ||
| 120 | if stripped.startswith("#"): | ||
| 121 | continue | ||
| 122 | assert "vruntime-bbmask.inc" not in stripped, \ | ||
| 123 | "vcontainer must not use vruntime-bbmask.inc (blocks OCI tooling)" | ||
| 124 | |||
| 125 | def test_no_image_fstypes(self, meta_virt_dir): | ||
| 126 | """vcontainer must NOT set IMAGE_FSTYPES -- 'oci' type needs image-oci.bbclass | ||
| 127 | which only container recipes inherit. Setting it distro-wide breaks other images.""" | ||
| 128 | content = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 129 | for line in content.splitlines(): | ||
| 130 | stripped = line.strip() | ||
| 131 | if stripped.startswith("#"): | ||
| 132 | continue | ||
| 133 | assert not stripped.startswith("IMAGE_FSTYPES"), \ | ||
| 134 | "IMAGE_FSTYPES must not be set in vcontainer.conf (breaks non-container images)" | ||
| 135 | |||
| 136 | def test_does_not_duplicate_base_settings(self, meta_virt_dir): | ||
| 137 | """vcontainer should not re-declare settings already in vruntime-base.inc.""" | ||
| 138 | content = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 139 | for line in content.splitlines(): | ||
| 140 | stripped = line.strip() | ||
| 141 | if stripped.startswith("#"): | ||
| 142 | continue | ||
| 143 | assert not stripped.startswith("DISTRO_FEATURES ="), \ | ||
| 144 | "DISTRO_FEATURES belongs in vruntime-base.inc" | ||
| 145 | assert not stripped.startswith("DISTRO_FEATURES_OPTED_OUT"), \ | ||
| 146 | "DISTRO_FEATURES_OPTED_OUT belongs in vruntime-base.inc" | ||
| 147 | |||
| 148 | |||
| 149 | class TestVcontainerBbmask: | ||
| 150 | """Test the vcontainer-bbmask.inc keeps OCI tooling available.""" | ||
| 151 | |||
| 152 | def test_exists(self, meta_virt_dir): | ||
| 153 | path = meta_virt_dir / "conf" / "distro" / "include" / "vcontainer-bbmask.inc" | ||
| 154 | assert path.exists() | ||
| 155 | |||
| 156 | def test_masks_graphics(self, meta_virt_dir): | ||
| 157 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vcontainer-bbmask.inc").read_text() | ||
| 158 | assert "recipes-graphics" in content | ||
| 159 | assert "recipes-sato" in content | ||
| 160 | |||
| 161 | def test_masks_virtualization_platforms(self, meta_virt_dir): | ||
| 162 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vcontainer-bbmask.inc").read_text() | ||
| 163 | assert "recipes-extended/xen/" in content | ||
| 164 | assert "recipes-extended/libvirt/" in content | ||
| 165 | |||
| 166 | def test_does_not_mask_oci_tooling(self, meta_virt_dir): | ||
| 167 | """OCI tooling must NOT be masked — this is the key difference from vruntime.""" | ||
| 168 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vcontainer-bbmask.inc").read_text() | ||
| 169 | # Check non-comment lines for things that must stay unmasked | ||
| 170 | for line in content.splitlines(): | ||
| 171 | stripped = line.strip() | ||
| 172 | if stripped.startswith("#"): | ||
| 173 | continue | ||
| 174 | assert "recipes-containers/umoci/" not in stripped, "umoci must not be masked" | ||
| 175 | assert "recipes-containers/container-registry/" not in stripped, "container-registry must not be masked" | ||
| 176 | assert "recipes-extended/images/" not in stripped, "container image recipes must not be masked" | ||
| 177 | assert "recipes-containers/oci-image-tools/" not in stripped, "oci-image-tools must not be masked" | ||
| 178 | assert "recipes-containers/sloci-image/" not in stripped, "sloci-image must not be masked" | ||
| 179 | |||
| 180 | def test_masks_orchestration(self, meta_virt_dir): | ||
| 181 | content = (meta_virt_dir / "conf" / "distro" / "include" / "vcontainer-bbmask.inc").read_text() | ||
| 182 | assert "recipes-containers/kubernetes/" in content | ||
| 183 | assert "recipes-containers/k3s/" in content | ||
| 184 | |||
| 185 | |||
| 186 | class TestVruntimeConfRefactored: | ||
| 187 | """Test that vruntime.conf still works after refactoring.""" | ||
| 188 | |||
| 189 | def test_requires_shared_base(self, meta_virt_dir): | ||
| 190 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 191 | assert "require conf/distro/include/vruntime-base.inc" in content | ||
| 192 | |||
| 193 | def test_still_has_bbmask(self, meta_virt_dir): | ||
| 194 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 195 | assert "vruntime-bbmask.inc" in content | ||
| 196 | assert "vruntime-bbmask-oe-core.inc" in content | ||
| 197 | assert "vruntime-bbmask-meta-oe.inc" in content | ||
| 198 | |||
| 199 | def test_sets_distro_vruntime(self, meta_virt_dir): | ||
| 200 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 201 | assert 'DISTRO = "vruntime"' in content | ||
| 202 | |||
| 203 | def test_no_direct_poky_require(self, meta_virt_dir): | ||
| 204 | """vruntime.conf should get poky.conf via vruntime-base.inc, not directly.""" | ||
| 205 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 206 | for line in content.splitlines(): | ||
| 207 | stripped = line.strip() | ||
| 208 | if stripped.startswith("#"): | ||
| 209 | continue | ||
| 210 | assert "require conf/distro/poky.conf" not in stripped, \ | ||
| 211 | "vruntime.conf should not directly require poky.conf (it comes via vruntime-base.inc)" | ||
| 212 | |||
| 213 | def test_does_not_duplicate_base_settings(self, meta_virt_dir): | ||
| 214 | """vruntime.conf should not re-declare settings already in vruntime-base.inc.""" | ||
| 215 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 216 | for line in content.splitlines(): | ||
| 217 | stripped = line.strip() | ||
| 218 | if stripped.startswith("#"): | ||
| 219 | continue | ||
| 220 | assert not stripped.startswith("DISTRO_FEATURES ="), \ | ||
| 221 | "DISTRO_FEATURES belongs in vruntime-base.inc, not vruntime.conf" | ||
| 222 | assert not stripped.startswith("DISTRO_FEATURES_OPTED_OUT"), \ | ||
| 223 | "DISTRO_FEATURES_OPTED_OUT belongs in vruntime-base.inc" | ||
| 224 | |||
| 225 | def test_busybox_init(self, meta_virt_dir): | ||
| 226 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 227 | assert "busybox" in content | ||
| 228 | |||
| 229 | def test_ptest_glib_disabled(self, meta_virt_dir): | ||
| 230 | content = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 231 | assert 'PTEST_ENABLED:pn-glib-2.0 = ""' in content | ||
| 232 | |||
| 233 | |||
| 234 | class TestContainerMulticonfigs: | ||
| 235 | """Test container multiconfig files.""" | ||
| 236 | |||
| 237 | def test_container_aarch64_exists(self, meta_virt_dir): | ||
| 238 | path = meta_virt_dir / "conf" / "multiconfig" / "container-aarch64.conf" | ||
| 239 | assert path.exists(), f"Missing: {path}" | ||
| 240 | |||
| 241 | def test_container_x86_64_exists(self, meta_virt_dir): | ||
| 242 | path = meta_virt_dir / "conf" / "multiconfig" / "container-x86-64.conf" | ||
| 243 | assert path.exists(), f"Missing: {path}" | ||
| 244 | |||
| 245 | def test_old_container_conf_removed(self, meta_virt_dir): | ||
| 246 | path = meta_virt_dir / "conf" / "multiconfig" / "container.conf" | ||
| 247 | assert not path.exists(), f"Stale placeholder should be removed: {path}" | ||
| 248 | |||
| 249 | def test_aarch64_uses_vcontainer(self, meta_virt_dir): | ||
| 250 | content = (meta_virt_dir / "conf" / "multiconfig" / "container-aarch64.conf").read_text() | ||
| 251 | assert 'DISTRO = "vcontainer"' in content | ||
| 252 | assert 'MACHINE = "qemuarm64"' in content | ||
| 253 | |||
| 254 | def test_x86_64_uses_vcontainer(self, meta_virt_dir): | ||
| 255 | content = (meta_virt_dir / "conf" / "multiconfig" / "container-x86-64.conf").read_text() | ||
| 256 | assert 'DISTRO = "vcontainer"' in content | ||
| 257 | assert 'MACHINE = "qemux86-64"' in content | ||
| 258 | |||
| 259 | def test_aarch64_has_separate_tmpdir(self, meta_virt_dir): | ||
| 260 | content = (meta_virt_dir / "conf" / "multiconfig" / "container-aarch64.conf").read_text() | ||
| 261 | assert "tmp-container-aarch64" in content | ||
| 262 | |||
| 263 | def test_x86_64_has_separate_tmpdir(self, meta_virt_dir): | ||
| 264 | content = (meta_virt_dir / "conf" / "multiconfig" / "container-x86-64.conf").read_text() | ||
| 265 | assert "tmp-container-x86-64" in content | ||
| 266 | |||
| 267 | |||
| 268 | class TestOCIMultiarchClassUpdated: | ||
| 269 | """Test oci-multiarch.bbclass points to container-* multiconfigs.""" | ||
| 270 | |||
| 271 | def test_default_mc_aarch64(self, meta_virt_dir): | ||
| 272 | content = (meta_virt_dir / "classes" / "oci-multiarch.bbclass").read_text() | ||
| 273 | assert 'OCI_MULTIARCH_MC[aarch64] ?= "container-aarch64"' in content | ||
| 274 | |||
| 275 | def test_default_mc_x86_64(self, meta_virt_dir): | ||
| 276 | content = (meta_virt_dir / "classes" / "oci-multiarch.bbclass").read_text() | ||
| 277 | assert 'OCI_MULTIARCH_MC[x86_64] ?= "container-x86-64"' in content | ||
| 278 | |||
| 279 | def test_no_vruntime_mc_defaults(self, meta_virt_dir): | ||
| 280 | """oci-multiarch defaults must NOT reference vruntime-* (BBMASK blocks OCI tools).""" | ||
| 281 | content = (meta_virt_dir / "classes" / "oci-multiarch.bbclass").read_text() | ||
| 282 | # Check non-comment lines | ||
| 283 | for line in content.splitlines(): | ||
| 284 | stripped = line.strip() | ||
| 285 | if stripped.startswith("#"): | ||
| 286 | continue | ||
| 287 | if "OCI_MULTIARCH_MC" in stripped and "?=" in stripped: | ||
| 288 | assert "vruntime" not in stripped, \ | ||
| 289 | f"OCI MC default must not reference vruntime: {stripped}" | ||
| 290 | |||
| 291 | |||
| 292 | class TestMetaVirtHostConf: | ||
| 293 | """Test meta-virt-host.conf includes container multiconfigs.""" | ||
| 294 | |||
| 295 | def test_bbmulticonfig_has_container_mcs(self, meta_virt_dir): | ||
| 296 | content = (meta_virt_dir / "conf" / "distro" / "include" / "meta-virt-host.conf").read_text() | ||
| 297 | assert "container-aarch64" in content | ||
| 298 | assert "container-x86-64" in content | ||
| 299 | |||
| 300 | def test_bbmulticonfig_still_has_vruntime(self, meta_virt_dir): | ||
| 301 | """Existing vruntime MCs must not be removed.""" | ||
| 302 | content = (meta_virt_dir / "conf" / "distro" / "include" / "meta-virt-host.conf").read_text() | ||
| 303 | assert "vruntime-aarch64" in content | ||
| 304 | assert "vruntime-x86-64" in content | ||
| 305 | |||
| 306 | |||
| 307 | class TestContainerBaseMultiarchRecipe: | ||
| 308 | """Test container-base-multiarch.bb recipe.""" | ||
| 309 | |||
| 310 | def test_exists(self, meta_virt_dir): | ||
| 311 | path = meta_virt_dir / "recipes-extended" / "images" / "container-base-multiarch.bb" | ||
| 312 | assert path.exists(), f"Missing: {path}" | ||
| 313 | |||
| 314 | def test_inherits_oci_multiarch(self, meta_virt_dir): | ||
| 315 | content = (meta_virt_dir / "recipes-extended" / "images" / "container-base-multiarch.bb").read_text() | ||
| 316 | assert "inherit oci-multiarch" in content | ||
| 317 | |||
| 318 | def test_recipe_target(self, meta_virt_dir): | ||
| 319 | content = (meta_virt_dir / "recipes-extended" / "images" / "container-base-multiarch.bb").read_text() | ||
| 320 | assert 'OCI_MULTIARCH_RECIPE = "container-base"' in content | ||
| 321 | |||
| 322 | def test_platforms(self, meta_virt_dir): | ||
| 323 | content = (meta_virt_dir / "recipes-extended" / "images" / "container-base-multiarch.bb").read_text() | ||
| 324 | assert "aarch64" in content | ||
| 325 | assert "x86_64" in content | ||
| 326 | |||
| 327 | def test_has_license(self, meta_virt_dir): | ||
| 328 | content = (meta_virt_dir / "recipes-extended" / "images" / "container-base-multiarch.bb").read_text() | ||
| 329 | assert "LICENSE" in content | ||
| 330 | |||
| 331 | |||
| 332 | # ============================================================================= | ||
| 333 | # Tier 2: Consistency checks across files | ||
| 334 | # ============================================================================= | ||
| 335 | |||
| 336 | class TestCrossFileConsistency: | ||
| 337 | """Verify that the distro/multiconfig/bbclass files are consistent.""" | ||
| 338 | |||
| 339 | def test_vcontainer_and_vruntime_share_same_base(self, meta_virt_dir): | ||
| 340 | """Both distros must require the same shared base.""" | ||
| 341 | vruntime = (meta_virt_dir / "conf" / "distro" / "vruntime.conf").read_text() | ||
| 342 | vcontainer = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 343 | base_require = "require conf/distro/include/vruntime-base.inc" | ||
| 344 | assert base_require in vruntime | ||
| 345 | assert base_require in vcontainer | ||
| 346 | |||
| 347 | def test_multiconfig_distro_matches_conf(self, meta_virt_dir): | ||
| 348 | """Multiconfig DISTRO must match vcontainer.conf's DISTRO.""" | ||
| 349 | vcontainer = (meta_virt_dir / "conf" / "distro" / "vcontainer.conf").read_text() | ||
| 350 | mc_arm = (meta_virt_dir / "conf" / "multiconfig" / "container-aarch64.conf").read_text() | ||
| 351 | mc_x86 = (meta_virt_dir / "conf" / "multiconfig" / "container-x86-64.conf").read_text() | ||
| 352 | assert 'DISTRO = "vcontainer"' in vcontainer | ||
| 353 | assert 'DISTRO = "vcontainer"' in mc_arm | ||
| 354 | assert 'DISTRO = "vcontainer"' in mc_x86 | ||
| 355 | |||
| 356 | def test_bbclass_mc_names_match_multiconfig_files(self, meta_virt_dir): | ||
| 357 | """oci-multiarch.bbclass MC names must have matching multiconfig/*.conf files.""" | ||
| 358 | content = (meta_virt_dir / "classes" / "oci-multiarch.bbclass").read_text() | ||
| 359 | mc_dir = meta_virt_dir / "conf" / "multiconfig" | ||
| 360 | |||
| 361 | # Extract default MC names from ?= lines | ||
| 362 | for line in content.splitlines(): | ||
| 363 | if "OCI_MULTIARCH_MC" in line and "?=" in line: | ||
| 364 | # Parse: OCI_MULTIARCH_MC[arch] ?= "mc-name" | ||
| 365 | mc_name = line.split('"')[1] if '"' in line else None | ||
| 366 | if mc_name: | ||
| 367 | mc_file = mc_dir / f"{mc_name}.conf" | ||
| 368 | assert mc_file.exists(), \ | ||
| 369 | f"bbclass references MC '{mc_name}' but {mc_file} missing" | ||
| 370 | |||
| 371 | def test_bbclass_machines_match_multiconfig(self, meta_virt_dir): | ||
| 372 | """oci-multiarch.bbclass machine names must match multiconfig MACHINE values.""" | ||
| 373 | bbclass = (meta_virt_dir / "classes" / "oci-multiarch.bbclass").read_text() | ||
| 374 | |||
| 375 | # Extract machine mapping from bbclass | ||
| 376 | machine_map = {} | ||
| 377 | for line in bbclass.splitlines(): | ||
| 378 | if "OCI_MULTIARCH_MACHINE" in line and "?=" in line: | ||
| 379 | # OCI_MULTIARCH_MACHINE[aarch64] ?= "qemuarm64" | ||
| 380 | if "[" in line and "]" in line: | ||
| 381 | arch = line.split("[")[1].split("]")[0] | ||
| 382 | machine = line.split('"')[1] | ||
| 383 | machine_map[arch] = machine | ||
| 384 | |||
| 385 | # Extract MC mapping from bbclass | ||
| 386 | mc_map = {} | ||
| 387 | for line in bbclass.splitlines(): | ||
| 388 | if "OCI_MULTIARCH_MC" in line and "?=" in line: | ||
| 389 | if "[" in line and "]" in line: | ||
| 390 | arch = line.split("[")[1].split("]")[0] | ||
| 391 | mc = line.split('"')[1] | ||
| 392 | mc_map[arch] = mc | ||
| 393 | |||
| 394 | # Verify each MC's MACHINE matches the bbclass machine mapping | ||
| 395 | mc_dir = meta_virt_dir / "conf" / "multiconfig" | ||
| 396 | for arch, mc_name in mc_map.items(): | ||
| 397 | mc_file = mc_dir / f"{mc_name}.conf" | ||
| 398 | if mc_file.exists() and arch in machine_map: | ||
| 399 | mc_content = mc_file.read_text() | ||
| 400 | expected_machine = machine_map[arch] | ||
| 401 | assert f'MACHINE = "{expected_machine}"' in mc_content, \ | ||
| 402 | f"MC {mc_name} MACHINE should be {expected_machine}" | ||
| 403 | |||
| 404 | |||
| 405 | # ============================================================================= | ||
| 406 | # Tier 3: Build output verification (requires bitbake) | ||
| 407 | # ============================================================================= | ||
| 408 | |||
| 409 | @pytest.mark.slow | ||
| 410 | class TestMultiArchBuildOutput: | ||
| 411 | """Verify OCI output from bitbake container-base-multiarch. | ||
| 412 | |||
| 413 | These tests require a prior build: | ||
| 414 | bitbake container-base-multiarch | ||
| 415 | """ | ||
| 416 | |||
| 417 | @pytest.fixture(scope="class") | ||
| 418 | def multiarch_output(self, build_dir): | ||
| 419 | """Find the multi-arch OCI output directory.""" | ||
| 420 | # oci-multiarch.bbclass puts output in DEPLOY_DIR_IMAGE | ||
| 421 | # Try the main build's deploy dir first | ||
| 422 | candidates = [ | ||
| 423 | build_dir / "tmp" / "deploy" / "images", | ||
| 424 | ] | ||
| 425 | # Also check machine-specific dirs | ||
| 426 | for candidate in candidates: | ||
| 427 | if candidate.exists(): | ||
| 428 | for d in candidate.rglob("*-multiarch-oci"): | ||
| 429 | if (d / "index.json").exists(): | ||
| 430 | return d | ||
| 431 | pytest.skip("Multi-arch OCI output not found — run: bitbake container-base-multiarch") | ||
| 432 | |||
| 433 | def test_index_json_exists(self, multiarch_output): | ||
| 434 | assert (multiarch_output / "index.json").exists() | ||
| 435 | |||
| 436 | def test_oci_layout_exists(self, multiarch_output): | ||
| 437 | assert (multiarch_output / "oci-layout").exists() | ||
| 438 | layout = json.loads((multiarch_output / "oci-layout").read_text()) | ||
| 439 | assert layout.get("imageLayoutVersion") == "1.0.0" | ||
| 440 | |||
| 441 | def test_blobs_directory_exists(self, multiarch_output): | ||
| 442 | assert (multiarch_output / "blobs" / "sha256").is_dir() | ||
| 443 | |||
| 444 | def test_index_is_oci_image_index(self, multiarch_output): | ||
| 445 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 446 | assert index.get("schemaVersion") == 2 | ||
| 447 | assert index.get("mediaType") == "application/vnd.oci.image.index.v1+json" | ||
| 448 | |||
| 449 | def test_has_arm64_platform(self, multiarch_output): | ||
| 450 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 451 | platforms = [m.get("platform", {}).get("architecture") | ||
| 452 | for m in index.get("manifests", [])] | ||
| 453 | assert "arm64" in platforms, f"arm64 not in platforms: {platforms}" | ||
| 454 | |||
| 455 | def test_has_amd64_platform(self, multiarch_output): | ||
| 456 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 457 | platforms = [m.get("platform", {}).get("architecture") | ||
| 458 | for m in index.get("manifests", [])] | ||
| 459 | assert "amd64" in platforms, f"amd64 not in platforms: {platforms}" | ||
| 460 | |||
| 461 | def test_all_manifests_have_os_linux(self, multiarch_output): | ||
| 462 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 463 | for m in index.get("manifests", []): | ||
| 464 | assert m.get("platform", {}).get("os") == "linux" | ||
| 465 | |||
| 466 | def test_manifest_blobs_exist(self, multiarch_output): | ||
| 467 | """Every manifest digest must have a matching blob.""" | ||
| 468 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 469 | blobs_dir = multiarch_output / "blobs" / "sha256" | ||
| 470 | for m in index.get("manifests", []): | ||
| 471 | digest = m["digest"] | ||
| 472 | assert digest.startswith("sha256:") | ||
| 473 | blob_hash = digest[len("sha256:"):] | ||
| 474 | assert (blobs_dir / blob_hash).exists(), \ | ||
| 475 | f"Missing blob for manifest: {digest}" | ||
| 476 | |||
| 477 | def test_manifest_sizes_match(self, multiarch_output): | ||
| 478 | """Manifest size in index must match actual blob size.""" | ||
| 479 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 480 | blobs_dir = multiarch_output / "blobs" / "sha256" | ||
| 481 | for m in index.get("manifests", []): | ||
| 482 | blob_hash = m["digest"][len("sha256:"):] | ||
| 483 | blob_path = blobs_dir / blob_hash | ||
| 484 | if blob_path.exists(): | ||
| 485 | actual_size = blob_path.stat().st_size | ||
| 486 | assert actual_size == m["size"], \ | ||
| 487 | f"Size mismatch for {m['digest']}: index={m['size']} actual={actual_size}" | ||
| 488 | |||
| 489 | def test_each_manifest_is_valid_json(self, multiarch_output): | ||
| 490 | """Each manifest blob must be valid JSON with expected OCI fields.""" | ||
| 491 | index = json.loads((multiarch_output / "index.json").read_text()) | ||
| 492 | blobs_dir = multiarch_output / "blobs" / "sha256" | ||
| 493 | for m in index.get("manifests", []): | ||
| 494 | blob_hash = m["digest"][len("sha256:"):] | ||
| 495 | blob_path = blobs_dir / blob_hash | ||
| 496 | if blob_path.exists(): | ||
| 497 | manifest = json.loads(blob_path.read_text()) | ||
| 498 | assert manifest.get("schemaVersion") == 2 | ||
| 499 | assert "config" in manifest | ||
| 500 | assert "layers" in manifest | ||
| 501 | |||
| 502 | |||
| 503 | @pytest.mark.slow | ||
| 504 | class TestSingleArchContainerBuild: | ||
| 505 | """Verify single-arch container OCI output. | ||
| 506 | |||
| 507 | Requires: bitbake mc:container-x86-64:container-base | ||
| 508 | """ | ||
| 509 | |||
| 510 | @pytest.fixture(scope="class") | ||
| 511 | def x86_oci_dir(self, build_dir): | ||
| 512 | """Find x86-64 container OCI output.""" | ||
| 513 | deploy = build_dir / "tmp-container-x86-64" / "deploy" / "images" / "qemux86-64" | ||
| 514 | if not deploy.exists(): | ||
| 515 | pytest.skip("x86-64 container build not found — run: bitbake mc:container-x86-64:container-base") | ||
| 516 | for d in deploy.glob("container-base*oci"): | ||
| 517 | if (d / "index.json").exists(): | ||
| 518 | return d | ||
| 519 | pytest.skip("container-base OCI output not found in deploy dir") | ||
| 520 | |||
| 521 | def test_index_json_valid(self, x86_oci_dir): | ||
| 522 | index = json.loads((x86_oci_dir / "index.json").read_text()) | ||
| 523 | assert "manifests" in index | ||
| 524 | assert len(index["manifests"]) >= 1 | ||
| 525 | |||
| 526 | def test_has_oci_layout(self, x86_oci_dir): | ||
| 527 | assert (x86_oci_dir / "oci-layout").exists() | ||
| 528 | |||
| 529 | def test_has_blobs(self, x86_oci_dir): | ||
| 530 | blobs = x86_oci_dir / "blobs" / "sha256" | ||
| 531 | assert blobs.is_dir() | ||
| 532 | assert len(list(blobs.iterdir())) > 0, "No blobs found" | ||
diff --git a/tests/test_vdkr_registry.py b/tests/test_vdkr_registry.py index 72a27169..e429f6ab 100644 --- a/tests/test_vdkr_registry.py +++ b/tests/test_vdkr_registry.py | |||
| @@ -112,15 +112,20 @@ class TestImageCompoundCommands: | |||
| 112 | return None, None | 112 | return None, None |
| 113 | 113 | ||
| 114 | # Parse the images output to find alpine | 114 | # Parse the images output to find alpine |
| 115 | # Docker output format varies by version: | ||
| 116 | # Old: REPOSITORY TAG IMAGE ID CREATED SIZE | ||
| 117 | # New: IMAGE ID DISK USAGE CONTENT SIZE EXTRA | ||
| 115 | for line in images_result.stdout.splitlines(): | 118 | for line in images_result.stdout.splitlines(): |
| 116 | if "alpine" in line.lower() and "REPOSITORY" not in line: | 119 | if "alpine" in line.lower() and "REPOSITORY" not in line and "IMAGE" not in line: |
| 117 | # Parse: REPOSITORY TAG IMAGE_ID CREATED SIZE | ||
| 118 | parts = line.split() | 120 | parts = line.split() |
| 119 | if len(parts) >= 3: | 121 | if len(parts) >= 2: |
| 120 | repo = parts[0] | 122 | image_ref = parts[0] |
| 121 | tag = parts[1] | 123 | image_id = parts[1] |
| 122 | image_id = parts[2] | 124 | # Old format: repo and tag are separate columns |
| 123 | return f"{repo}:{tag}", image_id | 125 | if ':' not in image_ref and len(parts) >= 3: |
| 126 | image_ref = f"{parts[0]}:{parts[1]}" | ||
| 127 | image_id = parts[2] | ||
| 128 | return image_ref, image_id | ||
| 124 | return None, None | 129 | return None, None |
| 125 | 130 | ||
| 126 | def test_image_ls(self): | 131 | def test_image_ls(self): |
diff --git a/tests/test_xen_runtime.py b/tests/test_xen_runtime.py index 697c57ee..b31153bd 100644 --- a/tests/test_xen_runtime.py +++ b/tests/test_xen_runtime.py | |||
| @@ -7,21 +7,11 @@ Xen runtime boot tests - boot xen-image-minimal and verify hypervisor. | |||
| 7 | These tests boot an actual Xen Dom0 image via runqemu, verify the | 7 | These tests boot an actual Xen Dom0 image via runqemu, verify the |
| 8 | hypervisor is functional, check guest bundling, and exercise vxn/containerd. | 8 | hypervisor is functional, check guest bundling, and exercise vxn/containerd. |
| 9 | 9 | ||
| 10 | Build prerequisites (minimum for Dom0 boot tests): | 10 | The tests automatically build xen-image-minimal with the required |
| 11 | DISTRO_FEATURES:append = " xen systemd" | 11 | DISTRO_FEATURES before booting. No local.conf changes needed. |
| 12 | MACHINE = "qemux86-64" # or qemuarm64 | ||
| 13 | bitbake xen-image-minimal | ||
| 14 | |||
| 15 | For guest bundling tests: | ||
| 16 | IMAGE_INSTALL:append:pn-xen-image-minimal = " alpine-xen-guest-bundle" | ||
| 17 | |||
| 18 | For vxn/containerd tests: | ||
| 19 | DISTRO_FEATURES:append = " virtualization vcontainer vxn" | ||
| 20 | IMAGE_INSTALL:append:pn-xen-image-minimal = " vxn" | ||
| 21 | BBMULTICONFIG = "vruntime-aarch64 vruntime-x86-64" | ||
| 22 | 12 | ||
| 23 | Run with: | 13 | Run with: |
| 24 | pytest tests/test_xen_runtime.py -v --machine qemux86-64 | 14 | pytest tests/test_xen_runtime.py -v --poky-dir /opt/bruce/poky |
| 25 | 15 | ||
| 26 | Skip network-dependent tests: | 16 | Skip network-dependent tests: |
| 27 | pytest tests/test_xen_runtime.py -v -m "boot and not network" | 17 | pytest tests/test_xen_runtime.py -v -m "boot and not network" |
| @@ -33,7 +23,10 @@ Custom paths and longer timeout: | |||
| 33 | --boot-timeout 180 | 23 | --boot-timeout 180 |
| 34 | """ | 24 | """ |
| 35 | 25 | ||
| 26 | import os | ||
| 36 | import re | 27 | import re |
| 28 | import subprocess | ||
| 29 | import tempfile | ||
| 37 | import time | 30 | import time |
| 38 | import pytest | 31 | import pytest |
| 39 | from pathlib import Path | 32 | from pathlib import Path |
| @@ -50,6 +43,29 @@ except ImportError: | |||
| 50 | # are defined in conftest.py to avoid conflicts with other test files. | 43 | # are defined in conftest.py to avoid conflicts with other test files. |
| 51 | 44 | ||
| 52 | 45 | ||
| 46 | def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 47 | """Run bitbake with optional variable overrides via -R conf file.""" | ||
| 48 | bb_cmd = "bitbake" | ||
| 49 | conf_file = None | ||
| 50 | if extra_vars: | ||
| 51 | conf_file = tempfile.NamedTemporaryFile( | ||
| 52 | mode='w', suffix='.conf', prefix='pytest-xen-', | ||
| 53 | dir=str(build_dir / "conf"), delete=False) | ||
| 54 | for var, val in extra_vars.items(): | ||
| 55 | conf_file.write(f'{var} = "{val}"\n') | ||
| 56 | conf_file.close() | ||
| 57 | bb_cmd += f" -R {conf_file.name}" | ||
| 58 | bb_cmd += f" {recipe}" | ||
| 59 | poky_dir = build_dir.parent | ||
| 60 | full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 61 | try: | ||
| 62 | return subprocess.run(full_cmd, shell=True, cwd=build_dir, | ||
| 63 | timeout=timeout, capture_output=True, text=True) | ||
| 64 | finally: | ||
| 65 | if conf_file: | ||
| 66 | os.unlink(conf_file.name) | ||
| 67 | |||
| 68 | |||
| 53 | class XenRunner: | 69 | class XenRunner: |
| 54 | """ | 70 | """ |
| 55 | Manages a runqemu session for Xen boot testing. | 71 | Manages a runqemu session for Xen boot testing. |
| @@ -218,11 +234,25 @@ def machine(request): | |||
| 218 | 234 | ||
| 219 | 235 | ||
| 220 | @pytest.fixture(scope="module") | 236 | @pytest.fixture(scope="module") |
| 221 | def xen_session(request, poky_dir, build_dir, machine): | 237 | def xen_image(build_dir): |
| 238 | """Build xen-image-minimal with required distro features.""" | ||
| 239 | result = _run_bitbake( | ||
| 240 | build_dir, "xen-image-minimal", | ||
| 241 | extra_vars={ | ||
| 242 | "DISTRO_FEATURES:append": " xen vxn", | ||
| 243 | }, | ||
| 244 | ) | ||
| 245 | if result.returncode != 0: | ||
| 246 | pytest.fail(f"Xen image build failed: {result.stderr}") | ||
| 247 | |||
| 248 | |||
| 249 | @pytest.fixture(scope="module") | ||
| 250 | def xen_session(request, poky_dir, build_dir, machine, xen_image): | ||
| 222 | """ | 251 | """ |
| 223 | Module-scoped fixture that boots xen-image-minimal once for all tests. | 252 | Module-scoped fixture that builds xen-image-minimal and boots it |
| 253 | once for all tests. | ||
| 224 | 254 | ||
| 225 | Skips if pexpect is not available, image is not found, or boot fails. | 255 | Skips if pexpect is not available or boot fails. |
| 226 | """ | 256 | """ |
| 227 | if not PEXPECT_AVAILABLE: | 257 | if not PEXPECT_AVAILABLE: |
| 228 | pytest.skip("pexpect not installed. Run: pip install pexpect") | 258 | pytest.skip("pexpect not installed. Run: pip install pexpect") |
