diff --git a/crates/etc-merge/src/lib.rs b/crates/etc-merge/src/lib.rs index 66d74ad1be..aaa48c8708 100644 --- a/crates/etc-merge/src/lib.rs +++ b/crates/etc-merge/src/lib.rs @@ -788,21 +788,43 @@ fn merge_leaf( }; if matches!(new_inode, Some(Inode::Directory(..))) { - anyhow::bail!("Modified config file {file:?} newly defaults to directory. Cannot merge") + tracing::warn!( + "Modified config file {file:?} newly defaults to a directory in the new image; \ + keeping the image's directory and skipping host customization" + ); + return Ok(()); }; - // If a new file with the same path exists, we delete it - new_etc_fd - .remove_all_optional(&file) - .context(format!("Deleting {file:?}"))?; - if let Some(target) = symlink { + new_etc_fd + .remove_all_optional(&file) + .context(format!("Deleting {file:?}"))?; // Using rustix's symlinkat here as we might have absolute symlinks which clash with ambient_authority symlinkat(&**target, new_etc_fd, file).context(format!("Creating symlink {file:?}"))?; } else { - current_etc_fd + // `Dir::copy` truncates an existing regular file, so do not remove the + // image version first. If opening a path through an absolute symlink + // fails, the image version remains intact. + let copy_result = current_etc_fd .copy(&file, new_etc_fd, &file) - .with_context(|| format!("Copying file {file:?}"))?; + .with_context(|| format!("Copying file {file:?}")); + if let Err(error) = ©_result { + let sandbox_escape = error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io_error| { + io_error.kind() == std::io::ErrorKind::PermissionDenied + && io_error.raw_os_error().is_none() + }) + }); + if sandbox_escape { + tracing::warn!( + "Skipping {file:?}: current path escapes the /etc sandbox; keeping image version" + ); + return Ok(()); + } + } + copy_result?; }; rustix::fs::chownat( @@ -916,8 +938,8 @@ pub fn merge( .context("Merging modified files")?; for removed in &diff.removed { - // Use symlink_metadata_optional so that symlinks that resolve to a path - // outside the new_etc_fd don't get followed + // Use symlink_metadata (lstat) so we don't follow absolute symlinks out + // of the cap-std sandbox (e.g. /etc/ssl/cert.pem → /etc/pki/…). let stat = new_etc_fd.symlink_metadata_optional(&removed)?; let Some(stat) = stat else { @@ -1291,11 +1313,14 @@ mod tests { let merge_res = merge(&c, ¤t_etc_files, &n, &new_etc_files.unwrap(), &diff); - assert!(merge_res.is_err()); - assert_eq!( - merge_res.unwrap_err().root_cause().to_string(), - "Modified config file \"file-to-dir\" newly defaults to directory. Cannot merge" + // The image's directory wins over the host's modified file; merge succeeds with a warning. + assert!( + merge_res.is_ok(), + "Expected merge to succeed: {:?}", + merge_res ); + // The directory should still exist in new_etc (image's directory wins) + assert!(n.metadata("file-to-dir").unwrap().is_dir()); Ok(()) } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4a8ccea630..bc573b8262 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -327,6 +327,10 @@ 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 and write a GRUB rollback entry, and `--merge-etc` to carry + /// forward `/etc` customisations via a 3-way merge. ToExistingRoot(crate::install::InstallToExistingRootOpts), /// Nondestructively create a fresh installation state inside an existing bootc system. /// @@ -2246,6 +2250,7 @@ async fn run_from_opt(opt: Opt) -> Result { InstallOpts::ToFilesystem(opts) => { crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip) .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 c82d857f7e..a12a4f5cf4 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -144,6 +144,7 @@ mod aleph; pub(crate) mod baseline; pub(crate) mod completion; pub(crate) mod config; +pub(crate) mod migrate; mod osbuild; pub(crate) mod osconfig; @@ -549,6 +550,52 @@ pub(crate) struct InstallToExistingRootOpts { #[clap(flatten)] pub(crate) composefs_opts: InstallComposefsOpts, + + /// Preserve the running system's `/var` data into the new bootc deployment. + /// + /// After a plain `bootc install to-existing-root`, the new deployment's + /// `/var` is initially empty (ostree bind-mounts it from a fresh directory). + /// Passing this flag performs the following additional steps **after** the + /// core install completes: + /// + /// 1. `/var` content is copied into the new deployment: + /// - **Reflink copy** (btrfs / XFS): `cp --reflink=always` performs an + /// instantaneous copy-on-write clone — no extra disk space consumed. + /// - **Full copy** (filesystems without reflinks): data is copied into + /// the deployment's writable `/var` directory. + /// 2. Paths listed with `--preserve-var-skip` (or in install configuration) + /// are excluded. Rollback preservation is intentionally independent. + /// + /// The running system's `root_path` must be mounted (e.g. `-v /:/target`). + #[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, + + /// Merge the running system's `/etc` customisations into the new deployment. + /// + /// Plain `bootc install to-existing-root` populates the new deployment's + /// `/etc` directly from the image. The running admin's customisations + /// (NIC profiles, SSH host keys, secrets, custom CA certificates, etc.) + /// remain at `/etc` but are not applied to the new deployment. + /// + /// Passing this flag runs a 3-way merge using the `etc-merge` algorithm + /// after the core install completes: + /// + /// A (pristine baseline) = `/usr/etc` — image's shipped defaults + /// B (current live) = `/etc` — running system's `/etc` + /// C (new deployment) = `/etc` — deploy target + /// + /// The diff A→B captures every file the admin changed relative to the image + /// defaults and applies those changes onto C. This is the same algorithm + /// bootc uses during `bootc upgrade`, applied at install time rather than + /// only at upgrade time. + /// + /// The running system's `root_path` must be mounted (e.g. `-v /:/target`). + #[clap(long)] + pub(crate) merge_etc: bool, } #[derive(Debug, clap::Parser, PartialEq, Eq)] @@ -1843,7 +1890,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()?; @@ -1910,7 +1957,7 @@ async fn install_with_sysroot( } } - Ok(()) + Ok(camino::Utf8PathBuf::from(deployment_path.to_string())) } enum BoundImages { @@ -1949,7 +1996,11 @@ impl BoundImages { } } -async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> { +async fn ostree_install( + state: &State, + rootfs: &RootSetup, + cleanup: Cleanup, +) -> Result { // We verify this upfront because it's currently required by bootupd let boot_uuid = rootfs .get_boot_uuid()? @@ -1961,10 +2012,10 @@ async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> // 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, @@ -1987,19 +2038,20 @@ async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> // We must drop the sysroot here in order to close any open file // descriptors. + deployment_path }; // Run this on every install as the penultimate step install_finalize(&rootfs.physical_root_path).await?; - Ok(()) + Ok(deployment_path) } async fn install_to_filesystem_impl( state: &State, rootfs: &mut RootSetup, cleanup: Cleanup, -) -> Result<()> { +) -> Result> { if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.extend(&Cmdline::from("selinux=0")); } @@ -2021,7 +2073,7 @@ async fn install_to_filesystem_impl( } } - if state.composefs_options.composefs_backend { + let deployment_path = if state.composefs_options.composefs_backend { // Pre-flight disk space check for native composefs install path. { let imgref = &state.source.imageref; @@ -2074,8 +2126,9 @@ async fn install_to_filesystem_impl( ) .context("SELinux labeling of composefs objects")?; } + None } else { - ostree_install(state, rootfs, cleanup).await?; + let deployment_path = ostree_install(state, rootfs, cleanup).await?; // For s390x, we set zipl as the bootloader // this needs to be done after the ostree commit is deployed, @@ -2094,7 +2147,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 @@ -2116,7 +2170,7 @@ async fn install_to_filesystem_impl( } } - Ok(()) + Ok(deployment_path) } fn installation_complete() { @@ -2454,7 +2508,7 @@ pub(crate) async fn install_to_filesystem( opts: InstallToFilesystemOpts, targeting_host_root: bool, cleanup: Cleanup, -) -> Result<()> { +) -> Result> { // Log the installation operation to systemd journal const INSTALL_FILESYSTEM_JOURNAL_ID: &str = "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3"; let source_image = opts @@ -2725,14 +2779,14 @@ pub(crate) async fn install_to_filesystem( skip_finalize, }; - install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; + let deployment_path = install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?; // Drop all data about the root except the path to ensure any file descriptors etc. are closed. drop(rootfs); installation_complete(); - Ok(()) + Ok(deployment_path) } pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> Result<()> { @@ -2765,7 +2819,22 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> false => Cleanup::Skip, }; - let opts = InstallToFilesystemOpts { + // Extract migration flags before opts is consumed. + let preserve_var = opts.preserve_var; + let merge_etc = opts.merge_etc; + 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)?; + if (preserve_var || merge_etc) && opts.composefs_opts.composefs_backend { + anyhow::bail!("--preserve-var and --merge-etc require the ostree backend"); + } + + let fs_opts = InstallToFilesystemOpts { filesystem_opts: InstallTargetFilesystemOpts { root_path: opts.root_path, root_mount_spec: None, @@ -2780,7 +2849,42 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> composefs_opts: opts.composefs_opts, }; - install_to_filesystem(opts, true, cleanup).await + let deployment_path = install_to_filesystem(fs_opts, true, cleanup).await?; + + // Post-install migration steps (run after the ostree deploy is complete). + 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 = if root_path.join("sysroot/ostree").exists() { + root_path.join("sysroot") + } else { + root_path.clone() + }; + 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 merge_etc { + println!(); + println!("Merging running /etc into new deployment..."); + let deployment_path = deployment_path + .as_ref() + .context("Install did not produce an ostree deployment")?; + let physical_root = if root_path.join("sysroot/ostree").exists() { + root_path.join("sysroot") + } else { + root_path.clone() + }; + migrate::merge_etc_into_deployment(&root_path, &physical_root.join(deployment_path)) + .context("Post-install /etc merge")?; + } + + Ok(()) } /// Read the /boot entry from /etc/fstab, if it exists diff --git a/crates/lib/src/install/config.rs b/crates/lib/src/install/config.rs index 7f123e45f2..1dea85a2d6 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) diff --git a/crates/lib/src/install/migrate.rs b/crates/lib/src/install/migrate.rs new file mode 100644 index 0000000000..e07c4190a4 --- /dev/null +++ b/crates/lib/src/install/migrate.rs @@ -0,0 +1,395 @@ +//! # 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. +//! +//! These steps are triggered by passing `--preserve-var` and/or `--merge-etc` +//! to `bootc install to-existing-root`. They run **after** the core install +//! (ostree deploy + bootupd) completes but **before** the first reboot, while +//! the package-mode environment is still the running OS. +//! +//! ## `/var` preservation (`--preserve-var`) +//! +//! After a plain `bootc install to-existing-root`, the new deployment's `/var` +//! is bound from `/ostree/deploy//var/` — an initially-empty +//! directory. The old package-mode `/var` is stranded at `/var` +//! (inside the container, the host root is mounted at `root_path`). +//! +//! Two strategies are tried in order: +//! +//! - **Strategy C — reflink copy** (btrfs / XFS with reflinks): `cp --reflink=always` +//! performs an instantaneous copy-on-write clone. No extra disk space is used +//! until data diverges. +//! +//! - **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`). +//! +//! Exclusions such as `tmp`, `log/journal`, `lib/containers`, or `lib/rpm` +//! can be supplied through configuration or repeated `--preserve-var-skip` +//! options. +//! +//! ## `/etc` merge (`--merge-etc`) +//! +//! A plain `bootc install to-existing-root` populates the new deployment's `/etc` +//! from the image. The running system's admin customisations (NIC profiles, SSH +//! host keys, secrets, etc.) end up at `/etc` but are not applied to +//! the new `/etc`. +//! +//! `--merge-etc` runs the 3-way merge from the `etc-merge` crate at install time: +//! +//! | Input | Source | +//! |-------|--------| +//! | A — pristine baseline | `/usr/etc` (image's shipped defaults) | +//! | B — current live | `/etc` (running admin customisations) | +//! | C — new deployment | `/etc` (deploy target, written by image) | +//! +//! The diff A→B captures everything the admin changed relative to the image +//! defaults and applies those changes onto C. Machine-specific files (SSH host +//! keys, machine-id, NIC profiles) are included because they are precisely what +//! needs to be transferred to make the migrated system functional. +//! +//! Rollback preservation is intentionally independent from `/var` migration. + +use std::path::{Component, Path}; +use std::process::Stdio; + +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::ambient_authority; +use cap_std_ext::cap_std::fs::Dir as CapStdDir; +use composefs_ctl::composefs::generic_tree::{FileSystem, Stat}; +use etc_merge::{compute_diff, merge, traverse_etc}; +use fn_error_context::context; + +// ── Top-level entry points ──────────────────────────────────────────────────── + +/// Run the 3-way `/etc` merge on the new deployment. +/// +/// Inputs: +/// - **A (pristine)** = `/usr/etc` +/// - **B (current)** = `/etc` +/// - **C (new)** = `/etc` +/// +/// `root_path` is the host root as seen from inside the install container. +#[context("Running 3-way /etc merge into new deployment")] +pub(crate) fn merge_etc_into_deployment(root_path: &Path, deploy_dir: &Path) -> Result<()> { + let deploy_usr_etc = deploy_dir.join("usr/etc"); + let deploy_etc = deploy_dir.join("etc"); + let host_etc = root_path.join("etc"); + + merge_etc(&host_etc, &deploy_usr_etc, &deploy_etc) +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// 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<()> { + std::fs::create_dir_all(new_var).with_context(|| format!("Creating {}", new_var.display()))?; + + let mut effective_exclusions = ["tmp", "cache", "log/journal", "lib/containers"] + .into_iter() + .map(String::from) + .collect::>(); + effective_exclusions.extend(exclusions.iter().cloned()); + + if reflinks_supported(src_var, new_var) { + println!(" Filesystem supports reflinks — using copy-on-write clone (Strategy C)"); + preserve_var_reflink(src_var, new_var, &effective_exclusions) + } else { + println!(" Filesystem does not support reflinks — falling back to full copy (Strategy D)"); + preserve_var_copy(src_var, new_var, &effective_exclusions) + } +} + +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")) +} + +/// Returns true if the filesystem hosting `new_var` supports reflinks. +/// +/// Probes by attempting a zero-byte reflink from `src_var` into `new_var`. +fn reflinks_supported(src_var: &Path, new_var: &Path) -> bool { + let probe_src = src_var.join(".bootc-reflink-probe-src"); + let probe_dst = new_var.join(".bootc-reflink-probe"); + + let _ = std::fs::write(&probe_src, b"probe"); + let result = std::process::Command::new("cp") + .args(["--reflink=always", "-a"]) + .arg(&probe_src) + .arg(&probe_dst) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + let _ = std::fs::remove_file(&probe_src); + let _ = std::fs::remove_file(&probe_dst); + result +} + +/// Strategy C: reflink-copy each top-level entry under `src_var` into `new_var`. +/// +/// Skips well-known ephemeral subdirectories. +#[context("Reflink-copying /var into new deployment (Strategy C)")] +fn preserve_var_reflink(src_var: &Path, new_var: &Path, exclusions: &[String]) -> Result<()> { + let entries = + std::fs::read_dir(src_var).with_context(|| format!("Reading {}", src_var.display()))?; + + for entry in entries { + let entry = entry.with_context(|| format!("Reading entry in {}", src_var.display()))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if exclusions.iter().any(|s| s == name_str.as_ref()) { + println!( + " Skipping {} (ephemeral)", + src_var.join(name_str.as_ref()).display() + ); + continue; + } + + let src_entry = src_var.join(name_str.as_ref()); + let dst_entry = new_var.join(name_str.as_ref()); + + if let Some(skip) = exclusions.iter().find_map(|path| { + path.strip_prefix(&format!("{name_str}/")) + .filter(|rest| !rest.contains('/')) + }) { + copy_dir_skip_subdir(&src_entry, &dst_entry, skip, true)?; + continue; + } + + println!( + " Reflink-copying {} → {}", + src_entry.display(), + dst_entry.display() + ); + let status = std::process::Command::new("cp") + .args(["--reflink=always", "-a", "--no-clobber"]) + .arg(&src_entry) + .arg(new_var) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("cp --reflink=always {}", src_entry.display()))?; + + anyhow::ensure!( + status.success(), + "cp --reflink=always failed for {}", + src_entry.display() + ); + } + + println!(" /var reflink copy complete."); + Ok(()) +} + +/// Copy a directory recursively, skipping one named subdirectory. +/// +/// Used by both Strategy C and Strategy D to copy `var/log/` while excluding +/// `var/log/journal/`. `reflink` selects whether `cp --reflink=always` or +/// plain `cp -a` is used. +fn copy_dir_skip_subdir(src: &Path, dst: &Path, skip_name: &str, reflink: bool) -> Result<()> { + std::fs::create_dir_all(dst).with_context(|| format!("Creating {}", dst.display()))?; + + let entries = std::fs::read_dir(src).with_context(|| format!("Reading {}", src.display()))?; + + for entry in entries { + let entry = entry.with_context(|| format!("Reading entry in {}", src.display()))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str == skip_name { + println!(" Skipping {}/{} (ephemeral)", src.display(), name_str); + continue; + } + + let src_entry = src.join(name_str.as_ref()); + let mut cmd = std::process::Command::new("cp"); + if reflink { + cmd.args(["--reflink=always", "-a", "--no-clobber"]); + } else { + cmd.args(["-a", "--no-clobber"]); + } + let status = cmd + .arg(&src_entry) + .arg(dst) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("cp -a {}", src_entry.display()))?; + + anyhow::ensure!(status.success(), "cp failed for {}", src_entry.display()); + } + + 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) so that the copy is +/// instantaneous and atomic from the kernel's perspective. +/// +/// A future improvement would be to accept a user-supplied exclusion list so +/// that specific high-risk directories (e.g. `/var/lib/pgsql`) can be skipped +/// and migrated manually. For now, operators are responsible for stopping +/// affected services before running `bootc install to-existing-root --preserve-var` +/// on ext4 (or other non-reflink) filesystems. +/// +/// See: +#[context("Copying /var into new deployment (Strategy D)")] +fn preserve_var_copy(src_var: &Path, new_var: &Path, exclusions: &[String]) -> Result<()> { + let entries = + std::fs::read_dir(src_var).with_context(|| format!("Reading {}", src_var.display()))?; + + for entry in entries { + let entry = entry.with_context(|| format!("Reading entry in {}", src_var.display()))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if exclusions.iter().any(|s| s == name_str.as_ref()) { + println!( + " Skipping {} (ephemeral)", + src_var.join(name_str.as_ref()).display() + ); + continue; + } + + let src_entry = src_var.join(name_str.as_ref()); + let dst_entry = new_var.join(name_str.as_ref()); + + if let Some(skip) = exclusions.iter().find_map(|path| { + path.strip_prefix(&format!("{name_str}/")) + .filter(|rest| !rest.contains('/')) + }) { + copy_dir_skip_subdir(&src_entry, &dst_entry, skip, false)?; + continue; + } + + println!( + " Copying {} → {}", + src_entry.display(), + dst_entry.display() + ); + let status = std::process::Command::new("cp") + .args(["-a", "--no-clobber"]) + .arg(&src_entry) + .arg(new_var) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("cp -a {}", src_entry.display()))?; + + anyhow::ensure!(status.success(), "cp -a failed for {}", src_entry.display()); + } + + println!(" /var copy complete."); + Ok(()) +} + +/// 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() +} + +/// 3-way `/etc` merge. +/// +/// - `host_etc` = `/etc` (running system, input B) +/// - `deploy_usr_etc` = `/usr/etc` (image defaults, input A) +/// - `deploy_etc` = `/etc` (new deploy target, input C) +#[context("Merging running /etc into new deployment")] +fn merge_etc(host_etc: &Path, deploy_usr_etc: &Path, deploy_etc: &Path) -> Result<()> { + let pristine_fd = CapStdDir::open_ambient_dir(deploy_usr_etc, ambient_authority()) + .with_context(|| format!("Opening pristine etc: {}", deploy_usr_etc.display()))?; + let current_fd = CapStdDir::open_ambient_dir(host_etc, ambient_authority()) + .with_context(|| format!("Opening running etc: {}", host_etc.display()))?; + let new_fd = CapStdDir::open_ambient_dir(deploy_etc, ambient_authority()) + .with_context(|| format!("Opening deploy etc: {}", deploy_etc.display()))?; + + let (pristine_tree, current_tree, new_tree_opt) = + traverse_etc(&pristine_fd, ¤t_fd, Some(&new_fd)) + .context("Traversing /etc trees for 3-way merge")?; + + let new_tree = new_tree_opt.unwrap_or_else(|| FileSystem::new(Stat::uninitialized())); + + let diff = + compute_diff(&pristine_tree, ¤t_tree, &new_tree).context("Computing /etc diff")?; + + println!(" /etc diff (changes being applied from running system):"); + etc_merge::print_diff(&diff, &mut std::io::stdout()); + + merge(¤t_fd, ¤t_tree, &new_fd, &new_tree, &diff) + .context("Applying /etc 3-way merge")?; + + println!(" /etc merge complete."); + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exclusions_are_relative_and_normalized() { + assert_eq!( + validate_exclusions(&["lib/rpm/".into()]).unwrap(), + ["lib/rpm"] + ); + assert!(validate_exclusions(&["../etc".into()]).is_err()); + assert!(validate_exclusions(&["/etc".into()]).is_err()); + assert!(validate_exclusions(&["lib/containers/storage".into()]).is_err()); + } + + #[test] + fn deployment_var_is_stateroot_var() { + let deploy = Path::new("/sysroot/ostree/deploy/default/deploy/checksum.0"); + assert_eq!( + deployment_var_path(deploy).unwrap(), + Path::new("/sysroot/ostree/deploy/default/var") + ); + } +}