From e83a5446afbcb5c9c90f1ebfb89d6486ce3207b3 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 28 Sep 2026 22:20:39 +0200 Subject: [PATCH 01/14] Bump composefs-rs dependency to 0.9.4 This is needed for aboot support. Signed-off-by: Alexander Larsson --- Cargo.lock | 62 ++++++++++++++++++++++++++++++++++++------------------ Cargo.toml | 4 ++-- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7cef20f1e5..ba2a7ec4f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,8 +780,9 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7678ceb9ed3a1cd466aac8cae1d055d77336c7698fc3d7c8b8cbc5a3a3d33e42" dependencies = [ "anyhow", "composefs-ioctls", @@ -802,18 +803,18 @@ dependencies = [ "xxhash-rust", "zerocopy", "zlink-core", - "zstd 0.13.3", + "zstd 0.14.0", ] [[package]] name = "composefs-boot" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61074a193dda9270830d3598d26b618b0181726c8cf61ab59d3f66924875d5e" dependencies = [ "anyhow", "composefs", "fn-error-context", - "hex", "pcre2", "regex-automata", "rustix", @@ -823,8 +824,9 @@ dependencies = [ [[package]] name = "composefs-ctl" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32bf730c9fe70c9d0a3b9f013547520c2d6d0ebb966a538ac73700a576114532" dependencies = [ "anyhow", "cap-std-ext", @@ -852,8 +854,9 @@ dependencies = [ [[package]] name = "composefs-fuse" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a795d7cd30c58b9245122c3a41409c35ca3bf30dc313cfac700662f3a6e6e176" dependencies = [ "anyhow", "composefs", @@ -867,8 +870,9 @@ dependencies = [ [[package]] name = "composefs-ioctls" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aad17a5f5c2c2f667c1c5cb543f6d6a5b5984fab651178eecc62b83008ebe6d" dependencies = [ "rustix", "thiserror 2.0.20", @@ -876,8 +880,9 @@ dependencies = [ [[package]] name = "composefs-oci" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f7302aba57a0663087356e3df39d0bf43adf7304d845f5873b5b62974a4123" dependencies = [ "anyhow", "async-compression", @@ -893,6 +898,7 @@ dependencies = [ "fn-error-context", "hex", "indicatif", + "oci-delta", "ocidir", "rustix", "serde", @@ -904,13 +910,14 @@ dependencies = [ "tracing", "zlink", "zlink-core", - "zstd 0.13.3", + "zstd 0.14.0", ] [[package]] name = "composefs-splitdirfdstream" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe821055dd88f7c2d7e3ee8ac1e54be8b646f676d568c1f37e53c606b911f09" dependencies = [ "openssl", "rand 0.10.3", @@ -922,8 +929,9 @@ dependencies = [ [[package]] name = "composefs-storage" -version = "0.9.2" -source = "git+https://github.com/composefs/composefs-rs?tag=v0.9.2#f720d6c79ac6b082fb1e182eed3b291f88e2a7f4" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "999579b801c75fec30b73eb416f40eff07725432ea704b1fb646ad709a1664ba" dependencies = [ "anyhow", "base64 0.23.0", @@ -942,7 +950,7 @@ dependencies = [ "toml", "tracing", "zlink", - "zstd 0.13.3", + "zstd 0.14.0", ] [[package]] @@ -2396,6 +2404,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "oci-delta" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc185feabe86ac78e5c4ae8b800078f257621ba652cd426d516a846aed93b543" +dependencies = [ + "anyhow", + "flate2", + "hex", + "oci-spec", + "openssl", + "zstd 0.13.3", +] + [[package]] name = "oci-spec" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 76245127ae..7f84496782 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ clap_mangen = { version = "0.3.0" } # pulls), which drags in rustls-webpki/untrusted/webpki-root-certs whose # licenses aren't in our cargo-deny allow list. Re-enable once that's sorted. # See: https://github.com/bootc-dev/bootc/pull/2295 -composefs-ctl = { git = "https://github.com/composefs/composefs-rs", tag = "v0.9.2", default-features = false, features = [ +composefs-ctl = { version = "0.9.4", default-features = false, features = [ "pre-6.15", "pre-6.16", "oci", @@ -87,7 +87,7 @@ linux-kernel-cmdline = { version = "0.1.1", features = ["serde"] } xshell = "0.2.6" # To develop against a local composefs-rs checkout, add a [patch] section at the end of this file: -# [patch."https://github.com/composefs/composefs-rs"] +# [patch.crates-io] # composefs-ctl = { path = "/composefs-rs/crates/composefs-ctl" } # See https://github.com/coreos/cargo-vendor-filterer From 16aff46dbc59f9c7adc5aa27581dbf6831a5a5f4 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 18 Sep 2026 16:56:24 +0200 Subject: [PATCH 02/14] composefs: recognize aboot boot artifacts Add the Aboot boot type and map composefs-boot aboot entries to it. Reject aboot operations before modifying bootloader state until the deployment and lifecycle paths are implemented. Assisted-by: AI Signed-off-by: Alexander Larsson --- .../backwards_compat/bcompat_boot.rs | 3 +++ crates/lib/src/bootc_composefs/boot.rs | 22 ++++++++++++++----- crates/lib/src/bootc_composefs/delete.rs | 1 + crates/lib/src/bootc_composefs/finalize.rs | 1 + crates/lib/src/bootc_composefs/gc.rs | 1 + crates/lib/src/bootc_composefs/rollback.rs | 1 + crates/lib/src/bootc_composefs/state.rs | 2 +- crates/lib/src/bootc_composefs/status.rs | 2 ++ crates/lib/src/bootc_composefs/update.rs | 8 +++++-- docs/src/host-v1.schema.json | 3 ++- 10 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs index f89fcbfb6a..b7cbc33c17 100644 --- a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs +++ b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs @@ -392,6 +392,9 @@ pub(crate) async fn prepend_custom_prefix( handle_bls_conf(storage, cfs_cmdline, boot_dir, true)?; } }, + BootType::Aboot => { + tracing::debug!("aboot backwards compatibility is not implemented") + } }; Ok(()) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 265fd58c4a..0ab514ba24 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -364,6 +364,7 @@ pub enum BootType { #[default] Bls, Uki, + Aboot, } impl ::std::fmt::Display for BootType { @@ -371,6 +372,7 @@ impl ::std::fmt::Display for BootType { let s = match self { BootType::Bls => "bls", BootType::Uki => "uki", + BootType::Aboot => "aboot", }; write!(f, "{}", s) @@ -384,6 +386,7 @@ impl TryFrom<&str> for BootType { match value { "bls" => Ok(Self::Bls), "uki" => Ok(Self::Uki), + "aboot" => Ok(Self::Aboot), unrecognized => Err(anyhow::anyhow!( "Unrecognized boot option: '{unrecognized}'" )), @@ -397,6 +400,7 @@ impl From<&ComposefsBootEntry> for BootType { ComposefsBootEntry::Type1(..) => Self::Bls, ComposefsBootEntry::Type2(..) => Self::Uki, ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + ComposefsBootEntry::Aboot(..) => Self::Aboot, } } } @@ -909,6 +913,7 @@ pub(crate) fn setup_composefs_bls_boot( let (bls_config, boot_digest, os_id) = match &entry { ComposefsBootEntry::Type1(..) => anyhow::bail!("Found Type1 entries in /boot"), ComposefsBootEntry::Type2(..) => anyhow::bail!("Found UKI"), + ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"), ComposefsBootEntry::UsrLibModulesVmLinuz(usr_lib_modules_vmlinuz) => { let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) @@ -1826,6 +1831,7 @@ pub(crate) fn setup_composefs_uki_boot( ComposefsBootEntry::UsrLibModulesVmLinuz(..) => { tracing::debug!("Skipping vmlinuz in /usr/lib/modules") } + ComposefsBootEntry::Aboot(..) => anyhow::bail!("Found aboot payload"), ComposefsBootEntry::Type2(entry) => { // If --uki-addon is not passed, we don't install any addon (whether @@ -2124,6 +2130,15 @@ pub(crate) async fn setup_composefs_boot( entries, } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + if boot_type == BootType::Aboot { + bail!("aboot boot setup is not implemented"); + } + let composefs_mnt_fd = repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?; @@ -2217,12 +2232,6 @@ pub(crate) async fn setup_composefs_boot( })?; } - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(entry); - let repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!( "BUG: Arc still has other references after boot image generation" @@ -2253,6 +2262,7 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, + BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), }; write_composefs_state( diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 97295f3896..fc9a3ae8c0 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -154,6 +154,7 @@ fn delete_depl_boot_entries( BootType::Uki => { remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } + BootType::Aboot => anyhow::bail!("aboot deletion is not implemented"), }, BootloaderKind::BLSCompatible => { diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 642e8d6d40..a95ae1468f 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -145,6 +145,7 @@ pub(crate) async fn composefs_backend_finalize( rename_exchange_bls_entries(&entries_dir)?; } BootType::Uki => finalize_staged_grub_uki(boot_dir)?, + BootType::Aboot => anyhow::bail!("aboot finalization is not implemented"), }, BootloaderKind::BLSCompatible => { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 06fc1f090a..12cdbe94a5 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -312,6 +312,7 @@ pub(crate) async fn composefs_gc( delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)? } BootType::Uki => delete_uki(storage, verity, gc_opts.dry_run)?, + BootType::Aboot => anyhow::bail!("aboot garbage collection is not implemented"), } } diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 307545463d..51f64aebf5 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -260,6 +260,7 @@ pub(crate) async fn composefs_rollback( BootType::Uki => { rollback_grub_uki_entries(boot_dir)?; } + BootType::Aboot => anyhow::bail!("aboot rollback is not implemented"), }, BootloaderKind::BLSCompatible => { diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index dd15d7c2f8..1d6912bce4 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -227,7 +227,7 @@ pub(crate) fn update_boot_digest_in_origin( /// * `deployment_id` - Unique SHA512 hash identifier for this deployment /// * `imgref` - Container image reference for the deployment /// * `staged` - Whether this is a staged deployment (writes to transient state dir) -/// * `boot_type` - Boot loader type (`Bls` or `Uki`) +/// * `boot_type` - Boot artifact type (`Bls`, `Uki`, or `Aboot`) /// * `boot_digest` - Optional boot digest for verification /// * `manifest_digest` - OCI manifest content digest, stored in the origin file so the /// manifest+config can be retrieved from the composefs repo later diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 807391172a..28e80af5a9 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -714,6 +714,7 @@ fn set_soft_reboot_capability( } BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host), + BootType::Aboot => Ok(()), } } @@ -1074,6 +1075,7 @@ async fn composefs_deployment_status_from( (is_rollback_queued, None, Some(menuentries)) } + BootType::Aboot => anyhow::bail!("aboot status is not implemented"), }, // We will have BLS stuff and the UKI stuff in the same DIR diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c0dcafe346..5d3e7ecb98 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -200,6 +200,7 @@ pub(crate) fn validate_update( .context("Removing staged grub user config")?; } } + BootType::Aboot => anyhow::bail!("aboot update cleanup is not implemented"), }, BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, @@ -309,6 +310,10 @@ pub(crate) async fn do_upgrade( let Some(entry) = entries.iter().next() else { anyhow::bail!("No boot entries!"); }; + let boot_type = BootType::from(entry); + if boot_type == BootType::Aboot { + anyhow::bail!("aboot boot setup is not implemented"); + } let mounted_fs = Dir::reopen_dir( &repo @@ -328,8 +333,6 @@ pub(crate) async fn do_upgrade( anyhow::bail!("Merge conflicts found in etc"); } - let boot_type = BootType::from(entry); - let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); let (boot_digest, deploy_id) = match boot_type { @@ -356,6 +359,7 @@ pub(crate) async fn do_upgrade( &repo, &oci_fs, )?, + BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), }; // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 8e0fa84dd6..32a36a0df3 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -204,7 +204,8 @@ "type": "string", "enum": [ "Bls", - "Uki" + "Uki", + "Aboot" ] }, "Bootloader": { From c1c27d2b56b603e51cbad9601c026aefb7a69327 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 9 Oct 2026 10:27:42 +0200 Subject: [PATCH 03/14] kernel: Exclude UKI addons from primary kernel discovery An addon can sort before the primary UKI and supply the wrong kernel version and command line. Ignore addon candidates when selecting the kernel; addon installation continues to use the separate boot-entry inventory. Images with only addons now report no kernel; addons alongside vmlinuz no longer force UKI backend selection. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/kernel.rs | 53 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs index 83e7cc24cf..2c38f19920 100644 --- a/crates/lib/src/kernel.rs +++ b/crates/lib/src/kernel.rs @@ -182,7 +182,7 @@ pub(crate) fn split_kernel(root: &Dir, output: &Dir) -> Result { /// Returns the path to the first UKI found in the container root, if any. /// -/// Looks in `/boot/EFI/Linux/*.efi`. If multiple UKIs are present, returns +/// Looks in `/boot/EFI/Linux/*.efi`, excluding addons. If multiple UKIs are present, returns /// the first one in sorted order for determinism. fn find_uki_path(root: &Dir) -> Result> { let Some(boot) = root.open_dir_optional(crate::install::BOOT)? else { @@ -198,7 +198,7 @@ fn find_uki_path(root: &Dir) -> Result> { let name = entry.file_name(); let name_path = Path::new(&name); let extension = name_path.extension().and_then(|v| v.to_str()); - if extension == Some("efi") { + if extension == Some("efi") && !name.to_string_lossy().ends_with(".addon.efi") { if let Some(name_str) = name.to_str() { uki_files.push(name_str.to_owned()); } @@ -361,4 +361,53 @@ mod tests { assert_eq!(path.as_str(), "boot/EFI/Linux/aaa.efi"); Ok(()) } + + #[test] + fn test_find_kernel_with_addons() -> Result<()> { + for (has_uki, has_vmlinuz, has_cmdline, expected_unified) in [ + (true, false, true, Some(true)), + (false, false, false, None), + (false, true, false, Some(false)), + (true, false, false, Some(true)), + ] { + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir_all("boot/EFI/Linux")?; + root.write("boot/EFI/Linux/000.addon.efi", create_minimal_pe())?; + if has_uki { + let mut uki = create_minimal_pe(); + if !has_cmdline { + const SECTION_HEADER_OFFSET: usize = 0x188; + uki[SECTION_HEADER_OFFSET..SECTION_HEADER_OFFSET + 8] + .copy_from_slice(b".linux\0\0"); + } + root.write("boot/EFI/Linux/kernel.efi", uki)?; + } + if has_vmlinuz { + root.create_dir_all("usr/lib/modules/6.12")?; + root.write("usr/lib/modules/6.12/vmlinuz", b"kernel")?; + } + + let kernel = find_kernel(&root)?; + assert_eq!(kernel.as_ref().map(|k| k.kernel.unified), expected_unified); + if let Some(kernel) = kernel { + assert_eq!( + kernel.kernel.version, + if has_uki { "kernel" } else { "6.12" } + ); + if let KernelType::Uki { path, cmdline } = kernel.k_type { + assert_eq!(path, "boot/EFI/Linux/kernel.efi"); + assert_eq!( + cmdline.as_ref().map(ToString::to_string).as_deref(), + has_cmdline.then_some("quiet splash") + ); + } else { + let KernelType::Vmlinuz { path, .. } = kernel.k_type else { + panic!("Expected vmlinuz"); + }; + assert_eq!(path, "usr/lib/modules/6.12/vmlinuz"); + } + } + } + Ok(()) + } } From 352d9bc6c5361c2f881f8851b375556f811f5702 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:29:09 +0200 Subject: [PATCH 04/14] kernel: Recognize modern aboot boot artifacts This extends KernelType with a new Aboot version, and extends the reporting of "bootc container inspect" to give aboot info as well. Note: We're not recognizing traditional ostree aboot setups with an aboot.img in /usr/lib/modules/$kver, because we will be treating these differently, enforcing the composefs backend. Also, we want the artifacts in /boot so that we can sign them without having that affect the boot transformed digest. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/container_export.rs | 4 +- crates/lib/src/install.rs | 10 +- crates/lib/src/kernel.rs | 316 +++++++++++++++++++++- crates/lib/src/spec.rs | 3 + crates/lib/src/status.rs | 47 +++- crates/lib/src/ukify.rs | 8 +- crates/tests-integration/src/container.rs | 6 +- docs/src/man/bootc-container-inspect.8.md | 2 +- 8 files changed, 372 insertions(+), 24 deletions(-) diff --git a/crates/lib/src/container_export.rs b/crates/lib/src/container_export.rs index a993ad9652..f48037ae77 100644 --- a/crates/lib/src/container_export.rs +++ b/crates/lib/src/container_export.rs @@ -396,8 +396,8 @@ fn handle_kernel_relocation( append_dir_entry(tar_builder, "boot")?; append_dir_entry(tar_builder, "boot/grub2")?; - // UKIs don't need relocation - they're already in /boot/EFI/Linux - if kernel_info.kernel.unified { + // UKIs and aboot payloads don't need relocation - they're already in /boot. + if kernel_info.k_type.is_unified() { return Ok(()); } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 03eeb22172..10dec8cbf8 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1782,7 +1782,7 @@ async fn prepare_install( let kernel = crate::kernel::find_kernel(root)?; ( - kernel.as_ref().map(|k| k.kernel.unified).unwrap_or(false), + kernel.as_ref().is_some_and(|k| k.k_type.is_unified()), kernel, ) } else { @@ -1907,11 +1907,9 @@ async fn prepare_install( // // NOTE: This isn't really 100% accurate 100% of the time as the cmdline can be in an addon if let Some(root_filesystem) = root_filesystem { - if let Some(k) = kernel - && let crate::kernel::KernelType::Uki { cmdline, .. } = k.k_type - { - let allow_missing_fsverity = match cmdline { - Some(cmdline) => ComposefsCmdline::find_in_cmdline(&cmdline)? + if let Some(kernel) = kernel.as_ref().filter(|k| k.k_type.is_unified()) { + let allow_missing_fsverity = match kernel.k_type.cmdline() { + Some(cmdline) => ComposefsCmdline::find_in_cmdline(cmdline)? .is_some_and(|cfs_cmdline| cfs_cmdline.allow_missing_fsverity), None => false, }; diff --git a/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs index 2c38f19920..301f3ab8ab 100644 --- a/crates/lib/src/kernel.rs +++ b/crates/lib/src/kernel.rs @@ -2,14 +2,19 @@ //! //! This module provides functionality to detect kernel information in container //! images, supporting both traditional kernels (with separate vmlinuz/initrd) and -//! Unified Kernel Images (UKI). +//! Unified Kernel Images (UKI), and aboot payloads. +use std::io::{Read, Seek, SeekFrom}; use std::path::Path; use anyhow::{Context, Result}; use camino::Utf8PathBuf; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; +use composefs_boot::{ + android_boot::{AndroidBootImage, Component}, + bootloader::AbootEncoding, +}; use composefs_ctl::composefs_boot; use linux_kernel_cmdline::utf8::Cmdline; use serde::Serialize; @@ -24,16 +29,21 @@ pub(crate) struct Kernel { /// `/usr/lib/modules/` directory name. For UKI images, this is the UKI filename /// (without the .efi extension). pub(crate) version: String, - /// Whether the kernel is packaged as a UKI (Unified Kernel Image). + /// Whether the kernel and initramfs are packaged together, as a UKI or aboot image. pub(crate) unified: bool, } /// Path to kernel component(s) /// -/// UKI kernels only have the single PE binary, whereas +/// UKI and aboot kernels have a single boot image, whereas /// traditional "vmlinuz" kernels have distinct kernel and /// initramfs. pub(crate) enum KernelType { + Aboot { + path: Utf8PathBuf, + encoding: AbootEncoding, + cmdline: Cmdline<'static>, + }, Uki { path: Utf8PathBuf, /// The commandline we found in the UKI @@ -67,10 +77,21 @@ impl From for Kernel { /// /// This function first attempts to find a UKI in `/boot/EFI/Linux/*.efi`. /// If that doesn't exist, it falls back to looking for a traditional kernel -/// layout with `/usr/lib/modules//vmlinuz`. +/// layout with `/usr/lib/modules//vmlinuz`. Modern aboot payloads +/// are detected in `/boot/aboot-.img` and cannot coexist with either. /// /// Returns `None` if no kernel is found. pub(crate) fn find_kernel(root: &Dir) -> Result> { + if let Some(aboot) = find_aboot_kernel(root)? { + anyhow::ensure!( + find_uki_path(root)?.is_none() + && ostree_ext::bootabletree::find_kernel_dir_fs(root)?.is_none() + && !has_type1_entries(root)?, + "aboot payload cannot be combined with other boot artifacts" + ); + return Ok(Some(aboot)); + } + // First, try to find a UKI if let Some(uki_path) = find_uki_path(root)? { let version = uki_path.file_stem().unwrap_or(uki_path.as_str()).to_owned(); @@ -132,6 +153,142 @@ pub(crate) fn find_kernel(root: &Dir) -> Result> { Ok(None) } +pub(crate) fn read_aboot_metadata( + image: &mut R, +) -> Result<(AbootEncoding, Cmdline<'static>)> { + let mut magic = [0; 8]; + image + .read_exact(&mut magic) + .context("Reading aboot payload magic")?; + image.seek(SeekFrom::Start(0))?; + let (encoding, cmdline) = if magic == *b"ANDROID!" { + let header = AndroidBootImage::parse(image).context("Parsing Android boot image")?; + header.component(image, Component::Kernel)?; + header.component(image, Component::Ramdisk)?; + (AbootEncoding::AndroidV2, header.cmdline()?.to_owned()) + } else { + composefs_boot::uki::get_section_buffered(image, ".linux")?; + image.seek(SeekFrom::Start(0))?; + composefs_boot::uki::get_section_buffered(image, ".initrd")?; + image.seek(SeekFrom::Start(0))?; + ( + AbootEncoding::Uki, + composefs_boot::uki::get_cmdline_buffered(image)?, + ) + }; + Ok((encoding, Cmdline::from(cmdline))) +} + +fn find_aboot_kernel(root: &Dir) -> Result> { + let Some(boot) = root.open_dir_optional("boot")? else { + return Ok(None); + }; + let mut payload = None; + let mut vbmeta_versions = Vec::new(); + for entry in boot.entries()? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if let Some(version) = name + .strip_prefix("vbmeta-") + .and_then(|s| s.strip_suffix(".img")) + { + anyhow::ensure!(entry.file_type()?.is_file(), "{name} is not a regular file"); + vbmeta_versions.push(version.to_owned()); + } + let Some(version) = name + .strip_prefix("aboot-") + .and_then(|s| s.strip_suffix(".img")) + else { + continue; + }; + anyhow::ensure!(!version.is_empty(), "aboot payload has no kernel version"); + anyhow::ensure!(entry.file_type()?.is_file(), "{name} is not a regular file"); + anyhow::ensure!(payload.is_none(), "multiple aboot payloads found"); + payload = Some((version.to_owned(), name.to_owned())); + } + for version in &vbmeta_versions { + anyhow::ensure!( + payload.as_ref().is_some_and(|(v, _)| v == version), + "vbmeta image has no matching aboot payload" + ); + } + let Some((version, name)) = payload else { + return Ok(None); + }; + let path = Utf8PathBuf::from("boot").join(name); + let mut file = root + .open(&path) + .with_context(|| format!("Opening {path}"))?; + let (encoding, cmdline) = + read_aboot_metadata(&mut file).with_context(|| format!("Parsing {path}"))?; + anyhow::ensure!( + encoding != AbootEncoding::Uki || vbmeta_versions.is_empty(), + "ukiboot payload must not have a vbmeta image" + ); + Ok(Some(KernelInternal { + kernel: Kernel { + version, + unified: true, + }, + k_type: KernelType::Aboot { + path, + encoding, + cmdline, + }, + })) +} + +/// The boot artifact type reported by `bootc container inspect`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ContainerImageType { + Aboot, + AbootEfi, + Uki, + Vmlinuz, +} + +impl ContainerImageType { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Aboot => "aboot", + Self::AbootEfi => "aboot-efi", + Self::Uki => "UKI", + Self::Vmlinuz => "vmlinuz", + } + } +} + +impl KernelType { + pub(crate) fn cmdline(&self) -> Option<&Cmdline<'static>> { + match self { + Self::Aboot { cmdline, .. } => Some(cmdline), + Self::Uki { cmdline, .. } => cmdline.as_ref(), + Self::Vmlinuz { .. } => None, + } + } + + pub(crate) fn is_unified(&self) -> bool { + matches!(self, Self::Uki { .. } | Self::Aboot { .. }) + } + + pub(crate) fn image_type(&self) -> ContainerImageType { + match self { + Self::Aboot { + encoding: AbootEncoding::AndroidV2, + .. + } => ContainerImageType::Aboot, + Self::Aboot { + encoding: AbootEncoding::Uki, + .. + } => ContainerImageType::AbootEfi, + Self::Uki { .. } => ContainerImageType::Uki, + Self::Vmlinuz { .. } => ContainerImageType::Vmlinuz, + } + } +} + /// Files that distributions ship next to `vmlinuz` in `/usr/lib/modules//` /// which belong to that kernel binary, and so must travel with it. /// @@ -155,7 +312,7 @@ pub(crate) const KERNEL_COMPANION_FILES: &[&str] = &[".vmlinuz.hmac"]; pub(crate) fn split_kernel(root: &Dir, output: &Dir) -> Result { let kernel = find_kernel(root)?.ok_or_else(|| anyhow::anyhow!("No kernel found in rootfs"))?; let KernelType::Vmlinuz { path, initramfs } = &kernel.k_type else { - anyhow::bail!("UKIs are not supported"); + anyhow::bail!("Only traditional kernels can be split"); }; let kver = kernel.kernel.version; @@ -180,6 +337,21 @@ pub(crate) fn split_kernel(root: &Dir, output: &Dir) -> Result { Ok(kver) } +fn has_type1_entries(root: &Dir) -> Result { + let Some(entries) = root.open_dir_optional("boot/loader/entries")? else { + return Ok(false); + }; + for entry in entries.entries()? { + if Path::new(&entry?.file_name()) + .extension() + .is_some_and(|s| s == "conf") + { + return Ok(true); + } + } + Ok(false) +} + /// Returns the path to the first UKI found in the container root, if any. /// /// Looks in `/boot/EFI/Linux/*.efi`, excluding addons. If multiple UKIs are present, returns @@ -219,6 +391,133 @@ mod tests { use bootc_utils::create_minimal_pe; use cap_std_ext::{cap_std, cap_tempfile, dirext::CapStdExtDirExt}; + fn android_image() -> Vec { + let mut image = vec![0; 4097]; + image[..8].copy_from_slice(b"ANDROID!"); + for (offset, value) in [(8, 1u32), (16, 1), (36, 2048), (40, 2), (1644, 1660)] { + image[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + image[64..69].copy_from_slice(b"quiet"); + image[2048] = 1; + image[4096] = 2; + image + } + + fn aboot_uki() -> Vec { + let mut image = create_minimal_pe(); + image[0x86..0x88].copy_from_slice(&3u16.to_le_bytes()); + let headers = 0x188; + for (i, name) in [b".linux\0\0", b".initrd\0"].iter().enumerate() { + let offset = headers + 40 * (i + 1); + image[offset..offset + 8].copy_from_slice(*name); + image[offset + 8..offset + 12].copy_from_slice(&1u32.to_le_bytes()); + image[offset + 16..offset + 20].copy_from_slice(&1u32.to_le_bytes()); + image[offset + 20..offset + 24].copy_from_slice(&0x200u32.to_le_bytes()); + } + image + } + + #[test] + fn test_aboot_kernel_detection() -> Result<()> { + for (payload, encoding, image_type) in [ + ( + android_image(), + AbootEncoding::AndroidV2, + ContainerImageType::Aboot, + ), + ( + aboot_uki(), + AbootEncoding::Uki, + ContainerImageType::AbootEfi, + ), + ] { + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir("boot")?; + root.write("boot/aboot-6.12.img", payload)?; + let kernel = find_kernel(&root)?.unwrap(); + assert_eq!(kernel.kernel.version, "6.12"); + assert_eq!(kernel.k_type.image_type(), image_type); + assert!(kernel.kernel.unified); + assert!(kernel.k_type.is_unified()); + assert!( + kernel + .k_type + .cmdline() + .unwrap() + .to_string() + .contains("quiet") + ); + let KernelType::Aboot { + path, + encoding: actual, + .. + } = kernel.k_type + else { + panic!() + }; + assert_eq!(path, "boot/aboot-6.12.img"); + assert_eq!(actual, encoding); + root.write("boot/aboot-6.13.img", android_image())?; + assert!(find_kernel(&root).is_err()); + } + Ok(()) + } + + #[test] + fn test_aboot_detection_validation() -> Result<()> { + for (files, valid) in [ + (vec![("boot/aboot-6.12.img", android_image())], true), + ( + vec![ + ("boot/aboot-6.12.img", android_image()), + ("boot/EFI/Linux/slot.addon.efi", create_minimal_pe()), + ], + true, + ), + ( + vec![ + ("boot/aboot-6.12.img", android_image()), + ("boot/loader/entries/example.conf", vec![0]), + ], + false, + ), + (vec![("boot/aboot-6.12.img", b"ANDROID!".to_vec())], false), + (vec![("boot/aboot-.img", android_image())], false), + (vec![("boot/vbmeta-6.12.img", vec![0])], false), + ( + vec![ + ("boot/aboot-6.12.img", aboot_uki()), + ("boot/vbmeta-6.12.img", vec![0]), + ], + false, + ), + ( + vec![ + ("boot/aboot-6.12.img", android_image()), + ("usr/lib/modules/6.12/vmlinuz", vec![0]), + ], + false, + ), + ] { + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + for (path, contents) in files { + let path = std::path::Path::new(path); + root.create_dir_all(path.parent().unwrap())?; + root.write(path, contents)?; + } + assert_eq!(find_kernel(&root).is_ok(), valid); + } + let root = cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir_all("usr/lib/modules/6.12")?; + root.write("usr/lib/modules/6.12/vmlinuz", b"kernel")?; + root.write("usr/lib/modules/6.12/aboot.img", b"legacy")?; + assert!(matches!( + find_kernel(&root)?.unwrap().k_type, + KernelType::Vmlinuz { .. } + )); + Ok(()) + } + #[test] fn test_find_kernel_none() -> Result<()> { let tempdir = cap_tempfile::tempdir(cap_std::ambient_authority())?; @@ -238,6 +537,8 @@ mod tests { let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel"); assert_eq!(kernel_internal.kernel.version, "6.12.0-100.fc41.x86_64"); assert!(!kernel_internal.kernel.unified); + assert!(!kernel_internal.k_type.is_unified()); + assert!(kernel_internal.k_type.cmdline().is_none()); match &kernel_internal.k_type { KernelType::Vmlinuz { path, initramfs } => { assert_eq!( @@ -249,7 +550,7 @@ mod tests { "usr/lib/modules/6.12.0-100.fc41.x86_64/initramfs.img" ); } - KernelType::Uki { .. } => panic!("Expected Vmlinuz, got Uki"), + _ => panic!("Expected Vmlinuz"), } Ok(()) } @@ -263,11 +564,12 @@ mod tests { let kernel_internal = find_kernel(&tempdir)?.expect("should find kernel"); assert_eq!(kernel_internal.kernel.version, "fedora-6.12.0"); assert!(kernel_internal.kernel.unified); + assert!(kernel_internal.k_type.is_unified()); match &kernel_internal.k_type { KernelType::Uki { path, .. } => { assert_eq!(path.as_str(), "boot/EFI/Linux/fedora-6.12.0.efi"); } - KernelType::Vmlinuz { .. } => panic!("Expected Uki, got Vmlinuz"), + _ => panic!("Expected Uki"), } Ok(()) } diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index b58ed0ef18..f40c5c785d 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -469,6 +469,9 @@ pub(crate) struct DeploymentEntry<'a> { #[derive(Debug, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct ContainerInspect { + /// The boot artifact type in the image, if present. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub(crate) image_type: Option, /// Kernel arguments embedded in the container image. pub(crate) kargs: Vec, /// Information about the kernel in the container image. diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 4525f60c71..cae628a50d 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -949,12 +949,14 @@ fn container_inspect_print_human( if let Some(kernel) = &inspect.kernel { rows.push(("Kernel", kernel.version.clone())); - let kernel_type = if kernel.unified { "UKI" } else { "vmlinuz" }; - rows.push(("Type", kernel_type.to_string())); } else { rows.push(("Kernel", "".to_string())); } + if let Some(image_type) = inspect.image_type { + rows.push(("Type", image_type.as_str().to_string())); + } + let kargs = if inspect.kargs.is_empty() { "".to_string() } else { @@ -989,8 +991,14 @@ pub(crate) fn container_inspect( )?; let kargs = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; let kargs: Vec = kargs.iter_str().map(|s| s.to_owned()).collect(); - let kernel = crate::kernel::find_kernel(&root)?.map(Into::into); - let inspect = crate::spec::ContainerInspect { kargs, kernel }; + let kernel = crate::kernel::find_kernel(&root)?; + let image_type = kernel.as_ref().map(|k| k.k_type.image_type()); + let kernel = kernel.map(Into::into); + let inspect = crate::spec::ContainerInspect { + kargs, + kernel, + image_type, + }; // Determine output format: explicit --format wins, then --json, then default to human-readable let format = format.unwrap_or(if json { @@ -1290,6 +1298,7 @@ mod tests { #[test] fn test_container_inspect_human_readable() { let inspect = crate::spec::ContainerInspect { + image_type: Some(crate::kernel::ContainerImageType::Vmlinuz), kargs: vec!["console=ttyS0".into(), "quiet".into()], kernel: Some(crate::kernel::Kernel { version: "6.12.0-100.fc41.x86_64".into(), @@ -1310,6 +1319,7 @@ mod tests { #[test] fn test_container_inspect_human_readable_uki() { let inspect = crate::spec::ContainerInspect { + image_type: Some(crate::kernel::ContainerImageType::Uki), kargs: vec![], kernel: Some(crate::kernel::Kernel { version: "6.12.0-100.fc41.x86_64".into(), @@ -1330,6 +1340,7 @@ mod tests { #[test] fn test_container_inspect_human_readable_no_kernel() { let inspect = crate::spec::ContainerInspect { + image_type: None, kargs: vec!["console=ttyS0".into()], kernel: None, }; @@ -1343,6 +1354,34 @@ mod tests { similar_asserts::assert_eq!(output, expected); } + #[test] + fn test_container_inspect_human_readable_aboot() { + for (image_type, label) in [ + (crate::kernel::ContainerImageType::Aboot, "aboot"), + (crate::kernel::ContainerImageType::AbootEfi, "aboot-efi"), + ] { + let inspect = crate::spec::ContainerInspect { + image_type: Some(image_type), + kargs: vec!["console=ttyS0".into()], + kernel: Some(crate::kernel::Kernel { + version: "6.12".into(), + unified: true, + }), + }; + let mut w = Vec::new(); + container_inspect_print_human(&inspect, &mut w).unwrap(); + let output = String::from_utf8(w).unwrap(); + similar_asserts::assert_eq!( + output, + format!("Kernel: 6.12\n Type: {label}\n Kargs: console=ttyS0\n") + ); + let json = serde_json::to_value(&inspect).unwrap(); + assert_eq!(json["type"], label); + assert_eq!(json["kernel"]["unified"], true); + assert!(json.get("aboot").is_none()); + } + } + #[test] fn test_human_readable_booted_usroverlay() { let w = diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 669876919f..64dd531fe3 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -88,8 +88,9 @@ pub(crate) async fn build_ukify( // Extract vmlinuz and initramfs paths, or bail if this is already a UKI let (vmlinuz_path, initramfs_path) = match &kernel_final.k_type { crate::kernel::KernelType::Vmlinuz { path, initramfs } => (path, initramfs), - crate::kernel::KernelType::Uki { path, .. } => { - anyhow::bail!("Cannot build UKI: rootfs already contains a UKI at {path}"); + crate::kernel::KernelType::Aboot { path, .. } + | crate::kernel::KernelType::Uki { path, .. } => { + anyhow::bail!("Cannot build UKI: rootfs already contains a boot image at {path}"); } }; @@ -229,7 +230,8 @@ mod tests { assert!(result.is_err()); let err = format!("{:#}", result.unwrap_err()); assert!( - err.contains("already contains a UKI") || err.contains("ukify executable not found"), + err.contains("already contains a boot image") + || err.contains("ukify executable not found"), "Unexpected error message: {err}" ); } diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index d43e00d53f..aeae897086 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -54,10 +54,14 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> { .as_bool() .expect("kernel.unified should be a boolean"); - let is_uki = std::env::var("BOOTC_boot_type").is_ok_and(|var| var == "uki"); + let boot_type = std::env::var("BOOTC_boot_type").unwrap_or_default(); + let is_uki = boot_type == "uki"; if let Some(variant) = std::env::var("BOOTC_variant").ok() { match (variant.as_str(), is_uki) { + ("composefs", _) if boot_type == "aboot" => { + assert!(unified, "Expected unified=true for aboot variant"); + } (v @ "ostree", _) | (v @ "composefs", false) => { assert!(!unified, "Expected unified=false for variant {v}"); // For traditional kernels, version should look like a uname (contains digits) diff --git a/docs/src/man/bootc-container-inspect.8.md b/docs/src/man/bootc-container-inspect.8.md index 034e723c96..1904148611 100644 --- a/docs/src/man/bootc-container-inspect.8.md +++ b/docs/src/man/bootc-container-inspect.8.md @@ -17,7 +17,7 @@ The command outputs a JSON object with the following fields: - `kargs`: An array of kernel arguments embedded in the container image. - `kernel`: An object containing kernel information (or `null` if no kernel is found): - `version`: The kernel version identifier. For vmlinuz kernels, this is derived from the `/usr/lib/modules/` directory name (equivalent to `uname -r`). For UKI images, this is the UKI filename without the `.efi` extension - which should usually be the same as the uname. - - `unified`: A boolean indicating whether the kernel is packaged as a UKI (Unified Kernel Image). + - `unified`: A boolean indicating whether the kernel and initramfs are packaged together, as a UKI (Unified Kernel Image) or aboot image. # OPTIONS From 335f3aa2ae4b433758514604c571b0ab25157b09 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:29:24 +0200 Subject: [PATCH 05/14] container: Extract boot artifact preparation from uki codepath This moves the code for kernel selection, composefs digest calculation, and command-line assembly so aboot builders can reuse it later. Note: This is just a code motion and no significant beheviour is changed (other than maybe some specific error message). Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/boot_artifact.rs | 209 ++++++++++++++++++++++++++++ crates/lib/src/cli.rs | 24 +--- crates/lib/src/lib.rs | 1 + crates/lib/src/ukify.rs | 233 +++----------------------------- 4 files changed, 227 insertions(+), 240 deletions(-) create mode 100644 crates/lib/src/boot_artifact.rs diff --git a/crates/lib/src/boot_artifact.rs b/crates/lib/src/boot_artifact.rs new file mode 100644 index 0000000000..05822a55ca --- /dev/null +++ b/crates/lib/src/boot_artifact.rs @@ -0,0 +1,209 @@ +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std::fs::Dir; +use composefs::erofs::format::FormatVersion; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_ctl::composefs; +use linux_kernel_cmdline::utf8::Cmdline; + +use crate::bootc_composefs::digest::compute_composefs_digest; +use crate::bootc_composefs::status::build_composefs_karg; +use crate::cli::ErofsVersionArg; +use crate::kernel::KernelType; + +fn resolve_erofs_version(requested: Option) -> FormatVersion { + requested.map(Into::into).unwrap_or(FormatVersion::V1) +} + +fn composefs_kargs_for_boot_artifact( + preferred_digest: Sha512HashValue, + preferred_version: FormatVersion, + compatibility_v2_digest: Option, + allow_missing_fsverity: bool, +) -> Vec { + let mut kargs = vec![build_composefs_karg( + preferred_digest, + preferred_version, + allow_missing_fsverity, + )]; + if let Some(v2_digest) = compatibility_v2_digest { + kargs.push(build_composefs_karg( + v2_digest, + FormatVersion::V2, + allow_missing_fsverity, + )); + } + kargs +} + +#[derive(Debug)] +pub(crate) struct BootArtifactInputs { + pub(crate) kernel_version: String, + pub(crate) vmlinuz: Utf8PathBuf, + pub(crate) initramfs: Utf8PathBuf, + pub(crate) cmdline: String, +} + +pub(crate) async fn prepare_boot_artifact( + rootfs: &Utf8Path, + kernel_dir: Option<&Utf8Path>, + extra_kargs: &[String], + allow_missing_fsverity: bool, + erofs_version: Option, + write_dumpfile_to: Option<&Utf8Path>, +) -> Result { + let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority()) + .with_context(|| format!("Opening rootfs {rootfs}"))?; + + let (kernel_version, vmlinuz, initramfs, external_paths) = match kernel_dir { + Some(kernel_dir) => { + let kver = kernel_dir + .components() + .next_back() + .ok_or_else(|| anyhow::anyhow!("Could not determine kernel version"))?; + ( + kver.to_string(), + kernel_dir.join("vmlinuz"), + kernel_dir.join("initramfs.img"), + true, + ) + } + None => { + let kernel = crate::kernel::find_kernel(&root)? + .ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?; + match kernel.k_type { + KernelType::Vmlinuz { path, initramfs } => { + (kernel.kernel.version, path, initramfs, false) + } + KernelType::Aboot { path, .. } | KernelType::Uki { path, .. } => anyhow::bail!( + "Cannot build boot artifact: rootfs already contains a boot image at {path}" + ), + } + } + }; + + if external_paths { + if !vmlinuz.exists() { + anyhow::bail!("Kernel not found at {vmlinuz}"); + } + if !initramfs.exists() { + anyhow::bail!("Initramfs not found at {initramfs}"); + } + } else { + if !root.try_exists(&vmlinuz).context("Checking for vmlinuz")? { + anyhow::bail!("Kernel not found at {vmlinuz}"); + } + if !root + .try_exists(&initramfs) + .context("Checking for initramfs")? + { + anyhow::bail!("Initramfs not found at {initramfs}"); + } + } + + let erofs_version = resolve_erofs_version(erofs_version); + let composefs_digest = + compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; + let composefs_digest = Sha512HashValue::from_hex(&composefs_digest) + .context("Parsing computed composefs digest")?; + let compatibility_v2_digest = if erofs_version == FormatVersion::V1 { + let digest = compute_composefs_digest(rootfs, FormatVersion::V2, None).await?; + Some(Sha512HashValue::from_hex(&digest).context("Parsing computed V2 digest")?) + } else { + None + }; + + let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; + for karg in composefs_kargs_for_boot_artifact( + composefs_digest, + erofs_version, + compatibility_v2_digest, + allow_missing_fsverity, + ) { + cmdline.extend(&Cmdline::from(karg)); + } + for karg in extra_kargs { + cmdline.extend(&Cmdline::from(karg)); + } + + Ok(BootArtifactInputs { + kernel_version, + vmlinuz, + initramfs, + cmdline: cmdline.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use bootc_utils::create_minimal_pe; + + use super::*; + + #[tokio::test] + async fn no_kernel() { + let tempdir = tempfile::tempdir().unwrap(); + let path = Utf8Path::from_path(tempdir.path()).unwrap(); + + let err = prepare_boot_artifact(path, None, &[], false, None, None) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("No kernel found")); + } + + #[tokio::test] + async fn rejects_uki() { + let tempdir = tempfile::tempdir().unwrap(); + let path = Utf8Path::from_path(tempdir.path()).unwrap(); + fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); + fs::write( + tempdir.path().join("boot/EFI/Linux/test.efi"), + &create_minimal_pe(), + ) + .unwrap(); + + let err = prepare_boot_artifact(path, None, &[], false, None, None) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("already contains a boot image")); + } + + #[test] + fn test_composefs_kargs_for_boot_artifact() { + let v1 = Sha512HashValue::EMPTY; + let v2 = Sha512HashValue::from_hex("aa".repeat(64)).unwrap(); + for (requested, expected_version, fallback, expected_len, expected_prefixes) in [ + ( + None, + FormatVersion::V1, + Some(v2.clone()), + 2, + ["composefs.digest=v1-sha512-12:", "composefs="], + ), + ( + Some(ErofsVersionArg::V1), + FormatVersion::V1, + Some(v2.clone()), + 2, + ["composefs.digest=v1-sha512-12:", "composefs="], + ), + ( + Some(ErofsVersionArg::V2), + FormatVersion::V2, + None, + 1, + ["composefs=", ""], + ), + ] { + let version = resolve_erofs_version(requested); + let kargs = composefs_kargs_for_boot_artifact(v1.clone(), version, fallback, false); + assert_eq!(kargs.len(), expected_len); + for (karg, prefix) in kargs.iter().zip(expected_prefixes) { + assert!(karg.starts_with(prefix), "unexpected karg: {karg}"); + } + assert_eq!(version, expected_version); + } + } +} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0a7c0e5987..d5fc8bf8bd 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -2253,33 +2253,11 @@ async fn run_from_opt(opt: Opt) -> Result { kernel_dir, args, } => { - let kernel = match kernel_dir { - Some(kernel_dir) => { - let kver = kernel_dir - .components() - .last() - .ok_or_else(|| anyhow::anyhow!("Could not determine kernel version"))?; - - Some(crate::kernel::KernelInternal { - kernel: crate::kernel::Kernel { - unified: false, - version: kver.to_string(), - }, - k_type: crate::kernel::KernelType::Vmlinuz { - path: kernel_dir.join("vmlinuz"), - initramfs: kernel_dir.join("initramfs.img"), - }, - }) - } - - None => None, - }; - crate::ukify::build_ukify( &rootfs, &kargs, &args, - kernel, + kernel_dir.as_deref(), allow_missing_verity, erofs_version, write_dumpfile_to.as_deref(), diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index edd50e347b..0610044cea 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -63,6 +63,7 @@ //! - [`linux-kernel-cmdline`](../linux_kernel_cmdline/index.html) - Cmdline parsing //! - [`etc-merge`](../etc_merge/index.html) - `/etc` three-way merge +mod boot_artifact; mod bootc_composefs; pub(crate) mod bootc_kargs; mod bootloader; diff --git a/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 64dd531fe3..4615275f79 100644 --- a/crates/lib/src/ukify.rs +++ b/crates/lib/src/ukify.rs @@ -10,64 +10,25 @@ use std::process::Command; use anyhow::{Context, Result}; use bootc_utils::CommandRunExt; use camino::Utf8Path; -use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; -use linux_kernel_cmdline::utf8::Cmdline; -use composefs::erofs::format::FormatVersion; -use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; -use composefs_ctl::composefs; - -use crate::bootc_composefs::digest::compute_composefs_digest; -use crate::bootc_composefs::status::build_composefs_karg; +use crate::boot_artifact::prepare_boot_artifact; use crate::cli::ErofsVersionArg; -use crate::kernel::KernelInternal; - -fn resolve_erofs_version(requested: Option) -> FormatVersion { - requested.map(Into::into).unwrap_or(FormatVersion::V1) -} - -fn composefs_kargs_for_uki( - preferred_digest: Sha512HashValue, - preferred_version: FormatVersion, - compatibility_v2_digest: Option, - allow_missing_fsverity: bool, -) -> Vec { - let mut kargs = vec![build_composefs_karg( - preferred_digest, - preferred_version, - allow_missing_fsverity, - )]; - if let Some(v2_digest) = compatibility_v2_digest { - kargs.push(build_composefs_karg( - v2_digest, - FormatVersion::V2, - allow_missing_fsverity, - )); - } - kargs -} /// Build a UKI from the given rootfs. /// -/// This function: -/// 1. Verifies that ukify is available -/// 2. Finds the kernel in the rootfs -/// 3. Computes the composefs digest -/// 4. Reads kernel arguments from kargs.d -/// 5. Appends any additional kargs provided via --karg -/// 6. Invokes ukify with computed arguments plus any pass-through args +/// Finds the kernel, computes the composefs digest, reads kernel arguments from +/// kargs.d, then invokes ukify with any additional pass-through arguments. #[context("Building UKI")] pub(crate) async fn build_ukify( rootfs: &Utf8Path, extra_kargs: &[String], args: &[OsString], - kernel: Option, + kernel_dir: Option<&Utf8Path>, allow_missing_fsverity: bool, erofs_version: Option, write_dumpfile_to: Option<&Utf8Path>, ) -> Result<()> { - // Warn if --karg is used (temporary workaround) if !extra_kargs.is_empty() { tracing::warn!( "The --karg flag is temporary and will be removed as soon as possible \ @@ -75,201 +36,39 @@ pub(crate) async fn build_ukify( ); } - // Open the rootfs directory - let root = Dir::open_ambient_dir(rootfs, cap_std_ext::cap_std::ambient_authority()) - .with_context(|| format!("Opening rootfs {rootfs}"))?; - - let kernel_final = match kernel { - Some(ref kernel) => kernel, - None => &crate::kernel::find_kernel(&root)? - .ok_or_else(|| anyhow::anyhow!("No kernel found in {rootfs}"))?, - }; - - // Extract vmlinuz and initramfs paths, or bail if this is already a UKI - let (vmlinuz_path, initramfs_path) = match &kernel_final.k_type { - crate::kernel::KernelType::Vmlinuz { path, initramfs } => (path, initramfs), - crate::kernel::KernelType::Aboot { path, .. } - | crate::kernel::KernelType::Uki { path, .. } => { - anyhow::bail!("Cannot build UKI: rootfs already contains a boot image at {path}"); - } - }; - - // Verify kernel and initramfs exist - // - // NOTE: Not using cap_std here as the vmlinuz/initramfs path from - // args can be outside of "rootfs" - if kernel.is_some() { - if !vmlinuz_path.exists() { - anyhow::bail!("Kernel not found at {vmlinuz_path}"); - } - - if !initramfs_path.exists() { - anyhow::bail!("Initramfs not found at {initramfs_path}"); - } - } else { - if !root - .try_exists(&vmlinuz_path) - .context("Checking for vmlinuz")? - { - anyhow::bail!("Kernel not found at {vmlinuz_path}"); - } - - if !root - .try_exists(&initramfs_path) - .context("Checking for initramfs")? - { - anyhow::bail!("Initramfs not found at {initramfs_path}"); - } - } - - let erofs_version = resolve_erofs_version(erofs_version); - if !crate::utils::have_executable("ukify")? { anyhow::bail!( "ukify executable not found in PATH. Please install systemd-ukify or equivalent." ); } - // Compute the preferred digest. With V1, retain the dumpfile behavior for - // that preferred digest and add a legacy V2 compatibility digest below. - let composefs_digest = - compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; - let composefs_digest = Sha512HashValue::from_hex(&composefs_digest) - .context("Parsing computed composefs digest")?; - let compatibility_v2_digest = if erofs_version == FormatVersion::V1 { - let digest = compute_composefs_digest(rootfs, FormatVersion::V2, None).await?; - Some(Sha512HashValue::from_hex(&digest).context("Parsing computed V2 digest")?) - } else { - None - }; - - // Get kernel arguments from kargs.d - let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; - - // Add the composefs digest, tagging the karg with the same EROFS format - // version used to compute it so it stays boot-compatible (see - // `build_composefs_karg`). - for karg in composefs_kargs_for_uki( - composefs_digest, - erofs_version, - compatibility_v2_digest, + let inputs = prepare_boot_artifact( + rootfs, + kernel_dir, + extra_kargs, allow_missing_fsverity, - ) { - cmdline.extend(&Cmdline::from(karg)); - } - - // Add any extra kargs provided via --karg - for karg in extra_kargs { - cmdline.extend(&Cmdline::from(karg)); - } - - let cmdline_str = cmdline.to_string(); + erofs_version, + write_dumpfile_to, + ) + .await?; - // Build the ukify command with cwd set to rootfs so paths can be relative let mut cmd = Command::new("ukify"); cmd.current_dir(rootfs); cmd.arg("build") .arg("--linux") - .arg(&vmlinuz_path) + .arg(&inputs.vmlinuz) .arg("--initrd") - .arg(&initramfs_path) + .arg(&inputs.initramfs) .arg("--uname") - .arg(&kernel_final.kernel.version) + .arg(&inputs.kernel_version) .arg("--cmdline") - .arg(&cmdline_str) + .arg(&inputs.cmdline) .arg("--os-release") .arg("@usr/lib/os-release"); - - // Add pass-through arguments cmd.args(args); tracing::debug!("Executing ukify: {:?}", cmd); - - // Run ukify cmd.run_inherited().context("Running ukify")?; Ok(()) } - -#[cfg(test)] -mod tests { - use bootc_utils::create_minimal_pe; - use std::fs; - - use super::*; - #[tokio::test] - async fn test_build_ukify_no_kernel() { - let tempdir = tempfile::tempdir().unwrap(); - let path = Utf8Path::from_path(tempdir.path()).unwrap(); - - let result = - build_ukify(path, &[], &[], None, false, Some(ErofsVersionArg::V2), None).await; - assert!(result.is_err()); - let err = format!("{:#}", result.unwrap_err()); - assert!( - err.contains("No kernel found") || err.contains("ukify executable not found"), - "Unexpected error message: {err}" - ); - } - - #[tokio::test] - async fn test_build_ukify_already_uki() { - let tempdir = tempfile::tempdir().unwrap(); - let path = Utf8Path::from_path(tempdir.path()).unwrap(); - - // Create a UKI structure - fs::create_dir_all(tempdir.path().join("boot/EFI/Linux")).unwrap(); - fs::write( - tempdir.path().join("boot/EFI/Linux/test.efi"), - &create_minimal_pe(), - ) - .unwrap(); - - let result = - build_ukify(path, &[], &[], None, false, Some(ErofsVersionArg::V2), None).await; - assert!(result.is_err()); - let err = format!("{:#}", result.unwrap_err()); - assert!( - err.contains("already contains a boot image") - || err.contains("ukify executable not found"), - "Unexpected error message: {err}" - ); - } - - #[test] - fn test_composefs_kargs_for_uki() { - let v1 = Sha512HashValue::EMPTY; - let v2 = Sha512HashValue::from_hex("aa".repeat(64)).unwrap(); - for (requested, expected_version, fallback, expected_len, expected_prefixes) in [ - ( - None, - FormatVersion::V1, - Some(v2.clone()), - 2, - ["composefs.digest=v1-sha512-12:", "composefs="], - ), - ( - Some(ErofsVersionArg::V1), - FormatVersion::V1, - Some(v2.clone()), - 2, - ["composefs.digest=v1-sha512-12:", "composefs="], - ), - ( - Some(ErofsVersionArg::V2), - FormatVersion::V2, - None, - 1, - ["composefs=", ""], - ), - ] { - let version = resolve_erofs_version(requested); - let kargs = composefs_kargs_for_uki(v1.clone(), version, fallback, false); - assert_eq!(kargs.len(), expected_len); - for (karg, prefix) in kargs.iter().zip(expected_prefixes) { - assert!(karg.starts_with(prefix), "unexpected karg: {karg}"); - } - assert_eq!(version, expected_version); - } - } -} From 44e5ab20edca90c76344c753fc46b191efcc5849 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 9 Oct 2026 10:59:30 +0200 Subject: [PATCH 06/14] container: Replace composefs digest kargs instead of append Kernel arguments from the image or --karg can contain stale composefs digests. Apply the computed arguments after those inputs, replacing existing values and removing duplicates so these are not duplicated, which could cause confusion. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/boot_artifact.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/lib/src/boot_artifact.rs b/crates/lib/src/boot_artifact.rs index 05822a55ca..e504734442 100644 --- a/crates/lib/src/boot_artifact.rs +++ b/crates/lib/src/boot_artifact.rs @@ -101,6 +101,11 @@ pub(crate) async fn prepare_boot_artifact( } } + let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; + for karg in extra_kargs { + cmdline.extend(&Cmdline::from(karg)); + } + let erofs_version = resolve_erofs_version(erofs_version); let composefs_digest = compute_composefs_digest(rootfs, erofs_version, write_dumpfile_to).await?; @@ -113,17 +118,15 @@ pub(crate) async fn prepare_boot_artifact( None }; - let mut cmdline = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?; for karg in composefs_kargs_for_boot_artifact( composefs_digest, erofs_version, compatibility_v2_digest, allow_missing_fsverity, ) { - cmdline.extend(&Cmdline::from(karg)); - } - for karg in extra_kargs { - cmdline.extend(&Cmdline::from(karg)); + for param in &Cmdline::from(karg) { + cmdline.add_or_modify(¶m); + } } Ok(BootArtifactInputs { From b0a215567b47aaa4c7c67f12ecace7e907fb0fd5 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:30:07 +0200 Subject: [PATCH 07/14] container: Add "container aboot" command to build aboot images This uses aboot-update to build Android boot or ukiboot images, similar to the "container ukify" command. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/aboot.rs | 115 ++++++++++++++++++++++++ crates/lib/src/cli.rs | 51 +++++++++++ crates/lib/src/lib.rs | 1 + docs/src/SUMMARY.md | 1 + docs/src/man/bootc-container-aboot.8.md | 56 ++++++++++++ docs/src/man/bootc-container.8.md | 2 +- 6 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 crates/lib/src/aboot.rs create mode 100644 docs/src/man/bootc-container-aboot.8.md diff --git a/crates/lib/src/aboot.rs b/crates/lib/src/aboot.rs new file mode 100644 index 0000000000..380e4a1c57 --- /dev/null +++ b/crates/lib/src/aboot.rs @@ -0,0 +1,115 @@ +//! Build Android boot or ukiboot images using aboot-update. + +use std::fs::File; +use std::io::BufReader; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; +use bootc_utils::CommandRunExt; +use camino::Utf8Path; +use fn_error_context::context; +use linux_kernel_cmdline::utf8::Cmdline; + +use crate::boot_artifact::prepare_boot_artifact; + +#[context("Building aboot image")] +pub(crate) async fn build_aboot( + rootfs: &Utf8Path, + kernel_dir: Option<&Utf8Path>, + out: Option<&Utf8Path>, + allow_missing_fsverity: bool, + write_dumpfile_to: Option<&Utf8Path>, +) -> Result<()> { + if !crate::utils::have_executable("aboot-update")? { + anyhow::bail!("aboot-update executable not found in PATH"); + } + + let inputs = prepare_boot_artifact( + rootfs, + kernel_dir, + &[], + allow_missing_fsverity, + None, + write_dumpfile_to, + ) + .await?; + + let mut cmd = Command::new("aboot-update"); + cmd.arg("--root") + .arg(rootfs) + .arg("--cmdline") + .arg(&inputs.cmdline); + if let Some(kernel_dir) = kernel_dir { + cmd.arg("--kernel-dir").arg(kernel_dir); + } + if let Some(out) = out { + cmd.arg("--out").arg(out); + } + cmd.arg(&inputs.kernel_version); + + tracing::debug!("Executing aboot-update: {:?}", cmd); + cmd.run_inherited().context("Running aboot-update")?; + + let output_dir = out + .map(ToOwned::to_owned) + .unwrap_or_else(|| rootfs.join("boot")); + let image_path = output_dir.join(format!("aboot-{}.img", inputs.kernel_version)); + let actual_cmdline = read_cmdline(&image_path)?; + validate_composefs_cmdline(&inputs.cmdline, &actual_cmdline) +} + +fn read_cmdline(path: &Utf8Path) -> Result { + let image = File::open(path).with_context(|| format!("Opening generated image {path}"))?; + let mut image = BufReader::new(image); + let (_, cmdline) = crate::kernel::read_aboot_metadata(&mut image) + .with_context(|| format!("Parsing generated aboot image {path}"))?; + Ok(cmdline.to_string()) +} + +fn validate_composefs_cmdline(expected: &str, actual: &str) -> Result<()> { + let composefs_args = |cmdline: &str| -> Vec { + Cmdline::from(cmdline) + .iter_str() + .filter(|arg| arg.starts_with("composefs=") || arg.starts_with("composefs.digest=")) + .map(str::to_owned) + .collect() + }; + let mut expected = composefs_args(expected); + ensure!( + !expected.is_empty(), + "Generated command line has no composefs parameter" + ); + let mut actual = composefs_args(actual); + expected.sort_unstable(); + actual.sort_unstable(); + ensure!( + actual == expected, + "Generated image has the wrong composefs parameters" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_composefs_cmdline() { + validate_composefs_cmdline("quiet composefs=?abcd", "bootconfig composefs=?abcd").unwrap(); + validate_composefs_cmdline( + "composefs.digest=v1-sha512-12:abcd composefs=efgh", + "composefs=efgh composefs.digest=v1-sha512-12:abcd", + ) + .unwrap(); + + for actual in [ + "quiet", + "composefs=efgh", + "composefs=abcd composefs=abcd", + "composefs=?abcd composefs=?abcd", + "composefs.digest=v1-sha512-12:abcd composefs=efgh composefs=efgh", + ] { + assert!(validate_composefs_cmdline("composefs=?abcd", actual).is_err()); + } + } +} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index d5fc8bf8bd..21eb0d4be1 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -533,6 +533,29 @@ pub(crate) enum ContainerOpts { #[clap(last = true)] args: Vec, }, + /// Build an Android boot or ukiboot image using aboot-update. + Aboot { + /// Operate on the provided rootfs. + #[clap(long, default_value = "/")] + rootfs: Utf8PathBuf, + + /// Make fs-verity validation optional in case the filesystem doesn't support it + #[clap(long)] + allow_missing_verity: bool, + + /// Write a dumpfile to this path + #[clap(long)] + write_dumpfile_to: Option, + + /// The directory containing vmlinuz and initramfs.img. + /// Must be of the format /parent/$kernel_version. + #[clap(long)] + kernel_dir: Option, + + /// Output directory. Defaults to $rootfs/boot. + #[clap(long)] + out: Option, + }, /// Export container filesystem as a tar archive. /// /// This command exports the container filesystem in a bootable format with proper @@ -2264,6 +2287,22 @@ async fn run_from_opt(opt: Opt) -> Result { ) .await } + ContainerOpts::Aboot { + rootfs, + allow_missing_verity, + write_dumpfile_to, + kernel_dir, + out, + } => { + crate::aboot::build_aboot( + &rootfs, + kernel_dir.as_deref(), + out.as_deref(), + allow_missing_verity, + write_dumpfile_to.as_deref(), + ) + .await + } ContainerOpts::Export { format, target, @@ -2876,6 +2915,18 @@ mod tests { "{args:?}" ); } + + assert!(matches!( + Opt::parse_including_static([ + "bootc", + "container", + "aboot", + "--out", + "/out", + ]), + Opt::Container(ContainerOpts::Aboot { out, .. }) + if out.as_deref() == Some(Utf8Path::new("/out")) + )); } #[test] diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 0610044cea..cb00f6a3ed 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -63,6 +63,7 @@ //! - [`linux-kernel-cmdline`](../linux_kernel_cmdline/index.html) - Cmdline parsing //! - [`etc-merge`](../etc_merge/index.html) - `/etc` three-way merge +mod aboot; mod boot_artifact; mod bootc_composefs; pub(crate) mod bootc_kargs; diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fe4d0ebfa2..f346e4f1fc 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -63,6 +63,7 @@ - [`man bootc-container-split-kernel-and-rootfs`](man/bootc-container-split-kernel-and-rootfs.8.md) - [`man bootc-container-ukify`](man/bootc-container-ukify.8.md) - [`man bootc-container-compute-composefs-digest`](man/bootc-container-compute-composefs-digest.8.md) +- [`man bootc-container-aboot`](man/bootc-container-aboot.8.md) - [`man bootc-container-lint`](man/bootc-container-lint.8.md) # Architecture diff --git a/docs/src/man/bootc-container-aboot.8.md b/docs/src/man/bootc-container-aboot.8.md new file mode 100644 index 0000000000..2162df475d --- /dev/null +++ b/docs/src/man/bootc-container-aboot.8.md @@ -0,0 +1,56 @@ +# NAME + +bootc-container-aboot - Build an Android boot or ukiboot image using aboot-update + +# SYNOPSIS + +bootc container aboot [OPTIONS] + +# DESCRIPTION + +Build an Android boot image or ukiboot UKI from a container root filesystem using +`aboot-update`. The image type and configuration details depends on the `aboot.cfg`. This +image includes kernel, initrd, cmdline, and (optionally) dtb. + +The image is written to `aboot-.img` in the output directory. By default, +that directory is `/boot` within the supplied root filesystem. `aboot-update` must be +available in the build environment. + +# OPTIONS + + +**--rootfs**=*ROOTFS* + + Operate on the provided rootfs + + Default: / + +**--allow-missing-verity** + + Make fs-verity validation optional in case the filesystem doesn't support it + +**--write-dumpfile-to**=*WRITE_DUMPFILE_TO* + + Write a dumpfile to this path + +**--kernel-dir**=*KERNEL_DIR* + + The directory containing vmlinuz and initramfs.img. Must be of the format /parent/$kernel_version + +**--out**=*OUT* + + Output directory. Defaults to $rootfs/boot + + + +# EXAMPLES + + bootc container aboot --rootfs /target --kernel-dir /kernel/KVER --out /out + +# SEE ALSO + +**bootc**(8), **bootc-container-ukify**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-container.8.md b/docs/src/man/bootc-container.8.md index 8f54ccc5b6..f5372f4aaa 100644 --- a/docs/src/man/bootc-container.8.md +++ b/docs/src/man/bootc-container.8.md @@ -24,10 +24,10 @@ Operations which can be executed as part of a container build | **bootc container compute-composefs-digest** | Output the bootable composefs digest for a directory | | **bootc container split-kernel-and-rootfs** | Split kernel and rootfs from a container image | | **bootc container ukify** | Build a Unified Kernel Image (UKI) using ukify | +| **bootc container aboot** | Build an Android boot or ukiboot image using aboot-update | # VERSION - From de18835dcbfd2f6511eb7e9a90f05f52cbdc17aa Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:31:02 +0200 Subject: [PATCH 08/14] install: Break out helper function from setup_composefs_boot() This just moves some code out, which will make later aboot changes easier to do. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 98 ++++++++++++++------------ 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 0ab514ba24..ce2419c29b 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2099,53 +2099,12 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } -#[context("Setting up composefs boot")] -pub(crate) async fn setup_composefs_boot( +fn install_composefs_bootloader( root_setup: &RootSetup, state: &State, - pull_result: &composefs_oci::PullResult, - allow_missing_fsverity: bool, + postfetch: &PostFetchState, + mounted_root: &MountedImageRoot, ) -> Result<()> { - const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; - - tracing::info!( - message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID, - bootc.operation = "boot_setup", - bootc.config_digest = %pull_result.config_digest, - bootc.allow_missing_fsverity = allow_missing_fsverity, - "Setting up composefs boot", - ); - - let mut repo = open_composefs_repo(&root_setup.physical_root)?; - if allow_missing_fsverity { - repo.set_insecure(); - } - - let repo = Arc::new(repo); - - let crate::bootc_composefs::repo::BootImage { - id, - boot_ids, - fs, - entries, - } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; - - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; - - let boot_type = BootType::from(entry); - if boot_type == BootType::Aboot { - bail!("aboot boot setup is not implemented"); - } - - let composefs_mnt_fd = repo - .mount(&id.to_hex()) - .context("Failed to mount composefs image")?; - let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; - - let postfetch = PostFetchState::new(state, mounted_root.dir())?; - let boot_uuid = root_setup .get_boot_uuid()? .or(root_setup.rootfs_uuid.as_deref()) @@ -2231,6 +2190,57 @@ pub(crate) async fn setup_composefs_boot( ) })?; } + Ok(()) +} + +#[context("Setting up composefs boot")] +pub(crate) async fn setup_composefs_boot( + root_setup: &RootSetup, + state: &State, + pull_result: &composefs_oci::PullResult, + allow_missing_fsverity: bool, +) -> Result<()> { + const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; + + tracing::info!( + message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID, + bootc.operation = "boot_setup", + bootc.config_digest = %pull_result.config_digest, + bootc.allow_missing_fsverity = allow_missing_fsverity, + "Setting up composefs boot", + ); + + let mut repo = open_composefs_repo(&root_setup.physical_root)?; + if allow_missing_fsverity { + repo.set_insecure(); + } + + let repo = Arc::new(repo); + + let crate::bootc_composefs::repo::BootImage { + id, + boot_ids, + fs, + entries, + } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; + + let Some(entry) = entries.iter().next() else { + anyhow::bail!("No boot entries!"); + }; + + let boot_type = BootType::from(entry); + if boot_type == BootType::Aboot { + bail!("aboot boot setup is not implemented"); + } + + let composefs_mnt_fd = repo + .mount(&id.to_hex()) + .context("Failed to mount composefs image")?; + let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; + + let postfetch = PostFetchState::new(state, mounted_root.dir())?; + + install_composefs_bootloader(root_setup, state, &postfetch, &mounted_root)?; let repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!( From 9ba4dd101c5b5dda65584170efbb53c6ed55c9c5 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:32:32 +0200 Subject: [PATCH 09/14] composefs: Install aboot bootloader Pick (or allow selecting) the right bootloader for aboot images, and install EFI files needed in case of ukiboot. Command lines in immutable images are validated. This changes how bootloader "none" is handled in the composefs backend. Before it was not supported, but now it's is used for android boot aboot images, where the bootloader is not on disk but in the board. We also need to be more careful when picking the primary boot entry, becase discover enumerates Type2 entries before the aboot, but we might have non-bootable Type2 entries (like addons), and we don't want those to cover an aboot entry. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 497 +++++++++++++++--- crates/lib/src/bootc_composefs/repo.rs | 31 +- crates/lib/src/bootc_composefs/status.rs | 4 +- crates/lib/src/install.rs | 99 ++-- crates/lib/src/spec.rs | 8 +- docs/src/host-v1.schema.json | 5 + docs/src/man/bootc-install-to-disk.8.md | 1 + .../man/bootc-install-to-existing-root.8.md | 1 + docs/src/man/bootc-install-to-filesystem.8.md | 1 + 9 files changed, 526 insertions(+), 121 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index ce2419c29b..3b973baaf4 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -63,12 +63,12 @@ use std::cell::Cell; use std::fs::create_dir_all; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; use std::os::fd::AsFd; use std::path::Path; use std::sync::Arc; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use bootc_mount::tempmount::TempMount; use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::{ @@ -79,14 +79,17 @@ use clap::ValueEnum; use composefs::erofs::format::FormatVersion; use composefs::fs::read_file; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; -use composefs::tree::{FileSystem, RegularFile}; +use composefs::tree::{FileSystem, ImageError, RegularFile}; use composefs_boot::bootloader::{ - BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType, - UsrLibModulesVmlinuz, + AbootEncoding, AbootEntry, BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, + EFI_ADDON_FILE_EXT, EFI_EXT, PEType, UsrLibModulesVmlinuz, }; use composefs_boot::cmdline::{KARG_COMPOSEFS_DIGEST, KARG_V2}; use composefs_boot::{ - cmdline::ComposefsCmdline as ComposefsBootCmdline, os_release::OsReleaseInfo, uki, + android_boot::{AndroidBootError, AndroidBootImage, Component}, + cmdline::ComposefsCmdline as ComposefsBootCmdline, + os_release::OsReleaseInfo, + uki, }; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; @@ -645,6 +648,24 @@ pub(crate) fn compute_boot_digest_uki(uki_reader: &mut R) -> Res Ok(hex::encode(digest)) } +#[context("Computing Android boot digest")] +pub(crate) fn compute_boot_digest_aboot( + reader: &mut R, + metadata: &AndroidBootImage, +) -> Result { + let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256()) + .context("Creating hasher")?; + for component in [Component::Kernel, Component::Ramdisk] { + hasher.update(&metadata.component(reader, component)?)?; + } + match metadata.component(reader, Component::Dtb) { + Ok(dtb) => hasher.update(&dtb)?, + Err(AndroidBootError::MissingComponent("dtb")) => {} + Err(error) => return Err(error.into()), + } + Ok(hex::encode(hasher.finish()?)) +} + /// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum /// /// # Returns @@ -1167,13 +1188,13 @@ fn parse_uki_composefs_candidates(cmdline: &str) -> Result Result { let first = candidates .first() - .ok_or_else(|| anyhow!("No composefs digest in UKI cmdline"))? + .ok_or_else(|| anyhow!("No composefs digest in boot artifact cmdline"))? .insecure; anyhow::ensure!( candidates .iter() .all(|candidate| candidate.insecure == first), - "UKI composefs candidates have mixed fs-verity policies" + "Boot artifact composefs candidates have mixed fs-verity policies" ); Ok(first) } @@ -1414,41 +1435,40 @@ fn uki_file_name(file_path: &Path) -> Result { .ok_or_else(|| anyhow!("Could not get UKI file name from {file_path}")) } -/// Inspect primary UKIs to discover their requested fs-verity policy before -/// creating the durable repository. Final artifact validation occurs while -/// writing each UKI to the ESP. -pub(crate) fn uki_fsverity_policy( +/// Inspect primary boot artifacts to discover the image's fs-verity policy +/// before creating the durable repository. Final artifact validation occurs +/// while installing the boot artifact. +pub(crate) fn image_fsverity_policy( repo: &crate::store::ComposefsRepository, entries: &[ComposefsBootEntry], ) -> Result> { let mut policy = None; for entry in entries { - let ComposefsBootEntry::Type2(entry) = entry else { - continue; - }; - if !matches!(entry.pe_type, PEType::Uki) { - continue; - } - let mut reader = match &entry.file { - RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { - std::fs::File::from(repo.open_object(id)?) + let cmdline = match entry { + ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => { + let mut reader = match &entry.file { + RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { + std::fs::File::from(repo.open_object(id)?) + } + RegularFile::Inline(..) | RegularFile::Sparse(..) => { + anyhow::bail!("UKI file is not a regular external object") + } + }; + uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")? } - RegularFile::Inline(..) | RegularFile::Sparse(..) => { - anyhow::bail!("UKI file is not a regular external object") + ComposefsBootEntry::Aboot(entry) => { + let payload = read_aboot_payload(entry, repo)?; + compute_aboot_boot_digest(entry.encoding, &payload)?.0 } + _ => continue, }; - let cmdline = uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")?; - let candidates = parse_uki_composefs_candidates(&cmdline).with_context(|| { - format!( - "Parsing composefs kernel arguments in UKI {}", - entry.file_path.display() - ) - })?; + let candidates = parse_uki_composefs_candidates(&cmdline) + .context("Parsing composefs kernel arguments in boot artifact")?; let current = uki_candidates_policy(&candidates)?; if let Some(previous) = policy { anyhow::ensure!( previous == current, - "Primary UKIs request conflicting composefs fs-verity policies" + "Primary boot artifacts request conflicting composefs fs-verity policies" ); } else { policy = Some(current); @@ -1457,42 +1477,50 @@ pub(crate) fn uki_fsverity_policy( Ok(policy) } -/// The composefs digests embedded in the primary UKI's kernel cmdline, used to +/// The composefs digests embedded in the primary boot artifact, used to /// reconcile the freshly-generated boot image digest before it is mounted. struct ExpectedComposefsDigest { candidates: Vec, uki_name: String, } -/// Scans `entries` for the primary UKI (`PEType::Uki`, not an addon) and +/// Scans `entries` for the primary UKI or aboot payload and /// extracts the composefs digests embedded in its kernel cmdline. /// -/// Returns `Ok(None)` if there is no UKI entry (e.g. a BLS-only boot setup) — +/// Returns `Ok(None)` for a BLS-only boot setup — /// there's nothing to validate against in that case. fn find_expected_composefs_digest( repo: &crate::store::ComposefsRepository, entries: &[ComposefsBootEntry], ) -> Result> { for entry in entries { - let ComposefsBootEntry::Type2(entry) = entry else { - continue; - }; - if !matches!(entry.pe_type, PEType::Uki) { - continue; - } - let mut uki_reader = match &entry.file { - RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { - std::fs::File::from(repo.open_object(id)?) + let (cmdline, uki_name) = match entry { + ComposefsBootEntry::Aboot(entry) => { + let payload = read_aboot_payload(entry, repo)?; + ( + compute_aboot_boot_digest(entry.encoding, &payload)?.0, + entry.payload.path.display().to_string(), + ) } - RegularFile::Inline(..) | RegularFile::Sparse(..) => { - anyhow::bail!("UKI file is not a regular external object") + ComposefsBootEntry::Type2(entry) if matches!(entry.pe_type, PEType::Uki) => { + let mut reader = match &entry.file { + RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { + std::fs::File::from(repo.open_object(id)?) + } + RegularFile::Inline(..) | RegularFile::Sparse(..) => { + anyhow::bail!("UKI file is not a regular external object") + } + }; + ( + uki::get_cmdline_buffered(&mut reader).context("Getting UKI cmdline")?, + uki_file_name(&entry.file_path)?, + ) } + _ => continue, }; - let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; let candidates = parse_uki_composefs_candidates(&cmdline) .context("Parsing composefs kernel arguments")?; uki_candidates_policy(&candidates)?; - let uki_name = uki_file_name(&entry.file_path)?; return Ok(Some(ExpectedComposefsDigest { candidates, uki_name, @@ -1509,7 +1537,7 @@ fn find_expected_composefs_digest( /// This handles images built with older (or newer) composefs-rs tooling /// that computed their embedded UKI digest using a different default xattr /// filtering mode or EROFS format than the one bootc's own build used. -#[context("Verifying composefs digest against UKI")] +#[context("Verifying composefs digest against boot artifact")] pub(crate) fn ensure_correct_composefs_digest( repo: &Arc, manifest_digest: &composefs_oci::OciDigest, @@ -1517,10 +1545,10 @@ pub(crate) fn ensure_correct_composefs_digest( expected_ids: ExpectedBootImageIds, entries: &[ComposefsBootEntry], ) -> Result { - let Some(expected) = - find_expected_composefs_digest(repo, entries).context("Checking UKI composefs digest")? + let Some(expected) = find_expected_composefs_digest(repo, entries) + .context("Checking boot artifact composefs digest")? else { - // No UKI (e.g. a BLS-only setup); nothing to cross-check. + // BLS-only setup; nothing to cross-check. return Ok(RecoveredBootImage { id: computed_id, expected_ids, @@ -1789,7 +1817,7 @@ pub(crate) fn setup_composefs_uki_boot( ) -> Result<(String, Sha512HashValue)> { let (esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { - state.require_no_kargs_for_uki()?; + state.require_no_kargs_for_immutable_boot()?; // Locate ESP partition device by walking up to the root disk(s) // @@ -2099,12 +2127,183 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } +fn read_aboot_payload( + entry: &AbootEntry, + repo: &ComposefsRepository, +) -> Result> { + read_file(&entry.payload.file, repo).context("Reading aboot payload") +} + +fn compute_aboot_boot_digest(encoding: AbootEncoding, payload: &[u8]) -> Result<(String, String)> { + let mut image = Cursor::new(payload); + match encoding { + AbootEncoding::Uki => { + let cmdline = uki::get_cmdline(payload) + .context("Getting UKI command line")? + .to_string(); + let digest = compute_boot_digest_uki(&mut image)?; + Ok((cmdline, digest)) + } + AbootEncoding::AndroidV2 => { + let metadata = AndroidBootImage::parse(&mut image)?; + let cmdline = metadata.cmdline()?.to_string(); + let digest = compute_boot_digest_aboot(&mut image, &metadata)?; + Ok((cmdline, digest)) + } + } +} + +fn install_ukiboot( + root: &Dir, + source: &FileSystem, + repo: &ComposefsRepository, + esp: &Dir, +) -> Result<()> { + let (efi_arch, fallback_name) = match std::env::consts::ARCH { + "x86_64" => ("x64", "BOOTX64.EFI"), + "aarch64" => ("aa64", "BOOTAA64.EFI"), + arch => bail!("ukiboot is not supported on {arch}"), + }; + let (vendor, ..) = + parse_os_release(root)?.ok_or_else(|| anyhow!("Failed to parse os-release"))?; + let get_file = |path: &str| { + source + .as_dir() + .split_ref(path.as_ref()) + .and_then(|(dir, name)| dir.get_file(name)) + }; + + for (vendor_path, fallback_path, packaged_name) in [ + ( + format!("ukiboot{efi_arch}.efi"), + fallback_name.to_string(), + format!("ukiboot{efi_arch}.efi"), + ), + ( + "ukiboot_a.efi.extra.d/slot_a.addon.efi".into(), + "ukiboot_a.efi.extra.d/slot_a.addon.efi".into(), + "slot_a.addon.efi".into(), + ), + ( + "ukiboot_b.efi.extra.d/slot_b.addon.efi".into(), + "ukiboot_b.efi.extra.d/slot_b.addon.efi".into(), + "slot_b.addon.efi".into(), + ), + ] { + // We prefer files in /boot, because they can be signed without affecting the boot-transformed cfs digest + let preferred = format!("boot/efi/EFI/{vendor}/{vendor_path}"); + let fallback = format!("usr/libexec/ukiboot/efi/{packaged_name}"); + let file = match get_file(&preferred) { + Err(ImageError::NotFound(..)) => get_file(&fallback), + result => result, + } + .with_context(|| format!("Finding {preferred} or {fallback}"))?; + let data = read_file(file, repo).context("Reading ukiboot EFI file")?; + for target in [ + format!("EFI/{vendor}/{vendor_path}"), + format!("EFI/BOOT/{fallback_path}"), + ] { + let target = Utf8Path::new(&target); + if let Some(parent) = target.parent() { + esp.create_dir_all(parent)?; + } + esp.atomic_write(target, &data) + .with_context(|| format!("Writing ukiboot EFI file to ESP at {target}"))?; + } + } + Ok(()) +} + +fn validate_bootloader( + entry: &ComposefsBootEntry, + bootloader: Bootloader, +) -> Result<()> { + if let ComposefsBootEntry::Aboot(entry) = entry { + match (entry.encoding, bootloader) { + (AbootEncoding::AndroidV2, Bootloader::None) => {} + (AbootEncoding::Uki, Bootloader::Ukiboot) => {} + (encoding, bootloader) => { + bail!("aboot encoding {encoding:?} cannot be used with bootloader {bootloader}") + } + } + } else { + ensure!( + bootloader != Bootloader::None, + "Bootloader set to none is not supported with the composefs backend unless using an aboot artifact" + ); + ensure!( + bootloader != Bootloader::Ukiboot, + "Bootloader ukiboot requires an aboot artifact with the composefs backend" + ); + } + Ok(()) +} + +/// Pick the first real bootable entry, skipping UKI addons. +/// +/// Addons can precede primary artifacts in discovery order, including aboot +/// payloads. They must not determine the boot type or installation path. +pub(crate) fn primary_boot_entry( + entries: &[ComposefsBootEntry], +) -> Result<&ComposefsBootEntry> { + entries.iter().find(|entry| !matches!(entry, ComposefsBootEntry::Type2(e) if !matches!(e.pe_type, PEType::Uki))) + .ok_or_else(|| anyhow!("No primary boot artifact")) +} + +fn image_bootloader(entry: &ComposefsBootEntry) -> Option { + match entry { + ComposefsBootEntry::Aboot(entry) => Some(match entry.encoding { + AbootEncoding::AndroidV2 => Bootloader::None, + AbootEncoding::Uki => Bootloader::Ukiboot, + }), + _ => None, + } +} + +fn setup_composefs_aboot_boot( + repo: &ComposefsRepository, + id: &Sha512HashValue, + entry: &ComposefsBootEntry, + allow_missing_fsverity: bool, +) -> Result { + let ComposefsBootEntry::Aboot(entry) = entry else { + bail!("Expected an aboot boot entry") + }; + let payload = read_aboot_payload(entry, repo)?; + let (cmdline, boot_digest) = compute_aboot_boot_digest(entry.encoding, &payload)?; + let candidates = parse_uki_composefs_candidates(&cmdline)?; + let insecure = uki_candidates_policy(&candidates)?; + ensure!( + primary_uki_candidate(&candidates).digest == *id, + "Aboot composefs digest does not match the deployment" + ); + match (allow_missing_fsverity, insecure) { + (true, false) => tracing::warn!( + "--allow-missing-fsverity was requested but the aboot image requires fs-verity" + ), + (false, true) => tracing::warn!("aboot image permits booting without fs-verity"), + _ => {} + } + + Ok(boot_digest) +} + fn install_composefs_bootloader( root_setup: &RootSetup, state: &State, - postfetch: &PostFetchState, - mounted_root: &MountedImageRoot, + bootloader: Bootloader, + composefs_mnt_fd: std::os::fd::OwnedFd, + repo: &ComposefsRepository, + fs: &FileSystem, ) -> Result<()> { + if bootloader == Bootloader::None { + return Ok(()); + } + let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; + + if bootloader == Bootloader::Ukiboot { + return mounted_root.with_esp(|esp| install_ukiboot(mounted_root.dir(), fs, repo, esp)); + } let boot_uuid = root_setup .get_boot_uuid()? .or(root_setup.rootfs_uuid.as_deref()) @@ -2116,10 +2315,7 @@ fn install_composefs_bootloader( &root_setup.device_info.require_single_root()?, boot_uuid, )?; - } else if matches!( - postfetch.detected_bootloader, - Bootloader::Grub | Bootloader::GrubCC - ) { + } else if matches!(bootloader, Bootloader::Grub | Bootloader::GrubCC) { let chroot_target = Utf8Path::from_path(mounted_root.root_path()) .ok_or_else(|| anyhow!("composefs tmpdir path is not valid UTF-8"))?; // Like the ostree backend, bind the physical root's real /boot (an @@ -2139,7 +2335,7 @@ fn install_composefs_bootloader( )?; // FIXME: Remove this hack once we have support in bootupd - if matches!(postfetch.detected_bootloader, Bootloader::GrubCC) { + if matches!(bootloader, Bootloader::GrubCC) { // bootupctl wrote this under the physical root's real /boot (via // the bind mount above), not under the composefs root. root_setup @@ -2223,24 +2419,29 @@ pub(crate) async fn setup_composefs_boot( fs, entries, } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; - - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; - + let entry = primary_boot_entry(&entries)?; let boot_type = BootType::from(entry); - if boot_type == BootType::Aboot { - bail!("aboot boot setup is not implemented"); - } let composefs_mnt_fd = repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?; - let mounted_root = MountedImageRoot::new(composefs_mnt_fd, &root_setup.device_info)?; + let image_root = Dir::reopen_dir(&composefs_mnt_fd)?; - let postfetch = PostFetchState::new(state, mounted_root.dir())?; + let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?; - install_composefs_bootloader(root_setup, state, &postfetch, &mounted_root)?; + validate_bootloader(entry, postfetch.detected_bootloader)?; + if matches!(boot_type, BootType::Uki | BootType::Aboot) { + state.require_no_kargs_for_immutable_boot()?; + } + + install_composefs_bootloader( + root_setup, + state, + postfetch.detected_bootloader, + composefs_mnt_fd, + &repo, + &fs, + )?; let repo = Arc::try_unwrap(repo).map_err(|_| { anyhow::anyhow!( @@ -2257,7 +2458,7 @@ pub(crate) async fn setup_composefs_boot( &provisional_deploy_id, provisional_format, entry, - mounted_root.dir(), + &image_root, )?, provisional_deploy_id, ), @@ -2272,7 +2473,10 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, - BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), + BootType::Aboot => ( + setup_composefs_aboot_boot(&repo, &id, entry, allow_missing_fsverity)?, + provisional_deploy_id, + ), }; write_composefs_state( @@ -2293,7 +2497,7 @@ pub(crate) async fn setup_composefs_boot( .open_dir(format!("{STATE_DIR_RELATIVE}/{}", deploy_id.to_hex())) .context("Opening deployment state")?; crate::install::osconfig::inject_root_ssh_authorized_keys( - mounted_root.dir(), + &image_root, &deployment, state.load_policy()?.as_ref(), contents, @@ -2328,6 +2532,7 @@ pub(crate) fn expected_boot_image_ids( mod tests { use super::*; use composefs::erofs::format::FormatVersion; + use composefs_boot::bootloader::{AbootArtifact, AbootEncoding, AbootEntry, Type2Entry}; #[test] fn test_grub_bls_abs_entries_path() -> Result<()> { @@ -2360,6 +2565,152 @@ mod tests { assert!(rendered.contains("root=UUID=abc")); } + #[tokio::test] + async fn test_install_ukiboot() -> Result<()> { + use composefs::fs::read_filesystem; + use composefs::repository::RepositoryConfig; + + let temp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure(); + let (repo, _) = ComposefsRepository::init_path(&*temp, "repo", config)?; + let repo = Arc::new(repo); + let root = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + root.create_dir_all("usr/lib")?; + root.write("usr/lib/os-release", "ID=testos\n")?; + + let (loader, fallback) = match std::env::consts::ARCH { + "x86_64" => ("ukibootx64.efi", "BOOTX64.EFI"), + "aarch64" => ("ukibootaa64.efi", "BOOTAA64.EFI"), + _ => return Ok(()), + }; + let files = [ + (loader, loader, fallback), + ( + "slot_a.addon.efi", + "ukiboot_a.efi.extra.d/slot_a.addon.efi", + "ukiboot_a.efi.extra.d/slot_a.addon.efi", + ), + ( + "slot_b.addon.efi", + "ukiboot_b.efi.extra.d/slot_b.addon.efi", + "ukiboot_b.efi.extra.d/slot_b.addon.efi", + ), + ]; + for signed_mask in 0..8 { + let source = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + let esp = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?; + source.create_dir_all("usr/libexec/ukiboot/efi")?; + for (index, (name, vendor_path, _)) in files.iter().enumerate() { + source.write( + format!("usr/libexec/ukiboot/efi/{name}"), + format!("unsigned-{name}").repeat(if index == 0 { 512 } else { 1 }), + )?; + if signed_mask & (1 << index) != 0 { + let path = format!("boot/efi/EFI/testos/{vendor_path}"); + let path = Utf8Path::new(&path); + source.create_dir_all(path.parent().unwrap())?; + source.write( + path, + format!("signed-{name}").repeat(if index == 0 { 512 } else { 1 }), + )?; + } + } + let fs = read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone())) + .await?; + install_ukiboot(&root, &fs, &repo, &esp)?; + for (index, (name, vendor_path, fallback_path)) in files.iter().enumerate() { + let expected = if signed_mask & (1 << index) != 0 { + format!("signed-{name}") + } else { + format!("unsigned-{name}") + } + .repeat(if index == 0 { 512 } else { 1 }); + for path in [ + format!("EFI/testos/{vendor_path}"), + format!("EFI/BOOT/{fallback_path}"), + ] { + assert_eq!(esp.read_to_string(path)?, expected); + } + } + + let preferred = format!("boot/efi/EFI/testos/{loader}"); + source.remove_file_optional(&preferred)?; + source.create_dir_all(&preferred)?; + let fs = read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone())) + .await?; + assert!(install_ukiboot(&root, &fs, &repo, &esp).is_err()); + source.remove_dir(&preferred)?; + source.remove_file(format!("usr/libexec/ukiboot/efi/{loader}"))?; + let fs = read_filesystem(source.reopen_as_ownedfd()?, ".".into(), Some(repo.clone())) + .await?; + assert!(install_ukiboot(&root, &fs, &repo, &esp).is_err()); + } + Ok(()) + } + + fn fake_aboot_entry() -> ComposefsBootEntry { + ComposefsBootEntry::Aboot(AbootEntry { + kver: "1.0".into(), + encoding: AbootEncoding::AndroidV2, + payload: AbootArtifact { + path: "/boot/aboot-1.0.img".into(), + file: RegularFile::External(Sha512HashValue::EMPTY, 4096), + }, + vbmeta: None, + }) + } + + #[test] + fn test_validate_bootloader() { + let bls = ComposefsBootEntry::UsrLibModulesVmLinuz(UsrLibModulesVmlinuz { + kver: "1.0".into(), + vmlinuz: RegularFile::Inline(Default::default()), + initramfs: Some(RegularFile::Inline(Default::default())), + os_release: None, + }); + let android = fake_aboot_entry(); + let mut ukiboot = fake_aboot_entry(); + if let ComposefsBootEntry::Aboot(entry) = &mut ukiboot { + entry.encoding = AbootEncoding::Uki; + } + let uki = ComposefsBootEntry::Type2(Type2Entry { + kver: None, + file_path: "test.efi".into(), + file: RegularFile::Inline(Default::default()), + pe_type: PEType::Uki, + }); + assert_eq!(image_bootloader(&android), Some(Bootloader::None)); + assert_eq!(image_bootloader(&ukiboot), Some(Bootloader::Ukiboot)); + assert_eq!(image_bootloader(&uki), None); + assert_eq!(image_bootloader(&bls), None); + for bootloader in [ + Bootloader::None, + Bootloader::Ukiboot, + Bootloader::Grub, + Bootloader::GrubCC, + Bootloader::Systemd, + ] { + for (entry, expected) in [ + (&android, bootloader == Bootloader::None), + (&ukiboot, bootloader == Bootloader::Ukiboot), + ( + &uki, + !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot), + ), + ( + &bls, + !matches!(bootloader, Bootloader::None | Bootloader::Ukiboot), + ), + ] { + assert_eq!( + validate_bootloader(entry, bootloader).is_ok(), + expected, + "{entry:?}, {bootloader:?}" + ); + } + } + } + #[test] fn test_pe_output_dir() { let mounted_efi = Path::new("/esp"); diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 4fe7809567..ec90ae7144 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -117,8 +117,11 @@ pub(crate) struct InitializedComposefs { pub(crate) repository_insecure: bool, } -pub(crate) fn final_repository_policy(uki_policy: Option, requested_relaxed: bool) -> bool { - uki_policy == Some(true) || (uki_policy.is_none() && requested_relaxed) +pub(crate) fn final_repository_policy( + image_fsverity_policy: Option, + requested_relaxed: bool, +) -> bool { + image_fsverity_policy == Some(true) || (image_fsverity_policy.is_none() && requested_relaxed) } /// Enforce the durable repository policy after inspecting the imported image. @@ -136,7 +139,7 @@ pub(crate) fn validate_repository_policy( } if !repository_insecure && requested_relaxed && !allow_missing_verity_explicit { anyhow::bail!( - "Initial insecure UKI conflicts with the existing strict composefs repository; explicitly pass --allow-missing-verity to permit this session" + "Initial insecure boot artifact conflicts with the existing strict composefs repository; explicitly pass --allow-missing-verity to permit this session" ); } Ok(()) @@ -375,10 +378,10 @@ pub(crate) fn prepare_boot_image( }) } -/// Inspect UKI policy without generating or recovering a boot image. Boot -/// image generation is deferred until the durable repository policy has been -/// finalized and the normal boot setup path runs. -pub(crate) fn inspect_uki_policy( +/// Inspect the image's fs-verity policy without generating or recovering a boot +/// image. Boot image generation is deferred until the durable repository policy +/// has been finalized and the normal boot setup path runs. +pub(crate) fn inspect_image_fsverity_policy( repo: &Arc, pull_result: &PullResult, ) -> Result> { @@ -388,9 +391,9 @@ pub(crate) fn inspect_uki_policy( Some(&pull_result.config_verity), &composefs_oci::OciTransformOptions::default(), ) - .context("Creating composefs filesystem for UKI policy inspection")?; + .context("Creating composefs filesystem for image fs-verity policy inspection")?; let entries = get_boot_resources(&fs, &**repo).context("Extracting boot entries")?; - crate::bootc_composefs::boot::uki_fsverity_policy(repo, &entries) + crate::bootc_composefs::boot::image_fsverity_policy(repo, &entries) } /// Pull an image directly into the composefs repository via skopeo. @@ -752,7 +755,7 @@ mod tests { #[test] fn test_final_repository_policy_matrix() { - for (uki_policy, requested_relaxed, expected) in [ + for (image_fsverity_policy, requested_relaxed, expected) in [ (Some(false), false, false), (Some(false), true, false), (Some(true), false, true), @@ -761,7 +764,7 @@ mod tests { (None, true, true), ] { assert_eq!( - final_repository_policy(uki_policy, requested_relaxed), + final_repository_policy(image_fsverity_policy, requested_relaxed), expected ); } @@ -769,7 +772,7 @@ mod tests { #[test] fn test_existing_repository_policy_matrix() { - for (insecure, uki_policy, requested, explicit, expected) in [ + for (insecure, image_fsverity_policy, requested, explicit, expected) in [ (false, Some(false), false, false, true), (false, Some(true), true, false, false), (false, Some(true), true, true, true), @@ -781,11 +784,11 @@ mod tests { (true, None, false, false, false), (true, None, true, false, true), ] { - let requested_relaxed = final_repository_policy(uki_policy, requested); + let requested_relaxed = final_repository_policy(image_fsverity_policy, requested); assert_eq!( validate_repository_policy(insecure, requested_relaxed, explicit).is_ok(), expected, - "insecure={insecure} uki_policy={uki_policy:?} requested={requested} explicit={explicit}" + "insecure={insecure} image_fsverity_policy={image_fsverity_policy:?} requested={requested} explicit={explicit}" ); } } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 28e80af5a9..a2882bf8b7 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -333,8 +333,8 @@ fn get_sorted_type1_boot_entries_helper( // For grub and grub-cc, sort by filename in descending order // See: https://github.com/bootc-dev/bootc/issues/2221 Bootloader::Grub | Bootloader::GrubCC => b.filename.cmp(&a.filename), - Bootloader::None => { - unreachable!("Bootloader checked during installation should not have been none") + Bootloader::Ukiboot | Bootloader::None => { + unreachable!("Bootloader checked during installation does not use BLS entries") } }; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 10dec8cbf8..dd85fc7af0 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -426,10 +426,6 @@ impl InstallComposefsOpts { stateroot: Option<&str>, ) -> Result<()> { if self.composefs_backend { - anyhow::ensure!( - !matches!(bootloader, Some(Bootloader::None)), - "Bootloader set to none is not supported with the composefs backend" - ); let default = ostree_container::deploy::STATEROOT_DEFAULT; if let Some(stateroot) = stateroot.filter(|&s| s != default) { anyhow::bail!( @@ -437,6 +433,10 @@ impl InstallComposefsOpts { ); } } else { + anyhow::ensure!( + !matches!(bootloader, Some(Bootloader::Ukiboot)), + "ukiboot requires the composefs backend" + ); anyhow::ensure!( !self.allow_missing_verity, "--allow-missing-verity requires the composefs backend" @@ -772,8 +772,8 @@ impl State { Ok(()) } - /// Return an error if kernel arguments are provided, intended to be used for UKI paths - pub(crate) fn require_no_kargs_for_uki(&self) -> Result<()> { + /// Return an error if kernel arguments are provided, for prebuilt boot artifacts + pub(crate) fn require_no_kargs_for_immutable_boot(&self) -> Result<()> { if self .config_opts .karg @@ -781,7 +781,9 @@ impl State { .map(|v| !v.is_empty()) .unwrap_or_default() { - anyhow::bail!("Cannot use externally specified kernel arguments with UKI"); + anyhow::bail!( + "Cannot use externally specified kernel arguments with a prebuilt boot image" + ); } Ok(()) } @@ -1897,7 +1899,7 @@ async fn prepare_install( .and_then(|c| c.filesystem_root()) .and_then(|r| r.fstype)); - let mut is_uki = false; + let mut immutable_boot = false; // For composefs backend, automatically disable fs-verity hard requirement if the // filesystem doesn't support it @@ -1922,11 +1924,13 @@ async fn prepare_install( } composefs_options.allow_missing_verity = allow_missing_fsverity; - is_uki = true; + immutable_boot = true; } // If `--allow-missing-verity` is already passed via CLI, don't modify - if composefs_options.composefs_backend && !composefs_options.allow_missing_verity && !is_uki + if composefs_options.composefs_backend + && !composefs_options.allow_missing_verity + && !immutable_boot { composefs_options.allow_missing_verity = !root_filesystem.supports_fsverity(); } @@ -1937,7 +1941,7 @@ async fn prepare_install( .map(|f| f.to_string()) .unwrap_or("None".into()), allow_missing_fsverity = composefs_options.allow_missing_verity, - uki = is_uki, + immutable_boot = immutable_boot, "ComposeFS install prep", ); @@ -1986,20 +1990,15 @@ async fn prepare_install( } impl PostFetchState { - pub(crate) fn new(state: &State, d: &Dir) -> Result { + pub(crate) fn new( + state: &State, + d: &Dir, + image_bootloader: Option, + ) -> Result { // Determine bootloader type for the target system - // Priority: user-specified > bootupd availability > systemd-boot fallback - let detected_bootloader = { - if let Some(bootloader) = state.config_opts.bootloader.clone() { - bootloader - } else { - if crate::bootloader::supports_bootupd(d)? { - crate::spec::Bootloader::Grub - } else { - crate::spec::Bootloader::Systemd - } - } - }; + // Priority: user-specified > image-required > bootupd availability > systemd-boot fallback + let detected_bootloader = + select_bootloader(state.config_opts.bootloader, image_bootloader, d)?; println!("Bootloader: {detected_bootloader}"); let r = Self { detected_bootloader, @@ -2008,6 +2007,21 @@ impl PostFetchState { } } +fn select_bootloader( + configured: Option, + image: Option, + root: &Dir, +) -> Result { + if let Some(bootloader) = configured.or(image) { + return Ok(bootloader); + } + if crate::bootloader::supports_bootupd(root)? { + Ok(Bootloader::Grub) + } else { + Ok(Bootloader::Systemd) + } +} + /// Given a baseline root filesystem with an ostree sysroot initialized: /// - install the container to that root /// - install the bootloader @@ -2035,7 +2049,7 @@ async fn install_with_sysroot( .physical_root .open_dir(&deployment_path) .context("Opening deployment dir")?; - let postfetch = PostFetchState::new(state, &deployment_dir)?; + let postfetch = PostFetchState::new(state, &deployment_dir, None)?; if cfg!(target_arch = "s390x") { // TODO: Integrate s390x support into install_via_bootupd @@ -2058,7 +2072,7 @@ async fn install_with_sysroot( Some(bind_boot_path.as_path()), )?; } - Bootloader::Systemd | Bootloader::GrubCC => { + Bootloader::Systemd | Bootloader::GrubCC | Bootloader::Ukiboot => { anyhow::bail!("bootupd is required for ostree-based installs"); } Bootloader::None => { @@ -2223,11 +2237,13 @@ async fn install_to_filesystem_impl( fetch_imgref.as_ref(), ) .await?; - let uki_policy = - crate::bootc_composefs::repo::inspect_uki_policy(&initialized.repo, &pull_result)?; + let image_fsverity_policy = crate::bootc_composefs::repo::inspect_image_fsverity_policy( + &initialized.repo, + &pull_result, + )?; let requested_relaxed = crate::bootc_composefs::repo::final_repository_policy( - uki_policy, + image_fsverity_policy, state.composefs_options.allow_missing_verity, ); if !initialized.created { @@ -2238,7 +2254,7 @@ async fn install_to_filesystem_impl( )?; } else if !requested_relaxed && provisional_relaxed { anyhow::bail!( - "Initial UKI requires fs-verity, but the target filesystem does not support it" + "Initial boot artifact requires fs-verity, but the target filesystem does not support it" ); } // Repository handles retain LOCK_SH, so no Arc may remain before the @@ -3187,7 +3203,9 @@ mod tests { (false, false, None, None, Some("myroot"), true), (true, false, None, None, None, true), (true, true, addon(), Some(Bootloader::Systemd), None, true), - (true, false, None, Some(Bootloader::None), None, false), + (true, false, None, Some(Bootloader::None), None, true), + (true, false, None, Some(Bootloader::Ukiboot), None, true), + (false, false, None, Some(Bootloader::Ukiboot), None, false), (true, false, None, None, Some("default"), true), (true, false, None, None, Some("myroot"), false), ]; @@ -3216,6 +3234,25 @@ mod tests { ); } + #[test] + fn test_select_bootloader() -> Result<()> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert_eq!(select_bootloader(None, None, &root)?, Bootloader::Systemd); + assert_eq!( + select_bootloader(None, Some(Bootloader::None), &root)?, + Bootloader::None + ); + assert_eq!( + select_bootloader(None, Some(Bootloader::Ukiboot), &root)?, + Bootloader::Ukiboot + ); + assert_eq!( + select_bootloader(Some(Bootloader::Grub), Some(Bootloader::None), &root)?, + Bootloader::Grub + ); + Ok(()) + } + #[test] #[cfg(feature = "install-to-disk")] fn install_opts_serializable() { diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index f40c5c785d..ab3428d312 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -242,6 +242,8 @@ pub enum Bootloader { GrubCC, /// Use SystemdBoot as the bootloader Systemd, + /// Use ukiboot as the bootloader + Ukiboot, /// Don't use a bootloader managed by bootc None, } @@ -261,6 +263,7 @@ impl Display for Bootloader { Bootloader::Grub => "grub", Bootloader::GrubCC => "grub-cc", Bootloader::Systemd => "systemd", + Bootloader::Ukiboot => "ukiboot", Bootloader::None => "none", }; @@ -276,6 +279,7 @@ impl FromStr for Bootloader { "grub" => Ok(Self::Grub), "grub-cc" => Ok(Self::GrubCC), "systemd" => Ok(Self::Systemd), + "ukiboot" => Ok(Self::Ukiboot), "none" => Ok(Self::None), unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")), } @@ -289,7 +293,9 @@ impl Bootloader { match self { Bootloader::Grub => Ok(BootloaderKind::GRUBClassic), Bootloader::Systemd | Bootloader::GrubCC => Ok(BootloaderKind::BLSCompatible), - Bootloader::None => anyhow::bail!("Bootloader was None"), + Bootloader::Ukiboot | Bootloader::None => { + anyhow::bail!("Bootloader {self} does not use BLS entries") + } } } } diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 32a36a0df3..80bda0e8f6 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -226,6 +226,11 @@ "type": "string", "const": "systemd" }, + { + "description": "Use ukiboot as the bootloader", + "type": "string", + "const": "ukiboot" + }, { "description": "Don't use a bootloader managed by bootc", "type": "string", diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index a68ece0078..be5c6a6dae 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -168,6 +168,7 @@ set `discoverable-partitions = true` in their install configuration - grub - grub-cc - systemd + - ukiboot - none **--via-loopback** diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 3ff11166ed..74d628dbce 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -205,6 +205,7 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo - grub - grub-cc - systemd + - ukiboot - none **--acknowledge-destructive** diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 9549e156b2..94d4e9e47c 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -122,6 +122,7 @@ is currently expected to be empty by default. - grub - grub-cc - systemd + - ukiboot - none **--composefs-backend** From 865be1bb9c6f4e94f41241cc48306714058692ca Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:34:47 +0200 Subject: [PATCH 10/14] composefs: Support aboot updates and rollbacks Implement staging, finalization, and boot-time reconciliation for aboot deployments. The boot time reconciliation is done in the bootc-aboot-reconcile.service which is enbled by the bootc generator if needed (and starts once writable state is available). The reconciliation includes active slot tracking to ensure we're not GC:ing bootable images. Updates stage the boot image and optional vbmeta alongside the deployment and record their hashes in persistent pending state. At shutdown it, verifies the payloads, records the attempt, invalidates the non-booted slot mapping, and invokes aboot-deploy which will write the image to the next free boot partition. On the following boot, use the active deployment to determine whether the attempt succeeded. Clean up successful attempts while retaining failed candidates without automatically retrying them. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/aboot.rs | 862 +++++++++++++++++++++ crates/lib/src/bootc_composefs/boot.rs | 114 ++- crates/lib/src/bootc_composefs/delete.rs | 13 +- crates/lib/src/bootc_composefs/finalize.rs | 64 +- crates/lib/src/bootc_composefs/gc.rs | 159 +++- crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/rollback.rs | 50 +- crates/lib/src/bootc_composefs/state.rs | 58 +- crates/lib/src/bootc_composefs/status.rs | 239 +++++- crates/lib/src/bootc_composefs/update.rs | 152 ++-- crates/lib/src/cli.rs | 23 + crates/lib/src/composefs_consts.rs | 8 + crates/lib/src/generator.rs | 99 +++ crates/lib/src/store/mod.rs | 96 ++- systemd/bootc-aboot-reconcile.service | 11 + 15 files changed, 1767 insertions(+), 182 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/aboot.rs create mode 100644 systemd/bootc-aboot-reconcile.service diff --git a/crates/lib/src/bootc_composefs/aboot.rs b/crates/lib/src/bootc_composefs/aboot.rs new file mode 100644 index 0000000000..a0c93d0d14 --- /dev/null +++ b/crates/lib/src/bootc_composefs/aboot.rs @@ -0,0 +1,862 @@ +//! Persistent Aboot state relative to the physical root: +//! +//! ```text +//! state/boot/aboot/ +//! slots/a observed deployment ID for slot A +//! slots/b observed deployment ID for slot B +//! pending staged deployment, payload hashes, and finalization lock +//! attempted deployment attempt and the boot ID which initiated it +//! rollback queued rollback deployment and originating boot ID +//! +//! state/deploy//aboot/ +//! aboot.img staged boot partition payload +//! vbmeta.img optional staged vbmeta payload +//! ``` +//! +//! A missing slot file means that slot has no known valid mapping. Pending and +//! attempted state retain an update until a later boot observes its result. +//! `/run/composefs/staged-deployment` is a transient version of pending state +//! used by the common status and finalization code. +//! +//! Update staging first creates the deployment state, then writes and hashes the boot +//! (and optional vbmeta payloads) below that deployment. Only after those writes are +//! durable does it publish `pending` and the transient staged view. No boot partitions +//! are written during staging. +//! +//! At shutdown the finalizer verifies the staged payload hashes, writes `attempted`, +//! invalidates the non-booted slot mapping, and invokes aboot-deploy. On the next boot, +//! observing the candidate consumes `pending`, its staged payload directory, and +//! `attempted`. Observing another deployment retains them without automatically retrying +//! the write. An unattempted pending update is projected back into `/run` and rearmed +//! unless it was staged download-only. + +use anyhow::{Context, Result, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use canon_json::CanonJsonSerialize; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use linux_kernel_cmdline::utf8::Cmdline; +use rustix::fs::fsync; +use serde::{Deserialize, Serialize}; + +use crate::composefs_consts::{ + ABOOT_ARTIFACT_DIR, ABOOT_ATTEMPTED_FNAME, ABOOT_IMAGE_FNAME, ABOOT_PENDING_FNAME, + ABOOT_ROLLBACK_FNAME, ABOOT_STATE_DIR, ABOOT_VBMETA_FNAME, STATE_DIR_RELATIVE, +}; + +use super::{ + boot::BootType, + state::{read_boot_type, remove_staged_deployment, write_staged_deployment}, + status::StagedDeployment, +}; + +const SLOT_SUFFIX: &str = "androidboot.slot_suffix"; + +/// Logical A/B slot observed from the running kernel command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Slot { + A, + B, +} + +impl Slot { + pub(crate) fn from_cmdline(cmdline: &Cmdline<'_>) -> Option { + let mut suffixes = cmdline + .iter() + .filter(|param| param.key() == SLOT_SUFFIX.into()); + let suffix = suffixes.next()?; + if suffixes.next().is_some() { + return None; + } + match suffix.value()? { + "_a" => Some(Self::A), + "_b" => Some(Self::B), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::A => "a", + Self::B => "b", + } + } +} + +/// Durable staged-deployment state and hashes of the payloads to flash to the boot partitions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PendingDeployment { + pub(crate) depl_id: String, + pub(crate) finalization_locked: bool, + boot_image_sha256: String, + vbmeta_image_sha256: Option, +} + +impl PendingDeployment { + pub(crate) fn staged(&self) -> StagedDeployment { + StagedDeployment { + depl_id: self.depl_id.clone(), + finalization_locked: self.finalization_locked, + } + } +} + +/// Records an attempted deployment and the system boot id which initiated it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AttemptedDeployment { + pub(crate) depl_id: String, + boot_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct QueuedRollback { + depl_id: String, + boot_id: String, +} + +/// Absolute paths to staged payloads after their hashes have been verified. +pub(crate) struct ArtifactPaths { + pub(crate) boot: Utf8PathBuf, + pub(crate) vbmeta: Option, +} + +fn boot_id() -> Result { + Ok(std::fs::read_to_string("/proc/sys/kernel/random/boot_id")? + .trim() + .to_string()) +} + +enum ReconcileAction { + None, + RemoveTransient, + Publish(PendingDeployment), +} + +/// An open fd to the persistent Aboot state +pub(crate) struct AbootState<'a> { + sysroot: &'a Dir, + state: Option, + slots: Option, +} + +impl<'a> AbootState<'a> { + pub(crate) fn open(sysroot: &'a Dir) -> Result { + let state = sysroot.open_dir_optional(ABOOT_STATE_DIR)?; + let slots = match state.as_ref() { + Some(state) => state.open_dir_optional("slots")?, + None => None, + }; + Ok(Self { + sysroot, + state, + slots, + }) + } + + fn ensure_state(&mut self) -> Result<&Dir> { + if self.state.is_none() { + let mut dir = self.sysroot.try_clone()?; + for component in ABOOT_STATE_DIR.split('/') { + dir.create_dir_all(component)?; + fsync(dir.reopen_as_ownedfd()?).context("Syncing aboot state directory")?; + dir = dir.open_dir(component)?; + } + self.state = Some(dir); + } + Ok(self.state.as_ref().unwrap()) + } + + fn ensure_slots(&mut self) -> Result<&Dir> { + if self.slots.is_none() { + { + let state = self.ensure_state()?; + state.create_dir_all("slots")?; + fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state directory")?; + } + self.slots = Some(self.state.as_ref().unwrap().open_dir("slots")?); + } + Ok(self.slots.as_ref().unwrap()) + } + + fn read_json Deserialize<'de>>(&self, name: &str) -> Result> { + let Some(state) = self.state.as_ref() else { + return Ok(None); + }; + let Some(contents) = state.read_optional(name)? else { + return Ok(None); + }; + serde_json::from_slice(&contents) + .with_context(|| format!("Parsing aboot {name}")) + .map(Some) + } + + fn write_json(&mut self, name: &str, value: &impl CanonJsonSerialize) -> Result<()> { + let state = self.ensure_state()?; + state.atomic_write(name, value.to_canon_json_vec()?)?; + fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state")?; + Ok(()) + } + + fn remove_state_file(&self, name: &str) -> Result<()> { + let Some(state) = self.state.as_ref() else { + return Ok(()); + }; + state.remove_file_optional(name)?; + fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state")?; + Ok(()) + } + + pub(crate) fn read_pending(&self) -> Result> { + self.read_json(ABOOT_PENDING_FNAME) + } + + /// Publish or replace a pending deployment unless one was already attempted. + pub(crate) fn write_pending(&mut self, pending: &PendingDeployment) -> Result<()> { + ensure!( + self.read_attempted()?.is_none(), + "Cannot replace an attempted aboot deployment" + ); + self.write_json(ABOOT_PENDING_FNAME, pending) + } + + pub(crate) fn read_attempted(&self) -> Result> { + self.read_json(ABOOT_ATTEMPTED_FNAME) + } + + fn read_rollback(&self) -> Result> { + self.read_json(ABOOT_ROLLBACK_FNAME) + } + + pub(crate) fn queued_rollback(&self) -> Result> { + let Some(rollback) = self.read_rollback()? else { + return Ok(None); + }; + if rollback.boot_id != boot_id()? { + return Ok(None); + } + Ok(Some(rollback.depl_id)) + } + + pub(crate) fn queue_rollback(&mut self, deployment: &str) -> Result<()> { + let digest = Sha512HashValue::from_hex(deployment).context("Invalid rollback digest")?; + ensure!( + self.slot_deployments()?.contains(&digest), + "Rollback deployment is not mapped to an aboot slot" + ); + self.write_json( + ABOOT_ROLLBACK_FNAME, + &QueuedRollback { + depl_id: deployment.to_string(), + boot_id: boot_id()?, + }, + ) + } + + pub(crate) fn clear_rollback(&self) -> Result<()> { + self.remove_state_file(ABOOT_ROLLBACK_FNAME) + } + + /// Make a download-only pending deployment eligible for finalization. + pub(crate) fn unlock_pending(&mut self, deployment: &str) -> Result { + let mut pending = self + .read_pending()? + .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?; + ensure!( + pending.depl_id == deployment, + "Pending aboot deployment changed" + ); + pending.finalization_locked = false; + self.write_pending(&pending)?; + Ok(pending) + } + + /// Store the exact partition payloads before publishing the pending deployment. + pub(crate) fn stage_artifacts( + &self, + deployment: &str, + finalization_locked: bool, + boot: &[u8], + vbmeta: Option<&[u8]>, + ) -> Result { + Sha512HashValue::from_hex(deployment).context("Invalid staged deployment digest")?; + ensure!( + read_boot_type(self.sysroot, deployment)? == Some(BootType::Aboot), + "Staged deployment is not aboot" + ); + let deployment_dir = self + .sysroot + .open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}")) + .context("Opening staged deployment")?; + deployment_dir.create_dir_all(ABOOT_ARTIFACT_DIR)?; + fsync(deployment_dir.reopen_as_ownedfd()?).context("Syncing staged deployment")?; + let artifacts = deployment_dir.open_dir(ABOOT_ARTIFACT_DIR)?; + artifacts.atomic_write(ABOOT_IMAGE_FNAME, boot)?; + match vbmeta { + Some(vbmeta) => artifacts.atomic_write(ABOOT_VBMETA_FNAME, vbmeta)?, + None => { + artifacts.remove_file_optional(ABOOT_VBMETA_FNAME)?; + } + } + fsync(artifacts.reopen_as_ownedfd()?).context("Syncing staged aboot artifacts")?; + Ok(PendingDeployment { + depl_id: deployment.to_string(), + finalization_locked, + boot_image_sha256: hex::encode(openssl::sha::sha256(boot)), + vbmeta_image_sha256: vbmeta.map(|data| hex::encode(openssl::sha::sha256(data))), + }) + } + + /// Verify staged payloads and return paths suitable for passing to aboot-deploy. + pub(crate) fn verify_artifacts( + &self, + root_path: &Utf8Path, + pending: &PendingDeployment, + ) -> Result { + let relative = Utf8PathBuf::from(STATE_DIR_RELATIVE) + .join(&pending.depl_id) + .join(ABOOT_ARTIFACT_DIR); + let artifacts = self.sysroot.open_dir(&relative)?; + let boot = artifacts.read(ABOOT_IMAGE_FNAME)?; + ensure!( + hex::encode(openssl::sha::sha256(&boot)) == pending.boot_image_sha256, + "Staged aboot image was modified" + ); + let vbmeta = match pending.vbmeta_image_sha256.as_ref() { + Some(expected) => { + let data = artifacts.read(ABOOT_VBMETA_FNAME)?; + ensure!( + hex::encode(openssl::sha::sha256(&data)) == *expected, + "Staged vbmeta image was modified" + ); + Some(root_path.join(&relative).join(ABOOT_VBMETA_FNAME)) + } + None => None, + }; + Ok(ArtifactPaths { + boot: root_path.join(relative).join(ABOOT_IMAGE_FNAME), + vbmeta, + }) + } + + fn remove_artifacts(&self, deployment: &str) -> Result<()> { + let deployment_dir = self + .sysroot + .open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}")) + .context("Opening deployed state")?; + if deployment_dir + .open_dir_optional(ABOOT_ARTIFACT_DIR)? + .is_some() + { + deployment_dir + .remove_dir_all(ABOOT_ARTIFACT_DIR) + .context("Removing staged aboot artifacts")?; + } + fsync(deployment_dir.reopen_as_ownedfd()?).context("Syncing deployed state")?; + Ok(()) + } + + pub(crate) fn discard_pending(&self) -> Result<()> { + ensure!( + self.read_attempted()?.is_none(), + "An attempted aboot update has not been reconciled" + ); + if let Some(pending) = self.read_pending()? { + self.remove_state_file(ABOOT_PENDING_FNAME)?; + self.remove_artifacts(&pending.depl_id)?; + } + remove_staged_deployment()?; + Ok(()) + } + + /// Mark an update attempted before invalidating a slot or writing partitions. + pub(crate) fn record_attempt(&mut self, deployment: &str) -> Result<()> { + ensure!( + self.read_attempted()?.is_none(), + "Aboot deployment was already attempted" + ); + let pending = self + .read_pending()? + .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?; + ensure!( + pending.depl_id == deployment, + "Pending aboot deployment changed" + ); + self.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: deployment.to_string(), + boot_id: boot_id()?, + }, + ) + } + + fn reconcile_state(&mut self, booted: &str, current_boot_id: &str) -> Result { + if self + .read_rollback()? + .is_some_and(|rollback| rollback.boot_id != current_boot_id) + { + self.remove_state_file(ABOOT_ROLLBACK_FNAME)?; + } + if let Some(attempted) = self.read_attempted()? { + if attempted.boot_id == current_boot_id { + // The still-running system cannot tell us whether the new payload will boot. + return Ok(ReconcileAction::None); + } + if attempted.depl_id == booted { + // Remove pending first so interruption cannot make a successful write retryable. + self.remove_state_file(ABOOT_PENDING_FNAME)?; + self.remove_artifacts(&attempted.depl_id)?; + self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?; + return Ok(ReconcileAction::RemoveTransient); + } + // Firmware booted another deployment; retain the candidate without retrying it. + return Ok(ReconcileAction::None); + } + + let Some(pending) = self.read_pending()? else { + return Ok(ReconcileAction::RemoveTransient); + }; + Ok(ReconcileAction::Publish(pending)) + } + + /// Restore the transient staged view and return whether finalization should be armed. + pub(crate) fn reconcile(&mut self, booted: &str) -> Result { + match self.reconcile_state(booted, &boot_id()?)? { + ReconcileAction::None => Ok(false), + ReconcileAction::RemoveTransient => { + remove_staged_deployment()?; + Ok(false) + } + ReconcileAction::Publish(pending) => { + write_staged_deployment(&pending.staged())?; + Ok(!pending.finalization_locked) + } + } + } + + pub(crate) fn slot_deployments(&self) -> Result> { + let mut deployments = Vec::new(); + for slot in [Slot::A, Slot::B] { + let Some(deployment) = self.read_slot(slot)? else { + continue; + }; + if !deployments.contains(&deployment) { + deployments.push(deployment); + } + } + Ok(deployments) + } + + fn read_slot(&self, slot: Slot) -> Result> { + let path = format!("{ABOOT_STATE_DIR}/slots/{}", slot.name()); + let Some(slots) = self.slots.as_ref() else { + return Ok(None); + }; + let Some(contents) = slots.read_optional(slot.name())? else { + return Ok(None); + }; + let digest = match Sha512HashValue::from_hex(contents.trim_ascii()) { + Ok(digest) => digest, + Err(err) => { + tracing::warn!(%path, %err, "Invalid aboot slot mapping"); + return Ok(None); + } + }; + if read_boot_type(self.sysroot, &digest.to_hex())? != Some(BootType::Aboot) { + tracing::warn!(%path, "Aboot slot mapping has no corresponding aboot deployment"); + return Ok(None); + } + Ok(Some(digest)) + } + + /// Record a slot mapping only after that deployment has been observed running. + pub(crate) fn record_booted(&mut self, cmdline: &Cmdline<'_>, digest: &str) -> Result<()> { + let digest = + Sha512HashValue::from_hex(digest).context("Invalid booted deployment digest")?; + ensure!( + read_boot_type(self.sysroot, &digest.to_hex())? == Some(BootType::Aboot), + "Booted deployment is not aboot" + ); + let Some(slot) = Slot::from_cmdline(cmdline) else { + tracing::warn!("No unambiguous aboot slot suffix; leaving slot mappings unchanged"); + return Ok(()); + }; + if self.read_slot(slot)?.as_ref() == Some(&digest) { + return Ok(()); + } + + self.ensure_slots()? + .atomic_write(slot.name(), format!("{}\n", digest.to_hex())) + .context("Recording booted aboot slot")?; + Ok(()) + } + + /// Invalidate only the non-booted slot mapping before deployment. + pub(crate) fn invalidate_other(&self, booted_slot: Slot) -> Result<()> { + let other = match booted_slot { + Slot::A => Slot::B, + Slot::B => Slot::A, + }; + let Some(slots) = self.slots.as_ref() else { + return Ok(()); + }; + slots.remove_file_optional(other.name())?; + fsync(slots.reopen_as_ownedfd()?).context("Syncing invalidated aboot slot")?; + Ok(()) + } + + pub(crate) fn delete_deployment(&self, deployment: &str) -> Result<()> { + let digest = Sha512HashValue::from_hex(deployment).context("Invalid deployment digest")?; + let current_boot_id = boot_id()?; + + if let Some(rollback) = self.read_rollback()? { + if rollback.boot_id == current_boot_id { + ensure!( + rollback.depl_id != deployment, + "Cannot delete a queued aboot rollback deployment" + ); + } else { + self.clear_rollback()?; + } + } + + if let Some(attempted) = self + .read_attempted()? + .filter(|attempted| attempted.depl_id == deployment) + { + ensure!( + attempted.boot_id != current_boot_id, + "Cannot delete an aboot deployment attempted during this boot" + ); + if self + .read_pending()? + .is_some_and(|pending| pending.depl_id == deployment) + { + self.remove_state_file(ABOOT_PENDING_FNAME)?; + remove_staged_deployment()?; + self.remove_artifacts(deployment)?; + } + self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?; + } else if self + .read_pending()? + .is_some_and(|pending| pending.depl_id == deployment) + { + self.remove_state_file(ABOOT_PENDING_FNAME)?; + remove_staged_deployment()?; + self.remove_artifacts(deployment)?; + } + + let mut removed = false; + for slot in [Slot::A, Slot::B] { + if self.read_slot(slot)?.as_ref() == Some(&digest) { + self.slots.as_ref().unwrap().remove_file(slot.name())?; + removed = true; + } + } + if removed { + fsync(self.slots.as_ref().unwrap().reopen_as_ownedfd()?) + .context("Syncing aboot slot mappings")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composefs_consts::STATE_DIR_RELATIVE; + use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir}; + + fn deployment(root: &Dir, digest: &str, boot_type: &str) -> Result<()> { + let path = format!("{STATE_DIR_RELATIVE}/{digest}"); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + format!("[boot]\nboot_type={boot_type}\n"), + )?; + Ok(()) + } + + #[test] + fn slot_suffix() { + for (cmdline, expected) in [ + ("androidboot.slot_suffix=_a", Some(Slot::A)), + ("quiet androidboot.slot_suffix=_b rw", Some(Slot::B)), + ("", None), + ("androidboot.slot_suffix", None), + ("androidboot.slot_suffix=", None), + ("androidboot.slot_suffix=a", None), + ("androidboot.slot_suffix=_c", None), + ( + "androidboot.slot_suffix=_a androidboot.slot_suffix=_b", + None, + ), + ( + "androidboot.slot_suffix=_a androidboot.slot_suffix=_a", + None, + ), + ] { + assert_eq!( + Slot::from_cmdline(&Cmdline::from(cmdline)), + expected, + "{cmdline}" + ); + } + } + + #[test] + fn slot_lifecycle() -> Result<()> { + let root = tempdir(ambient_authority())?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + deployment(&root, &a, "aboot")?; + deployment(&root, &b, "aboot")?; + let cmdline_a = Cmdline::from("androidboot.slot_suffix=_a"); + let cmdline_b = Cmdline::from("androidboot.slot_suffix=_b"); + let mut state = AbootState::open(&root)?; + + assert_eq!(state.read_slot(Slot::A)?, None); + state.invalidate_other(Slot::A)?; + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&cmdline_a, &a)?; + assert_eq!(state.read_slot(Slot::B)?, None); + state.record_booted(&cmdline_b, &b)?; + assert_eq!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + assert_eq!( + state.read_slot(Slot::B)?, + Some(Sha512HashValue::from_hex(&b)?) + ); + + for slot in [Slot::A, Slot::B] { + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&cmdline_b, &b)?; + state.invalidate_other(slot)?; + state.invalidate_other(slot)?; + assert!(state.read_slot(slot)?.is_some()); + let other = if slot == Slot::A { Slot::B } else { Slot::A }; + assert_eq!(state.read_slot(other)?, None); + assert!(!root.try_exists(format!("{ABOOT_STATE_DIR}/slots/{}", other.name()))?); + } + + state.record_booted(&cmdline_a, &a)?; + state.record_booted(&Cmdline::from(""), &b)?; + assert_eq!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + state.record_booted(&cmdline_a, &b)?; + assert_eq!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&b)?) + ); + Ok(()) + } + + #[test] + fn invalid_mappings() -> Result<()> { + let root = tempdir(ambient_authority())?; + let slots = format!("{ABOOT_STATE_DIR}/slots"); + root.create_dir_all(&slots)?; + let mut state = AbootState::open(&root)?; + for contents in [String::new(), "invalid".into(), "aa".repeat(64)] { + root.atomic_write(format!("{slots}/a"), contents)?; + assert_eq!(state.read_slot(Slot::A)?, None); + } + root.atomic_write(format!("{slots}/a"), [0xff; 128])?; + assert_eq!(state.read_slot(Slot::A)?, None); + let digest = "aa".repeat(64); + root.atomic_write(format!("{slots}/a"), &digest)?; + deployment(&root, &digest, "uki")?; + assert_eq!(state.read_slot(Slot::A)?, None); + assert!( + state + .record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest) + .is_err() + ); + assert!( + state + .record_booted(&Cmdline::from(""), "../invalid") + .is_err() + ); + Ok(()) + } + + #[test] + fn deployments_deduplicates_slots() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + deployment(&root, &digest, "aboot")?; + let mut state = AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &digest)?; + assert_eq!( + state.slot_deployments()?, + vec![Sha512HashValue::from_hex(digest)?] + ); + Ok(()) + } + + #[test] + fn pending_artifacts_and_attempt() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + deployment(&root, &digest, "aboot")?; + let mut state = AbootState::open(&root)?; + let pending = state.stage_artifacts(&digest, true, b"boot", Some(b"vbmeta"))?; + state.write_pending(&pending)?; + assert_eq!(state.read_pending()?, Some(pending.clone())); + + let paths = state.verify_artifacts(Utf8Path::new("/sysroot"), &pending)?; + assert_eq!( + paths.boot, + Utf8PathBuf::from(format!( + "/sysroot/{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}/{ABOOT_IMAGE_FNAME}" + )) + ); + assert!(paths.vbmeta.is_some()); + + let pending = state.unlock_pending(&digest)?; + assert!(!pending.finalization_locked); + state.record_attempt(&digest)?; + assert_eq!(state.read_attempted()?.unwrap().depl_id, digest); + assert!(state.write_pending(&pending).is_err()); + + let artifacts = root.open_dir(format!( + "{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}" + ))?; + artifacts.atomic_write(ABOOT_IMAGE_FNAME, b"modified")?; + assert!( + state + .verify_artifacts(Utf8Path::new("/sysroot"), &pending) + .is_err() + ); + Ok(()) + } + + #[test] + fn reconcile_attempt() -> Result<()> { + let root = tempdir(ambient_authority())?; + let digest = "aa".repeat(64); + let artifact_dir = format!("{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}"); + deployment(&root, &digest, "aboot")?; + let mut state = AbootState::open(&root)?; + let pending = state.stage_artifacts(&digest, false, b"boot", None)?; + state.write_pending(&pending)?; + assert!(root.try_exists(&artifact_dir)?); + assert!(matches!( + state.reconcile_state("booted", "new-boot")?, + ReconcileAction::Publish(_) + )); + + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: digest.clone(), + boot_id: "old-boot".into(), + }, + )?; + assert!(matches!( + state.reconcile_state("booted", "old-boot")?, + ReconcileAction::None + )); + assert!(matches!( + state.reconcile_state("booted", "new-boot")?, + ReconcileAction::None + )); + assert!(state.read_pending()?.is_some()); + assert!(root.try_exists(&artifact_dir)?); + assert!(matches!( + state.reconcile_state(&digest, "new-boot")?, + ReconcileAction::RemoveTransient + )); + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + assert!(!root.try_exists(&artifact_dir)?); + Ok(()) + } + + #[test] + fn rollback_and_deletion() -> Result<()> { + let root = tempdir(ambient_authority())?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + let staged = "cc".repeat(64); + for digest in [&a, &b, &staged] { + deployment(&root, digest, "aboot")?; + } + let mut state = AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + + state.queue_rollback(&b)?; + assert_eq!(state.queued_rollback()?.as_deref(), Some(b.as_str())); + assert!(state.delete_deployment(&b).is_err()); + state.clear_rollback()?; + + let pending = state.stage_artifacts(&staged, false, b"boot", None)?; + state.write_pending(&pending)?; + state.delete_deployment(&staged)?; + assert!(state.read_pending()?.is_none()); + assert!(!root.try_exists(format!( + "{STATE_DIR_RELATIVE}/{staged}/{ABOOT_ARTIFACT_DIR}" + ))?); + + state.delete_deployment(&b)?; + assert_eq!(state.read_slot(Slot::B)?, None); + assert_eq!( + state.read_slot(Slot::A)?, + Some(Sha512HashValue::from_hex(&a)?) + ); + + let pending = state.stage_artifacts(&staged, false, b"boot", None)?; + state.write_pending(&pending)?; + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: staged.clone(), + boot_id: boot_id()?, + }, + )?; + assert!(state.delete_deployment(&staged).is_err()); + state.write_json( + ABOOT_ATTEMPTED_FNAME, + &AttemptedDeployment { + depl_id: staged.clone(), + boot_id: "previous-boot".into(), + }, + )?; + state.delete_deployment(&staged)?; + assert!(state.read_pending()?.is_none()); + assert!(state.read_attempted()?.is_none()); + Ok(()) + } + + #[test] + fn rollback_marker_expires_after_boot() -> Result<()> { + let root = tempdir(ambient_authority())?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + deployment(&root, &a, "aboot")?; + deployment(&root, &b, "aboot")?; + let mut state = AbootState::open(&root)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + state.queue_rollback(&b)?; + + let current_boot_id = boot_id()?; + assert!(matches!( + state.reconcile_state(&a, ¤t_boot_id)?, + ReconcileAction::RemoveTransient + )); + assert_eq!(state.queued_rollback()?.as_deref(), Some(b.as_str())); + assert!(matches!( + state.reconcile_state(&b, "next-boot")?, + ReconcileAction::RemoveTransient + )); + assert!(state.read_rollback()?.is_none()); + Ok(()) + } +} diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 3b973baaf4..a297c12e71 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -2260,32 +2260,80 @@ fn image_bootloader(entry: &ComposefsBootEntry) -> Option, + boot_ids: &ExpectedBootImageIds, + encoding: AbootEncoding, + payload: &[u8], allow_missing_fsverity: bool, ) -> Result { - let ComposefsBootEntry::Aboot(entry) = entry else { - bail!("Expected an aboot boot entry") - }; - let payload = read_aboot_payload(entry, repo)?; - let (cmdline, boot_digest) = compute_aboot_boot_digest(entry.encoding, &payload)?; - let candidates = parse_uki_composefs_candidates(&cmdline)?; + let (cmdline, boot_digest) = compute_aboot_boot_digest(encoding, payload)?; + validate_aboot_composefs_candidates(id, boot_ids, &cmdline, allow_missing_fsverity)?; + Ok(boot_digest) +} + +fn validate_aboot_composefs_candidates( + id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, + cmdline: &str, + allow_missing_fsverity: bool, +) -> Result<()> { + let candidates = parse_uki_composefs_candidates(cmdline) + .context("Parsing composefs kernel arguments in aboot image")?; let insecure = uki_candidates_policy(&candidates)?; + validate_uki_candidates(&candidates, boot_ids, None)?; ensure!( primary_uki_candidate(&candidates).digest == *id, - "Aboot composefs digest does not match the deployment" + "aboot primary composefs digest does not match the selected boot image" + ); + ensure!( + !insecure || allow_missing_fsverity, + "The aboot image requests insecure composefs operation, but this repository requires fs-verity" ); match (allow_missing_fsverity, insecure) { (true, false) => tracing::warn!( "--allow-missing-fsverity was requested but the aboot image requires fs-verity" ), - (false, true) => tracing::warn!("aboot image permits booting without fs-verity"), _ => {} } - Ok(boot_digest) + Ok(()) +} + +pub(crate) struct PreparedAbootArtifacts { + pub(crate) boot_digest: String, + pub(crate) payload: Box<[u8]>, + pub(crate) vbmeta: Option>, +} + +pub(crate) fn prepare_composefs_aboot_artifacts( + repo: &ComposefsRepository, + id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, + entry: &ComposefsBootEntry, + allow_missing_fsverity: bool, +) -> Result { + let ComposefsBootEntry::Aboot(entry) = entry else { + bail!("Expected an aboot boot entry") + }; + let payload = read_aboot_payload(entry, repo)?; + let boot_digest = validate_composefs_aboot_payload( + id, + boot_ids, + entry.encoding, + &payload, + allow_missing_fsverity, + )?; + let vbmeta = entry + .vbmeta + .as_ref() + .map(|artifact| read_file(&artifact.file, repo).context("Reading vbmeta payload")) + .transpose()?; + Ok(PreparedAbootArtifacts { + boot_digest, + payload, + vbmeta, + }) } fn install_composefs_bootloader( @@ -2473,17 +2521,23 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, - BootType::Aboot => ( - setup_composefs_aboot_boot(&repo, &id, entry, allow_missing_fsverity)?, - provisional_deploy_id, - ), + BootType::Aboot => { + let artifacts = prepare_composefs_aboot_artifacts( + &repo, + &id, + &boot_ids, + entry, + allow_missing_fsverity, + )?; + (artifacts.boot_digest, provisional_deploy_id) + } }; write_composefs_state( &root_setup.physical_root_path, &deploy_id, &crate::spec::ImageReference::from(state.target_imgref.clone()), - None, + true, boot_type, boot_digest, &pull_result.manifest_digest.to_string(), @@ -2892,6 +2946,32 @@ mod tests { } } + #[test] + fn test_aboot_composefs_candidates() { + let v1 = Sha512HashValue::EMPTY; + let v2 = other_digest(); + let ids = ExpectedBootImageIds { + v1: vec![v1.clone()], + v2: vec![v2.clone()], + }; + let dual = format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(v2.clone(), false) + ); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &dual, false).is_ok()); + let stale_fallback = format!( + "{} {}", + uki_v1(v1.clone(), false), + uki_v2(Sha512HashValue::from_hex("bb".repeat(64)).unwrap(), false) + ); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &stale_fallback, false).is_err()); + assert!(validate_aboot_composefs_candidates(&v2, &ids, &dual, false).is_err()); + let insecure = format!("{} {}", uki_v1(v1.clone(), true), uki_v2(v2, true)); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, false).is_err()); + assert!(validate_aboot_composefs_candidates(&v1, &ids, &insecure, true).is_ok()); + } + #[test] fn test_uki_composefs_candidates() { let v1 = Sha512HashValue::EMPTY; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index fc9a3ae8c0..71b0d260b5 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -5,9 +5,11 @@ use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use crate::{ bootc_composefs::{ + aboot, boot::{BootType, get_efi_uuid_source}, gc::{GCOpts, composefs_gc}, rollback::{composefs_rollback, rename_exchange_user_cfg}, + state::remove_staged_deployment, status::{get_composefs_status, get_sorted_grub_uki_boot_entries}, }, composefs_consts::{ @@ -154,7 +156,7 @@ fn delete_depl_boot_entries( BootType::Uki => { remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } - BootType::Aboot => anyhow::bail!("aboot deletion is not implemented"), + BootType::Aboot => anyhow::bail!("Unexpected aboot deployment in boot-entry deletion"), }, BootloaderKind::BLSCompatible => { @@ -261,7 +263,14 @@ pub(crate) async fn delete_composefs_deployment( tracing::info!("Deleting {kind}deployment '{deployment_id}'"); - delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; + if booted.boot_type == BootType::Aboot { + aboot::AbootState::open(&storage.physical_root)?.delete_deployment(deployment_id)?; + if deleting_staged { + remove_staged_deployment()?; + } + } else { + delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; + } composefs_gc( storage, diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index a95ae1468f..114dd8ff4e 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -1,9 +1,9 @@ -use std::path::Path; +use std::{path::Path, process::Command}; -use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}; use crate::bootc_composefs::status::get_composefs_status; +use crate::bootc_composefs::{aboot, boot::BootType}; use crate::composefs_consts::STATE_DIR_ABS; use crate::install::BOOT; use crate::spec::BootloaderKind; @@ -96,6 +96,16 @@ pub(crate) async fn composefs_backend_finalize( "Staged deployment is not a composefs deployment" ))?; + let mut aboot_state = (staged_composefs.boot_type == BootType::Aboot) + .then(|| aboot::AbootState::open(&storage.physical_root)) + .transpose()?; + if let Some(state) = aboot_state.as_ref() + && state.read_attempted()?.is_some() + { + tracing::warn!("Aboot deployment was already attempted; not flashing it again"); + return Ok(()); + } + // Mount the booted EROFS image to get pristine etc let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?; let composefs_fd = mount_composefs_image( @@ -136,6 +146,14 @@ pub(crate) async fn composefs_backend_finalize( // Unmount EROFS drop(erofs_tmp_mnt); + if staged_composefs.boot_type == BootType::Aboot { + return finalize_staged_aboot( + storage, + aboot_state.as_mut().unwrap(), + &staged_composefs.verity, + ); + } + let boot_dir = storage.require_boot_dir()?; match booted_composefs.bootloader.kind()? { @@ -145,7 +163,7 @@ pub(crate) async fn composefs_backend_finalize( rename_exchange_bls_entries(&entries_dir)?; } BootType::Uki => finalize_staged_grub_uki(boot_dir)?, - BootType::Aboot => anyhow::bail!("aboot finalization is not implemented"), + BootType::Aboot => unreachable!(), }, BootloaderKind::BLSCompatible => { @@ -196,6 +214,46 @@ pub(crate) fn hold_boot() -> Result<()> { } } +#[context("Finalizing staged aboot deployment {deployment}")] +fn finalize_staged_aboot( + storage: &Storage, + state: &mut aboot::AbootState<'_>, + deployment: &str, +) -> Result<()> { + let pending = state + .read_pending()? + .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?; + anyhow::ensure!( + pending.depl_id == deployment, + "Pending aboot deployment changed" + ); + anyhow::ensure!( + !pending.finalization_locked, + "Pending aboot deployment is download-only" + ); + let artifacts = state.verify_artifacts(&storage.physical_root_path, &pending)?; + let kernel_cmdline = + std::fs::read_to_string("/proc/cmdline").context("Reading kernel command line")?; + let booted_slot = aboot::Slot::from_cmdline(&linux_kernel_cmdline::utf8::Cmdline::from( + kernel_cmdline.as_str(), + )); + + state.record_attempt(deployment)?; + if let Some(slot) = booted_slot { + state.invalidate_other(slot)?; + } + + let mut command = Command::new("aboot-deploy"); + command.args(["--local", "--boot-image"]); + command.arg(artifacts.boot); + if let Some(vbmeta) = artifacts.vbmeta { + command.arg("--vbmeta-image").arg(vbmeta); + } + let status = command.status().context("Running aboot-deploy")?; + anyhow::ensure!(status.success(), "aboot-deploy exited with status {status}"); + Ok(()) +} + #[context("Grub: Finalizing staged UKI")] fn finalize_staged_grub_uki(boot_fd: &Dir) -> Result<()> { let entries_dir = boot_fd.open_dir("grub2")?; diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 12cdbe94a5..d21e878540 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -217,6 +217,26 @@ pub(crate) struct GCOpts { pub(crate) prune_repo: bool, } +fn orphaned_state_dirs<'a>( + state_dirs: &'a [String], + live_deployments: &[&str], + bootloader_entries: &[BootloaderEntry], + is_aboot: bool, +) -> Vec<&'a String> { + state_dirs + .iter() + .filter(|state| { + if is_aboot { + !live_deployments.contains(&state.as_str()) + } else { + !bootloader_entries + .iter() + .any(|entry| &entry.fsverity == *state) + } + }) + .collect() +} + /// 1. List all bootloader entries /// 2. List all EROFS images /// 3. List all state directories @@ -284,8 +304,15 @@ pub(crate) async fn composefs_gc( let sysroot = &storage.physical_root; - let bootloader_entries = list_bootloader_entries(storage)?; - let boot_binaries = collect_boot_binaries(storage)?; + let is_aboot = booted_cfs_status.boot_type == BootType::Aboot; + let (bootloader_entries, boot_binaries) = if is_aboot { + (Vec::new(), Vec::new()) + } else { + ( + list_bootloader_entries(storage)?, + collect_boot_binaries(storage)?, + ) + }; tracing::debug!("bootloader_entries: {bootloader_entries:?}"); tracing::debug!("boot_binaries: {boot_binaries:?}"); @@ -312,7 +339,9 @@ pub(crate) async fn composefs_gc( delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)? } BootType::Uki => delete_uki(storage, verity, gc_opts.dry_run)?, - BootType::Aboot => anyhow::bail!("aboot garbage collection is not implemented"), + BootType::Aboot => { + anyhow::bail!("Aboot artifact unexpectedly reached boot binary garbage collection") + } } } @@ -328,18 +357,36 @@ pub(crate) async fn composefs_gc( let staged = &host.status.staged; - // State dirs without a bootloader entry are from interrupted deployments. - let orphaned_state_dirs: Vec<_> = state_dirs - .iter() - .filter(|s| !bootloader_entries.iter().any(|entry| &entry.fsverity == *s)) - .collect(); + let live_deployments = host + .list_deployments() + .into_iter() + .map(|deployment| { + deployment + .require_composefs() + .map(|cfs| cfs.verity.as_str()) + }) + .collect::>>()?; + + // Aboot uses slot/pending references from status (because there are no BLS files). + // BLS/UKI use raw boot entries, which can retain deployments omitted from status + // after interrupted operations. + let orphaned_state_dirs = orphaned_state_dirs( + &state_dirs, + &live_deployments, + &bootloader_entries, + is_aboot, + ); // Bootloader entries without a state dir are from interrupted cleanups. - let orphaned_boot_entries: Vec<_> = bootloader_entries - .iter() - .map(|entry| &entry.fsverity) - .filter(|verity| !state_dirs.contains(verity)) - .collect(); + let orphaned_boot_entries: Vec<_> = if is_aboot { + Vec::new() + } else { + bootloader_entries + .iter() + .map(|entry| &entry.fsverity) + .filter(|verity| !state_dirs.contains(verity)) + .collect() + }; let all_orphans: Vec<_> = orphaned_state_dirs .iter() @@ -506,10 +553,10 @@ pub(crate) async fn composefs_gc( let (mut objects_bytes, mut objects_removed) = (0, 0); - // Now GC the UKI/UKI Addons from `.boot` EROFS if we have them - // These won't be GC'd by the above `repo.gc` as the EROFS have - // `/boot` masked - for verity in all_orphans { + // Now GC the UKI/UKI Addons from `.boot` EROFS if we have them. + // Aboot artifacts may share repository objects and are left to repository GC. + // These won't be GC'd by the above `repo.gc` as the EROFS have `/boot` masked. + for verity in all_orphans.iter().copied().filter(|_| !is_aboot) { let verity_to_sha = Sha512HashValue::from_hex(verity); let Ok(verity_to_sha) = verity_to_sha else { @@ -613,7 +660,85 @@ pub(crate) async fn composefs_gc( mod tests { use super::*; use crate::bootc_composefs::status::list_type1_entries; + use crate::bootc_composefs::{aboot, status::ComposefsCmdline}; + use crate::composefs_consts::STATE_DIR_RELATIVE; + use crate::store::ComposefsRepository; use crate::testutils::{ChangeType, TestRoot}; + use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir}; + use composefs_ctl::composefs::repository::RepositoryConfig; + + fn write_aboot_origin(root: &Dir, digest: &str) -> Result<()> { + let path = format!("{STATE_DIR_RELATIVE}/{digest}"); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + "[boot]\nboot_type=aboot\ndigest=boot-digest\n", + )?; + Ok(()) + } + + #[test] + fn aboot_orphans_only_unreferenced_state() { + let state = ["booted".into(), "rollback".into(), "invalid".into()]; + let live = ["booted", "rollback"]; + let orphans = orphaned_state_dirs(&state, &live, &[], true); + assert_eq!( + orphans.iter().map(|s| s.as_str()).collect::>(), + ["invalid"] + ); + + let live = ["booted"]; + let orphans = orphaned_state_dirs(&state, &live, &[], true); + assert_eq!( + orphans.iter().map(|s| s.as_str()).collect::>(), + ["rollback", "invalid"] + ); + } + + #[tokio::test] + async fn aboot_gc_uses_status_roots() -> Result<()> { + let root = tempdir(ambient_authority())?; + root.create_dir_all("composefs")?; + root.create_dir("run")?; + let repo_dir = root.open_dir("composefs")?; + let config = RepositoryConfig::new(composefs_ctl::composefs::fsverity::Algorithm::SHA512) + .set_insecure(); + let (repo, _) = ComposefsRepository::init_path(&repo_dir, ".", config)?; + let repo = std::sync::Arc::new(repo); + let storage = Storage::new_composefs_for_test(&root, &root.open_dir("run")?)?; + let booted = "aa".repeat(64); + let rollback = "bb".repeat(64); + let orphan = "cc".repeat(64); + for digest in [&booted, &rollback, &orphan] { + write_aboot_origin(&root, digest)?; + } + let mut aboot_state = aboot::AbootState::open(&root)?; + aboot_state.record_booted( + &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_a"), + &booted, + )?; + aboot_state.record_booted( + &linux_kernel_cmdline::utf8::Cmdline::from("androidboot.slot_suffix=_b"), + &rollback, + )?; + let cmdline = Box::leak(Box::new(ComposefsCmdline::new(&booted))); + let booted_cfs = BootedComposefs { repo, cmdline }; + + composefs_gc( + &storage, + &booted_cfs, + GCOpts { + dry_run: false, + prune_repo: true, + }, + ) + .await?; + + assert!(root.try_exists(format!("{STATE_DIR_RELATIVE}/{booted}"))?); + assert!(root.try_exists(format!("{STATE_DIR_RELATIVE}/{rollback}"))?); + assert!(!root.try_exists(format!("{STATE_DIR_RELATIVE}/{orphan}"))?); + Ok(()) + } #[test] fn test_image_refs_match_v2_when_v1_is_present() { diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index fe5bc9c6a5..ed4db44a6b 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod aboot; pub(crate) mod backwards_compat; pub(crate) mod boot; pub(crate) mod delete; diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 51f64aebf5..fe686c69ee 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -1,4 +1,4 @@ -use std::io::Write; +use std::{io::Write, process::Command}; use anyhow::{Context, Result, anyhow}; use cap_std_ext::cap_std::fs::Dir; @@ -7,6 +7,7 @@ use fn_error_context::context; use ocidir::cap_std::ambient_authority; use rustix::fs::{AtFlags, RenameFlags, fsync, renameat_with}; +use crate::bootc_composefs::aboot; use crate::bootc_composefs::boot::{ BootType, FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key, secondary_sort_key, type1_entry_conf_file_name, @@ -250,22 +251,43 @@ pub(crate) async fn composefs_rollback( anyhow::bail!("Rollback deployment not a composefs deployment") }; - let boot_dir = storage.require_boot_dir()?; + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + anyhow::ensure!( + rollback_entry.boot_type == BootType::Aboot, + "Rollback deployment is not aboot" + ); + let mut state = aboot::AbootState::open(&storage.physical_root)?; + state.discard_pending()?; + + let status = Command::new("aboot-deploy") + .arg("--rollback") + .status() + .context("Running aboot-deploy --rollback")?; + anyhow::ensure!(status.success(), "aboot-deploy exited with status {status}"); - match &rollback_entry.bootloader.kind()? { - BootloaderKind::GRUBClassic => match rollback_entry.boot_type { - BootType::Bls => { + if reverting { + state.clear_rollback()?; + } else { + state.queue_rollback(&rollback_entry.verity)?; + } + } else { + let boot_dir = storage.require_boot_dir()?; + + match &rollback_entry.bootloader.kind()? { + BootloaderKind::GRUBClassic => match rollback_entry.boot_type { + BootType::Bls => { + rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; + } + BootType::Uki => { + rollback_grub_uki_entries(boot_dir)?; + } + BootType::Aboot => anyhow::bail!("Unexpected aboot rollback deployment"), + }, + + BootloaderKind::BLSCompatible => { + // We use BLS entries for systemd UKI as well rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; } - BootType::Uki => { - rollback_grub_uki_entries(boot_dir)?; - } - BootType::Aboot => anyhow::bail!("aboot rollback is not implemented"), - }, - - BootloaderKind::BLSCompatible => { - // We use BLS entries for systemd UKI as well - rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; } } diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 1d6912bce4..57055a3de6 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -64,6 +64,17 @@ pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result Result> { + Sha512HashValue::from_hex(deployment_id).context("Invalid deployment digest")?; + let Some(origin) = read_origin(sysroot, deployment_id)? else { + return Ok(None); + }; + origin + .get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_TYPE) + .map(|value| BootType::try_from(value.as_str())) + .transpose() +} + pub(crate) fn get_booted_bls(boot_dir: &Dir, booted_cfs: &BootedComposefs) -> Result { let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?; @@ -226,7 +237,7 @@ pub(crate) fn update_boot_digest_in_origin( /// * `root_path` - The root filesystem path (typically `/sysroot`) /// * `deployment_id` - Unique SHA512 hash identifier for this deployment /// * `imgref` - Container image reference for the deployment -/// * `staged` - Whether this is a staged deployment (writes to transient state dir) +/// * `initialize_var` - Whether to populate the shared `/var` from the image /// * `boot_type` - Boot artifact type (`Bls`, `Uki`, or `Aboot`) /// * `boot_digest` - Optional boot digest for verification /// * `manifest_digest` - OCI manifest content digest, stored in the origin file so the @@ -239,13 +250,12 @@ pub(crate) fn update_boot_digest_in_origin( /// * `var` - Symlink to shared `/var` directory /// * `{deployment_id}.origin` - Origin configuration with image ref, boot, and image metadata /// -/// For staged deployments, also writes to `/run/composefs/staged-deployment`. #[context("Writing composefs state")] pub(crate) async fn write_composefs_state( root_path: &Utf8PathBuf, deployment_id: &Sha512HashValue, target_imgref: &ImageReference, - staged: Option, + initialize_var: bool, boot_type: BootType, boot_digest: String, manifest_digest: &str, @@ -271,7 +281,7 @@ pub(crate) async fn write_composefs_state( &root_path, &deployment_id.to_hex(), &state_path, - staged.is_none(), + initialize_var, allow_missing_fsverity, )?; @@ -307,24 +317,30 @@ pub(crate) async fn write_composefs_state( ) .context("Failed to write to .origin file")?; - if let Some(staged) = staged { - std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) - .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; - - let staged_depl_dir = - Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) - .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; - - staged_depl_dir - .atomic_write( - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - staged - .to_canon_json_vec() - .context("Failed to serialize staged deployment JSON")?, - ) - .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"))?; - } + Ok(()) +} + +pub(crate) fn write_staged_deployment(staged: &StagedDeployment) -> Result<()> { + std::fs::create_dir_all(COMPOSEFS_TRANSIENT_STATE_DIR) + .with_context(|| format!("Creating {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + let dir = Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .with_context(|| format!("Opening {COMPOSEFS_TRANSIENT_STATE_DIR}"))?; + dir.atomic_write( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + staged + .to_canon_json_vec() + .context("Failed to serialize staged deployment JSON")?, + ) + .with_context(|| format!("Writing to {COMPOSEFS_STAGED_DEPLOYMENT_FNAME}")) +} +pub(crate) fn remove_staged_deployment() -> Result<()> { + let Some(dir) = + Dir::open_ambient_dir("/run", ambient_authority())?.open_dir_optional("composefs")? + else { + return Ok(()); + }; + dir.remove_file_optional(COMPOSEFS_STAGED_DEPLOYMENT_FNAME)?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index a2882bf8b7..065079723a 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -1,6 +1,6 @@ use std::{io::Read, sync::OnceLock}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use bootc_mount::inspect_filesystem; use composefs_ctl::composefs::erofs::format::FormatVersion; use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; @@ -16,9 +16,10 @@ use serde::{Deserialize, Serialize}; use crate::{ bootc_composefs::{ + aboot, boot::BootType, selinux::are_selinux_policies_compatible, - state::{get_usr_overlay_status, read_origin}, + state::{get_usr_overlay_status, read_boot_type, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ @@ -619,6 +620,7 @@ fn boot_entry_from_composefs_deployment( origin: tini::Ini, verity: &str, missing_verity_allowed: bool, + bootloader: Bootloader, ) -> Result { let image = match origin.get::("origin", ORIGIN_CONTAINER) { Some(img_name_from_config) => { @@ -668,7 +670,7 @@ fn boot_entry_from_composefs_deployment( composefs: Some(crate::spec::BootEntryComposefs { verity: verity.into(), boot_type, - bootloader: get_bootloader()?, + bootloader, boot_digest, missing_verity_allowed, }), @@ -921,6 +923,109 @@ fn rollback_queued_from_first_entry( } } +fn read_staged_deployment() -> Result> { + let path = format!("{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("Reading {path}")), + }; + serde_json::from_str(&contents) + .with_context(|| format!("Parsing {path}")) + .map(Some) +} + +fn aboot_entry(storage: &Storage, verity: &str, missing_verity_allowed: bool) -> Result { + let origin = read_origin(&storage.physical_root, verity)? + .ok_or_else(|| anyhow::anyhow!("No origin file for aboot deployment {verity}"))?; + let entry = boot_entry_from_composefs_deployment( + storage, + origin, + verity, + missing_verity_allowed, + Bootloader::None, + )?; + ensure!( + entry.require_composefs()?.boot_type == BootType::Aboot, + "Slot references non-aboot deployment {verity}" + ); + Ok(entry) +} + +fn composefs_aboot_status(storage: &Storage, cmdline: &ComposefsCmdline) -> Result { + let state = aboot::AbootState::open(&storage.physical_root)?; + let staged = match state.read_pending()? { + Some(pending) => Some(pending.staged()), + None => read_staged_deployment()?, + }; + composefs_aboot_status_from(storage, &state, cmdline, staged.as_ref()) +} + +fn composefs_aboot_status_from( + storage: &Storage, + state: &aboot::AbootState<'_>, + cmdline: &ComposefsCmdline, + staged: Option<&StagedDeployment>, +) -> Result { + let mut host = Host::new(HostSpec { + image: None, + boot_order: BootOrder::Default, + }); + + host.status.booted = Some(aboot_entry( + storage, + &cmdline.digest, + cmdline.allow_missing_fsverity, + )?); + + if let Some(staged) = staged.filter(|s| s.depl_id != *cmdline.digest) { + let mut entry = aboot_entry(storage, &staged.depl_id, cmdline.allow_missing_fsverity)?; + entry.download_only = staged.finalization_locked; + host.status.staged = Some(entry); + } + + let mut seen = vec![cmdline.digest.to_string()]; + if let Some(staged) = host.status.staged.as_ref() { + seen.push(staged.require_composefs()?.verity.clone()); + } + for deployment in state.slot_deployments()? { + let verity = deployment.to_hex(); + if seen.contains(&verity) { + continue; + } + seen.push(verity.clone()); + let entry = aboot_entry(storage, &verity, cmdline.allow_missing_fsverity)?; + if host.status.rollback.is_none() { + host.status.rollback = Some(entry); + } else { + host.status.other_deployments.push(entry); + } + } + + if let Some(queued) = state.queued_rollback()? { + let rollback = + host.status.rollback.as_ref().ok_or_else(|| { + anyhow::anyhow!("Queued aboot rollback deployment is unavailable") + })?; + ensure!( + rollback.require_composefs()?.verity == queued, + "Queued aboot rollback does not match the rollback deployment" + ); + host.status.rollback_queued = true; + host.spec.boot_order = BootOrder::Rollback; + } + + host.spec.image = host + .status + .staged + .as_ref() + .or(host.status.booted.as_ref()) + .and_then(|entry| entry.image.as_ref()) + .map(|image| image.image.clone()); + host.status.usr_overlay = get_usr_overlay_status().ok().flatten(); + Ok(host) +} + #[context("Getting composefs deployment status")] async fn composefs_deployment_status_from( storage: &Storage, @@ -928,6 +1033,10 @@ async fn composefs_deployment_status_from( ) -> Result { let booted_composefs_digest = &cmdline.digest; + if read_boot_type(&storage.physical_root, booted_composefs_digest)? == Some(BootType::Aboot) { + return composefs_aboot_status(storage, cmdline); + } + let boot_dir = storage.require_boot_dir()?; // This is our source of truth @@ -940,20 +1049,10 @@ async fn composefs_deployment_status_from( let mut host = Host::new(host_spec); - let staged_deployment = match std::fs::File::open(format!( - "{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}" - )) { - Ok(mut f) => { - let mut s = String::new(); - f.read_to_string(&mut s)?; - - Ok(Some(s)) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - }?; + let staged_deployment = read_staged_deployment()?; let mut boot_type: Option = None; + let bootloader = get_bootloader()?; // Boot entries from deployments that are neither booted nor staged deployments // Rollback deployment is in here, but may also contain stale deployment entries @@ -983,6 +1082,7 @@ async fn composefs_deployment_status_from( ini, &verity_digest, cmdline.allow_missing_fsverity, + bootloader, )?; // SAFETY: boot_entry.composefs will always be present @@ -1006,10 +1106,8 @@ async fn composefs_deployment_status_from( } if let Some(staged_deployment) = &staged_deployment { - let staged_depl = serde_json::from_str::(&staged_deployment)?; - - if verity_digest == staged_depl.depl_id { - boot_entry.download_only = staged_depl.finalization_locked; + if verity_digest == staged_deployment.depl_id { + boot_entry.download_only = staged_deployment.finalization_locked; host.status.staged = Some(boot_entry); continue; } @@ -1075,7 +1173,9 @@ async fn composefs_deployment_status_from( (is_rollback_queued, None, Some(menuentries)) } - BootType::Aboot => anyhow::bail!("aboot status is not implemented"), + BootType::Aboot => { + anyhow::bail!("Aboot deployment unexpectedly reached GRUB status handling") + } }, // We will have BLS stuff and the UKI stuff in the same DIR @@ -1155,14 +1255,113 @@ async fn composefs_deployment_status_from( mod tests { use cap_std_ext::{cap_std, dirext::CapStdExtDirExt}; + use crate::bootc_composefs::aboot; use crate::bootc_composefs::boot::{ FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key, secondary_sort_key, type1_entry_conf_file_name, }; + use crate::composefs_consts::{ABOOT_STATE_DIR, STATE_DIR_RELATIVE}; use crate::parsers::grub_menuconfig::MenuentryBody; use super::*; + fn write_aboot_origin(root: &Dir, digest: &str) -> Result<()> { + let path = format!("{STATE_DIR_RELATIVE}/{digest}"); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + "[boot]\nboot_type=aboot\ndigest=boot-digest\n", + )?; + Ok(()) + } + + #[test] + fn aboot_status_from_slots() -> Result<()> { + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + root.create_dir("run")?; + let run = root.open_dir("run")?; + let storage = Storage::new_composefs_for_test(&root, &run)?; + let a = "aa".repeat(64); + let b = "bb".repeat(64); + let staged_id = "cc".repeat(64); + for digest in [&a, &b, &staged_id] { + write_aboot_origin(&root, digest)?; + } + let mut aboot_state = aboot::AbootState::open(&root)?; + aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?; + aboot_state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?; + + let cmdline = ComposefsCmdline::new(&a); + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + let booted = host.require_composefs_booted()?; + assert_eq!(booted.verity, a); + assert_eq!(booted.boot_type, BootType::Aboot); + assert_eq!(booted.bootloader, Bootloader::None); + assert_eq!( + host.status + .rollback + .as_ref() + .unwrap() + .require_composefs()? + .verity, + b + ); + assert!(host.status.staged.is_none()); + assert!(host.status.other_deployments.is_empty()); + assert!(!host.status.rollback_queued); + + aboot_state.queue_rollback(&b)?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + assert!(host.status.rollback_queued); + assert_eq!(host.spec.boot_order, BootOrder::Rollback); + aboot_state.clear_rollback()?; + + let staged = StagedDeployment { + depl_id: staged_id.clone(), + finalization_locked: true, + }; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; + let staged_entry = host.status.staged.as_ref().unwrap(); + assert_eq!(staged_entry.require_composefs()?.verity, staged_id); + assert!(staged_entry.download_only); + assert_eq!( + host.status + .rollback + .as_ref() + .unwrap() + .require_composefs()? + .verity, + b + ); + + let staged = StagedDeployment { + depl_id: b.clone(), + finalization_locked: false, + }; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, Some(&staged))?; + assert_eq!( + host.status + .staged + .as_ref() + .unwrap() + .require_composefs()? + .verity, + b + ); + assert!(host.status.rollback.is_none()); + + root.atomic_write(format!("{ABOOT_STATE_DIR}/slots/b"), "invalid")?; + let host = composefs_aboot_status_from(&storage, &aboot_state, &cmdline, None)?; + assert!(host.status.rollback.is_none()); + assert_eq!(host.list_deployments().len(), 1); + + let pending = aboot_state.stage_artifacts(&staged_id, true, b"boot", None)?; + aboot_state.write_pending(&pending)?; + let host = composefs_aboot_status(&storage, &cmdline)?; + assert!(host.status.staged.as_ref().unwrap().download_only); + Ok(()) + } + #[test] fn test_composefs_parsing() { const DIGEST: &str = "8b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad528b7df143d91c716ecfa5fc1730022f6b421b05cedee8fd52b1fc65a96030ad52"; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 5d3e7ecb98..a5f5db0321 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; -use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use cap_std_ext::cap_std::fs::Dir; use composefs::erofs::format::FormatVersion; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::BootOps; @@ -18,15 +18,17 @@ use crate::bootc_composefs::gc::GCOpts; use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ + aboot, boot::{ - BootSetupType, BootType, print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot, + BootSetupType, BootType, prepare_composefs_aboot_artifacts, + print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot, setup_composefs_uki_boot, }, gc::composefs_gc, repo::pull_composefs_repo, service::start_finalize_stated_svc, soft_reboot::prepare_soft_reboot_composefs, - state::write_composefs_state, + state::{write_composefs_state, write_staged_deployment}, status::{ ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status, get_container_manifest_and_config, get_imginfo, @@ -184,26 +186,26 @@ pub(crate) fn validate_update( } let booted = host.require_composefs_booted()?; - let boot_dir = storage.require_boot_dir()?; - - // Remove staged bootloader entries, if any - // GC should take care of the UKI PEs and other binaries - match get_bootloader()?.kind()? { - BootloaderKind::GRUBClassic => match booted.boot_type { - BootType::Bls => rm_staged_type1_ent(boot_dir)?, - - BootType::Uki => { - let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?; - - if grub.exists(USER_CFG_STAGED) { - grub.remove_file(USER_CFG_STAGED) - .context("Removing staged grub user config")?; + if booted.boot_type == BootType::Aboot { + ensure_no_aboot_attempt(storage)?; + } else { + // Remove staged bootloader entries, if any + // GC should take care of the UKI PEs and other binaries + let boot_dir = storage.require_boot_dir()?; + match get_bootloader()?.kind()? { + BootloaderKind::GRUBClassic => match booted.boot_type { + BootType::Bls => rm_staged_type1_ent(boot_dir)?, + BootType::Uki => { + let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?; + if grub.exists(USER_CFG_STAGED) { + grub.remove_file(USER_CFG_STAGED) + .context("Removing staged grub user config")?; + } } - } - BootType::Aboot => anyhow::bail!("aboot update cleanup is not implemented"), - }, - - BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, + BootType::Aboot => unreachable!(), + }, + BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?, + } } // Remove state directories for either serialisation of the same rootfs. @@ -224,6 +226,19 @@ pub(crate) fn validate_update( Ok(UpdateAction::Proceed) } +fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> { + let state = aboot::AbootState::open(&storage.physical_root)?; + anyhow::ensure!( + state.read_attempted()?.is_none(), + "An attempted aboot update has not been reconciled" + ); + anyhow::ensure!( + state.queued_rollback()?.is_none(), + "An aboot rollback is queued" + ); + Ok(()) +} + /// This is just an intersection of SwitchOpts and UpgradeOpts pub(crate) struct DoUpgradeOpts { pub(crate) apply: bool, @@ -271,6 +286,11 @@ pub(crate) async fn do_upgrade( opts: &DoUpgradeOpts, manifest: &ostree_ext::oci_spec::image::ImageManifest, ) -> Result<()> { + // Unpulled images bypass validate_update(), so check aboot state here too. + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + ensure_no_aboot_attempt(storage)?; + } + // Pre-flight disk space check before pulling. crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?; @@ -307,12 +327,14 @@ pub(crate) async fn do_upgrade( ); } - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; + let entry = super::boot::primary_boot_entry(&entries)?; let boot_type = BootType::from(entry); - if boot_type == BootType::Aboot { - anyhow::bail!("aboot boot setup is not implemented"); + let booted_type = host.require_composefs_booted()?.boot_type; + if boot_type == BootType::Aboot || booted_type == BootType::Aboot { + anyhow::ensure!( + boot_type == booted_type, + "Cannot switch between aboot and non-aboot boot layouts" + ); } let mounted_fs = Dir::reopen_dir( @@ -334,6 +356,7 @@ pub(crate) async fn do_upgrade( } let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version()); + let mut aboot_artifacts = None; let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( @@ -359,7 +382,18 @@ pub(crate) async fn do_upgrade( &repo, &oci_fs, )?, - BootType::Aboot => anyhow::bail!("aboot boot setup is not implemented"), + BootType::Aboot => { + let prepared = prepare_composefs_aboot_artifacts( + &repo, + &id, + &boot_ids, + entry, + booted_cfs.cmdline.allow_missing_fsverity, + )?; + let boot_digest = prepared.boot_digest.clone(); + aboot_artifacts = Some(prepared); + (boot_digest, provisional_deploy_id) + } }; // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by @@ -385,7 +419,7 @@ pub(crate) async fn do_upgrade( &Utf8PathBuf::from("/sysroot"), &deploy_id, imgref, - Some(staged_state), + false, boot_type, boot_digest, &manifest_digest, @@ -393,6 +427,18 @@ pub(crate) async fn do_upgrade( ) .await?; + if let Some(artifacts) = aboot_artifacts { + let mut state = aboot::AbootState::open(&storage.physical_root)?; + let pending = state.stage_artifacts( + &staged_state.depl_id, + staged_state.finalization_locked, + &artifacts.payload, + artifacts.vbmeta.as_deref(), + )?; + state.write_pending(&pending)?; + } + write_staged_deployment(&staged_state)?; + // We take into account the staged bootloader entries so this won't remove // the currently staged entry // @@ -432,37 +478,27 @@ pub(crate) async fn apply_upgrade_from_downloaded( return Ok(()); } - start_finalize_stated_svc()?; - - let staged_depl_dir = Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) - .context("Opening transient state directory")?; - - let current = staged_depl_dir - .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) - .context("Reading staged file")?; - - let mut new_staged: StagedDeployment = - serde_json::from_str(¤t).context("Deserializing staged file")?; - - // Make the staged deployment not download_only - new_staged.finalization_locked = false; + let staged_id = &staged.require_composefs()?.verity; + if host.require_composefs_booted()?.boot_type == BootType::Aboot { + let mut state = aboot::AbootState::open(&storage.physical_root)?; + let pending = state.unlock_pending(staged_id)?; + write_staged_deployment(&pending.staged())?; + } else { + let staged_depl_dir = + Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient state directory")?; + let current = staged_depl_dir + .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) + .context("Reading staged file")?; + let mut new_staged: StagedDeployment = + serde_json::from_str(¤t).context("Deserializing staged file")?; + new_staged.finalization_locked = false; + write_staged_deployment(&new_staged)?; + } - staged_depl_dir - .atomic_replace_with( - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - |f| -> std::io::Result<()> { - serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from) - }, - ) - .context("Writing staged file")?; + start_finalize_stated_svc()?; - return apply_upgrade( - storage, - composefs, - &staged.require_composefs()?.verity, - &do_upgrade_opts, - ) - .await; + return apply_upgrade(storage, composefs, staged_id, &do_upgrade_opts).await; } #[context("Upgrading composefs")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 21eb0d4be1..cc7536f2ff 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -44,9 +44,11 @@ use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::state::get_usr_overlay_status; use crate::bootc_composefs::{ + aboot, digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, rollback::composefs_rollback, + service::start_finalize_stated_svc, state::composefs_usr_overlay, switch::switch_composefs, update::upgrade_composefs, @@ -847,6 +849,8 @@ pub(crate) enum InternalsOpts { }, /// Ensure that a composefs repository is initialized TestComposefs, + /// Record the booted aboot slot in persistent deployment state. + ComposefsAbootReconcile, /// Loopback device cleanup helper (internal use only) LoopbackCleanupHelper { /// Device path to clean up @@ -2430,6 +2434,25 @@ async fn run_from_opt(opt: Opt) -> Result { } Opt::Status(opts) => super::status::status(opts).await, Opt::Internals(opts) => match opts { + InternalsOpts::ComposefsAbootReconcile => { + let storage = get_storage().await?; + let digest = match storage.kind()? { + BootedStorageKind::Composefs(booted) => booted.cmdline.digest.to_string(), + BootedStorageKind::Ostree(_) => { + anyhow::bail!("Aboot reconciliation requires the composefs backend") + } + }; + ensure!( + crate::bootc_composefs::state::read_boot_type(&storage.physical_root, &digest)? + == Some(crate::bootc_composefs::boot::BootType::Aboot), + "Aboot reconciliation requires an aboot deployment" + ); + let mut state = aboot::AbootState::open(&storage.physical_root)?; + if state.reconcile(&digest)? { + start_finalize_stated_svc()?; + } + Ok(()) + } InternalsOpts::SystemdGenerator { normal_dir, early_dir: _, diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index c9022acf54..fdaf7e1682 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -7,6 +7,14 @@ pub(crate) const COMPOSEFS_STAGED_DEPLOYMENT_FNAME: &str = "staged-deployment"; pub(crate) const STATE_DIR_ABS: &str = "/sysroot/state/deploy"; /// Relative path to composefs-backend state directory. Relative to /sysroot pub(crate) const STATE_DIR_RELATIVE: &str = "state/deploy"; +pub(crate) const ABOOT_STATE_DIR: &str = "state/boot/aboot"; +pub(crate) const ABOOT_PENDING_FNAME: &str = "pending"; +pub(crate) const ABOOT_ATTEMPTED_FNAME: &str = "attempted"; +pub(crate) const ABOOT_ROLLBACK_FNAME: &str = "rollback"; +pub(crate) const ABOOT_ARTIFACT_DIR: &str = "aboot"; +pub(crate) const ABOOT_IMAGE_FNAME: &str = "aboot.img"; +pub(crate) const ABOOT_VBMETA_FNAME: &str = "vbmeta.img"; +pub(crate) const ABOOT_RECONCILE_SERVICE: &str = "bootc-aboot-reconcile.service"; /// Relative path to the shared 'var' directory. Relative to /sysroot pub(crate) const SHARED_VAR_PATH: &str = "state/os/default/var"; diff --git a/crates/lib/src/generator.rs b/crates/lib/src/generator.rs index 3c4d6d973a..9cdfff7778 100644 --- a/crates/lib/src/generator.rs +++ b/crates/lib/src/generator.rs @@ -9,7 +9,10 @@ use ostree_ext::container_utils::{OSTREE_BOOTED, is_ostree_booted_in}; use ostree_ext::{gio, ostree}; use rustix::{fd::AsFd, fs::StatVfsMountFlags}; +use crate::bootc_composefs::{boot::BootType, state::read_boot_type, status::ComposefsCmdline}; +use crate::composefs_consts::ABOOT_RECONCILE_SERVICE; use crate::install::DESTRUCTIVE_CLEANUP; +use linux_kernel_cmdline::utf8::Cmdline; const STATUS_ONBOOT_UNIT: &str = "bootc-status-updated-onboot.target"; const STATUS_PATH_UNIT: &str = "bootc-status-updated.path"; @@ -174,6 +177,8 @@ pub(crate) fn generator(root: &Dir, unit_dir: &Dir) -> Result<()> { generate_tmpfiles_ordering(unit_dir)?; } + aboot_generator_impl(root, unit_dir)?; + // === Ostree-specific generator logic === // Only run on ostree systems (native composefs boots skip below). if !root.try_exists(OSTREE_BOOTED)? { @@ -231,6 +236,23 @@ pub(crate) fn shadow_sync_generator_impl(root: &Dir, unit_dir: &Dir) -> Result Result { + let Some(cmdline) = root.read_to_string_optional("proc/cmdline")? else { + return Ok(false); + }; + let Some(cmdline) = ComposefsCmdline::find_in_cmdline(&Cmdline::from(cmdline.as_str()))? else { + return Ok(false); + }; + let Some(sysroot) = root.open_dir_optional("sysroot")? else { + return Ok(false); + }; + if read_boot_type(&sysroot, &cmdline.digest)? != Some(BootType::Aboot) { + return Ok(false); + } + enable_unit(unit_dir, ABOOT_RECONCILE_SERVICE, MULTI_USER_TARGET)?; + Ok(true) +} + /// Parse /etc/fstab and check if the root mount is out of sync with the composefs /// state, and if so, fix it. fn generate_fstab_editor(unit_dir: &Dir) -> Result<()> { @@ -347,6 +369,83 @@ mod tests { Ok(tempdir) } + #[test] + fn aboot_reconcile_enablement() -> Result<()> { + let digest = "ab".repeat(64); + for boot_type in [None, Some("bls"), Some("uki"), Some("aboot")] { + let root = fixture()?; + let units = root.open_dir("run/systemd/system")?; + assert!(!aboot_generator_impl(&root, &units)?); + root.create_dir("proc")?; + root.atomic_write( + "proc/cmdline", + format!("composefs=?{digest} androidboot.slot_suffix=_a"), + )?; + if let Some(boot_type) = boot_type { + let path = format!( + "sysroot/{}/{digest}", + crate::composefs_consts::STATE_DIR_RELATIVE + ); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + format!("[boot]\nboot_type={boot_type}\n"), + )?; + } + let expected = boot_type == Some("aboot"); + for _ in 0..2 { + assert_eq!(aboot_generator_impl(&root, &units)?, expected); + } + let link = format!("{MULTI_USER_TARGET}.wants/{ABOOT_RECONCILE_SERVICE}"); + if expected { + assert_eq!( + units.read_link_contents(link)?, + std::path::Path::new("/usr/lib/systemd/system").join(ABOOT_RECONCILE_SERVICE) + ); + } else { + assert_eq!(units.entries()?.count(), 0); + } + } + Ok(()) + } + + #[test] + fn aboot_reconcile_with_dual_format_cmdline() -> Result<()> { + use composefs_ctl::composefs::erofs::format::FormatVersion; + use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; + + let root = fixture()?; + let units = root.open_dir("run/systemd/system")?; + root.create_dir("proc")?; + let v1 = Sha512HashValue::from_hex("ab".repeat(64))?; + let v2 = Sha512HashValue::from_hex("cd".repeat(64))?; + root.atomic_write( + "proc/cmdline", + format!( + "{} {} androidboot.slot_suffix=_a", + crate::bootc_composefs::status::build_composefs_karg( + v1.clone(), + FormatVersion::V1, + false, + ), + crate::bootc_composefs::status::build_composefs_karg(v2, FormatVersion::V2, false,), + ), + )?; + let digest = v1.to_hex(); + let path = format!( + "sysroot/{}/{digest}", + crate::composefs_consts::STATE_DIR_RELATIVE + ); + root.create_dir_all(&path)?; + root.atomic_write( + format!("{path}/{digest}.origin"), + "[boot]\nboot_type=aboot\n", + )?; + + assert!(aboot_generator_impl(&root, &units)?); + Ok(()) + } + #[test] fn test_generator_no_fstab() -> Result<()> { let tempdir = fixture()?; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index e517bc9637..5b89a415ea 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -117,8 +117,11 @@ use composefs::repository::{RepositoryConfig, RepositoryOpenError}; use composefs_ctl::composefs; use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix; -use crate::bootc_composefs::boot::{EFI_LINUX, mount_esp, mount_esp_readonly, mount_esp_writable}; +use crate::bootc_composefs::boot::{ + BootType, EFI_LINUX, mount_esp, mount_esp_readonly, mount_esp_writable, +}; use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader}; +use crate::bootc_composefs::{aboot, state::read_boot_type}; use crate::composefs_consts::{BLS_ENTRY_FILE_PREFIX, TYPE1_ENT_PATH}; use crate::install::BOOT; use crate::lsm; @@ -585,36 +588,51 @@ impl BootedStorage { let r = match &env { Environment::ComposefsBooted(cmdline) => { let (physical_root, run, is_ro) = get_physical_root_and_run()?; + let is_aboot = + read_boot_type(&physical_root, &cmdline.digest)? == Some(BootType::Aboot); + if is_aboot && !is_ro && matches!(esp_access, EspAccess::ReadWrite) { + let kernel_cmdline = std::fs::read_to_string("/proc/cmdline") + .context("Reading kernel command line")?; + aboot::AbootState::open(&physical_root)?.record_booted( + &linux_kernel_cmdline::utf8::Cmdline::from(kernel_cmdline.as_str()), + &cmdline.digest, + )?; + } let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?; if cmdline.allow_missing_fsverity { composefs.set_insecure(); } let composefs = Arc::new(composefs); - // Locate ESP by walking up to the root disk(s), and reading - // [`LOADER_DEVICE_PART_UUID`] and matching the UUID with the - // respective ESP device UUID - // - // Both mount variants transparently reuse an already-mounted - // ESP when present (e.g. auto-mounted at /boot ro via - // `systemd.mount-extra` in the deployment cmdline). - let esp_path = find_booted_from_esp(&physical_root)?; - - let esp_mount = match esp_access { - EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?, - EspAccess::ReadWrite => mount_esp_writable(&esp_path)?, - }; - - let (boot_dir, grub_boot_path) = match get_bootloader()?.kind()? { - // We can have a separate /boot and not /sysroot/boot - BootloaderKind::GRUBClassic => { - let (dir, path) = get_boot_dir_for_grub(&physical_root)?; - (dir, Some(path)) - } - // NOTE: Handle XBOOTLDR partitions here if and when we use it - BootloaderKind::BLSCompatible => { - (esp_mount.fd.try_clone().context("Cloning fd")?, None) - } + let (boot_dir, grub_boot_path, esp) = if is_aboot { + (None, None, None) + } else { + // Locate ESP by walking up to the root disk(s), and reading + // [`LOADER_DEVICE_PART_UUID`] and matching the UUID with the + // respective ESP device UUID + // + // Both mount variants transparently reuse an already-mounted + // ESP when present (e.g. auto-mounted at /boot ro via + // `systemd.mount-extra` in the deployment cmdline). + let esp_path = find_booted_from_esp(&physical_root)?; + + let esp_mount = match esp_access { + EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?, + EspAccess::ReadWrite => mount_esp_writable(&esp_path)?, + }; + + let (boot_dir, grub_boot_path) = match get_bootloader()?.kind()? { + // We can have a separate /boot and not /sysroot/boot + BootloaderKind::GRUBClassic => { + let (dir, path) = get_boot_dir_for_grub(&physical_root)?; + (dir, Some(path)) + } + // NOTE: Handle XBOOTLDR partitions here if and when we use it + BootloaderKind::BLSCompatible => { + (esp_mount.fd.try_clone().context("Cloning fd")?, None) + } + }; + (Some(boot_dir), grub_boot_path, Some(esp_mount)) }; let storage = Storage { @@ -622,9 +640,9 @@ impl BootedStorage { physical_root_path: Utf8PathBuf::from("/sysroot"), is_ro, run, - boot_dir: Some(boot_dir), + boot_dir, grub_boot_path, - esp: Some(esp_mount), + esp, ostree: Default::default(), composefs: OnceCell::from(composefs.clone()), imgstore: Default::default(), @@ -636,9 +654,11 @@ impl BootedStorage { // lacked the prefix — we can't use meta.json presence as a trigger // because open_upgrade() in the initramfs writes meta.json before // userspace ever runs. - let cmdline = composefs_booted()? - .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?; - prepend_custom_prefix(&storage, &cmdline).await?; + if !is_aboot { + let cmdline = composefs_booted()? + .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?; + prepend_custom_prefix(&storage, &cmdline).await?; + } Some(Self { storage }) } @@ -756,6 +776,22 @@ pub(crate) struct CachedImageStatus { } impl Storage { + #[cfg(test)] + pub(crate) fn new_composefs_for_test(physical_root: &Dir, run: &Dir) -> Result { + Ok(Self { + physical_root: physical_root.try_clone()?, + physical_root_path: Utf8PathBuf::from("/sysroot"), + is_ro: false, + boot_dir: None, + grub_boot_path: None, + esp: None, + run: run.try_clone()?, + ostree: Default::default(), + composefs: Default::default(), + imgstore: Default::default(), + }) + } + /// Create a new storage accessor from an existing ostree sysroot. /// /// This is used for non-booted scenarios (e.g., `bootc install`) where diff --git a/systemd/bootc-aboot-reconcile.service b/systemd/bootc-aboot-reconcile.service new file mode 100644 index 0000000000..bc23864f1e --- /dev/null +++ b/systemd/bootc-aboot-reconcile.service @@ -0,0 +1,11 @@ +[Unit] +Description=Record Booted Aboot Slot +Documentation=man:bootc(1) +RequiresMountsFor=/sysroot +After=local-fs.target +Before=bootc-fetch-apply-updates.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/bootc internals composefs-aboot-reconcile From da4495c6e508587fd7137bd5811332a220c3cbec Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:40:51 +0200 Subject: [PATCH 11/14] docs: Describe native composefs aboot support Document /boot-only discovery, immutable artifact command lines, artifact-selected disk layouts, and persistent A/B update behavior. Assisted-by: AI Signed-off-by: Alexander Larsson --- docs/src/SUMMARY.md | 1 + docs/src/bootc-composefs.7.md | 4 +- .../bootc-experimental-composefs-aboot.7.md | 92 +++++++++++++++++++ docs/src/bootc-sysroot.7.md | 7 +- 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 docs/src/bootc-experimental-composefs-aboot.7.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index f346e4f1fc..122a5b7971 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -88,6 +88,7 @@ # Experimental features - [bootc image](bootc-experimental-image.7.md) +- [composefs aboot support](bootc-experimental-composefs-aboot.7.md) - [unified storage](bootc-experimental-unified-storage.7.md) - [fsck](bootc-experimental-fsck.7.md) - [install reset](bootc-experimental-install-reset.7.md) diff --git a/docs/src/bootc-composefs.7.md b/docs/src/bootc-composefs.7.md index 66c556c0c4..c14ec48dbf 100644 --- a/docs/src/bootc-composefs.7.md +++ b/docs/src/bootc-composefs.7.md @@ -126,6 +126,7 @@ and may change or be removed: - UKI addons (`--uki-addon`). Addons are only installed by `bootc install`: they aren't updated on upgrade, garbage collected, or reverted on rollback. +- [Android A/B boot images and ukiboot](bootc-experimental-composefs-aboot.7.md). - [Unified storage](bootc-experimental-unified-storage.7.md). ## Limitations @@ -138,7 +139,8 @@ and may change or be removed: backend yet, so for now a system has to be reinstalled. We fully intend to support moving to composefs without a reinstall; see [Future work](#future-work). -- `--bootloader=none` is not supported. +- `--bootloader=none` is only supported for Android A/B boot images; see + [aboot support](bootc-experimental-composefs-aboot.7.md). - `--soft-reboot=auto` doesn't fall back to a regular reboot when the new deployment can't be soft rebooted into; see [Soft reboots](bootc-upgrades.7.md#soft-reboots). diff --git a/docs/src/bootc-experimental-composefs-aboot.7.md b/docs/src/bootc-experimental-composefs-aboot.7.md new file mode 100644 index 0000000000..e3218d1004 --- /dev/null +++ b/docs/src/bootc-experimental-composefs-aboot.7.md @@ -0,0 +1,92 @@ +# Experimental composefs aboot support + +This page covers booting the composefs backend with an Android-style A/B boot +image. For the repository layout, fsverity modes, and general composefs +installation model, see [the composefs backend documentation](bootc-composefs.7.md). +Both the aboot integration and its on-disk state format are experimental. + +## Boot layouts + +There are two supported ways to consume a `/boot/aboot-.img` artifact: + +| Artifact encoding | Bootloader and boot partitions | +| --- | --- | +| Android boot image v2 | `none`; Android `boot_a` and `boot_b`, with no ESP | +| ukiboot image | `ukiboot`; ESP with ukiboot, plus A/B boot images | + +The real Android layout deliberately has **no ESP**. For that payload, bootc infers +bootloader `none` and does not install an EFI bootloader; the platform firmware and +`aboot-deploy` manage the A/B boot partitions. Both layouts use the composefs backend +for the root filesystem. They cannot be switched in place to or from the BLS/UKI +layouts managed by GRUB or systemd-boot. Android and ukiboot layouts also cannot +be switched in place. + +## Building an image + +`bootc container aboot` computes the V1 and V2 composefs digests of the rootfs, +adds both to the kernel command line, and invokes `aboot-update` to create the +artifact. See [EROFS formats](bootc-composefs.7.md#erofs-formats) for why +both digests are included. `/etc/aboot.cfg` controls whether `aboot-update` +emits an Android boot image or a ukiboot image (as well as other details). As +with [building a composefs UKI](building/bootc-sealed-images.7.md#building-sealed-images), +split the kernel and initramfs from the rootfs before generating the boot +artifact, then copy the generated `aboot-.img` into the final +image's `/boot`. + +```sh +bootc container aboot --rootfs /target \ + --kernel-dir /kernel/KVER --out /out +``` + +The command checks that the generated artifact contains both expected composefs +arguments. It requires `aboot-update` in the build environment; the installed +system needs `aboot-deploy` for updates and rollbacks. If the root filesystem +cannot enforce fsverity, pass `--allow-missing-verity` when building the artifact. +This produces an **unsealed** image, as explained in +[composefs integrity modes](bootc-composefs.7.md#overview). + +Bootc automatically selects the native composefs backend, and bootloader `none` from an Android boot +v2 payload and `ukiboot` from a ukiboot payload. An explicit bootloader setting is +optional, but must agree with the payload. +`bootc container inspect --json` reports `"type": "aboot"` for Android payloads +and `"type": "aboot-efi"` for ukiboot payloads; image-builder uses this to +select UEFI boot mode for ukiboot without a bootloader configuration file. + +Modern aboot artifacts are discovered only under `/boot`. An `aboot.img` under +`/usr/lib/modules/` belongs to the legacy OSTree integration and +does not select this backend. A matching `vbmeta-.img` may accompany +an Android payload. ukiboot payloads do not use vbmeta. + +When installing the running container image, `bootc install to-disk` selects the +backend and layout from its boot artifact and reads repart definitions from that +same container. With `--source-imgref`, the source image's root filesystem is not +available during preparation, so no aboot disk layout is inferred. The normally +pulled image must match the selected encoding. +Supplied repart definitions must match the artifact's slot labels and partition +type GUIDs; otherwise bootc creates a default layout. Android uses `boot_a`, +`boot_b`, `vbmeta_a`, and `vbmeta_b`; ukiboot uses `ukiboot_a`, `ukiboot_b`, +`ukibootctl`, and an ESP. Both layouts include a root filesystem and initialize +both boot slots. Platform-specific sizing, device trees, and `/etc/aboot.cfg` +settings remain the image builder's responsibility. + +Prebuilt aboot command lines are immutable. Additional installation `--karg` +arguments are rejected; include them when building the artifact. + +## Updates, rollback, and recovery + +An update stores the new boot image (and optional vbmeta image) under +`/state/deploy//aboot/`. A persistent pending record under +`/state/boot/aboot/` tracks those artifacts and their hashes. Staging does not write +either boot partition. At shutdown, bootc verifies the artifacts, records the attempt, and +requires an unambiguous booted slot and calls `aboot-deploy` to flash the inactive slot. + +On the next boot, bootc records the observed `androidboot.slot_suffix` kernel commandline +option and reconciles the attempted deployment. The previous slot becomes the rollback +deployment; `bootc rollback` queues it through `aboot-deploy --rollback`. `bootc status` +indicates when a rollback is queued. A failed boot is **not** automatically retried: bootc +retains the attempted update for inspection and will not stage another update until that +state is resolved. + +The firmware's slot-selection and success-marking behavior is outside bootc. For example, +the ukiboot enables `ukiboot-set-success.service` to mark a successful boot. Validate that +behavior on the target hardware before relying on automatic fallback. diff --git a/docs/src/bootc-sysroot.7.md b/docs/src/bootc-sysroot.7.md index 9e3310c112..64473b7c12 100644 --- a/docs/src/bootc-sysroot.7.md +++ b/docs/src/bootc-sysroot.7.md @@ -90,7 +90,7 @@ Unlike the ostree backend, which keeps its repository at `/ostree/repo`, the com - `/state/deploy//`: Persistent per-deployment state, one directory per deployment (see below for how it is named): - `etc/`: a writable copy of the deployment's `/etc`, bind-mounted onto the booted root's `/etc` - `var`: a symlink to the shared `/state/os/default/var`, bind-mounted onto the booted root's `/var` - - `.origin`: an INI file recording the image reference, boot type (BLS or UKI) and digest, and the OCI manifest digest (the latter is what keeps a deployment's objects alive across garbage collection) + - `.origin`: an INI file recording the image reference, boot type (BLS, UKI, or aboot) and digest, and the OCI manifest digest (the latter is what keeps a deployment's objects alive across garbage collection) Although composefs-rs supports other fs-verity hash algorithms, bootc currently hardcodes `SHA-512` for the repository. This is why EROFS image IDs and object identifiers are 128-character hex strings. @@ -103,4 +103,7 @@ deployment was staged. There is no `/ostree/repo`; the composefs backend doesn't use the ostree repository at all. A minimal `/ostree` directory is still created, but only to hold a compatibility symlink (`ostree/bootc -> ../composefs/bootc`) so that existing tooling expecting `/usr/lib/bootc/storage` to resolve through `ostree/bootc` keeps working. -Transient, not-yet-finalized deployment state (used while staging an update before reboot) lives under `/run/composefs/staged-deployment` and is never persisted to disk. +The transient view of a staged deployment lives under +`/run/composefs/staged-deployment`. Aboot additionally persists pending and +attempted state so an interrupted A/B update can be reconciled after reboot; +see [aboot update state](bootc-experimental-composefs-aboot.7.md#updates-rollback-and-recovery). From 004eaa234ae611550f4ddbf4b0ff0d25da66c40d Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Mon, 12 Oct 2026 14:31:24 +0200 Subject: [PATCH 12/14] install: Allow explicit repart definition directories Let repart callers collect and use definitions in a chosen directory, preparing for layouts with additional required partitions. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/install/baseline.rs | 59 +++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index d45688a596..75396fc4fa 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -229,7 +229,7 @@ struct RepartPartition { file: String, } -fn can_use_systemd_repart() -> bool { +fn has_systemd_repart() -> bool { if Command::new("systemd-repart") .arg("--help") .stdout(Stdio::null()) @@ -240,6 +240,14 @@ fn can_use_systemd_repart() -> bool { return false; } + true +} + +fn can_use_systemd_repart() -> bool { + if !has_systemd_repart() { + return false; + } + let repart_config_dirs = [ Path::new("/etc/repart.d"), Path::new("/run/repart.d"), @@ -254,7 +262,7 @@ fn can_use_systemd_repart() -> bool { .is_some_and(|mut entries| entries.next().is_some()) }); - return has_config; + has_config } /// The first systemd version that supports `systemd-repart --include-partitions=`. @@ -296,26 +304,28 @@ fn repart_partition_is_required(part: &RepartPartition) -> bool { || ptype.eq_ignore_ascii_case(BIOS_BOOT) } -/// Collect the repart.d definitions for the partitions we require (root, ESP -/// and BIOS boot) into [`REPART_FILTERED_DEFINITIONS_DIR`]. +/// Collect the selected repart.d definitions into dest_dir. /// /// systemd < 253 does not support the `--include-partitions=` option, so instead /// we point systemd-repart at a directory containing only the definitions we want /// it to act on -fn collect_repart_definitions(dry_partitions: &[RepartPartition]) -> Result<()> { - let dest_dir = Path::new(REPART_FILTERED_DEFINITIONS_DIR); +fn collect_repart_definitions( + dry_partitions: &[RepartPartition], + dest_dir: &Path, + filter: impl Fn(&RepartPartition) -> bool, +) -> Result<()> { // Start from a clean directory so stale definitions from a previous run // don't leak in. if dest_dir.exists() { std::fs::remove_dir_all(dest_dir) - .with_context(|| format!("Removing {REPART_FILTERED_DEFINITIONS_DIR}"))?; + .with_context(|| format!("Removing {}", dest_dir.display()))?; } std::fs::create_dir_all(dest_dir) - .with_context(|| format!("Creating {REPART_FILTERED_DEFINITIONS_DIR}"))?; + .with_context(|| format!("Creating {}", dest_dir.display()))?; for part in dry_partitions { - if !repart_partition_is_required(part) { + if !filter(part) { continue; } @@ -326,8 +336,11 @@ fn collect_repart_definitions(dry_partitions: &[RepartPartition]) -> Result<()> .map(|d| Path::new(d).join(&part.file)) .find(|p| p.exists()) .ok_or_else(|| anyhow::anyhow!("Could not find repart.d definition {}", part.file))?; + let name = src + .file_name() + .context("Missing repart.d definition filename")?; - std::fs::copy(&src, dest_dir.join(&part.file)) + std::fs::copy(&src, dest_dir.join(name)) .with_context(|| format!("Copying repart.d definition {}", src.display()))?; } @@ -346,7 +359,7 @@ fn systemd_repart( // Dry-run to check what partitions would be created // Send `generic_image` as false so that we can see ALL defined // partitions - let dry_partitions = systemd_repart_run(device, false, true)?; + let dry_partitions = systemd_repart_run(device, false, true, None)?; if dry_partitions.is_empty() { anyhow::bail!("systemd-repart returned empty partitions"); @@ -359,10 +372,14 @@ fn systemd_repart( if has_root { // Root partition is defined in repart.d config, run for real if need_filtered_definitions(generic_image)? { - collect_repart_definitions(&dry_partitions)?; + collect_repart_definitions( + &dry_partitions, + Path::new(REPART_FILTERED_DEFINITIONS_DIR), + repart_partition_is_required, + )?; } - let partitions = systemd_repart_run(device, generic_image, false)?; + let partitions = systemd_repart_run(device, generic_image, false, None)?; let layout = parse_repart_layout(&partitions)?; return Ok(layout); } @@ -409,7 +426,11 @@ fn systemd_repart( } if need_filtered_definitions(generic_image)? { - collect_repart_definitions(&dry_partitions)?; + collect_repart_definitions( + &dry_partitions, + Path::new(REPART_FILTERED_DEFINITIONS_DIR), + repart_partition_is_required, + )?; std::fs::write( Path::new(REPART_FILTERED_DEFINITIONS_DIR).join("50-root.conf"), @@ -422,7 +443,7 @@ fn systemd_repart( .context("Writing root repart config")?; } - let partitions = systemd_repart_run(device, generic_image, false)?; + let partitions = systemd_repart_run(device, generic_image, false, None)?; let layout = parse_repart_layout(&partitions)?; Ok(layout) @@ -430,12 +451,12 @@ fn systemd_repart( /// Run systemd-repart on the device and return the parsed JSON output. /// `dry_run`: if true, no changes are written to disk. -/// `definitions`: if set, uses `--definitions=` and `--empty=allow`; -/// otherwise uses the default config search paths with `--empty=force`. +/// `definitions`: if set, uses that directory instead of the default search paths. fn systemd_repart_run( device: &Device, generic_image: bool, dry_run: bool, + definitions: Option<&Path>, ) -> Result> { let mut cmd = Command::new("systemd-repart"); @@ -463,7 +484,9 @@ fn systemd_repart_run( // don't know if the user would want to run those on this disk itself // or if this disk would be used to create an AMI/VHD and a separate disk // would be used for the other partitions - if generic_image { + if let Some(definitions) = definitions { + cmd.arg(format!("--definitions={}", definitions.display())); + } else if generic_image { if need_filtered_definitions(generic_image)? { // Older systemd lacks --include-partitions; act only on the // pre-filtered definitions collected by collect_repart_definitions. From e582abb01f3c3bcabf7040b69baf11c55087b203 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Thu, 8 Oct 2026 17:40:00 +0200 Subject: [PATCH 13/14] install: Support aboot in install to-disk Derive Android or ukiboot disk layouts from the source image's boot artifact when installing the running container image. With --source-imgref, skip artifact-based layout selection rather than inferring it from the installation host. Validate repart definitions and the pulled artifact against the selected layout, then initialize both slots. Keep the legacy OSTree installation path unchanged. Assisted-by: AI Signed-off-by: Alexander Larsson --- crates/lib/src/bootc_composefs/boot.rs | 187 +++++++- crates/lib/src/install.rs | 51 ++- crates/lib/src/install/baseline.rs | 425 +++++++++++++++++- .../bootc-experimental-composefs-aboot.7.md | 7 +- docs/src/man/bootc-install-to-disk.8.md | 15 + 5 files changed, 670 insertions(+), 15 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a297c12e71..155bd6dce7 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -115,7 +115,10 @@ use crate::{ bootc_composefs::repo::open_composefs_repo, store::{ComposefsRepository, Storage}, }; -use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState}; +use crate::{ + bootc_composefs::status::get_sorted_grub_uki_boot_entries, + install::{AbootDiskLayout, PostFetchState}, +}; use crate::{ composefs_consts::{ BOOT_LOADER_ENTRIES, STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_RELATIVE, UKI_NAME_PREFIX, @@ -2300,6 +2303,90 @@ fn validate_aboot_composefs_candidates( Ok(()) } +#[cfg(feature = "install-to-disk")] +fn aboot_partition_path( + disk: &bootc_blockdev::Device, + label: &str, + payload_size: usize, +) -> Result { + let mut partitions = disk + .children + .iter() + .flatten() + .filter(|part| part.partlabel.as_deref() == Some(label)); + let partition = partitions + .next() + .ok_or_else(|| anyhow::anyhow!("Missing {label} partition"))?; + ensure!( + partitions.next().is_none(), + "Multiple {label} partitions found" + ); + ensure!( + payload_size as u64 <= partition.size, + "{label} image is larger than its partition" + ); + Ok(partition.path()) +} + +#[cfg(feature = "install-to-disk")] +fn write_aboot_partition(path: &str, label: &str, payload: &[u8]) -> Result<()> { + let mut target = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .with_context(|| format!("Opening {label} partition at {path}"))?; + target + .write_all(payload) + .with_context(|| format!("Writing {label} partition at {path}"))?; + target + .sync_all() + .with_context(|| format!("Syncing {path}"))?; + Ok(()) +} + +#[cfg(feature = "install-to-disk")] +fn aboot_disk_partition_payloads( + layout: AbootDiskLayout, + artifacts: &PreparedAbootArtifacts, +) -> Result> { + let partitions = match layout { + AbootDiskLayout::Android => vec![ + ("boot_b", artifacts.payload.as_ref()), + ("vbmeta_b", artifacts.vbmeta.as_deref().unwrap_or(&[0])), + ("boot_a", artifacts.payload.as_ref()), + ("vbmeta_a", artifacts.vbmeta.as_deref().unwrap_or(&[0])), + ], + AbootDiskLayout::Ukiboot => { + ensure!( + artifacts.vbmeta.is_none(), + "ukiboot layout has no vbmeta partition" + ); + vec![ + ("ukiboot_b", artifacts.payload.as_ref()), + ("ukiboot_a", artifacts.payload.as_ref()), + ] + } + }; + Ok(partitions) +} + +#[cfg(feature = "install-to-disk")] +fn install_aboot_disk_payload( + root_setup: &RootSetup, + layout: AbootDiskLayout, + artifacts: &PreparedAbootArtifacts, +) -> Result<()> { + let disk = &root_setup.device_info; + let partitions = aboot_disk_partition_payloads(layout, artifacts)?; + let targets = partitions + .iter() + .map(|(label, payload)| aboot_partition_path(disk, label, payload.len())) + .collect::>>()?; + for ((label, payload), path) in partitions.into_iter().zip(targets) { + write_aboot_partition(&path, label, payload)?; + } + Ok(()) +} + pub(crate) struct PreparedAbootArtifacts { pub(crate) boot_digest: String, pub(crate) payload: Box<[u8]>, @@ -2437,6 +2524,32 @@ fn install_composefs_bootloader( Ok(()) } +fn validate_aboot_disk_layout( + encoding: Option, + layout: Option, +) -> Result<()> { + match (encoding, layout) { + (Some(encoding), Some(layout)) => { + let expected = match encoding { + AbootEncoding::AndroidV2 => AbootDiskLayout::Android, + AbootEncoding::Uki => AbootDiskLayout::Ukiboot, + }; + ensure!( + layout == expected, + "Selected aboot disk layout {layout:?} does not match pulled artifact encoding {encoding:?}" + ); + } + (Some(_), None) => bail!( + "Pulled image contains an aboot artifact but no aboot disk layout was selected; run to-disk from a container with matching boot artifacts" + ), + (None, Some(layout)) => { + bail!("Selected aboot disk layout {layout:?} but pulled image has no aboot artifact") + } + (None, None) => {} + } + Ok(()) +} + #[context("Setting up composefs boot")] pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, @@ -2470,6 +2583,14 @@ pub(crate) async fn setup_composefs_boot( let entry = primary_boot_entry(&entries)?; let boot_type = BootType::from(entry); + if root_setup.disk_install { + let encoding = match entry { + ComposefsBootEntry::Aboot(aboot) => Some(aboot.encoding), + _ => None, + }; + validate_aboot_disk_layout(encoding, root_setup.aboot_disk_layout)?; + } + let composefs_mnt_fd = repo .mount(&id.to_hex()) .context("Failed to mount composefs image")?; @@ -2529,6 +2650,10 @@ pub(crate) async fn setup_composefs_boot( entry, allow_missing_fsverity, )?; + #[cfg(feature = "install-to-disk")] + if let Some(layout) = root_setup.aboot_disk_layout { + install_aboot_disk_payload(root_setup, layout, &artifacts)?; + } (artifacts.boot_digest, provisional_deploy_id) } }; @@ -2588,6 +2713,66 @@ mod tests { use composefs::erofs::format::FormatVersion; use composefs_boot::bootloader::{AbootArtifact, AbootEncoding, AbootEntry, Type2Entry}; + #[test] + fn aboot_disk_layout_matches_pulled_artifact() { + assert!(validate_aboot_disk_layout(None, None).is_ok()); + assert!( + validate_aboot_disk_layout( + Some(AbootEncoding::AndroidV2), + Some(AbootDiskLayout::Android) + ) + .is_ok() + ); + assert!( + validate_aboot_disk_layout(Some(AbootEncoding::Uki), Some(AbootDiskLayout::Ukiboot)) + .is_ok() + ); + assert!( + validate_aboot_disk_layout(Some(AbootEncoding::Uki), Some(AbootDiskLayout::Android)) + .is_err() + ); + assert!(validate_aboot_disk_layout(Some(AbootEncoding::Uki), None).is_err()); + assert!(validate_aboot_disk_layout(None, Some(AbootDiskLayout::Ukiboot)).is_err()); + } + + #[cfg(feature = "install-to-disk")] + #[test] + fn aboot_initial_partition_payloads() -> Result<()> { + let mut artifacts = PreparedAbootArtifacts { + boot_digest: String::new(), + payload: Box::from(&b"boot"[..]), + vbmeta: Some(Box::from(&b"vbmeta"[..])), + }; + let android = aboot_disk_partition_payloads(AbootDiskLayout::Android, &artifacts)?; + assert_eq!( + android + .iter() + .map(|(label, payload)| (*label, *payload)) + .collect::>(), + [ + ("boot_b", &b"boot"[..]), + ("vbmeta_b", &b"vbmeta"[..]), + ("boot_a", &b"boot"[..]), + ("vbmeta_a", &b"vbmeta"[..]), + ] + ); + assert!(aboot_disk_partition_payloads(AbootDiskLayout::Ukiboot, &artifacts).is_err()); + + artifacts.vbmeta = None; + let android = aboot_disk_partition_payloads(AbootDiskLayout::Android, &artifacts)?; + assert_eq!(android[1].1, &[0]); + assert_eq!(android[3].1, &[0]); + let ukiboot = aboot_disk_partition_payloads(AbootDiskLayout::Ukiboot, &artifacts)?; + assert_eq!( + ukiboot + .iter() + .map(|(label, payload)| (*label, *payload)) + .collect::>(), + [("ukiboot_b", &b"boot"[..]), ("ukiboot_a", &b"boot"[..])] + ); + Ok(()) + } + #[test] fn test_grub_bls_abs_entries_path() -> Result<()> { let td = tempfile::tempdir()?; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index dd85fc7af0..a53e28f49c 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -510,6 +510,21 @@ pub(crate) struct InstallToDiskOpts { pub(crate) run_repart: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AbootDiskLayout { + Android, + Ukiboot, +} + +impl From for AbootDiskLayout { + fn from(encoding: composefs_ctl::composefs_boot::bootloader::AbootEncoding) -> Self { + match encoding { + composefs_ctl::composefs_boot::bootloader::AbootEncoding::AndroidV2 => Self::Android, + composefs_ctl::composefs_boot::bootloader::AbootEncoding::Uki => Self::Ukiboot, + } + } +} + #[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum ReplaceMode { @@ -720,6 +735,8 @@ pub(crate) struct State { pub(crate) composefs_options: InstallComposefsOpts, pub(crate) composefs_fsverity_supported: bool, pub(crate) allow_missing_verity_explicit: bool, + #[cfg(feature = "install-to-disk")] + pub(crate) aboot_disk_layout: Option, } // Shared read-only global state @@ -1438,6 +1455,8 @@ pub(crate) struct RootSetup { /// Target root path /target. pub(crate) target_root_path: Option, pub(crate) rootfs_uuid: Option, + pub(crate) aboot_disk_layout: Option, + pub(crate) disk_install: bool, /// True if we should skip finalizing skip_finalize: bool, boot: Option, @@ -1683,6 +1702,7 @@ async fn prepare_install( mut target_opts: InstallTargetOpts, mut composefs_options: InstallComposefsOpts, target_fs: Option, + disk_install: bool, ) -> Result> { tracing::trace!("Preparing install"); let allow_missing_verity_explicit = composefs_options.allow_missing_verity; @@ -1780,15 +1800,22 @@ async fn prepare_install( }; tracing::debug!("Target image reference: {target_imgref}"); - let (composefs_required, kernel) = if let Some(root) = target_rootfs.as_ref() { - let kernel = crate::kernel::find_kernel(root)?; - - ( - kernel.as_ref().is_some_and(|k| k.k_type.is_unified()), - kernel, - ) + let kernel = target_rootfs + .as_ref() + .map(crate::kernel::find_kernel) + .transpose()? + .flatten(); + let composefs_required = kernel.as_ref().is_some_and(|k| k.k_type.is_unified()); + #[cfg(feature = "install-to-disk")] + let aboot_disk_layout = if disk_install { + kernel.as_ref().and_then(|k| match k.k_type { + crate::kernel::KernelType::Aboot { encoding, .. } => { + Some(AbootDiskLayout::from(encoding)) + } + _ => None, + }) } else { - (false, None) + None }; tracing::debug!("Composefs required: {composefs_required}"); @@ -1811,7 +1838,7 @@ async fn prepare_install( }) .transpose()?; - // A UKI requires the composefs backend, and a composefs-native image + // A UKI or aboot image requires the composefs backend, and a composefs-native image // without ostree's configuration defaults to it. let composefs_default = crate::bootc_composefs::image::defaults_to_composefs_backend( &rootfs, @@ -1984,6 +2011,8 @@ async fn prepare_install( .map(|fs| fs.supports_fsverity()) .unwrap_or(true), allow_missing_verity_explicit, + #[cfg(feature = "install-to-disk")] + aboot_disk_layout, }); Ok(state) @@ -2384,6 +2413,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.target_opts, opts.composefs_opts, block_opts.filesystem, + true, ) .await?; @@ -2790,6 +2820,7 @@ pub(crate) async fn install_to_filesystem( opts.target_opts, opts.composefs_opts, Some(inspect.fstype.as_str().try_into()?), + false, ) .await?; @@ -2948,6 +2979,8 @@ pub(crate) async fn install_to_filesystem( physical_root: rootfs_fd, target_root_path: Some(target_root_path.clone()), rootfs_uuid: inspect.uuid.clone(), + aboot_disk_layout: None, + disk_install: false, boot, kargs, skip_finalize, diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 75396fc4fa..28e89a9728 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -25,6 +25,7 @@ use clap::ValueEnum; use fn_error_context::context; use serde::{Deserialize, Serialize}; +use super::AbootDiskLayout; use super::BOOT; use super::MountSpec; use super::RUN_BOOTC; @@ -73,6 +74,14 @@ pub(crate) const EFIPN_SIZE_MB: u32 = 512; /// We need more space than ostree as we have UKIs and UKI addons /// We might also need to store UKIs for pinned deployments pub(crate) const CFS_EFIPN_SIZE_MB: u32 = 2048; +const ABOOT_BOOT_SIZE_MB: u32 = 192; +const ABOOT_VBMETA_SIZE_MB: u32 = 64; +const UKIBOOT_ESP_SIZE_MB: u32 = 100; +const UKIBOOT_CTL_SIZE_MB: u32 = 1; +pub(crate) const ABOOT_BOOT_GUID: &str = "20117f86-e985-4357-b9ee-374bc1d8487d"; +pub(crate) const ABOOT_VBMETA_GUID: &str = "4b7a15d6-322c-42ac-8110-88b7da0c5d77"; +pub(crate) const UKIBOOT_BOOT_GUID: &str = "df331e4d-be00-463f-b4a7-8b43e18fb53a"; +const UKIBOOT_CTL_GUID: &str = "fefd9070-346f-4c9a-85e6-17f07f922773"; #[cfg(feature = "install-to-disk")] pub(crate) const PREPBOOT_GUID: &str = "9E1A2D38-C612-4316-AA26-8B49521E5A8B"; #[cfg(feature = "install-to-disk")] @@ -214,6 +223,8 @@ struct RepartPartition { /// Ex. "esp", "root-x86_64" #[serde(rename = "type")] partition_type: String, + #[serde(default)] + label: Option, /// 0-indexed partition number /// absent in older systemd-repart /// versions, in c9s @@ -552,6 +563,233 @@ fn parse_repart_layout(partitions: &[RepartPartition]) -> Result &'static [&'static str] { + match layout { + AbootDiskLayout::Android => &["boot_a", "boot_b", "vbmeta_a", "vbmeta_b"], + AbootDiskLayout::Ukiboot => &["ukiboot_a", "ukiboot_b", "ukibootctl"], + } +} + +fn aboot_partition_type(label: &str) -> &'static str { + match label { + "boot_a" | "boot_b" => ABOOT_BOOT_GUID, + "vbmeta_a" | "vbmeta_b" => ABOOT_VBMETA_GUID, + "ukiboot_a" | "ukiboot_b" => UKIBOOT_BOOT_GUID, + "ukibootctl" => UKIBOOT_CTL_GUID, + _ => unreachable!("unknown aboot partition label"), + } +} + +fn validate_aboot_repart_partitions( + partitions: &[RepartPartition], + layout: AbootDiskLayout, +) -> Result<()> { + let other_layout = match layout { + AbootDiskLayout::Android => AbootDiskLayout::Ukiboot, + AbootDiskLayout::Ukiboot => AbootDiskLayout::Android, + }; + let labels = aboot_partition_labels(layout); + let other_labels = aboot_partition_labels(other_layout); + anyhow::ensure!( + !partitions + .iter() + .any(|p| other_labels.contains(&p.label.as_deref().unwrap_or(""))), + "repart.d contains partitions for a different aboot layout" + ); + for label in labels { + let count = partitions + .iter() + .filter(|p| p.label.as_deref() == Some(*label)) + .count(); + anyhow::ensure!( + count <= 1, + "Duplicate {label} partition in aboot repart.d layout" + ); + if let Some(partition) = partitions + .iter() + .find(|p| p.label.as_deref() == Some(*label)) + { + anyhow::ensure!( + partition + .partition_type + .eq_ignore_ascii_case(aboot_partition_type(label)), + "aboot partition {label} has the wrong type GUID" + ); + } + } + Ok(()) +} + +fn validate_repart_boot_layout( + partitions: &[RepartPartition], + layout: Option, +) -> Result<()> { + if let Some(layout) = layout { + return validate_aboot_repart_partitions(partitions, layout); + } + anyhow::ensure!( + !partitions + .iter() + .any(|p| [AbootDiskLayout::Android, AbootDiskLayout::Ukiboot] + .iter() + .any(|layout| aboot_partition_labels(*layout) + .contains(&p.label.as_deref().unwrap_or("")))), + "aboot repart.d layout requires a matching aboot boot artifact" + ); + Ok(()) +} + +fn image_repart_partitions( + device: &Device, + layout: Option, +) -> Result> { + if !can_use_systemd_repart() { + return Ok(Vec::new()); + } + let partitions = systemd_repart_run(device, false, true, None)?; + validate_repart_boot_layout(&partitions, layout)?; + Ok(partitions) +} + +fn verify_image_aboot_filesystems( + device: &Device, + aboot_layout: AbootDiskLayout, + partition_layout: &PartitionLayout, +) -> Result<()> { + let labels = aboot_partition_labels(aboot_layout); + for label in labels { + let partition = device + .children + .iter() + .flatten() + .find(|part| part.partlabel.as_deref() == Some(label)) + .ok_or_else(|| anyhow::anyhow!("Missing {label} partition"))?; + anyhow::ensure!( + partition + .parttype + .as_deref() + .is_some_and(|guid| guid.eq_ignore_ascii_case(aboot_partition_type(label))), + "aboot partition {label} has the wrong type GUID" + ); + anyhow::ensure!( + partition.fstype.is_none(), + "aboot partition {label} must not contain a filesystem" + ); + } + if aboot_layout == AbootDiskLayout::Ukiboot { + let esp = device + .find_device_by_partno(partition_layout.esp_partno.context("Missing ukiboot ESP")?)?; + anyhow::ensure!( + esp.fstype.as_deref() == Some("vfat"), + "ukiboot ESP must be formatted as vfat" + ); + } + Ok(()) +} + +fn aboot_repart_definitions( + layout: AbootDiskLayout, + root_size: Option, + rootfs: Option, + partitions: &[RepartPartition], +) -> Result> { + let mut definitions = Vec::new(); + if layout == AbootDiskLayout::Ukiboot && !partitions.iter().any(|p| p.partition_type == "esp") { + let esp = format!( + "[Partition]\nType=esp\nFormat=vfat\nSizeMinBytes={UKIBOOT_ESP_SIZE_MB}M\nSizeMaxBytes={UKIBOOT_ESP_SIZE_MB}M\n" + ); + definitions.push(("05-efi.conf".into(), esp)); + } + let mut add = |order: u32, name: &str, part_type: &str, size: u32, flags: Option<&str>| { + if partitions.iter().any(|p| p.label.as_deref() == Some(name)) { + return; + } + let mut config = format!( + "[Partition]\nType={part_type}\nLabel={name}\nSizeMinBytes={size}M\nSizeMaxBytes={size}M\n" + ); + if let Some(flags) = flags { + config.push_str(&format!("Flags={flags}\n")); + } + definitions.push((format!("{order:02}-{name}.conf"), config)); + }; + + match layout { + AbootDiskLayout::Android => { + add( + 10, + "boot_a", + ABOOT_BOOT_GUID, + ABOOT_BOOT_SIZE_MB, + Some("0x3f000000000000"), + ); + add( + 11, + "boot_b", + ABOOT_BOOT_GUID, + ABOOT_BOOT_SIZE_MB, + Some("0x3d000000000000"), + ); + for (order, name) in [(12, "vbmeta_a"), (13, "vbmeta_b")] { + add(order, name, ABOOT_VBMETA_GUID, ABOOT_VBMETA_SIZE_MB, None); + } + } + AbootDiskLayout::Ukiboot => { + for (order, name) in [(10, "ukiboot_a"), (11, "ukiboot_b")] { + add(order, name, UKIBOOT_BOOT_GUID, ABOOT_BOOT_SIZE_MB, None); + } + add( + 12, + "ukibootctl", + UKIBOOT_CTL_GUID, + UKIBOOT_CTL_SIZE_MB, + None, + ); + } + } + + if partitions + .iter() + .any(|p| p.partition_type.starts_with("root-")) + { + return Ok(definitions); + } + let rootfs = rootfs.context( + "No root repart.d definition found; specify --filesystem or a root filesystem in the install config", + )?; + let mut root = format!("[Partition]\nType=root\nFormat={rootfs}\n"); + if let Some(size) = root_size { + root.push_str(&format!("SizeMinBytes={size}M\nSizeMaxBytes={size}M\n")); + } + definitions.push(("50-root.conf".into(), root)); + Ok(definitions) +} + +#[context("Running systemd-repart for aboot")] +fn systemd_repart_aboot( + device: &Device, + layout: AbootDiskLayout, + image_partitions: &[RepartPartition], + generated_definitions: &[(String, String)], + generic_image: bool, +) -> Result { + let definitions = tempfile::tempdir().context("Creating aboot repart definitions directory")?; + collect_repart_definitions(image_partitions, definitions.path(), |part| { + !generic_image + || repart_partition_is_required(part) + || aboot_partition_labels(layout).contains(&part.label.as_deref().unwrap_or("")) + })?; + for (name, config) in generated_definitions { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(definitions.path().join(name)) + .with_context(|| format!("Creating generated repart definition {name}"))? + .write_all(config.as_bytes())?; + } + let partitions = systemd_repart_run(device, false, false, Some(definitions.path()))?; + parse_repart_layout(&partitions) +} + /// Use sfdisk to create partitions #[context("Running sfdisk")] fn sfdisk( @@ -686,7 +924,19 @@ pub(crate) fn install_create_rootfs( std::fs::remove_dir_all(&mntdir)?; } - let use_systemd_repart = run_repart && can_use_systemd_repart(); + if state.aboot_disk_layout.is_some() { + anyhow::ensure!( + has_systemd_repart(), + "aboot to-disk install requires systemd-repart" + ); + } + let image_partitions = if state.aboot_disk_layout.is_some() || run_repart { + image_repart_partitions(&device, state.aboot_disk_layout)? + } else { + Vec::new() + }; + let use_systemd_repart = + state.aboot_disk_layout.is_some() || (run_repart && can_use_systemd_repart()); // Use the install configuration to find the block setup, if we have one let block_setup = if let Some(config) = install_config { @@ -702,7 +952,21 @@ pub(crate) fn install_create_rootfs( }; let serial = device.serial.as_deref().unwrap_or(""); let model = device.model.as_deref().unwrap_or(""); - let discoverable = use_discoverable_partitions(state); + let discoverable = state.aboot_disk_layout.is_none() && use_discoverable_partitions(state); + if let Some(layout) = state.aboot_disk_layout { + anyhow::ensure!( + block_setup == BlockSetup::Direct, + "{layout:?} aboot layout requires --block-setup direct" + ); + anyhow::ensure!( + layout != AbootDiskLayout::Ukiboot || super::ARCH_USES_EFI, + "ukiboot disk layout requires x86_64 or aarch64" + ); + anyhow::ensure!( + state.composefs_options.composefs_backend, + "aboot disk layouts require the composefs backend" + ); + } println!("Block setup: {block_setup}"); println!(" Size: {}", device.size); println!(" Serial: {serial}"); @@ -718,6 +982,19 @@ pub(crate) fn install_create_rootfs( .map(bootc_blockdev::parse_size_mib) .transpose() .context("Parsing root size")?; + let aboot_definitions = state + .aboot_disk_layout + .map(|layout| { + aboot_repart_definitions( + layout, + root_size, + opts.filesystem.or(install_config + .and_then(|c| c.filesystem_root()) + .and_then(|r| r.fstype)), + &image_partitions, + ) + }) + .transpose()?; // Load the policy from the container root, which also must be our install root let sepolicy = state.load_policy()?; @@ -730,7 +1007,15 @@ pub(crate) fn install_create_rootfs( let bootfs = mntdir.join("boot"); std::fs::create_dir_all(bootfs)?; - let layout = if use_systemd_repart { + let layout = if let Some(aboot_disk_layout) = state.aboot_disk_layout { + systemd_repart_aboot( + &device, + aboot_disk_layout, + &image_partitions, + aboot_definitions.as_deref().unwrap(), + state.config_opts.generic_image, + )? + } else if use_systemd_repart { systemd_repart( &device, root_size, @@ -762,6 +1047,10 @@ pub(crate) fn install_create_rootfs( // Re-read partition table to get updated children device.refresh()?; + if let Some(aboot_layout) = state.aboot_disk_layout { + verify_image_aboot_filesystems(&device, aboot_layout, &layout)?; + } + // Ensure we have a root filesystem let root_filesystem = if layout.used_repart { let root = device.find_device_by_partno(layout.rootpn)?; @@ -980,8 +1269,138 @@ pub(crate) fn install_create_rootfs( physical_root, target_root_path: None, rootfs_uuid: Some(root_uuid.to_string()), + aboot_disk_layout: state.aboot_disk_layout, + disk_install: true, boot, kargs, skip_finalize: false, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aboot_repart_specs() { + let android = + aboot_repart_definitions(AbootDiskLayout::Android, None, Some(Filesystem::Ext4), &[]) + .unwrap(); + assert_eq!(android.len(), 5); + assert_eq!(android[0].0, "10-boot_a.conf"); + assert!(android[0].1.contains("Flags=0x3f000000000000\n")); + assert!(android[1].1.contains("Flags=0x3d000000000000\n")); + assert!(android[4].1.contains("Type=root\nFormat=ext4\n")); + + let ukiboot = aboot_repart_definitions( + AbootDiskLayout::Ukiboot, + Some(4096), + Some(Filesystem::Ext4), + &[], + ) + .unwrap(); + assert_eq!(ukiboot.len(), 5); + assert_eq!(ukiboot[0].0, "05-efi.conf"); + assert!(ukiboot[0].1.contains("Type=esp\nFormat=vfat\n")); + assert!( + ukiboot[4] + .1 + .contains("SizeMinBytes=4096M\nSizeMaxBytes=4096M\n") + ); + } + + #[test] + fn image_aboot_repart_layout() -> Result<()> { + let mut partitions: Vec = serde_json::from_value(serde_json::json!([ + { "type": "20117f86-e985-4357-b9ee-374bc1d8487d", "label": "boot_a", "file": "10-boot-a.conf" }, + { "type": "20117f86-e985-4357-b9ee-374bc1d8487d", "label": "boot_b", "file": "11-boot-b.conf" }, + { "type": "4b7a15d6-322c-42ac-8110-88b7da0c5d77", "label": "vbmeta_a", "file": "12-vbmeta-a.conf" }, + { "type": "4b7a15d6-322c-42ac-8110-88b7da0c5d77", "label": "vbmeta_b", "file": "13-vbmeta-b.conf" }, + { "type": "root-arm64", "label": "root", "file": "50-root.conf" } + ]))?; + validate_aboot_repart_partitions(&partitions, AbootDiskLayout::Android)?; + assert!(validate_repart_boot_layout(&partitions, None).is_err()); + assert!(validate_repart_boot_layout(&partitions, Some(AbootDiskLayout::Ukiboot)).is_err()); + validate_repart_boot_layout(&[], None)?; + let correct_type = + std::mem::replace(&mut partitions[0].partition_type, "linux-generic".into()); + assert!(validate_aboot_repart_partitions(&partitions, AbootDiskLayout::Android).is_err()); + partitions[0].partition_type = correct_type; + + let unrelated: RepartPartition = serde_json::from_value(serde_json::json!({ + "type": ABOOT_BOOT_GUID, + "label": "cache", + "file": "20-cache.conf" + }))?; + assert!(!repart_partition_is_required(&partitions[0])); + assert!(!repart_partition_is_required(&unrelated)); + validate_aboot_repart_partitions(&[], AbootDiskLayout::Android)?; + validate_aboot_repart_partitions(&partitions[..3], AbootDiskLayout::Android)?; + let missing = + aboot_repart_definitions(AbootDiskLayout::Android, None, None, &partitions[1..])?; + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].0, "10-boot_a.conf"); + Ok(()) + } + + #[test] + fn aboot_preserves_image_repart_definitions() -> Result<()> { + for (layout, generic_image) in [ + (AbootDiskLayout::Android, false), + (AbootDiskLayout::Android, true), + (AbootDiskLayout::Ukiboot, false), + (AbootDiskLayout::Ukiboot, true), + ] { + let source = tempfile::tempdir()?; + let destination = tempfile::tempdir()?; + let root_config = "[Partition]\nType=root\nFormat=ext4\nLabel=custom-root\nSizeMinBytes=2G\nSizeMaxBytes=4G\n"; + let root_file = source.path().join("20-root.conf"); + std::fs::write(&root_file, root_config)?; + let var_file = source.path().join("30-var.conf"); + std::fs::write(&var_file, "[Partition]\nType=var\nFormat=ext4\n")?; + let mut partitions: Vec = serde_json::from_value(serde_json::json!([ + { "type": "root-x86-64", "label": "custom-root", "file": root_file }, + { "type": "var", "file": var_file } + ]))?; + let esp_config = + "[Partition]\nType=esp\nFormat=vfat\nLabel=custom-esp\nSizeMinBytes=512M\n"; + if layout == AbootDiskLayout::Ukiboot { + let esp_file = source.path().join("05-esp.conf"); + std::fs::write(&esp_file, esp_config)?; + partitions.push(serde_json::from_value(serde_json::json!({ + "type": "esp", "file": esp_file + }))?); + } + + validate_aboot_repart_partitions(&partitions, layout)?; + let generated = aboot_repart_definitions(layout, Some(8192), None, &partitions)?; + assert_eq!(generated.len(), aboot_partition_labels(layout).len()); + assert!( + generated + .iter() + .all(|(_, config)| !config.contains("Format=")) + ); + collect_repart_definitions(&partitions, destination.path(), |part| { + !generic_image + || repart_partition_is_required(part) + || aboot_partition_labels(layout).contains(&part.label.as_deref().unwrap_or("")) + })?; + assert_eq!( + std::fs::read_to_string(destination.path().join("20-root.conf"))?, + root_config + ); + assert_eq!( + destination.path().join("30-var.conf").exists(), + !generic_image + ); + if layout == AbootDiskLayout::Ukiboot { + assert_eq!( + std::fs::read_to_string(destination.path().join("05-esp.conf"))?, + esp_config + ); + } + assert!(aboot_repart_definitions(layout, None, None, &[]).is_err()); + } + Ok(()) + } +} diff --git a/docs/src/bootc-experimental-composefs-aboot.7.md b/docs/src/bootc-experimental-composefs-aboot.7.md index e3218d1004..bd4d2ab9e3 100644 --- a/docs/src/bootc-experimental-composefs-aboot.7.md +++ b/docs/src/bootc-experimental-composefs-aboot.7.md @@ -62,8 +62,11 @@ backend and layout from its boot artifact and reads repart definitions from that same container. With `--source-imgref`, the source image's root filesystem is not available during preparation, so no aboot disk layout is inferred. The normally pulled image must match the selected encoding. -Supplied repart definitions must match the artifact's slot labels and partition -type GUIDs; otherwise bootc creates a default layout. Android uses `boot_a`, +Existing repart definitions are preserved. Supplied boot partition definitions +must use the artifact's slot labels and partition type GUIDs. Bootc adds +definitions for missing boot partitions and generates a root definition only +when none is supplied. In that case, specify the root filesystem in the install +configuration or with `--filesystem`. Android uses `boot_a`, `boot_b`, `vbmeta_a`, and `vbmeta_b`; ukiboot uses `ukiboot_a`, `ukiboot_b`, `ukibootctl`, and an ESP. Both layouts include a root filesystem and initialize both boot slots. Platform-specific sizing, device trees, and `/etc/aboot.cfg` diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index be5c6a6dae..f2c80693e5 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -39,6 +39,16 @@ The root partition uses an architecture-specific DPS type GUID. Specific partiti sizes and type GUIDs are implementation details that may change between versions; use `install to-filesystem` if you need precise control over the partition layout. +For aboot images, the installer recognizes the boot artifact when installing from the +running container and selects one of two A/B layouts. The `android` layout uses `boot_a`, +`boot_b`, `vbmeta_a`, `vbmeta_b`, and a root filesystem partition, without an ESP. +The `ukiboot` layout uses an ESP, `ukiboot_a`, `ukiboot_b`, `ukibootctl`, and a root +filesystem partition. Additionally the aboot artifact is written to the boot partitions. +The installer preserves partitions defined by the running installer's `repart.d` +configuration and adds definitions for missing required partitions. +The built in partition layouts are not intended as universal layouts for all +possible aboot hardware, but work at least for virtualized testing. + ### Root filesystem discovery The root partition can be discovered at boot time in two ways: @@ -222,3 +232,8 @@ Install with custom kernel arguments: # VERSION + +For aboot to-disk installation from the running container image, its boot +artifacts determine the backend and disk layout. The pulled image must contain a +matching aboot artifact. Automatic aboot disk-layout selection is not performed +with `--source-imgref`. From fe824d53a1d0e320f8cd6832ba1517f20f1cbee1 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Fri, 9 Oct 2026 14:01:34 +0200 Subject: [PATCH 14/14] tests: Add integration test for ukiboot with install to-disk Exercise the native aboot installer through the existing bcvk and TMT runner on x86_64. Derive the image from the OSTree test rootfs so backend selection must follow the boot artifact, and verify the default disk layout, both payload slots, and boot completion. After that we try an update and rollback. Note: This manually installs bcvk 0.21.0, this can be dropped once bootc-dev/actions has been updated. Generated-by: AI Signed-off-by: Alexander Larsson --- .github/workflows/ci.yml | 47 ++++++++++++++- Justfile | 13 ++++ crates/xtask/src/xtask.rs | 5 +- tmt/plans/integration.fmf | 25 ++++++++ tmt/tests/Dockerfile.ukiboot | 34 +++++++++++ tmt/tests/Dockerfile.ukiboot.dockerignore | 2 + tmt/tests/booted/aboot_testlib.py | 43 ++++++++++++++ tmt/tests/booted/test-aboot-install.py | 52 ++++++++++++++++ .../booted/test-aboot-update-rollback.py | 59 +++++++++++++++++++ tmt/tests/tests.fmf | 18 ++++++ tmt/tests/ukiboot/aboot.cfg | 1 + tmt/tests/ukiboot/kargs.toml | 1 + 12 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 tmt/tests/Dockerfile.ukiboot create mode 100644 tmt/tests/Dockerfile.ukiboot.dockerignore create mode 100644 tmt/tests/booted/aboot_testlib.py create mode 100644 tmt/tests/booted/test-aboot-install.py create mode 100644 tmt/tests/booted/test-aboot-update-rollback.py create mode 100644 tmt/tests/ukiboot/aboot.cfg create mode 100644 tmt/tests/ukiboot/kargs.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256f1a9922..84006f024b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -655,6 +655,51 @@ jobs: name: container-export-test-${{ env.ARCH }} path: target/anaconda-test/*.log + test-ukiboot: + if: needs.compute-ci-level.outputs.run_heavy == 'true' && contains(fromJson(needs.compute-ci-level.outputs.package_os_matrix), 'fedora-44') + needs: [compute-ci-level, package] + runs-on: ubuntu-26.04 + env: + BOOTC_base: quay.io/fedora/fedora-bootc:44 + BOOTC_buildroot_base: quay.io/fedora/fedora:44 + BOOTC_variant: ostree + BOOTC_SKIP_PACKAGE: "1" + steps: + - uses: actions/checkout@v7 + - name: Bootc Ubuntu Setup + uses: bootc-dev/actions/bootc-ubuntu-setup@main + with: + libvirt: true + - name: Install bcvk with aboot support + # Temporary: Drop once bootc-dev/actions has 0.21.0 or later + run: | + set -euo pipefail + # renovate: datasource=github-releases depName=bootc-dev/bcvk + version=0.21.0 + target=bcvk-x86_64-unknown-linux-gnu + td=$(mktemp -d) + curl -fL --retry 5 --retry-delay 10 "https://github.com/bootc-dev/bcvk/releases/download/v${version}/${target}.tar.gz" -o "$td/bcvk.tar.gz" + tar -xzf "$td/bcvk.tar.gz" -C "$td" + sudo install -m 0755 "$td/$target" /usr/local/bin/bcvk + bcvk --version + - name: Install tmt + uses: ./.github/actions/install-tmt + - name: Download package artifacts + uses: actions/download-artifact@v8 + with: + name: packages-fedora-44 + path: target/packages/ + - name: Fetch external dependencies + run: just build-fetch + - name: Install, update, and roll back ukiboot image + run: just test-ukiboot + - name: Archive test logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: ukiboot-test-x86_64 + path: /var/tmp/tmt + # Gates the full heavy suite. Runs in merge_group / workflow_dispatch, and # also directly on PRs whenever compute-ci-level found no merge queue # active (see the merge-queue check in that job) — in that case a PR is @@ -666,7 +711,7 @@ jobs: # rather than being skipped along with it. required-checks-heavy: if: ${{ always() && (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.compute-ci-level.outputs.merge_queue_enabled != 'true')) }} - needs: [compute-ci-level, cargo-deny, validate, install-tests, docs, package, test-integration, test-upgrade, test-baseconfigs, test-container-export] + needs: [compute-ci-level, cargo-deny, validate, install-tests, docs, package, test-integration, test-upgrade, test-baseconfigs, test-container-export, test-ukiboot] # Cheap aggregation (jq only) — no need for a full VM. runs-on: ubuntu-slim steps: diff --git a/Justfile b/Justfile index afcb3a1984..41f1225562 100644 --- a/Justfile +++ b/Justfile @@ -33,6 +33,7 @@ base_img := env("BOOTC_image", "localhost/bootc") upgrade_img := base_img + "-upgrade" # Base image with tmt dependencies added, used as the boot source for upgrade tests upgrade_source_img := base_img + "-upgrade-source" +ukiboot_img := base_img + "-ukiboot" # Build variant: ostree (default) or composefs variant := env("BOOTC_variant", "ostree") @@ -192,6 +193,18 @@ test-tmt *ARGS: build @just _build-upgrade-image @just test-tmt-nobuild {{ARGS}} +[group('testing')] +build-ukiboot-test-image: build + podman build {{_nocache_arg}} --build-arg=base={{base_img}} -f tmt/tests/Dockerfile.ukiboot -t {{ukiboot_img}}:v1 . + +[group('testing')] +build-ukiboot-update-test-image: build-ukiboot-test-image + podman build {{_nocache_arg}} --build-arg=base={{base_img}} --build-arg=TEST_VERSION=2 -f tmt/tests/Dockerfile.ukiboot -t {{ukiboot_img}}:v2 . + +[group('testing')] +test-ukiboot: build-ukiboot-update-test-image + cargo xtask run-tmt --env=BOOTC_variant=composefs --filesystem=ext4 --boot-type=aboot --seal-state=unsealed --upgrade-image={{ukiboot_img}}:v2 {{ukiboot_img}}:v1 aboot + # Split out from `test-container` because, unlike the container integration tests, # unit tests don't depend on variant/filesystem/bootloader/boot_type/seal_state, so # CI runs this once per OS (in the `package` job) instead of once per test-integration diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index f5c47d88cb..710e885835 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -205,6 +205,8 @@ pub enum BootType { Bls, /// UKI boot Uki, + /// Aboot boot + Aboot, } impl Display for BootType { @@ -212,6 +214,7 @@ impl Display for BootType { match self { BootType::Bls => f.write_str("bls"), BootType::Uki => f.write_str("uki"), + BootType::Aboot => f.write_str("aboot"), } } } @@ -310,7 +313,7 @@ pub(crate) struct RunTmtArgs { #[arg(long, env = "BOOTC_seal_state")] pub(crate) seal_state: Option, - /// Boot entry type (bls or uki) + /// Boot entry type (bls, uki, or aboot) #[arg(long, env = "BOOTC_boot_type", default_value_t)] pub(crate) boot_type: BootType, diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 90e02dc28b..805a7c125a 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -367,4 +367,29 @@ execute: test: - /tmt/tests/tests/test-60-install-composefs-native extra-fixme_skip_if_uki: true + +/plan-61-aboot-install: + summary: Test artifact-selected ukiboot installation + enabled: false + discover: + how: fmf + test: + - /tmt/tests/tests/test-61-aboot-install + adjust: + - when: boot_type == aboot + enabled: true + extra-skip_if_ostree: true + +/plan-62-aboot-update-rollback: + summary: Test ukiboot A/B update and rollback + enabled: false + discover: + how: fmf + test: + - /tmt/tests/tests/test-62-aboot-update-rollback + adjust: + - when: boot_type == aboot + enabled: true + extra-try_bind_storage: true + extra-skip_if_ostree: true # END GENERATED PLANS diff --git a/tmt/tests/Dockerfile.ukiboot b/tmt/tests/Dockerfile.ukiboot new file mode 100644 index 0000000000..5b1f4acee9 --- /dev/null +++ b/tmt/tests/Dockerfile.ukiboot @@ -0,0 +1,34 @@ +ARG base=localhost/bootc +FROM ${base} AS rootfs +COPY tmt/tests/ukiboot/aboot.cfg /etc/aboot.cfg +COPY tmt/tests/ukiboot/kargs.toml /usr/lib/bootc/kargs.d/90-ukiboot.toml +RUN --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + dnf -y --setopt=install_weak_deps=False install aboot-deploy aboot-update ukiboot systemd-ukify systemd-boot && \ + dnf clean all && rm -rf /var/cache /var/log /var/lib/dnf && \ + rm -rf /etc/repart.d /run/repart.d /usr/lib/repart.d /usr/local/lib/repart.d /usr/lib/bootc/bound-images.d && \ + systemctl enable ukiboot-set-success.service +ARG TEST_VERSION=1 +RUN --network=none printf '%s\n' "${TEST_VERSION}" > /usr/share/bootc-aboot-test-version + +FROM rootfs AS split +RUN --network=none mkdir -p /boot/.kernel && \ + bootc container split-kernel-and-rootfs --rootfs / --output /boot/.kernel + +FROM scratch AS flattened +COPY --from=split / / + +FROM rootfs AS aboot +RUN --network=none --mount=type=bind,from=flattened,source=/,target=/target,ro <<'EOF' +set -eux +kver=$(ls /target/boot/.kernel) +mkdir -p /out +bootc container aboot --rootfs /target --kernel-dir "/target/boot/.kernel/${kver}" --out /out --allow-missing-verity +test -s "/out/aboot-${kver}.img" +EOF + +FROM flattened +RUN --network=none rm -rf /boot/.kernel +COPY --from=aboot /out/ /boot/ +LABEL containers.bootc="1" +CMD ["/sbin/init"] +RUN --network=none bootc container lint --skip nonempty-boot --fatal-warnings diff --git a/tmt/tests/Dockerfile.ukiboot.dockerignore b/tmt/tests/Dockerfile.ukiboot.dockerignore new file mode 100644 index 0000000000..e7898478f3 --- /dev/null +++ b/tmt/tests/Dockerfile.ukiboot.dockerignore @@ -0,0 +1,2 @@ +* +!tmt/tests/ukiboot/ diff --git a/tmt/tests/booted/aboot_testlib.py b/tmt/tests/booted/aboot_testlib.py new file mode 100644 index 0000000000..07c94e05d7 --- /dev/null +++ b/tmt/tests/booted/aboot_testlib.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path +import re +import subprocess +import time + + +def run(*args): + return subprocess.check_output(args, text=True).strip() + + +def status(): + return json.loads(run("bootc", "status", "--json"))["status"] + + +def verify_boot(slot, version): + assert run("systemctl", "is-enabled", "ukiboot-set-success.service") == "enabled" + for service in ("ukiboot-set-success.service", "bootc-aboot-reconcile.service"): + for attempt in range(60): + active = run("systemctl", "show", "--property=ActiveState", "--value", service) + assert active != "failed", service + if active == "active": + break + time.sleep(1) + else: + raise AssertionError(f"{service} did not finish at boot") + assert run("systemctl", "show", "--property=Result", "--value", service) == "success" + st = status() + booted = st["booted"] + assert booted["ostree"] is None, booted + assert booted["composefs"]["bootType"] == "Aboot", booted + assert run("findmnt", "--noheadings", "--output", "FSTYPE", "--target", "/sysroot") == "ext4" + assert st["staged"] is None, st + assert not st["rollbackQueued"], st + assert Path("/usr/share/bootc-aboot-test-version").read_text().strip() == str(version) + assert f"androidboot.slot_suffix=_{slot}" in Path("/proc/cmdline").read_text().split() + mapping = Path(f"/sysroot/state/boot/aboot/slots/{slot}").read_text().strip() + assert mapping == booted["composefs"]["verity"], (mapping, booted) + control = run("ukibootctl", "dump") + print(control) + slot_number = {"a": 0, "b": 1}[slot] + assert re.search(rf"^Slot {slot_number}: .*successful_boot: 1$", control, re.MULTILINE), control + return st diff --git a/tmt/tests/booted/test-aboot-install.py b/tmt/tests/booted/test-aboot-install.py new file mode 100644 index 0000000000..cfccd91f96 --- /dev/null +++ b/tmt/tests/booted/test-aboot-install.py @@ -0,0 +1,52 @@ +# number: 61 +# tmt: +# summary: Test artifact-selected ukiboot installation +# duration: 30m +# enabled: false +# adjust: +# - when: boot_type == aboot +# enabled: true +# extra: +# skip_if_ostree: true + +import hashlib +import json +from pathlib import Path + +from aboot_testlib import run, verify_boot + + +def main(): + print("TAP version 14\nukiboot installation and boot") + verify_boot("a", 1) + root_disk = Path("/dev/disk/by-partlabel/root-x86-64").resolve() + parent = run("lsblk", "--noheadings", "--output", "PKNAME", str(root_disk)) + table = json.loads(run("sfdisk", "--json", f"/dev/{parent}"))["partitiontable"] + parts = table["partitions"] + expected = { + "esp": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "ukiboot_a": "df331e4d-be00-463f-b4a7-8b43e18fb53a", + "ukiboot_b": "df331e4d-be00-463f-b4a7-8b43e18fb53a", + "ukibootctl": "fefd9070-346f-4c9a-85e6-17f07f922773", + "root-x86-64": "4f68bce3-e8cd-4db1-96e7-fbcaf984b709", + } + assert len(parts) == len(expected), parts + assert {p["name"]: p["type"].lower() for p in parts} == expected, parts + payloads = [] + for slot in ("a", "b"): + part = next(p for p in parts if p["name"] == f"ukiboot_{slot}") + assert part["size"] * table["sectorsize"] == 192 * 1024 * 1024, part + with open(f"/dev/disk/by-partlabel/ukiboot_{slot}", "rb") as payload: + assert payload.read(2) == b"MZ" + payload.seek(0) + payloads.append(hashlib.file_digest(payload, "sha256").hexdigest()) + assert payloads[0] == payloads[1], payloads + esp = Path(run("findmnt", "--noheadings", "--output", "TARGET", "--source", "/dev/disk/by-partlabel/esp")) + assert (esp / "EFI/BOOT/BOOTX64.EFI").is_file() + for slot in ("a", "b"): + assert list(esp.glob(f"EFI/*/ukiboot_{slot}.efi.extra.d/slot_{slot}.addon.efi")) + print("ok 1 - ukiboot installation and boot") + + +if __name__ == "__main__": + main() diff --git a/tmt/tests/booted/test-aboot-update-rollback.py b/tmt/tests/booted/test-aboot-update-rollback.py new file mode 100644 index 0000000000..7ff790827d --- /dev/null +++ b/tmt/tests/booted/test-aboot-update-rollback.py @@ -0,0 +1,59 @@ +# number: 62 +# tmt: +# summary: Test ukiboot A/B update and rollback +# duration: 30m +# enabled: false +# adjust: +# - when: boot_type == aboot +# enabled: true +# extra: +# skip_if_ostree: true +# try_bind_storage: true + +import json +import os +from pathlib import Path +import subprocess + +from aboot_testlib import run, status, verify_boot + +ORIGINAL = Path("/var/tmp/bootc-aboot-original.json") + + +def main(): + print("TAP version 14\nukiboot update and rollback") + count = int(os.environ.get("TMT_REBOOT_COUNT", "0")) + target = os.environ["BOOTC_upgrade_image"] + if count == 0: + initial = verify_boot("a", 1) + ORIGINAL.write_text(json.dumps(initial["booted"])) + run("bootc", "switch", "--transport", "containers-storage", target) + staged = status() + assert staged["booted"] == initial["booted"], staged + assert staged["staged"]["image"]["image"]["image"] == target, staged + assert staged["staged"]["composefs"]["bootType"] == "Aboot", staged + assert staged["staged"]["composefs"]["verity"] != initial["booted"]["composefs"]["verity"], staged + subprocess.run(["tmt-reboot"], check=True) + elif count == 1: + original = json.loads(ORIGINAL.read_text()) + updated = verify_boot("b", 2) + assert updated["booted"]["image"]["image"]["image"] == target, updated + assert updated["booted"]["composefs"]["verity"] != original["composefs"]["verity"], updated + assert updated["rollback"]["image"] == original["image"], updated + assert updated["rollback"]["composefs"]["verity"] == original["composefs"]["verity"], updated + run("bootc", "rollback") + assert status()["rollbackQueued"] + subprocess.run(["tmt-reboot"], check=True) + elif count == 2: + original = json.loads(ORIGINAL.read_text()) + restored = verify_boot("a", 1) + assert restored["booted"]["image"] == original["image"], restored + assert restored["booted"]["composefs"]["verity"] == original["composefs"]["verity"], restored + ORIGINAL.unlink() + print("ok 1 - ukiboot A/B update and rollback") + else: + raise AssertionError(f"Unexpected reboot count {count}") + + +if __name__ == "__main__": + main() diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 28300bfebe..a344255455 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -233,3 +233,21 @@ check: summary: Test that composefs-native images default to the composefs backend duration: 45m test: nu booted/test-install-composefs-native.nu + +/test-61-aboot-install: + summary: Test artifact-selected ukiboot installation + duration: 30m + enabled: false + adjust: + - when: boot_type == aboot + enabled: true + test: python3 booted/test-aboot-install.py + +/test-62-aboot-update-rollback: + summary: Test ukiboot A/B update and rollback + duration: 30m + enabled: false + adjust: + - when: boot_type == aboot + enabled: true + test: python3 booted/test-aboot-update-rollback.py diff --git a/tmt/tests/ukiboot/aboot.cfg b/tmt/tests/ukiboot/aboot.cfg new file mode 100644 index 0000000000..6188e4ca1a --- /dev/null +++ b/tmt/tests/ukiboot/aboot.cfg @@ -0,0 +1 @@ +BOOT_TYPE=ukiboot diff --git a/tmt/tests/ukiboot/kargs.toml b/tmt/tests/ukiboot/kargs.toml new file mode 100644 index 0000000000..babd0be256 --- /dev/null +++ b/tmt/tests/ukiboot/kargs.toml @@ -0,0 +1 @@ +kargs = ["root=gpt-auto", "rw"]