Skip to content

rollback: support named-subvolume btrfs layouts (--ambit subvol-rename) - #1184

Open
mxsb wants to merge 20 commits into
openSUSE:masterfrom
mxsb:mxsb/configurable-rollback-method
Open

mxsb wants to merge 20 commits into
openSUSE:masterfrom
mxsb:mxsb/configurable-rollback-method

Conversation

@mxsb

@mxsb mxsb commented Sep 7, 2026

Copy link
Copy Markdown

This continues #1133 (closed after review). I opted for a fresh PR rather than reopening the old one; it's the same branch, with all review feedback from #1133 addressed — see Changes since the #1133 review below.

On systems where fstab mounts the root filesystem with an explicit subvol= option (e.g. subvol=root, subvol=@rootfs), snapper rollback currently has no effect on the next boot. The BTRFS_IOC_DEFAULT_SUBVOL ioctl changes the default subvolume ID, but when the kernel mounts by name the default ID is ignored.

This affects systems using named-subvolume layouts (Fedora, Ubuntu, Debian, many manual btrfs setups) and is the root cause behind #365 and #1011 (and is discussed in related btrfs-rollback reports #722 and #159).

Changes

The ambit is the single parameter controlling how the rollback is performed. --ambit accepts a fourth value:

  • auto (default) — everything is detected: a top-level named subvol= root mount selects subvol-rename; otherwise the read-only/-write state of the default snapshot selects transactional/classic, exactly as before. No configuration needed on most systems.
  • classic / transactional — existing behavior, unchanged (set-default).
  • subvol-rename — atomically swaps the named root subvolume using renameat2(RENAME_EXCHANGE) (Linux 3.17+).

An explicit --ambit always wins over the detection. The whole decision lives in one pure function (client/snapper/ambit.cc), so the precedence is unit-tested. When an explicit classic/transactional sets the default subvolume ID while root is mounted by a top-level subvol= name — a rollback that cannot take effect on the next boot — snapper prints a warning.

rollbackSubvolRename is a virtual on Filesystem like setDefault, implemented for btrfs.

The subvol-rename sequence:

  1. Read-write copy of the rollback target created as <subvol>.incoming
  2. renameat2(RENAME_EXCHANGE) atomically swaps <subvol>.incoming<subvol> — no window where <subvol> does not exist
  3. A nested .snapshots subvolume is moved into the new root (a btrfs snapshot only contains an empty stub directory where a nested subvolume is; without this the default Fedora/Debian layout would lose its snapshot history to the old root)
  4. Old root moved to <subvol>.rollback.<N> for recovery

Only a top-level named subvolume selects subvol-rename: the kernel shows the resolved subvol= path in /proc/mounts even for mounts by default subvolume ID (e.g. the nested subvol=/@/.snapshots/N/snapshot on Tumbleweed), so a nested name is no evidence of a by-name mount and such systems keep using set-default.

Safety properties

  • Nested subvolume paths are never selected by detection and are rejected with a clear error when requested explicitly, before any disk operations.
  • Old root is never deleted. After the atomic swap, the old root is renamed to <subvol>.rollback.<N>. If that name already exists, a subvolume-ID-based fallback name <subvol>.rollback.svid.<id> is used — guaranteed unique across the btrfs filesystem.
  • Stale .incoming from a prior interrupted rollback is renamed (not deleted) before starting. If a previous rollback completed the swap but failed to rename .incoming, that subvolume is the old running root, still mounted by the kernel via subvolume ID. Deleting it risks corruption during the unmount sequence. It is renamed to <subvol>.rollback.svid.<id> instead.
  • rename_exchange failure deletes only the newly-created .incoming copy (safe — the swap never occurred) and throws a clear error.

Changes since the #1133 review

Addressing @aschnell's feedback:

  • /.snapshots unavailable before reboot (Fedora 44). Where .snapshots is nested inside the root subvolume, the rollback moves it into the new root; that unhooked it from the still-mounted old root (reached by subvolume id, not the exchanged name), so /.snapshots stopped resolving and snapper stopped working until the reboot. The rollback now re-mounts the .snapshots subvolume back onto the running root by its unchanged subvolume id, so snapper keeps working immediately. The mount is transient; after reboot the new root provides .snapshots as a nested subvolume again — no fstab entry required.
  • Leftover <subvol>.incoming.<N> never deleted. .incoming is only the transient name for the read-write copy during the swap; after the exchange the old root is renamed to <subvol>.rollback.<N>, so nothing named .incoming is left behind. To stop the .rollback.<N> backups from accumulating indefinitely, a new optional ROLLBACK_BACKUP_LIMIT config key prunes them: empty (the default) keeps all; N keeps the N newest and deletes the oldest on each rollback. The backup just created is never pruned.
  • Nested <subvol>.incoming.<N>/var/lib/portables. Expected btrfs behaviour rather than a misconfiguration: a btrfs snapshot does not recurse into nested subvolumes, so the read-write copy gets an empty stub directory where each nested subvolume (systemd's var/lib/portables, etc.) was. The one nested subvolume that matters, .snapshots, is explicitly moved into the new root; the remaining stubs are empty and are removed when the backup subvolume is pruned/deleted.
  • No-argument snapper rollback on subvol-rename roots (found while validating across distros). It snapshotted the btrfs default subvolume, which on these systems is the top-level (id 5, empty path) → an empty subvol= mount → EINVAL. It now falls back to the currently-mounted subvolume, the correct restore point.

Testing

  • Unit tests: ambit resolution precedence (incl. explicit-wins), the set-default-ineffective warning predicate, mount-option parsing, enum/name mapping (testsuite/ambit-test.cc); SDir::exchange/RENAME_EXCHANGE and the cross-directory SDir::rename (testsuite/rename-exchange.cc).
  • Integration tests on real btrfs (testsuite-real/rollback-subvol-rename.cc): happy path, .rollback.N collision → subvolid fallback, nested-.snapshots migration, ROLLBACK_BACKUP_LIMIT pruning.
  • CI: a --disable-rollback reduced-build job so the !ENABLE_ROLLBACK path keeps compiling.
  • End-to-end VM tests on Fedora 43, Debian 13, Ubuntu 26.04 (subvol-rename) and Tumbleweed (set-default) — all green. Unattended install → build from source → rollback → reboot → verify, as a small pluggable engine with fresh-snapshot isolation per case and TAP output. Cases cover: detection matches the declared method, no-argument rollback, two rollbacks without a reboot in between, .snapshots usable before reboot, the --ambit classic warning on a named root, the .rollback.N collision fallback, ROLLBACK_BACKUP_LIMIT pruning across three real rollback+reboot cycles, btrfs quota, SELinux-enforcing, the transactional ambit, and the already-default rejection. Branch: https://github.com/mxsb/snapper/tree/mxsb/testsuite-vm/testsuite-vm

I kept the VM test harness out of this PR to avoid scope creep, but it's ready to contribute — happy to open it as a separate PR if you'd find it useful in the repo.

Documentation

  • snapper(8)--ambit values and the rollback section
  • doc/rollback.txt — design rationale and mechanism descriptions
  • ROLLBACK_BACKUP_LIMIT documented in the config templates

Fixes #365. Related: #722, #159, #1011. Continues #1133.


Note: This patch was developed with the assistance of Claude (Anthropic AI). The design, testing, and review decisions were made by the author.

Max Schwaab and others added 20 commits May 29, 2026 22:55
Adds RollbackMethod enum (SET_DEFAULT, SUBVOL_RENAME) and two detection
functions to AppUtil:

- detect_rollback_method_from_options(): pure function over already-parsed
  mount options, testable without /proc/mounts access
- detect_rollback_method(): reads /proc/mounts via getMtabData() and
  delegates to the above

Returns SUBVOL_RENAME when a "subvol=<name>" mount option is present and
the name is not "/", SET_DEFAULT otherwise. This distinguishes systems
using named subvolumes (subvol=@root) from those relying on the btrfs
default subvolume id.

Also adds KEY_ROLLBACK_METHOD config key and ROLLBACK_METHOD="auto" to
the default config template, and a Boost unit test covering all detection
cases including the subvolid= vs subvol= prefix disambiguation.

config.h is included in AppUtil.cc as it now contains the first
conditional compilation block in that translation unit.
Reads ROLLBACK_METHOD from the snapper config and resolves the effective
rollback method before the ambit switch:

- "auto": detect from /proc/mounts via detect_rollback_method()
- "set-default": force the existing btrfs default subvolume ioctl
- "subvol-rename": use named subvolume swap; errors if root is not
  mounted with a named subvolume
- unknown value: error and exit

Adds get_subvol_name() as a thin wrapper around detect_rollback_method()
for the explicit "subvol-rename" path, making intent clear without
discarding the returned RollbackMethod.

Both CLASSIC and TRANSACTIONAL ambit paths switch on the resolved method.
The subvol-rename case exits with "not yet implemented" until the rename
logic is added in the next commit.
Adds Btrfs::rollbackSubvolRename() which performs rollback on systems
using named btrfs subvolumes (subvol=@root in fstab) by atomically
swapping subvolumes via renameat2(RENAME_EXCHANGE) rather than changing
the btrfs default subvolume id.

The sequence:
  1. Reject nested subvolume paths (e.g. root/@root)
  2. Mount the btrfs top-level (subvolid=5) via TmpMount
  3. Rename any stale .incoming from a previous interrupted rollback
  4. Create a rw snapshot of the rollback target as <subvol>.incoming
  5. Atomically swap <subvol> and <subvol>.incoming
  6. Rename old root to <subvol>.rollback.<N> (falls back to
     <subvol>.rollback.svid.<id> on collision)

The old root is never deleted. Strip leading slash from subvol= in
/proc/mounts to match kernel vs fstab differences (e.g. Debian).

Both CLASSIC and TRANSACTIONAL ambit paths dispatch to
rollbackSubvolRename() when rollback_method is SUBVOL_RENAME.
Add ROLLBACK_METHOD to snapper-configs(5) with descriptions of all three
values (auto, set-default, subvol-rename). Update the rollback section of
snapper(8) to mention auto-detection from /proc/mounts. Add doc/rollback.txt
explaining the design rationale, the two rollback methods, and why
subvol-rename is needed on systems using named subvolumes (subvol=@root).
Use flat subvolume structure instead of recreating snapper's
internal .snapshots/N/snapshot hierarchy. Avoids nested subvol
cleanup failures and tests the rename logic directly.

Add detect_rollback_method test against real /proc/mounts for
@root, root, @rootfs and @ naming conventions, verifying kernel
leading-slash normalization is handled correctly.
RollbackMethod, SubvolumeMode, Ambit enums and the detection functions
are only used by client code. Move them from snapper/AppUtil to
client/snapper/rollback-method to keep the library surface minimal.
Revert the config.h and Exception.h includes added to AppUtil.cc and the
sys/syscall.h include in FileUtils.cc (leftover from the removed renameat2
syscall fallback); none of them are used.
Drop the separate RollbackMethod enum and the detect_ambit helper. Add a
SUBVOL_RENAME value to GlobalOptions::Ambit (kept in GlobalOptions.h) and
determine the effective ambit from two small helpers:

  use_subvol_rename(rollback_method, subvol_name) - the mechanism, from
    ROLLBACK_METHOD and the root mount;
  classic_or_transactional(ambit, mode) - the set-default semantic, from
    --ambit or the read-only/-write state of the default snapshot.

SUBVOL_RENAME is selected only via ROLLBACK_METHOD, not --ambit, since a
transactional system is always mounted by default subvolume id and never by
name. Both helpers are pure and unit-tested, including the guards for an
unknown ROLLBACK_METHOD and for subvol-rename requested without a top-level
named subvolume.
An explicit --ambit always wins; with auto a top-level named root
subvolume selects subvol-rename, otherwise the ambit is derived from the
read-only/-write state of the default snapshot. The whole decision lives
in one pure function, determine_ambit, so the precedence is unit-tested,
including that an explicit --ambit is never overridden by detection.

The ROLLBACK_METHOD config key is dropped; the ambit is the single
parameter controlling how the rollback is performed.
Warn when an explicit --ambit classic/transactional sets the default
subvolume id while root is mounted by subvol= name - the rollback would
silently have no effect on the next boot (set_default_ineffective, unit
tested). Promote rollbackSubvolRename to Filesystem like setDefault,
removing the dynamic_cast in cmd-rollback.cc. Rename rollback-method.{h,cc}
to ambit.{h,cc} since the rollback-method concept is gone. Compute the
subvolume mode only with --ambit auto. Add subvol-rename to the zsh
completion and fix the nested-subvolume wording in doc/rollback.txt.
A btrfs snapshot does not include nested subvolumes: on layouts where the
.snapshots subvolume lives inside the root subvolume (e.g. the default
snapper setup on Fedora and Debian) the subvol-rename rollback left only
an empty stub directory in the new root, orphaning all snapshots in the
renamed-away old root and breaking snapper after the reboot. Move the
.snapshots subvolume into the new root after the swap; the running mount
stays valid since the kernel tracks it by subvolume id.

Adds a cross-directory SDir::rename and unit test, and a real-btrfs test
for the stub-replacement semantics. Found by the VM testsuite's second
rollback cycle.
Address review feedback: a bool predicate reads better with an "is_"
prefix. Pure rename of the declaration, definition, call site and unit
test; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
Address review feedback: the cross-directory rename overload hardcoded
flags 0, so a cross-directory RENAME_EXCHANGE could not be expressed.
Add "int flags = 0" and pass it through to renameat2(), mirroring the
same-directory overload. Existing 3-argument callers are unaffected.

Covered by a new cross-directory exchange case in rename-exchange.test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
Address review feedback: Btrfs declares rollbackSubvolRename
unconditionally (an override), but it was only defined inside the
ENABLE_ROLLBACK branch. Building with --disable-rollback therefore
failed to link:

  libsnapper.so: undefined reference to
    snapper::Btrfs::rollbackSubvolRename(...)

Add a stub in the #else branch that delegates to Filesystem (which
throws UnsupportedException), mirroring how setDefault/isDefault are
handled there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
The default CI job only builds the full feature set, so the
--disable-rollback link failure fixed in the previous commit would not
have been caught. Add an isolated Fedora job that bootstraps and
compiles with rollback disabled, guarding the feature-reduced build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
On a named-subvolume layout (Fedora/Debian) the .snapshots subvolume is
nested inside the root subvolume and reached through the root mount, not
by a separate mount. The subvol-rename rollback moves .snapshots into
the new root so the snapshot history is not orphaned, but that also
unhooks it from the still-mounted old root (the running system, held by
subvolume id). /.snapshots then no longer resolved and snapper stopped
working until the next reboot:

  # snapper list
  IO error (open failed path://.snapshots errno:2 (No such file ...)).

After moving .snapshots, mount it back onto the running root by its
(unchanged) subvolume id so snapper keeps working until the reboot swaps
in the new root, where /.snapshots is a nested subvolume again. The
temporary mount does not survive the reboot.

Verified end-to-end on a Fedora 43 VM: after rollback, findmnt shows
/.snapshots mounted (subvolid unchanged) and `snapper list` succeeds
before reboot; the reboot still applies the rollback. A pre-reboot
"snapper still works" assertion is added to the VM rollback test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
A subvol-rename rollback preserves the previous root as a top-level
<subvol>.rollback.<N> subvolume for recovery. These are outside the
.snapshots/<N> framework, so no cleanup algorithm ever removes them and
they accumulate without bound (reported in review).

Add an optional ROLLBACK_BACKUP_LIMIT config key. Consistent with
snapper never deleting anything unless configured, it defaults to empty
= keep all backups (no behaviour change). When set to a positive number,
a rollback deletes the oldest backups beyond that limit; ordering is by
btrfs subvolume id, which increases with every rollback, so the newest
backup (the one just created) is never deleted. Deletion is recursive
because a preserved root contains nested subvolumes (e.g.
var/lib/portables).

- BtrfsUtils::delete_subvolume gains a recursive flag (libbtrfsutil
  BTRFS_UTIL_DELETE_SUBVOLUME_RECURSIVE).
- prune_rollback_backups() is factored out and unit-tested directly in
  testsuite-real/rollback-subvol-rename (creates backups with a nested
  subvolume, verifies only the newest are kept and keep==0 keeps all).
- Documented in snapper-configs and the default config template.

Verified in a Fedora 43 VM: the testsuite-real suite (incl. the new
prune test) passes on a loopback btrfs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
The previous commit read ROLLBACK_BACKUP_LIMIT in Btrfs::evalConfigInfo
into a member, but the retention never took effect: the rollback client
builds its filesystem handler via get_filesystem() ->
Filesystem::create(fstype, subvolume, root_prefix), the overload that
does NOT call evalConfigInfo. The member (like qgroup on this path)
stayed at its default, so prune_rollback_backups() always saw a limit of
0 and kept everything.

Read the value in cmd-rollback.cc from the ProxyConfig and pass it into
rollbackSubvolRename() as an explicit parameter; drop the dead member
and its evalConfigInfo read. A VM cycle with the limit set now prunes as
configured (verified: 3 backups -> 2, and the rollback still boots).

Found because the testsuite-real unit test exercised prune directly but
not the config-to-rollback wiring; the VM integration test caught it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
`snapper rollback` with no argument creates a read-only snapshot of the
"default subvolume" as the restore point. createSnapshotOfDefault() resolved
that via the btrfs *default* subvolume id and mounted it with `subvol=<name>`.

On subvol-rename systems (e.g. Fedora, Debian, Ubuntu) the btrfs default
subvolume is the top-level (id 5), whose path is empty, so the mount option
became `subvol=` and the operation aborted early with a cryptic
`mount failed errno:22 (Invalid argument)` before any state was changed.

The booted root on these systems is a named subvolume reached via `subvol=`
in fstab, not by being the btrfs default. Fall back to the currently mounted
subvolume when the default resolves to the top-level (empty path); this is
what the system actually boots and the correct restore point. classic and
set-default roots are unaffected (their default subvolume has a real path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrmT9CcddjvedN9LoLjSqp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Snapper rollback on Ubuntu does not use earlier snapshot

1 participant