Home / News / What is an image mode 3-way merge?

What is an image mode 3-way merge?

When Red Hat Enterprise Linux (RHEL) is running in image mode and you want to make a change to its filesystem, you create a new image containing your changes. The system configurations that are different from the running image are merged to create a new default state of the operating system. A 3-way merge incorporates a third version, older than the current and new image, to minimize merge conflicts.

We typically talk about how filesystems are treated on disk differently in image mode than in package mode because it’s key to how image mode operates. You’ve probably read or heard something like the list below:

  • /usr → image state: Contents of the image will be extracted and overwrite local files
  • /etc → local configuration state: Contents of the image are merged with a preference for local files
  • /var → local state: Contents of the image will be ignored after the initial installation of any image

One of the common questions I hear when talking about image mode updates is “How exactly does image mode decide which updates from the image get applied and what stays the same in /etc during an upgrade?” To get to the answer, we’ll need to talk about some of the internal implementation details of bootc and OSTree, but by the end of this you should have a good basis for predicting how a 3-way merge will happen on a system, and maybe a few tricks to check for yourself. 

What’s in a merge anyway?

In image mode, we say that on image updates or switches, we merge the contents of the new image with the local files, with a preference for changes in local files. This means that if you set the hostname or IP address of the system, then a generic file in the new image won’t reset all your systems to localhost on an upgrade. That makes sense. We set the hostname, after all, and bootc can detect that the two files don’t match. It can detect which “direction” the change was made. But how does it know which one is right?

Because we said we prefer local files, we could just throw out anything in a new image, but that could cause other issues. For example, suppose there’s a file in our new image that doesn’t exist on the local machine. Is this a brand new config that should be added to the new /etc, or was the local file deliberately removed and therefore should be skipped? 

The question of merge direction or correctness is not a new problem. The 2-way versus 3-way merge has been around since diff3 and CVS version control in the late 1980s. A 3-way merge makes automatic merging more reliable by adding, as the term suggests, a third version. Unlike CVS, Git, or other version systems, we aren’t merging files on a line by line basis to create new composite files. We’re looking to avoid merge conflicts that require human intervention, in favor of a predefined way of making these determinations. Comparisons then are simpler modified, added, or deleted files. 

The key to making this work is that the third version must be older than either of the two versions we have on hand.  So we have the local /etc and the new image’s /etc, where do we get that third copy? For that, we turn to OSTree.

Where does /etc live?

You can think of OSTree as an engine for bootc, and it could be summarized as “Git for operating system binaries” (to quote the documentation). This is how we get multiple, parallel installations of independent operating systems on a single image mode host. The everyday operations are managed with bootc, which drives OSTree in the background. OSTree is where our rules for filesystem handling and this 3-way merge implementation comes from.

Each independent operating system on disk is called a deployment, and is essentially a complete filesystem that you could chroot into. Each one is a complete and bootable entity. If you look at the documentation for how OSTree creates a new deployment, you notice that it mentions a directory that’s not a usual part of a Linux distribution: /usr/etc. The new inbound image will have an /etc directory, like normal, but no /usr/etc. You can check this by running ls in podman on a bootc image you have handy. 

In OSTree, this directory is part of what’s known as the defaults or stateroot. On image mode hosts, this new stateroot is created by bootc during an update or switch to the new image..  Because the /usr/etc  directory is a critical part of the merge, and doesn’t exist in our bootc image, bootc maps /etc from the image to the /usr/etc directory in the new on-disk deployment. This is where we get our older copy that forms the third leg of this process.

On a running image mode host, you can look for local changes made to /etc using this diff command:

diff /etc /usr/etc

If it’s available, you can alternately use the ostree command for the OSTree view of changes in the currently booted image:

ostree admin config-diff

A word of warning: Using ostree directly requires root privileges and works below bootc. There are a few options you don’t want to run manually or accidentally. Looking at the local state against the image default is fine, but do be careful with the command.

One important thing about bootc and deployments is their relationship to the bootloader. The bootloader gets pointed directly to the deployment directory, where all of the required artifacts are actually stored on disk.  The filesystem you see when the image mode host is running is really a series of hard and soft links managed at boot. That means that the current local /etc on a running host is also hardlinked to the /etc in the corresponding deployment. When you issue a rollback, bootc tells the bootloader to boot the previous complete deployment, including its version of /etc. The 3-way merge we’re talking about doesn’t apply to rollbacks, only to upgrades or switching.

Bringing it all together

The players are assembled, so how does the scene run? Let’s walk through it.

