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 --- tests/test_multilayer_oci.py | 466 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tests/test_multilayer_oci.py (limited to 'tests') 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