[kde-linux/kde-linux] /: Set up snapper for kio-snapshot

Hadi Chokr <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 6f8d534974d6280c8f150ab3532cbe6414310b8f by Hadi Chokr.
Committed on 14/08/2026 at 17:59.
Pushed by silverhadch into branch 'master'.

Set up snapper for kio-snapshot

Signed-off-by: Hadi Chokr <[email protected]>

Closes #745 

This is part of #666

M  +1    -0    mkosi.conf.d/00-packages-core.conf
A  +5    -0    mkosi.extra/etc/xdg/baloofilerc
A  +84   -0    mkosi.extra/usr/lib/snapper-home-config
A  +159  -0    mkosi.extra/usr/lib/snapper-home-gc
M  +3    -0    mkosi.extra/usr/lib/systemd/system-preset/00-kde-linux.preset
A  +18   -0    mkosi.extra/usr/lib/systemd/system/[email protected]
A  +17   -0    mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.path
A  +15   -0    mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.service
A  +9    -0    mkosi.extra/usr/lib/systemd/system/snapper-cleanup.service.d/50-kde-linux-home-gc.conf
A  +5    -0    mkosi.extra/usr/lib/systemd/system/[email protected]/50-kde-linux-snapper.conf
A  +19   -0    mkosi.extra/usr/lib/tmpfiles.d/kde-linux-snapper.conf
A  +48   -0    mkosi.extra/usr/share/snapper/config-templates/home
M  +8    -0    mkosi.finalize.d/40-core.sh.chroot
M  +8    -0    mkosi.finalize.d/99-etc.sh.chroot

https://invent.kde.org/kde-linux/kde-linux/-/commit/6f8d534974d6280c8f150ab3532cbe6414310b8f

diff --git a/mkosi.conf.d/00-packages-core.conf b/mkosi.conf.d/00-packages-core.conf
index b3f60fb6..b8d72400 100644
--- a/mkosi.conf.d/00-packages-core.conf
+++ b/mkosi.conf.d/00-packages-core.conf
@@ -91,6 +91,7 @@ Packages=
     ntfsprogs # Manipulating ntfs filesystems; used by kpmcore
     nvidia-prime # prime-run command; used in kio via Dolphin, Plasma, KRunner
     sbsigntools # To sign and verify EFI binaries; needs low-level access to work
+    snapper # Snapshot management for the per-user home subvolumes
     xfsprogs # Manipulating XFS filesystems; used by kpmcore
     ydotool # Basic TUI and GUI app automation; needs low-level access to work
 
