mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-13 06:06:38 +00:00
Brings feature/updater3966abdb16, 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>
406 lines
16 KiB
Python
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
|