fix(container-dns): render the inventory value before docker reads it

ce43263745 replaced the literal 172.17.0.1 under networks.internet.dns
with {{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}, for a good reason: the
fixed address went stale whenever default-address-pools moved the
bridge. Nothing renders that field, though. The file arrives as
'-e @inventories/development/swarm.yml' and this lookup read it straight
out of variables, so the expression reached the daemon as its own source
text:

  dockerd: unable to configure the Docker daemon with file
  /etc/docker/daemon.json: invalid JSON:
  ParseAddr("{{ NETWORK_PUBLIC_DNS_RESOLVERS | first }}"):
  unable to parse IP

Then 'Start request repeated too quickly', and the node has no container
engine, which is where eight swarm jobs died - across centos and debian,
so it was never distro-specific. While the field held an address the
missing render step was invisible.

It now renders against the lookup's own templar, and refuses to emit a
value that still carries Jinja. A string that is not an address has no
business in daemon.json, and failing loudly beats leaving a node without
docker.
This commit is contained in:
kevinveenbirkenbach committed 2026-08-29 19:42:47 +02:00
1 parent de95a95c65
commit 5345f1632e
2 files changed
+124 -5

No files matched your search

+34 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
_TOR_ROLE = "svc-net-tor"
@@ -23,7 +24,35 @@ def _serves_only_on_loopback(facts: dict[str, Any]) -> bool:
)
def resolve_container_dns(variables: dict[str, Any]) -> list[str]:
def render(value: Any, templar: Any) -> str:
"""Return an inventory value with its Jinja resolved.
Args:
value: whatever the inventory holds for the field.
templar: the lookup's templar, or None outside a play.
``networks.internet.dns`` arrives through ``-e @inventories/...`` and is
read straight out of ``variables``, which hands back whatever the file
contains. While that was a literal address nobody noticed; an expression
there reached the daemon as its own source text and docker refused the
config with ``ParseAddr("{{ ... }}")``.
"""
text = str(value or "")
if "{{" not in text:
return text
rendered = str(templar.template(text)) if templar is not None else text
if "{{" in rendered:
raise AnsibleError(
f"networks.internet.dns is {text!r} and did not resolve to an "
f"address. Emitting it would put that source text in daemon.json, "
f"where docker refuses the whole config."
)
return rendered
def resolve_container_dns(
variables: dict[str, Any], templar: Any = None
) -> list[str]:
"""Return the resolver list for the container runtime, most specific first.
On a Tor node the node dnsmasq resolves ``.onion`` through Tor's DNSPort
@@ -52,9 +81,9 @@ def resolve_container_dns(variables: dict[str, Any]) -> list[str]:
bridge = ((facts.get("docker0") or {}).get("ipv4") or {}).get("address") or ""
on_tor_node = _TOR_ROLE in (variables.get("group_names") or [])
clearnet = ((variables.get("networks") or {}).get("internet") or {}).get(
"dns"
) or ""
clearnet = render(
((variables.get("networks") or {}).get("internet") or {}).get("dns"), templar
)
needs_bridge = on_tor_node or _serves_only_on_loopback(facts)
resolvers = (bridge if needs_bridge else "", clearnet)
@@ -73,4 +102,4 @@ class LookupModule(LookupBase):
if terms:
raise ValueError("lookup('container_dns') takes no positional terms.")
variables = variables or getattr(self._templar, "available_variables", {}) or {}
return [resolve_container_dns(variables)]
return [resolve_container_dns(variables, self._templar)]
@@ -126,5 +126,95 @@ class TestLoopbackOnlyHost(unittest.TestCase):
)
class _Templar:
"""Renders one expression, the way a play's templar would."""
def __init__(self, mapping):
self.mapping = mapping
def template(self, text):
for name, value in self.mapping.items():
if text.strip() == "{{ " + name + " | first }}":
return value[0]
return text
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(Exception) 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(Exception):
resolve_container_dns(
{
"networks": {"internet": {"dns": self.EXPRESSION}},
"group_names": [],
"ansible_facts": self.facts(["192.0.2.9"]),
},
_Templar({}),
)
if __name__ == "__main__":
unittest.main()