fix(mail): stop the container copy of msmtprc inheriting the host's loopback

One template serves two consumers with opposite needs. sys-svc-mail-msmtp
renders it to /root/.msmtprc, which the host itself reads, and web-app-wordpress
and web-app-moodle render the same file into their config directory, from where
it is mounted at /etc/msmtprc inside a container. resolve_smtp_host returned the
loopback for both, which is right for the first and meaningless for the second:
inside a container 127.0.0.1 is that container's own loopback and nothing is
listening on it.

WordPress 0 of run 31658270172 died on it. The deploy converged and then
"⏳ Wait for the delivery marker the healthcheck writes" retried sixteen times
before `assertion: wordpress_smoke_mail.rc == 0` evaluated to false. The reason
is in the rescue bundle rather than the log, identically in all three replicas
(containers/wordpress_wordpress.{1,2,3}.*.inspect.json):

    msmtp: cannot connect to 127.0.0.1, port 587: Connection refused
    msmtp: could not send mail (account default from /etc/msmtprc)

Those containers report Status: healthy with FailingStreak: 0, which is by
design since ebdadd91b decoupled the smoke mail from liveness -- the probe ends
on curl and the delivery assertion carries detection. The assertion was right;
the address it complained about was ours.

smtp_host gains in_container=, and the template passes it from a variable each
of the three call sites sets explicitly. There is deliberately no default: a
fourth consumer must fail loudly rather than silently inherit a loopback that
does not apply to it, which is the very defect being fixed. grep across roles,
utils and plugins finds exactly those three consumers today.

The alias needs pinning to resolve, and container_extra_hosts is the only
emitter of extra_hosts. It gained host_alias=, which appends
host.docker.internal at whatever reaches the host in the effective mode by
reusing _gateway_address -- host-gateway under compose, the stack host's routable
address under swarm. Hardcoding host-gateway at the call site would repeat the
mistake 7b1839681 already paid for: that alias resolves per node and misses the
one that publishes.

Measured, not assumed:

  - all four resolver branches: host 127.0.0.1, container host.docker.internal,
    bare infra node the manager, outside the rig the configured relay unchanged
  - both branches again through a real ansible lookup call, because run() at
    first did not forward the kwarg and Python-level testing would have missed
    a silently ignored parameter
  - the pin emits `host.docker.internal:host-gateway` under compose and
    `host.docker.internal:<node address>` under swarm
  - the destination exists under swarm: sockets-tcp.txt of all three nodes in
    the same rescue bundle shows `LISTEN 0 4096 *:587 users:(("dockerd",…))`, so
    the submission port is published on every interface of the node -- which is
    why the container's own loopback missed it and the host alias will not
  - and under compose by construction: mailu publishes submission on
    MAILU_IP4_PUBLIC (templates/compose.yml.j2:12), which is
    DOCKER_PUBLIC_BIND_HOST, which is 0.0.0.0 inside the rig
    (group_vars/all/00_general.yml:46)
  - 11 existing smtp_host unit tests stay green; make test-lint stays at 328

The moodle half fixes nothing that is currently red, and that is the point:
moodle renders the same broken file into two services but its healthcheck only
opens a TCP connection to the FPM port, so Moodle 0 of run 31658241728 passed
with mail that could never leave the container. It breaks when Moodle actually
sends -- a password reset, a forum subscription -- and no job would say so.

Not verified: that the relay accepts the message once reached. The port is
proven reachable, delivery is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-14 02:02:30 +02:00
1 parent f1dff50fb6
commit d0516716c5
8 files changed
+44 -10

No files matched your search