diff --git a/mkosi.extra/etc/xdg/baloofilerc b/mkosi.extra/etc/xdg/baloofilerc
new file mode 100644
index 00000000..a01cd4aa
--- /dev/null
+++ b/mkosi.extra/etc/xdg/baloofilerc
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: None
+# SPDX-License-Identifier: CC0-1.0
+
+[General]
+exclude folders[$e]=$HOME/.snapshots/
diff --git a/mkosi.extra/usr/lib/snapper-home-config b/mkosi.extra/usr/lib/snapper-home-config
new file mode 100755
index 00000000..e49de7f9
--- /dev/null
+++ b/mkosi.extra/usr/lib/snapper-home-config
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Creates the snapper config for one user's home subvolume. Run per login by
+# [email protected], which the [email protected] drop-in pulls
+# in.
+
+import pwd
+import subprocess
+import sys
+from pathlib import Path
+
+CONFIGS = Path("/etc/snapper/configs")
+HOMES = Path("/home")
+
+
+def is_subvolume(path):
+    return subprocess.run(
+        ["btrfs", "subvolume", "show", path],
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+    ).returncode == 0
+
+
+def snapper(config, *args):
+    # --no-dbus because snapperd is not necessarily up yet.
+    subprocess.run(["snapper", "--no-dbus", "--config", config, *args], check=True)
+
+
+def main():
+    if len(sys.argv) != 2:
+        print(f"Usage: {sys.argv[0]} <uid>", file=sys.stderr)
+        return 1
+
+    try:
+        uid = int(sys.argv[1])
+    except ValueError:
+        print(f"Not a uid: {sys.argv[1]}", file=sys.stderr)
+        return 1
+
+    if not 1000 <= uid < 65534:
+        return 0
+
+    try:
+        user = pwd.getpwuid(uid)
+    except KeyError:
+        return 0
+
+    home = Path(user.pw_dir)
+    if home == HOMES or not home.is_relative_to(HOMES):
+        return 0
+
+    # Keyed on the uid because config names become file names, and DOMAIN\user
+    # and user@realm are ordinary enough with networked accounts.
+    config = f"home_{uid}"
+    if (CONFIGS / config).exists():
+        return 0
+
+    if not is_subvolume(home):
+        return 0
+
+    print(f"Creating snapper config {config} for {home}")
+    snapper(config, "create-config", "--template", "home", str(home))
+
+    # ALLOW_USERS
+    if "," in user.pw_name:
+        print(f"Not setting ALLOW_USERS, '{user.pw_name}' cannot be put in the list",
+              file=sys.stderr)
+    else:
+        snapper(config, "set-config", f"ALLOW_USERS={user.pw_name}")
+
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except subprocess.CalledProcessError as error:
+        print(f"{error.cmd[0]} failed with exit status {error.returncode}", file=sys.stderr)
+        sys.exit(1)
+    except OSError as error:
+        print(error, file=sys.stderr)
+        sys.exit(1)
diff --git a/mkosi.extra/usr/lib/snapper-home-gc b/mkosi.extra/usr/lib/snapper-home-gc
new file mode 100755
index 00000000..125fa97a
--- /dev/null
+++ b/mkosi.extra/usr/lib/snapper-home-gc
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Deletes home configs that have no user left, along with their snapshots and
+# the home subvolume. Runs off snapper-cleanup.timer through a drop-in.
+
+import os
+import pwd
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+CONFIGS = Path("/etc/snapper/configs")
+NSSWITCH = Path("/etc/nsswitch.conf")
+
+# CONF_DIR is baked in at build time: /etc/conf.d on Arch, /etc/sysconfig on
+# buildstream, with a symlink between the two. Resolve to land on the real file.
+SYSCONFIG = (Path("/etc/conf.d/snapper"), Path("/etc/sysconfig/snapper"))
+
+# Same range snapper-home-config creates for.
+UID_MIN = 1000
+UID_MAX = 65534
+
+LOCAL_SOURCES = {"files", "altfiles", "systemd", "mymachines"}
+
+SUBVOLUME_RE = re.compile(r'^SUBVOLUME="(.*)"', re.MULTILINE)
+SNAPPER_CONFIGS_RE = re.compile(r'^SNAPPER_CONFIGS="([^"]*)"', re.MULTILINE)
+ACTION_RE = re.compile(r"\[[^]]*\]")
+
+
+def is_subvolume(path):
+    # Subvolume roots are inode 256. Unlike btrfs subvolume show, this tells
+    # "gone" apart from "cannot tell".
+    try:
+        return os.stat(path).st_ino == 256
+    except FileNotFoundError:
+        return False
+    except OSError:
+        return True
+
+
+def uid_of(config):
+    try:
+        uid = int(config[len("home_"):])
+    except ValueError:
+        return None
+    return uid if UID_MIN <= uid < UID_MAX else None
+
+
+def resolves(uid):
+    try:
+        pwd.getpwuid(uid)
+    except KeyError:
+        return False
+    return True
+
+
+def passwd_is_local():
+    # A failed getpwuid only means "deleted" if nothing remote answers for
+    # passwd. Anything unparseable counts as remote.
+    try:
+        text = NSSWITCH.read_text(errors="replace")
+    except OSError:
+        return False
+
+    for line in text.splitlines():
+        name, sep, sources = line.split("#", 1)[0].partition(":")
+        if sep and name.strip() == "passwd":
+            found = set(ACTION_RE.sub(" ", sources).split())
+            return bool(found) and found <= LOCAL_SOURCES
+    return False
+
+
+def prune_sysconfig(config):
+    path = next((path.resolve() for path in SYSCONFIG if path.exists()), None)
+    if path is None:
+        return
+
+    text = path.read_text()
+    match = SNAPPER_CONFIGS_RE.search(text)
+    if match is None:
+        return
+
+    names = " ".join(name for name in match.group(1).split() if name != config)
+    pruned = text[:match.start()] + f'SNAPPER_CONFIGS="{names}"' + text[match.end():]
+    if pruned == text:
+        return
+
+    tmp = path.with_name(path.name + ".kde-linux-gc")
+    tmp.write_text(pruned)
+    os.chmod(tmp, 0o644)
+    tmp.replace(path)
+
+
+def run(*args):
+    return subprocess.run(args).returncode == 0
+
+
+def main():
+    if not CONFIGS.is_dir():
+        return 0
+
+    if not is_subvolume("/"):
+        print("/ is not a btrfs subvolume, refusing to run", file=sys.stderr)
+        return 0
+
+    local = passwd_is_local()
+    if not local:
+        print("passwd is not answered locally, only deleting configs whose home is gone")
+
+    for path in sorted(CONFIGS.glob("home_*")):
+        config = path.name
+        uid = uid_of(config)
+        if uid is None or not path.is_file():
+            continue
+
+        try:
+            found = SUBVOLUME_RE.findall(path.read_text(errors="replace"))
+        except OSError as error:
+            print(f"Cannot read {path}: {error}", file=sys.stderr)
+            continue
+        if not found:
+            continue
+        subvolume = found[-1]
+
+        if not is_subvolume(subvolume):
+            # delete-config opens the subvolume and fails with ENOENT, so do the
+            # bookkeeping ourselves. The snapshots went with the subvolume.
+            print(f"Deleting snapper config {config}, {subvolume} is gone")
+            prune_sysconfig(config)
+            path.unlink(missing_ok=True)
+            continue
+
+        if not local or resolves(uid):
+            continue
+
+        print(f"Deleting snapper config {config}, uid {uid} is gone")
+        # --no-dbus because snapperd is not necessarily up.
+        if not run("snapper", "--no-dbus", "--config", config, "delete-config"):
+            print(f"Failed to delete snapper config {config}, leaving it behind",
+                  file=sys.stderr)
+            continue
+
+        # Only now, delete refuses while .snapshots is still nested in there.
+        print(f"Deleting {subvolume}")
+        if not run("btrfs", "subvolume", "delete", subvolume):
+            print(f"Failed to delete {subvolume}, leaving it behind", file=sys.stderr)
+
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except OSError as error:
+        print(error, file=sys.stderr)
+        sys.exit(1)
diff --git a/mkosi.extra/usr/lib/systemd/system-preset/00-kde-linux.preset b/mkosi.extra/usr/lib/systemd/system-preset/00-kde-linux.preset
index b93b654d..79df5b9d 100644
--- a/mkosi.extra/usr/lib/systemd/system-preset/00-kde-linux.preset
+++ b/mkosi.extra/usr/lib/systemd/system-preset/00-kde-linux.preset
@@ -23,6 +23,8 @@ enable pcscd.socket
 enable plasma-setup.service
 enable plasmalogin.service
 enable ratbagd.service
