From b315a127b8c1c11e10a99877c5eaa404d6c8b7cf Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 26 Jul 2026 22:52:47 -0400 Subject: [PATCH] fix(backend): skip gateway reconcile when no running bottles (0081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcile_running_bottles() gathered the gateway attach-resources (a read of the per-host singleton gateway's CA) *before* enumerating running bottles. On a cold-boot bring-up of a NON-default gateway instance with no host bottles — e.g. an isolated integration test's `-itest-` gateway — that read targeted the default `bot-bottle-orch-gateway`, which doesn't exist for that instance, so setUpClass died with "No such container: bot-bottle-orch-gateway" (seen on the 0081 PR's integration-docker job). Enumerate running bottles first and return early when there are none: the reconcile is a no-op with nothing to reattach, so the CA read is both wasted and wrong for a non-default instance. Production (default names, real bottles) is unchanged. Adds unit coverage for the three reconcile paths. Co-Authored-By: Claude Opus 4.8 --- bot_bottle/backend/gateway_attach.py | 11 ++++- tests/unit/test_gateway_attach.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_gateway_attach.py diff --git a/bot_bottle/backend/gateway_attach.py b/bot_bottle/backend/gateway_attach.py index 1f805671..c88a3400 100644 --- a/bot_bottle/backend/gateway_attach.py +++ b/bot_bottle/backend/gateway_attach.py @@ -37,9 +37,18 @@ def reconcile_running_bottles(backend: "BottleBackend[Any, Any, Any]") -> None: so any attach failure aborts bring-up. Every bottle is attempted first and the failures are raised together, so one bring-up surfaces the full blast radius rather than one bottle at a time.""" + # Enumerate the host's running bottles BEFORE gathering resources: with none + # to reattach, the reconcile is a no-op, and gathering resources first would + # read the current gateway's CA for nothing. That read targets the per-host + # singleton gateway, so a bring-up of a *non-default* instance with no host + # bottles (an isolated integration test's `-itest-` gateway) would otherwise + # fail looking for the default `bot-bottle-orch-gateway` (#519 review). + bottles = backend._running_bottles() + if not bottles: + return resources = backend._gateway_attach_resources() failures: list[str] = [] - for bottle in backend._running_bottles(): + for bottle in bottles: try: backend._attach_bottle_to_gateway(bottle, resources) except InfraLaunchError as exc: diff --git a/tests/unit/test_gateway_attach.py b/tests/unit/test_gateway_attach.py new file mode 100644 index 00000000..4e9caaef --- /dev/null +++ b/tests/unit/test_gateway_attach.py @@ -0,0 +1,72 @@ +"""Unit: the shared gateway-attach reconcile flow (PRD 0081). + +`reconcile_running_bottles` drives the cold-boot reattach across every backend +via three primitives. These exercise the control flow with a duck-typed fake +(the real `BottleBackend` ABC has a large surface; the reconcile only touches +the three methods below).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.backend.base import InfraLaunchError +from bot_bottle.backend.gateway_attach import ( + GatewayAttachResources, + reconcile_running_bottles, +) + + +class _FakeBackend: + def __init__(self, bottles: list[str], *, attach_fail: tuple[str, ...] = ()) -> None: + self._bottles = bottles + self._attach_fail = set(attach_fail) + self.resource_calls = 0 + self.attached: list[tuple[str, str]] = [] + + def _running_bottles(self) -> list[str]: + return self._bottles + + def _gateway_attach_resources(self) -> GatewayAttachResources: + self.resource_calls += 1 + return GatewayAttachResources(ca_pem="CA") + + def _attach_bottle_to_gateway( + self, bottle: str, resources: GatewayAttachResources, + ) -> None: + if bottle in self._attach_fail: + raise InfraLaunchError(f"attach {bottle} failed") + self.attached.append((bottle, resources.ca_pem)) + + +class TestReconcileRunningBottles(unittest.TestCase): + def test_no_bottles_skips_resource_gathering(self) -> None: + """With no running bottles the reconcile is a no-op and never gathers + resources — that read targets the per-host singleton gateway, so doing + it here would make a non-default/isolated bring-up fail looking for the + default `bot-bottle-orch-gateway` (#519 review).""" + fake = _FakeBackend([]) + reconcile_running_bottles(fake) # type: ignore[arg-type] + self.assertEqual(0, fake.resource_calls) + self.assertEqual([], fake.attached) + + def test_attaches_every_running_bottle_once(self) -> None: + fake = _FakeBackend(["a", "b"]) + reconcile_running_bottles(fake) # type: ignore[arg-type] + self.assertEqual(1, fake.resource_calls) + self.assertEqual([("a", "CA"), ("b", "CA")], fake.attached) + + def test_aggregates_attach_failures_and_raises(self) -> None: + """Every bottle is attempted before raising, and the failures are + reported together (fail hard, full blast radius).""" + fake = _FakeBackend(["a", "b", "c"], attach_fail=("a", "c")) + with self.assertRaises(InfraLaunchError) as ctx: + reconcile_running_bottles(fake) # type: ignore[arg-type] + msg = str(ctx.exception) + self.assertIn("2 running bottle(s)", msg) + self.assertIn("attach a failed", msg) + self.assertIn("attach c failed", msg) + self.assertEqual([("b", "CA")], fake.attached) + + +if __name__ == "__main__": + unittest.main()