[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 \