+14 -6
View File
@@ -13,9 +13,13 @@ through here, from two sources that are both optional:
those calls over the local network to the proxy that already serves the
vhost. Skipped unless the application has SSO enabled and the provider vhost
is an ``.onion`` name, so it is a no-op on clearnet.
2. ``extra_hosts=``, whatever the call site needs on top: a peer's overlay VIP,
a partner service reachable only on the host, the ``host.docker.internal``
alias.
2. ``host_alias=true``, which pins ``host.docker.internal`` at whatever reaches
the host in the effective mode. A container that must talk to a service the
host publishes -- msmtp relaying through the swarm ingress on :587, for
instance -- needs the alias, and it must not hardcode ``host-gateway``
because that breaks under swarm for the reason below.
3. ``extra_hosts=``, whatever the call site needs on top: a peer's overlay VIP,
or a partner service reachable only on the host.
Entries are ``"name:address"`` strings and are deduplicated in first-seen
order. With nothing to emit the lookup returns an empty string.
@@ -51,6 +55,7 @@ from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.module_utils.parsing.convert_bool import boolean as _to_bool
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
@@ -86,9 +91,12 @@ class LookupModule(LookupBase):
"pass application_id= explicitly"
)
entries = self._sso_pins(application_id) + self._caller_entries(
kwargs.get("extra_hosts")
)
entries = self._sso_pins(application_id)
if _to_bool(self._render(kwargs.get("host_alias", False)), strict=False):
entries.append(
f"{DOCKER_INTERNAL_HOST}:{self._gateway_address(DOCKER_INTERNAL_HOST)}"
)
entries += self._caller_entries(kwargs.get("extra_hosts"))
merged = list(dict.fromkeys(entries))
if not merged:
return [""]
+22 -3
View File
@@ -7,24 +7,36 @@ from ansible.module_utils.parsing.convert_bool import boolean as _to_bool
from ansible.plugins.lookup import LookupBase
LOOPBACK = "127.0.0.1"
CONTAINER_HOST_ALIAS = "host.docker.internal"
NODE_GROUP = "svc-swarm-node"
MANAGER_GROUP = "svc-swarm-manager"
def resolve_smtp_host(variables: dict[str, Any], email: dict[str, Any]) -> Any:
def resolve_smtp_host(
variables: dict[str, Any], email: dict[str, Any], *, in_container: bool = False
) -> Any:
"""Return the SMTP endpoint msmtp relays through.
Args:
variables: the play's variable namespace.
email: the resolved ``lookup('email')`` mapping.
in_container: the rendered config is mounted INTO a container rather
than read by the host's own msmtp.
Returns:
The configured relay host, the loopback address, or a swarm manager.
The configured relay host, the loopback address, the container's alias
for its host, or a swarm manager.
Outside the containerised test rig the configured relay is used unchanged.
Inside it that relay domain has neither DNS nor a route: a host carrying the
stack itself reaches mail on loopback, while a bare infra node has nothing
listening there and has to enter through the manager's routing mesh.
``in_container`` exists because one template serves two consumers. The host's
/root/.msmtprc is read by the host and loopback is right for it; the copy
mounted at /etc/msmtprc is read inside a container, where 127.0.0.1 is that
container's own loopback and nothing listens on it. Such a service must also
emit ``lookup('container_extra_hosts')`` so the alias resolves.
"""
relay = email.get("host")
in_rig = _to_bool(email.get("external"), strict=False) and _to_bool(
@@ -33,6 +45,9 @@ def resolve_smtp_host(variables: dict[str, Any], email: dict[str, Any]) -> Any:
if not in_rig:
return relay
if in_container:
return CONTAINER_HOST_ALIAS
if variables.get("DEPLOYMENT_MODE") == "compose" or NODE_GROUP in (
variables.get("group_names") or []
):
@@ -46,8 +61,11 @@ class LookupModule(LookupBase):
"""
Usage:
{{ lookup('smtp_host', email) }}
{{ lookup('smtp_host', email, in_container=true) }}
SMTP endpoint for the msmtp relay config. Takes the resolved email mapping.
Pass in_container=true when the rendered file is mounted into a container
instead of being read by the host's own msmtp.
"""
def run(self, terms, variables: dict[str, Any] | None = None, **kwargs):
@@ -59,4 +77,5 @@ class LookupModule(LookupBase):
if not isinstance(email, dict):
raise AnsibleError("smtp_host: the term must be the email mapping.")
variables = variables or getattr(self._templar, "available_variables", {}) or {}
return [resolve_smtp_host(variables, email)]
in_container = _to_bool(kwargs.get("in_container", False), strict=False)
return [resolve_smtp_host(variables, email, in_container=in_container)]
@@ -53,6 +53,8 @@
src: "msmtprc.conf.j2"
dest: "/root/.msmtprc"
mode: 600
vars:
sys_svc_mail_msmtp_in_container: false
- name: "🔄 Route hlth-msmtp via service-loader (after mailu compose-up)"
ansible.builtin.include_role:
@@ -9,7 +9,7 @@ tls {{ email.tls | on_off }}
tls_trust_file {{ sys_svc_mail_msmtp_ca_bundle_override | default(sys_svc_mail_msmtp_ca_bundle) }}
tls_host_override {{ email.host }}
account system_email_no_reply
host {{ lookup('smtp_host', email) }}
host {{ lookup('smtp_host', email, in_container=sys_svc_mail_msmtp_in_container) }}
port {{ email.port }}
from {{ email['from'] }}
{% if (email.auth | bool) %}
@@ -55,6 +55,7 @@
mode: "0640"
vars:
sys_svc_mail_msmtp_ca_bundle_override: /etc/ssl/certs/ca-certificates.crt
sys_svc_mail_msmtp_in_container: true
notify:
- compose build
- compose up
@@ -33,6 +33,7 @@
retries: 5
start_period: 15m
{{ lookup('container_depends_on', application_id) | indent(4) }}
{{ lookup('container_extra_hosts', host_alias=true) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{% set service_name = 'nginx' %}
@@ -67,6 +68,7 @@
sleep 60
done
{{ lookup('compose_restart') }}
{{ lookup('container_extra_hosts', host_alias=true) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{{ lookup('compose_volumes', application_id) }}
+1
View File
@@ -29,6 +29,7 @@
dest: "{{ WORDPRESS_MSMTP_ABS }}"
vars:
sys_svc_mail_msmtp_ca_bundle_override: /etc/ssl/certs/ca-certificates.crt
sys_svc_mail_msmtp_in_container: true
notify: compose up
- name: 🚿 Flush handlers to make {{ WORDPRESS_CONFIG_FILE }} available before patch
@@ -12,6 +12,7 @@
{{ lookup('container_ports', ['wordpress', 'http', DOCKER_BIND_HOST]) | indent(4) }}
{{ lookup('container_volumes', application_id, 'wordpress') | indent(4) }}
{{ lookup('container_extra_hosts', host_alias=true) | indent(4) }}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_depends_on', application_id) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}