+enable snapper-cleanup.timer
+enable snapper-timeline.timer
 enable systemd-oomd.service
 enable [email protected]
 enable thermald.service
@@ -48,6 +50,7 @@ enable kde-linux-configure-firefox.path
 enable kde-linux-iw-set-regdomain.path
 enable kde-linux-openqa-setup.service
 enable kde-linux-powertop.service
+enable kde-linux-snapper-home-gc.path
 enable kde-linux-volatile-var-lib-flatpak.service
 enable kde-linux-sysupdated.socket
 enable kde-linux-opt-cleaner-mask.service
diff --git a/mkosi.extra/usr/lib/systemd/system/[email protected] b/mkosi.extra/usr/lib/systemd/system/[email protected]
new file mode 100644
index 00000000..b41596ae
--- /dev/null
+++ b/mkosi.extra/usr/lib/systemd/system/[email protected]
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Instance name is the uid, courtesy of the [email protected] drop-in.
+
+[Unit]
+Description=Create Snapper Config for UID %i
+Documentation=https://invent.kde.org/kde-linux/kde-linux/-/work_items/745
+ConditionPathIsReadWrite=/etc
+ConditionKernelCommandLine=!kde-linux.live=1
+Conflicts=shutdown.target
+Before=shutdown.target
+
+[Service]
+Type=oneshot
+ExecCondition=/usr/bin/test %i -ge 1000
+ExecCondition=/usr/bin/test %i -lt 65534
+ExecStart=/usr/lib/snapper-home-config %i
diff --git a/mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.path b/mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.path
new file mode 100644
index 00000000..b4aaeffb
--- /dev/null
+++ b/mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.path
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Nothing runs at userdel time, so catch the passwd write instead. The cleanup
+# timer still covers homes that went away without passwd changing.
+
+[Unit]
+Description=Watch for Deleted Users
+ConditionPathIsReadWrite=/etc
+ConditionKernelCommandLine=!kde-linux.live=1
+
+[Path]
+PathChanged=/etc/passwd
+Unit=kde-linux-snapper-home-gc.service
+
+[Install]
+WantedBy=multi-user.target
diff --git a/mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.service b/mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.service
new file mode 100644
index 00000000..dbd219f7
--- /dev/null
+++ b/mkosi.extra/usr/lib/systemd/system/kde-linux-snapper-home-gc.service
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+[Unit]
+Description=Delete Snapper Configs for Users That Are Gone
+Documentation=https://invent.kde.org/kde-linux/kde-linux/-/work_items/745
+ConditionPathIsReadWrite=/etc
+ConditionKernelCommandLine=!kde-linux.live=1
+After=local-fs.target
+Conflicts=shutdown.target
+Before=shutdown.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/lib/snapper-home-gc
diff --git a/mkosi.extra/usr/lib/systemd/system/snapper-cleanup.service.d/50-kde-linux-home-gc.conf b/mkosi.extra/usr/lib/systemd/system/snapper-cleanup.service.d/50-kde-linux-home-gc.conf
new file mode 100644
index 00000000..5d434ef8
--- /dev/null
+++ b/mkosi.extra/usr/lib/systemd/system/snapper-cleanup.service.d/50-kde-linux-home-gc.conf
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Piggyback on snapper-cleanup.timer rather than adding a timer of our own.
+# Ordered first so the cleanup does not walk configs we are about to delete.
+
+[Unit]
+Wants=kde-linux-snapper-home-gc.service
+After=kde-linux-snapper-home-gc.service
diff --git a/mkosi.extra/usr/lib/systemd/system/[email protected]/50-kde-linux-snapper.conf b/mkosi.extra/usr/lib/systemd/system/[email protected]/50-kde-linux-snapper.conf
new file mode 100644
index 00000000..0b20957b
--- /dev/null
+++ b/mkosi.extra/usr/lib/systemd/system/[email protected]/50-kde-linux-snapper.conf
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+[Unit]
+Wants=kde-linux-snapper-home-config@%i.service
diff --git a/mkosi.extra/usr/lib/tmpfiles.d/kde-linux-snapper.conf b/mkosi.extra/usr/lib/tmpfiles.d/kde-linux-snapper.conf
new file mode 100644
index 00000000..ccc2c558
--- /dev/null
+++ b/mkosi.extra/usr/lib/tmpfiles.d/kde-linux-snapper.conf
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Snapper reads the list of active configs from the sysconfig file and fails
+# outright if it is missing, so make sure there is one. It only ever reaches an
+# installed system through the factory, which means systems updated from an
+# image without snapper never see it. Snapper fills in SNAPPER_CONFIGS itself.
+
+# The path is baked in at build time as CONF_DIR. Our buildstream one points at /etc/sysconfig,
+# matching btrfsmaintenance, while the Arch package uses /etc/conf.d, so that one
+# is a symlink for as long as we are on Arch to not break configs when moving to buildstream.
+d /etc/sysconfig 0755 root root -
+f /etc/sysconfig/snapper 0644 root root - SNAPPER_CONFIGS=""
+d /etc/conf.d 0755 root root -
+L /etc/conf.d/snapper - - - - /etc/sysconfig/snapper
+
+
+# Directories required by snapper for the per-user home configs.
+d /etc/snapper/configs 0755 root root -
diff --git a/mkosi.extra/usr/share/snapper/config-templates/home b/mkosi.extra/usr/share/snapper/config-templates/home
new file mode 100644
index 00000000..d758c344
--- /dev/null
+++ b/mkosi.extra/usr/share/snapper/config-templates/home
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+# Template for the per-user home configs created by snapper-home-config.
+# SUBVOLUME is filled in by snapper, ALLOW_USERS by the helper.
+
+SUBVOLUME=""
+FSTYPE="btrfs"
+
+# TODO
+# Space aware cleanup needs qgroup accounting. We run btrfs simple quotas
+# (kde-linux-btrfs.service), which account every extent to the subvolume that
+# allocated it first, so snapshots always look empty and the cleanup would
+# never fire. Stick to the count based limits below until that is sorted out.
+# With QGROUP empty neither of the two below does anything at all; they are
+# here as a starting point for when qgroups are sorted out. SPACE_LIMIT is the
+# share of the filesystem snapshots may occupy, FREE_LIMIT the share that has
+# to stay free.
+QGROUP=""
+SPACE_LIMIT="0.1"
+FREE_LIMIT="0.4"
+
+# ALLOW_USERS is set per user. SYNC_ACL puts an ACL on .snapshots so the owner
+# can browse their own snapshots, in Dolphin or otherwise, without root.
+ALLOW_USERS=""
+ALLOW_GROUPS=""
+SYNC_ACL="yes"
+
+BACKGROUND_COMPARISON="no"
+
+# No pre/post snapshots here, there is no package manager to hook.
+NUMBER_CLEANUP="no"
+NUMBER_MIN_AGE="1800"
+NUMBER_LIMIT="0"
+NUMBER_LIMIT_IMPORTANT="0"
+
+TIMELINE_CREATE="yes"
+TIMELINE_CLEANUP="yes"
+TIMELINE_MIN_AGE="1800"
+TIMELINE_LIMIT_HOURLY="6"
+TIMELINE_LIMIT_DAILY="7"
+TIMELINE_LIMIT_WEEKLY="4"
+TIMELINE_LIMIT_MONTHLY="2"
+TIMELINE_LIMIT_QUARTERLY="0"
+TIMELINE_LIMIT_YEARLY="0"
+
+EMPTY_PRE_POST_CLEANUP="yes"
+EMPTY_PRE_POST_MIN_AGE="1800"
diff --git a/mkosi.finalize.d/40-core.sh.chroot b/mkosi.finalize.d/40-core.sh.chroot
index 23882e2d..8d131646 100755
--- a/mkosi.finalize.d/40-core.sh.chroot
+++ b/mkosi.finalize.d/40-core.sh.chroot
@@ -166,6 +166,14 @@ EOF
 # See: https://wiki.archlinux.org/title/Locate#Btrfs
 if [ -f /etc/updatedb.conf ]; then
     sed -i 's/^PRUNE_BIND_MOUNTS = "yes"/PRUNE_BIND_MOUNTS = "no"/' /etc/updatedb.conf
