summaryrefslogtreecommitdiffstats
path: root/tests/test_multilayer_oci.py
blob: eac3d23e53249cb64699cc343f4df7f063d2c4aa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
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"