fix(container-dns): follow the live bridge, not a configured address

The swarm inventory carried the manager's docker bridge gateway as a literal under networks.internet.dns, so one field meant two things at once and the value went stale the moment default-address-pools moved the bridge. The daemon kept handing out the new address while the inventory kept naming the old one, and the DinD diagnostic probed that same literal, so it failed in step with the defect instead of catching it.

The inventory now declares only the clearnet resolver. The lookup hands containers the bridge whenever the host resolves solely through a loopback listener, which is how the simulated cluster nodes are wired and which docker strips from a container's resolv.conf. The diagnostic reads the gateway from the running daemon instead of being told it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-29 00:06:42 +02:00
1 parent 268c3082cb
commit ce43263745
6 files changed
+87 -8

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ networks:
internet:
ip4: "{{ lookup('env', 'INFINITO_SWARM_MGR_IP') }}"
ip6: "::1"
dns: "172.17.0.1" # nocheck: hardcoded-dns-resolver (mgr-01 docker bridge gateway, served by local dnsmasq)
dns: "{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}"
storage:
backend: nfs
+25 -1
View File
@@ -7,6 +7,22 @@ from ansible.plugins.lookup import LookupBase
_TOR_ROLE = "svc-net-tor"
def _serves_only_on_loopback(facts: dict[str, Any]) -> bool:
"""Return whether every resolver the host itself uses is a loopback address.
Docker drops loopback entries from a container's ``resolv.conf``, so such a
host leaves its containers with no resolver at all unless they are pointed
at the bridge the same daemon listens on. A host that also lists a routable
resolver is fine as it is, hence ``all`` rather than ``any``.
"""
nameservers = (facts.get("dns") or {}).get("nameservers")
if not isinstance(nameservers, list) or not nameservers:
return False
return all(
str(server).startswith("127.") or str(server) == "::1" for server in nameservers
)
def resolve_container_dns(variables: dict[str, Any]) -> list[str]:
"""Return the resolver list for the container runtime, most specific first.
@@ -16,6 +32,13 @@ def resolve_container_dns(variables: dict[str, Any]) -> list[str]:
bridge address because docker discards loopback resolvers, and it is
absent before docker itself is installed, hence the guard.
The same reachability problem exists without Tor wherever the host resolves
through a listener of its own on loopback, which is how the simulated
cluster nodes are wired. Handing containers the declared clearnet address
there was what a literal bridge address in the inventory used to paper
over, and that literal went stale the moment the daemon's address pool
moved the bridge off Docker's default.
Both deploy modes get the bridge: a Tor node runs the same dnsmasq
listener in compose and in swarm, so both resolve onions alike.
@@ -33,7 +56,8 @@ def resolve_container_dns(variables: dict[str, Any]) -> list[str]:
"dns"
) or ""
resolvers = (bridge if on_tor_node else "", clearnet)
needs_bridge = on_tor_node or _serves_only_on_loopback(facts)
resolvers = (bridge if needs_bridge else "", clearnet)
return list(dict.fromkeys(str(r) for r in resolvers if r))
@@ -7,7 +7,6 @@ set -euo pipefail
: "${BUSYBOX_IMAGE:?Missing Busybox image}"
: "${NODE_IMAGE?Missing Node image; pass it empty to skip the getaddrinfo check}"
: "${DNS_IP:?Missing env DNS_IP}"
: "${DOMAIN:?Missing env DOMAIN}"
SUBDOMAIN="foo.${DOMAIN}"
@@ -36,6 +35,14 @@ done
[ -S /var/run/docker.sock ] || fail "docker.sock missing after waiting"
container info >/dev/null 2>&1 || fail "container info still failing after waiting"
section "Resolve the bridge gateway the inner resolver answers on"
DNS_IP="$(container network inspect bridge \
| python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["IPAM"]["Config"][0]["Gateway"])')"
[ -n "${DNS_IP}" ] || fail "could not read the docker bridge gateway"
ok "bridge gateway ${DNS_IP}"
section "Docker-in-Docker DNS (busybox: A + no SERVFAIL)"
container run --rm --dns "${DNS_IP}" "${BUSYBOX_IMAGE}" sh -lc "
@@ -20,5 +20,4 @@
BUSYBOX_IMAGE: "{{ lookup('config', 'sys-svc-container', 'services.busybox.image') }}:{{ lookup('config', 'sys-svc-container', 'services.busybox.version') }}"
NODE_IMAGE: "{{ '' if DEPLOYMENT_MODE == 'swarm' else lookup('config', 'sys-svc-container', 'services.node.image') ~ ':' ~ lookup('config', 'sys-svc-container',
'services.node.version') }}"
DNS_IP: "{{ networks.internet.dns }}"
DOMAIN: "{{ DOMAIN_PRIMARY }}"
@@ -52,10 +52,9 @@ class TestDnsmasqListeners(unittest.TestCase):
self.assertIn("server=172.30.0.53", _render_dnsmasq())
def test_upstream_policy_stays_off_a_listener_we_do_not_own(self):
"""A shared dnsmasq brings its own upstreams, and in swarm
networks.internet.dns is the docker bridge that very dnsmasq answers
on. Writing a server= there points the resolver at itself, and
no-resolv would cut the upstreams its owner configured."""
"""A shared dnsmasq brings its own upstreams, chosen by whoever set it
up. Writing our server= into it overrides that choice, and no-resolv
would cut the upstreams its owner configured."""
rendered = _render_dnsmasq(TOR_DNSMASQ_OWNS_LISTENER=False)
self.assertNotIn("server=172.30.0.53", rendered)
self.assertNotIn("no-resolv", rendered)
@@ -76,5 +76,55 @@ class TestResolveContainerDns(unittest.TestCase):
self.assertEqual(resolve_container_dns({}), [])
class TestLoopbackOnlyHost(unittest.TestCase):
"""A host resolving through its own loopback listener cannot hand that
address to containers, because docker drops loopback entries."""
@staticmethod
def _facts(nameservers):
return {**_BRIDGE, "dns": {"nameservers": nameservers}}
def test_a_loopback_only_host_prefers_the_bridge_without_tor(self):
self.assertEqual(
resolve_container_dns(
_vars(group_names=[], ansible_facts=self._facts(["127.0.0.1"]))
),
["172.17.0.1", "172.30.0.53"],
)
def test_ipv6_loopback_counts_the_same(self):
self.assertEqual(
resolve_container_dns(
_vars(group_names=[], ansible_facts=self._facts(["::1"]))
),
["172.17.0.1", "172.30.0.53"],
)
def test_a_routable_resolver_alongside_loopback_needs_no_bridge(self):
self.assertEqual(
resolve_container_dns(
_vars(
group_names=[],
ansible_facts=self._facts(["127.0.0.1", "172.30.0.53"]),
)
),
["172.30.0.53"],
)
def test_a_routable_host_resolver_needs_no_bridge(self):
self.assertEqual(
resolve_container_dns(
_vars(group_names=[], ansible_facts=self._facts(["172.30.0.53"]))
),
["172.30.0.53"],
)
def test_an_empty_nameserver_list_is_not_loopback_only(self):
self.assertEqual(
resolve_container_dns(_vars(group_names=[], ansible_facts=self._facts([]))),
["172.30.0.53"],
)
if __name__ == "__main__":
unittest.main()