summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/conftest.py36
-rw-r--r--tests/pytest.ini1
-rw-r--r--tests/requirements.txt3
-rw-r--r--tests/test_container_cross_install.py450
-rw-r--r--tests/test_container_registry_script.py28
-rw-r--r--tests/test_containerd_runtime.py358
-rw-r--r--tests/test_incus_runtime.py64
-rw-r--r--tests/test_k3s_runtime.py92
-rw-r--r--tests/test_libvirt.py468
-rw-r--r--tests/test_lxc_runtime.py290
-rw-r--r--tests/test_vcontainer_auth_config.py642
-rw-r--r--tests/test_vcontainer_distro.py532
-rw-r--r--tests/test_vdkr_registry.py19
-rw-r--r--tests/test_xen_runtime.py62
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
264def _cleanup_stale_test_state(): 271def _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
17addopts = -v --tb=short --junitxml=/tmp/pytest-results.xml 18addopts = -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 @@
1pytest>=9.0.3,<10
2pytest-timeout>=2.4.0,<3
3pexpect>=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
8OCI containers into Yocto images. 8OCI containers into Yocto images.
9 9
10Run with: 10Run 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
13Run boot tests (requires built image): 13Full 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
16Single profile only:
17 pytest tests/test_container_cross_install.py -v --poky-dir /opt/bruce/poky --container-profiles docker
15 18
16Environment variables: 19Environment 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
21Note: These tests require a configured Yocto build environment. 24Note: Boot tests (TestBundledContainersBoot, TestCustomServiceFileBoot) build
25container-image-host with each --container-profiles profile, boot it, and
26verify containers are present and runnable. No local.conf editing required.
22""" 27"""
23 28
24import os 29import os
@@ -87,26 +92,55 @@ def meta_virt_dir(poky_dir):
87 return path 92 return path
88 93
89 94
90def run_bitbake(build_dir, recipe, task=None, extra_args=None, timeout=1800): 95def 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
630def _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"])
636def 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")
678def 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
592def _detect_containers_from_rootfs(build_dir, machine, image): 688def _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"""
5Containerd runtime tests — recipe checks, build, and boot verification.
6
7The tests automatically build container-image-host with
8CONTAINER_PROFILE=containerd before booting. No local.conf changes needed.
9
10Run:
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
18import os
19import re
20import subprocess
21import tempfile
22import time
23import pytest
24from pathlib import Path
25
26try:
27 import pexpect
28 PEXPECT_AVAILABLE = True
29except ImportError:
30 PEXPECT_AVAILABLE = False
31
32
33def _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")
61def 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")
69def 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")
78def 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")
86def 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")
97def 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
141def 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
159class 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
191class 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
217class 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
225class 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
233class 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
267class 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
289class 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 @@
5Incus runtime tests - boot container-image-host with incus and verify 5Incus runtime tests - boot container-image-host with incus and verify
6system container management. 6system container management.
7 7
8Build prerequisites (in local.conf): 8The tests automatically build container-image-host with CONTAINER_PROFILE=incus
9 require conf/distro/include/meta-virt-host.conf 9before 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
15Run: 11Run:
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
18Options: 14Options:
19 --boot-timeout QEMU boot timeout (default: 120s) 15 --boot-timeout QEMU boot timeout (default: 120s)
@@ -22,8 +18,11 @@ Options:
22 18
23import os 19import os
24import re 20import re
21import subprocess
22import tempfile
25import time 23import time
26import pytest 24import pytest
25from pathlib import Path
27 26
28try: 27try:
29 import pexpect 28 import pexpect
@@ -38,19 +37,60 @@ pytestmark = [
38] 37]
39 38
40 39
40def _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")
64def 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")
42def incus_qemu(request): 80def 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
8deployment. Multi-node tests use QEMU socket networking to connect two VMs 8deployment. Multi-node tests use QEMU socket networking to connect two VMs
9on a shared L2 segment and verify agent join + multi-node scheduling. 9on a shared L2 segment and verify agent join + multi-node scheduling.
10 10
11Build prerequisites (in local.conf): 11The tests automatically build container-image-host with CONTAINER_PROFILE=k3s-host
12 require conf/distro/include/meta-virt-host.conf 12before 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
18Run: 14Run:
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
28Options: 24Options:
29 --k3s-timeout Overall k3s readiness timeout (default: 300s) 25 --k3s-timeout Overall k3s readiness timeout (default: 300s)
@@ -43,6 +39,8 @@ Notes:
43import os 39import os
44import re 40import re
45import shutil 41import shutil
42import subprocess
43import tempfile
46import time 44import time
47import pytest 45import pytest
48from pathlib import Path 46from pathlib import Path
@@ -290,6 +288,39 @@ class K3sRunner:
290# Fixtures 288# Fixtures
291# ============================================================================ 289# ============================================================================
292 290
291def 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")
294def poky_dir(request): 325def 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")
328def k3s_session(request, poky_dir, build_dir, machine): 359def 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")
373def 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")
359def k3s_multinode(request, poky_dir, build_dir, machine): 398def 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"""
5Libvirt recipe and runtime tests.
6
7Tier 1: Static assertions on recipe files (no build required)
8Tier 2: Build verification (requires bitbake)
9Tier 3: Boot tests on kvm-image-minimal (requires QEMU with KVM)
10
11The tests automatically build kvm-image-minimal with the kvm
12DISTRO_FEATURE before booting. No local.conf changes needed.
13
14Run:
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
21Options:
22 --boot-timeout QEMU boot timeout (default: 120s)
23 --no-kvm Disable KVM acceleration
24"""
25
26import os
27import re
28import subprocess
29import tempfile
30import time
31import pytest
32from pathlib import Path
33
34try:
35 import pexpect
36 PEXPECT_AVAILABLE = True
37except ImportError:
38 PEXPECT_AVAILABLE = False
39
40
41def _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")
69def 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")
77def 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")
86def 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")
94def 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")
102def 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")
110def 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")
123def 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
167def 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
185class 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
256class 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
298class 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
324class 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
340class 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
372class 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
396class 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"""
5LXC runtime tests — boot container-image-host with lxc installed and
6exercise the LXC command-line lifecycle (create, start, attach, stop,
7destroy).
8
9The tests build container-image-host with CONTAINER_IMAGE_HOST_EXTRA_INSTALL
10including lxc. No local.conf changes needed.
11
12The download-template regression check (TestLxcDownloadTemplate) exists
13specifically to catch the class of bug reported on the meta-virt list
14on 2026-06-13 (Ferry Toth: "lxc: starting a container errors out"),
15where 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
17any network call. The test invokes lxc-create with the download template
18and asserts that the early mktemp error does not appear in the output,
19even if the actual download itself fails (e.g. no network in the test
20environment). That keeps the regression test useful in air-gapped CI
21without requiring outbound network from the qemu guest.
22
23Run:
24 pytest tests/test_lxc_runtime.py -v --poky-dir /opt/bruce/poky
25
26Options:
27 --boot-timeout QEMU boot timeout (default: 120s)
28 --no-kvm Disable KVM acceleration
29 --machine QEMU MACHINE (default: qemux86-64)
30"""
31
32import os
33import subprocess
34import tempfile
35import time
36from pathlib import Path
37
38import pytest
39
40try:
41 import pexpect
42 PEXPECT_AVAILABLE = True
43except ImportError:
44 PEXPECT_AVAILABLE = False
45
46
47pytestmark = [
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
58def _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")
89def 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")
110def 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
150def 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
165class 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
185class 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
236class 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"""
5Tests for the vcontainer registry-auth-config plumbing ("--config" /
6$VDKR_CONFIG / $VPDMN_CONFIG).
7
8These tests are split into two tiers:
9
10Tier 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
16Tier 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
22Tier 3 (live registry pull with --config) is intentionally NOT in this file.
23It belongs alongside test_vdkr_registry.py once the registry fixture grows a
24credentials-required mode.
25
26Run with:
27 pytest tests/test_vcontainer_auth_config.py -v
28"""
29
30import os
31import re
32import stat
33import subprocess
34import textwrap
35from pathlib import Path
36
37import 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
59def _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")
70def 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")
78def 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")
86def 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")
94def 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")
102def 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")
110def 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")
118def 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")
126def 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
138class 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
453def _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")
481def 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
514def _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
523class 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"""
5Tests for vcontainer distro and multi-arch OCI container build infrastructure.
6
7Verifies:
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
17Run with:
18 pytest tests/test_vcontainer_distro.py -v --poky-dir /opt/bruce/poky
19
20Build-dependent tests (marked @pytest.mark.slow) require:
21 bitbake container-base-multiarch
22"""
23
24import json
25import os
26import pytest
27from pathlib import Path
28
29
30@pytest.fixture(scope="module")
31def 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")
41def 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
54class 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
98class 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
149class 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
186class 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
234class 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
268class 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
292class 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
307class 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
336class 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
410class 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
504class 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.
7These tests boot an actual Xen Dom0 image via runqemu, verify the 7These tests boot an actual Xen Dom0 image via runqemu, verify the
8hypervisor is functional, check guest bundling, and exercise vxn/containerd. 8hypervisor is functional, check guest bundling, and exercise vxn/containerd.
9 9
10Build prerequisites (minimum for Dom0 boot tests): 10The tests automatically build xen-image-minimal with the required
11 DISTRO_FEATURES:append = " xen systemd" 11DISTRO_FEATURES before booting. No local.conf changes needed.
12 MACHINE = "qemux86-64" # or qemuarm64
13 bitbake xen-image-minimal
14
15For guest bundling tests:
16 IMAGE_INSTALL:append:pn-xen-image-minimal = " alpine-xen-guest-bundle"
17
18For 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
23Run with: 13Run 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
26Skip network-dependent tests: 16Skip 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
26import os
36import re 27import re
28import subprocess
29import tempfile
37import time 30import time
38import pytest 31import pytest
39from pathlib import Path 32from 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
46def _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
53class XenRunner: 69class 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")
221def xen_session(request, poky_dir, build_dir, machine): 237def 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")
250def 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")