mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
Five conflicts, resolved by what each side is for rather than by taking a
side:
- sys-service/01_prune.yml and sys-service-terminator/02_unlock.yml take
the incoming single `systemctl` invocation over four module loops. The
unlock side also drops an unmask step: the list it runs on is built with
`reject('search', ' masked')`, so it never held a masked unit and the
step did nothing.
- sys-stk-front-proxy/tasks/main.yml keeps this branch's version. The block
the other side still carries there moved into vhost.yml, which runs it
once per network sibling; taking theirs would have duplicated it.
vhost.yml holds every task of that block, the global injection and the
TLS layer included.
- web-app-matomo/08_reinject_consumers.yml likewise keeps the per-vhost
loop, which re-renders the injection for every network sibling and passes
matomo_token_override.
- utils/cache/domains.py combines both: the incoming side rendered
DOMAIN_PRIMARY with the real templar and variables, which is right, but
added a second renderer beside the module's own. _render_plain now takes
that context optionally, so the other two call sites stay as they were.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
396 lines
16 KiB
Python
396 lines
16 KiB
Python
import sys
|
|
import unittest
|
|
import unittest.mock as mock
|
|
from unittest.mock import patch
|
|
|
|
from ansible.errors import AnsibleError
|
|
|
|
import utils.tls_common as _tls_common
|
|
from plugins.lookup.cert import LookupModule
|
|
from utils.cache import _reset_cache_for_tests
|
|
|
|
sys.modules.setdefault("ansible.module_utils.tls_common", _tls_common)
|
|
|
|
|
|
class TestCertPlanLookup(unittest.TestCase):
|
|
def setUp(self):
|
|
_reset_cache_for_tests()
|
|
self.lookup = LookupModule()
|
|
self.lookup._loader = mock.MagicMock()
|
|
|
|
self.domains = {
|
|
"web-app-a": "a.example",
|
|
"web-app-b": ["b.example", "b-alt.example"],
|
|
"web-app-c": {"primary": "c.example", "api": "api.c.example"},
|
|
}
|
|
|
|
self.applications = {
|
|
"web-app-a": {"domains": {"aliases": ["alias-a.example"]}},
|
|
"web-app-b": {"server": {"tls": {"flavor": "self_signed"}}},
|
|
"web-app-c": {
|
|
"server": {
|
|
"tls": {"flavor": "self_signed", "domains_san": ["x.example"]}
|
|
}
|
|
},
|
|
"web-app-off": {"server": {"tls": {"enabled": False}}},
|
|
}
|
|
|
|
self.vars = {
|
|
"domains": self.domains,
|
|
"applications": self.applications,
|
|
"TLS_ENABLED": True,
|
|
"TLS_MODE": "letsencrypt",
|
|
"CERTBOT_FLAVOR": "san",
|
|
"LETSENCRYPT_LIVE_PATH": "/etc/letsencrypt/live/",
|
|
"TLS_SELFSIGNED_BASE_PATH": "/etc/infinito.nexus/selfsigned",
|
|
"TLS_SELFSIGNED_SCOPE": "global",
|
|
"CURRENT_PLAY_DOMAINS_ALL": [
|
|
"a.example",
|
|
"b.example",
|
|
"b-alt.example",
|
|
"c.example",
|
|
"api.c.example",
|
|
],
|
|
}
|
|
|
|
def _domains_from_vars(_terms, variables=None, **_kwargs):
|
|
return [(variables or {}).get("domains", {})]
|
|
|
|
def _applications_from_vars(_terms, variables=None, **_kwargs):
|
|
return [(variables or {}).get("applications", {})]
|
|
|
|
def _get(name, *_a, **_k):
|
|
if name == "domains":
|
|
return mock.MagicMock(run=_domains_from_vars)
|
|
if name == "applications":
|
|
return mock.MagicMock(run=_applications_from_vars)
|
|
return mock.MagicMock(run=lambda *_ra, **_rk: [{}])
|
|
|
|
loader_patcher = patch("plugins.lookup.cert.lookup_loader")
|
|
loader_mock = loader_patcher.start()
|
|
loader_mock.get.side_effect = _get
|
|
self.addCleanup(loader_patcher.stop)
|
|
|
|
def tearDown(self):
|
|
_reset_cache_for_tests()
|
|
|
|
def test_letsencrypt_plan_paths_and_sans_default(self):
|
|
out = self.lookup.run(["web-app-a"], variables=self.vars, mode="app")[0]
|
|
self.assertEqual(out["mode"], "letsencrypt")
|
|
self.assertEqual(
|
|
out["files"]["cert"], "/etc/letsencrypt/live/a.example/fullchain.pem"
|
|
)
|
|
self.assertEqual(
|
|
out["files"]["key"], "/etc/letsencrypt/live/a.example/privkey.pem"
|
|
)
|
|
self.assertEqual(out["domains"]["san"], ["a.example"])
|
|
|
|
def test_sans_leave_out_networks_without_tls(self):
|
|
v = dict(self.vars)
|
|
v["domains"] = dict(self.domains)
|
|
v["domains"]["web-app-a"] = ["a.example", "a.abc.onion"]
|
|
out = self.lookup.run(["web-app-a"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["domains"]["san"], ["a.example"])
|
|
|
|
def test_letsencrypt_plan_uses_cert_name_override(self):
|
|
v = dict(self.vars)
|
|
v["applications"] = dict(self.applications)
|
|
v["applications"]["web-app-a"] = {
|
|
"server": {"tls": {"letsencrypt_cert_name": "mycert"}}
|
|
}
|
|
|
|
out = self.lookup.run(["web-app-a"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["cert_id"], "mycert")
|
|
self.assertEqual(
|
|
out["files"]["cert"], "/etc/letsencrypt/live/mycert/fullchain.pem"
|
|
)
|
|
|
|
def test_letsencrypt_alias_vhost_gets_its_own_lineage_link(self):
|
|
for flavor in ("san", "dedicated"):
|
|
v = dict(self.vars, CERTBOT_FLAVOR=flavor)
|
|
out = self.lookup.run(["alias-a.example"], variables=v)[0]
|
|
self.assertEqual(out["application_id"], "web-app-a")
|
|
self.assertEqual(out["domain"], "a.example")
|
|
self.assertEqual(
|
|
out["files"]["cert"],
|
|
"/etc/letsencrypt/live/alias-a.example/fullchain.pem",
|
|
)
|
|
|
|
def test_letsencrypt_wildcard_redirect_source_gets_its_own_lineage_link(self):
|
|
v = dict(
|
|
self.vars,
|
|
CURRENT_PLAY_REDIRECT_DOMAINS=[
|
|
{"source": "*.legacy.example", "target": "legacy.example"},
|
|
{"source": "legacy.example", "target": "a.example"},
|
|
],
|
|
)
|
|
out = self.lookup.run(["*.legacy.example", "files.key"], variables=v)[0]
|
|
self.assertEqual(out, "/etc/letsencrypt/live/*.legacy.example/privkey.pem")
|
|
|
|
def test_letsencrypt_wildcard_flavor_keeps_the_app_lineage(self):
|
|
v = dict(self.vars, CERTBOT_FLAVOR="wildcard")
|
|
out = self.lookup.run(["alias-a.example", "files.cert"], variables=v)[0]
|
|
self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem")
|
|
|
|
def test_letsencrypt_app_term_keeps_the_primary_lineage(self):
|
|
out = self.lookup.run(["web-app-a", "files.cert"], variables=self.vars)[0]
|
|
self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem")
|
|
|
|
def test_letsencrypt_requires_the_certbot_flavor(self):
|
|
v = dict(self.vars)
|
|
del v["CERTBOT_FLAVOR"]
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-a"], variables=v, mode="app")
|
|
|
|
def test_selfsigned_global_scope(self):
|
|
out = self.lookup.run(["web-app-b"], variables=self.vars, mode="app")[0]
|
|
self.assertEqual(out["mode"], "self_signed")
|
|
self.assertEqual(out["scope"], "global")
|
|
self.assertEqual(out["cert_id"], "global")
|
|
self.assertEqual(
|
|
out["files"]["cert"], "/etc/infinito.nexus/selfsigned/global/fullchain.pem"
|
|
)
|
|
self.assertEqual(
|
|
out["files"]["key"], "/etc/infinito.nexus/selfsigned/global/privkey.pem"
|
|
)
|
|
|
|
self.assertEqual(
|
|
out["domains"]["san"],
|
|
["b.example", "a.example", "b-alt.example", "c.example", "api.c.example"],
|
|
)
|
|
|
|
def test_selfsigned_global_scope_requires_current_play_domains_all_list(self):
|
|
v = dict(self.vars)
|
|
del v["CURRENT_PLAY_DOMAINS_ALL"]
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v, mode="app")
|
|
|
|
v2 = dict(self.vars)
|
|
v2["CURRENT_PLAY_DOMAINS_ALL"] = {"a.example": True}
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v2, mode="app")
|
|
|
|
v3 = dict(self.vars)
|
|
v3["CURRENT_PLAY_DOMAINS_ALL"] = []
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v3, mode="app")
|
|
|
|
v4 = dict(self.vars)
|
|
v4["CURRENT_PLAY_DOMAINS_ALL"] = ["a.example", ""]
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v4, mode="app")
|
|
|
|
v5 = dict(self.vars)
|
|
v5["CURRENT_PLAY_DOMAINS_ALL"] = ["a.example", 123]
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v5, mode="app")
|
|
|
|
def test_selfsigned_app_scope_paths(self):
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_SCOPE"] = "app"
|
|
|
|
out = self.lookup.run(["web-app-b"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["scope"], "app")
|
|
self.assertEqual(out["cert_id"], "web-app-b")
|
|
self.assertEqual(
|
|
out["files"]["cert"],
|
|
"/etc/infinito.nexus/selfsigned/web-app-b/b.example/fullchain.pem",
|
|
)
|
|
self.assertEqual(
|
|
out["files"]["key"],
|
|
"/etc/infinito.nexus/selfsigned/web-app-b/b.example/privkey.pem",
|
|
)
|
|
|
|
def test_primary_domain_term_matches_the_application_term(self):
|
|
cases = [
|
|
("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="san")),
|
|
("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="dedicated")),
|
|
("web-app-a", "a.example", dict(self.vars, CERTBOT_FLAVOR="wildcard")),
|
|
("web-app-b", "b.example", dict(self.vars)),
|
|
("web-app-b", "b.example", dict(self.vars, TLS_SELFSIGNED_SCOPE="app")),
|
|
("web-app-c", "c.example", dict(self.vars, TLS_SELFSIGNED_SCOPE="app")),
|
|
]
|
|
for app_id, primary, v in cases:
|
|
by_app = self.lookup.run([app_id, "files"], variables=v)[0]
|
|
by_domain = self.lookup.run([primary, "files"], variables=v)[0]
|
|
self.assertEqual(by_domain, by_app, (app_id, primary))
|
|
|
|
def test_selfsigned_app_scope_secondary_domain_uses_the_app_leaf(self):
|
|
v = dict(self.vars, TLS_SELFSIGNED_SCOPE="app")
|
|
cases = [
|
|
("b-alt.example", "/etc/infinito.nexus/selfsigned/web-app-b/b.example"),
|
|
("api.c.example", "/etc/infinito.nexus/selfsigned/web-app-c/c.example"),
|
|
]
|
|
for term, leaf in cases:
|
|
out = self.lookup.run([term, "files"], variables=v)[0]
|
|
self.assertEqual(out["cert"], f"{leaf}/fullchain.pem")
|
|
self.assertEqual(out["key"], f"{leaf}/privkey.pem")
|
|
|
|
def test_letsencrypt_secondary_domain_gets_its_own_lineage_link(self):
|
|
v = dict(
|
|
self.vars,
|
|
applications=dict(self.applications, **{"web-app-b": {}}),
|
|
)
|
|
out = self.lookup.run(["b-alt.example", "files.cert"], variables=v)[0]
|
|
self.assertEqual(out, "/etc/letsencrypt/live/b-alt.example/fullchain.pem")
|
|
|
|
def test_letsencrypt_onion_domain_keeps_the_app_lineage(self):
|
|
domains = dict(self.domains, **{"web-app-a": ["a.example", "a.node.onion"]})
|
|
for flavor in ("san", "dedicated", "wildcard"):
|
|
v = dict(self.vars, CERTBOT_FLAVOR=flavor, domains=domains)
|
|
out = self.lookup.run(["a.node.onion", "files.cert"], variables=v)[0]
|
|
self.assertEqual(out, "/etc/letsencrypt/live/a.example/fullchain.pem")
|
|
|
|
def test_selfsigned_san_override(self):
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_SCOPE"] = "app"
|
|
|
|
out = self.lookup.run(["web-app-c"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["domains"]["san"], ["c.example", "x.example"])
|
|
|
|
def test_off_mode_returns_empty_files_and_san(self):
|
|
v = dict(self.vars)
|
|
v["domains"] = dict(self.domains)
|
|
v["domains"]["web-app-off"] = "off.example"
|
|
|
|
out = self.lookup.run(["web-app-off"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["enabled"], False)
|
|
self.assertEqual(out["mode"], "off")
|
|
self.assertEqual(out["files"]["cert"], "")
|
|
self.assertEqual(out["files"]["key"], "")
|
|
self.assertEqual(out["domains"]["san"], [])
|
|
|
|
def test_want_path(self):
|
|
val = self.lookup.run(
|
|
["web-app-a", "files.cert"], variables=self.vars, mode="app"
|
|
)[0]
|
|
self.assertEqual(val, "/etc/letsencrypt/live/a.example/fullchain.pem")
|
|
|
|
def test_missing_required_when_needed(self):
|
|
v = dict(self.vars)
|
|
del v["LETSENCRYPT_LIVE_PATH"]
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-a"], variables=v, mode="app")
|
|
|
|
v2 = dict(self.vars)
|
|
v2["applications"] = dict(self.applications)
|
|
v2["applications"]["web-app-a"] = {"server": {"tls": {"flavor": "self_signed"}}}
|
|
del v2["TLS_SELFSIGNED_BASE_PATH"]
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-a"], variables=v2, mode="app")
|
|
|
|
def test_invalid_scope(self):
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_SCOPE"] = "nope"
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v, mode="app")
|
|
|
|
def test_invalid_tls_mode_default(self):
|
|
v = dict(self.vars)
|
|
v["TLS_MODE"] = "invalid"
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-a"], variables=v, mode="app")
|
|
|
|
def test_selfsigned_base_path_expands_jinja_from_hostvars(self):
|
|
"""
|
|
TLS_SELFSIGNED_BASE_PATH may contain Jinja markers and must be expanded
|
|
inside the lookup using the host context (hostvars[inventory_hostname]).
|
|
"""
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/selfsigned"
|
|
|
|
v["inventory_hostname"] = "localhost"
|
|
v["hostvars"] = {
|
|
"localhost": {
|
|
"SOFTWARE_DOMAIN": "infinito.nexus",
|
|
}
|
|
}
|
|
|
|
out = self.lookup.run(["web-app-b"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["mode"], "self_signed")
|
|
self.assertEqual(
|
|
out["files"]["cert"], "/etc/infinito.nexus/selfsigned/global/fullchain.pem"
|
|
)
|
|
self.assertEqual(
|
|
out["files"]["key"], "/etc/infinito.nexus/selfsigned/global/privkey.pem"
|
|
)
|
|
|
|
def test_letsencrypt_live_path_expands_jinja_from_hostvars(self):
|
|
"""
|
|
LETSENCRYPT_LIVE_PATH may contain Jinja markers and must be expanded.
|
|
"""
|
|
v = dict(self.vars)
|
|
v["LETSENCRYPT_LIVE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/letsencrypt/live"
|
|
|
|
v["inventory_hostname"] = "localhost"
|
|
v["hostvars"] = {
|
|
"localhost": {
|
|
"SOFTWARE_DOMAIN": "infinito.nexus",
|
|
}
|
|
}
|
|
|
|
out = self.lookup.run(["web-app-a"], variables=v, mode="app")[0]
|
|
self.assertEqual(out["mode"], "letsencrypt")
|
|
self.assertEqual(
|
|
out["files"]["cert"],
|
|
"/etc/infinito.nexus/letsencrypt/live/a.example/fullchain.pem",
|
|
)
|
|
self.assertEqual(
|
|
out["files"]["key"],
|
|
"/etc/infinito.nexus/letsencrypt/live/a.example/privkey.pem",
|
|
)
|
|
|
|
def test_jinja_expansion_missing_var_fails_hard(self):
|
|
"""
|
|
With strict rendering, missing vars used inside the Jinja expression must
|
|
raise an AnsibleError (no leaking "{{ ... }}" into configs).
|
|
"""
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/selfsigned"
|
|
|
|
v["inventory_hostname"] = "localhost"
|
|
v["hostvars"] = {"localhost": {}}
|
|
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v, mode="app")
|
|
|
|
def test_jinja_expansion_uses_hostvars_over_lookup_vars(self):
|
|
"""
|
|
If both `variables` and hostvars contain SOFTWARE_DOMAIN, hostvars should win
|
|
(as implemented by ctx.update(hostvars[inventory_hostname])).
|
|
"""
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ SOFTWARE_DOMAIN }}/selfsigned"
|
|
|
|
v["SOFTWARE_DOMAIN"] = "Wrong.Name"
|
|
|
|
v["inventory_hostname"] = "localhost"
|
|
v["hostvars"] = {
|
|
"localhost": {
|
|
"SOFTWARE_DOMAIN": "infinito.nexus",
|
|
}
|
|
}
|
|
|
|
out = self.lookup.run(["web-app-b"], variables=v, mode="app")[0]
|
|
self.assertEqual(
|
|
out["files"]["cert"], "/etc/infinito.nexus/selfsigned/global/fullchain.pem"
|
|
)
|
|
|
|
def test_jinja_expression_that_renders_to_empty_triggers_failfast(self):
|
|
"""
|
|
If Jinja renders to something that still contains Jinja markers, or is invalid,
|
|
cert should fail fast. Here we use a construction that still contains Jinja
|
|
after rendering by injecting literal braces.
|
|
"""
|
|
v = dict(self.vars)
|
|
v["TLS_SELFSIGNED_BASE_PATH"] = "/etc/{{ '{{ STILL_JINJA }}' }}/selfsigned"
|
|
v["inventory_hostname"] = "localhost"
|
|
v["hostvars"] = {"localhost": {"SOFTWARE_DOMAIN": "infinito.nexus"}}
|
|
|
|
with self.assertRaises(AnsibleError):
|
|
self.lookup.run(["web-app-b"], variables=v, mode="app")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|