fix(backend): don't prompt in headless/non-interactive backend selection
lint / lint (push) Failing after 45s
test / unit (pull_request) Successful in 1m34s
test / integration-docker (pull_request) Successful in 12s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
test / stage-firecracker-inputs (pull_request) Successful in 3s
test / build-infra (pull_request) Successful in 3m21s
test / integration-firecracker (pull_request) Successful in 1m30s
test / coverage (pull_request) Successful in 1m30s
test / publish-infra (pull_request) Has been skipped
lint / lint (push) Failing after 45s
test / unit (pull_request) Successful in 1m34s
test / integration-docker (pull_request) Successful in 12s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
test / stage-firecracker-inputs (pull_request) Successful in 3s
test / build-infra (pull_request) Successful in 3m21s
test / integration-firecracker (pull_request) Successful in 1m30s
test / coverage (pull_request) Successful in 1m30s
test / publish-infra (pull_request) Has been skipped
_auto_select_backend gains a prompt parameter (default True). When
prompt=False the docker-fallback [i/d/q] menu is skipped and the call
dies immediately with an actionable message ("set
BOT_BOTTLE_BACKEND=docker or install a VM backend"), preventing hangs
in CI, webhook dispatch, and orchestrator launches.
prepare_with_preflight passes prompt=not spec.headless so the headless
start path can never block waiting for TTY input it cannot receive.
This commit is contained in:
@@ -649,6 +649,8 @@ def __getattr__(name: str) -> Any:
|
|||||||
|
|
||||||
def get_bottle_backend(
|
def get_bottle_backend(
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
*,
|
||||||
|
prompt: bool = True,
|
||||||
) -> BottleBackend[Any, Any]:
|
) -> BottleBackend[Any, Any]:
|
||||||
"""Resolve the bottle backend.
|
"""Resolve the bottle backend.
|
||||||
|
|
||||||
@@ -657,11 +659,16 @@ def get_bottle_backend(
|
|||||||
2. BOT_BOTTLE_BACKEND env var
|
2. BOT_BOTTLE_BACKEND env var
|
||||||
3. auto-selection: VM backend first, docker fallback with prompt
|
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
|
Dies with a pointer at the known backends if the chosen name
|
||||||
isn't implemented."""
|
isn't implemented."""
|
||||||
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
|
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND")
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
resolved = _auto_select_backend()
|
resolved = _auto_select_backend(prompt=prompt)
|
||||||
backends = _get_backends()
|
backends = _get_backends()
|
||||||
if resolved not in backends:
|
if resolved not in backends:
|
||||||
known = ", ".join(sorted(backends))
|
known = ", ".join(sorted(backends))
|
||||||
@@ -685,7 +692,7 @@ def _print_vm_install_instructions() -> None:
|
|||||||
info("Configure the host: ./cli.py backend setup")
|
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 / tier-2 backend auto-selection.
|
||||||
|
|
||||||
Tier 1: VM backend — macos-container on macOS when Apple Container is
|
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).
|
present (its preflight prints an install pointer).
|
||||||
|
|
||||||
Tier 2: docker, with a security warning and an interactive prompt.
|
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 -----------------------------------------
|
# --- Tier 1: VM backend -----------------------------------------
|
||||||
if has_backend("macos-container"):
|
if has_backend("macos-container"):
|
||||||
@@ -717,6 +726,11 @@ def _auto_select_backend() -> str:
|
|||||||
"docker is less secure than VM backends — "
|
"docker is less secure than VM backends — "
|
||||||
"containers share the host kernel."
|
"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(
|
sys.stderr.write(
|
||||||
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
|
f"bot-bottle: For better isolation, install the {vm!r} backend.\n"
|
||||||
f" [i] show {vm} install instructions and exit\n"
|
f" [i] show {vm} install instructions and exit\n"
|
||||||
|
|||||||
@@ -254,15 +254,18 @@ def prepare_with_preflight(
|
|||||||
injected callable, prompt y/N via the injected callable.
|
injected callable, prompt y/N via the injected callable.
|
||||||
|
|
||||||
`backend_name` selects which backend prepares the plan
|
`backend_name` selects which backend prepares the plan
|
||||||
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
|
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection).
|
||||||
passes whatever `--backend` resolved to.
|
|
||||||
|
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
|
Returns `(plan, identity)`. `plan` is None on dry-run or
|
||||||
operator-N, but `identity` is set as soon as `backend.prepare`
|
operator-N, but `identity` is set as soon as `backend.prepare`
|
||||||
returns so callers can reap the prepare-time state dir via
|
returns so callers can reap the prepare-time state dir via
|
||||||
`settle_state(identity)` in their finally — exactly the existing
|
`settle_state(identity)` in their finally — exactly the existing
|
||||||
semantics."""
|
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)
|
plan = backend.prepare(spec, stage_dir=stage_dir)
|
||||||
identity = _identity_from_plan(plan)
|
identity = _identity_from_plan(plan)
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,27 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
get_bottle_backend()
|
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):
|
def test_docker_fallback_user_picks_install(self):
|
||||||
# User picks [i] → print install instructions then die.
|
# User picks [i] → print install instructions then die.
|
||||||
class _FakeBackend:
|
class _FakeBackend:
|
||||||
|
|||||||
Reference in New Issue
Block a user