feat(backend): add teardown() to the contract; backend teardown
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:
@@ -560,6 +560,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
the backend is ready to launch and non-zero when something is
|
the backend is ready to launch and non-zero when something is
|
||||||
missing. Invoked by `./cli.py backend status [--backend=…]`."""
|
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
|
# 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
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
from . import setup as _setup
|
from . import setup as _setup
|
||||||
return _setup.status()
|
return _setup.status()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.teardown()
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ def setup() -> int:
|
|||||||
return 0
|
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:
|
def status() -> int:
|
||||||
ok = True
|
ok = True
|
||||||
if _docker_on_path():
|
if _docker_on_path():
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class FirecrackerBottleBackend(
|
|||||||
from . import setup as _setup
|
from . import setup as _setup
|
||||||
return _setup.status()
|
return _setup.status()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.teardown()
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ def setup() -> int:
|
|||||||
return 0
|
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:
|
def status() -> int:
|
||||||
ok = True
|
ok = True
|
||||||
if netpool.nft_table_present():
|
if netpool.nft_table_present():
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class MacosContainerBottleBackend(
|
|||||||
from . import setup as _setup
|
from . import setup as _setup
|
||||||
return _setup.status()
|
return _setup.status()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def teardown(cls) -> int:
|
||||||
|
from . import setup as _setup
|
||||||
|
return _setup.teardown()
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
def _preflight(self) -> None:
|
||||||
_resolve_plan.preflight()
|
_resolve_plan.preflight()
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ def setup() -> int:
|
|||||||
return 0
|
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:
|
def status() -> int:
|
||||||
ok = True
|
ok = True
|
||||||
if _container.is_macos():
|
if _container.is_macos():
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ 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(" 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(" 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")
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""`backend` CLI command — generic host setup/status across backends.
|
"""`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.
|
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
|
All dispatch to the backend's `setup()` / `status()` / `teardown()`
|
||||||
there are no backend-specific commands — swapping backends is just a
|
classmethods, so there are no backend-specific commands — swapping
|
||||||
different `--backend` (or `$BOT_BOTTLE_BACKEND`, or the host default).
|
backends is just a different `--backend` (or `$BOT_BOTTLE_BACKEND`, or
|
||||||
|
the host default).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,8 +26,9 @@ def cmd_backend(args: list[str]) -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"action",
|
"action",
|
||||||
choices=("setup", "status"),
|
choices=("setup", "status", "teardown"),
|
||||||
help="setup: provision/print host prerequisites; status: report readiness",
|
help="setup: provision/print host prerequisites; status: report "
|
||||||
|
"readiness; teardown: undo setup (uninstall)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
@@ -38,4 +41,6 @@ def cmd_backend(args: list[str]) -> int:
|
|||||||
backend = get_bottle_backend(ns.backend)
|
backend = get_bottle_backend(ns.backend)
|
||||||
if ns.action == "setup":
|
if ns.action == "setup":
|
||||||
return backend.setup()
|
return backend.setup()
|
||||||
|
if ns.action == "teardown":
|
||||||
|
return backend.teardown()
|
||||||
return backend.status()
|
return backend.status()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class TestCmdBackendDispatch(unittest.TestCase):
|
|||||||
b = MagicMock()
|
b = MagicMock()
|
||||||
b.setup.return_value = 0
|
b.setup.return_value = 0
|
||||||
b.status.return_value = 3
|
b.status.return_value = 3
|
||||||
|
b.teardown.return_value = 0
|
||||||
return b
|
return b
|
||||||
|
|
||||||
def test_setup_dispatches_to_backend(self):
|
def test_setup_dispatches_to_backend(self):
|
||||||
@@ -39,6 +40,14 @@ class TestCmdBackendDispatch(unittest.TestCase):
|
|||||||
b.status.assert_called_once_with()
|
b.status.assert_called_once_with()
|
||||||
self.assertEqual(3, rc)
|
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):
|
def test_no_backend_flag_uses_host_default(self):
|
||||||
b = self._fake_backend()
|
b = self._fake_backend()
|
||||||
with patch.object(cmd, "get_bottle_backend", return_value=b) as gbb:
|
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):
|
patch.object(docker_setup._util, "runsc_available", return_value=False):
|
||||||
self.assertEqual(1, docker_setup.status())
|
self.assertEqual(1, docker_setup.status())
|
||||||
|
|
||||||
|
def test_teardown_is_noop_success(self):
|
||||||
|
self.assertEqual(0, docker_setup.teardown())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user