diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index fe2c498..b00b3c6 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -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 diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index de6a22d..7f0c824 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -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() diff --git a/bot_bottle/backend/docker/setup.py b/bot_bottle/backend/docker/setup.py index 85e00e0..c666159 100644 --- a/bot_bottle/backend/docker/setup.py +++ b/bot_bottle/backend/docker/setup.py @@ -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(): diff --git a/bot_bottle/backend/firecracker/backend.py b/bot_bottle/backend/firecracker/backend.py index 632d124..e751ad2 100644 --- a/bot_bottle/backend/firecracker/backend.py +++ b/bot_bottle/backend/firecracker/backend.py @@ -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() diff --git a/bot_bottle/backend/firecracker/setup.py b/bot_bottle/backend/firecracker/setup.py index ffaf125..d71c0a6 100644 --- a/bot_bottle/backend/firecracker/setup.py +++ b/bot_bottle/backend/firecracker/setup.py @@ -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(): diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py index 61e1868..c3c38d6 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -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() diff --git a/bot_bottle/backend/macos_container/setup.py b/bot_bottle/backend/macos_container/setup.py index 1df3488..2125f3d 100644 --- a/bot_bottle/backend/macos_container/setup.py +++ b/bot_bottle/backend/macos_container/setup.py @@ -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(): diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index c4a9770..1dcb525 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -42,7 +42,7 @@ 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(" 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") diff --git a/bot_bottle/cli/backend.py b/bot_bottle/cli/backend.py index 831c3ae..ed6808f 100644 --- a/bot_bottle/cli/backend.py +++ b/bot_bottle/cli/backend.py @@ -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() diff --git a/tests/unit/test_cli_backend.py b/tests/unit/test_cli_backend.py index 4f3d6a3..16eea2c 100644 --- a/tests/unit/test_cli_backend.py +++ b/tests/unit/test_cli_backend.py @@ -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()