diff --git a/README.md b/README.md index 50b99c5..090f143 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,41 @@ all of that app's factory-image changes to an existing VM, and an in-guest update should not be assumed to reproduce them. A confirmed reset is the deliberate, destructive way to start again from the newest bundled factory. +### Repairing update holds in an older guest + +Older guests may fail Omarchy Update with conflicting `libaquamarine.so` +dependencies. New factory images hold the compatible Hyprland, aquamarine, +and Hyprtoolkit packages together, along with the direct-boot kernel and +headers. Updating the Mac app does not add these holds to an existing guest. + +Copy `guest/scripts/repair-update-holds.py` from this source checkout into the +guest, then run it **inside Omarchy**, with the updater closed: + +```sh +python3 repair-update-holds.py # preview only +sudo python3 repair-update-holds.py --apply +``` + +The command adds missing holds to both `/usr/share/try-omarchy/pacman.conf` +and `/etc/pacman.conf`. The first file is essential: Omarchy's pre-refresh +hook restores it over the second before updating. Existing holds, comments, +repository definitions, and unrelated settings are retained in each file. +Keep any custom settings you want to survive an update in the saved share +copy too; the existing update hook still replaces the active configuration. + +The repair prints a backup directory under +`/var/lib/try-omarchy/update-holds-backup.*`, preserving both original files +under their relative paths. To undo it, close the updater and restore each +backup to its original location with `sudo cp -p`. Running the repair again +makes no changes when the holds are already present. It refuses to write +while pacman has a transaction lock. + +Then retry **Update → Omarchy**. This command only repairs the hold list; it +does not install, downgrade, or upgrade packages, and cannot repair packages +that were already upgraded into an incompatible combination. If dependency +errors remain, retain the full error output for diagnosis instead of removing +the kernel or compositor holds. + ### Growing an existing VM disk To add capacity without resetting the VM, shut down Omarchy and run the diff --git a/guest/scripts/repair-update-holds.py b/guest/scripts/repair-update-holds.py new file mode 100755 index 0000000..c342d24 --- /dev/null +++ b/guest/scripts/repair-update-holds.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Repair package holds in an existing Try Omarchy guest without upgrading it.""" + +import argparse +import os +from pathlib import Path +import platform +import re +import shutil +import stat +import tempfile + + +HOLDS = ("linux-aarch64", "linux-aarch64-headers", "hyprland", "aquamarine", "hyprtoolkit") +CONFIGS = ("usr/share/try-omarchy/pacman.conf", "etc/pacman.conf") + + +def add_holds(text): + lines = text.splitlines(keepends=True) + section = None + options = [] + directives = [] + present = set() + for index, line in enumerate(lines): + content = line.split("#", 1)[0].strip() + if content.startswith("[") and content.endswith("]"): + section = content[1:-1] + if section == "options": + options.append(index) + elif section == "options" and re.match(r"IgnorePkg\s*=", content): + directives.append(index) + present.update(content.split("=", 1)[1].split()) + if len(options) != 1: + raise ValueError("expected exactly one [options] section") + missing = [name for name in HOLDS if name not in present] + if not missing: + return text + if directives: + index = directives[0] + line = lines[index] + body = line.rstrip("\r\n") + ending = line[len(body):] + value, marker, comment = body.partition("#") + lines[index] = value.rstrip() + " " + " ".join(missing) + if marker: + lines[index] += " " + marker + comment + lines[index] += ending + else: + index = options[0] + ending = "\r\n" if lines[index].endswith("\r\n") else "\n" + if not lines[index].endswith("\n"): + lines[index] += ending + lines.insert(index + 1, "IgnorePkg = " + " ".join(missing) + ending) + return "".join(lines) + + +def replace_file(path, content): + info = path.stat() + fd, name = tempfile.mkstemp(prefix=".try-omarchy-holds-", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + if os.geteuid() == 0: + os.fchown(stream.fileno(), info.st_uid, info.st_gid) + os.fchmod(stream.fileno(), stat.S_IMODE(info.st_mode)) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def repair(root, apply=False): + plans = [] + for relative in CONFIGS: + path = root / relative + if path.is_symlink() or not path.is_file(): + raise ValueError(f"missing or symlinked configuration: /{relative}") + before = path.read_bytes() + after = add_holds(before.decode("utf-8")).encode("utf-8") + plans.append((path, before, after)) + print(f"/{relative}: {'already correct' if before == after else 'add missing compatibility holds'}") + changes = [plan for plan in plans if plan[1] != plan[2]] + if not changes: + print("Both configurations already contain the required holds; nothing changed.") + return + if not apply: + print("Required holds: " + " ".join(HOLDS)) + print("Preview only. Run with sudo and --apply to save both configurations.") + return + + # Cooperate with pacman so a package transaction cannot overlap the repair. + lock = root / "var/lib/pacman/db.lck" + fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(fd) + try: + for path, before, _ in plans: + if path.read_bytes() != before: + raise ValueError("configuration changed during repair; retry when the updater is closed") + backup_root = root / "var/lib/try-omarchy" + backup_root.mkdir(parents=True, exist_ok=True) + backup = Path(tempfile.mkdtemp(prefix="update-holds-backup.", dir=backup_root)) + for path, _, _ in plans: + destination = backup / path.relative_to(root) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + print(f"Previous configurations retained in {backup}") + written = [] + try: + for path, before, after in changes: + replace_file(path, after) + written.append((path, before)) + except OSError: + for path, before in reversed(written): + replace_file(path, before) + raise + print("Guest update holds repaired. Retry Omarchy Update.") + finally: + lock.unlink() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="back up and update both configurations") + args = parser.parse_args() + if platform.system() != "Linux" or platform.machine() != "aarch64": + parser.error("run this command inside the Try Omarchy ARM guest") + if "omarchy.qemu_virgl=1" not in Path("/proc/cmdline").read_text().split(): + parser.error("this guest does not have the Try Omarchy boot marker") + if args.apply and os.geteuid() != 0: + parser.error("--apply requires sudo") + try: + repair(Path("/"), args.apply) + except (OSError, ValueError) as error: + parser.exit(1, f"repair-update-holds: {error}\nNo packages were installed or upgraded. Close the updater before retrying; do not remove an active pacman lock.\n") + + +if __name__ == "__main__": + main() diff --git a/guest/tests/test_repair_update_holds.py b/guest/tests/test_repair_update_holds.py new file mode 100644 index 0000000..79ba773 --- /dev/null +++ b/guest/tests/test_repair_update_holds.py @@ -0,0 +1,120 @@ +import contextlib +import importlib.util +import io +from pathlib import Path +import stat +import tempfile +import unittest +from unittest.mock import patch + + +GUEST = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("repair_update_holds", GUEST / "scripts/repair-update-holds.py") +repair = importlib.util.module_from_spec(spec) +spec.loader.exec_module(repair) + + +class UpdateHoldsTests(unittest.TestCase): + def test_preserves_custom_settings_comments_and_multiple_directives(self): + original = "# custom\n[options]\n IgnorePkg = custom linux-aarch64 # keep this\nIgnorePkg = aquamarine\nUnknownSetting = yes\n[custom]\nServer = https://example.com/$arch\n" + result = repair.add_holds(original) + self.assertIn("custom linux-aarch64 linux-aarch64-headers hyprland hyprtoolkit # keep this", result) + self.assertTrue(result.endswith("IgnorePkg = aquamarine\nUnknownSetting = yes\n[custom]\nServer = https://example.com/$arch\n")) + self.assertEqual(repair.add_holds(result), result) + + def test_missing_directive_added_inside_options(self): + for original in ("[options]", "# IgnorePkg = ignored\n[options]\nColor\n[core]\nServer = example\n"): + result = repair.add_holds(original) + self.assertIn("[options]\nIgnorePkg = " + " ".join(repair.HOLDS), result) + self.assertEqual(repair.add_holds(result), result) + + def test_crlf_and_absent_final_newline_preserved(self): + original = "[options]\r\nIgnorePkg = custom # comment\r\n[core]\r\nServer = example" + result = repair.add_holds(original) + self.assertNotIn("\n", result.replace("\r\n", "")) + self.assertTrue(result.endswith("Server = example")) + + def test_rejects_missing_or_duplicate_options(self): + for original in ("IgnorePkg = custom\n[core]\n", "[options]\n[options]\n"): + with self.assertRaises(ValueError): + repair.add_holds(original) + + def test_holds_match_factory_configuration(self): + text = (GUEST / "pacman.aarch64.conf").read_text() + holds = next(line.split("=", 1)[1].split() for line in text.splitlines() if line.startswith("IgnorePkg =")) + self.assertEqual(set(holds), set(repair.HOLDS)) + + def setUp(self): + output = contextlib.redirect_stdout(io.StringIO()) + output.__enter__() + self.addCleanup(output.__exit__, None, None, None) + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.paths = [self.root / relative for relative in repair.CONFIGS] + for path in self.paths: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("[options]\nIgnorePkg = linux-aarch64 custom\n[extra]\nServer = example\n") + path.chmod(0o640) + (self.root / "var/lib/pacman").mkdir(parents=True) + self.originals = [path.read_bytes() for path in self.paths] + + def test_preview_has_no_side_effects(self): + repair.repair(self.root) + self.assertEqual([p.read_bytes() for p in self.paths], self.originals) + self.assertFalse((self.root / "var/lib/try-omarchy").exists()) + self.assertFalse((self.root / "var/lib/pacman/db.lck").exists()) + + def test_apply_backup_modes_hook_restore_and_repeat(self): + repair.repair(self.root, apply=True) + backups = list((self.root / "var/lib/try-omarchy").iterdir()) + self.assertEqual(len(backups), 1) + for path, before in zip(self.paths, self.originals): + self.assertEqual((backups[0] / path.relative_to(self.root)).read_bytes(), before) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640) + # The existing pre-refresh hook restores the saved configuration. + self.paths[1].write_bytes(self.paths[0].read_bytes()) + after = [p.read_bytes() for p in self.paths] + repair.repair(self.root, apply=True) + self.assertEqual([p.read_bytes() for p in self.paths], after) + self.assertEqual(list((self.root / "var/lib/try-omarchy").iterdir()), backups) + self.assertFalse((self.root / "var/lib/pacman/db.lck").exists()) + + def test_checks_both_files_before_writing(self): + self.paths[1].write_text("[core]\n") + with self.assertRaises(ValueError): + repair.repair(self.root, apply=True) + self.assertEqual(self.paths[0].read_bytes(), self.originals[0]) + + def test_refuses_symlink(self): + self.paths[1].unlink() + self.paths[1].symlink_to(self.paths[0]) + with self.assertRaises(ValueError): + repair.repair(self.root, apply=True) + self.assertEqual(self.paths[0].read_bytes(), self.originals[0]) + + def test_active_pacman_lock_is_not_removed(self): + lock = self.root / "var/lib/pacman/db.lck" + lock.write_text("in use") + with self.assertRaises(FileExistsError): + repair.repair(self.root, apply=True) + self.assertEqual(lock.read_text(), "in use") + self.assertEqual([p.read_bytes() for p in self.paths], self.originals) + + def test_second_write_failure_restores_first(self): + replace = repair.replace_file + + def fail_second(path, content): + if path == self.paths[1]: + raise OSError("simulated write failure") + replace(path, content) + + with patch.object(repair, "replace_file", side_effect=fail_second): + with self.assertRaises(OSError): + repair.repair(self.root, apply=True) + self.assertEqual([p.read_bytes() for p in self.paths], self.originals) + self.assertFalse((self.root / "var/lib/pacman/db.lck").exists()) + + +if __name__ == "__main__": + unittest.main()