From deb6690f5c407dc6d0d0a2e4c6a27a3ebf5e3fd2 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:10:55 -0500 Subject: [PATCH 1/4] install: Preserve /var when installing to existing root Package-mode migrations need data from the running system to be available in the new OSTree deployment before reboot. Add opt-in copy/reflink migration, bounded exclusions, nested-mount handling, and defer cleanup until preservation succeeds. Validate backend compatibility and stored bound images before mutating the target. Assisted-by: AI Signed-off-by: ckyrouac --- crates/lib/src/cli.rs | 15 +- crates/lib/src/install.rs | 263 ++- crates/lib/src/install/config.rs | 31 + crates/lib/src/install/migrate.rs | 2080 +++++++++++++++++ crates/lib/src/store/mod.rs | 2 + crates/lib/src/store/reflink.rs | 279 +++ docs/src/bootc-installation.7.md | 8 + docs/src/man/bootc-install-config.5.md | 5 +- .../man/bootc-install-to-existing-root.8.md | 17 + 9 files changed, 2655 insertions(+), 45 deletions(-) create mode 100644 crates/lib/src/install/migrate.rs create mode 100644 crates/lib/src/store/reflink.rs diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index cc7536f2ff..d9c1f2acc9 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -348,6 +348,9 @@ pub(crate) enum InstallOpts { /// the running host root filesystem. Currently, the host root filesystem's `/boot` partition /// will be wiped, but the content of the existing root will otherwise be retained, and will /// need to be cleaned up if desired when rebooted into the new root. + /// + /// When migrating from a package-mode system, use `--preserve-var` to copy `/var` data + /// into the new deployment before rebooting. ToExistingRoot(crate::install::InstallToExistingRootOpts), /// Nondestructively create a fresh installation state inside an existing bootc system. /// @@ -2400,10 +2403,14 @@ async fn run_from_opt(opt: Opt) -> Result { InstallOpts::Mount(opts) => crate::mount::mount(opts).await, #[cfg(feature = "install-to-disk")] InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await, - InstallOpts::ToFilesystem(opts) => { - crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip) - .await - } + InstallOpts::ToFilesystem(opts) => crate::install::install_to_filesystem( + opts, + false, + crate::install::Cleanup::Skip, + false, + ) + .await + .map(|_| ()), InstallOpts::ToExistingRoot(opts) => { crate::install::install_to_existing_root(opts).await } diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index a53e28f49c..02bdf8b2a6 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -144,6 +144,7 @@ mod aleph; pub(crate) mod baseline; pub(crate) mod completion; pub(crate) mod config; +pub(crate) mod migrate; mod osbuild; pub(crate) mod osconfig; @@ -635,6 +636,23 @@ pub(crate) struct InstallToExistingRootOpts { #[clap(flatten)] pub(crate) composefs_opts: InstallComposefsOpts, + + /// Preserve the running system's `/var` data into the new bootc deployment. + /// + /// Non-excluded content is copied with reflinks when supported, or with a + /// full copy otherwise. Reflinks are created per file and are not a + /// snapshot; stop stateful services or use a consistent snapshot before + /// copying live database data. + /// + /// `/var/tmp`, `/var/cache`, `/var/log/journal`, and `/var/lib/containers` + /// are always excluded. Repeat `--preserve-var-skip` to add paths relative + /// to `/var`. Nested mountpoints are represented by empty directories. + #[clap(long)] + pub(crate) preserve_var: bool, + + /// Relative paths below `/var` to leave out while preserving `/var`. + #[clap(long = "preserve-var-skip", value_name = "PATH")] + pub(crate) preserve_var_skip: Vec, } #[derive(Debug, clap::Parser, PartialEq, Eq)] @@ -2062,7 +2080,7 @@ async fn install_with_sysroot( boot_uuid: &str, bound_images: BoundImages, has_ostree: bool, -) -> Result<()> { +) -> Result { let ostree = storage.get_ostree()?; let c_storage = storage.get_ensure_imgstore()?; @@ -2129,7 +2147,7 @@ async fn install_with_sysroot( } } - Ok(()) + Ok(camino::Utf8PathBuf::from(deployment_path.to_string())) } enum BoundImages { @@ -2140,35 +2158,41 @@ enum BoundImages { impl BoundImages { async fn from_state(state: &State) -> Result { - let bound_images = match state.config_opts.bound_images { - BoundImagesOpt::Skip => BoundImages::Skip, - others => { - let queried_images = crate::boundimage::query_bound_images(&state.container_root)?; - match others { - BoundImagesOpt::Stored => { - // Verify each bound image is present in the container storage - let mut r = Vec::with_capacity(queried_images.len()); - for image in queried_images { - let resolved = ResolvedBoundImage::from_image(&image).await?; - tracing::debug!("Resolved {}: {}", resolved.image, resolved.digest); - r.push(resolved) - } - BoundImages::Resolved(r) - } - BoundImagesOpt::Pull => { - // No need to resolve the images, we will pull them into the target later - BoundImages::Unresolved(queried_images) - } - BoundImagesOpt::Skip => anyhow::bail!("unreachable error"), + let bound_images_opt = state.config_opts.bound_images; + if bound_images_opt == BoundImagesOpt::Skip { + return Ok(BoundImages::Skip); + } + + let queried_images = crate::boundimage::query_bound_images(&state.container_root)?; + Self::from_queried(bound_images_opt, queried_images).await + } + + async fn from_queried( + bound_images_opt: BoundImagesOpt, + queried_images: Vec, + ) -> Result { + match bound_images_opt { + BoundImagesOpt::Stored => { + let mut resolved_images = Vec::with_capacity(queried_images.len()); + for image in queried_images { + let resolved = ResolvedBoundImage::from_image(&image).await?; + tracing::debug!("Resolved {}: {}", resolved.image, resolved.digest); + resolved_images.push(resolved); } + Ok(BoundImages::Resolved(resolved_images)) } - }; - - Ok(bound_images) + BoundImagesOpt::Pull => Ok(BoundImages::Unresolved(queried_images)), + BoundImagesOpt::Skip => Ok(BoundImages::Skip), + } } } -async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> { +async fn ostree_install( + state: &State, + rootfs: &RootSetup, + cleanup: Cleanup, + pre_resolved_bound_images: Option, +) -> Result { // We verify this upfront because it's currently required by bootupd let boot_uuid = rootfs .get_boot_uuid()? @@ -2176,14 +2200,17 @@ async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> .ok_or_else(|| anyhow!("No uuid for boot/root"))?; tracing::debug!("boot uuid={boot_uuid}"); - let bound_images = BoundImages::from_state(state).await?; + let bound_images = match pre_resolved_bound_images { + Some(bound_images) => bound_images, + None => BoundImages::from_state(state).await?, + }; // Initialize the ostree sysroot (repo, stateroot, etc.) - { + let deployment_path = { let (sysroot, has_ostree) = initialize_ostree_root(state, rootfs).await?; - install_with_sysroot( + let deployment_path = install_with_sysroot( state, rootfs, &sysroot, @@ -2206,19 +2233,21 @@ async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> // We must drop the sysroot here in order to close any open file // descriptors. + deployment_path }; // Run this on every install as the penultimate step install_finalize(&rootfs.physical_root_path).await?; - Ok(()) + Ok(deployment_path) } async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, cleanup: Cleanup, -) -> Result<()> { + pre_resolved_bound_images: Option, +) -> Result> { if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.extend(&Cmdline::from("selinux=0")); } @@ -2240,7 +2269,7 @@ async fn install_to_filesystem_impl( } } - if state.composefs_options.composefs_backend { + let deployment_path = if state.composefs_options.composefs_backend { let fetch_ref = state.source.composefs_fetch_reference(); let manifest = get_container_manifest_and_config(&fetch_ref).await?; // A capable filesystem gets a strict provisional repository. The @@ -2312,8 +2341,10 @@ async fn install_to_filesystem_impl( ) .context("SELinux labeling of composefs objects")?; } + None } else { - ostree_install(state, rootfs, cleanup).await?; + let deployment_path = + ostree_install(state, rootfs, cleanup, pre_resolved_bound_images).await?; // For s390x, we set zipl as the bootloader // this needs to be done after the ostree commit is deployed, @@ -2332,7 +2363,8 @@ async fn install_to_filesystem_impl( .run_capture_stderr() .context("Setting bootloader config to zipl")?; } - } + Some(deployment_path) + }; // As the very last step before filesystem finalization, do a full SELinux // relabel of the physical root filesystem. Any files that are already @@ -2354,7 +2386,7 @@ async fn install_to_filesystem_impl( } } - Ok(()) + Ok(deployment_path) } fn installation_complete() { @@ -2436,7 +2468,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, None).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(); @@ -2712,7 +2744,8 @@ pub(crate) async fn install_to_filesystem( opts: InstallToFilesystemOpts, targeting_host_root: bool, cleanup: Cleanup, -) -> Result<()> { + require_ostree_backend: bool, +) -> Result> { // Log the installation operation to systemd journal const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3"; let source_image = opts @@ -2824,6 +2857,21 @@ pub(crate) async fn install_to_filesystem( ) .await?; + // The image can select the composefs backend during preparation, so check + // compatibility after that choice is resolved but before target mutation. + validate_migration_backend( + require_ostree_backend, + state.composefs_options.composefs_backend, + )?; + + // Resolve stored bound images before replacement or boot-directory cleanup + // can mutate the existing root. Pull mode remains unresolved until deploy. + let pre_resolved_bound_images = if targeting_host_root && require_ostree_backend { + Some(BoundImages::from_state(&state).await?) + } else { + None + }; + // Check to see if this happens to be the real host root if !fsopts.acknowledge_destructive { warn_on_host_root(&target_rootfs_fd)?; @@ -2986,13 +3034,41 @@ pub(crate) async fn install_to_filesystem( skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + let deployment_path = + install_to_filesystem_impl(&state, &mut rootfs, cleanup, pre_resolved_bound_images).await?; // Drop all data about the root except the path to ensure any file descriptors etc. are closed. drop(rootfs); installation_complete(); + Ok(deployment_path) +} + +fn validate_migration_backend(require_ostree_backend: bool, composefs_backend: bool) -> Result<()> { + anyhow::ensure!( + !require_ostree_backend || !composefs_backend, + "--preserve-var requires the ostree backend" + ); + Ok(()) +} + +/// Resolve the physical sysroot when `root_path` points at an OSTree deployment. +fn physical_sysroot_path(root_path: &std::path::Path) -> std::path::PathBuf { + let possible_sysroot = root_path.join("sysroot"); + if possible_sysroot.join("ostree").exists() { + possible_sysroot + } else { + root_path.to_owned() + } +} + +fn write_destructive_cleanup_marker(root_path: &std::path::Path) -> Result<()> { + let physical_root = physical_sysroot_path(root_path); + let sysroot = Dir::open_ambient_dir(&physical_root, cap_std::ambient_authority()) + .with_context(|| format!("Opening physical sysroot {physical_root:?}"))?; + tracing::debug!("Writing {DESTRUCTIVE_CLEANUP}"); + sysroot.atomic_write(DESTRUCTIVE_CLEANUP, b"")?; Ok(()) } @@ -3026,7 +3102,23 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> false => Cleanup::Skip, }; - let opts = InstallToFilesystemOpts { + let preserve_var = opts.preserve_var; + let defer_cleanup = opts.cleanup && preserve_var; + let root_path = std::path::PathBuf::from(opts.root_path.as_str()); + let mut preserve_var_skip = opts.preserve_var_skip; + if let Some(config) = config::load_config()? { + if let Some(config_skip) = config.preserve_var_skip { + preserve_var_skip.extend(config_skip); + } + } + let preserve_var_skip = migrate::validate_exclusions(&preserve_var_skip)?; + let require_ostree_backend = preserve_var; + validate_migration_backend( + require_ostree_backend, + opts.composefs_opts.composefs_backend, + )?; + + let fs_opts = InstallToFilesystemOpts { filesystem_opts: InstallTargetFilesystemOpts { root_path: opts.root_path, root_mount_spec: None, @@ -3041,7 +3133,36 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> composefs_opts: opts.composefs_opts, }; - install_to_filesystem(opts, true, cleanup).await + let deployment_path = install_to_filesystem( + fs_opts, + true, + if defer_cleanup { + Cleanup::Skip + } else { + cleanup + }, + require_ostree_backend, + ) + .await?; + + if preserve_var { + println!(); + println!("Preserving /var..."); + let deployment_path = deployment_path + .as_ref() + .context("Install did not produce an ostree deployment")?; + let physical_root = physical_sysroot_path(&root_path); + let deploy_dir = physical_root.join(deployment_path); + let new_var = migrate::deployment_var_path(&deploy_dir)?; + migrate::preserve_var(&root_path.join("var"), &new_var, &preserve_var_skip) + .context("Post-install /var preservation")?; + } + + if defer_cleanup { + write_destructive_cleanup_marker(&root_path)?; + } + + Ok(()) } /// Read the /boot entry from /etc/fstab, if it exists @@ -3606,4 +3727,66 @@ UUID=boot-uuid /boot ext4 defaults 0 0 Ok(()) } + + #[test] + fn test_migration_backend_compatibility() { + let cases = [ + (false, false, true), + (false, true, true), + (true, false, true), + (true, true, false), + ]; + for (require_ostree_backend, composefs_backend, valid) in cases { + assert_eq!( + validate_migration_backend(require_ostree_backend, composefs_backend).is_ok(), + valid, + "require_ostree_backend={require_ostree_backend}, composefs_backend={composefs_backend}" + ); + } + } + + #[tokio::test] + async fn test_bound_images_pull_mode_stays_unresolved() { + let queried_images = vec![BoundImage { + image: "quay.io/example/app:latest".to_string(), + auth_file: None, + }]; + + let bound_images = BoundImages::from_queried(BoundImagesOpt::Pull, queried_images) + .await + .unwrap(); + let BoundImages::Unresolved(images) = bound_images else { + panic!("pull mode should defer bound image resolution until deployment"); + }; + assert_eq!(images.len(), 1); + assert_eq!(images[0].image, "quay.io/example/app:latest"); + } + + #[test] + fn test_destructive_cleanup_marker_uses_physical_sysroot() -> Result<()> { + let td = tempfile::tempdir()?; + let deployment_root = td.path(); + let physical_sysroot = deployment_root.join("sysroot"); + std::fs::create_dir_all(deployment_root.join("etc"))?; + std::fs::create_dir_all(physical_sysroot.join("ostree"))?; + std::fs::create_dir_all(physical_sysroot.join("etc"))?; + + write_destructive_cleanup_marker(deployment_root)?; + + assert!(physical_sysroot.join(DESTRUCTIVE_CLEANUP).is_file()); + assert!(!deployment_root.join(DESTRUCTIVE_CLEANUP).exists()); + Ok(()) + } + + #[test] + fn test_destructive_cleanup_marker_uses_root_without_nested_sysroot() -> Result<()> { + let td = tempfile::tempdir()?; + let root = td.path(); + std::fs::create_dir_all(root.join("etc"))?; + + write_destructive_cleanup_marker(root)?; + + assert!(root.join(DESTRUCTIVE_CLEANUP).is_file()); + Ok(()) + } } diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 55fdd42f98..fc3e86c599 100644 --- a/crates/lib/src/install/config.rs +++ b/crates/lib/src/install/config.rs @@ -132,6 +132,8 @@ pub(crate) struct InstallConfiguration { /// Enforce that the containers-storage stack has a non-default /// (i.e. not `insecureAcceptAnything`) container image signature policy. pub(crate) enforce_container_sigpolicy: Option, + /// Paths below `/var` which should not be copied by `--preserve-var`. + pub(crate) preserve_var_skip: Option>, } fn merge_basic(s: &mut Option, o: Option, _env: &EnvProperties) { @@ -226,6 +228,11 @@ impl Mergeable for InstallConfiguration { other.enforce_container_sigpolicy, env, ); + if let Some(other_skip) = other.preserve_var_skip { + self.preserve_var_skip + .get_or_insert_with(Default::default) + .extend(other_skip) + } if let Some(other_kargs) = other.kargs { self.kargs .get_or_insert_with(Default::default) @@ -878,6 +885,30 @@ skip-boot-uuid = false // skip_boot_uuid should be overridden to true assert_eq!(install.bootupd.unwrap().skip_boot_uuid.unwrap(), true); } + + #[test] + fn test_merge_preserve_var_skip_appends_paths() { + let env = EnvProperties { + sys_arch: "x86_64".to_string(), + }; + let mut install: InstallConfiguration = toml::from_str( + r#"preserve-var-skip = ["lib/first"] +"#, + ) + .unwrap(); + let other: InstallConfiguration = toml::from_str( + r#"preserve-var-skip = ["lib/second"] +"#, + ) + .unwrap(); + + install.merge(other, &env); + + assert_eq!( + install.preserve_var_skip.unwrap(), + ["lib/first", "lib/second"] + ); + } } #[test] diff --git a/crates/lib/src/install/migrate.rs b/crates/lib/src/install/migrate.rs new file mode 100644 index 0000000000..6acb406154 --- /dev/null +++ b/crates/lib/src/install/migrate.rs @@ -0,0 +1,2080 @@ +//! # Package-mode to image-mode migration helpers +//! +//! This module implements the post-install state-preservation steps that make +//! `bootc install to-existing-root` useful for converting a live package-mode +//! (RPM/DEB) system to a bootc image-mode deployment without losing data. +//! +//! This migration is triggered by passing `--preserve-var` to +//! `bootc install to-existing-root`. It runs **after** the core install +//! (ostree deploy + bootupd) completes but **before** the first reboot, while +//! the package-mode environment is still the running OS. +//! +//! ## `/var` preservation (`--preserve-var`) +//! +//! After a plain `bootc install to-existing-root`, the new deployment's `/var` +//! is bound from `/ostree/deploy//var/` — an initially-empty +//! directory. The old package-mode `/var` is stranded at `/var` +//! (inside the container, the host root is mounted at `root_path`). +//! +//! Two strategies are tried in order: +//! +//! - **Strategy C — reflink copy** (btrfs / XFS with reflinks): `cp --reflink=always` +//! clones files copy-on-write, avoiding an immediate second copy of their data. +//! Files are cloned individually, so this is not an atomic snapshot of the +//! `/var` tree or of a live database. +//! +//! - **Strategy D — plain copy** (ext4 and other non-reflink filesystems): +//! `cp -a` copies each non-ephemeral subdirectory of `/var` into the new +//! deployment's stateroot `var/`. This is correct but slow for large `/var` +//! trees, and **unsafe for live databases** (see the known-limitation comment +//! on `preserve_var_copy`). +//! +//! Nested mountpoints are selected from one `/proc/self/mountinfo` snapshot +//! before the copy. This is a point-in-time, best-effort snapshot, not a +//! race-free guard: mounts added or removed beneath `/var` during copying can +//! race with the copy, just like concurrent file changes. +//! Each missing entry is copied to a unique sibling staging directory and +//! published atomically without replacing an existing destination. A failed +//! entry leaves no partial final entry and can be retried; this is not a global +//! transaction, so entries completely published before a later failure remain. +//! +//! Exclusions such as `tmp`, `log/journal`, `lib/containers`, or `lib/rpm` +//! can be supplied through configuration or repeated `--preserve-var-skip` +//! options. +//! +//! Rollback preservation is intentionally independent from `/var` migration. + +use std::path::{Component, Path}; +use std::{ + collections::BTreeSet, + ffi::OsStr, + os::fd::AsFd, + os::unix::ffi::OsStringExt, + path::PathBuf, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use crate::store::reflink::supports_reflink; +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::{Dir as CapStdDir, Metadata, MetadataExt}; +use cap_std_ext::cap_std::io_lifetimes::AsFilelike; +use cap_std_ext::cmdext::{CapStdExtCommandExt, CmdFds}; +#[cfg(test)] +use fn_error_context::context; + +/// Preserve the running `/var` into the new deployment's `var/` directory. +/// +/// `src_var` is the running system's `/var` (at `/var`). +/// `new_var` is the new deployment's empty `var/` directory. +pub(crate) fn preserve_var(src_var: &Path, new_var: &Path, exclusions: &[String]) -> Result<()> { + // Resolve symlink aliases and path components before comparing against the + // absolute, kernel-resolved mountpoints in mountinfo. + let canonical_src_var = std::fs::canonicalize(src_var) + .with_context(|| format!("Canonicalizing source /var {}", src_var.display()))?; + let mountinfo_snapshot = read_mountinfo_snapshot()?; + let nested_mounts = + nested_mountpoints(&canonical_src_var, parse_mountinfo(&mountinfo_snapshot)?)?; + let src_var = CapStdDir::open_ambient_dir(&canonical_src_var, ambient_authority()) + .with_context(|| format!("Opening source {}", canonical_src_var.display()))?; + let new_var = open_or_create_var_dir(new_var)?; + + let mut effective_exclusions = ["tmp", "cache", "log/journal", "lib/containers"] + .into_iter() + .map(String::from) + .collect::>(); + effective_exclusions.extend(exclusions.iter().cloned()); + + let copy_result = if supports_reflink(&src_var, &new_var)? { + println!(" Filesystem supports reflinks — using copy-on-write clone (Strategy C)"); + preserve_var_reflink_mounts(&src_var, &new_var, &effective_exclusions, &nested_mounts) + } else { + println!(" Filesystem does not support reflinks — falling back to full copy (Strategy D)"); + preserve_var_copy_mounts(&src_var, &new_var, &effective_exclusions, &nested_mounts) + }; + copy_result?; + + Ok(()) +} + +/// Read one complete installer mount namespace snapshot. The initial snapshot +/// is used to select nested mounts for this copy; changes during copying can +/// race with it, just like concurrent changes to files beneath `/var`. +fn read_mountinfo_snapshot() -> Result> { + std::fs::read("/proc/self/mountinfo") + .context("Reading installer /proc/self/mountinfo for /var migration") +} + +fn nested_mountpoints( + src_var: &Path, + mountpoints: impl IntoIterator, +) -> Result> { + anyhow::ensure!(src_var.is_absolute(), "Source /var path must be absolute"); + let canonical_src_var = std::fs::canonicalize(src_var) + .with_context(|| format!("Canonicalizing source /var {}", src_var.display()))?; + let mut nested = BTreeSet::new(); + for mountpoint in mountpoints { + let Ok(relative) = mountpoint.strip_prefix(&canonical_src_var) else { + continue; + }; + // The mount on /var itself is not a nested mount. Only child paths are + // filtered; Path::strip_prefix provides component-safe containment. + if relative.as_os_str().is_empty() { + continue; + } + anyhow::ensure!( + relative + .components() + .all(|component| matches!(component, Component::Normal(_))), + "Invalid nested mountpoint below {}: {}", + canonical_src_var.display(), + mountpoint.display() + ); + nested.insert(relative.to_path_buf()); + } + Ok(nested) +} + +/// Parse the byte-oriented Linux mountinfo format without requiring UTF-8. +/// Kernel pathname escapes are decoded before paths are constructed. +fn parse_mountinfo(data: &[u8]) -> Result> { + let mut mountpoints = Vec::new(); + let lines = data.split(|byte| *byte == b'\n'); + let line_count = lines.clone().count(); + for (index, line) in lines.enumerate() { + if line.is_empty() && index + 1 == line_count { + continue; // A final newline is conventional. + } + anyhow::ensure!(!line.is_empty(), "Empty mountinfo line {}", index + 1); + anyhow::ensure!( + !line.contains(&0), + "NUL byte in mountinfo line {}", + index + 1 + ); + let fields = line + .split(|byte| byte.is_ascii_whitespace()) + .filter(|field| !field.is_empty()) + .collect::>(); + let separator = fields + .iter() + .enumerate() + .skip(6) + .find_map(|(index, field)| (*field == b"-").then_some(index)) + .with_context(|| { + format!("Malformed mountinfo line {}: missing separator", index + 1) + })?; + anyhow::ensure!( + separator >= 6 && fields.len() == separator + 4, + "Malformed mountinfo line {}: invalid field count", + index + 1 + ); + anyhow::ensure!( + is_positive_decimal(fields[0]) + && is_decimal(fields[1]) + && is_device_number(fields[2]) + && !fields[5].is_empty() + && !fields[separator + 1].is_empty() + && !fields[separator + 2].is_empty() + && !fields[separator + 3].is_empty(), + "Malformed mountinfo line {}: invalid required field", + index + 1 + ); + // The root field is not used for mount selection. Decode it to ensure + // its escape syntax is valid, but don't impose pathname constraints: + // unlike the mountpoint, it may be relative or noncanonical. + decode_mountinfo_escapes(fields[3]) + .with_context(|| format!("Malformed root path on mountinfo line {}", index + 1))?; + let mountpoint = decode_mountinfo_path(fields[4]) + .with_context(|| format!("Malformed mountpoint on mountinfo line {}", index + 1))?; + decode_mountinfo_escapes(fields[separator + 2]) + .with_context(|| format!("Malformed source path on mountinfo line {}", index + 1))?; + mountpoints.push(mountpoint); + } + anyhow::ensure!(!mountpoints.is_empty(), "Mountinfo contained no records"); + Ok(mountpoints) +} + +fn is_decimal(value: &[u8]) -> bool { + !value.is_empty() + && value.iter().all(u8::is_ascii_digit) + && std::str::from_utf8(value) + .ok() + .and_then(|value| value.parse::().ok()) + .is_some() +} + +fn is_positive_decimal(value: &[u8]) -> bool { + is_decimal(value) + && std::str::from_utf8(value) + .ok() + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value > 0) +} + +fn is_device_number(value: &[u8]) -> bool { + let Some(separator) = value.iter().position(|byte| *byte == b':') else { + return false; + }; + is_decimal(&value[..separator]) && is_decimal(&value[separator + 1..]) +} + +fn decode_mountinfo_path(value: &[u8]) -> Result { + let decoded = decode_mountinfo_escapes(value)?; + anyhow::ensure!( + decoded.first() == Some(&b'/'), + "Mountinfo path is not absolute" + ); + let mut components = decoded[1..].split(|byte| *byte == b'/'); + anyhow::ensure!( + decoded.len() == 1 + || components.all(|component| { + !component.is_empty() && component != b"." && component != b".." + }), + "Mountinfo path is not canonical" + ); + Ok(PathBuf::from(std::ffi::OsString::from_vec(decoded))) +} + +fn decode_mountinfo_escapes(value: &[u8]) -> Result> { + let mut decoded = Vec::with_capacity(value.len()); + let mut index = 0; + while index < value.len() { + if value[index] != b'\\' { + decoded.push(value[index]); + index += 1; + continue; + } + anyhow::ensure!( + index + 3 < value.len() + && value[index + 1..index + 4] + .iter() + .all(|byte| (b'0'..=b'7').contains(byte)), + "Invalid octal escape in mountinfo path" + ); + // Mountinfo escapes are three octal digits. Decode wider than a byte + // so malformed values such as `\\777` cannot overflow or wrap. + let escaped = u16::from(value[index + 1] - b'0') * 64 + + u16::from(value[index + 2] - b'0') * 8 + + u16::from(value[index + 3] - b'0'); + anyhow::ensure!( + escaped <= u16::from(u8::MAX), + "Octal escape exceeds one byte" + ); + anyhow::ensure!( + matches!(escaped, 0o11 | 0o12 | 0o40 | 0o134), + "Unsupported octal escape in mountinfo path" + ); + decoded.push(escaped as u8); + index += 4; + } + Ok(decoded) +} + +/// Open the destination through its parent capability, creating just the +/// final `/var` directory if needed. The caller-supplied parent is the initial +/// ambient authority; all subsequent traversal is relative to opened handles. +fn open_or_create_var_dir(path: &Path) -> Result { + let parent = path.parent().context("Destination /var has no parent")?; + let name = path + .file_name() + .context("Destination /var has no final path component")?; + let parent = CapStdDir::open_ambient_dir(parent, ambient_authority()) + .with_context(|| format!("Opening destination parent {}", parent.display()))?; + match parent.create_dir(name) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + anyhow::ensure!( + parent.symlink_metadata(name)?.is_dir(), + "Destination {} exists and is not a directory", + path.display() + ); + } + Err(e) => return Err(e).with_context(|| format!("Creating {}", path.display())), + } + open_child_dir_nofollow(&parent, name) + .with_context(|| format!("Opening destination {}", path.display())) +} + +pub(crate) fn deployment_var_path(deploy_dir: &Path) -> Result { + let deploy_parent = deploy_dir + .parent() + .context("Deployment path has no parent")?; + let stateroot = deploy_parent + .parent() + .context("Deployment path has no stateroot")?; + Ok(stateroot.join("var")) +} + +/// Run GNU cp with directory handles inherited as fixed descriptors. Each +/// operand is `/proc/self/fd/N[/entry]`; cp's top-level paths therefore remain +/// rooted in the opened capabilities even if the original paths are renamed. +/// GNU cp still performs its own recursive traversal beneath each operand, so +/// this is not race-safe against a concurrent writer replacing entries while +/// the copy is in progress. +fn run_cp( + cp_program: &OsStr, + src_root: &CapStdDir, + src_names: &[&OsStr], + dst_root: &CapStdDir, + dst_name: Option<&OsStr>, + options: &[&str], + stdout: Stdio, + stderr: Stdio, +) -> Result { + let src_fd = Arc::new( + src_root + .as_filelike_view::() + .as_fd() + .try_clone_to_owned()?, + ); + let dst_fd = Arc::new( + dst_root + .as_filelike_view::() + .as_fd() + .try_clone_to_owned()?, + ); + let mut fds = CmdFds::new(); + fds.take_fd_n(src_fd, 3); + fds.take_fd_n(dst_fd, 4); + + let src_paths = src_names.iter().map(|name| { + let mut path = PathBuf::from("/proc/self/fd/3"); + path.push(name); + path + }); + let mut dst_path = PathBuf::from("/proc/self/fd/4"); + if let Some(name) = dst_name { + dst_path.push(name); + } + + let status = Command::new(cp_program) + .args(options) + .arg("--") + .args(src_paths) + .arg(dst_path) + .stdout(stdout) + .stderr(stderr) + .take_fds(fds) + .status() + .context("Running cp")?; + Ok(status) +} + +/// Strategy C: reflink-copy each top-level entry under `src_var` into `new_var`. +/// +/// Skips well-known ephemeral subdirectories. +#[cfg(test)] +#[context("Reflink-copying /var into new deployment (Strategy C)")] +fn preserve_var_reflink( + src_var: &CapStdDir, + new_var: &CapStdDir, + exclusions: &[String], +) -> Result<()> { + preserve_var_reflink_mounts(src_var, new_var, exclusions, &BTreeSet::new()) +} + +fn preserve_var_reflink_mounts( + src_var: &CapStdDir, + new_var: &CapStdDir, + exclusions: &[String], + mountpoints: &BTreeSet, +) -> Result<()> { + copy_var_filtered( + src_var, + new_var, + Path::new(""), + exclusions, + mountpoints, + true, + false, + OsStr::new("cp"), + ) + .context("Reflink-copying /var into new deployment (Strategy C)")?; + println!(" /var reflink copy complete."); + Ok(()) +} + +/// Copy a directory's ordinary children together with cp -a, recursing only +/// into directories whose descendants contain a mountpoint or exclusion. +/// Batching ordinary siblings in one cp invocation retains hardlinks between +/// those entries. Hardlinks crossing a mountpoint or exclusion split, or +/// crossing separate copies while merging into an existing destination +/// directory, may not remain linked. Untouched subtrees remain entirely under +/// cp -a's control. +fn copy_var_filtered( + src_dir: &CapStdDir, + dst_dir: &CapStdDir, + relative_dir: &Path, + exclusions: &[String], + mountpoints: &BTreeSet, + reflink: bool, + destination_created: bool, + cp_program: &OsStr, +) -> Result<()> { + // Existing directories are merged in place, so capture their own metadata + // before reading or copying children can alter their atime/mtime. The same + // wrapper restores source metadata on newly-created destination directories. + let source_metadata = if destination_created { + Some(snapshot_directory_metadata(src_dir).with_context(|| { + format!( + "Snapshotting source metadata for /var/{}", + relative_dir.display() + ) + })?) + } else { + None + }; + let destination_metadata = if destination_created { + None + } else { + Some(dst_dir.metadata(".").with_context(|| { + format!( + "Snapshotting existing destination timestamps for /var/{}", + relative_dir.display() + ) + })?) + }; + + let copy_result = copy_var_filtered_inner( + src_dir, + dst_dir, + relative_dir, + exclusions, + mountpoints, + reflink, + cp_program, + ); + let restore_result = match (source_metadata.as_ref(), destination_metadata.as_ref()) { + (Some(metadata), _) => restore_directory_metadata(dst_dir, metadata), + (None, Some(metadata)) => restore_directory_timestamps(dst_dir, metadata), + (None, None) => Ok(()), + } + .with_context(|| { + format!( + "Restoring directory metadata for /var/{}", + relative_dir.display() + ) + }); + + match (copy_result, restore_result) { + (Ok(()), Ok(())) => Ok(()), + (Ok(()), Err(restore_error)) => Err(restore_error), + (Err(copy_error), Ok(())) => Err(copy_error), + (Err(copy_error), Err(restore_error)) => Err(copy_error.context(format!( + "Additionally failed to restore /var/{} metadata: {restore_error:#}", + relative_dir.display() + ))), + } +} + +fn copy_var_filtered_inner( + src_dir: &CapStdDir, + dst_dir: &CapStdDir, + relative_dir: &Path, + exclusions: &[String], + mountpoints: &BTreeSet, + reflink: bool, + cp_program: &OsStr, +) -> Result<()> { + let entries = src_dir + .read_dir(".") + .with_context(|| format!("Reading /var/{}", relative_dir.display()))?; + + let mut included_entries = Vec::new(); + for entry in entries { + let entry = entry.context("Reading entry in /var during migration")?; + let name = entry.file_name(); + let relative = relative_dir.join(&name); + + if exclusions + .iter() + .any(|excluded| Path::new(excluded) == relative) + { + println!(" Skipping {} (excluded)", relative.display()); + continue; + } + + if mountpoints.contains(&relative) { + println!( + " Creating empty mountpoint placeholder for {}", + relative.display() + ); + create_staged_placeholder(src_dir, dst_dir, &name)?; + continue; + } + + let has_nested_mount = mountpoints + .iter() + .any(|mountpoint| mountpoint != &relative && mountpoint.starts_with(&relative)); + let has_nested_exclusion = exclusions.iter().any(|excluded| { + let excluded = Path::new(excluded); + excluded != relative && excluded.starts_with(&relative) + }); + let source_metadata = src_dir.symlink_metadata(&name)?; + if source_metadata.is_dir() { + let child_src = open_child_dir_nofollow(src_dir, &name) + .with_context(|| format!("Opening /var/{}", relative.display()))?; + // Use the opened directory as the source of truth for metadata. + // This keeps the directory metadata and xattrs tied to the same + // inode even if its parent entry is replaced after the open. + let existing_dst = open_existing_child_dir(dst_dir, &name)?; + if let Some(child_dst) = existing_dst { + copy_var_filtered( + &child_src, + &child_dst, + &relative, + exclusions, + mountpoints, + reflink, + false, + cp_program, + )?; + } else if destination_entry_exists(dst_dir, &name)? { + // A destination of a different type is an existing entry, not + // permission to replace it with the source directory. + continue; + } else if has_nested_mount || has_nested_exclusion { + copy_filtered_directory_staged( + &child_src, + dst_dir, + &name, + &relative, + exclusions, + mountpoints, + reflink, + cp_program, + )?; + } else { + included_entries.push(name); + } + continue; + } + if destination_entry_exists(dst_dir, &name)? { + // Match cp --no-clobber without ever opening the existing object. + continue; + } + included_entries.push(name); + } + + if !included_entries.is_empty() { + let names = included_entries + .iter() + .map(|name| name.as_os_str()) + .collect::>(); + copy_batch_staged( + cp_program, + src_dir, + &names, + dst_dir, + reflink, + &format!("/var/{}", relative_dir.display()), + )?; + } + + Ok(()) +} + +static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(0); + +/// Make an empty, uniquely-named staging directory beneath the destination +/// capability. Publishing from the same parent keeps the final rename atomic. +fn create_staging_dir(parent: &CapStdDir) -> Result<(std::ffi::OsString, CapStdDir)> { + use rustix::fs::{Mode, mkdirat}; + + let parent_fd = parent.as_filelike_view::(); + for _ in 0..100 { + let id = NEXT_STAGING_ID.fetch_add(1, Ordering::Relaxed); + let name = + std::ffi::OsString::from(format!(".bootc-preserve-var.{}.{}", std::process::id(), id)); + match mkdirat(parent_fd.as_fd(), &name, Mode::from_bits_truncate(0o700)) { + Ok(()) => match open_child_dir_nofollow(parent, &name) { + Ok(directory) => return Ok((name, directory)), + Err(error) => { + parent.remove_dir_all(&name).with_context(|| { + format!("Cleaning staging directory after open failure: {error}") + })?; + return Err(error).context("Opening /var migration staging directory"); + } + }, + Err(error) if error == rustix::io::Errno::EXIST => continue, + Err(error) => { + return Err(error).context("Creating /var migration staging directory"); + } + } + } + anyhow::bail!("Unable to allocate a unique /var migration staging directory") +} + +/// Ensure temporary staging entries are removed on both success and failure. +/// Any entries already published remain complete; transactions are per entry, +/// not a global rollback of earlier successful publications. +fn with_staging_dir( + parent: &CapStdDir, + operation: impl FnOnce(&OsStr, &CapStdDir) -> Result, +) -> Result { + let (name, directory) = create_staging_dir(parent)?; + let result = operation(&name, &directory); + drop(directory); + let cleanup = parent + .remove_dir_all(&name) + .context("Removing /var migration staging data"); + match (result, cleanup) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Err(error), Err(cleanup_error)) => Err(error.context(format!( + "Additionally failed to clean migration staging data: {cleanup_error:#}" + ))), + } +} + +fn destination_entry_exists(parent: &CapStdDir, name: &OsStr) -> Result { + match parent.symlink_metadata(name) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn publish_staged_entry( + staging: &CapStdDir, + destination: &CapStdDir, + name: &OsStr, +) -> Result { + use rustix::fs::{RenameFlags, renameat_with}; + + match renameat_with(staging, name, destination, name, RenameFlags::NOREPLACE) { + Ok(()) => Ok(true), + // A destination can appear after the initial no-clobber check. Merge + // staged directories into a same-type destination without replacing + // existing children; other collisions remain untouched. + Err(error) if error == rustix::io::Errno::EXIST => { + if staging.symlink_metadata(name)?.is_dir() { + if let Some(destination_dir) = open_existing_child_dir(destination, name)? { + let staging_dir = open_child_dir_nofollow(staging, name) + .context("Opening staged directory after publish collision")?; + merge_staged_directory(&staging_dir, &destination_dir)?; + } + } + Ok(false) + } + Err(error) => Err(error.into()), + } +} + +/// Move staged children into an existing destination directory without +/// replacing entries already present there. Keep the destination directory's +/// metadata unchanged; moving children updates its timestamps, so restore its +/// original metadata after the merge. +fn merge_staged_directory(staging: &CapStdDir, destination: &CapStdDir) -> Result<()> { + let metadata = snapshot_directory_metadata(destination)?; + let names = staging + .read_dir(".")? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>()?; + + let merge_result = (|| { + for name in names { + publish_staged_entry(staging, destination, &name)?; + } + Ok(()) + })(); + let restore_result = restore_directory_metadata(destination, &metadata); + + match (merge_result, restore_result) { + (Ok(()), Ok(())) => Ok(()), + (Ok(()), Err(error)) => Err(error), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(restore_error)) => Err(error.context(format!( + "Additionally failed to restore merged destination directory metadata: {restore_error:#}" + ))), + } +} + +fn copy_batch_staged( + cp_program: &OsStr, + source: &CapStdDir, + names: &[&OsStr], + destination: &CapStdDir, + reflink: bool, + description: &str, +) -> Result<()> { + with_staging_dir(destination, |_, staging| { + let options = if reflink { + &["--reflink=always", "-a"][..] + } else { + &["-a"][..] + }; + let status = run_cp( + cp_program, + source, + names, + staging, + None, + options, + Stdio::inherit(), + Stdio::inherit(), + )?; + anyhow::ensure!(status.success(), "cp failed while copying {description}"); + + for name in names { + publish_staged_entry(staging, destination, name)?; + } + Ok(()) + }) +} + +fn copy_filtered_directory_staged( + source: &CapStdDir, + destination_parent: &CapStdDir, + name: &OsStr, + relative: &Path, + exclusions: &[String], + mountpoints: &BTreeSet, + reflink: bool, + cp_program: &OsStr, +) -> Result<()> { + with_staging_dir(destination_parent, |_, staging| { + staging.create_dir(name)?; + let destination = open_child_dir_nofollow(staging, name)?; + copy_var_filtered( + source, + &destination, + relative, + exclusions, + mountpoints, + reflink, + true, + cp_program, + )?; + publish_staged_entry(staging, destination_parent, name)?; + Ok(()) + }) +} + +fn create_staged_placeholder( + src_parent: &CapStdDir, + dst_parent: &CapStdDir, + name: &OsStr, +) -> Result<()> { + with_staging_dir(dst_parent, |_, staging| { + create_mountpoint_placeholder(src_parent, staging, name)?; + if !publish_staged_entry(staging, dst_parent, name)? { + validate_empty_placeholder(dst_parent, name, src_parent.symlink_metadata(name)?)?; + } + Ok(()) + }) +} + +fn validate_empty_placeholder( + destination_parent: &CapStdDir, + name: &OsStr, + source_metadata: Metadata, +) -> Result<()> { + let destination_metadata = destination_parent.symlink_metadata(name)?; + if source_metadata.is_dir() { + anyhow::ensure!( + destination_metadata.is_dir(), + "Mountpoint placeholder {} exists with the wrong type", + name.to_string_lossy() + ); + let destination = open_child_dir_nofollow(destination_parent, name)?; + let mut entries = destination.read_dir(".")?; + anyhow::ensure!( + entries.next().transpose()?.is_none(), + "Mountpoint placeholder {} is not empty", + name.to_string_lossy() + ); + } else { + anyhow::ensure!( + source_metadata.is_file() && destination_metadata.is_file(), + "Mountpoint placeholder {} exists with the wrong type", + name.to_string_lossy() + ); + anyhow::ensure!( + destination_metadata.len() == 0, + "Mountpoint placeholder {} is not empty", + name.to_string_lossy() + ); + } + Ok(()) +} + +fn create_mountpoint_placeholder( + src_parent: &CapStdDir, + dst_parent: &CapStdDir, + name: &OsStr, +) -> Result<()> { + let metadata = src_parent.symlink_metadata(name)?; + if metadata.is_dir() { + let src_dir = open_child_dir_nofollow(src_parent, name) + .context("Opening mountpoint source directory")?; + let directory_metadata = src_dir + .metadata(".") + .context("Reading mountpoint source directory metadata")?; + dst_parent.create_dir(name).with_context(|| { + format!( + "Creating mountpoint directory placeholder {}", + name.to_string_lossy() + ) + })?; + let dst_dir = open_child_dir_nofollow(dst_parent, name) + .context("Opening mountpoint directory placeholder")?; + preserve_directory_metadata(&src_dir, &dst_dir, &directory_metadata) + .context("Preserving mountpoint directory metadata")?; + return Ok(()); + } + anyhow::ensure!( + metadata.is_file(), + "Cannot create safe placeholder for non-file mountpoint {}", + name.to_string_lossy() + ); + use cap_std_ext::cap_std::fs::OpenOptions; + let dst_file = dst_parent + .open_with(name, OpenOptions::new().write(true).create_new(true)) + .context("Creating empty mountpoint file placeholder")?; + preserve_metadata( + src_parent, + name, + &metadata, + dst_file.as_filelike_view::().as_fd(), + ) + .context("Preserving mountpoint file metadata") +} + +/// Reproduce metadata for a directory which was split into filtered children +/// instead of being copied by `cp -a` as one subtree. +fn preserve_directory_metadata( + src_dir: &CapStdDir, + dst_dir: &CapStdDir, + metadata: &Metadata, +) -> Result<()> { + restore_directory_metadata( + dst_dir, + &snapshot_directory_metadata_from_source(src_dir, metadata)?, + ) +} + +struct DirectoryMetadataSnapshot { + metadata: Metadata, + xattrs: Vec<(std::ffi::OsString, Vec)>, +} + +fn snapshot_directory_metadata(directory: &CapStdDir) -> Result { + let metadata = directory.metadata(".")?; + snapshot_directory_metadata_from_source(directory, &metadata) +} + +fn snapshot_directory_metadata_from_source( + directory: &CapStdDir, + metadata: &Metadata, +) -> Result { + use cap_std_ext::dirext::CapStdExtDirExt; + + let mut xattrs = Vec::new(); + for attribute in directory.listxattrs(".")?.iter() { + if let Some(value) = directory.getxattr(".", attribute)? { + xattrs.push((attribute.to_os_string(), value)); + } + } + Ok(DirectoryMetadataSnapshot { + metadata: metadata.clone(), + xattrs, + }) +} + +fn restore_directory_metadata( + directory: &CapStdDir, + snapshot: &DirectoryMetadataSnapshot, +) -> Result<()> { + use rustix::fs::{Mode, fchmod}; + + let dst_fd = open_directory_for_metadata(directory)?; + let mut errors = Vec::new(); + if let Err(error) = preserve_metadata_owner(&snapshot.metadata, &dst_fd) { + errors.push(format!("owner/group: {error:#}")); + } + if let Err(error) = fchmod( + &dst_fd, + Mode::from_bits_truncate(snapshot.metadata.mode() & 0o7777), + ) { + errors.push(format!("mode: {error:#}")); + } + if let Err(error) = apply_xattrs_exact(&dst_fd, &snapshot.xattrs) { + errors.push(format!("xattrs: {error:#}")); + } + if let Err(error) = preserve_metadata_timestamps(&snapshot.metadata, &dst_fd) { + errors.push(format!("timestamps: {error:#}")); + } + if !errors.is_empty() { + anyhow::bail!("restoring directory metadata failed: {}", errors.join("; ")); + } + Ok(()) +} + +fn restore_directory_timestamps(directory: &CapStdDir, metadata: &Metadata) -> Result<()> { + let dst_fd = open_directory_for_metadata(directory)?; + preserve_metadata_timestamps(metadata, &dst_fd) +} + +/// Open a usable fd for fd-based metadata operations while remaining anchored +/// to the pinned directory capability. CapStdDir may hold an O_PATH fd, which +/// cannot be used with fchmod, xattr, or timestamp operations. +fn open_directory_for_metadata(directory: &CapStdDir) -> Result { + use rustix::fs::{Mode, OFlags, openat}; + + let directory_file = directory.as_filelike_view::(); + Ok(openat( + directory_file.as_fd(), + ".", + OFlags::CLOEXEC | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::RDONLY, + Mode::empty(), + )?) +} + +/// Preserve ownership, mode, timestamps, and available xattrs (including ACL +/// and SELinux labels) on a newly-created destination object. Callers only use +/// this for objects created by this migration, preserving the no-clobber rule. +/// For mountpoint file placeholders, source metadata and xattrs still use the +/// parent/name pair captured before the destination is opened; replacing that +/// source entry concurrently can therefore race those reads. Destination +/// updates remain anchored to the opened destination fd. Split directory +/// metadata and xattrs instead use the opened source directory handle. +fn preserve_metadata( + src_parent: &CapStdDir, + src_name: &OsStr, + metadata: &Metadata, + dst_fd: Fd, +) -> Result<()> { + use cap_std_ext::dirext::CapStdExtDirExt; + + let mut source_xattrs = Vec::new(); + for attribute in src_parent.listxattrs(src_name)?.iter() { + if let Some(value) = src_parent.getxattr(src_name, attribute)? { + source_xattrs.push((attribute.to_os_string(), value)); + } + } + preserve_metadata_owner_and_mode(metadata, &dst_fd)?; + apply_xattrs_exact(&dst_fd, &source_xattrs)?; + preserve_metadata_timestamps(metadata, &dst_fd) +} + +/// Make the destination xattr set exactly match the source snapshot. Removing +/// extras first matters for inherited ACLs and labels as well as user xattrs. +fn apply_xattrs_exact( + destination: Fd, + source_xattrs: &[(std::ffi::OsString, Vec)], +) -> Result<()> { + use rustix::fs::{fremovexattr, fsetxattr}; + use std::collections::BTreeSet; + + let destination_names = list_fd_xattrs(&destination)?; + let source_names = source_xattrs + .iter() + .map(|(name, _)| name.as_os_str()) + .collect::>(); + for name in destination_names { + if !source_names.contains(name.as_os_str()) { + fremovexattr(&destination, &name).with_context(|| { + format!("Removing destination xattr {}", name.to_string_lossy()) + })?; + } + } + for (name, value) in source_xattrs { + fsetxattr(&destination, name, value, rustix::fs::XattrFlags::empty()) + .with_context(|| format!("Setting source xattr {}", name.to_string_lossy()))?; + } + Ok(()) +} + +fn list_fd_xattrs(fd: Fd) -> Result> { + use std::os::unix::ffi::OsStringExt; + + // Linux caps the complete xattr-name list at XATTR_LIST_MAX (64 KiB). + let mut names = vec![0; 64 * 1024]; + let length = rustix::fs::flistxattr(fd, &mut names[..])?; + names.truncate(length); + if names.is_empty() { + return Ok(Vec::new()); + } + anyhow::ensure!(names.last() == Some(&0), "Malformed xattr name list"); + names.pop(); + names + .split(|byte| *byte == 0) + .filter(|name| !name.is_empty()) + .map(|name| Ok(std::ffi::OsString::from_vec(name.to_vec()))) + .collect() +} + +fn preserve_metadata_owner_and_mode(metadata: &Metadata, dst_fd: Fd) -> Result<()> { + use rustix::fs::{Mode, fchmod}; + + preserve_metadata_owner(metadata, &dst_fd)?; + fchmod(&dst_fd, Mode::from_bits_truncate(metadata.mode() & 0o7777))?; + Ok(()) +} + +fn preserve_metadata_owner(metadata: &Metadata, dst_fd: Fd) -> Result<()> { + use rustix::fs::{Gid, Uid, fchown}; + + let destination_metadata = rustix::fs::fstat(&dst_fd)?; + if destination_metadata.st_uid != metadata.uid() + || destination_metadata.st_gid != metadata.gid() + { + fchown( + &dst_fd, + Some(Uid::from_raw(metadata.uid())), + Some(Gid::from_raw(metadata.gid())), + )?; + } + Ok(()) +} + +fn preserve_metadata_timestamps(metadata: &Metadata, dst_fd: Fd) -> Result<()> { + use rustix::fs::{Timestamps, futimens}; + use rustix::time::Timespec; + + futimens( + &dst_fd, + &Timestamps { + last_access: Timespec { + tv_sec: metadata.atime(), + tv_nsec: metadata.atime_nsec() as _, + }, + last_modification: Timespec { + tv_sec: metadata.mtime(), + tv_nsec: metadata.mtime_nsec() as _, + }, + }, + )?; + Ok(()) +} + +/// Strategy D: plain recursive copy of `/var` for filesystems without reflink support. +/// +/// # Known limitation +/// +/// This performs a full `cp -a` of every included subdirectory of the running +/// `/var` into the new deployment's ostree stateroot `var/`. For most workloads +/// this is fine, but it is **unsafe for databases and other applications that +/// keep open write handles into `/var`** (e.g. PostgreSQL in `/var/lib/pgsql`, +/// MySQL/MariaDB in `/var/lib/mysql`, SQLite databases under `/var/lib/*`). +/// Copying a live database with `cp -a` will almost certainly produce a +/// corrupted copy. +/// +/// The correct fix is to run this migration only after stopping all stateful +/// services that write to `/var`, or — better — to migrate the filesystem to +/// btrfs or XFS (which support reflinks, Strategy C) to avoid copying every +/// file's data immediately. Reflinks clone files individually; they do not +/// provide an atomic snapshot of the `/var` tree or of a live database. +/// +/// Exclusions can be configured with install configuration or repeated +/// `--preserve-var-skip` options. Paths are relative to `/var` and may be one +/// component (such as `lib`) or exactly one child (such as `lib/pgsql`). `/var` +/// itself and deeper paths are not accepted. Excluded state is not migrated by +/// this command and must be handled separately. Exclusions do not make a live database copy +/// consistent: any database state that is copied while it is being written can +/// still be corrupted. Stop stateful services before copying their data, or use +/// a consistent snapshot. +/// +/// See: +#[cfg(test)] +#[context("Copying /var into new deployment (Strategy D)")] +fn preserve_var_copy( + src_var: &CapStdDir, + new_var: &CapStdDir, + exclusions: &[String], +) -> Result<()> { + preserve_var_copy_mounts(src_var, new_var, exclusions, &BTreeSet::new()) +} + +fn preserve_var_copy_mounts( + src_var: &CapStdDir, + new_var: &CapStdDir, + exclusions: &[String], + mountpoints: &BTreeSet, +) -> Result<()> { + copy_var_filtered( + src_var, + new_var, + Path::new(""), + exclusions, + mountpoints, + false, + false, + OsStr::new("cp"), + ) + .context("Copying /var into new deployment (Strategy D)")?; + println!(" /var copy complete."); + Ok(()) +} + +/// Open a single source child directory without following a symlink swapped +/// into place after the caller's metadata check. The entry name comes directly +/// from `read_dir`, so this is one path component beneath the capability root. +fn open_child_dir_nofollow(parent: &CapStdDir, name: &OsStr) -> std::io::Result { + use rustix::fs::{Mode, OFlags}; + + let fd = rustix::fs::openat( + parent.as_filelike_view::().as_fd(), + name, + OFlags::CLOEXEC | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::RDONLY, + Mode::empty(), + )?; + Ok(CapStdDir::from_std_file(std::fs::File::from(fd))) +} + +#[cfg(test)] +fn open_or_create_child_dir(parent: &CapStdDir, name: &OsStr) -> Result<(CapStdDir, bool)> { + let created = match parent.create_dir(name) { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + anyhow::ensure!( + parent.symlink_metadata(name)?.is_dir(), + "Destination child {} exists and is not a directory", + name.to_string_lossy() + ); + false + } + Err(e) => return Err(e).context("Creating destination child directory"), + }; + let dir = + open_child_dir_nofollow(parent, name).context("Opening destination child directory")?; + Ok((dir, created)) +} + +/// Open an existing destination directory without following symlinks. The +/// opened handle pins the directory used for the recursive merge. +fn open_existing_child_dir(parent: &CapStdDir, name: &OsStr) -> Result> { + let existing: Result> = match open_child_dir_nofollow(parent, name) { + Ok(directory) => Ok(Some(directory)), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) || error.raw_os_error() == Some(libc::ELOOP) => + { + Ok(None) + } + Err(error) => Err(error.into()), + }; + existing.with_context(|| { + format!( + "Opening existing destination directory {}", + name.to_string_lossy() + ) + }) +} + +/// Validate exclusions before touching either tree. Paths are relative to `/var`. +pub(crate) fn validate_exclusions(exclusions: &[String]) -> Result> { + exclusions + .iter() + .map(|path| { + let path = path.trim_end_matches('/'); + anyhow::ensure!(!path.is_empty(), "empty /var exclusion"); + anyhow::ensure!( + Path::new(path) + .components() + .all(|component| matches!(component, Component::Normal(_))), + "invalid /var exclusion {path:?}; expected a relative path" + ); + anyhow::ensure!( + Path::new(path).components().count() <= 2, + "unsupported nested /var exclusion {path:?}; use at most one child path" + ); + Ok(path.to_string()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::dirext::CapStdExtDirExt; + use std::{ + fs, + os::unix::{ + ffi::OsStrExt, + fs::{MetadataExt as StdMetadataExt, PermissionsExt, symlink}, + }, + time::{Duration, SystemTime}, + }; + + fn cap_dir(path: &Path) -> CapStdDir { + CapStdDir::open_ambient_dir(path, ambient_authority()).unwrap() + } + + fn cap_dir_path(path: &Path) -> CapStdDir { + use rustix::fs::{Mode, OFlags, openat}; + + let directory = cap_dir(path); + let fd = openat( + directory.as_filelike_view::().as_fd(), + ".", + OFlags::CLOEXEC | OFlags::DIRECTORY | OFlags::PATH, + Mode::empty(), + ) + .unwrap(); + CapStdDir::from_std_file(std::fs::File::from(fd)) + } + + #[test] + fn exclusions_are_relative_and_normalized() { + assert_eq!( + validate_exclusions(&["lib/rpm/".into()]).unwrap(), + ["lib/rpm"] + ); + assert!(validate_exclusions(&["../etc".into()]).is_err()); + assert!(validate_exclusions(&["/etc".into()]).is_err()); + assert!(validate_exclusions(&["lib/containers/storage".into()]).is_err()); + } + + #[test] + fn deployment_var_is_stateroot_var() { + let deploy = Path::new("/sysroot/ostree/deploy/default/deploy/checksum.0"); + assert_eq!( + deployment_var_path(deploy).unwrap(), + Path::new("/sysroot/ostree/deploy/default/var") + ); + } + + #[test] + fn directory_metadata_restoration_accepts_path_capability() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir(&src_path)?; + fs::create_dir(&dst_path)?; + fs::set_permissions(&src_path, fs::Permissions::from_mode(0o751))?; + + let source = cap_dir(&src_path); + let has_user_xattr = source + .setxattr(".", "user.bootc_path_fd", b"metadata") + .is_ok(); + let destination = cap_dir_path(&dst_path); + let snapshot = snapshot_directory_metadata(&source)?; + restore_directory_metadata(&destination, &snapshot)?; + assert_eq!( + fs::metadata(&dst_path)?.permissions().mode() & 0o7777, + snapshot.metadata.mode() & 0o7777 + ); + if has_user_xattr { + assert_eq!( + destination.getxattr(".", "user.bootc_path_fd")?, + Some(b"metadata".to_vec()) + ); + } + + restore_directory_timestamps(&destination, &snapshot.metadata)?; + let restored = fs::metadata(&dst_path)?; + assert_eq!(restored.atime(), snapshot.metadata.atime()); + assert_eq!(restored.atime_nsec(), snapshot.metadata.atime_nsec()); + assert_eq!(restored.mtime(), snapshot.metadata.mtime()); + assert_eq!(restored.mtime_nsec(), snapshot.metadata.mtime_nsec()); + Ok(()) + } + + #[test] + fn mountinfo_decodes_escaped_and_non_utf8_paths() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_var = temp.path().join("var"); + fs::create_dir(&src_var)?; + let source = src_var.as_os_str().as_bytes(); + let mut mountinfo = format!( + "36 0 0:32 / {0} rw,relatime - tmpfs tmpfs rw\n37 25 0:33 / {0}/rpc\\040pipefs rw - nfs nfs rw\n", + src_var.display() + ) + .into_bytes(); + mountinfo.extend_from_slice(b"38 25 0:34 / "); + mountinfo.extend_from_slice(source); + mountinfo.extend_from_slice(b"/\xffstate rw - tmpfs tmpfs rw\n"); + let parsed = parse_mountinfo(&mountinfo)?; + let escaped_name = src_var.join("rpc pipefs"); + assert_eq!(parsed[1], escaped_name); + let mut non_utf8 = source.to_vec(); + non_utf8.extend_from_slice(b"/\xffstate"); + assert_eq!(parsed[2].as_os_str().as_bytes(), non_utf8); + + let nested = nested_mountpoints(&src_var, parsed)?; + assert_eq!( + nested, + BTreeSet::from([ + PathBuf::from("rpc pipefs"), + PathBuf::from(OsStr::from_bytes(b"\xffstate")) + ]) + ); + Ok(()) + } + + #[test] + fn unrelated_mount_records_do_not_change_nested_mount_selection() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_var = temp.path().join("target/var"); + fs::create_dir_all(&src_var)?; + let base = format!( + "36 0 0:32 / {0} rw - tmpfs tmpfs rw\n37 25 0:33 / {0}/lib/data rw - nfs nfs rw\n", + src_var.display() + ); + let with_unrelated = format!( + "21 0 0:21 / / rw - overlay overlay rw\n{base}38 25 0:34 / /unrelated rw - tmpfs tmpfs rw\n" + ); + + let selected = |mountinfo: &str| -> Result> { + nested_mountpoints(&src_var, parse_mountinfo(mountinfo.as_bytes())?) + }; + assert_eq!( + selected(&base)?, + BTreeSet::from([PathBuf::from("lib/data")]) + ); + assert_eq!(selected(&with_unrelated)?, selected(&base)?); + Ok(()) + } + + #[test] + fn mountinfo_allows_relative_and_noncanonical_roots_with_valid_nested_mountpoints() -> Result<()> + { + let temp = tempfile::tempdir()?; + let src_var = temp.path().join("target/var"); + fs::create_dir_all(&src_var)?; + + for root in ["/some/../root", "relative/root"] { + let mountinfo = format!( + "36 0 0:32 / {0} rw - tmpfs tmpfs rw\n37 25 0:33 {1} {0}/lib/data rw - nfs nfs rw\n", + src_var.display(), + root + ); + + assert_eq!( + nested_mountpoints(&src_var, parse_mountinfo(mountinfo.as_bytes())?)?, + BTreeSet::from([PathBuf::from("lib/data")]), + "root field {root:?} should not affect mountpoint selection" + ); + } + Ok(()) + } + + #[test] + fn mountinfo_rejects_malformed_records_and_escapes() { + for mountinfo in [ + b"".as_slice(), + b"not a mountinfo record\n", + b"36 25 0:32 /bad\\04x /target/var rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / /target/var\\04x rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / /target/var\\400 rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / /target/var\\777 rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / /target/var\\041 rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / relative rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / /target/var/../outside rw - tmpfs tmpfs rw\n", + b"36 25 0:32 / /target/var rw - tmpfs tmpfs rw extra\n", + ] { + assert!( + parse_mountinfo(mountinfo).is_err(), + "accepted {mountinfo:?}" + ); + } + } + + #[test] + fn nested_mountpoint_detection_is_component_safe() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_var = temp.path().join("target/var"); + fs::create_dir_all(&src_var)?; + let alias = temp.path().join("var-alias"); + symlink(&src_var, &alias)?; + let paths = [ + src_var.clone(), + src_var.join("lib/data"), + temp.path().join("target/var-old/other"), + PathBuf::from("/elsewhere/var/mount"), + ]; + assert_eq!( + nested_mountpoints(&alias, paths)?, + BTreeSet::from([PathBuf::from("lib/data")]) + ); + assert!(nested_mountpoints(&temp.path().join("missing"), []).is_err()); + Ok(()) + } + + #[test] + fn both_copy_strategies_leave_nested_mountpoints_empty() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("lib/bound"))?; + fs::create_dir_all(src_path.join("lib/sibling"))?; + fs::write(src_path.join("lib/file-bound"), b"do not migrate")?; + fs::write( + src_path.join("lib/bound/mounted-sentinel"), + b"do not migrate", + )?; + fs::write(src_path.join("lib/sibling/sentinel"), b"migrate")?; + fs::create_dir(&dst_path)?; + let mounts = BTreeSet::from([PathBuf::from("lib/bound"), PathBuf::from("lib/file-bound")]); + let src = cap_dir(&src_path); + let dst = cap_dir(&dst_path); + + preserve_var_copy_mounts(&src, &dst, &[], &mounts)?; + assert!(dst_path.join("lib/bound").is_dir()); + assert_eq!(fs::read_dir(dst_path.join("lib/bound"))?.count(), 0); + assert_eq!(fs::read(dst_path.join("lib/file-bound"))?, b""); + assert_eq!(fs::read(dst_path.join("lib/sibling/sentinel"))?, b"migrate"); + + let excluded_dst_path = temp.path().join("excluded-dst"); + fs::create_dir(&excluded_dst_path)?; + preserve_var_copy_mounts( + &src, + &cap_dir(&excluded_dst_path), + &["lib/bound".into()], + &mounts, + )?; + assert!(!excluded_dst_path.join("lib/bound").exists()); + + let reflink_dst = temp.path().join("reflink-dst"); + fs::create_dir(&reflink_dst)?; + let reflink_dst = cap_dir(&reflink_dst); + if supports_reflink(&src, &reflink_dst)? { + preserve_var_reflink_mounts(&src, &reflink_dst, &[], &mounts)?; + assert!(reflink_dst.symlink_metadata("lib/bound")?.is_dir()); + assert_eq!( + fs::read_dir(temp.path().join("reflink-dst/lib/bound"))?.count(), + 0 + ); + assert_eq!( + fs::read(temp.path().join("reflink-dst/lib/file-bound"))?, + b"" + ); + assert_eq!( + fs::read(temp.path().join("reflink-dst/lib/sibling/sentinel"))?, + b"migrate" + ); + } + Ok(()) + } + + #[test] + fn split_directories_and_mountpoint_placeholders_keep_metadata() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("lib/bound"))?; + fs::create_dir(&dst_path)?; + fs::set_permissions(src_path.join("lib"), fs::Permissions::from_mode(0o751))?; + fs::set_permissions( + src_path.join("lib/bound"), + fs::Permissions::from_mode(0o711), + )?; + // Old atimes make traversal update them on relatime filesystems; the + // copy must use the metadata captured before reading child entries. + let expected_atime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_001); + let expected_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_002); + for path in [src_path.join("lib"), src_path.join("lib/bound")] { + fs::File::open(path)?.set_times( + fs::FileTimes::new() + .set_accessed(expected_atime) + .set_modified(expected_mtime), + )?; + } + + let src = cap_dir(&src_path); + let dst = cap_dir(&dst_path); + let xattrs_supported = src + .setxattr("lib", "user.bootc_migrate_test", b"parent") + .is_ok() + && src + .setxattr("lib/bound", "user.bootc_migrate_test", b"placeholder") + .is_ok(); + preserve_var_copy_mounts( + &src, + &dst, + &[], + &BTreeSet::from([PathBuf::from("lib/bound")]), + )?; + + for (source, destination, expected_mode, expected_xattr) in [ + ("lib", "lib", 0o751, b"parent".as_slice()), + ("lib/bound", "lib/bound", 0o711, b"placeholder".as_slice()), + ] { + let source_metadata = fs::metadata(src_path.join(source))?; + let destination_metadata = fs::metadata(dst_path.join(destination))?; + assert_eq!(destination_metadata.mode() & 0o7777, expected_mode); + assert_eq!(destination_metadata.uid(), source_metadata.uid()); + assert_eq!(destination_metadata.gid(), source_metadata.gid()); + assert_eq!( + destination_metadata.atime(), + expected_atime + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() as i64 + ); + assert_eq!( + destination_metadata.mtime(), + expected_mtime + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() as i64 + ); + assert_eq!(destination_metadata.atime_nsec(), 0); + assert_eq!(destination_metadata.mtime_nsec(), 0); + if xattrs_supported { + assert_eq!( + dst.getxattr(destination, "user.bootc_migrate_test")?, + Some(expected_xattr.to_vec()) + ); + } + } + Ok(()) + } + + #[test] + fn split_directory_xattrs_match_source_exactly_when_supported() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir(&src_path)?; + fs::create_dir(&dst_path)?; + let source = cap_dir(&src_path); + let destination = cap_dir(&dst_path); + if source + .setxattr(".", "user.bootc_expected", b"source") + .is_err() + || destination + .setxattr(".", "user.bootc_inherited", b"extra") + .is_err() + { + return Ok(()); + } + + let snapshot = snapshot_directory_metadata(&source)?; + restore_directory_metadata(&destination, &snapshot)?; + + assert_eq!( + destination.getxattr(".", "user.bootc_expected")?, + Some(b"source".to_vec()) + ); + assert_eq!(destination.getxattr(".", "user.bootc_inherited")?, None); + Ok(()) + } + + #[test] + fn filtered_copy_does_not_change_existing_destination_directory() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("tree/excluded"))?; + fs::write(src_path.join("tree/sentinel"), b"source")?; + fs::write(src_path.join("tree/new-child"), b"new child")?; + fs::write(src_path.join("root-new-child"), b"new root child")?; + fs::create_dir_all(dst_path.join("tree"))?; + fs::write(dst_path.join("tree/sentinel"), b"destination")?; + fs::set_permissions(src_path.join("tree"), fs::Permissions::from_mode(0o751))?; + fs::set_permissions(&dst_path, fs::Permissions::from_mode(0o710))?; + fs::set_permissions(dst_path.join("tree"), fs::Permissions::from_mode(0o700))?; + let expected_atime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_101); + let expected_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_102); + let expected_root_atime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_104); + let expected_root_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_103); + fs::File::open(dst_path.join("tree"))?.set_times( + fs::FileTimes::new() + .set_accessed(expected_atime) + .set_modified(expected_mtime), + )?; + fs::File::open(&dst_path)?.set_times( + fs::FileTimes::new() + .set_accessed(expected_root_atime) + .set_modified(expected_root_mtime), + )?; + let destination = cap_dir(&dst_path); + let xattrs_supported = destination + .setxattr("tree", "user.bootc_migrate_test", b"existing") + .is_ok(); + let expected_owner = fs::metadata(dst_path.join("tree"))?; + + preserve_var_copy_mounts( + &cap_dir(&src_path), + &destination, + &["tree/excluded".into()], + &BTreeSet::new(), + )?; + + let destination_metadata = fs::metadata(dst_path.join("tree"))?; + assert_eq!(destination_metadata.mode() & 0o7777, 0o700); + assert_eq!(destination_metadata.uid(), expected_owner.uid()); + assert_eq!(destination_metadata.gid(), expected_owner.gid()); + assert_eq!( + destination_metadata.atime(), + expected_atime + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() as i64 + ); + assert_eq!( + destination_metadata.mtime(), + expected_mtime + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() as i64 + ); + assert_eq!(fs::read(dst_path.join("tree/sentinel"))?, b"destination"); + assert_eq!(fs::read(dst_path.join("tree/new-child"))?, b"new child"); + assert_eq!( + fs::read(dst_path.join("root-new-child"))?, + b"new root child" + ); + assert_eq!(fs::read_dir(dst_path.join("tree"))?.count(), 2); + assert_eq!(destination_metadata.modified()?, expected_mtime); + + let root_metadata = fs::metadata(&dst_path)?; + assert_eq!(root_metadata.mode() & 0o7777, 0o710); + assert_eq!( + root_metadata.atime(), + expected_root_atime + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() as i64 + ); + assert_eq!(root_metadata.modified()?, expected_root_mtime); + if xattrs_supported { + assert_eq!( + destination.getxattr("tree", "user.bootc_migrate_test")?, + Some(b"existing".to_vec()) + ); + } + Ok(()) + } + + #[test] + fn staged_directory_collision_merges_missing_children_without_clobbering() -> Result<()> { + let temp = tempfile::tempdir()?; + let destination_path = temp.path().join("destination"); + fs::create_dir(&destination_path)?; + let destination = cap_dir(&destination_path); + with_staging_dir(&destination, |_, staging| { + staging.create_dir("tree")?; + staging.write("tree/existing", b"staged collision")?; + staging.write("tree/missing", b"new child")?; + + // Simulate a concurrent creator winning after staging completed. + fs::create_dir(destination_path.join("tree"))?; + fs::write(destination_path.join("tree/existing"), b"destination")?; + fs::set_permissions( + destination_path.join("tree"), + fs::Permissions::from_mode(0o700), + )?; + let destination_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_301); + fs::File::open(destination_path.join("tree"))? + .set_times(fs::FileTimes::new().set_modified(destination_mtime))?; + let expected_owner = fs::metadata(destination_path.join("tree"))?; + + assert!(!publish_staged_entry( + staging, + &destination, + OsStr::new("tree") + )?); + assert_eq!( + fs::read(destination_path.join("tree/existing"))?, + b"destination" + ); + assert_eq!( + fs::read(destination_path.join("tree/missing"))?, + b"new child" + ); + + let metadata = fs::metadata(destination_path.join("tree"))?; + assert_eq!(metadata.mode() & 0o7777, 0o700); + assert_eq!(metadata.uid(), expected_owner.uid()); + assert_eq!(metadata.gid(), expected_owner.gid()); + assert_eq!(metadata.modified()?, destination_mtime); + Ok(()) + })?; + Ok(()) + } + + #[test] + fn unfiltered_directory_collisions_preserve_destination_metadata() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + fs::create_dir_all(src_path.join("state"))?; + fs::write(src_path.join("state/existing"), b"source")?; + fs::write(src_path.join("state/new"), b"new")?; + fs::write(src_path.join("collision-file"), b"source collision")?; + fs::create_dir(src_path.join("directory-vs-file"))?; + fs::write(src_path.join("directory-vs-file/new"), b"must not replace")?; + fs::set_permissions(src_path.join("state"), fs::Permissions::from_mode(0o751))?; + let source_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_201); + fs::File::open(src_path.join("state"))? + .set_times(fs::FileTimes::new().set_modified(source_mtime))?; + + let src = cap_dir(&src_path); + for (strategy, reflink) in [("copy", false), ("reflink", true)] { + let dst_path = temp.path().join(format!("dst-{strategy}")); + fs::create_dir_all(dst_path.join("state"))?; + fs::write(dst_path.join("state/existing"), b"destination")?; + fs::write(dst_path.join("collision-file"), b"keep collision")?; + fs::write(dst_path.join("directory-vs-file"), b"keep file")?; + fs::set_permissions(dst_path.join("state"), fs::Permissions::from_mode(0o700))?; + let destination_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_202); + fs::File::open(dst_path.join("state"))? + .set_times(fs::FileTimes::new().set_modified(destination_mtime))?; + let dst = cap_dir(&dst_path); + if reflink && !supports_reflink(&src, &dst)? { + continue; + } + + if reflink { + preserve_var_reflink(&src, &dst, &[])?; + } else { + preserve_var_copy(&src, &dst, &[])?; + } + + let destination_metadata = fs::metadata(dst_path.join("state"))?; + assert_eq!(destination_metadata.mode() & 0o7777, 0o700); + assert_eq!(destination_metadata.modified()?, destination_mtime); + assert_eq!(fs::read(dst_path.join("state/existing"))?, b"destination"); + assert_eq!(fs::read(dst_path.join("state/new"))?, b"new"); + assert_eq!( + fs::read(dst_path.join("collision-file"))?, + b"keep collision" + ); + assert_eq!(fs::read(dst_path.join("directory-vs-file"))?, b"keep file"); + assert!(!dst_path.join("directory-vs-file/new").exists()); + } + Ok(()) + } + + #[test] + fn failed_copy_batch_is_cleaned_and_can_be_retried() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + fs::create_dir(&src_path)?; + fs::write(src_path.join("first"), b"linked data")?; + fs::hard_link(src_path.join("first"), src_path.join("second"))?; + let src = cap_dir(&src_path); + + let failing_cp = temp.path().join("cp-fails-after-copy"); + fs::write( + &failing_cp, + b"#!/bin/sh\nreflink=no\nwhile [ \"$1\" != -- ]; do\n [ \"$1\" = --reflink=always ] && reflink=yes\n shift\ndone\nshift\nsource=$1\nshift\nwhile [ \"$#\" -gt 1 ]; do shift; done\ndestination=$1\nif [ \"$reflink\" = yes ]; then\n /bin/cp --reflink=always -a -- \"$source\" \"$destination\"\nelse\n /bin/cp -a -- \"$source\" \"$destination\"\nfi\nexit 1\n", + )?; + fs::set_permissions(&failing_cp, fs::Permissions::from_mode(0o755))?; + + for (strategy, reflink) in [("copy", false), ("reflink", true)] { + let dst_path = temp.path().join(format!("dst-{strategy}")); + fs::create_dir(&dst_path)?; + let dst = cap_dir(&dst_path); + if reflink && !supports_reflink(&src, &dst)? { + continue; + } + + let error = copy_var_filtered( + &src, + &dst, + Path::new(""), + &[], + &BTreeSet::new(), + reflink, + false, + failing_cp.as_os_str(), + ); + assert!(error.is_err()); + assert_eq!(fs::read_dir(&dst_path)?.count(), 0); + + if reflink { + preserve_var_reflink(&src, &dst, &[])?; + } else { + preserve_var_copy(&src, &dst, &[])?; + } + let first = fs::metadata(dst_path.join("first"))?; + let second = fs::metadata(dst_path.join("second"))?; + assert_eq!(first.ino(), second.ino()); + assert_eq!(fs::read(dst_path.join("first"))?, b"linked data"); + } + Ok(()) + } + + #[test] + fn empty_mountpoint_placeholders_are_retry_safe_and_type_checked() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + fs::create_dir_all(src_path.join("directory-mount"))?; + fs::write(src_path.join("file-mount"), b"mounted data")?; + let src = cap_dir(&src_path); + + for (strategy, reflink) in [("copy", false), ("reflink", true)] { + let dst_path = temp.path().join(format!("dst-{strategy}")); + fs::create_dir(&dst_path)?; + let dst = cap_dir(&dst_path); + if reflink && !supports_reflink(&src, &dst)? { + continue; + } + + fs::create_dir(dst_path.join("directory-mount"))?; + fs::write(dst_path.join("file-mount"), b"")?; + let mounts = BTreeSet::from([ + PathBuf::from("directory-mount"), + PathBuf::from("file-mount"), + ]); + if reflink { + preserve_var_reflink_mounts(&src, &dst, &[], &mounts)?; + preserve_var_reflink_mounts(&src, &dst, &[], &mounts)?; + } else { + preserve_var_copy_mounts(&src, &dst, &[], &mounts)?; + preserve_var_copy_mounts(&src, &dst, &[], &mounts)?; + } + assert_eq!(fs::read_dir(dst_path.join("directory-mount"))?.count(), 0); + assert_eq!(fs::metadata(dst_path.join("file-mount"))?.len(), 0); + + fs::write(dst_path.join("directory-mount/sentinel"), b"keep")?; + let error = if reflink { + preserve_var_reflink_mounts(&src, &dst, &[], &mounts) + } else { + preserve_var_copy_mounts(&src, &dst, &[], &mounts) + }; + assert!(error.is_err()); + assert_eq!( + fs::read(dst_path.join("directory-mount/sentinel"))?, + b"keep" + ); + fs::remove_file(dst_path.join("directory-mount/sentinel"))?; + fs::write(dst_path.join("file-mount"), b"keep file")?; + let error = if reflink { + preserve_var_reflink_mounts(&src, &dst, &[], &mounts) + } else { + preserve_var_copy_mounts(&src, &dst, &[], &mounts) + }; + assert!(error.is_err()); + assert_eq!(fs::read(dst_path.join("file-mount"))?, b"keep file"); + + let wrong_type = temp.path().join(format!("wrong-type-{strategy}")); + fs::create_dir(&wrong_type)?; + fs::write(wrong_type.join("directory-mount"), b"keep wrong type")?; + fs::create_dir(wrong_type.join("file-mount"))?; + fs::write(wrong_type.join("file-mount/sentinel"), b"keep wrong type")?; + let wrong_dst = cap_dir(&wrong_type); + let error = if reflink { + preserve_var_reflink_mounts(&src, &wrong_dst, &[], &mounts) + } else { + preserve_var_copy_mounts(&src, &wrong_dst, &[], &mounts) + }; + assert!(error.is_err()); + assert_eq!( + fs::read(wrong_type.join("directory-mount"))?, + b"keep wrong type" + ); + assert_eq!( + fs::read(wrong_type.join("file-mount/sentinel"))?, + b"keep wrong type" + ); + } + Ok(()) + } + + #[test] + fn mountpoint_placeholders_fail_without_reusing_existing_destinations() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("directory-mount"))?; + fs::write(src_path.join("file-mount"), b"source")?; + fs::create_dir_all(dst_path.join("directory-mount"))?; + fs::write(dst_path.join("directory-mount/sentinel"), b"keep")?; + fs::create_dir(dst_path.join("file-mount"))?; + fs::write(dst_path.join("file-mount/sentinel"), b"also keep")?; + + let src = cap_dir(&src_path); + let dst = cap_dir(&dst_path); + assert!(create_mountpoint_placeholder(&src, &dst, OsStr::new("directory-mount")).is_err()); + assert!(create_mountpoint_placeholder(&src, &dst, OsStr::new("file-mount")).is_err()); + assert_eq!( + fs::read(dst_path.join("directory-mount/sentinel"))?, + b"keep" + ); + assert_eq!( + fs::read(dst_path.join("file-mount/sentinel"))?, + b"also keep" + ); + Ok(()) + } + + #[test] + fn destination_directory_symlinks_are_rejected_without_following() -> Result<()> { + let temp = tempfile::tempdir()?; + let target = temp.path().join("target"); + fs::create_dir(&target)?; + fs::write(target.join("sentinel"), b"unchanged")?; + + let var_link = temp.path().join("var-link"); + symlink(&target, &var_link)?; + assert!(open_or_create_var_dir(&var_link).is_err()); + + let parent_path = temp.path().join("parent"); + fs::create_dir(&parent_path)?; + symlink(&target, parent_path.join("child-link"))?; + let parent = cap_dir(&parent_path); + assert!(open_or_create_child_dir(&parent, OsStr::new("child-link")).is_err()); + + let src_path = temp.path().join("src"); + fs::create_dir(&src_path)?; + fs::create_dir(src_path.join("mount"))?; + symlink(&target, parent_path.join("mount"))?; + assert!( + create_mountpoint_placeholder(&cap_dir(&src_path), &parent, OsStr::new("mount")) + .is_err() + ); + + assert_eq!(fs::read(target.join("sentinel"))?, b"unchanged"); + assert_eq!(fs::read_dir(target)?.count(), 1); + Ok(()) + } + + #[test] + fn plain_copy_skips_multiple_children_under_parent() -> Result<()> { + let exclusions = ["tmp".into(), "log/journal".into(), "log/private".into()]; + + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("tmp"))?; + fs::create_dir_all(src_path.join("log/journal"))?; + fs::create_dir_all(src_path.join("log/private"))?; + fs::create_dir_all(src_path.join("log/keep"))?; + fs::write(src_path.join("tmp/ignored"), b"tmp")?; + fs::write(src_path.join("log/journal/ignored"), b"journal")?; + fs::write(src_path.join("log/private/ignored"), b"private")?; + fs::write(src_path.join("log/keep/preserved"), b"keep")?; + fs::hard_link( + src_path.join("log/keep/preserved"), + src_path.join("log/keep/preserved-hardlink"), + )?; + fs::create_dir(&dst_path)?; + + preserve_var_copy(&cap_dir(&src_path), &cap_dir(&dst_path), &exclusions)?; + + assert!(!dst_path.join("tmp").exists()); + assert!(!dst_path.join("log/journal").exists()); + assert!(!dst_path.join("log/private").exists()); + assert_eq!(fs::read(dst_path.join("log/keep/preserved"))?, b"keep"); + assert_eq!( + fs::metadata(dst_path.join("log/keep/preserved"))?.ino(), + fs::metadata(dst_path.join("log/keep/preserved-hardlink"))?.ino() + ); + Ok(()) + } + + #[test] + fn reflink_strategy_skips_multiple_children_under_parent() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("log/journal"))?; + fs::create_dir_all(src_path.join("log/private"))?; + fs::create_dir_all(src_path.join("log/keep"))?; + fs::write(src_path.join("log/journal/ignored"), b"journal")?; + fs::write(src_path.join("log/private/ignored"), b"private")?; + fs::write(src_path.join("log/keep/preserved"), b"keep")?; + fs::create_dir(&dst_path)?; + let src = cap_dir(&src_path); + let dst = cap_dir(&dst_path); + let supports_reflink = supports_reflink(&src, &dst)?; + if !supports_reflink { + fs::remove_file(src_path.join("log/keep/preserved"))?; + fs::remove_dir(src_path.join("log/keep"))?; + } + + // Exercise Strategy C's exclusion routing on every filesystem and, if + // available, its reflink copy of a retained sibling. + preserve_var_reflink(&src, &dst, &["log/journal".into(), "log/private".into()])?; + + assert!(!dst_path.join("log/journal").exists()); + assert!(!dst_path.join("log/private").exists()); + assert_eq!( + dst_path.join("log/keep/preserved").exists(), + supports_reflink + ); + Ok(()) + } + + #[test] + fn copy_preserves_symlink_without_traversing_target() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + let outside = temp.path().join("outside"); + fs::create_dir(&src_path)?; + fs::create_dir(&dst_path)?; + fs::create_dir(&outside)?; + fs::write(outside.join("sentinel"), b"outside")?; + symlink(&outside, src_path.join("escape"))?; + + preserve_var_copy( + &cap_dir(&src_path), + &cap_dir(&dst_path), + &["escape/sentinel".into()], + )?; + + let copied = fs::symlink_metadata(dst_path.join("escape"))?; + assert!(copied.file_type().is_symlink()); + assert_eq!(fs::read_link(dst_path.join("escape"))?, outside); + // The target remains outside the destination tree; it was not copied. + assert!(!dst_path.join("sentinel").exists()); + Ok(()) + } + + #[test] + fn copy_preserves_archive_metadata_and_hardlinks() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + let src_state = src_path.join("state"); + fs::create_dir_all(&src_state)?; + fs::create_dir(&dst_path)?; + fs::write(src_state.join("one"), b"contents")?; + fs::hard_link(src_state.join("one"), src_state.join("two"))?; + fs::set_permissions(&src_state, fs::Permissions::from_mode(0o751))?; + fs::set_permissions(src_state.join("one"), fs::Permissions::from_mode(0o640))?; + let src_cap = cap_dir(&src_path); + let dst_cap = cap_dir(&dst_path); + let xattr_supported = src_cap + .setxattr("state/one", "user.bootc_migrate_test", b"xattr") + .is_ok(); + let expected_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); + fs::File::open(&src_state)?.set_times(fs::FileTimes::new().set_modified(expected_mtime))?; + + preserve_var_copy(&src_cap, &dst_cap, &[])?; + + let copied_dir = fs::metadata(dst_path.join("state"))?; + let copied_one = fs::metadata(dst_path.join("state/one"))?; + let copied_two = fs::metadata(dst_path.join("state/two"))?; + assert_eq!(copied_dir.permissions().mode() & 0o7777, 0o751); + assert_eq!(copied_one.permissions().mode() & 0o7777, 0o640); + assert_eq!(copied_dir.modified()?, expected_mtime); + assert_eq!(copied_one.ino(), copied_two.ino()); + assert_eq!(copied_one.nlink(), 2); + assert_eq!(copied_one.uid(), fs::metadata(src_state.join("one"))?.uid()); + assert_eq!(copied_one.gid(), fs::metadata(src_state.join("one"))?.gid()); + if xattr_supported { + assert_eq!( + dst_cap.getxattr("state/one", "user.bootc_migrate_test")?, + Some(b"xattr".to_vec()) + ); + } + Ok(()) + } + + #[test] + fn copy_keeps_existing_collisions_untouched() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir_all(src_path.join("state"))?; + fs::create_dir_all(dst_path.join("state"))?; + fs::write(src_path.join("state/existing"), b"source")?; + fs::write(src_path.join("state/new"), b"new")?; + fs::write(dst_path.join("state/existing"), b"destination")?; + + preserve_var_copy(&cap_dir(&src_path), &cap_dir(&dst_path), &[])?; + + assert_eq!(fs::read(dst_path.join("state/existing"))?, b"destination"); + assert_eq!(fs::read(dst_path.join("state/new"))?, b"new"); + Ok(()) + } +} diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 5b89a415ea..1d1e814ab4 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -129,6 +129,8 @@ use crate::podstorage::CStorage; use crate::spec::{BootloaderKind, ImageStatus}; use crate::utils::{EfiError, deployment_fd, open_dir_remount_rw, read_uefi_var}; +pub(crate) mod reflink; + /// See pub type ComposefsRepository = composefs::repository::Repository; diff --git a/crates/lib/src/store/reflink.rs b/crates/lib/src/store/reflink.rs new file mode 100644 index 0000000000..148a40153b --- /dev/null +++ b/crates/lib/src/store/reflink.rs @@ -0,0 +1,279 @@ +//! Pairwise reflink capability probing for storage directories. + +use std::io::Write as _; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::fs::{Dir as CapStdDir, File, OpenOptions}; +use cap_std_ext::cap_std::io_lifetimes::AsFilelike; +use rustix::fd::AsFd as _; + +/// Returns whether a file in `src` can be reflinked into `dst`. +/// +/// The result is scoped to this source/destination pair; it is deliberately +/// not cached. Temporary files are created exclusively beneath the supplied +/// directory capabilities and removed before returning. +pub(crate) fn supports_reflink(src: &CapStdDir, dst: &CapStdDir) -> Result { + probe_with(src, dst, |destination, source| { + let source_view = source.as_filelike_view::(); + let destination_view = destination.as_filelike_view::(); + let source_fd = source_view.as_fd(); + let destination_fd = destination_view.as_fd(); + rustix::fs::ioctl_ficlone(destination_fd, source_fd) + }) +} + +fn probe_with( + src: &CapStdDir, + dst: &CapStdDir, + clone: impl FnOnce(&File, &File) -> rustix::io::Result<()>, +) -> Result { + let mut source = match create_probe_file(src) { + Ok(source) => source, + Err(error) => return source_probe_failure(error), + }; + let mut destination = match create_probe_file(dst) { + Ok(destination) => destination, + Err(error) => { + source + .cleanup() + .context("Removing reflink probe source after destination creation failed")?; + return Err(error); + } + }; + + let probe_result = match source + .file + .as_mut() + .expect("probe source file is open") + .write_all(b"bootc reflink probe") + { + Err(error) => { + source_probe_failure(anyhow::Error::from(error).context("Writing reflink probe source")) + } + Ok(()) => match clone( + destination + .file + .as_ref() + .expect("probe destination file is open"), + source.file.as_ref().expect("probe source file is open"), + ) { + Ok(()) => Ok(true), + Err(error) if reflink_unsupported(error) => Ok(false), + Err(error) => Err(anyhow::Error::from(error).context("Probing FICLONE support")), + }, + }; + + // Attempt both removals even if one fails. Drop provides a best-effort + // fallback if the operation above returned early (for example, a write + // error). + let source_cleanup = source.cleanup(); + let destination_cleanup = destination.cleanup(); + source_cleanup.context("Removing reflink probe source")?; + destination_cleanup.context("Removing reflink probe destination")?; + probe_result +} + +fn reflink_unsupported(error: rustix::io::Errno) -> bool { + // Both files are newly created regular files, so EINVAL here indicates + // that FICLONE is unsupported rather than an invalid source type. + matches!( + error, + rustix::io::Errno::OPNOTSUPP | rustix::io::Errno::XDEV | rustix::io::Errno::INVAL + ) +} + +fn source_probe_access_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem + ) + }) + }) +} + +fn source_probe_failure(error: anyhow::Error) -> Result { + if source_probe_access_error(&error) { + tracing::debug!("Cannot prepare reflink probe source, using full copy: {error:#}"); + Ok(false) + } else { + Err(error) + } +} + +struct ProbeFile<'a> { + dir: &'a CapStdDir, + name: String, + file: Option, + removed: bool, +} + +impl ProbeFile<'_> { + fn cleanup(&mut self) -> std::io::Result<()> { + self.cleanup_with(|dir, name| dir.remove_file(name)) + } + + fn cleanup_with( + &mut self, + remove_file: impl FnOnce(&CapStdDir, &str) -> std::io::Result<()>, + ) -> std::io::Result<()> { + drop(self.file.take()); + match remove_file(self.dir, &self.name) { + Ok(()) => { + self.removed = true; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + self.removed = true; + Ok(()) + } + Err(error) => Err(error), + } + } +} + +impl Drop for ProbeFile<'_> { + fn drop(&mut self) { + if !self.removed { + let _ = self.cleanup(); + } + } +} + +fn create_probe_file(dir: &CapStdDir) -> Result> { + loop { + let name = format!(".bootc-reflink-probe-{}", uuid::Uuid::new_v4()); + match create_probe_file_with_name(dir, &name) { + Ok(file) => return Ok(file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error).context("Creating reflink probe file"), + } + } +} + +fn create_probe_file_with_name<'a>( + dir: &'a CapStdDir, + name: &str, +) -> std::io::Result> { + let file = dir.open_with( + name, + OpenOptions::new().read(true).write(true).create_new(true), + )?; + Ok(ProbeFile { + dir, + name: name.to_owned(), + file: Some(file), + removed: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_std_ext::cap_std::ambient_authority; + use std::fs; + + fn cap_dir(path: &std::path::Path) -> CapStdDir { + CapStdDir::open_ambient_dir(path, ambient_authority()).unwrap() + } + + #[test] + fn probe_reports_success_and_cleans_up() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir(&src_path)?; + fs::create_dir(&dst_path)?; + + let supported = probe_with(&cap_dir(&src_path), &cap_dir(&dst_path), |_, _| Ok(()))?; + assert!(supported); + assert!(fs::read_dir(&src_path)?.next().is_none()); + assert!(fs::read_dir(&dst_path)?.next().is_none()); + Ok(()) + } + + #[test] + fn probe_classifies_unsupported_and_propagates_unexpected_errors() -> Result<()> { + let temp = tempfile::tempdir()?; + let src_path = temp.path().join("src"); + let dst_path = temp.path().join("dst"); + fs::create_dir(&src_path)?; + fs::create_dir(&dst_path)?; + let src = cap_dir(&src_path); + let dst = cap_dir(&dst_path); + + assert!(!probe_with(&src, &dst, |_, _| { + Err(rustix::io::Errno::OPNOTSUPP) + })?); + assert!(!probe_with(&src, &dst, |_, _| { + Err(rustix::io::Errno::INVAL) + })?); + assert!(fs::read_dir(&src_path)?.next().is_none()); + assert!(fs::read_dir(&dst_path)?.next().is_none()); + + assert!(probe_with(&src, &dst, |_, _| Err(rustix::io::Errno::IO)).is_err()); + assert!(fs::read_dir(&src_path)?.next().is_none()); + assert!(fs::read_dir(&dst_path)?.next().is_none()); + Ok(()) + } + + #[test] + fn classifies_only_known_unsupported_errors() { + assert!(reflink_unsupported(rustix::io::Errno::OPNOTSUPP)); + assert!(reflink_unsupported(rustix::io::Errno::XDEV)); + assert!(reflink_unsupported(rustix::io::Errno::INVAL)); + assert!(!reflink_unsupported(rustix::io::Errno::IO)); + } + + #[test] + fn source_permission_and_read_only_errors_select_full_copy() { + for error in [ + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + std::io::Error::from(std::io::ErrorKind::ReadOnlyFilesystem), + ] { + let error = anyhow::Error::from(error).context("Creating reflink probe file"); + assert!(source_probe_access_error(&error)); + assert!(!source_probe_failure(error).unwrap()); + } + assert!(source_probe_failure(anyhow::anyhow!("unrelated error")).is_err()); + } + + #[test] + fn cleanup_retries_unlink_on_drop_after_failure() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("src"); + fs::create_dir(&path)?; + let dir = cap_dir(&path); + let name = ".bootc-reflink-probe-retry"; + let mut probe = create_probe_file_with_name(&dir, name)?; + + let error = probe + .cleanup_with(|_, _| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!(path.join(name).exists()); + + drop(probe); + assert!(!path.join(name).exists()); + Ok(()) + } + + #[test] + fn probe_file_creation_never_overwrites_existing_files() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("src"); + fs::create_dir(&path)?; + let existing = path.join(".bootc-reflink-probe-collision"); + fs::write(&existing, b"leave this alone")?; + + let error = + match create_probe_file_with_name(&cap_dir(&path), ".bootc-reflink-probe-collision") { + Ok(_) => panic!("create_new unexpectedly replaced an existing probe file"), + Err(error) => error, + }; + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(existing)?, b"leave this alone"); + Ok(()) + } +} diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index 22f086adda..73360fe718 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -294,6 +294,14 @@ and data from the old system (now accessible at `/sysroot`) to the new system. T useful for preserving network settings, user accounts, or application data from the previous installation. +With the OSTree backend, `--preserve-var` copies the old `/var` into the new +deployment before rebooting. It uses reflinks when supported and otherwise makes a +full copy; neither strategy is a consistent snapshot of live data. Stop stateful +services before copying their data. `/var/tmp`, `/var/cache`, `/var/log/journal`, +and `/var/lib/containers` are always excluded. Use repeatable +`--preserve-var-skip PATH` options or the `preserve-var-skip` install configuration +key for additional relative paths below `/var`. + ##### Before reboot: Injecting new configuration After running `bootc install to-existing-root`, you may want to inject diff --git a/docs/src/man/bootc-install-config.5.md b/docs/src/man/bootc-install-config.5.md index 7211c4aab7..35726137bd 100644 --- a/docs/src/man/bootc-install-config.5.md +++ b/docs/src/man/bootc-install-config.5.md @@ -51,7 +51,10 @@ The `install` section supports these subfields: path) includes a default policy which requires signatures. When `true`, image pulls will be rejected if the policy file specifies `insecureAcceptAnything` as the default. Defaults to `false`. - This is equivalent to the `--enforce-container-sigpolicy` CLI flag. + This is equivalent to the `--enforce-container-sigpolicy` CLI flag. +- `preserve-var-skip`: An array of relative paths below `/var` to exclude when + `bootc install to-existing-root --preserve-var` copies data. Each path may + contain at most one slash. The always-excluded paths cannot be re-included. # filesystem diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 74d628dbce..03cbbcdbb1 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -16,6 +16,15 @@ host root filesystem\'s `/boot` partition will be wiped, but the content of the existing root will otherwise be retained, and will need to be cleaned up if desired when rebooted into the new root. +The opt-in `--preserve-var` option copies the running system's `/var` into the +new deployment before rebooting. It requires the OSTree backend, uses reflinks +when supported, and otherwise performs a full copy. Reflinks are created per +file, not as an atomic snapshot; stop stateful services or use a consistent +snapshot before copying live database data. The paths `tmp`, `cache`, +`log/journal`, and `lib/containers` are always excluded. Repeat +`--preserve-var-skip` to exclude additional paths relative to `/var`; each path +may contain at most one slash, for example `lib/rpm`. + ## Managing configuration: before and after reboot When using `to-existing-root`, there are two distinct scenarios for managing @@ -216,6 +225,14 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo Add the bootc-destructive-cleanup systemd service to delete files from the previous install on first boot +**--preserve-var** + + Preserve the running system's `/var` data into the new bootc deployment + +**--preserve-var-skip**=*PATH* + + Relative paths below `/var` to leave out while preserving `/var` + **--composefs-backend** Use the composefs backend instead of ostree. This is the default for images with a UKI, and for images with /usr/lib/composefs/setup-root-conf.toml and no ostree prepare-root.conf From 740119a86e262921046e6074bff823d736a12c9d Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:11:06 -0500 Subject: [PATCH 2/4] tmt: Test package-mode /var preservation A /var migration is useful only if copied data remains available after the first reboot. Add a single-guest plan that invokes --preserve-var and verifies a host marker after boot, while keeping TMT workdirs accessible when /var/tmp is excluded. Assisted-by: AI Signed-off-by: ckyrouac --- .packit.yaml | 44 +++ Justfile | 27 ++ hack/provision-packit.sh | 8 +- tmt/plans/package-mode.fmf | 141 +++++++++ .../test-to-existing-root-preserve-var.sh | 295 ++++++++++++++++++ .../test-50-to-existing-root-preserve-var.fmf | 10 + 6 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 tmt/plans/package-mode.fmf create mode 100755 tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh create mode 100644 tmt/tests/test-50-to-existing-root-preserve-var.fmf diff --git a/.packit.yaml b/.packit.yaml index e2f7430571..680059b0e3 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -102,6 +102,28 @@ jobs: context: running_env: "packit" + # Run package-mode migration coverage on a single guest alongside the main + # tier-1 integration plan. + - job: tests + trigger: pull_request + identifier: package-mode-tier-1 + require: + label: + present: + - ci/tier-1 + targets: + - centos-stream-10-x86_64 + - centos-stream-10-aarch64 + - fedora-latest-stable-x86_64 + - fedora-latest-stable-aarch64 + tmt_plan: /tmt/plans/package-mode + tf_extra_params: + environments: + - tmt: + context: + running_env: "packit" + package_source: "artifacts" + # ci/merge: full target set - job: tests trigger: pull_request @@ -124,6 +146,28 @@ jobs: context: running_env: "packit" + - job: tests + trigger: pull_request + identifier: package-mode-full + require: + label: + present: + - ci/merge + targets: + - centos-stream-9-x86_64 + - centos-stream-9-aarch64 + - centos-stream-10-x86_64 + - centos-stream-10-aarch64 + - fedora-all-x86_64 + - fedora-all-aarch64 + tmt_plan: /tmt/plans/package-mode + tf_extra_params: + environments: + - tmt: + context: + running_env: "packit" + package_source: "artifacts" + - job: propose_downstream trigger: release dist_git_branches: diff --git a/Justfile b/Justfile index 41f1225562..1b8f67f267 100644 --- a/Justfile +++ b/Justfile @@ -205,6 +205,33 @@ build-ukiboot-update-test-image: build-ukiboot-test-image test-ukiboot: build-ukiboot-update-test-image cargo xtask run-tmt --env=BOOTC_variant=composefs --filesystem=ext4 --boot-type=aboot --seal-state=unsealed --upgrade-image={{ukiboot_img}}:v2 {{ukiboot_img}}:v1 aboot +# Run the package-mode migration plan against the current checkout image. +# The VM cannot share controller Podman storage, so transfer it as an OCI archive. +[group('testing')] +test-tmt-package-mode: + #!/bin/bash + set -euo pipefail + just build + archive_dir=$(mktemp -d -- tmt/plans/.package-mode-local.XXXXXX) + cleanup() { + rm -rf -- "$archive_dir" + } + trap cleanup EXIT + trap 'cleanup; exit 130' INT + trap 'cleanup; exit 143' TERM + archive_path="$archive_dir/bootc.oci" + if [[ -e "$archive_path" ]]; then + echo "Refusing to overwrite existing archive: $archive_path" >&2 + exit 1 + fi + podman save --format oci-archive --output "$archive_path" localhost/bootc + tmt \ + --context=running_env=packit \ + --context=package_source=local \ + --context="test_disk_image=${test_disk_image:-centos-stream-10}" \ + run --environment "PACKAGE_MODE_IMAGE_ARCHIVE=$archive_path" \ + plans --name '^/tmt/plans/package-mode$' + # Split out from `test-container` because, unlike the container integration tests, # unit tests don't depend on variant/filesystem/bootloader/boot_type/seal_state, so # CI runs this once per OS (in the `package` job) instead of once per test-integration diff --git a/hack/provision-packit.sh b/hack/provision-packit.sh index 9ec9a144ae..cbe176e83b 100755 --- a/hack/provision-packit.sh +++ b/hack/provision-packit.sh @@ -15,6 +15,7 @@ trap 'rm -rf -- "$BOOTC_TEMPDIR"' EXIT cp -a . "$BOOTC_TEMPDIR" # Keep testing farm run folder +mkdir -p /var/ARTIFACTS cp -r /var/ARTIFACTS "$BOOTC_TEMPDIR" # Copy bootc repo @@ -91,6 +92,9 @@ podman build --jobs=4 --from "$BASE" -v "$BOOTC_TEMPDIR":/bootc-test:z -t localh # Keep these in sync with what's used in hack/lbi podman pull -q --retry 5 --retry-delay 5s quay.io/curl/curl:latest quay.io/curl/curl-base:latest registry.access.redhat.com/ubi9/podman:latest -# Run system-reinstall-bootc # TODO make it more scriptable instead of expect + send -./system-reinstall-bootc.exp +if [[ ${BOOTC_PROVISION_PACKIT_SKIP_REINSTALL:-0} == 1 ]]; then + echo "BOOTC_PROVISION_PACKIT_SKIP_REINSTALL=1: skipping system-reinstall-bootc conversion and reboot" +else + ./system-reinstall-bootc.exp +fi diff --git a/tmt/plans/package-mode.fmf b/tmt/plans/package-mode.fmf new file mode 100644 index 0000000000..7930c556f4 --- /dev/null +++ b/tmt/plans/package-mode.fmf @@ -0,0 +1,141 @@ +summary: Test package-mode to-existing-root migration flags +environment: + PACKAGE_SOURCE: $@{package_source} +provision: + how: virtual + image: $@{test_disk_image} + user: root +prepare: + # The VM cannot share controller Podman storage; TMT syncs the OCI archive + # through its workdir before prepare runs. + - how: install + order: 93 + package: + - podman + - jq + - bootc + when: package_source == local + # Packit build-only preparation needs the full integration toolchain. + - how: install + order: 93 + package: + - podman + - skopeo + - jq + - bootc + - system-reinstall-bootc + - zstd + - cpio + - rpm-build + - expect + - ansible-core + - parted + - lvm2 + - dosfstools + - e2fsprogs + when: package_source == artifacts + # Reject missing/unknown sources before any mode-specific setup runs. + - how: shell + order: 95 + script: | + case "${PACKAGE_SOURCE:-}" in + artifacts|local) ;; + *) + printf 'package_source must be artifacts or local (got %q)\n' "${PACKAGE_SOURCE:-}" >&2 + exit 1 + ;; + esac + # Load the TMT-synced archive into rootful guest Podman, then clean its copy. + - how: shell + order: 96 + script: | + set -eu + archive_relative=${PACKAGE_MODE_IMAGE_ARCHIVE:-} + case "$archive_relative" in + tmt/plans/.package-mode-local.*/bootc.oci) ;; + *) + printf 'Unexpected PACKAGE_MODE_IMAGE_ARCHIVE path: %s\n' "$archive_relative" >&2 + exit 1 + ;; + esac + archive_directory_relative=${archive_relative%/*} + temporary_name=${archive_directory_relative##*/} + case "$temporary_name" in + .package-mode-local.*) ;; + *) + printf 'Unexpected package-mode archive directory: %s\n' "$archive_directory_relative" >&2 + exit 1 + ;; + esac + if [ "$archive_directory_relative" != "tmt/plans/$temporary_name" ]; then + printf 'Package-mode archive must be directly under tmt/plans: %s\n' "$archive_relative" >&2 + exit 1 + fi + + tree_root=$(realpath -e -- "${TMT_TREE:?TMT_TREE is required}") + if [ ! -d "$tree_root" ]; then + printf 'TMT_TREE is not a directory: %s\n' "$tree_root" >&2 + exit 1 + fi + plans_root=$(realpath -e -- "$tree_root/tmt/plans") + plans_root_prefix=${tree_root%/}/ + if [ "$plans_root" = "$tree_root" ]; then + printf 'TMT plans directory resolves to the worktree root: %s\n' "$plans_root" >&2 + exit 1 + fi + case "$plans_root" in + "$plans_root_prefix"*) ;; + *) + printf 'TMT plans directory resolves outside the worktree: %s\n' "$plans_root" >&2 + exit 1 + ;; + esac + archive_directory=$(realpath -m -- "$tree_root/$archive_directory_relative") + if [ "$archive_directory" != "$plans_root/$temporary_name" ]; then + printf 'Package-mode archive directory resolves outside tmt/plans: %s\n' "$archive_relative" >&2 + exit 1 + fi + cleanup() { + rm -rf -- "$archive_directory" + } + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + archive_path=$(realpath -e -- "$archive_directory/bootc.oci") + if [ "$archive_path" != "$archive_directory/bootc.oci" ]; then + printf 'Package-mode archive must not resolve outside its temporary directory: %s\n' "$archive_relative" >&2 + exit 1 + fi + if [ ! -f "$archive_path" ]; then + printf 'Package-mode archive is not a regular file: %s\n' "$archive_relative" >&2 + exit 1 + fi + podman --remote=false load --input "$archive_path" + podman --remote=false image exists localhost/bootc + when: package_source == local + - how: shell + order: 98 + script: + - mkdir -p bootc && cp /var/share/test-artifacts/*.src.rpm bootc + # Packit artifacts are already staged in the guest before extraction. + - cd bootc && rpm2cpio *.src.rpm > pkg.cpio && cpio -idmv < pkg.cpio && rm pkg.cpio && rm -f *-vendor.tar.zstd && zstd -d *.tar.zstd && tar -xvf *.tar -C . --strip-components=1 && ls -al + - cd bootc/hack && BOOTC_PROVISION_PACKIT_SKIP_REINSTALL=1 ./provision-packit.sh + when: package_source == artifacts + # Both sources now provide localhost/bootc in the guest's rootful storage. + # Pull the LBI references needed to install the image. + - how: shell + order: 99 + script: | + podman --remote=false image exists localhost/bootc + podman --remote=false pull quay.io/curl/curl:latest + podman --remote=false pull quay.io/curl/curl-base:latest + podman --remote=false pull registry.access.redhat.com/ubi9/podman:latest + when: package_source == artifacts or package_source == local +execute: + how: tmt +discover: + how: fmf + prune: true + test: + - /tmt/tests/test-50-to-existing-root-preserve-var diff --git a/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh b/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh new file mode 100755 index 0000000000..779df53189 --- /dev/null +++ b/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# Both local and Packit runs prepare one package-mode guest, then convert and +# reboot that same guest; local loads the checkout image, Packit builds it. +set -xeuo pipefail + +target_image=localhost/bootc +var_marker=/var/lib/bootc-tmt-preserve-var/sentinel +tmt_plan_data_suffix=/tmt/plans/package-mode/data +tmt_workdir_parent=/var/tmp/tmt +tmt_workdir_root=/var/lib/bootc-tmt-workdirs +tmt_artifacts_parent=/var/ARTIFACTS +tmt_test_data_sentinel_name=workdir-persistence-sentinel +tmt_test_data_post_reboot_marker_name=post-reboot-marker + +get_tmt_run_dir() { + local plan_data=${TMT_PLAN_DATA:-} + local run_dir + local resolved_plan_data + local persistent_run_dir + local link_target + + if [[ "$plan_data" != *"$tmt_plan_data_suffix" ]]; then + printf 'TMT_PLAN_DATA must end in %s (got %q)\n' "$tmt_plan_data_suffix" "$plan_data" >&2 + return 1 + fi + run_dir=${plan_data%"$tmt_plan_data_suffix"} + if [[ ( ! $run_dir =~ ^/var/tmp/tmt/run-[[:alnum:]_.-]+$ && + ! $run_dir =~ ^/var/ARTIFACTS/work-[[:alnum:]_.-]+$ ) || $run_dir == *..* ]]; then + printf 'Expected a safe TMT run directory under %s or %s (got %q)\n' \ + "$tmt_workdir_parent" "$tmt_artifacts_parent" "$run_dir" >&2 + return 1 + fi + case ${TMT_REBOOT_COUNT:-0} in + 0) + if ! resolved_plan_data=$(realpath -e -- "$plan_data"); then + printf 'TMT_PLAN_DATA must be an existing path without symlinked components (got %q)\n' \ + "$plan_data" >&2 + return 1 + fi + if [[ $resolved_plan_data != "$plan_data" ]]; then + printf 'TMT_PLAN_DATA must be a canonical path without symlinked components (got %q, resolves to %q)\n' \ + "$plan_data" "$resolved_plan_data" >&2 + return 1 + fi + ;; + 1) + if [[ $run_dir == /var/tmp/tmt/* ]]; then + persistent_run_dir=$tmt_workdir_root/${run_dir##*/} + if [[ ! -L $run_dir ]] || ! link_target=$(readlink -- "$run_dir") || + [[ $link_target != "$persistent_run_dir" ]]; then + printf 'TMT run directory must link directly to %s (got %q)\n' \ + "$persistent_run_dir" "$(readlink -- "$run_dir" 2>/dev/null || true)" >&2 + return 1 + fi + if ! resolved_plan_data=$(realpath -e -- "$plan_data") || + [[ $resolved_plan_data != "$persistent_run_dir$tmt_plan_data_suffix" ]]; then + printf 'TMT_PLAN_DATA must resolve to %s (got %q, resolves to %q)\n' \ + "$persistent_run_dir$tmt_plan_data_suffix" "$plan_data" "${resolved_plan_data:-}" >&2 + return 1 + fi + else + # Packit/Testing Farm keeps its workdir under /var/ARTIFACTS. + # It must remain an ordinary directory, with no symlinked path + # components, and stay on the filesystem preserve-var copies. + if [[ -L $run_dir || ! -d $run_dir ]] || + ! resolved_plan_data=$(realpath -e -- "$plan_data") || + [[ $resolved_plan_data != "$plan_data" ]]; then + printf 'TMT workdir and plan data must remain canonical, real paths after reboot (got %q)\n' \ + "$plan_data" >&2 + return 1 + fi + verify_tmt_workdir_in_preserved_var "$run_dir" || return 1 + fi + ;; + *) + printf 'Unexpected TMT_REBOOT_COUNT=%s\n' "${TMT_REBOOT_COUNT}" >&2 + return 1 + ;; + esac + + printf '%s\n' "$run_dir" +} + +verify_tmt_workdir_in_preserved_var() { + local run_dir=$1 + local mount_target + + if ! mount_target=$(findmnt --noheadings --output TARGET --target "$run_dir"); then + printf 'Could not determine the filesystem containing TMT workdir %s\n' \ + "$run_dir" >&2 + return 1 + fi + # /var itself may be a separate filesystem; otherwise the containing root + # filesystem is copied as part of /var. Any mount below either is excluded + # from preserve-var and would make this workdir disappear across reboot. + if [[ $mount_target != / && $mount_target != /var ]]; then + printf 'TMT workdir %s is on nested mount %s, which --preserve-var does not copy\n' \ + "$run_dir" "$mount_target" >&2 + return 1 + fi + return 0 +} + +case ${TMT_REBOOT_COUNT:-0} in + 0) + podman --remote=false image exists "$target_image" + + # The guest must still be the original package-mode system. + test ! -e /run/ostree-booted + + mkdir -p "${var_marker%/*}" + + # TMT connects as root. Package-mode hosts keep root's home at /root, + # while bootc images link /root to /var/roothome, so preserve its key + # under /var for TMT to reconnect after the migration reboot. + install -d -m 0700 /var/roothome/.ssh + install -m 0600 /root/.ssh/authorized_keys /var/roothome/.ssh/authorized_keys + restorecon -RF /var/roothome/.ssh + + printf '%s\n' preserved-var | tee "$var_marker" + + tmt_run_dir=$(get_tmt_run_dir) + tmt_run_id=${tmt_run_dir##*/} + if [[ $tmt_run_dir == /var/ARTIFACTS/* ]]; then + tmt_run_layout=artifacts + else + tmt_run_layout=var-tmp + fi + if [[ ! -d "$tmt_run_dir" || -L "$tmt_run_dir" ]]; then + printf 'TMT run directory is missing or not a directory: %s\n' "$tmt_run_dir" >&2 + exit 1 + fi + tmt_run_dir_identity=$(stat -c '%d:%i' -- "$tmt_run_dir") + persistent_tmt_run_dir=$tmt_workdir_root/$tmt_run_id + if [[ -e "$persistent_tmt_run_dir" || -L "$persistent_tmt_run_dir" ]]; then + printf 'Persistent TMT workdir already exists: %s\n' "$persistent_tmt_run_dir" >&2 + exit 1 + fi + test -d "$TMT_TEST_DATA" + tmt_test_data_sentinel=$TMT_TEST_DATA/$tmt_test_data_sentinel_name + test ! -e "$tmt_test_data_sentinel" + printf '%s\n' workdir-persistence-sentinel > "$tmt_test_data_sentinel" + if [[ $tmt_run_layout == var-tmp ]]; then + # --preserve-var excludes /var/tmp. Stage a full copy on /var, then + # redirect the original path so TMT's post-reboot writes survive. + install -d -m 0755 "$tmt_workdir_root" + staging_parent=$(mktemp -d "$tmt_workdir_root/.${tmt_run_id}.XXXXXX") + trap 'if [[ -n ${staging_parent:-} ]]; then rm -rf -- "$staging_parent"; fi' EXIT + cp -a -- "$tmt_run_dir" "$staging_parent/" + staged_run_dir=$staging_parent/$tmt_run_id + mv -T --no-clobber -- "$staged_run_dir" "$persistent_tmt_run_dir" + if [[ -e "$staged_run_dir" || -L "$staged_run_dir" ]]; then + printf 'Persistent TMT workdir appeared during staging: %s\n' \ + "$persistent_tmt_run_dir" >&2 + exit 1 + fi + rmdir -- "$staging_parent" + staging_parent= + trap - EXIT + + install -d -m 0755 /etc/tmpfiles.d + printf 'd %s 0755 root root -\nL+ %s - - - - %s\n' \ + "$tmt_workdir_parent" "$tmt_run_dir" "$persistent_tmt_run_dir" \ + > /etc/tmpfiles.d/bootc-tmt-workdir.conf + + disposable_run_dir=$tmt_workdir_parent/.${tmt_run_id}.original.$$ + if [[ -e "$disposable_run_dir" || -L "$disposable_run_dir" ]]; then + printf 'Disposable TMT workdir already exists: %s\n' "$disposable_run_dir" >&2 + exit 1 + fi + + tmt_run_dir_moved=0 + tmt_run_dir_swap_complete=0 + cleanup_tmt_run_dir_swap() { + local status=$? + local disposable_identity + local original_identity + + trap - EXIT HUP INT TERM + if (( tmt_run_dir_moved )); then + # A signal can arrive after ln succeeds but before the flag is + # updated, so recognize the completed swap from the link too. + if [[ -L $tmt_run_dir ]] && + [[ $(readlink -- "$tmt_run_dir") == "$persistent_tmt_run_dir" ]]; then + tmt_run_dir_swap_complete=1 + fi + + if (( ! tmt_run_dir_swap_complete )); then + disposable_identity=$(stat -c '%d:%i' -- "$disposable_run_dir" 2>/dev/null || true) + if [[ $disposable_identity == "$tmt_run_dir_identity" ]]; then + if [[ ! -e $tmt_run_dir && ! -L $tmt_run_dir ]]; then + # --no-clobber ensures a replacement path created after + # the existence check is never overwritten. + mv -T --no-clobber -- "$disposable_run_dir" "$tmt_run_dir" || true + original_identity=$(stat -c '%d:%i' -- "$tmt_run_dir" 2>/dev/null || true) + if [[ $original_identity != "$tmt_run_dir_identity" ]]; then + disposable_identity=$(stat -c '%d:%i' -- "$disposable_run_dir" 2>/dev/null || true) + if [[ $disposable_identity == "$tmt_run_dir_identity" ]]; then + printf 'Could not restore TMT workdir because no-replace skipped the move; the original remains at %s\n' \ + "$disposable_run_dir" >&2 + else + printf 'Failed to restore TMT workdir from %s to %s\n' \ + "$disposable_run_dir" "$tmt_run_dir" >&2 + fi + fi + else + printf 'Cannot restore TMT workdir because the original path now exists; the original remains at %s\n' \ + "$disposable_run_dir" >&2 + fi + elif [[ -e $disposable_run_dir || -L $disposable_run_dir ]]; then + printf 'Leaving replacement at disposable TMT workdir path untouched: %s\n' \ + "$disposable_run_dir" >&2 + fi + fi + fi + exit "$status" + } + trap cleanup_tmt_run_dir_swap EXIT + trap 'exit 1' HUP INT TERM + tmt_run_dir_moved=1 + mv -T --no-clobber -- "$tmt_run_dir" "$disposable_run_dir" + disposable_run_dir_identity=$(stat -c '%d:%i' -- "$disposable_run_dir" 2>/dev/null || true) + if [[ $disposable_run_dir_identity != "$tmt_run_dir_identity" ]]; then + tmt_run_dir_moved=0 + printf 'Failed to move original TMT workdir to %s; the destination may have appeared concurrently\n' \ + "$disposable_run_dir" >&2 + exit 1 + fi + ln -s -- "$persistent_tmt_run_dir" "$tmt_run_dir" + tmt_run_dir_swap_complete=1 + # Keep the old tree: it is disposable under /var/tmp and vanishes on + # reboot, while retaining it keeps this shell's cwd valid until we leave. + tmt_run_dir_moved=0 + trap - EXIT HUP INT TERM + else + # /var/ARTIFACTS is already inside preserved /var. Do not move or + # symlink the Testing Farm workdir; preserve-var must carry it. + verify_tmt_workdir_in_preserved_var "$tmt_run_dir" + fi + + # The shell may still be inside the renamed workdir; leave it before + # installing or rebooting so the old cwd is no longer in use. + cd / + + # Run the build-under-test as a privileged installer, with the + # package-mode root mounted at /target. + podman --remote=false run --rm --privileged --pid=host --user=root:root \ + -v /dev:/dev \ + -v /:/target \ + -v /boot:/target/boot \ + -v /var/lib/containers:/var/lib/containers \ + --security-opt label=type:unconfined_t \ + "$target_image" \ + bootc install to-existing-root --preserve-var --acknowledge-destructive + + bootc status + # Ask the guest to reboot itself; this is more reliable than + # testcloud's ACPI soft-poweroff for a full bootc deployment reboot. + tmt-reboot -c 'systemctl reboot' + ;; + 1) + test -e /run/ostree-booted + + tmt_run_dir=$(get_tmt_run_dir) + tmt_run_id=${tmt_run_dir##*/} + if [[ $tmt_run_dir == /var/ARTIFACTS/* ]]; then + # The Packit workdir itself must have survived preserve-var intact. + test -d "$tmt_run_dir" + test ! -L "$tmt_run_dir" + verify_tmt_workdir_in_preserved_var "$tmt_run_dir" + else + persistent_tmt_run_dir=$tmt_workdir_root/$tmt_run_id + test -L "$tmt_run_dir" + test "$(readlink -f -- "$tmt_run_dir")" = "$persistent_tmt_run_dir" + fi + test -d "$TMT_PLAN_DATA" + test -d "$TMT_TEST_DATA" + test -f "$TMT_TEST_METADATA" + tmt_test_data_sentinel=$TMT_TEST_DATA/$tmt_test_data_sentinel_name + tmt_test_data_post_reboot_marker=$TMT_TEST_DATA/$tmt_test_data_post_reboot_marker_name + test -f "$tmt_test_data_sentinel" + test "$(<"$tmt_test_data_sentinel")" = workdir-persistence-sentinel + printf '%s\n' post-reboot-workdir-writable > "$tmt_test_data_post_reboot_marker" + test "$(<"$tmt_test_data_post_reboot_marker")" = post-reboot-workdir-writable + + booted_image=$(bootc status --json | jq -r '.status.booted.image.image.image') + test "$booted_image" = "$target_image" + + test "$(<"$var_marker")" = preserved-var + ;; + *) + printf 'Unexpected TMT_REBOOT_COUNT=%s\n' "$TMT_REBOOT_COUNT" >&2 + exit 1 + ;; +esac diff --git a/tmt/tests/test-50-to-existing-root-preserve-var.fmf b/tmt/tests/test-50-to-existing-root-preserve-var.fmf new file mode 100644 index 0000000000..305222ad67 --- /dev/null +++ b/tmt/tests/test-50-to-existing-root-preserve-var.fmf @@ -0,0 +1,10 @@ +# The VM cannot share controller Podman storage, so TMT syncs the local OCI +# archive to the single guest before migration. It reboots once, then verifies +# preserved /var data. +summary: Package-mode /var preservation +duration: 45m +adjust: + - when: running_env == image_mode + enabled: false + because: this migration test requires a package-mode target +test: bash booted/package-mode/test-to-existing-root-preserve-var.sh From 84cb879fca16126af3c962ed79760a44291b6626 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:21:16 -0500 Subject: [PATCH 3/4] tmt: Install preserve-var workdir rule in deployment The package-mode host's /etc is not migrated by to-existing-root, so a tmpfiles rule written there disappears on reboot. Write and verify the rule in the mounted deployment's persistent /etc before reboot instead. Assisted-by: AI Signed-off-by: ckyrouac --- .../test-to-existing-root-preserve-var.sh | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh b/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh index 779df53189..4bd9365609 100755 --- a/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh +++ b/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh @@ -158,11 +158,6 @@ case ${TMT_REBOOT_COUNT:-0} in staging_parent= trap - EXIT - install -d -m 0755 /etc/tmpfiles.d - printf 'd %s 0755 root root -\nL+ %s - - - - %s\n' \ - "$tmt_workdir_parent" "$tmt_run_dir" "$persistent_tmt_run_dir" \ - > /etc/tmpfiles.d/bootc-tmt-workdir.conf - disposable_run_dir=$tmt_workdir_parent/.${tmt_run_id}.original.$$ if [[ -e "$disposable_run_dir" || -L "$disposable_run_dir" ]]; then printf 'Disposable TMT workdir already exists: %s\n' "$disposable_run_dir" >&2 @@ -253,6 +248,46 @@ case ${TMT_REBOOT_COUNT:-0} in "$target_image" \ bootc install to-existing-root --preserve-var --acknowledge-destructive + if [[ $tmt_run_layout == var-tmp ]]; then + # to-existing-root does not migrate the old host's /etc. Add the + # tmpfiles rule to the new deployment's persistent /etc instead. + podman --remote=false run --rm --privileged --pid=host --user=root:root \ + -v /dev:/dev \ + -v /:/target \ + -v /boot:/target/boot \ + -v /var/lib/containers:/var/lib/containers \ + --security-opt label=type:unconfined_t \ + "$target_image" \ + bash -euo pipefail -c ' + deployment=/mnt/bootc-tmt-deployment + cleanup() { + local status=$? + trap - EXIT + if ! umount -R "$deployment"; then + printf "Failed to unmount deployment target %s\n" "$deployment" >&2 + status=1 + fi + exit "$status" + } + trap cleanup EXIT + + workdir_parent=$1 + run_dir=$2 + persistent_run_dir=$3 + mkdir -p "$deployment" + bootc install mount --sysroot /target --latest "$deployment" + rule=$deployment/etc/tmpfiles.d/bootc-tmt-workdir.conf + install -d -m 0755 "${rule%/*}" + tmpfiles_rule=( + "d $workdir_parent 0755 root root -" + "L+ $run_dir - - - - $persistent_run_dir" + ) + printf '%s\n' "${tmpfiles_rule[@]}" > "$rule" + printf '%s\n' "${tmpfiles_rule[@]}" | cmp - "$rule" + ' install-tmt-tmpfiles-rule \ + "$tmt_workdir_parent" "$tmt_run_dir" "$persistent_tmt_run_dir" + fi + bootc status # Ask the guest to reboot itself; this is more reliable than # testcloud's ACPI soft-poweroff for a full bootc deployment reboot. @@ -270,6 +305,7 @@ case ${TMT_REBOOT_COUNT:-0} in verify_tmt_workdir_in_preserved_var "$tmt_run_dir" else persistent_tmt_run_dir=$tmt_workdir_root/$tmt_run_id + test -f /etc/tmpfiles.d/bootc-tmt-workdir.conf test -L "$tmt_run_dir" test "$(readlink -f -- "$tmt_run_dir")" = "$persistent_tmt_run_dir" fi From 7b6190788484608faf3dd58ae353a1ac21f44100 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:51:14 -0500 Subject: [PATCH 4/4] tests: Restore nested bind-mount preserve-var coverage The split package-mode test no longer exercised how preserve-var handles a nested bind mount. Add a derived image with an enabled mount and verify copied data and the rebooted mount behavior. Assisted-by: AI Signed-off-by: ckyrouac --- tmt/plans/package-mode.fmf | 23 +++++++++++++ .../test-to-existing-root-preserve-var.sh | 33 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tmt/plans/package-mode.fmf b/tmt/plans/package-mode.fmf index 7930c556f4..9966633eff 100644 --- a/tmt/plans/package-mode.fmf +++ b/tmt/plans/package-mode.fmf @@ -131,6 +131,29 @@ prepare: podman --remote=false pull quay.io/curl/curl:latest podman --remote=false pull quay.io/curl/curl-base:latest podman --remote=false pull registry.access.redhat.com/ubi9/podman:latest + workdir=$(mktemp -d) + trap 'rm -rf -- "$workdir"' EXIT + cat > "$workdir/Containerfile" <<'EOF' + FROM localhost/bootc + RUN unit='var-lib-bootc\x2dtmt\x2dpreserve\x2dvar\x2dbind-target.mount' && \ + mkdir -p /usr/etc/systemd/system/local-fs.target.wants && \ + printf '%s\n' \ + '[Unit]' \ + 'Description=bootc TMT preserve-var bind mount' \ + '' \ + '[Mount]' \ + 'What=/var/lib/bootc-tmt-preserve-var-bind/source' \ + 'Where=/var/lib/bootc-tmt-preserve-var-bind/target' \ + 'Type=none' \ + 'Options=bind' \ + '' \ + '[Install]' \ + 'WantedBy=local-fs.target' \ + > "/usr/etc/systemd/system/$unit" && \ + ln -s "../$unit" "/usr/etc/systemd/system/local-fs.target.wants/$unit" + EOF + podman --remote=false build -t localhost/bootc-preserve-var-test "$workdir" + podman --remote=false image exists localhost/bootc-preserve-var-test when: package_source == artifacts or package_source == local execute: how: tmt diff --git a/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh b/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh index 4bd9365609..4bcae52089 100755 --- a/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh +++ b/tmt/tests/booted/package-mode/test-to-existing-root-preserve-var.sh @@ -3,8 +3,11 @@ # reboot that same guest; local loads the checkout image, Packit builds it. set -xeuo pipefail -target_image=localhost/bootc +target_image=localhost/bootc-preserve-var-test var_marker=/var/lib/bootc-tmt-preserve-var/sentinel +bind_dir=/var/lib/bootc-tmt-preserve-var-bind +bind_source=$bind_dir/source +bind_target=$bind_dir/target tmt_plan_data_suffix=/tmt/plans/package-mode/data tmt_workdir_parent=/var/tmp/tmt tmt_workdir_root=/var/lib/bootc-tmt-workdirs @@ -118,6 +121,13 @@ case ${TMT_REBOOT_COUNT:-0} in restorecon -RF /var/roothome/.ssh printf '%s\n' preserved-var | tee "$var_marker" + mkdir -p "$bind_source" "$bind_target" + printf '%s\n' bind-source-data > "$bind_source/sentinel" + printf '%s\n' mounted-only-data > "$bind_source/mounted-sentinel" + printf '%s\n' underlying-target-data > "$bind_target/underlying-sentinel" + mount --bind "$bind_source" "$bind_target" + test "$(<"$bind_target/mounted-sentinel")" = mounted-only-data + test ! -e "$bind_target/underlying-sentinel" tmt_run_dir=$(get_tmt_run_dir) tmt_run_id=${tmt_run_dir##*/} @@ -248,6 +258,23 @@ case ${TMT_REBOOT_COUNT:-0} in "$target_image" \ bootc install to-existing-root --preserve-var --acknowledge-destructive + # Find the new stateroot by the copied source sentinel; generated + # stateroot names are intentionally not assumed. + new_var= + for candidate in /ostree/deploy/*/var; do + if [[ -f "$candidate${bind_source#/var}/sentinel" ]]; then + new_var=$candidate + break + fi + done + test -n "$new_var" + test "$(<"$new_var${bind_source#/var}/sentinel")" = bind-source-data + bind_target_in_new_var=$new_var${bind_target#/var} + test -d "$bind_target_in_new_var" + test -z "$(find "$bind_target_in_new_var" -mindepth 1 -print -quit)" + test ! -e "$bind_target_in_new_var/mounted-sentinel" + test ! -e "$bind_target_in_new_var/underlying-sentinel" + if [[ $tmt_run_layout == var-tmp ]]; then # to-existing-root does not migrate the old host's /etc. Add the # tmpfiles rule to the new deployment's persistent /etc instead. @@ -296,6 +323,10 @@ case ${TMT_REBOOT_COUNT:-0} in 1) test -e /run/ostree-booted + mountpoint -q "$bind_target" + test "$(<"$bind_target/mounted-sentinel")" = mounted-only-data + test ! -e "$bind_target/underlying-sentinel" + tmt_run_dir=$(get_tmt_run_dir) tmt_run_id=${tmt_run_dir##*/} if [[ $tmt_run_dir == /var/ARTIFACTS/* ]]; then