mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
Merge branch 'main' into feature/store-i18n
Fourteen real conflicts plus the 66 generated READMEs.
utils/cache/files.py and its test: both sides added a skip-dir rule, resolved
as a union so the root `build/` prune and the per-root walk key coexist.
plugins/lookup/database.py and postgres_libraries.py: main's added lookups kept,
with get_entity_name renamed to entity_name as this branch renamed it.
roles/web-app-{mailu,matrix}/meta/services.yml: main's `covers: [email]` and its
MAIL_PROVIDER-gated email entry, plus this branch's gitlab/jira/n8n/jitsi
consumers, which the merge base carried on neither side.
group_vars/all/17_resource.yml: this branch's lookup('resource', ...) refactor;
main's single added literal is already covered by _POSTGRES_MAX_LOCKS_PER_TRANSACTION.
docs/requirements/035-mcp-proxy-expansion.md: this branch moved the design into
ADR 0003, so the requirement keeps only the user story and the criteria. Main's
delta inside the moved block was two new roles, carried into the ADR audit:
web-app-stalwart as adapter_candidate, svc-net-tor-smtp as privileged_control_plane.
The no_surface total said 99 while its own subcategories summed to 112.
inventories/development/default.yml: biber keeps no explicit email. Both sides fixed
the same CI failure; this branch fixed it in _apply_account_defaults, which renders
biber@{{ DOMAIN_PRIMARY }} (utils/cache/users/__init__.py:68) — byte-identical to
the address main hardcoded, so the inventory line is a second copy of one fact.
roles/web-app-{gitlab,keycloak}/files/playwright/playwright.spec.js and
roles/web-app-gitlab/templates/playwright.env.j2: union of both import sets, with
requireDotenvValue kept over decodeDotenvQuotedValue for CANONICAL_DOMAIN because
an empty value must fail rather than match every substring.
The 66 role READMEs take this side: aceb5295ef moved Cosmos, Quick Setup and
Credits to the documentation build, and test_readme.py now rejects them in a
committed README. Main's prose edits inside them are carried over by hand (the
Mailu deprecation banner, Mailu -> Stalwart in erpnext and zammad), and the two
READMEs main added fresh are stripped of the three generated sections.
roles/web-app-sphinx/README.md stays deleted; 6eb9a59d14 merged that role into
web-app-docs. The root README's generated role table was regenerated from the
merged metadata.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f2fedfc677
351 files changed
+9968
-416
No files matched your search
@@ -6,6 +6,7 @@ from ansible.errors import AnsibleError
|
||||
from plugins.lookup.container_extra_hosts import LookupModule
|
||||
|
||||
ONION = "auth." + "b" * 56 + ".onion"
|
||||
MAIL_ONION = "mail." + "c" * 56 + ".onion"
|
||||
|
||||
|
||||
class TestContainerExtraHostsLookup(unittest.TestCase):
|
||||
@@ -18,9 +19,13 @@ class TestContainerExtraHostsLookup(unittest.TestCase):
|
||||
def _run(terms, variables=None, **_kwargs):
|
||||
vars_ = variables or {}
|
||||
if name == "config":
|
||||
if "services.email.enabled" in terms:
|
||||
return [vars_.get("_email_enabled", False)]
|
||||
return [vars_.get("_sso_enabled", False)]
|
||||
if name == "tls":
|
||||
return [vars_.get("_provider_domain", "")]
|
||||
if name == "email":
|
||||
return [{"host": vars_.get("_mail_host", "")}]
|
||||
raise AssertionError(f"unexpected lookup '{name}'")
|
||||
|
||||
plugin.run.side_effect = _run
|
||||
@@ -32,7 +37,7 @@ class TestContainerExtraHostsLookup(unittest.TestCase):
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def _run(self, extra_hosts=None, **overrides):
|
||||
def _run(self, extra_hosts=None, docker_flags=None, **overrides):
|
||||
variables = {
|
||||
"application_id": "web-app-espocrm",
|
||||
"_sso_enabled": True,
|
||||
@@ -43,8 +48,75 @@ class TestContainerExtraHostsLookup(unittest.TestCase):
|
||||
}
|
||||
variables.update(overrides)
|
||||
kwargs = {} if extra_hosts is None else {"extra_hosts": extra_hosts}
|
||||
if docker_flags is not None:
|
||||
kwargs["docker_flags"] = docker_flags
|
||||
return self.lookup.run(None, variables=variables, **kwargs)[0]
|
||||
|
||||
def test_an_onion_mail_relay_is_pinned_so_a_proxyless_client_can_reach_it(self):
|
||||
"""GitLab's Net::SMTP takes no proxy, so an unpinned .onion relay
|
||||
swallows every message: the send is queued and reported successful."""
|
||||
block = self._run(
|
||||
_sso_enabled=False, _email_enabled=True, _mail_host=MAIL_ONION
|
||||
)
|
||||
self.assertEqual(
|
||||
block,
|
||||
'extra_hosts:\n - "host.docker.internal:host-gateway"\n'
|
||||
f' - "{MAIL_ONION}:host-gateway"',
|
||||
)
|
||||
|
||||
def test_docker_flags_emit_add_host_for_a_launcher_built_container(self):
|
||||
"""Discourse has no compose service -- its launcher builds the
|
||||
container from docker_args, so the pins must reach it as run flags."""
|
||||
block = self._run(
|
||||
_sso_enabled=False,
|
||||
_email_enabled=True,
|
||||
_mail_host=MAIL_ONION,
|
||||
docker_flags=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
block,
|
||||
" - --add-host=host.docker.internal:host-gateway\n"
|
||||
f" - --add-host={MAIL_ONION}:host-gateway",
|
||||
)
|
||||
|
||||
def test_docker_flags_stay_empty_when_there_is_nothing_to_pin(self):
|
||||
self.assertEqual(
|
||||
self._run(_sso_enabled=False, _email_enabled=False, docker_flags=True), ""
|
||||
)
|
||||
|
||||
def test_a_clearnet_mail_relay_is_left_alone(self):
|
||||
self.assertEqual(
|
||||
self._run(
|
||||
_sso_enabled=False, _email_enabled=True, _mail_host="mail.example.org"
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_mail_disabled_emits_nothing(self):
|
||||
self.assertEqual(
|
||||
self._run(_sso_enabled=False, _email_enabled=False, _mail_host=MAIL_ONION),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_swarm_pins_the_mail_relay_to_the_node_address(self):
|
||||
block = self._run(
|
||||
_sso_enabled=False,
|
||||
_email_enabled=True,
|
||||
_mail_host=MAIL_ONION,
|
||||
DEPLOYMENT_MODE="swarm",
|
||||
ansible_facts={"default_ipv4": {"address": "10.0.0.7"}},
|
||||
)
|
||||
self.assertIn(f' - "{MAIL_ONION}:10.0.0.7"', block)
|
||||
|
||||
def test_the_sso_and_mail_pins_coexist_without_repeating_the_alias(self):
|
||||
block = self._run(_email_enabled=True, _mail_host=MAIL_ONION)
|
||||
self.assertEqual(
|
||||
block,
|
||||
'extra_hosts:\n - "host.docker.internal:host-gateway"\n'
|
||||
f' - "{ONION}:host-gateway"\n'
|
||||
f' - "{MAIL_ONION}:host-gateway"',
|
||||
)
|
||||
|
||||
def test_compose_pins_the_provider_to_the_host_gateway(self):
|
||||
self.assertEqual(
|
||||
self._run(),
|
||||
@@ -131,7 +203,7 @@ class TestContainerExtraHostsLookup(unittest.TestCase):
|
||||
variables={"application_id": "web-app-joomla"},
|
||||
application_id="web-app-penpot",
|
||||
)
|
||||
self.assertEqual(seen, ["web-app-penpot"])
|
||||
self.assertEqual(set(seen), {"web-app-penpot"})
|
||||
|
||||
def test_caller_entries_alone_emit_a_block_without_sso(self):
|
||||
self.assertEqual(
|
||||
|
||||
@@ -160,6 +160,7 @@ class DatabaseLookupTests(unittest.TestCase):
|
||||
self.assertTrue(out["enabled"])
|
||||
self.assertFalse(out["shared"])
|
||||
self.assertTrue(out["local"])
|
||||
self.assertTrue(out["managed"])
|
||||
|
||||
self.assertEqual(out["id"], "svc-db-postgres")
|
||||
|
||||
@@ -196,6 +197,50 @@ class DatabaseLookupTests(unittest.TestCase):
|
||||
"5432",
|
||||
)
|
||||
|
||||
def test_dedicated_with_name_override_is_not_managed(self):
|
||||
applications = {
|
||||
"web-app-foo": {
|
||||
"services": {
|
||||
"postgres": {
|
||||
"enabled": True,
|
||||
"shared": False,
|
||||
"name": "foo-postgres-1",
|
||||
}
|
||||
},
|
||||
"credentials": {"database_password": "pw"},
|
||||
},
|
||||
"svc-db-postgres": {
|
||||
"services": {
|
||||
"postgres": {
|
||||
"name": "postgres-central",
|
||||
"image": "postgis/postgis",
|
||||
"version": "16",
|
||||
"ports": {"local": {"postgres": "5432"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
ports = {"localhost": {"database": {"svc-db-postgres": "5432"}}}
|
||||
vars_ = {
|
||||
"applications": applications,
|
||||
"ports": ports,
|
||||
"DIR_COMPOSITIONS": "/opt/compose/",
|
||||
}
|
||||
|
||||
lookup = self._make_lookup(vars_)
|
||||
|
||||
with patch.object(
|
||||
self.db_lookup_mod,
|
||||
"get_entity_name",
|
||||
side_effect=self._fake_get_entity_name,
|
||||
):
|
||||
out = lookup.run(["web-app-foo"], variables=vars_)[0]
|
||||
|
||||
self.assertTrue(out["enabled"])
|
||||
self.assertFalse(out["shared"])
|
||||
self.assertTrue(out["local"])
|
||||
self.assertFalse(out["managed"])
|
||||
|
||||
def test_postgres_shared_uses_central_name_for_host_instance_container_volume(self):
|
||||
applications = {
|
||||
"web-app-foo": {
|
||||
@@ -231,6 +276,7 @@ class DatabaseLookupTests(unittest.TestCase):
|
||||
self.assertTrue(out["enabled"])
|
||||
self.assertTrue(out["shared"])
|
||||
self.assertFalse(out["local"])
|
||||
self.assertFalse(out["managed"])
|
||||
|
||||
self.assertEqual(out["id"], "svc-db-postgres")
|
||||
|
||||
@@ -329,6 +375,48 @@ class DatabaseLookupTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(out["version"], "15")
|
||||
|
||||
def test_image_override_on_consumer_wins_over_default(self):
|
||||
applications = {
|
||||
"web-app-foo": {
|
||||
"services": {
|
||||
"postgres": {
|
||||
"enabled": True,
|
||||
"shared": False,
|
||||
"image": "postgres",
|
||||
"version": "17",
|
||||
}
|
||||
},
|
||||
"credentials": {"database_password": "pw"},
|
||||
},
|
||||
"svc-db-postgres": {
|
||||
"services": {
|
||||
"postgres": {
|
||||
"name": "postgres-central",
|
||||
"image": "postgis/postgis",
|
||||
"version": "16",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
ports = {"localhost": {"database": {"svc-db-postgres": "5432"}}}
|
||||
vars_ = {
|
||||
"applications": applications,
|
||||
"ports": ports,
|
||||
"DIR_COMPOSITIONS": "/opt/compose/",
|
||||
}
|
||||
|
||||
lookup = self._make_lookup(vars_)
|
||||
|
||||
with patch.object(
|
||||
self.db_lookup_mod,
|
||||
"get_entity_name",
|
||||
side_effect=self._fake_get_entity_name,
|
||||
):
|
||||
out = lookup.run(["web-app-foo"], variables=vars_)[0]
|
||||
|
||||
self.assertEqual(out["image"], "postgres")
|
||||
self.assertEqual(out["version"], "17")
|
||||
|
||||
def test_local_flag_for_variant_disabling_dedicated_db(self):
|
||||
applications = {
|
||||
"web-app-foo": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from unittest.mock import patch
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
@@ -14,7 +15,7 @@ from utils.cache import _reset_cache_for_tests
|
||||
from utils.cache import base as cache_base
|
||||
from utils.cache import users as cache_users
|
||||
from utils.cache.yaml import dump_yaml_str
|
||||
from utils.roles.mapping import ROLE_FILE_META_SERVICES
|
||||
from utils.roles.mapping import ROLE_FILE_META_DOMAINS, ROLE_FILE_META_SERVICES
|
||||
|
||||
|
||||
def _write_role_config(base_dir: Path, role_name: str, payload: dict) -> None:
|
||||
@@ -96,6 +97,7 @@ class TestEmailLookup(unittest.TestCase):
|
||||
554 5.7.1. Plaintext submission is port 587; 25 is the inbound MX and
|
||||
offers no AUTH at all."""
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailu",
|
||||
"groups": {"web-app-mailu": ["host1"]},
|
||||
"SYSTEM_EMAIL_HOST": "mail.abc123.onion",
|
||||
"TLS_ENABLED": True,
|
||||
@@ -109,6 +111,7 @@ class TestEmailLookup(unittest.TestCase):
|
||||
|
||||
def test_clearnet_external_keeps_tls(self) -> None:
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailu",
|
||||
"groups": {"web-app-mailu": ["host1"]},
|
||||
"SYSTEM_EMAIL_HOST": "mail.example.org",
|
||||
"TLS_ENABLED": True,
|
||||
@@ -143,6 +146,7 @@ class TestEmailLookup(unittest.TestCase):
|
||||
for host, tls_enabled, external, expected in cases:
|
||||
with self.subTest(host=host, tls=tls_enabled, external=external):
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailu",
|
||||
"groups": {"web-app-mailu": ["host1"]} if external else {},
|
||||
"SYSTEM_EMAIL_HOST": host,
|
||||
"TLS_ENABLED": tls_enabled,
|
||||
@@ -168,6 +172,7 @@ class TestEmailLookup(unittest.TestCase):
|
||||
for host, tls_enabled, external, expected in cases:
|
||||
with self.subTest(host=host, external=external):
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailu",
|
||||
"groups": {"web-app-mailu": ["host1"]} if external else {},
|
||||
"SYSTEM_EMAIL_HOST": host,
|
||||
"TLS_ENABLED": tls_enabled,
|
||||
@@ -240,6 +245,184 @@ class TestEmailLookup(unittest.TestCase):
|
||||
with self.assertRaises(AnsibleError):
|
||||
self.lookup.run(["a", "b"], variables={})
|
||||
|
||||
def test_an_onion_relay_never_announces_starttls(self) -> None:
|
||||
# No CA issues a certificate for a .onion name, so a client that
|
||||
# verifies the peer can never complete the handshake -- Ruby's
|
||||
# Net::SMTP drops the mail after three silent retries. Tor already
|
||||
# carries the confidentiality, which is why `tls` is False here too.
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{"sso": {"oidc": {"submission_via_relay": True}}},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov", "web-app-keycloak"],
|
||||
"groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]},
|
||||
"TLS_ENABLED": True,
|
||||
"SYSTEM_EMAIL_HOST": "mail." + "a" * 56 + ".onion",
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertFalse(result["start_tls"])
|
||||
self.assertFalse(result["tls"])
|
||||
|
||||
def test_sso_relay_provider_disables_auth_and_uses_port_25(self) -> None:
|
||||
# submission_via_relay + Keycloak deployed -> relay on 25, no auth, STARTTLS.
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{"sso": {"oidc": {"submission_via_relay": True}}},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov", "web-app-keycloak"],
|
||||
"groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]},
|
||||
"TLS_ENABLED": True,
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertEqual(result["port"], 25)
|
||||
self.assertFalse(result["auth"])
|
||||
self.assertTrue(result["start_tls"])
|
||||
|
||||
def test_sso_relay_inactive_without_keycloak(self) -> None:
|
||||
# Same provider, but Keycloak is not deployed: keep authenticated 465.
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{"sso": {"oidc": {"submission_via_relay": True}}},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov"],
|
||||
"groups": {"web-app-mailprov": ["host1"]},
|
||||
"TLS_ENABLED": True,
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertEqual(result["port"], 465)
|
||||
self.assertTrue(result["auth"])
|
||||
self.assertFalse(result["start_tls"])
|
||||
|
||||
def test_sso_relay_inactive_when_sso_enabled_pinned_false(self) -> None:
|
||||
# A variant pins the provider's sso.enabled to a literal false while
|
||||
# submission_via_relay stays true (role default) and Keycloak is still
|
||||
# deployed: the provider keeps password auth, so no relay mode.
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{"sso": {"enabled": False, "oidc": {"submission_via_relay": True}}},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov", "web-app-keycloak"],
|
||||
"groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]},
|
||||
"TLS_ENABLED": True,
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertEqual(result["port"], 465)
|
||||
self.assertTrue(result["auth"])
|
||||
self.assertFalse(result["start_tls"])
|
||||
|
||||
def test_a_provider_without_plaintext_submission_falls_back_to_its_mx(
|
||||
self,
|
||||
) -> None:
|
||||
"""Stalwart's default config binds the implicit-TLS client ports and the
|
||||
MX, never 587, so the onion path (which drops TLS) has no submission
|
||||
listener to reach. The port follows what the provider declares, and AUTH
|
||||
drops with it: an MX offers none, and announcing it aborts msmtp with
|
||||
EX_UNAVAILABLE before a message is ever sent."""
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{"mailprov": {"ports": {"public": {"smtp": 25, "smtps": 465}}}},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov"],
|
||||
"groups": {"web-app-mailprov": ["host1"]},
|
||||
"SYSTEM_EMAIL_HOST": "mail.x.onion",
|
||||
"TLS_ENABLED": True,
|
||||
"DOMAIN_PRIMARY": "x.onion",
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertFalse(result["tls"])
|
||||
self.assertEqual(result["port"], 25)
|
||||
self.assertFalse(result["auth"])
|
||||
self.assertEqual(result["auth_mechanism"], "off")
|
||||
|
||||
def test_a_provider_declaring_submission_keeps_the_authenticated_path(
|
||||
self,
|
||||
) -> None:
|
||||
"""Mailu binds 587, so the same onion target stays on plaintext
|
||||
submission with AUTH PLAIN. The MX fallback above must not reach a
|
||||
provider that declares a submission listener."""
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{
|
||||
"mailprov": {
|
||||
"ports": {"public": {"smtp": 25, "smtps": 465, "submission": 587}}
|
||||
}
|
||||
},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov"],
|
||||
"groups": {"web-app-mailprov": ["host1"]},
|
||||
"SYSTEM_EMAIL_HOST": "mail.x.onion",
|
||||
"TLS_ENABLED": True,
|
||||
"DOMAIN_PRIMARY": "x.onion",
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertFalse(result["tls"])
|
||||
self.assertEqual(result["port"], 587)
|
||||
self.assertTrue(result["auth"])
|
||||
self.assertEqual(result["auth_mechanism"], "plain")
|
||||
|
||||
def test_sso_relay_active_with_untemplated_enabled_gate(self) -> None:
|
||||
# The role default gates sso.enabled on group_names (raw Jinja here);
|
||||
# the guard must not treat the untemplated string as false.
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{
|
||||
"sso": {
|
||||
"enabled": "{{ 'web-app-keycloak' in group_names }}",
|
||||
"oidc": {"submission_via_relay": True},
|
||||
}
|
||||
},
|
||||
)
|
||||
variables = {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["web-app-mailprov", "web-app-keycloak"],
|
||||
"groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]},
|
||||
"TLS_ENABLED": True,
|
||||
"inventory_hostname": "host1",
|
||||
}
|
||||
result = self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
self.assertEqual(result["port"], 25)
|
||||
self.assertFalse(result["auth"])
|
||||
self.assertTrue(result["start_tls"])
|
||||
|
||||
def test_computed_defaults_are_templated(self) -> None:
|
||||
self.lookup._templar = _DummyTemplar(
|
||||
{"DOMAIN_PRIMARY_RESOLVED": "mail.example.org"}
|
||||
@@ -252,6 +435,154 @@ class TestEmailLookup(unittest.TestCase):
|
||||
self.assertEqual(result["domain"], "mail.example.org")
|
||||
|
||||
|
||||
class TestProviderOnionIsClusterWide(unittest.TestCase):
|
||||
"""In swarm only the manager is in svc-net-tor, so a worker renders the
|
||||
provider's clearnet name while the provider (onion-primary on the
|
||||
manager) never deployed a certificate for it. TLS must follow the
|
||||
provider host's view, not the worker's."""
|
||||
|
||||
ONION = "a" * 56 + ".onion"
|
||||
TOR_ON = "{{ 'svc-net-tor' in group_names }}"
|
||||
STALWART_PORTS: ClassVar[dict[str, int]] = {"smtp": 25, "smtps": 465}
|
||||
MAILU_PORTS: ClassVar[dict[str, int]] = {
|
||||
"smtp": 25,
|
||||
"smtps": 465,
|
||||
"submission": 587,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
_reset_cache_for_tests()
|
||||
cls.addClassCleanup(_reset_cache_for_tests)
|
||||
|
||||
def setUp(self) -> None:
|
||||
from ansible.parsing.dataloader import DataLoader
|
||||
from ansible.template import Templar
|
||||
|
||||
cache_base._reset()
|
||||
_reset_cache_for_tests()
|
||||
self.lookup = LookupModule()
|
||||
self.lookup._templar = Templar(loader=DataLoader(), variables={})
|
||||
self._cwd = str(Path.cwd())
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self._tmp = Path(self._tmpdir.name)
|
||||
(self._tmp / "roles").mkdir(parents=True, exist_ok=True)
|
||||
os.chdir(self._tmp)
|
||||
self._tokens_store_patcher = patch.object(
|
||||
cache_users, "_load_store_users", return_value={}
|
||||
)
|
||||
self._tokens_store_patcher.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tokens_store_patcher.stop()
|
||||
os.chdir(self._cwd)
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def _seed(self, *, ports, node=None, provider_tor=None) -> None:
|
||||
"""Write the tor provider and the mail provider roles.
|
||||
|
||||
Args:
|
||||
ports: the mail provider's published SMTP ports.
|
||||
node: svc-net-tor's node onion; defaults to ``ONION``.
|
||||
provider_tor: the mail provider's ``tor`` block; defaults to
|
||||
enabled on svc-net-tor hosts, inheriting exclusive/primary.
|
||||
"""
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"svc-net-tor",
|
||||
{
|
||||
"tor": {
|
||||
"enabled": True,
|
||||
"shared": True,
|
||||
"exclusive": True,
|
||||
"primary": True,
|
||||
"node": self.ONION if node is None else node,
|
||||
}
|
||||
},
|
||||
)
|
||||
_write_role_config(
|
||||
self._tmp,
|
||||
"web-app-mailprov",
|
||||
{
|
||||
"mailprov": {"ports": {"public": ports}},
|
||||
"tor": provider_tor or {"enabled": self.TOR_ON},
|
||||
},
|
||||
)
|
||||
domains = self._tmp / "roles" / "web-app-mailprov" / ROLE_FILE_META_DOMAINS
|
||||
domains.write_text(
|
||||
dump_yaml_str({"canonical": {"mail": "mail.x.test"}, "aliases": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _worker(self, *, tor_hosts=("mgr",)) -> dict:
|
||||
return {
|
||||
"MAIL_PROVIDER": "web-app-mailprov",
|
||||
"group_names": ["svc-swarm-node"],
|
||||
"groups": {
|
||||
"web-app-mailprov": ["mgr"],
|
||||
"svc-net-tor": list(tor_hosts),
|
||||
},
|
||||
"hostvars": {
|
||||
"mgr": {"group_names": ["web-app-mailprov", "svc-net-tor"]},
|
||||
"other": {"group_names": ["svc-net-tor"]},
|
||||
},
|
||||
"SYSTEM_EMAIL_HOST": "mail.x.test",
|
||||
"TLS_ENABLED": True,
|
||||
"DOMAIN_PRIMARY": "x.test",
|
||||
"inventory_hostname": "wrk",
|
||||
}
|
||||
|
||||
def _run(self, variables: dict) -> dict:
|
||||
return self.lookup.run(
|
||||
[], variables=variables, roles_dir=str(self._tmp / "roles")
|
||||
)[0]
|
||||
|
||||
def test_worker_of_an_onion_stalwart_drops_tls_and_auth(self) -> None:
|
||||
self._seed(ports=self.STALWART_PORTS)
|
||||
|
||||
result = self._run(self._worker())
|
||||
|
||||
self.assertFalse(result["tls"])
|
||||
self.assertFalse(result["start_tls"])
|
||||
self.assertFalse(result["auth"])
|
||||
self.assertEqual(result["port"], 25)
|
||||
|
||||
def test_worker_of_an_onion_mailu_keeps_plain_submission(self) -> None:
|
||||
self._seed(ports=self.MAILU_PORTS)
|
||||
|
||||
result = self._run(self._worker())
|
||||
|
||||
self.assertFalse(result["tls"])
|
||||
self.assertEqual(result["port"], 587)
|
||||
self.assertTrue(result["auth"])
|
||||
self.assertEqual(result["auth_mechanism"], "plain")
|
||||
|
||||
def test_tor_on_another_host_keeps_tls(self) -> None:
|
||||
self._seed(ports=self.STALWART_PORTS)
|
||||
|
||||
result = self._run(self._worker(tor_hosts=("other",)))
|
||||
|
||||
self.assertTrue(result["tls"])
|
||||
self.assertEqual(result["port"], 465)
|
||||
|
||||
def test_dual_stack_provider_keeps_tls(self) -> None:
|
||||
self._seed(
|
||||
ports=self.STALWART_PORTS,
|
||||
provider_tor={"enabled": self.TOR_ON, "exclusive": False, "primary": False},
|
||||
)
|
||||
|
||||
result = self._run(self._worker())
|
||||
|
||||
self.assertTrue(result["tls"])
|
||||
|
||||
def test_empty_tor_node_keeps_tls(self) -> None:
|
||||
self._seed(ports=self.STALWART_PORTS, node="")
|
||||
|
||||
result = self._run(self._worker())
|
||||
|
||||
self.assertTrue(result["tls"])
|
||||
|
||||
|
||||
class _DummyTemplar:
|
||||
def __init__(self, available_variables: dict[str, str]) -> None:
|
||||
self.available_variables = available_variables
|
||||
|
||||
@@ -37,6 +37,21 @@ class _DummyTemplar:
|
||||
return value
|
||||
|
||||
|
||||
class _RenderingTemplar:
|
||||
"""A templar that actually resolves expressions, like the real one.
|
||||
|
||||
``_DummyTemplar`` returns its input unchanged, so it cannot expose a value
|
||||
that only misbehaves while still an unrendered template.
|
||||
"""
|
||||
|
||||
def __init__(self, available_variables=None, rendered=None):
|
||||
self.available_variables = available_variables or {}
|
||||
self._rendered = rendered or {}
|
||||
|
||||
def template(self, value):
|
||||
return self._rendered.get(value, value)
|
||||
|
||||
|
||||
def _email(external=True, host=RELAY):
|
||||
return {"external": external, "host": host}
|
||||
|
||||
@@ -67,6 +82,18 @@ class TestSmtpHostLookup(unittest.TestCase):
|
||||
vars_ = {"DOCKER_IN_CONTAINER": True, "DEPLOYMENT_MODE": "compose"}
|
||||
self.assertEqual(self._resolve(vars_), "127.0.0.1")
|
||||
|
||||
def test_an_unrendered_group_var_still_reaches_the_rig_loopback(self):
|
||||
"""DOCKER_IN_CONTAINER is a group_vars expression, and a lookup receives it
|
||||
raw. Read unrendered it is neither true nor false, so the rig kept the
|
||||
configured relay instead of loopback — and on an onion node that relay is a
|
||||
.onion, which msmtp cannot route (EX_UNAVAILABLE, 'proxy failure')."""
|
||||
raw = "{{ (ansible_facts['env']['container'] | default('')) != '' }}"
|
||||
vars_ = {"DOCKER_IN_CONTAINER": raw, "DEPLOYMENT_MODE": "compose"}
|
||||
lm = self.LookupModule()
|
||||
lm._templar = _RenderingTemplar(vars_, {raw: True})
|
||||
lm._loader = None
|
||||
self.assertEqual(lm.run([_email()], variables=vars_)[0], "127.0.0.1")
|
||||
|
||||
def test_swarm_node_uses_loopback(self):
|
||||
vars_ = {
|
||||
"DOCKER_IN_CONTAINER": True,
|
||||
|
||||
@@ -224,6 +224,55 @@ class TestTorPortsLookup(unittest.TestCase):
|
||||
|
||||
self.assertEqual(collect_onion_ports(apps, ["web-app-nope"]), [])
|
||||
|
||||
def _mail_apps(self) -> dict:
|
||||
"""Stalwart provides email; Mailu only covers it, as the migration round
|
||||
deploys both."""
|
||||
return self._apps(
|
||||
**{
|
||||
"web-app-stalwart": {
|
||||
"stalwart": {
|
||||
"provides": "email",
|
||||
"ports": {
|
||||
"public": {"smtp": 25, "imaps": 993},
|
||||
"onion": {"smtp": True},
|
||||
},
|
||||
}
|
||||
},
|
||||
"web-app-mailu": {
|
||||
"mailu": {
|
||||
"covers": ["email"],
|
||||
"ports": {
|
||||
"public": {"smtp": 25, "submission": 587, "imap": 143},
|
||||
"onion": {"smtp": True, "submission": True, "imap": True},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def test_a_coverer_keeps_its_ports_when_it_is_the_only_provider(self) -> None:
|
||||
ports = collect_onion_ports(self._mail_apps(), ["web-app-mailu"])
|
||||
|
||||
self.assertEqual(ports, [25, 143, 587])
|
||||
|
||||
def test_a_coverer_is_skipped_when_another_deployed_role_provides(self) -> None:
|
||||
"""The provider owns the host mail ports, so the co-tenant's forwards
|
||||
would advertise endpoints nothing answers on."""
|
||||
ports = collect_onion_ports(
|
||||
self._mail_apps(), ["web-app-stalwart", "web-app-mailu"]
|
||||
)
|
||||
|
||||
self.assertEqual(ports, [25])
|
||||
|
||||
def test_the_provider_keeps_its_own_ports_beside_a_coverer(self) -> None:
|
||||
both = collect_onion_ports(
|
||||
self._mail_apps(), ["web-app-stalwart", "web-app-mailu"]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
both, collect_onion_ports(self._mail_apps(), ["web-app-stalwart"])
|
||||
)
|
||||
|
||||
def test_collect_onion_ports_ignores_non_mappings(self) -> None:
|
||||
self.assertEqual(collect_onion_ports({}, ["a"]), [])
|
||||
self.assertEqual(collect_onion_ports(None, ["a"]), [])
|
||||
|
||||
Reference in new issue
Block a user