+
+    # Keep the per-user home snapshots out of the index.
+    if grep -q '^PRUNENAMES' /etc/updatedb.conf; then
+        sed -i '/^PRUNENAMES/ s/"[[:space:]]*$/ .snapshots"/' /etc/updatedb.conf
+    else
+        echo 'PRUNENAMES = ".snapshots"' >> /etc/updatedb.conf
+    fi
+    grep '^PRUNENAMES' /etc/updatedb.conf | grep -q '\.snapshots' || exit 1
 fi
 
 # Clean up final remnants of the build process
diff --git a/mkosi.finalize.d/99-etc.sh.chroot b/mkosi.finalize.d/99-etc.sh.chroot
index 164952aa..ab78add7 100755
--- a/mkosi.finalize.d/99-etc.sh.chroot
+++ b/mkosi.finalize.d/99-etc.sh.chroot
@@ -54,6 +54,14 @@ rm --force \
     hostname \
     locale.conf
 
+# Snapper rewrites this file every time a config is created or deleted, so it is
+# state, not configuration. Left in the factory, an image update could put the
+# empty version back and orphan every config. tmpfiles.d creates it instead.
+# Compatibility with Arch till we are on buildstream since Arch uses conf.d.
+rm --force \
+    /usr/share/factory/etc/conf.d/snapper \
+    /usr/share/factory/etc/sysconfig/snapper
+
     # These are actually needed and must be copied from the factory onto the host. If they are missing useradd will
     # not configure subuids, breaking podman for instance.
     # subuid \
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.