From 92efc22c26b15380350bce7d968740142ae948eb Mon Sep 17 00:00:00 2001 From: Bruce Ashfield Date: Thu, 1 Jan 2026 17:14:05 +0000 Subject: container-bundle: add package-based container bundling support This class creates installable packages that bundle pre-processed container images. When installed via IMAGE_INSTALL, containers are automatically merged into the target image's container storage. Component relationships for bundling a local container: 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: application recipe -> container image recipe -> bundle recipe -> IMAGE_INSTALL in host image -> container deployed on target Usage: inherit container-bundle CONTAINER_BUNDLES = "myapp-container:autostart redis-container" CONTAINER_BUNDLES format: source[:autostart-policy] - source: Container IMAGE recipe name or remote registry URL - autostart-policy: Optional (autostart, always, unless-stopped, on-failure) Features: - Auto-generates dependencies on container image recipes (do_image_complete) - Supports remote containers via skopeo (requires CONTAINER_DIGESTS) - Runtime auto-detected from CONTAINER_PROFILE (docker/podman) - Produces OCI directories and metadata for container-cross-install Signed-off-by: Bruce Ashfield --- classes/container-bundle.bbclass | 441 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 classes/container-bundle.bbclass (limited to 'classes') diff --git a/classes/container-bundle.bbclass b/classes/container-bundle.bbclass new file mode 100644 index 00000000..fdd241ee --- /dev/null +++ b/classes/container-bundle.bbclass @@ -0,0 +1,441 @@ +# 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)}" + +# 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" + +# Determine multiconfig name for blob building based on target architecture +def get_vruntime_multiconfig(d): + arch = d.getVar('TARGET_ARCH') + if arch == 'aarch64': + return 'vruntime-aarch64' + elif arch in ['x86_64', 'i686', 'i586']: + return 'vruntime-x86-64' + else: + return None + +# Get the MACHINE name used in the multiconfig (for deploy path) +def get_vruntime_machine(d): + arch = d.getVar('TARGET_ARCH') + if arch == 'aarch64': + return 'qemuarm64' + elif arch in ['x86_64', 'i686', 'i586']: + return 'qemux86-64' + else: + return None + +# Map TARGET_ARCH to blob directory name (aarch64, x86_64) +def get_blob_arch(d): + """Map Yocto TARGET_ARCH to blob directory name""" + arch = d.getVar('TARGET_ARCH') + blob_map = { + 'aarch64': 'aarch64', + 'arm': 'aarch64', # Use aarch64 blobs for 32-bit ARM too + 'x86_64': 'x86_64', + 'i686': 'x86_64', + 'i586': 'x86_64', + } + return blob_map.get(arch, 'aarch64') + +VRUNTIME_MULTICONFIG = "${@get_vruntime_multiconfig(d)}" +VRUNTIME_MACHINE = "${@get_vruntime_machine(d)}" +BLOB_ARCH = "${@get_blob_arch(d)}" + +# Path to vrunner.sh from vdkr-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" -- cgit v1.2.3-54-g00ecf