From 25acb056c37b85fc76961cf8b477f006455b11d3 Mon Sep 17 00:00:00 2001 From: Bruce Ashfield Date: Thu, 15 Jan 2026 21:50:38 +0000 Subject: vcontainer: add multi-arch OCI support Add functions to detect and handle multi-architecture OCI Image Index format with automatic platform selection during import. Also add oci-multiarch.bbclass for build-time multi-arch OCI creation. Runtime support (vcontainer-common.sh): - is_oci_image_index() - detect multi-arch OCI images - get_oci_platforms() - list available platforms - select_platform_manifest() - select manifest for target architecture - extract_platform_oci() - extract single platform to new OCI dir - normalize_arch_to_oci/from_oci() - architecture name mapping - Update vimport to auto-select platform from multi-arch images Build-time support (oci-multiarch.bbclass): - Create OCI Image Index from multiconfig builds - Collect images from vruntime-aarch64, vruntime-x86-64 - Combine blobs and create unified manifest list Signed-off-by: Bruce Ashfield --- tests/README.md | 63 +++- tests/conftest.py | 10 +- tests/test_multiarch_oci.py | 711 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 777 insertions(+), 7 deletions(-) create mode 100644 tests/test_multiarch_oci.py (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index 92719a66..4f5aed28 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,10 +33,10 @@ source oe-init-build-env # 3. Build the standalone SDK tarball (includes blobs + QEMU) MACHINE=qemux86-64 bitbake vcontainer-tarball -# Output: tmp/deploy/sdk/vcontainer-standalone-x86_64.sh +# Output: tmp/deploy/sdk/vcontainer-standalone.sh # 4. Extract the tarball (self-extracting installer) -/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone-x86_64.sh -d /tmp/vcontainer -y +/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone.sh -d /tmp/vcontainer -y # 5. Set up the environment cd /tmp/vcontainer @@ -441,6 +441,20 @@ tests/ │ ├── TestVdkrRecipes # vdkr builds │ ├── TestMulticonfig # multiconfig setup │ └── TestBundledContainersBoot # boot and verify containers +├── test_multiarch_oci.py # Multi-architecture OCI tests +│ ├── TestOCIImageIndexDetection # multi-arch OCI detection +│ ├── TestPlatformSelection # arch selection (aarch64/x86_64) +│ ├── TestGetOCIPlatforms # platform listing +│ ├── TestExtractPlatformOCI # single-platform extraction +│ ├── TestMultiArchOCIClass # oci-multiarch.bbclass tests +│ ├── TestBackwardCompatibility # single-arch OCI compat +│ ├── TestVrunnerMultiArch # vrunner.sh multi-arch support +│ ├── TestVcontainerCommonMultiArch # vcontainer-common.sh support +│ └── TestContainerRegistryMultiArch # registry manifest list support +├── test_multilayer_oci.py # Multi-layer OCI tests +│ ├── TestMultiLayerOCIClass # OCI_LAYERS support +│ ├── TestMultiLayerOCIBuild # layer build verification +│ └── TestLayerCaching # layer cache tests └── README.md # This file ``` @@ -448,7 +462,46 @@ tests/ ## Quick Reference -### Full vdkr + vpdmn test run (recommended) +### Full Multi-Architecture Regression Test (recommended) + +This builds everything needed for comprehensive testing of both x86_64 and aarch64: + +```bash +# Build all components (blobs, SDK, images, containers for both architectures) +cd /opt/bruce/poky && source oe-init-build-env && \ +bitbake mc:vruntime-aarch64:vdkr-initramfs-create && \ +bitbake mc:vruntime-x86-64:vdkr-initramfs-create && \ +bitbake mc:vruntime-aarch64:vpdmn-initramfs-create && \ +bitbake mc:vruntime-x86-64:vpdmn-initramfs-create && \ +MACHINE=qemux86-64 bitbake vcontainer-tarball && \ +MACHINE=qemux86-64 bitbake container-image-host && \ +MACHINE=qemuarm64 bitbake container-image-host && \ +MACHINE=qemux86-64 bitbake container-app-base && \ +MACHINE=qemuarm64 bitbake container-app-base +``` + +Then extract the SDK and run the full test suite: + +```bash +# Extract SDK and run all tests +/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone.sh -d /tmp/vcontainer -y && \ +cd /opt/bruce/poky/meta-virtualization && \ +pytest tests/ -v --vdkr-dir /tmp/vcontainer --poky-dir /opt/bruce/poky +``` + +To test a specific architecture: + +```bash +# Test x86_64 +pytest tests/ -v --vdkr-dir /tmp/vcontainer --poky-dir /opt/bruce/poky --arch x86_64 + +# Test aarch64 +pytest tests/ -v --vdkr-dir /tmp/vcontainer --poky-dir /opt/bruce/poky --arch aarch64 +``` + +--- + +### Full vdkr + vpdmn test run (single architecture) ```bash # 1. Build the unified standalone SDK (includes both vdkr and vpdmn) @@ -457,7 +510,7 @@ source oe-init-build-env MACHINE=qemux86-64 bitbake vcontainer-tarball # 2. Extract the tarball (self-extracting installer) -/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone-x86_64.sh -d /tmp/vcontainer -y +/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone.sh -d /tmp/vcontainer -y # 3. Run fast tests for both tools (skips network and slow tests) cd /opt/bruce/poky/meta-virtualization @@ -474,7 +527,7 @@ pytest tests/test_vdkr.py tests/test_vpdmn.py -v --vdkr-dir /tmp/vcontainer MACHINE=qemux86-64 bitbake vcontainer-tarball # Extract -/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone-x86_64.sh -d /tmp/vcontainer -y +/opt/bruce/poky/build/tmp/deploy/sdk/vcontainer-standalone.sh -d /tmp/vcontainer -y # Run vdkr tests only pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vcontainer diff --git a/tests/conftest.py b/tests/conftest.py index accb8f17..a0b8fd0b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -416,14 +416,17 @@ class VdkrRunner: raise AssertionError(error_msg) return result - def memres_start(self, timeout=120, port_forwards=None): + def memres_start(self, timeout=120, port_forwards=None, no_registry=False): """Start memory resident mode. Args: timeout: Command timeout in seconds port_forwards: List of port forwards, e.g., ["8080:80", "2222:22"] + no_registry: Disable baked-in registry (default False - registry check is now smart) """ args = ["memres", "start"] + if no_registry: + args.append("--no-registry") if port_forwards: for pf in port_forwards: args.extend(["-p", pf]) @@ -646,14 +649,17 @@ class VpdmnRunner: raise AssertionError(error_msg) return result - def memres_start(self, timeout=120, port_forwards=None): + def memres_start(self, timeout=120, port_forwards=None, no_registry=False): """Start memory resident mode. Args: timeout: Command timeout in seconds port_forwards: List of port forwards, e.g., ["8080:80", "2222:22"] + no_registry: Disable baked-in registry (default False - registry check is now smart) """ args = ["memres", "start"] + if no_registry: + args.append("--no-registry") if port_forwards: for pf in port_forwards: args.extend(["-p", pf]) diff --git a/tests/test_multiarch_oci.py b/tests/test_multiarch_oci.py new file mode 100644 index 00000000..3ae642cd --- /dev/null +++ b/tests/test_multiarch_oci.py @@ -0,0 +1,711 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield +# +# SPDX-License-Identifier: MIT +""" +Tests for multi-architecture OCI container support. + +These tests verify: +- OCI Image Index detection and platform selection +- Multi-arch import via vdkr/vrunner +- Architecture normalization (aarch64 <-> arm64, x86_64 <-> amd64) +- Backward compatibility with single-arch OCI images + +Run with: + pytest tests/test_multiarch_oci.py -v --poky-dir /opt/bruce/poky + +Environment variables: + POKY_DIR: Path to poky directory (default: /opt/bruce/poky) + +Note: Some tests require the shell scripts from meta-virtualization/recipes-containers/vcontainer/files/ +""" + +import os +import json +import subprocess +import tempfile +import shutil +import pytest +from pathlib import Path + + +# Note: Command line options are defined in conftest.py + + +@pytest.fixture(scope="module") +def meta_virt_dir(request): + """Path to meta-virtualization layer.""" + poky_dir = Path(request.config.getoption("--poky-dir")) + path = poky_dir / "meta-virtualization" + if not path.exists(): + pytest.skip(f"meta-virtualization not found: {path}") + return path + + +@pytest.fixture(scope="module") +def vcontainer_files_dir(meta_virt_dir): + """Path to vcontainer shell scripts.""" + path = meta_virt_dir / "recipes-containers" / "vcontainer" / "files" + if not path.exists(): + pytest.skip(f"vcontainer files not found: {path}") + return path + + +@pytest.fixture +def multiarch_oci_dir(tmp_path): + """Create a mock multi-arch OCI directory for testing. + + Creates an OCI Image Index with two platforms: arm64 and amd64. + The blobs are minimal mock data sufficient for testing detection. + """ + oci_dir = tmp_path / "test-multiarch-oci" + oci_dir.mkdir() + + # Create blobs directory + blobs = oci_dir / "blobs" / "sha256" + blobs.mkdir(parents=True) + + # Create mock config for arm64 + arm64_config = { + "architecture": "arm64", + "os": "linux", + "config": {}, + "rootfs": {"type": "layers", "diff_ids": []} + } + arm64_config_json = json.dumps(arm64_config) + arm64_config_digest = create_mock_blob(blobs, arm64_config_json) + + # Create mock config for amd64 + amd64_config = { + "architecture": "amd64", + "os": "linux", + "config": {}, + "rootfs": {"type": "layers", "diff_ids": []} + } + amd64_config_json = json.dumps(amd64_config) + amd64_config_digest = create_mock_blob(blobs, amd64_config_json) + + # Create mock layer blob (shared between both) + layer_content = b"mock layer content for testing" + layer_digest = create_mock_blob(blobs, layer_content, binary=True) + + # Create manifest for arm64 + arm64_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{arm64_config_digest}", + "size": len(arm64_config_json) + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": f"sha256:{layer_digest}", + "size": len(layer_content) + } + ] + } + arm64_manifest_json = json.dumps(arm64_manifest) + arm64_manifest_digest = create_mock_blob(blobs, arm64_manifest_json) + + # Create manifest for amd64 + amd64_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{amd64_config_digest}", + "size": len(amd64_config_json) + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": f"sha256:{layer_digest}", + "size": len(layer_content) + } + ] + } + amd64_manifest_json = json.dumps(amd64_manifest) + amd64_manifest_digest = create_mock_blob(blobs, amd64_manifest_json) + + # Create OCI Image Index + index = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": f"sha256:{arm64_manifest_digest}", + "size": len(arm64_manifest_json), + "platform": {"architecture": "arm64", "os": "linux"} + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": f"sha256:{amd64_manifest_digest}", + "size": len(amd64_manifest_json), + "platform": {"architecture": "amd64", "os": "linux"} + } + ] + } + + (oci_dir / "index.json").write_text(json.dumps(index, indent=2)) + (oci_dir / "oci-layout").write_text('{"imageLayoutVersion": "1.0.0"}') + + return oci_dir + + +@pytest.fixture +def singlearch_oci_dir(tmp_path): + """Create a mock single-arch OCI directory for testing. + + Creates a standard single-arch OCI without platform info in index.json. + """ + oci_dir = tmp_path / "test-singlearch-oci" + oci_dir.mkdir() + + # Create blobs directory + blobs = oci_dir / "blobs" / "sha256" + blobs.mkdir(parents=True) + + # Create mock config + config = { + "architecture": "amd64", + "os": "linux", + "config": {}, + "rootfs": {"type": "layers", "diff_ids": []} + } + config_json = json.dumps(config) + config_digest = create_mock_blob(blobs, config_json) + + # Create mock layer + layer_content = b"mock layer content" + layer_digest = create_mock_blob(blobs, layer_content, binary=True) + + # Create manifest + manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{config_digest}", + "size": len(config_json) + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": f"sha256:{layer_digest}", + "size": len(layer_content) + } + ] + } + manifest_json = json.dumps(manifest) + manifest_digest = create_mock_blob(blobs, manifest_json) + + # Create standard index.json WITHOUT platform info + index = { + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": f"sha256:{manifest_digest}", + "size": len(manifest_json) + } + ] + } + + (oci_dir / "index.json").write_text(json.dumps(index, indent=2)) + (oci_dir / "oci-layout").write_text('{"imageLayoutVersion": "1.0.0"}') + + return oci_dir + + +def create_mock_blob(blobs_dir, content, binary=False): + """Create a mock blob and return its digest (without sha256: prefix).""" + import hashlib + + if isinstance(content, str): + content_bytes = content.encode('utf-8') + else: + content_bytes = content + + digest = hashlib.sha256(content_bytes).hexdigest() + blob_path = blobs_dir / digest + + if binary: + blob_path.write_bytes(content_bytes) + else: + blob_path.write_text(content) + + return digest + + +def source_shell_functions(vcontainer_files_dir, tmp_path): + """Create a test script that sources the shell functions.""" + # We need to extract specific functions from vcontainer-common.sh + # for unit testing without running the full script + test_script = tmp_path / "test_functions.sh" + + # Copy the necessary functions from vcontainer-common.sh + vcontainer_common = vcontainer_files_dir / "vcontainer-common.sh" + if not vcontainer_common.exists(): + return None + + # Read and extract the multi-arch functions + content = vcontainer_common.read_text() + + # Find the multi-arch section + start_marker = "# Multi-Architecture OCI Support" + end_marker = "show_usage()" + + start_idx = content.find(start_marker) + end_idx = content.find(end_marker, start_idx) + + if start_idx == -1 or end_idx == -1: + return None + + functions = content[start_idx:end_idx] + + test_script.write_text(f"""#!/bin/bash +# Extracted multi-arch functions for testing + +{functions} + +# Test harness +"$@" +""") + test_script.chmod(0o755) + return test_script + + +class TestOCIImageIndexDetection: + """Test OCI Image Index detection functions.""" + + def test_is_oci_image_index_with_multiarch(self, multiarch_oci_dir, vcontainer_files_dir, tmp_path): + """Test that is_oci_image_index detects multi-arch OCI.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "is_oci_image_index", str(multiarch_oci_dir)], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode == 0, f"Expected multi-arch detection to succeed. stderr: {result.stderr}" + + def test_is_oci_image_index_with_single_arch(self, singlearch_oci_dir, vcontainer_files_dir, tmp_path): + """Test that is_oci_image_index returns false for single-arch OCI.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "is_oci_image_index", str(singlearch_oci_dir)], + capture_output=True, + text=True, + timeout=10 + ) + # Single-arch without platform info should return non-zero + assert result.returncode != 0, "Expected single-arch detection to fail" + + def test_is_oci_image_index_missing_file(self, tmp_path, vcontainer_files_dir): + """Test that is_oci_image_index handles missing index.json.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + empty_dir = tmp_path / "empty-oci" + empty_dir.mkdir() + + result = subprocess.run( + [str(test_script), "is_oci_image_index", str(empty_dir)], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode != 0, "Expected missing file detection to fail" + + +class TestPlatformSelection: + """Test architecture selection functions.""" + + def test_select_platform_manifest_aarch64(self, multiarch_oci_dir, vcontainer_files_dir, tmp_path): + """Test selecting arm64 manifest for aarch64 target.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "select_platform_manifest", str(multiarch_oci_dir), "aarch64"], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode == 0, f"Expected platform selection to succeed. stderr: {result.stderr}" + # Should output a digest + assert result.stdout.strip(), "Expected digest output" + + def test_select_platform_manifest_x86_64(self, multiarch_oci_dir, vcontainer_files_dir, tmp_path): + """Test selecting amd64 manifest for x86_64 target.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "select_platform_manifest", str(multiarch_oci_dir), "x86_64"], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode == 0, f"Expected platform selection to succeed. stderr: {result.stderr}" + assert result.stdout.strip(), "Expected digest output" + + def test_select_platform_manifest_not_found(self, multiarch_oci_dir, vcontainer_files_dir, tmp_path): + """Test that selecting missing platform returns error.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "select_platform_manifest", str(multiarch_oci_dir), "riscv64"], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode != 0 or not result.stdout.strip(), "Expected missing platform to fail" + + def test_arch_normalization_aarch64_to_arm64(self, vcontainer_files_dir, tmp_path): + """Test that aarch64 normalizes to arm64.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "normalize_arch_to_oci", "aarch64"], + capture_output=True, + text=True, + timeout=10 + ) + assert result.stdout.strip() == "arm64" + + def test_arch_normalization_x86_64_to_amd64(self, vcontainer_files_dir, tmp_path): + """Test that x86_64 normalizes to amd64.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "normalize_arch_to_oci", "x86_64"], + capture_output=True, + text=True, + timeout=10 + ) + assert result.stdout.strip() == "amd64" + + +class TestGetOCIPlatforms: + """Test platform listing function.""" + + def test_get_platforms_multiarch(self, multiarch_oci_dir, vcontainer_files_dir, tmp_path): + """Test getting available platforms from multi-arch OCI.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "get_oci_platforms", str(multiarch_oci_dir)], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode == 0 + platforms = result.stdout.strip().split() + assert "arm64" in platforms + assert "amd64" in platforms + + +class TestExtractPlatformOCI: + """Test single-platform extraction function.""" + + def test_extract_platform_creates_valid_oci(self, multiarch_oci_dir, vcontainer_files_dir, tmp_path): + """Test that extract_platform_oci creates a valid single-arch OCI.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + # First get the arm64 manifest digest + result = subprocess.run( + [str(test_script), "select_platform_manifest", str(multiarch_oci_dir), "aarch64"], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode == 0 + manifest_digest = result.stdout.strip() + + # Extract platform to new directory + extracted_dir = tmp_path / "extracted-oci" + result = subprocess.run( + [str(test_script), "extract_platform_oci", str(multiarch_oci_dir), str(extracted_dir), manifest_digest], + capture_output=True, + text=True, + timeout=10 + ) + assert result.returncode == 0, f"Extraction failed: {result.stderr}" + + # Verify extracted OCI structure + assert (extracted_dir / "index.json").exists() + assert (extracted_dir / "oci-layout").exists() + assert (extracted_dir / "blobs" / "sha256").is_dir() + + # Verify index.json has single manifest + index = json.loads((extracted_dir / "index.json").read_text()) + assert len(index.get("manifests", [])) == 1 + assert index["manifests"][0]["digest"] == f"sha256:{manifest_digest}" + + +class TestMultiArchOCIClass: + """Test oci-multiarch.bbclass file.""" + + def test_bbclass_exists(self, meta_virt_dir): + """Test that the oci-multiarch.bbclass file exists.""" + class_file = meta_virt_dir / "classes" / "oci-multiarch.bbclass" + assert class_file.exists(), f"Class file not found: {class_file}" + + def test_bbclass_has_required_variables(self, meta_virt_dir): + """Test that oci-multiarch.bbclass defines required variables.""" + class_file = meta_virt_dir / "classes" / "oci-multiarch.bbclass" + content = class_file.read_text() + + assert "OCI_MULTIARCH_RECIPE" in content + assert "OCI_MULTIARCH_PLATFORMS" in content + assert "OCI_MULTIARCH_MC" in content + + def test_bbclass_creates_image_index(self, meta_virt_dir): + """Test that oci-multiarch.bbclass creates OCI Image Index.""" + class_file = meta_virt_dir / "classes" / "oci-multiarch.bbclass" + content = class_file.read_text() + + assert "do_create_multiarch_index" in content + assert "application/vnd.oci.image.index.v1+json" in content + + +class TestBackwardCompatibility: + """Test backward compatibility with single-arch OCI images.""" + + def test_single_arch_oci_structure(self, singlearch_oci_dir): + """Verify single-arch OCI has expected structure.""" + assert (singlearch_oci_dir / "index.json").exists() + assert (singlearch_oci_dir / "oci-layout").exists() + + index = json.loads((singlearch_oci_dir / "index.json").read_text()) + assert "manifests" in index + assert len(index["manifests"]) == 1 + # Single-arch should NOT have platform in manifest entry + assert "platform" not in index["manifests"][0] + + def test_single_arch_detection_fails(self, singlearch_oci_dir, vcontainer_files_dir, tmp_path): + """Test that single-arch OCI is not detected as multi-arch.""" + test_script = source_shell_functions(vcontainer_files_dir, tmp_path) + if test_script is None: + pytest.skip("Could not extract shell functions") + + result = subprocess.run( + [str(test_script), "is_oci_image_index", str(singlearch_oci_dir)], + capture_output=True, + text=True, + timeout=10 + ) + # Should return non-zero (not a multi-arch image index) + assert result.returncode != 0 + + +class TestVrunnerMultiArch: + """Test vrunner.sh multi-arch support.""" + + def test_vrunner_has_multiarch_functions(self, vcontainer_files_dir): + """Test that vrunner.sh contains multi-arch functions.""" + vrunner = vcontainer_files_dir / "vrunner.sh" + assert vrunner.exists() + + content = vrunner.read_text() + assert "is_oci_image_index" in content + assert "select_platform_manifest" in content + assert "extract_platform_oci" in content + assert "normalize_arch_to_oci" in content + + def test_vrunner_batch_import_handles_multiarch(self, vcontainer_files_dir): + """Test that vrunner batch import section checks for multi-arch.""" + vrunner = vcontainer_files_dir / "vrunner.sh" + content = vrunner.read_text() + + # Batch import section should check for multi-arch + assert "BATCH_IMPORT" in content + # The multi-arch handling should be in the batch processing loop + assert "is_oci_image_index" in content + + +class TestVcontainerCommonMultiArch: + """Test vcontainer-common.sh multi-arch support.""" + + def test_vcontainer_common_has_multiarch_functions(self, vcontainer_files_dir): + """Test that vcontainer-common.sh contains multi-arch functions.""" + vcontainer_common = vcontainer_files_dir / "vcontainer-common.sh" + assert vcontainer_common.exists() + + content = vcontainer_common.read_text() + assert "is_oci_image_index" in content + assert "select_platform_manifest" in content + assert "extract_platform_oci" in content + assert "get_oci_platforms" in content + assert "normalize_arch_to_oci" in content + assert "normalize_arch_from_oci" in content + + def test_vimport_handles_multiarch(self, vcontainer_files_dir): + """Test that vimport section handles multi-arch OCI.""" + vcontainer_common = vcontainer_files_dir / "vcontainer-common.sh" + content = vcontainer_common.read_text() + + # vimport should detect and handle multi-arch + assert "vimport)" in content + # Should have multi-arch detection in OCI handling + assert "Multi-arch OCI detected" in content or "is_oci_image_index" in content + + +class TestContainerRegistryMultiArch: + """Test container registry multi-arch support.""" + + def test_registry_script_has_manifest_list_support(self, meta_virt_dir): + """Test that container-registry-index.bb has manifest list support.""" + registry_bb = meta_virt_dir / "recipes-containers" / "container-registry" / "container-registry-index.bb" + assert registry_bb.exists() + + content = registry_bb.read_text() + # Should have manifest list functions + assert "update_manifest_list" in content + assert "is_manifest_list" in content + assert "get_manifest_list" in content + assert "push_by_digest" in content + + def test_registry_script_always_creates_manifest_lists(self, meta_virt_dir): + """Test that push always creates manifest lists.""" + registry_bb = meta_virt_dir / "recipes-containers" / "container-registry" / "container-registry-index.bb" + content = registry_bb.read_text() + + # Should mention manifest lists in push output + assert "manifest list" in content.lower() + + def test_registry_script_has_multi_directory_support(self, meta_virt_dir): + """Test that container-registry-index.bb supports multi-directory scanning.""" + registry_bb = meta_virt_dir / "recipes-containers" / "container-registry" / "container-registry-index.bb" + content = registry_bb.read_text() + + # Should have DEPLOY_DIR_IMAGES variable for multi-arch scanning + assert "DEPLOY_DIR_IMAGES" in content + # Should iterate over machine directories + assert "machine_dir" in content + # Should show which machine the image is from + assert "[from $machine_name]" in content or "from $machine_name" in content + + def test_registry_script_supports_push_by_path(self, meta_virt_dir): + """Test that push command supports direct OCI directory path.""" + registry_bb = meta_virt_dir / "recipes-containers" / "container-registry" / "container-registry-index.bb" + content = registry_bb.read_text() + + # Should detect if argument is a path to an OCI directory + assert "index.json" in content + # Should have direct path mode + assert "Direct path mode" in content or "Pushing OCI directory" in content + + def test_registry_script_supports_push_by_name(self, meta_virt_dir): + """Test that push by name scans all machine directories.""" + registry_bb = meta_virt_dir / "recipes-containers" / "container-registry" / "container-registry-index.bb" + content = registry_bb.read_text() + + # Should support name filter mode + assert "image_filter" in content + # Should scan all architectures when pushing by name + assert "all architectures" in content.lower() or "all archs" in content.lower() + + def test_registry_script_env_var_override(self, meta_virt_dir): + """Test that DEPLOY_DIR_IMAGES can be overridden via environment.""" + registry_bb = meta_virt_dir / "recipes-containers" / "container-registry" / "container-registry-index.bb" + content = registry_bb.read_text() + + # Should use environment variable with fallback to baked-in value + assert "${DEPLOY_DIR_IMAGES:-" in content or "DEPLOY_DIR_IMAGES:-" in content + + +class TestRegistryMultiArchIntegration: + """Integration tests for registry multi-arch push (requires registry fixture).""" + + @pytest.fixture + def mock_deploy_dirs(self, tmp_path, multiarch_oci_dir): + """Create mock deploy directory structure with multiple machines.""" + deploy_images = tmp_path / "deploy" / "images" + + # Create qemuarm64 machine dir with arm64 OCI + arm64_dir = deploy_images / "qemuarm64" + arm64_dir.mkdir(parents=True) + arm64_oci = arm64_dir / "container-base-latest-oci" + shutil.copytree(multiarch_oci_dir, arm64_oci) + + # Modify the arm64 OCI to only have arm64 manifest + arm64_index = json.loads((arm64_oci / "index.json").read_text()) + arm64_index["manifests"] = [m for m in arm64_index["manifests"] + if m.get("platform", {}).get("architecture") == "arm64"] + (arm64_oci / "index.json").write_text(json.dumps(arm64_index, indent=2)) + + # Create qemux86-64 machine dir with amd64 OCI + amd64_dir = deploy_images / "qemux86-64" + amd64_dir.mkdir(parents=True) + amd64_oci = amd64_dir / "container-base-latest-oci" + shutil.copytree(multiarch_oci_dir, amd64_oci) + + # Modify the amd64 OCI to only have amd64 manifest + amd64_index = json.loads((amd64_oci / "index.json").read_text()) + amd64_index["manifests"] = [m for m in amd64_index["manifests"] + if m.get("platform", {}).get("architecture") == "amd64"] + (amd64_oci / "index.json").write_text(json.dumps(amd64_index, indent=2)) + + return deploy_images + + def test_mock_deploy_structure(self, mock_deploy_dirs): + """Verify mock deploy directory structure is correct.""" + assert (mock_deploy_dirs / "qemuarm64" / "container-base-latest-oci" / "index.json").exists() + assert (mock_deploy_dirs / "qemux86-64" / "container-base-latest-oci" / "index.json").exists() + + # Verify arm64 OCI has arm64 only + arm64_index = json.loads( + (mock_deploy_dirs / "qemuarm64" / "container-base-latest-oci" / "index.json").read_text() + ) + assert len(arm64_index["manifests"]) == 1 + assert arm64_index["manifests"][0]["platform"]["architecture"] == "arm64" + + # Verify amd64 OCI has amd64 only + amd64_index = json.loads( + (mock_deploy_dirs / "qemux86-64" / "container-base-latest-oci" / "index.json").read_text() + ) + assert len(amd64_index["manifests"]) == 1 + assert amd64_index["manifests"][0]["platform"]["architecture"] == "amd64" + + def test_discover_oci_in_multiple_machines(self, mock_deploy_dirs): + """Test that OCI directories can be discovered in multiple machine dirs.""" + found = [] + for machine_dir in mock_deploy_dirs.iterdir(): + if machine_dir.is_dir(): + for oci_dir in machine_dir.glob("*-oci"): + if (oci_dir / "index.json").exists(): + index = json.loads((oci_dir / "index.json").read_text()) + arch = index["manifests"][0].get("platform", {}).get("architecture", "unknown") + found.append((machine_dir.name, oci_dir.name, arch)) + + assert len(found) == 2 + machines = {f[0] for f in found} + assert "qemuarm64" in machines + assert "qemux86-64" in machines + + archs = {f[2] for f in found} + assert "arm64" in archs + assert "amd64" in archs -- cgit v1.2.3-54-g00ecf