fix(container-dns): take the bridge from the pool, not from a fact

ce43263745 replaced the bridge literal with the clearnet resolver and left
the lookup to supply the bridge from ansible_facts.docker0. That fact
cannot be there on the run that needs it: facts are gathered at play
start, nothing in the role re-gathers them, and on a fresh node docker is
installed afterwards - so the render that writes daemon.json always sees
no docker0 and emits the clearnet resolver alone.

Measured on one node of the same job: docker0 is 10.208.0.1/24 while its
/etc/docker/daemon.json carries "dns": ["1.1.1.1"]. The bridge existed and
was missing from the config that had to name it.

The inner containers then asked a public resolver for a name only the node
dnsmasq serves, and the DinD check reported it accurately seven times:

  Server: 1.1.1.1
  ** server can't find infinito.example: NXDOMAIN

(The '[container] CA trust certificate is not a regular file' line above it
is noise - container.py catches that and falls back to plain docker run.)

default-address-pools is where docker takes docker0 from, so the
declaration answers before the daemon exists and gives the same answer
after: subnet_gateway("10.208.0.0/12") is the 10.208.0.1 measured above. An
observed docker0 still wins, so a host whose bridge sits elsewhere keeps
its real address.

That also settles what the docstring flagged as unavoidable. The
fact-derived value appeared only from the second run on, so daemon.json
differed between passes and restarted the container runtime under a live
stack. Both passes now render the same bytes.

Only the swarm path showed it: 18 jobs died at the daemon start before
reaching this handler, and the six that got here in the next run are the
first to exercise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-29 22:08:51 +02:00
1 parent 87c1ba0371
commit 285ba1e76c
2 files changed
+110 -1

No files matched your search

+39 -1
View File
@@ -5,6 +5,7 @@ from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.networks.address import subnet_gateway
from utils.templating.ansible import _trust_as_template
_TOR_ROLE = "svc-net-tor"
@@ -59,6 +60,43 @@ def render(value: Any, templar: Any) -> str:
return rendered
def bridge_address(variables: dict[str, Any]) -> str:
"""Return the docker bridge gateway containers can reach the host on.
Args:
variables: the variables in scope for the lookup.
``ansible_facts`` only carries ``docker0`` once the daemon has run, and the
daemon config is written before that on a fresh node - facts are gathered at
play start, and nothing re-gathers them after the install. Reading the fact
alone therefore yields nothing on the very run that has to emit the address,
which leaves the inner containers pointed at the clearnet resolver and the
DinD DNS check failing on a domain only the node dnsmasq knows.
``default-address-pools`` is where docker takes that address from, so the
declaration answers before the daemon exists and answers identically
afterwards. That also keeps daemon.json byte-identical across passes, which
the fact-derived value could not: it appeared only from the second run on
and restarted the container runtime under a live stack.
"""
facts = variables.get("ansible_facts") or {}
observed = ((facts.get("docker0") or {}).get("ipv4") or {}).get("address") or ""
if observed:
return str(observed)
pools = variables.get("NETWORK_DOCKER_ADDRESS_POOLS") or []
base = str((pools[0] or {}).get("base") or "") if pools else ""
if not base:
return ""
try:
return subnet_gateway(base)
except ValueError as exc:
raise AnsibleError(
f"networks: the first docker address pool {base!r} holds no gateway "
f"address, so containers have no way back to the host resolver."
) from exc
def resolve_container_dns(variables: dict[str, Any], templar: Any = None) -> list[str]:
"""Return the resolver list for the container runtime, most specific first.
@@ -85,7 +123,7 @@ def resolve_container_dns(variables: dict[str, Any], templar: Any = None) -> lis
difference restarts the container runtime underneath a live stack.
"""
facts = variables.get("ansible_facts") or {}
bridge = ((facts.get("docker0") or {}).get("ipv4") or {}).get("address") or ""
bridge = bridge_address(variables)
on_tor_node = _TOR_ROLE in (variables.get("group_names") or [])
clearnet = render(
@@ -1,5 +1,6 @@
import sys
import unittest
from typing import ClassVar
from . import PROJECT_ROOT
@@ -153,6 +154,76 @@ class _Templar:
return text
class TestTheBridgeSurvivesTheFirstRun(unittest.TestCase):
"""The run that writes daemon.json is the one with no docker0 fact.
Facts are gathered at play start and nothing re-gathers them after the
daemon is installed, so a fresh node renders its daemon config while
``ansible_facts.docker0`` is still absent. Deriving the address from the
fact alone emitted the clearnet resolver on its own, and the inner
containers then asked a public resolver for a domain only the node dnsmasq
knows: ``server can't find infinito.example: NXDOMAIN``.
"""
POOLS: ClassVar[list] = [{"base": "10.208.0.0/12", "size": 24}]
def facts(self, **extra):
"""Return facts for a host that resolves only through loopback.
Args:
extra: additional fact keys, e.g. a ``docker0`` entry.
"""
return {"dns": {"nameservers": ["127.0.0.1"]}, **extra}
def test_the_bridge_is_known_before_docker_ever_ran(self):
self.assertEqual(
resolve_container_dns(
_vars(
ansible_facts=self.facts(),
group_names=[],
networks={"internet": {"dns": "192.0.2.1"}},
NETWORK_DOCKER_ADDRESS_POOLS=self.POOLS,
)
),
["10.208.0.1", "192.0.2.1"],
)
def test_the_pass_after_docker_renders_the_same_bytes(self):
"""A differing render restarts the runtime under a live stack."""
before = resolve_container_dns(
_vars(
ansible_facts=self.facts(),
group_names=[],
networks={"internet": {"dns": "192.0.2.1"}},
NETWORK_DOCKER_ADDRESS_POOLS=self.POOLS,
)
)
after = resolve_container_dns(
_vars(
ansible_facts=self.facts(docker0={"ipv4": {"address": "10.208.0.1"}}),
group_names=[],
networks={"internet": {"dns": "192.0.2.1"}},
NETWORK_DOCKER_ADDRESS_POOLS=self.POOLS,
)
)
self.assertEqual(before, after)
def test_an_observed_bridge_still_wins_over_the_declaration(self):
self.assertEqual(
resolve_container_dns(
_vars(
ansible_facts=self.facts(
docker0={"ipv4": {"address": "172.17.0.1"}}
),
group_names=[],
networks={"internet": {"dns": "192.0.2.1"}},
NETWORK_DOCKER_ADDRESS_POOLS=self.POOLS,
)
),
["172.17.0.1", "192.0.2.1"],
)
class TestTheTrustTagIsStillWhereBothSidesLookForIt(unittest.TestCase):
"""Without this the whole class below can pass while production is broken.