We run bootc upgrade on an image mode host to pull down a new image. Then bootc stages that image as follows: 

  1. Creates a new OSTree deployment from the image contents
  2. Creates the new /usr/etc defaults for that deployment
  3. Sets a flag to trigger the bootloader update and the /etc merge at next boot

This is important because we don’t want to merge changes on the live system, but they must be available at the next boot for the system to start. Remember that no changes are made to live hosts by bootc, only at reboots. We may also miss local changes made after an image was staged. When we check bootc status, we can see the new deployment listed, and this is what the staged status really means. 

Once we reboot into the new deployment, the merge actually takes place as part of system startup. The 3-way merge combines the new defaults (/usr/etc of the deployment we are booting), the old defaults (/usr/etc of the previous deployment), and the local /etc (hardlinked /etc from the previous deployment) to create a new /etc on disk (Figure 1). There are no complicated state databases or other means in play, just direct comparison of copies of files on disk.

New defaults are compared to local state, using the current defaults to break conflicts, resulting in new local state for /etc.

Figure 1: The 3-way merge.

Exploring changes by hand

To get to our new desired /etc state, we first compare the two defaults, then we compare that against the local /etc.  We can’t really watch how the merge is actually done, but because these are all on disk after staging an update, we can use diff to compare the directories. On a bootc host with an update staged, but not applied, I ran the following sets of diffs. For readability, the staged image filepaths have the environment variable I set to make the command easier for me to type. You can find the location of any deployment on disk by looking for the OSTree checksum for an image in the full output of bootc status

Let’s track some deliberate changes through our comparisons to predict what will happen at reboot. I added an authorized users banner to the message of the day (/etc/motd), deleted /etc/login.defs, and added a default rcfile for tcsh.  Move, add, delete are the basic changes we’d be making to an image, but note that removing login.defs isn’t something you’d normally want to do. I’m just making sure we have a removed file.

Here’s the section I added to the Containerfile to make these changes. It’s not elegant, but it’s functional enough to get the point across:

RUN rm /etc/login.defs
RUN echo "This is a private system. Unauthorized access is prohibited." > /etc/motd
COPY tchshrc /etc/skel/.tcshrc

The first comparison is between the defaults, using the newly staged image as the base. I’m using -q to only display that files differ and not the actual contents, and --no-dereference to skip any symlinks in the defaults that aren’t valid as a result of the copy process.

sudo diff -rq --no-dereference $STAGED_ROOT/usr/etc /usr/etc
Only in /usr/etc: hostname
** Only in /usr/etc: login.defs
** Files STAGED_ROOT/usr/etc/motd and /usr/etc/motd differ
Only in /usr/etc: resolv.conf
Files STAGED_ROOT/usr/etc/shadow and /usr/etc/shadow differ
Files STAGED_ROOT/usr/etc/shadow- and /usr/etc/shadow- differ
** Only in STAGED_ROOT/usr/etc/skel: .tcshrc
Files STAGED_ROOT/usr/etc/yum.repos.d/redhat.repo and /usr/etc/yum.repos.d/redhat.repo differ

For the sake of this article, I’ve placed two asterisks before the changes made through the Containerfile. The other lines are just a result of the build process. Our Containerfile auto-generates a password for a break-glass user, which results in differences between /etc/shadow (because the seed changes).

Now, let’s look at the current defaults and the local state, with the same diff options:

sudo diff -rq --no-dereference /usr/etc /etc
Only in /etc/containers: networks
Only in /etc/issue.d: 22_clhm_enp1s0.issue
Only in /etc: locale.conf
Files /usr/etc/machine-id and /etc/machine-id differ
Files /usr/etc/resolv.conf and /etc/resolv.conf differ
Only in /etc/rhsm/facts: bootc.facts
Only in /etc/ssh: ssh_host_ecdsa_key
Only in /etc/ssh: ssh_host_ecdsa_key.pub
Only in /etc/ssh: ssh_host_ed25519_key
Only in /etc/ssh: ssh_host_ed25519_key.pub
Only in /etc/ssh: ssh_host_rsa_key
Only in /etc/ssh: ssh_host_rsa_key.pub
Only in /etc/tmpfiles.d: bootc-root-ssh.conf
Only in /etc: vconsole.conf

Most of these changes are install-time or runtime changes. The files that we’re tracking don’t show up in this comparison, which means they are the same in the current defaults and in the local state.

What about between the new defaults and the local state?

