fix(backend): skip gateway reconcile when no running bottles (0081)
tracker-policy-pr / check-pr (pull_request) Successful in 12s
lint / lint (push) Successful in 56s
test / unit (pull_request) Failing after 46s
test / integration-docker (pull_request) Successful in 1m1s
test / coverage (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:52:47 -04:00
parent 3c0d2fb66f
commit b315a127b8
2 changed files with 82 additions and 1 deletions
+10 -1
View File
@@ -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:
+72
View File
@@ -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()