Compare commits

..

5 Commits

Author SHA1 Message Date
didericis-claude af4133d83b test(lifecycle): cover edge paths to satisfy diff-coverage gate
test / stage-firecracker-inputs (pull_request) Successful in 3s
test / unit (pull_request) Successful in 30s
tracker-policy-pr / check-pr (pull_request) Successful in 6s
lint / lint (push) Successful in 48s
test / integration-docker (pull_request) Successful in 26s
test / build-infra (pull_request) Successful in 3m36s
test / integration-firecracker (pull_request) Successful in 1m31s
test / coverage (pull_request) Successful in 2m5s
test / publish-infra (pull_request) Has been skipped
Add three new tests:
- noop when healthy but docker inspect fails (returns True → don't churn)
- build failure raises GatewayError
- _ensure_network creates the network when it doesn't exist

Also update the integration test to use new OrchestratorService API
(infra_name/image instead of orchestrator_name/gateway_name/gateway_image).

Brings diff-coverage from 86% to 90.3% against origin/main.
2026-07-20 20:11:05 +00:00
didericis-claude e712967df5 fix(pyright): resolve type errors introduced by lifecycle refactor
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / integration-docker (pull_request) Successful in 28s
test / unit (pull_request) Successful in 39s
lint / lint (push) Successful in 42s
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / build-infra (pull_request) Successful in 3m39s
test / integration-firecracker (pull_request) Successful in 1m49s
test / coverage (pull_request) Failing after 2m4s
test / publish-infra (pull_request) Has been skipped
- Remove unused INFRA_IMAGE import from test_orchestrator_lifecycle
- Update integration test to use new single-container OrchestratorService
  API (infra_name/image replaces orchestrator_name/gateway_name/gateway_image)
- Move type: ignore to the lambda line in gateway_init SIGHUP handler
- Break two long lines in test_orchestrator_lifecycle
2026-07-20 19:48:52 +00:00
didericis-claude 5b8c8ceb1d fix(lint): resolve pylint findings in gateway_init
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / integration-docker (pull_request) Successful in 11s
test / unit (pull_request) Successful in 34s
lint / lint (push) Failing after 44s
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / build-infra (pull_request) Successful in 3m58s
test / integration-firecracker (pull_request) Successful in 2m4s
test / coverage (pull_request) Failing after 1m57s
test / publish-infra (pull_request) Has been skipped
- Extract _sigkill_all() to cut nesting depth below the 5-block limit
- Add pylint: disable=consider-using-with on Popen (process must outlive caller)
- Break long SIGHUP signal line to stay within 100 chars
2026-07-20 19:41:37 +00:00
didericis-claude 52c0df7741 feat(docker): consolidate to single infra container under gateway_init supervise tree
test / stage-firecracker-inputs (pull_request) Successful in 4s
test / integration-docker (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / unit (pull_request) Successful in 37s
lint / lint (push) Failing after 2m41s
test / build-infra (pull_request) Successful in 3m59s
test / integration-firecracker (pull_request) Successful in 1m35s
test / coverage (pull_request) Successful in 2m7s
test / publish-infra (pull_request) Has been skipped
Collapses the two-container Docker model (gateway + orchestrator) into one
bot-bottle-infra container, matching the macOS and Firecracker backends.

- Dockerfile.infra: now a shared gateway+orchestrator base (COPY bot_bottle
  from orchestrator build, no CMD override)
- Dockerfile.infra.fc: new Firecracker-specific layer (buildah/crun/netavark)
- gateway_init: adds orchestrator daemon with _OPT_IN_DAEMONS gating so it
  only starts when BOT_BOTTLE_GATEWAY_DAEMONS explicitly includes it
- orchestrator/lifecycle: OrchestratorService manages one infra container;
  builds orchestrator (intermediate) then infra; live source bind-mounted at
  /bot-bottle-src with PYTHONPATH so the subprocess uses the checkout
- backend/consolidated_util: extracts provision_bottle + teardown_consolidated
  shared across all three backends; removes duplication in docker/fc/macos
  consolidated_launch modules
- firecracker/infra_vm: builds four images (orchestrator→gateway→infra→infra.fc)
- All unit tests updated and passing (1878 tests)
- PRD status: Draft → Active
2026-07-20 15:36:37 -04:00
didericis-claude 6544c3f49f docs(prd): consolidate infra backend for docker
Adds PRD for collapsing the Docker backend from two containers
(gateway + orchestrator) to a single bot-bottle-infra container,
restructuring Dockerfile.infra as the shared base, and extracting
duplicated CA polling / teardown / provision helpers into a shared
backend utility module.

Closes #431
2026-07-20 15:36:37 -04:00
11 changed files with 113 additions and 301 deletions
+8 -74
View File
@@ -45,8 +45,7 @@ from typing import TYPE_CHECKING, Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info, warn
from ..util import read_tty_line
from ..log import die, info
from ..manifest import Manifest, ManifestIndex
from ..supervise import SupervisePlan
from ..util import expand_tilde
@@ -649,26 +648,19 @@ def __getattr__(name: str) -> Any:
def get_bottle_backend(
name: str | None = None,
*,
prompt: bool = True,
) -> BottleBackend[Any, Any]:
"""Resolve the bottle backend.
`name` precedence:
1. explicit arg (e.g. resume passes the recorded backend name)
1. explicit arg (CLI `--backend=<name>` passes through here)
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.
3. `macos-container` on compatible macOS hosts
4. `firecracker` on KVM-capable Linux hosts
5. default `docker`
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(prompt=prompt)
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name()
backends = _get_backends()
if resolved not in backends:
known = ", ".join(sorted(backends))
@@ -676,35 +668,7 @@ def get_bottle_backend(
return backends[resolved]
def _platform_vm_suggestion() -> str:
"""Platform-appropriate VM backend name for install suggestions."""
return "macos-container" if sys.platform == "darwin" else "firecracker"
def _print_vm_install_instructions() -> None:
"""Print platform-appropriate VM backend install instructions to stderr."""
vm = _platform_vm_suggestion()
if vm == "macos-container":
info("Install Apple Container: https://github.com/apple/container/releases")
info("Then start the service: container system start")
else:
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
info("Configure the host: ./cli.py backend setup")
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
installed; firecracker on KVM-capable Linux even before the binary is
present (its preflight prints an install pointer).
Tier 2: docker, with a security warning and an interactive prompt.
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 -----------------------------------------
def _default_backend_name() -> str:
if has_backend("macos-container"):
return "macos-container"
# A KVM-capable Linux host defaults to firecracker even when the
@@ -714,37 +678,7 @@ def _auto_select_backend(prompt: bool = True) -> str:
from .firecracker import FirecrackerBottleBackend
if FirecrackerBottleBackend.is_host_capable():
return "firecracker"
# --- Tier 2: docker fallback ------------------------------------
if not has_backend("docker"):
info("No backend available on this host.")
_print_vm_install_instructions()
die("no backend available; install a VM backend and re-run")
vm = _platform_vm_suggestion()
warn(
"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"
" [d] use docker anyway\n"
" [q] quit\n"
"bot-bottle: choice [i/d/q]: "
)
sys.stderr.flush()
reply = read_tty_line().strip().lower()
if reply == "d":
return "docker"
if reply == "i":
_print_vm_install_instructions()
die("not proceeding with docker; install a VM backend or set BOT_BOTTLE_BACKEND=docker")
return "docker"
def known_backend_names() -> tuple[str, ...]:
+1 -1
View File
@@ -88,7 +88,7 @@ def require_firecracker() -> None:
booting a VM without it."""
if not is_linux():
die("firecracker backend is only supported on Linux (KVM). "
"On macOS use the macos-container backend.")
"On macOS use --backend=macos-container.")
if shutil.which("firecracker") is None:
info("Firecracker is required but was not found on PATH.")
info("Install: https://github.com/firecracker-microvm/firecracker/releases")
+10 -2
View File
@@ -3,10 +3,18 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from ..util import read_tty_line as read_tty_line
PROG = "cli.py"
USER_CWD = os.getcwd()
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
def read_tty_line() -> str:
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
try:
with open("/dev/tty", "r", encoding="utf-8") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
+3 -7
View File
@@ -21,20 +21,16 @@ from __future__ import annotations
import sys
from ..backend import get_bottle_backend, has_backend, known_backend_names
from ..backend import get_bottle_backend, known_backend_names
from ..log import info
from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int:
# Order: stable backend iteration so the y/N output is
# deterministic across runs. Skip backends whose runtime
# isn't available on this host so e.g. macos-container
# doesn't error on Linux.
# deterministic across runs.
plans = [
(name, get_bottle_backend(name))
for name in known_backend_names()
if has_backend(name)
(name, get_bottle_backend(name)) for name in known_backend_names()
]
prepared = [(name, b, b.prepare_cleanup()) for name, b in plans]
+18 -7
View File
@@ -27,6 +27,7 @@ from ..backend import (
BottleSpec,
enumerate_active_agents,
get_bottle_backend,
known_backend_names,
)
from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan
@@ -56,6 +57,15 @@ def cmd_start(argv: list[str]) -> int:
"into a cached layer."
),
)
parser.add_argument(
"--backend",
choices=known_backend_names(),
default=None,
help=(
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
"or host auto-selection). Overrides the env var when set."
),
)
parser.add_argument(
"--headless",
action="store_true",
@@ -105,10 +115,11 @@ def cmd_start(argv: list[str]) -> int:
os.environ["BOT_BOTTLE_NO_CACHE"] = "1"
manifest = ManifestIndex.resolve(USER_CWD)
backend_name: str | None = args.backend
if args.headless:
return _start_headless(
manifest, args, dry_run=dry_run
manifest, args, dry_run=dry_run, backend_name=backend_name
)
agent_name: str | None = args.name
@@ -159,6 +170,7 @@ def cmd_start(argv: list[str]) -> int:
return _launch_bottle(
spec,
dry_run=dry_run,
backend_name=backend_name,
)
@@ -170,6 +182,7 @@ def _start_headless(
args: argparse.Namespace,
*,
dry_run: bool,
backend_name: str | None,
) -> int:
"""Non-interactive launch path for orchestrators / CI / webhooks.
@@ -217,6 +230,7 @@ def _start_headless(
return _launch_bottle(
spec,
dry_run=dry_run,
backend_name=backend_name,
assume_yes=True,
headless_prompt_text=prompt,
)
@@ -254,18 +268,15 @@ 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).
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).
(`None` `$BOT_BOTTLE_BACKEND` host auto-selection). The CLI
passes whatever `--backend` resolved to.
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, prompt=not spec.headless)
backend = get_bottle_backend(backend_name)
plan = backend.prepare(spec, stage_dir=stage_dir)
identity = _identity_from_plan(plan)
-10
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import ipaddress
import os
import sys
def is_ip_literal(value: str) -> bool:
@@ -18,15 +17,6 @@ def is_ip_literal(value: str) -> bool:
return True
def read_tty_line() -> str:
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
try:
with open("/dev/tty", "r", encoding="utf-8") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
def expand_tilde(path: str) -> str:
"""Expand a leading '~' to $HOME. Leaves paths without a leading
tilde unchanged. Falls back to the empty string if $HOME is unset
+2 -143
View File
@@ -57,16 +57,14 @@ class TestGetBottleBackend(unittest.TestCase):
return self._available
# No macOS container and the host can't run firecracker (no
# KVM / not Linux) → docker fallback with a prompt. Simulate
# the user picking "d" (use docker anyway).
# KVM / not Linux) → docker is the last resort.
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, "read_tty_line", return_value="d"):
}):
b = get_bottle_backend()
self.assertEqual("docker", b.name)
@@ -98,145 +96,6 @@ class TestGetBottleBackend(unittest.TestCase):
with self.assertRaises(SystemExit):
get_bottle_backend("nonexistent")
def test_no_backend_available_dies(self):
# No VM and no docker → print install instructions and die.
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", False),
}), \
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
get_bottle_backend()
def test_docker_fallback_user_quits(self):
# VM unavailable, docker available, user picks "q" → die.
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, "read_tty_line", return_value="q"), \
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
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:
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, "read_tty_line", return_value="i"), \
patch.object(backend_mod, "_print_vm_install_instructions") as mock_inst, \
patch.object(backend_mod, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
get_bottle_backend()
mock_inst.assert_called_once()
class TestReadTtyLine(unittest.TestCase):
"""Unit tests for the shared read_tty_line helper in bot_bottle.util."""
def test_reads_from_dev_tty(self):
from unittest.mock import mock_open
from bot_bottle.util import read_tty_line
m = mock_open(read_data="hello\n")
with patch("builtins.open", m):
result = read_tty_line()
self.assertEqual("hello", result)
m.assert_called_once_with("/dev/tty", "r", encoding="utf-8")
def test_falls_back_to_stdin_on_oserror(self):
import io
from bot_bottle.util import read_tty_line
import sys as _sys
with patch("builtins.open", side_effect=OSError("no tty")), \
patch.object(_sys, "stdin", io.StringIO("world\n")):
result = read_tty_line()
self.assertEqual("world", result)
class TestPrintVmInstallInstructions(unittest.TestCase):
"""Unit tests for _print_vm_install_instructions platform branches."""
def test_linux_prints_firecracker_instructions(self):
from bot_bottle.backend import _print_vm_install_instructions
with patch.object(backend_mod, "_platform_vm_suggestion",
return_value="firecracker"), \
patch.object(backend_mod, "info") as mock_info:
_print_vm_install_instructions()
messages = [str(c[0][0]) for c in mock_info.call_args_list]
self.assertTrue(any("Firecracker" in m for m in messages))
def test_macos_prints_apple_container_instructions(self):
from bot_bottle.backend import _print_vm_install_instructions
with patch.object(backend_mod, "_platform_vm_suggestion",
return_value="macos-container"), \
patch.object(backend_mod, "info") as mock_info:
_print_vm_install_instructions()
messages = [str(c[0][0]) for c in mock_info.call_args_list]
self.assertTrue(any("Apple Container" in m for m in messages))
class TestKnownBackendNames(unittest.TestCase):
def test_returns_backends_sorted(self):
+5 -41
View File
@@ -1,10 +1,8 @@
"""Unit: `cli.py cleanup` walks every available backend.
"""Unit: `cli.py cleanup` walks every backend (issue follow-up).
Asserts cmd_cleanup queries each available backend's `prepare_cleanup`,
combines the y/N output, and runs each backend's `cleanup` when the
operator confirms. Unavailable backends (e.g. macos-container on Linux)
are skipped so that `cleanup` never errors on a platform where a backend
is not installed. Mocks the backends and stdin."""
Asserts cmd_cleanup queries each backend's `prepare_cleanup`,
combines the y/N output, and runs each backend's `cleanup` when
the operator confirms. Mocks the backends and stdin."""
from __future__ import annotations
@@ -23,7 +21,7 @@ def _make_backend(empty: bool = True):
class TestCmdCleanup(unittest.TestCase):
def test_iterates_every_available_backend(self):
def test_iterates_every_backend(self):
docker, docker_plan = _make_backend(empty=False)
fc, fc_plan = _make_backend(empty=False)
backends_by_name = {"docker": docker, "firecracker": fc}
@@ -34,8 +32,6 @@ class TestCmdCleanup(unittest.TestCase):
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
), patch.object(
cmd, "has_backend", return_value=True,
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
@@ -46,32 +42,6 @@ class TestCmdCleanup(unittest.TestCase):
docker.cleanup.assert_called_once_with(docker_plan)
fc.cleanup.assert_called_once_with(fc_plan)
def test_skips_unavailable_backends(self):
# macos-container is not available on Linux — must be silently skipped.
docker, docker_plan = _make_backend(empty=False)
macos = MagicMock()
backends_by_name = {"docker": docker}
def _has(name: str) -> bool:
return name == "docker"
with patch.object(
cmd, "known_backend_names",
return_value=("docker", "macos-container"),
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
), patch.object(
cmd, "has_backend", side_effect=_has,
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
self.assertEqual(0, cmd.cmd_cleanup([]))
docker.prepare_cleanup.assert_called_once()
docker.cleanup.assert_called_once_with(docker_plan)
macos.prepare_cleanup.assert_not_called()
def test_short_circuits_when_all_empty(self):
docker, _ = _make_backend(empty=True)
fc, _ = _make_backend(empty=True)
@@ -83,8 +53,6 @@ class TestCmdCleanup(unittest.TestCase):
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
), patch.object(
cmd, "has_backend", return_value=True,
), patch.object(
cmd, "_prompt_yes",
) as prompt:
@@ -104,8 +72,6 @@ class TestCmdCleanup(unittest.TestCase):
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
), patch.object(
cmd, "has_backend", return_value=True,
), patch.object(
cmd, "_prompt_yes", return_value=False,
):
@@ -126,8 +92,6 @@ class TestCmdCleanup(unittest.TestCase):
), patch.object(
cmd, "get_bottle_backend",
side_effect=lambda name: backends_by_name[name], # type: ignore
), patch.object(
cmd, "has_backend", return_value=True,
), patch.object(
cmd, "_prompt_yes", return_value=True,
):
+45 -13
View File
@@ -1,29 +1,61 @@
"""Unit: backend resolution priority — explicit name > env var.
"""Unit: `cli.py start --backend=<name>` flag (issue #77).
The `--backend` flag has been removed from `cli.py start`; backend
selection is driven by BOT_BOTTLE_BACKEND or auto-selection only.
`get_bottle_backend` still accepts an explicit name so that `resume`
(which records the backend from a prior session) and the `backend`
sub-command can pass one directly."""
Asserts that the flag wins over the env var, that the env var is
the fallback, and that the choices are pulled from the backend
registry (so adding a backend lights up in argparse without code
edits)."""
from __future__ import annotations
import argparse
import os
import unittest
from unittest.mock import patch
class TestBackendResolutionPriority(unittest.TestCase):
def test_explicit_name_overrides_env_var(self):
from bot_bottle.backend import known_backend_names
class TestStartBackendFlag(unittest.TestCase):
"""The flag is wired by `cmd_start`'s argparse and threaded
through `prepare_with_preflight(backend_name=...)`. Rather than
drive the whole start flow (which builds containers), we test
the argparse shape and the resolution function separately."""
def _build_parser(self):
# Mirror the parser definition from `cmd_start` so this
# test doesn't have to invoke the full command.
parser = argparse.ArgumentParser(prog="cb start")
parser.add_argument(
"--backend",
choices=known_backend_names(),
default=None,
)
parser.add_argument("name")
return parser
def test_flag_recognized(self):
args = self._build_parser().parse_args(["--backend=firecracker", "researcher"])
self.assertEqual("firecracker", args.backend)
self.assertEqual("researcher", args.name)
def test_flag_default_none_means_env_or_default_backend(self):
args = self._build_parser().parse_args(["researcher"])
self.assertIsNone(args.backend)
def test_invalid_backend_rejected_by_argparse(self):
parser = self._build_parser()
with self.assertRaises(SystemExit):
parser.parse_args(["--backend=garbage", "researcher"])
def test_resolution_priority_explicit_over_env(self):
# Independent assertion that get_bottle_backend (where
# `--backend` ultimately threads to) prefers the explicit
# name over BOT_BOTTLE_BACKEND.
from bot_bottle.backend import get_bottle_backend
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
self.assertEqual("docker", get_bottle_backend("docker").name)
def test_env_var_used_when_no_explicit_name(self):
from bot_bottle.backend import get_bottle_backend
with patch.dict(os.environ, {"BOT_BOTTLE_BACKEND": "firecracker"}):
self.assertEqual("firecracker", get_bottle_backend().name)
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -175,6 +175,14 @@ class TestCmdStartHeadless(unittest.TestCase):
)
self.assertEqual("researcher-2", self._spec().label)
# -- backend wiring ------------------------------------------------
def test_backend_flag_forwarded(self):
start_mod.cmd_start(
["--headless", "--backend=docker", "researcher", "--bottle", "claude",
"--prompt", "Do it"]
)
self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"])
class TestPrepareWithPreflight(unittest.TestCase):
+13 -3
View File
@@ -82,7 +82,7 @@ class TestCmdStartSelector(unittest.TestCase):
# ------------------------------------------------------------------
def test_explicit_agent_skips_agent_picker(self):
rc = start_mod.cmd_start(["researcher"])
rc = start_mod.cmd_start(["--backend=docker", "researcher"])
self.assertEqual(0, rc)
self._agent_picker_mock.assert_not_called()
self._bottle_picker_mock.assert_called_once()
@@ -100,7 +100,7 @@ class TestCmdStartSelector(unittest.TestCase):
def test_agent_absent_shows_agent_picker(self):
self._agent_picker_mock.return_value = "researcher"
rc = start_mod.cmd_start([])
rc = start_mod.cmd_start(["--backend=docker"])
self.assertEqual(0, rc)
self._agent_picker_mock.assert_called_once()
call_kwargs = self._agent_picker_mock.call_args
@@ -111,7 +111,7 @@ class TestCmdStartSelector(unittest.TestCase):
def test_agent_picker_cancel_skips_bottle_picker(self):
self._agent_picker_mock.return_value = None
rc = start_mod.cmd_start([])
rc = start_mod.cmd_start(["--backend=docker"])
self.assertEqual(0, rc)
self._bottle_picker_mock.assert_not_called()
self._launch_mock.assert_not_called()
@@ -168,6 +168,16 @@ class TestCmdStartSelector(unittest.TestCase):
# Backend wiring
# ------------------------------------------------------------------
def test_explicit_backend_forwarded(self):
start_mod.cmd_start(["--backend=docker", "researcher"])
_, kwargs = self._launch_mock.call_args
self.assertEqual("docker", kwargs["backend_name"])
def test_absent_backend_uses_default(self):
start_mod.cmd_start(["researcher"])
_, kwargs = self._launch_mock.call_args
self.assertIsNone(kwargs["backend_name"])
def test_bot_bottle_backend_env_skips_backend_picker(self):
os.environ["BOT_BOTTLE_BACKEND"] = "docker"
try: