From c492cd7af76ddd4c6d62d7f0c183a036d0ade21c Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Fri, 30 Oct 2026 09:27:34 -0500 Subject: [PATCH 01/11] install: Add tested /var migration engine The /var copy needs careful filtering around exclusions and nested mounts, and must avoid publishing partial entries or overwriting existing data. Isolating the copy engine and its tests makes these filesystem guarantees reviewable before adding installer orchestration. Assisted-by: AI Signed-off-by: ckyrouac --- crates/lib/src/install.rs | 2 + crates/lib/src/install/migrate.rs | 1374 +++++++++++++++++++ crates/lib/src/install/migrate/mountinfo.rs | 322 +++++ 3 files changed, 1698 insertions(+) create mode 100644 crates/lib/src/install/migrate.rs create mode 100644 crates/lib/src/install/migrate/mountinfo.rs diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 36ae98393..5010619d5 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -144,6 +144,8 @@ mod aleph; pub(crate) mod baseline; pub(crate) mod completion; pub(crate) mod config; +#[cfg(test)] +mod migrate; mod osbuild; pub(crate) mod osconfig; diff --git a/crates/lib/src/install/migrate.rs b/crates/lib/src/install/migrate.rs new file mode 100644 index 000000000..e0dfa7532 --- /dev/null +++ b/crates/lib/src/install/migrate.rs @@ -0,0 +1,1374 @@ +//! # 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`). +//! +//! This uses `cp -a` to copy each non-ephemeral subdirectory of `/var` into the +//! new deployment's stateroot `var/`. The copy is not a consistent snapshot of +//! live data; stop stateful services before migrating their data. +//! +//! 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. + +mod mountinfo; + +use std::path::{Component, Path}; +use std::{ + collections::BTreeSet, + ffi::OsStr, + os::fd::AsFd, + path::PathBuf, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +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; + +/// 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) +} + +/// 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, + 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, + 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, + 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, + 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, + 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, + &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, + description: &str, +) -> Result<()> { + with_staging_dir(destination, |_, staging| { + let options = &["-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, + 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, + 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(()) +} + +/// Plain recursive copy of `/var` into the new deployment. +/// +/// # 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 to use a consistent snapshot before +/// copying their data. +/// +/// 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, + 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::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 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 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 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 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 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/install/migrate/mountinfo.rs b/crates/lib/src/install/migrate/mountinfo.rs new file mode 100644 index 000000000..13eb5d3eb --- /dev/null +++ b/crates/lib/src/install/migrate/mountinfo.rs @@ -0,0 +1,322 @@ +//! Read `/proc/self/mountinfo` and select mounts nested beneath the source `/var`. +//! +//! The byte-oriented parser preserves non-UTF-8 Unix paths and decodes kernel +//! escapes. The `procfs` crate requires UTF-8 and leaves escaped mountpoint +//! paths undecoded; bootc-mount/findmnt exposes `String` targets and invokes +//! external `findmnt`, so neither preserves arbitrary path bytes. There is no +//! standard-library mountinfo API. + +use std::{ + collections::BTreeSet, + os::unix::ffi::OsStringExt, + path::{Component, Path, PathBuf}, +}; + +use anyhow::{Context, Result}; + +/// Read one snapshot and return the paths of mounts nested beneath `src_var`. +/// +/// `src_var` is the canonical path used to open the source directory; +/// mountinfo paths are already kernel-canonical. +pub(super) fn nested_mountpoints_for(src_var: &Path) -> Result> { + let snapshot = read_mountinfo_snapshot()?; + nested_mountpoints(src_var, parse_mountinfo(&snapshot)?) +} + +/// Read one complete installer mount namespace snapshot. +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 mut nested = BTreeSet::new(); + for mountpoint in mountpoints { + let Ok(relative) = mountpoint.strip_prefix(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 {}: {}", + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, os::unix::ffi::OsStrExt}; + + #[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(std::ffi::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<()> { + use std::os::unix::fs::symlink; + + 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"), + ]; + let canonical_src_var = fs::canonicalize(&alias)?; + assert_eq!( + nested_mountpoints(&canonical_src_var, paths)?, + BTreeSet::from([PathBuf::from("lib/data")]) + ); + Ok(()) + } +} From bb4359d16ebc9057c1044a144671b476b277fe06 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Mon, 2 Nov 2026 11:29:22 -0600 Subject: [PATCH 02/11] install: Separate bound-image resolution from state lookup Stored images should be validated before target mutations. Assisted-by: AI Signed-off-by: ckyrouac --- crates/lib/src/cli.rs | 9 +++- crates/lib/src/install.rs | 98 +++++++++++++++++++++++++++------------ 2 files changed, 76 insertions(+), 31 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index ed784c4e5..aac06e0f5 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -2523,8 +2523,13 @@ async fn run_from_opt(opt: Opt) -> Result { #[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 + crate::install::install_to_filesystem( + opts, + false, + crate::install::Cleanup::Skip, + false, + ) + .await } 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 5010619d5..4b9c23c1a 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2188,35 +2188,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()? @@ -2224,7 +2230,10 @@ 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.) @@ -2266,6 +2275,7 @@ async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, cleanup: Cleanup, + pre_resolved_bound_images: Option, ) -> Result<()> { if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.extend(&Cmdline::from("selinux=0")); @@ -2361,7 +2371,7 @@ async fn install_to_filesystem_impl( .context("SELinux labeling of composefs objects")?; } } else { - ostree_install(state, rootfs, cleanup).await?; + 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, @@ -2484,7 +2494,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(); @@ -2760,6 +2770,7 @@ pub(crate) async fn install_to_filesystem( opts: InstallToFilesystemOpts, targeting_host_root: bool, cleanup: Cleanup, + resolve_bound_images_early: bool, ) -> Result<()> { // Log the installation operation to systemd journal const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3"; @@ -2872,6 +2883,18 @@ pub(crate) async fn install_to_filesystem( ) .await?; + // Callers may opt in when stored bound images must be validated before + // target mutations. Other install flows retain their existing resolution + // timing by passing false. + let pre_resolved_bound_images = if targeting_host_root + && resolve_bound_images_early + && !state.composefs_options.composefs_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)?; @@ -3034,7 +3057,7 @@ pub(crate) async fn install_to_filesystem( skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + 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); @@ -3089,7 +3112,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> composefs_opts: opts.composefs_opts, }; - install_to_filesystem(opts, true, cleanup).await + install_to_filesystem(opts, true, cleanup, false).await } /// Read the /boot entry from /etc/fstab, if it exists @@ -3672,4 +3695,21 @@ UUID=boot-uuid /boot ext4 defaults 0 0 Ok(()) } + + #[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"); + } } From 5609f7accc2154693acd848c102078cad5d7ecf6 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Fri, 30 Oct 2026 09:42:57 -0500 Subject: [PATCH 03/11] install: Wire /var migration into existing-root install Users converting an existing package-mode root need an opt-in path to move its state into the new OSTree deployment before reboot. Keep migration behind the existing-root install option and defer destructive cleanup until the copy succeeds, while preserving the established install behavior for other callers. Assisted-by: AI Signed-off-by: ckyrouac --- crates/lib/src/cli.rs | 20 ++-- crates/lib/src/install.rs | 179 +++++++++++++++++++++++++++--- crates/lib/src/install/config.rs | 31 ++++++ crates/lib/src/install/migrate.rs | 74 ++++++++++++ 4 files changed, 278 insertions(+), 26 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index aac06e0f5..0978657a7 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -380,6 +380,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. /// @@ -2522,15 +2525,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, - false, - ) - .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 4b9c23c1a..7e2755be0 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -144,8 +144,7 @@ mod aleph; pub(crate) mod baseline; pub(crate) mod completion; pub(crate) mod config; -#[cfg(test)] -mod migrate; +pub(crate) mod migrate; mod osbuild; pub(crate) mod osconfig; @@ -667,6 +666,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)] @@ -2110,7 +2126,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()?; @@ -2177,7 +2193,7 @@ async fn install_with_sysroot( } } - Ok(()) + Ok(camino::Utf8PathBuf::from(deployment_path.to_string())) } enum BoundImages { @@ -2222,7 +2238,7 @@ async fn ostree_install( rootfs: &RootSetup, cleanup: Cleanup, pre_resolved_bound_images: Option, -) -> Result<()> { +) -> Result { // We verify this upfront because it's currently required by bootupd let boot_uuid = rootfs .get_boot_uuid()? @@ -2237,10 +2253,10 @@ async fn ostree_install( // 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, @@ -2263,12 +2279,13 @@ async fn ostree_install( // 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( @@ -2276,7 +2293,7 @@ async fn install_to_filesystem_impl( rootfs: &mut RootSetup, cleanup: Cleanup, pre_resolved_bound_images: Option, -) -> Result<()> { +) -> Result> { if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.extend(&Cmdline::from("selinux=0")); } @@ -2298,7 +2315,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 @@ -2370,8 +2387,10 @@ async fn install_to_filesystem_impl( ) .context("SELinux labeling of composefs objects")?; } + None } else { - ostree_install(state, rootfs, cleanup, pre_resolved_bound_images).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, @@ -2390,7 +2409,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 @@ -2412,7 +2432,7 @@ async fn install_to_filesystem_impl( } } - Ok(()) + Ok(deployment_path) } fn installation_complete() { @@ -2771,7 +2791,7 @@ pub(crate) async fn install_to_filesystem( targeting_host_root: bool, cleanup: Cleanup, resolve_bound_images_early: bool, -) -> Result<()> { +) -> Result> { // Log the installation operation to systemd journal const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3"; let source_image = opts @@ -2883,6 +2903,13 @@ 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( + resolve_bound_images_early, + state.composefs_options.composefs_backend, + )?; + // Callers may opt in when stored bound images must be validated before // target mutations. Other install flows retain their existing resolution // timing by passing false. @@ -3057,13 +3084,41 @@ pub(crate) async fn install_to_filesystem( skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup, pre_resolved_bound_images).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(()) } @@ -3097,7 +3152,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, @@ -3112,7 +3183,36 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> composefs_opts: opts.composefs_opts, }; - install_to_filesystem(opts, true, cleanup, false).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 @@ -3696,6 +3796,23 @@ 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 { @@ -3712,4 +3829,32 @@ UUID=boot-uuid /boot ext4 defaults 0 0 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 55fdd42f9..fc3e86c59 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 index e0dfa7532..da34c0d22 100644 --- a/crates/lib/src/install/migrate.rs +++ b/crates/lib/src/install/migrate.rs @@ -58,6 +58,67 @@ 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 nested_mounts = mountinfo::nested_mountpoints_for(&canonical_src_var)?; + 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()); + + println!(" Copying /var into the new deployment"); + preserve_var_copy_mounts(&src_var, &new_var, &effective_exclusions, &nested_mounts)?; + + Ok(()) +} + +/// 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. @@ -930,6 +991,15 @@ mod tests { 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()?; @@ -1233,6 +1303,10 @@ mod tests { 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"))?; From 99c36fcb3ad7e4d6b1f43a9ac089320718812230 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Fri, 30 Oct 2026 09:28:05 -0500 Subject: [PATCH 04/11] install: Use reflinks for /var migration when available Large /var trees are expensive to duplicate when the source and deployment filesystems support copy-on-write clones. Probe the actual source/destination pair, use reflinks when available, and retain a full-copy fallback for unsupported filesystems. Assisted-by: AI Signed-off-by: ckyrouac --- crates/lib/src/install/migrate.rs | 353 +++++++++++++++++++++++++++++- crates/lib/src/store/mod.rs | 2 + crates/lib/src/store/reflink.rs | 279 +++++++++++++++++++++++ 3 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 crates/lib/src/store/reflink.rs diff --git a/crates/lib/src/install/migrate.rs b/crates/lib/src/install/migrate.rs index da34c0d22..2227cb85f 100644 --- a/crates/lib/src/install/migrate.rs +++ b/crates/lib/src/install/migrate.rs @@ -16,9 +16,18 @@ //! directory. The old package-mode `/var` is stranded at `/var` //! (inside the container, the host root is mounted at `root_path`). //! -//! This uses `cp -a` to copy each non-ephemeral subdirectory of `/var` into the -//! new deployment's stateroot `var/`. The copy is not a consistent snapshot of -//! live data; stop stateful services before migrating their data. +//! 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 @@ -50,6 +59,7 @@ use std::{ }, }; +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}; @@ -78,8 +88,14 @@ pub(crate) fn preserve_var(src_var: &Path, new_var: &Path, exclusions: &[String] .collect::>(); effective_exclusions.extend(exclusions.iter().cloned()); - println!(" Copying /var into the new deployment"); - preserve_var_copy_mounts(&src_var, &new_var, &effective_exclusions, &nested_mounts)?; + 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(()) } @@ -174,6 +190,40 @@ fn run_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 @@ -187,6 +237,7 @@ fn copy_var_filtered( relative_dir: &Path, exclusions: &[String], mountpoints: &BTreeSet, + reflink: bool, destination_created: bool, cp_program: &OsStr, ) -> Result<()> { @@ -220,6 +271,7 @@ fn copy_var_filtered( relative_dir, exclusions, mountpoints, + reflink, cp_program, ); let restore_result = match (source_metadata.as_ref(), destination_metadata.as_ref()) { @@ -251,6 +303,7 @@ fn copy_var_filtered_inner( relative_dir: &Path, exclusions: &[String], mountpoints: &BTreeSet, + reflink: bool, cp_program: &OsStr, ) -> Result<()> { let entries = src_dir @@ -302,6 +355,7 @@ fn copy_var_filtered_inner( &relative, exclusions, mountpoints, + reflink, false, cp_program, )?; @@ -317,6 +371,7 @@ fn copy_var_filtered_inner( &relative, exclusions, mountpoints, + reflink, cp_program, )?; } else { @@ -341,6 +396,7 @@ fn copy_var_filtered_inner( src_dir, &names, dst_dir, + reflink, &format!("/var/{}", relative_dir.display()), )?; } @@ -470,10 +526,15 @@ fn copy_batch_staged( source: &CapStdDir, names: &[&OsStr], destination: &CapStdDir, + reflink: bool, description: &str, ) -> Result<()> { with_staging_dir(destination, |_, staging| { - let options = &["-a"][..]; + let options = if reflink { + &["--reflink=always", "-a"][..] + } else { + &["-a"][..] + }; let status = run_cp( cp_program, source, @@ -500,6 +561,7 @@ fn copy_filtered_directory_staged( relative: &Path, exclusions: &[String], mountpoints: &BTreeSet, + reflink: bool, cp_program: &OsStr, ) -> Result<()> { with_staging_dir(destination_parent, |_, staging| { @@ -511,6 +573,7 @@ fn copy_filtered_directory_staged( relative, exclusions, mountpoints, + reflink, true, cp_program, )?; @@ -817,7 +880,7 @@ fn preserve_metadata_timestamps(metadata: &Metadata, dst_fd: Fd) -> Re Ok(()) } -/// Plain recursive copy of `/var` into the new deployment. +/// Strategy D: plain recursive copy of `/var` for filesystems without reflink support. /// /// # Known limitation /// @@ -830,8 +893,10 @@ fn preserve_metadata_timestamps(metadata: &Metadata, dst_fd: Fd) -> Re /// corrupted copy. /// /// The correct fix is to run this migration only after stopping all stateful -/// services that write to `/var`, or to use a consistent snapshot before -/// copying their data. +/// 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 @@ -866,6 +931,7 @@ fn preserve_var_copy_mounts( exclusions, mountpoints, false, + false, OsStr::new("cp"), ) .context("Copying /var into new deployment (Strategy D)")?; @@ -1036,6 +1102,63 @@ mod tests { Ok(()) } + #[test] + #[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()?; @@ -1269,6 +1392,185 @@ mod tests { 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()?; @@ -1361,6 +1663,39 @@ mod tests { 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()?; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 5b89a415e..1d1e814ab 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 000000000..148a40153 --- /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(()) + } +} From c9b8db461e78a5bc546beb6d408746c3dadecad8 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Fri, 30 Oct 2026 09:28:12 -0500 Subject: [PATCH 05/11] docs: Explain preserve-var migration behavior Users converting package-mode systems need to understand the copy strategy, required backend, exclusions, and lack of snapshot consistency before opting in. Assisted-by: AI Signed-off-by: ckyrouac --- docs/src/bootc-installation.7.md | 8 ++++++++ docs/src/man/bootc-install-config.5.md | 5 ++++- .../src/man/bootc-install-to-existing-root.8.md | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index 22f086add..73360fe71 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 7211c4aab..35726137b 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 d9694977c..5baf2c280 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 0aac02c9a065e1001ba2382919676dc94b215ade Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:11:06 -0500 Subject: [PATCH 06/11] 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 e2f743057..680059b0e 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 ce77decf4..247671a05 100644 --- a/Justfile +++ b/Justfile @@ -209,6 +209,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 a12a1a674..905d51981 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 @@ -95,6 +96,9 @@ fi # Keep these in sync with what's used in hack/lbi; ubi9/podman is pinned, see hack/lbi/podman.image 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:9.8-1793032728 -# 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 000000000..7930c556f --- /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 000000000..779df5318 --- /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 000000000..305222ad6 --- /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 9d2d2175c58bd5d3a7e2c9960d797e4ea5073f77 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:21:16 -0500 Subject: [PATCH 07/11] 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 779df5318..4bd936560 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 c7e37b185f80797b6f64d832ffdc33bfa2ba36d9 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 22 Oct 2026 10:51:14 -0500 Subject: [PATCH 08/11] 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 7930c556f..9966633ef 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 4bd936560..4bcae5208 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 From de1179638e6209a02ced984d3d6c35e3ea62943c Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Tue, 27 Oct 2026 10:17:50 -0500 Subject: [PATCH 09/11] lib: Harden preserve-var migration safety Reject wipe mode before installation can mutate the running source tree. Make no-clobber collisions visible and clarify the copy, staging, and crash-durability guarantees. Assisted-by: AI Signed-off-by: ckyrouac --- crates/lib/src/install.rs | 63 ++++++++++--- crates/lib/src/install/migrate.rs | 144 ++++++++++++++++++------------ 2 files changed, 138 insertions(+), 69 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 7e2755be0..1e15fbbe5 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -677,6 +677,14 @@ pub(crate) struct InstallToExistingRootOpts { /// `/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. + /// + /// Do not combine this with `--replace=wipe`: wiping the target would erase + /// the source `/var` before it can be copied. An abrupt termination may + /// leave hidden `.bootc-preserve-var.*` staging directories in the new + /// `/var`; they are not automatically removed later. Verify an individual + /// entry is an abandoned migration artifact before manually removing it. + /// Staged publication is atomic for visibility, but is not guaranteed + /// durable across sudden power loss. #[clap(long)] pub(crate) preserve_var: bool, @@ -2790,8 +2798,10 @@ pub(crate) async fn install_to_filesystem( opts: InstallToFilesystemOpts, targeting_host_root: bool, cleanup: Cleanup, - resolve_bound_images_early: bool, + require_ostree_backend: bool, ) -> Result> { + validate_preserve_var_replace(require_ostree_backend, opts.filesystem_opts.replace)?; + // Log the installation operation to systemd journal const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3"; let source_image = opts @@ -2906,17 +2916,13 @@ pub(crate) async fn install_to_filesystem( // The image can select the composefs backend during preparation, so check // compatibility after that choice is resolved but before target mutation. validate_migration_backend( - resolve_bound_images_early, + require_ostree_backend, state.composefs_options.composefs_backend, )?; - // Callers may opt in when stored bound images must be validated before - // target mutations. Other install flows retain their existing resolution - // timing by passing false. - let pre_resolved_bound_images = if targeting_host_root - && resolve_bound_images_early - && !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 @@ -2931,8 +2937,10 @@ pub(crate) async fn install_to_filesystem( Some(ReplaceMode::Wipe) => { let rootfs_fd = rootfs_fd.try_clone()?; println!("Wiping contents of root"); - tokio::task::spawn_blocking(move || remove_all_in_dir_no_xdev(&rootfs_fd, true)) - .await??; + tokio::task::spawn_blocking(move || { + wipe_install_target(&rootfs_fd, require_ostree_backend) + }) + .await??; } Some(ReplaceMode::Alongside) => { clean_boot_directories(&target_rootfs_fd, &target_root_path, is_already_ostree)? @@ -3103,6 +3111,20 @@ fn validate_migration_backend(require_ostree_backend: bool, composefs_backend: b Ok(()) } +fn validate_preserve_var_replace(preserve_var: bool, replace: Option) -> Result<()> { + anyhow::ensure!( + !(preserve_var && matches!(replace, Some(ReplaceMode::Wipe))), + "--preserve-var cannot be used with --replace=wipe: wiping the target would erase the source /var before it can be copied" + ); + Ok(()) +} + +fn wipe_install_target(rootfs: &Dir, preserve_var: bool) -> Result<()> { + validate_preserve_var_replace(preserve_var, Some(ReplaceMode::Wipe))?; + remove_all_in_dir_no_xdev(rootfs, true)?; + 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"); @@ -3123,6 +3145,10 @@ fn write_destructive_cleanup_marker(root_path: &std::path::Path) -> Result<()> { } pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> Result<()> { + // Reject this before configuration loading, mounting, preparation, or any + // target changes: a wipe would destroy the running source /var. + validate_preserve_var_replace(opts.preserve_var, opts.replace)?; + // Log the existing root installation operation to systemd journal const INSTALL_EXISTING_ROOT_JOURNAL_ID: &str = "7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1"; let source_image = opts @@ -3813,6 +3839,21 @@ UUID=boot-uuid /boot ext4 defaults 0 0 } } + #[test] + fn preserve_var_rejects_wipe_before_target_contents_can_be_removed() -> Result<()> { + let target = tempfile::tempdir()?; + let sentinel = target.path().join("var/state.db"); + std::fs::create_dir_all(sentinel.parent().unwrap())?; + std::fs::write(&sentinel, b"must remain untouched")?; + + let rootfs = Dir::open_ambient_dir(target.path(), cap_std::ambient_authority())?; + let error = wipe_install_target(&rootfs, true).unwrap_err(); + + assert!(error.to_string().contains("--preserve-var")); + assert_eq!(std::fs::read(&sentinel)?, b"must remain untouched"); + Ok(()) + } + #[tokio::test] async fn test_bound_images_pull_mode_stays_unresolved() { let queried_images = vec![BoundImage { diff --git a/crates/lib/src/install/migrate.rs b/crates/lib/src/install/migrate.rs index 2227cb85f..00b54be0e 100644 --- a/crates/lib/src/install/migrate.rs +++ b/crates/lib/src/install/migrate.rs @@ -27,7 +27,7 @@ //! `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`). +//! on `preserve_var_copy_mounts`). //! //! Nested mountpoints are selected from one `/proc/self/mountinfo` snapshot //! before the copy. This is a point-in-time, best-effort snapshot, not a @@ -35,12 +35,27 @@ //! 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. +//! entry leaves no partial final entry and can be retried; there is no global +//! rollback, 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. +//! The built-in exclusions are `tmp`, `cache`, `log/journal`, and +//! `lib/containers`. Additional exclusions can be supplied through +//! configuration or repeated `--preserve-var-skip` options; `lib/rpm` is not +//! excluded unless explicitly configured. +//! +//! Staging directories use names beginning `.bootc-preserve-var.` beneath the +//! destination `/var`. They are cleaned up during normal success and error +//! handling, but an abrupt termination can leave them behind. They are not +//! automatically removed on a later run because a matching entry could be +//! unrelated. Before manually removing one, ensure no migration is running and +//! verify the individual entry is an artifact of an interrupted migration; do +//! not blindly remove every similarly named entry. +//! +//! Staged rename publication is atomic for namespace visibility, but this code +//! does not fsync copied files or directories. It therefore makes no guarantee +//! that a published entry survives sudden power loss or kernel failure. This is +//! per-entry publication, not a transaction: entries published before a later +//! error remain. //! //! Rollback preservation is intentionally independent from `/var` migration. @@ -217,7 +232,7 @@ fn preserve_var_reflink_mounts( mountpoints, true, false, - OsStr::new("cp"), + OsStr::new(GNU_CP), ) .context("Reflink-copying /var into new deployment (Strategy C)")?; println!(" /var reflink copy complete."); @@ -329,7 +344,7 @@ fn copy_var_filtered_inner( " Creating empty mountpoint placeholder for {}", relative.display() ); - create_staged_placeholder(src_dir, dst_dir, &name)?; + create_staged_placeholder(src_dir, dst_dir, &name, &relative)?; continue; } @@ -362,6 +377,10 @@ fn copy_var_filtered_inner( } 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. + println!( + " Skipping destination collision at /var path {:?}", + relative + ); continue; } else if has_nested_mount || has_nested_exclusion { copy_filtered_directory_staged( @@ -381,6 +400,10 @@ fn copy_var_filtered_inner( } if destination_entry_exists(dst_dir, &name)? { // Match cp --no-clobber without ever opening the existing object. + println!( + " Skipping destination collision at /var path {:?}", + relative + ); continue; } included_entries.push(name); @@ -391,20 +414,22 @@ fn copy_var_filtered_inner( .iter() .map(|name| name.as_os_str()) .collect::>(); - copy_batch_staged( - cp_program, - src_dir, - &names, - dst_dir, - reflink, - &format!("/var/{}", relative_dir.display()), - )?; + copy_batch_staged(cp_program, src_dir, &names, dst_dir, reflink, relative_dir)?; } Ok(()) } static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(0); +// Bound collision retries so a pathological destination cannot make staging +// allocation loop forever. The process id plus monotonic id makes collisions +// exceptional; one hundred attempts leave room for stale entries while still +// failing promptly if the namespace is obstructed. +const STAGING_NAME_ATTEMPTS: u32 = 100; + +// Do not resolve privileged copy commands through a caller-controlled PATH. +// Lower-level helpers still accept an executable parameter for test injection. +const GNU_CP: &str = "/usr/bin/cp"; /// Make an empty, uniquely-named staging directory beneath the destination /// capability. Publishing from the same parent keeps the final rename atomic. @@ -412,7 +437,7 @@ fn create_staging_dir(parent: &CapStdDir) -> Result<(std::ffi::OsString, CapStdD use rustix::fs::{Mode, mkdirat}; let parent_fd = parent.as_filelike_view::(); - for _ in 0..100 { + for _ in 0..STAGING_NAME_ATTEMPTS { let id = NEXT_STAGING_ID.fetch_add(1, Ordering::Relaxed); let name = std::ffi::OsString::from(format!(".bootc-preserve-var.{}.{}", std::process::id(), id)); @@ -436,8 +461,8 @@ fn create_staging_dir(parent: &CapStdDir) -> Result<(std::ffi::OsString, CapStdD } /// 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. +/// Any entries already published remain complete; there is no global rollback +/// of earlier successful publications. fn with_staging_dir( parent: &CapStdDir, operation: impl FnOnce(&OsStr, &CapStdDir) -> Result, @@ -470,6 +495,7 @@ fn publish_staged_entry( staging: &CapStdDir, destination: &CapStdDir, name: &OsStr, + relative: &Path, ) -> Result { use rustix::fs::{RenameFlags, renameat_with}; @@ -479,11 +505,15 @@ fn publish_staged_entry( // staged directories into a same-type destination without replacing // existing children; other collisions remain untouched. Err(error) if error == rustix::io::Errno::EXIST => { + println!( + " Destination collision at /var path {:?}; keeping existing entries", + relative + ); 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)?; + merge_staged_directory(&staging_dir, &destination_dir, relative)?; } } Ok(false) @@ -496,7 +526,11 @@ fn publish_staged_entry( /// 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<()> { +fn merge_staged_directory( + staging: &CapStdDir, + destination: &CapStdDir, + relative_dir: &Path, +) -> Result<()> { let metadata = snapshot_directory_metadata(destination)?; let names = staging .read_dir(".")? @@ -505,7 +539,7 @@ fn merge_staged_directory(staging: &CapStdDir, destination: &CapStdDir) -> Resul let merge_result = (|| { for name in names { - publish_staged_entry(staging, destination, &name)?; + publish_staged_entry(staging, destination, &name, &relative_dir.join(&name))?; } Ok(()) })(); @@ -527,7 +561,7 @@ fn copy_batch_staged( names: &[&OsStr], destination: &CapStdDir, reflink: bool, - description: &str, + relative_dir: &Path, ) -> Result<()> { with_staging_dir(destination, |_, staging| { let options = if reflink { @@ -545,10 +579,14 @@ fn copy_batch_staged( Stdio::inherit(), Stdio::inherit(), )?; - anyhow::ensure!(status.success(), "cp failed while copying {description}"); + anyhow::ensure!( + status.success(), + "cp failed while copying /var/{}", + relative_dir.display() + ); for name in names { - publish_staged_entry(staging, destination, name)?; + publish_staged_entry(staging, destination, name, &relative_dir.join(name))?; } Ok(()) }) @@ -577,7 +615,7 @@ fn copy_filtered_directory_staged( true, cp_program, )?; - publish_staged_entry(staging, destination_parent, name)?; + publish_staged_entry(staging, destination_parent, name, relative)?; Ok(()) }) } @@ -586,10 +624,11 @@ fn create_staged_placeholder( src_parent: &CapStdDir, dst_parent: &CapStdDir, name: &OsStr, + relative: &Path, ) -> Result<()> { with_staging_dir(dst_parent, |_, staging| { create_mountpoint_placeholder(src_parent, staging, name)?; - if !publish_staged_entry(staging, dst_parent, name)? { + if !publish_staged_entry(staging, dst_parent, name, relative)? { validate_empty_placeholder(dst_parent, name, src_parent.symlink_metadata(name)?)?; } Ok(()) @@ -880,34 +919,6 @@ fn preserve_metadata_timestamps(metadata: &Metadata, dst_fd: Fd) -> Re 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( @@ -918,6 +929,23 @@ fn preserve_var_copy( preserve_var_copy_mounts(src_var, new_var, exclusions, &BTreeSet::new()) } +/// Strategy D: plain recursive copy of `/var` for filesystems without reflink support. +/// +/// This performs `cp -a` for included state and is **unsafe for databases and +/// other applications with open write handles into `/var`** (for example, +/// PostgreSQL in `/var/lib/pgsql`, MySQL/MariaDB in `/var/lib/mysql`, or SQLite +/// databases under `/var/lib/*`). A live database copy may be corrupted. +/// Stop stateful services before copying, or use a consistent snapshot. +/// Reflinks (Strategy C) also clone files individually and are not an atomic +/// snapshot of `/var` or a live database. +/// +/// Additional exclusions can be configured or supplied with repeated +/// `--preserve-var-skip` options. Paths are relative to `/var`, with one +/// component or exactly one child component allowed. Excluded state is not +/// migrated and must be handled separately. Exclusions do not make a copied +/// live database consistent. +/// +/// See: fn preserve_var_copy_mounts( src_var: &CapStdDir, new_var: &CapStdDir, @@ -932,7 +960,7 @@ fn preserve_var_copy_mounts( mountpoints, false, false, - OsStr::new("cp"), + OsStr::new(GNU_CP), ) .context("Copying /var into new deployment (Strategy D)")?; println!(" /var copy complete."); @@ -1102,7 +1130,6 @@ mod tests { Ok(()) } - #[test] #[test] fn both_copy_strategies_leave_nested_mountpoints_empty() -> Result<()> { let temp = tempfile::tempdir()?; @@ -1371,7 +1398,8 @@ mod tests { assert!(!publish_staged_entry( staging, &destination, - OsStr::new("tree") + OsStr::new("tree"), + Path::new("tree") )?); assert_eq!( fs::read(destination_path.join("tree/existing"))?, From 67ac5c68d6c883e3c59226f7bb42631144a8a1dc Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Tue, 27 Oct 2026 10:34:45 -0500 Subject: [PATCH 10/11] docs: Clarify preserve-var crash durability Clarify that a completed preserve-var copy is not guaranteed to survive sudden power loss or kernel failure, while retaining the consistent-live-snapshot caveat. Assisted-by: AI Signed-off-by: ckyrouac --- docs/src/bootc-installation.7.md | 3 ++- docs/src/man/bootc-install-to-existing-root.8.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index 73360fe71..1615715c3 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -297,7 +297,8 @@ 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`, +services before copying their data. A completed copy is not guaranteed to survive +sudden power loss or a kernel failure. `/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`. 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 5baf2c280..9c86b86e5 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -20,7 +20,8 @@ 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`, +snapshot before copying live database data. A completed copy is not guaranteed +to survive sudden power loss or a kernel failure. 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`. From bb305fcdd33bf70a5a3391ffa784f53572db47d9 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Tue, 27 Oct 2026 10:44:51 -0500 Subject: [PATCH 11/11] docs: Clarify preserve-var migration guarantees Explain that atomic per-entry namespace visibility does not make the migration a tree-wide snapshot or transaction, and distinguish both from crash durability. This helps operators reason about partial migrations and live database data. Assisted-by: AI Signed-off-by: ckyrouac --- docs/src/bootc-installation.7.md | 12 ++++++++---- docs/src/man/bootc-install-to-existing-root.8.md | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/src/bootc-installation.7.md b/docs/src/bootc-installation.7.md index 1615715c3..410d2c45c 100644 --- a/docs/src/bootc-installation.7.md +++ b/docs/src/bootc-installation.7.md @@ -296,10 +296,14 @@ 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. A completed copy is not guaranteed to survive -sudden power loss or a kernel failure. `/var/tmp`, `/var/cache`, `/var/log/journal`, -and `/var/lib/containers` are always excluded. Use repeatable +full copy. Each new entry is staged and renamed into place atomically for +namespace visibility. This is not a tree-wide point-in-time snapshot or a +global all-or-nothing transaction: the copy proceeds over time, and a later +failure can leave a partial migration. Neither strategy provides a consistent +snapshot of live data; stop stateful services before copying their data. There +is no crash-durability guarantee: copied content and renames are not guaranteed +to survive sudden power loss or a kernel failure. `/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`. 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 9c86b86e5..1ba30f7cb 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -19,10 +19,14 @@ 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. A completed copy is not guaranteed -to survive sudden power loss or a kernel failure. The paths `tmp`, `cache`, -`log/journal`, and `lib/containers` are always excluded. Repeat +file. Each new entry is staged and renamed into place atomically for namespace +visibility, but the migration is not a tree-wide point-in-time snapshot or a +global all-or-nothing transaction: it proceeds over time, and a later failure +can leave a partial migration. The copy is not a consistent snapshot of live +data; stop stateful services or use a consistent snapshot before copying live +database data. There is no crash-durability guarantee: copied content and +renames are not guaranteed to survive sudden power loss or a kernel failure. +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`.