From 4d8fc28985dbd69ca5b0b2cfe3e977d74fe5b3dd Mon Sep 17 00:00:00 2001 From: Bruce Ashfield Date: Wed, 14 Jan 2026 20:59:14 +0000 Subject: image-oci: add layer caching for multi-layer OCI builds Add layer caching to speed up multi-layer OCI image rebuilds. When enabled, pre-installed package layers are cached to disk and restored on subsequent builds, avoiding repeated package installation. New variables: - OCI_LAYER_CACHE: Enable/disable caching (default "1") - OCI_LAYER_CACHE_DIR: Cache location (default ${TOPDIR}/oci-layer-cache/${MACHINE}) Cache key is computed from: - Layer name and type - Sorted package list - Package versions from PKGDATA_DIR - MACHINE and TUNE_PKGARCH Cache automatically invalidates when: - Package versions change - Layer definition changes - Architecture changes Benefits: - First build: ~10-30s per layer (cache miss, packages installed) - Subsequent builds: ~1s per layer (cache hit, files copied) - Shared across recipes with identical layer definitions Build log shows cache status: NOTE: OCI Cache HIT: Layer 'base' (be88c180f651416b) NOTE: OCI: Pre-installed packages for 3 layers (cache: 3 hits, 0 misses) Also adds comprehensive pytest suite for multi-layer OCI functionality including tests for 1/2/3 layer modes and cache behavior. Signed-off-by: Bruce Ashfield --- classes/image-oci-umoci.inc | 177 +++++++++++++++- classes/image-oci.bbclass | 26 +++ docs/container-bundling.md | 64 ++++++ tests/test_multilayer_oci.py | 466 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 731 insertions(+), 2 deletions(-) create mode 100644 tests/test_multilayer_oci.py diff --git a/classes/image-oci-umoci.inc b/classes/image-oci-umoci.inc index 1ff36718..fbb77cd0 100644 --- a/classes/image-oci-umoci.inc +++ b/classes/image-oci-umoci.inc @@ -5,12 +5,152 @@ # directories using Yocto's package manager classes. The shell code then copies # from these pre-installed directories. +def oci_compute_layer_cache_key(d, layer_name, layer_type, layer_packages): + """ + Compute a cache key for a layer based on its definition and package versions. + + The cache key is a SHA256 hash of: + - Layer name and type + - Sorted package list + - Package versions from PKGDATA_DIR + - Machine and architecture info + + Returns: (cache_key, cache_info) tuple where cache_info is human-readable + """ + import hashlib + import os + import json + + packages = sorted(layer_packages.split()) + pkgdata_dir = d.getVar('PKGDATA_DIR') + machine = d.getVar('MACHINE') + tune_pkgarch = d.getVar('TUNE_PKGARCH') + + # Build cache key components + cache_components = { + 'layer_name': layer_name, + 'layer_type': layer_type, + 'packages': packages, + 'machine': machine, + 'tune_pkgarch': tune_pkgarch, + 'pkg_versions': {} + } + + # Get package versions from pkgdata + for pkg in packages: + pkg_info_file = os.path.join(pkgdata_dir, 'runtime', pkg) + if os.path.exists(pkg_info_file): + try: + with open(pkg_info_file, 'r') as f: + for line in f: + if line.startswith('PKGV:'): + cache_components['pkg_versions'][pkg] = line.split(':', 1)[1].strip() + break + elif line.startswith(f'PKGV_{pkg}:'): + cache_components['pkg_versions'][pkg] = line.split(':', 1)[1].strip() + break + except Exception: + pass + + # Create deterministic JSON for hashing + cache_json = json.dumps(cache_components, sort_keys=True) + cache_key = hashlib.sha256(cache_json.encode()).hexdigest()[:16] + + # Human-readable info for logging + pkg_vers = [f"{p}={cache_components['pkg_versions'].get(p, '?')}" for p in packages[:3]] + if len(packages) > 3: + pkg_vers.append(f"...+{len(packages)-3} more") + cache_info = f"{layer_name}:{' '.join(pkg_vers)}" + + return cache_key, cache_info + + +def oci_check_layer_cache(d, cache_key, layer_name): + """ + Check if a cached layer exists. + + Returns: path to cached layer rootfs if found, None otherwise + """ + import os + + cache_dir = d.getVar('OCI_LAYER_CACHE_DIR') + if not cache_dir: + return None + + cached_path = os.path.join(cache_dir, f'{cache_key}-{layer_name}') + marker_file = os.path.join(cached_path, '.oci-layer-cache') + + if os.path.isdir(cached_path) and os.path.exists(marker_file): + return cached_path + + return None + + +def oci_cache_layer(d, cache_key, layer_name, layer_rootfs): + """ + Save a layer rootfs to the cache. + """ + import os + import shutil + import time + + cache_dir = d.getVar('OCI_LAYER_CACHE_DIR') + if not cache_dir: + return + + bb.utils.mkdirhier(cache_dir) + + cached_path = os.path.join(cache_dir, f'{cache_key}-{layer_name}') + + # Remove any existing cache for this key + if os.path.exists(cached_path): + shutil.rmtree(cached_path) + + # Copy layer rootfs to cache + bb.note(f"OCI Cache: Saving layer '{layer_name}' to cache ({cache_key})") + shutil.copytree(layer_rootfs, cached_path, symlinks=True) + + # Write cache marker with metadata + marker_file = os.path.join(cached_path, '.oci-layer-cache') + with open(marker_file, 'w') as f: + f.write(f"cache_key={cache_key}\n") + f.write(f"layer_name={layer_name}\n") + f.write(f"created={time.strftime('%Y-%m-%dT%H:%M:%SZ')}\n") + f.write(f"machine={d.getVar('MACHINE')}\n") + + +def oci_restore_layer_from_cache(d, cached_path, layer_rootfs): + """ + Restore a layer rootfs from the cache. + """ + import os + import shutil + + # Ensure target directory exists and is empty + if os.path.exists(layer_rootfs): + shutil.rmtree(layer_rootfs) + bb.utils.mkdirhier(layer_rootfs) + + # Copy cached content (excluding the cache marker) + for item in os.listdir(cached_path): + if item == '.oci-layer-cache': + continue + src = os.path.join(cached_path, item) + dst = os.path.join(layer_rootfs, item) + if os.path.isdir(src): + shutil.copytree(src, dst, symlinks=True) + else: + shutil.copy2(src, dst) + + python oci_multilayer_install_packages() { """ Pre-install packages for each packages layer in OCI_LAYERS. Creates temp rootfs directories with packages installed using Yocto's PM. The shell IMAGE_CMD:oci then copies from these directories. + + Supports layer caching when OCI_LAYER_CACHE = "1" to speed up rebuilds. """ import os import shutil @@ -32,7 +172,15 @@ python oci_multilayer_install_packages() { shutil.rmtree(layer_rootfs_base) bb.utils.mkdirhier(layer_rootfs_base) - bb.note("OCI: Pre-installing packages for multi-layer mode") + # Check if caching is enabled + cache_enabled = d.getVar('OCI_LAYER_CACHE') == '1' + if cache_enabled: + bb.note("OCI: Pre-installing packages for multi-layer mode (caching enabled)") + else: + bb.note("OCI: Pre-installing packages for multi-layer mode (caching disabled)") + + cache_hits = 0 + cache_misses = 0 # Parse OCI_LAYERS and install packages for each packages layer layer_num = 0 @@ -48,17 +196,42 @@ python oci_multilayer_install_packages() { layer_num += 1 layer_rootfs = os.path.join(layer_rootfs_base, f'layer-{layer_num}-{layer_name}') + # Check cache if enabled + if cache_enabled: + cache_key, cache_info = oci_compute_layer_cache_key(d, layer_name, layer_type, layer_content) + cached_path = oci_check_layer_cache(d, cache_key, layer_name) + + if cached_path: + bb.note(f"OCI Cache HIT: Layer '{layer_name}' ({cache_key})") + oci_restore_layer_from_cache(d, cached_path, layer_rootfs) + cache_hits += 1 + # Store the path for the shell code to use + d.setVar(f'OCI_LAYER_{layer_num}_ROOTFS', layer_rootfs) + d.setVar(f'OCI_LAYER_{layer_num}_NAME', layer_name) + continue + else: + bb.note(f"OCI Cache MISS: Layer '{layer_name}' ({cache_info})") + cache_misses += 1 + bb.note(f"OCI: Pre-installing layer {layer_num} '{layer_name}' to {layer_rootfs}") # Call the package installation function oci_install_layer_packages(d, layer_rootfs, layer_content, layer_name) + # Cache the installed layer if caching is enabled + if cache_enabled: + oci_cache_layer(d, cache_key, layer_name, layer_rootfs) + # Store the path for the shell code to use d.setVar(f'OCI_LAYER_{layer_num}_ROOTFS', layer_rootfs) d.setVar(f'OCI_LAYER_{layer_num}_NAME', layer_name) d.setVar('OCI_LAYER_COUNT', str(layer_num)) - bb.note(f"OCI: Pre-installed packages for {layer_num} layers") + + if cache_enabled: + bb.note(f"OCI: Pre-installed packages for {layer_num} layers (cache: {cache_hits} hits, {cache_misses} misses)") + else: + bb.note(f"OCI: Pre-installed packages for {layer_num} layers") } # Run the Python function before IMAGE_CMD:oci diff --git a/classes/image-oci.bbclass b/classes/image-oci.bbclass index 64b17d97..41f7b3d1 100644 --- a/classes/image-oci.bbclass +++ b/classes/image-oci.bbclass @@ -195,6 +195,32 @@ OCI_LAYER_MODE ?= "single" # OCI_LAYERS ?= "" +# ============================================================================= +# Layer Caching (for multi-layer mode) +# ============================================================================= +# +# OCI_LAYER_CACHE: Enable/disable layer caching ("1" or "0") +# When enabled, pre-installed package layers are cached to avoid +# reinstalling packages on subsequent builds. +# +# OCI_LAYER_CACHE_DIR: Directory for storing cached layers +# Default: ${TOPDIR}/oci-layer-cache/${MACHINE} +# Cache is keyed by: layer definition + package versions + architecture +# +# Cache key components: +# - Layer name and type +# - Sorted package list +# - Package versions (from PKGDATA_DIR) +# - MACHINE and TUNE_PKGARCH +# +# Cache invalidation: +# - Any package version change invalidates layers containing that package +# - Layer definition changes invalidate that specific layer +# - MACHINE/arch changes use separate cache directories +# +OCI_LAYER_CACHE ?= "1" +OCI_LAYER_CACHE_DIR ?= "${TOPDIR}/oci-layer-cache/${MACHINE}" + # whether the oci image dir should be left as a directory, or # bundled into a tarball. OCI_IMAGE_TAR_OUTPUT ?= "true" diff --git a/docs/container-bundling.md b/docs/container-bundling.md index a695a5b8..745622b5 100644 --- a/docs/container-bundling.md +++ b/docs/container-bundling.md @@ -193,6 +193,70 @@ OCI_LAYERS = "\ " ``` +### Layer Caching + +Multi-layer builds cache pre-installed package layers for faster rebuilds. +Installing packages requires configuring the package manager, resolving +dependencies, and running post-install scripts - this is slow. Caching +saves the fully-installed layer rootfs after the first build so subsequent +builds can skip package installation entirely. + +#### Configuration + + # Enabled by default + OCI_LAYER_CACHE ?= "1" + OCI_LAYER_CACHE_DIR ?= "${TOPDIR}/oci-layer-cache/${MACHINE}" + +#### Cache Key Components + +The cache key is a SHA256 hash of: + +| Component | Why It Matters | +|-----------|----------------| +| Layer name | Different layers cached separately | +| Layer type | `packages` vs `directories` vs `files` | +| Package list (sorted) | Adding/removing packages invalidates cache | +| Package versions | Upgrading a package invalidates cache | +| MACHINE, TUNE_PKGARCH | Architecture-specific packages | + +#### Advantages + +**Faster rebuilds**: Subsequent builds restore cached layers in ~1 second +instead of ~10-30 seconds per layer for package installation. + +**Efficient development**: When only your app layer changes, base and +dependency layers are restored from cache: + + OCI_LAYERS = "\ + base:packages:base-files+busybox \ # Cached - stable + deps:packages:python3+python3-pip \ # Cached - stable + app:packages:myapp \ # Rebuilt - changes often + " + +**Automatic invalidation**: Cache invalidates when packages change version, +layers are modified, or architecture changes. No manual clearing needed. + +**Shared across recipes**: Cache stored in `${TOPDIR}/oci-layer-cache/` so +recipes with identical layers share the same cached content. + +#### Build Log Example + + # First build - cache misses + NOTE: OCI Cache MISS: Layer 'base' (base:base-files=3.0.14 ...) + NOTE: OCI Cache: Saving layer 'base' to cache (be88c180f651416b) + NOTE: OCI: Pre-installed packages for 3 layers (cache: 0 hits, 3 misses) + + # Second build - cache hits + NOTE: OCI Cache HIT: Layer 'base' (be88c180f651416b) + NOTE: OCI: Pre-installed packages for 3 layers (cache: 3 hits, 0 misses) + +#### When to Disable + +Disable caching with `OCI_LAYER_CACHE = "0"` if you: +- Suspect cache corruption +- Need fully reproducible builds with no local state +- Are debugging package installation issues + ### OCI_IMAGE_CMD vs OCI_IMAGE_ENTRYPOINT # CMD (default) - replaced when user passes arguments diff --git a/tests/test_multilayer_oci.py b/tests/test_multilayer_oci.py new file mode 100644 index 00000000..eac3d23e --- /dev/null +++ b/tests/test_multilayer_oci.py @@ -0,0 +1,466 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield +# +# SPDX-License-Identifier: MIT +""" +Tests for multi-layer OCI container image support. + +These tests verify that OCI_LAYER_MODE = "multi" creates proper multi-layer +OCI images and that layer caching works correctly. + +Run with: + pytest tests/test_multilayer_oci.py -v --poky-dir /opt/bruce/poky + +Environment variables: + POKY_DIR: Path to poky directory (default: /opt/bruce/poky) + BUILD_DIR: Path to build directory (default: $POKY_DIR/build) + MACHINE: Target machine (default: qemux86-64) + +Note: These tests require a configured Yocto build environment. +""" + +import os +import json +import subprocess +import shutil +import pytest +from pathlib import Path + + +# Note: Command line options are defined in conftest.py + + +@pytest.fixture(scope="module") +def poky_dir(request): + """Path to poky directory.""" + path = Path(request.config.getoption("--poky-dir")) + if not path.exists(): + pytest.skip(f"Poky directory not found: {path}") + return path + + +@pytest.fixture(scope="module") +def build_dir(request, poky_dir): + """Path to build directory.""" + path = request.config.getoption("--build-dir") + if path: + path = Path(path) + else: + path = poky_dir / "build" + + if not path.exists(): + pytest.skip(f"Build directory not found: {path}") + return path + + +@pytest.fixture(scope="module") +def machine(request): + """Target machine.""" + return request.config.getoption("--machine") + + +@pytest.fixture(scope="module") +def deploy_dir(build_dir, machine): + """Path to deploy directory for the machine.""" + path = build_dir / "tmp" / "deploy" / "images" / machine + if not path.exists(): + pytest.skip(f"Deploy directory not found: {path}") + return path + + +@pytest.fixture(scope="module") +def meta_virt_dir(poky_dir): + """Path to meta-virtualization layer.""" + path = poky_dir / "meta-virtualization" + if not path.exists(): + pytest.skip(f"meta-virtualization not found: {path}") + return path + + +@pytest.fixture(scope="module") +def layer_cache_dir(build_dir, machine): + """Path to OCI layer cache directory.""" + return build_dir / "oci-layer-cache" / machine + + +def run_bitbake(build_dir, recipe, task=None, extra_args=None, timeout=1800): + """Run a bitbake command within the Yocto environment.""" + # Build the bitbake command + bb_cmd = "bitbake" + if task: + bb_cmd += f" -c {task}" + bb_cmd += f" {recipe}" + if extra_args: + bb_cmd += " " + " ".join(extra_args) + + # Source oe-init-build-env and run bitbake + poky_dir = build_dir.parent + full_cmd = f"bash -c 'cd {poky_dir} && source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" + + result = subprocess.run( + full_cmd, + shell=True, + cwd=build_dir, + timeout=timeout, + capture_output=True, + text=True, + ) + return result + + +def get_oci_layer_count(oci_dir): + """Get the number of layers in an OCI image using skopeo.""" + result = subprocess.run( + ["skopeo", "inspect", f"oci:{oci_dir}"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + return None + + try: + data = json.loads(result.stdout) + return len(data.get("Layers", [])) + except json.JSONDecodeError: + return None + + +def get_task_log(build_dir, machine, recipe, task): + """Get the path to a bitbake task log.""" + work_dir = build_dir / "tmp" / "work" + + # Find the work directory for the recipe + for arch_dir in work_dir.glob(f"*{machine}*"): + recipe_dir = arch_dir / recipe + if recipe_dir.exists(): + # Find the latest version directory + for version_dir in sorted(recipe_dir.iterdir(), reverse=True): + log_dir = version_dir / "temp" + logs = list(log_dir.glob(f"log.{task}.*")) + if logs: + return max(logs, key=lambda p: p.stat().st_mtime) + return None + + +class TestMultiLayerOCIClass: + """Test OCI multi-layer bbclass functionality.""" + + def test_bbclass_exists(self, meta_virt_dir): + """Test that the image-oci.bbclass file exists.""" + class_file = meta_virt_dir / "classes" / "image-oci.bbclass" + assert class_file.exists(), f"Class file not found: {class_file}" + + def test_umoci_inc_exists(self, meta_virt_dir): + """Test that the image-oci-umoci.inc file exists.""" + inc_file = meta_virt_dir / "classes" / "image-oci-umoci.inc" + assert inc_file.exists(), f"Include file not found: {inc_file}" + + def test_multilayer_recipe_exists(self, meta_virt_dir): + """Test that the multi-layer demo recipe exists.""" + recipe = meta_virt_dir / "recipes-demo" / "images" / "app-container-multilayer.bb" + assert recipe.exists(), f"Recipe not found: {recipe}" + + def test_cache_variables_defined(self, meta_virt_dir): + """Test that layer caching variables are defined in bbclass.""" + class_file = meta_virt_dir / "classes" / "image-oci.bbclass" + content = class_file.read_text() + + assert "OCI_LAYER_CACHE" in content, "OCI_LAYER_CACHE not defined" + assert "OCI_LAYER_CACHE_DIR" in content, "OCI_LAYER_CACHE_DIR not defined" + + def test_layer_mode_variables_defined(self, meta_virt_dir): + """Test that layer mode variables are defined in bbclass.""" + class_file = meta_virt_dir / "classes" / "image-oci.bbclass" + content = class_file.read_text() + + assert "OCI_LAYER_MODE" in content, "OCI_LAYER_MODE not defined" + assert "OCI_LAYERS" in content, "OCI_LAYERS not defined" + + +class TestMultiLayerOCIBuild: + """Test building multi-layer OCI images.""" + + @pytest.mark.slow + def test_multilayer_recipe_builds(self, build_dir): + """Test that app-container-multilayer recipe builds successfully.""" + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + + if result.returncode != 0: + if "Nothing PROVIDES" in result.stderr: + pytest.skip("app-container-multilayer recipe not available") + pytest.fail(f"Build failed:\nstdout: {result.stdout}\nstderr: {result.stderr}") + + @pytest.mark.slow + def test_multilayer_produces_correct_layers(self, build_dir, deploy_dir): + """Test that multi-layer build produces 3 layers.""" + # Ensure the recipe is built + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed, skipping layer count check") + + # Find the OCI directory + oci_dirs = list(deploy_dir.glob("app-container-multilayer*-oci")) + assert len(oci_dirs) > 0, "No OCI directory found for app-container-multilayer" + + # Get the actual OCI directory (resolve symlink if needed) + oci_dir = oci_dirs[0] + if oci_dir.is_symlink(): + oci_dir = oci_dir.resolve() + + # Check layer count + layer_count = get_oci_layer_count(oci_dir) + assert layer_count is not None, f"Failed to inspect OCI image: {oci_dir}" + assert layer_count == 3, f"Expected 3 layers, got {layer_count}" + + +class TestLayerCaching: + """Test OCI layer caching functionality.""" + + @pytest.mark.slow + def test_cache_directory_created(self, build_dir, layer_cache_dir): + """Test that the layer cache directory is created after build.""" + # Run the build + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed, skipping cache test") + + # Check cache directory exists + assert layer_cache_dir.exists(), f"Cache directory not created: {layer_cache_dir}" + + @pytest.mark.slow + def test_cache_entries_exist(self, build_dir, layer_cache_dir): + """Test that cache entries are created for each layer.""" + # Run the build + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed, skipping cache test") + + # Skip if cache dir doesn't exist + if not layer_cache_dir.exists(): + pytest.skip("Cache directory not found") + + # Check for cache entries (format: {hash}-{layer_name}) + cache_entries = list(layer_cache_dir.iterdir()) + assert len(cache_entries) >= 3, f"Expected at least 3 cache entries, found {len(cache_entries)}" + + # Check for expected layer names + entry_names = [e.name for e in cache_entries] + has_base = any("base" in name for name in entry_names) + has_shell = any("shell" in name for name in entry_names) + has_app = any("app" in name for name in entry_names) + + assert has_base, f"No cache entry for 'base' layer. Found: {entry_names}" + assert has_shell, f"No cache entry for 'shell' layer. Found: {entry_names}" + assert has_app, f"No cache entry for 'app' layer. Found: {entry_names}" + + @pytest.mark.slow + def test_cache_marker_file(self, build_dir, layer_cache_dir): + """Test that cache entries have marker files.""" + # Run the build + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed, skipping cache test") + + if not layer_cache_dir.exists(): + pytest.skip("Cache directory not found") + + # Check each cache entry has a marker file + cache_entries = [e for e in layer_cache_dir.iterdir() if e.is_dir()] + for entry in cache_entries: + marker = entry / ".oci-layer-cache" + assert marker.exists(), f"No marker file in cache entry: {entry}" + + # Check marker content + content = marker.read_text() + assert "cache_key=" in content + assert "layer_name=" in content + assert "created=" in content + + @pytest.mark.slow + def test_cache_hit_on_rebuild(self, build_dir, machine): + """Test that cache hits occur on rebuild.""" + # First build - should have cache misses + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + if result.returncode != 0: + pytest.skip("First build failed") + + # Clean the work directory to force re-run of do_image_oci + work_pattern = f"tmp/work/*{machine}*/app-container-multilayer/*/oci-layer-rootfs" + for work_dir in build_dir.glob(work_pattern): + if work_dir.exists(): + shutil.rmtree(work_dir) + + # Remove stamp file to force task re-run + stamp_pattern = f"tmp/stamps/*{machine}*/app-container-multilayer/*.do_image_oci*" + for stamp in build_dir.glob(stamp_pattern): + stamp.unlink() + + # Second build - should have cache hits + result = run_bitbake(build_dir, "app-container-multilayer", timeout=3600) + if result.returncode != 0: + pytest.fail(f"Second build failed:\n{result.stderr}") + + # Check the log for cache hit messages + log_file = get_task_log(build_dir, machine, "app-container-multilayer", "do_image_oci") + if log_file and log_file.exists(): + log_content = log_file.read_text() + assert "OCI Cache HIT" in log_content, \ + "No cache hits found in log. Expected 'OCI Cache HIT' messages." + # Count hits vs misses + hits = log_content.count("OCI Cache HIT") + misses = log_content.count("OCI Cache MISS") + assert hits >= 3, f"Expected at least 3 cache hits, got {hits} hits and {misses} misses" + + +class TestSingleLayerBackwardCompat: + """Test that single-layer mode (default) still works.""" + + @pytest.mark.slow + def test_single_layer_recipe_builds(self, build_dir, meta_virt_dir): + """Test that a single-layer OCI recipe still builds.""" + # Check if app-container (single-layer) recipe exists + recipe = meta_virt_dir / "recipes-demo" / "images" / "app-container.bb" + if not recipe.exists(): + pytest.skip("app-container recipe not found") + + result = run_bitbake(build_dir, "app-container", timeout=3600) + if result.returncode != 0: + if "Nothing PROVIDES" in result.stderr: + pytest.skip("app-container recipe not available") + pytest.fail(f"Build failed: {result.stderr}") + + @pytest.mark.slow + def test_single_layer_produces_one_layer(self, build_dir, deploy_dir, meta_virt_dir): + """Test that single-layer build produces 1 layer.""" + # Check if recipe exists + recipe = meta_virt_dir / "recipes-demo" / "images" / "app-container.bb" + if not recipe.exists(): + pytest.skip("app-container recipe not found") + + result = run_bitbake(build_dir, "app-container", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed") + + # Find the OCI directory + oci_dirs = list(deploy_dir.glob("app-container-*-oci")) + # Filter out multilayer + oci_dirs = [d for d in oci_dirs if "multilayer" not in d.name] + + if not oci_dirs: + pytest.skip("No OCI directory found for app-container") + + oci_dir = oci_dirs[0] + if oci_dir.is_symlink(): + oci_dir = oci_dir.resolve() + + layer_count = get_oci_layer_count(oci_dir) + assert layer_count is not None, f"Failed to inspect OCI image: {oci_dir}" + assert layer_count == 1, f"Expected 1 layer for single-layer mode, got {layer_count}" + + +class TestTwoLayerBaseImage: + """Test two-layer OCI images using OCI_BASE_IMAGE.""" + + def test_layered_recipe_exists(self, meta_virt_dir): + """Test that the two-layer demo recipe exists.""" + recipe = meta_virt_dir / "recipes-demo" / "images" / "app-container-layered.bb" + assert recipe.exists(), f"Recipe not found: {recipe}" + + def test_layered_recipe_uses_base_image(self, meta_virt_dir): + """Test that the layered recipe uses OCI_BASE_IMAGE.""" + recipe = meta_virt_dir / "recipes-demo" / "images" / "app-container-layered.bb" + if not recipe.exists(): + pytest.skip("Recipe not found") + + content = recipe.read_text() + assert "OCI_BASE_IMAGE" in content, "Recipe should use OCI_BASE_IMAGE" + assert "container-base" in content, "Recipe should use container-base as base" + + @pytest.mark.slow + def test_layered_recipe_builds(self, build_dir): + """Test that app-container-layered recipe builds successfully.""" + # First ensure the base image is built + result = run_bitbake(build_dir, "container-base", timeout=3600) + if result.returncode != 0: + if "Nothing PROVIDES" in result.stderr: + pytest.skip("container-base recipe not available") + pytest.fail(f"Base image build failed: {result.stderr}") + + # Now build the layered image + result = run_bitbake(build_dir, "app-container-layered", timeout=3600) + if result.returncode != 0: + if "Nothing PROVIDES" in result.stderr: + pytest.skip("app-container-layered recipe not available") + pytest.fail(f"Build failed:\nstdout: {result.stdout}\nstderr: {result.stderr}") + + @pytest.mark.slow + def test_layered_produces_two_layers(self, build_dir, deploy_dir): + """Test that two-layer build produces 2 layers (base + app).""" + # Ensure the base is built first + result = run_bitbake(build_dir, "container-base", timeout=3600) + if result.returncode != 0: + pytest.skip("Base image build failed") + + # Build the layered image + result = run_bitbake(build_dir, "app-container-layered", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed, skipping layer count check") + + # Find the OCI directory + oci_dirs = list(deploy_dir.glob("app-container-layered*-oci")) + assert len(oci_dirs) > 0, "No OCI directory found for app-container-layered" + + # Get the actual OCI directory (resolve symlink if needed) + oci_dir = oci_dirs[0] + if oci_dir.is_symlink(): + oci_dir = oci_dir.resolve() + + # Check layer count - should be 2 (base + app) + layer_count = get_oci_layer_count(oci_dir) + assert layer_count is not None, f"Failed to inspect OCI image: {oci_dir}" + assert layer_count == 2, f"Expected 2 layers (base + app), got {layer_count}" + + @pytest.mark.slow + def test_base_image_produces_one_layer(self, build_dir, deploy_dir): + """Test that container-base (the base image) produces 1 layer.""" + result = run_bitbake(build_dir, "container-base", timeout=3600) + if result.returncode != 0: + pytest.skip("Build failed") + + # Find the OCI directory + oci_dirs = list(deploy_dir.glob("container-base*-oci")) + if not oci_dirs: + pytest.skip("No OCI directory found for container-base") + + oci_dir = oci_dirs[0] + if oci_dir.is_symlink(): + oci_dir = oci_dir.resolve() + + layer_count = get_oci_layer_count(oci_dir) + assert layer_count is not None, f"Failed to inspect OCI image: {oci_dir}" + assert layer_count == 1, f"Expected 1 layer for base image, got {layer_count}" + + +class TestLayerTypes: + """Test different OCI_LAYERS types.""" + + def test_packages_layer_type(self, meta_virt_dir): + """Test that 'packages' layer type is supported.""" + recipe = meta_virt_dir / "recipes-demo" / "images" / "app-container-multilayer.bb" + if not recipe.exists(): + pytest.skip("Recipe not found") + + content = recipe.read_text() + assert "packages" in content, "Recipe should use 'packages' layer type" + + def test_directories_layer_type_documented(self, meta_virt_dir): + """Test that 'directories' layer type is documented.""" + class_file = meta_virt_dir / "classes" / "image-oci.bbclass" + content = class_file.read_text() + assert "directories" in content, "directories layer type should be documented" + + def test_files_layer_type_documented(self, meta_virt_dir): + """Test that 'files' layer type is documented.""" + class_file = meta_virt_dir / "classes" / "image-oci.bbclass" + content = class_file.read_text() + assert "files" in content, "files layer type should be documented" -- cgit v1.2.3-54-g00ecf