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/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 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/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/boot_artifact.rs b/crates/lib/src/boot_artifact.rs new file mode 100644 index 0000000000..e504734442 --- /dev/null +++ b/crates/lib/src/boot_artifact.rs @@ -0,0 +1,212 @@ +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 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?; + 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 + }; + + for karg in composefs_kargs_for_boot_artifact( + composefs_digest, + erofs_version, + compatibility_v2_digest, + allow_missing_fsverity, + ) { + for param in &Cmdline::from(karg) { + cmdline.add_or_modify(¶m); + } + } + + 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/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/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..155bd6dce7 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; @@ -112,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, @@ -364,6 +370,7 @@ pub enum BootType { #[default] Bls, Uki, + Aboot, } impl ::std::fmt::Display for BootType { @@ -371,6 +378,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 +392,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 +406,7 @@ impl From<&ComposefsBootEntry> for BootType { ComposefsBootEntry::Type1(..) => Self::Bls, ComposefsBootEntry::Type2(..) => Self::Uki, ComposefsBootEntry::UsrLibModulesVmLinuz(..) => Self::Bls, + ComposefsBootEntry::Aboot(..) => Self::Aboot, } } } @@ -641,6 +651,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 @@ -909,6 +937,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) @@ -1162,13 +1191,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) } @@ -1409,41 +1438,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); @@ -1452,42 +1480,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, @@ -1504,7 +1540,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, @@ -1512,10 +1548,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, @@ -1784,7 +1820,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) // @@ -1826,6 +1862,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 @@ -2093,44 +2130,315 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } -#[context("Setting up composefs boot")] -pub(crate) async fn setup_composefs_boot( - root_setup: &RootSetup, - state: &State, - pull_result: &composefs_oci::PullResult, +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 validate_composefs_aboot_payload( + id: &Sha512HashValue, + boot_ids: &ExpectedBootImageIds, + encoding: AbootEncoding, + payload: &[u8], + allow_missing_fsverity: bool, +) -> Result { + 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<()> { - const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; + 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 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" + ), + _ => {} + } - 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", + 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(()) +} - let mut repo = open_composefs_repo(&root_setup.physical_root)?; - if allow_missing_fsverity { - repo.set_insecure(); +#[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(()) +} - let repo = Arc::new(repo); +pub(crate) struct PreparedAbootArtifacts { + pub(crate) boot_digest: String, + pub(crate) payload: Box<[u8]>, + pub(crate) vbmeta: Option>, +} - let crate::bootc_composefs::repo::BootImage { +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, - fs, - entries, - } = crate::bootc_composefs::repo::prepare_boot_image(&repo, pull_result)?; + 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, + }) +} - let composefs_mnt_fd = repo - .mount(&id.to_hex()) - .context("Failed to mount composefs image")?; +fn install_composefs_bootloader( + root_setup: &RootSetup, + state: &State, + 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)?; - let postfetch = PostFetchState::new(state, mounted_root.dir())?; - + 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()) @@ -2142,10 +2450,7 @@ pub(crate) async fn setup_composefs_boot( &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 @@ -2165,7 +2470,7 @@ pub(crate) async fn setup_composefs_boot( )?; // 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 @@ -2216,13 +2521,97 @@ pub(crate) async fn setup_composefs_boot( ) })?; } + Ok(()) +} - let Some(entry) = entries.iter().next() else { - anyhow::bail!("No boot entries!"); - }; +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, + 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 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")?; + let image_root = Dir::reopen_dir(&composefs_mnt_fd)?; + + let postfetch = PostFetchState::new(state, &image_root, image_bootloader(entry))?; + + 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!( "BUG: Arc still has other references after boot image generation" @@ -2238,7 +2627,7 @@ pub(crate) async fn setup_composefs_boot( &provisional_deploy_id, provisional_format, entry, - mounted_root.dir(), + &image_root, )?, provisional_deploy_id, ), @@ -2253,13 +2642,27 @@ pub(crate) async fn setup_composefs_boot( &repo, &fs, )?, + BootType::Aboot => { + let artifacts = prepare_composefs_aboot_artifacts( + &repo, + &id, + &boot_ids, + 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) + } }; 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(), @@ -2273,7 +2676,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, @@ -2308,6 +2711,67 @@ 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 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<()> { @@ -2340,6 +2804,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"); @@ -2521,6 +3131,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 97295f3896..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,6 +156,7 @@ fn delete_depl_boot_entries( BootType::Uki => { remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } + BootType::Aboot => anyhow::bail!("Unexpected aboot deployment in boot-entry deletion"), }, BootloaderKind::BLSCompatible => { @@ -260,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 642e8d6d40..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,6 +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 => unreachable!(), }, BootloaderKind::BLSCompatible => { @@ -195,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 06fc1f090a..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,6 +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 artifact unexpectedly reached boot binary garbage collection") + } } } @@ -327,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() @@ -505,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 { @@ -612,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/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/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 307545463d..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,21 +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)?; - } - }, - - 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 dd15d7c2f8..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,8 +237,8 @@ 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) -/// * `boot_type` - Boot loader type (`Bls` or `Uki`) +/// * `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 /// manifest+config can be retrieved from the composefs repo later @@ -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 807391172a..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::{ @@ -333,8 +334,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") } }; @@ -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, }), @@ -714,6 +716,7 @@ fn set_soft_reboot_capability( } BootType::Uki => set_reboot_capable_uki_deployments(storage, booted_cmdline, host), + BootType::Aboot => Ok(()), } } @@ -920,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, @@ -927,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 @@ -939,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 @@ -982,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 @@ -1005,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; } @@ -1074,6 +1173,9 @@ async fn composefs_deployment_status_from( (is_rollback_queued, None, Some(menuentries)) } + BootType::Aboot => { + anyhow::bail!("Aboot deployment unexpectedly reached GRUB status handling") + } }, // We will have BLS stuff and the UKI stuff in the same DIR @@ -1153,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 c0dcafe346..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,25 +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")?; + } } - } - }, - - 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. @@ -223,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, @@ -270,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)?; @@ -306,9 +327,15 @@ 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); + 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( &repo @@ -328,9 +355,8 @@ 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 mut aboot_artifacts = None; let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( @@ -356,6 +382,18 @@ pub(crate) async fn do_upgrade( &repo, &oci_fs, )?, + 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 @@ -381,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, @@ -389,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 // @@ -428,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 0a7c0e5987..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, @@ -533,6 +535,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 @@ -824,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 @@ -2253,39 +2280,33 @@ 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(), ) .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, @@ -2413,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: _, @@ -2898,6 +2938,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/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/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/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/install.rs b/crates/lib/src/install.rs index 03eeb22172..a53e28f49c 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" @@ -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 @@ -772,8 +789,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 +798,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(()) } @@ -1436,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, @@ -1681,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; @@ -1778,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().map(|k| k.kernel.unified).unwrap_or(false), - 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}"); @@ -1809,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, @@ -1897,7 +1926,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 @@ -1907,11 +1936,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, }; @@ -1924,11 +1951,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(); } @@ -1939,7 +1968,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", ); @@ -1982,26 +2011,23 @@ 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) } 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, @@ -2010,6 +2036,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 @@ -2037,7 +2078,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 @@ -2060,7 +2101,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 => { @@ -2225,11 +2266,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 { @@ -2240,7 +2283,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 @@ -2370,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?; @@ -2776,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?; @@ -2934,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, @@ -3189,7 +3236,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), ]; @@ -3218,6 +3267,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/install/baseline.rs b/crates/lib/src/install/baseline.rs index d45688a596..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 @@ -229,7 +240,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 +251,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 +273,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 +315,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 +347,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 +370,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 +383,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 +437,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 +454,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 +462,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 +495,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. @@ -529,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( @@ -663,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 { @@ -679,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}"); @@ -695,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()?; @@ -707,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, @@ -739,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)?; @@ -957,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/crates/lib/src/kernel.rs b/crates/lib/src/kernel.rs index 83e7cc24cf..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,9 +337,24 @@ 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`. 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 +370,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()); } @@ -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(()) } @@ -361,4 +663,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(()) + } } diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index edd50e347b..cb00f6a3ed 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -63,6 +63,8 @@ //! - [`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; mod bootloader; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index b58ed0ef18..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") + } } } } @@ -469,6 +475,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/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/crates/lib/src/ukify.rs b/crates/lib/src/ukify.rs index 669876919f..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,199 +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::Uki { path, .. } => { - anyhow::bail!("Cannot build UKI: rootfs already contains a UKI 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 UKI") || 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); - } - } -} 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/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/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fe4d0ebfa2..122a5b7971 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 @@ -87,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..bd4d2ab9e3 --- /dev/null +++ b/docs/src/bootc-experimental-composefs-aboot.7.md @@ -0,0 +1,95 @@ +# 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. +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` +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). diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 8e0fa84dd6..80bda0e8f6 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": { @@ -225,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-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-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 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 - diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index a68ece0078..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: @@ -168,6 +178,7 @@ set `discoverable-partitions = true` in their install configuration - grub - grub-cc - systemd + - ukiboot - none **--via-loopback** @@ -221,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`. 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** 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 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"]