From bad25cd6004a36cf977435ffde254102b680bcf7 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Tue, 3 Mar 2026 18:22:54 +0000 Subject: [PATCH] install/flat: Use composefs pipeline for rootfs and related changes - Use composefs-rs for SELinux labeling and kernel installation - Implement streaming copy for kernel/initramfs files - Consolidate bootloader installation logic - Add /usr overlay status tracking - Various test and documentation updates Note: blockdev.rs and ADOPTERS.md changes moved to separate PRs per review feedback Assisted-by: Claude Code (Opus) Signed-off-by: Eric Curtin --- Cargo.toml | 3 +- crates/lib/src/bootc_composefs/boot.rs | 2 +- crates/lib/src/install.rs | 414 +++++++++++++++++- crates/lib/src/install/baseline.rs | 1 + crates/mount/Cargo.toml | 5 +- crates/mount/src/lib.rs | 10 + crates/mount/src/mount.rs | 2 - crates/mount/src/tempmount.rs | 8 + crates/tests-integration/src/install.rs | 76 ++++ .../src/tests-integration.rs | 7 + crates/utils/Cargo.toml | 1 + crates/utils/src/command.rs | 22 +- docs/src/SUMMARY.md | 11 + docs/src/man/bootc-install-to-filesystem.8.md | 4 + hack/Containerfile.flat-test | 20 + 15 files changed, 553 insertions(+), 33 deletions(-) create mode 100644 crates/mount/src/lib.rs create mode 100644 hack/Containerfile.flat-test diff --git a/Cargo.toml b/Cargo.toml index 7e0add2c5..4b87a7143 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,10 +44,11 @@ clap_mangen = { version = "0.2.20" } # If adding/removing crates here, also update docs/Dockerfile.mdbook and docs/src/internals.md. # # To develop against a local composefs-rs checkout, add a [patch] section at the end of this file: -# [patch."https://github.com/containers/composefs-rs"] +# [patch."https://github.com/composefs/composefs-rs"] # composefs = { path = "/home/user/src/composefs-rs/crates/composefs" } # composefs-boot = { path = "/home/user/src/composefs-rs/crates/composefs-boot" } # composefs-oci = { path = "/home/user/src/composefs-rs/crates/composefs-oci" } +# cfsctl = { path = "/home/user/src/composefs-rs/crates/cfsctl" } # The Justfile will auto-detect these and bind-mount them into container builds. composefs = { git = "https://github.com/composefs/composefs-rs", rev = "b928c6bd6c051e111d3efc3d25cdaf9159182ed0", package = "composefs", features = ["rhel9"] } cfsctl = { git = "https://github.com/composefs/composefs-rs", rev = "b928c6bd6c051e111d3efc3d25cdaf9159182ed0", package = "cfsctl", features = ["rhel9"] } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index f64540c5b..c232c83c9 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -730,7 +730,7 @@ pub(crate) fn setup_composefs_bls_boot( let (config_path, booted_bls) = if is_upgrade { let boot_dir = Dir::open_ambient_dir(&entry_paths.config_path, ambient_authority())?; - let mut booted_bls = get_booted_bls(&boot_dir)?; + let mut booted_bls: BLSConfig = get_booted_bls(&boot_dir)?; booted_bls.sort_key = Some(secondary_sort_key(&os_id)); let staged_path = loader_path.join(STAGED_BOOT_LOADER_ENTRIES); diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 6ab5a3cc0..439d73a91 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -173,6 +173,10 @@ use cap_std_ext::cap_tempfile::TempDir; use cap_std_ext::cmdext::CapStdExtCommandExt; use cap_std_ext::prelude::CapStdExtDirExt; use clap::ValueEnum; +use composefs::fs::write_to_path; +use composefs_boot::bootloader::{BootEntry, get_boot_resources}; +use composefs_boot::selabel; +use composefs_oci::image::create_filesystem as create_composefs_filesystem; use fn_error_context::context; use ostree::gio; use ostree_ext::ostree; @@ -236,6 +240,9 @@ const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ /// Kernel argument used to specify we want the rootfs mounted read-write by default pub(crate) const RW_KARG: &str = "rw"; +/// Marker file written to the target root to indicate a flat (non-ostree) install was performed. +pub(crate) const FLAT_INSTALL_MARKER: &str = "etc/.bootc-flat"; + #[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallTargetOpts { // TODO: A size specifier which allocates free space for the root in *addition* to the base container image size @@ -486,6 +493,15 @@ pub(crate) struct InstallTargetFilesystemOpts { /// is then the responsibility of the invoking code to perform those operations. #[clap(long)] pub(crate) skip_finalize: bool, + + /// Install in "flat" mode: the container rootfs is written to the target filesystem + /// as a regular writable directory tree (no composefs overlay at boot time). This is + /// experimental. Post-install, bootc day-2 operations (upgrade, rollback, etc.) are + /// unavailable on the installed system. A composefs repository is created at + /// `/sysroot/composefs` as an intermediate step; it can be removed post-install or + /// retained to enable future conversion to immutable mode. + #[clap(long)] + pub(crate) flat: bool, } #[derive(Debug, Clone, clap::Parser, PartialEq, Eq)] @@ -1263,6 +1279,8 @@ pub(crate) struct RootSetup { skip_finalize: bool, boot: Option, pub(crate) kargs: CmdlineOwned, + /// If true, perform a flat installation (no ostree/composefs) + pub(crate) flat: bool, } fn require_boot_uuid(spec: &MountSpec) -> Result<&str> { @@ -1793,29 +1811,30 @@ async fn install_with_sysroot( .context("Opening deployment dir")?; let postfetch = PostFetchState::new(state, &deployment_dir)?; + // Install bootloader, handling architecture-specific requirements + let target_root = rootfs + .target_root_path + .as_ref() + .unwrap_or(&rootfs.physical_root_path); + if cfg!(target_arch = "s390x") { // TODO: Integrate s390x support into install_via_bootupd - crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?; + install_bootloader_via_zipl(&rootfs.device_info, boot_uuid)?; } else { - match postfetch.detected_bootloader { - Bootloader::Grub => { - crate::bootloader::install_via_bootupd( - &rootfs.device_info, - &rootfs - .target_root_path - .clone() - .unwrap_or(rootfs.physical_root_path.clone()), - &state.config_opts, - Some(&deployment_path.as_str()), - )?; - } - Bootloader::Systemd => { - anyhow::bail!("bootupd is required for ostree-based installs"); - } - Bootloader::None => { - tracing::debug!("Skip bootloader installation due set to None"); - } - } + // For ostree installs, use the detected bootloader; otherwise use configured bootloader + let bootloader = match postfetch.detected_bootloader { + Bootloader::Grub => Some(Bootloader::Grub), + Bootloader::Systemd => anyhow::bail!("bootupd is required for ostree-based installs"), + Bootloader::None => Some(Bootloader::None), + }; + install_bootloader( + &rootfs.device_info, + Some(boot_uuid), + target_root, + &state.config_opts, + Some(&deployment_path.as_str()), + bootloader.as_ref(), + )?; } tracing::debug!("Installed bootloader"); @@ -1840,6 +1859,90 @@ async fn install_with_sysroot( Ok(()) } +/// Install the bootloader using bootupd. +/// +/// This is a helper to reduce duplication between ostree and flat installs. +/// +/// # Arguments +/// * `device_info` - Device information for the target +/// * `target_root` - Path to the target root filesystem +/// * `config_opts` - Configuration options +/// * `deployment_path` - Optional deployment path for ostree-based installs +#[context("Installing bootloader via bootupd")] +fn install_bootloader_via_bootupd( + device_info: &bootc_blockdev::Device, + target_root: &Utf8PathBuf, + config_opts: &InstallConfigOpts, + deployment_path: Option<&str>, +) -> Result<()> { + crate::bootloader::install_via_bootupd(device_info, target_root, config_opts, deployment_path) +} + +/// Install the bootloader using zipl (s390x architecture). +/// +/// # Arguments +/// * `device_info` - Device information for the target +/// * `boot_uuid` - Boot UUID (required for zipl) +#[context("Installing bootloader via zipl")] +fn install_bootloader_via_zipl( + device_info: &bootc_blockdev::Device, + boot_uuid: &str, +) -> Result<()> { + crate::bootloader::install_via_zipl(device_info, boot_uuid) +} + +/// Install the bootloader, handling both s390x (zipl) and other architectures (bootupd). +/// +/// This helper consolidates the bootloader installation logic to reduce duplication +/// between ostree and flat installs. +/// +/// # Arguments +/// * `device_info` - Device information for the target +/// * `boot_uuid` - Boot UUID (required for zipl on s390x) +/// * `target_root` - Path to the target root filesystem +/// * `config_opts` - Configuration options +/// * `deployment_path` - Optional deployment path for ostree-based installs +/// * `bootloader_override` - Optional bootloader override (None uses default behavior) +#[context("Installing bootloader")] +fn install_bootloader( + device_info: &bootc_blockdev::Device, + boot_uuid: Option<&str>, + target_root: &Utf8PathBuf, + config_opts: &InstallConfigOpts, + deployment_path: Option<&str>, + bootloader_override: Option<&Bootloader>, +) -> Result<()> { + let default_bootloader = Bootloader::default(); + let bootloader = bootloader_override.unwrap_or( + &config_opts + .bootloader + .as_ref() + .unwrap_or(&default_bootloader), + ); + + match bootloader { + Bootloader::None => { + tracing::debug!("Skipping bootloader installation (bootloader=none)"); + } + _ => { + if cfg!(target_arch = "s390x") { + let boot_uuid = + boot_uuid.ok_or_else(|| anyhow!("Boot UUID required for zipl on s390x"))?; + install_bootloader_via_zipl(device_info, boot_uuid)?; + } else { + install_bootloader_via_bootupd( + device_info, + target_root, + config_opts, + deployment_path, + )?; + } + } + } + + Ok(()) +} + enum BoundImages { Skip, Resolved(Vec), @@ -1876,6 +1979,215 @@ impl BoundImages { } } +/// Write a BLS entry for a flat (non-ostree) installation. +#[context("Creating flat BLS entry")] +fn create_flat_bls_entry( + rootfs: &RootSetup, + kargs: CmdlineOwned, + kernel_version: &str, + vmlinuz_boot_path: &Utf8PathBuf, + initramfs_boot_path: &Utf8PathBuf, +) -> Result<()> { + use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; + + let mut cfg = BLSConfig::default(); + cfg.with_title(format!("Linux {kernel_version}")) + .with_version(kernel_version.to_string()) + .with_cfg(BLSConfigType::NonEFI { + linux: vmlinuz_boot_path.clone(), + initrd: vec![initramfs_boot_path.clone()], + options: Some(kargs), + }); + + let entry_path = format!("boot/loader/entries/flat-{kernel_version}.conf"); + rootfs + .physical_root + .create_dir_all("boot/loader/entries") + .context("Creating boot/loader/entries")?; + + let content = format!("{cfg}"); + rootfs + .physical_root + .atomic_write(&entry_path, content.as_bytes()) + .with_context(|| format!("Writing BLS entry {entry_path}"))?; + + tracing::debug!("Wrote BLS entry: {entry_path}"); + Ok(()) +} + +/// Copy a RegularFile from a composefs repository to a destination path using streaming. +/// +/// This function handles both inline files (small files stored in the metadata) +/// and external files (stored in the object store), using streaming I/O to avoid +/// loading large files entirely into memory. +/// +/// # Arguments +/// * `file` - The RegularFile to copy +/// * `repo` - The composefs repository +/// * `dest_dir` - Destination directory +/// * `dest_path` - Destination path relative to dest_dir +#[context("Copying regular file to {dest_path}")] +fn copy_regular_file( + file: &composefs::tree::RegularFile, + repo: &composefs::repository::Repository, + dest_dir: &Dir, + dest_path: &str, +) -> Result<()> { + use composefs::tree::RegularFile; + + match file { + RegularFile::Inline(data) => { + // For inline files (small), just write directly + dest_dir + .atomic_write(dest_path, data.as_ref()) + .context("Writing inline file")?; + } + RegularFile::External(id, _size) => { + // For external files, stream from the object store + let mut reader = std::fs::File::from(repo.open_object(id)?); + dest_dir + .atomic_replace_with(dest_path, |writer| -> Result<(), std::io::Error> { + std::io::copy(&mut reader, writer)?; + Ok(()) + }) + .context("Streaming external file")?; + } + } + + Ok(()) +} + +/// Perform a flat (non-ostree) installation. +/// +/// The approach here is to: +/// 1. Pull the image into a composefs repository at `composefs/` on the target. +/// This reuses composefs-rs's SELinux labeling support and kernel installation flow. +/// The composefs repo is preserved at `/sysroot/composefs`; users who want to convert +/// to immutable composefs mode later can do so, and anyone who doesn't want the extra +/// metadata can simply `rm -rf /sysroot/composefs`. +/// 2. Check out the filesystem from the composefs repo to the target using `write_to_path`, +/// which supports reflink copies on filesystems that enable it (btrfs, XFS). +/// 3. Write the kernel and initramfs to `/boot/` from the composefs repo objects. +/// 4. Create a standard BLS entry pointing at `root=UUID=...` (not a composefs overlay). +#[context("Performing flat install")] +async fn flat_install(state: &State, rootfs: &RootSetup) -> Result<()> { + println!("Installing in flat mode (experimental)"); + + // Step 1: Pull the image into the composefs repository on the target. + // allow_missing_fsverity=true because we don't use fsverity at boot time in flat mode. + let (image_id, _verity) = initialize_composefs_repository(state, rootfs, true).await?; + + // Step 2: Build the filesystem tree from the pulled image. + // Set insecure=true to match the allow_missing_fsverity used during pull above; + // flat mode targets (e.g. ext4) typically don't support fs-verity. + let mut repo = crate::bootc_composefs::repo::open_composefs_repo(&rootfs.physical_root)?; + repo.set_insecure(true); + let mut fs = create_composefs_filesystem(&repo, &image_id, None)?; + + // Step 3: Apply SELinux labels from the image's file_contexts. + // Returns true if a policy was found and labels applied, false if no policy was found. + selabel::selabel(&mut fs, &repo).context("Applying SELinux labels")?; + + // Step 4: Extract kernel/initramfs boot entries from the composefs tree. + let boot_entries = get_boot_resources(&fs, &repo)?; + let vmlinuz_entry = boot_entries + .into_iter() + .find_map(|e| match e { + BootEntry::UsrLibModulesVmLinuz(v) => Some(v), + _ => None, + }) + .ok_or_else(|| anyhow!("No vmlinuz kernel found in flat install image"))?; + let kernel_version = vmlinuz_entry.kver.as_ref().to_owned(); + + // Step 5: Check out the filesystem to the target directory. + // On reflink-capable filesystems (btrfs, XFS) this efficiently shares blocks + // with the composefs object store. + // Note: write_to_path is synchronous; bootc uses a single-threaded tokio runtime + // so we call it directly rather than via block_in_place (which requires multi-threaded). + let target_std = rootfs.physical_root_path.as_std_path().to_owned(); + write_to_path(&repo, &fs.root, &target_std) + .context("Checking out container rootfs to target")?; + + // Step 6: Write vmlinuz and initramfs to /boot/ on the target. + let vmlinuz_dest = format!("boot/vmlinuz-{kernel_version}"); + rootfs + .physical_root + .create_dir_all("boot") + .context("Creating boot directory")?; + + // Copy vmlinuz using streaming to avoid loading entire file into memory + copy_regular_file( + &vmlinuz_entry.vmlinuz, + &repo, + &rootfs.physical_root, + &vmlinuz_dest, + ) + .with_context(|| format!("Writing {vmlinuz_dest}"))?; + + let initramfs_boot_path = Utf8PathBuf::from(format!("/boot/initramfs-{kernel_version}.img")); + if let Some(initramfs) = &vmlinuz_entry.initramfs { + let initramfs_dest = format!("boot/initramfs-{kernel_version}.img"); + // Copy initramfs using streaming to avoid loading entire file into memory + copy_regular_file(initramfs, &repo, &rootfs.physical_root, &initramfs_dest) + .with_context(|| format!("Writing {initramfs_dest}"))?; + } else { + crate::utils::medium_visibility_warning( + "No initramfs found in image; boot may require manual initramfs generation", + ); + } + + // Step 7: Create BLS entry. + // Assemble kargs: root/boot filesystem kargs, install config kargs, + // kargs.d from the container image, then CLI kargs. + // This mirrors the kargs assembly in ostree_install (keep in sync). + let mut kargs = rootfs.kargs.clone(); + if let Some(install_config_kargs) = state.install_config.as_ref().and_then(|c| c.kargs.as_ref()) + { + for karg in install_config_kargs { + kargs.extend(&Cmdline::from(karg.as_str())); + } + } + let kargsd = + crate::bootc_kargs::get_kargs_in_root(&rootfs.physical_root, std::env::consts::ARCH)?; + kargs.extend(&kargsd); + if let Some(cli_kargs) = state.config_opts.karg.as_ref() { + for karg in cli_kargs { + kargs.extend(karg); + } + } + let vmlinuz_boot_path = Utf8PathBuf::from(format!("/boot/vmlinuz-{kernel_version}")); + create_flat_bls_entry( + rootfs, + kargs, + &kernel_version, + &vmlinuz_boot_path, + &initramfs_boot_path, + )?; + + // Step 8: Install bootloader. + let boot_uuid = rootfs.get_boot_uuid()?.or(rootfs.rootfs_uuid.as_deref()); + let target_root = rootfs + .target_root_path + .as_ref() + .unwrap_or(&rootfs.physical_root_path); + install_bootloader( + &rootfs.device_info, + boot_uuid, + target_root, + &state.config_opts, + None, // deployment_path (not used for flat installs) + state.config_opts.bootloader.as_ref(), + )?; + + // Step 9: Write flat install marker. + rootfs + .physical_root + .atomic_write(FLAT_INSTALL_MARKER, b"") + .context("Writing flat install marker")?; + + Ok(()) +} + async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> { // We verify this upfront because it's currently required by bootupd let boot_uuid = rootfs @@ -1948,7 +2260,9 @@ async fn install_to_filesystem_impl( } } - if state.composefs_options.composefs_backend { + if rootfs.flat { + flat_install(state, rootfs).await?; + } else if state.composefs_options.composefs_backend { // Load a fd for the mounted target physical root let (id, verity) = initialize_composefs_repository( @@ -2585,6 +2899,7 @@ pub(crate) async fn install_to_filesystem( boot, kargs, skip_finalize, + flat: fsopts.flat, }; install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; @@ -2635,6 +2950,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> replace: opts.replace, skip_finalize: true, acknowledge_destructive: opts.acknowledge_destructive, + flat: false, }, source_opts: opts.source_opts, target_opts: opts.target_opts, @@ -3027,4 +3343,60 @@ UUID=boot-uuid /boot ext4 defaults 0 0 Ok(()) } + + #[test] + fn test_create_flat_bls_entry() -> Result<()> { + let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?; + td.create_dir_all("boot")?; + + let rootfs = RootSetup { + #[cfg(feature = "install-to-disk")] + luks_device: None, + device_info: bootc_blockdev::Device { + name: "vda".to_string(), + serial: None, + model: None, + partlabel: None, + parttype: None, + partuuid: None, + partn: None, + children: None, + size: 0, + maj_min: None, + start: None, + label: None, + fstype: None, + uuid: None, + path: None, + pttype: None, + }, + physical_root_path: Utf8PathBuf::from("/test"), + physical_root: td.try_clone()?, + target_root_path: None, + rootfs_uuid: None, + skip_finalize: false, + boot: None, + kargs: CmdlineOwned::from("root=UUID=abc123 rw"), + flat: true, + }; + + let kernel_version = "6.12.0-100.fc41.x86_64"; + let vmlinuz = Utf8PathBuf::from(format!("/boot/vmlinuz-{kernel_version}")); + let initramfs = Utf8PathBuf::from(format!("/boot/initramfs-{kernel_version}.img")); + + let kargs = CmdlineOwned::from("root=UUID=abc123 rw"); + create_flat_bls_entry(&rootfs, kargs, kernel_version, &vmlinuz, &initramfs)?; + + // Read back the BLS entry and verify its contents + let entry_path = format!("boot/loader/entries/flat-{kernel_version}.conf"); + let content = String::from_utf8(td.read(&entry_path)?)?; + + assert!(content.contains(&format!("title Linux {kernel_version}"))); + assert!(content.contains(&format!("version {kernel_version}"))); + assert!(content.contains(&format!("linux /boot/vmlinuz-{kernel_version}"))); + assert!(content.contains(&format!("initrd /boot/initramfs-{kernel_version}.img"))); + assert!(content.contains("options root=UUID=abc123 rw")); + + Ok(()) + } } diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index 9b393b467..0603e1b15 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -488,5 +488,6 @@ pub(crate) fn install_create_rootfs( boot, kargs, skip_finalize: false, + flat: false, }) } diff --git a/crates/mount/Cargo.toml b/crates/mount/Cargo.toml index d90b0bf0c..b0c674c41 100644 --- a/crates/mount/Cargo.toml +++ b/crates/mount/Cargo.toml @@ -17,7 +17,7 @@ anyhow = { workspace = true } camino = { workspace = true, features = ["serde1"] } fn-error-context = { workspace = true } libc = { workspace = true } -rustix = { workspace = true } +rustix = { workspace = true, features = ["thread", "net", "fs", "process", "mount"] } serde = { workspace = true, features = ["derive"] } tracing = { workspace = true } tempfile = { workspace = true } @@ -25,6 +25,3 @@ cap-std-ext = { workspace = true } [dev-dependencies] indoc = { workspace = true } - -[lib] -path = "src/mount.rs" diff --git a/crates/mount/src/lib.rs b/crates/mount/src/lib.rs new file mode 100644 index 000000000..387ce8e82 --- /dev/null +++ b/crates/mount/src/lib.rs @@ -0,0 +1,10 @@ +//! Internal mount utilities for bootc. +//! +//! This crate provides utilities for mounting and managing filesystem mounts +//! during bootc installation and operation. + +mod mount; +pub use mount::*; + +pub mod tempmount; +pub use tempmount::*; diff --git a/crates/mount/src/mount.rs b/crates/mount/src/mount.rs index 20f80e292..132212e53 100644 --- a/crates/mount/src/mount.rs +++ b/crates/mount/src/mount.rs @@ -23,8 +23,6 @@ use rustix::{ }; use serde::Deserialize; -pub mod tempmount; - /// Well known identifier for pid 1 pub const PID1: Pid = const { match Pid::from_raw(1) { diff --git a/crates/mount/src/tempmount.rs b/crates/mount/src/tempmount.rs index 702f5a8e3..324aa1bc0 100644 --- a/crates/mount/src/tempmount.rs +++ b/crates/mount/src/tempmount.rs @@ -1,3 +1,8 @@ +//! Temporary mount management utilities. +//! +//! This module provides the [`TempMount`] type for creating temporary mounts +//! that are automatically unmounted when dropped. + use std::os::fd::AsFd; use anyhow::{Context, Result}; @@ -7,8 +12,11 @@ use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; use fn_error_context::context; use rustix::mount::{MountFlags, MoveMountFlags, UnmountFlags, move_mount, unmount}; +/// A temporary mount that is automatically unmounted when dropped. pub struct TempMount { + /// The temporary directory used as the mount point. pub dir: tempfile::TempDir, + /// The directory file descriptor for the mount. pub fd: Dir, } diff --git a/crates/tests-integration/src/install.rs b/crates/tests-integration/src/install.rs index dc9d2afd2..2a6443414 100644 --- a/crates/tests-integration/src/install.rs +++ b/crates/tests-integration/src/install.rs @@ -173,3 +173,79 @@ pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments) libtest_mimic::run(&testargs, tests.into()).exit() } + +#[context("Flat install tests")] +pub(crate) fn run_flat(image: &str, mut testargs: libtest_mimic::Arguments) -> Result<()> { + testargs.test_threads = Some(1); + let image: &'static str = String::from(image).leak(); + + let tests = [Trial::test("flat install to-filesystem", move || { + let sh = &xshell::Shell::new()?; + // Create a sparse file and format it as ext4 + let size = 5 * 1000 * 1000 * 1000u64; + let mut tmpdisk = tempfile::NamedTempFile::new_in("/var/tmp")?; + tmpdisk.as_file_mut().set_len(size)?; + let tmpdisk = tmpdisk.into_temp_path(); + let tmpdisk_str = tmpdisk.to_str().unwrap(); + + // Set up loop device and format + let loopdev = cmd!(sh, "sudo losetup --find --show {tmpdisk_str}") + .read()? + .trim() + .to_string(); + cmd!(sh, "sudo mkfs.ext4 -L root {loopdev}").run()?; + + // Mount the target + let tmpdir = tempfile::TempDir::new_in("/var/tmp")?; + let target = tmpdir.path().to_str().unwrap(); + cmd!(sh, "sudo mount {loopdev} {target}").run()?; + + // Run flat install (skip bootloader for CI) and capture result for cleanup + let r = (|| -> Result<()> { + cmd!(sh, "sudo {BASE_ARGS...} -v {target}:/target {image} bootc install to-filesystem --bootloader=none --flat /target").run()?; + + // Verify flat install marker + assert!( + std::path::Path::new(target) + .join("etc/.bootc-flat") + .exists(), + "Missing flat install marker" + ); + + // Verify no ostree directory was created + assert!( + !std::path::Path::new(target).join("ostree").exists(), + "ostree directory should not exist in flat install" + ); + + // Verify boot entries exist + let boot_entries = std::path::Path::new(target).join("boot/loader/entries"); + let has_entry = boot_entries.exists() + && std::fs::read_dir(&boot_entries)?.any(|e| { + e.ok() + .map(|e| e.file_name().to_string_lossy().contains("flat-")) + .unwrap_or(false) + }); + assert!(has_entry, "No flat BLS entry found in boot/loader/entries"); + + // Verify vmlinuz was copied to /boot + let has_vmlinuz = + std::fs::read_dir(std::path::Path::new(target).join("boot"))?.any(|e| { + e.ok() + .map(|e| e.file_name().to_string_lossy().starts_with("vmlinuz-")) + .unwrap_or(false) + }); + assert!(has_vmlinuz, "No vmlinuz-* found in /boot"); + + Ok(()) + })(); + + // Clean up regardless of result + let _ = cmd!(sh, "sudo umount --lazy {target}").run(); + let _ = cmd!(sh, "sudo losetup --detach {loopdev}").run(); + + Ok(r?) + })]; + + libtest_mimic::run(&testargs, tests.into()).exit() +} diff --git a/crates/tests-integration/src/tests-integration.rs b/crates/tests-integration/src/tests-integration.rs index d412ae39d..b3b812d3b 100644 --- a/crates/tests-integration/src/tests-integration.rs +++ b/crates/tests-integration/src/tests-integration.rs @@ -26,6 +26,12 @@ pub(crate) enum Opt { #[clap(flatten)] testargs: libtest_mimic::Arguments, }, + InstallFlat { + /// Source container image reference + image: String, + #[clap(flatten)] + testargs: libtest_mimic::Arguments, + }, HostPrivileged { image: String, #[clap(flatten)] @@ -54,6 +60,7 @@ fn main() { let r = match opt { Opt::SystemReinstall { image, testargs } => system_reinstall::run(&image, testargs), Opt::InstallAlongside { image, testargs } => install::run_alongside(&image, testargs), + Opt::InstallFlat { image, testargs } => install::run_flat(&image, testargs), Opt::HostPrivileged { image, testargs } => hostpriv::run_hostpriv(&image, testargs), Opt::Container { testargs } => container::run(testargs), Opt::RunVM(opts) => runvm::run(opts), diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 474d8de6e..c0ac9ae76 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -12,6 +12,7 @@ anstream = { workspace = true } anyhow = { workspace = true } cap-std-ext = {workspace = true, features = ["fs_utf8"] } chrono = { workspace = true, features = ["std"] } +libc = { workspace = true } owo-colors = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/utils/src/command.rs b/crates/utils/src/command.rs index 0a9d759c1..1fe1e4aae 100644 --- a/crates/utils/src/command.rs +++ b/crates/utils/src/command.rs @@ -1,5 +1,8 @@ //! Helpers intended for [`std::process::Command`] and related structures. +// Allow unsafe code for prctl FFI declaration +#![allow(unsafe_code)] + use std::{ fmt::Write, io::{Read, Seek}, @@ -9,6 +12,14 @@ use std::{ use anyhow::{Context, Result}; +// prctl constants and extern declaration for parent death signal +// PR_SET_PDEATHSIG = 1 +const PR_SET_PDEATHSIG: libc::c_int = 1; + +unsafe extern "C" { + fn prctl(option: libc::c_int, arg2: libc::c_ulong) -> libc::c_int; +} + /// Helpers intended for [`std::process::Command`]. pub trait CommandRunExt { /// Log (at debug level) the full child commandline. @@ -150,10 +161,13 @@ impl CommandRunExt for Command { // SAFETY: This API is safe to call in a forked child. unsafe { self.pre_exec(|| { - rustix::process::set_parent_process_death_signal(Some( - rustix::process::Signal::TERM, - )) - .map_err(Into::into) + // Use prctl directly via libc since rustix removed this API + // SIGTERM = 15 + let ret = prctl(PR_SET_PDEATHSIG, libc::SIGTERM as libc::c_ulong); + if ret != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) }) } } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 5dd3d8a99..4d02d3097 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -23,10 +23,13 @@ - [Logically bound images](logically-bound-images.md) - [Booting local builds](booting-local-builds.md) - [`man bootc`](man/bootc.8.md) +- [`man bootc-config`](man/bootc-config.5.md) - [`man bootc-status`](man/bootc-status.8.md) - [`man bootc-upgrade`](man/bootc-upgrade.8.md) - [`man bootc-switch`](man/bootc-switch.8.md) - [`man bootc-rollback`](man/bootc-rollback.8.md) +- [`man bootc-edit`](man/bootc-edit.8.md) +- [`man bootc-config-diff`](man/bootc-config-diff.8.md) - [`man bootc-usr-overlay`](man/bootc-usr-overlay.8.md) - [`man bootc-fetch-apply-updates.service`](man/bootc-fetch-apply-updates.service.5.md) - [`man bootc-status-updated.path`](man/bootc-status-updated.path.5.md) @@ -41,12 +44,19 @@ - [`man bootc-install-to-disk`](man/bootc-install-to-disk.8.md) - [`man bootc-install-to-filesystem`](man/bootc-install-to-filesystem.8.md) - [`man bootc-install-to-existing-root`](man/bootc-install-to-existing-root.8.md) +- [`man bootc-install-finalize`](man/bootc-install-finalize.8.md) +- [`man bootc-install-print-configuration`](man/bootc-install-print-configuration.8.md) +- [`man bootc-install-ensure-completion`](man/bootc-install-ensure-completion.8.md) +- [`man system-reinstall-bootc`](man/system-reinstall-bootc.8.md) - [`man bootc-destructive-cleanup.service`](man/bootc-destructive-cleanup.service.5.md) # Bootc usage in containers - [Read-only when in a default container](bootc-in-container.md) +- [`man bootc-container`](man/bootc-container.8.md) - [`man bootc-container-lint`](man/bootc-container-lint.8.md) +- [`man bootc-container-inspect`](man/bootc-container-inspect.8.md) +- [`man bootc-container-ukify`](man/bootc-container-ukify.8.md) # Architecture @@ -62,6 +72,7 @@ - [composefs backend](experimental-composefs.md) - [unified storage](experimental-unified-storage.md) - [`man bootc-root-setup.service`](man/bootc-root-setup.service.5.md) +- [`man bootc-composefs-finalize-staged`](man/bootc-composefs-finalize-staged.8.md) - [fsck](experimental-fsck.md) - [install reset](experimental-install-reset.md) - [--progress-fd](experimental-progress-fd.md) diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index d3e557c7c..00e7470a4 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -49,6 +49,10 @@ is currently expected to be empty by default. The default mode is to "finalize" the target filesystem by invoking `fstrim` and similar operations, and finally mounting it readonly. This option skips those operations. It is then the responsibility of the invoking code to perform those operations +**--flat** + + Install in "flat" mode: the container rootfs is written to the target filesystem as a regular writable directory tree (no composefs overlay at boot time). This is experimental. Post-install, bootc day-2 operations (upgrade, rollback, etc.) are unavailable on the installed system. A composefs repository is created at /sysroot/composefs as an intermediate step; it can be removed post-install or retained to enable future conversion to immutable mode + **--source-imgref**=*SOURCE_IMGREF* Install the system from an explicitly given source diff --git a/hack/Containerfile.flat-test b/hack/Containerfile.flat-test new file mode 100644 index 000000000..c3084b9d8 --- /dev/null +++ b/hack/Containerfile.flat-test @@ -0,0 +1,20 @@ +# Build a test image with --flat support layered on fedora-bootc:43 +# Stage 1: build bootc binary on Fedora 43 (matching target libraries) +FROM quay.io/fedora/fedora-bootc:43 AS builder + +RUN dnf -y install cargo rust gcc openssl-devel pkg-config perl \ + ostree-devel glib2-devel libzstd-devel make skopeo \ + && dnf clean all + +COPY . /src +WORKDIR /src + +# Build only the bootc binary (release mode for speed) +RUN --mount=type=cache,target=/root/.cargo/registry \ + cargo build -p bootc --release && \ + cp target/release/bootc /usr/local/bin/bootc-flat + +# Stage 2: final image is fedora-bootc:43 with our bootc +FROM quay.io/fedora/fedora-bootc:43 + +COPY --from=builder /usr/local/bin/bootc-flat /usr/bin/bootc