Files
computer-playbook/tests/unit/python/plugins/lookup/test_container_dns.py
T
kevinveenbirkenbachandClaude Opus 5 285ba1e76c 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>
2026-08-29 22:08:51 +02:00

329 lines
12 KiB
Python

import sys
import unittest
from typing import ClassVar
from . import PROJECT_ROOT
def _ensure_repo_root_on_syspath():
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
_ensure_repo_root_on_syspath()
from ansible.errors import AnsibleError # noqa: E402
from plugins.lookup.container_dns import resolve_container_dns # noqa: E402
from utils.templating.ansible import ( # noqa: E402
TrustedAsTemplate,
_trust_as_template,
)
_BRIDGE = {"docker0": {"ipv4": {"address": "172.17.0.1"}}}
_CLEARNET = {"internet": {"dns": "172.30.0.53"}}
def _vars(**overrides):
variables = {
"ansible_facts": _BRIDGE,
"group_names": ["svc-net-tor"],
"networks": _CLEARNET,
"DEPLOYMENT_MODE": "compose",
}
variables.update(overrides)
return variables
class TestResolveContainerDns(unittest.TestCase):
"""The bridge resolver must come first on a Tor node so .onion resolves,
with the clearnet resolver kept as fallback."""
def test_tor_node_prefers_the_bridge_resolver(self):
self.assertEqual(resolve_container_dns(_vars()), ["172.17.0.1", "172.30.0.53"])
def test_without_tor_only_clearnet(self):
self.assertEqual(resolve_container_dns(_vars(group_names=[])), ["172.30.0.53"])
def test_the_bridge_follows_ownership_not_the_deploy_mode(self):
self.assertEqual(
resolve_container_dns(_vars(DEPLOYMENT_MODE="swarm")),
["172.17.0.1", "172.30.0.53"],
)
def test_a_tor_node_without_clearnet_keeps_the_bridge(self):
self.assertEqual(
resolve_container_dns(_vars(DEPLOYMENT_MODE="swarm", networks={})),
["172.17.0.1"],
)
def test_a_non_tor_node_yields_nothing_without_clearnet(self):
self.assertEqual(resolve_container_dns(_vars(group_names=[], networks={})), [])
def test_a_bridge_equal_to_clearnet_is_not_emitted_twice(self):
self.assertEqual(
resolve_container_dns(_vars(networks={"internet": {"dns": "172.17.0.1"}})),
["172.17.0.1"],
)
def test_without_bridge_only_clearnet(self):
self.assertEqual(
resolve_container_dns(_vars(ansible_facts={})), ["172.30.0.53"]
)
def test_without_clearnet_only_bridge(self):
self.assertEqual(resolve_container_dns(_vars(networks={})), ["172.17.0.1"])
def test_nothing_configured_yields_empty_list(self):
self.assertEqual(
resolve_container_dns(_vars(ansible_facts={}, networks={})), []
)
def test_missing_keys_do_not_raise(self):
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"],
)
class _Templar:
"""Renders one expression, the way a play's templar would.
Ansible 2.19+ renders only strings carrying ``TrustedAsTemplate``, and
``str()`` drops that tag. A stub that renders any string passes while
production returns the expression untouched, so this one refuses an
untagged string exactly as the real templar does.
"""
def __init__(self, mapping):
self.mapping = mapping
def template(self, text):
if TrustedAsTemplate is not None and not TrustedAsTemplate.is_tagged_on(text):
return text
for name, value in self.mapping.items():
if str(text).strip() == "{{ " + name + " | first }}":
return value[0]
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.
``_trust_as_template`` and the stub both key off the same private ansible
module. Were it to move, the tagging degrades to a no-op and the templar
stops rendering, while the stub degrades to rendering anything and keeps
the tests green. This asserts the shared premise instead.
"""
def test_the_tag_class_is_importable(self):
self.assertIsNotNone(
TrustedAsTemplate,
"ansible no longer exposes TrustedAsTemplate where "
"utils.templating.ansible looks for it; every lookup that "
"re-tags before templating silently stopped rendering",
)
def test_tagging_is_observable(self):
tagged = _trust_as_template("{{ x }}")
self.assertTrue(TrustedAsTemplate.is_tagged_on(tagged))
self.assertFalse(TrustedAsTemplate.is_tagged_on(str(tagged)))
class TestInventoryExpressionIsRendered(unittest.TestCase):
"""The field arrives through `-e @inventory.yml` and is read raw.
While it held a literal address nobody noticed that nothing rendered it.
An expression there reached the daemon as its own source text, and docker
refused the whole config: ParseAddr("{{ ... }}"): unable to parse IP.
"""
EXPRESSION = "{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}"
def facts(self, nameservers):
return {"dns": {"nameservers": nameservers}, "docker0": {"ipv4": {}}}
def test_an_expression_is_resolved_against_the_play(self):
templar = _Templar({"NETWORK_PUBLIC_DNS_RESOLVERS": ["192.0.2.1", "192.0.2.2"]})
result = resolve_container_dns(
{
"networks": {"internet": {"dns": self.EXPRESSION}},
"group_names": [],
"ansible_facts": self.facts(["192.0.2.9"]),
},
templar,
)
self.assertEqual(["192.0.2.1"], result)
def test_no_resolver_ever_carries_jinja(self):
"""What reaches daemon.json must be an address, not its source text."""
templar = _Templar({"NETWORK_PUBLIC_DNS_RESOLVERS": ["192.0.2.1"]})
for nameservers in ([], ["127.0.0.1"], ["192.0.2.9"]):
with self.subTest(nameservers=nameservers):
for resolver in resolve_container_dns(
{
"networks": {"internet": {"dns": self.EXPRESSION}},
"group_names": [],
"ansible_facts": self.facts(nameservers),
},
templar,
):
self.assertNotIn("{{", resolver)
def test_a_literal_survives_without_a_templar(self):
"""Outside a play there is nothing to render against, and no need."""
self.assertEqual(
["172.30.0.53"],
resolve_container_dns(
{
"networks": {"internet": {"dns": "172.30.0.53"}},
"group_names": [],
"ansible_facts": self.facts(["192.0.2.9"]),
}
),
)
def test_an_unresolvable_expression_aborts_instead_of_reaching_docker(self):
"""This is the shape that reached CI: no templar, so nothing rendered."""
with self.assertRaises(AnsibleError) as caught:
resolve_container_dns(
{
"networks": {"internet": {"dns": self.EXPRESSION}},
"group_names": [],
"ansible_facts": self.facts(["192.0.2.9"]),
}
)
self.assertIn("daemon.json", str(caught.exception))
def test_a_templar_that_cannot_resolve_it_aborts_too(self):
with self.assertRaises(AnsibleError):
resolve_container_dns(
{
"networks": {"internet": {"dns": self.EXPRESSION}},
"group_names": [],
"ansible_facts": self.facts(["192.0.2.9"]),
},
_Templar({}),
)
if __name__ == "__main__":
unittest.main()