# SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield # # SPDX-License-Identifier: MIT # # container-bundle.bbclass # =========================================================================== # Container bundling class for creating installable container packages # =========================================================================== # # This class creates packages that bundle pre-processed container images. # When these packages are installed via IMAGE_INSTALL, the containers are # automatically merged into the target image's container storage. # # =========================================================================== # Component Relationships # =========================================================================== # # To bundle a local container like "myapp:autostart", three recipe types # work together: # # 1. Application Recipe (builds the software) # recipes-demo/myapp/myapp_1.0.bb # ├── Compiles application binaries # └── Creates installable package (myapp) # # 2. Container Image Recipe (creates OCI image containing the app) # recipes-demo/images/myapp-container.bb # ├── inherit image image-oci # ├── IMAGE_INSTALL = "myapp" # └── Produces: ${DEPLOY_DIR_IMAGE}/myapp-container-latest-oci/ # # 3. Bundle Recipe (packages container images for deployment) # recipes-demo/bundles/my-bundle_1.0.bb # ├── inherit container-bundle # ├── CONTAINER_BUNDLES = "myapp-container:autostart" # └── Creates installable package with OCI data # # Flow diagram: # # myapp_1.0.bb myapp-container.bb # (application) (container image) # │ │ # │ IMAGE_INSTALL="myapp" │ inherit image-oci # └──────────────┬────────────────┘ # │ # ▼ # myapp-container-latest-oci/ # (OCI directory in DEPLOY_DIR_IMAGE) # │ # │ CONTAINER_BUNDLES="myapp-container" # ▼ # my-bundle_1.0.bb ──────► my-bundle package # (inherits container-bundle) │ # │ IMAGE_INSTALL="my-bundle" # ▼ # container-image-host # (target host image) # # =========================================================================== # When to Use This Class vs BUNDLED_CONTAINERS # =========================================================================== # # There are two ways to bundle containers into a host image: # # 1. BUNDLED_CONTAINERS variable (simpler, no extra recipe needed) # Set in local.conf or image recipe: # BUNDLED_CONTAINERS = "container-base:docker myapp-container:docker:autostart" # # 2. container-bundle packages (this class) # Create a bundle recipe, install via IMAGE_INSTALL # # Decision guide: # # Use Case | BUNDLED_CONTAINERS | Bundle Recipe # --------------------------------------------|--------------------|-------------- # Simple: containers in one host image | recommended | overkill # Reuse containers across multiple images | repetitive | recommended # Remote containers (docker.io/library/...) | not supported | required # Package versioning and dependencies | not supported | supported # Distribute pre-built container set | not supported | supported # # For most single-image use cases, BUNDLED_CONTAINERS is simpler: # - No bundle recipe needed # - Dependencies auto-generated at parse time # - vrunner batch-import runs once for all containers # # Use this class (container-bundle) when you need: # - Remote container fetching via skopeo # - A distributable/versioned package of containers # - To share the same bundle across multiple different host images # # =========================================================================== # Usage # =========================================================================== # # inherit container-bundle # # CONTAINER_BUNDLES = "\ # myapp-container \ # mydb-container:autostart \ # docker.io/library/redis:7 \ # " # # # REQUIRED for remote containers (sanitize key: replace / and : with _): # CONTAINER_DIGESTS[docker.io_library_redis_7] = "sha256:..." # # # To get the digest, use skopeo: # # skopeo inspect docker://docker.io/library/redis:7 | jq -r '.Digest' # # Variable format: source[:autostart-policy] # - source: Either a container image recipe name or a remote registry URL # * Local: "myapp-container", "container-base" (recipe names) # * Remote: "docker.io/library/alpine:3.19" (contains / or .) # - autostart-policy: Optional. autostart | always | unless-stopped | on-failure # # Runtime Selection (in order of precedence): # 1. CONTAINER_BUNDLE_RUNTIME in recipe (explicit override) # 2. CONTAINER_PROFILE distro/local.conf setting # 3. Default: "docker" # # Remote containers: # - Must have pinned digest via CONTAINER_DIGESTS # - A licensing warning is emitted during fetch # - Fetched using skopeo-native in do_fetch phase # # Local containers: # - Must be container IMAGE recipes (inherit image-oci) # - Built via dependency on recipe:do_image_complete # - OCI directory picked up from DEPLOY_DIR_IMAGE # # =========================================================================== # Integration with container-cross-install.bbclass # =========================================================================== # # This class creates packages that are processed by container-cross-install: # 1. Installs OCI directories to ${datadir}/container-bundles/${RUNTIME}/oci/ # 2. Installs refs file to ${datadir}/container-bundles/${RUNTIME}/${PN}.refs # 3. Installs metadata to ${datadir}/container-bundles/${PN}.meta # 4. container-cross-install.bbclass imports these via vrunner at image time # # The runtime subdirectory (docker/ vs podman/) tells container-cross-install # which vrunner runtime to use for import. # # See also: container-cross-install.bbclass CONTAINER_BUNDLES ?= "" # Default runtime based on CONTAINER_PROFILE # Can be overridden in recipe with CONTAINER_BUNDLE_RUNTIME = "podman" def get_bundle_runtime(d): """Determine container runtime from CONTAINER_PROFILE or default to docker""" profile = d.getVar('CONTAINER_PROFILE') or 'docker' if profile in ['podman']: return 'podman' # docker, containerd, k3s-*, default all use docker storage format return 'docker' CONTAINER_BUNDLE_RUNTIME ?= "${@get_bundle_runtime(d)}" # Inherit shared functions for multiconfig/machine/arch mapping inherit container-common # Dependencies on native tools # vcontainer-native provides vrunner.sh # Blobs come from multiconfig builds (vdkr-initramfs-create, vpdmn-initramfs-create) DEPENDS += "qemuwrapper-cross qemu-system-native skopeo-native" DEPENDS += "vcontainer-native" VRUNTIME_MULTICONFIG = "${@get_vruntime_multiconfig(d)}" VRUNTIME_MACHINE = "${@get_vruntime_machine(d)}" BLOB_ARCH = "${@get_blob_arch(d)}" # Path to vrunner.sh from vcontainer-native VRUNNER_PATH = "${STAGING_BINDIR_NATIVE}/vrunner.sh" # Blobs come from multiconfig deploy directory # These are built by vdkr-initramfs-create and vpdmn-initramfs-create VDKR_BLOB_DIR = "${TOPDIR}/tmp-${VRUNTIME_MULTICONFIG}/deploy/images/${VRUNTIME_MACHINE}/vdkr" VPDMN_BLOB_DIR = "${TOPDIR}/tmp-${VRUNTIME_MULTICONFIG}/deploy/images/${VRUNTIME_MACHINE}/vpdmn" def is_remote_container(source): """Detect if source is a registry URL vs local recipe name. Remote indicators: contains '/' or '.' in the base name (before first :) Local: simple recipe name like "myapp" or "container-base" """ base = source.split(':')[0] if ':' in source else source return '/' in base or '.' in base python __anonymous() { bundles = (d.getVar('CONTAINER_BUNDLES') or "").split() if not bundles: return # Get runtime from CONTAINER_BUNDLE_RUNTIME (set based on CONTAINER_PROFILE) runtime = d.getVar('CONTAINER_BUNDLE_RUNTIME') or 'docker' if runtime not in ['docker', 'podman']: bb.fatal(f"Invalid CONTAINER_BUNDLE_RUNTIME '{runtime}': must be 'docker' or 'podman'") local_recipes = [] remote_urls = [] processed_bundles = [] for bundle in bundles: # New format: source[:autostart-policy] # For remote URLs like docker.io/library/redis:7, we need to handle # the tag colon differently from the autostart colon if is_remote_container(bundle): # Remote: could be "docker.io/library/redis:7" or "docker.io/library/redis:7:autostart" # Find the last colon that's an autostart policy if bundle.endswith(':autostart') or bundle.endswith(':always') or \ bundle.endswith(':unless-stopped') or bundle.endswith(':on-failure') or \ bundle.endswith(':no'): last_colon = bundle.rfind(':') source = bundle[:last_colon] autostart = bundle[last_colon+1:] else: source = bundle autostart = "" remote_urls.append(source) else: # Local: "myapp" or "myapp:autostart" parts = bundle.split(':') source = parts[0] autostart = parts[1] if len(parts) > 1 else "" local_recipes.append(source) # Store normalized format: source:runtime:autostart (for metadata file) processed_bundles.append(f"{source}:{runtime}:{autostart}" if autostart else f"{source}:{runtime}") # Add dependencies for local container recipes # Local containers are built in the MAIN context (not multiconfig) # and their OCI images are in main DEPLOY_DIR_IMAGE if local_recipes: deps = "" for recipe in local_recipes: # Container recipes produce OCI images via do_image_complete deps += f" {recipe}:do_image_complete" if deps: d.appendVarFlag('do_compile', 'depends', deps) # Store parsed lists for tasks d.setVar('_LOCAL_CONTAINERS', ' '.join(local_recipes)) d.setVar('_REMOTE_CONTAINERS', ' '.join(remote_urls)) d.setVar('_PROCESSED_BUNDLES', ' '.join(processed_bundles)) d.setVar('_BUNDLE_RUNTIME', runtime) } # S must be a real directory S = "${WORKDIR}/sources" B = "${WORKDIR}/build" do_unpack[noexec] = "1" do_patch[noexec] = "1" do_configure[noexec] = "1" python do_fetch_containers() { import subprocess import os remote_containers = (d.getVar('_REMOTE_CONTAINERS') or "").split() if not remote_containers: return workdir = d.getVar('WORKDIR') fetched_dir = os.path.join(workdir, 'fetched') os.makedirs(fetched_dir, exist_ok=True) # Find skopeo in native sysroot (available after do_prepare_recipe_sysroot) # skopeo-native installs to sbindir, not bindir staging_sbindir = d.getVar('STAGING_SBINDIR_NATIVE') skopeo = os.path.join(staging_sbindir, 'skopeo') for url in remote_containers: if not url: continue # Digest is REQUIRED for remote containers # Varflag key must be sanitized (no / or : allowed in BitBake varflag names) sanitized_key = url.replace('/', '_').replace(':', '_') digest = d.getVarFlag('CONTAINER_DIGESTS', sanitized_key) if not digest: bb.fatal(f"Remote container '{url}' requires a pinned digest.\n" f"Add: CONTAINER_DIGESTS[{sanitized_key}] = \"sha256:...\"\n" f"Get digest with: skopeo inspect docker://{url} | jq -r '.Digest'") # Emit licensing warning bb.warn(f"Fetching third-party container: {url}\n" f"Ensure you have rights to redistribute this container in your image.\n" f"Check the container's license terms before distribution.") # Strip tag from URL when using digest (skopeo doesn't support both) # e.g., docker.io/library/busybox:1.36 -> docker.io/library/busybox base_url = url.rsplit(':', 1)[0] if ':' in url.split('/')[-1] else url src = f"{base_url}@{digest}" name = url.replace('/', '_').replace(':', '_') dest_dir = os.path.join(fetched_dir, name) dest = f"oci:{dest_dir}:latest" bb.note(f"Fetching {src} -> {dest}") try: subprocess.check_call([skopeo, 'copy', f'docker://{src}', dest]) except subprocess.CalledProcessError as e: bb.fatal(f"Failed to fetch container '{url}': {e}") } do_fetch_containers[network] = "1" addtask fetch_containers after do_prepare_recipe_sysroot before do_compile do_compile() { set -e mkdir -p "${S}" # Clean OCI directory to avoid nested copies from incremental builds rm -rf "${B}/oci" mkdir -p "${B}/oci" # Clear refs file to avoid duplicates from incremental builds : > "${B}/oci-refs.txt" RUNTIME="${_BUNDLE_RUNTIME}" bbnote "Collecting OCI images for runtime: ${RUNTIME}" # Collect OCI directories - NO vrunner here, just copy OCI images # vrunner will be run ONCE by container-cross-install at rootfs time for bundle in ${_PROCESSED_BUNDLES}; do # Extract source from bundle format source=$(echo "$bundle" | sed -E 's/:(docker|podman)(:(autostart|always|unless-stopped|on-failure|no))?$//') collect_oci "$source" done # Store metadata for autostart processing (one bundle per line) printf '%s\n' ${_PROCESSED_BUNDLES} > "${B}/bundle-metadata.txt" } collect_oci() { local source="$1" # Determine OCI directory and image reference if echo "$source" | grep -qE '[/.]'; then # Remote container - already fetched to WORKDIR/fetched/ local name=$(echo "$source" | sed 's|[/:]|_|g') local oci_src="${WORKDIR}/fetched/${name}" local tag=$(echo "$source" | grep -oE ':[^:]+$' | sed 's/^://' || echo "latest") local base_name=$(echo "$source" | sed 's|.*/||' | sed 's/:.*$//') local image_ref="${base_name}:${tag}" else # Local container - from DEPLOY_DIR local oci_src="${DEPLOY_DIR_IMAGE}/${source}-latest-oci" if [ ! -d "${oci_src}" ]; then oci_src="${DEPLOY_DIR_IMAGE}/${source}-oci" fi if [ ! -d "${oci_src}" ]; then oci_src="${DEPLOY_DIR_IMAGE}/${source}" fi local image_ref="${source}:latest" fi if [ ! -d "${oci_src}" ]; then bbfatal "Container OCI directory not found: ${oci_src}" fi # Copy OCI directory to build dir with image ref as name # Format: image_ref (e.g., busybox:1.36 or container-base:latest) local oci_name=$(echo "${image_ref}" | sed 's|[/:]|_|g') local oci_dest="${B}/oci/${oci_name}" bbnote "Collecting OCI: ${oci_src} -> ${oci_dest} (ref: ${image_ref})" cp -rL "${oci_src}" "${oci_dest}" # Store the image reference for later use echo "${oci_name}:${image_ref}" >> "${B}/oci-refs.txt" } do_install() { # Install OCI directories for container-cross-install to process # NO storage tars - vrunner runs once at rootfs time RUNTIME="${_BUNDLE_RUNTIME}" # Install OCI directories if [ -d "${B}/oci" ] && [ -n "$(ls -A ${B}/oci 2>/dev/null)" ]; then install -d ${D}${datadir}/container-bundles/${RUNTIME}/oci cp -r ${B}/oci/* ${D}${datadir}/container-bundles/${RUNTIME}/oci/ fi # Install OCI references file if [ -f "${B}/oci-refs.txt" ]; then install -d ${D}${datadir}/container-bundles/${RUNTIME} install -m 0644 ${B}/oci-refs.txt \ ${D}${datadir}/container-bundles/${RUNTIME}/${PN}.refs fi # Install metadata for autostart service generation if [ -f "${B}/bundle-metadata.txt" ]; then install -d ${D}${datadir}/container-bundles install -m 0644 ${B}/bundle-metadata.txt \ ${D}${datadir}/container-bundles/${PN}.meta fi } FILES:${PN} = "${datadir}/container-bundles" # Automatically trigger multiconfig blob builds # Note: This does NOT create circular dependencies because the blob build chain # (vdkr/vpdmn-initramfs-create -> vdkr/vpdmn-rootfs-image) is completely separate # from container image recipes. Circular deps only occur if bundle packages are # globally added to all images (including container images themselves). do_compile[mcdepends] = "mc::${VRUNTIME_MULTICONFIG}:vdkr-initramfs-create:do_deploy mc::${VRUNTIME_MULTICONFIG}:vpdmn-initramfs-create:do_deploy"