[kde-linux/os-autoinst-distri-kdelinux] /: Add snapper and RootFSv3 test cases.

Hadi Chokr <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 10107cb875be25a10f178bab53f253a887698c15 by Hadi Chokr.
Committed on 19/08/2026 at 12:19.
Pushed by silverhadch into branch 'master'.

Add snapper and RootFSv3 test cases.

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

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

M  +1    -0    README.md
A  +441  -0    extensions/openqa/usr/lib/kde-linux-openqa/tests/snapper.py
M  +1    -0    main.pm
A  +10   -0    tests/kdelinux/system/snapper.py

https://invent.kde.org/kde-linux/os-autoinst-distri-kdelinux/-/commit/10107cb875be25a10f178bab53f253a887698c15

diff --git a/README.md b/README.md
index f4aefa0..cce32b7 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ openQA workflows.
 | `kdelinux/app/package_compatibility_helper` | Checks the Package Compatibility Helper opens for an unsupported package type. |
 | `kdelinux/app/discover_install` | Installs, launches, and uninstalls an application through Discover. |
 | `kdelinux/system/system_development` | Toggles developer mode and runs set-up-system-development to install kde-builder, checking each works. |
+| `kdelinux/system/snapper` | Checks the snapper setup for the per-user home subvolumes; the files it needs in `/etc`, the home template, the config created on login, snapshots taken by the user, and the config, snapshots and home going away with the user. |
 | `kdelinux/system/collect_logs` | Runs collect-logs and checks it produces a redacted `.tar.zst` archive. |
 | `kdelinux/system/desktop_session_services` | Checks whether any essential process has ever crashed. Every crash that dumps core is recorded by systemd-coredump, so we ask coredumpctl and fail if any of the processes we care about show up. This test should be run at the very *end* of the test suite. |
 | `common/shutdown` | Executes `systemctl poweroff` and waits for shutdown |
