Files
computer-playbook/tests/unit/python/plugins/lookup/test_ca_injected.py
T
kevinveenbirkenbachandClaude Opus 5.5 2597ce1b90 fix(ca_injected): read an application's own TLS on its canonical domain
Asked by application id, lookup('tls') aligns the domain to the vhost in `domain`. sys-svc-cdn reloads the compose handlers inside every sibling of the vhost loop, so a handler flushed after the loop still saw the application's onion sibling there: on prometheus' second stack deploy (the MCP adapter rebuild) ca_injected answered False, the swarm CA override was skipped and compose_file_args dropped it from docker stack deploy. Blackbox lost the CA mount and the docs healthz probe failed with an unknown authority (API#1, Jenkins#1), and the spec flip recreated every task at once, so the sso-proxy reached central redis before its sidecar registered and the converge gate failed on NOAUTH (OnlyOffice#1, Nextcloud#1, GitLab#1, Mattermost#1; run 40131272595).

The CA mount belongs to the containers, not to a vhost, so the own-TLS check now asks with the canonical domain, which no alignment touches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:44:25 +01:00

343 lines
12 KiB
Python

"""Unit tests for the ca_injected lookup plugin.
Pins the predicate that decides whether the self-signed root CA is bind-mounted
into an application's containers. Anything reading the mounted file must agree
with the handler gate that creates the mount, so every term is pinned here,
including their short-circuit order.
"""
from __future__ import annotations
import importlib.util
import unittest
import unittest.mock as mock
from ansible.errors import AnsibleError
from . import PROJECT_ROOT
MODULE = "lookup_ca_injected"
ISSUER = "web-app-keycloak"
def _load_lookup():
spec = importlib.util.spec_from_file_location(
MODULE, str(PROJECT_ROOT / "plugins/lookup/ca_injected.py")
)
mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(mod)
return mod
class _DummyTemplar:
def template(self, value):
return value
class TestCaInjectedLookup(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_lookup()
def _resolve(
self,
*,
domains,
enabled,
mode,
app="web-app-moodle",
services=None,
tls_mode="self_signed",
peer_enabled=True,
peer_mode="self_signed",
issuers=(ISSUER,),
):
calls: list[tuple[str, list]] = []
own_domain = domains.get(app)
def fake_get(name, **_kwargs):
def run(args, variables=None):
calls.append((name, list(args)))
if name == "config":
return [services or {}]
if name == "domains":
return [domains]
if args[0] == own_domain:
return [enabled if args[1] == "enabled" else mode]
if args[0] == app:
return [False if args[1] == "enabled" else "off"]
if isinstance(peer_mode, Exception) and args[1] == "mode":
raise peer_mode
return [peer_enabled if args[1] == "enabled" else peer_mode]
return mock.Mock(run=run)
plugin = self.mod.LookupModule()
plugin._loader = None
plugin._templar = _DummyTemplar()
with (
mock.patch.object(self.mod.lookup_loader, "get", side_effect=fake_get),
mock.patch.object(self.mod, "issuer_applications", return_value=issuers),
):
variables = {} if tls_mode is None else {"TLS_MODE": tls_mode}
result = plugin.run([app], variables=variables)
return result[0], calls
def test_a_self_signed_app_with_a_domain_gets_the_ca(self):
verdict, _ = self._resolve(
domains={"web-app-moodle": "moodle.example"},
enabled=True,
mode="self_signed",
)
self.assertIs(verdict, True)
def test_own_tls_is_read_on_the_canonical_domain_not_the_vhost_sibling(self):
verdict, calls = self._resolve(
domains={"web-app-moodle": "moodle.example"},
enabled=True,
mode="self_signed",
)
self.assertIs(
verdict,
True,
"asked by application id, tls aligns to the vhost in `domain`; a "
"handler flushed after the onion sibling's vhost then reads TLS off "
"and the next stack deploy drops the CA mount",
)
self.assertEqual(calls[1], ("tls", ["moodle.example", "enabled"]))
def test_letsencrypt_does_not_get_the_ca(self):
verdict, _ = self._resolve(
domains={"web-app-moodle": "moodle.example"},
enabled=True,
mode="letsencrypt",
)
self.assertIs(verdict, False)
def test_tls_disabled_does_not_get_the_ca(self):
verdict, calls = self._resolve(
domains={"web-app-moodle": "moodle.example"},
enabled=False,
mode="self_signed",
)
self.assertIs(verdict, False)
self.assertEqual([name for name, _ in calls], ["domains", "tls", "config"])
def test_an_app_without_a_domain_never_asks_about_tls(self):
verdict, calls = self._resolve(domains={}, enabled=True, mode="self_signed")
self.assertIs(verdict, False)
self.assertEqual([name for name, _ in calls], ["domains", "config"])
def test_a_domainless_ca_client_gets_the_ca_from_a_self_signed_issuer(self):
verdict, calls = self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}, "socket-proxy": {}},
)
self.assertIs(
verdict,
True,
"a container that only calls the self-signed endpoint has no domain "
"of its own, and without the CA every outbound call fails the "
"handshake",
)
self.assertEqual(
calls,
[
("domains", []),
("config", ["web-app-moodle", "services", {}]),
("tls", [ISSUER, "enabled"]),
("tls", [ISSUER, "mode"]),
],
)
def test_a_domainless_ca_client_gets_the_ca_on_the_deployment_mode(self):
verdict, calls = self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}, "socket-proxy": {}},
issuers=(),
)
self.assertIs(
verdict,
True,
"with no issuer to answer for it, a domainless ca_client still needs "
"the CA whenever the deployment itself is self-signed",
)
self.assertEqual([name for name, _ in calls], ["domains", "config"])
def test_a_ca_client_with_a_domain_never_answers_on_the_deployment_mode(self):
verdict, calls = self._resolve(
domains={"web-app-moodle": "moodle.example"},
enabled=True,
mode="letsencrypt",
services={"agent-broker": {"ca_client": True}},
tls_mode="self_signed",
peer_mode="letsencrypt",
)
self.assertIs(
verdict,
False,
"an app that has a domain is served over its own resolved mode, which "
"may override the deployment default; the ca_client answer comes from "
"the issuer's resolved mode, never from the global TLS_MODE",
)
self.assertEqual(
[name for name, _ in calls],
["domains", "tls", "tls", "config", "tls", "tls"],
)
def test_an_onion_served_ca_client_gets_the_ca_on_the_deployment_mode(self):
verdict, _ = self._resolve(
domains={"web-app-moodle": "abc.onion"},
enabled=False,
mode="self_signed",
services={"api": {"ca_client": True}},
tls_mode="self_signed",
issuers=(),
)
self.assertIs(
verdict,
True,
"resolve_enabled returns False for a .onion primary, so an onion app "
"has a domain and no TLS of its own; without the CA its outbound call "
"to a self-signed endpoint dies at the handshake",
)
def test_an_onion_served_app_without_the_declaration_still_gets_nothing(self):
verdict, _ = self._resolve(
domains={"web-app-moodle": "abc.onion"},
enabled=False,
mode="self_signed",
services={"api": {}},
tls_mode="self_signed",
)
self.assertIs(verdict, False)
def test_a_ca_client_gets_nothing_when_the_ca_is_not_self_signed(self):
verdict, _ = self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}},
tls_mode="letsencrypt",
issuers=(),
)
self.assertIs(verdict, False)
def test_a_ca_client_with_its_own_letsencrypt_still_trusts_a_self_signed_issuer(
self,
):
verdict, _ = self._resolve(
domains={"web-app-matrix": "matrix.example"},
enabled=True,
mode="letsencrypt",
app="web-app-matrix",
services={"matrix": {"ca_client": True}},
)
self.assertIs(
verdict,
True,
"a client served over its own letsencrypt certificate still calls the "
"issuer, whose per-app override may be self-signed",
)
def test_an_onion_only_ca_client_gets_the_ca_from_a_self_signed_issuer(self):
verdict, _ = self._resolve(
domains={"web-app-matrix": "matrix.example.onion"},
enabled=False,
mode="off",
app="web-app-matrix",
services={"matrix": {"ca_client": True}},
)
self.assertIs(
verdict,
True,
"an onion-only role has no TLS of its own but still calls the "
"self-signed clearnet issuer",
)
def test_a_ca_client_gets_nothing_when_the_issuer_serves_no_tls(self):
verdict, _ = self._resolve(
domains={"web-app-matrix": "matrix.example.onion"},
enabled=False,
mode="off",
app="web-app-matrix",
services={"matrix": {"ca_client": True}},
peer_enabled=False,
peer_mode="off",
tls_mode="letsencrypt",
)
self.assertIs(
verdict,
False,
"on a tor-only node the issuer is served over plain http, so there is "
"no self-signed endpoint to trust",
)
def test_a_ca_client_gets_nothing_when_the_issuer_is_not_self_signed(self):
verdict, _ = self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}},
peer_mode="letsencrypt",
tls_mode="letsencrypt",
)
self.assertIs(verdict, False)
def test_a_ca_client_without_an_issuer_gets_nothing(self):
verdict, _ = self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}},
issuers=(),
tls_mode="letsencrypt",
)
self.assertIs(verdict, False)
def test_a_ca_client_keeps_the_refusal_of_an_unresolvable_issuer_mode(self):
with self.assertRaises(
AnsibleError,
msg="a missing mode read as 'not self signed' withholds the CA in "
"silence, and every handshake then fails far from here",
):
self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}},
peer_mode=AnsibleError("tls: required variable 'TLS_MODE' is missing"),
)
def test_a_ca_client_refuses_an_absent_tls_mode(self):
with self.assertRaises(
AnsibleError,
msg="a missing mode read as 'not self signed' withholds the CA in "
"silence, and every handshake then fails far from here",
):
self._resolve(
domains={},
enabled=True,
mode="self_signed",
services={"agent-broker": {"ca_client": True}},
issuers=(),
tls_mode=None,
)
def test_the_term_is_required(self):
plugin = self.mod.LookupModule()
plugin._templar = _DummyTemplar()
with self.assertRaises(AnsibleError):
plugin.run([], variables={})
with self.assertRaises(AnsibleError):
plugin.run([" "], variables={})
if __name__ == "__main__":
unittest.main()