mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
1 parent
87c1ba0371
commit
285ba1e76c
2 files changed
+110
-1
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user