From f784b5f31f964e85cd6939a784eb5dd4f463e55b Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Mon, 9 Mar 2026 16:34:13 -0400 Subject: [PATCH 01/10] feat: build rootfs and initramfs images from packages Build rootfs (erofs) and initramfs (cpio.zst) images from RPM package sysroots instead of using pre-built Yocto images. This enables AVOCADO_OS_BUILD_ID injection into os-release for hardware-rooted boot verification. Key changes: - Add top-level rootfs/initramfs config sections with sensible defaults - Install initramfs sysroot alongside rootfs during sdk install - Build rootfs erofs and initramfs cpio images during runtime build - Create usrmerge symlinks, empty /etc/machine-id, /init symlink - Add offline systemctl wrapper for service enablement in sysroots - Add depmod wrapper gated by AVOCADO_SYSROOT_SCRIPTS for cross-arch - Use host-native ldconfig -r for cross-arch ld.so.cache generation - Run systemd preset-all at build time for read-only rootfs - Clean initramfs sysroot in sdk clean - Add initramfs sysroot type to lockfile --- src/commands/runtime/build.rs | 144 ++++++++++++- src/commands/sdk/clean.rs | 3 +- src/commands/sdk/install.rs | 366 +++++++++++++++++++++++++++++++++- src/utils/config.rs | 47 +++++ src/utils/lockfile.rs | 17 ++ src/utils/stamps.rs | 20 ++ src/utils/target.rs | 6 + 7 files changed, 594 insertions(+), 9 deletions(-) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 9c41307..bf2d08d 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1467,11 +1467,141 @@ echo "Docker image priming complete.""#, } }; + let rootfs_build_section = format!( + r#" +# Build rootfs image from shared sysroot +ROOTFS_SYSROOT="$AVOCADO_PREFIX/rootfs" +if [ -d "$ROOTFS_SYSROOT/usr" ]; then + echo "Building rootfs image from packages..." + + # Work on a copy so we don't mutate the shared sysroot used for extension priming + ROOTFS_WORK="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/rootfs-work" + rm -rf "$ROOTFS_WORK" + cp -a "$ROOTFS_SYSROOT" "$ROOTFS_WORK" + + # Create usrmerge symlinks (Yocto image class does this, not any RPM package) + ln -sfn usr/bin "$ROOTFS_WORK/bin" + ln -sfn usr/sbin "$ROOTFS_WORK/sbin" + ln -sfn usr/lib "$ROOTFS_WORK/lib" + + # Post-processing (matches Yocto avocado-image-rootfs.bb) + rm -rf "$ROOTFS_WORK/media" "$ROOTFS_WORK/mnt" "$ROOTFS_WORK/srv" + rm -rf "$ROOTFS_WORK/boot/"* + mkdir -p "$ROOTFS_WORK/opt" + + # Create empty /etc/machine-id for stateless systemd on read-only rootfs. + # systemd will bind-mount a transient machine-id at boot. + # (matches OE read_only_rootfs_hook in rootfs-postcommands.bbclass) + touch "$ROOTFS_WORK/etc/machine-id" + + # Enable systemd service units via preset files. + # (matches OE systemd_preset_all in image.bbclass) + if [ -e "$ROOTFS_WORK/usr/lib/systemd/systemd" ]; then + "$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/systemctl" --root="$ROOTFS_WORK" --preset-mode=enable-only preset-all 2>/dev/null || true + echo "Applied systemd presets" + fi + + # Generate ld.so.cache so the read-only rootfs has a working linker cache. + # Uses the container's host-native ldconfig with -r (chroot flag). + # ldconfig -r does chroot() but continues as the host binary — no binfmt needed. + # This matches Yocto's ldconfig-native approach. + /usr/sbin/ldconfig -r "$ROOTFS_WORK" -c new -X 2>/dev/null || true + echo "Generated ld.so.cache" + + # Compute deterministic AVOCADO_OS_BUILD_ID from installed packages + PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$ROOTFS_SYSROOT" | sort) + PKG_HASH=$(echo "$PKG_NEVRA" | sha256sum | awk '{{print $1}}') + OS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$PKG_HASH'))") + + # Inject identity into os-release (in the work copy only) + echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_WORK/usr/lib/os-release" + echo "AVOCADO_RUNTIME_NAME=$RUNTIME_NAME" >> "$ROOTFS_WORK/usr/lib/os-release" + echo "AVOCADO_RUNTIME_VERSION=$RUNTIME_VERSION" >> "$ROOTFS_WORK/usr/lib/os-release" + + # Build erofs image (reproducible flags matching ext/image.rs) + ROOTFS_OUTPUT="$OUTPUT_DIR/avocado-image-rootfs-$TARGET_ARCH.erofs-lz4" + mkfs.erofs \ + -T "${{SOURCE_DATE_EPOCH:-0}}" \ + -U 00000000-0000-0000-0000-000000000000 \ + -x -1 \ + --all-root \ + -z lz4hc \ + "$ROOTFS_OUTPUT" \ + "$ROOTFS_WORK" + + rm -rf "$ROOTFS_WORK" + export AVOCADO_ROOTFS_IMAGE="$ROOTFS_OUTPUT" + export AVOCADO_OS_BUILD_ID="$OS_BUILD_ID" + echo "Built rootfs: $ROOTFS_OUTPUT (AVOCADO_OS_BUILD_ID=$OS_BUILD_ID)" +else + echo "No rootfs sysroot found — skipping rootfs image build." +fi"#, + namespace_uuid = namespace_uuid, + ); + + let initramfs_build_section = format!( + r#" +# Build initramfs image from shared sysroot +INITRAMFS_SYSROOT="$AVOCADO_PREFIX/initramfs" +if [ -d "$INITRAMFS_SYSROOT/usr" ]; then + echo "Building initramfs image from packages..." + + INITRAMFS_WORK="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/initramfs-work" + rm -rf "$INITRAMFS_WORK" + cp -a "$INITRAMFS_SYSROOT" "$INITRAMFS_WORK" + + # Create usrmerge symlinks (Yocto image class does this, not any RPM package) + ln -sfn usr/bin "$INITRAMFS_WORK/bin" + ln -sfn usr/sbin "$INITRAMFS_WORK/sbin" + ln -sfn usr/lib "$INITRAMFS_WORK/lib" + + # Post-processing (matches Yocto avocado-image-initramfs.bb) + rm -rf "$INITRAMFS_WORK/media" "$INITRAMFS_WORK/mnt" "$INITRAMFS_WORK/srv" + rm -rf "$INITRAMFS_WORK/boot/"* + mkdir -p "$INITRAMFS_WORK/sysroot" + mkdir -p "$INITRAMFS_WORK/opt" + + # Compute deterministic build ID for initramfs + INITRAMFS_PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$INITRAMFS_SYSROOT" | sort) + INITRAMFS_PKG_HASH=$(echo "$INITRAMFS_PKG_NEVRA" | sha256sum | awk '{{print $1}}') + INITRAMFS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$INITRAMFS_PKG_HASH'))") + + # Inject identity into os-release-initrd (if it exists in the sysroot) + if [ -f "$INITRAMFS_WORK/usr/lib/os-release-initrd" ]; then + echo "AVOCADO_OS_BUILD_ID=$INITRAMFS_BUILD_ID" >> "$INITRAMFS_WORK/usr/lib/os-release-initrd" + fi + + # Create /init symlink so the kernel can find the init process in the initramfs. + # (matches OE IMAGE_CMD:cpio in image_types.bbclass — creates /init -> /sbin/init) + if [ ! -L "$INITRAMFS_WORK/init" ] && [ ! -e "$INITRAMFS_WORK/init" ]; then + if [ -L "$INITRAMFS_WORK/sbin/init" ] || [ -e "$INITRAMFS_WORK/sbin/init" ]; then + ln -sf /sbin/init "$INITRAMFS_WORK/init" + echo "Created /init -> /sbin/init symlink" + else + echo "WARNING: /sbin/init not found in initramfs — kernel may not find init" + fi + fi + + # Build cpio archive (zstd compressed) + INITRAMFS_OUTPUT="$OUTPUT_DIR/avocado-image-initramfs-$TARGET_ARCH.cpio.zst" + (cd "$INITRAMFS_WORK" && find . | sort | cpio --reproducible -o -H newc --quiet | zstd -3 -f -o "$INITRAMFS_OUTPUT") + + rm -rf "$INITRAMFS_WORK" + export AVOCADO_INITRAMFS_IMAGE="$INITRAMFS_OUTPUT" + export AVOCADO_INITRAMFS_BUILD_ID="$INITRAMFS_BUILD_ID" + echo "Built initramfs: $INITRAMFS_OUTPUT" +else + echo "No initramfs sysroot found — skipping initramfs image build." +fi"#, + namespace_uuid = namespace_uuid, + ); + let script = format!( r#" # Set common variables RUNTIME_NAME="{runtime_name}" TARGET_ARCH="{target_arch}" +RUNTIME_VERSION="{runtime_version}" VAR_DIR=$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/var-staging mkdir -p "$VAR_DIR/lib/avocado/images" @@ -1491,26 +1621,32 @@ rm -f "$RUNTIME_EXT_DIR"/*.raw 2>/dev/null || true # Copy required extension images from global output/extensions to runtime-specific location echo "Copying required extension images to runtime-specific directory..." {copy_section} + +# Build rootfs and initramfs images from package sysroots +{rootfs_build_section} +{initramfs_build_section} + +# Assemble var partition content {var_files_section} {runtime_var_files_section} {manifest_section} {update_authority_section} {docker_section} -# Potential future SDK target hook. -# echo "Run: avocado-pre-image-var-$TARGET_ARCH $RUNTIME_NAME" -# avocado-pre-image-var-$TARGET_ARCH $RUNTIME_NAME - mkfs.btrfs -r "$VAR_DIR" \ --subvol rw:lib/avocado \ -f "$OUTPUT_DIR/avocado-image-var-$TARGET_ARCH.btrfs" +# Run SDK lifecycle hook (stone create) — uses newly built rootfs + initramfs + var echo -e "\033[94m[INFO]\033[0m Running SDK lifecycle hook 'avocado-build' for '$TARGET_ARCH'." avocado-build-$TARGET_ARCH $RUNTIME_NAME "#, runtime_name = self.runtime_name, target_arch = target_arch, + runtime_version = runtime_version, copy_section = copy_section, + rootfs_build_section = rootfs_build_section, + initramfs_build_section = initramfs_build_section, var_files_section = var_files_section, runtime_var_files_section = runtime_var_files_section, manifest_section = manifest_section, diff --git a/src/commands/sdk/clean.rs b/src/commands/sdk/clean.rs index 076ea4e..d6acfce 100644 --- a/src/commands/sdk/clean.rs +++ b/src/commands/sdk/clean.rs @@ -159,7 +159,8 @@ impl SdkCleanCommand { ); } - let remove_command = "rm -rf $AVOCADO_SDK_PREFIX"; + let remove_command = + "rm -rf $AVOCADO_SDK_PREFIX $AVOCADO_PREFIX/rootfs $AVOCADO_PREFIX/initramfs"; let run_config = RunConfig { container_image: container_image.to_string(), target: target.clone(), diff --git a/src/commands/sdk/install.rs b/src/commands/sdk/install.rs index b78f7c5..f859b79 100644 --- a/src/commands/sdk/install.rs +++ b/src/commands/sdk/install.rs @@ -446,7 +446,7 @@ chmod +x $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/grep # Create symlinks for common scriptlet commands that should noop # Allowlist approach: we create wrappers for what we DON'T want, not for what we DO want for cmd in useradd groupadd usermod groupmod userdel groupdel chown chmod chgrp \ - flock systemctl systemd-tmpfiles ldconfig depmod udevadm \ + flock systemd-tmpfiles udevadm \ dbus-send killall service update-rc.d invoke-rc.d \ gtk-update-icon-cache glib-compile-schemas update-desktop-database \ fc-cache mkfontdir mkfontscale install-info update-mime-database \ @@ -460,6 +460,242 @@ for cmd in useradd groupadd usermod groupmod userdel groupdel chown chmod chgrp ln -sf noop-command $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/$cmd done +# Create depmod wrapper that operates against the installroot sysroot +# Only runs when AVOCADO_SYSROOT_SCRIPTS=1 (set for rootfs/initramfs installs, +# NOT for extension installs where depmod is unnecessary) +cat > $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/depmod << 'DEPMOD_EOF' +#!/bin/bash +# depmod wrapper for sysroot installs +# Only active for rootfs/initramfs (AVOCADO_SYSROOT_SCRIPTS=1), noop for extensions +if [ "$AVOCADO_SYSROOT_SCRIPTS" = "1" ] && [ -n "$AVOCADO_EXT_INSTALLROOT" ]; then + # Extract kernel version from arguments (last non-flag argument) + KVER="" + ARGS=() + for arg in "$@"; do + case "$arg" in + -*) ARGS+=("$arg") ;; + *) KVER="$arg" ;; + esac + done + if [ -z "$KVER" ]; then + KVER=$(ls "$AVOCADO_EXT_INSTALLROOT/usr/lib/modules/" 2>/dev/null | head -n 1) + fi + if [ -n "$KVER" ] && [ -d "$AVOCADO_EXT_INSTALLROOT/usr/lib/modules/$KVER" ]; then + exec /sbin/depmod "${ARGS[@]}" -b "$AVOCADO_EXT_INSTALLROOT" "$KVER" + fi +fi +exit 0 +DEPMOD_EOF +chmod +x $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/depmod + +# ldconfig is noop'd during install — ldconfig -r requires chroot which doesn't +# work cross-arch. Instead, ldconfig is run during the rootfs/initramfs build step +# against the work copy so ld.so.cache is baked into the final image. +ln -sf noop-command $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/ldconfig + +# Create systemctl wrapper that handles enable/disable/preset for sysroot installs. +# Only active for rootfs/initramfs (AVOCADO_SYSROOT_SCRIPTS=1), noop for extensions. +# This is a minimal offline implementation (like Yocto's systemd-systemctl-native) +# because the SDK's systemctl has hardcoded sysconfdir paths that break --root. +cat > $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/systemctl << 'SYSTEMCTL_EOF' +#!/bin/bash +# Minimal offline systemctl for sysroot enable/disable/preset operations. +# Parses [Install] sections and creates symlinks directly — no running systemd needed. + +ROOT="" +ACTION="" +UNITS=() +PRESET_MODE="" + +# Parse arguments +while [ $# -gt 0 ]; do + case "$1" in + --root=*) ROOT="${1#--root=}" ;; + --root) shift; ROOT="$1" ;; + --preset-mode=*) PRESET_MODE="${1#--preset-mode=}" ;; + --no-block|--no-reload|--force|-f|--now) ;; # ignore + enable|disable|preset|preset-all|mask|unmask|daemon-reload|restart|start|stop|reload|is-enabled) + ACTION="$1" ;; + -*) ;; # ignore unknown flags + *) UNITS+=("$1") ;; + esac + shift +done + +# For scriptlet calls, use AVOCADO_EXT_INSTALLROOT as root if --root not given +if [ "$AVOCADO_SYSROOT_SCRIPTS" = "1" ] && [ -z "$ROOT" ] && [ -n "$AVOCADO_EXT_INSTALLROOT" ]; then + ROOT="$AVOCADO_EXT_INSTALLROOT" +fi + +# Noop for actions we don't handle offline, or if no root is set +case "$ACTION" in + enable|disable|preset|preset-all|mask|unmask) ;; + *) exit 0 ;; +esac + +[ -z "$ROOT" ] && exit 0 + +UNIT_DIR="$ROOT/usr/lib/systemd/system" +ETC_DIR="$ROOT/etc/systemd/system" + +# Parse WantedBy/RequiredBy/Alias from a unit file's [Install] section +parse_install_section() { + local unit_file="$1" + local key="$2" + [ -f "$unit_file" ] || return + local in_install=false + while IFS= read -r line; do + line="${line%%#*}" # strip comments + line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace + [ -z "$line" ] && continue + case "$line" in + \[Install\]*) in_install=true; continue ;; + \[*) in_install=false; continue ;; + esac + if $in_install; then + case "$line" in + ${key}=*) + local val="${line#*=}" + val="${val#"${val%%[![:space:]]*}"}" + echo "$val" + ;; + esac + fi + done < "$unit_file" +} + +do_enable() { + local unit="$1" + local unit_file="$UNIT_DIR/$unit" + [ -f "$unit_file" ] || return 0 + + # Process WantedBy + for target in $(parse_install_section "$unit_file" "WantedBy"); do + local wants_dir="$ETC_DIR/${target}.wants" + mkdir -p "$wants_dir" + ln -sf "/usr/lib/systemd/system/$unit" "$wants_dir/$unit" + done + + # Process RequiredBy + for target in $(parse_install_section "$unit_file" "RequiredBy"); do + local requires_dir="$ETC_DIR/${target}.requires" + mkdir -p "$requires_dir" + ln -sf "/usr/lib/systemd/system/$unit" "$requires_dir/$unit" + done + + # Process Alias + for alias in $(parse_install_section "$unit_file" "Alias"); do + ln -sf "/usr/lib/systemd/system/$unit" "$ETC_DIR/$alias" + done + + # Process Also (recursive enable) + for also in $(parse_install_section "$unit_file" "Also"); do + do_enable "$also" + done +} + +do_disable() { + local unit="$1" + local unit_file="$UNIT_DIR/$unit" + [ -f "$unit_file" ] || return 0 + + for target in $(parse_install_section "$unit_file" "WantedBy"); do + rm -f "$ETC_DIR/${target}.wants/$unit" + done + for target in $(parse_install_section "$unit_file" "RequiredBy"); do + rm -f "$ETC_DIR/${target}.requires/$unit" + done + for alias in $(parse_install_section "$unit_file" "Alias"); do + rm -f "$ETC_DIR/$alias" + done +} + +do_mask() { + local unit="$1" + ln -sf /dev/null "$ETC_DIR/$unit" +} + +do_unmask() { + local unit="$1" + local link="$ETC_DIR/$unit" + [ -L "$link" ] && [ "$(readlink "$link")" = "/dev/null" ] && rm -f "$link" +} + +# Load preset rules: returns lines like "enable " or "disable " +load_presets() { + local preset_dirs=("$ROOT/etc/systemd/system-preset" "$ROOT/usr/lib/systemd/system-preset") + # In initrd mode, also check initrd-preset + if [ -f "$ROOT/etc/initrd-release" ]; then + preset_dirs=("$ROOT/usr/lib/systemd/initrd-preset" "${preset_dirs[@]}") + fi + for dir in "${preset_dirs[@]}"; do + [ -d "$dir" ] || continue + for f in $(ls "$dir"/*.preset 2>/dev/null | sort); do + while IFS= read -r line; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + [ -z "$line" ] && continue + echo "$line" + done < "$f" + done + done +} + +check_preset() { + local unit="$1" + local rules + rules=$(load_presets) + while IFS= read -r rule; do + local action pattern + action="${rule%% *}" + pattern="${rule#* }" + pattern="${pattern#"${pattern%%[![:space:]]*}"}" + [ -z "$pattern" ] && continue + # fnmatch-style: check if unit matches pattern + case "$unit" in + $pattern) echo "$action"; return ;; + esac + done <<< "$rules" + # Default: enable + echo "enable" +} + +case "$ACTION" in + enable) + for unit in "${UNITS[@]}"; do do_enable "$unit"; done ;; + disable) + for unit in "${UNITS[@]}"; do do_disable "$unit"; done ;; + mask) + for unit in "${UNITS[@]}"; do do_mask "$unit"; done ;; + unmask) + for unit in "${UNITS[@]}"; do do_unmask "$unit"; done ;; + preset) + for unit in "${UNITS[@]}"; do + result=$(check_preset "$unit") + if [ "$result" = "enable" ] && [ "$PRESET_MODE" != "disable-only" ]; then + do_enable "$unit" + elif [ "$result" = "disable" ] && [ "$PRESET_MODE" != "enable-only" ]; then + do_disable "$unit" + fi + done ;; + preset-all) + if [ -d "$UNIT_DIR" ]; then + for unit_file in "$UNIT_DIR"/*.service "$UNIT_DIR"/*.socket "$UNIT_DIR"/*.timer "$UNIT_DIR"/*.path "$UNIT_DIR"/*.target; do + [ -f "$unit_file" ] || continue + unit="$(basename "$unit_file")" + result=$(check_preset "$unit") + if [ "$result" = "enable" ] && [ "$PRESET_MODE" != "disable-only" ]; then + do_enable "$unit" + elif [ "$result" = "disable" ] && [ "$PRESET_MODE" != "enable-only" ]; then + do_disable "$unit" + fi + done + fi ;; +esac +exit 0 +SYSTEMCTL_EOF +chmod +x $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/systemctl + # Create shell wrapper for scriptlet interpreter cat > $AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/scriptlet-shell.sh << 'SHELL_EOF' #!/bin/bash @@ -904,10 +1140,20 @@ $DNF_SDK_HOST \ } // Install rootfs sysroot — repo scoping via --releasever, lock file pins exact version + // Read rootfs packages from top-level config (defaults to avocado-pkg-rootfs if absent) print_info("Installing rootfs sysroot.", OutputLevel::Normal); - let rootfs_base_pkg = "avocado-pkg-rootfs"; - let rootfs_config_version = "*"; + let rootfs_packages = config.get_rootfs_packages(); + let rootfs_base_pkg = rootfs_packages + .keys() + .next() + .map(|s| s.as_str()) + .unwrap_or("avocado-pkg-rootfs"); + let rootfs_config_version = rootfs_packages + .values() + .next() + .and_then(|v| v.as_str()) + .unwrap_or("*"); let rootfs_pkg = build_package_spec_with_lock( &lock_file, target, @@ -925,8 +1171,20 @@ $DNF_SDK_HOST \ let rootfs_command = format!( r#" +# Create usrmerge symlinks before install so scriptlets (depmod, ldconfig) can +# resolve /lib/modules, /sbin, /bin paths within the sysroot +mkdir -p $AVOCADO_PREFIX/rootfs/usr/bin $AVOCADO_PREFIX/rootfs/usr/sbin $AVOCADO_PREFIX/rootfs/usr/lib +ln -sfn usr/bin $AVOCADO_PREFIX/rootfs/bin +ln -sfn usr/sbin $AVOCADO_PREFIX/rootfs/sbin +ln -sfn usr/lib $AVOCADO_PREFIX/rootfs/lib + +RPM_NO_CHROOT_FOR_SCRIPTS=1 \ +AVOCADO_EXT_INSTALLROOT=$AVOCADO_PREFIX/rootfs \ +AVOCADO_SYSROOT_SCRIPTS=1 \ +PATH=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin:$PATH \ +RPM_CONFIGDIR=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts \ RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ -$DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ +$DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ {dnf_args_str} {yes} --installroot $AVOCADO_PREFIX/rootfs install {rootfs_pkg} "# ); @@ -988,6 +1246,106 @@ $DNF_SDK_HOST $DNF_NO_SCRIPTS $DNF_SDK_TARGET_REPO_CONF \ return Err(anyhow::anyhow!("Failed to install rootfs sysroot.")); } + // Install initramfs sysroot — parallel to rootfs, defaults to avocado-pkg-initramfs + print_info("Installing initramfs sysroot.", OutputLevel::Normal); + + let initramfs_packages = config.get_initramfs_packages(); + let initramfs_base_pkg = initramfs_packages + .keys() + .next() + .map(|s| s.as_str()) + .unwrap_or("avocado-pkg-initramfs"); + let initramfs_config_version = initramfs_packages + .values() + .next() + .and_then(|v| v.as_str()) + .unwrap_or("*"); + let initramfs_pkg = build_package_spec_with_lock( + &lock_file, + target, + &SysrootType::Initramfs, + initramfs_base_pkg, + initramfs_config_version, + ); + + let initramfs_command = format!( + r#" +# Create usrmerge symlinks before install so scriptlets (depmod, ldconfig) can +# resolve /lib/modules, /sbin, /bin paths within the sysroot +mkdir -p $AVOCADO_PREFIX/initramfs/usr/bin $AVOCADO_PREFIX/initramfs/usr/sbin $AVOCADO_PREFIX/initramfs/usr/lib +ln -sfn usr/bin $AVOCADO_PREFIX/initramfs/bin +ln -sfn usr/sbin $AVOCADO_PREFIX/initramfs/sbin +ln -sfn usr/lib $AVOCADO_PREFIX/initramfs/lib + +RPM_NO_CHROOT_FOR_SCRIPTS=1 \ +AVOCADO_EXT_INSTALLROOT=$AVOCADO_PREFIX/initramfs \ +AVOCADO_SYSROOT_SCRIPTS=1 \ +PATH=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin:$PATH \ +RPM_CONFIGDIR=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts \ +RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ +$DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ + {dnf_args_str} {yes} --installroot $AVOCADO_PREFIX/initramfs install {initramfs_pkg} +"# + ); + + let run_config = RunConfig { + container_image: container_image.to_string(), + target: target.to_string(), + command: initramfs_command, + verbose: self.verbose, + source_environment: false, + interactive: !self.force, + repo_url: repo_url.map(|s| s.to_string()), + repo_release: repo_release.map(|s| s.to_string()), + container_args: merged_container_args.cloned(), + dnf_args: self.dnf_args.clone(), + disable_weak_dependencies: config.get_sdk_disable_weak_dependencies(), + ..Default::default() + }; + + let initramfs_success = run_container_command( + container_helper, + run_config, + runs_on_context, + self.sdk_arch.as_ref(), + ) + .await?; + + if initramfs_success { + print_success("Installed initramfs sysroot.", OutputLevel::Normal); + + let installed_versions = container_helper + .query_installed_packages( + &SysrootType::Initramfs, + &[initramfs_base_pkg.to_string()], + container_image, + target, + repo_url.map(|s| s.to_string()), + repo_release.map(|s| s.to_string()), + merged_container_args.cloned(), + runs_on_context, + self.sdk_arch.as_ref(), + ) + .await?; + + if !installed_versions.is_empty() { + lock_file.update_sysroot_versions( + target, + &SysrootType::Initramfs, + installed_versions, + ); + if self.verbose { + print_info( + "Updated lock file with initramfs package version.", + OutputLevel::Normal, + ); + } + lock_file.save(&src_dir)?; + } + } else { + return Err(anyhow::anyhow!("Failed to install initramfs sysroot.")); + } + // Install target-sysroot if there are compile sections referenced by active extensions. // Only install compile deps for extensions that are dependencies of active runtimes. let active_compile_sections = diff --git a/src/utils/config.rs b/src/utils/config.rs index 46f318d..fc36532 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -474,6 +474,13 @@ pub struct SplitPackageConfig { pub files: Vec, } +/// Rootfs or initramfs image configuration (top-level) +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct ImageConfig { + #[serde(alias = "dependencies")] + pub packages: Option>, +} + /// Provision profile configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProvisionProfileConfig { @@ -672,6 +679,12 @@ pub struct Config { #[serde(alias = "runtime")] pub runtimes: Option>, pub sdk: Option, + /// Top-level rootfs image configuration. When absent, defaults to + /// `{ packages: { "avocado-pkg-rootfs": "*" } }`. + pub rootfs: Option, + /// Top-level initramfs image configuration. When absent, defaults to + /// `{ packages: { "avocado-pkg-initramfs": "*" } }`. + pub initramfs: Option, #[serde(alias = "provision")] pub provision_profiles: Option>, /// Signing keys mapping friendly names to key IDs @@ -925,6 +938,8 @@ impl Config { distro: None, runtimes: None, sdk: None, + rootfs: None, + initramfs: None, provision_profiles: None, signing_keys: None, connect: None, @@ -2342,6 +2357,38 @@ impl Config { .unwrap_or(false) // Default to false (enable weak dependencies) } + /// Get rootfs packages from top-level config. + /// Defaults to `{ "avocado-pkg-rootfs": "*" }` when the section is absent. + pub fn get_rootfs_packages(&self) -> HashMap { + if let Some(ref rootfs) = self.rootfs { + if let Some(ref packages) = rootfs.packages { + return packages.clone(); + } + } + let mut default = HashMap::new(); + default.insert( + "avocado-pkg-rootfs".to_string(), + serde_yaml::Value::String("*".to_string()), + ); + default + } + + /// Get initramfs packages from top-level config. + /// Defaults to `{ "avocado-pkg-initramfs": "*" }` when the section is absent. + pub fn get_initramfs_packages(&self) -> HashMap { + if let Some(ref initramfs) = self.initramfs { + if let Some(ref packages) = initramfs.packages { + return packages.clone(); + } + } + let mut default = HashMap::new(); + default.insert( + "avocado-pkg-initramfs".to_string(), + serde_yaml::Value::String("*".to_string()), + ); + default + } + /// Get signing keys mapping (name -> keyid or global name) #[allow(dead_code)] // Public API for future use pub fn get_signing_keys(&self) -> Option<&HashMap> { diff --git a/src/utils/lockfile.rs b/src/utils/lockfile.rs index b03dd60..9154154 100644 --- a/src/utils/lockfile.rs +++ b/src/utils/lockfile.rs @@ -29,6 +29,8 @@ pub enum SysrootType { Sdk(String), /// Rootfs sysroot ($AVOCADO_PREFIX/rootfs) Rootfs, + /// Initramfs sysroot ($AVOCADO_PREFIX/initramfs) + Initramfs, /// Target sysroot ($AVOCADO_PREFIX/sdk/target-sysroot) TargetSysroot, /// Extension sysroot ($AVOCADO_EXT_SYSROOTS/{name}) @@ -62,6 +64,11 @@ impl SysrootType { rpm_configdir: None, root_path: Some("$AVOCADO_PREFIX/rootfs".to_string()), }, + SysrootType::Initramfs => RpmQueryConfig { + rpm_etcconfigdir: None, + rpm_configdir: None, + root_path: Some("$AVOCADO_PREFIX/initramfs".to_string()), + }, SysrootType::TargetSysroot => RpmQueryConfig { // Target-sysroot: same approach as rootfs - unset config and use --root rpm_etcconfigdir: None, @@ -203,6 +210,10 @@ pub struct TargetLocks { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub rootfs: PackageVersions, + /// Initramfs packages (shared across all host architectures) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub initramfs: PackageVersions, + /// Target-sysroot packages (shared across all host architectures) #[serde( default, @@ -528,6 +539,7 @@ impl LockFile { .get(arch) .and_then(|pkgs| pkgs.get(package)), SysrootType::Rootfs => target_locks.rootfs.get(package), + SysrootType::Initramfs => target_locks.initramfs.get(package), SysrootType::TargetSysroot => target_locks.target_sysroot.get(package), SysrootType::Extension(name) => target_locks .extensions @@ -554,6 +566,7 @@ impl LockFile { let packages = match sysroot { SysrootType::Sdk(arch) => target_locks.sdk.entry(arch.clone()).or_default(), SysrootType::Rootfs => &mut target_locks.rootfs, + SysrootType::Initramfs => &mut target_locks.initramfs, SysrootType::TargetSysroot => &mut target_locks.target_sysroot, SysrootType::Extension(name) => { &mut target_locks @@ -580,6 +593,7 @@ impl LockFile { let packages = match sysroot { SysrootType::Sdk(arch) => target_locks.sdk.entry(arch.clone()).or_default(), SysrootType::Rootfs => &mut target_locks.rootfs, + SysrootType::Initramfs => &mut target_locks.initramfs, SysrootType::TargetSysroot => &mut target_locks.target_sysroot, SysrootType::Extension(name) => { &mut target_locks @@ -609,6 +623,7 @@ impl LockFile { let result = match sysroot { SysrootType::Sdk(arch) => target_locks.sdk.get(arch), SysrootType::Rootfs => Some(&target_locks.rootfs), + SysrootType::Initramfs => Some(&target_locks.initramfs), SysrootType::TargetSysroot => Some(&target_locks.target_sysroot), SysrootType::Extension(name) => { target_locks.extensions.get(name).map(|ext| &ext.packages) @@ -626,6 +641,7 @@ impl LockFile { || self.targets.values().all(|target_locks| { target_locks.sdk.is_empty() && target_locks.rootfs.is_empty() + && target_locks.initramfs.is_empty() && target_locks.target_sysroot.is_empty() && extensions_are_empty(&target_locks.extensions) && target_locks.runtimes.is_empty() @@ -711,6 +727,7 @@ impl LockFile { let pkg_map = match sysroot { SysrootType::Sdk(arch) => target_locks.sdk.get_mut(arch), SysrootType::Rootfs => Some(&mut target_locks.rootfs), + SysrootType::Initramfs => Some(&mut target_locks.initramfs), SysrootType::TargetSysroot => Some(&mut target_locks.target_sysroot), SysrootType::Extension(name) => target_locks .extensions diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index 0115471..2354846 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -784,6 +784,26 @@ pub fn compute_sdk_input_hash(config: &serde_yaml::Value) -> Result } } + // Include rootfs.packages (affects rootfs sysroot installed during sdk install) + if let Some(rootfs) = config.get("rootfs") { + if let Some(packages) = rootfs.get("packages") { + hash_data.insert( + serde_yaml::Value::String("rootfs.packages".to_string()), + packages.clone(), + ); + } + } + + // Include initramfs.packages (affects initramfs sysroot installed during sdk install) + if let Some(initramfs) = config.get("initramfs") { + if let Some(packages) = initramfs.get("packages") { + hash_data.insert( + serde_yaml::Value::String("initramfs.packages".to_string()), + packages.clone(), + ); + } + } + let config_hash = compute_config_hash(&serde_yaml::Value::Mapping(hash_data))?; Ok(StampInputs::new(config_hash)) } diff --git a/src/utils/target.rs b/src/utils/target.rs index e50d4eb..f9f8b93 100644 --- a/src/utils/target.rs +++ b/src/utils/target.rs @@ -242,6 +242,8 @@ mod tests { provision_profiles: None, signing_keys: None, connect: None, + rootfs: None, + initramfs: None, } } @@ -259,6 +261,8 @@ mod tests { provision_profiles: None, signing_keys: None, connect: None, + rootfs: None, + initramfs: None, } } @@ -276,6 +280,8 @@ mod tests { provision_profiles: None, signing_keys: None, connect: None, + rootfs: None, + initramfs: None, } } From 172a0a49dde8c510e2d1dcae790a9b2e8f3a5c0e Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Mon, 9 Mar 2026 17:23:41 -0400 Subject: [PATCH 02/10] feat: add top-level rootfs and initramfs CLI commands with shared code Add `avocado rootfs {install,image,clean}` and `avocado initramfs {install,image,clean}` as top-level commands. The image subcommands support `--out` for specifying the output directory. Refactor sdk/install.rs and runtime/build.rs to call into the new shared modules instead of duplicating the sysroot install and image build logic inline. This removes ~320 lines of duplicated code. --- src/commands/initramfs/clean.rs | 90 +++++++++ src/commands/initramfs/image.rs | 248 +++++++++++++++++++++++++ src/commands/initramfs/install.rs | 146 +++++++++++++++ src/commands/initramfs/mod.rs | 7 + src/commands/mod.rs | 2 + src/commands/rootfs/clean.rs | 93 ++++++++++ src/commands/rootfs/image.rs | 270 +++++++++++++++++++++++++++ src/commands/rootfs/install.rs | 299 ++++++++++++++++++++++++++++++ src/commands/rootfs/mod.rs | 7 + src/commands/runtime/build.rs | 133 +------------ src/commands/sdk/install.rs | 228 ++++------------------- src/main.rs | 266 ++++++++++++++++++++++++++ src/utils/config.rs | 67 +++++++ src/utils/stamps.rs | 18 ++ 14 files changed, 1550 insertions(+), 324 deletions(-) create mode 100644 src/commands/initramfs/clean.rs create mode 100644 src/commands/initramfs/image.rs create mode 100644 src/commands/initramfs/install.rs create mode 100644 src/commands/initramfs/mod.rs create mode 100644 src/commands/rootfs/clean.rs create mode 100644 src/commands/rootfs/image.rs create mode 100644 src/commands/rootfs/install.rs create mode 100644 src/commands/rootfs/mod.rs diff --git a/src/commands/initramfs/clean.rs b/src/commands/initramfs/clean.rs new file mode 100644 index 0000000..c68ca22 --- /dev/null +++ b/src/commands/initramfs/clean.rs @@ -0,0 +1,90 @@ +//! Initramfs clean command (delegates to shared clean logic). + +use anyhow::{Context, Result}; +use std::sync::Arc; + +use crate::utils::{ + config::Config, + container::{RunConfig, SdkContainer}, + output::{print_error, print_info, print_success, OutputLevel}, + target::resolve_target_required, +}; + +use crate::commands::rootfs::clean::clean_sysroot_command; + +/// Implementation of the 'initramfs clean' command. +pub struct InitramfsCleanCommand { + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + sdk_arch: Option, +} + +impl InitramfsCleanCommand { + pub fn new( + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + target, + container_args, + dnf_args, + sdk_arch: None, + } + } + + pub fn with_sdk_arch(mut self, sdk_arch: Option) -> Self { + self.sdk_arch = sdk_arch; + self + } + + pub async fn execute(&self) -> Result<()> { + let composed = Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()).with_context( + || format!("Failed to load composed config from {}", self.config_path), + )?, + ); + let config = &composed.config; + let target = resolve_target_required(self.target.as_deref(), config)?; + let container_image = config + .get_sdk_image() + .context("No SDK container image specified in configuration")?; + + let container_helper = + SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); + + print_info("Cleaning initramfs sysroot.", OutputLevel::Normal); + + let run_config = RunConfig { + container_image: container_image.to_string(), + target: target.to_string(), + command: clean_sysroot_command("initramfs"), + verbose: self.verbose, + source_environment: false, + interactive: false, + repo_url: config.get_sdk_repo_url(), + repo_release: config.get_sdk_repo_release(), + container_args: config.merge_sdk_container_args(self.container_args.as_ref()), + dnf_args: self.dnf_args.clone(), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let success = container_helper.run_in_container(run_config).await?; + if success { + print_success("Cleaned initramfs sysroot.", OutputLevel::Normal); + } else { + print_error("Failed to clean initramfs sysroot.", OutputLevel::Normal); + return Err(anyhow::anyhow!("Failed to clean initramfs sysroot")); + } + + Ok(()) + } +} diff --git a/src/commands/initramfs/image.rs b/src/commands/initramfs/image.rs new file mode 100644 index 0000000..9ba75cc --- /dev/null +++ b/src/commands/initramfs/image.rs @@ -0,0 +1,248 @@ +//! Initramfs image build command and shared build script generation. + +use anyhow::{Context, Result}; +use std::sync::Arc; + +use crate::utils::{ + config::Config, + container::{RunConfig, SdkContainer}, + output::{print_error, print_info, print_success, OutputLevel}, + runs_on::RunsOnContext, + target::resolve_target_required, +}; + +use crate::commands::rootfs::image::NAMESPACE_UUID; + +/// Generate the shell script fragment that builds an initramfs image from the shared sysroot. +/// +/// The generated script expects these shell variables to be set: +/// - `$AVOCADO_PREFIX` — SDK prefix (container volume) +/// - `$OUTPUT_DIR` — directory for output image +/// - `$TARGET_ARCH` — target architecture string +/// - `$RUNTIME_NAME` — runtime name (for work dir path) +/// +/// Exports on success: +/// - `$AVOCADO_INITRAMFS_IMAGE` — path to built image +/// - `$AVOCADO_INITRAMFS_FILESYSTEM` — filesystem format used +/// - `$AVOCADO_INITRAMFS_BUILD_ID` — deterministic build ID +pub fn generate_initramfs_build_script(namespace_uuid: &str, initramfs_filesystem: &str) -> String { + format!( + r#" +# Build initramfs image from shared sysroot +INITRAMFS_SYSROOT="$AVOCADO_PREFIX/initramfs" +if [ -d "$INITRAMFS_SYSROOT/usr" ]; then + echo "Building initramfs image from packages..." + + INITRAMFS_WORK="${{INITRAMFS_WORK_DIR:-$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/initramfs-work}}" + rm -rf "$INITRAMFS_WORK" + cp -a "$INITRAMFS_SYSROOT" "$INITRAMFS_WORK" + + # Create usrmerge symlinks (Yocto image class does this, not any RPM package) + ln -sfn usr/bin "$INITRAMFS_WORK/bin" + ln -sfn usr/sbin "$INITRAMFS_WORK/sbin" + ln -sfn usr/lib "$INITRAMFS_WORK/lib" + + # Post-processing (matches Yocto avocado-image-initramfs.bb) + rm -rf "$INITRAMFS_WORK/media" "$INITRAMFS_WORK/mnt" "$INITRAMFS_WORK/srv" + rm -rf "$INITRAMFS_WORK/boot/"* + mkdir -p "$INITRAMFS_WORK/sysroot" + mkdir -p "$INITRAMFS_WORK/opt" + + # Compute deterministic build ID for initramfs + INITRAMFS_PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$INITRAMFS_SYSROOT" | sort) + INITRAMFS_PKG_HASH=$(echo "$INITRAMFS_PKG_NEVRA" | sha256sum | awk '{{print $1}}') + INITRAMFS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$INITRAMFS_PKG_HASH'))") + + # Inject identity into os-release-initrd (if it exists in the sysroot) + if [ -f "$INITRAMFS_WORK/usr/lib/os-release-initrd" ]; then + echo "AVOCADO_OS_BUILD_ID=$INITRAMFS_BUILD_ID" >> "$INITRAMFS_WORK/usr/lib/os-release-initrd" + fi + + # Create /init symlink so the kernel can find the init process in the initramfs. + # (matches OE IMAGE_CMD:cpio in image_types.bbclass — creates /init -> /sbin/init) + if [ ! -L "$INITRAMFS_WORK/init" ] && [ ! -e "$INITRAMFS_WORK/init" ]; then + if [ -L "$INITRAMFS_WORK/sbin/init" ] || [ -e "$INITRAMFS_WORK/sbin/init" ]; then + ln -sf /sbin/init "$INITRAMFS_WORK/init" + echo "Created /init -> /sbin/init symlink" + else + echo "WARNING: /sbin/init not found in initramfs — kernel may not find init" + fi + fi + + # Build initramfs image using configured filesystem format + INITRAMFS_FS="{initramfs_filesystem}" + INITRAMFS_OUTPUT="$OUTPUT_DIR/avocado-image-initramfs-$TARGET_ARCH.$INITRAMFS_FS" + echo "Building initramfs image: $INITRAMFS_FS" + case "$INITRAMFS_FS" in + cpio) + (cd "$INITRAMFS_WORK" && find . | sort | cpio --reproducible -o -H newc --quiet > "$INITRAMFS_OUTPUT") + ;; + cpio.zst) + (cd "$INITRAMFS_WORK" && find . | sort | cpio --reproducible -o -H newc --quiet | zstd -3 -f -o "$INITRAMFS_OUTPUT") + ;; + cpio.lz4) + (cd "$INITRAMFS_WORK" && find . | sort | cpio --reproducible -o -H newc --quiet | lz4 -l -f - "$INITRAMFS_OUTPUT") + ;; + cpio.gz) + (cd "$INITRAMFS_WORK" && find . | sort | cpio --reproducible -o -H newc --quiet | gzip -9 > "$INITRAMFS_OUTPUT") + ;; + *) + echo "ERROR: unsupported initramfs filesystem format: $INITRAMFS_FS" + exit 1 + ;; + esac + + rm -rf "$INITRAMFS_WORK" + export AVOCADO_INITRAMFS_IMAGE="$INITRAMFS_OUTPUT" + export AVOCADO_INITRAMFS_FILESYSTEM="$INITRAMFS_FS" + export AVOCADO_INITRAMFS_BUILD_ID="$INITRAMFS_BUILD_ID" + echo "Built initramfs: $INITRAMFS_OUTPUT" +else + echo "No initramfs sysroot found — skipping initramfs image build." +fi"#, + namespace_uuid = namespace_uuid, + initramfs_filesystem = initramfs_filesystem, + ) +} + +/// Implementation of the 'initramfs image' command. +pub struct InitramfsImageCommand { + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + sdk_arch: Option, + runs_on: Option, + nfs_port: Option, + out_dir: Option, +} + +impl InitramfsImageCommand { + pub fn new( + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + target, + container_args, + dnf_args, + sdk_arch: None, + runs_on: None, + nfs_port: None, + out_dir: None, + } + } + + pub fn with_sdk_arch(mut self, sdk_arch: Option) -> Self { + self.sdk_arch = sdk_arch; + self + } + + pub fn with_runs_on(mut self, runs_on: Option, nfs_port: Option) -> Self { + self.runs_on = runs_on; + self.nfs_port = nfs_port; + self + } + + pub fn with_output_dir(mut self, out_dir: Option) -> Self { + self.out_dir = out_dir; + self + } + + pub async fn execute(&self) -> Result<()> { + let composed = Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()).with_context( + || format!("Failed to load composed config from {}", self.config_path), + )?, + ); + let config = &composed.config; + let target_arch = resolve_target_required(self.target.as_deref(), config)?; + let merged_container_args = config.merge_sdk_container_args(self.container_args.as_ref()); + let container_image = config + .get_sdk_image() + .context("No SDK container image specified in configuration")?; + let repo_url = config.get_sdk_repo_url(); + let repo_release = config.get_sdk_repo_release(); + + let container_helper = + SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); + + let mut runs_on_context: Option = if let Some(ref runs_on) = self.runs_on { + Some( + container_helper + .create_runs_on_context(runs_on, self.nfs_port, container_image, self.verbose) + .await?, + ) + } else { + None + }; + + print_info("Building initramfs image.", OutputLevel::Normal); + + let initramfs_filesystem = config.get_initramfs_filesystem(); + let build_section = generate_initramfs_build_script(NAMESPACE_UUID, &initramfs_filesystem); + + let out_dir_setup = if let Some(ref out) = self.out_dir { + format!(r#"OUTPUT_DIR="{out}""#) + } else { + r#"OUTPUT_DIR="$AVOCADO_PREFIX/output/images""#.to_string() + }; + + let script = format!( + r#"set -euo pipefail +TARGET_ARCH="{target_arch}" +RUNTIME_NAME="${{AVOCADO_RUNTIME_NAME:-standalone}}" +{out_dir_setup} +mkdir -p "$OUTPUT_DIR" +{build_section} +"# + ); + + let run_config = RunConfig { + container_image: container_image.to_string(), + target: target_arch.to_string(), + command: script, + verbose: self.verbose, + source_environment: true, + interactive: false, + repo_url: repo_url.clone(), + repo_release: repo_release.clone(), + container_args: merged_container_args.clone(), + dnf_args: self.dnf_args.clone(), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let result = if let Some(ref context) = runs_on_context { + container_helper + .run_in_container_with_context(&run_config, context) + .await + } else { + container_helper.run_in_container(run_config).await + }; + + if let Some(ref mut context) = runs_on_context { + if let Err(e) = context.teardown().await { + print_error( + &format!("Warning: Failed to cleanup remote resources: {e}"), + OutputLevel::Normal, + ); + } + } + + let success = result?; + if success { + print_success("Built initramfs image.", OutputLevel::Normal); + } else { + return Err(anyhow::anyhow!("Failed to build initramfs image.")); + } + + Ok(()) + } +} diff --git a/src/commands/initramfs/install.rs b/src/commands/initramfs/install.rs new file mode 100644 index 0000000..1ea4948 --- /dev/null +++ b/src/commands/initramfs/install.rs @@ -0,0 +1,146 @@ +//! Initramfs sysroot install command (delegates to shared sysroot install). + +use anyhow::{Context, Result}; +use std::sync::Arc; + +use crate::utils::{ + config::{ComposedConfig, Config}, + container::SdkContainer, + lockfile::{LockFile, SysrootType}, + output::{print_error, OutputLevel}, + runs_on::RunsOnContext, + target::validate_and_log_target, +}; + +use crate::commands::rootfs::install::{install_sysroot, SysrootInstallParams}; + +/// Implementation of the 'initramfs install' command. +pub struct InitramfsInstallCommand { + config_path: String, + verbose: bool, + force: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + no_stamps: bool, + runs_on: Option, + nfs_port: Option, + sdk_arch: Option, + composed_config: Option>, +} + +impl InitramfsInstallCommand { + pub fn new( + config_path: String, + verbose: bool, + force: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + force, + target, + container_args, + dnf_args, + no_stamps: false, + runs_on: None, + nfs_port: None, + sdk_arch: None, + composed_config: None, + } + } + + pub fn with_no_stamps(mut self, no_stamps: bool) -> Self { + self.no_stamps = no_stamps; + self + } + + pub fn with_runs_on(mut self, runs_on: Option, nfs_port: Option) -> Self { + self.runs_on = runs_on; + self.nfs_port = nfs_port; + self + } + + pub fn with_sdk_arch(mut self, sdk_arch: Option) -> Self { + self.sdk_arch = sdk_arch; + self + } + + #[allow(dead_code)] + pub fn with_composed_config(mut self, config: Arc) -> Self { + self.composed_config = Some(config); + self + } + + pub async fn execute(&self) -> Result<()> { + let composed = match &self.composed_config { + Some(cc) => Arc::clone(cc), + None => Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()).with_context( + || format!("Failed to load composed config from {}", self.config_path), + )?, + ), + }; + + let config = &composed.config; + let target = validate_and_log_target(self.target.as_deref(), config)?; + let merged_container_args = config.merge_sdk_container_args(self.container_args.as_ref()); + let container_image = config.get_sdk_image().ok_or_else(|| { + anyhow::anyhow!("No container image specified in config under 'sdk.image'") + })?; + + let repo_url = config.get_sdk_repo_url(); + let repo_release = config.get_sdk_repo_release(); + + let container_helper = + SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); + + let mut runs_on_context: Option = if let Some(ref runs_on) = self.runs_on { + Some( + container_helper + .create_runs_on_context(runs_on, self.nfs_port, container_image, self.verbose) + .await?, + ) + } else { + None + }; + + let src_dir = std::path::Path::new(&self.config_path) + .parent() + .unwrap_or(std::path::Path::new(".")); + let mut lock_file = LockFile::load(src_dir)?; + + let result = install_sysroot(&mut SysrootInstallParams { + sysroot_type: SysrootType::Initramfs, + config, + lock_file: &mut lock_file, + src_dir, + container_helper: &container_helper, + container_image, + target: &target, + repo_url: repo_url.as_deref(), + repo_release: repo_release.as_deref(), + merged_container_args: merged_container_args.clone(), + dnf_args: self.dnf_args.clone(), + verbose: self.verbose, + force: self.force, + runs_on_context: runs_on_context.as_ref(), + sdk_arch: self.sdk_arch.as_ref(), + }) + .await; + + if let Some(ref mut context) = runs_on_context { + if let Err(e) = context.teardown().await { + print_error( + &format!("Warning: Failed to cleanup remote resources: {e}"), + OutputLevel::Normal, + ); + } + } + + result + } +} diff --git a/src/commands/initramfs/mod.rs b/src/commands/initramfs/mod.rs new file mode 100644 index 0000000..3918fee --- /dev/null +++ b/src/commands/initramfs/mod.rs @@ -0,0 +1,7 @@ +pub mod clean; +pub mod image; +pub mod install; + +pub use clean::InitramfsCleanCommand; +pub use image::InitramfsImageCommand; +pub use install::InitramfsInstallCommand; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4f365ab..e051235 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,9 +5,11 @@ pub mod ext; pub mod fetch; pub mod hitl; pub mod init; +pub mod initramfs; pub mod install; pub mod provision; pub mod prune; +pub mod rootfs; pub mod runtime; pub mod sdk; pub mod sign; diff --git a/src/commands/rootfs/clean.rs b/src/commands/rootfs/clean.rs new file mode 100644 index 0000000..4501591 --- /dev/null +++ b/src/commands/rootfs/clean.rs @@ -0,0 +1,93 @@ +//! Rootfs clean command and shared clean logic. + +use anyhow::{Context, Result}; +use std::sync::Arc; + +use crate::utils::{ + config::Config, + container::{RunConfig, SdkContainer}, + output::{print_error, print_info, print_success, OutputLevel}, + target::resolve_target_required, +}; + +/// Generate the shell command to clean a sysroot (rootfs or initramfs). +pub fn clean_sysroot_command(sysroot_dir: &str) -> String { + format!(r#"rm -rf "$AVOCADO_PREFIX/{sysroot_dir}""#) +} + +/// Implementation of the 'rootfs clean' command. +pub struct RootfsCleanCommand { + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + sdk_arch: Option, +} + +impl RootfsCleanCommand { + pub fn new( + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + target, + container_args, + dnf_args, + sdk_arch: None, + } + } + + pub fn with_sdk_arch(mut self, sdk_arch: Option) -> Self { + self.sdk_arch = sdk_arch; + self + } + + pub async fn execute(&self) -> Result<()> { + let composed = Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()).with_context( + || format!("Failed to load composed config from {}", self.config_path), + )?, + ); + let config = &composed.config; + let target = resolve_target_required(self.target.as_deref(), config)?; + let container_image = config + .get_sdk_image() + .context("No SDK container image specified in configuration")?; + + let container_helper = + SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); + + print_info("Cleaning rootfs sysroot.", OutputLevel::Normal); + + let run_config = RunConfig { + container_image: container_image.to_string(), + target: target.to_string(), + command: clean_sysroot_command("rootfs"), + verbose: self.verbose, + source_environment: false, + interactive: false, + repo_url: config.get_sdk_repo_url(), + repo_release: config.get_sdk_repo_release(), + container_args: config.merge_sdk_container_args(self.container_args.as_ref()), + dnf_args: self.dnf_args.clone(), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let success = container_helper.run_in_container(run_config).await?; + if success { + print_success("Cleaned rootfs sysroot.", OutputLevel::Normal); + } else { + print_error("Failed to clean rootfs sysroot.", OutputLevel::Normal); + return Err(anyhow::anyhow!("Failed to clean rootfs sysroot")); + } + + Ok(()) + } +} diff --git a/src/commands/rootfs/image.rs b/src/commands/rootfs/image.rs new file mode 100644 index 0000000..878c2ea --- /dev/null +++ b/src/commands/rootfs/image.rs @@ -0,0 +1,270 @@ +//! Rootfs image build command and shared build script generation. + +use anyhow::{Context, Result}; +use std::sync::Arc; + +use crate::utils::{ + config::Config, + container::{RunConfig, SdkContainer}, + output::{print_error, print_info, print_success, OutputLevel}, + runs_on::RunsOnContext, + target::resolve_target_required, +}; + +/// Namespace UUID for deterministic OS build ID generation (shared with runtime build). +pub const NAMESPACE_UUID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + +/// Generate the shell script fragment that builds a rootfs image from the shared sysroot. +/// +/// The generated script expects these shell variables to be set: +/// - `$AVOCADO_PREFIX` — SDK prefix (container volume) +/// - `$AVOCADO_SDK_PREFIX` — SDK tools prefix +/// - `$OUTPUT_DIR` — directory for output image +/// - `$TARGET_ARCH` — target architecture string +/// - `$RUNTIME_NAME` — runtime name (for os-release injection) +/// - `$RUNTIME_VERSION` — runtime version (for os-release injection) +/// +/// Exports on success: +/// - `$AVOCADO_ROOTFS_IMAGE` — path to built image +/// - `$AVOCADO_ROOTFS_FILESYSTEM` — filesystem format used +/// - `$AVOCADO_OS_BUILD_ID` — deterministic build ID +pub fn generate_rootfs_build_script(namespace_uuid: &str, rootfs_filesystem: &str) -> String { + format!( + r#" +# Build rootfs image from shared sysroot +ROOTFS_SYSROOT="$AVOCADO_PREFIX/rootfs" +if [ -d "$ROOTFS_SYSROOT/usr" ]; then + echo "Building rootfs image from packages..." + + # Work on a copy so we don't mutate the shared sysroot used for extension priming + ROOTFS_WORK="${{ROOTFS_WORK_DIR:-$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/rootfs-work}}" + rm -rf "$ROOTFS_WORK" + cp -a "$ROOTFS_SYSROOT" "$ROOTFS_WORK" + + # Create usrmerge symlinks (Yocto image class does this, not any RPM package) + ln -sfn usr/bin "$ROOTFS_WORK/bin" + ln -sfn usr/sbin "$ROOTFS_WORK/sbin" + ln -sfn usr/lib "$ROOTFS_WORK/lib" + + # Post-processing (matches Yocto avocado-image-rootfs.bb) + rm -rf "$ROOTFS_WORK/media" "$ROOTFS_WORK/mnt" "$ROOTFS_WORK/srv" + rm -rf "$ROOTFS_WORK/boot/"* + mkdir -p "$ROOTFS_WORK/opt" + + # Create empty /etc/machine-id for stateless systemd on read-only rootfs. + # systemd will bind-mount a transient machine-id at boot. + # (matches OE read_only_rootfs_hook in rootfs-postcommands.bbclass) + touch "$ROOTFS_WORK/etc/machine-id" + + # Enable systemd service units via preset files. + # (matches OE systemd_preset_all in image.bbclass) + if [ -e "$ROOTFS_WORK/usr/lib/systemd/systemd" ]; then + "$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/systemctl" --root="$ROOTFS_WORK" --preset-mode=enable-only preset-all 2>/dev/null || true + echo "Applied systemd presets" + fi + + # Generate ld.so.cache so the read-only rootfs has a working linker cache. + # Uses the container's host-native ldconfig with -r (chroot flag). + # ldconfig -r does chroot() but continues as the host binary — no binfmt needed. + # This matches Yocto's ldconfig-native approach. + /usr/sbin/ldconfig -r "$ROOTFS_WORK" -c new -X 2>/dev/null || true + echo "Generated ld.so.cache" + + # Compute deterministic AVOCADO_OS_BUILD_ID from installed packages + PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$ROOTFS_SYSROOT" | sort) + PKG_HASH=$(echo "$PKG_NEVRA" | sha256sum | awk '{{print $1}}') + OS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$PKG_HASH'))") + + # Inject identity into os-release (in the work copy only) + echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_WORK/usr/lib/os-release" + echo "AVOCADO_RUNTIME_NAME=$RUNTIME_NAME" >> "$ROOTFS_WORK/usr/lib/os-release" + echo "AVOCADO_RUNTIME_VERSION=$RUNTIME_VERSION" >> "$ROOTFS_WORK/usr/lib/os-release" + + # Build rootfs image using configured filesystem format + ROOTFS_FS="{rootfs_filesystem}" + ROOTFS_OUTPUT="$OUTPUT_DIR/avocado-image-rootfs-$TARGET_ARCH.$ROOTFS_FS" + echo "Building rootfs image: $ROOTFS_FS" + case "$ROOTFS_FS" in + erofs.zst) + mkfs.erofs \ + -T "${{SOURCE_DATE_EPOCH:-0}}" \ + -U 00000000-0000-0000-0000-000000000000 \ + -x -1 \ + --all-root \ + -z zstd \ + "$ROOTFS_OUTPUT" \ + "$ROOTFS_WORK" + ;; + erofs.lz4) + mkfs.erofs \ + -T "${{SOURCE_DATE_EPOCH:-0}}" \ + -U 00000000-0000-0000-0000-000000000000 \ + -x -1 \ + --all-root \ + -z lz4hc \ + "$ROOTFS_OUTPUT" \ + "$ROOTFS_WORK" + ;; + *) + echo "ERROR: unsupported rootfs filesystem format: $ROOTFS_FS" + exit 1 + ;; + esac + + rm -rf "$ROOTFS_WORK" + export AVOCADO_ROOTFS_IMAGE="$ROOTFS_OUTPUT" + export AVOCADO_ROOTFS_FILESYSTEM="$ROOTFS_FS" + export AVOCADO_OS_BUILD_ID="$OS_BUILD_ID" + echo "Built rootfs: $ROOTFS_OUTPUT (AVOCADO_OS_BUILD_ID=$OS_BUILD_ID)" +else + echo "No rootfs sysroot found — skipping rootfs image build." +fi"#, + namespace_uuid = namespace_uuid, + rootfs_filesystem = rootfs_filesystem, + ) +} + +/// Implementation of the 'rootfs image' command. +pub struct RootfsImageCommand { + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + sdk_arch: Option, + runs_on: Option, + nfs_port: Option, + out_dir: Option, +} + +impl RootfsImageCommand { + pub fn new( + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + target, + container_args, + dnf_args, + sdk_arch: None, + runs_on: None, + nfs_port: None, + out_dir: None, + } + } + + pub fn with_sdk_arch(mut self, sdk_arch: Option) -> Self { + self.sdk_arch = sdk_arch; + self + } + + pub fn with_runs_on(mut self, runs_on: Option, nfs_port: Option) -> Self { + self.runs_on = runs_on; + self.nfs_port = nfs_port; + self + } + + pub fn with_output_dir(mut self, out_dir: Option) -> Self { + self.out_dir = out_dir; + self + } + + pub async fn execute(&self) -> Result<()> { + let composed = Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()).with_context( + || format!("Failed to load composed config from {}", self.config_path), + )?, + ); + let config = &composed.config; + let target_arch = resolve_target_required(self.target.as_deref(), config)?; + let merged_container_args = config.merge_sdk_container_args(self.container_args.as_ref()); + let container_image = config + .get_sdk_image() + .context("No SDK container image specified in configuration")?; + let repo_url = config.get_sdk_repo_url(); + let repo_release = config.get_sdk_repo_release(); + + let container_helper = + SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); + + let mut runs_on_context: Option = if let Some(ref runs_on) = self.runs_on { + Some( + container_helper + .create_runs_on_context(runs_on, self.nfs_port, container_image, self.verbose) + .await?, + ) + } else { + None + }; + + print_info("Building rootfs image.", OutputLevel::Normal); + + let rootfs_filesystem = config.get_rootfs_filesystem(); + let build_section = generate_rootfs_build_script(NAMESPACE_UUID, &rootfs_filesystem); + + // Wrap the build script with variable setup for standalone execution + let out_dir_setup = if let Some(ref out) = self.out_dir { + format!(r#"OUTPUT_DIR="{out}""#) + } else { + r#"OUTPUT_DIR="$AVOCADO_PREFIX/output/images""#.to_string() + }; + + let script = format!( + r#"set -euo pipefail +TARGET_ARCH="{target_arch}" +RUNTIME_NAME="${{AVOCADO_RUNTIME_NAME:-standalone}}" +RUNTIME_VERSION="${{AVOCADO_RUNTIME_VERSION:-0.0.0}}" +{out_dir_setup} +mkdir -p "$OUTPUT_DIR" +{build_section} +"# + ); + + let run_config = RunConfig { + container_image: container_image.to_string(), + target: target_arch.to_string(), + command: script, + verbose: self.verbose, + source_environment: true, + interactive: false, + repo_url: repo_url.clone(), + repo_release: repo_release.clone(), + container_args: merged_container_args.clone(), + dnf_args: self.dnf_args.clone(), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let result = if let Some(ref context) = runs_on_context { + container_helper + .run_in_container_with_context(&run_config, context) + .await + } else { + container_helper.run_in_container(run_config).await + }; + + // Teardown runs_on context + if let Some(ref mut context) = runs_on_context { + if let Err(e) = context.teardown().await { + print_error( + &format!("Warning: Failed to cleanup remote resources: {e}"), + OutputLevel::Normal, + ); + } + } + + let success = result?; + if success { + print_success("Built rootfs image.", OutputLevel::Normal); + } else { + return Err(anyhow::anyhow!("Failed to build rootfs image.")); + } + + Ok(()) + } +} diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs new file mode 100644 index 0000000..c768af2 --- /dev/null +++ b/src/commands/rootfs/install.rs @@ -0,0 +1,299 @@ +//! Rootfs sysroot install command and shared install logic for rootfs/initramfs. + +use anyhow::{Context, Result}; +use std::path::Path; +use std::sync::Arc; + +use crate::utils::{ + config::{ComposedConfig, Config}, + container::{RunConfig, SdkContainer}, + lockfile::{build_package_spec_with_lock, LockFile, SysrootType}, + output::{print_error, print_info, print_success, OutputLevel}, + runs_on::RunsOnContext, + target::validate_and_log_target, +}; + +/// Parameters for the shared sysroot install function. +pub struct SysrootInstallParams<'a> { + pub sysroot_type: SysrootType, + pub config: &'a Config, + pub lock_file: &'a mut LockFile, + pub src_dir: &'a Path, + pub container_helper: &'a SdkContainer, + pub container_image: &'a str, + pub target: &'a str, + pub repo_url: Option<&'a str>, + pub repo_release: Option<&'a str>, + pub merged_container_args: Option>, + pub dnf_args: Option>, + pub verbose: bool, + pub force: bool, + pub runs_on_context: Option<&'a RunsOnContext>, + pub sdk_arch: Option<&'a String>, +} + +/// Install a sysroot (rootfs or initramfs) via DNF into the SDK container volume. +/// +/// This is the shared implementation used by both `avocado rootfs install`, +/// `avocado initramfs install`, and `avocado sdk install`. +pub async fn install_sysroot(params: &mut SysrootInstallParams<'_>) -> Result<()> { + let (label, sysroot_dir, default_pkg) = match params.sysroot_type { + SysrootType::Rootfs => ("rootfs", "rootfs", "avocado-pkg-rootfs"), + SysrootType::Initramfs => ("initramfs", "initramfs", "avocado-pkg-initramfs"), + _ => return Err(anyhow::anyhow!("Unsupported sysroot type for install")), + }; + + print_info(&format!("Installing {label} sysroot."), OutputLevel::Normal); + + // Get packages from config + let packages = match params.sysroot_type { + SysrootType::Rootfs => params.config.get_rootfs_packages(), + SysrootType::Initramfs => params.config.get_initramfs_packages(), + _ => unreachable!(), + }; + + let base_pkg = packages + .keys() + .next() + .map(|s| s.as_str()) + .unwrap_or(default_pkg); + let config_version = packages + .values() + .next() + .and_then(|v| v.as_str()) + .unwrap_or("*"); + let pkg = build_package_spec_with_lock( + params.lock_file, + params.target, + ¶ms.sysroot_type, + base_pkg, + config_version, + ); + + let yes = if params.force { "-y" } else { "" }; + let dnf_args_str = if let Some(args) = ¶ms.dnf_args { + format!(" {} ", args.join(" ")) + } else { + String::new() + }; + + let command = format!( + r#" +# Create usrmerge symlinks before install so scriptlets (depmod, ldconfig) can +# resolve /lib/modules, /sbin, /bin paths within the sysroot +mkdir -p $AVOCADO_PREFIX/{sysroot_dir}/usr/bin $AVOCADO_PREFIX/{sysroot_dir}/usr/sbin $AVOCADO_PREFIX/{sysroot_dir}/usr/lib +ln -sfn usr/bin $AVOCADO_PREFIX/{sysroot_dir}/bin +ln -sfn usr/sbin $AVOCADO_PREFIX/{sysroot_dir}/sbin +ln -sfn usr/lib $AVOCADO_PREFIX/{sysroot_dir}/lib + +RPM_NO_CHROOT_FOR_SCRIPTS=1 \ +AVOCADO_EXT_INSTALLROOT=$AVOCADO_PREFIX/{sysroot_dir} \ +AVOCADO_SYSROOT_SCRIPTS=1 \ +PATH=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin:$PATH \ +RPM_CONFIGDIR=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts \ +RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ +$DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ + {dnf_args_str} {yes} --installroot $AVOCADO_PREFIX/{sysroot_dir} install {pkg} +"# + ); + + let mut run_config = RunConfig { + container_image: params.container_image.to_string(), + target: params.target.to_string(), + command, + verbose: params.verbose, + source_environment: false, + interactive: !params.force, + repo_url: params.repo_url.map(|s| s.to_string()), + repo_release: params.repo_release.map(|s| s.to_string()), + container_args: params.merged_container_args.clone(), + dnf_args: params.dnf_args.clone(), + disable_weak_dependencies: params.config.get_sdk_disable_weak_dependencies(), + ..Default::default() + }; + + // Inject sdk_arch if provided + if let Some(arch) = params.sdk_arch { + run_config.sdk_arch = Some(arch.clone()); + } + + let success = if let Some(context) = params.runs_on_context { + params + .container_helper + .run_in_container_with_context(&run_config, context) + .await? + } else { + params.container_helper.run_in_container(run_config).await? + }; + + if success { + print_success(&format!("Installed {label} sysroot."), OutputLevel::Normal); + + // Query installed version and update lock file + let installed_versions = params + .container_helper + .query_installed_packages( + ¶ms.sysroot_type, + &[base_pkg.to_string()], + params.container_image, + params.target, + params.repo_url.map(|s| s.to_string()), + params.repo_release.map(|s| s.to_string()), + params.merged_container_args.clone(), + params.runs_on_context, + params.sdk_arch, + ) + .await?; + + if !installed_versions.is_empty() { + params.lock_file.update_sysroot_versions( + params.target, + ¶ms.sysroot_type, + installed_versions, + ); + if params.verbose { + print_info( + &format!("Updated lock file with {label} package version."), + OutputLevel::Normal, + ); + } + params.lock_file.save(params.src_dir)?; + } + } else { + return Err(anyhow::anyhow!("Failed to install {label} sysroot.")); + } + + Ok(()) +} + +/// Implementation of the 'rootfs install' command. +pub struct RootfsInstallCommand { + config_path: String, + verbose: bool, + force: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + no_stamps: bool, + runs_on: Option, + nfs_port: Option, + sdk_arch: Option, + composed_config: Option>, +} + +impl RootfsInstallCommand { + pub fn new( + config_path: String, + verbose: bool, + force: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + force, + target, + container_args, + dnf_args, + no_stamps: false, + runs_on: None, + nfs_port: None, + sdk_arch: None, + composed_config: None, + } + } + + pub fn with_no_stamps(mut self, no_stamps: bool) -> Self { + self.no_stamps = no_stamps; + self + } + + pub fn with_runs_on(mut self, runs_on: Option, nfs_port: Option) -> Self { + self.runs_on = runs_on; + self.nfs_port = nfs_port; + self + } + + pub fn with_sdk_arch(mut self, sdk_arch: Option) -> Self { + self.sdk_arch = sdk_arch; + self + } + + #[allow(dead_code)] + pub fn with_composed_config(mut self, config: Arc) -> Self { + self.composed_config = Some(config); + self + } + + pub async fn execute(&self) -> Result<()> { + let composed = match &self.composed_config { + Some(cc) => Arc::clone(cc), + None => Arc::new( + Config::load_composed(&self.config_path, self.target.as_deref()).with_context( + || format!("Failed to load composed config from {}", self.config_path), + )?, + ), + }; + + let config = &composed.config; + let target = validate_and_log_target(self.target.as_deref(), config)?; + let merged_container_args = config.merge_sdk_container_args(self.container_args.as_ref()); + let container_image = config.get_sdk_image().ok_or_else(|| { + anyhow::anyhow!("No container image specified in config under 'sdk.image'") + })?; + + let repo_url = config.get_sdk_repo_url(); + let repo_release = config.get_sdk_repo_release(); + + let container_helper = + SdkContainer::from_config(&self.config_path, config)?.verbose(self.verbose); + + let mut runs_on_context: Option = if let Some(ref runs_on) = self.runs_on { + Some( + container_helper + .create_runs_on_context(runs_on, self.nfs_port, container_image, self.verbose) + .await?, + ) + } else { + None + }; + + let src_dir = std::path::Path::new(&self.config_path) + .parent() + .unwrap_or(std::path::Path::new(".")); + let mut lock_file = LockFile::load(src_dir)?; + + let result = install_sysroot(&mut SysrootInstallParams { + sysroot_type: SysrootType::Rootfs, + config, + lock_file: &mut lock_file, + src_dir, + container_helper: &container_helper, + container_image, + target: &target, + repo_url: repo_url.as_deref(), + repo_release: repo_release.as_deref(), + merged_container_args: merged_container_args.clone(), + dnf_args: self.dnf_args.clone(), + verbose: self.verbose, + force: self.force, + runs_on_context: runs_on_context.as_ref(), + sdk_arch: self.sdk_arch.as_ref(), + }) + .await; + + // Always teardown runs_on context + if let Some(ref mut context) = runs_on_context { + if let Err(e) = context.teardown().await { + print_error( + &format!("Warning: Failed to cleanup remote resources: {e}"), + OutputLevel::Normal, + ); + } + } + + result + } +} diff --git a/src/commands/rootfs/mod.rs b/src/commands/rootfs/mod.rs new file mode 100644 index 0000000..5e96ac8 --- /dev/null +++ b/src/commands/rootfs/mod.rs @@ -0,0 +1,7 @@ +pub mod clean; +pub mod image; +pub mod install; + +pub use clean::RootfsCleanCommand; +pub use image::RootfsImageCommand; +pub use install::RootfsInstallCommand; diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index bf2d08d..175cc0f 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1,3 +1,5 @@ +use crate::commands::initramfs::image::generate_initramfs_build_script; +use crate::commands::rootfs::image::{generate_rootfs_build_script, NAMESPACE_UUID}; use crate::commands::sdk::SdkCompileCommand; use crate::utils::{ config::{ComposedConfig, Config}, @@ -1467,134 +1469,11 @@ echo "Docker image priming complete.""#, } }; - let rootfs_build_section = format!( - r#" -# Build rootfs image from shared sysroot -ROOTFS_SYSROOT="$AVOCADO_PREFIX/rootfs" -if [ -d "$ROOTFS_SYSROOT/usr" ]; then - echo "Building rootfs image from packages..." - - # Work on a copy so we don't mutate the shared sysroot used for extension priming - ROOTFS_WORK="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/rootfs-work" - rm -rf "$ROOTFS_WORK" - cp -a "$ROOTFS_SYSROOT" "$ROOTFS_WORK" - - # Create usrmerge symlinks (Yocto image class does this, not any RPM package) - ln -sfn usr/bin "$ROOTFS_WORK/bin" - ln -sfn usr/sbin "$ROOTFS_WORK/sbin" - ln -sfn usr/lib "$ROOTFS_WORK/lib" - - # Post-processing (matches Yocto avocado-image-rootfs.bb) - rm -rf "$ROOTFS_WORK/media" "$ROOTFS_WORK/mnt" "$ROOTFS_WORK/srv" - rm -rf "$ROOTFS_WORK/boot/"* - mkdir -p "$ROOTFS_WORK/opt" - - # Create empty /etc/machine-id for stateless systemd on read-only rootfs. - # systemd will bind-mount a transient machine-id at boot. - # (matches OE read_only_rootfs_hook in rootfs-postcommands.bbclass) - touch "$ROOTFS_WORK/etc/machine-id" - - # Enable systemd service units via preset files. - # (matches OE systemd_preset_all in image.bbclass) - if [ -e "$ROOTFS_WORK/usr/lib/systemd/systemd" ]; then - "$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin/systemctl" --root="$ROOTFS_WORK" --preset-mode=enable-only preset-all 2>/dev/null || true - echo "Applied systemd presets" - fi - - # Generate ld.so.cache so the read-only rootfs has a working linker cache. - # Uses the container's host-native ldconfig with -r (chroot flag). - # ldconfig -r does chroot() but continues as the host binary — no binfmt needed. - # This matches Yocto's ldconfig-native approach. - /usr/sbin/ldconfig -r "$ROOTFS_WORK" -c new -X 2>/dev/null || true - echo "Generated ld.so.cache" - - # Compute deterministic AVOCADO_OS_BUILD_ID from installed packages - PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$ROOTFS_SYSROOT" | sort) - PKG_HASH=$(echo "$PKG_NEVRA" | sha256sum | awk '{{print $1}}') - OS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$PKG_HASH'))") - - # Inject identity into os-release (in the work copy only) - echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_WORK/usr/lib/os-release" - echo "AVOCADO_RUNTIME_NAME=$RUNTIME_NAME" >> "$ROOTFS_WORK/usr/lib/os-release" - echo "AVOCADO_RUNTIME_VERSION=$RUNTIME_VERSION" >> "$ROOTFS_WORK/usr/lib/os-release" - - # Build erofs image (reproducible flags matching ext/image.rs) - ROOTFS_OUTPUT="$OUTPUT_DIR/avocado-image-rootfs-$TARGET_ARCH.erofs-lz4" - mkfs.erofs \ - -T "${{SOURCE_DATE_EPOCH:-0}}" \ - -U 00000000-0000-0000-0000-000000000000 \ - -x -1 \ - --all-root \ - -z lz4hc \ - "$ROOTFS_OUTPUT" \ - "$ROOTFS_WORK" - - rm -rf "$ROOTFS_WORK" - export AVOCADO_ROOTFS_IMAGE="$ROOTFS_OUTPUT" - export AVOCADO_OS_BUILD_ID="$OS_BUILD_ID" - echo "Built rootfs: $ROOTFS_OUTPUT (AVOCADO_OS_BUILD_ID=$OS_BUILD_ID)" -else - echo "No rootfs sysroot found — skipping rootfs image build." -fi"#, - namespace_uuid = namespace_uuid, - ); - - let initramfs_build_section = format!( - r#" -# Build initramfs image from shared sysroot -INITRAMFS_SYSROOT="$AVOCADO_PREFIX/initramfs" -if [ -d "$INITRAMFS_SYSROOT/usr" ]; then - echo "Building initramfs image from packages..." - - INITRAMFS_WORK="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/initramfs-work" - rm -rf "$INITRAMFS_WORK" - cp -a "$INITRAMFS_SYSROOT" "$INITRAMFS_WORK" - - # Create usrmerge symlinks (Yocto image class does this, not any RPM package) - ln -sfn usr/bin "$INITRAMFS_WORK/bin" - ln -sfn usr/sbin "$INITRAMFS_WORK/sbin" - ln -sfn usr/lib "$INITRAMFS_WORK/lib" - - # Post-processing (matches Yocto avocado-image-initramfs.bb) - rm -rf "$INITRAMFS_WORK/media" "$INITRAMFS_WORK/mnt" "$INITRAMFS_WORK/srv" - rm -rf "$INITRAMFS_WORK/boot/"* - mkdir -p "$INITRAMFS_WORK/sysroot" - mkdir -p "$INITRAMFS_WORK/opt" - - # Compute deterministic build ID for initramfs - INITRAMFS_PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$INITRAMFS_SYSROOT" | sort) - INITRAMFS_PKG_HASH=$(echo "$INITRAMFS_PKG_NEVRA" | sha256sum | awk '{{print $1}}') - INITRAMFS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$INITRAMFS_PKG_HASH'))") - - # Inject identity into os-release-initrd (if it exists in the sysroot) - if [ -f "$INITRAMFS_WORK/usr/lib/os-release-initrd" ]; then - echo "AVOCADO_OS_BUILD_ID=$INITRAMFS_BUILD_ID" >> "$INITRAMFS_WORK/usr/lib/os-release-initrd" - fi - - # Create /init symlink so the kernel can find the init process in the initramfs. - # (matches OE IMAGE_CMD:cpio in image_types.bbclass — creates /init -> /sbin/init) - if [ ! -L "$INITRAMFS_WORK/init" ] && [ ! -e "$INITRAMFS_WORK/init" ]; then - if [ -L "$INITRAMFS_WORK/sbin/init" ] || [ -e "$INITRAMFS_WORK/sbin/init" ]; then - ln -sf /sbin/init "$INITRAMFS_WORK/init" - echo "Created /init -> /sbin/init symlink" - else - echo "WARNING: /sbin/init not found in initramfs — kernel may not find init" - fi - fi + let rootfs_build_section = + generate_rootfs_build_script(NAMESPACE_UUID, &config.get_rootfs_filesystem()); - # Build cpio archive (zstd compressed) - INITRAMFS_OUTPUT="$OUTPUT_DIR/avocado-image-initramfs-$TARGET_ARCH.cpio.zst" - (cd "$INITRAMFS_WORK" && find . | sort | cpio --reproducible -o -H newc --quiet | zstd -3 -f -o "$INITRAMFS_OUTPUT") - - rm -rf "$INITRAMFS_WORK" - export AVOCADO_INITRAMFS_IMAGE="$INITRAMFS_OUTPUT" - export AVOCADO_INITRAMFS_BUILD_ID="$INITRAMFS_BUILD_ID" - echo "Built initramfs: $INITRAMFS_OUTPUT" -else - echo "No initramfs sysroot found — skipping initramfs image build." -fi"#, - namespace_uuid = namespace_uuid, - ); + let initramfs_build_section = + generate_initramfs_build_script(NAMESPACE_UUID, &config.get_initramfs_filesystem()); let script = format!( r#" diff --git a/src/commands/sdk/install.rs b/src/commands/sdk/install.rs index f859b79..578105e 100644 --- a/src/commands/sdk/install.rs +++ b/src/commands/sdk/install.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use crate::commands::rootfs::install::{install_sysroot, SysrootInstallParams}; use crate::utils::{ config::{find_active_compile_sections, find_active_extensions, ComposedConfig, Config}, container::{normalize_sdk_arch, RunConfig, SdkContainer}, @@ -1139,213 +1140,46 @@ $DNF_SDK_HOST \ } } - // Install rootfs sysroot — repo scoping via --releasever, lock file pins exact version - // Read rootfs packages from top-level config (defaults to avocado-pkg-rootfs if absent) - print_info("Installing rootfs sysroot.", OutputLevel::Normal); - - let rootfs_packages = config.get_rootfs_packages(); - let rootfs_base_pkg = rootfs_packages - .keys() - .next() - .map(|s| s.as_str()) - .unwrap_or("avocado-pkg-rootfs"); - let rootfs_config_version = rootfs_packages - .values() - .next() - .and_then(|v| v.as_str()) - .unwrap_or("*"); - let rootfs_pkg = build_package_spec_with_lock( - &lock_file, + // Install rootfs sysroot via shared install logic + install_sysroot(&mut SysrootInstallParams { + sysroot_type: SysrootType::Rootfs, + config, + lock_file: &mut lock_file, + src_dir: &src_dir, + container_helper, + container_image, target, - &SysrootType::Rootfs, - rootfs_base_pkg, - rootfs_config_version, - ); - - let yes = if self.force { "-y" } else { "" }; - let dnf_args_str = if let Some(args) = &self.dnf_args { - format!(" {} ", args.join(" ")) - } else { - String::new() - }; - - let rootfs_command = format!( - r#" -# Create usrmerge symlinks before install so scriptlets (depmod, ldconfig) can -# resolve /lib/modules, /sbin, /bin paths within the sysroot -mkdir -p $AVOCADO_PREFIX/rootfs/usr/bin $AVOCADO_PREFIX/rootfs/usr/sbin $AVOCADO_PREFIX/rootfs/usr/lib -ln -sfn usr/bin $AVOCADO_PREFIX/rootfs/bin -ln -sfn usr/sbin $AVOCADO_PREFIX/rootfs/sbin -ln -sfn usr/lib $AVOCADO_PREFIX/rootfs/lib - -RPM_NO_CHROOT_FOR_SCRIPTS=1 \ -AVOCADO_EXT_INSTALLROOT=$AVOCADO_PREFIX/rootfs \ -AVOCADO_SYSROOT_SCRIPTS=1 \ -PATH=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin:$PATH \ -RPM_CONFIGDIR=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts \ -RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ -$DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ - {dnf_args_str} {yes} --installroot $AVOCADO_PREFIX/rootfs install {rootfs_pkg} -"# - ); - - let run_config = RunConfig { - container_image: container_image.to_string(), - target: target.to_string(), - command: rootfs_command, - verbose: self.verbose, - source_environment: false, - interactive: !self.force, - repo_url: repo_url.map(|s| s.to_string()), - repo_release: repo_release.map(|s| s.to_string()), - container_args: merged_container_args.cloned(), + repo_url, + repo_release, + merged_container_args: merged_container_args.cloned(), dnf_args: self.dnf_args.clone(), - disable_weak_dependencies: config.get_sdk_disable_weak_dependencies(), - // runs_on handled by shared context - ..Default::default() - }; - - let rootfs_success = run_container_command( - container_helper, - run_config, + verbose: self.verbose, + force: self.force, runs_on_context, - self.sdk_arch.as_ref(), - ) + sdk_arch: self.sdk_arch.as_ref(), + }) .await?; - if rootfs_success { - print_success("Installed rootfs sysroot.", OutputLevel::Normal); - - // Query installed version and update lock file - let installed_versions = container_helper - .query_installed_packages( - &SysrootType::Rootfs, - &[rootfs_base_pkg.to_string()], - container_image, - target, - repo_url.map(|s| s.to_string()), - repo_release.map(|s| s.to_string()), - merged_container_args.cloned(), - runs_on_context, - self.sdk_arch.as_ref(), - ) - .await?; - - if !installed_versions.is_empty() { - lock_file.update_sysroot_versions(target, &SysrootType::Rootfs, installed_versions); - if self.verbose { - print_info( - "Updated lock file with rootfs package version.", - OutputLevel::Normal, - ); - } - // Save lock file immediately after rootfs install - lock_file.save(&src_dir)?; - } - } else { - return Err(anyhow::anyhow!("Failed to install rootfs sysroot.")); - } - - // Install initramfs sysroot — parallel to rootfs, defaults to avocado-pkg-initramfs - print_info("Installing initramfs sysroot.", OutputLevel::Normal); - - let initramfs_packages = config.get_initramfs_packages(); - let initramfs_base_pkg = initramfs_packages - .keys() - .next() - .map(|s| s.as_str()) - .unwrap_or("avocado-pkg-initramfs"); - let initramfs_config_version = initramfs_packages - .values() - .next() - .and_then(|v| v.as_str()) - .unwrap_or("*"); - let initramfs_pkg = build_package_spec_with_lock( - &lock_file, + // Install initramfs sysroot via shared install logic + install_sysroot(&mut SysrootInstallParams { + sysroot_type: SysrootType::Initramfs, + config, + lock_file: &mut lock_file, + src_dir: &src_dir, + container_helper, + container_image, target, - &SysrootType::Initramfs, - initramfs_base_pkg, - initramfs_config_version, - ); - - let initramfs_command = format!( - r#" -# Create usrmerge symlinks before install so scriptlets (depmod, ldconfig) can -# resolve /lib/modules, /sbin, /bin paths within the sysroot -mkdir -p $AVOCADO_PREFIX/initramfs/usr/bin $AVOCADO_PREFIX/initramfs/usr/sbin $AVOCADO_PREFIX/initramfs/usr/lib -ln -sfn usr/bin $AVOCADO_PREFIX/initramfs/bin -ln -sfn usr/sbin $AVOCADO_PREFIX/initramfs/sbin -ln -sfn usr/lib $AVOCADO_PREFIX/initramfs/lib - -RPM_NO_CHROOT_FOR_SCRIPTS=1 \ -AVOCADO_EXT_INSTALLROOT=$AVOCADO_PREFIX/initramfs \ -AVOCADO_SYSROOT_SCRIPTS=1 \ -PATH=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts/bin:$PATH \ -RPM_CONFIGDIR=$AVOCADO_SDK_PREFIX/ext-rpm-config-scripts \ -RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ -$DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ - {dnf_args_str} {yes} --installroot $AVOCADO_PREFIX/initramfs install {initramfs_pkg} -"# - ); - - let run_config = RunConfig { - container_image: container_image.to_string(), - target: target.to_string(), - command: initramfs_command, - verbose: self.verbose, - source_environment: false, - interactive: !self.force, - repo_url: repo_url.map(|s| s.to_string()), - repo_release: repo_release.map(|s| s.to_string()), - container_args: merged_container_args.cloned(), + repo_url, + repo_release, + merged_container_args: merged_container_args.cloned(), dnf_args: self.dnf_args.clone(), - disable_weak_dependencies: config.get_sdk_disable_weak_dependencies(), - ..Default::default() - }; - - let initramfs_success = run_container_command( - container_helper, - run_config, + verbose: self.verbose, + force: self.force, runs_on_context, - self.sdk_arch.as_ref(), - ) + sdk_arch: self.sdk_arch.as_ref(), + }) .await?; - if initramfs_success { - print_success("Installed initramfs sysroot.", OutputLevel::Normal); - - let installed_versions = container_helper - .query_installed_packages( - &SysrootType::Initramfs, - &[initramfs_base_pkg.to_string()], - container_image, - target, - repo_url.map(|s| s.to_string()), - repo_release.map(|s| s.to_string()), - merged_container_args.cloned(), - runs_on_context, - self.sdk_arch.as_ref(), - ) - .await?; - - if !installed_versions.is_empty() { - lock_file.update_sysroot_versions( - target, - &SysrootType::Initramfs, - installed_versions, - ); - if self.verbose { - print_info( - "Updated lock file with initramfs package version.", - OutputLevel::Normal, - ); - } - lock_file.save(&src_dir)?; - } - } else { - return Err(anyhow::anyhow!("Failed to install initramfs sysroot.")); - } - // Install target-sysroot if there are compile sections referenced by active extensions. // Only install compile deps for extensions that are dependencies of active runtimes. let active_compile_sections = diff --git a/src/main.rs b/src/main.rs index 1563cdb..748c12f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,9 +42,11 @@ use commands::ext::{ use commands::fetch::FetchCommand; use commands::hitl::HitlServerCommand; use commands::init::InitCommand; +use commands::initramfs::{InitramfsCleanCommand, InitramfsImageCommand, InitramfsInstallCommand}; use commands::install::InstallCommand; use commands::provision::ProvisionCommand; use commands::prune::PruneCommand; +use commands::rootfs::{RootfsCleanCommand, RootfsImageCommand, RootfsInstallCommand}; use commands::runtime::{ RuntimeBuildCommand, RuntimeCleanCommand, RuntimeDeployCommand, RuntimeDepsCommand, RuntimeDnfCommand, RuntimeInstallCommand, RuntimeListCommand, RuntimeProvisionCommand, @@ -103,6 +105,16 @@ enum Commands { #[command(subcommand)] command: ExtCommands, }, + /// Rootfs sysroot and image commands + Rootfs { + #[command(subcommand)] + command: RootfsCommands, + }, + /// Initramfs sysroot and image commands + Initramfs { + #[command(subcommand)] + command: InitramfsCommands, + }, /// Initialize a new avocado project Init { /// Directory to initialize (defaults to current directory) @@ -2192,6 +2204,132 @@ async fn main() -> Result<()> { Ok(()) } }, + Commands::Rootfs { command } => match command { + RootfsCommands::Install { + config, + verbose, + force, + target, + container_args, + dnf_args, + } => { + let install_cmd = RootfsInstallCommand::new( + config, + verbose, + force, + target.or(cli.target.clone()), + container_args, + dnf_args, + ) + .with_no_stamps(cli.no_stamps) + .with_runs_on(cli.runs_on.clone(), cli.nfs_port) + .with_sdk_arch(cli.sdk_arch.clone()); + install_cmd.execute().await?; + Ok(()) + } + RootfsCommands::Image { + config, + verbose, + target, + out_dir, + container_args, + dnf_args, + } => { + let image_cmd = RootfsImageCommand::new( + config, + verbose, + target.or(cli.target.clone()), + container_args, + dnf_args, + ) + .with_runs_on(cli.runs_on.clone(), cli.nfs_port) + .with_sdk_arch(cli.sdk_arch.clone()) + .with_output_dir(out_dir); + image_cmd.execute().await?; + Ok(()) + } + RootfsCommands::Clean { + config, + verbose, + target, + container_args, + dnf_args, + } => { + let clean_cmd = RootfsCleanCommand::new( + config, + verbose, + target.or(cli.target.clone()), + container_args, + dnf_args, + ) + .with_sdk_arch(cli.sdk_arch.clone()); + clean_cmd.execute().await?; + Ok(()) + } + }, + Commands::Initramfs { command } => match command { + InitramfsCommands::Install { + config, + verbose, + force, + target, + container_args, + dnf_args, + } => { + let install_cmd = InitramfsInstallCommand::new( + config, + verbose, + force, + target.or(cli.target.clone()), + container_args, + dnf_args, + ) + .with_no_stamps(cli.no_stamps) + .with_runs_on(cli.runs_on.clone(), cli.nfs_port) + .with_sdk_arch(cli.sdk_arch.clone()); + install_cmd.execute().await?; + Ok(()) + } + InitramfsCommands::Image { + config, + verbose, + target, + out_dir, + container_args, + dnf_args, + } => { + let image_cmd = InitramfsImageCommand::new( + config, + verbose, + target.or(cli.target.clone()), + container_args, + dnf_args, + ) + .with_runs_on(cli.runs_on.clone(), cli.nfs_port) + .with_sdk_arch(cli.sdk_arch.clone()) + .with_output_dir(out_dir); + image_cmd.execute().await?; + Ok(()) + } + InitramfsCommands::Clean { + config, + verbose, + target, + container_args, + dnf_args, + } => { + let clean_cmd = InitramfsCleanCommand::new( + config, + verbose, + target.or(cli.target.clone()), + container_args, + dnf_args, + ) + .with_sdk_arch(cli.sdk_arch.clone()); + clean_cmd.execute().await?; + Ok(()) + } + }, Commands::Hitl { command } => match command { HitlCommands::Server { config_path, @@ -3036,6 +3174,134 @@ enum ExtCommands { }, } +#[derive(Subcommand)] +enum RootfsCommands { + /// Install rootfs sysroot packages via DNF + Install { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Force the operation to proceed, bypassing warnings or confirmation prompts + #[arg(short, long)] + force: bool, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, + /// Build rootfs image from sysroot + Image { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Output directory on host for the resulting image + #[arg(long = "out")] + out_dir: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, + /// Remove rootfs sysroot + Clean { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, +} + +#[derive(Subcommand)] +enum InitramfsCommands { + /// Install initramfs sysroot packages via DNF + Install { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Force the operation to proceed, bypassing warnings or confirmation prompts + #[arg(short, long)] + force: bool, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, + /// Build initramfs image from sysroot + Image { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Output directory on host for the resulting image + #[arg(long = "out")] + out_dir: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, + /// Remove initramfs sysroot + Clean { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, +} + #[derive(Subcommand)] enum HitlCommands { /// Start a HITL server container with preconfigured settings diff --git a/src/utils/config.rs b/src/utils/config.rs index fc36532..9cdc709 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -479,6 +479,9 @@ pub struct SplitPackageConfig { pub struct ImageConfig { #[serde(alias = "dependencies")] pub packages: Option>, + /// Filesystem format for the image (e.g., "erofs-zstd", "erofs-lz4", "cpio", "cpio-zstd"). + /// Defaults depend on context: rootfs defaults to "erofs-zstd", initramfs to "cpio". + pub filesystem: Option, } /// Provision profile configuration @@ -1686,6 +1689,52 @@ impl Config { } } } + + // Merge rootfs section based on include patterns + if let Some(external_rootfs) = external_config.get("rootfs") { + if ExtensionSource::matches_include_pattern("rootfs", include_patterns) { + let main_map = main_config.as_mapping_mut().unwrap(); + let rootfs_key = serde_yaml::Value::String("rootfs".to_string()); + if !main_map.contains_key(&rootfs_key) { + // Main config has no rootfs section — use the external one + main_map.insert(rootfs_key, external_rootfs.clone()); + } else if let (Some(main_rootfs), Some(ext_rootfs_map)) = ( + main_map + .get_mut(&rootfs_key) + .and_then(|v| v.as_mapping_mut()), + external_rootfs.as_mapping(), + ) { + // Deep merge: add fields from external that don't exist in main + for (k, v) in ext_rootfs_map { + if !main_rootfs.contains_key(k) { + main_rootfs.insert(k.clone(), v.clone()); + } + } + } + } + } + + // Merge initramfs section based on include patterns + if let Some(external_initramfs) = external_config.get("initramfs") { + if ExtensionSource::matches_include_pattern("initramfs", include_patterns) { + let main_map = main_config.as_mapping_mut().unwrap(); + let initramfs_key = serde_yaml::Value::String("initramfs".to_string()); + if !main_map.contains_key(&initramfs_key) { + main_map.insert(initramfs_key, external_initramfs.clone()); + } else if let (Some(main_initramfs), Some(ext_initramfs_map)) = ( + main_map + .get_mut(&initramfs_key) + .and_then(|v| v.as_mapping_mut()), + external_initramfs.as_mapping(), + ) { + for (k, v) in ext_initramfs_map { + if !main_initramfs.contains_key(k) { + main_initramfs.insert(k.clone(), v.clone()); + } + } + } + } + } } /// Find a matching extension key in the main config's ext section. @@ -2389,6 +2438,24 @@ impl Config { default } + /// Get rootfs filesystem format from top-level config. + /// Defaults to `"erofs.lz4"` when the section or field is absent. + pub fn get_rootfs_filesystem(&self) -> String { + self.rootfs + .as_ref() + .and_then(|r| r.filesystem.clone()) + .unwrap_or_else(|| "erofs.lz4".to_string()) + } + + /// Get initramfs filesystem format from top-level config. + /// Defaults to `"cpio.zst"` when the section or field is absent. + pub fn get_initramfs_filesystem(&self) -> String { + self.initramfs + .as_ref() + .and_then(|i| i.filesystem.clone()) + .unwrap_or_else(|| "cpio.zst".to_string()) + } + /// Get signing keys mapping (name -> keyid or global name) #[allow(dead_code)] // Public API for future use pub fn get_signing_keys(&self) -> Option<&HashMap> { diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index 2354846..1ecd2a1 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -950,6 +950,24 @@ pub fn compute_runtime_input_hash( ); } + // Include rootfs/initramfs filesystem formats (changes should trigger rebuild) + if let Some(rootfs) = parsed.get("rootfs") { + if let Some(fs) = rootfs.get("filesystem") { + hash_data.insert( + serde_yaml::Value::String("rootfs.filesystem".to_string()), + fs.clone(), + ); + } + } + if let Some(initramfs) = parsed.get("initramfs") { + if let Some(fs) = initramfs.get("filesystem") { + hash_data.insert( + serde_yaml::Value::String("initramfs.filesystem".to_string()), + fs.clone(), + ); + } + } + let config_hash = compute_config_hash(&serde_yaml::Value::Mapping(hash_data))?; Ok(StampInputs::new(config_hash)) } From 35124cb487a273ada69b7819fabe1954334e9119 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Tue, 10 Mar 2026 12:50:35 -0400 Subject: [PATCH 03/10] Replace avocado-build hooks with stone bundle in runtime build - Replace avocado-build-$TARGET_ARCH hook call with direct `stone bundle` invocation, building the .aos bundle before var partition assembly - Add os_bundle field (image_id + sha256) to runtime manifest (v2) when the .aos file is produced by stone bundle - Update connect upload to discover and upload .aos artifact alongside extension images - Reorder build script: stone bundle runs after rootfs/initramfs but before var assembly, so the manifest can include the bundle hash --- src/commands/connect/upload.rs | 36 ++++++++++++++++++++-- src/commands/runtime/build.rs | 56 +++++++++++++++++++++++++++++----- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/commands/connect/upload.rs b/src/commands/connect/upload.rs index 3cdf185..610aa94 100644 --- a/src/commands/connect/upload.rs +++ b/src/commands/connect/upload.rs @@ -380,8 +380,9 @@ fn format_version_from_manifest(manifest: &serde_json::Value) -> String { // Artifact discovery (manifest-driven, with SHA256 hashing for TUF) // --------------------------------------------------------------------------- -/// Discover artifacts from the manifest's extensions list. +/// Discover artifacts from the manifest's extensions list and os_bundle. /// Each extension has an `image_id` that maps to a `{image_id}.raw` file on disk. +/// The os_bundle (if present) is also included as an artifact. /// Computes SHA256 of each artifact for TUF target metadata. fn discover_artifacts(dir: &Path, manifest: &serde_json::Value) -> Result> { let images_dir = dir.join("lib/avocado/images"); @@ -423,8 +424,39 @@ fn discover_artifacts(dir: &Path, manifest: &serde_json::Value) -> Result " + aos_image_id + ".raw") + manifest["os_bundle"] = dict( + image_id=aos_image_id, + sha256=aos_sha256, + ) + with open(manifest_path, "w") as f: json.dump(manifest, f, indent=2) print("Created runtime manifest with " + str(len(extensions)) + " extension(s)") # Clean up stale images not referenced by the current manifest current_image_files = set(ext["image_id"] + ".raw" for ext in extensions) +if "os_bundle" in manifest: + current_image_files.add(manifest["os_bundle"]["image_id"] + ".raw") for fname in os.listdir(images_dir): if fname.endswith(".raw") and fname not in current_image_files: stale_path = os.path.join(images_dir, fname) @@ -1505,7 +1521,35 @@ echo "Copying required extension images to runtime-specific directory..." {rootfs_build_section} {initramfs_build_section} -# Assemble var partition content +# Build OS bundle (.aos) — needs rootfs + initramfs + kernel (all built above) +STONE_MANIFEST="${{AVOCADO_STONE_MANIFEST:-$AVOCADO_SDK_PREFIX/stone/stone-$TARGET_ARCH.json}}" +STONE_INPUT_DIR="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME" +STONE_BUILD_DIR="$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/stone" +STONE_AOS_OUTPUT="$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/os-bundle.aos" +export STONE_AOS_OUTPUT + +# Build include path flags from AVOCADO_STONE_INCLUDE_PATHS +STONE_INCLUDE_FLAGS="" +if [ -n "${{AVOCADO_STONE_INCLUDE_PATHS:-}}" ]; then + for path in $AVOCADO_STONE_INCLUDE_PATHS; do + STONE_INCLUDE_FLAGS="$STONE_INCLUDE_FLAGS -i $path" + done +fi +STONE_INCLUDE_FLAGS="$STONE_INCLUDE_FLAGS -i $STONE_INPUT_DIR" + +echo -e "\033[94m[INFO]\033[0m Running stone bundle." +echo -e " Manifest: $STONE_MANIFEST" +echo -e " Output: $STONE_AOS_OUTPUT" +echo -e " Build dir: $STONE_BUILD_DIR" + +stone bundle \ + --os-release "$AVOCADO_PREFIX/rootfs/usr/lib/os-release" \ + -m "$STONE_MANIFEST" \ + $STONE_INCLUDE_FLAGS \ + -o "$STONE_AOS_OUTPUT" \ + --build-dir "$STONE_BUILD_DIR" + +# Assemble var partition content (after stone bundle so manifest can include os_bundle) {var_files_section} {runtime_var_files_section} {manifest_section} @@ -1515,10 +1559,6 @@ echo "Copying required extension images to runtime-specific directory..." mkfs.btrfs -r "$VAR_DIR" \ --subvol rw:lib/avocado \ -f "$OUTPUT_DIR/avocado-image-var-$TARGET_ARCH.btrfs" - -# Run SDK lifecycle hook (stone create) — uses newly built rootfs + initramfs + var -echo -e "\033[94m[INFO]\033[0m Running SDK lifecycle hook 'avocado-build' for '$TARGET_ARCH'." -avocado-build-$TARGET_ARCH $RUNTIME_NAME "#, runtime_name = self.runtime_name, target_arch = target_arch, @@ -1869,7 +1909,7 @@ runtimes: assert!(script.contains("RUNTIME_NAME=\"test-runtime\"")); assert!(script.contains("TARGET_ARCH=\"x86_64\"")); assert!(script.contains("VAR_DIR=$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/var-staging")); - assert!(script.contains("avocado-build-$TARGET_ARCH $RUNTIME_NAME")); + assert!(script.contains("stone bundle")); assert!(script.contains("mkfs.btrfs")); } @@ -2137,7 +2177,7 @@ extensions: // Manifest should be generated dynamically via Python assert!(script.contains("manifest.json")); - assert!(script.contains("manifest_version=1")); + assert!(script.contains("manifest_version=2")); assert!(script.contains("AVOCADO_RUNTIME_NAME=\"test-runtime\"")); assert!(script.contains("AVOCADO_EXT_PAIRS=\"test-ext:1.0.0\"")); assert!(script.contains("AVOCADO_NS_UUID=")); From dcee00e3c0dfcef64b24d445f90e13c4dcf6aeb8 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Tue, 10 Mar 2026 13:12:10 -0400 Subject: [PATCH 04/10] fix: reorder build script so var image exists before stone bundle Move var partition assembly (var_files, manifest, mkfs.btrfs) before stone bundle so the var image exists when stone bundle copies manifest inputs to the build directory. This fixes provision failing with "Image file 'avocado-image-var-*.btrfs' not found". The manifest baked into the var partition no longer contains os_bundle (it doesn't exist yet). After stone bundle produces the .aos, a post-bundle step patches the manifest in var-staging with os_bundle for connect upload (which reads var-staging directly, not the btrfs). --- src/commands/runtime/build.rs | 78 +++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 038b6e9..f8a1179 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1146,28 +1146,12 @@ manifest = dict( extensions=extensions, ) -# Include os_bundle if the .aos file was built -aos_path = os.environ.get("STONE_AOS_OUTPUT", "") -if aos_path and os.path.isfile(aos_path): - with open(aos_path, "rb") as f: - aos_sha256 = hashlib.sha256(f.read()).hexdigest() - aos_image_id = str(uuid.uuid5(namespace, aos_sha256)) - dest = os.path.join(images_dir, aos_image_id + ".raw") - shutil.copy2(aos_path, dest) - print(" OS bundle: os-bundle.aos -> " + aos_image_id + ".raw") - manifest["os_bundle"] = dict( - image_id=aos_image_id, - sha256=aos_sha256, - ) - with open(manifest_path, "w") as f: json.dump(manifest, f, indent=2) print("Created runtime manifest with " + str(len(extensions)) + " extension(s)") -# Clean up stale images not referenced by the current manifest +# Clean up stale extension images (os_bundle cleanup happens after stone bundle) current_image_files = set(ext["image_id"] + ".raw" for ext in extensions) -if "os_bundle" in manifest: - current_image_files.add(manifest["os_bundle"]["image_id"] + ".raw") for fname in os.listdir(images_dir): if fname.endswith(".raw") and fname not in current_image_files: stale_path = os.path.join(images_dir, fname) @@ -1521,7 +1505,18 @@ echo "Copying required extension images to runtime-specific directory..." {rootfs_build_section} {initramfs_build_section} -# Build OS bundle (.aos) — needs rootfs + initramfs + kernel (all built above) +# Assemble var partition content and build var image +{var_files_section} +{runtime_var_files_section} +{manifest_section} +{update_authority_section} +{docker_section} + +mkfs.btrfs -r "$VAR_DIR" \ + --subvol rw:lib/avocado \ + -f "$OUTPUT_DIR/avocado-image-var-$TARGET_ARCH.btrfs" + +# Build OS bundle (.aos) — needs rootfs + initramfs + kernel + var (all built above) STONE_MANIFEST="${{AVOCADO_STONE_MANIFEST:-$AVOCADO_SDK_PREFIX/stone/stone-$TARGET_ARCH.json}}" STONE_INPUT_DIR="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME" STONE_BUILD_DIR="$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/stone" @@ -1549,16 +1544,45 @@ stone bundle \ -o "$STONE_AOS_OUTPUT" \ --build-dir "$STONE_BUILD_DIR" -# Assemble var partition content (after stone bundle so manifest can include os_bundle) -{var_files_section} -{runtime_var_files_section} -{manifest_section} -{update_authority_section} -{docker_section} +# Patch manifest in var-staging to add os_bundle reference (for connect upload) +# The btrfs image for provisioning doesn't need os_bundle — initial flash doesn't OTA. +# Connect upload reads from var-staging directly, so it sees this update. +python3 << 'PYEOF' +import json, hashlib, uuid, os, shutil -mkfs.btrfs -r "$VAR_DIR" \ - --subvol rw:lib/avocado \ - -f "$OUTPUT_DIR/avocado-image-var-$TARGET_ARCH.btrfs" +aos_path = os.environ.get("STONE_AOS_OUTPUT", "") +if not (aos_path and os.path.isfile(aos_path)): + print("No .aos file found, skipping os_bundle manifest patch.") + exit(0) + +namespace = uuid.UUID(os.environ["AVOCADO_NS_UUID"]) +images_dir = os.environ["AVOCADO_IMAGES_DIR"] +manifest_path = os.environ["AVOCADO_MANIFEST_PATH"] + +with open(aos_path, "rb") as f: + aos_sha256 = hashlib.sha256(f.read()).hexdigest() +aos_image_id = str(uuid.uuid5(namespace, aos_sha256)) +dest = os.path.join(images_dir, aos_image_id + ".raw") +shutil.copy2(aos_path, dest) +print(" OS bundle: os-bundle.aos -> " + aos_image_id + ".raw") + +with open(manifest_path, "r") as f: + manifest = json.load(f) +manifest["os_bundle"] = dict(image_id=aos_image_id, sha256=aos_sha256) +with open(manifest_path, "w") as f: + json.dump(manifest, f, indent=2) +print("Patched manifest with os_bundle reference.") + +# Clean up stale os_bundle images +current_image_files = set() +for ext in manifest.get("extensions", []): + current_image_files.add(ext["image_id"] + ".raw") +current_image_files.add(aos_image_id + ".raw") +for fname in os.listdir(images_dir): + if fname.endswith(".raw") and fname not in current_image_files: + os.remove(os.path.join(images_dir, fname)) + print(" Removed stale image: " + fname) +PYEOF "#, runtime_name = self.runtime_name, target_arch = target_arch, From 23b3b701cd1e70f6ad9222cb1d06e62c16ef5c5c Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Tue, 10 Mar 2026 15:25:45 -0400 Subject: [PATCH 05/10] fix: write AVOCADO_OS_BUILD_ID to sysroot and install all rootfs packages Write AVOCADO_OS_BUILD_ID to the sysroot os-release in addition to the work copy so stone bundle can read it for the verify section. Also fix rootfs install to install all configured packages instead of only the first one. --- src/commands/rootfs/image.rs | 5 ++++- src/commands/rootfs/install.rs | 38 +++++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/commands/rootfs/image.rs b/src/commands/rootfs/image.rs index 878c2ea..065e7db 100644 --- a/src/commands/rootfs/image.rs +++ b/src/commands/rootfs/image.rs @@ -75,11 +75,14 @@ if [ -d "$ROOTFS_SYSROOT/usr" ]; then PKG_HASH=$(echo "$PKG_NEVRA" | sha256sum | awk '{{print $1}}') OS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$PKG_HASH'))") - # Inject identity into os-release (in the work copy only) + # Inject identity into os-release (work copy for the image, sysroot for stone) echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_WORK/usr/lib/os-release" echo "AVOCADO_RUNTIME_NAME=$RUNTIME_NAME" >> "$ROOTFS_WORK/usr/lib/os-release" echo "AVOCADO_RUNTIME_VERSION=$RUNTIME_VERSION" >> "$ROOTFS_WORK/usr/lib/os-release" + # Also write AVOCADO_OS_BUILD_ID to the sysroot so stone bundle can read it + echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_SYSROOT/usr/lib/os-release" + # Build rootfs image using configured filesystem format ROOTFS_FS="{rootfs_filesystem}" ROOTFS_OUTPUT="$OUTPUT_DIR/avocado-image-rootfs-$TARGET_ARCH.$ROOTFS_FS" diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs index c768af2..d0e4378 100644 --- a/src/commands/rootfs/install.rs +++ b/src/commands/rootfs/install.rs @@ -52,23 +52,37 @@ pub async fn install_sysroot(params: &mut SysrootInstallParams<'_>) -> Result<() _ => unreachable!(), }; + // Build package specs for all configured packages + let pkg_specs: Vec = if packages.is_empty() { + vec![build_package_spec_with_lock( + params.lock_file, + params.target, + ¶ms.sysroot_type, + default_pkg, + "*", + )] + } else { + packages + .iter() + .map(|(name, version)| { + let ver = version.as_str().unwrap_or("*"); + build_package_spec_with_lock( + params.lock_file, + params.target, + ¶ms.sysroot_type, + name, + ver, + ) + }) + .collect() + }; + let pkg = pkg_specs.join(" "); + // The first package name is used as the base for lock file queries let base_pkg = packages .keys() .next() .map(|s| s.as_str()) .unwrap_or(default_pkg); - let config_version = packages - .values() - .next() - .and_then(|v| v.as_str()) - .unwrap_or("*"); - let pkg = build_package_spec_with_lock( - params.lock_file, - params.target, - ¶ms.sysroot_type, - base_pkg, - config_version, - ); let yes = if params.force { "-y" } else { "" }; let dnf_args_str = if let Some(args) = ¶ms.dnf_args { From 1847eebf0e4ce4ba2b55b92e3aac7577154c1c0b Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Tue, 10 Mar 2026 19:02:04 -0400 Subject: [PATCH 06/10] fix: resolve clippy and formatting warnings for CI compliance Fix map_clone clippy lint by using .cloned() and apply cargo fmt. --- src/commands/initramfs/install.rs | 2 + src/commands/rootfs/image.rs | 9 +- src/commands/rootfs/install.rs | 177 ++++++++++++++++++++++++++++-- src/commands/runtime/build.rs | 25 ++++- src/commands/sdk/install.rs | 4 + src/utils/config.rs | 8 +- src/utils/stamps.rs | 110 +++++++++++++++++-- 7 files changed, 311 insertions(+), 24 deletions(-) diff --git a/src/commands/initramfs/install.rs b/src/commands/initramfs/install.rs index 1ea4948..8844e2d 100644 --- a/src/commands/initramfs/install.rs +++ b/src/commands/initramfs/install.rs @@ -129,6 +129,8 @@ impl InitramfsInstallCommand { force: self.force, runs_on_context: runs_on_context.as_ref(), sdk_arch: self.sdk_arch.as_ref(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), }) .await; diff --git a/src/commands/rootfs/image.rs b/src/commands/rootfs/image.rs index 065e7db..8da3cd1 100644 --- a/src/commands/rootfs/image.rs +++ b/src/commands/rootfs/image.rs @@ -71,16 +71,19 @@ if [ -d "$ROOTFS_SYSROOT/usr" ]; then echo "Generated ld.so.cache" # Compute deterministic AVOCADO_OS_BUILD_ID from installed packages - PKG_NEVRA=$(rpm -qa --queryformat '%{{NEVRA}}\n' --root "$ROOTFS_SYSROOT" | sort) + PKG_NEVRA=$(rpm --dbpath /var/lib/rpm -qa --queryformat '%{{NEVRA}}\n' --root "$ROOTFS_SYSROOT" | sort) PKG_HASH=$(echo "$PKG_NEVRA" | sha256sum | awk '{{print $1}}') OS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$PKG_HASH'))") # Inject identity into os-release (work copy for the image, sysroot for stone) + # Strip any prior injected fields from the work copy before appending + sed -i '/^AVOCADO_OS_BUILD_ID=/d;/^AVOCADO_RUNTIME_NAME=/d;/^AVOCADO_RUNTIME_VERSION=/d' "$ROOTFS_WORK/usr/lib/os-release" echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_WORK/usr/lib/os-release" echo "AVOCADO_RUNTIME_NAME=$RUNTIME_NAME" >> "$ROOTFS_WORK/usr/lib/os-release" echo "AVOCADO_RUNTIME_VERSION=$RUNTIME_VERSION" >> "$ROOTFS_WORK/usr/lib/os-release" # Also write AVOCADO_OS_BUILD_ID to the sysroot so stone bundle can read it + sed -i '/^AVOCADO_OS_BUILD_ID=/d' "$ROOTFS_SYSROOT/usr/lib/os-release" echo "AVOCADO_OS_BUILD_ID=$OS_BUILD_ID" >> "$ROOTFS_SYSROOT/usr/lib/os-release" # Build rootfs image using configured filesystem format @@ -88,7 +91,7 @@ if [ -d "$ROOTFS_SYSROOT/usr" ]; then ROOTFS_OUTPUT="$OUTPUT_DIR/avocado-image-rootfs-$TARGET_ARCH.$ROOTFS_FS" echo "Building rootfs image: $ROOTFS_FS" case "$ROOTFS_FS" in - erofs.zst) + erofs-zst) mkfs.erofs \ -T "${{SOURCE_DATE_EPOCH:-0}}" \ -U 00000000-0000-0000-0000-000000000000 \ @@ -98,7 +101,7 @@ if [ -d "$ROOTFS_SYSROOT/usr" ]; then "$ROOTFS_OUTPUT" \ "$ROOTFS_WORK" ;; - erofs.lz4) + erofs-lz4) mkfs.erofs \ -T "${{SOURCE_DATE_EPOCH:-0}}" \ -U 00000000-0000-0000-0000-000000000000 \ diff --git a/src/commands/rootfs/install.rs b/src/commands/rootfs/install.rs index d0e4378..dbd301b 100644 --- a/src/commands/rootfs/install.rs +++ b/src/commands/rootfs/install.rs @@ -1,6 +1,7 @@ //! Rootfs sysroot install command and shared install logic for rootfs/initramfs. use anyhow::{Context, Result}; +use std::collections::HashSet; use std::path::Path; use std::sync::Arc; @@ -10,6 +11,10 @@ use crate::utils::{ lockfile::{build_package_spec_with_lock, LockFile, SysrootType}, output::{print_error, print_info, print_success, OutputLevel}, runs_on::RunsOnContext, + stamps::{ + compute_initramfs_input_hash, compute_rootfs_input_hash, generate_write_stamp_script, + Stamp, StampOutputs, + }, target::validate_and_log_target, }; @@ -30,12 +35,67 @@ pub struct SysrootInstallParams<'a> { pub force: bool, pub runs_on_context: Option<&'a RunsOnContext>, pub sdk_arch: Option<&'a String>, + /// Skip stamp writing when true. + pub no_stamps: bool, + /// Parsed (merged) YAML config — needed for stamp hash computation. + pub parsed: Option<&'a serde_yaml::Value>, +} + +/// Detect package removals by comparing config packages against lock file. +/// Returns true if the sysroot needs to be cleaned and reinstalled from scratch. +fn detect_sysroot_package_removals( + config: &Config, + sysroot_type: &SysrootType, + target: &str, + lock_file: &mut LockFile, +) -> bool { + let locked_names = lock_file.get_locked_package_names(target, sysroot_type); + + if locked_names.is_empty() { + return false; + } + + let config_names: HashSet = match sysroot_type { + SysrootType::Rootfs => config.get_rootfs_packages().keys().cloned().collect(), + SysrootType::Initramfs => config.get_initramfs_packages().keys().cloned().collect(), + _ => return false, + }; + + let removed: Vec = locked_names.difference(&config_names).cloned().collect(); + + if removed.is_empty() { + return false; + } + + let label = match sysroot_type { + SysrootType::Rootfs => "rootfs", + SysrootType::Initramfs => "initramfs", + _ => "sysroot", + }; + print_info( + &format!( + "Packages removed from {label}: {}. Cleaning sysroot for fresh install.", + removed.join(", ") + ), + OutputLevel::Normal, + ); + + // Remove only the stale entries, preserving version pins for remaining packages + lock_file.remove_packages_from_sysroot(target, sysroot_type, &removed); + + true } /// Install a sysroot (rootfs or initramfs) via DNF into the SDK container volume. /// /// This is the shared implementation used by both `avocado rootfs install`, /// `avocado initramfs install`, and `avocado sdk install`. +/// +/// Features: +/// - Detects package removals by comparing config against lock file +/// - Forces clean reinstall when packages are removed (DNF is additive-only) +/// - Tracks all installed packages in the lock file +/// - Writes install stamps for staleness detection pub async fn install_sysroot(params: &mut SysrootInstallParams<'_>) -> Result<()> { let (label, sysroot_dir, default_pkg) = match params.sysroot_type { SysrootType::Rootfs => ("rootfs", "rootfs", "avocado-pkg-rootfs"), @@ -45,6 +105,47 @@ pub async fn install_sysroot(params: &mut SysrootInstallParams<'_>) -> Result<() print_info(&format!("Installing {label} sysroot."), OutputLevel::Normal); + // Detect package removals: compare current config packages with lock file. + // If packages were removed, we must clean the sysroot and reinstall from scratch + // because DNF install is additive-only and cannot remove packages. + let needs_clean_reinstall = detect_sysroot_package_removals( + params.config, + ¶ms.sysroot_type, + params.target, + params.lock_file, + ); + + if needs_clean_reinstall { + let clean_command = format!(r#"rm -rf "$AVOCADO_PREFIX/{sysroot_dir}""#); + let clean_config = RunConfig { + container_image: params.container_image.to_string(), + target: params.target.to_string(), + command: clean_command, + verbose: params.verbose, + source_environment: true, + interactive: false, + repo_url: params.repo_url.map(|s| s.to_string()), + repo_release: params.repo_release.map(|s| s.to_string()), + container_args: params.merged_container_args.clone(), + sdk_arch: params.sdk_arch.cloned(), + ..Default::default() + }; + + if let Some(context) = params.runs_on_context { + params + .container_helper + .run_in_container_with_context(&clean_config, context) + .await + .ok(); + } else { + params + .container_helper + .run_in_container(clean_config) + .await + .ok(); + } + } + // Get packages from config let packages = match params.sysroot_type { SysrootType::Rootfs => params.config.get_rootfs_packages(), @@ -77,12 +178,13 @@ pub async fn install_sysroot(params: &mut SysrootInstallParams<'_>) -> Result<() .collect() }; let pkg = pkg_specs.join(" "); - // The first package name is used as the base for lock file queries - let base_pkg = packages - .keys() - .next() - .map(|s| s.as_str()) - .unwrap_or(default_pkg); + + // Collect all package names for lock file queries + let all_package_names: Vec = if packages.is_empty() { + vec![default_pkg.to_string()] + } else { + packages.keys().cloned().collect() + }; let yes = if params.force { "-y" } else { "" }; let dnf_args_str = if let Some(args) = ¶ms.dnf_args { @@ -143,12 +245,12 @@ $DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ if success { print_success(&format!("Installed {label} sysroot."), OutputLevel::Normal); - // Query installed version and update lock file + // Query installed versions for ALL config packages and update lock file let installed_versions = params .container_helper .query_installed_packages( ¶ms.sysroot_type, - &[base_pkg.to_string()], + &all_package_names, params.container_image, params.target, params.repo_url.map(|s| s.to_string()), @@ -167,12 +269,67 @@ $DNF_SDK_HOST $DNF_SDK_TARGET_REPO_CONF \ ); if params.verbose { print_info( - &format!("Updated lock file with {label} package version."), + &format!("Updated lock file with {label} package versions."), OutputLevel::Normal, ); } params.lock_file.save(params.src_dir)?; } + + // Write install stamp (unless --no-stamps or no parsed config available) + if !params.no_stamps { + if let Some(parsed) = params.parsed { + let stamp_result = match params.sysroot_type { + SysrootType::Rootfs => { + let inputs = compute_rootfs_input_hash(parsed)?; + let outputs = StampOutputs::default(); + Ok(Stamp::rootfs_install(params.target, inputs, outputs)) + } + SysrootType::Initramfs => { + let inputs = compute_initramfs_input_hash(parsed)?; + let outputs = StampOutputs::default(); + Ok(Stamp::initramfs_install(params.target, inputs, outputs)) + } + _ => Err(anyhow::anyhow!("Unsupported sysroot type for stamps")), + }; + + if let Ok(stamp) = stamp_result { + let stamp_script = generate_write_stamp_script(&stamp)?; + let stamp_config = RunConfig { + container_image: params.container_image.to_string(), + target: params.target.to_string(), + command: stamp_script, + verbose: params.verbose, + source_environment: true, + interactive: false, + repo_url: params.repo_url.map(|s| s.to_string()), + repo_release: params.repo_release.map(|s| s.to_string()), + container_args: params.merged_container_args.clone(), + sdk_arch: params.sdk_arch.cloned(), + ..Default::default() + }; + + if let Some(context) = params.runs_on_context { + params + .container_helper + .run_in_container_with_context(&stamp_config, context) + .await?; + } else { + params + .container_helper + .run_in_container(stamp_config) + .await?; + } + + if params.verbose { + print_info( + &format!("Wrote install stamp for {label}."), + OutputLevel::Normal, + ); + } + } + } + } } else { return Err(anyhow::anyhow!("Failed to install {label} sysroot.")); } @@ -295,6 +452,8 @@ impl RootfsInstallCommand { force: self.force, runs_on_context: runs_on_context.as_ref(), sdk_arch: self.sdk_arch.as_ref(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), }) .await; diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index f8a1179..e10811f 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1537,8 +1537,15 @@ echo -e " Manifest: $STONE_MANIFEST" echo -e " Output: $STONE_AOS_OUTPUT" echo -e " Build dir: $STONE_BUILD_DIR" +STONE_INITRD_FLAG="" +INITRD_OS_RELEASE="$AVOCADO_PREFIX/initramfs/usr/lib/os-release-initrd" +if [ -f "$INITRD_OS_RELEASE" ]; then + STONE_INITRD_FLAG="--os-release-initrd $INITRD_OS_RELEASE" +fi + stone bundle \ --os-release "$AVOCADO_PREFIX/rootfs/usr/lib/os-release" \ + $STONE_INITRD_FLAG \ -m "$STONE_MANIFEST" \ $STONE_INCLUDE_FLAGS \ -o "$STONE_AOS_OUTPUT" \ @@ -1568,7 +1575,23 @@ print(" OS bundle: os-bundle.aos -> " + aos_image_id + ".raw") with open(manifest_path, "r") as f: manifest = json.load(f) -manifest["os_bundle"] = dict(image_id=aos_image_id, sha256=aos_sha256) +os_build_id = None +os_release_path = os.path.join(os.environ.get("AVOCADO_PREFIX", ""), "rootfs/usr/lib/os-release") +if os.path.isfile(os_release_path): + with open(os_release_path) as f: + for line in f: + if line.startswith("AVOCADO_OS_BUILD_ID="): + os_build_id = line.strip().split("=", 1)[1] + break + +initramfs_build_id = os.environ.get("AVOCADO_INITRAMFS_BUILD_ID") + +os_bundle = dict(image_id=aos_image_id, sha256=aos_sha256) +if os_build_id: + os_bundle["os_build_id"] = os_build_id +if initramfs_build_id: + os_bundle["initramfs_build_id"] = initramfs_build_id +manifest["os_bundle"] = os_bundle with open(manifest_path, "w") as f: json.dump(manifest, f, indent=2) print("Patched manifest with os_bundle reference.") diff --git a/src/commands/sdk/install.rs b/src/commands/sdk/install.rs index 578105e..a5032b4 100644 --- a/src/commands/sdk/install.rs +++ b/src/commands/sdk/install.rs @@ -1157,6 +1157,8 @@ $DNF_SDK_HOST \ force: self.force, runs_on_context, sdk_arch: self.sdk_arch.as_ref(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), }) .await?; @@ -1177,6 +1179,8 @@ $DNF_SDK_HOST \ force: self.force, runs_on_context, sdk_arch: self.sdk_arch.as_ref(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), }) .await?; diff --git a/src/utils/config.rs b/src/utils/config.rs index 9cdc709..81dca6e 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -479,8 +479,8 @@ pub struct SplitPackageConfig { pub struct ImageConfig { #[serde(alias = "dependencies")] pub packages: Option>, - /// Filesystem format for the image (e.g., "erofs-zstd", "erofs-lz4", "cpio", "cpio-zstd"). - /// Defaults depend on context: rootfs defaults to "erofs-zstd", initramfs to "cpio". + /// Filesystem format for the image (e.g., "erofs-zst", "erofs-lz4", "cpio", "cpio.zst"). + /// Defaults depend on context: rootfs defaults to "erofs-lz4", initramfs to "cpio.zst". pub filesystem: Option, } @@ -2439,12 +2439,12 @@ impl Config { } /// Get rootfs filesystem format from top-level config. - /// Defaults to `"erofs.lz4"` when the section or field is absent. + /// Defaults to `"erofs-lz4"` when the section or field is absent. pub fn get_rootfs_filesystem(&self) -> String { self.rootfs .as_ref() .and_then(|r| r.filesystem.clone()) - .unwrap_or_else(|| "erofs.lz4".to_string()) + .unwrap_or_else(|| "erofs-lz4".to_string()) } /// Get initramfs filesystem format from top-level config. diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index 1ecd2a1..a2bdaa4 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -70,6 +70,8 @@ pub enum StampComponent { Sdk, Extension, Runtime, + Rootfs, + Initramfs, } impl fmt::Display for StampComponent { @@ -78,6 +80,8 @@ impl fmt::Display for StampComponent { StampComponent::Sdk => write!(f, "sdk"), StampComponent::Extension => write!(f, "ext"), StampComponent::Runtime => write!(f, "runtime"), + StampComponent::Rootfs => write!(f, "rootfs"), + StampComponent::Initramfs => write!(f, "initramfs"), } } } @@ -308,6 +312,30 @@ impl Stamp { ) } + /// Create rootfs install stamp + pub fn rootfs_install(target: &str, inputs: StampInputs, outputs: StampOutputs) -> Self { + Self::new( + StampCommand::Install, + StampComponent::Rootfs, + None, + target.to_string(), + inputs, + outputs, + ) + } + + /// Create initramfs install stamp + pub fn initramfs_install(target: &str, inputs: StampInputs, outputs: StampOutputs) -> Self { + Self::new( + StampCommand::Install, + StampComponent::Initramfs, + None, + target.to_string(), + inputs, + outputs, + ) + } + /// Get the stamp file path relative to $AVOCADO_PREFIX/.stamps/ /// /// For SDK stamps, the path includes the target architecture (which represents @@ -321,6 +349,8 @@ impl Stamp { (StampComponent::Runtime, Some(name)) => { format!("runtime/{}/{}.stamp", name, self.command) } + (StampComponent::Rootfs, _) => format!("rootfs/{}.stamp", self.command), + (StampComponent::Initramfs, _) => format!("initramfs/{}.stamp", self.command), _ => panic!("Component name required for Extension and Runtime"), } } @@ -449,6 +479,16 @@ impl StampRequirement { Self::new(StampCommand::Provision, StampComponent::Runtime, Some(name)) } + /// Rootfs install requirement + pub fn rootfs_install() -> Self { + Self::new(StampCommand::Install, StampComponent::Rootfs, None) + } + + /// Initramfs install requirement + pub fn initramfs_install() -> Self { + Self::new(StampCommand::Install, StampComponent::Initramfs, None) + } + /// Get the stamp file path relative to $AVOCADO_PREFIX/.stamps/ /// /// For SDK stamps, the path includes the host architecture to support @@ -468,6 +508,8 @@ impl StampRequirement { (StampComponent::Runtime, Some(name), _) => { format!("runtime/{}/{}.stamp", name, self.command) } + (StampComponent::Rootfs, _, _) => format!("rootfs/{}.stamp", self.command), + (StampComponent::Initramfs, _, _) => format!("initramfs/{}.stamp", self.command), _ => panic!("Component name required for Extension and Runtime"), } } @@ -485,6 +527,8 @@ impl StampRequirement { (StampComponent::Runtime, Some(name), _) => { format!("runtime '{}' {}", name, self.command) } + (StampComponent::Rootfs, _, _) => format!("rootfs {}", self.command), + (StampComponent::Initramfs, _, _) => format!("initramfs {}", self.command), _ => format!("{} {}", self.component, self.command), } } @@ -527,6 +571,12 @@ impl StampRequirement { (StampComponent::Runtime, Some(name), StampCommand::Provision) => { format!("avocado runtime provision {name}") } + (StampComponent::Rootfs, _, StampCommand::Install) => { + "avocado rootfs install".to_string() + } + (StampComponent::Initramfs, _, StampCommand::Install) => { + "avocado initramfs install".to_string() + } _ => format!("avocado {} {}", self.component, self.command), } } @@ -886,6 +936,42 @@ pub fn compute_ext_input_hash(config: &serde_yaml::Value, ext_name: &str) -> Res Ok(StampInputs::new(config_hash)) } +/// Compute input hash for rootfs install +/// Includes: rootfs.packages +pub fn compute_rootfs_input_hash(config: &serde_yaml::Value) -> Result { + let mut hash_data = serde_yaml::Mapping::new(); + + if let Some(rootfs) = config.get("rootfs") { + if let Some(packages) = rootfs.get("packages") { + hash_data.insert( + serde_yaml::Value::String("rootfs.packages".to_string()), + packages.clone(), + ); + } + } + + let config_hash = compute_config_hash(&serde_yaml::Value::Mapping(hash_data))?; + Ok(StampInputs::new(config_hash)) +} + +/// Compute input hash for initramfs install +/// Includes: initramfs.packages +pub fn compute_initramfs_input_hash(config: &serde_yaml::Value) -> Result { + let mut hash_data = serde_yaml::Mapping::new(); + + if let Some(initramfs) = config.get("initramfs") { + if let Some(packages) = initramfs.get("packages") { + hash_data.insert( + serde_yaml::Value::String("initramfs.packages".to_string()), + packages.clone(), + ); + } + } + + let config_hash = compute_config_hash(&serde_yaml::Value::Mapping(hash_data))?; + Ok(StampInputs::new(config_hash)) +} + /// Compute input hash for runtime install /// Includes: runtime..dependencies (merged with target), kernel config, /// extension docker_images (affects var partition priming) @@ -1277,6 +1363,8 @@ pub fn resolve_required_stamps_for_runtime_build_with_arch( let mut reqs = vec![ sdk_install, compile_deps_install, + StampRequirement::rootfs_install(), + StampRequirement::initramfs_install(), StampRequirement::runtime_install(runtime_name), ]; @@ -1698,16 +1786,20 @@ mod tests { // Should have: // - SDK install (1) // - compile-deps install (1) + // - rootfs install (1) + // - initramfs install (1) // - Runtime install (1) // - app install + build + image (3) // - config-dev install + build + image (3) // - avocado-ext-dev install + build + image (3) - // Total: 12 - assert_eq!(reqs.len(), 12); + // Total: 14 + assert_eq!(reqs.len(), 14); - // Verify SDK, compile-deps, and runtime install are present + // Verify SDK, compile-deps, rootfs, initramfs, and runtime install are present assert!(reqs.contains(&StampRequirement::sdk_install())); assert!(reqs.contains(&StampRequirement::compile_deps_install())); + assert!(reqs.contains(&StampRequirement::rootfs_install())); + assert!(reqs.contains(&StampRequirement::initramfs_install())); assert!(reqs.contains(&StampRequirement::runtime_install("my-runtime"))); // Verify all extensions have install, build, and image @@ -1739,11 +1831,13 @@ mod tests { // Should have: // - SDK install (1) // - compile-deps install (1) + // - rootfs install (1) + // - initramfs install (1) // - Runtime install (1) // - app install + build + image (3) // - config-dev install + build + image (3) - // Total: 9 - assert_eq!(reqs.len(), 9); + // Total: 11 + assert_eq!(reqs.len(), 11); // Verify local extensions require install, build, and image assert!(reqs.contains(&StampRequirement::ext_install("app"))); @@ -1799,10 +1893,12 @@ mod tests { let reqs = resolve_required_stamps_for_runtime_build("minimal-runtime", &ext_deps); - // Should have SDK install + compile-deps + runtime install - assert_eq!(reqs.len(), 3); + // Should have SDK install + compile-deps + rootfs + initramfs + runtime install + assert_eq!(reqs.len(), 5); assert!(reqs.contains(&StampRequirement::sdk_install())); assert!(reqs.contains(&StampRequirement::compile_deps_install())); + assert!(reqs.contains(&StampRequirement::rootfs_install())); + assert!(reqs.contains(&StampRequirement::initramfs_install())); assert!(reqs.contains(&StampRequirement::runtime_install("minimal-runtime"))); } From 4209e772f52875b1a7c26ca9bb6fe642528464dc Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 11 Mar 2026 08:23:15 -0400 Subject: [PATCH 07/10] fix: inject AVOCADO_OS_BUILD_ID into initrd-release The initramfs build was only writing the content-addressable hash to os-release-initrd but not to initrd-release, leaving BUILD_ID as "0". Now injects AVOCADO_OS_BUILD_ID into both files. --- src/commands/initramfs/image.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/initramfs/image.rs b/src/commands/initramfs/image.rs index 9ba75cc..056d945 100644 --- a/src/commands/initramfs/image.rs +++ b/src/commands/initramfs/image.rs @@ -53,7 +53,10 @@ if [ -d "$INITRAMFS_SYSROOT/usr" ]; then INITRAMFS_PKG_HASH=$(echo "$INITRAMFS_PKG_NEVRA" | sha256sum | awk '{{print $1}}') INITRAMFS_BUILD_ID=$(python3 -c "import uuid; print(uuid.uuid5(uuid.UUID('{namespace_uuid}'), '$INITRAMFS_PKG_HASH'))") - # Inject identity into os-release-initrd (if it exists in the sysroot) + # Inject identity into initrd-release and os-release-initrd + if [ -f "$INITRAMFS_WORK/usr/lib/initrd-release" ]; then + echo "AVOCADO_OS_BUILD_ID=$INITRAMFS_BUILD_ID" >> "$INITRAMFS_WORK/usr/lib/initrd-release" + fi if [ -f "$INITRAMFS_WORK/usr/lib/os-release-initrd" ]; then echo "AVOCADO_OS_BUILD_ID=$INITRAMFS_BUILD_ID" >> "$INITRAMFS_WORK/usr/lib/os-release-initrd" fi From b40221658b422e451d8f1a5bd3901a0afc16756f Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 11 Mar 2026 12:24:36 -0400 Subject: [PATCH 08/10] feat: support erofs-lz4 and erofs-zst compression for extension images Add erofs-lz4 and erofs-zst as valid filesystem types, passing the appropriate -z compression flag to mkfs.erofs. Introduce compute_ext_input_hash_with_fs so that changing the filesystem format invalidates the image stamp. Resolve the effective filesystem early from per-extension config or the rootfs default. --- src/commands/ext/image.rs | 94 ++++++++++++++++++++++++++++++++++----- src/utils/stamps.rs | 21 +++++++++ 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/commands/ext/image.rs b/src/commands/ext/image.rs index f4b899e..d83d6e9 100644 --- a/src/commands/ext/image.rs +++ b/src/commands/ext/image.rs @@ -7,9 +7,9 @@ use crate::utils::config::{ComposedConfig, Config, ExtensionLocation}; use crate::utils::container::{RunConfig, SdkContainer}; use crate::utils::output::{print_info, print_success, OutputLevel}; use crate::utils::stamps::{ - compute_ext_input_hash, generate_batch_read_stamps_script, generate_write_stamp_script, - resolve_required_stamps, validate_stamps_batch, Stamp, StampCommand, StampComponent, - StampOutputs, + compute_ext_input_hash, compute_ext_input_hash_with_fs, generate_batch_read_stamps_script, + generate_write_stamp_script, resolve_required_stamps, validate_stamps_batch, Stamp, + StampCommand, StampComponent, StampOutputs, }; use crate::utils::target::resolve_target_required; @@ -111,6 +111,16 @@ impl ExtImageCommand { .get_sdk_image() .ok_or_else(|| anyhow::anyhow!("No SDK container image specified in configuration."))?; + // Resolve the effective filesystem for this extension early — needed for stamp hashing. + // If the extension explicitly sets `filesystem`, use that; otherwise inherit from rootfs. + let rootfs_fs = config.get_rootfs_filesystem(); + let effective_fs = parsed + .get("extensions") + .and_then(|e| e.get(&self.extension)) + .and_then(|ext| ext.get("filesystem")) + .and_then(|v| v.as_str()) + .unwrap_or(&rootfs_fs); + // Validate stamps before proceeding (unless --no-stamps) if !self.no_stamps { let container_helper = @@ -148,7 +158,8 @@ impl ExtImageCommand { .await?; // Compute current inputs from composed config for staleness detection. - // Only compare against Extension stamps — SDK/compile-deps stamps use their own hash. + // Use the base hash (without filesystem) to match what install/build stamps wrote. + // The filesystem-aware hash is only used when writing/reading the image stamp itself. let current_inputs = compute_ext_input_hash(parsed, &self.extension).ok(); let validation = validate_stamps_batch( &required, @@ -326,17 +337,19 @@ impl ExtImageCommand { .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) .unwrap_or_else(|| vec!["sysext", "confext"]); - // Get filesystem type (defaults to "squashfs") + // Use the effective filesystem resolved earlier (from ext config or rootfs default). + // The per-extension `filesystem` key can still override via ext_config, but + // effective_fs already accounts for that from the raw parsed YAML. let filesystem = ext_config .get("filesystem") .and_then(|v| v.as_str()) - .unwrap_or("squashfs"); + .unwrap_or(effective_fs); match filesystem { - "squashfs" | "erofs" => {} + "squashfs" | "erofs" | "erofs-lz4" | "erofs-zst" => {} other => { return Err(anyhow::anyhow!( - "Extension '{}' has invalid filesystem type '{}'. Must be 'squashfs' or 'erofs'.", + "Extension '{}' has invalid filesystem type '{}'. Must be 'squashfs', 'erofs', 'erofs-lz4', or 'erofs-zst'.", self.extension, other )); @@ -447,7 +460,8 @@ impl ExtImageCommand { // Write extension image stamp (unless --no-stamps) if !self.no_stamps { - let inputs = compute_ext_input_hash(parsed, &self.extension)?; + let inputs = + compute_ext_input_hash_with_fs(parsed, &self.extension, Some(filesystem))?; let outputs = StampOutputs::default(); let stamp = Stamp::ext_image(&self.extension, &target, inputs, outputs); let stamp_script = generate_write_stamp_script(&stamp)?; @@ -625,7 +639,12 @@ impl ExtImageCommand { .collect::>(); let mkfs_command = match filesystem { - "erofs" => { + "erofs" | "erofs-lz4" | "erofs-zst" => { + let compress_flag = match filesystem { + "erofs-lz4" => "\n -z lz4hc \\", + "erofs-zst" => "\n -z zstd \\", + _ => "", + }; let exclude_flags = var_excludes .iter() .map(|p| format!(" --exclude-path={p} \\")) @@ -642,7 +661,7 @@ mkfs.erofs \ -T "$SOURCE_DATE_EPOCH" \ -U 00000000-0000-0000-0000-000000000000 \ -x -1 \ - --all-root \{exclude_section} + --all-root \{compress_flag}{exclude_section} "$OUTPUT_FILE" \ "$AVOCADO_EXT_SYSROOTS/$EXT_NAME""# ) @@ -745,6 +764,59 @@ mod tests { ); } + #[test] + fn test_create_build_script_erofs_lz4_includes_compression() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 0, "erofs-lz4", &[]); + + assert!( + script.contains("mkfs.erofs"), + "erofs-lz4 should invoke mkfs.erofs" + ); + assert!( + script.contains("-z lz4hc"), + "erofs-lz4 should include -z lz4hc compression flag" + ); + assert!( + !script.contains("-z zstd"), + "erofs-lz4 should not include zstd compression" + ); + } + + #[test] + fn test_create_build_script_erofs_zst_includes_compression() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 0, "erofs-zst", &[]); + + assert!( + script.contains("mkfs.erofs"), + "erofs-zst should invoke mkfs.erofs" + ); + assert!( + script.contains("-z zstd"), + "erofs-zst should include -z zstd compression flag" + ); + assert!( + !script.contains("-z lz4hc"), + "erofs-zst should not include lz4 compression" + ); + } + + #[test] + fn test_create_build_script_erofs_uncompressed_no_z_flag() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 0, "erofs", &[]); + + assert!( + script.contains("mkfs.erofs"), + "erofs should invoke mkfs.erofs" + ); + assert!( + !script.contains("-z "), + "plain erofs should not include any -z compression flag" + ); + } + #[test] fn test_create_build_script_squashfs_contains_reproducible_flags() { let cmd = make_cmd("my-ext"); diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index a2bdaa4..b1d49b8 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -906,6 +906,18 @@ pub fn compute_compile_deps_input_hash( } pub fn compute_ext_input_hash(config: &serde_yaml::Value, ext_name: &str) -> Result { + compute_ext_input_hash_with_fs(config, ext_name, None) +} + +/// Compute input hash for an extension, including an optional resolved filesystem format. +/// When `filesystem` is `Some`, it is included in the hash so that changing the image +/// format (e.g. squashfs → erofs-lz4) invalidates the stamp. The caller is responsible +/// for resolving the effective value (explicit per-extension override or rootfs default). +pub fn compute_ext_input_hash_with_fs( + config: &serde_yaml::Value, + ext_name: &str, + filesystem: Option<&str>, +) -> Result { let mut hash_data = serde_yaml::Mapping::new(); // Include ext..dependencies @@ -932,6 +944,15 @@ pub fn compute_ext_input_hash(config: &serde_yaml::Value, ext_name: &str) -> Res } } + // Include the resolved filesystem format when provided — determines the image + // format (.raw contents) and must invalidate the stamp when it changes. + if let Some(fs) = filesystem { + hash_data.insert( + serde_yaml::Value::String(format!("ext.{ext_name}.filesystem")), + serde_yaml::Value::String(fs.to_string()), + ); + } + let config_hash = compute_config_hash(&serde_yaml::Value::Mapping(hash_data))?; Ok(StampInputs::new(config_hash)) } From 93c2a18d18fc9f77e97070cb38d44d2fa36b1d87 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 11 Mar 2026 19:00:36 -0400 Subject: [PATCH 09/10] fix: clean stone build dir before bundle to prevent stale artifacts The stone build directory at $AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/stone persisted between builds, causing stone to reuse cached rootfs images instead of picking up freshly built ones. Add rm -rf before invoking stone bundle as a defensive measure. --- src/commands/runtime/build.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index e10811f..9908421 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1094,6 +1094,8 @@ mkdir -p "$IMAGES_DIR" BUILD_ID="{build_id}" BUILT_AT="{built_at}" RUNTIME_VERSION="{runtime_version}" +# Clean stale runtime manifests from previous builds +rm -rf "$VAR_DIR/lib/avocado/runtimes" MANIFEST_DIR="$VAR_DIR/lib/avocado/runtimes/$BUILD_ID" mkdir -p "$MANIFEST_DIR" @@ -1520,6 +1522,8 @@ mkfs.btrfs -r "$VAR_DIR" \ STONE_MANIFEST="${{AVOCADO_STONE_MANIFEST:-$AVOCADO_SDK_PREFIX/stone/stone-$TARGET_ARCH.json}}" STONE_INPUT_DIR="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME" STONE_BUILD_DIR="$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/stone" +# Clean previous stone build artifacts to prevent stale image reuse +rm -rf "$STONE_BUILD_DIR" STONE_AOS_OUTPUT="$AVOCADO_PREFIX/output/runtimes/$RUNTIME_NAME/os-bundle.aos" export STONE_AOS_OUTPUT From 0fe355e97481b77105eb8aed7be7666b2de52eef Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 11 Mar 2026 22:43:35 -0400 Subject: [PATCH 10/10] release: avocado-cli 0.28.0 --- Cargo.lock | 34 +++++++++++++++++----------------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d477466..a8eed76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,7 +152,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "avocado-cli" -version = "0.27.0" +version = "0.28.0" dependencies = [ "anyhow", "base64", @@ -1173,9 +1173,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.88" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1504,9 +1504,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", @@ -2612,9 +2612,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.111" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -2625,9 +2625,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.61" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -2639,9 +2639,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.111" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2649,9 +2649,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.111" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -2662,9 +2662,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.111" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -2718,9 +2718,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.88" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 766122c..c327fd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "avocado-cli" -version = "0.27.0" +version = "0.28.0" edition = "2021" description = "Command line interface for Avocado." authors = ["Avocado"]