diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e1082..50a2394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -630,6 +630,7 @@ are absent), so shell regressions are caught locally before the push. ### Bug fixes +- **`beetroot modes` / `doctor` / `build --vm-kernel` no longer advertise the x86_64-only `binder: vm` KVM path (or the native redroid host path) as usable on a non-x86_64 host (#190).** The whole guest stack is x86_64 and KVM only virtualizes the host's native architecture, so `qemu.detect_accel` is now host-arch-aware: on a non-x86_64 host an explicit `vm.accel: kvm` is a cross-arch error and `auto` resolves straight to TCG regardless of `/dev/kvm` (fixing both the `beetroot modes` KVM probe and the `beetroot doctor` `vm.accel` row). `beetroot modes` reports `redroid (binder: vm, KVM accel)` and `redroid (binder: host / auto)` as `unsupported` (arch mismatch) while keeping the TCG row reachable with an honest cross-arch note (`qemu-system-x86_64` under TCG boots the x86_64 guest cross-arch, just even slower). `beetroot build --vm-kernel --check` surfaces a non-x86_64 problem for the **local rootfs bake** (which stages x86_64 libs, pins the x86_64 loader, and copies `arch/x86/boot/bzImage`) — the prebuilt-**fetch** path is left cross-arch functional and is not gated. - **`beetroot modes` no longer reports the redroid host/auto mode as `supported` when the Docker CLI is present but the daemon is stopped (#179).** The verdict now gates on a live `docker info` probe (relocated from `builder` into a shared `capabilities.docker_daemon_responsive()`): a ready host binder + installed CLI but an unresponsive `dockerd` reports `needs-setup` with the remedy "start the Docker daemon" instead of falsely advertising a mode that can't boot a container. - **`beetroot doctor` on a `binder: vm` instance no longer reports `pass` when the QEMU binary or the guest kernel/rootfs artifacts are missing (#191).** `health()` gained a `vm.qemu` row (QEMU on `PATH`, reusing the same install remedy `beetroot modes` prints) and a `vm.artifacts` row (kernel + rootfs resolve, else a `beetroot build --vm-kernel` hint), so a green `vm.accel` alone can no longer imply a bootable VM. - **`beetroot doctor` no longer false-fails a healthy micro-VM by reusing the USB-style `adb.serial` row (#164).** The vm backend now emits an `adb.connect` (connect-then-verify) row instead, matching the redroid path — a TCP adb target only appears after an explicit `adb connect`, so the always-listed-serial assumption was wrongly failing a fresh adb-server lifetime and making doctor exit non-zero. diff --git a/docs/design/binderless-hosts-qemu-tcg.md b/docs/design/binderless-hosts-qemu-tcg.md index fd96323..55ffa1c 100644 --- a/docs/design/binderless-hosts-qemu-tcg.md +++ b/docs/design/binderless-hosts-qemu-tcg.md @@ -387,7 +387,14 @@ expensive step. 5. **KVM fast path** — ✅ done (issue #44): `detect_accel` probes `/dev/kvm` (read+write) and prefers `-accel kvm` (`-cpu host`); `auto` falls back to TCG, an explicit `kvm` request on a host without `/dev/kvm` - errors loudly. + errors loudly. The whole guest stack is x86_64, and KVM only virtualizes + the host's **native** architecture, so `detect_accel` is host-arch-aware + (issue #190): on a non-x86_64 host an explicit `kvm` is a cross-arch error + and `auto` resolves to TCG regardless of `/dev/kvm` (a stray native + `/dev/kvm` can't accelerate the x86_64 guest). `beetroot modes` reports the + KVM path — and the native `binder: host/auto` redroid path — as + `unsupported` on a non-x86_64 host, while the TCG path stays reachable + cross-arch (even slower). ### Usage diff --git a/docs/how-it-works/binder-and-modes.md b/docs/how-it-works/binder-and-modes.md index cda6509..ae097c2 100644 --- a/docs/how-it-works/binder-and-modes.md +++ b/docs/how-it-works/binder-and-modes.md @@ -75,24 +75,28 @@ adb binaries, then classifies each mode: | Verdict | When | |---------|------| -| `supported` | binder is **ready** (`/dev/binder*` nodes exist, or `binder` is in `/proc/filesystems`) **and** the Docker CLI is present. | +| `supported` | host is **x86_64**, binder is **ready** (`/dev/binder*` nodes exist, or `binder` is in `/proc/filesystems`) **and** the Docker CLI is present. | | `needs-setup` | binder is **loadable** (`CONFIG_ANDROID_BINDER_IPC=m`/`=y` but not loaded) → `sudo modprobe binder_linux devices=binder,hwbinder,vndbinder`; **or** binder is ready but Docker isn't installed. | -| `unsupported` | the kernel has binder **compiled out** (`# CONFIG_ANDROID_BINDER_IPC is not set`) — no Docker flag can fix this. | +| `unsupported` | the kernel has binder **compiled out** (`# CONFIG_ANDROID_BINDER_IPC is not set`) — no Docker flag can fix this; **or** the host is **not x86_64** — Beetroot's redroid image is the x86_64 `*_houdini_magisk` build and runs natively against the host kernel (no emulation), so it can't boot on, e.g., arm64 (issue [#190](https://github.com/Xiddoc/Beetroot/issues/190)) — use `binder: vm` (TCG cross-arch) instead. | | `unknown` | binder isn't present and the kernel config couldn't be read (e.g. macOS, locked-down `/proc`). | ### `redroid (binder: vm, KVM accel)` +The guest is x86_64, and KVM only virtualizes the host's **native** architecture, so this row is **x86_64-only** — on a non-x86_64 host KVM can never accelerate the x86_64 guest (issue [#190](https://github.com/Xiddoc/Beetroot/issues/190)). + | Verdict | When | |---------|------| -| `supported` | `/dev/kvm` is usable **and** QEMU is installed. (You still build the guest once: `beetroot build --vm-kernel`.) | -| `needs-setup` | `/dev/kvm` usable but QEMU missing → install `qemu-system-x86`. | -| `unsupported` | no usable `/dev/kvm` — use the TCG row instead, or move to a KVM-capable host/runner. | +| `supported` | host is **x86_64**, `/dev/kvm` is usable, **and** QEMU is installed. (You still build the guest once: `beetroot build --vm-kernel`.) | +| `needs-setup` | host is x86_64, `/dev/kvm` usable, but QEMU missing → install `qemu-system-x86`. | +| `unsupported` | no usable `/dev/kvm` (use the TCG row, or move to a KVM-capable host/runner); **or** the host is **not x86_64** (KVM can't accelerate the x86_64 guest cross-arch — use the TCG row). | ### `redroid (binder: vm, TCG accel)` +Software emulation, so this row is reachable **cross-arch**: `qemu-system-x86_64` under TCG boots the x86_64 guest even on a non-x86_64 host (just even slower). + | Verdict | When | |---------|------| -| `supported` | QEMU is installed. Works with **no** host binder and **no** KVM. Build the guest once with `beetroot build --vm-kernel`. | +| `supported` | QEMU is installed. Works with **no** host binder and **no** KVM. On a non-x86_64 host the reason flags the extra **cross-arch** cost (even slower than native-arch TCG). Build the guest once with `beetroot build --vm-kernel`. | | `needs-setup` | QEMU missing → install `qemu-system-x86`, then `beetroot build --vm-kernel`. | ### `adb backend (adopt remote device)` diff --git a/docs/reference/config.md b/docs/reference/config.md index c2fec04..9b0f83e 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -344,7 +344,7 @@ binder: host ``` !!! tip "`binder: vm` boots an emulated micro-VM" - Selecting `vm` dispatches `beetroot up` to a QEMU micro-VM that ships its own binder-enabled kernel. Build the guest artifacts once with `beetroot build --vm-kernel`, point `vm.kernel` / `vm.rootfs` at them (or set `BEETROOT_VM_KERNEL` / `BEETROOT_VM_ROOTFS`), and run `beetroot apply` then `beetroot up`. On a host with `/dev/kvm` this is near-native; without it the backend falls back to TCG (~5-20x slower — a slow first boot is expected, not a hang). The slow path is **never** engaged automatically; `binder: vm` is always an explicit opt-in. See [Binderless hosts (QEMU/TCG)](../design/binderless-hosts-qemu-tcg.md). + Selecting `vm` dispatches `beetroot up` to a QEMU micro-VM that ships its own binder-enabled kernel. Build the guest artifacts once with `beetroot build --vm-kernel`, point `vm.kernel` / `vm.rootfs` at them (or set `BEETROOT_VM_KERNEL` / `BEETROOT_VM_ROOTFS`), and run `beetroot apply` then `beetroot up`. On an **x86_64** host with `/dev/kvm` this is near-native; without it the backend falls back to TCG (~5-20x slower — a slow first boot is expected, not a hang). The whole guest stack is x86_64, so **KVM only accelerates on an x86_64 host** — on a non-x86_64 host (e.g. arm64) KVM cannot virtualize the x86_64 guest and `beetroot modes` / `doctor` report the KVM path as `unsupported`; the guest still boots there under TCG cross-arch emulation (even slower). The slow path is **never** engaged automatically; `binder: vm` is always an explicit opt-in. See [Binderless hosts (QEMU/TCG)](../design/binderless-hosts-qemu-tcg.md). !!! warning "Frida is not yet supported under `binder: vm`" The micro-VM guest is network-isolated, so the `vm` backend is scoped to ADB forwarding (`beetroot shell`) only. `beetroot frida-addr ` raises a friendly "not yet supported on the 'vm' backend" error (exit 2) instead of emitting an address, `beetroot doctor` omits the `frida.handshake` row, and `ls` / `status` report the Frida address as `unsupported`. Any `frida:` block in a `binder: vm` config is ignored (no `frida-server` is staged). For Frida, use `binder: auto` / `host` (redroid) or `beetroot adopt` an external rooted device. Tracked as a follow-up to [#44](https://github.com/Xiddoc/Beetroot/issues/44). @@ -362,7 +362,7 @@ QEMU micro-VM tunables. Consulted **only** when `binder: vm`; ignored otherwise. |-------|------|---------|-------------| | `vm.kernel` | string \| null | `null` | Host path to the guest `bzImage`. `null` defers to `BEETROOT_VM_KERNEL`. | | `vm.rootfs` | string \| null | `null` | Host path to the guest ext4 root image. `null` defers to `BEETROOT_VM_ROOTFS`. | -| `vm.accel` | string | `auto` | QEMU accelerator: `auto` (probe `/dev/kvm`, prefer KVM, else TCG), `kvm` (force; errors if `/dev/kvm` is absent), or `tcg` (force software emulation). | +| `vm.accel` | string | `auto` | QEMU accelerator: `auto` (probe `/dev/kvm`, prefer KVM, else TCG), `kvm` (force; errors if `/dev/kvm` is absent — or, on a non-x86_64 host, with a cross-arch error, since KVM can't accelerate the x86_64 guest there), or `tcg` (force software emulation). On a non-x86_64 host `auto` resolves straight to TCG regardless of `/dev/kvm`. | | `vm.smp` | int \| `auto` | `auto` | Guest vCPUs (`-smp`). `auto` pins `-smp` to the host's **physical** core count (HyperThread siblings collapsed, capped by CPU affinity so a cgroup-limited CI runner is respected) — the vm-rnd-log §B.5 measured optimum, since more vCPUs than physical cores oversubscribe the emulator. An explicit integer (>= 1) pins it. | | `vm.memory_mib` | int | `8192` | Guest RAM in MiB (`-m`). Must be >= 256. Authoritative for `binder: vm`; `resources.mem` is the Docker cap used by `binder: auto` / `host`. Both knobs kept by decision in [#104](https://github.com/Xiddoc/Beetroot/issues/104). | | `vm.boot_cache` | bool | `false` | Warm-start boot cache. When `true`, the first `up` cold-boots through a qcow2 overlay and checkpoints the running machine state with QEMU `savevm`; every later `up` *resumes* that checkpoint (`-loadvm`) instead of cold-booting — **~10 s vs ~3-4 min under TCG**. Resume reverts the guest to the checkpoint each time (a fast *known-good boot*, not persistence) — so `/data` writes made after the first boot (installed apps, logins, flashed-module state) are discarded on every warm `up`, and Beetroot prints a runtime warning on each resume so that reset is never silent. Set `vm.boot_cache: false` if you need `/data` to persist across restarts. The checkpoint lives at `/vm-overlay.qcow2` (~2 GiB) and auto-invalidates when the kernel/rootfs changes (a digest is recorded beside it); delete it by hand to force a reset otherwise. Requires `qemu-img`. See [Warm-start boot cache](#warm-start-boot-cache-vmboot_cache). | diff --git a/src/beetroot/builder.py b/src/beetroot/builder.py index ec33761..7af81fa 100644 --- a/src/beetroot/builder.py +++ b/src/beetroot/builder.py @@ -25,6 +25,7 @@ import fcntl import hashlib import os +import platform import shlex import shutil import subprocess @@ -38,6 +39,7 @@ from . import capabilities, config, console, kernel_download, paths, rootfs_download from .settings import settings +from .vm import qemu # The patcher CLI flag each GApps vendor needs. Keyed by the *resolved* vendor # (config.GappsVendor) rather than the user-facing intent, so the build matches @@ -1459,6 +1461,25 @@ def vm_bake_preflight(*, redroid_tar: Path | None = None) -> list[PreflightProbl """ cfg = _RootfsConfig.from_env(out_image=Path("preflight"), vm_dir=Path("preflight")) problems: list[PreflightProblem] = [] + # The local bake is genuinely x86_64-only: it stages x86_64-linux-gnu libs, + # pins /lib64/ld-linux-x86-64.so.2, and ``cp``s arch/x86/boot/bzImage — all + # of which assume an x86_64 build host. A non-x86_64 host can't bake, but it + # CAN still *fetch* the prebuilt artifacts (which run under TCG cross-arch), + # so this is a bake-only problem, never a fetch-path one (issue #190). + if not qemu.host_is_guest_arch(): + problems.append( + PreflightProblem( + requirement="x86_64 build host", + detail=( + f"the local rootfs bake only runs on an x86_64 build host " + f"(this host is {platform.machine()})" + ), + fix=( + "fetch the prebuilt artifacts instead of forcing a local bake " + "(the default `beetroot build --vm-kernel`), or bake on an x86_64 host" + ), + ) + ) for attr, pkg in _VM_STATIC_BINS: path: Path = getattr(cfg, attr) if not path.is_file(): diff --git a/src/beetroot/capabilities.py b/src/beetroot/capabilities.py index a5f8916..056bd17 100644 --- a/src/beetroot/capabilities.py +++ b/src/beetroot/capabilities.py @@ -26,6 +26,7 @@ from __future__ import annotations +import platform import shutil import subprocess @@ -111,8 +112,8 @@ class ModeSupport(BaseModel): remedy: str -def _redroid_host( - binder: hostcheck.BinderStatus, *, docker: bool, docker_daemon: bool +def _redroid_host( # noqa: PLR0911 # one verdict per host-state branch; splitting hurts readability + binder: hostcheck.BinderStatus, *, docker: bool, docker_daemon: bool, host_is_x86_64: bool ) -> ModeSupport: """ Classify the ``redroid`` backend on the host-binder path. @@ -123,10 +124,27 @@ def _redroid_host( docker_daemon: Whether the Docker *daemon* answers ``docker info``. Only meaningful when ``docker`` is ``True`` (there's no daemon to probe without the CLI). + host_is_x86_64: Whether the host machine is x86_64. Beetroot's redroid + image is the x86_64 ``*_houdini_magisk`` tag, so a non-x86_64 host + can't run it natively no matter how ready the binder driver is. Returns: The :class:`ModeSupport` verdict for ``binder: host`` / ``auto``. """ + if not host_is_x86_64: + # redroid runs Android's userspace against the host kernel with no + # emulation, so the x86_64 image can't execute on a non-x86_64 host — + # no binder/Docker step would fix the arch mismatch. Point at the vm + # TCG path, which *can* boot the x86_64 guest cross-arch. + return ModeSupport( + mode=MODE_REDROID_HOST, + status="unsupported", + reason=( + f"host is {platform.machine()}; Beetroot's redroid image is x86_64 and " + "runs natively against the host kernel (no emulation), so it can't boot here" + ), + remedy="use `binder: vm` (TCG cross-arch emulation), or run on an x86_64 host", + ) if binder.state == "ready": if not docker: return ModeSupport( @@ -163,17 +181,33 @@ def _redroid_host( ) -def _vm_kvm(*, kvm: bool, qemu_present: bool) -> ModeSupport: +def _vm_kvm(*, kvm: bool, qemu_present: bool, host_is_x86_64: bool) -> ModeSupport: """ Classify the ``binder: vm`` KVM (near-native) fast path. Args: kvm: Whether a usable ``/dev/kvm`` is present. qemu_present: Whether the QEMU system emulator is on ``PATH``. + host_is_x86_64: Whether the host machine is x86_64. KVM can only + virtualize the host's native arch, so it can never accelerate the + x86_64 guest on a non-x86_64 host. Returns: The :class:`ModeSupport` verdict for ``binder: vm`` under KVM. """ + if not host_is_x86_64: + # KVM cross-arch is physically impossible: an x86_64 guest can't be + # KVM-accelerated on an arm64 (or other) host. This is unambiguously + # unsupported — no /dev/kvm or QEMU install would help. TCG is the path. + return ModeSupport( + mode=MODE_VM_KVM, + status="unsupported", + reason=( + f"host is {platform.machine()}; KVM cannot accelerate the x86_64 guest " + "(KVM only virtualizes the host's native arch) — use the TCG path" + ), + remedy="use the `binder: vm` TCG path (cross-arch software emulation), no KVM needed", + ) if not kvm: return ModeSupport( mode=MODE_VM_KVM, @@ -196,30 +230,44 @@ def _vm_kvm(*, kvm: bool, qemu_present: bool) -> ModeSupport: ) -def _vm_tcg(*, qemu_present: bool) -> ModeSupport: +def _vm_tcg(*, qemu_present: bool, host_is_x86_64: bool) -> ModeSupport: """ Classify the ``binder: vm`` TCG (software-emulation) fallback path. This is the path for hosts with neither host binder nor KVM — it needs only - QEMU and the built guest artifacts. + QEMU and the built guest artifacts. It stays reachable on a non-x86_64 host + (``qemu-system-x86_64`` under TCG boots the x86_64 guest cross-arch), but the + reason then flags the extra cross-arch cost so the host isn't told the path + is plainly fast. Args: qemu_present: Whether the QEMU system emulator is on ``PATH``. + host_is_x86_64: Whether the host machine is x86_64. When ``False`` the + reason gains a cross-arch note (even slower) — but the verdict is + never ``unsupported``, since TCG *can* emulate the x86_64 guest. Returns: The :class:`ModeSupport` verdict for ``binder: vm`` under TCG. """ + # On a non-x86_64 host, qemu-system-x86_64 under TCG still boots the guest — + # it just emulates a foreign ISA on top of the usual software emulation, so + # it is even slower. Flag that honestly instead of promising ~5-20x. + speed = ( + f"cross-arch x86_64 emulation on {platform.machine()} — even slower than native-arch TCG" + if not host_is_x86_64 + else "software emulation (~5-20x slower, no KVM needed)" + ) if not qemu_present: return ModeSupport( mode=MODE_VM_TCG, status="needs-setup", - reason="the QEMU system emulator was not found", + reason=f"the QEMU system emulator was not found ({speed})", remedy=_QEMU_INSTALL, ) return ModeSupport( mode=MODE_VM_TCG, status="supported", - reason=f"QEMU installed; software emulation (~5-20x slower, no KVM needed) — {_BUILD_HINT}", + reason=f"QEMU installed; {speed} — {_BUILD_HINT}", remedy="", ) @@ -263,6 +311,7 @@ def classify_modes( # noqa: PLR0913 # each keyword-only param is a distinct ho docker: bool, docker_daemon: bool, adb_present: bool, + host_is_x86_64: bool, ) -> list[ModeSupport]: """ Fold the raw host probes into the per-mode support matrix. @@ -278,14 +327,20 @@ def classify_modes( # noqa: PLR0913 # each keyword-only param is a distinct ho docker_daemon: Whether the Docker daemon answers ``docker info`` (only meaningful when ``docker`` is ``True``). adb_present: Whether the ``adb`` client is on ``PATH``. + host_is_x86_64: Whether the host machine is the x86_64 guest arch. The + whole ``binder: vm`` stack (and the redroid image) is x86_64, so a + non-x86_64 host can never KVM-accelerate or natively run it — only + the cross-arch TCG path stays reachable (issue #190). Returns: One :class:`ModeSupport` per mode, in a stable display order. """ return [ - _redroid_host(binder, docker=docker, docker_daemon=docker_daemon), - _vm_kvm(kvm=kvm, qemu_present=qemu_present), - _vm_tcg(qemu_present=qemu_present), + _redroid_host( + binder, docker=docker, docker_daemon=docker_daemon, host_is_x86_64=host_is_x86_64 + ), + _vm_kvm(kvm=kvm, qemu_present=qemu_present, host_is_x86_64=host_is_x86_64), + _vm_tcg(qemu_present=qemu_present, host_is_x86_64=host_is_x86_64), _adb_adopt(adb_present=adb_present), ] @@ -309,7 +364,9 @@ def survey(settings: Settings | None = None) -> list[ModeSupport]: docker_present = shutil.which(cfg.docker_bin) is not None return classify_modes( binder=hostcheck.binder_status(), - # ``detect_accel("auto")`` returns "kvm" only when /dev/kvm is usable. + # ``detect_accel("auto")`` returns "kvm" only when /dev/kvm is usable + # AND the host is the x86_64 guest arch — on a foreign arch it resolves + # to "tcg" regardless of /dev/kvm, so this KVM probe reads False there. kvm=qemu.detect_accel("auto") == "kvm", qemu_present=shutil.which(cfg.qemu_bin) is not None, docker=docker_present, @@ -318,4 +375,5 @@ def survey(settings: Settings | None = None) -> list[ModeSupport]: # the flag on the no-CLI path anyway. docker_daemon=docker_present and docker_daemon_responsive(), adb_present=shutil.which("adb") is not None, + host_is_x86_64=qemu.host_is_guest_arch(), ) diff --git a/src/beetroot/vm/qemu.py b/src/beetroot/vm/qemu.py index 055caa1..f46cd1e 100644 --- a/src/beetroot/vm/qemu.py +++ b/src/beetroot/vm/qemu.py @@ -28,6 +28,7 @@ import contextlib import errno import os +import platform import signal import subprocess import time @@ -38,6 +39,30 @@ # the configured ``auto``/``kvm``/``tcg`` against the live host probe. ResolvedAccel = Literal["kvm", "tcg"] +# The ``platform.machine()`` values that name the x86_64 guest architecture the +# entire ``binder: vm`` stack is built for (``qemu-system-x86_64``, the guest +# bzImage/rootfs, the x86_64 redroid image). The single source of truth for +# "is this host the guest arch?", reused by :mod:`beetroot.capabilities` and +# :mod:`beetroot.builder` so the literal isn't re-spelled per call site. +GUEST_ARCH_MACHINES: frozenset[str] = frozenset({"x86_64", "amd64"}) + + +def host_is_guest_arch() -> bool: + """ + Return True iff this host's machine is the x86_64 guest architecture. + + The ``binder: vm`` guest is hard-x86_64, so KVM (which can only virtualize + the host's *native* arch) can accelerate it only when the host itself is + x86_64. A non-x86_64 host can still boot the guest under TCG (full software + emulation, cross-arch), just slower. + + Returns: + ``True`` when ``platform.machine()`` is one of + :data:`GUEST_ARCH_MACHINES`, else ``False``. + """ + return platform.machine().lower() in GUEST_ARCH_MACHINES + + # The guest-side ADB port redroid listens on. The user-net ``hostfwd`` maps # this to a per-instance host port so ``adb connect localhost:`` reaches # the guest's adbd. @@ -73,7 +98,14 @@ def _dev_kvm_usable() -> bool: def detect_accel(requested: Literal["auto", "kvm", "tcg"]) -> ResolvedAccel: """ - Resolve the configured accelerator against the live ``/dev/kvm`` probe. + Resolve the configured accelerator against the host arch + ``/dev/kvm`` probe. + + KVM can only virtualize the host's *native* architecture, and the + ``binder: vm`` guest is hard-x86_64, so on a non-x86_64 host KVM can never + accelerate the guest (:func:`host_is_guest_arch`) — an explicit ``kvm`` + there is a hard cross-arch error and ``auto`` resolves straight to ``tcg`` + regardless of ``/dev/kvm``. On an x86_64 host the live ``/dev/kvm`` probe + decides. Args: requested: The configured ``vm.accel`` value (``auto`` / ``kvm`` / @@ -83,12 +115,29 @@ def detect_accel(requested: Literal["auto", "kvm", "tcg"]) -> ResolvedAccel: ``"kvm"`` or ``"tcg"`` — the concrete accelerator to pass to QEMU. Raises: - QemuLaunchError: If ``requested`` is ``"kvm"`` but ``/dev/kvm`` is - absent or not read/writable. The expensive TCG path is never - silently substituted for an explicit KVM request. + QemuLaunchError: If ``requested`` is ``"kvm"`` but this host cannot + accelerate the x86_64 guest — either the host arch is not x86_64 + (cross-arch, no setup would fix it) or ``/dev/kvm`` is absent or + not read/writable. The expensive TCG path is never silently + substituted for an explicit KVM request. """ if requested == "tcg": return "tcg" + # KVM cross-arch is physically impossible: an x86_64 guest can't be KVM- + # accelerated on, say, an arm64 host. Rule it out before the /dev/kvm probe + # so a foreign-arch host with a stray /dev/kvm (its own native KVM) isn't + # mistaken for a usable guest accelerator. + if not host_is_guest_arch(): + if requested == "kvm": + raise QemuLaunchError( + f"vm.accel: kvm was requested but KVM cannot accelerate the x86_64 " + f"micro-VM guest on a {platform.machine()!r} host — KVM only " + "virtualizes the host's native architecture. Use accel: tcg (slow, " + "cross-arch software emulation) or accel: auto (auto-falls back to " + "tcg here). Run `beetroot modes` to see what this host supports." + ) + # ``auto`` on a foreign arch: TCG is the only reachable path, never KVM. + return "tcg" if requested == "kvm": if not _dev_kvm_usable(): raise QemuLaunchError( diff --git a/tests/test_builder.py b/tests/test_builder.py index ab4075c..3e3f281 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1735,6 +1735,7 @@ def _ready( # noqa: PLR0913 # test helper; each kwarg toggles one preflight br which: object = None, daemon: bool = True, euid: int = 0, + machine: str = "x86_64", ) -> None: cfg = self._cfg(tmp_path, present=present) monkeypatch.setattr(builder._RootfsConfig, "from_env", lambda **_k: cfg) @@ -1743,6 +1744,9 @@ def _ready( # noqa: PLR0913 # test helper; each kwarg toggles one preflight br # The bake's root-privilege preflight (#231) — default to root so the # other branches stay isolated; root-specific tests override ``euid``. monkeypatch.setattr("beetroot.builder.os.geteuid", lambda: euid) + # The bake's host-arch preflight (#190) — default to the x86_64 guest arch + # so the other branches stay isolated; arch-specific tests override it. + monkeypatch.setattr("beetroot.builder.platform.machine", lambda: machine) def test_ready_host_has_no_problems( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -1848,6 +1852,36 @@ def test_root_euid_has_no_privilege_problem( names = [p.requirement for p in builder.vm_bake_preflight()] assert "root privilege" not in names + def test_non_x86_64_bake_preflight_reports_arch_problem( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # issue #190: the local bake is genuinely x86_64-only (x86_64-linux-gnu + # libs, /lib64/ld-linux-x86-64.so.2, arch/x86/boot/bzImage), so a foreign + # arch must surface a bake preflight problem. + self._ready(monkeypatch, tmp_path, machine="aarch64") + problems = builder.vm_bake_preflight() + arch = next(p for p in problems if p.requirement == "x86_64 build host") + assert "aarch64" in arch.detail + assert "prebuilt" in arch.fix + + def test_x86_64_bake_preflight_has_no_arch_problem( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._ready(monkeypatch, tmp_path, machine="x86_64") + names = [p.requirement for p in builder.vm_bake_preflight()] + assert "x86_64 build host" not in names + + def test_non_x86_64_fetch_preflight_has_no_arch_problem( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The fetch path works cross-arch under TCG, so the arch problem must + # NOT appear in vm_fetch_preflight (issue #190). curl/tar present so the + # fetch preflight is otherwise clean. + self._ready(monkeypatch, tmp_path, machine="aarch64") + names = [p.requirement for p in builder.vm_fetch_preflight()] + assert "x86_64 build host" not in names + assert names == [] + class TestBuildImageDaemonPreflight: """issue #193: ``beetroot build`` runs a Docker-daemon preflight.""" diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 372df66..6c0d087 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -13,6 +13,7 @@ import subprocess from unittest import mock +import pytest from typer.testing import CliRunner from beetroot import capabilities, cli, hostcheck @@ -32,75 +33,135 @@ def _by_mode(results: list[capabilities.ModeSupport]) -> dict[str, capabilities. def test_redroid_host_ready_with_docker_daemon_is_supported() -> None: - r = capabilities._redroid_host(_binder("ready"), docker=True, docker_daemon=True) + r = capabilities._redroid_host( + _binder("ready"), docker=True, docker_daemon=True, host_is_x86_64=True + ) assert r.status == "supported" def test_redroid_host_ready_docker_cli_but_daemon_down_needs_setup() -> None: # issue #179: CLI present but ``dockerd`` not answering is NOT "supported" — # redroid can't boot a container without a live daemon. - r = capabilities._redroid_host(_binder("ready"), docker=True, docker_daemon=False) + r = capabilities._redroid_host( + _binder("ready"), docker=True, docker_daemon=False, host_is_x86_64=True + ) assert r.status == "needs-setup" assert "daemon is not responding" in r.reason assert r.remedy == "start the Docker daemon" def test_redroid_host_ready_without_docker_needs_setup() -> None: - r = capabilities._redroid_host(_binder("ready"), docker=False, docker_daemon=False) + r = capabilities._redroid_host( + _binder("ready"), docker=False, docker_daemon=False, host_is_x86_64=True + ) assert r.status == "needs-setup" assert "Docker" in r.remedy def test_redroid_host_loadable_needs_setup() -> None: - r = capabilities._redroid_host(_binder("loadable"), docker=True, docker_daemon=True) + r = capabilities._redroid_host( + _binder("loadable"), docker=True, docker_daemon=True, host_is_x86_64=True + ) assert r.status == "needs-setup" assert r.remedy == "remedy-loadable" def test_redroid_host_unsupported() -> None: - r = capabilities._redroid_host(_binder("unsupported"), docker=True, docker_daemon=True) + r = capabilities._redroid_host( + _binder("unsupported"), docker=True, docker_daemon=True, host_is_x86_64=True + ) assert r.status == "unsupported" def test_redroid_host_unknown() -> None: - r = capabilities._redroid_host(_binder("unknown"), docker=True, docker_daemon=True) + r = capabilities._redroid_host( + _binder("unknown"), docker=True, docker_daemon=True, host_is_x86_64=True + ) assert r.status == "unknown" +def test_redroid_host_non_x86_64_is_unsupported_before_binder( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # issue #190: the x86_64 redroid image can't run natively on a foreign arch, + # no matter how ready the binder driver is — arch overrides the binder state. + monkeypatch.setattr("beetroot.capabilities.platform.machine", lambda: "aarch64") + r = capabilities._redroid_host( + _binder("ready"), docker=True, docker_daemon=True, host_is_x86_64=False + ) + assert r.status == "unsupported" + assert "aarch64" in r.reason + assert "vm" in r.remedy + + # ---- _vm_kvm -------------------------------------------------------------- def test_vm_kvm_no_kvm_is_unsupported() -> None: - r = capabilities._vm_kvm(kvm=False, qemu_present=True) + r = capabilities._vm_kvm(kvm=False, qemu_present=True, host_is_x86_64=True) assert r.status == "unsupported" assert "TCG" in r.remedy def test_vm_kvm_kvm_without_qemu_needs_setup() -> None: - r = capabilities._vm_kvm(kvm=True, qemu_present=False) + r = capabilities._vm_kvm(kvm=True, qemu_present=False, host_is_x86_64=True) assert r.status == "needs-setup" assert "QEMU" in r.remedy def test_vm_kvm_kvm_and_qemu_supported() -> None: - r = capabilities._vm_kvm(kvm=True, qemu_present=True) + r = capabilities._vm_kvm(kvm=True, qemu_present=True, host_is_x86_64=True) assert r.status == "supported" +def test_vm_kvm_non_x86_64_is_unsupported_even_with_kvm( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # issue #190: KVM cross-arch is physically impossible — a foreign-arch host + # with its own /dev/kvm still can't accelerate the x86_64 guest. + monkeypatch.setattr("beetroot.capabilities.platform.machine", lambda: "aarch64") + r = capabilities._vm_kvm(kvm=True, qemu_present=True, host_is_x86_64=False) + assert r.status == "unsupported" + assert "aarch64" in r.reason + assert "TCG" in r.remedy + + # ---- _vm_tcg -------------------------------------------------------------- def test_vm_tcg_without_qemu_needs_setup() -> None: - r = capabilities._vm_tcg(qemu_present=False) + r = capabilities._vm_tcg(qemu_present=False, host_is_x86_64=True) assert r.status == "needs-setup" def test_vm_tcg_with_qemu_supported() -> None: - r = capabilities._vm_tcg(qemu_present=True) + r = capabilities._vm_tcg(qemu_present=True, host_is_x86_64=True) assert r.status == "supported" assert "5-20x" in r.reason +def test_vm_tcg_non_x86_64_stays_reachable_with_cross_arch_note( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # issue #190: TCG can boot the x86_64 guest cross-arch, so it must NOT be + # unsupported on a foreign arch — but the reason flags the extra cost so the + # host isn't told the path is plainly fast. + monkeypatch.setattr("beetroot.capabilities.platform.machine", lambda: "aarch64") + r = capabilities._vm_tcg(qemu_present=True, host_is_x86_64=False) + assert r.status == "supported" + assert "cross-arch" in r.reason + assert "5-20x" not in r.reason + + +def test_vm_tcg_non_x86_64_without_qemu_still_needs_setup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("beetroot.capabilities.platform.machine", lambda: "aarch64") + r = capabilities._vm_tcg(qemu_present=False, host_is_x86_64=False) + assert r.status == "needs-setup" + assert "cross-arch" in r.reason + + # ---- _adb_adopt ----------------------------------------------------------- @@ -128,6 +189,7 @@ def test_classify_modes_returns_all_four_in_order() -> None: docker=True, docker_daemon=True, adb_present=True, + host_is_x86_64=True, ) assert [r.mode for r in results] == [ capabilities.MODE_REDROID_HOST, @@ -149,6 +211,7 @@ def test_classify_modes_binder_ready_daemon_up_redroid_supported() -> None: docker=True, docker_daemon=True, adb_present=False, + host_is_x86_64=True, ) ) assert results[capabilities.MODE_REDROID_HOST].status == "supported" @@ -165,6 +228,7 @@ def test_classify_modes_binder_ready_daemon_down_redroid_needs_setup() -> None: docker=True, docker_daemon=False, adb_present=False, + host_is_x86_64=True, ) ) redroid = results[capabilities.MODE_REDROID_HOST] @@ -183,6 +247,7 @@ def test_classify_modes_binderless_kvmless_host_only_tcg_and_adb() -> None: docker=True, docker_daemon=True, adb_present=True, + host_is_x86_64=True, ) ) assert results[capabilities.MODE_REDROID_HOST].status == "unsupported" @@ -191,6 +256,51 @@ def test_classify_modes_binderless_kvmless_host_only_tcg_and_adb() -> None: assert results[capabilities.MODE_ADB].status == "supported" +def test_classify_modes_non_x86_64_host_kvm_and_redroid_unsupported_tcg_reachable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # issue #190: on a non-x86_64 host the x86_64 KVM path and the native redroid + # image are both unsupported, but the TCG cross-arch path stays reachable. + monkeypatch.setattr("beetroot.capabilities.platform.machine", lambda: "aarch64") + results = _by_mode( + capabilities.classify_modes( + binder=_binder("ready"), # even a ready binder can't rescue a foreign arch + kvm=True, # even a native /dev/kvm can't accelerate the x86_64 guest + qemu_present=True, + docker=True, + docker_daemon=True, + adb_present=True, + host_is_x86_64=False, + ) + ) + assert results[capabilities.MODE_REDROID_HOST].status == "unsupported" + assert results[capabilities.MODE_VM_KVM].status == "unsupported" + assert "aarch64" in results[capabilities.MODE_VM_KVM].reason + assert results[capabilities.MODE_VM_TCG].status == "supported" + assert "cross-arch" in results[capabilities.MODE_VM_TCG].reason + # adb is arch-agnostic (drives a remote device) — unaffected by the guard. + assert results[capabilities.MODE_ADB].status == "supported" + + +def test_classify_modes_x86_64_host_keeps_current_behavior() -> None: + # The x86_64 path is unchanged: a ready host with kvm+qemu keeps all four + # supported, proving the guard only fires on a foreign arch (issue #190). + results = _by_mode( + capabilities.classify_modes( + binder=_binder("ready"), + kvm=True, + qemu_present=True, + docker=True, + docker_daemon=True, + adb_present=True, + host_is_x86_64=True, + ) + ) + assert results[capabilities.MODE_REDROID_HOST].status == "supported" + assert results[capabilities.MODE_VM_KVM].status == "supported" + assert results[capabilities.MODE_VM_TCG].status == "supported" + + # ---- survey --------------------------------------------------------------- @@ -280,6 +390,30 @@ def test_survey_accepts_injected_settings() -> None: assert len(results) == 4 +def test_survey_threads_non_x86_64_host_into_verdicts() -> None: + # issue #190: survey() must probe platform.machine() and thread a foreign + # arch through the classifier — the real host arch (via qemu.host_is_guest_arch) + # is what selects the KVM/redroid unsupported + TCG cross-arch verdicts. + with ( + mock.patch("beetroot.hostcheck.binder_status", return_value=_binder("ready")), + # A ready binder + all binaries present would normally give host/KVM + # "supported" on x86_64 — the foreign arch is the sole reason they flip. + mock.patch("beetroot.vm.qemu.detect_accel", return_value="tcg"), + mock.patch("shutil.which", return_value="/usr/bin/x"), + mock.patch.object(capabilities, "docker_daemon_responsive", return_value=True), + # Patch the shared platform module so BOTH the survey probe + # (qemu.host_is_guest_arch) and the classifier reason strings + # (capabilities.platform.machine) agree on the foreign arch. + mock.patch("platform.machine", return_value="aarch64"), + ): + results = _by_mode(capabilities.survey()) + assert results[capabilities.MODE_REDROID_HOST].status == "unsupported" + assert results[capabilities.MODE_VM_KVM].status == "unsupported" + assert "aarch64" in results[capabilities.MODE_VM_KVM].reason + assert results[capabilities.MODE_VM_TCG].status == "supported" + assert "cross-arch" in results[capabilities.MODE_VM_TCG].reason + + # ---- docker_daemon_responsive (relocated from builder, issue #179) -------- diff --git a/tests/test_vm_backend.py b/tests/test_vm_backend.py index 13e55c9..26953f1 100644 --- a/tests/test_vm_backend.py +++ b/tests/test_vm_backend.py @@ -1608,6 +1608,30 @@ def test_explicit_kvm_unavailable_fails(self, monkeypatch: pytest.MonkeyPatch) - assert row.status == "fail" assert "/dev/kvm" in (row.reason or "") + def test_explicit_kvm_on_foreign_arch_is_cross_arch_fail( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # issue #190: doctor's vm.accel row routes through the real detect_accel, + # so a foreign-arch host with an explicit kvm request reports a truthful + # cross-arch fail — not a near-native pass. + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "aarch64") + row = vm_backend._accel_check("kvm") + assert row.status == "fail" + assert "cannot accelerate the x86_64" in (row.reason or "") + assert "near-native" not in (row.reason or "") + + def test_auto_on_foreign_arch_is_tcg_row_not_near_native( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # ``auto`` on a foreign arch resolves to TCG even if a native /dev/kvm + # exists — the doctor row must be the TCG note, not a near-native pass. + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "aarch64") + monkeypatch.setattr(qemu, "_dev_kvm_usable", lambda: True) + row = vm_backend._accel_check("auto") + assert row.status == "pass" + assert "5-20x" in (row.reason or "") + assert "near-native" not in (row.reason or "") + # --------------------------------------------------------------------------- # logs (LogReader capability — reads the persisted QEMU serial console) diff --git a/tests/test_vm_qemu.py b/tests/test_vm_qemu.py index 76d89a4..9739697 100644 --- a/tests/test_vm_qemu.py +++ b/tests/test_vm_qemu.py @@ -58,6 +58,57 @@ def _access(path: str, mode: int) -> bool: assert captured["mode"] == os.R_OK | os.W_OK +class TestDetectAccelHostArch: + """issue #190: KVM cross-arch is impossible; the guard is arch-aware.""" + + def test_host_is_guest_arch_true_on_x86_64(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "x86_64") + assert qemu.host_is_guest_arch() is True + + def test_host_is_guest_arch_true_on_amd64_case_insensitive( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "AMD64") + assert qemu.host_is_guest_arch() is True + + def test_host_is_guest_arch_false_on_arm(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "aarch64") + assert qemu.host_is_guest_arch() is False + + def test_explicit_kvm_raises_cross_arch_on_foreign_host_even_with_dev_kvm( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A foreign-arch host may have its own native /dev/kvm, but it can't + # accelerate the x86_64 guest — the cross-arch rejection precedes the + # /dev/kvm probe, so os.access must never be reached. + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "aarch64") + + def _boom(*_a: object, **_k: object) -> bool: + raise AssertionError("cross-arch kvm must reject before probing /dev/kvm") + + monkeypatch.setattr("beetroot.vm.qemu.os.access", _boom) + with pytest.raises(qemu.QemuLaunchError, match="cannot accelerate the x86_64") as exc: + qemu.detect_accel("kvm") + assert "aarch64" in str(exc.value) + assert "beetroot modes" in str(exc.value) + + def test_auto_resolves_to_tcg_on_foreign_host_even_with_dev_kvm( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # ``auto`` on a foreign arch must be TCG regardless of a present, usable + # native /dev/kvm — KVM can't virtualize the x86_64 guest here. + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "aarch64") + monkeypatch.setattr("beetroot.vm.qemu.os.access", lambda *_a, **_k: True) + assert qemu.detect_accel("auto") == "tcg" + + def test_x86_64_host_behavior_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None: + # On x86_64 the /dev/kvm probe still decides, unchanged. + monkeypatch.setattr("beetroot.vm.qemu.platform.machine", lambda: "x86_64") + monkeypatch.setattr("beetroot.vm.qemu.os.access", lambda *_a, **_k: True) + assert qemu.detect_accel("auto") == "kvm" + assert qemu.detect_accel("kvm") == "kvm" + + # --------------------------------------------------------------------------- # build_qemu_argv # ---------------------------------------------------------------------------