"""An installed-guest update must be bounded, inspectable and reversible.""" import importlib.util import json from pathlib import Path import subprocess import sys import tempfile import unittest from unittest.mock import patch import zipfile class GuestUpdateTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.script = Path(__file__).resolve().parents[1] / 'dev/guest-update.py' def setUp(self): self.assertTrue(self.script.is_file(), 'guest update helper must exist') spec = importlib.util.spec_from_file_location('guest_update', self.script) self.mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(self.mod) self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.base = Path(self.tmp.name) self.source = self.base / 'source' for name, value in {'hydra/__init__.py': '', 'hydra/cli.py': 'NEW = True\n', 'bin/hydra': '#!/usr/bin/python3\n', 'image/rootfs/etc/motd': 'Hydra OS\n', 'image/rootfs/usr/share/hydra/fastfetch.txt': 'hydra logo\n'}.items(): dest = self.source / name dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(value) self.root = self.base / 'guest' marker = self.root / 'usr/share/hydra/build-manifest.json' marker.parent.mkdir(parents=True) marker.write_text('{"original": true}\n') self.old = self.root / 'usr/lib/hydra/hydra/cli.py' self.old.parent.mkdir(parents=True) self.old.write_text('OLD = True\n') self.bundle = self.base / 'update.pyz' def build(self): return self.mod.build_bundle(self.source, self.bundle) def test_dry_run_preserves_installed_files_and_user_disk(self): self.build() disk = self.root / 'home/user/.local/state/hydra/work/disk.qcow2' disk.parent.mkdir(parents=True) disk.write_bytes(b'user disk') result = self.mod.apply_bundle(self.bundle, self.root) self.assertFalse(result['applied']) self.assertEqual(self.old.read_text(), 'OLD = True\n') self.assertEqual(disk.read_bytes(), b'user disk') self.assertFalse((self.root / 'var/lib/hydra/updates').exists()) def test_apply_saves_backup_and_preserves_original_image_evidence(self): self.build() result = self.mod.apply_bundle(self.bundle, self.root, execute=True) self.assertTrue(result['applied']) self.assertEqual(self.old.read_text(), 'NEW = True\n') backup = Path(result['backup']) self.assertEqual((backup / 'usr/lib/hydra/hydra/cli.py').read_text(), 'OLD = True\n') self.assertEqual(json.loads((self.root / 'usr/share/hydra/build-manifest.json').read_text()), {'original': True}) self.assertTrue((self.root / 'usr/share/hydra/runtime-update.json').is_file()) self.assertEqual((self.root / 'usr/share/hydra/fastfetch.txt').read_text(), 'hydra logo\n') def test_branding_preserves_vendor_release_and_backs_up_etc_symlink(self): vendor = self.root / 'usr/lib/os-release' vendor.parent.mkdir(parents=True, exist_ok=True) vendor.write_text('NAME="Gentoo"\nID=gentoo\nVERSION_ID=2.18\n') release = self.root / 'etc/os-release' release.parent.mkdir(parents=True) release.symlink_to('../usr/lib/os-release') self.build() result = self.mod.apply_bundle(self.bundle, self.root, execute=True) self.assertFalse(release.is_symlink()) self.assertIn('PRETTY_NAME="Hydra OS"', release.read_text()) self.assertIn('ID_LIKE=gentoo', release.read_text()) self.assertIn('VERSION_ID=2.18', release.read_text()) self.assertEqual(vendor.read_text(), 'NAME="Gentoo"\nID=gentoo\nVERSION_ID=2.18\n') saved = Path(result['backup']) / 'etc/os-release' self.assertTrue(saved.is_symlink()) self.assertEqual(str(saved.readlink()), '../usr/lib/os-release') def test_non_hydra_root_is_refused_before_changes(self): self.build() (self.root / 'usr/share/hydra/build-manifest.json').unlink() with self.assertRaisesRegex(self.mod.UpdateError, 'Hydra'): self.mod.apply_bundle(self.bundle, self.root, execute=True) self.assertEqual(self.old.read_text(), 'OLD = True\n') def test_modified_payload_is_refused_before_changes(self): self.build() other = self.base / 'tampered.pyz' with zipfile.ZipFile(self.bundle) as src, zipfile.ZipFile(other, 'w') as dst: for name in src.namelist(): data = b'CORRUPTED\n' if name == 'payload/usr/lib/hydra/hydra/cli.py' else src.read(name) dst.writestr(name, data) with self.assertRaisesRegex(self.mod.UpdateError, 'checksum'): self.mod.apply_bundle(other, self.root, execute=True) self.assertEqual(self.old.read_text(), 'OLD = True\n') def test_symlink_destination_cannot_redirect_privileged_update(self): self.build() outside = self.base / 'outside' outside.write_text('KEEP\n') self.old.unlink() self.old.symlink_to(outside) with self.assertRaisesRegex(self.mod.UpdateError, '[Ss]ymlink'): self.mod.apply_bundle(self.bundle, self.root, execute=True) self.assertEqual(outside.read_text(), 'KEEP\n') def test_bundle_never_overwrites_an_existing_output(self): self.bundle.write_bytes(b'keep this artifact') with self.assertRaises((FileExistsError, self.mod.UpdateError)): self.build() self.assertEqual(self.bundle.read_bytes(), b'keep this artifact') def test_self_contained_bundle_applies_without_source_checkout(self): self.build() result = subprocess.run([sys.executable, str(self.bundle), '--root', str(self.root), '--apply'], capture_output=True, text=True, timeout=10, cwd=self.base) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(self.old.read_text(), 'NEW = True\n') self.assertIn('Runtime updated', result.stdout) def test_write_failure_rolls_back_files_already_replaced(self): self.build() actual_write = self.mod.atomic_write calls = 0 def fail_once(*args): nonlocal calls calls += 1 if calls == 3: raise OSError('injected full disk') return actual_write(*args) with patch.object(self.mod, 'atomic_write', side_effect=fail_once): with self.assertRaisesRegex(OSError, 'full disk'): self.mod.apply_bundle(self.bundle, self.root, execute=True) self.assertEqual(self.old.read_text(), 'OLD = True\n') self.assertFalse((self.root / 'etc/motd').exists()) self.assertFalse((self.root / 'usr/lib/hydra/bin/hydra').exists()) def test_persistent_restore_failures_attempt_all_cleanup_and_preserve_apply_error(self): import contextlib import io launcher = self.root / 'usr/lib/hydra/bin/hydra' launcher.parent.mkdir(parents=True) launcher.write_text('OLD LAUNCHER\n') self.build() actual_write = self.mod.atomic_write original_error = OSError('original apply failure: disk remains full') calls = 0 def persistent_failure(*args): nonlocal calls calls += 1 if calls == 5: raise original_error if calls > 5: raise OSError('persistent restore failure: disk remains full') return actual_write(*args) errors = io.StringIO() with contextlib.redirect_stderr(errors), patch.object(self.mod, 'atomic_write', side_effect=persistent_failure): with self.assertRaises(OSError) as raised: self.mod.apply_bundle(self.bundle, self.root, execute=True) self.assertIs(raised.exception, original_error) self.assertEqual(self.old.read_text(), 'NEW = True\n') self.assertFalse((self.root / 'etc/motd').exists()) self.assertFalse((self.root / 'usr/lib/hydra/hydra/__init__.py').exists()) backups = list((self.root / 'var/lib/hydra/updates').iterdir()) self.assertEqual(len(backups), 1) backup = backups[0] self.assertEqual((backup / 'usr/lib/hydra/hydra/cli.py').read_text(), 'OLD = True\n') self.assertEqual((backup / 'usr/lib/hydra/bin/hydra').read_text(), 'OLD LAUNCHER\n') self.assertIn(str(backup), errors.getvalue()) self.assertIn('rollback incomplete', errors.getvalue().lower()) self.assertIn('usr/lib/hydra/hydra/cli.py', errors.getvalue()) self.assertIn('usr/lib/hydra/bin/hydra', errors.getvalue()) self.assertIn('persistent restore failure', errors.getvalue())