"""Unit: Docker launch teardown warning on ExitStack failure (issue #156). When a callback registered in the ExitStack raises during teardown, the teardown function must emit a WARNING-level message that includes the container name and operation type, rather than silently discarding the exception. """ from __future__ import annotations import contextlib import io import tempfile import unittest from pathlib import Path from unittest import mock from bot_bottle.agent_provider import AgentProvisionPlan from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import launch as launch_mod from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.consolidated_launch import LaunchContext from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import ManifestIndex from tests.unit import use_bottle_root _INDEX = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) def _plan(tmp: str) -> DockerBottlePlan: stage = Path(tmp) manifest = _INDEX.load_for_agent("demo") spec = BottleSpec( manifest=_INDEX, agent_name="demo", copy_cwd=False, user_cwd=tmp, identity="test-teardown-00001", ) return DockerBottlePlan( spec=spec, manifest=manifest, stage_dir=stage, git_gate_plan=GitGatePlan( slug="test-teardown-00001", entrypoint_script=stage / "entrypoint.sh", hook_script=stage / "hook.sh", access_hook_script=stage / "access-hook.sh", upstreams=(), ), egress_plan=EgressPlan( slug="test-teardown-00001", routes_path=stage / "egress.yaml", routes=(), token_env_map={}, ), supervise_plan=None, agent_provision=AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", image="bot-bottle-claude:latest", dockerfile="", guest_home="/home/node", instance_name="bot-bottle-test-teardown-abc", prompt_file=stage / "prompt.txt", guest_env={}, ), slug="test-teardown-00001", forwarded_env={}, use_runsc=False, ) class TestTeardownWarning(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.mkdtemp(prefix="docker-launch-teardown-test.") self.addCleanup(lambda: __import__("shutil").rmtree(self._tmp, ignore_errors=True)) self.addCleanup(use_bottle_root(Path(self._tmp))) # sandbox the gateway CA write def test_teardown_failure_emits_warning_with_container_and_operation(self): plan = _plan(self._tmp) buf = io.StringIO() gw = mock.Mock() gw.ca_cert_pem.return_value = "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n" ctx = LaunchContext( bottle_id="b1", identity_token="t", source_ip="172.20.0.4", network="bot-bottle-gateway", gateway_ip="172.20.0.2", orchestrator_url="http://orch:8099", ) with mock.patch.object(launch_mod.docker_mod, "build_image"), \ mock.patch.object(launch_mod, "launch_consolidated", return_value=ctx), \ mock.patch.object(launch_mod, "teardown_consolidated"), \ mock.patch.object(launch_mod, "DockerGateway", return_value=gw), \ mock.patch.object( launch_mod, "consolidated_agent_compose", return_value={"services": {"agent": {}}}, ), \ mock.patch.object( launch_mod, "write_compose_file", return_value=Path("/tmp/compose.yml"), ), \ mock.patch.object(launch_mod, "compose_up"), \ mock.patch.object(launch_mod, "compose_dump_logs"), \ mock.patch.object( launch_mod, "compose_down", side_effect=RuntimeError("compose down failed"), ), \ contextlib.redirect_stderr(buf): provision = mock.Mock(return_value=None) with launch_mod.launch(plan, provision=provision): pass output = buf.getvalue() self.assertIn("bot-bottle: warning:", output) self.assertIn("bot-bottle-test-teardown-abc", output) self.assertIn("compose-down", output) if __name__ == "__main__": unittest.main()