diff --git a/bot_bottle/backend/docker/gateway.py b/bot_bottle/backend/docker/gateway.py index 71d630d4..4d591dac 100644 --- a/bot_bottle/backend/docker/gateway.py +++ b/bot_bottle/backend/docker/gateway.py @@ -144,19 +144,33 @@ class DockerGateway(Gateway): # by older releases. Replace it below. stale = True else: - # inspect failed: either the network is absent (create it below) or - # it exists but docker can't parse it. A daemon that default-enables - # IPv6 attaches an fdd0::/64 subnet whose `::1/64` gateway trips - # docker's own netip.ParseAddr in `network inspect`/`ls`, so the - # command exits non-zero. A fixed release never *creates* such a - # network, but one can survive on a shared host from an older or - # concurrent launch — and `--ipv6=false` alone can't heal it, since - # the create below only no-ops on "already exists". Force-replace it - # so later reads (e.g. `_network_cidr` pinning a source IP) stop - # failing. An absent network's inspect error is "No such network"; - # anything else means it exists but is poisoned. + # inspect failed. Classify by stderr — do NOT assume "not absent" + # implies "poisoned": a transient daemon/API error, permission + # failure, timeout, or bad context also fails here, and destroying + # the shared gateway on that guess would tear the network out from + # under every live bottle. err = inspected.stderr.lower() - stale = "no such network" not in err and "not found" not in err + if "no such network" in err or "not found" in err: + # Absent: nothing to replace — create it below. + stale = False + elif "parseaddr" in err: + # Present but poisoned. A daemon that default-enables IPv6 + # attaches an fdd0::/64 subnet whose `::1/64` gateway trips + # docker's own netip.ParseAddr in `network inspect`/`ls`, so the + # command exits non-zero with that signature. A fixed release + # never *creates* such a network, but one can survive on a + # shared host from an older or concurrent launch — and + # `--ipv6=false` alone can't heal it, since the create below only + # no-ops on "already exists". Force-replace it so later reads + # (e.g. `_network_cidr` pinning a source IP) stop failing. + stale = True + else: + # Unrecognized failure: no evidence the network is malformed. + # Surface it rather than mutate shared state on a guess. + raise GatewayError( + f"gateway network {self.network} could not be inspected: " + f"{inspected.stderr.strip()}" + ) if stale: # Migrate the stale/poisoned network. Removing the fixed gateway is # safe here: this launch recreates it. diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index cc48e8c2..03b0b370 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -303,6 +303,33 @@ class TestDockerGateway(unittest.TestCase): creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] self.assertEqual(1, len(creates)) + def test_ensure_running_does_not_destroy_on_generic_inspect_error(self) -> None: + # A generic inspect failure (daemon hiccup, permission, timeout) is NOT + # evidence of a poisoned network. Only the ParseAddr poison signature may + # take the destructive heal path; anything else must surface as an error + # without tearing down a possibly-healthy shared gateway. + calls: list[list[str]] = [] + + def fake(argv: list[str], **_kw: object) -> Mock: + calls.append(argv) + if argv[:2] == ["docker", "ps"]: + return _proc(stdout="") + if argv[:3] == ["docker", "network", "inspect"]: + return _proc( + returncode=1, + stderr="Cannot connect to the Docker daemon at unix:///var/run/docker.sock", + ) + return _proc() + + with patch(_RUN_DOCKER, side_effect=fake): + with self.assertRaises(GatewayError): + self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN) + # No mutation of the shared gateway: neither the container nor the + # network is removed, and nothing is recreated. + self.assertNotIn(["docker", "network", "rm", self.sc.network], calls) + self.assertFalse(any(c[:3] == ["docker", "rm", "--force"] for c in calls)) + self.assertEqual([], [c for c in calls if c[:3] == ["docker", "network", "create"]]) + def test_ca_cert_pem_reads_from_container(self) -> None: with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m: self.assertEqual(_CA_PEM, self.sc.ca_cert_pem())