fix(ca): pass the CA override to the deployment that was granted it

sys-svc-compose writes compose.ca.override.yml for exactly lookup('ca_injected', application_id), which since 1254e27c2d also answers true for a service that declares ca_client and only calls the self-signed endpoint. compose_file_args rebuilt that predicate instead of asking it, and its copy still demanded a domain, so for a domainless ca_client the file was written and never reached --compose-file. Swarm takes its file list from that lookup; compose finds the file on disk by itself, which is why only the swarm jobs of run 36064589578 failed while the compose job passed.

The broker's container in that run carries no SSL_CERT_FILE and no CA mount, while openwebui, same job and same node, carries seven CA env vars and four binds. Every outbound call to Keycloak then fails the handshake, which the CLI test reports as 503 and Open WebUI relays as HTTP 400.

Two corrections in ca_injected itself. Its templar.template() call was dead: as_str strips the TrustedAsTemplate tag and ansible-core 2.19+ refuses to render an untrusted string, so the line could not resolve what it advertised, and tls.py and cert.py never template TLS_MODE either. And the ca_client branch short-circuited on the global TLS_MODE, skipping resolve_mode's per-app server.tls.mode override; it now applies only where there is no domain and so no per-app answer to get.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-25 08:32:33 +02:00
1 parent 0b9ae03d63
commit b6bf0b0bc1
4 files changed
+101 -69

No files matched your search

@@ -74,7 +74,8 @@ class TestCaInjectedLookup(unittest.TestCase):
plugin._loader = None
plugin._templar = _DummyTemplar()
with mock.patch.object(self.mod.lookup_loader, "get", side_effect=fake_get):
result = plugin.run([app], variables={"TLS_MODE": tls_mode})
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):
@@ -104,7 +105,7 @@ class TestCaInjectedLookup(unittest.TestCase):
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], ["config", "domains"])
self.assertEqual([name for name, _ in calls], ["domains", "config"])
def test_a_domainless_ca_client_gets_the_ca_on_the_deployment_mode(self):
verdict, calls = self._resolve(
@@ -120,7 +121,24 @@ class TestCaInjectedLookup(unittest.TestCase):
"of its own, and without the CA every outbound call fails the "
"handshake",
)
self.assertEqual([name for name, _ in calls], ["config"])
self.assertEqual([name for name, _ in calls], ["domains", "config"])
def test_a_ca_client_with_a_domain_follows_its_own_tls_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",
)
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 shortcut must not "
"answer for it on the global TLS_MODE",
)
self.assertEqual([name for name, _ in calls], ["domains", "tls", "tls"])
def test_a_ca_client_gets_nothing_when_the_ca_is_not_self_signed(self):
verdict, _ = self._resolve(
@@ -132,6 +150,20 @@ class TestCaInjectedLookup(unittest.TestCase):
)
self.assertIs(verdict, False)
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}},
tls_mode=None,
)
def test_the_term_is_required(self):
plugin = self.mod.LookupModule()
plugin._templar = _DummyTemplar()
@@ -30,6 +30,14 @@ class _DomainsResolveStub:
return [(variables or {}).get("domains", {})]
class _CaInjectedStub:
def __init__(self, injected: bool):
self._injected = injected
def run(self, terms, variables=None, **kwargs):
return [self._injected]
class TestComposeFArgs(unittest.TestCase):
def setUp(self):
self.m = _load_module(
@@ -48,18 +56,48 @@ class TestComposeFArgs(unittest.TestCase):
},
}
def _loader_dispatch(self, tls_stub):
# "domains" resolves through variables['domains'] to keep tests hermetic;
# "tls" resolves through the per-test stub.
def _loader_dispatch(self, tls_stub, ca_injected=None):
verdict = tls_stub.run(None)[0]
injected = (
(verdict["enabled"] and verdict["mode"] == "self_signed")
if ca_injected is None
else ca_injected
)
def _get(name, *args, **kwargs):
if name == "domains":
return _DomainsResolveStub()
if name == "tls":
return tls_stub
if name == "ca_injected":
return _CaInjectedStub(injected)
raise AssertionError(f"unexpected lookup name: {name}")
return _get
def test_a_domainless_ca_client_still_gets_the_ca_override_file(self):
with (
patch.object(
self.m.lookup_loader,
"get",
side_effect=self._loader_dispatch(
_TlsResolveStub(False, "off"), ca_injected=True
),
),
patch.object(
self.m, "get_docker_paths", side_effect=self._stub_get_docker_paths
),
patch.object(self.m, "_role_provides_override", return_value=False),
):
out = self.lookup.run(["web-app-a"], variables={**self.vars, "domains": {}})
self.assertIn(
"/x/compose.ca.override.yml",
out[0],
"ca_injected grants a domainless ca_client the override and the "
"handler writes it; a domain gate here leaves that file on disk "
"while the container runs without a trust store",
)
def _stub_get_docker_paths(self, application_id: str, base_dir: str) -> dict:
self.assertEqual(application_id, "web-app-a")
self.assertEqual(base_dir, "/x/")