From 579f3bb071d6015aea6f94f87eb37d1da40ac7cc Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 23:03:31 +0300 Subject: [PATCH] Keep systemd-managed directories inside root snapshots --- hydra/image.py | 37 +++++++++++++++++++++++ tests/test_image.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/hydra/image.py b/hydra/image.py index 973074c..254aa13 100644 --- a/hydra/image.py +++ b/hydra/image.py @@ -266,6 +266,42 @@ def brand_os_release(root): write(root, 'etc/os-release', '\n'.join(retained) + '\n') +def configure_managed_root_tmpfiles(root): + """Keep automatic systemd directories inside the recoverable root snapshot. + + Same-name overrides retain the installed vendor policy, changing only the + three subvolume-creation rules. Do not mask nspawn cleanup/socket rules or + the separately mounted /home. Unexpected vendor changes need a review. + """ + overrides = [] + for filename, target in (('home.conf', '/srv'), + ('portables.conf', '/var/lib/portables'), + ('systemd-nspawn.conf', '/var/lib/machines')): + vendor = root_path(root, 'usr/lib/tmpfiles.d/' + filename) + destination = 'etc/tmpfiles.d/' + filename + override = root_path(root, destination, follow_leaf=False) + if vendor.is_symlink() or not vendor.is_file(): + raise ImageError(f'Required ordinary vendor tmpfiles policy is missing: {filename}') + if override.is_symlink() or override.exists(): + raise ImageError(f'Refusing to replace an existing tmpfiles override: {filename}') + lines = vendor.read_text().splitlines(keepends=True) + matches = [] + for index, line in enumerate(lines): + fields = line.split() + if fields and not fields[0].startswith('#') and len(fields) > 1 and fields[1] == target: + matches.append((index, fields[0])) + if len(matches) != 1 or matches[0][1] not in ('q', 'Q'): + raise ImageError(f'Expected one vendor tmpfiles q/Q rule for {target}: {filename}') + index = matches[0][0] + line = lines[index] + start = len(line) - len(line.lstrip(' \t')) + lines[index] = line[:start] + 'd' + line[start + 1:] + overrides.append((destination, ''.join(lines))) + # Validate the complete policy before publishing any same-name overrides. + for destination, content in overrides: + write(root, destination, content) + + def configure_rootfs(root, source): root, source = Path(root), Path(source) # Refuse incomplete integrations before changing the exported copy. @@ -281,6 +317,7 @@ def configure_rootfs(root, source): alias.symlink_to(kernel.name) sanitize_rootfs(root) copy_overlay(root, source / 'image/rootfs') + configure_managed_root_tmpfiles(root) brand_os_release(root) write(root, 'etc/hydra/live-mode/dracut.conf', (source / 'image/dracut.conf').read_text()) root_path(root, 'etc/hydra/live-mode/dracut.conf.d').mkdir() diff --git a/tests/test_image.py b/tests/test_image.py index 22714a8..52a24db 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -23,6 +23,17 @@ class ImageTests(unittest.TestCase): self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) / 'rootfs' self.root.mkdir() + self.vendor_tmpfiles = { + 'home.conf': '# vendor home policy\nQ /home 0755 - - -\nq /srv 0755 - - -\n', + 'portables.conf': '# vendor portables policy\nQ /var/lib/portables 0700\n', + 'systemd-nspawn.conf': ( + '# vendor nspawn policy\nQ /var/lib/machines 0700 - - -\n' + 'R! /var/lib/machines/.#*\nR! /.#machine.*\n' + 'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n' + 'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'), + } + for name, content in self.vendor_tmpfiles.items(): + self.file('usr/lib/tmpfiles.d/' + name, content) def file(self, relative, content='preserved'): path = self.root / relative @@ -35,6 +46,67 @@ class ImageTests(unittest.TestCase): self.file('boot/vmlinuz-' + version, 'kernel') return version + def test_managed_root_tmpfiles_creates_ordinary_directories_without_losing_vendor_rules(self): + self.kernel() + source = Path(__file__).resolve().parents[1] + retained = [self.file(path + '/retained-data', 'user data') + for path in ('srv', 'var/lib/portables', 'var/lib/machines')] + image.configure_rootfs(self.root, source) + expected = { + 'home.conf': '# vendor home policy\nQ /home 0755 - - -\nd /srv 0755 - - -\n', + 'portables.conf': '# vendor portables policy\nd /var/lib/portables 0700\n', + 'systemd-nspawn.conf': ( + '# vendor nspawn policy\nd /var/lib/machines 0700 - - -\n' + 'R! /var/lib/machines/.#*\nR! /.#machine.*\n' + 'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n' + 'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'), + } + for name, content in expected.items(): + with self.subTest(name=name): + override = self.root / 'etc/tmpfiles.d' / name + self.assertTrue(override.is_file(), f'missing managed-root policy: {name}') + self.assertEqual(override.read_text(), content) + self.assertEqual(override.stat().st_mode & 0o777, 0o644) + self.assertEqual((self.root / 'usr/lib/tmpfiles.d' / name).read_text(), + self.vendor_tmpfiles[name]) + self.assertEqual([path.read_text() for path in retained], ['user data'] * 3) + + def test_managed_root_tmpfiles_refuses_missing_duplicate_or_changed_vendor_rule(self): + import shutil + self.kernel() + source = Path(__file__).resolve().parents[1] + for index, content in enumerate((None, '# no managed path\n', 'd /var/lib/machines 0700\n', + 'Q /var/lib/machines 0700\nq /var/lib/machines 0700\n', + 'Q /var/lib/machines 0700\nd /var/lib/machines 0700\n')): + with self.subTest(content=content): + root = Path(self.tmp.name) / ('drift-' + str(index)) + shutil.copytree(self.root, root) + vendor = root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf' + if content is None: + vendor.unlink() + else: + vendor.write_text(content) + with self.assertRaisesRegex(image.ImageError, 'tmpfiles'): + image.configure_rootfs(root, source) + self.assertFalse((root / 'etc/tmpfiles.d/home.conf').exists()) + + def test_managed_root_tmpfiles_refuses_existing_override(self): + self.kernel() + original = self.file('etc/tmpfiles.d/home.conf', 'd /srv 0710 local local -\n') + with self.assertRaisesRegex(image.ImageError, 'tmpfiles'): + image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) + self.assertEqual(original.read_text(), 'd /srv 0710 local local -\n') + + def test_managed_root_tmpfiles_refuses_vendor_symlink(self): + self.kernel() + original = self.file('usr/lib/tmpfiles.d/other.conf', 'Q /var/lib/machines 0700\n') + vendor = self.root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf' + vendor.unlink() + vendor.symlink_to('other.conf') + with self.assertRaisesRegex(image.ImageError, 'tmpfiles'): + image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) + self.assertEqual(original.read_text(), 'Q /var/lib/machines 0700\n') + def test_installs_independent_updater_and_base_recovery_assets(self): self.kernel() source = Path(__file__).resolve().parents[1]