Files
computer-playbook/plugins/lookup/email.py
T
kevinveenbirkenbachandClaude Opus 5.5 ff288a3dd4 Merge branch 'feature/updater' into feature/store-i18n
Brings feature/updater 3966abdb16, including updater-lockdown-probe: the deferred probe host guard (aa88429c25), the per-host post-load queue (70ed8a2e98), the NFS probe noauto (0ece4fcbc4), the E2E prepull gate (ef20262813), the worktree swarm lab subnet fixes, the Tor port window and restart skip, the Prometheus Docker poller, the Stalwart JMAP client cache and the FusionDirectory PHP hardening mount.

Conflict resolutions:
- plugins/lookup/email.py: keep store-i18n's NETWORK_MODE forwarding into the provider view and add updater's provider carrier.
- roles/svc-net-tor/templates/test.env.j2: updater's ONION_PORT_TIMEOUT=130, store-i18n's ONION_SUFFIX.
- roles/svc-swarm-node/README.md: updater's ingress guard wording, without the generated Quick Setup and Credits sections.
- roles/test-e2e-cli/tasks/main.yml: updater's cli_role_id loop variable with store-i18n's async cli.once condition.
- roles/web-app-prometheus/templates/nginx/locations.conf.j2: store-i18n's entity_name filter with updater's container_service lookup.
- tasks/stages/01_constructor.yml: updater's order (svc-swarm-node after the preload context opens) under store-i18n's task name, whose loop also covers svc-cache.

