Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions crates/etc-merge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,11 @@ 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
Expand All @@ -800,9 +804,23 @@ fn merge_leaf(
// 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
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 a path component in the current /etc leads through an absolute symlink that
// escapes the cap-std root (e.g. /etc/alternatives symlinks), cap-std raises
// "a path led outside of the filesystem". Warn and skip rather than aborting the
// whole merge; the image's existing content wins for that path.
if let Err(ref e) = copy_result {
if e.to_string().contains("a path led outside of the filesystem") {
tracing::warn!(
"Skipping {file:?}: path escapes /etc sandbox (absolute symlink component); \
keeping image's version"
);
return Ok(());
}
}
copy_result?;
};

rustix::fs::chownat(
Expand Down Expand Up @@ -916,8 +934,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 {
Expand Down Expand Up @@ -1291,11 +1309,10 @@ mod tests {

let merge_res = merge(&c, &current_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(())
}
Expand Down
4 changes: 4 additions & 0 deletions crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,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.
///
Expand Down
85 changes: 83 additions & 2 deletions crates/lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -549,6 +550,51 @@ 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. The running kernel and initramfs are saved to
/// `<root>/var/lib/pkgmode-rollback/` BEFORE `/boot` is wiped.
/// 2. `/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.
/// - **Bind-mount unit** (ext4 fallback): a `var.mount` systemd unit is
/// injected into the new deployment's `etc/`, causing the first boot
/// to bind-mount the old `/var` from `/sysroot/var`.
/// 3. A "Previous OS" BLS boot entry is written so GRUB presents a
/// package-mode rollback option (sort-key `zz-pkgmode`, last in menu).
///
/// The running system's `root_path` must be mounted (e.g. `-v /:/target`).
#[clap(long)]
pub(crate) preserve_var: bool,

/// 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 `<root>/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) = `<deploy>/usr/etc` — image's shipped defaults
/// B (current live) = `<root>/etc` — running system's `/etc`
/// C (new deployment) = `<deploy>/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)]
Expand Down Expand Up @@ -2765,7 +2811,24 @@ 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());

// Phase 0 (migration only): save the running kernel/initramfs BEFORE
// clean_boot_directories() wipes /boot inside install_to_filesystem.
let kver = if preserve_var {
println!();
println!("Saving running kernel and initramfs for package-mode rollback...");
let kver = migrate::save_pkgmode_kernel(&root_path)
.context("Saving package-mode kernel before /boot wipe")?;
Some(kver)
} else {
None
};

let fs_opts = InstallToFilesystemOpts {
filesystem_opts: InstallTargetFilesystemOpts {
root_path: opts.root_path,
root_mount_spec: None,
Expand All @@ -2780,7 +2843,25 @@ 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(fs_opts, true, cleanup).await?;

// Post-install migration steps (run after the ostree deploy is complete).
if preserve_var {
println!();
println!("Preserving /var and writing rollback BLS entry...");
let kver = kver.as_deref().unwrap();
migrate::preserve_var_and_write_rollback(&root_path, kver)
.context("Post-install /var preservation")?;
}

if merge_etc {
println!();
println!("Merging running /etc into new deployment...");
migrate::merge_etc_into_deployment(&root_path)
.context("Post-install /etc merge")?;
}

Ok(())
}

/// Read the /boot entry from /etc/fstab, if it exists
Expand Down
Loading
Loading