diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index cb673c5..128ca27 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -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) 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: + 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" diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index c2f57c8..b70328c 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -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) diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index 7fb8cd9..a5a4c18 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -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: