diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 9080681..fe2c498 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -538,6 +538,28 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): doesn't fail when `cli.py list active` walks past firecracker.""" + @classmethod + @abstractmethod + def setup(cls) -> int: + """Emit this backend's one-time host setup — privileged network + pool, daemon bring-up, install pointers, etc. — as + host-appropriate config or commands. Prints to stdout/stderr and + returns a shell exit code (0 = nothing to report / success). A + backend that needs no host setup prints a short note and returns + 0. Invoked generically by `./cli.py backend setup [--backend=…]` + so operators can provision any backend without a + backend-specific command. Classmethod (like `is_available`) — + it's a host query, not per-bottle state.""" + + @classmethod + @abstractmethod + def status(cls) -> int: + """Report whether this backend's prerequisites are satisfied on + the host — binaries, daemon reachability, network pool, range + conflicts, etc. Prints a human-readable summary; returns 0 when + the backend is ready to launch and non-zero when something is + missing. Invoked by `./cli.py backend status [--backend=…]`.""" + # Import concrete backend classes AFTER the base types are defined, so # each backend module can pull BottleSpec / BottlePlan / BottleBackend diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index b3b9e3f..de6a22d 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -54,6 +54,16 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup launch.""" return shutil.which("docker") is not None + @classmethod + def setup(cls) -> int: + from . import setup as _setup + return _setup.setup() + + @classmethod + def status(cls) -> int: + from . import setup as _setup + return _setup.status() + def _preflight(self) -> None: _resolve_plan.preflight() diff --git a/bot_bottle/backend/docker/setup.py b/bot_bottle/backend/docker/setup.py new file mode 100644 index 0000000..85e00e0 --- /dev/null +++ b/bot_bottle/backend/docker/setup.py @@ -0,0 +1,80 @@ +"""Host setup + status for the Docker backend. + +Unlike Firecracker, the Docker backend needs no privileged one-time +host provisioning (no TAP pool / nft table) — networks and the sidecar +bundle are created per-launch. So `setup()` is mostly an install/daemon +pointer, and `status()` reports whether docker is usable. + +This is intentionally minimal; a richer version (daemon config checks, +gVisor/runsc install guidance, rootless-docker hints) is tracked +separately. Reached via `DockerBottleBackend.setup` / `.status`, which +the generic `./cli.py backend {setup,status}` dispatches to. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys + +from . import util as _util + + +def _docker_on_path() -> bool: + return shutil.which("docker") is not None + + +def _daemon_reachable() -> bool: + if not _docker_on_path(): + return False + return subprocess.run( + ["docker", "info"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ).returncode == 0 + + +def _print_install_pointer() -> None: + sys.stderr.write("Docker is required but was not found on PATH.\n") + sys.stderr.write(" macOS: Docker Desktop https://docs.docker.com/desktop/install/mac-install/\n") + sys.stderr.write(" Linux: Docker Engine https://docs.docker.com/engine/install/\n") + + +def setup() -> int: + if not _docker_on_path(): + _print_install_pointer() + return 1 + sys.stderr.write( + "Docker backend: no privileged host setup required — networks and " + "the sidecar bundle are created per-launch.\n" + ) + if not _daemon_reachable(): + sys.stderr.write( + "The Docker daemon isn't reachable; start it (e.g. Docker " + "Desktop, or `systemctl start docker`).\n" + ) + return 1 + if not _util.runsc_available(): + sys.stderr.write( + "Optional: register the gVisor (`runsc`) runtime with Docker for " + "a userspace syscall barrier; bottles auto-detect and use it.\n" + ) + return 0 + + +def status() -> int: + ok = True + if _docker_on_path(): + sys.stderr.write("docker on PATH: yes\n") + else: + sys.stderr.write("docker on PATH: NO\n") + ok = False + if ok and _daemon_reachable(): + sys.stderr.write("docker daemon: reachable\n") + elif ok: + sys.stderr.write("docker daemon: UNREACHABLE\n") + ok = False + runsc = _docker_on_path() and _util.runsc_available() + sys.stderr.write(f"gVisor runsc runtime: {'registered' if runsc else 'not registered (optional)'}\n") + if not ok: + sys.stderr.write("\nRun: ./cli.py backend setup --backend=docker\n") + return 0 if ok else 1 diff --git a/bot_bottle/backend/firecracker/backend.py b/bot_bottle/backend/firecracker/backend.py index 58d49f9..632d124 100644 --- a/bot_bottle/backend/firecracker/backend.py +++ b/bot_bottle/backend/firecracker/backend.py @@ -46,6 +46,16 @@ class FirecrackerBottleBackend( install pointer at launch.""" return _util.is_host_capable() + @classmethod + def setup(cls) -> int: + from . import setup as _setup + return _setup.setup() + + @classmethod + def status(cls) -> int: + from . import setup as _setup + return _setup.status() + def _preflight(self) -> None: _resolve_plan.preflight() diff --git a/bot_bottle/backend/firecracker/isolation_probe.py b/bot_bottle/backend/firecracker/isolation_probe.py index 233665d..949199f 100644 --- a/bot_bottle/backend/firecracker/isolation_probe.py +++ b/bot_bottle/backend/firecracker/isolation_probe.py @@ -108,7 +108,7 @@ def verify_isolation(private_key: Path, guest_ip: str) -> None: die(f"ISOLATION FAILURE: the VM reached the host canary " f"{canary_ip}:{canary_port}. The egress boundary is not in " f"force — refusing to run the agent (fail-closed). Verify the " - f"nft table with: ./cli.py firecracker setup") + f"nft table with: ./cli.py backend setup --backend=firecracker") if result.returncode == 2: die("isolation probe inconclusive: the guest has no python3/bash/nc " "to run the connectivity test. Refusing to continue " diff --git a/bot_bottle/backend/firecracker/setup.py b/bot_bottle/backend/firecracker/setup.py new file mode 100644 index 0000000..ffaf125 --- /dev/null +++ b/bot_bottle/backend/firecracker/setup.py @@ -0,0 +1,109 @@ +"""Host setup + status for the Firecracker backend. + +`setup()` prints the host-appropriate config for the privileged, +one-time network pool (TAP devices + isolation nftables table) the +backend needs. On NixOS it points at the flake module (and prints a +paste-able fallback); elsewhere it prints the sudo command for the +bundled setup script. `status()` reports what's present, including +whether the pool range collides with an existing route. + +Called through `FirecrackerBottleBackend.setup` / `.status`, which the +generic `./cli.py backend {setup,status}` command dispatches to. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from . import netpool + + +def _owner() -> str: + return os.environ.get("USER", "youruser") + + +def _is_nixos() -> bool: + if Path("/etc/NIXOS").exists(): + return True + try: + return "ID=nixos" in Path("/etc/os-release").read_text() + except OSError: + return False + + +def _warn_overlaps() -> None: + """Warn if the chosen pool range collides with an existing host route + (Tailscale CGNAT peer, a docker/libvirt bridge, the LAN…).""" + conflicts = netpool.overlapping_routes() + if not conflicts: + return + detail = "\n".join(f" {c.dst} dev {c.dev}" for c in conflicts) + sys.stderr.write( + f"WARNING: pool range (base {netpool.ip_base()}, " + f"{netpool.pool_size()} slots) overlaps existing routes:\n" + f"{detail}\n" + "Set BOT_BOTTLE_FC_IP_BASE to a free range before setup, or the " + "pool may shadow / be shadowed by the above.\n\n" + ) + + +def setup() -> int: + slots = netpool.all_slots() + sys.stderr.write( + f"Firecracker network pool: {len(slots)} slots " + f"({slots[0].iface}..{slots[-1].iface}), base {netpool.ip_base()}.\n" + f"This is a one-time privileged setup (needs root once).\n\n" + ) + _warn_overlaps() + if _is_nixos(): + sys.stderr.write( + "Detected NixOS. Preferred: consume the flake module (versioned, " + "no copy-paste drift):\n\n" + " # flake inputs (point at wherever you host bot-bottle):\n" + " inputs.bot-bottle.url = \"git+ssh://\";\n" + " # host module:\n" + " imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ];\n" + f" services.bot-bottle-firecracker = {{ enable = true; owner = \"{_owner()}\"; }};\n\n" + "Then `nixos-rebuild switch`. Channel (non-flake) users can " + "`imports = [ /nix/firecracker-netpool.nix ];` instead.\n\n" + "Fallback — paste this generated module directly:\n\n" + ) + sys.stdout.write(netpool.render_nixos_module()) + else: + sys.stderr.write("Run the one-time setup as root:\n\n") + sys.stdout.write(netpool.render_shell_setup() + "\n") + sys.stderr.write( + "\n(On NixOS, use the declarative module instead — this host " + "was not detected as NixOS.)\n" + ) + return 0 + + +def status() -> int: + ok = True + if netpool.nft_table_present(): + sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: present\n") + else: + sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: MISSING\n") + ok = False + missing = netpool.missing_taps() + total = netpool.pool_size() + if missing: + sys.stderr.write(f"TAP pool: {total - len(missing)}/{total} present " + f"(missing: {', '.join(missing)})\n") + ok = False + else: + sys.stderr.write(f"TAP pool: {total}/{total} present\n") + conflicts = netpool.overlapping_routes() + if conflicts: + detail = ", ".join(f"{c.dst} dev {c.dev}" for c in conflicts) + sys.stderr.write(f"range overlap: base {netpool.ip_base()} CLASHES " + f"with {detail}\n") + ok = False + else: + sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n") + if not ok: + sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n") + return 0 if ok else 1 diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py index bd43ed8..61e1868 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -36,6 +36,16 @@ class MacosContainerBottleBackend( def is_available(cls) -> bool: return _container.is_available() + @classmethod + def setup(cls) -> int: + from . import setup as _setup + return _setup.setup() + + @classmethod + def status(cls) -> int: + from . import setup as _setup + return _setup.status() + def _preflight(self) -> None: _resolve_plan.preflight() diff --git a/bot_bottle/backend/macos_container/setup.py b/bot_bottle/backend/macos_container/setup.py new file mode 100644 index 0000000..1df3488 --- /dev/null +++ b/bot_bottle/backend/macos_container/setup.py @@ -0,0 +1,69 @@ +"""Host setup + status for the macOS Apple Container backend. + +Like Docker, this backend needs no privileged network-pool provisioning +— it wants Apple's `container` CLI installed and its system service +running. `setup()` points at the install/`container system start` steps; +`status()` reports readiness. Reached via +`MacosContainerBottleBackend.setup` / `.status`, dispatched from the +generic `./cli.py backend {setup,status}`. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys + +from . import util as _container + + +def _service_running() -> bool: + if shutil.which("container") is None: + return False + return subprocess.run( + ["container", "system", "status"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ).returncode == 0 + + +def setup() -> int: + if not _container.is_macos(): + sys.stderr.write("macos-container backend requires macOS.\n") + return 1 + if shutil.which("container") is None: + sys.stderr.write("Apple Container is required but was not found on PATH.\n") + sys.stderr.write("Install: https://github.com/apple/container/releases\n") + return 1 + if not _service_running(): + sys.stderr.write( + "Apple Container is installed but its system service isn't " + "running. Start it with: container system start\n" + ) + return 1 + sys.stderr.write( + "macos-container backend: ready — no privileged host setup required.\n" + ) + return 0 + + +def status() -> int: + ok = True + if _container.is_macos(): + sys.stderr.write("host: macOS\n") + else: + sys.stderr.write("host: NOT macOS (backend unsupported here)\n") + ok = False + if shutil.which("container") is not None: + sys.stderr.write("container CLI on PATH: yes\n") + else: + sys.stderr.write("container CLI on PATH: NO\n") + ok = False + if ok: + sys.stderr.write( + f"container system service: {'running' if _service_running() else 'NOT running'}\n" + ) + if not _service_running(): + ok = False + if not ok: + sys.stderr.write("\nRun: ./cli.py backend setup --backend=macos-container\n") + return 0 if ok else 1 diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index 6c6db94..c4a9770 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -1,6 +1,6 @@ """Main CLI dispatcher. -Commands: cleanup, commit, edit, info, init, list, resume, start, supervise +Commands: backend, cleanup, commit, edit, info, init, list, resume, start, supervise """ from __future__ import annotations @@ -13,10 +13,10 @@ from ..manifest import ManifestError from ..store_manager import StoreManager from ._common import PROG from . import list as _list_mod +from .backend import cmd_backend from .cleanup import cmd_cleanup from .commit import cmd_commit from .edit import cmd_edit -from .firecracker import cmd_firecracker from .info import cmd_info from .init import cmd_init from .resume import cmd_resume @@ -26,10 +26,10 @@ from .supervise import cmd_supervise cmd_list = _list_mod.cmd_list COMMANDS = { + "backend": cmd_backend, "cleanup": cmd_cleanup, "commit": cmd_commit, "edit": cmd_edit, - "firecracker": cmd_firecracker, "info": cmd_info, "init": cmd_init, "list": cmd_list, @@ -42,10 +42,10 @@ COMMANDS = { def usage() -> None: sys.stderr.write(f"usage: {PROG} [args...]\n\n") sys.stderr.write("Commands:\n") + sys.stderr.write(" backend set up or check a backend's host prerequisites (setup|status)\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n") sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n") sys.stderr.write(" edit open an agent in vim for editing\n") - sys.stderr.write(" firecracker one-time network setup for the Firecracker backend\n") sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n") sys.stderr.write(" list list available agents or active containers\n") diff --git a/bot_bottle/cli/backend.py b/bot_bottle/cli/backend.py new file mode 100644 index 0000000..831c3ae --- /dev/null +++ b/bot_bottle/cli/backend.py @@ -0,0 +1,41 @@ +"""`backend` CLI command — generic host setup/status across backends. + +`./cli.py backend setup [--backend=NAME]` provisions (or points at how +to provision) the chosen backend's one-time host prerequisites. +`./cli.py backend status [--backend=NAME]` reports readiness. + +Both dispatch to the backend's `setup()` / `status()` classmethods, so +there are no backend-specific commands — swapping backends is just a +different `--backend` (or `$BOT_BOTTLE_BACKEND`, or the host default). +""" + +from __future__ import annotations + +import argparse + +from ..backend import get_bottle_backend, known_backend_names +from ._common import PROG + + +def cmd_backend(args: list[str]) -> int: + parser = argparse.ArgumentParser( + prog=f"{PROG} backend", + description="Set up or check a backend's host prerequisites.", + ) + parser.add_argument( + "action", + choices=("setup", "status"), + help="setup: provision/print host prerequisites; status: report readiness", + ) + parser.add_argument( + "--backend", + choices=known_backend_names(), + default=None, + help="backend to target (default: $BOT_BOTTLE_BACKEND or the host default)", + ) + ns = parser.parse_args(args) + + backend = get_bottle_backend(ns.backend) + if ns.action == "setup": + return backend.setup() + return backend.status() diff --git a/bot_bottle/cli/firecracker.py b/bot_bottle/cli/firecracker.py deleted file mode 100644 index 3d37736..0000000 --- a/bot_bottle/cli/firecracker.py +++ /dev/null @@ -1,79 +0,0 @@ -"""`firecracker` CLI command — one-time network setup helper. - -`./cli.py firecracker setup` prints the host-appropriate config for the -privileged, one-time network pool (TAP devices + isolation nftables -table) the Firecracker backend needs. On NixOS it prints a declarative -module to paste into your config (imperative rules don't survive -nixos-rebuild); elsewhere it prints the sudo command for the bundled -setup script. `./cli.py firecracker status` reports what's present. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from ..backend.firecracker import netpool - - -def _is_nixos() -> bool: - if Path("/etc/NIXOS").exists(): - return True - try: - return "ID=nixos" in Path("/etc/os-release").read_text() - except OSError: - return False - - -def _cmd_setup() -> int: - slots = netpool.all_slots() - sys.stderr.write( - f"Firecracker network pool: {len(slots)} slots " - f"({slots[0].iface}..{slots[-1].iface}), base {netpool.ip_base()}.\n" - f"This is a one-time privileged setup (needs root once).\n\n" - ) - if _is_nixos(): - sys.stderr.write( - "Detected NixOS. Paste this module into your configuration and " - "`nixos-rebuild switch` (imperative rules would not survive a " - "rebuild):\n\n" - ) - sys.stdout.write(netpool.render_nixos_module()) - else: - sys.stderr.write("Run the one-time setup as root:\n\n") - sys.stdout.write(netpool.render_shell_setup() + "\n") - sys.stderr.write( - "\n(On NixOS, use the declarative module instead — this host " - "was not detected as NixOS.)\n" - ) - return 0 - - -def _cmd_status() -> int: - ok = True - if netpool.nft_table_present(): - sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: present\n") - else: - sys.stderr.write(f"nft table inet {netpool.NFT_TABLE}: MISSING\n") - ok = False - missing = netpool.missing_taps() - total = netpool.pool_size() - if missing: - sys.stderr.write(f"TAP pool: {total - len(missing)}/{total} present " - f"(missing: {', '.join(missing)})\n") - ok = False - else: - sys.stderr.write(f"TAP pool: {total}/{total} present\n") - if not ok: - sys.stderr.write("\nRun: ./cli.py firecracker setup\n") - return 0 if ok else 1 - - -def cmd_firecracker(args: list[str]) -> int: - sub = args[0] if args else "" - if sub == "setup": - return _cmd_setup() - if sub == "status": - return _cmd_status() - sys.stderr.write("usage: firecracker {setup|status}\n") - return 2 diff --git a/tests/unit/test_cli_backend.py b/tests/unit/test_cli_backend.py new file mode 100644 index 0000000..4f3d6a3 --- /dev/null +++ b/tests/unit/test_cli_backend.py @@ -0,0 +1,79 @@ +"""Unit: the generic `backend` CLI command. + +`./cli.py backend {setup,status} [--backend=NAME]` resolves a backend +and dispatches to its `setup()` / `status()` classmethods — no +backend-specific commands. Also covers the docker backend's own +setup/status logic (the firecracker path is exercised by +test_firecracker_backend via netpool).""" + +from __future__ import annotations + +import subprocess +import unittest +from unittest.mock import MagicMock, patch + +from bot_bottle.cli import backend as cmd +from bot_bottle.backend.docker import setup as docker_setup + + +class TestCmdBackendDispatch(unittest.TestCase): + def _fake_backend(self): + b = MagicMock() + b.setup.return_value = 0 + b.status.return_value = 3 + return b + + def test_setup_dispatches_to_backend(self): + b = self._fake_backend() + with patch.object(cmd, "get_bottle_backend", return_value=b) as gbb: + rc = cmd.cmd_backend(["setup", "--backend", "firecracker"]) + gbb.assert_called_once_with("firecracker") + b.setup.assert_called_once_with() + b.status.assert_not_called() + self.assertEqual(0, rc) + + def test_status_dispatches_and_returns_backend_code(self): + b = self._fake_backend() + with patch.object(cmd, "get_bottle_backend", return_value=b): + rc = cmd.cmd_backend(["status", "--backend", "docker"]) + b.status.assert_called_once_with() + self.assertEqual(3, rc) + + def test_no_backend_flag_uses_host_default(self): + b = self._fake_backend() + with patch.object(cmd, "get_bottle_backend", return_value=b) as gbb: + cmd.cmd_backend(["status"]) + gbb.assert_called_once_with(None) + + def test_unknown_action_exits(self): + with self.assertRaises(SystemExit): + cmd.cmd_backend(["bogus"]) + + def test_unknown_backend_rejected_by_argparse(self): + with self.assertRaises(SystemExit): + cmd.cmd_backend(["status", "--backend", "nope"]) + + +class TestDockerSetupStatus(unittest.TestCase): + def _ok(self): + return subprocess.CompletedProcess([], 0, stdout="", stderr="") + + def test_setup_ok_when_docker_and_daemon_present(self): + with patch.object(docker_setup.shutil, "which", return_value="/usr/bin/docker"), \ + patch.object(docker_setup, "_daemon_reachable", return_value=True), \ + patch.object(docker_setup._util, "runsc_available", return_value=True): + self.assertEqual(0, docker_setup.setup()) + + def test_setup_fails_when_docker_missing(self): + with patch.object(docker_setup.shutil, "which", return_value=None): + self.assertEqual(1, docker_setup.setup()) + + def test_status_fails_when_daemon_unreachable(self): + with patch.object(docker_setup.shutil, "which", return_value="/usr/bin/docker"), \ + patch.object(docker_setup, "_daemon_reachable", return_value=False), \ + patch.object(docker_setup._util, "runsc_available", return_value=False): + self.assertEqual(1, docker_setup.status()) + + +if __name__ == "__main__": + unittest.main()