diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-01-01 17:15:29 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-02-09 03:32:52 +0000 |
| commit | 1165c61f5ab8ada644c7def03e991890c4d380ca (patch) | |
| tree | 1cd1c1d58039afad5a1d24b5c10d8030237e2d7c /tests/test_container_cross_install.py | |
| parent | c32e1081c81ba27f0d5a21a1885601f04d329d21 (diff) | |
| download | meta-virtualization-1165c61f5ab8ada644c7def03e991890c4d380ca.tar.gz | |
tests: add pytest framework for vdkr and vpdmn
Add pytest-based test suite for testing vdkr and vpdmn CLI tools.
Tests use a separate state directory (~/.vdkr-test/) to avoid
interfering with production images.
Test files:
- conftest.py: Pytest fixtures for VdkrRunner and VpdmnRunner
- test_vdkr.py: Docker CLI tests (images, vimport, vrun, volumes, etc.)
- test_vpdmn.py: Podman CLI tests (mirrors vdkr test coverage)
- memres-test.sh: Helper script for running tests with memres
- pytest.ini: Pytest configuration and markers
Test categories:
- Basic operations: images, info, version
- Import/export: vimport, load, save
- Container execution: vrun, run, exec
- Storage management: system df, vstorage
- Memory resident mode: memres/vmemres start/stop/status
Running tests:
pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vcontainer-standalone
pytest tests/test_vpdmn.py -v --vdkr-dir /tmp/vcontainer-standalone
Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'tests/test_container_cross_install.py')
| -rw-r--r-- | tests/test_container_cross_install.py | 906 |
1 files changed, 906 insertions, 0 deletions
diff --git a/tests/test_container_cross_install.py b/tests/test_container_cross_install.py new file mode 100644 index 00000000..9a4d23a4 --- /dev/null +++ b/tests/test_container_cross_install.py | |||
| @@ -0,0 +1,906 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Tests for container-cross-install - Yocto container bundling system. | ||
| 6 | |||
| 7 | These tests verify that container-cross-install correctly bundles | ||
| 8 | OCI containers into Yocto images. | ||
| 9 | |||
| 10 | Run with: | ||
| 11 | pytest tests/test_container_cross_install.py -v | ||
| 12 | |||
| 13 | Run boot tests (requires built image): | ||
| 14 | pytest tests/test_container_cross_install.py::TestBundledContainersBoot -v | ||
| 15 | |||
| 16 | Environment variables: | ||
| 17 | POKY_DIR: Path to poky directory (default: /opt/bruce/poky) | ||
| 18 | BUILD_DIR: Path to build directory (default: $POKY_DIR/build) | ||
| 19 | MACHINE: Target machine (default: qemux86-64) | ||
| 20 | |||
| 21 | Note: These tests require a configured Yocto build environment. | ||
| 22 | """ | ||
| 23 | |||
| 24 | import os | ||
| 25 | import re | ||
| 26 | import subprocess | ||
| 27 | import time | ||
| 28 | import pytest | ||
| 29 | from pathlib import Path | ||
| 30 | |||
| 31 | # Optional import for boot tests | ||
| 32 | try: | ||
| 33 | import pexpect | ||
| 34 | PEXPECT_AVAILABLE = True | ||
| 35 | except ImportError: | ||
| 36 | PEXPECT_AVAILABLE = False | ||
| 37 | |||
| 38 | |||
| 39 | # Note: Command line options (--poky-dir, --build-dir, --machine, --image, --boot-timeout) | ||
| 40 | # are defined in conftest.py to avoid conflicts with other test files. | ||
| 41 | |||
| 42 | |||
| 43 | @pytest.fixture(scope="module") | ||
| 44 | def poky_dir(request): | ||
| 45 | """Path to poky directory.""" | ||
| 46 | path = Path(request.config.getoption("--poky-dir")) | ||
| 47 | if not path.exists(): | ||
| 48 | pytest.skip(f"Poky directory not found: {path}") | ||
| 49 | return path | ||
| 50 | |||
| 51 | |||
| 52 | @pytest.fixture(scope="module") | ||
| 53 | def build_dir(request, poky_dir): | ||
| 54 | """Path to build directory.""" | ||
| 55 | path = request.config.getoption("--build-dir") | ||
| 56 | if path: | ||
| 57 | path = Path(path) | ||
| 58 | else: | ||
| 59 | path = poky_dir / "build" | ||
| 60 | |||
| 61 | if not path.exists(): | ||
| 62 | pytest.skip(f"Build directory not found: {path}") | ||
| 63 | return path | ||
| 64 | |||
| 65 | |||
| 66 | @pytest.fixture(scope="module") | ||
| 67 | def machine(request): | ||
| 68 | """Target machine.""" | ||
| 69 | return request.config.getoption("--machine") | ||
| 70 | |||
| 71 | |||
| 72 | @pytest.fixture(scope="module") | ||
| 73 | def deploy_dir(build_dir, machine): | ||
| 74 | """Path to deploy directory for the machine.""" | ||
| 75 | path = build_dir / "tmp" / "deploy" / "images" / machine | ||
| 76 | if not path.exists(): | ||
| 77 | pytest.skip(f"Deploy directory not found: {path}") | ||
| 78 | return path | ||
| 79 | |||
| 80 | |||
| 81 | @pytest.fixture(scope="module") | ||
| 82 | def meta_virt_dir(poky_dir): | ||
| 83 | """Path to meta-virtualization layer.""" | ||
| 84 | path = poky_dir / "meta-virtualization" | ||
| 85 | if not path.exists(): | ||
| 86 | pytest.skip(f"meta-virtualization not found: {path}") | ||
| 87 | return path | ||
| 88 | |||
| 89 | |||
| 90 | def run_bitbake(build_dir, recipe, task=None, extra_args=None, timeout=1800): | ||
| 91 | """Run a bitbake command.""" | ||
| 92 | cmd = ["bitbake"] | ||
| 93 | if task: | ||
| 94 | cmd.extend(["-c", task]) | ||
| 95 | cmd.append(recipe) | ||
| 96 | if extra_args: | ||
| 97 | cmd.extend(extra_args) | ||
| 98 | |||
| 99 | env = os.environ.copy() | ||
| 100 | env["BUILDDIR"] = str(build_dir) | ||
| 101 | |||
| 102 | result = subprocess.run( | ||
| 103 | cmd, | ||
| 104 | cwd=build_dir, | ||
| 105 | env=env, | ||
| 106 | timeout=timeout, | ||
| 107 | capture_output=True, | ||
| 108 | text=True, | ||
| 109 | ) | ||
| 110 | return result | ||
| 111 | |||
| 112 | |||
| 113 | class TestContainerCrossClass: | ||
| 114 | """Test container-cross-install.bbclass functionality.""" | ||
| 115 | |||
| 116 | def test_class_exists(self, meta_virt_dir): | ||
| 117 | """Test that the bbclass file exists.""" | ||
| 118 | class_file = meta_virt_dir / "classes" / "container-cross-install.bbclass" | ||
| 119 | assert class_file.exists(), f"Class file not found: {class_file}" | ||
| 120 | |||
| 121 | def test_class_syntax(self, meta_virt_dir): | ||
| 122 | """Basic syntax check on the bbclass.""" | ||
| 123 | class_file = meta_virt_dir / "classes" / "container-cross-install.bbclass" | ||
| 124 | content = class_file.read_text() | ||
| 125 | |||
| 126 | # Check for expected content | ||
| 127 | assert "BUNDLED_CONTAINERS" in content | ||
| 128 | assert "container_cross_install" in content or "do_rootfs" in content | ||
| 129 | |||
| 130 | |||
| 131 | class TestOCIImageBuild: | ||
| 132 | """Test building OCI container images.""" | ||
| 133 | |||
| 134 | @pytest.mark.slow | ||
| 135 | def test_container_app_base_build(self, build_dir, deploy_dir): | ||
| 136 | """Test building container-app-base OCI image.""" | ||
| 137 | result = run_bitbake(build_dir, "container-app-base", timeout=3600) | ||
| 138 | |||
| 139 | # May not exist in all setups | ||
| 140 | if result.returncode != 0: | ||
| 141 | if "Nothing PROVIDES" in result.stderr: | ||
| 142 | pytest.skip("container-app-base recipe not available") | ||
| 143 | pytest.fail(f"Build failed: {result.stderr}") | ||
| 144 | |||
| 145 | # Check OCI output exists | ||
| 146 | oci_dirs = list(deploy_dir.glob("*-oci")) | ||
| 147 | assert len(oci_dirs) > 0, "No OCI directories found in deploy" | ||
| 148 | |||
| 149 | |||
| 150 | class TestBundledContainers: | ||
| 151 | """Test end-to-end container bundling.""" | ||
| 152 | |||
| 153 | @pytest.mark.slow | ||
| 154 | def test_image_with_bundled_container(self, build_dir, deploy_dir): | ||
| 155 | """ | ||
| 156 | Test building an image with BUNDLED_CONTAINERS set. | ||
| 157 | |||
| 158 | This requires a local.conf with BUNDLED_CONTAINERS defined. | ||
| 159 | """ | ||
| 160 | # Check if BUNDLED_CONTAINERS is configured | ||
| 161 | local_conf = build_dir / "conf" / "local.conf" | ||
| 162 | if local_conf.exists(): | ||
| 163 | content = local_conf.read_text() | ||
| 164 | if "BUNDLED_CONTAINERS" not in content: | ||
| 165 | pytest.skip("BUNDLED_CONTAINERS not configured in local.conf") | ||
| 166 | else: | ||
| 167 | pytest.skip("local.conf not found") | ||
| 168 | |||
| 169 | # Build the image (this will take a while) | ||
| 170 | result = run_bitbake( | ||
| 171 | build_dir, | ||
| 172 | "core-image-minimal", # Or whatever image is configured | ||
| 173 | task="rootfs", | ||
| 174 | timeout=3600, | ||
| 175 | ) | ||
| 176 | |||
| 177 | if result.returncode != 0: | ||
| 178 | pytest.fail(f"Image build failed: {result.stderr}") | ||
| 179 | |||
| 180 | |||
| 181 | class TestRemoteContainerBundle: | ||
| 182 | """Test remote container fetching via container-bundle.bbclass. | ||
| 183 | |||
| 184 | These tests verify that containers can be pulled from remote registries | ||
| 185 | (like docker.io) during the Yocto build and bundled into target images. | ||
| 186 | """ | ||
| 187 | |||
| 188 | @pytest.mark.network | ||
| 189 | @pytest.mark.slow | ||
| 190 | def test_remote_bundle_recipe_builds(self, build_dir): | ||
| 191 | """Test that remote-container-bundle recipe builds successfully. | ||
| 192 | |||
| 193 | This verifies: | ||
| 194 | 1. Recipe syntax is valid | ||
| 195 | 2. skopeo can fetch the remote container | ||
| 196 | 3. The container is processed and packaged | ||
| 197 | """ | ||
| 198 | result = run_bitbake( | ||
| 199 | build_dir, | ||
| 200 | "remote-container-bundle", | ||
| 201 | timeout=1800, # 30 min - network fetch can be slow | ||
| 202 | ) | ||
| 203 | assert result.returncode == 0, f"Build failed: {result.stderr}" | ||
| 204 | |||
| 205 | @pytest.mark.network | ||
| 206 | @pytest.mark.slow | ||
| 207 | def test_remote_bundle_package_created(self, build_dir, deploy_dir): | ||
| 208 | """Test that remote-container-bundle creates the expected package.""" | ||
| 209 | # First ensure it's built | ||
| 210 | result = run_bitbake( | ||
| 211 | build_dir, | ||
| 212 | "remote-container-bundle", | ||
| 213 | timeout=1800, | ||
| 214 | ) | ||
| 215 | if result.returncode != 0: | ||
| 216 | pytest.skip(f"Build failed, skipping package check: {result.stderr}") | ||
| 217 | |||
| 218 | # Check for the package in deploy directory | ||
| 219 | # Package format depends on PACKAGE_CLASSES config (rpm, deb, or ipk) | ||
| 220 | # Note: packages are in tmp/deploy/{rpm,deb,ipk}/, not in images/ | ||
| 221 | base_deploy = build_dir / "tmp" / "deploy" | ||
| 222 | |||
| 223 | # Only check package directories that exist (user may have configured | ||
| 224 | # any of: package_rpm, package_deb, package_ipk) | ||
| 225 | pkg_formats = ["rpm", "deb", "ipk"] | ||
| 226 | existing_pkg_dirs = [base_deploy / fmt for fmt in pkg_formats | ||
| 227 | if (base_deploy / fmt).exists()] | ||
| 228 | |||
| 229 | if not existing_pkg_dirs: | ||
| 230 | pytest.skip(f"No package deploy directories found in {base_deploy}") | ||
| 231 | |||
| 232 | found = False | ||
| 233 | for pkg_dir in existing_pkg_dirs: | ||
| 234 | packages = list(pkg_dir.rglob("*remote-container-bundle*")) | ||
| 235 | if packages: | ||
| 236 | found = True | ||
| 237 | break | ||
| 238 | |||
| 239 | assert found, ( | ||
| 240 | f"No remote-container-bundle package found. " | ||
| 241 | f"Checked: {[str(d) for d in existing_pkg_dirs]}" | ||
| 242 | ) | ||
| 243 | |||
| 244 | @pytest.mark.network | ||
| 245 | @pytest.mark.slow | ||
| 246 | def test_remote_bundle_in_image(self, build_dir, deploy_dir): | ||
| 247 | """Test building an image with the remote container bundle. | ||
| 248 | |||
| 249 | This is the full end-to-end test: build an image that includes | ||
| 250 | the remote-container-bundle package. | ||
| 251 | """ | ||
| 252 | # Configure local.conf to include remote-container-bundle | ||
| 253 | local_conf = build_dir / "conf" / "local.conf" | ||
| 254 | if not local_conf.exists(): | ||
| 255 | pytest.skip("local.conf not found") | ||
| 256 | |||
| 257 | content = local_conf.read_text() | ||
| 258 | |||
| 259 | # Check if already configured | ||
| 260 | if "remote-container-bundle" not in content: | ||
| 261 | # Add to local.conf (append to avoid conflicts) | ||
| 262 | with open(local_conf, "a") as f: | ||
| 263 | f.write('\n# Added by pytest for remote container bundle test\n') | ||
| 264 | f.write('IMAGE_INSTALL:append:pn-core-image-minimal = " remote-container-bundle"\n') | ||
| 265 | |||
| 266 | # Build the image | ||
| 267 | result = run_bitbake( | ||
| 268 | build_dir, | ||
| 269 | "core-image-minimal", | ||
| 270 | task="rootfs", | ||
| 271 | timeout=3600, | ||
| 272 | ) | ||
| 273 | |||
| 274 | if result.returncode != 0: | ||
| 275 | pytest.fail(f"Image build failed: {result.stderr}") | ||
| 276 | |||
| 277 | |||
| 278 | class TestVdkrRecipes: | ||
| 279 | """Test vdkr/vpdmn recipe builds.""" | ||
| 280 | |||
| 281 | @pytest.mark.slow | ||
| 282 | def test_vcontainer_tarball(self, build_dir): | ||
| 283 | """Test creating vcontainer standalone SDK. | ||
| 284 | |||
| 285 | This builds the SDK-based standalone distribution with vdkr and vpdmn. | ||
| 286 | Requires blobs to be built first via multiconfig. | ||
| 287 | """ | ||
| 288 | result = run_bitbake( | ||
| 289 | build_dir, | ||
| 290 | "vcontainer-tarball", | ||
| 291 | timeout=3600, | ||
| 292 | ) | ||
| 293 | assert result.returncode == 0, f"SDK build failed: {result.stderr}" | ||
| 294 | |||
| 295 | # Check SDK installer exists | ||
| 296 | sdk_deploy = build_dir / "tmp" / "deploy" / "sdk" | ||
| 297 | installers = list(sdk_deploy.glob("vcontainer-standalone*.sh")) | ||
| 298 | assert len(installers) > 0, f"No SDK installer found in {sdk_deploy}" | ||
| 299 | |||
| 300 | def test_vdkr_initramfs_create(self, build_dir): | ||
| 301 | """Test vdkr-initramfs-create builds.""" | ||
| 302 | result = run_bitbake(build_dir, "vdkr-initramfs-create") | ||
| 303 | assert result.returncode == 0, f"Build failed: {result.stderr}" | ||
| 304 | |||
| 305 | def test_vpdmn_initramfs_create(self, build_dir): | ||
| 306 | """Test vpdmn-initramfs-create builds.""" | ||
| 307 | result = run_bitbake(build_dir, "vpdmn-initramfs-create") | ||
| 308 | assert result.returncode == 0, f"Build failed: {result.stderr}" | ||
| 309 | |||
| 310 | |||
| 311 | class TestMulticonfig: | ||
| 312 | """Test multiconfig setup for vdkr.""" | ||
| 313 | |||
| 314 | def test_multiconfig_files_exist(self, meta_virt_dir): | ||
| 315 | """Test that multiconfig files exist.""" | ||
| 316 | mc_dir = meta_virt_dir / "conf" / "multiconfig" | ||
| 317 | |||
| 318 | aarch64_conf = mc_dir / "vruntime-aarch64.conf" | ||
| 319 | x86_64_conf = mc_dir / "vruntime-x86-64.conf" | ||
| 320 | |||
| 321 | assert aarch64_conf.exists() or x86_64_conf.exists(), \ | ||
| 322 | f"No multiconfig files found in {mc_dir}" | ||
| 323 | |||
| 324 | @pytest.mark.slow | ||
| 325 | def test_multiconfig_build(self, build_dir): | ||
| 326 | """Test multiconfig image build.""" | ||
| 327 | # Check if multiconfig is enabled | ||
| 328 | local_conf = build_dir / "conf" / "local.conf" | ||
| 329 | if local_conf.exists(): | ||
| 330 | content = local_conf.read_text() | ||
| 331 | if "BBMULTICONFIG" not in content: | ||
| 332 | pytest.skip("BBMULTICONFIG not configured") | ||
| 333 | else: | ||
| 334 | pytest.skip("local.conf not found") | ||
| 335 | |||
| 336 | # Try to build multiconfig target | ||
| 337 | result = run_bitbake( | ||
| 338 | build_dir, | ||
| 339 | "mc:vruntime-aarch64:vdkr-rootfs-image", | ||
| 340 | timeout=3600, | ||
| 341 | ) | ||
| 342 | |||
| 343 | if result.returncode != 0: | ||
| 344 | if "Invalid multiconfig target" in result.stderr: | ||
| 345 | pytest.skip("vruntime-aarch64 multiconfig not available") | ||
| 346 | pytest.fail(f"Multiconfig build failed: {result.stderr}") | ||
| 347 | |||
| 348 | |||
| 349 | # ============================================================================ | ||
| 350 | # Boot Tests - Verify bundled containers are visible after boot | ||
| 351 | # ============================================================================ | ||
| 352 | |||
| 353 | class RunqemuSession: | ||
| 354 | """ | ||
| 355 | Manages a runqemu session for boot testing. | ||
| 356 | |||
| 357 | Uses pexpect to interact with the serial console. | ||
| 358 | """ | ||
| 359 | |||
| 360 | def __init__(self, poky_dir, build_dir, machine, image, fstype="ext4", | ||
| 361 | use_kvm=True, timeout=120): | ||
| 362 | self.poky_dir = Path(poky_dir) | ||
| 363 | self.build_dir = Path(build_dir) | ||
| 364 | self.machine = machine | ||
| 365 | self.image = image | ||
| 366 | self.fstype = fstype | ||
| 367 | self.use_kvm = use_kvm | ||
| 368 | self.timeout = timeout | ||
| 369 | self.child = None | ||
| 370 | self.booted = False | ||
| 371 | |||
| 372 | def start(self): | ||
| 373 | """Start runqemu and wait for login prompt.""" | ||
| 374 | if not PEXPECT_AVAILABLE: | ||
| 375 | raise RuntimeError("pexpect not installed. Run: pip install pexpect") | ||
| 376 | |||
| 377 | # Build the runqemu command | ||
| 378 | # We need to source oe-init-build-env first | ||
| 379 | kvm_opt = "kvm" if self.use_kvm else "" | ||
| 380 | cmd = ( | ||
| 381 | f"bash -c 'cd {self.poky_dir} && " | ||
| 382 | f"source oe-init-build-env {self.build_dir} >/dev/null 2>&1 && " | ||
| 383 | f"runqemu {self.machine} {self.image} {self.fstype} nographic slirp {kvm_opt} " | ||
| 384 | f"qemuparams=\"-m 2048\"'" | ||
| 385 | ) | ||
| 386 | |||
| 387 | print(f"Starting runqemu: {cmd}") | ||
| 388 | self.child = pexpect.spawn(cmd, encoding='utf-8', timeout=self.timeout) | ||
| 389 | |||
| 390 | # Log output for debugging | ||
| 391 | self.child.logfile_read = open('/tmp/runqemu-test.log', 'w') | ||
| 392 | |||
| 393 | # Wait for login prompt | ||
| 394 | try: | ||
| 395 | # Look for common login prompts | ||
| 396 | index = self.child.expect([ | ||
| 397 | r'login:', | ||
| 398 | r'root@', # Already logged in | ||
| 399 | pexpect.TIMEOUT, | ||
| 400 | pexpect.EOF, | ||
| 401 | ], timeout=self.timeout) | ||
| 402 | |||
| 403 | if index == 0: | ||
| 404 | # Send login | ||
| 405 | self.child.sendline('root') | ||
| 406 | # Wait for shell prompt | ||
| 407 | self.child.expect([r'root@', r'#', r'\$'], timeout=30) | ||
| 408 | self.booted = True | ||
| 409 | elif index == 1: | ||
| 410 | # Already at prompt | ||
| 411 | self.booted = True | ||
| 412 | elif index == 2: | ||
| 413 | raise RuntimeError(f"Timeout waiting for login (>{self.timeout}s)") | ||
| 414 | elif index == 3: | ||
| 415 | raise RuntimeError("runqemu terminated unexpectedly") | ||
| 416 | |||
| 417 | except Exception as e: | ||
| 418 | self.stop() | ||
| 419 | raise RuntimeError(f"Failed to boot: {e}") | ||
| 420 | |||
| 421 | return self | ||
| 422 | |||
| 423 | def run_command(self, cmd, timeout=60): | ||
| 424 | """Run a command and return the output.""" | ||
| 425 | if not self.booted: | ||
| 426 | raise RuntimeError("System not booted") | ||
| 427 | |||
| 428 | # Wait for prompt to be ready, then clear buffer | ||
| 429 | time.sleep(0.3) | ||
| 430 | |||
| 431 | # Send command | ||
| 432 | self.child.sendline(cmd) | ||
| 433 | |||
| 434 | try: | ||
| 435 | # Wait for the prompt to return (command completed) | ||
| 436 | # Match the full prompt pattern: root@hostname:path# | ||
| 437 | self.child.expect(r'root@[^:]+:[^#]+#', timeout=timeout) | ||
| 438 | |||
| 439 | # Get everything before the prompt | ||
| 440 | raw_output = self.child.before | ||
| 441 | |||
| 442 | # Parse: split by newlines, skip command echo (first line), take the rest | ||
| 443 | lines = raw_output.replace('\r', '').split('\n') | ||
| 444 | |||
| 445 | # Filter out empty lines and the command echo | ||
| 446 | output_lines = [] | ||
| 447 | for i, line in enumerate(lines): | ||
| 448 | stripped = line.strip() | ||
| 449 | if not stripped: | ||
| 450 | continue | ||
| 451 | # First non-empty line is usually the command echo | ||
| 452 | if i == 0 or (output_lines == [] and cmd[:10] in line): | ||
| 453 | continue | ||
| 454 | output_lines.append(stripped) | ||
| 455 | |||
| 456 | return '\n'.join(output_lines) | ||
| 457 | |||
| 458 | except pexpect.TIMEOUT: | ||
| 459 | print(f"[TIMEOUT] Command '{cmd}' timed out after {timeout}s") | ||
| 460 | return "" | ||
| 461 | |||
| 462 | def stop(self): | ||
| 463 | """Shutdown the QEMU instance.""" | ||
| 464 | if self.child: | ||
| 465 | try: | ||
| 466 | # Try graceful shutdown first | ||
| 467 | if self.booted: | ||
| 468 | self.child.sendline('poweroff') | ||
| 469 | time.sleep(2) | ||
| 470 | |||
| 471 | # Force terminate if still running | ||
| 472 | if self.child.isalive(): | ||
| 473 | self.child.terminate(force=True) | ||
| 474 | except Exception: | ||
| 475 | pass | ||
| 476 | finally: | ||
| 477 | if self.child.logfile_read: | ||
| 478 | self.child.logfile_read.close() | ||
| 479 | self.child = None | ||
| 480 | self.booted = False | ||
| 481 | |||
| 482 | |||
| 483 | @pytest.fixture(scope="module") | ||
| 484 | def check_rootfs_freshness(build_dir, machine, request): | ||
| 485 | """ | ||
| 486 | Check if the rootfs image is fresh compared to OCI containers and bbclass. | ||
| 487 | Warns if rootfs appears stale. | ||
| 488 | """ | ||
| 489 | image = request.config.getoption("--image") | ||
| 490 | fstype = request.config.getoption("--image-fstype") | ||
| 491 | |||
| 492 | deploy_dir = build_dir / "tmp" / "deploy" / "images" / machine | ||
| 493 | |||
| 494 | # Find the rootfs image | ||
| 495 | rootfs_pattern = f"{image}-{machine}.rootfs.{fstype}" | ||
| 496 | rootfs_files = list(deploy_dir.glob(f"{image}-*.rootfs.{fstype}")) | ||
| 497 | |||
| 498 | if not rootfs_files: | ||
| 499 | pytest.skip(f"No rootfs image found: {deploy_dir}/{rootfs_pattern}") | ||
| 500 | |||
| 501 | rootfs = max(rootfs_files, key=lambda p: p.stat().st_mtime) | ||
| 502 | rootfs_mtime = rootfs.stat().st_mtime | ||
| 503 | rootfs_age_hours = (time.time() - rootfs_mtime) / 3600 | ||
| 504 | |||
| 505 | # Check OCI container timestamps | ||
| 506 | oci_dirs = list(deploy_dir.glob("*-oci")) | ||
| 507 | stale_containers = [] | ||
| 508 | for oci_dir in oci_dirs: | ||
| 509 | if oci_dir.is_dir(): | ||
| 510 | oci_mtime = oci_dir.stat().st_mtime | ||
| 511 | if oci_mtime > rootfs_mtime: | ||
| 512 | stale_containers.append(oci_dir.name) | ||
| 513 | |||
| 514 | # Check bbclass timestamp | ||
| 515 | meta_virt = build_dir.parent / "meta-virtualization" | ||
| 516 | bbclass = meta_virt / "classes" / "container-cross-install.bbclass" | ||
| 517 | bbclass_newer = False | ||
| 518 | if bbclass.exists() and bbclass.stat().st_mtime > rootfs_mtime: | ||
| 519 | bbclass_newer = True | ||
| 520 | |||
| 521 | # Get options | ||
| 522 | fail_stale = request.config.getoption("--fail-stale") | ||
| 523 | max_age = request.config.getoption("--max-age") | ||
| 524 | |||
| 525 | # Generate warnings | ||
| 526 | warnings = [] | ||
| 527 | is_stale = False | ||
| 528 | |||
| 529 | if stale_containers: | ||
| 530 | warnings.append(f"OCI containers newer than rootfs: {', '.join(stale_containers)}") | ||
| 531 | is_stale = True | ||
| 532 | if bbclass_newer: | ||
| 533 | warnings.append("container-cross-install.bbclass modified after rootfs was built") | ||
| 534 | is_stale = True | ||
| 535 | if rootfs_age_hours > max_age: | ||
| 536 | warnings.append(f"rootfs is {rootfs_age_hours:.1f} hours old (max: {max_age}h)") | ||
| 537 | |||
| 538 | if warnings: | ||
| 539 | warning_msg = ( | ||
| 540 | f"\n{'='*60}\n" | ||
| 541 | f"{'ERROR' if (fail_stale and is_stale) else 'WARNING'}: Rootfs may be stale!\n" | ||
| 542 | f" Image: {rootfs.name}\n" | ||
| 543 | f" Built: {time.ctime(rootfs_mtime)}\n" | ||
| 544 | f" Issues:\n" | ||
| 545 | ) | ||
| 546 | for w in warnings: | ||
| 547 | warning_msg += f" - {w}\n" | ||
| 548 | warning_msg += ( | ||
| 549 | f"\n To rebuild:\n" | ||
| 550 | f" MACHINE={machine} bitbake {image} -C rootfs\n" | ||
| 551 | f"{'='*60}\n" | ||
| 552 | ) | ||
| 553 | print(warning_msg) | ||
| 554 | |||
| 555 | if fail_stale and is_stale: | ||
| 556 | pytest.fail("Rootfs is stale. Rebuild with: " | ||
| 557 | f"MACHINE={machine} bitbake {image} -C rootfs") | ||
| 558 | |||
| 559 | return { | ||
| 560 | 'rootfs': rootfs, | ||
| 561 | 'age_hours': rootfs_age_hours, | ||
| 562 | 'stale_containers': stale_containers, | ||
| 563 | 'bbclass_newer': bbclass_newer, | ||
| 564 | } | ||
| 565 | |||
| 566 | |||
| 567 | @pytest.fixture(scope="class") | ||
| 568 | def runqemu_session(request, poky_dir, build_dir, machine, check_rootfs_freshness): | ||
| 569 | """ | ||
| 570 | Fixture that boots an image and provides a session for running commands. | ||
| 571 | |||
| 572 | The session is shared across all tests in the class for efficiency. | ||
| 573 | """ | ||
| 574 | if not PEXPECT_AVAILABLE: | ||
| 575 | pytest.skip("pexpect not installed. Run: pip install pexpect") | ||
| 576 | |||
| 577 | image = request.config.getoption("--image") | ||
| 578 | fstype = request.config.getoption("--image-fstype") | ||
| 579 | timeout = request.config.getoption("--boot-timeout") | ||
| 580 | use_kvm = not request.config.getoption("--no-kvm") | ||
| 581 | |||
| 582 | session = RunqemuSession(poky_dir, build_dir, machine, image, | ||
| 583 | fstype=fstype, use_kvm=use_kvm, timeout=timeout) | ||
| 584 | |||
| 585 | try: | ||
| 586 | session.start() | ||
| 587 | yield session | ||
| 588 | finally: | ||
| 589 | session.stop() | ||
| 590 | |||
| 591 | |||
| 592 | def _detect_containers_from_rootfs(build_dir, machine, image): | ||
| 593 | """ | ||
| 594 | Detect bundled containers by checking storage directories in the rootfs. | ||
| 595 | |||
| 596 | Checks: | ||
| 597 | 1. Docker: /var/lib/docker/image/overlay2/repositories.json | ||
| 598 | 2. Podman: /var/lib/containers/storage/vfs-images/images.json | ||
| 599 | |||
| 600 | Returns dict with 'docker' and 'podman' lists, or None if no containers found. | ||
| 601 | """ | ||
| 602 | import subprocess | ||
| 603 | import json | ||
| 604 | |||
| 605 | docker_containers = [] | ||
| 606 | podman_containers = [] | ||
| 607 | |||
| 608 | # Find the rootfs ext4 image | ||
| 609 | deploy_dir = build_dir / "tmp" / "deploy" / "images" / machine | ||
| 610 | rootfs_pattern = f"{image}-{machine}.rootfs.ext4" | ||
| 611 | rootfs_path = deploy_dir / rootfs_pattern | ||
| 612 | |||
| 613 | if not rootfs_path.exists(): | ||
| 614 | # Try to find via symlink resolution | ||
| 615 | for f in deploy_dir.glob(f"{image}*.ext4"): | ||
| 616 | if f.is_symlink() or f.is_file(): | ||
| 617 | rootfs_path = f | ||
| 618 | break | ||
| 619 | |||
| 620 | if not rootfs_path.exists(): | ||
| 621 | return None | ||
| 622 | |||
| 623 | # Use debugfs to check container storage | ||
| 624 | try: | ||
| 625 | # Check Docker repositories.json | ||
| 626 | result = subprocess.run( | ||
| 627 | ['debugfs', '-R', 'cat /var/lib/docker/image/overlay2/repositories.json', str(rootfs_path)], | ||
| 628 | capture_output=True, text=True, timeout=10 | ||
| 629 | ) | ||
| 630 | if result.returncode == 0 and result.stdout.strip(): | ||
| 631 | try: | ||
| 632 | repos = json.loads(result.stdout) | ||
| 633 | # Docker repositories.json format: {"Repositories": {"name:tag": {"name:tag": "sha256:..."}}} | ||
| 634 | if 'Repositories' in repos: | ||
| 635 | for name in repos['Repositories'].keys(): | ||
| 636 | # Extract just the name part (before :tag) | ||
| 637 | container_name = name.split(':')[0] if ':' in name else name | ||
| 638 | docker_containers.append(container_name) | ||
| 639 | except json.JSONDecodeError: | ||
| 640 | pass | ||
| 641 | |||
| 642 | # Check Podman images.json | ||
| 643 | result = subprocess.run( | ||
| 644 | ['debugfs', '-R', 'cat /var/lib/containers/storage/vfs-images/images.json', str(rootfs_path)], | ||
| 645 | capture_output=True, text=True, timeout=10 | ||
| 646 | ) | ||
| 647 | if result.returncode == 0 and result.stdout.strip(): | ||
| 648 | try: | ||
| 649 | images = json.loads(result.stdout) | ||
| 650 | # Podman images.json is a list of image objects with 'names' field | ||
| 651 | for img in images: | ||
| 652 | if 'names' in img and img['names']: | ||
| 653 | for name in img['names']: | ||
| 654 | # Extract container name from full reference | ||
| 655 | # e.g., "docker.io/library/container-base:latest" -> "container-base" | ||
| 656 | parts = name.split('/') | ||
| 657 | last_part = parts[-1] if parts else name | ||
| 658 | container_name = last_part.split(':')[0] if ':' in last_part else last_part | ||
| 659 | podman_containers.append(container_name) | ||
| 660 | except json.JSONDecodeError: | ||
| 661 | pass | ||
| 662 | |||
| 663 | if docker_containers or podman_containers: | ||
| 664 | return { | ||
| 665 | 'docker': list(set(docker_containers)), # Deduplicate | ||
| 666 | 'podman': list(set(podman_containers)), | ||
| 667 | } | ||
| 668 | |||
| 669 | except (subprocess.TimeoutExpired, FileNotFoundError): | ||
| 670 | pass | ||
| 671 | |||
| 672 | return None | ||
| 673 | |||
| 674 | |||
| 675 | def _check_bundle_packages_in_manifest(build_dir, machine, image): | ||
| 676 | """ | ||
| 677 | Check if any container bundle packages are installed by looking at the manifest. | ||
| 678 | |||
| 679 | Returns True if bundle packages found, False otherwise. | ||
| 680 | """ | ||
| 681 | manifest = build_dir / "tmp" / "deploy" / "images" / machine / f"{image}-{machine}.rootfs.manifest" | ||
| 682 | if not manifest.exists(): | ||
| 683 | return False | ||
| 684 | |||
| 685 | content = manifest.read_text() | ||
| 686 | for line in content.splitlines(): | ||
| 687 | # Bundle packages typically have 'bundle' in the name | ||
| 688 | if 'bundle' in line.lower() and 'container' in line.lower(): | ||
| 689 | return True | ||
| 690 | return False | ||
| 691 | |||
| 692 | |||
| 693 | def _parse_bundled_containers_from_local_conf(build_dir): | ||
| 694 | """ | ||
| 695 | Parse legacy BUNDLED_CONTAINERS variable from local.conf. | ||
| 696 | |||
| 697 | Returns dict with 'docker' and 'podman' lists, or None if not configured. | ||
| 698 | """ | ||
| 699 | local_conf = build_dir / "conf" / "local.conf" | ||
| 700 | if not local_conf.exists(): | ||
| 701 | return None | ||
| 702 | |||
| 703 | content = local_conf.read_text() | ||
| 704 | |||
| 705 | docker_containers = [] | ||
| 706 | podman_containers = [] | ||
| 707 | |||
| 708 | for line in content.splitlines(): | ||
| 709 | line = line.strip() | ||
| 710 | if line.startswith('#'): | ||
| 711 | continue | ||
| 712 | if 'BUNDLED_CONTAINERS' not in line: | ||
| 713 | continue | ||
| 714 | |||
| 715 | # Parse the value: "container-oci:runtime container2-oci:runtime" | ||
| 716 | match = re.search(r'BUNDLED_CONTAINERS\s*=\s*"([^"]*)"', line) | ||
| 717 | if match: | ||
| 718 | value = match.group(1) | ||
| 719 | for item in value.split(): | ||
| 720 | if ':' in item: | ||
| 721 | container_oci, runtime = item.split(':', 1) | ||
| 722 | # Extract container name from OCI name | ||
| 723 | # e.g., "container-app-base-latest-oci" -> "container-app-base" | ||
| 724 | name = container_oci.replace('-latest-oci', '').replace('-oci', '') | ||
| 725 | if runtime == 'docker': | ||
| 726 | docker_containers.append(name) | ||
| 727 | elif runtime == 'podman': | ||
| 728 | podman_containers.append(name) | ||
| 729 | |||
| 730 | if docker_containers or podman_containers: | ||
| 731 | return { | ||
| 732 | 'docker': docker_containers, | ||
| 733 | 'podman': podman_containers, | ||
| 734 | } | ||
| 735 | |||
| 736 | return None | ||
| 737 | |||
| 738 | |||
| 739 | @pytest.fixture(scope="module") | ||
| 740 | def bundled_containers_config(build_dir, request): | ||
| 741 | """ | ||
| 742 | Detect bundled containers from rootfs storage or BUNDLED_CONTAINERS variable. | ||
| 743 | |||
| 744 | Detection order: | ||
| 745 | 1. Direct detection: Check container storage in rootfs | ||
| 746 | (Docker: /var/lib/docker, Podman: /var/lib/containers/storage) | ||
| 747 | 2. Legacy: Parse BUNDLED_CONTAINERS variable from local.conf | ||
| 748 | |||
| 749 | Returns a dict with: | ||
| 750 | - 'docker': list of container names expected in docker | ||
| 751 | - 'podman': list of container names expected in podman | ||
| 752 | """ | ||
| 753 | # Get machine and image from pytest options | ||
| 754 | machine = request.config.getoption("--machine", default="qemux86-64") | ||
| 755 | image = request.config.getoption("--image", default="container-image-host") | ||
| 756 | |||
| 757 | # Try to detect containers directly from rootfs storage | ||
| 758 | result = _detect_containers_from_rootfs(build_dir, machine, image) | ||
| 759 | if result: | ||
| 760 | return result | ||
| 761 | |||
| 762 | # Fallback to legacy BUNDLED_CONTAINERS variable | ||
| 763 | result = _parse_bundled_containers_from_local_conf(build_dir) | ||
| 764 | if result: | ||
| 765 | return result | ||
| 766 | |||
| 767 | # Check if bundle packages are installed but containers not detected | ||
| 768 | if _check_bundle_packages_in_manifest(build_dir, machine, image): | ||
| 769 | pytest.skip("Bundle packages installed but no containers detected in storage (image may need rebuild)") | ||
| 770 | |||
| 771 | pytest.skip("No container bundles found (no containers in rootfs storage and no BUNDLED_CONTAINERS in local.conf)") | ||
| 772 | |||
| 773 | |||
| 774 | class TestBundledContainersBoot: | ||
| 775 | """ | ||
| 776 | Boot tests to verify bundled containers are visible. | ||
| 777 | |||
| 778 | These tests boot the actual Yocto image and verify that | ||
| 779 | `docker images` or `podman images` shows the bundled containers. | ||
| 780 | |||
| 781 | Prerequisites: | ||
| 782 | - Image must be built with BUNDLED_CONTAINERS configured | ||
| 783 | - pexpect must be installed: pip install pexpect | ||
| 784 | |||
| 785 | Run with: | ||
| 786 | pytest tests/test_container_cross_install.py::TestBundledContainersBoot -v | ||
| 787 | |||
| 788 | Options: | ||
| 789 | --image IMAGE Image name to boot (default: core-image-minimal) | ||
| 790 | --machine MACHINE Machine to use (default: qemux86-64) | ||
| 791 | --boot-timeout SECS Timeout for boot (default: 120) | ||
| 792 | """ | ||
| 793 | |||
| 794 | @pytest.mark.slow | ||
| 795 | @pytest.mark.boot | ||
| 796 | def test_system_boots(self, runqemu_session): | ||
| 797 | """Test that the system boots successfully.""" | ||
| 798 | assert runqemu_session.booted, "System failed to boot" | ||
| 799 | |||
| 800 | # Basic sanity check | ||
| 801 | output = runqemu_session.run_command('uname -a') | ||
| 802 | assert 'Linux' in output, f"Unexpected uname output: {output}" | ||
| 803 | |||
| 804 | @pytest.mark.slow | ||
| 805 | @pytest.mark.boot | ||
| 806 | def test_docker_images_visible(self, runqemu_session, bundled_containers_config): | ||
| 807 | """Test that bundled Docker containers are visible.""" | ||
| 808 | expected = bundled_containers_config['docker'] | ||
| 809 | if not expected: | ||
| 810 | pytest.skip("No Docker containers in bundle packages or BUNDLED_CONTAINERS") | ||
| 811 | |||
| 812 | # Check if docker is available | ||
| 813 | output = runqemu_session.run_command('which docker') | ||
| 814 | if '/docker' not in output: | ||
| 815 | pytest.skip("docker not installed in image") | ||
| 816 | |||
| 817 | # Get docker images | ||
| 818 | output = runqemu_session.run_command('docker images', timeout=30) | ||
| 819 | print(f"docker images output:\n{output}") | ||
| 820 | |||
| 821 | # Verify each expected container is present | ||
| 822 | missing = [] | ||
| 823 | for container in expected: | ||
| 824 | if container not in output: | ||
| 825 | missing.append(container) | ||
| 826 | |||
| 827 | assert not missing, f"Missing Docker containers: {missing}\nOutput:\n{output}" | ||
| 828 | |||
| 829 | @pytest.mark.slow | ||
| 830 | @pytest.mark.boot | ||
| 831 | def test_podman_images_visible(self, runqemu_session, bundled_containers_config): | ||
| 832 | """Test that bundled Podman containers are visible.""" | ||
| 833 | expected = bundled_containers_config['podman'] | ||
| 834 | if not expected: | ||
| 835 | pytest.skip("No Podman containers in bundle packages or BUNDLED_CONTAINERS") | ||
| 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 | |||
| 878 | assert 'CONTAINER_WORKS' in output, \ | ||
| 879 | f"Container {container} failed to run.\nOutput:\n{output}" | ||
| 880 | |||
| 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] | ||
| 899 | output = runqemu_session.run_command( | ||
| 900 | f'podman run --rm --entrypoint /bin/echo {container}:latest "CONTAINER_WORKS"', | ||
| 901 | timeout=60 | ||
| 902 | ) | ||
| 903 | print(f"podman run output:\n{output}") | ||
| 904 | |||
| 905 | assert 'CONTAINER_WORKS' in output, \ | ||
| 906 | f"Container {container} failed to run.\nOutput:\n{output}" | ||
