"""Lookup ``container_extra_hosts``: the single emitter of ``extra_hosts:``. Compose rejects a mapping that carries the key twice, so nothing else in the repo may write ``extra_hosts:`` by hand. Everything a service needs pinned goes through here, from two sources that are both optional: 1. The SSO back-channel pin, added automatically. On an onion deployment the OIDC endpoints handed to an application are public ``.onion`` authorities. That is right for the browser, which reaches them through Tor, but unroutable for the application's own server-side calls (code-to-token exchange, JWKS, userinfo): a container resolves ``.onion`` through Tor's resolver, not through DNS. Pinning the provider to a reachable address sends 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. ``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. ``entries_only=true`` returns those strings as a list instead of the rendered block, for a container that is not described by a compose file at all: ``web-app-discourse`` is built by discourse_docker's ``launcher``, whose only argument surface is ``docker_args:`` in its ``app.yml``, so it needs ``--add-host=`` lines. The automatic pin follows the effective deploy mode, and in swarm it must NOT be ``host-gateway``: that alias resolves per node, while openresty publishes :80 with ``mode: host`` under ``node.role == manager``, so every replica on a worker dialled its own gateway and the token exchange died with 'Connection refused' (7b1839681, measured on penpot in run 31340965955). Swarm therefore gets the stack host's routable address -- the destination svc-net-tor already sends all hidden-service traffic to. ``ansible_facts`` carries it because the compose file is rendered once with the manager as inventory host and swarm applies the same ``extra_hosts`` to every task. The mode is read as ``compose_mode_force or DEPLOYMENT_MODE`` rather than the bare cluster mode, because ``container_networks`` resolves attachments through that same effective mode: a role with an override must not lose its pin and its overlay at once. Caller-supplied entries are passed through untouched, so a call site that needs a different address per mode must still decide that itself. Usage in any service template: {{ lookup('container_extra_hosts') | indent(4) }} {{ lookup('container_extra_hosts', extra_hosts=['host.docker.internal:host-gateway']) | indent(4) }} {{ lookup('container_extra_hosts', application_id='web-app-nextcloud') | indent(4) }} Usage from a non-compose call site: {% for entry in lookup('container_extra_hosts', entries_only=True, wantlist=True) %} - --add-host={{ entry }} {% endfor %} """ 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 from utils.networks.lookup_context import resolve_var HOST_GATEWAY = "host-gateway" DOCKER_INTERNAL_HOST = "host.docker.internal" SSO_PROVIDER = "web-app-keycloak" class LookupModule(LookupBase): def run( self, terms: list[Any] | None, variables: dict[str, Any] | None = None, **kwargs: Any, ) -> list[str]: """Render the pins this application needs to reach onion-hosted services. Kwargs: application_id: role whose pins to resolve; defaults to the play var. extra_hosts: additional `name:address` entries from the caller. host_alias: also pin `host.docker.internal` to the gateway. docker_flags: emit `--add-host=` flags for a `docker run` command line instead of a compose `extra_hosts:` block. Returns a single-element list holding the rendered block, or [""]. """ if terms: raise AnsibleError( "container_extra_hosts lookup takes no positional terms; pass " "extra_hosts=[...] and application_id= as keyword arguments" ) self._vars = ( variables or getattr(self._templar, "available_variables", {}) or {} ) application_id = self._render( kwargs.get("application_id") or self._vars.get("application_id") ) if not application_id: raise AnsibleError( "container_extra_hosts lookup: no application_id in the play vars; " "pass application_id= explicitly" ) entries = self._sso_pins(application_id) + self._mail_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 _to_bool(self._render(kwargs.get("entries_only", False)), strict=False): return merged if not merged: return [""] if _to_bool(self._render(kwargs.get("docker_flags", False)), strict=False): return ["\n".join(f" - --add-host={entry}" for entry in merged)] lines = ["extra_hosts:"] + [f' - "{entry}"' for entry in merged] return ["\n".join(lines)] def _sso_pins(self, application_id: str) -> list[str]: """The provider pin, empty unless this is an onion node running SSO. Args: application_id: the role whose ``services.sso`` gate decides. The gate is read with a default rather than strictly, because roles that declare no ``sso`` entry at all are legitimate call sites here -- they just have nothing to pin. """ if not bool( self._lookup("config", application_id, "services.sso.enabled", False) ): return [] provider_host = str(self._lookup("tls", SSO_PROVIDER, "domain") or "").strip() if not provider_host.endswith(".onion"): return [] return [ f"{DOCKER_INTERNAL_HOST}:{HOST_GATEWAY}", f"{provider_host}:{self._gateway_address(provider_host)}", ] def _mail_pins(self, application_id: str) -> list[str]: """The mail-provider pin, empty unless this is an onion node sending mail. Args: application_id: the role whose ``services.email`` gate decides. The same unroutability as the SSO pin, one service down: an application dials the relay by the provider's public name, which on an onion deployment resolves only through Tor. ``sys-svc-mail-msmtp`` answers that with a SOCKS proxy, but a client with no proxy setting at all -- Ruby's ``Net::SMTP``, so GitLab queues the mail and reports success while nothing leaves -- has no other route to the relay. """ if not bool( self._lookup("config", application_id, "services.email.enabled", False) ): return [] mail_host = str( (self._lookup("email", application_id) or {}).get("host") or "" ).strip() if not mail_host.endswith(".onion"): return [] return [ f"{DOCKER_INTERNAL_HOST}:{HOST_GATEWAY}", f"{mail_host}:{self._gateway_address(mail_host)}", ] def _caller_entries(self, extra_hosts: Any) -> list[str]: """Normalise the ``extra_hosts=`` argument to a list of mappings. Args: extra_hosts: one ``"name:address"``, a list of them, or None. Entries that render empty are dropped, so a call site can build its list with inline conditionals. Raises: AnsibleError: an entry carries no ``:`` separator, which Docker would reject only at deploy time. """ if not extra_hosts: return [] if isinstance(extra_hosts, str): extra_hosts = [extra_hosts] entries: list[str] = [] for entry in extra_hosts: text = str(self._render(entry) or "").strip() if not text: continue if ":" not in text: raise AnsibleError( f"container_extra_hosts: extra_hosts entry '{text}' is not a " "'name:address' mapping" ) entries.append(text) return entries def _gateway_address(self, provider_host: str) -> str: """Resolve what the provider host must point at in the effective mode. Args: provider_host: only used to name the host in errors. Under swarm this is the stack host's address, not the local node's: the template renders once on the manager and swarm copies the same ``extra_hosts`` to every task, which is what makes the pin reach the one node where openresty publishes. """ if self._compose_mode() == "compose": return HOST_GATEWAY facts = self._vars.get("ansible_facts") or {} address = str((facts.get("default_ipv4") or {}).get("address") or "").strip() if not address: raise AnsibleError( f"container_extra_hosts: cannot pin '{provider_host}' outside compose " "mode because ansible_facts.default_ipv4.address is unset; the swarm " f"pin must carry the stack host's address, not the '{HOST_GATEWAY}' " "alias, which resolves per node and misses the manager that publishes " "the vhost. Gather facts before rendering the service template." ) return address def _compose_mode(self) -> str: """Resolve the effective deploy mode the way the network lookups do. ``compose_mode_force`` is a per-role override of the cluster ``DEPLOYMENT_MODE``. Both can still be templates when they reach a lookup, so both go through ``resolve_var``, which passes a value through unchanged rather than raising when it cannot render. """ templar = getattr(self, "_templar", None) mode = str(resolve_var(templar, self._vars.get("DEPLOYMENT_MODE", "compose"))) forced = resolve_var(templar, self._vars.get("compose_mode_force", "")) return str(forced or mode).strip() def _render(self, value: Any) -> Any: """Template a value read straight out of the play vars. Args: value: raw var value; ``include_role: vars:`` hands these over unrendered, e.g. ``application_id: "{{ some_other_var }}"``. Raises: AnsibleError: the value stayed a template, which would bake ``{{ ... }}`` into the compose file. """ templar = getattr(self, "_templar", None) if templar is None or not isinstance(value, str) or "{{" not in value: return value rendered = templar.template(value) if isinstance(rendered, str) and "{{" in rendered: raise AnsibleError( f"container_extra_hosts: '{value}' did not render; it reached the " "lookup untrusted as a template." ) return rendered def _lookup(self, name: str, *terms: Any) -> Any: return lookup_loader.get( name, loader=self._loader, templar=getattr(self, "_templar", None) ).run(list(terms), variables=self._vars)[0]