From c3949f7fa76d8e833ce6af68ee94ced489db260c Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:29:35 -0400 Subject: [PATCH 1/2] 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 3953f6a9ee..fee2aef2da 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 5f31d9794a9242114bf89f3ced2e46e0787da0e1 Mon Sep 17 00:00:00 2001 From: Joseph Marrero Corchado Date: Wed, 30 Sep 2026 23:31:23 -0400 Subject: [PATCH 2/2] install: Add --initialize-var-mounts for separate /var filesystems 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. With the new --initialize-var-mounts option, a fresh to-filesystem installation discovers 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. The option is rejected for alongside and existing-deployment installs, before anything on the target is changed. Callers remain responsible for mounting these filesystems at boot. Without the option nothing changes, so callers that mount an empty /var filesystem and fill it themselves after installation are not affected. Doing this by default was considered, but would have changed behavior for them. Image builders can tell whether to pass the option from the `Features:` list that `bootc --version` now prints after the version; the first line stays the bare version for existing parsers. The feature describes the binary rather than an image, so it is not part of `container inspect`, and `--version` needs no new query interface. 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 passes the option, checks contents, ownership, symlinks and SELinux labels on the actual volumes, including a nested one, and checks that without the option they stay empty. Related: https://github.com/bootc-dev/bootc/issues/1615 Related: https://github.com/bootc-dev/bootc/issues/997 Generated-by: AI --- crates/lib/src/cli.rs | 31 +- crates/lib/src/install.rs | 111 ++++- crates/lib/src/install/var_mounts.rs | 441 ++++++++++++++++++ docs/src/man/bootc-install-to-filesystem.8.md | 34 ++ .../test-install-to-filesystem-var-mount.sh | 122 +++-- 5 files changed, 706 insertions(+), 33 deletions(-) create mode 100644 crates/lib/src/install/var_mounts.rs diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0a7c0e5987..c29e1bcf63 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -976,6 +976,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 @@ -986,7 +1002,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. @@ -3163,4 +3179,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}")), "{long}"); + } + } } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 03eeb22172..2babd909b2 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; @@ -224,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 features 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 @@ -554,6 +557,14 @@ pub(crate) struct InstallTargetFilesystemOpts { #[clap(long)] pub(crate) boot_mount_spec: Option, + /// Initialize empty filesystems mounted at or below /var in the target + /// from the image's /var, including nested mounts. + /// + /// Without this, filesystems that the caller mounted there are left alone, + /// and hide the image's /var content at boot. + #[clap(long)] + pub(crate) initialize_var_mounts: bool, + /// 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)] @@ -2177,6 +2188,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")); @@ -2291,6 +2303,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. @@ -2299,10 +2325,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)); @@ -2392,7 +2440,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(); @@ -2663,6 +2711,25 @@ pub enum Cleanup { } /// Implementation of the `bootc install to-filesystem` CLI command. +/// Reinstalling over or alongside an existing OS must not initialize its +/// live state. Checked before anything on the target is changed. +fn check_initialize_var_mounts( + initialize_var_mounts: bool, + targeting_host_root: bool, + is_already_ostree: bool, + replace: Option, +) -> Result<()> { + anyhow::ensure!( + !initialize_var_mounts + || !(targeting_host_root + || is_already_ostree + || replace == Some(ReplaceMode::Alongside)), + "--initialize-var-mounts is not supported over an existing ostree system \ + or with --replace=alongside" + ); + Ok(()) +} + #[context("Installing to filesystem")] pub(crate) async fn install_to_filesystem( opts: InstallToFilesystemOpts, @@ -2744,6 +2811,12 @@ pub(crate) async fn install_to_filesystem( ); fsopts.root_path = possible_physical_root; }; + check_initialize_var_mounts( + fsopts.initialize_var_mounts, + targeting_host_root, + is_already_ostree, + fsopts.replace, + )?; // Get a file descriptor for the root path // It will be /target/sysroot on ostree OS, or will be /target @@ -2939,7 +3012,12 @@ pub(crate) async fn install_to_filesystem( skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + let var_mounts = if fsopts.initialize_var_mounts { + var_mounts::discover(&rootfs.physical_root_path)? + } else { + Vec::new() + }; + 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); @@ -2984,6 +3062,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> root_path: opts.root_path, root_mount_spec: None, boot_mount_spec: None, + initialize_var_mounts: false, replace: opts.replace, skip_finalize: true, acknowledge_destructive: opts.acknowledge_destructive, @@ -3301,6 +3380,34 @@ mod tests { } } + #[test] + fn test_check_initialize_var_mounts() { + use ReplaceMode::*; + // (initialize, host root, already ostree, replace, allowed) + let cases = [ + (false, true, true, Some(Alongside), true), + (true, false, false, None, true), + (true, false, false, Some(Wipe), true), + (true, false, false, Some(Alongside), false), + (true, false, true, None, false), + (true, true, false, None, false), + ]; + for (init, host_root, ostree, replace, allowed) in cases { + let r = check_initialize_var_mounts(init, host_root, ostree, replace); + match r { + Ok(()) => assert!(allowed, "{init} {host_root} {ostree} {replace:?}"), + Err(e) => { + assert!(!allowed, "{init} {host_root} {ostree} {replace:?}: {e}"); + assert_eq!( + e.to_string(), + "--initialize-var-mounts is not supported over an existing ostree \ + system or with --replace=alongside" + ); + } + } + } + } + #[test] fn test_mountspec() { let mut ms = MountSpec::new("/dev/vda4", "/boot"); diff --git a/crates/lib/src/install/var_mounts.rs b/crates/lib/src/install/var_mounts.rs new file mode 100644 index 0000000000..0d4f90bea5 --- /dev/null +++ b/crates/lib/src/install/var_mounts.rs @@ -0,0 +1,441 @@ +//! 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; + +/// Listed by `bootc --version`, so that tools preparing a target for +/// `install to-filesystem` know they can pass `--initialize-var-mounts`. +/// Older versions leave /var 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. +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 9549e156b2..b5f830d752 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -16,6 +16,36 @@ 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. +To use separate filesystems for `/var` or its subdirectories, mount them beneath +*ROOT_PATH* before invoking this command, and pass `--initialize-var-mounts`. +On a fresh installation, bootc then initializes empty mounted trees from the +image's initial `/var` contents, including nested mounts. Without that option, +such filesystems are left alone: the image's initial `/var` contents stay in +the deployment's state directory, where these mounts hide them at boot. +`--initialize-var-mounts` is an error over an existing ostree system or with +`--replace=alongside`. + +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 synced, and SELinux labeled when SELinux is +enabled. 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. 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. + +Tools can tell whether a bootc binary supports `--initialize-var-mounts` by +looking for `initialize-var-mounts` in the `Features:` list of +`bootc --version`. + # OPTIONS @@ -33,6 +63,10 @@ is currently expected to be empty by default. Mount specification for the /boot filesystem +**--initialize-var-mounts** + + Initialize empty filesystems mounted at or below /var in the target from the image's /var, including nested mounts + **--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 fee2aef2da..b2cc6f8571 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,32 +142,40 @@ 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 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 - -# 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 the partitions, with /var as a separate partition and a nested +# filesystem below it +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 + + 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 +} -# Critical: Mount /var as a separate partition -mkdir -p /var/mnt/target/var -mount /dev/BL/var02 /var/mnt/target/var +mount_target echo "Filesystem layout:" mount | grep /var/mnt/target || true @@ -181,21 +197,25 @@ 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 \ - --disable-selinux \ - "${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 +} + +run_install --initialize-var-mounts # Verify the installation succeeded echo "Verifying installation..." @@ -221,5 +241,47 @@ 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 "Caller-mounted /var is left alone without --initialize-var-mounts" +# Start over on fresh filesystems, keeping their UUIDs for KARGS +umount -R /var/mnt/target +mkfs.vfat -F32 "$EFI_PART" +mkfs.ext4 -F -U "$BOOT_UUID" "$BOOT_PART" +mkfs.ext4 -F -U "$ROOT_UUID" /dev/BL/root02 +mkfs.ext4 -F /dev/BL/var02 +mkfs.ext4 -F /dev/BL/child02 +if [[ $is_composefs != "null" ]]; then + tune2fs -O verity /dev/BL/var02 + tune2fs -O verity /dev/BL/root02 +fi +mount_target +run_install +test ! -e /var/mnt/target/var/lib/bootc-var-test/parent +test ! -e "$child/seed" +# The image's /var stays in the backend's state directory instead +if [[ $is_composefs == "null" ]]; then + seeded_var=/var/mnt/target/ostree/deploy/default/var +else + seeded_var=/var/mnt/target/state/os/default/var +fi +test "$(cat "$seeded_var/lib/bootc-var-test/parent")" = 'parent seed' echo "Installation to-filesystem with separate /var mount succeeded!"