Skip to content

install: Add --preserve-var and --merge-etc to to-existing-root - #2384

Draft
ckyrouac wants to merge 6 commits into
bootc-dev:mainfrom
ckyrouac:image-to-package
Draft

ckyrouac wants to merge 6 commits into
bootc-dev:mainfrom
ckyrouac:image-to-package

Conversation

@ckyrouac

Copy link
Copy Markdown
Collaborator

This is AI generated with lots of guidance.


Add two flags to bootc install to-existing-root that are most useful
when migrating a package-mode system to a bootc image built externally:

--preserve-var Copy the running system's /var data into the new
deployment, and write a package-mode GRUB rollback
boot entry.

--merge-etc Apply the running system's /etc customisations onto
the new deployment via a 3-way merge.

Typical usage:

podman run --rm --privileged --pid=host --user=root:root
-v /dev:/dev --security-opt label=type:unconfined_t
-v /:/target -v /var/lib/containers:/var/lib/containers

bootc install to-existing-root
--preserve-var
--merge-etc
--acknowledge-destructive

@bootc-bot
bootc-bot Bot requested a review from cgwalters August 17, 2026 16:06
@github-actions github-actions Bot added the area/install Issues related to `bootc install` label Aug 17, 2026
Comment thread crates/etc-merge/src/lib.rs Outdated
// "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") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid string matching errors, we can use downcasts

But perhaps better we should be using https://docs.rs/cap-std-ext/5.1.2/cap_std_ext/struct.RootDir.html ? And yes we'd need a copy api there.

Perhaps simplest to open the parent dir rooted, then do a copy that way

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status at e04ef8a: the string match is gone, but merge_leaf still uses cap-std Dir::copy and not RootDir, so a host path through an absolute symlink now fails the merge (after install) instead of being skipped. The /etc/ssl/certs failure the test works around is a different case (host symlink, image directory).

Generated-by: https://github.com/jmarrero-bot#llms

Comment thread crates/lib/src/install/migrate.rs Outdated
Comment on lines +56 to +61
//! When `--preserve-var` is passed, the running kernel and initramfs are saved
//! to `<root_path>/var/lib/pkgmode-rollback/` **before** the install wipes
//! `/boot`. After the install a third BLS entry (`pkgmode-rollback.conf`) is
//! written into the active `loader.N/entries/` directory so GRUB presents a
//! "Previous OS" option. The entry is invisible to ostree (which only reads
//! `ostree-*.conf` files) and to bootupd (which does not touch `loader/entries/`).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of hacky, I think it'd be cleaner to just not delete the entries (and kernel/initramfs) at all which should be a separate option.

Comment thread crates/lib/src/install/migrate.rs Outdated
.context("Locating new ostree deployment directory")?;
println!(" Deployment directory: {}", deploy_dir.display());

// The deployment's var/ is two levels up from the deploy dir:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's APIs for this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed: the deployment path now comes from install_with_sysroot. The physical root is still guessed with root_path.join("sysroot/ostree").exists() (install.rs:3075, 3092), and deployment_var_path() walks two parents up from the deploy dir instead of using the deployment's stateroot.

Generated-by: https://github.com/jmarrero-bot#llms

Comment thread crates/lib/src/install/migrate.rs Outdated
/// We enumerate `<root_path>/ostree/deploy/` and return the most recently
/// modified entry under each stateroot's `deploy/` subdirectory.
#[context("Locating new ostree deployment directory")]
fn find_deploy_dir(root_path: &Path) -> Result<PathBuf> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's APIs for htis too

Comment thread crates/lib/src/install/migrate.rs Outdated
/// `root_path` is the host root as seen from inside the install container.
/// `kver` is the running kernel version string returned by `save_pkgmode_kernel`.
#[context("Running post-install /var preservation and rollback entry")]
pub(crate) fn preserve_var_and_write_rollback(root_path: &Path, kver: &str) -> Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general let's try to use cap-std

Comment thread crates/lib/src/install/migrate.rs Outdated
/// 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our storage code already probes this I believe

Comment thread crates/lib/src/install/migrate.rs Outdated
let src_entry = src_var.join(name_str.as_ref());
let dst_entry = new_var.join(name_str.as_ref());

// For `log/`, skip only the `journal` subdirectory (large; regenerated by journald).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this special cased?

Comment thread crates/lib/src/install/migrate.rs Outdated
continue;
}

// For `lib/`, skip `containers` (podman/docker storage — overlay mounts cannot

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto, I don't think we should have special cases buried in this code. We should make it configurable of course in general, via e.g. --preserve-var-skip=log/,lib/containers or something

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open at e04ef8a: --preserve-var-skip only adds exclusions, and tmp, cache, log/journal and lib/containers stay hardcoded in preserve_var() (migrate.rs:108) with no way to turn them off. lib/containers includes podman named volumes: in a VM run the copied /var had no lib/containers, so a volume created before migrating was missing from the copy (it only showed up after reboot because the carried fstab remounted the original /var).