Integration fixes:
- roles/sys-stk-front-proxy: git applied updater's IS_STACK_HOST guard to the "Freeze front proxy context" task, which would have left workers without front_proxy_* facts. main.yml stays as on store-i18n; the guard moves into the per-sibling vhost.yml split, where the probe facts and the queue sit behind IS_STACK_HOST and front_proxy_domain_conf_dst_resolved stays unguarded for the Deploy arguments. deferred_probe.yml takes updater's host guard verbatim.
- roles/svc-net-tor/tasks/00_core.yml: the restart probe tests IS_SWARM_MODE instead of a literal DEPLOYMENT_MODE comparison.
- roles/web-app-stalwart/tasks/jmap_call.yml: the cached client splits into stalwart_jmap_url and stalwart_jmap_user_password, so the exec argv references no fact that carries a credential.
- roles/sys-service-loader/tasks/list_or_shoot.yml: the two direct-shoot tasks share their preload condition in one block.
- tests: the Prometheus wiring test imports entity_name and pins the host resources its renders need; the email carrier tests use an onion-only provider, which on store-i18n is what drops TLS (a dual-stack provider keeps it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:54:41 +01:00

406 lines
16 KiB
Python

# nocheck: comments-valid explanatory WHY-comments in this SPOT lookup predate the stricter lint
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from plugins.lookup.applications import LookupModule as ApplicationsLookup
from plugins.lookup.domain import LookupModule as DomainLookup
from plugins.lookup.users import LookupModule as UsersLookup
from utils.cache.carrier import APPLICATIONS_RENDERED_FACT
from utils.domains.primary_domain import get_domain
from utils.mail.provider import deployed_roles, resolve_active_provider
from utils.networks.reachability import network, network_of
from utils.roles.entity.name import entity_name
SYSTEM_EMAIL_PREFIX = "SYSTEM_EMAIL_"
# Default mail provider role; overridable per inventory via the MAIL_PROVIDER var.
DEFAULT_MAIL_PROVIDER = "web-app-stalwart"
# Declared in resolution order so each computed default sees already-resolved
# predecessors (for example ``port`` depends on ``tls``).
RESOLUTION_ORDER = (
"enabled",
"timeout",
"external",
"environment",
"domain",
"host",
"tls",
"port",
"auth",
"auth_mechanism",
"start_tls",
"smtp",
"from",
"username",
"password",
)
_TRUE_STRINGS = frozenset({"true", "yes", "1", "on"})
_FALSE_STRINGS = frozenset({"false", "no", "0", "off"})
def _short_to_var(short_key: str) -> str:
return SYSTEM_EMAIL_PREFIX + short_key.upper()
def _as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in _TRUE_STRINGS:
return True
if lowered in _FALSE_STRINGS:
return False
return bool(value)
def _render(
value: Any,
templar: Any | None,
variables: dict[str, Any] | None = None,
) -> Any:
if templar is None:
return value
if not isinstance(value, str) or "{{" not in value:
return value
# Loop: inventory expressions like DOMAIN_PRIMARY="{{ lookup('env','INFINITO_DOMAIN') }}" need multiple substitution passes.
from utils.templating.ansible import _templar_render_best_effort
base_vars: dict[str, Any] = dict(variables or {})
for _ in range(4):
if not isinstance(value, str) or "{{" not in value:
break
prev = value
try:
value = _templar_render_best_effort(templar, value, base_vars)
except Exception:
break
if value == prev:
break
return value
def _provider_carrier(variables: dict[str, Any], host: str) -> Mapping[str, Any] | None:
hostvars = variables.get("hostvars")
raw_get = getattr(hostvars, "raw_get", None)
try:
host_vars = raw_get(host) if callable(raw_get) else hostvars[host]
except (KeyError, TypeError):
return None
if not isinstance(host_vars, Mapping):
return None
carrier = host_vars.get(APPLICATIONS_RENDERED_FACT)
return carrier if isinstance(carrier, Mapping) else None
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Any]:
terms = terms or []
if len(terms) > 1:
raise AnsibleError(
"email: expected 0 or 1 terms: lookup('email'[, application_id])"
)
variables = variables or getattr(self._templar, "available_variables", {}) or {}
self._kwargs = kwargs
templar = getattr(self, "_templar", None)
resolved: dict[str, Any] = {}
for short_key in RESOLUTION_ORDER:
var_name = _short_to_var(short_key)
raw = variables.get(var_name)
if raw is None or raw == "":
resolved[short_key] = _render(
self._compute(short_key, resolved, variables),
templar,
variables,
)
else:
resolved[short_key] = _render(raw, templar, variables)
if len(terms) == 0:
return [resolved]
application_id = str(terms[0]).strip()
overrides = self._app_email_overrides(application_id, variables)
merged = dict(resolved)
for key, value in overrides.items():
merged[str(key).lower()] = _render(value, templar, variables)
return [merged]
def _compute(
self,
short_key: str,
resolved: dict[str, Any],
variables: dict[str, Any],
) -> Any:
if short_key == "enabled":
return True
if short_key == "timeout":
return "30"
if short_key == "external":
# Cluster-wide, not per-host: in swarm the mail provider is
# manager-pinned so worker group_names lack it, yet every node
# relays through the central provider (routing mesh). Gate on the
# group having a host so workers use the authenticated config, not
# a localhost root sender the provider rejects
# ("Sender address rejected: Domain not found").
groups = variables.get("groups") or {}
return bool(groups.get(self._mail_provider(variables)))
if short_key == "environment":
external = _as_bool(resolved.get("external"))
tls_enabled = _as_bool(variables.get("TLS_ENABLED"))
docker_in_container = _as_bool(variables.get("DOCKER_IN_CONTAINER"))
base = "external" if (external or tls_enabled) else "localhost"
suffix = "_container" if (docker_in_container and not tls_enabled) else ""
return base + suffix
if short_key == "domain":
return variables.get("DOMAIN_PRIMARY")
if short_key == "tls":
external = _as_bool(resolved.get("external"))
if not external:
return False
mail_host = str(resolved.get("host") or "").lower()
if not network(network_of(mail_host)).tls or self._provider_onion_primary(
variables
):
return False
return _as_bool(variables.get("TLS_ENABLED"))
if short_key == "port":
external = _as_bool(resolved.get("external"))
if not external:
return 25
ports = self._provider_ports(variables)
if _as_bool(resolved.get("tls")):
return ports.get("smtps", 465)
# Plaintext submission is the onion/no-TLS path, but a provider is
# only reachable there if it binds one. Stalwart's default config
# binds the implicit-TLS client ports plus the MX, so it advertises
# no `submission`; fall back to the MX port rather than a listener
# nothing answers on (`auth` drops with it -- an MX takes local
# recipients without SMTP AUTH).
return ports.get("submission") or ports.get("smtp", 25)
if short_key == "host":
env = resolved.get("environment")
# `environment` folds TLS_ENABLED into its "external" base, so a
# non-external relay under TLS would otherwise resolve to the mailu
# domain while auth/tls/from stay in localhost mode -- an unreachable,
# inconsistent render that aborts the msmtp health unit. Gate host on
# `external` directly, like tls/auth/from do.
if not _as_bool(resolved.get("external")) or env in (
"external_container",
"localhost",
"localhost_container",
):
return "localhost"
return self._lookup_mail_provider_domain(variables)
if short_key == "auth":
env = resolved.get("environment")
if env in ("external_container", "localhost"):
return False
# The MX listener offers no SMTP AUTH: when `port` fell back to it
# because the provider binds no plaintext submission, announcing
# AUTH aborts msmtp with EX_UNAVAILABLE before it ever sends.
ports = self._provider_ports(variables)
if not ports.get("submission") and resolved.get("port") == ports.get(
"smtp", 25
):
return False
return _as_bool(resolved.get("external"))
if short_key == "auth_mechanism":
if not _as_bool(resolved.get("auth")):
return "off"
return "on" if _as_bool(resolved.get("tls")) else "plain"
if short_key == "start_tls":
return False
if short_key == "smtp":
return True
if short_key == "from":
external = _as_bool(resolved.get("external"))
if external:
no_reply = self._lookup_no_reply_user(variables)
if isinstance(no_reply, dict):
email = no_reply.get("email")
if email:
return email
inventory_hostname = variables.get("inventory_hostname") or "localhost"
return f"root@{inventory_hostname}.localdomain"
if short_key == "username":
return resolved.get("from")
if short_key == "password":
no_reply = self._lookup_no_reply_user(variables)
if isinstance(no_reply, dict):
tokens = no_reply.get("tokens") or {}
if isinstance(tokens, dict):
return tokens.get(self._mail_provider(variables), "") or ""
return ""
raise AnsibleError(f"email: unknown key {short_key!r}")
def _mail_provider(self, variables: dict[str, Any]) -> str:
value = variables.get("MAIL_PROVIDER")
configured = str(value).strip() if value else DEFAULT_MAIL_PROVIDER
return resolve_active_provider(
configured,
deployed_roles(variables.get("groups")),
Path.cwd() / "roles",
)
def _provider_services(self, variables: dict[str, Any]) -> dict[str, Any]:
"""The active provider's ``services`` block, or ``{}`` when it cannot be
resolved. The port probe reads the provider's own self-declaration."""
apps = ApplicationsLookup()
apps._templar = getattr(self, "_templar", None)
forwarded = {
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
}
try:
entry = apps.run(
[self._mail_provider(variables), {}],
variables=variables,
**forwarded,
)[0]
except AnsibleError:
return {}
if not isinstance(entry, dict):
return {}
services = entry.get("services") or {}
return services if isinstance(services, dict) else {}
def _provider_ports(self, variables: dict[str, Any]) -> dict[str, int]:
"""The SMTP ports the active provider publishes, keyed by protocol.
Read from the provider's own ``services.<entity>.ports.public`` rather
than assumed, so a provider that binds no plaintext ``submission``
(Stalwart) resolves differently from one that does (Mailu) without
either being named here.
"""
services = self._provider_services(variables)
entity = entity_name(self._mail_provider(variables))
entry = services.get(entity) or {}
if not isinstance(entry, dict):
return {}
ports = (entry.get("ports") or {}).get("public") or {}
if not isinstance(ports, dict):
return {}
out: dict[str, int] = {}
for name, value in ports.items():
try:
out[str(name)] = int(value)
except (TypeError, ValueError):
continue
return out
def _provider_onion_primary(self, variables: dict[str, Any]) -> bool:
"""True when the provider's own host serves it onion-first.
Resolved from the provider host's ``group_names`` and ``NETWORK_MODE``
rather than this host's: in swarm only the manager is in
``svc-net-tor``, and the provider's ``tor.enabled`` renders against
``group_names``, so a worker would otherwise see the clearnet name the
provider never serves a certificate for.
"""
groups = variables.get("groups") or {}
provider = self._mail_provider(variables)
tor_hosts = set(groups.get("svc-net-tor") or [])
provider_hosts = [h for h in groups.get(provider) or [] if h in tor_hosts]
if not provider_hosts:
return False
own_groups = list(variables.get("group_names") or [])
fallback = own_groups + [
g for g in ("svc-net-tor", provider) if g not in own_groups
]
try:
provider_vars = variables["hostvars"][provider_hosts[0]]
provider_groups = provider_vars["group_names"]
except (KeyError, TypeError):
provider_vars = {}
provider_groups = fallback
provider_view = dict(variables)
provider_view["group_names"] = list(provider_groups or fallback)
if provider_vars.get("NETWORK_MODE"):
provider_view["NETWORK_MODE"] = provider_vars["NETWORK_MODE"]
carrier = _provider_carrier(variables, provider_hosts[0])
if carrier is not None:
provider_view[APPLICATIONS_RENDERED_FACT] = carrier
forwarded = {
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
}
try:
domains = lookup_loader.get(
"domains",
loader=getattr(self, "_loader", None),
templar=getattr(self, "_templar", None),
).run([], variables=provider_view, **forwarded)[0]
domain = get_domain(domains, provider)
except Exception:
return False
return not network(network_of(domain)).tls
def _lookup_mail_provider_domain(self, variables: dict[str, Any]) -> Any:
domain_lookup = DomainLookup()
domain_lookup._templar = getattr(self, "_templar", None)
try:
return domain_lookup.run(
[self._mail_provider(variables)], variables=variables
)[0]
except Exception:
return "localhost"
def _lookup_no_reply_user(self, variables: dict[str, Any]) -> dict[str, Any]:
users_lookup = UsersLookup()
users_lookup._templar = getattr(self, "_templar", None)
forwarded = {
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
}
try:
entry = users_lookup.run(
["no-reply", {}], variables=variables, **forwarded
)[0]
except AnsibleError:
return {}
return entry if isinstance(entry, dict) else {}
def _app_email_overrides(
self,
application_id: str,
variables: dict[str, Any],
) -> dict[str, Any]:
apps = ApplicationsLookup()
apps._templar = getattr(self, "_templar", None)
forwarded = {
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
}
try:
entry = apps.run(
[application_id, {}],
variables=variables,
**forwarded,
)[0]
except AnsibleError:
return {}
if not isinstance(entry, dict):
return {}
# Per the materialised payload exposes services under the
# bare ``services`` key (no ``compose.services`` envelope).
services = entry.get("services") or {}
email = services.get("email") or {} if isinstance(services, dict) else {}
if not isinstance(email, dict):
return {}
return email