Files
computer-playbook/plugins/lookup/container_extra_hosts.py

284 lines
12 KiB
Python

"""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=<entry>`` 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]