b315a127b8
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>
73 lines
2.8 KiB
Python
73 lines
2.8 KiB
Python
"""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()
|