Generated-by: https://github.com/jmarrero-bot#llms

@cgwalters

Copy link
Copy Markdown
Collaborator

In a live chat about this the problem that /var/lib/rpm may exist came up - thanks @marrusl .

We should support a config file for this functionality (or reuse existing bootc install config) that supports exclusions e.g.

@ckyrouac ckyrouac added the ci/tier-1 Run CI for tier-1 OS (centos-10) only label Sep 22, 2026
@github-actions github-actions Bot added the area/documentation Updates to the documentation label Sep 30, 2026
@cgwalters

Copy link
Copy Markdown
Collaborator

@ckyrouac Do you think this'll be ready for 1.18?

@ckyrouac

ckyrouac commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@ckyrouac Do you think this'll be ready for 1.18?

yea it should be. I'll add it to 1.18.

@ckyrouac ckyrouac added this to the 1.18 milestone Oct 6, 2026
@jmarrero
jmarrero self-requested a review October 9, 2026 15:48
Preserve existing-root data while providing a clear, reliable /etc merge
path for migration.

Assisted-by: AI
Signed-off-by: ckyrouac <ckyrouac@redhat.com>
Use directory-capability checks to preserve /var efficiently across
filesystems and document migration exclusions.

Assisted-by: AI
Signed-off-by: ckyrouac <ckyrouac@redhat.com>
Exercise package-mode migration flags through a focused TMT path with
predictable Fedora defaults.

Assisted-by: AI
Signed-off-by: ckyrouac <ckyrouac@redhat.com>
Make package-mode TMT runs work locally and in Packit by consistently
resolving artifacts, playbooks, and workdirs.

Assisted-by: AI
Signed-off-by: ckyrouac <ckyrouac@redhat.com>
Stream package-mode image transfers and make staging, cleanup, and
version validation reliable across test execution.

Assisted-by: AI
Signed-off-by: ckyrouac <ckyrouac@redhat.com>
Prepare the target's boot filesystem and image state required by the
package-mode boot test.

Assisted-by: AI
Signed-off-by: ckyrouac <ckyrouac@redhat.com>

@jmarrero-bot jmarrero-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are findings from @jmarrero's review that aren't already raised in the existing threads.

Reproduced in VMs: Fedora Cloud 44 package-mode hosts (btrfs, UEFI, SELinux enforcing) with a local user and some /etc edits, migrated with --preserve-var --merge-etc to a fedora-bootc:44 image built from e04ef8a. The image adds a user and group, installs an SELinux module, and edits /etc/pam.d/login and /etc/ssh/sshd_config. Points marked (code) come from reading the code; the rest were reproduced.