diff --git a/extensions/openqa/usr/lib/kde-linux-openqa/tests/snapper.py b/extensions/openqa/usr/lib/kde-linux-openqa/tests/snapper.py
new file mode 100644
index 0000000..6d0a402
--- /dev/null
+++ b/extensions/openqa/usr/lib/kde-linux-openqa/tests/snapper.py
@@ -0,0 +1,441 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+
+import os
+import pwd
+import re
+import shutil
+import subprocess
+import time
+import unittest
+from pathlib import Path
+from lib.common import user_manager
+from lib.sut import openqa_junit_xml
+
+# Checks the per-user home subvolume snapshots: the config created on login,
+# snapshotting a home as its owner, the timeline picking it up, and the config,
+# snapshots and home going away with the user.
+
+CONFIGS = Path('/etc/snapper/configs')
+SYSCONFIG = Path('/etc/sysconfig/snapper')
+UPDATEDB = Path('/etc/updatedb.conf')
+
+CONFIG_HELPER = '/usr/lib/snapper-home-config'
+GC_HELPER = '/usr/lib/snapper-home-gc'
+
+UNITS = (
+    'snapper-timeline.timer',
+    'snapper-cleanup.timer',
+    'kde-linux-snapper-home-gc.path',
+)
+
+# Throwaway accounts, high enough to not collide with the installed user but
+# still inside the range the helpers act on. No account exists for GONE_UID.
+SUBVOLUME_USER = 'openqasnapper'
+SUBVOLUME_UID = 61234
+PLAIN_USER = 'openqaplain'
+PLAIN_UID = 61235
+GONE_UID = 61236
+SYSTEM_UID = 999
+
+GC_TIMEOUT = 90
+
+SNAPPER_CONFIGS_RE = re.compile(r'^SNAPPER_CONFIGS="([^"]*)"', re.MULTILINE)
+
+
+def _run(*args):
+    return subprocess.run(args, capture_output=True, text=True)
+
+
+def _journal(unit):
+    return _run('journalctl', '--no-pager', '-o', 'cat', '-n', '50', '-u', unit).stdout.strip()
+
+
+def _is_subvolume(path):
+    # Subvolume roots are inode 256.
+    try:
+        return os.stat(path).st_ino == 256
+    except OSError:
+        return False
+
+
+def _settings(path):
+    settings = {}
+    for line in Path(path).read_text(errors='replace').splitlines():
+        line = line.strip()
+        if not line or line.startswith('#') or '=' not in line:
+            continue
+        key, _, value = line.partition('=')
+        settings[key.strip()] = value.strip().strip('"')
+    return settings
+
+
+def _active_configs():
+    match = SNAPPER_CONFIGS_RE.search(SYSCONFIG.read_text(errors='replace'))
+    return match.group(1).split() if match else []
+
+
+def _set_active_configs(names):
+    text = SYSCONFIG.read_text(errors='replace')
+    line = f'SNAPPER_CONFIGS="{" ".join(names)}"'
+    match = SNAPPER_CONFIGS_RE.search(text)
+    if match is None:
+        SYSCONFIG.write_text(f'{text}\n{line}\n')
+        return
+    SYSCONFIG.write_text(text[:match.start()] + line + text[match.end():])
+
+
+def _snapshot_numbers(subvolume):
+    return sorted(
+        path.name
+        for path in (Path(subvolume) / '.snapshots').glob('*')
+        if path.name.isdigit())
+
+
+def _listing(path):
+    try:
+        return sorted(entry.name for entry in Path(path).iterdir()) or 'nothing'
+    except OSError as error:
+        return str(error)
+
+
+def _wait(predicate, timeout=30):
+    # snapperd finishes deletions after the call returns, and btrfs frees a
+    # subvolume in the background, so nothing here is true the instant we ask.
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline and not predicate():
+        time.sleep(1)
+    return predicate()
+
+
+class SnapperTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(self):
+        self.user = pwd.getpwnam(user_manager.installed().name)
+        self.user_config = f'home_{self.user.pw_uid}'
+        self.snapshots = Path(self.user.pw_dir) / '.snapshots'
+
+    @classmethod
+    def tearDownClass(self):
+        for name, uid in ((SUBVOLUME_USER, SUBVOLUME_UID), (PLAIN_USER, PLAIN_UID)):
+            self._forget_account(name, uid)
+        self._forget_config(f'home_{GONE_UID}')
+        self._forget_config(f'home_{SYSTEM_UID}')
+
+    @classmethod
+    def _forget_config(self, config):
+        path = CONFIGS / config
+        if path.is_file():
+            # --no-dbus because snapperd is not necessarily up.
+            _run('snapper', '--no-dbus', '--config', config, 'delete-config')
+            path.unlink(missing_ok=True)
+        active = _active_configs()
+        if config in active:
+            _set_active_configs([name for name in active if name != config])
+
+    @classmethod
+    def _forget_account(self, name, uid):
+        self._forget_config(f'home_{uid}')
+        _run('userdel', '--force', name)
+        home = Path('/home') / name
+        if _is_subvolume(home):
+            if _run('btrfs', 'subvolume', 'delete', '--recursive', str(home)).returncode != 0:
+                _run('btrfs', 'subvolume', 'delete', str(home))
+        elif home.is_dir():
+            shutil.rmtree(home, ignore_errors=True)
+
+    def _must_run(self, *args):
+        result = _run(*args)
+        self.assertEqual(
+            result.returncode, 0,
+            f'{" ".join(args)} failed ({result.returncode}): {result.stderr.strip()}')
+        return result
+
+    def _as_user(self, name, *args):
+        return _run('sudo', '--non-interactive', '-u', name, *args)
+
+    def _create_account(self, name, uid, subvolume):
+        self._forget_account(name, uid)
+        self.addCleanup(self._forget_account, name, uid)
+
+        home = Path('/home') / name
+        if subvolume:
+            self._must_run('btrfs', 'subvolume', 'create', str(home))
+        else:
+            home.mkdir(mode=0o700, exist_ok=True)
+
+        self._must_run('useradd', '--no-create-home', '--home-dir', str(home),
+                       '--uid', str(uid), '--shell', '/usr/bin/nologin', name)
+        entry = pwd.getpwnam(name)
+        os.chown(home, entry.pw_uid, entry.pw_gid)
+        os.chmod(home, 0o700)
+        return home
+
+    def _account_with_config(self):
+        home = self._create_account(SUBVOLUME_USER, SUBVOLUME_UID, subvolume=True)
+        unit = self._start_config_unit(SUBVOLUME_UID)
+        config = CONFIGS / f'home_{SUBVOLUME_UID}'
+        self.assertTrue(config.is_file(), f'{config} was not created:\n{_journal(unit)}')
+        return home, config
+
+    def _start_config_unit(self, uid):
+        unit = f'kde-linux-snapper-home-config@{uid}.service'
+        result = _run('systemctl', 'start', unit)
+        self.assertEqual(
+            result.returncode, 0,
+            f'{unit} failed: {result.stderr.strip()}\n{_journal(unit)}')
+        return unit
+
+    def test_01_units_enabled_and_running(self):
+        """The snapper timers and the passwd watcher must be enabled and running."""
+        problems = []
+        for unit in UNITS:
+            enabled = _run('systemctl', 'is-enabled', unit).stdout.strip()
+            if enabled != 'enabled':
+                problems.append(f'  {unit} is {enabled or "not installed"}, expected enabled')
+                continue
+            active = _run('systemctl', 'is-active', unit).stdout.strip()
+            if active != 'active':
+                problems.append(f'  {unit} is {active}, expected active')
+        self.assertFalse(problems, 'unit problems:\n' + '\n'.join(problems))
+
+    def test_02_user_home_is_a_subvolume(self):
+        """The installed user's home must be a subvolume, the rest hangs off that."""
+        self.assertTrue(
+            _is_subvolume(self.user.pw_dir),
+            f'{self.user.pw_dir} is not a btrfs subvolume, it cannot be snapshotted')
+
+    def test_03_login_created_the_home_config(self):
+        """Logging in must have created the user's home config through [email protected]."""
+        unit = f'kde-linux-snapper-home-config@{self.user.pw_uid}.service'
+        self.assertNotEqual(
+            _run('systemctl', 'is-failed', unit).stdout.strip(), 'failed',
+            f'{unit} failed:\n{_journal(unit)}')
+
+        path = CONFIGS / self.user_config
+        self.assertTrue(path.is_file(), f'{path} was not created on login:\n{_journal(unit)}')
+
+        settings = _settings(path)
+        self.assertEqual(
+            settings.get('SUBVOLUME'), self.user.pw_dir,
+            f'{path} does not point at the user home')
+        self.assertEqual(
+            settings.get('ALLOW_USERS'), self.user.pw_name,
+            f'{path} does not allow {self.user.pw_name}')
+
+    def test_04_snapshots_directory_is_readable_by_the_user(self):
+        """SYNC_ACL must let the owner browse their own snapshots without root."""
+        self.assertTrue(self.snapshots.is_dir(), f'{self.snapshots} does not exist')
+        result = self._as_user(self.user.pw_name, 'ls', '--almost-all', str(self.snapshots))
+        self.assertEqual(
+            result.returncode, 0,
+            f'{self.user.pw_name} cannot read {self.snapshots}: {result.stderr.strip()}')
+
+    def test_05_user_can_snapshot_their_own_home(self):
+        """ALLOW_USERS must let the user create, list and delete snapshots."""
+        create = self._as_user(
+            self.user.pw_name, 'snapper', '--config', self.user_config,
+            'create', '--print-number', '--description', 'kde-linux-openqa')
+        self.assertEqual(
+            create.returncode, 0,
+            f'{self.user.pw_name} could not create a snapshot: {create.stderr.strip()}')
+
+        number = create.stdout.strip()
+        self.assertTrue(number.isdigit(), f'snapper printed {number!r} instead of a number')
+        self.addCleanup(
+            _run, 'snapper', '--no-dbus', '--config', self.user_config, 'delete', number)
+        self.assertIn(
+            number, _snapshot_numbers(self.user.pw_dir),
+            f'snapshot {number} did not appear under {self.snapshots}')
+
+        listing = self._as_user(
+            self.user.pw_name, 'snapper', '--config', self.user_config, 'list')
+        self.assertEqual(
+            listing.returncode, 0,
+            f'{self.user.pw_name} could not list snapshots: {listing.stderr.strip()}')
+        self.assertIn('kde-linux-openqa', listing.stdout, f'snapshot {number} is not listed')
+
+        delete = self._as_user(
+            self.user.pw_name, 'snapper', '--config', self.user_config, 'delete', number)
+        self.assertEqual(
+            delete.returncode, 0,
+            f'{self.user.pw_name} could not delete their snapshot: {delete.stderr.strip()}')
+        snapshot = self.snapshots / number
+        self.assertTrue(
+            _wait(lambda: not (snapshot / 'info.xml').exists()),
+            f'{self.user.pw_name} deleted snapshot {number} but snapper kept it, '
+            f'{snapshot} still holds {_listing(snapshot)}')
+
+    def test_06_baloo_excludes_the_snapshots(self):
+        """Baloo must parse the exclusion, not merely have it sitting in a config file."""
+        result = self._as_user(
+            self.user.pw_name, 'balooctl6', 'config', 'list', 'excludeFolders')
+        self.assertEqual(
+            result.returncode, 0,
+            f'balooctl6 could not list excludeFolders: {result.stderr.strip()}')
+
+        excluded = {
+            line.strip().rstrip('/')
+            for line in result.stdout.splitlines()
+            if line.strip()
+        }
+        self.assertIn(
+            str(self.snapshots), excluded,
+            f'baloo does not exclude {self.snapshots}, it excludes {sorted(excluded)}')
+
+    def test_07_updatedb_prunes_the_snapshots(self):
+        """locate must not index the snapshots either."""
+        if not UPDATEDB.is_file():
+            self.skipTest(f'{UPDATEDB} is not present')
+
+        marker = Path(self.user.pw_dir) / 'kde-linux-openqa-locate-marker'
+        marker.write_text('kde-linux-openqa\n')
+        os.chown(marker, self.user.pw_uid, self.user.pw_gid)
+        self.addCleanup(marker.unlink, True)
+
+        create = self._as_user(
+            self.user.pw_name, 'snapper', '--config', self.user_config,
+            'create', '--print-number', '--description', 'kde-linux-openqa-locate')
+        self.assertEqual(
+            create.returncode, 0,
+            f'could not snapshot the marker: {create.stderr.strip()}')
+        number = create.stdout.strip()
+        self.addCleanup(
+            _run, 'snapper', '--no-dbus', '--config', self.user_config, 'delete', number)
+
+        snapshotted = self.snapshots / number / 'snapshot' / marker.name
+        self.assertTrue(
+            snapshotted.exists(),
+            f'{snapshotted} is missing, the snapshot did not capture the marker')
+
+        database = Path('/tmp/kde-linux-openqa-locate.db')
+        self.addCleanup(database.unlink, True)
+        self._must_run(
+            'updatedb', '--output', str(database),
+            '--database-root', self.user.pw_dir)
+
+        found = _run('locate', '--database', str(database), marker.name).stdout.split()
+        self.assertIn(
+            str(marker), found,
+            f'locate did not index {marker} at all, so this test proves nothing')
+        self.assertNotIn(
+            str(snapshotted), found,
+            f'locate indexed {snapshotted}, .snapshots is not pruned')
+
+    def test_08_config_helper_skips_system_uids(self):
+        """Neither the helper nor the unit may create a config for a system uid."""
+        result = _run(CONFIG_HELPER, str(SYSTEM_UID))
+        self.assertEqual(
+            result.returncode, 0,
+            f'{CONFIG_HELPER} {SYSTEM_UID} failed: {result.stderr.strip()}')
+
+        # The unit guards the range with ExecCondition, which skips instead of failing.
+        self._start_config_unit(SYSTEM_UID)
+        self.assertFalse(
+            (CONFIGS / f'home_{SYSTEM_UID}').exists(),
+            f'a config was created for system uid {SYSTEM_UID}')
+
+    def test_09_config_helper_skips_non_subvolume_homes(self):
+        """A plain directory home has nothing to snapshot, so it gets no config."""
+        self._create_account(PLAIN_USER, PLAIN_UID, subvolume=False)
+        self._start_config_unit(PLAIN_UID)
+        self.assertFalse(
+            (CONFIGS / f'home_{PLAIN_UID}').exists(),
+            f'a config was created for {PLAIN_USER}, whose home is not a subvolume')
+
+    def test_10_config_created_for_a_subvolume_home(self):
+        """A subvolume home gets a config keyed on the uid and allowed to its owner."""
+        home, path = self._account_with_config()
+        config = path.name
+
+        settings = _settings(path)
+        self.assertEqual(settings.get('SUBVOLUME'), str(home), f'{path} points elsewhere')
+        self.assertEqual(
+            settings.get('ALLOW_USERS'), SUBVOLUME_USER, f'{path} does not allow its owner')
+        self.assertTrue((home / '.snapshots').is_dir(), f'{home}/.snapshots was not created')
+
+        rerun = _run(CONFIG_HELPER, str(SUBVOLUME_UID))
+        self.assertEqual(
+            rerun.returncode, 0, f'a second run failed: {rerun.stderr.strip()}')
+        self.assertEqual(
+            _active_configs().count(config), 1,
+            f'{config} ended up in {SYSCONFIG} more than once')
+
+    def test_11_timeline_snapshots_the_home_configs(self):
+        """The timeline must pick the home configs up out of the sysconfig file."""
+        home = Path(self.user.pw_dir)
+        before = _snapshot_numbers(home)
+        result = _run('systemctl', 'start', 'snapper-timeline.service')
+        self.assertEqual(
+            result.returncode, 0,
+            'snapper-timeline.service failed: '
+            f'{result.stderr.strip()}\n{_journal("snapper-timeline.service")}')
+
+        def _new_timeline_snapshot():
+            listing = self._as_user(
+                self.user.pw_name, 'snapper', '--config', self.user_config,
+                'list', '--columns', 'number,type,date').stdout
+            for line in listing.splitlines():
+                fields = [field.strip() for field in line.split('|')]
+                if len(fields) < 3 or fields[0] not in set(_snapshot_numbers(home)) - set(before):
+                    continue
+                if fields[1] != 'timeline':
+                    continue
+                try:
+                    created = time.mktime(time.strptime(fields[2], '%a %d %b %Y %I:%M:%S %p %Z'))
+                except ValueError:
+                    continue
+                if time.time() - created < 60:
+                    return fields[0]
+            return None
+
+        number = _wait(lambda: _new_timeline_snapshot() is not None, 60) and _new_timeline_snapshot()
+
+        for leftover in sorted(set(_snapshot_numbers(home)) - set(before)):
+            self.addCleanup(
+                _run, 'snapper', '--no-dbus', '--config', self.user_config, 'delete', leftover)
+
+        self.assertIsNotNone(
+            number,
+            f'the timeline did not snapshot {home}:\n{_journal("snapper-timeline.service")}')
+
+    def test_12_gc_deletes_configs_whose_home_is_gone(self):
+        """A config pointing at a subvolume that no longer exists must be collected."""
+        config = f'home_{GONE_UID}'
+        path = CONFIGS / config
+        self.addCleanup(self._forget_config, config)
+
+        path.write_text(f'SUBVOLUME="/home/kde-linux-openqa-gone-{GONE_UID}"\nFSTYPE="btrfs"\n')
+        _set_active_configs(_active_configs() + [config])
+
+        result = _run(GC_HELPER)
+        self.assertEqual(result.returncode, 0, f'{GC_HELPER} failed: {result.stderr.strip()}')
+        self.assertFalse(
+            path.exists(), f'{config} was kept: {result.stdout.strip()}')
+        self.assertNotIn(
+            config, _active_configs(), f'{config} was not pruned from {SYSCONFIG}')
+
+    def test_13_deleting_a_user_collects_their_config_and_home(self):
+        """Deleting a user must trip the passwd watcher, taking the config and the home with it."""
+        home, config = self._account_with_config()
+
+        # The accounts the earlier tests create and delete write /etc/passwd often
+        # enough to trip the unit's start rate limit, which would keep the watcher
+        # from starting it at all here.
+        _run('systemctl', 'reset-failed', 'kde-linux-snapper-home-gc.service')
+
+        self._must_run('userdel', '--force', SUBVOLUME_USER)
+        _wait(lambda: not config.exists() and not home.exists(), GC_TIMEOUT)
+
+        journal = _journal('kde-linux-snapper-home-gc.service')
+        self.assertFalse(
+            config.exists(),
+            f'{config} was still there {GC_TIMEOUT}s after deleting {SUBVOLUME_USER}:\n{journal}')
+        self.assertNotIn(
+            f'home_{SUBVOLUME_UID}', _active_configs(),
+            f'home_{SUBVOLUME_UID} is still listed in {SYSCONFIG}:\n{journal}')
+        self.assertFalse(home.exists(), f'{home} was not deleted with the account:\n{journal}')
+
+
+if __name__ == '__main__':
+    openqa_junit_xml.run(SnapperTests, 'snapper')
diff --git a/main.pm b/main.pm
index 7144909..65431d8 100755
--- a/main.pm
+++ b/main.pm
@@ -63,6 +63,7 @@ sub test_kdelinux {
     loadtest 'kdelinux/app/package_compatibility_helper.py';
     loadtest 'kdelinux/app/discover_install.py';
     loadtest 'kdelinux/system/system_development.py';
+    loadtest 'kdelinux/system/snapper.py';
     loadtest 'kdelinux/system/logout.py';
     loadtest 'kdelinux/sddm/sddm_password_login.py';
     loadtest 'kdelinux/desktop/secret_service_persistence_get.py';
diff --git a/tests/kdelinux/system/snapper.py b/tests/kdelinux/system/snapper.py
new file mode 100644
index 0000000..b6c0d2f
--- /dev/null
+++ b/tests/kdelinux/system/snapper.py
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Hadi Chokr <[email protected]>
+from testapi import *
+from lib.test import cli_test
+
+def run(self):
+    # Runs as root because it creates and deletes accounts and home subvolumes.
+    # The timeout covers waiting on the passwd watcher and a timeline run.
+    test = cli_test.CliTest('snapper', timeout=300)
+    test.run_python()
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.