mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
1 parent
de95a95c65
commit
5345f1632e
2 files changed
+124
-5
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user