feat(backend): add teardown() to the contract; backend teardown
lint / lint (push) Failing after 1m56s
test / unit (pull_request) Successful in 52s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Failing after 1m2s

Add an abstract teardown() classmethod to BottleBackend — the inverse of
setup(), surfaced as `./cli.py backend teardown [--backend=NAME]`
(uninstall). Symmetric with setup: it prints the privileged commands /
declarative config change to remove the host prerequisites.

- firecracker: NixOS-aware — disable the flake module (or drop the
  import) and rebuild, or `firecracker-netpool.sh down` imperatively.
- docker / macos-container: nothing to undo (no privileged host state);
  print a short note.

Not called by the launch path or the test suite. Extends test_cli_backend
for the new dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-11 16:56:17 -04:00
parent 15f0e0b507
commit 656966c2c4
10 changed files with 89 additions and 8 deletions
+11
View File
@@ -560,6 +560,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
the backend is ready to launch and non-zero when something is
missing. Invoked by `./cli.py backend status [--backend=…]`."""
@classmethod
@abstractmethod
def teardown(cls) -> int:
"""Undo `setup()` — the inverse operation, surfaced as
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric
with setup: where setup is advisory (prints the privileged
commands / declarative config to apply), teardown prints the
commands / config change to remove the host prerequisites. A
backend with no host setup prints a short note and returns 0.
Not called by the launch path or the test suite."""
# Import concrete backend classes AFTER the base types are defined, so
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
+5
View File
@@ -64,6 +64,11 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
from . import setup as _setup
return _setup.status()
@classmethod
def teardown(cls) -> int:
from . import setup as _setup
return _setup.teardown()
def _preflight(self) -> None:
_resolve_plan.preflight()
+9
View File
@@ -61,6 +61,15 @@ def setup() -> int:
return 0
def teardown() -> int:
sys.stderr.write(
"Docker backend: nothing to undo — it provisions no privileged host "
"state (networks and the sidecar bundle are per-launch and are "
"removed by `./cli.py cleanup`). Docker itself is left installed.\n"
)
return 0
def status() -> int:
ok = True
if _docker_on_path():
@@ -56,6 +56,11 @@ class FirecrackerBottleBackend(
from . import setup as _setup
return _setup.status()
@classmethod
def teardown(cls) -> int:
from . import setup as _setup
return _setup.teardown()
def _preflight(self) -> None:
_resolve_plan.preflight()
+19
View File
@@ -81,6 +81,25 @@ def setup() -> int:
return 0
def teardown() -> int:
slots = netpool.all_slots()
sys.stderr.write(
f"Undo the Firecracker network pool ({len(slots)} slots, base "
f"{netpool.ip_base()}) — a privileged, one-time operation.\n\n"
)
if _is_nixos():
sys.stderr.write(
"On NixOS: set `services.bot-bottle-firecracker.enable = false;` "
"(or drop the module import) and `nixos-rebuild switch`. The TAP "
"netdevs and nft table are removed declaratively.\n\n"
"To tear down imperatively before a rebuild (does not persist):\n\n"
)
else:
sys.stderr.write("Run the teardown as root:\n\n")
sys.stdout.write("sudo ./scripts/firecracker-netpool.sh down\n")
return 0
def status() -> int:
ok = True
if netpool.nft_table_present():
@@ -46,6 +46,11 @@ class MacosContainerBottleBackend(
from . import setup as _setup
return _setup.status()
@classmethod
def teardown(cls) -> int:
from . import setup as _setup
return _setup.teardown()
def _preflight(self) -> None:
_resolve_plan.preflight()
@@ -46,6 +46,16 @@ def setup() -> int:
return 0
def teardown() -> int:
sys.stderr.write(
"macos-container backend: nothing to undo — it provisions no "
"privileged host state. The Apple Container CLI and its system "
"service are left as-is (stop the service yourself with "
"`container system stop` if you want).\n"
)
return 0
def status() -> int:
ok = True
if _container.is_macos():
+1 -1
View File
@@ -42,7 +42,7 @@ COMMANDS = {
def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [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(" backend set up / check / undo a backend's host prerequisites (setup|status|teardown)\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")
+12 -7
View File
@@ -1,12 +1,14 @@
"""`backend` CLI command — generic host setup/status across backends.
`./cli.py backend setup [--backend=NAME]` provisions (or points at how
`./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.
`./cli.py backend status [--backend=NAME]` reports readiness.
`./cli.py backend teardown [--backend=NAME]` undoes setup (uninstall).
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).
All dispatch to the backend's `setup()` / `status()` / `teardown()`
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
@@ -24,8 +26,9 @@ def cmd_backend(args: list[str]) -> int:
)
parser.add_argument(
"action",
choices=("setup", "status"),
help="setup: provision/print host prerequisites; status: report readiness",
choices=("setup", "status", "teardown"),
help="setup: provision/print host prerequisites; status: report "
"readiness; teardown: undo setup (uninstall)",
)
parser.add_argument(
"--backend",
@@ -38,4 +41,6 @@ def cmd_backend(args: list[str]) -> int:
backend = get_bottle_backend(ns.backend)
if ns.action == "setup":
return backend.setup()
if ns.action == "teardown":
return backend.teardown()
return backend.status()
+12
View File
@@ -21,6 +21,7 @@ class TestCmdBackendDispatch(unittest.TestCase):
b = MagicMock()
b.setup.return_value = 0
b.status.return_value = 3
b.teardown.return_value = 0
return b
def test_setup_dispatches_to_backend(self):
@@ -39,6 +40,14 @@ class TestCmdBackendDispatch(unittest.TestCase):
b.status.assert_called_once_with()
self.assertEqual(3, rc)
def test_teardown_dispatches_to_backend(self):
b = self._fake_backend()
with patch.object(cmd, "get_bottle_backend", return_value=b):
rc = cmd.cmd_backend(["teardown", "--backend", "firecracker"])
b.teardown.assert_called_once_with()
b.setup.assert_not_called()
self.assertEqual(0, 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:
@@ -74,6 +83,9 @@ class TestDockerSetupStatus(unittest.TestCase):
patch.object(docker_setup._util, "runsc_available", return_value=False):
self.assertEqual(1, docker_setup.status())
def test_teardown_is_noop_success(self):
self.assertEqual(0, docker_setup.teardown())
if __name__ == "__main__":
unittest.main()