From ce2e73ae2f19fc329d7fa539d460e70fd045ac1d Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:29:35 -0400 Subject: [PATCH 1/6] tmt: Use the boot partition UUID in the separate /var test The test passed the EFI system partition's UUID as the boot mount spec, while /boot is a separate ext4 partition in its layout. Use the UUID of the boot partition, which is what the installed system needs to mount. This went unnoticed because the test does not boot the result. Generated-by: AI --- tmt/tests/booted/test-install-to-filesystem-var-mount.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index c64d70c181..d369864d4b 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -142,7 +142,7 @@ mkfs.ext4 -F /dev/BL/root02 # Get UUIDs for bootc install ROOT_UUID=$(blkid -s UUID -o value /dev/BL/root02) -BOOT_UUID=$(blkid -s UUID -o value "$EFI_PART") +BOOT_UUID=$(blkid -s UUID -o value "$BOOT_PART") # Mount the partitions mkdir -p /var/mnt/target From 2f2f0df0f6ebc4b2ad3922bd24d1582650f07d60 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:31:23 -0400 Subject: [PATCH 2/6] install: Initialize mounted /var filesystems from the image A common setup places /var, or parts of it such as /var/log, on separate filesystems (for example LVM logical volumes) so that runaway writes cannot fill the root filesystem. With `install to-filesystem` these filesystems end up empty: the deployment backend seeds the image's /var into its own state directory (`ostree/deploy//var`, or the composefs shared var), and at boot the separately mounted filesystems hide that content. On a fresh to-filesystem installation, discover filesystems mounted at the target's /var or below. Each empty top-level mount tree, including its nested mounts, is initialized from the seeded /var with `cp --archive`, then SELinux labeled and synced. A tree that already contains data is preserved as a whole, without merging. Alongside, host-root and existing-deployment installs are not affected, nor is `install to-disk`. Callers remain responsible for mounting these filesystems at boot. Note this changes behavior for callers that mount an empty /var filesystem and fill it themselves after installation: they now find the image's content there first. The seeded copy in the state directory is kept; on OSTree, emptying it would make the next deployment reseed it. Hardlinks cannot span filesystems, so trees with nested mounts are copied without preserving them. The roots of these filesystems are labeled unconditionally: without selinuxfs (e.g. in an osbuild buildroot), an inode with no label still reports the kernel's unlabeled context, which the regular relabel walk takes as already labeled. The separate /var TMT test now ships content in the image, adds a nested logical volume, and checks contents, ownership, symlinks and SELinux labels on the actual volumes. It no longer disables SELinux. Related: https://github.com/bootc-dev/bootc/issues/1615 Related: https://github.com/bootc-dev/bootc/issues/997 Generated-by: AI --- crates/lib/src/install.rs | 51 +- crates/lib/src/install/var_mounts.rs | 436 ++++++++++++++++++ docs/src/man/bootc-install-to-filesystem.8.md | 20 + .../test-install-to-filesystem-var-mount.sh | 31 +- 4 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 crates/lib/src/install/var_mounts.rs diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index d4dc852dbf..949f8d5f6b 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -146,6 +146,7 @@ pub(crate) mod completion; pub(crate) mod config; mod osbuild; pub(crate) mod osconfig; +mod var_mounts; use std::collections::HashMap; use std::io::Write; @@ -2090,6 +2091,7 @@ async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, cleanup: Cleanup, + var_mounts: &[Utf8PathBuf], ) -> Result<()> { if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.extend(&Cmdline::from("selinux=0")); @@ -2204,6 +2206,20 @@ async fn install_to_filesystem_impl( } } + // The deployment backend seeds its own state directory, not the caller's + // mounted /var tree. Populate fresh external filesystems while all nested + // mounts are still visible, before labeling and finalizing the installation. + let prepared_var_mounts = if !var_mounts.is_empty() { + let source = if state.composefs_options.composefs_backend { + Utf8PathBuf::from(crate::composefs_consts::SHARED_VAR_PATH) + } else { + Utf8PathBuf::from(format!("ostree/deploy/{}/var", state.stateroot())) + }; + var_mounts::populate(&rootfs.physical_root, &source, var_mounts)? + } else { + Vec::new() + }; + // As the very last step before filesystem finalization, do a full SELinux // relabel of the physical root filesystem. Any files that are already // labeled (e.g. ostree deployment contents, composefs objects) are skipped. @@ -2212,10 +2228,32 @@ async fn install_to_filesystem_impl( let mut path = Utf8PathBuf::from(""); crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None) .context("Final SELinux relabeling of physical root")?; + // The physical-root walk skips mountpoints. Label each newly + // prepared filesystem separately, using its deployed /var path. + for mount in &prepared_var_mounts { + let mut path = var_mounts::target_path(mount); + // These are fresh filesystems; label their roots unconditionally. + // Without selinuxfs (e.g. in an osbuild buildroot), an inode without + // a label still reports the kernel's unlabeled context, which the + // walk below would take as labeled. + for p in [path.clone(), path.join(LOST_AND_FOUND)] { + if let Some(meta) = rootfs.physical_root.symlink_metadata_optional(&p)? { + crate::lsm::relabel(&rootfs.physical_root, &meta, &p, None, &policy) + .with_context(|| format!("SELinux labeling of /{p}"))?; + } + } + crate::lsm::ensure_dir_labeled_recurse(&rootfs.physical_root, &mut path, &policy, None) + .with_context(|| format!("SELinux labeling of /{path}"))?; + } } else { tracing::debug!("Skipping final SELinux relabel (SELinux is disabled)"); } + // Flush the /var filesystems we wrote, surfacing any writeback errors. + // Unlike root and boot they are not remounted read-only or frozen: they + // may be bind mounts or lack freeze support, and callers may add content. + var_mounts::sync(&rootfs.physical_root, &prepared_var_mounts)?; + // Finalize mounted filesystems if !rootfs.skip_finalize { let bootfs = rootfs.boot.as_ref().map(|_| ("boot", BOOT)); @@ -2305,7 +2343,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { (rootfs, loopback_dev) }; - install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip).await?; + install_to_filesystem_impl(&state, &mut rootfs, Cleanup::Skip, &[]).await?; // Drop all data about the root except the bits we need to ensure any file descriptors etc. are closed. let (root_path, luksdev) = rootfs.into_storage(); @@ -2833,7 +2871,16 @@ pub(crate) async fn install_to_filesystem( skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + // Reinstalling alongside an existing OS must not initialize its live state. + let var_mounts = if targeting_host_root + || is_already_ostree + || matches!(fsopts.replace, Some(ReplaceMode::Alongside)) + { + Vec::new() + } else { + var_mounts::discover(&rootfs.physical_root_path)? + }; + install_to_filesystem_impl(&state, &mut rootfs, cleanup, &var_mounts).await?; // Drop all data about the root except the path to ensure any file descriptors etc. are closed. drop(rootfs); diff --git a/crates/lib/src/install/var_mounts.rs b/crates/lib/src/install/var_mounts.rs new file mode 100644 index 0000000000..729d1e765a --- /dev/null +++ b/crates/lib/src/install/var_mounts.rs @@ -0,0 +1,436 @@ +//! Initialize caller-mounted /var filesystems from the installed deployment. + +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use bootc_mount::Filesystem; +use bootc_utils::CommandRunExt; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt, dirext::CapStdExtDirExt}; +use rustix::fs::{Mode, OFlags}; + +use super::LOST_AND_FOUND; + +const VAR: &str = "var"; + +/// The path of a mount (relative to /var) from the installation root. +pub(super) fn target_path(mount: &Utf8Path) -> Utf8PathBuf { + if mount.as_str().is_empty() { + VAR.into() + } else { + Utf8Path::new(VAR).join(mount) + } +} + +/// Mount paths relative to /var, including the empty path for /var itself. +pub(super) fn discover(root: &Utf8Path) -> Result> { + let root = root + .canonicalize_utf8() + .context("Resolving installation root")?; + let mounts = + bootc_mount::run_findmnt(&["--submounts", "--mountpoint"], None, Some(root.as_str()))?; + let mut paths = Vec::new(); + collect(&root.join(VAR), &mounts.filesystems, &mut paths); + paths.sort(); + paths.dedup(); + Ok(paths) +} + +fn collect(var: &Utf8Path, mounts: &[Filesystem], paths: &mut Vec) { + for mount in mounts { + // An error here only means the mount is outside the target's /var. + if let Ok(path) = Utf8Path::new(&mount.target).strip_prefix(var) { + paths.push(path.to_owned()); + } + if let Some(children) = &mount.children { + collect(var, children, paths); + } + } +} + +/// A fresh mount tree may contain lost+found and directories needed to reach +/// child mounts. Everything else is existing state, which we must preserve. +fn is_empty_mount_tree(dir: &Dir, path: &Utf8Path, mounts: &[Utf8PathBuf]) -> Result { + let context = || format!("Reading /{}", target_path(path)); + for entry in dir.entries().with_context(context)? { + let entry = entry.with_context(context)?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return Ok(false); + }; + let child = path.join(name); + if !entry.file_type().with_context(context)?.is_dir() { + return Ok(false); + } + let child_dir = dir.open_dir(name).with_context(context)?; + if name == LOST_AND_FOUND && mounts.iter().any(|m| m == path) { + let mut entries = child_dir.entries().with_context(context)?; + if entries.next().transpose().with_context(context)?.is_some() { + return Ok(false); + } + } else if mounts.iter().any(|m| m.starts_with(&child)) { + if !is_empty_mount_tree(&child_dir, &child, mounts)? { + return Ok(false); + } + } else { + return Ok(false); + } + } + Ok(true) +} + +/// Check that a mountpoint and its ancestors are directories (not symlinks) +/// in the image's /var. Mounting on a symlinked path would not correspond to +/// the image's /var layout. Returns false if the image lacks the path. +fn check_source_path(source: &Dir, path: &Utf8Path) -> Result { + let mut dir = source.try_clone()?; + let mut walked = Utf8PathBuf::new(); + for component in path.iter() { + walked.push(component); + let Some(meta) = dir.symlink_metadata_optional(component)? else { + return Ok(false); + }; + if !meta.is_dir() { + bail!( + "Cannot initialize /{}: /{} in the image is a symlink or not a directory", + target_path(path), + target_path(&walked) + ); + } + dir = dir.open_dir(component)?; + } + Ok(true) +} + +/// Copy each top-level mount tree once, with all its nested mounts in place. +/// Existing state is never merged or replaced. A tree with existing content +/// (including content in a nested mount) is left untouched in its entirety. +/// +/// Returns the mounts that were initialized, or left empty because the image +/// has no content for them. These still need labeling and finalization. +pub(super) fn populate( + root: &Dir, + source: &Utf8Path, + mounts: &[Utf8PathBuf], +) -> Result> { + let mut prepared = Vec::new(); + if mounts.is_empty() { + return Ok(prepared); + } + let source_dir = root + .open_dir(source) + .with_context(|| format!("Opening {source}"))?; + for mount in mounts { + if mounts + .iter() + .any(|parent| parent != mount && mount.starts_with(parent)) + { + continue; + } + let tree: Vec<_> = mounts + .iter() + .filter(|m| m.starts_with(mount)) + .cloned() + .collect(); + let target = target_path(mount); + let target_dir = root + .open_dir(&target) + .with_context(|| format!("Opening /{target}"))?; + if !is_empty_mount_tree(&target_dir, mount, mounts)? { + println!("Preserving existing contents of /{target} and its child mounts"); + continue; + } + if !check_source_path(&source_dir, mount)? { + println!("No initial content for /{target} in the image"); + prepared.extend(tree); + continue; + } + // Also validate nested mounts, which cp would otherwise reach implicitly. + for nested in tree.iter().filter(|m| *m != mount) { + check_source_path(&source_dir, nested)?; + } + println!("Initializing /{target} from the image"); + // cp -a preserves ownership, modes, and xattrs (including SELinux + // labels). Hardlinks cannot span destination filesystems: materialize + // regular files independently when the destination has nested mounts. + let mut cp = Command::new("cp"); + cp.args(["--archive", "--reflink=auto"]); + if tree.len() > 1 { + cp.arg("--no-preserve=links"); + } + cp.arg("--") + .arg(source.join(mount).join(".")) + .arg(target.join(".")) + .cwd_dir(root.try_clone()?) + .run_capture_stderr() + .with_context(|| format!("Initializing /{target}"))?; + prepared.extend(tree); + } + Ok(prepared) +} + +/// Flush the filesystems of the given mounts, surfacing writeback errors. +pub(super) fn sync(root: &Dir, mounts: &[Utf8PathBuf]) -> Result<()> { + for mount in mounts { + let path = target_path(mount); + // cap-std opens directories with O_PATH, which syncfs() rejects. + let fd = rustix::fs::openat( + root, + path.as_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .with_context(|| format!("Opening /{path}"))?; + rustix::fs::syncfs(&fd).with_context(|| format!("Syncing /{path}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std::ambient_authority; + use cap_std_ext::cap_std::fs::{MetadataExt, Permissions, PermissionsExt}; + use cap_std_ext::cap_tempfile::TempDir; + + #[test] + fn populate_var_mounts() -> Result<()> { + for (mounts, existing) in [ + (vec![""], false), + (vec!["", "opt", "opt/app"], false), + (vec!["opt", "opt/app"], false), + (vec!["opt", "opt/app"], true), + (vec!["opt/app"], false), + ] { + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var/opt/app")?; + root.write("state/var/opt/app/seed", "image seed")?; + root.set_permissions("state/var/opt/app/seed", Permissions::from_mode(0o640))?; + root.write("state/var/opt/parent", "parent seed")?; + root.symlink_contents("seed", "state/var/opt/app/link")?; + let mounts: Vec = mounts.into_iter().map(Into::into).collect(); + for mount in &mounts { + root.create_dir_all(Utf8Path::new("var").join(mount).join("lost+found"))?; + } + if existing { + root.write("var/opt/app/seed", "existing state")?; + } + let initialized = populate(&root, "state/var".into(), &mounts)?; + assert_eq!(initialized, if existing { vec![] } else { mounts.clone() }); + assert_eq!( + root.read_to_string("var/opt/app/seed")?, + if existing { + "existing state" + } else { + "image seed" + } + ); + if existing { + assert!(!root.try_exists("var/opt/parent")?); + } else { + assert_eq!(root.metadata("var/opt/app/seed")?.mode() & 0o777, 0o640); + assert_eq!( + root.read_link("var/opt/app/link")?, + std::path::Path::new("seed") + ); + } + // A later invocation must not merge a different image into state. + root.write("state/var/opt/app/new-file", "new image")?; + assert!(populate(&root, "state/var".into(), &mounts)?.is_empty()); + assert!(!root.try_exists("var/opt/app/new-file")?); + } + Ok(()) + } + + #[test] + fn prepare_unseeded_mount() -> Result<()> { + // The image has no /var/srv; the empty mount still needs labeling. + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var/opt")?; + root.create_dir_all("var/srv/lost+found")?; + let mounts = vec![Utf8PathBuf::from("srv")]; + assert_eq!(populate(&root, "state/var".into(), &mounts)?, mounts); + Ok(()) + } + + #[test] + fn source_symlink() -> Result<()> { + // Only trees that would be initialized are validated. + for existing in [false, true] { + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var")?; + root.create_dir_all("var/opt/app")?; + root.symlink_contents("/outside", "state/var/opt")?; + if existing { + root.write("var/opt/app/data", "existing state")?; + } + let result = populate(&root, "state/var".into(), &["opt/app".into()]); + if existing { + assert!(result?.is_empty()); + } else { + assert_eq!( + result.unwrap_err().to_string(), + "Cannot initialize /var/opt/app: /var/opt in the image is a symlink or not a directory" + ); + } + } + Ok(()) + } + + #[test] + fn empty_mount_tree() -> Result<()> { + // Entries below /var (a trailing slash denotes a directory), mounts + // relative to /var, and whether the /var tree counts as empty. + let cases: &[(&[&str], &[&str], bool)] = &[ + (&[], &[""], true), + (&["lost+found/"], &[""], true), + (&["lost+found/", "lost+found/x"], &[""], false), + (&["file"], &[""], false), + (&["tmp/"], &[""], false), + ( + &["lib/", "lib/app/", "lib/app/lost+found/"], + &["", "lib/app"], + true, + ), + (&["lib/", "lib/lost+found/"], &["", "lib/app"], false), + (&["lib/", "tmp/"], &["", "lib/app"], false), + (&["lib/app/", "lib/app/state"], &["", "lib/app"], false), + ]; + for (entries, mounts, expected) in cases { + let root = TempDir::new(ambient_authority())?; + for entry in *entries { + match entry.strip_suffix('/') { + Some(dir) => root.create_dir_all(dir)?, + None => root.write(entry, "data")?, + } + } + let mounts: Vec = mounts.iter().map(|m| Utf8PathBuf::from(*m)).collect(); + assert_eq!( + is_empty_mount_tree(&root, "".into(), &mounts)?, + *expected, + "{entries:?} {mounts:?}" + ); + } + Ok(()) + } + + #[test] + fn sync_mounts() -> Result<()> { + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("var/lib/app")?; + sync(&root, &["".into(), "lib/app".into()])?; + let err = sync(&root, &["missing".into()]).unwrap_err(); + assert_eq!(err.to_string(), "Opening /var/missing"); + Ok(()) + } + + #[test] + fn target_paths() { + assert_eq!(target_path("".into()), "var"); + assert_eq!(target_path("lib/app".into()), "var/lib/app"); + } + + #[test] + fn preserve_non_utf8_state() -> Result<()> { + use std::os::unix::ffi::OsStrExt; + let root = TempDir::new(ambient_authority())?; + root.create_dir_all("state/var/opt")?; + root.write("state/var/opt/seed", "image")?; + root.create_dir_all("var/opt")?; + let target = root.open_dir("var/opt")?; + let name = std::ffi::OsStr::from_bytes(b"existing-\xff"); + target.write(name, "existing state")?; + populate(&root, "state/var".into(), &["opt".into()])?; + assert_eq!(target.read_to_string(name)?, "existing state"); + assert!(!target.try_exists("seed")?); + Ok(()) + } + + #[test] + fn collect_only_target_var_mounts() -> Result<()> { + let mounts: bootc_mount::Findmnt = serde_json::from_value(serde_json::json!({ + "filesystems": [{ + "source": "/dev/root", "target": "/target with spaces", "maj:min": "0:1", + "fstype": "ext4", "options": "rw", "children": [ + {"source": "/dev/a", "target": "/target with spaces/var/opt", + "maj:min": "0:2", "fstype": "ext4", "options": "rw", "children": [ + {"source": "/dev/b", "target": "/target with spaces/var/opt/app", + "maj:min": "0:3", "fstype": "xfs", "options": "rw"} + ]}, + {"source": "/dev/c", "target": "/target with spaces/variable", + "maj:min": "0:4", "fstype": "ext4", "options": "rw"}, + {"source": "/dev/d", "target": "/other/var/opt", + "maj:min": "0:5", "fstype": "ext4", "options": "rw"} + ] + }] + }))?; + let mut paths = Vec::new(); + collect( + "/target with spaces/var".into(), + &mounts.filesystems, + &mut paths, + ); + assert_eq!(paths, vec![Utf8PathBuf::from("opt"), "opt/app".into()]); + Ok(()) + } + + #[test] + #[ignore = "requires a private user/mount namespace; run with unshare -Urnm"] + fn mounted_var_filesystems() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = Utf8Path::from_path(temp.path()).unwrap(); + Command::new("mount") + .args(["--bind", path.as_str(), path.as_str()]) + .run_capture_stderr()?; + let result = (|| -> Result<()> { + assert!(discover(path)?.is_empty()); + let root = Dir::open_ambient_dir(path, ambient_authority())?; + root.create_dir_all("state/var/opt/app")?; + root.write("state/var/opt/app/seed", "seed")?; + root.set_permissions("state/var/opt/app/seed", Permissions::from_mode(0o640))?; + root.hard_link("state/var/opt/app/seed", &root, "state/var/parent-link")?; + root.hard_link( + "state/var/opt/app/seed", + &root, + "state/var/opt/app/same-volume-link", + )?; + root.symlink_contents("seed", "state/var/opt/app/symlink")?; + root.create_dir("var")?; + Command::new("mount") + .args(["-t", "tmpfs", "tmpfs"]) + .arg(path.join("var")) + .run_capture_stderr()?; + root.create_dir_all("var/opt/app")?; + Command::new("mount") + .args(["-t", "tmpfs", "tmpfs"]) + .arg(path.join("var/opt/app")) + .run_capture_stderr()?; + let mounts = discover(path)?; + assert_eq!(mounts, vec![Utf8PathBuf::new(), "opt/app".into()]); + assert_eq!(populate(&root, "state/var".into(), &mounts)?, mounts); + for file in [ + "var/parent-link", + "var/opt/app/seed", + "var/opt/app/same-volume-link", + ] { + assert_eq!(root.read_to_string(file)?, "seed"); + assert_eq!(root.metadata(file)?.mode() & 0o777, 0o640); + } + assert_ne!( + root.metadata("var/parent-link")?.dev(), + root.metadata("var/opt/app/seed")?.dev() + ); + assert_eq!( + root.read_link("var/opt/app/symlink")?, + std::path::Path::new("seed") + ); + root.write("var/opt/app/seed", "existing state")?; + assert!(populate(&root, "state/var".into(), &mounts)?.is_empty()); + assert_eq!(root.read_to_string("var/opt/app/seed")?, "existing state"); + Ok(()) + })(); + let unmount = bootc_mount::unmount_recursive(path); + result?; + unmount + } +} diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 2d2b8b6f62..406496ecd6 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -16,6 +16,26 @@ necessary platform partitions (such as the EFI system partition) are prepared and mounted by an external tool or script. The root filesystem is currently expected to be empty by default. +Mount filesystems for `/var` or its subdirectories beneath *ROOT_PATH* +before invoking this command. On a fresh installation, bootc initializes +empty mounted trees from the image's initial `/var` contents, including +nested mounts. Empty `lost+found` directories and directories needed to reach +nested mountpoints do not prevent initialization. Within a tree being +initialized, mountpoints must resolve to directories in the image, not +symlinks. A mount for which the image has no content is left empty. +Filesystems that bootc initializes or leaves empty are SELinux labeled and +synced. Unlike the root filesystem, they are not remounted read-only. +When a tree includes nested filesystems, regular files are copied independently +instead of preserving hardlinks, which cannot span filesystems. Symbolic links, +ownership, permissions, and extended attributes are preserved. + +If a mounted tree or any of its nested mounts already contains data, bootc +preserves that entire tree without merging image contents into it. Alongside +installations do not initialize external `/var` filesystems. This does not +change upgrade semantics: subsequent image updates do not update `/var`. +The caller remains responsible for configuring `/etc/fstab` or mount units +so the filesystems are mounted at the same locations on subsequent boots. + # OPTIONS diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index d369864d4b..fa161d6d39 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -28,6 +28,14 @@ bootc image copy-to-storage cat > /tmp/Containerfile.drop-lbis <<'EOF' FROM localhost/bootc as base RUN rm -rf /usr/lib/bootc/bound-images.d/* +RUN mkdir -p /var/lib/bootc-var-test/child && \ + printf 'parent seed\n' > /var/lib/bootc-var-test/parent && \ + printf 'child seed\n' > /var/lib/bootc-var-test/child/seed && \ + chmod 0640 /var/lib/bootc-var-test/child/seed && \ + chown 1001:1002 /var/lib/bootc-var-test/child/seed && \ + ln /var/lib/bootc-var-test/child/seed /var/lib/bootc-var-test/crosslink && \ + ln /var/lib/bootc-var-test/child/seed /var/lib/bootc-var-test/child/hardlink && \ + ln -s seed /var/lib/bootc-var-test/child/symlink EOF is_composefs=$(bootc status --json | jq '.status.booted.composefs') @@ -134,10 +142,12 @@ vgcreate BL "$LVM_PART" # Create logical volumes lvcreate -L 4G -n var02 BL +lvcreate -L 256M -n child02 BL lvcreate -l 100%FREE -n root02 BL # Create filesystems on logical volumes mkfs.ext4 -F /dev/BL/var02 +mkfs.ext4 -F /dev/BL/child02 mkfs.ext4 -F /dev/BL/root02 # Get UUIDs for bootc install @@ -160,6 +170,8 @@ touch /var/mnt/target/boot/efi/EFI/fedora/grubx64.efi # Critical: Mount /var as a separate partition mkdir -p /var/mnt/target/var mount /dev/BL/var02 /var/mnt/target/var +mkdir -p /var/mnt/target/var/lib/bootc-var-test/child +mount /dev/BL/child02 /var/mnt/target/var/lib/bootc-var-test/child echo "Filesystem layout:" mount | grep /var/mnt/target || true @@ -191,7 +203,6 @@ podman run \ --security-opt label=type:unconfined_t \ "$TARGET_IMAGE" \ bootc install to-filesystem \ - --disable-selinux \ "${COMPOSEFS_BACKEND_PARAMS[@]}" \ "${KARGS[@]}" \ --root-mount-spec=UUID="$ROOT_UUID" \ @@ -222,5 +233,23 @@ else fi fi +# Check the volume contents, not just successful deployment. The nested LV +# must contain its own seed files rather than hide them on the parent LV. +test "$(cat /var/mnt/target/var/lib/bootc-var-test/parent)" = 'parent seed' +child=/var/mnt/target/var/lib/bootc-var-test/child +test "$(cat "$child/seed")" = 'child seed' +test "$(stat -c '%u:%g:%a' "$child/seed")" = '1001:1002:640' +test "$(readlink "$child/symlink")" = seed +test "$(cat "$child/hardlink")" = 'child seed' +test "$(cat /var/mnt/target/var/lib/bootc-var-test/crosslink)" = 'child seed' +if selinuxenabled; then + for path in /var /var/lib/bootc-var-test/child /var/lib/bootc-var-test/child/seed; do + test "$(stat -c '%C' "/var/mnt/target$path")" = "$(matchpathcon -n "$path")" + done +fi +umount "$child" +test ! -e "$child/seed" +mount /dev/BL/child02 "$child" +test "$(cat "$child/seed")" = 'child seed' echo "Installation to-filesystem with separate /var mount succeeded!" From ab44316f36364280024eba9b67c565619303f123 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:32:27 -0400 Subject: [PATCH 3/6] container: Advertise install features in `container inspect` Tools that prepare a target for `install to-filesystem`, such as disk image builders, need to know whether the bootc in an image initializes mounted /var filesystems before they mount them: older versions leave such filesystems empty, so they hide the image's /var content at boot, and versions before 1.12 reject mountpoints in the target entirely. Add an `install-features` list to `bootc container inspect` output, currently containing `initialize-var-mounts`. It describes the bootc binary rather than the image, but this is the command builders already run against the image. The human-readable output is unchanged. Generated-by: AI --- crates/lib/src/install.rs | 2 ++ crates/lib/src/install/var_mounts.rs | 5 +++++ crates/lib/src/spec.rs | 3 +++ crates/lib/src/status.rs | 9 ++++++++- crates/tests-integration/src/container.rs | 4 ++++ docs/src/man/bootc-container-inspect.8.md | 14 ++++++++++++-- 6 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 949f8d5f6b..9cccbd42ce 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -225,6 +225,8 @@ const ALONGSIDE_ROOT_MOUNT: &str = "/target"; pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup"; /// This is an ext4 special directory we need to ignore. const LOST_AND_FOUND: &str = "lost+found"; +/// Optional `bootc install` behaviors, advertised by `bootc container inspect`. +pub(crate) const INSTALL_FEATURES: &[&str] = &[var_mounts::FEATURE]; /// The mount path for selinux const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi diff --git a/crates/lib/src/install/var_mounts.rs b/crates/lib/src/install/var_mounts.rs index 729d1e765a..e1cbceb15c 100644 --- a/crates/lib/src/install/var_mounts.rs +++ b/crates/lib/src/install/var_mounts.rs @@ -11,6 +11,11 @@ use rustix::fs::{Mode, OFlags}; use super::LOST_AND_FOUND; +/// Advertised by `bootc container inspect`, so that tools preparing a target +/// for `install to-filesystem` know that mounting /var filesystems is useful. +/// Older versions leave such filesystems empty, or reject them entirely. +pub(super) const FEATURE: &str = "initialize-var-mounts"; + const VAR: &str = "var"; /// The path of a mount (relative to /var) from the installation root. diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 15ad1014fe..6c44e9a650 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -473,6 +473,9 @@ pub(crate) struct ContainerInspect { pub(crate) kargs: Vec, /// Information about the kernel in the container image. pub(crate) kernel: Option, + /// Optional `bootc install` behaviors implemented by this bootc binary, + /// for tools that prepare a target for `install to-filesystem`. + pub(crate) install_features: Vec<&'static str>, } impl Host { diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 4525f60c71..93f5f6b222 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -990,7 +990,11 @@ 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 inspect = crate::spec::ContainerInspect { + kargs, + kernel, + install_features: crate::install::INSTALL_FEATURES.to_vec(), + }; // Determine output format: explicit --format wins, then --json, then default to human-readable let format = format.unwrap_or(if json { @@ -1295,6 +1299,7 @@ mod tests { version: "6.12.0-100.fc41.x86_64".into(), unified: false, }), + install_features: vec![], }; let mut w = Vec::new(); container_inspect_print_human(&inspect, &mut w).unwrap(); @@ -1315,6 +1320,7 @@ mod tests { version: "6.12.0-100.fc41.x86_64".into(), unified: true, }), + install_features: vec![], }; let mut w = Vec::new(); container_inspect_print_human(&inspect, &mut w).unwrap(); @@ -1332,6 +1338,7 @@ mod tests { let inspect = crate::spec::ContainerInspect { kargs: vec!["console=ttyS0".into()], kernel: None, + install_features: vec![], }; let mut w = Vec::new(); container_inspect_print_human(&inspect, &mut w).unwrap(); diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index d43e00d53f..b405e7d40d 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -35,6 +35,10 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> { assert!(kargs.iter().any(|arg| arg == "kargsd-othertest=2")); assert!(kargs.iter().any(|arg| arg == "testing-kargsd=3")); + // check advertised install features + let features = inspect.get("install-features").unwrap().as_array().unwrap(); + assert!(features.iter().any(|f| f == "initialize-var-mounts")); + // check kernel field let kernel = inspect .get("kernel") diff --git a/docs/src/man/bootc-container-inspect.8.md b/docs/src/man/bootc-container-inspect.8.md index 034e723c96..0961f219f2 100644 --- a/docs/src/man/bootc-container-inspect.8.md +++ b/docs/src/man/bootc-container-inspect.8.md @@ -18,6 +18,10 @@ The command outputs a JSON object with the following fields: - `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). +- `install-features`: An array of optional `bootc install` behaviors implemented by this bootc binary, + for tools that prepare a target for `bootc install to-filesystem`: + - `initialize-var-mounts`: Empty filesystems mounted at `/var` or below in the target are initialized + from the image's `/var` content. See **bootc-install-to-filesystem**(8). # OPTIONS @@ -60,7 +64,10 @@ Example output (vmlinuz kernel): "kernel": { "version": "6.12.0-0.rc6.51.fc42.x86_64", "unified": false - } + }, + "install-features": [ + "initialize-var-mounts" + ] } ``` @@ -72,7 +79,10 @@ Example output (UKI): "kernel": { "version": "7e11ac46e3e022053e7226a20104ac656bf72d1a", "unified": true - } + }, + "install-features": [ + "initialize-var-mounts" + ] } ``` From 0e123a7f6358515a85c49ac461536bd4ffa34b8a Mon Sep 17 00:00:00 2001 From: Joseph Marrero Date: Fri, 2 Oct 2026 03:48:15 +0000 Subject: [PATCH 4/6] blockdev: Add lookup of a colocated Variable Data Partition The Discoverable Partitions Specification defines a type GUID for a partition holding /var. Let the installer find one on the disks backing the root, the same way it finds the ESP, so a later change can use it as the default /var without the caller passing anything. Prep for honoring DPS /var in install to-filesystem. Generated-by: AI --- crates/blockdev/src/blockdev.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index 1906efa025..ab41d30454 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -82,6 +82,9 @@ pub const ESP: &str = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"; /// BIOS boot partition type GUID for GPT pub const BIOS_BOOT: &str = "21686148-6449-6e6f-744e-656564454649"; +/// Variable Data Partition type GUID (DPS), the same on all architectures +pub const VAR: &str = "4d21b016-b534-45c2-a9fb-5c16e091fd2d"; + #[derive(Debug, Deserialize)] struct DevicesOutput { blockdevices: Vec, @@ -230,6 +233,16 @@ impl Device { Ok((!bios_boots.is_empty()).then_some(bios_boots)) } + /// Find the first Variable Data Partition (DPS) among all root devices + /// backing this device. Returns None if there is none. + pub fn find_colocated_var(&self) -> Result> { + Ok(self + .find_all_roots()? + .iter() + .find_map(|root| root.find_partition_of_type(VAR)) + .cloned()) + } + /// Find a child partition by partition type (case-insensitive). pub fn find_partition_of_type(&self, parttype: &str) -> Option<&Device> { self.children.as_ref()?.iter().find(|child| { @@ -870,6 +883,19 @@ mod test { assert_eq!(bios.parttype.as_deref().unwrap(), BIOS_BOOT); } + /// A Variable Data Partition is found by its type GUID, whatever the case. + #[test] + fn test_find_partition_of_var() { + let fixture = include_str!("../tests/fixtures/lsblk.json"); + let devs: DevicesOutput = serde_json::from_str(fixture).unwrap(); + let mut dev = devs.blockdevices.into_iter().next().unwrap(); + assert!(dev.find_partition_of_type(VAR).is_none()); + let last = dev.children.as_mut().unwrap().last_mut().unwrap(); + last.parttype = Some(VAR.to_uppercase()); + let var = dev.find_partition_of_type(VAR).unwrap(); + assert_eq!(var.partn, Some(3)); + } + /// Verify that without the udev database, partition type fields are null /// and partition discovery fails. This simulates what happens when bootc /// runs inside a sandbox (like osbuild's bwrap) without /run/udev. From 71e20867816ea0224d897d526e430281878f08c4 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Date: Fri, 2 Oct 2026 03:48:21 +0000 Subject: [PATCH 5/6] install: Add --var-mount-spec and honor a DPS Variable Data Partition Having bootc mount the /var filesystem itself, like it does for the ESP, lets installers pass just a source instead of assembling the mount tree, and lets bootc record the mount for the installed system (fstab for ostree, systemd.mount-extra for composefs). Without the option, a Variable Data Partition on the root's disk is used by default, since DPS is how such layouts are meant to be described. An empty value turns that off and keeps the image's /var in the state directory, for installs that set up /var at first boot. --var is accepted as an alias. The mount is unmounted again once /var is initialized; the booted system does the mounting from then on. Generated-by: AI --- crates/lib/src/bootc_composefs/boot.rs | 27 +- crates/lib/src/install.rs | 95 +++++- crates/lib/src/install/baseline.rs | 1 + crates/lib/src/install/config.rs | 10 + crates/lib/src/install/var_mounts.rs | 275 +++++++++++++++++- docs/src/man/bootc-install-config.5.md | 4 + docs/src/man/bootc-install-to-filesystem.8.md | 19 +- .../test-install-to-filesystem-var-mount.sh | 197 ++++++++++--- 8 files changed, 557 insertions(+), 71 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da8..fa24c1fc40 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -794,20 +794,23 @@ pub(crate) fn setup_composefs_bls_boot( build_composefs_karg(id.clone(), format_version, allow_missing_fsverity); cmdline_options.extend(&Cmdline::from(&composefs_cmdline)); - // If there's a separate /boot partition, add a systemd.mount-extra + // If there's a separate /boot or /var, add a systemd.mount-extra // karg so systemd mounts it after reboot. This avoids writing to // /etc/fstab which conflicts with transient etc (see #1388). - if let Some(boot) = root_setup.boot_mount_spec() { - if !boot.source.is_empty() { - let mount_extra = format!( - "systemd.mount-extra={}:/boot:{}:{}", - boot.source, - boot.fstype, - boot.options.as_deref().unwrap_or("defaults"), - ); - cmdline_options.extend(&Cmdline::from(mount_extra.as_str())); - tracing::debug!("Added /boot mount karg: {mount_extra}"); - } + for mount in [root_setup.boot_mount_spec(), root_setup.var_mount_spec()] + .into_iter() + .flatten() + .filter(|m| !m.source.is_empty()) + { + let mount_extra = format!( + "systemd.mount-extra={}:{}:{}:{}", + mount.source, + mount.target, + mount.fstype, + mount.options.as_deref().unwrap_or("defaults"), + ); + cmdline_options.extend(&Cmdline::from(mount_extra.as_str())); + tracing::debug!("Added {} mount karg: {mount_extra}", mount.target); } // Locate ESP partition device by walking up to the root disk(s) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 9cccbd42ce..40bb79384b 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -490,6 +490,17 @@ pub(crate) struct InstallTargetFilesystemOpts { #[clap(long)] pub(crate) boot_mount_spec: Option, + /// Source device specification to mount at /var, such as `UUID=...`, + /// `LABEL=...` or a device path. + /// + /// bootc mounts it on the target, initializes it from the image's /var + /// and records it for mounting at boot. By default, a Variable Data + /// Partition (per the Discoverable Partitions Specification) on the disk + /// backing the root is used, if there is one. An empty value disables + /// that discovery and all /var handling. + #[clap(long, alias = "var")] + pub(crate) var_mount_spec: Option, + /// Initialize the system in-place; at the moment, only one mode for this is implemented. /// In the future, it may also be supported to set up an explicit "dual boot" system. #[clap(long)] @@ -656,6 +667,8 @@ pub(crate) struct State { pub(crate) composefs_options: InstallComposefsOpts, pub(crate) composefs_fsverity_supported: bool, pub(crate) allow_missing_verity_explicit: bool, + /// The image has a UKI as its kernel + pub(crate) is_uki: bool, } // Shared read-only global state @@ -1289,15 +1302,22 @@ async fn install_container( } } - // Write the entry for /boot to /etc/fstab. TODO: Encourage OSes to use the karg? + // Write the entries for /boot and /var to /etc/fstab. TODO: Encourage OSes to use the karg? // Or better bind this with the grub data. - // We omit it if the boot mountspec argument was empty - if let Some(boot) = root_setup.boot.as_ref() { - if !boot.source.is_empty() { - crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| { - writeln!(w, "{}", boot.to_fstab()).map_err(Into::into) - })?; - } + // We omit /boot if the boot mountspec argument was empty + let fstab = [root_setup.boot_mount_spec(), root_setup.var_mount_spec()] + .into_iter() + .flatten() + .filter(|m| !m.source.is_empty()) + .map(|m| m.to_fstab()) + .collect::>(); + if !fstab.is_empty() { + crate::lsm::atomic_replace_labeled(&root, "etc/fstab", 0o644.into(), sepolicy, |w| { + fstab + .iter() + .try_for_each(|l| writeln!(w, "{l}")) + .map_err(Into::into) + })?; } if let Some(contents) = state.root_ssh_authorized_keys.as_deref() { @@ -1375,6 +1395,8 @@ pub(crate) struct RootSetup { /// True if we should skip finalizing skip_finalize: bool, boot: Option, + /// A /var filesystem that bootc mounted itself for the installation. + var: Option, pub(crate) kargs: CmdlineOwned, } @@ -1395,6 +1417,11 @@ impl RootSetup { self.boot.as_ref() } + /// Get the mount spec of the /var filesystem that bootc mounted, if any. + pub(crate) fn var_mount_spec(&self) -> Option<&MountSpec> { + self.var.as_ref().map(|v| &v.spec) + } + // Drop any open file descriptors and return just the mount path and backing luks device, if any #[cfg(feature = "install-to-disk")] fn into_storage(self) -> (Utf8PathBuf, Option) { @@ -1898,6 +1925,7 @@ async fn prepare_install( .map(|fs| fs.supports_fsverity()) .unwrap_or(true), allow_missing_verity_explicit, + is_uki, }); Ok(state) @@ -2255,6 +2283,10 @@ async fn install_to_filesystem_impl( // Unlike root and boot they are not remounted read-only or frozen: they // may be bind mounts or lack freeze support, and callers may add content. var_mounts::sync(&rootfs.physical_root, &prepared_var_mounts)?; + // A /var that we mounted is for the booted system to mount, not ours to leave behind. + if let Some(var) = &rootfs.var { + var.unmount()?; + } // Finalize mounted filesystems if !rootfs.skip_finalize { @@ -2869,18 +2901,56 @@ pub(crate) async fn install_to_filesystem( target_root_path: Some(target_root_path.clone()), rootfs_uuid: inspect.uuid.clone(), boot, + var: None, kargs, skip_finalize, }; - // Reinstalling alongside an existing OS must not initialize its live state. - let var_mounts = if targeting_host_root + // Reinstalling alongside an existing OS must not touch its live state. + let var_plan = if targeting_host_root || is_already_ostree || matches!(fsopts.replace, Some(ReplaceMode::Alongside)) { - Vec::new() + anyhow::ensure!( + fsopts.var_mount_spec.as_deref().is_none_or(str::is_empty), + "--var-mount-spec is not supported when installing to the host root, over an \ + existing ostree system or with --replace=alongside" + ); + if let Some(spec) = state + .install_config + .as_ref() + .and_then(|c| c.var_mount_spec.as_deref()) + .filter(|s| !s.is_empty()) + { + tracing::warn!( + "Ignoring var-mount-spec={spec} from the install config: it is not supported \ + when installing to the host root, over an existing ostree system or with \ + --replace=alongside" + ); + } + var_mounts::Plan::Disabled } else { - var_mounts::discover(&rootfs.physical_root_path)? + let config_var_mount_spec = state + .install_config + .as_ref() + .and_then(|c| c.var_mount_spec.as_deref()); + let spec = fsopts.var_mount_spec.as_deref().or(config_var_mount_spec); + let caller_mounts = !var_mounts::discover(&rootfs.physical_root_path)?.is_empty(); + let cannot_record = state.composefs_options.composefs_backend && state.is_uki; + var_mounts::plan(spec, caller_mounts, cannot_record, || { + rootfs.device_info.find_colocated_var() + })? + }; + if let var_mounts::Plan::Mount(source) = &var_plan { + rootfs.var = Some(var_mounts::mount( + &rootfs.physical_root_path, + &rootfs.physical_root, + source, + )?); + } + let var_mounts = match var_plan { + var_mounts::Plan::Disabled => Vec::new(), + _ => var_mounts::discover(&rootfs.physical_root_path)?, }; install_to_filesystem_impl(&state, &mut rootfs, cleanup, &var_mounts).await?; @@ -2927,6 +2997,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> root_path: opts.root_path, root_mount_spec: None, boot_mount_spec: None, + var_mount_spec: None, replace: opts.replace, skip_finalize: true, acknowledge_destructive: opts.acknowledge_destructive, diff --git a/crates/lib/src/install/baseline.rs b/crates/lib/src/install/baseline.rs index f4d07c96ac..56fc4d77af 100644 --- a/crates/lib/src/install/baseline.rs +++ b/crates/lib/src/install/baseline.rs @@ -955,6 +955,7 @@ pub(crate) fn install_create_rootfs( target_root_path: None, rootfs_uuid: Some(root_uuid.to_string()), boot, + var: None, kargs, skip_finalize: false, }) diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 55fdd42f98..a9ee3f5674 100644 --- a/crates/lib/src/install/config.rs +++ b/crates/lib/src/install/config.rs @@ -118,6 +118,9 @@ pub(crate) struct InstallConfiguration { pub(crate) root_mount_spec: Option, /// Mount specification for the /boot filesystem. pub(crate) boot_mount_spec: Option, + /// Source device specification to mount at /var. An empty string disables + /// discovery of a Variable Data Partition and all /var handling. + pub(crate) var_mount_spec: Option, /// Bootupd configuration pub(crate) bootupd: Option, /// Bootloader to use (grub, systemd, none) @@ -214,6 +217,7 @@ impl Mergeable for InstallConfiguration { merge_basic(&mut self.stateroot, other.stateroot, env); merge_basic(&mut self.root_mount_spec, other.root_mount_spec, env); merge_basic(&mut self.boot_mount_spec, other.boot_mount_spec, env); + merge_basic(&mut self.var_mount_spec, other.var_mount_spec, env); self.bootupd.merge(other.bootupd, env); merge_basic(&mut self.bootloader, other.bootloader, env); merge_basic( @@ -775,12 +779,14 @@ stateroot = "custom" r#"[install] root-mount-spec = "LABEL=rootfs" boot-mount-spec = "UUID=abcd-1234" +var-mount-spec = "LABEL=var" "#, ) .unwrap(); let install = c.install.unwrap(); assert_eq!(install.root_mount_spec.unwrap(), "LABEL=rootfs"); assert_eq!(install.boot_mount_spec.unwrap(), "UUID=abcd-1234"); + assert_eq!(install.var_mount_spec.unwrap(), "LABEL=var"); } #[test] @@ -791,11 +797,13 @@ boot-mount-spec = "UUID=abcd-1234" let mut install: InstallConfiguration = toml::from_str( r#"root-mount-spec = "UUID=old" boot-mount-spec = "UUID=oldboot" +var-mount-spec = "UUID=oldvar" "#, ) .unwrap(); let other = InstallConfiguration { root_mount_spec: Some("LABEL=newroot".to_string()), + var_mount_spec: Some("".to_string()), ..Default::default() }; install.merge(other, &env); @@ -803,6 +811,8 @@ boot-mount-spec = "UUID=oldboot" assert_eq!(install.root_mount_spec.as_deref().unwrap(), "LABEL=newroot"); // boot_mount_spec should remain unchanged assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "UUID=oldboot"); + // An empty var_mount_spec is a value like any other + assert_eq!(install.var_mount_spec.as_deref().unwrap(), ""); } /// Empty mount specs are valid and signal to omit mount kargs entirely. diff --git a/crates/lib/src/install/var_mounts.rs b/crates/lib/src/install/var_mounts.rs index e1cbceb15c..020328cce4 100644 --- a/crates/lib/src/install/var_mounts.rs +++ b/crates/lib/src/install/var_mounts.rs @@ -1,5 +1,6 @@ //! Initialize caller-mounted /var filesystems from the installed deployment. +use std::cell::Cell; use std::process::Command; use anyhow::{Context, Result, bail}; @@ -9,7 +10,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt, dirext::CapStdExtDirExt}; use rustix::fs::{Mode, OFlags}; -use super::LOST_AND_FOUND; +use super::{LOST_AND_FOUND, MountSpec}; /// Advertised by `bootc container inspect`, so that tools preparing a target /// for `install to-filesystem` know that mounting /var filesystems is useful. @@ -27,6 +28,219 @@ pub(super) fn target_path(mount: &Utf8Path) -> Utf8PathBuf { } } +/// What to do about /var in the target of `install to-filesystem`. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Plan { + /// Leave /var alone, as bootc did before supporting separate /var. + Disabled, + /// Initialize whatever filesystems the caller already mounted at or below /var. + CallerMounted, + /// Mount this source at /var, then initialize it. + Mount(String), +} + +/// Filesystems that we are willing to mount on /var when we discover a +/// Variable Data Partition, as opposed to e.g. LUKS, LVM or swap signatures. +const MOUNTABLE_FSTYPES: &[&str] = &["ext2", "ext3", "ext4", "xfs", "btrfs", "f2fs"]; + +/// Decide the plan from `--var-mount-spec` (or its config file equivalent). +/// +/// An empty spec disables everything, including discovery of a DPS Variable +/// Data Partition on the disk(s) backing the root, which is used when no +/// spec was given and nothing is mounted at or below /var yet. +/// +/// If `cannot_record` is set, the installed system has no way to be told to +/// mount /var (sealed UKIs have a fixed command line), so a spec is an +/// error and discovery is skipped. +pub(super) fn plan( + configured: Option<&str>, + caller_mounts: bool, + cannot_record: bool, + find_var_partition: impl FnOnce() -> Result>, +) -> Result { + match configured { + Some("") => return Ok(Plan::Disabled), + Some(spec) if cannot_record => bail!( + "Cannot mount {spec} on /var: this installation cannot record the mount \ + (composefs with a UKI has a fixed kernel command line); mount it before \ + installing and set it up in the image instead" + ), + Some(spec) => return Ok(Plan::Mount(spec.to_owned())), + None if caller_mounts => return Ok(Plan::CallerMounted), + None => {} + } + // Discovery is opportunistic: failing to probe the disks is not a reason + // to fail an install that never asked for a separate /var. + let part = match find_var_partition() { + Ok(Some(part)) => part, + Ok(None) => return Ok(Plan::CallerMounted), + Err(e) => { + tracing::warn!("Looking for a Variable Data Partition: {e:#}"); + return Ok(Plan::CallerMounted); + } + }; + if cannot_record { + tracing::warn!( + "Ignoring Variable Data Partition {}: the mount cannot be recorded in a composefs UKI", + part.path() + ); + return Ok(Plan::CallerMounted); + } + // gpt-auto-generator only mounts a /var partition whose PARTUUID matches + // the machine-id, so we always record the mount ourselves. + match (&part.partuuid, part.fstype.as_deref()) { + (Some(partuuid), Some(fstype)) if MOUNTABLE_FSTYPES.contains(&fstype) => { + println!("Using Variable Data Partition {} for /var", part.path()); + Ok(Plan::Mount(format!("PARTUUID={partuuid}"))) + } + _ => { + tracing::warn!( + "Ignoring Variable Data Partition {}: its contents ({}) are not a supported filesystem", + part.path(), + part.fstype.as_deref().unwrap_or("empty") + ); + Ok(Plan::CallerMounted) + } + } +} + +/// A /var filesystem that bootc mounted itself. Dropping it undoes the mount +/// (and the creation of the mountpoint), so an error does not leave it behind. +#[derive(Debug)] +pub(super) struct Mounted { + /// How the booted system should mount it. + pub(super) spec: MountSpec, + root_path: Utf8PathBuf, + root: Dir, + /// We mounted it (it was not already a mountpoint). + mounted: Cell, + /// We created the mountpoint directory. + created: Cell, +} + +/// Mount `source` on the target's /var, unless something is mounted there already. +pub(super) fn mount(root_path: &Utf8Path, root: &Dir, source: &str) -> Result { + let var = root_path.join(VAR); + let existing = discover(root_path)?; + let var_is_mount = existing.iter().any(|m| m.as_str().is_empty()); + // Mounting over caller mounts below /var would hide them. + if !var_is_mount && !existing.is_empty() { + bail!( + "Cannot mount {source} on {var}: {} is already mounted below it", + root_path.join(target_path(&existing[0])) + ); + } + let created = match root.symlink_metadata_optional(VAR)? { + Some(meta) if meta.is_dir() => false, + Some(_) => bail!("{var} exists but is not a directory"), + None => { + root.create_dir(VAR) + .with_context(|| format!("Creating {var}"))?; + true + } + }; + if var_is_mount { + check_mounted_matches(&var, source)?; + } + let guard = Mounted { + spec: MountSpec::new(source, "/var"), + root_path: root_path.to_owned(), + root: root.try_clone()?, + mounted: Cell::new(false), + created: Cell::new(created), + }; + if !var_is_mount { + println!("Mounting {source} on {var}"); + bootc_mount::mount(source, &var)?; + guard.mounted.set(true); + } + Ok(guard) +} + +/// Resolve a mount source (`/dev/...`, `UUID=...`, `LABEL=...`) to a canonical device path. +fn resolve_source(source: &str) -> Result { + let dev = if source.starts_with('/') { + source.to_owned() + } else { + Command::new("findfs") + .arg(source) + .run_get_string() + .with_context(|| format!("Resolving {source}"))? + .trim() + .to_owned() + }; + Utf8Path::new(&dev) + .canonicalize_utf8() + .with_context(|| format!("Resolving {dev}")) +} + +/// A caller already mounted /var: make sure it is the filesystem the +/// spec names, since we record the spec for the booted system. +fn check_mounted_matches(var: &Utf8Path, source: &str) -> Result<()> { + let mounted = bootc_mount::inspect_filesystem(var)?; + // btrfs sources look like /dev/vda3[/subvol] + let mounted_source = mounted.source.split('[').next().unwrap_or_default(); + if !mounted_source.starts_with("/dev/") { + bail!( + "/var is already mounted from {} (not a block device), which cannot match \ + --var-mount-spec {source}", + mounted.source + ); + } + if resolve_source(source)? != resolve_source(mounted_source)? { + bail!( + "/var is already mounted from {} but --var-mount-spec names {source}", + mounted.source + ); + } + Ok(()) +} + +impl Mounted { + /// Undo [`mount`], leaving the target as we found it. + pub(super) fn unmount(&self) -> Result<()> { + let path = self.root_path.join(VAR); + if self.mounted.get() { + Command::new("umount") + .arg(&path) + .run_inherited_with_cmd_context()?; + self.mounted.set(false); + } + if self.created.get() { + self.root + .remove_dir(VAR) + .with_context(|| format!("Removing {path}"))?; + self.created.set(false); + } + Ok(()) + } +} + +impl Drop for Mounted { + fn drop(&mut self) { + if let Err(e) = self.unmount() { + tracing::warn!("Cleaning up /var mount: {e:#}"); + // Do not leave a mounted /var behind on the target: detach it lazily. + if self.mounted.get() { + let path = self.root_path.join(VAR); + match Command::new("umount") + .arg("--lazy") + .arg(&path) + .run_inherited() + { + Ok(()) => { + self.mounted.set(false); + if let Err(e) = self.unmount() { + tracing::warn!("Cleaning up /var mountpoint: {e:#}"); + } + } + Err(e) => tracing::warn!("Lazily unmounting {path}: {e:#}"), + } + } + } + } +} + /// Mount paths relative to /var, including the empty path for /var itself. pub(super) fn discover(root: &Utf8Path) -> Result> { let root = root @@ -198,6 +412,65 @@ mod tests { use cap_std_ext::cap_std::fs::{MetadataExt, Permissions, PermissionsExt}; use cap_std_ext::cap_tempfile::TempDir; + fn partition(fstype: Option<&str>) -> bootc_blockdev::Device { + serde_json::from_value(serde_json::json!({ + "name": "vda4", "path": "/dev/vda4", "size": 1, "partuuid": "abcd", + "fstype": fstype, + })) + .unwrap() + } + + #[test] + fn test_plan() { + let none = || Ok(None); + let part = |fstype| move || Ok(Some(partition(fstype))); + let mount = |s: &str| Plan::Mount(s.into()); + // An empty spec wins over discovery; an explicit one skips it. + assert_eq!( + plan(Some(""), false, false, part(Some("ext4"))).unwrap(), + Plan::Disabled + ); + assert_eq!( + plan(Some("UUID=1"), false, false, none).unwrap(), + mount("UUID=1") + ); + assert_eq!(plan(None, false, false, none).unwrap(), Plan::CallerMounted); + // Discovery needs a filesystem we can mount + assert_eq!( + plan(None, false, false, part(Some("xfs"))).unwrap(), + mount("PARTUUID=abcd") + ); + for fstype in [None, Some("crypto_LUKS"), Some("swap"), Some("LVM2_member")] { + assert_eq!( + plan(None, false, false, part(fstype)).unwrap(), + Plan::CallerMounted, + "{fstype:?}" + ); + } + // Discovery is for when the caller mounted nothing at /var + assert_eq!( + plan(None, true, false, part(Some("ext4"))).unwrap(), + Plan::CallerMounted + ); + // Without a way to record the mount, discovery is skipped and a spec is an error + assert_eq!( + plan(None, false, true, part(Some("ext4"))).unwrap(), + Plan::CallerMounted + ); + assert!(plan(Some("UUID=1"), false, true, none).is_err()); + assert_eq!(plan(Some(""), false, true, none).unwrap(), Plan::Disabled); + // Failing to look for a partition is not fatal, unless a spec was given + let fails = || anyhow::bail!("lsblk failed"); + assert_eq!( + plan(None, false, false, fails).unwrap(), + Plan::CallerMounted + ); + assert_eq!( + plan(Some("UUID=1"), false, false, fails).unwrap(), + mount("UUID=1") + ); + } + #[test] fn populate_var_mounts() -> Result<()> { for (mounts, existing) in [ diff --git a/docs/src/man/bootc-install-config.5.md b/docs/src/man/bootc-install-config.5.md index 7211c4aab7..8beb5d315b 100644 --- a/docs/src/man/bootc-install-config.5.md +++ b/docs/src/man/bootc-install-config.5.md @@ -40,6 +40,10 @@ The `install` section supports these subfields: - `boot-mount-spec`: A string specifying the /boot filesystem mount specification. If not provided and /boot is a separate mount, its UUID will be used. An empty string signals to omit boot mount kargs entirely. +- `var-mount-spec`: A string specifying the /var filesystem mount specification. + If not provided, a Variable Data Partition on the disk backing the root is + used, if there is one. An empty value disables that discovery and all /var handling. + Only `install to-filesystem` uses it; `install to-disk` ignores it. - `discoverable-partitions`: Boolean. When `true`, root discovery uses the Discoverable Partitions Specification via `systemd-gpt-auto-generator` and the `root=` kernel argument is omitted. This requires the bootloader to diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 406496ecd6..299b1ee8b1 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -16,7 +16,20 @@ necessary platform partitions (such as the EFI system partition) are prepared and mounted by an external tool or script. The root filesystem is currently expected to be empty by default. -Mount filesystems for `/var` or its subdirectories beneath *ROOT_PATH* +To use a separate `/var`, pass `--var-mount-spec` (for example +`UUID=...` or a device path): bootc mounts it at `/var` in the target, initializes +it as described below, and records it in `/etc/fstab` (or, with the composefs +backend, a `systemd.mount-extra` kernel argument) for the installed system. If +a Variable Data Partition (as defined by the Discoverable Partitions +Specification) exists on the disk backing the root filesystem, it is used by +default. An empty value disables that discovery and all /var handling, +keeping the image's `/var` in the root filesystem's state directory. +With the composefs backend and a UKI, the installed system's kernel command +line is fixed, so the mount cannot be recorded: an explicit spec is an error +and discovery is skipped. `--var-mount-spec` is also an error when installing +to the host root, over an existing ostree system or with `--replace=alongside`. + +Alternatively, mount filesystems for `/var` or its subdirectories beneath *ROOT_PATH* before invoking this command. On a fresh installation, bootc initializes empty mounted trees from the image's initial `/var` contents, including nested mounts. Empty `lost+found` directories and directories needed to reach @@ -53,6 +66,10 @@ so the filesystems are mounted at the same locations on subsequent boots. Mount specification for the /boot filesystem +**--var-mount-spec**=*VAR_MOUNT_SPEC* + + Source device specification to mount at /var, such as `UUID=...`, `LABEL=...` or a device path + **--replace**=*REPLACE* Initialize the system in-place; at the moment, only one mode for this is implemented. In the future, it may also be supported to set up an explicit "dual boot" system diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index fa161d6d39..a32f93e3a9 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -120,8 +120,10 @@ parted -s "$LOOP_DEV" mkpart primary fat32 2MiB 1026MiB parted -s "$LOOP_DEV" set 2 esp on # Boot partition (1 GiB) parted -s "$LOOP_DEV" mkpart primary ext4 1026MiB 2052MiB -# LVM partition (rest of disk) -parted -s "$LOOP_DEV" mkpart primary 2052MiB 100% +# LVM partition +parted -s "$LOOP_DEV" mkpart primary 2052MiB 90% +# A spare partition, made a DPS Variable Data Partition by the later scenarios +parted -s "$LOOP_DEV" mkpart primary ext4 90% 100% # Reload partition table partprobe "$LOOP_DEV" @@ -131,6 +133,9 @@ sleep 2 EFI_PART="${LOOP_DEV}p2" BOOT_PART="${LOOP_DEV}p3" LVM_PART="${LOOP_DEV}p4" +VAR_PART="${LOOP_DEV}p5" +# Partition type GUID of a DPS Variable Data Partition +DPS_VAR_TYPE=4d21b016-b534-45c2-a9fb-5c16e091fd2d # Create filesystems on boot partitions mkfs.vfat -F32 "$EFI_PART" @@ -154,18 +159,22 @@ mkfs.ext4 -F /dev/BL/root02 ROOT_UUID=$(blkid -s UUID -o value /dev/BL/root02) BOOT_UUID=$(blkid -s UUID -o value "$BOOT_PART") -# Mount the partitions -mkdir -p /var/mnt/target -mount /dev/BL/root02 /var/mnt/target -mkdir -p /var/mnt/target/boot -mount "$BOOT_PART" /var/mnt/target/boot -mkdir -p /var/mnt/target/boot/efi -mount "$EFI_PART" /var/mnt/target/boot/efi +# Mount root, /boot and the ESP (but no /var) the way an installer would +mount_target() { + mkdir -p /var/mnt/target + mount /dev/BL/root02 /var/mnt/target + mkdir -p /var/mnt/target/boot + mount "$BOOT_PART" /var/mnt/target/boot + mkdir -p /var/mnt/target/boot/efi + mount "$EFI_PART" /var/mnt/target/boot/efi + + # Create EFI directory structure with some files (simulating existing EFI content) + mkdir -p /var/mnt/target/boot/efi/EFI/fedora + touch /var/mnt/target/boot/efi/EFI/fedora/shimx64.efi + touch /var/mnt/target/boot/efi/EFI/fedora/grubx64.efi +} -# Create EFI directory structure with some files (simulating existing EFI content) -mkdir -p /var/mnt/target/boot/efi/EFI/fedora -touch /var/mnt/target/boot/efi/EFI/fedora/shimx64.efi -touch /var/mnt/target/boot/efi/EFI/fedora/grubx64.efi +mount_target # Critical: Mount /var as a separate partition mkdir -p /var/mnt/target/var @@ -194,44 +203,53 @@ if [[ $is_composefs != "null" ]]; then fi fi -# Run bootc install to-filesystem from within the container image under test -podman run \ - --rm --privileged \ - -v /var/mnt/target:/target \ - -v /dev:/dev \ - --pid=host \ - --security-opt label=type:unconfined_t \ - "$TARGET_IMAGE" \ - bootc install to-filesystem \ - "${COMPOSEFS_BACKEND_PARAMS[@]}" \ - "${KARGS[@]}" \ - --root-mount-spec=UUID="$ROOT_UUID" \ - --boot-mount-spec=UUID="$BOOT_UUID" \ - /target +run_install() { + # Run bootc install to-filesystem from within the container image under test + podman run \ + --rm --privileged \ + -v /var/mnt/target:/target \ + -v /dev:/dev \ + --pid=host \ + --security-opt label=type:unconfined_t \ + "$TARGET_IMAGE" \ + bootc install to-filesystem \ + "${COMPOSEFS_BACKEND_PARAMS[@]}" \ + "${KARGS[@]}" \ + --root-mount-spec=UUID="$ROOT_UUID" \ + --boot-mount-spec=UUID="$BOOT_UUID" \ + "$@" \ + /target +} -# Verify the installation succeeded -echo "Verifying installation..." +run_install -if [[ $is_composefs == "null" ]]; then - test -d /var/mnt/target/ostree - test -d /var/mnt/target/ostree/repo +# Verify the installation succeeded +verify_install() { + echo "Verifying installation..." - # Verify bootloader was installed (grub2 or loader for different configurations) - test -d /var/mnt/target/boot/grub2 || test -d /var/mnt/target/boot/loader -else - test -d /var/mnt/target/composefs + if [[ $is_composefs == "null" ]]; then + test -d /var/mnt/target/ostree + test -d /var/mnt/target/ostree/repo - # TODO(Johan-Liebert1): This is getting bootloader from the VM, which is not quite correct - # It works for now as the CI runs separately for each bootloader, but we need to get the - # bootloader from the installed systemd if we wish to run the tests locally without rebuilding the images - # This probably also happens in other tests, one instance is install-outside-container - if [[ $bootloader == "grub" ]]; then + # Verify bootloader was installed (grub2 or loader for different configurations) test -d /var/mnt/target/boot/grub2 || test -d /var/mnt/target/boot/loader else - test -d /var/mnt/target/boot/efi/EFI - test -d /var/mnt/target/boot/efi/loader/entries + test -d /var/mnt/target/composefs + + # TODO(Johan-Liebert1): This is getting bootloader from the VM, which is not quite correct + # It works for now as the CI runs separately for each bootloader, but we need to get the + # bootloader from the installed systemd if we wish to run the tests locally without rebuilding the images + # This probably also happens in other tests, one instance is install-outside-container + if [[ $bootloader == "grub" ]]; then + test -d /var/mnt/target/boot/grub2 || test -d /var/mnt/target/boot/loader + else + test -d /var/mnt/target/boot/efi/EFI + test -d /var/mnt/target/boot/efi/loader/entries + fi fi -fi +} + +verify_install # Check the volume contents, not just successful deployment. The nested LV # must contain its own seed files rather than hide them on the parent LV. @@ -252,4 +270,93 @@ test ! -e "$child/seed" mount /dev/BL/child02 "$child" test "$(cat "$child/seed")" = 'child seed' -echo "Installation to-filesystem with separate /var mount succeeded!" +echo "Caller-mounted /var succeeded; now letting bootc mount /var itself" + +# Start over with fresh filesystems and no /var mounted by us +reset_target() { + umount -R /var/mnt/target + mkfs.vfat -F32 "$EFI_PART" + mkfs.ext4 -F "$BOOT_PART" + mkfs.ext4 -F /dev/BL/root02 + mkfs.ext4 -F /dev/BL/var02 + mkfs.ext4 -F "$VAR_PART" + if [[ $is_composefs != "null" ]]; then + tune2fs -O verity /dev/BL/root02 + fi + ROOT_UUID=$(blkid -s UUID -o value /dev/BL/root02) + BOOT_UUID=$(blkid -s UUID -o value "$BOOT_PART") + mount_target +} + +# Check that a filesystem got the image's /var content, and that the +# installed system is told to mount it at /var. +check_var_fs() { + local dev=$1 uuid_arg=$2 + mkdir -p /var/mnt/varcheck + mount "$dev" /var/mnt/varcheck + test "$(cat /var/mnt/varcheck/lib/bootc-var-test/parent)" = 'parent seed' + test "$(cat /var/mnt/varcheck/lib/bootc-var-test/child/seed)" = 'child seed' + test "$(stat -c '%u:%g:%a' /var/mnt/varcheck/lib/bootc-var-test/child/seed)" = '1001:1002:640' + umount /var/mnt/varcheck + # bootc unmounts what it mounted and removes the mountpoint it created. + # The mount happened in the container's namespace, so check that the + # directory is gone from the filesystem itself. + test ! -e /var/mnt/target/var + # The mount is recorded for the booted system + if [[ $is_composefs == "null" ]]; then + grep -rqE "^$uuid_arg /var " /var/mnt/target/ostree/deploy/default/deploy/*/etc/fstab + elif [[ $boot_type != "uki" ]]; then + grep -rq "systemd.mount-extra=$uuid_arg:/var:" /var/mnt/target/boot + fi +} + +# The installed system must not have been told to mount the spare partition +check_var_part_untouched() { + mkdir -p /var/mnt/varcheck + mount "$VAR_PART" /var/mnt/varcheck + test -z "$(ls -A /var/mnt/varcheck | grep -v '^lost+found$')" + umount /var/mnt/varcheck + if [[ $is_composefs == "null" ]]; then + if grep -qs ' /var ' /var/mnt/target/ostree/deploy/default/deploy/*/etc/fstab; then exit 1; fi + fi +} + +echo "Explicit --var-mount-spec" +reset_target +VAR_UUID=$(blkid -s UUID -o value /dev/BL/var02) +if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then + # A UKI has a fixed command line, so there is nowhere to record the mount + if run_install --var-mount-spec="UUID=$VAR_UUID"; then + echo "--var-mount-spec unexpectedly succeeded with a composefs UKI" + exit 1 + fi +else + run_install --var-mount-spec="UUID=$VAR_UUID" + verify_install + check_var_fs /dev/BL/var02 "UUID=$VAR_UUID" +fi + +echo "Discovery of a DPS Variable Data Partition" +reset_target +sfdisk --part-type "$LOOP_DEV" 5 "$DPS_VAR_TYPE" +partprobe "$LOOP_DEV" +udevadm settle +run_install +verify_install +if [[ $is_composefs != "null" && $boot_type == "uki" ]]; then + # Discovery is skipped (with a warning) when the mount cannot be recorded + check_var_part_untouched +else + check_var_fs "$VAR_PART" "PARTUUID=$(blkid -s PARTUUID -o value "$VAR_PART")" +fi + +echo "An empty --var-mount-spec disables discovery" +reset_target +run_install --var-mount-spec= +verify_install +check_var_part_untouched +if [[ $is_composefs == "null" ]]; then + test "$(cat /var/mnt/target/ostree/deploy/default/var/lib/bootc-var-test/parent)" = 'parent seed' +fi + +echo "Installation to-filesystem with separate /var mount and bootc-mounted /var succeeded!" From d8cad1028e4b8162231e7c3b338cbccd13cc06ba Mon Sep 17 00:00:00 2001 From: Joseph Marrero Date: Fri, 2 Oct 2026 03:50:20 +0000 Subject: [PATCH 6/6] cli: Advertise global features in `bootc --version` Installer-visible capabilities describe the binary, not the image, and `--version` works wherever bootc does, including where there is no image to inspect. Drop install-features from `container inspect` again and list features after the version instead, keeping the first line the bare version for existing parsers. Generated-by: AI --- crates/lib/src/cli.rs | 31 ++++++++++++++++++- crates/lib/src/install.rs | 4 +-- crates/lib/src/install/var_mounts.rs | 6 ++-- crates/lib/src/spec.rs | 3 -- crates/lib/src/status.rs | 9 +----- crates/tests-integration/src/container.rs | 12 ++++--- docs/src/man/bootc-container-inspect.8.md | 14 ++------- docs/src/man/bootc-install-to-filesystem.8.md | 6 ++++ tmt/tests/booted/bootc_testlib.nu | 2 +- .../booted/test-49-composefs-1-16-bridge.nu | 2 +- 10 files changed, 54 insertions(+), 35 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 578a74adb9..2313281741 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -973,6 +973,22 @@ impl InternalsOpts { const GENERATOR_BIN: &'static str = "bootc-systemd-generator"; } +/// The text of `bootc --version`: the version, then the optional features of +/// this binary that tools driving bootc can rely on being present. +fn long_version() -> &'static str { + static LONG_VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); + LONG_VERSION + .get_or_init(|| format_long_version(clap::crate_version!(), crate::install::FEATURES)) +} + +fn format_long_version(version: &str, features: &[&str]) -> String { + let features = features + .iter() + .map(|f| format!("\n - {f}")) + .collect::(); + format!("{version}\nFeatures:{features}") +} + /// Deploy and transactionally in-place with bootable container images. /// /// The `bootc` project currently uses ostree-containers as a backend @@ -983,7 +999,7 @@ impl InternalsOpts { #[derive(Debug, Parser, PartialEq, Eq)] #[clap(name = "bootc")] #[clap(rename_all = "kebab-case")] -#[clap(version,long_version=clap::crate_version!())] +#[clap(version, long_version = long_version())] #[allow(clippy::large_enum_variant)] pub(crate) enum Opt { /// Download and queue an updated container image to apply. @@ -3161,4 +3177,17 @@ mod tests { } } } + + #[test] + fn test_long_version() { + assert_eq!( + format_long_version("1.2.3", &["a", "b"]), + "1.2.3\nFeatures:\n - a\n - b" + ); + // Every feature we have shows up in `bootc --version` + let long = Opt::command().render_long_version(); + for f in crate::install::FEATURES { + assert!(long.contains(&format!("\n - {f}\n")), "{long}"); + } + } } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 40bb79384b..d7ba381cfd 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -225,8 +225,8 @@ const ALONGSIDE_ROOT_MOUNT: &str = "/target"; pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup"; /// This is an ext4 special directory we need to ignore. const LOST_AND_FOUND: &str = "lost+found"; -/// Optional `bootc install` behaviors, advertised by `bootc container inspect`. -pub(crate) const INSTALL_FEATURES: &[&str] = &[var_mounts::FEATURE]; +/// Optional behaviors of this binary, listed by `bootc --version`. +pub(crate) const FEATURES: &[&str] = &[var_mounts::FEATURE]; /// The mount path for selinux const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi diff --git a/crates/lib/src/install/var_mounts.rs b/crates/lib/src/install/var_mounts.rs index 020328cce4..2c1bac8d32 100644 --- a/crates/lib/src/install/var_mounts.rs +++ b/crates/lib/src/install/var_mounts.rs @@ -12,10 +12,10 @@ use rustix::fs::{Mode, OFlags}; use super::{LOST_AND_FOUND, MountSpec}; -/// Advertised by `bootc container inspect`, so that tools preparing a target -/// for `install to-filesystem` know that mounting /var filesystems is useful. +/// Advertised by `bootc --version`, so that tools preparing a target for +/// `install to-filesystem` know that /var filesystems are initialized. /// Older versions leave such filesystems empty, or reject them entirely. -pub(super) const FEATURE: &str = "initialize-var-mounts"; +pub(super) const FEATURE: &str = "install-var-mount"; const VAR: &str = "var"; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 6c44e9a650..15ad1014fe 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -473,9 +473,6 @@ pub(crate) struct ContainerInspect { pub(crate) kargs: Vec, /// Information about the kernel in the container image. pub(crate) kernel: Option, - /// Optional `bootc install` behaviors implemented by this bootc binary, - /// for tools that prepare a target for `install to-filesystem`. - pub(crate) install_features: Vec<&'static str>, } impl Host { diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 93f5f6b222..4525f60c71 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -990,11 +990,7 @@ 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, - install_features: crate::install::INSTALL_FEATURES.to_vec(), - }; + let inspect = crate::spec::ContainerInspect { kargs, kernel }; // Determine output format: explicit --format wins, then --json, then default to human-readable let format = format.unwrap_or(if json { @@ -1299,7 +1295,6 @@ mod tests { version: "6.12.0-100.fc41.x86_64".into(), unified: false, }), - install_features: vec![], }; let mut w = Vec::new(); container_inspect_print_human(&inspect, &mut w).unwrap(); @@ -1320,7 +1315,6 @@ mod tests { version: "6.12.0-100.fc41.x86_64".into(), unified: true, }), - install_features: vec![], }; let mut w = Vec::new(); container_inspect_print_human(&inspect, &mut w).unwrap(); @@ -1338,7 +1332,6 @@ mod tests { let inspect = crate::spec::ContainerInspect { kargs: vec!["console=ttyS0".into()], kernel: None, - install_features: vec![], }; let mut w = Vec::new(); container_inspect_print_human(&inspect, &mut w).unwrap(); diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index b405e7d40d..7af83babbb 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -24,6 +24,13 @@ pub(crate) fn test_bootc_status() -> Result<()> { Ok(()) } +pub(crate) fn test_bootc_version_features() -> Result<()> { + let sh = Shell::new()?; + let version = cmd!(sh, "bootc --version").read()?; + assert!(version.contains("\n - install-var-mount"), "{version}"); + Ok(()) +} + pub(crate) fn test_bootc_container_inspect() -> Result<()> { let sh = Shell::new()?; let inspect: serde_json::Value = @@ -35,10 +42,6 @@ pub(crate) fn test_bootc_container_inspect() -> Result<()> { assert!(kargs.iter().any(|arg| arg == "kargsd-othertest=2")); assert!(kargs.iter().any(|arg| arg == "testing-kargsd=3")); - // check advertised install features - let features = inspect.get("install-features").unwrap().as_array().unwrap(); - assert!(features.iter().any(|f| f == "initialize-var-mounts")); - // check kernel field let kernel = inspect .get("kernel") @@ -550,6 +553,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> { new_test("install config", test_bootc_install_config), new_test("printconfig --all", test_bootc_install_config_all), new_test("status", test_bootc_status), + new_test("version features", test_bootc_version_features), new_test("container inspect", test_bootc_container_inspect), new_test("system-reinstall --help", test_system_reinstall_help), new_test("container export tar", test_container_export_tar), diff --git a/docs/src/man/bootc-container-inspect.8.md b/docs/src/man/bootc-container-inspect.8.md index 0961f219f2..034e723c96 100644 --- a/docs/src/man/bootc-container-inspect.8.md +++ b/docs/src/man/bootc-container-inspect.8.md @@ -18,10 +18,6 @@ The command outputs a JSON object with the following fields: - `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). -- `install-features`: An array of optional `bootc install` behaviors implemented by this bootc binary, - for tools that prepare a target for `bootc install to-filesystem`: - - `initialize-var-mounts`: Empty filesystems mounted at `/var` or below in the target are initialized - from the image's `/var` content. See **bootc-install-to-filesystem**(8). # OPTIONS @@ -64,10 +60,7 @@ Example output (vmlinuz kernel): "kernel": { "version": "6.12.0-0.rc6.51.fc42.x86_64", "unified": false - }, - "install-features": [ - "initialize-var-mounts" - ] + } } ``` @@ -79,10 +72,7 @@ Example output (UKI): "kernel": { "version": "7e11ac46e3e022053e7226a20104ac656bf72d1a", "unified": true - }, - "install-features": [ - "initialize-var-mounts" - ] + } } ``` diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 299b1ee8b1..5ed4bc9fd8 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -29,6 +29,12 @@ line is fixed, so the mount cannot be recorded: an explicit spec is an error and discovery is skipped. `--var-mount-spec` is also an error when installing to the host root, over an existing ostree system or with `--replace=alongside`. +Tools can tell whether a bootc binary supports this by looking for +`install-var-mount` in the `Features:` list of `bootc --version`. It covers +both /var filesystems that bootc mounts (`--var-mount-spec` or a discovered +Variable Data Partition) and ones the caller mounted; older versions leave +such filesystems empty, or reject them entirely. + Alternatively, mount filesystems for `/var` or its subdirectories beneath *ROOT_PATH* before invoking this command. On a fresh installation, bootc initializes empty mounted trees from the image's initial `/var` contents, including diff --git a/tmt/tests/booted/bootc_testlib.nu b/tmt/tests/booted/bootc_testlib.nu index 2560d9b13b..9eced883f8 100644 --- a/tmt/tests/booted/bootc_testlib.nu +++ b/tmt/tests/booted/bootc_testlib.nu @@ -52,7 +52,7 @@ export def maybe_upgrade [] { } # Save the pre-upgrade bootc version so post-upgrade tests # can detect known incompatibilities with older versions. - let pre_ver = (bootc --version | parse "bootc {v}" | get 0.v) + let pre_ver = (bootc --version | lines | first | parse "bootc {v}" | get 0.v) $pre_ver | save /var/bootc-pre-upgrade-version print $"Pre-upgrade bootc version: ($pre_ver)" diff --git a/tmt/tests/booted/test-49-composefs-1-16-bridge.nu b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu index 0f209313c2..f1f5c42ad6 100644 --- a/tmt/tests/booted/test-49-composefs-1-16-bridge.nu +++ b/tmt/tests/booted/test-49-composefs-1-16-bridge.nu @@ -60,7 +60,7 @@ def required-old-bootc-sha256 [] { } def assert-old-fixture [] { - let version = (bootc --version | str trim) + let version = (bootc --version | lines | first | str trim) assert equal $version "bootc 1.16.0" let rpm_version = (rpm -q --qf '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n' bootc | str trim) let binary_sha256 = (sha256sum /usr/bin/bootc | split row " " | first | str downcase)