mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
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:
1 parent
268c3082cb
commit
ce43263745
6 files changed
+87
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user