diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-01-01 17:14:05 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-02-09 03:32:52 +0000 |
| commit | 79d03d5350c446223c847135c7115a656adc01d9 (patch) | |
| tree | 4c988222e2de61d49ce592de0d40175ee61400cd | |
| parent | 0cdb55047d352ccfffcf76d242ca132315bd0659 (diff) | |
| download | meta-virtualization-79d03d5350c446223c847135c7115a656adc01d9.tar.gz | |
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 <bruce.ashfield@gmail.com>
| -rw-r--r-- | classes/container-bundle.bbclass | 441 | ||||
| -rw-r--r-- | recipes-demo/autostart-test/autostart-test_1.0.bb | 16 | ||||
| -rw-r--r-- | recipes-demo/autostart-test/files/autostart-test.sh | 21 | ||||
| -rw-r--r-- | recipes-demo/images/autostart-test-container.bb | 24 | ||||
| -rw-r--r-- | recipes-extended/container-bundles/example-container-bundle_1.0.bb | 68 | ||||
| -rw-r--r-- | recipes-extended/container-bundles/remote-container-bundle_1.0.bb | 50 |
6 files changed, 620 insertions, 0 deletions
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 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | # | ||
| 5 | # container-bundle.bbclass | ||
| 6 | # =========================================================================== | ||
| 7 | # Container bundling class for creating installable container packages | ||
| 8 | # =========================================================================== | ||
| 9 | # | ||
| 10 | # This class creates packages that bundle pre-processed container images. | ||
| 11 | # When these packages are installed via IMAGE_INSTALL, the containers are | ||
| 12 | # automatically merged into the target image's container storage. | ||
| 13 | # | ||
| 14 | # =========================================================================== | ||
| 15 | # Component Relationships | ||
| 16 | # =========================================================================== | ||
| 17 | # | ||
| 18 | # To bundle a local container like "myapp:autostart", three recipe types | ||
| 19 | # work together: | ||
| 20 | # | ||
| 21 | # 1. Application Recipe (builds the software) | ||
| 22 | # recipes-demo/myapp/myapp_1.0.bb | ||
| 23 | # ├── Compiles application binaries | ||
| 24 | # └── Creates installable package (myapp) | ||
| 25 | # | ||
| 26 | # 2. Container Image Recipe (creates OCI image containing the app) | ||
| 27 | # recipes-demo/images/myapp-container.bb | ||
| 28 | # ├── inherit image image-oci | ||
| 29 | # ├── IMAGE_INSTALL = "myapp" | ||
| 30 | # └── Produces: ${DEPLOY_DIR_IMAGE}/myapp-container-latest-oci/ | ||
| 31 | # | ||
| 32 | # 3. Bundle Recipe (packages container images for deployment) | ||
| 33 | # recipes-demo/bundles/my-bundle_1.0.bb | ||
| 34 | # ├── inherit container-bundle | ||
| 35 | # ├── CONTAINER_BUNDLES = "myapp-container:autostart" | ||
| 36 | # └── Creates installable package with OCI data | ||
| 37 | # | ||
| 38 | # Flow diagram: | ||
| 39 | # | ||
| 40 | # myapp_1.0.bb myapp-container.bb | ||
| 41 | # (application) (container image) | ||
| 42 | # │ │ | ||
| 43 | # │ IMAGE_INSTALL="myapp" │ inherit image-oci | ||
| 44 | # └──────────────┬────────────────┘ | ||
| 45 | # │ | ||
| 46 | # ▼ | ||
| 47 | # myapp-container-latest-oci/ | ||
| 48 | # (OCI directory in DEPLOY_DIR_IMAGE) | ||
| 49 | # │ | ||
| 50 | # │ CONTAINER_BUNDLES="myapp-container" | ||
| 51 | # ▼ | ||
| 52 | # my-bundle_1.0.bb ──────► my-bundle package | ||
| 53 | # (inherits container-bundle) │ | ||
| 54 | # │ IMAGE_INSTALL="my-bundle" | ||
| 55 | # ▼ | ||
| 56 | # container-image-host | ||
| 57 | # (target host image) | ||
| 58 | # | ||
| 59 | # =========================================================================== | ||
| 60 | # When to Use This Class vs BUNDLED_CONTAINERS | ||
| 61 | # =========================================================================== | ||
| 62 | # | ||
| 63 | # There are two ways to bundle containers into a host image: | ||
| 64 | # | ||
| 65 | # 1. BUNDLED_CONTAINERS variable (simpler, no extra recipe needed) | ||
| 66 | # Set in local.conf or image recipe: | ||
| 67 | # BUNDLED_CONTAINERS = "container-base:docker myapp-container:docker:autostart" | ||
| 68 | # | ||
| 69 | # 2. container-bundle packages (this class) | ||
| 70 | # Create a bundle recipe, install via IMAGE_INSTALL | ||
| 71 | # | ||
| 72 | # Decision guide: | ||
| 73 | # | ||
| 74 | # Use Case | BUNDLED_CONTAINERS | Bundle Recipe | ||
| 75 | # --------------------------------------------|--------------------|-------------- | ||
| 76 | # Simple: containers in one host image | recommended | overkill | ||
| 77 | # Reuse containers across multiple images | repetitive | recommended | ||
| 78 | # Remote containers (docker.io/library/...) | not supported | required | ||
| 79 | # Package versioning and dependencies | not supported | supported | ||
| 80 | # Distribute pre-built container set | not supported | supported | ||
| 81 | # | ||
| 82 | # For most single-image use cases, BUNDLED_CONTAINERS is simpler: | ||
| 83 | # - No bundle recipe needed | ||
| 84 | # - Dependencies auto-generated at parse time | ||
| 85 | # - vrunner batch-import runs once for all containers | ||
| 86 | # | ||
| 87 | # Use this class (container-bundle) when you need: | ||
| 88 | # - Remote container fetching via skopeo | ||
| 89 | # - A distributable/versioned package of containers | ||
| 90 | # - To share the same bundle across multiple different host images | ||
| 91 | # | ||
| 92 | # =========================================================================== | ||
| 93 | # Usage | ||
| 94 | # =========================================================================== | ||
| 95 | # | ||
| 96 | # inherit container-bundle | ||
| 97 | # | ||
| 98 | # CONTAINER_BUNDLES = "\ | ||
| 99 | # myapp-container \ | ||
| 100 | # mydb-container:autostart \ | ||
| 101 | # docker.io/library/redis:7 \ | ||
| 102 | # " | ||
| 103 | # | ||
| 104 | # # REQUIRED for remote containers (sanitize key: replace / and : with _): | ||
| 105 | # CONTAINER_DIGESTS[docker.io_library_redis_7] = "sha256:..." | ||
| 106 | # | ||
| 107 | # # To get the digest, use skopeo: | ||
| 108 | # # skopeo inspect docker://docker.io/library/redis:7 | jq -r '.Digest' | ||
| 109 | # | ||
| 110 | # Variable format: source[:autostart-policy] | ||
| 111 | # - source: Either a container image recipe name or a remote registry URL | ||
| 112 | # * Local: "myapp-container", "container-base" (recipe names) | ||
| 113 | # * Remote: "docker.io/library/alpine:3.19" (contains / or .) | ||
| 114 | # - autostart-policy: Optional. autostart | always | unless-stopped | on-failure | ||
| 115 | # | ||
| 116 | # Runtime Selection (in order of precedence): | ||
| 117 | # 1. CONTAINER_BUNDLE_RUNTIME in recipe (explicit override) | ||
| 118 | # 2. CONTAINER_PROFILE distro/local.conf setting | ||
| 119 | # 3. Default: "docker" | ||
| 120 | # | ||
| 121 | # Remote containers: | ||
| 122 | # - Must have pinned digest via CONTAINER_DIGESTS | ||
| 123 | # - A licensing warning is emitted during fetch | ||
| 124 | # - Fetched using skopeo-native in do_fetch phase | ||
| 125 | # | ||
| 126 | # Local containers: | ||
| 127 | # - Must be container IMAGE recipes (inherit image-oci) | ||
| 128 | # - Built via dependency on recipe:do_image_complete | ||
| 129 | # - OCI directory picked up from DEPLOY_DIR_IMAGE | ||
| 130 | # | ||
| 131 | # =========================================================================== | ||
| 132 | # Integration with container-cross-install.bbclass | ||
| 133 | # =========================================================================== | ||
| 134 | # | ||
| 135 | # This class creates packages that are processed by container-cross-install: | ||
| 136 | # 1. Installs OCI directories to ${datadir}/container-bundles/${RUNTIME}/oci/ | ||
| 137 | # 2. Installs refs file to ${datadir}/container-bundles/${RUNTIME}/${PN}.refs | ||
| 138 | # 3. Installs metadata to ${datadir}/container-bundles/${PN}.meta | ||
| 139 | # 4. container-cross-install.bbclass imports these via vrunner at image time | ||
| 140 | # | ||
| 141 | # The runtime subdirectory (docker/ vs podman/) tells container-cross-install | ||
| 142 | # which vrunner runtime to use for import. | ||
| 143 | # | ||
| 144 | # See also: container-cross-install.bbclass | ||
| 145 | |||
| 146 | CONTAINER_BUNDLES ?= "" | ||
| 147 | |||
| 148 | # Default runtime based on CONTAINER_PROFILE | ||
| 149 | # Can be overridden in recipe with CONTAINER_BUNDLE_RUNTIME = "podman" | ||
| 150 | def get_bundle_runtime(d): | ||
| 151 | """Determine container runtime from CONTAINER_PROFILE or default to docker""" | ||
| 152 | profile = d.getVar('CONTAINER_PROFILE') or 'docker' | ||
| 153 | if profile in ['podman']: | ||
| 154 | return 'podman' | ||
| 155 | # docker, containerd, k3s-*, default all use docker storage format | ||
| 156 | return 'docker' | ||
| 157 | |||
| 158 | CONTAINER_BUNDLE_RUNTIME ?= "${@get_bundle_runtime(d)}" | ||
| 159 | |||
| 160 | # Dependencies on native tools | ||
| 161 | # vcontainer-native provides vrunner.sh | ||
| 162 | # Blobs come from multiconfig builds (vdkr-initramfs-create, vpdmn-initramfs-create) | ||
| 163 | DEPENDS += "qemuwrapper-cross qemu-system-native skopeo-native" | ||
| 164 | DEPENDS += "vcontainer-native" | ||
| 165 | |||
| 166 | # Determine multiconfig name for blob building based on target architecture | ||
| 167 | def get_vruntime_multiconfig(d): | ||
| 168 | arch = d.getVar('TARGET_ARCH') | ||
| 169 | if arch == 'aarch64': | ||
| 170 | return 'vruntime-aarch64' | ||
| 171 | elif arch in ['x86_64', 'i686', 'i586']: | ||
| 172 | return 'vruntime-x86-64' | ||
| 173 | else: | ||
| 174 | return None | ||
| 175 | |||
| 176 | # Get the MACHINE name used in the multiconfig (for deploy path) | ||
| 177 | def get_vruntime_machine(d): | ||
| 178 | arch = d.getVar('TARGET_ARCH') | ||
| 179 | if arch == 'aarch64': | ||
| 180 | return 'qemuarm64' | ||
| 181 | elif arch in ['x86_64', 'i686', 'i586']: | ||
| 182 | return 'qemux86-64' | ||
| 183 | else: | ||
| 184 | return None | ||
| 185 | |||
| 186 | # Map TARGET_ARCH to blob directory name (aarch64, x86_64) | ||
| 187 | def get_blob_arch(d): | ||
| 188 | """Map Yocto TARGET_ARCH to blob directory name""" | ||
| 189 | arch = d.getVar('TARGET_ARCH') | ||
| 190 | blob_map = { | ||
| 191 | 'aarch64': 'aarch64', | ||
| 192 | 'arm': 'aarch64', # Use aarch64 blobs for 32-bit ARM too | ||
| 193 | 'x86_64': 'x86_64', | ||
| 194 | 'i686': 'x86_64', | ||
| 195 | 'i586': 'x86_64', | ||
| 196 | } | ||
| 197 | return blob_map.get(arch, 'aarch64') | ||
| 198 | |||
| 199 | VRUNTIME_MULTICONFIG = "${@get_vruntime_multiconfig(d)}" | ||
| 200 | VRUNTIME_MACHINE = "${@get_vruntime_machine(d)}" | ||
| 201 | BLOB_ARCH = "${@get_blob_arch(d)}" | ||
| 202 | |||
| 203 | # Path to vrunner.sh from vdkr-native | ||
| 204 | VRUNNER_PATH = "${STAGING_BINDIR_NATIVE}/vrunner.sh" | ||
| 205 | |||
| 206 | # Blobs come from multiconfig deploy directory | ||
| 207 | # These are built by vdkr-initramfs-create and vpdmn-initramfs-create | ||
| 208 | VDKR_BLOB_DIR = "${TOPDIR}/tmp-${VRUNTIME_MULTICONFIG}/deploy/images/${VRUNTIME_MACHINE}/vdkr" | ||
| 209 | VPDMN_BLOB_DIR = "${TOPDIR}/tmp-${VRUNTIME_MULTICONFIG}/deploy/images/${VRUNTIME_MACHINE}/vpdmn" | ||
| 210 | |||
| 211 | def is_remote_container(source): | ||
| 212 | """Detect if source is a registry URL vs local recipe name. | ||
| 213 | |||
| 214 | Remote indicators: contains '/' or '.' in the base name (before first :) | ||
| 215 | Local: simple recipe name like "myapp" or "container-base" | ||
| 216 | """ | ||
| 217 | base = source.split(':')[0] if ':' in source else source | ||
| 218 | return '/' in base or '.' in base | ||
| 219 | |||
| 220 | python __anonymous() { | ||
| 221 | bundles = (d.getVar('CONTAINER_BUNDLES') or "").split() | ||
| 222 | if not bundles: | ||
| 223 | return | ||
| 224 | |||
| 225 | # Get runtime from CONTAINER_BUNDLE_RUNTIME (set based on CONTAINER_PROFILE) | ||
| 226 | runtime = d.getVar('CONTAINER_BUNDLE_RUNTIME') or 'docker' | ||
| 227 | if runtime not in ['docker', 'podman']: | ||
| 228 | bb.fatal(f"Invalid CONTAINER_BUNDLE_RUNTIME '{runtime}': must be 'docker' or 'podman'") | ||
| 229 | |||
| 230 | local_recipes = [] | ||
| 231 | remote_urls = [] | ||
| 232 | processed_bundles = [] | ||
| 233 | |||
| 234 | for bundle in bundles: | ||
| 235 | # New format: source[:autostart-policy] | ||
| 236 | # For remote URLs like docker.io/library/redis:7, we need to handle | ||
| 237 | # the tag colon differently from the autostart colon | ||
| 238 | if is_remote_container(bundle): | ||
| 239 | # Remote: could be "docker.io/library/redis:7" or "docker.io/library/redis:7:autostart" | ||
| 240 | # Find the last colon that's an autostart policy | ||
| 241 | if bundle.endswith(':autostart') or bundle.endswith(':always') or \ | ||
| 242 | bundle.endswith(':unless-stopped') or bundle.endswith(':on-failure') or \ | ||
| 243 | bundle.endswith(':no'): | ||
| 244 | last_colon = bundle.rfind(':') | ||
| 245 | source = bundle[:last_colon] | ||
| 246 | autostart = bundle[last_colon+1:] | ||
| 247 | else: | ||
| 248 | source = bundle | ||
| 249 | autostart = "" | ||
| 250 | remote_urls.append(source) | ||
| 251 | else: | ||
| 252 | # Local: "myapp" or "myapp:autostart" | ||
| 253 | parts = bundle.split(':') | ||
| 254 | source = parts[0] | ||
| 255 | autostart = parts[1] if len(parts) > 1 else "" | ||
| 256 | local_recipes.append(source) | ||
| 257 | |||
| 258 | # Store normalized format: source:runtime:autostart (for metadata file) | ||
| 259 | processed_bundles.append(f"{source}:{runtime}:{autostart}" if autostart else f"{source}:{runtime}") | ||
| 260 | |||
| 261 | # Add dependencies for local container recipes | ||
| 262 | # Local containers are built in the MAIN context (not multiconfig) | ||
| 263 | # and their OCI images are in main DEPLOY_DIR_IMAGE | ||
| 264 | if local_recipes: | ||
| 265 | deps = "" | ||
| 266 | for recipe in local_recipes: | ||
| 267 | # Container recipes produce OCI images via do_image_complete | ||
| 268 | deps += f" {recipe}:do_image_complete" | ||
| 269 | if deps: | ||
| 270 | d.appendVarFlag('do_compile', 'depends', deps) | ||
| 271 | |||
| 272 | # Store parsed lists for tasks | ||
| 273 | d.setVar('_LOCAL_CONTAINERS', ' '.join(local_recipes)) | ||
| 274 | d.setVar('_REMOTE_CONTAINERS', ' '.join(remote_urls)) | ||
| 275 | d.setVar('_PROCESSED_BUNDLES', ' '.join(processed_bundles)) | ||
| 276 | d.setVar('_BUNDLE_RUNTIME', runtime) | ||
| 277 | } | ||
| 278 | |||
| 279 | # S must be a real directory | ||
| 280 | S = "${WORKDIR}/sources" | ||
| 281 | B = "${WORKDIR}/build" | ||
| 282 | |||
| 283 | do_unpack[noexec] = "1" | ||
| 284 | do_patch[noexec] = "1" | ||
| 285 | do_configure[noexec] = "1" | ||
| 286 | |||
| 287 | python do_fetch_containers() { | ||
| 288 | import subprocess | ||
| 289 | import os | ||
| 290 | |||
| 291 | remote_containers = (d.getVar('_REMOTE_CONTAINERS') or "").split() | ||
| 292 | if not remote_containers: | ||
| 293 | return | ||
| 294 | |||
| 295 | workdir = d.getVar('WORKDIR') | ||
| 296 | fetched_dir = os.path.join(workdir, 'fetched') | ||
| 297 | os.makedirs(fetched_dir, exist_ok=True) | ||
| 298 | |||
| 299 | # Find skopeo in native sysroot (available after do_prepare_recipe_sysroot) | ||
| 300 | # skopeo-native installs to sbindir, not bindir | ||
| 301 | staging_sbindir = d.getVar('STAGING_SBINDIR_NATIVE') | ||
| 302 | skopeo = os.path.join(staging_sbindir, 'skopeo') | ||
| 303 | |||
| 304 | for url in remote_containers: | ||
| 305 | if not url: | ||
| 306 | continue | ||
| 307 | |||
| 308 | # Digest is REQUIRED for remote containers | ||
| 309 | # Varflag key must be sanitized (no / or : allowed in BitBake varflag names) | ||
| 310 | sanitized_key = url.replace('/', '_').replace(':', '_') | ||
| 311 | digest = d.getVarFlag('CONTAINER_DIGESTS', sanitized_key) | ||
| 312 | if not digest: | ||
| 313 | bb.fatal(f"Remote container '{url}' requires a pinned digest.\n" | ||
| 314 | f"Add: CONTAINER_DIGESTS[{sanitized_key}] = \"sha256:...\"\n" | ||
| 315 | f"Get digest with: skopeo inspect docker://{url} | jq -r '.Digest'") | ||
| 316 | |||
| 317 | # Emit licensing warning | ||
| 318 | bb.warn(f"Fetching third-party container: {url}\n" | ||
| 319 | f"Ensure you have rights to redistribute this container in your image.\n" | ||
| 320 | f"Check the container's license terms before distribution.") | ||
| 321 | |||
| 322 | # Strip tag from URL when using digest (skopeo doesn't support both) | ||
| 323 | # e.g., docker.io/library/busybox:1.36 -> docker.io/library/busybox | ||
| 324 | base_url = url.rsplit(':', 1)[0] if ':' in url.split('/')[-1] else url | ||
| 325 | src = f"{base_url}@{digest}" | ||
| 326 | name = url.replace('/', '_').replace(':', '_') | ||
| 327 | dest_dir = os.path.join(fetched_dir, name) | ||
| 328 | dest = f"oci:{dest_dir}:latest" | ||
| 329 | |||
| 330 | bb.note(f"Fetching {src} -> {dest}") | ||
| 331 | |||
| 332 | try: | ||
| 333 | subprocess.check_call([skopeo, 'copy', f'docker://{src}', dest]) | ||
| 334 | except subprocess.CalledProcessError as e: | ||
| 335 | bb.fatal(f"Failed to fetch container '{url}': {e}") | ||
| 336 | } | ||
| 337 | |||
| 338 | do_fetch_containers[network] = "1" | ||
| 339 | addtask fetch_containers after do_prepare_recipe_sysroot before do_compile | ||
| 340 | |||
| 341 | do_compile() { | ||
| 342 | set -e | ||
| 343 | |||
| 344 | mkdir -p "${S}" | ||
| 345 | |||
| 346 | # Clean OCI directory to avoid nested copies from incremental builds | ||
| 347 | rm -rf "${B}/oci" | ||
| 348 | mkdir -p "${B}/oci" | ||
| 349 | |||
| 350 | # Clear refs file to avoid duplicates from incremental builds | ||
| 351 | : > "${B}/oci-refs.txt" | ||
| 352 | |||
| 353 | RUNTIME="${_BUNDLE_RUNTIME}" | ||
| 354 | bbnote "Collecting OCI images for runtime: ${RUNTIME}" | ||
| 355 | |||
| 356 | # Collect OCI directories - NO vrunner here, just copy OCI images | ||
| 357 | # vrunner will be run ONCE by container-cross-install at rootfs time | ||
| 358 | for bundle in ${_PROCESSED_BUNDLES}; do | ||
| 359 | # Extract source from bundle format | ||
| 360 | source=$(echo "$bundle" | sed -E 's/:(docker|podman)(:(autostart|always|unless-stopped|on-failure|no))?$//') | ||
| 361 | collect_oci "$source" | ||
| 362 | done | ||
| 363 | |||
| 364 | # Store metadata for autostart processing (one bundle per line) | ||
| 365 | printf '%s\n' ${_PROCESSED_BUNDLES} > "${B}/bundle-metadata.txt" | ||
| 366 | } | ||
| 367 | |||
| 368 | collect_oci() { | ||
| 369 | local source="$1" | ||
| 370 | |||
| 371 | # Determine OCI directory and image reference | ||
| 372 | if echo "$source" | grep -qE '[/.]'; then | ||
| 373 | # Remote container - already fetched to WORKDIR/fetched/ | ||
| 374 | local name=$(echo "$source" | sed 's|[/:]|_|g') | ||
| 375 | local oci_src="${WORKDIR}/fetched/${name}" | ||
| 376 | local tag=$(echo "$source" | grep -oE ':[^:]+$' | sed 's/^://' || echo "latest") | ||
| 377 | local base_name=$(echo "$source" | sed 's|.*/||' | sed 's/:.*$//') | ||
| 378 | local image_ref="${base_name}:${tag}" | ||
| 379 | else | ||
| 380 | # Local container - from DEPLOY_DIR | ||
| 381 | local oci_src="${DEPLOY_DIR_IMAGE}/${source}-latest-oci" | ||
| 382 | if [ ! -d "${oci_src}" ]; then | ||
| 383 | oci_src="${DEPLOY_DIR_IMAGE}/${source}-oci" | ||
| 384 | fi | ||
| 385 | if [ ! -d "${oci_src}" ]; then | ||
| 386 | oci_src="${DEPLOY_DIR_IMAGE}/${source}" | ||
| 387 | fi | ||
| 388 | local image_ref="${source}:latest" | ||
| 389 | fi | ||
| 390 | |||
| 391 | if [ ! -d "${oci_src}" ]; then | ||
| 392 | bbfatal "Container OCI directory not found: ${oci_src}" | ||
| 393 | fi | ||
| 394 | |||
| 395 | # Copy OCI directory to build dir with image ref as name | ||
| 396 | # Format: image_ref (e.g., busybox:1.36 or container-base:latest) | ||
| 397 | local oci_name=$(echo "${image_ref}" | sed 's|[/:]|_|g') | ||
| 398 | local oci_dest="${B}/oci/${oci_name}" | ||
| 399 | |||
| 400 | bbnote "Collecting OCI: ${oci_src} -> ${oci_dest} (ref: ${image_ref})" | ||
| 401 | cp -rL "${oci_src}" "${oci_dest}" | ||
| 402 | |||
| 403 | # Store the image reference for later use | ||
| 404 | echo "${oci_name}:${image_ref}" >> "${B}/oci-refs.txt" | ||
| 405 | } | ||
| 406 | |||
| 407 | do_install() { | ||
| 408 | # Install OCI directories for container-cross-install to process | ||
| 409 | # NO storage tars - vrunner runs once at rootfs time | ||
| 410 | |||
| 411 | RUNTIME="${_BUNDLE_RUNTIME}" | ||
| 412 | |||
| 413 | # Install OCI directories | ||
| 414 | if [ -d "${B}/oci" ] && [ -n "$(ls -A ${B}/oci 2>/dev/null)" ]; then | ||
| 415 | install -d ${D}${datadir}/container-bundles/${RUNTIME}/oci | ||
| 416 | cp -r ${B}/oci/* ${D}${datadir}/container-bundles/${RUNTIME}/oci/ | ||
| 417 | fi | ||
| 418 | |||
| 419 | # Install OCI references file | ||
| 420 | if [ -f "${B}/oci-refs.txt" ]; then | ||
| 421 | install -d ${D}${datadir}/container-bundles/${RUNTIME} | ||
| 422 | install -m 0644 ${B}/oci-refs.txt \ | ||
| 423 | ${D}${datadir}/container-bundles/${RUNTIME}/${PN}.refs | ||
| 424 | fi | ||
| 425 | |||
| 426 | # Install metadata for autostart service generation | ||
| 427 | if [ -f "${B}/bundle-metadata.txt" ]; then | ||
| 428 | install -d ${D}${datadir}/container-bundles | ||
| 429 | install -m 0644 ${B}/bundle-metadata.txt \ | ||
| 430 | ${D}${datadir}/container-bundles/${PN}.meta | ||
| 431 | fi | ||
| 432 | } | ||
| 433 | |||
| 434 | FILES:${PN} = "${datadir}/container-bundles" | ||
| 435 | |||
| 436 | # Automatically trigger multiconfig blob builds | ||
| 437 | # Note: This does NOT create circular dependencies because the blob build chain | ||
| 438 | # (vdkr/vpdmn-initramfs-create -> vdkr/vpdmn-rootfs-image) is completely separate | ||
| 439 | # from container image recipes. Circular deps only occur if bundle packages are | ||
| 440 | # globally added to all images (including container images themselves). | ||
| 441 | do_compile[mcdepends] = "mc::${VRUNTIME_MULTICONFIG}:vdkr-initramfs-create:do_deploy mc::${VRUNTIME_MULTICONFIG}:vpdmn-initramfs-create:do_deploy" | ||
diff --git a/recipes-demo/autostart-test/autostart-test_1.0.bb b/recipes-demo/autostart-test/autostart-test_1.0.bb new file mode 100644 index 00000000..38086023 --- /dev/null +++ b/recipes-demo/autostart-test/autostart-test_1.0.bb | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | SUMMARY = "Simple test service for container autostart verification" | ||
| 2 | DESCRIPTION = "A shell script that runs continuously and logs timestamps, \ | ||
| 3 | useful for verifying container autostart functionality." | ||
| 4 | LICENSE = "MIT" | ||
| 5 | LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" | ||
| 6 | |||
| 7 | SRC_URI = "file://autostart-test.sh" | ||
| 8 | |||
| 9 | S = "${UNPACKDIR}" | ||
| 10 | |||
| 11 | do_install() { | ||
| 12 | install -d ${D}${bindir} | ||
| 13 | install -m 0755 ${S}/autostart-test.sh ${D}${bindir}/autostart-test | ||
| 14 | } | ||
| 15 | |||
| 16 | RDEPENDS:${PN} = "busybox" | ||
diff --git a/recipes-demo/autostart-test/files/autostart-test.sh b/recipes-demo/autostart-test/files/autostart-test.sh new file mode 100644 index 00000000..30043fa7 --- /dev/null +++ b/recipes-demo/autostart-test/files/autostart-test.sh | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # | ||
| 3 | # Simple test service for verifying container autostart | ||
| 4 | # Writes timestamps to stdout (captured by container logs) | ||
| 5 | # | ||
| 6 | |||
| 7 | INTERVAL=${INTERVAL:-5} | ||
| 8 | MESSAGE=${MESSAGE:-"autostart-test is running"} | ||
| 9 | |||
| 10 | echo "=== Autostart Test Service ===" | ||
| 11 | echo "Started at: $(date)" | ||
| 12 | echo "PID: $$" | ||
| 13 | echo "Interval: ${INTERVAL}s" | ||
| 14 | echo "==============================" | ||
| 15 | |||
| 16 | count=0 | ||
| 17 | while true; do | ||
| 18 | count=$((count + 1)) | ||
| 19 | echo "[${count}] $(date): ${MESSAGE}" | ||
| 20 | sleep ${INTERVAL} | ||
| 21 | done | ||
diff --git a/recipes-demo/images/autostart-test-container.bb b/recipes-demo/images/autostart-test-container.bb new file mode 100644 index 00000000..73a29a49 --- /dev/null +++ b/recipes-demo/images/autostart-test-container.bb | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | SUMMARY = "Autostart test container" | ||
| 2 | DESCRIPTION = "A container for testing autostart functionality. \ | ||
| 3 | Runs a simple service that logs timestamps continuously." | ||
| 4 | LICENSE = "MIT" | ||
| 5 | LIC_FILES_CHKSUM = "file://${COREBASE}/meta/COPYING.MIT;md5=3da9cfbcb788c80a0384361b4de20420" | ||
| 6 | |||
| 7 | # Inherit from container-app-base for standard container setup | ||
| 8 | require recipes-extended/images/container-app-base.bb | ||
| 9 | |||
| 10 | # The test service that runs continuously | ||
| 11 | CONTAINER_APP = "autostart-test" | ||
| 12 | CONTAINER_APP_CMD = "/usr/bin/autostart-test" | ||
| 13 | |||
| 14 | # To test autostart, add to local.conf: | ||
| 15 | # BUNDLED_CONTAINERS = "autostart-test-container-latest-oci:docker:autostart" | ||
| 16 | # | ||
| 17 | # Then verify on target: | ||
| 18 | # docker ps # Should show container running | ||
| 19 | # docker logs autostart-test-container # Should show timestamp logs | ||
| 20 | # | ||
| 21 | # For Podman: | ||
| 22 | # BUNDLED_CONTAINERS = "autostart-test-container-latest-oci:podman:autostart" | ||
| 23 | # podman ps | ||
| 24 | # podman logs autostart-test-container | ||
diff --git a/recipes-extended/container-bundles/example-container-bundle_1.0.bb b/recipes-extended/container-bundles/example-container-bundle_1.0.bb new file mode 100644 index 00000000..a91e4609 --- /dev/null +++ b/recipes-extended/container-bundles/example-container-bundle_1.0.bb | |||
| @@ -0,0 +1,68 @@ | |||
| 1 | # example-container-bundle_1.0.bb | ||
| 2 | # =========================================================================== | ||
| 3 | # Example container bundle recipe demonstrating container-bundle.bbclass | ||
| 4 | # =========================================================================== | ||
| 5 | # | ||
| 6 | # This recipe shows how to create a package that bundles containers. | ||
| 7 | # When installed via IMAGE_INSTALL, the containers are automatically | ||
| 8 | # merged into the target image's container storage. | ||
| 9 | # | ||
| 10 | # Usage in image recipe (e.g., container-image-host.bb): | ||
| 11 | # IMAGE_INSTALL += "example-container-bundle" | ||
| 12 | # | ||
| 13 | # Or in local.conf (use pn- override for specific images): | ||
| 14 | # IMAGE_INSTALL:append:pn-container-image-host = " example-container-bundle" | ||
| 15 | # | ||
| 16 | # IMPORTANT: Do NOT use global IMAGE_INSTALL:append without pn- override! | ||
| 17 | # This causes circular dependencies when container images try to include | ||
| 18 | # the bundle that depends on them. | ||
| 19 | # | ||
| 20 | # =========================================================================== | ||
| 21 | |||
| 22 | SUMMARY = "Example container bundle" | ||
| 23 | DESCRIPTION = "Demonstrates container-bundle.bbclass by bundling the \ | ||
| 24 | container-base image. Use this as a template for your \ | ||
| 25 | own container bundles." | ||
| 26 | HOMEPAGE = "https://github.com/anthropics/meta-virtualization" | ||
| 27 | LICENSE = "MIT" | ||
| 28 | LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" | ||
| 29 | |||
| 30 | inherit container-bundle | ||
| 31 | |||
| 32 | # Define containers to bundle | ||
| 33 | # Format: source[:autostart-policy] | ||
| 34 | # | ||
| 35 | # source: Either a local recipe name or registry URL | ||
| 36 | # - Local: "container-base" (simple recipe name) | ||
| 37 | # - Remote: "docker.io/library/alpine:3.19" (registry URL) | ||
| 38 | # | ||
| 39 | # autostart: (optional) autostart | always | unless-stopped | on-failure | ||
| 40 | # | ||
| 41 | # Runtime is determined automatically from CONTAINER_PROFILE (or CONTAINER_BUNDLE_RUNTIME) | ||
| 42 | |||
| 43 | # Bundle the test containers we've been using: | ||
| 44 | # - container-base: minimal busybox container | ||
| 45 | # - container-app-base: busybox with app structure | ||
| 46 | # - autostart-test-container: container that logs startup for autostart testing | ||
| 47 | CONTAINER_BUNDLES = "\ | ||
| 48 | container-base \ | ||
| 49 | container-app-base \ | ||
| 50 | autostart-test-container:autostart \ | ||
| 51 | " | ||
| 52 | |||
| 53 | # Override runtime if needed (uncomment to force a specific runtime): | ||
| 54 | # CONTAINER_BUNDLE_RUNTIME = "podman" | ||
| 55 | |||
| 56 | # For remote containers (not used in this example), you MUST provide digests: | ||
| 57 | # CONTAINER_DIGESTS[docker.io/library/redis:7] = "sha256:e422889e156e..." | ||
| 58 | # | ||
| 59 | # Get the digest with: | ||
| 60 | # skopeo inspect docker://docker.io/library/redis:7 | jq -r '.Digest' | ||
| 61 | |||
| 62 | # Example with multiple containers and autostart: | ||
| 63 | # CONTAINER_BUNDLES = "\ | ||
| 64 | # myapp:autostart \ | ||
| 65 | # mydb \ | ||
| 66 | # docker.io/library/redis:7 \ | ||
| 67 | # " | ||
| 68 | # CONTAINER_DIGESTS[docker.io/library/redis:7] = "sha256:e422889..." | ||
diff --git a/recipes-extended/container-bundles/remote-container-bundle_1.0.bb b/recipes-extended/container-bundles/remote-container-bundle_1.0.bb new file mode 100644 index 00000000..7da267e5 --- /dev/null +++ b/recipes-extended/container-bundles/remote-container-bundle_1.0.bb | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | # | ||
| 5 | # remote-container-bundle_1.0.bb | ||
| 6 | # =========================================================================== | ||
| 7 | # Test recipe for remote container fetching via container-bundle.bbclass | ||
| 8 | # =========================================================================== | ||
| 9 | # | ||
| 10 | # This recipe demonstrates and tests fetching containers from a remote | ||
| 11 | # registry during the Yocto build. The container is pulled via skopeo | ||
| 12 | # and bundled into a package that can be installed into target images. | ||
| 13 | # | ||
| 14 | # Usage in image recipe: | ||
| 15 | # IMAGE_INSTALL += "remote-container-bundle" | ||
| 16 | # | ||
| 17 | # Or in local.conf: | ||
| 18 | # IMAGE_INSTALL:append:pn-container-image-host = " remote-container-bundle" | ||
| 19 | # | ||
| 20 | # The container will be available as "busybox:1.36" in the target's | ||
| 21 | # Docker/Podman storage after boot. | ||
| 22 | # | ||
| 23 | # =========================================================================== | ||
| 24 | |||
| 25 | SUMMARY = "Remote container bundle test" | ||
| 26 | DESCRIPTION = "Tests container-bundle.bbclass remote container fetching. \ | ||
| 27 | Pulls busybox from docker.io and bundles it for installation." | ||
| 28 | HOMEPAGE = "https://github.com/anthropics/meta-virtualization" | ||
| 29 | LICENSE = "MIT" | ||
| 30 | LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" | ||
| 31 | |||
| 32 | inherit container-bundle | ||
| 33 | |||
| 34 | # Remote container from Docker Hub | ||
| 35 | # Using busybox as it's small (~2MB) and available for multiple architectures | ||
| 36 | CONTAINER_BUNDLES = "\ | ||
| 37 | docker.io/library/busybox:1.36 \ | ||
| 38 | " | ||
| 39 | |||
| 40 | # REQUIRED: Pinned digest for reproducible builds | ||
| 41 | # Get with: skopeo inspect docker://docker.io/library/busybox:1.36 | jq -r '.Digest' | ||
| 42 | # Note: This is the multi-arch manifest digest, skopeo will select the correct arch | ||
| 43 | # Key format: Replace / and : with _ for BitBake variable flag compatibility | ||
| 44 | CONTAINER_DIGESTS[docker.io_library_busybox_1.36] = "sha256:768e5c6f5cb6db0794eec98dc7a967f40631746c32232b78a3105fb946f3ab83" | ||
| 45 | |||
| 46 | # Note: busybox is GPL-licensed, so no LICENSE_FLAGS needed. | ||
| 47 | # For containers with commercial licenses, you would add: | ||
| 48 | # LICENSE_FLAGS:append = " commercial" | ||
| 49 | # And accept in local.conf: | ||
| 50 | # LICENSE_FLAGS_ACCEPTED:append = " commercial" | ||
