From 1649f73a695f13e261339c9057ef0ced9be70a8d Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 23 Jul 2026 01:58:15 +0200 Subject: [PATCH] feat(image): auto-enable cross-arch chroot via qemu binfmt Building a foreign-arch image (e.g. arm64 Raspberry Pi OS on an x86 host) needs a qemu binfmt handler, or the chroot fails with "Exec format error". crossarch detects this before the target is erased and, on confirmation, installs qemu-user-static via the host package manager (apt-get/dnf/zypper/pacman) and registers binfmt; otherwise it aborts with per-distro instructions. Co-Authored-By: Claude Opus 4.8 --- lim/image/crossarch.py | 71 +++++++++++++++++++++++++++++++++++ tests/unit/test_cross_arch.py | 50 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 lim/image/crossarch.py create mode 100644 tests/unit/test_cross_arch.py diff --git a/lim/image/crossarch.py b/lim/image/crossarch.py new file mode 100644 index 0000000..129398c --- /dev/null +++ b/lim/image/crossarch.py @@ -0,0 +1,71 @@ +"""Ensure the host can chroot into a foreign-architecture image (qemu binfmt).""" + +import platform +import shutil +from pathlib import Path + +from lim import runner, ui +from lim.errors import LimError + +# Building natively needs no qemu; on these the chroot runs directly. +_NATIVE_ARM = ("aarch64", "armv7l", "armv6l", "arm64") + +# Repo-based installs only; AUR helpers refuse to run from this root process. +_BINFMT_INSTALLERS = { + "apt-get": ["apt-get", "install", "-y", "qemu-user-static", "binfmt-support"], + "dnf": ["dnf", "install", "-y", "qemu-user-static"], + "zypper": ["zypper", "--non-interactive", "install", "qemu-linux-user"], + "pacman": ["pacman", "-S", "--needed", "--noconfirm", "qemu-user-static-binfmt"], +} + + +def _entry_enabled(entry: Path) -> bool: + try: + return "enabled" in entry.read_text() + except OSError: + return False + + +def qemu_binfmt_enabled() -> bool: + binfmt = Path("/proc/sys/fs/binfmt_misc") + return binfmt.is_dir() and any(_entry_enabled(e) for e in binfmt.glob("qemu-*")) + + +def auto_enable() -> bool: + """Install qemu-user-static via the host package manager and register binfmt.""" + command = next((cmd for tool, cmd in _BINFMT_INSTALLERS.items() if shutil.which(tool)), None) + if command is None: + ui.warning("No supported host package manager found (apt-get/dnf/zypper/pacman).") + return False + runner.run(command, sudo=True, check=False) + runner.run(["systemctl", "restart", "systemd-binfmt"], sudo=True, check=False) + return qemu_binfmt_enabled() + + +def ensure_ready() -> None: + """Make foreign-arch chroot work, or fail with actionable guidance. + + Called before the target is erased, so a host that cannot run the image's + binaries aborts early instead of mid-build with a cryptic "Exec format error". + """ + if platform.machine() in _NATIVE_ARM: + return + if qemu_binfmt_enabled(): + ui.info("Cross-architecture build: qemu binfmt handlers are registered.") + return + ui.warning( + f"Cross-architecture build: this host ({platform.machine()}) cannot run the " + "image's ARM binaries in chroot yet." + ) + if ui.confirm("Install qemu-user-static and register binfmt now?"): + if auto_enable(): + ui.success("qemu binfmt handlers registered.") + return + ui.warning("Automatic setup did not register a handler.") + raise LimError( + "Cross-architecture chroot is not available. Install qemu-user-static + binfmt " + "manually, or build on the target itself (e.g. the Pi booted from USB).\n" + " Arch/Manjaro : qemu-user-static + qemu-user-static-binfmt (AUR), " + "then: sudo systemctl restart systemd-binfmt\n" + " Debian/Ubuntu: sudo apt install qemu-user-static binfmt-support" + ) diff --git a/tests/unit/test_cross_arch.py b/tests/unit/test_cross_arch.py new file mode 100644 index 0000000..6f78974 --- /dev/null +++ b/tests/unit/test_cross_arch.py @@ -0,0 +1,50 @@ +import pytest + +from lim.errors import LimError +from lim.image import crossarch + + +class TestCrossArch: + def test_native_arm_is_ok(self, monkeypatch): + monkeypatch.setattr(crossarch.platform, "machine", lambda: "aarch64") + crossarch.ensure_ready() + + def test_cross_arch_with_binfmt_ok(self, monkeypatch): + monkeypatch.setattr(crossarch.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(crossarch, "qemu_binfmt_enabled", lambda: True) + crossarch.ensure_ready() + + def test_declined_auto_install_raises(self, monkeypatch, answers): + monkeypatch.setattr(crossarch.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(crossarch, "qemu_binfmt_enabled", lambda: False) + answers("n") + with pytest.raises(LimError, match="Cross-architecture"): + crossarch.ensure_ready() + + def test_auto_install_success_proceeds(self, monkeypatch, answers): + monkeypatch.setattr(crossarch.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(crossarch, "qemu_binfmt_enabled", lambda: False) + monkeypatch.setattr(crossarch, "auto_enable", lambda: True) + answers("y") + crossarch.ensure_ready() + + def test_auto_install_failure_raises(self, monkeypatch, answers): + monkeypatch.setattr(crossarch.platform, "machine", lambda: "x86_64") + monkeypatch.setattr(crossarch, "qemu_binfmt_enabled", lambda: False) + monkeypatch.setattr(crossarch, "auto_enable", lambda: False) + answers("y") + with pytest.raises(LimError, match="Cross-architecture"): + crossarch.ensure_ready() + + def test_auto_enable_uses_detected_manager_and_registers(self, monkeypatch, fake_runner): + monkeypatch.setattr( + crossarch.shutil, "which", lambda tool: "/bin/pacman" if tool == "pacman" else None + ) + monkeypatch.setattr(crossarch, "qemu_binfmt_enabled", lambda: True) + assert crossarch.auto_enable() is True + assert fake_runner.find("pacman", "-S", "qemu-user-static-binfmt") + assert fake_runner.find("systemctl", "restart", "systemd-binfmt") + + def test_auto_enable_without_manager_returns_false(self, monkeypatch): + monkeypatch.setattr(crossarch.shutil, "which", lambda _tool: None) + assert crossarch.auto_enable() is False