Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,9 @@
are absent), so shell regressions are caught locally before the push.

### Bug fixes
- **`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.
- **`compose.ps_status` now recognises every daemon-unreachable phrasing and can't hang (#178).** It maps both `cannot connect to the docker daemon` and `failed to connect to the docker API at ...` (the custom/rootless `DOCKER_HOST` phrasing) to `docker-unreachable` instead of misreporting a live container as `not-created`, and it runs `docker compose ps` under a bounded 20 s timeout (`subprocess.TimeoutExpired` → `docker-unreachable`) so a wedged daemon or an unresponsive TCP `DOCKER_HOST` degrades gracefully rather than hanging `ls`/`status`/`doctor` forever. Scoped to the read-only `ps_status` probe; lifecycle/streaming verbs are unchanged.
- **The Magisk denylist now hides root in the Play-Integrity/DroidGuard process (#170).** Denylist entries take a `package[/process]` shape, and `magisk-config.sh` inserts the real package into `package_name` and the process into `process` — never copying one value into both columns. The default enrolls `com.google.android.gms.unstable` (DroidGuard) as a **process** of the `com.google.android.gms` package instead of a bogus `package_name` that matched no installed app, so vanilla (non-Shamiko) Magisk actually hides root in the attestation process. The grammar validates both halves (SQL-injection prophylaxis), the health check matches enrolment by the package half, and the example configs demonstrate the corrected form.
- **`registry._read()` no longer silently drops an instance row that fails row-level validation (#252).** A row with a known backend kind but a rejected payload is now preserved opaquely so its port index stays reserved and it round-trips; a row too broken to salvage (bad `created_at`, missing/non-int index) is surfaced loudly (backed up to `.bak` with a hint) instead of being dropped and having its index silently reused.
Expand Down
4 changes: 2 additions & 2 deletions docs/design/binderless-hosts-qemu-tcg.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ expensive step.
the instance's stride-allocated host ADB port.
4. **`doctor` + banners** — ✅ done (issue #44): `beetroot up` prints the
§7.3 capability banner (quiet on KVM, loud on TCG) and `beetroot doctor`
runs `vm.process` + `vm.accel` rows. `binder: auto`/`host` keep their
existing behaviour.
runs `vm.process` + `vm.accel` + `vm.qemu` + `vm.artifacts` + `adb.connect`
rows (issue #191/#164). `binder: auto`/`host` keep their existing behaviour.
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`
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/running-in-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ hosted runner (the Option A path above) in three tiers:
is heavy and non-blocking while it's hardened.
* **Tier-VM** builds the binder-enabled guest kernel + rootfs, boots redroid
inside the `binder: vm` QEMU micro-VM, and drives it through the adb backend
(`ls` / `shell` / the `doctor` `vm.process` + `vm.accel` rows; Frida is
(`ls` / `shell` / the `doctor` `vm.process` + `vm.accel` + `vm.qemu` +
`vm.artifacts` + `adb.connect` rows; Frida is
asserted to report its "not yet supported on the vm backend" message). On a
GitHub-hosted runner there is no `/dev/kvm`, so it runs under TCG — a slow
(~100 s+) but real boot. The kernel + rootfs build is the long pole.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/sandbox-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ uv run beetroot logs alpha # follow Android init → sys.boot_completed=1
Once it reports boot completion, confirm the instance is healthy:

```bash
uv run beetroot doctor alpha # adb, magisk, frida, plus the vm.process / vm.accel rows
uv run beetroot doctor alpha # vm.process / vm.accel / vm.qemu / vm.artifacts / adb.connect rows
uv run beetroot shell alpha # interactive shell on the guest
```

Expand Down
2 changes: 1 addition & 1 deletion docs/how-it-works/binder-and-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ They answer different questions and you'll use both:
| Scope | the **host** | one **existing instance** |
| When | *before* creating an instance / picking `binder` | *after* `up`, to debug a running instance |
| Needs an instance? | No | Yes |
| Answers | "what can this machine run?" | "is this instance healthy?" (adb, magisk, frida, `host.binder`, `vm.process`, `vm.accel`) |
| Answers | "what can this machine run?" | "is this instance healthy?" (adb, magisk, frida, `host.binder`, `vm.process`, `vm.accel`, `vm.qemu`, `vm.artifacts`) |

`doctor`'s `host.binder` row reports the same binder probe `modes` uses, but
only in the context of a single instance; `modes` is the standalone,
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,8 @@ Redroid checks: `compose.status`, `host.binder`, `adb.connect`, `frida.handshake

Adb checks: `adb.serial`, `frida.handshake`, `magisk.zygisk`, `magisk.denylist.com.google.android.gms`. `compose.status` is not applicable.

VM (`binder: vm`) checks: `vm.process` (is QEMU alive?), `vm.accel` (KVM vs the slow-TCG note), `vm.qemu` (the QEMU emulator is on `PATH`), `vm.artifacts` (the guest kernel + rootfs exist — else a `beetroot build --vm-kernel` hint), and `adb.connect` (connect-then-verify against the forwarded loopback port, not the USB-style `adb.serial`). The Frida and Magisk rows are omitted — the network-isolated guest has no Frida and boots a plain redroid image with no Magisk.

---

## `modes`
Expand Down
94 changes: 76 additions & 18 deletions src/beetroot/backends/vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Self

from beetroot import builder, config, console, paths, ports, registry
from beetroot import builder, capabilities, config, console, paths, ports, registry
from beetroot.api import (
FRIDA_ADDRESS_UNSUPPORTED,
AdbNotInstalledError,
BackendCapabilityError,
InstanceNotFoundError,
adb_device_health,
_check_adb_connect,
)
from beetroot.backends import register_backend
from beetroot.settings import settings
Expand Down Expand Up @@ -973,14 +973,27 @@ def health(self) -> dict[str, CheckResult]:
"""
Aggregate the VM-backed health checks for this instance.

Includes a VM-specific ``vm.process`` row (is QEMU alive?) and a
``vm.accel`` row (kvm vs the slow-tcg note), then the shared adb rows so
downstream tools grep uniformly across backend kinds. Three shared rows
are dropped because they can never pass on the network-isolated, plain
upstream redroid guest: ``frida.handshake`` (Frida is unsupported —
issue #44) and the ``magisk.*`` rows (the ``binder: vm`` guest boots an
unmodified redroid image with no Magisk — issue #163), so a permanent
``fail`` row would be noise.
Emits VM-specific rows rather than reusing the shared adb health set,
which assumes a Magisk-flashed, USB-listed device the ``binder: vm``
guest is not:

* ``vm.process`` — is the QEMU micro-VM process alive?
* ``vm.accel`` — resolved accelerator (kvm vs the slow-tcg note).
* ``vm.qemu`` — is the QEMU emulator binary on ``PATH``? Without it the
VM can never boot (issue #191), so a green ``vm.accel`` alone is not
proof of readiness.
* ``vm.artifacts`` — do the configured guest kernel + rootfs exist on the
host? A missing artifact is a ``beetroot build --vm-kernel`` away
(issue #191).
* ``adb.connect`` — connect-then-verify against the forwarded loopback
adb port. A TCP adb target only appears *after* an explicit ``adb
connect``, so the USB-style always-listed ``adb.serial`` row false-
fails a healthy VM from a fresh adb-server lifetime (issue #164).

``frida.handshake`` and the ``magisk.*`` rows are intentionally absent:
the network-isolated guest has no Frida (issue #44) and boots an
unmodified upstream redroid image with no Magisk (issue #163), so a
permanent ``fail`` row would be noise.

Returns:
Ordered dict of check name → :class:`CheckResult`.
Expand All @@ -995,16 +1008,61 @@ def health(self) -> dict[str, CheckResult]:
else CheckResult(status="fail", reason="QEMU micro-VM is not running")
)
checks["vm.accel"] = _accel_check(self._cfg.vm.accel)
shared = adb_device_health(self)
shared.pop("frida.handshake", None)
# The guest runs plain redroid (no Magisk), so the magisk rows would be a
# permanent fail — drop them rather than mislead (issue #163).
for name in list(shared):
if name.startswith("magisk."):
shared.pop(name)
checks.update(shared)
checks["vm.qemu"] = self._qemu_binary_check()
checks["vm.artifacts"] = self._artifacts_check()
checks["adb.connect"] = _check_adb_connect(self.adb_address)
return checks

def _qemu_binary_check(self) -> CheckResult:
"""
Report whether the QEMU emulator binary is on ``PATH`` (issue #191).

``CheckResult`` has no dedicated remedy field, so the fix pointer is
folded into the ``reason`` — reusing the shared
:data:`capabilities._QEMU_INSTALL` string so the doctor row matches what
``beetroot modes`` prints.

Returns:
A ``pass`` row when ``settings.qemu_bin`` resolves, else a ``fail``
row (a VM whose emulator is missing can never boot).
"""
from beetroot.api import CheckResult # noqa: PLC0415 # avoid import cycle with api.py

if shutil.which(settings.qemu_bin) is not None:
return CheckResult(status="pass")
return CheckResult(
status="fail",
# Reuse the shared modes remedy verbatim (do not duplicate the literal).
reason=(
f"QEMU ({settings.qemu_bin}) not found on PATH — {capabilities._QEMU_INSTALL}" # noqa: SLF001 # shared cross-module remedy string
),
)

def _artifacts_check(self) -> CheckResult:
"""
Report whether the guest kernel + rootfs artifacts exist (issue #191).

Resolves both artifacts through :func:`_resolve_artifact` (which raises
:class:`qemu.QemuLaunchError` when the file is unset or missing). A green
``vm.accel`` alone would otherwise imply the VM can boot when the kernel
or rootfs has never been built. The ``QemuLaunchError`` message already
names the missing artifact and points at ``beetroot build --vm-kernel``.

Returns:
A ``pass`` row when both artifacts resolve, else a ``fail`` row whose
reason names the missing artifact (the resolver's own message already
points at ``beetroot build --vm-kernel``, matching
:data:`capabilities._BUILD_HINT`).
"""
from beetroot.api import CheckResult # noqa: PLC0415 # avoid import cycle with api.py

try:
_resolve_artifact(self._cfg.vm.kernel, settings.vm_kernel, "kernel")
_resolve_artifact(self._cfg.vm.rootfs, settings.vm_rootfs, "rootfs")
except qemu.QemuLaunchError as exc:
return CheckResult(status="fail", reason=str(exc))
return CheckResult(status="pass")


def _accel_check(requested: Literal["auto", "kvm", "tcg"]) -> CheckResult:
"""
Expand Down
23 changes: 3 additions & 20 deletions src/beetroot/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

from pydantic import BaseModel, ConfigDict

from . import config, console, kernel_download, paths, rootfs_download
from . import capabilities, config, console, kernel_download, paths, rootfs_download
from .settings import settings

# The patcher CLI flag each GApps vendor needs. Keyed by the *resolved* vendor
Expand Down Expand Up @@ -313,7 +313,7 @@ def build_image( # noqa: PLR0913 # 7 keyword-only params; each is a distinct i
# Docker daemon, so fail fast with the same actionable remedy the bake path
# uses instead of a generic ``command failed (exit 1)`` mid-build (issue
# #193).
if not _docker_daemon_responsive():
if not capabilities.docker_daemon_responsive():
raise BootstrapError(
f"Docker daemon: `{settings.docker_bin} info` failed (daemon not running?) — "
"start the daemon (e.g. `sudo systemctl start docker`)"
Expand Down Expand Up @@ -1405,23 +1405,6 @@ class PreflightProblem(BaseModel):
("xtables_multi", "iptables"),
)

# Probe timeout for the ``docker info`` daemon check (seconds).
_DOCKER_INFO_TIMEOUT: Final[int] = 20


def _docker_daemon_responsive() -> bool:
"""Return ``True`` iff the host Docker daemon answers ``docker info``."""
try:
result = subprocess.run( # noqa: S603 # docker bin from settings; fixed argv
[settings.docker_bin, "info"],
check=False,
capture_output=True,
timeout=_DOCKER_INFO_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0


def vm_fetch_preflight() -> list[PreflightProblem]:
"""
Expand Down Expand Up @@ -1532,7 +1515,7 @@ def vm_bake_preflight(*, redroid_tar: Path | None = None) -> list[PreflightProbl
fix="install Docker Engine (apt-get install docker.io)",
)
)
elif not _docker_daemon_responsive():
elif not capabilities.docker_daemon_responsive():
problems.append(
PreflightProblem(
requirement="Docker daemon",
Expand Down
79 changes: 68 additions & 11 deletions src/beetroot/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,45 @@
from __future__ import annotations

import shutil
import subprocess

from pydantic import BaseModel, ConfigDict

from . import hostcheck
from .settings import Settings
from .settings import Settings, settings
from .vm import qemu

# Probe timeout for the ``docker info`` daemon check (seconds).
_DOCKER_INFO_TIMEOUT = 20


def docker_daemon_responsive() -> bool:
"""
Return ``True`` iff the host Docker daemon answers ``docker info``.

A ``shutil.which(docker_bin)`` presence probe only proves the *CLI* is
installed — it says nothing about whether ``dockerd`` is actually running.
Anything that needs to *launch* a container (a redroid boot, a
``beetroot build``) must probe daemon liveness with ``docker info`` (issues
#179 / #193), so this lives here as the single shared probe rather than a
per-caller duplicate.

Returns:
``True`` iff ``docker info`` exits 0 within
:data:`_DOCKER_INFO_TIMEOUT` seconds.
"""
try:
result = subprocess.run( # noqa: S603 # docker bin from settings; fixed argv
[settings.docker_bin, "info"],
check=False,
capture_output=True,
timeout=_DOCKER_INFO_TIMEOUT,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0


# Per-mode verdict (the ``status`` field of :class:`ModeSupport`):
#
# * ``supported`` — works on this host as-is (modulo runtime inputs like a
Expand Down Expand Up @@ -79,27 +111,44 @@ class ModeSupport(BaseModel):
remedy: str


def _redroid_host(binder: hostcheck.BinderStatus, *, docker: bool) -> ModeSupport:
def _redroid_host(
binder: hostcheck.BinderStatus, *, docker: bool, docker_daemon: bool
) -> ModeSupport:
"""
Classify the ``redroid`` backend on the host-binder path.

Args:
binder: The probed host binder capability.
docker: Whether the Docker CLI is on ``PATH``.
docker_daemon: Whether the Docker *daemon* answers ``docker info``.
Only meaningful when ``docker`` is ``True`` (there's no daemon to
probe without the CLI).

Returns:
The :class:`ModeSupport` verdict for ``binder: host`` / ``auto``.
"""
if binder.state == "ready":
if docker:
if not docker:
return ModeSupport(
mode=MODE_REDROID_HOST, status="supported", reason=binder.reason, remedy=""
mode=MODE_REDROID_HOST,
status="needs-setup",
reason="host binder is ready, but the Docker CLI was not found",
remedy="install Docker and ensure the daemon is running",
)
if not docker_daemon:
# The CLI is present but ``dockerd`` isn't answering — redroid can't
# boot a container, so this is not "supported" (issue #179).
return ModeSupport(
mode=MODE_REDROID_HOST,
status="needs-setup",
reason=(
"host binder is ready and the Docker CLI is present, but the "
"Docker daemon is not responding"
),
remedy="start the Docker daemon",
)
return ModeSupport(
mode=MODE_REDROID_HOST,
status="needs-setup",
reason="host binder is ready, but the Docker CLI was not found",
remedy="install Docker and ensure the daemon is running",
mode=MODE_REDROID_HOST, status="supported", reason=binder.reason, remedy=""
)
if binder.state == "loadable":
return ModeSupport(
Expand Down Expand Up @@ -206,12 +255,13 @@ def _adb_adopt(*, adb_present: bool) -> ModeSupport:
)


def classify_modes(
def classify_modes( # noqa: PLR0913 # each keyword-only param is a distinct host probe
*,
binder: hostcheck.BinderStatus,
kvm: bool,
qemu_present: bool,
docker: bool,
docker_daemon: bool,
adb_present: bool,
) -> list[ModeSupport]:
"""
Expand All @@ -225,13 +275,15 @@ def classify_modes(
kvm: Whether a usable ``/dev/kvm`` is present.
qemu_present: Whether the QEMU system emulator is on ``PATH``.
docker: Whether the Docker CLI is on ``PATH``.
docker_daemon: Whether the Docker daemon answers ``docker info``
(only meaningful when ``docker`` is ``True``).
adb_present: Whether the ``adb`` client is on ``PATH``.

Returns:
One :class:`ModeSupport` per mode, in a stable display order.
"""
return [
_redroid_host(binder, docker=docker),
_redroid_host(binder, docker=docker, docker_daemon=docker_daemon),
_vm_kvm(kvm=kvm, qemu_present=qemu_present),
_vm_tcg(qemu_present=qemu_present),
_adb_adopt(adb_present=adb_present),
Expand All @@ -254,11 +306,16 @@ def survey(settings: Settings | None = None) -> list[ModeSupport]:
The per-mode support matrix.
"""
cfg = settings if settings is not None else Settings()
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.
kvm=qemu.detect_accel("auto") == "kvm",
qemu_present=shutil.which(cfg.qemu_bin) is not None,
docker=shutil.which(cfg.docker_bin) is not None,
docker=docker_present,
# The daemon probe only matters when the CLI is present — without it
# there's nothing to ask ``docker info``, and the classifier ignores
# the flag on the no-CLI path anyway.
docker_daemon=docker_present and docker_daemon_responsive(),
adb_present=shutil.which("adb") is not None,
)
Loading