refactor(backend): generic setup/status; drop firecracker-only CLI
Add abstract `setup()`/`status()` classmethods to BottleBackend (same
per-host, no-instance shape as is_available) so host provisioning is
part of the backend contract, not a per-backend command. Replace the
`./cli.py firecracker {setup,status}` command with a generic
`./cli.py backend {setup,status} [--backend=NAME]` that resolves a
backend (flag / $BOT_BOTTLE_BACKEND / host default) and dispatches —
swapping backends is just a different --backend.
Implementations:
- firecracker: moved out of cli/ into backend/firecracker/setup.py
(network pool module/script + range-overlap check), unchanged output.
- docker: new backend/docker/setup.py — reports docker on PATH, daemon
reachability, and gVisor runsc; setup notes no privileged pool is
needed. Minimal placeholder; richer version tracked in #345.
- macos-container: new setup.py — container CLI + system-service checks.
Also retarget the launch-preflight / isolation-probe pointers to the new
command. New test_cli_backend covers dispatch + docker setup/status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -538,6 +538,28 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
doesn't fail when `cli.py list active` walks past
|
doesn't fail when `cli.py list active` walks past
|
||||||
firecracker."""
|
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
|
# Import concrete backend classes AFTER the base types are defined, so
|
||||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
launch."""
|
launch."""
|
||||||
return shutil.which("docker") is not None
|
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:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -46,6 +46,16 @@ class FirecrackerBottleBackend(
|
|||||||
install pointer at launch."""
|
install pointer at launch."""
|
||||||
return _util.is_host_capable()
|
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:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ def verify_isolation(private_key: Path, guest_ip: str) -> None:
|
|||||||
die(f"ISOLATION FAILURE: the VM reached the host canary "
|
die(f"ISOLATION FAILURE: the VM reached the host canary "
|
||||||
f"{canary_ip}:{canary_port}. The egress boundary is not in "
|
f"{canary_ip}:{canary_port}. The egress boundary is not in "
|
||||||
f"force — refusing to run the agent (fail-closed). Verify the "
|
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:
|
if result.returncode == 2:
|
||||||
die("isolation probe inconclusive: the guest has no python3/bash/nc "
|
die("isolation probe inconclusive: the guest has no python3/bash/nc "
|
||||||
"to run the connectivity test. Refusing to continue "
|
"to run the connectivity test. Refusing to continue "
|
||||||
|
|||||||
@@ -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://<your-bot-bottle-remote>\";\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 = [ <bot-bottle>/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
|
||||||
@@ -36,6 +36,16 @@ class MacosContainerBottleBackend(
|
|||||||
def is_available(cls) -> bool:
|
def is_available(cls) -> bool:
|
||||||
return _container.is_available()
|
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:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Main CLI dispatcher.
|
"""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
|
from __future__ import annotations
|
||||||
@@ -13,10 +13,10 @@ from ..manifest import ManifestError
|
|||||||
from ..store_manager import StoreManager
|
from ..store_manager import StoreManager
|
||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import list as _list_mod
|
from . import list as _list_mod
|
||||||
|
from .backend import cmd_backend
|
||||||
from .cleanup import cmd_cleanup
|
from .cleanup import cmd_cleanup
|
||||||
from .commit import cmd_commit
|
from .commit import cmd_commit
|
||||||
from .edit import cmd_edit
|
from .edit import cmd_edit
|
||||||
from .firecracker import cmd_firecracker
|
|
||||||
from .info import cmd_info
|
from .info import cmd_info
|
||||||
from .init import cmd_init
|
from .init import cmd_init
|
||||||
from .resume import cmd_resume
|
from .resume import cmd_resume
|
||||||
@@ -26,10 +26,10 @@ from .supervise import cmd_supervise
|
|||||||
cmd_list = _list_mod.cmd_list
|
cmd_list = _list_mod.cmd_list
|
||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
|
"backend": cmd_backend,
|
||||||
"cleanup": cmd_cleanup,
|
"cleanup": cmd_cleanup,
|
||||||
"commit": cmd_commit,
|
"commit": cmd_commit,
|
||||||
"edit": cmd_edit,
|
"edit": cmd_edit,
|
||||||
"firecracker": cmd_firecracker,
|
|
||||||
"info": cmd_info,
|
"info": cmd_info,
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
"list": cmd_list,
|
"list": cmd_list,
|
||||||
@@ -42,10 +42,10 @@ COMMANDS = {
|
|||||||
def usage() -> None:
|
def usage() -> None:
|
||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\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(" 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(" 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(" 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(" 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(" 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")
|
sys.stderr.write(" list list available agents or active containers\n")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user