Files
computer-playbook/plugins/lookup/container_url.py
T
kevinveenbirkenbachandClaude Opus 5 bab08063f3 feat(plugins): ask for a container-network origin instead of assembling one
A sibling container is reached at http://<entity name>:<internal port>,
and that string was written out per call site: a literal protocol, an
entity_name interpolation for the host, and a lookup('config', ...,
'services.<key>.ports.internal.http') for the port. Nine of them across
seven roles, each needing its own `# nocheck: literal-protocol-lookup`,
because the sibling lint rightly objects to a protocol glued in front of
a lookup - the protocol is supposed to come from wherever the TLS
posture is decided.

A container network has no TLS posture. It speaks plaintext by
construction, which is a property of the network rather than of any
caller, so stating it nine times stated it eight times too often.
lookup('container_url', application_id, service_key) holds all three
facts, and because no literal protocol is left at the call site the
suppression markers go with them.

The port is deliberately the internal one. A sibling that goes out to
the published port leaves the container network and comes back through
the ingress, which the dns-search suffix turns into the public FQDN.
The lookup refuses a service that declares no internal http port rather
than falling back to anything.

test_container_url_lookup.py keeps the shape from coming back: a
protocol, an interpolation mentioning entity_name, a colon, a second
interpolation. Nothing else matches that, and `# nocheck:
container-url-lookup` covers an origin this lookup genuinely cannot
express - a port that is not ports.internal.http, or a host that is not
the application's entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-18 02:13:05 +02:00

122 lines
4.5 KiB
Python

"""Lookup `container_url`: the origin a sibling container is reached on.
One place for the three facts a container-network URL is made of: the
hostname Docker resolves, the port the service listens on inside the
network, and the protocol that network speaks.
The protocol is the reason this is a lookup rather than a two-term
expression. ``tests/lint/repository/yaml/test_literal_protocol_with_lookup.py``
forbids gluing a literal ``http://`` in front of a value a lookup
produced, because the protocol must come from wherever the TLS posture
is decided. A container network has no TLS posture: it speaks plaintext
by construction. Writing that out per call site meant repeating the same
``# nocheck: literal-protocol-lookup`` next to every one of them, which
states a decision nine times instead of once. Holding it here states it
once, and the call sites carry no literal protocol for the lint to see.
Reached with the ``entity_name`` of the application rather than the
service key, matching how every existing caller of this shape addresses
a sibling and how ``docker stack deploy`` names the stack.
The port is ``services.<service_key>.ports.internal.http``. Internal,
never published: the published port exists for the host, and a sibling
that goes out to it leaves the container network and comes back through
the ingress, which the dns-search suffix turns into the public FQDN.
The result carries no trailing slash and no path, so a caller appends
its own:
GITEA_INTERNAL_API_URL: "{{ lookup('container_url', application_id, 'gitea') }}/api/v1"
Args:
application_id: the role whose container is addressed.
service_key: its ``meta/services.yml`` key.
Returns:
``http://<entity name>:<internal http port>``.
Raises:
AnsibleError: the application is unknown, the service key is absent,
or it declares no internal http port.
"""
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils.roles.entity.name import entity_name
SCHEME = "http"
def _as_str(value: Any) -> str:
return "" if value is None else str(value).strip()
def _internal_http_port(
applications: dict[str, Any], application_id: str, service_key: str
) -> int:
app = applications.get(application_id)
if not isinstance(app, dict):
raise AnsibleError(f"container_url: unknown application_id '{application_id}'")
services = app.get("services")
entry = services.get(service_key) if isinstance(services, dict) else None
if not isinstance(entry, dict):
raise AnsibleError(
f"container_url: service '{service_key}' missing in "
f"'{application_id}' services config"
)
ports = entry.get("ports")
internal = ports.get("internal") if isinstance(ports, dict) else None
port = internal.get("http") if isinstance(internal, dict) else None
try:
return int(port)
except (TypeError, ValueError):
raise AnsibleError(
f"container_url: services.{service_key}.ports.internal.http is "
f"{port!r} for '{application_id}', which is not a port"
) from None
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[str]:
if not terms or len(terms) != 2:
raise AnsibleError(
"container_url lookup requires exactly two terms: "
"application_id and service_key"
)
application_id = _as_str(terms[0])
service_key = _as_str(terms[1])
if not application_id:
raise AnsibleError(
"container_url: application_id must be a non-empty string"
)
if not service_key:
raise AnsibleError("container_url: service_key must be a non-empty string")
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
applications = lookup_loader.get(
"applications", loader=self._loader, templar=getattr(self, "_templar", None)
).run([], variables=vars_)[0]
port = _internal_http_port(applications, application_id, service_key)
host = entity_name(application_id)
if not host:
raise AnsibleError(
f"container_url: cannot derive a hostname from application_id "
f"'{application_id}'"
)
return [f"{SCHEME}://{host}:{port}"]