summaryrefslogtreecommitdiffstats
path: root/tests/test_container_cross_install.py
diff options
context:
space:
mode:
authorBruce Ashfield <bruce.ashfield@gmail.com>2026-01-01 17:15:29 +0000
committerBruce Ashfield <bruce.ashfield@gmail.com>2026-02-09 03:32:52 +0000
commit1165c61f5ab8ada644c7def03e991890c4d380ca (patch)
tree1cd1c1d58039afad5a1d24b5c10d8030237e2d7c /tests/test_container_cross_install.py
parentc32e1081c81ba27f0d5a21a1885601f04d329d21 (diff)
downloadmeta-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.py906
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"""
5Tests for container-cross-install - Yocto container bundling system.
6
7These tests verify that container-cross-install correctly bundles
8OCI containers into Yocto images.
9
10Run with:
11 pytest tests/test_container_cross_install.py -v
12
13Run boot tests (requires built image):
14 pytest tests/test_container_cross_install.py::TestBundledContainersBoot -v
15
16Environment 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
21Note: These tests require a configured Yocto build environment.
22"""
23
24import os
25import re
26import subprocess
27import time
28import pytest
29from pathlib import Path
30
31# Optional import for boot tests
32try:
33 import pexpect
34 PEXPECT_AVAILABLE = True
35except 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")
44def 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")
53def 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")
67def machine(request):
68 """Target machine."""
69 return request.config.getoption("--machine")
70
71
72@pytest.fixture(scope="module")
73def 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")
82def 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
90def 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
113class 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
131class 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
150class 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
181class 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
278class 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
311class 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
353class 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")
484def 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")
568def 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
592def _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
675def _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
693def _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")
740def 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
774class 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}"