diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-02-05 21:37:24 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-02-09 03:34:12 +0000 |
| commit | b4ad3f9eb2f022b6f69b2e78dbca80974d5bf84a (patch) | |
| tree | 89da66f8e07714891b9f3f5e114568c84cb49dae /classes | |
| parent | bcddeedc6f657841bf0cbb9cf06e9de1633bbe6d (diff) | |
| download | meta-virtualization-b4ad3f9eb2f022b6f69b2e78dbca80974d5bf84a.tar.gz | |
image-oci: add host layer type and delta-only copying
Add two enhancements to multi-layer OCI image support:
1. Delta-only copying for directories/files layers:
- directories and files layers now only copy content that doesn't
already exist in the bundle rootfs from earlier layers
- Prevents duplication when a directories layer references paths
that were already populated by a packages layer
- Logs show "delta: N copied, M skipped" for visibility
2. New 'host' layer type for build machine content:
- Copies files from the build machine filesystem (outside Yocto)
- Format: name:host:source_path:dest_path
- Multiple pairs: name:host:src1:dst1+src2:dst2
- Emits warning at parse time about reproducibility impact
- Fatal error if source path doesn't exist
- Use case: deployment-specific config, certificates, keys that
cannot be packaged in recipes
Example:
OCI_LAYERS = "\
base:packages:busybox \
app:directories:/opt/myapp \
certs:host:/etc/ssl/certs/ca.crt:/etc/ssl/certs/ca.crt \
"
Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'classes')
| -rw-r--r-- | classes/image-oci-umoci.inc | 61 | ||||
| -rw-r--r-- | classes/image-oci.bbclass | 43 |
2 files changed, 90 insertions, 14 deletions
diff --git a/classes/image-oci-umoci.inc b/classes/image-oci-umoci.inc index fbb77cd0..616c20ae 100644 --- a/classes/image-oci-umoci.inc +++ b/classes/image-oci-umoci.inc | |||
| @@ -589,22 +589,67 @@ IMAGE_CMD:oci() { | |||
| 589 | fi | 589 | fi |
| 590 | 590 | ||
| 591 | elif [ "$oci_layer_type" = "directories" ]; then | 591 | elif [ "$oci_layer_type" = "directories" ]; then |
| 592 | # Copy directories from IMAGE_ROOTFS | 592 | # Copy directories from IMAGE_ROOTFS (delta-only: skip files already in bundle) |
| 593 | for oci_dir in $oci_layer_content; do | 593 | for oci_dir in $oci_layer_content; do |
| 594 | if [ -d "${IMAGE_ROOTFS}$oci_dir" ]; then | 594 | if [ -d "${IMAGE_ROOTFS}$oci_dir" ]; then |
| 595 | mkdir -p "$image_bundle_name/rootfs$(dirname $oci_dir)" | 595 | oci_delta_copied=0 |
| 596 | cp -a "${IMAGE_ROOTFS}$oci_dir" "$image_bundle_name/rootfs$oci_dir" | 596 | oci_delta_skipped=0 |
| 597 | bbnote "OCI: Added directory $oci_dir" | 597 | # Walk the directory and copy only files not in bundle |
| 598 | while IFS= read -r oci_src_file; do | ||
| 599 | oci_rel_path="${oci_src_file#${IMAGE_ROOTFS}}" | ||
| 600 | oci_dst_file="$image_bundle_name/rootfs$oci_rel_path" | ||
| 601 | if [ ! -e "$oci_dst_file" ]; then | ||
| 602 | mkdir -p "$(dirname "$oci_dst_file")" | ||
| 603 | cp -a "$oci_src_file" "$oci_dst_file" | ||
| 604 | oci_delta_copied=$((oci_delta_copied + 1)) | ||
| 605 | else | ||
| 606 | oci_delta_skipped=$((oci_delta_skipped + 1)) | ||
| 607 | fi | ||
| 608 | done < <(find "${IMAGE_ROOTFS}$oci_dir" -type f -o -type l) | ||
| 609 | # Also copy empty directories | ||
| 610 | while IFS= read -r oci_src_dir; do | ||
| 611 | oci_rel_path="${oci_src_dir#${IMAGE_ROOTFS}}" | ||
| 612 | oci_dst_dir="$image_bundle_name/rootfs$oci_rel_path" | ||
| 613 | if [ ! -e "$oci_dst_dir" ]; then | ||
| 614 | mkdir -p "$oci_dst_dir" | ||
| 615 | fi | ||
| 616 | done < <(find "${IMAGE_ROOTFS}$oci_dir" -type d) | ||
| 617 | bbnote "OCI: Added directory $oci_dir (delta: $oci_delta_copied copied, $oci_delta_skipped skipped)" | ||
| 618 | else | ||
| 619 | bbwarn "OCI: Directory not found in IMAGE_ROOTFS: $oci_dir" | ||
| 598 | fi | 620 | fi |
| 599 | done | 621 | done |
| 600 | 622 | ||
| 601 | elif [ "$oci_layer_type" = "files" ]; then | 623 | elif [ "$oci_layer_type" = "files" ]; then |
| 602 | # Copy specific files from IMAGE_ROOTFS | 624 | # Copy specific files from IMAGE_ROOTFS (delta-only: skip files already in bundle) |
| 603 | for oci_file in $oci_layer_content; do | 625 | for oci_file in $oci_layer_content; do |
| 604 | if [ -e "${IMAGE_ROOTFS}$oci_file" ]; then | 626 | if [ -e "${IMAGE_ROOTFS}$oci_file" ]; then |
| 605 | mkdir -p "$image_bundle_name/rootfs$(dirname $oci_file)" | 627 | oci_dst_file="$image_bundle_name/rootfs$oci_file" |
| 606 | cp -a "${IMAGE_ROOTFS}$oci_file" "$image_bundle_name/rootfs$oci_file" | 628 | if [ ! -e "$oci_dst_file" ]; then |
| 607 | bbnote "OCI: Added file $oci_file" | 629 | mkdir -p "$(dirname "$oci_dst_file")" |
| 630 | cp -a "${IMAGE_ROOTFS}$oci_file" "$oci_dst_file" | ||
| 631 | bbnote "OCI: Added file $oci_file" | ||
| 632 | else | ||
| 633 | bbnote "OCI: Skipped file $oci_file (already in bundle)" | ||
| 634 | fi | ||
| 635 | else | ||
| 636 | bbwarn "OCI: File not found in IMAGE_ROOTFS: $oci_file" | ||
| 637 | fi | ||
| 638 | done | ||
| 639 | |||
| 640 | elif [ "$oci_layer_type" = "host" ]; then | ||
| 641 | # Copy files from build machine filesystem (outside Yocto) | ||
| 642 | # Format: source_path:dest_path pairs separated by + (already converted to space) | ||
| 643 | for oci_host_pair in $oci_layer_content; do | ||
| 644 | # Split on last : to handle paths that might contain : | ||
| 645 | oci_host_src="${oci_host_pair%:*}" | ||
| 646 | oci_host_dst="${oci_host_pair##*:}" | ||
| 647 | if [ -e "$oci_host_src" ]; then | ||
| 648 | mkdir -p "$image_bundle_name/rootfs$(dirname $oci_host_dst)" | ||
| 649 | cp -a "$oci_host_src" "$image_bundle_name/rootfs$oci_host_dst" | ||
| 650 | bbnote "OCI: Added from host: $oci_host_src -> $oci_host_dst" | ||
| 651 | else | ||
| 652 | bbfatal "OCI: Host path not found: $oci_host_src" | ||
| 608 | fi | 653 | fi |
| 609 | done | 654 | done |
| 610 | fi | 655 | fi |
diff --git a/classes/image-oci.bbclass b/classes/image-oci.bbclass index 41f7b3d1..ea2b63df 100644 --- a/classes/image-oci.bbclass +++ b/classes/image-oci.bbclass | |||
| @@ -167,9 +167,10 @@ OCI_LAYER_MODE ?= "single" | |||
| 167 | # Each layer is defined as: "name:type:content" | 167 | # Each layer is defined as: "name:type:content" |
| 168 | # | 168 | # |
| 169 | # Layer Types: | 169 | # Layer Types: |
| 170 | # packages - Copy files installed by specified packages | 170 | # packages - Install packages using Yocto's package manager |
| 171 | # directories - Copy specific directories from IMAGE_ROOTFS | 171 | # directories - Copy specific directories from IMAGE_ROOTFS (delta-only) |
| 172 | # files - Copy specific files from IMAGE_ROOTFS | 172 | # files - Copy specific files from IMAGE_ROOTFS (delta-only) |
| 173 | # host - Copy files from build machine filesystem (outside Yocto) | ||
| 173 | # | 174 | # |
| 174 | # Format: Space-separated list of layer definitions | 175 | # Format: Space-separated list of layer definitions |
| 175 | # OCI_LAYERS = "layer1:type:content layer2:type:content ..." | 176 | # OCI_LAYERS = "layer1:type:content layer2:type:content ..." |
| @@ -181,6 +182,18 @@ OCI_LAYER_MODE ?= "single" | |||
| 181 | # "app:directories:/opt/myapp+/etc/myapp" | 182 | # "app:directories:/opt/myapp+/etc/myapp" |
| 182 | # "config:files:/etc/myapp.conf+/etc/default/myapp" | 183 | # "config:files:/etc/myapp.conf+/etc/default/myapp" |
| 183 | # | 184 | # |
| 185 | # NOTE: directories/files only copy content NOT already present in | ||
| 186 | # earlier layers (delta-only), avoiding duplication with packages layers. | ||
| 187 | # | ||
| 188 | # For host type, content is source:dest pairs (use + as delimiter): | ||
| 189 | # "certs:host:/etc/ssl/certs/my-ca.crt:/etc/ssl/certs/my-ca.crt" | ||
| 190 | # "config:host:/home/builder/config:/etc/myapp/config+/home/builder/keys:/etc/myapp/keys" | ||
| 191 | # | ||
| 192 | # WARNING: host layers copy content from the build machine that is NOT | ||
| 193 | # part of the Yocto build. This affects reproducibility - the build output | ||
| 194 | # depends on the state of the build machine. Use sparingly for deployment- | ||
| 195 | # specific config, keys, or certificates that cannot be packaged. | ||
| 196 | # | ||
| 184 | # Note: Use + as delimiter because ; is interpreted as shell command separator | 197 | # Note: Use + as delimiter because ; is interpreted as shell command separator |
| 185 | # | 198 | # |
| 186 | # Example: | 199 | # Example: |
| @@ -189,9 +202,10 @@ OCI_LAYER_MODE ?= "single" | |||
| 189 | # base:packages:base-files+base-passwd+netbase+busybox \ | 202 | # base:packages:base-files+base-passwd+netbase+busybox \ |
| 190 | # python:packages:python3+python3-pip \ | 203 | # python:packages:python3+python3-pip \ |
| 191 | # app:directories:/opt/myapp \ | 204 | # app:directories:/opt/myapp \ |
| 205 | # certs:host:/etc/ssl/certs/my-ca.crt:/etc/ssl/certs/ \ | ||
| 192 | # " | 206 | # " |
| 193 | # | 207 | # |
| 194 | # Result: 3 layers (base, python, app) plus any base image layers | 208 | # Result: 4 layers (base, python, app, certs) plus any base image layers |
| 195 | # | 209 | # |
| 196 | OCI_LAYERS ?= "" | 210 | OCI_LAYERS ?= "" |
| 197 | 211 | ||
| @@ -288,6 +302,7 @@ python __anonymous() { | |||
| 288 | bb.fatal("OCI_LAYER_MODE = 'multi' requires OCI_LAYERS to be defined") | 302 | bb.fatal("OCI_LAYER_MODE = 'multi' requires OCI_LAYERS to be defined") |
| 289 | 303 | ||
| 290 | has_packages_layer = False | 304 | has_packages_layer = False |
| 305 | host_layer_warnings = [] | ||
| 291 | 306 | ||
| 292 | # Parse and validate layer definitions | 307 | # Parse and validate layer definitions |
| 293 | for layer_def in oci_layers.split(): | 308 | for layer_def in oci_layers.split(): |
| @@ -296,11 +311,27 @@ python __anonymous() { | |||
| 296 | bb.fatal(f"Invalid OCI_LAYERS entry '{layer_def}': " | 311 | bb.fatal(f"Invalid OCI_LAYERS entry '{layer_def}': " |
| 297 | "format is 'name:type:content'") | 312 | "format is 'name:type:content'") |
| 298 | layer_name, layer_type, layer_content = parts[0], parts[1], ':'.join(parts[2:]) | 313 | layer_name, layer_type, layer_content = parts[0], parts[1], ':'.join(parts[2:]) |
| 299 | if layer_type not in ('packages', 'directories', 'files'): | 314 | if layer_type not in ('packages', 'directories', 'files', 'host'): |
| 300 | bb.fatal(f"Invalid layer type '{layer_type}' in '{layer_def}': " | 315 | bb.fatal(f"Invalid layer type '{layer_type}' in '{layer_def}': " |
| 301 | "must be 'packages', 'directories', or 'files'") | 316 | "must be 'packages', 'directories', 'files', or 'host'") |
| 302 | if layer_type == 'packages': | 317 | if layer_type == 'packages': |
| 303 | has_packages_layer = True | 318 | has_packages_layer = True |
| 319 | elif layer_type == 'host': | ||
| 320 | # Validate host layer format and collect warnings | ||
| 321 | # Format: source:dest pairs separated by + | ||
| 322 | for pair in layer_content.replace('+', ' ').split(): | ||
| 323 | if ':' not in pair: | ||
| 324 | bb.fatal(f"Invalid host layer content '{pair}' in '{layer_def}': " | ||
| 325 | "format is 'source_path:dest_path'") | ||
| 326 | src_path = pair.rsplit(':', 1)[0] | ||
| 327 | host_layer_warnings.append(f" Layer '{layer_name}': {src_path}") | ||
| 328 | |||
| 329 | # Emit warning for host layers (content from build machine, not Yocto) | ||
| 330 | if host_layer_warnings: | ||
| 331 | bb.warn("OCI image includes content from build machine filesystem (host layers).\n" | ||
| 332 | "This content is NOT part of the Yocto build and affects reproducibility.\n" | ||
| 333 | "The build output will depend on the state of the build machine.\n" | ||
| 334 | "Host paths used:\n" + "\n".join(host_layer_warnings)) | ||
| 304 | 335 | ||
| 305 | # Add package manager native dependency if using 'packages' layer type | 336 | # Add package manager native dependency if using 'packages' layer type |
| 306 | if has_packages_layer: | 337 | if has_packages_layer: |