sudo diff -rq --no-dereference $STAGED_ROOT/usr/etc /etc
Only in /etc/containers: networks
Only in /etc: hostname
Only in /etc/issue.d: 22_clhm_enp1s0.issue
Only in /etc: locale.conf
** Only in /etc: login.defs
Files STAGED_ROOT/usr/etc/machine-id and /etc/machine-id differ
** Files STAGED_ROOT/usr/etc/motd and /etc/motd differ
Only in /etc: resolv.conf
Only in /etc/rhsm/facts: bootc.facts
Files STAGED_ROOT/usr/etc/shadow and /etc/shadow differ
Files STAGED_ROOT/usr/etc/shadow- and /etc/shadow- differ
** Only in STAGED_ROOT/usr/etc/skel: .tcshrc
Only in /etc/ssh: ssh_host_ecdsa_key
Only in /etc/ssh: ssh_host_ecdsa_key.pub
Only in /etc/ssh: ssh_host_ed25519_key
Only in /etc/ssh: ssh_host_ed25519_key.pub
Only in /etc/ssh: ssh_host_rsa_key
Only in /etc/ssh: ssh_host_rsa_key.pub
Only in /etc/tmpfiles.d: bootc-root-ssh.conf
Only in /etc: vconsole.conf
Files STAGED_ROOT/usr/etc/yum.repos.d/redhat.repo and /etc/yum.repos.d/redhat.repo differ

Our new tcshrc shows up as just in the new defaults, we can see there’s a difference between the MOTD files, and that login.defs is only in the new defaults. In a simple (or 2-way) merge, we’d be unsure what updates to make. Should we change the MOTD? Is deleting login.defs the right thing to do? What about /etc/shadow or the machine-id? We know what those changes should be, but we need to make sure this is automated and doesn’t require human intervention at boot.

By adding the current defaults (the state created at build time for the image) to the comparison, we can make our policy based decisions with more predictable outcomes.

Combinations and outcome

Generally, files in the new defaults win, unless there was a local change in /etc.  Changes to /etc get carried along for the life of the host, even if the software installed is radically different as the result of a bootc switch. If there’s a file removed in the new defaults, and no local changes are made, that file is removed.

New defaults

Exiting defaults

Local state

Result

ANY

Exists

Changed

Local kept

Modified

Exists

Exists

New kept

Added

Missing

Missing

Added

Deleted

Exists

Exists

Deleted

Caveats and party tricks

Now I need to reiterate that we’ve dug into internal implementation details here, and not something you should be poking at regularly. Don’t add something to a Containerfile in /usr/etc (to enforce a new default, for instance), or you’ll end up with problems, because this results in a literally undefined behavior. This is an excellent reason to add RUN bootc container lint to the end of every image mode Containerfile. That warns you about potential problems like this one.

Resetting a file to image control

However, you could put a file back under control of image updates and eliminate local modifications by copying a file from the defaults into /etc. Again, this is not something you’ll need regularly, but a good thing to have in your toolbox.

For example, what if you wanted to start controlling admin privileges with a drop-in file in /etc/sudoers.d centrally managed in the image, but had been editing /etc/sudoers locally? An update would have both sets of privileges, the new drop-in file would be created because it wasn’t in the defaults, but the local changes wouldn’t be removed. This risks configuration drift. But because the /etc/sudoers that came with the RPM is in the image defaults, you could copy that over the local modifications and return /etc/sudoers to image control.

Changing baselines on deployed hosts

This can also be used to distribute a simple image controlled configuration change across multiple existing hosts without the need to roll a new image and schedule reboots. 

For example, if the NTP pool needed to be updated fleet wide and in the standard build, you could create and test the new /etc/chrony.conf in the image build. Once that works for new deployments, you can copy that exact file from the build repository to the existing systems. Be sure it’s identical, using something to create it locally might have extra headers not present in the image. For the first merge, this local copy of /etc/chrony.conf would be considered a local modification and kept. However, on the next update, since these two files are now identical according to the updated defaults, control over that file is returned to the image.

3-way merge demystified

In my experience, there’s only a handful of times I’ve needed to think about more than just incoming /etc and local /etc when trying to understand changes that were made (or not made, for that matter). I hope this has been helpful for getting a deeper understanding of just how a 3-way merge happens in the image mode context. This can be crucial when designing your standard images and deciding what to manage with the image, and what to manage on a host by host basis. This also applies when integrating image mode operations into existing automation and configuration management. Being able to split cleanly between build time and run time management helps smooth that integration work.

The post What is an image mode 3-way merge? appeared first on Red Hat Developer.

Tagged:

Leave a Reply

Your email address will not be published. Required fields are marked *