Remove CLI backend assumptions and add docker fallback prompt #434

Merged
didericis merged 5 commits from remove-cli-backend-assumptions into main 2026-07-20 18:32:49 -04:00
3 changed files with 44 additions and 6 deletions
Showing only changes of commit d9e685e860 - Show all commits
+17 -3
View File
@@ -649,6 +649,8 @@ def __getattr__(name: str) -> Any:
def get_bottle_backend(
name: str | None = None,
*,
prompt: bool = True,
) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend.
@@ -657,11 +659,16 @@ def get_bottle_backend(
2. BOT_BOTTLE_BACKEND env var
3. auto-selection: VM backend first, docker fallback with prompt
`prompt` controls whether auto-selection may block on an interactive
[i/d/q] prompt when falling back to docker. Pass `prompt=False` in
non-interactive contexts (headless launches, CI) so the call dies
with an actionable message instead of hanging.
Dies with a pointer at the known backends if the chosen name
isn't implemented."""
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
if resolved is None:
resolved = _auto_select_backend()
resolved = _auto_select_backend(prompt=prompt)
didericis marked this conversation as resolved Outdated
Outdated
Review

This logic is undoubtably used other places, should be called from a util script/consolidate where this logic is defined

This logic is undoubtably used other places, should be called from a util script/consolidate where this logic is defined
Outdated
Review

Good call — the same logic lives in cli/_common.py::read_tty_line. Will move it to bot_bottle/util.py so both the CLI layer and the backend can import from one place.

Good call — the same logic lives in `cli/_common.py::read_tty_line`. Will move it to `bot_bottle/util.py` so both the CLI layer and the backend can import from one place.
backends = _get_backends()
if resolved not in backends:
known = ", ".join(sorted(backends))
@@ -685,7 +692,7 @@ def _print_vm_install_instructions() -> None:
info("Configure the host: ./cli.py backend setup")
def _auto_select_backend() -> str:
def _auto_select_backend(prompt: bool = True) -> str:
"""Tier-1 / tier-2 backend auto-selection.
Tier 1: VM backend — macos-container on macOS when Apple Container is
@@ -693,7 +700,9 @@ def _auto_select_backend() -> str:
present (its preflight prints an install pointer).
Tier 2: docker, with a security warning and an interactive prompt.
When docker is also absent, prints VM install instructions and exits.
When `prompt=False` (headless / CI), dies with an actionable message
instead of blocking on a TTY read. When docker is also absent, prints
VM install instructions and exits.
"""
# --- Tier 1: VM backend -----------------------------------------
if has_backend("macos-container"):
@@ -717,6 +726,11 @@ def _auto_select_backend() -> str:
"docker is less secure than VM backends — "
"containers share the host kernel."
)
if not prompt:
Review

Added if not prompt: die(...) here. When prompt=False (headless / CI) the docker-fallback prompt is bypassed entirely — die() emits an actionable message and exits instead of blocking on read_tty_line().

Added `if not prompt: die(...)` here. When `prompt=False` (headless / CI) the docker-fallback prompt is bypassed entirely — `die()` emits an actionable message and exits instead of blocking on `read_tty_line()`.
die(
f"no VM backend available; set BOT_BOTTLE_BACKEND=docker to proceed "
f"with docker, or install the {vm!r} backend."
)
sys.stderr.write(
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
f" [i] show {vm} install instructions and exit\n"
+6 -3
View File
@@ -254,15 +254,18 @@ def prepare_with_preflight(
injected callable, prompt y/N via the injected callable.
`backend_name` selects which backend prepares the plan
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
passes whatever `--backend` resolved to.
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection).
When `spec.headless` is True the docker-fallback prompt is suppressed:
auto-selection dies with an actionable message rather than blocking
on a TTY read (which would hang CI, webhook dispatch, and orchestrators).
Returns `(plan, identity)`. `plan` is None on dry-run or
operator-N, but `identity` is set as soon as `backend.prepare`
returns so callers can reap the prepare-time state dir via
`settle_state(identity)` in their finally — exactly the existing
semantics."""
backend = get_bottle_backend(backend_name)
backend = get_bottle_backend(backend_name, prompt=not spec.headless)
plan = backend.prepare(spec, stage_dir=stage_dir)
identity = _identity_from_plan(plan)
+21
View File
@@ -142,6 +142,27 @@ class TestGetBottleBackend(unittest.TestCase):
get_bottle_backend()
def test_docker_fallback_non_interactive_dies(self):
# prompt=False: headless/CI contexts must not block on a TTY read.
class _FakeBackend:
def __init__(self, name: str, available: bool) -> None:
self.name = name
self._available = available
def is_available(self) -> bool:
return self._available
with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod.FirecrackerBottleBackend,
"is_host_capable", classmethod(lambda cls: False)), \
patch.object(backend_mod, "_backends", {
"macos-container": _FakeBackend("macos-container", False),
"docker": _FakeBackend("docker", True),
}), \
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
get_bottle_backend(prompt=False)
def test_docker_fallback_user_picks_install(self):
# User picks [i] → print install instructions then die.
class _FakeBackend: