mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
Six conflicts, all from git pairing store-i18n's renamed roles with their designer originals. web-app-meta/meta/services.yml and web-app-sphinx/vars/main.yml: store-i18n renamed web-app-sphinx to web-app-meta and dropped its custom build, so the role keeps the prebuilt ghcr.io/infinito-nexus/meta image and the old sphinx role stays deleted. svc-ai-tika/templates/compose.yml.j2: a bogus rename pairing dropped sphinx build lines into the tika service. Rejected; the template is byte-identical to its pre-merge state again. web-app-docs/templates/Dockerfile.j2: the designer file renders sphinx under the design title and reads SPHINX_IMAGE, SPHINX_OUTPUT_DIR and SPHINX_EXEC_DIR, none of which exist in web-app-docs, which builds from files/Dockerfile. Rejected. web-app-docs/templates/playwright.env.j2 and files/playwright/test-design.js: both sides add independently, so both are kept. Of the designer's three new env reads only designTitle survives; iconUrl and faviconUrl are shadowed inside the test by stricter requireDotenvValue reads and were dead at module scope. plugins/lookup/email.py merges clean toward designer, which is intended rather than a loss:08ef66d8de(2026-10-08) replaces the submission_via_relay port-25 rule of233f15f3e0(2026-07-01) with per-bot app passwords under SSO, and carries the matching 04_manage_user/app_password tasks. Checked after resolving: no duplicate YAML keys across the 17 merged YAML files, and no nocheck line dropped above a renamed key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
# nocheck: comments-valid explanatory WHY-comments in this SPOT lookup predate the stricter lint
|
|
from __future__ import annotations
|
|
|
|
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.domains.primary_domain import get_domain
|
|
from utils.mail.provider import deployed_roles, resolve_active_provider
|
|
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
|
|
|
|
|
|
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 mail_host.endswith(".onion") 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`` 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_groups = variables["hostvars"][provider_hosts[0]]["group_names"]
|
|
except (KeyError, TypeError):
|
|
provider_groups = fallback
|
|
provider_view = dict(variables)
|
|
provider_view["group_names"] = list(provider_groups or fallback)
|
|
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 str(domain or "").lower().endswith(".onion")
|
|
|
|
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
|