Blockers

  1. --merge-etc uses the new image's /usr/etc as the pristine baseline, so every difference between the host and the image is applied as if it were an admin edit (inline comments below). After reboot:

    • the image's user and group are gone from /etc/passwd and /etc/group (getent finds neither);
    • /etc/pam.d/login, /etc/ssh/sshd_config, crypto-policies/config, selinux/semanage.conf and selinux/targeted/policy/policy.35 are the host's copies. The image's SELinux module isn't in the loaded policy (chcon -t with its type fails);
    • ostree admin config-diff lists 257 entries, including M for passwd, group, shadow, pam.d/login, ssh/sshd_config and the compiled policy, so later image updates to those files should no longer apply (from ostree's merge rules; no upgrade was run).

    The test's rm -rf /etc/ssl/certs (package-mode.fmf:111) hides one such case. Without it the merge fails on ssl/certs (a symlink on the host, a directory in the image); see 2.

  2. Both migrations run after the install has finished. The log goes bootupd, Installation complete!, then Preserving /var.... By then /boot has only the new ostree entry, so a failure leaves no package-mode entry to go back to. In the VMs:

    • Without the ssl/certs workaround, the merge failed partway. passwd, group, pam, sshd and the policy were already the host's, but subuid/subgid were still the image's (the local user is in passwd, not in subuid). The VM then rebooted into that half-merged deployment.
    • With /var/log on a separate XFS filesystem, /var preservation failed mid-copy (nis, opt and spool weren't copied, and /etc wasn't merged).
    • merge_etc never checks diff.unmergable_paths. The diff printed Unmergable File 'ssl/certs' now defaults to a directory in new etc and the merge went ahead anyway. The composefs update path checks it first (update.rs:326).
    • With a composefs-native image (setup-root-conf.toml, no prepare-root.conf) and no --composefs-backend, install picked composefs itself (install.rs:1866), wiped /boot, printed Installation complete! and then failed with Install did not produce an ostree deployment.
    • Not from this PR's code, but the same ordering: on an earlier attempt, with the image's logically bound images missing from host storage, to-existing-root wiped /boot (only efi/ was left) before failing on that check, leaving no boot entry. Every check that can fail should run before /boot is touched.
  3. (code) The GRUB rollback entry was removed, but the PR description and cli.rs:351 still say --preserve-var writes one.

Should fix

  • The reflink probe only checks the top-level /var pair. With /var/log on XFS, cp --reflink=always failed with Invalid cross-device link. In the same VM, cp -a --reflink=auto copied that /var/log and still cloned on btrfs (btrfs filesystem du shows 0 exclusive). That would remove reflink.rs and one of the two copy loops. Plain cp -a already defaults to reflink=auto in coreutils 9.
  • A separate /var gets copied but not used. Fedora Cloud mounts /var as a btrfs subvolume. With --merge-etc the host fstab comes along (A fstab), so after reboot /var is the original subvolume mounted over the deployment's var, and the copy is hidden under /sysroot/ostree/deploy/default/var. cp -a also descends into other mounts under /var (it tried to copy the XFS /var/log above).
  • The copy_dir_skip_subdir path recreates /var/lib and /var/log without their metadata. In the copy, /var/lib was 0755 var_t (host: 0711 var_lib_t) and /var/log was var_t (host: var_log_t).
  • /root and /home aren't migrated. A file in /root was gone after reboot (/root is /var/roothome). /home only came back because the carried fstab mounts the home subvolume; on a host without a separate /home nothing would copy it (code: only /var is copied). The test works around /root by copying authorized_keys into /var/roothome by hand.

Tests and PR structure

  • About 930 lines of test infrastructure, 507 of them in five Ansible playbooks, move one image archive from a harness VM to the target. Building the image on the target VM would remove most of that.
  • Commit 2 rewrites about a third of commit 1's migrate.rs (122 lines removed, 420 added) and adds reflink.rs. Commits 4 to 6 rework the plan from commit 3 (for example, copy-test-image.yml is added in 3 and deleted in 5). Suggest splitting: etc-merge prep, /var preservation, /etc merge (after a design discussion), and the test infrastructure as its own PR.

The 7 etc-merge and 13 migrate/reflink unit tests pass at e04ef8a (SELinux-enabled build); none of the above is covered by them.

Generated-by: https://github.com/jmarrero-bot#llms

Comment thread crates/lib/src/install.rs
/// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't what bootc upgrade does on ostree: there libostree does the merge, and etc-merge is only used by the composefs backend, whose baseline is the booted deployment's /etc. Here the new image's /usr/etc stands in for a baseline the host never had (Blocker 1).

Comment thread crates/lib/src/install.rs
}
}
let preserve_var_skip = migrate::validate_exclusions(&preserve_var_skip)?;
if (preserve_var || merge_etc) && opts.composefs_opts.composefs_backend {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only sees the CLI flag. install.rs:1866 can switch to the composefs backend later (UKI or composefs-native image). In the VM that wiped /boot, printed Installation complete! and then failed with Install did not produce an ostree deployment.

Comment thread crates/lib/src/install.rs
};

install_to_filesystem(opts, true, cleanup).await
let deployment_path = install_to_filesystem(fs_opts, true, cleanup).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both migrations run after this returns, so after /boot is wiped and bootupd has run. Reproduced a half-merged /etc and a partial /var, each left as the only boot entry (Blocker 2).


let new_tree = new_tree_opt.unwrap_or_else(|| FileSystem::new(Stat::uninitialized()));

let diff = compute_diff_without_deletions(&pristine_tree, &current_tree, &new_tree)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diff.unmergable_paths isn't checked before merge(). In the VM it held ssl/certs and the merge failed partway. update.rs:326 checks it first.

.collect::<Vec<_>>();
effective_exclusions.extend(exclusions.iter().cloned());

if supports_reflink(&src_var, &new_var)? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The probe covers only the top-level /var pair. With /var/log on XFS this failed with Invalid cross-device link; cp -a --reflink=auto handled the same tree and still cloned on btrfs.

) -> Result<()> {
let src = open_child_dir_nofollow(src_root, dir_name)
.with_context(|| format!("Opening /var/{}", dir_name.to_string_lossy()))?;
let dst = open_or_create_child_dir(dst_root, dir_name)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The directory is created fresh, so its mode and label are lost: in the VM copy /var/lib was 0755 var_t (host 0711 var_lib_t), /var/log var_t (host var_log_t).

Comment thread crates/lib/src/cli.rs
/// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No GRUB rollback entry is written anymore; this line and the PR description still say so.

cat > "$workdir/Containerfile" <<'EOF'
FROM localhost/bootc
# Fedora Cloud and fedora-bootc have different /etc/ssl/certs baselines.
RUN rm -rf /etc/ssl/certs && \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this the merge fails on Fedora Cloud 44: the host's /etc/ssl/certs is a symlink to /etc/pki/tls/certs and the image has a directory. A real migration hits it too.

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works around /root not being migrated: without it root's keys and files in /root are gone after reboot (reproduced with a marker file).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/documentation Updates to the documentation area/install Issues related to `bootc install` ci/tier-1 Run CI for tier-1 OS (centos-10) only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants