Important premises
This guide is designed (and works) only for those who install Fedora in the classic way, i.e. with the default partitioning and subvolume layouts:
lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
zram0 xyz:a 0 XG 0 disk [SWAP]
nvme0n1 xyz:a 0 XXX,XG 0 disk
├─nvme0n1p1 xyz:a 0 XXXM 0 part /boot/efi
├─nvme0n1p2 xyz:a 0 XG 0 part /boot
└─nvme0n1p3 xyz:a 0 XXX,XG 0 part
└─luks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx xyz:a 0 XXX,XG 0 crypt /home
/
sudo btrfs subvolume list /
ID xxx gen yyyyyy top level 5 path home
ID xxx gen yyyyyy top level 5 path root
In this guide we will explore how to take “system” snapshots, therefore only of the / subvolume.
The /boot partition (because in ext4) and the /home subvolume are not included in the snapshot (snapshotting the latter is useless and would only make the snapshots heavier; I still recommend using a backup system for your data in /home, for example through applications like Déjà Dup or Pika Backup).
More details on the filesystem types used by partitions with:
lsblk -f
Since it is not possible to snapshot /boot, which contains the installed kernels, we will configure Snapper (the tool we are going to use to snapshot) not to retain snapshots that are too old: in this way we avoid problems booting from a snapshot for which the kernel it needs is no longer present.
While there are two great graphics tools (GUI) for this type of work, they won’t be used in this guide. This is because neither of them fully meets the requirements that an app of this type should have: being simple to configure and being perfectly integrated with the system.
Neither Timeshift nor Btrfs Assistant meets all of these requirements: Timeshift does not work with Fedora’s default subvolume layout; Btrfs Assistant does not integrate at all with the GNOME environment (which is Fedora’s default DE) and is not particularly intuitive for a novice user.
Packages to install before starting
Install this COPR repo:
sudo dnf copr enable zzahkaboom24/grub-btrfs
and then install these packages:
sudo dnf install snapper grub-btrfs libdnf5-plugin-actions python3-btrfsutil
Let’s get started!
First of all let’s configure Snapper:
sudo snapper -c root create-config /
sudo nano /etc/snapper/configs/root
# subvolume to snapshot
SUBVOLUME="/"
# filesystem type
FSTYPE="btrfs"
# btrfs qgroup for space aware cleanup algorithms
QGROUP=""
# fraction or absolute size of the filesystems space the snapshots may use
SPACE_LIMIT="0.5"
# fraction or absolute size of the filesystems space that should be free
FREE_LIMIT="0.2"
# users and groups allowed to work with config
ALLOW_USERS=""
ALLOW_GROUPS=""
# sync users and groups from ALLOW_USERS and ALLOW_GROUPS to .snapshots
# directory
SYNC_ACL="no"
# start comparing pre- and post-snapshot in background after creating
# post-snapshot
BACKGROUND_COMPARISON="yes"
# run daily number cleanup
NUMBER_CLEANUP="yes"
# limit for number cleanup
NUMBER_MIN_AGE="3600"
NUMBER_LIMIT="6"
NUMBER_LIMIT_IMPORTANT="4"
# create hourly snapshots
TIMELINE_CREATE="yes"
# cleanup hourly snapshots after some time
TIMELINE_CLEANUP="yes"
# limits for timeline cleanup
TIMELINE_MIN_AGE="3600"
TIMELINE_LIMIT_HOURLY="5"
TIMELINE_LIMIT_DAILY="3"
TIMELINE_LIMIT_WEEKLY="0"
TIMELINE_LIMIT_MONTHLY="0"
TIMELINE_LIMIT_QUARTERLY="0"
TIMELINE_LIMIT_YEARLY="0"
# cleanup empty pre-post-pairs
EMPTY_PRE_POST_CLEANUP="yes"
# limits for empty pre-post-pair cleanup
EMPTY_PRE_POST_MIN_AGE="3600"
sudo nano /etc/dnf/libdnf5-plugins/actions.d/snapper.actions
# Get the snapshot description
pre_transaction::::/usr/bin/sh -c echo\ "tmp.cmd=$(ps\ -o\ command\ --no-headers\ -p\ '${pid}')"
# Creates pre snapshots for root and stores snapshot numbers in variables
pre_transaction::::/usr/bin/sh -c echo\ "tmp.snapper_pre_root=$(snapper\ -c\ root\ create\ -c\ number\ -t\ pre\ -p\ -d\ '${tmp.cmd}')"
# Creates post snapshots for root if pre snapshot numbers exist
post_transaction::::/usr/bin/sh -c [\ -n\ "${tmp.snapper_pre_root}"\ ]\ &&\ snapper\ -c\ root\ create\ -c\ number\ -t\ post\ --pre-number\ "${tmp.snapper_pre_root}"\ -d\ "${tmp.cmd}"
Now let’s verify that the daemons used by Snapper are working properly:
systemctl status snapper-timeline.timer
systemctl status snapper-cleanup.timer
Both must be enabled and active, otherwise:
sudo systemctl enable --now snapper-timeline.timer
sudo systemctl enable --now snapper-cleanup.timer
Ok, so far we have already done most of the work. Snapshots will be automatically created every hour and every pre- and post-installation or package update, and older ones will be automatically deleted. Note that mine is just an example configuration: if you are an experienced user, you can configure Snapper as you see fit.
To view the updated list of available snapshots at any time, simply launch:
sudo snapper list
Now let’s continue by configuring grub-btrfs. This package allows you to get a list of available snapshots directly in the GRUB boot menu, in case the system fails to boot and you need to boot from a snapshot.
sudo systemctl edit --full grub-btrfsd
[Unit]
Description=Regenerate grub-btrfs.cfg
[Service]
Type=simple
LogLevelMax=notice
# Set the possible paths for `grub-mkconfig`
Environment="PATH=/sbin:/bin:/usr/sbin:/usr/bin"
# Load environment variables from the configuration
EnvironmentFile=/etc/default/grub-btrfs/config
# Start the daemon, usage of it is:
# grub-btrfsd [-h, --help] [-t, --timeshift-auto] [-l, --log-file LOG_FILE] SNAPSHOTS_DIRS
# SNAPSHOTS_DIRS Snapshot directories to watch, without effect when --timeshift-auto
# Optional arguments:
# -t, --timeshift-auto Automatically detect Timeshifts snapshot directory
# -o, --timeshift-old Activate for timeshift versions <22.06
# -l, --log-file Specify a logfile to write to
# -v, --verbose Let the log of the daemon be more verbose
# -s, --syslog Write to syslog
ExecStart=/usr/bin/grub-btrfsd --syslog /.snapshots
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now grub-btrfsd
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
Reboot.
We conclude by installing this script which will greatly simplify the rollback process, automating the entire process and reducing it to a single command.
git clone 'https://github.com/jrabinow/snapper-rollback.git'
cd snapper-rollback
sudo cp snapper-rollback.py /usr/local/sbin/snapper-rollback
sudo cp snapper-rollback.conf /etc/
sudo nano /etc/snapper-rollback.conf
# Rollback to snapper snapshot
#
# Requires a flat subvolume layout like specified here:
# https://wiki.archlinux.org/index.php/Snapper#Suggested_filesystem_layout
#
# Run with snapshot number as an argument like "snapper-rollback 642"
# This can be run either from your installed system or from a live arch ISO if
# you adjust the settings accordingly
#
# Some terminology:
# - your linux root is what gets mounted to `/` at boot time. Referred to
# as `main` in this tool
# - your btrfs root is the btrfs toplevel subvolume with subvol id = 5. See
# https://btrfs.readthedocs.io/en/latest/btrfs-subvolume.html#description
# for more info.
# Referred to as `root` in this tool.
# config for btrfs root
[root]
# Name of your linux root subvolume
subvol_main = root
# Name of your snapper snapshot subvolume
subvol_snapshots = root/.snapshots
# Directory to which your btrfs root is mounted.
mountpoint = /btrfsroot
# Device file for btrfs root.
# If your btrfs root isn't mounted to `mountpoint` directory, then automatically
# mount this device there before rolling back. This parameter is optional, but
# if unset, you'll have to mount your btrfs root manually.
dev = /dev/*
BE CAREFUL: replace /dev/* with the device on which the root subvolume is installed (in my case it is /dev/mapper/luks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, but yours may be different!
Just another step: due to this issue, you need to edit /usr/local/sbin/snapper-rollback
sudo nano /usr/local/sbin/snapper-rollback
#!/usr/bin/env -S python3
# -*- coding: utf-8 -*-
"""
Script to rollback to snapper snapshot using the layout proposed in the snapper
archwiki page
https://wiki.archlinux.org/index.php/Snapper#Suggested_filesystem_layout
"""
from datetime import datetime
import argparse
import btrfsutil
import configparser
import logging
import os
import pathlib
import sys
LOG = logging.getLogger()
LOG.setLevel("INFO")
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
ch = logging.StreamHandler()
ch.setFormatter(formatter)
LOG.addHandler(ch)
def parse_args():
parser = argparse.ArgumentParser(
description="Rollback to snapper snapshot based on snapshot ID",
)
parser.add_argument(
"snap_id", metavar="SNAPID", type=str, help="ID of snapper snapshot"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="don't actually do anything, just print the actions out",
)
parser.add_argument(
"-c",
"--config",
type=str,
default="/etc/snapper-rollback.conf",
help="configuration file to use (default: /etc/snapper-rollback.conf)",
)
args = parser.parse_args()
return args
def read_config(configfile):
config = configparser.ConfigParser()
config.read(configfile)
return config
def ensure_dir(dirpath, dry_run=False):
if not os.path.isdir(dirpath):
try:
if dry_run:
LOG.info("mkdir -p '{}'".format(dirpath))
else:
os.makedirs(dirpath)
except OSError as e:
LOG.fatal("error creating dir '{}': {}".format(dirpath, e))
raise
def mount_subvol_id5(target, source=None, dry_run=False):
"""
There is no built-in `mount` function in python, let's shell out to an `os.system` call
Also see https://stackoverflow.com/a/29156997 for a cleaner alternative
"""
ensure_dir(target, dry_run=dry_run)
if not os.path.ismount(target):
shellcmd = "mount -o subvolid=5 {} {}".format(source or "", target)
if dry_run:
LOG.info(shellcmd)
ret = 0
else:
ret = os.system(shellcmd)
if ret != 0:
raise OSError("unable to mount {}".format(target))
def fix_path_after_rename(path, subvol_main, subvol_main_newname):
"""
If path is inside subvol_main, rewrite it to use subvol_main_newname instead,
since subvol_main has been renamed to subvol_main_newname.
"""
path_str = str(path)
main_str = str(subvol_main) + '/'
if path_str.startswith(main_str):
return pathlib.Path(str(subvol_main_newname) + '/' + path_str[len(main_str):])
return path
def rollback(subvol_main, subvol_main_newname, subvol_rollback_src, dev, dry_run=False):
"""
Rename linux root subvolume, then create a snapshot of the subvolume to
the old linux root location
"""
try:
if dry_run:
LOG.info("mv {} {}".format(subvol_main, subvol_main_newname))
actual_src = fix_path_after_rename(subvol_rollback_src, subvol_main, subvol_main_newname)
LOG.info(
"btrfs subvolume snapshot {} {}".format(
actual_src, subvol_main
)
)
LOG.info("btrfs subvolume set-default {}".format(subvol_main))
else:
os.rename(str(subvol_main), str(subvol_main_newname))
actual_src = fix_path_after_rename(subvol_rollback_src, subvol_main, subvol_main_newname)
btrfsutil.create_snapshot(str(actual_src), str(subvol_main))
btrfsutil.set_default_subvolume(str(subvol_main))
LOG.info(
"{}Rollback to {} complete. Reboot to finish".format(
"[DRY-RUN MODE] " if dry_run else "", subvol_rollback_src
)
)
except FileNotFoundError as e:
LOG.fatal(
f"Missing {subvol_main}: Is {dev} mounted with the option subvolid=5?"
)
except btrfsutil.BtrfsUtilError as e:
LOG.error(f"{e}")
if not os.path.isdir(str(subvol_main)):
LOG.info(f"Moving {subvol_main_newname} back to {subvol_main}")
if dry_run:
LOG.warning("mv {} {}".format(subvol_main_newname, subvol_main))
else:
os.rename(str(subvol_main_newname), str(subvol_main))
def main():
args = parse_args()
config = read_config(args.config)
mountpoint = pathlib.Path(config.get("root", "mountpoint"))
subvol_main = mountpoint / config.get("root", "subvol_main")
subvol_rollback_src = (
mountpoint / config.get("root", "subvol_snapshots") / args.snap_id / "snapshot"
)
try:
dev = config.get("root", "dev")
except configparser.NoOptionError as e:
dev = None
confirm_typed_value = "CONFIRM"
try:
confirmation = input(
f"Are you SURE you want to rollback? Type '{confirm_typed_value}' to continue: "
)
if confirmation != confirm_typed_value:
LOG.fatal("Bad confirmation, exiting...")
sys.exit(0)
except KeyboardInterrupt as e:
sys.exit(1)
date = datetime.now().strftime("%Y-%m-%dT%H:%M")
subvol_main_newname = pathlib.Path(f"{subvol_main}{date}")
try:
mount_subvol_id5(mountpoint, source=dev, dry_run=args.dry_run)
rollback(
subvol_main,
subvol_main_newname,
subvol_rollback_src,
dev,
dry_run=args.dry_run,
)
except PermissionError as e:
LOG.fatal("Permission denied: {}".format(e))
exit(1)
if __name__ == "__main__":
main()
sudo chmod +x /usr/local/sbin/snapper-rollback
(this last command may be unnecessary, as the file should already have the necessary permissions)
Now everything should work properly.
At this point, when something is wrong with our system or we simply want to undo some changes, we have two options:
- the first is the
snapper undochangecommand - with this command we can cancel the result of a transaction carried out via DNF; since we have set Snapper to take a snapshot before (pre-) and a snapshot after (post-) each transaction, just take note of the ID number of these two snapshots (sudo snapper list) and issue the command
sudo snapper undochange pre-number..post-number
- the second is the
snapper-rollbackcommand - with this command we can “go back in time” and restore the system to a previous state; to do it
sudo snapper-rollback snapshot-number
(BTW: you can also see what the result of a rollback would be without actually executing it; to do it just add the option --dry-run)
And that’s it! Enjoy!
After the rollback: restoring Snapper
After rebooting into the restored system, Snapper needs to be reconfigured. This is because the new root subvolume doesn’t have a .snapshots subvolume yet, and the old configuration references are stale.
Run these command in order:
sudo rmdir /.snapshots
sudo bash -c 'echo SNAPPER_CONFIGS="" > /etc/sysconfig/snapper'
sudo rm -rf /etc/snapper/configs/root
sudo snapper -c root create-config /
Then reapply your custom Snapper settings:
sudo nano /etc/snapper/configs/root
And restore the configuration exactly as shown in the initial setup section of this guide.
Finally verify everything is working:
sudo snapper list
After the rollback, the old system is preserved as a renamed subvolume (e.g. rootyyy-mm-ddT00:00) with all its snapshots inside. You can safely delete everything with:
sudo mount -o subvolid=5 /dev/* /btrfsroot
My dev/* is /dev/mapper/luks-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx but yours may be different!
sudo btrfs subvolume list /
Delete all snapshot subvolumes first (the ones nested inside the old root), then .snapshots, then the old root itself:
sudo btrfs subvolume delete --subvolid ID /btrfsroot
Repeat for each ID in the correct order: snapshots first, then .snapshots, then the old root last.
Final notes
No guide can ever be 100% accurate and this is no exception. There may be some errors or omissions. So, before performing any steps, first make sure you understand what you are doing. If you notice any errors or omissions or if you have suggestions to improve the guide, comment below the post. Any contribution is welcome!