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"] 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>()) .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/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..056d945 --- /dev/null +++ b/src/commands/initramfs/image.rs @@ -0,0 +1,251 @@ +//! 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 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 + + # 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..8844e2d --- /dev/null +++ b/src/commands/initramfs/install.rs @@ -0,0 +1,148 @@ +//! 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(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), + }) + .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..8da3cd1 --- /dev/null +++ b/src/commands/rootfs/image.rs @@ -0,0 +1,276 @@ +//! 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 --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 + 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..dbd301b --- /dev/null +++ b/src/commands/rootfs/install.rs @@ -0,0 +1,472 @@ +//! 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; + +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, + stamps::{ + compute_initramfs_input_hash, compute_rootfs_input_hash, generate_write_stamp_script, + Stamp, StampOutputs, + }, + 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>, + /// 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"), + SysrootType::Initramfs => ("initramfs", "initramfs", "avocado-pkg-initramfs"), + _ => return Err(anyhow::anyhow!("Unsupported sysroot type for install")), + }; + + 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(), + SysrootType::Initramfs => params.config.get_initramfs_packages(), + _ => 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(" "); + + // 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 { + 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 versions for ALL config packages and update lock file + let installed_versions = params + .container_helper + .query_installed_packages( + ¶ms.sysroot_type, + &all_package_names, + 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 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.")); + } + + 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(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), + }) + .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 9c41307..9908421 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}, @@ -1092,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" @@ -1137,7 +1141,7 @@ for pair in ext_pairs: extensions.append(dict(name=name, version=version, image_id=image_id)) manifest = dict( - manifest_version=1, + manifest_version=2, id=build_id, built_at=built_at, runtime=dict(name=runtime_name, version=runtime_version), @@ -1148,7 +1152,7 @@ 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) for fname in os.listdir(images_dir): if fname.endswith(".raw") and fname not in current_image_files: @@ -1467,11 +1471,18 @@ echo "Docker image priming complete.""#, } }; + let rootfs_build_section = + generate_rootfs_build_script(NAMESPACE_UUID, &config.get_rootfs_filesystem()); + + let initramfs_build_section = + generate_initramfs_build_script(NAMESPACE_UUID, &config.get_initramfs_filesystem()); + 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 +1502,121 @@ 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 and build var image {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" -echo -e "\033[94m[INFO]\033[0m Running SDK lifecycle hook 'avocado-build' for '$TARGET_ARCH'." -avocado-build-$TARGET_ARCH $RUNTIME_NAME +# 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" +# 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 + +# 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_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" \ + --build-dir "$STONE_BUILD_DIR" + +# 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 + +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) +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.") + +# 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, + 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, @@ -1854,7 +1960,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")); } @@ -2122,7 +2228,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=")); 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..a5032b4 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}, @@ -446,7 +447,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 +461,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 @@ -903,91 +1140,50 @@ $DNF_SDK_HOST \ } } - // Install rootfs sysroot — repo scoping via --releasever, lock file pins exact version - print_info("Installing rootfs sysroot.", OutputLevel::Normal); - - let rootfs_base_pkg = "avocado-pkg-rootfs"; - let rootfs_config_version = "*"; - 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#" -RPM_ETCCONFIGDIR="$DNF_SDK_TARGET_PREFIX" \ -$DNF_SDK_HOST $DNF_NO_SCRIPTS $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() - }; + verbose: self.verbose, + force: self.force, + runs_on_context, + sdk_arch: self.sdk_arch.as_ref(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), + }) + .await?; - let rootfs_success = run_container_command( + // 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, - run_config, + container_image, + target, + repo_url, + repo_release, + merged_container_args: merged_container_args.cloned(), + dnf_args: self.dnf_args.clone(), + verbose: self.verbose, + force: self.force, runs_on_context, - self.sdk_arch.as_ref(), - ) + sdk_arch: self.sdk_arch.as_ref(), + no_stamps: self.no_stamps, + parsed: Some(&composed.merged_value), + }) .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 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 46f318d..81dca6e 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -474,6 +474,16 @@ 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>, + /// 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, +} + /// Provision profile configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProvisionProfileConfig { @@ -672,6 +682,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 +941,8 @@ impl Config { distro: None, runtimes: None, sdk: None, + rootfs: None, + initramfs: None, provision_profiles: None, signing_keys: None, connect: None, @@ -1671,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. @@ -2342,6 +2406,56 @@ 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 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/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..b1d49b8 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), } } @@ -784,6 +834,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)) } @@ -836,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 @@ -862,6 +944,51 @@ 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)) +} + +/// 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)) } @@ -930,6 +1057,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)) } @@ -1239,6 +1384,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), ]; @@ -1660,16 +1807,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 @@ -1701,11 +1852,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"))); @@ -1761,10 +1914,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"))); } 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, } }