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 3953f6a9ee..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 "$EFI_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 +BOOT_UUID=$(blkid -s UUID -o value "$BOOT_PART") + +# 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!"