Files
bot-bottle/tests/unit/test_docker_infra.py
T
didericis-claude 5777824d0b feat(backend): reconcile running bottles' CA on gateway bring-up (0081)
Add `attach_bottled_agents_to_gateway()` as an `@abc.abstractmethod` on
`BottleBackend` and implement the first reconcile step across all three
backends: on a gateway cold boot, replace every already-running agent's
trusted CA with the freshly-minted gateway CA. Reconciling running bottles
is a backend responsibility (only the backend can enumerate its agents and
reach them — firecracker over SSH, docker/macOS over exec/cp), so making it
an abstract method keeps the fix cross-backend by construction.

The gateway rootfs is ephemeral, so a rebuild mints a new CA that every
running bottle distrusts (SSL verification fails — #510). This reconcile is
what distributes the fresh CA, so a routine gateway rebuild now doubles as a
free CA rotation. Per-bottle steps are best-effort: one unreachable or
malformed bottle is logged and skipped, never blocking the others.

Trigger — `Gateway.connect_to_orchestrator` now returns a cold-boot bool
(True when it actually (re)brought the gateway up, False when a healthy
current gateway was left untouched); each infra `ensure_running` gates the
reconcile on it, so it fires exactly on cold boot and never on an adopt.

Git-gate re-provision and egress-token restore fold into this same reconcile
on the same trigger in follow-up PRs (#516).

Refs #516. Addresses #510.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:15:14 +00:00

113 lines
4.7 KiB
Python

"""Unit: DockerInfraService composes the orchestrator + gateway services.
`DockerInfraService` no longer owns the container lifecycles — it composes the
`DockerOrchestrator` (control plane) and `DockerGateway` (data plane) services,
each tested in its own module. These tests isolate the *composition*: both are
built, the orchestrator comes up first, then the gateway is connected to it with
a freshly minted token."""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.backend.docker.infra import (
ORCHESTRATOR_NAME,
DockerInfraService,
)
from bot_bottle.gateway import GATEWAY_NAME
_RUN = "bot_bottle.backend.docker.infra.run_docker"
class TestDockerInfraService(unittest.TestCase):
def setUp(self) -> None:
self.svc = DockerInfraService(port=8099)
self.orch = MagicMock()
self.orch.url.return_value = "http://127.0.0.1:8099"
self.orch.gateway_url.return_value = f"http://{ORCHESTRATOR_NAME}:8099"
self.orch.mint_gateway_token.return_value = "gw.jwt"
self.gw = MagicMock()
# Default to a cold boot (gateway (re)created) so the reconcile branch
# runs; the actual reconcile is stubbed so these composition tests stay
# isolated from docker.
self.gw.connect_to_orchestrator.return_value = True
for name, mock in (("orchestrator", self.orch), ("gateway", self.gw)):
p = patch.object(self.svc, name, return_value=mock)
p.start()
self.addCleanup(p.stop)
ap = patch(
"bot_bottle.backend.docker.backend.DockerBottleBackend"
".attach_bottled_agents_to_gateway"
)
self.attach = ap.start()
self.addCleanup(ap.stop)
def test_url_delegates_to_the_orchestrator(self) -> None:
self.assertEqual("http://127.0.0.1:8099", self.svc.url())
def test_is_healthy_delegates_to_the_orchestrator(self) -> None:
self.orch.is_healthy.return_value = True
self.assertTrue(self.svc.is_healthy())
self.orch.is_healthy.assert_called_once()
def test_ensure_running_builds_both_then_starts_orchestrator_first(self) -> None:
url = self.svc.ensure_running()
self.assertEqual("http://127.0.0.1:8099", url)
# Both images built; the control plane comes up before the gateway.
self.orch.ensure_built.assert_called_once()
self.gw.ensure_built.assert_called_once()
self.orch.ensure_running.assert_called_once()
def test_ensure_running_connects_gateway_with_minted_token(self) -> None:
self.svc.ensure_running()
# The gateway is bound to the orchestrator's *gateway_url* (the control
# DNS name), carrying the orchestrator-minted `gateway` token.
self.gw.connect_to_orchestrator.assert_called_once_with(
f"http://{ORCHESTRATOR_NAME}:8099", "gw.jwt")
self.orch.mint_gateway_token.assert_called_once()
def test_cold_boot_reconciles_running_bottles(self) -> None:
# A (re)created gateway (connect returns True) triggers the bring-up
# reconcile so running bottles re-trust the fresh gateway (PRD 0081).
self.gw.connect_to_orchestrator.return_value = True
self.svc.ensure_running()
self.attach.assert_called_once_with()
def test_no_reconcile_when_gateway_left_untouched(self) -> None:
# A healthy current gateway (connect returns False) is not a cold boot,
# so there is nothing to reconcile.
self.gw.connect_to_orchestrator.return_value = False
self.svc.ensure_running()
self.attach.assert_not_called()
def test_stop_removes_both_containers(self) -> None:
with patch(_RUN) as run:
self.svc.stop()
rms = [
c.args[0] for c in run.call_args_list
if c.args[0][:3] == ["docker", "rm", "--force"]
]
self.assertTrue(any(GATEWAY_NAME in a for a in rms))
self.assertTrue(any(ORCHESTRATOR_NAME in a for a in rms))
def test_named_mounts_propagate_to_both_planes(self) -> None:
svc = DockerInfraService(
root_mount_source="registry-volume",
gateway_ca_mount_source="ca-volume",
)
self.assertEqual("registry-volume", svc.orchestrator()._root_mount_source)
self.assertEqual("ca-volume", svc.gateway()._ca_mount_source)
def test_host_path_and_named_root_mount_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(ValueError, "host_root or root_mount_source"):
DockerInfraService(
host_root=Path("/host/path"),
root_mount_source="registry-volume",
)
if __name__ == "__main__":
unittest.main()