Files
computer-playbook/plugins/lookup/compose_file_args.py
T
kevinveenbirkenbachandClaude Opus 5 b6bf0b0bc1 fix(ca): pass the CA override to the deployment that was granted it
sys-svc-compose writes compose.ca.override.yml for exactly lookup('ca_injected', application_id), which since 1254e27c2d also answers true for a service that declares ca_client and only calls the self-signed endpoint. compose_file_args rebuilt that predicate instead of asking it, and its copy still demanded a domain, so for a domainless ca_client the file was written and never reached --compose-file. Swarm takes its file list from that lookup; compose finds the file on disk by itself, which is why only the swarm jobs of run 36064589578 failed while the compose job passed.

The broker's container in that run carries no SSL_CERT_FILE and no CA mount, while openwebui, same job and same node, carries seven CA env vars and four binds. Every outbound call to Keycloak then fails the handshake, which the CLI test reports as 503 and Open WebUI relays as HTTP 400.

Two corrections in ca_injected itself. Its templar.template() call was dead: as_str strips the TrustedAsTemplate tag and ansible-core 2.19+ refuses to render an untrusted string, so the line could not resolve what it advertised, and tls.py and cert.py never template TLS_MODE either. And the ca_client branch short-circuited on the global TLS_MODE, skipping resolve_mode's per-app server.tls.mode override; it now applies only where there is no domain and so no per-app answer to get.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:32:33 +02:00

176 lines
6.1 KiB
Python

# Build compose "-f <file>" arguments for an application instance.
#
# HARD FAIL PRINCIPLE:
# - No silent defaults that change behavior.
# - Fail loudly if required variables are missing/invalid.
#
# Rules:
# - Always include base compose.yml
# - Include compose.override.yml ONLY when the ROLE (application_id) provides one
# (same logic as tasks/04_files.yml with_first_found)
# - Include compose.ca.override.yml ONLY when lookup('ca_injected') is true
#
# Optional kwargs:
# - include_ca (bool, default True):
# - True -> normal behavior (append CA override when enabled+self_signed)
# - False -> do NOT append CA override (used during CA-inject bootstrap)
#
# IMPORTANT (your requested change):
# - This lookup NO LONGER reads variables['compose'].
# - It ALWAYS builds compose paths via utils.docker.paths_utils.get_docker_paths()
# using DIR_COMPOSITIONS.
from __future__ import annotations
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 utils.docker.paths_utils import get_docker_paths
from utils.templating.jinja import render_strict
def _as_str(v: Any) -> str:
return "" if v is None else str(v).strip()
def _require_dict(d: Any, label: str) -> dict:
if not isinstance(d, dict):
raise AnsibleError(f"compose_file_args: {label} must be a dict, got {type(d)}")
return d
def _maybe_template(templar: Any, value: Any) -> Any:
"""
Render via Ansible templar if available.
Unit tests may not inject a templar -> then we skip templating.
"""
if isinstance(value, (dict, list, tuple, int, float, bool)) or value is None:
return value
if templar is None:
return value
tpl = getattr(templar, "template", None)
if callable(tpl):
return tpl(value)
return value
def _role_provides_override(*, application_id: str, templar: Any) -> bool:
"""
Mirror tasks/04_files.yml "with_first_found" logic:
- "{{ application_id | abs_role_path_by_application_id }}/templates/compose.override.yml.j2"
- "{{ application_id | abs_role_path_by_application_id }}/files/compose.override.yml"
Only if one of these exists, we append "-f <compose.files.compose_override>".
"""
tpl = getattr(templar, "template", None)
if not callable(tpl):
raise AnsibleError(
"compose_file_args: templar is required to resolve abs_role_path_by_application_id"
)
role_base = _as_str(tpl("{{ application_id | abs_role_path_by_application_id }}"))
if not role_base:
raise AnsibleError(
"compose_file_args: abs_role_path_by_application_id resolved to empty"
)
c1 = str(Path(role_base) / "templates" / "compose.override.yml.j2")
c2 = str(Path(role_base) / "files" / "compose.override.yml")
return Path(c1).is_file() or Path(c2).is_file()
class LookupModule(LookupBase):
def run(self, terms, variables: dict | None = None, **kwargs):
variables = variables or {}
if not terms or len(terms) != 1:
raise AnsibleError(
"compose_file_args: exactly one term required (application_id)"
)
application_id = _as_str(terms[0])
if not application_id:
raise AnsibleError("compose_file_args: application_id is empty")
include_ca = kwargs.get("include_ca", True)
if not isinstance(include_ca, bool):
raise AnsibleError("compose_file_args: include_ca must be a bool")
flag = kwargs.get("flag", "-f")
if flag not in ("-f", "--compose-file", "-c"):
raise AnsibleError(
"compose_file_args: flag must be one of -f, --compose-file, -c"
)
templar = getattr(self, "_templar", None)
raw_base_dir = variables.get("DIR_COMPOSITIONS")
if (
isinstance(raw_base_dir, str)
and "{{" in raw_base_dir
and templar is not None
):
raw_base_dir = templar.template(raw_base_dir)
base_dir = _as_str(raw_base_dir)
if not base_dir:
raise AnsibleError(
"compose_file_args: missing required variable 'DIR_COMPOSITIONS'"
)
compose = get_docker_paths(application_id, base_dir)
compose = _require_dict(compose, "compose")
files = _require_dict(compose.get("files"), "compose.files")
base = render_strict(
files.get("compose"),
variables=variables,
var_name="compose.files.compose",
err_prefix="compose_file_args",
)
override = render_strict(
files.get("compose_override"),
variables=variables,
var_name="compose.files.compose_override",
err_prefix="compose_file_args",
)
ca_override = render_strict(
files.get("compose_ca_override"),
variables=variables,
var_name="compose.files.compose_ca_override",
err_prefix="compose_file_args",
)
if not _as_str(base):
raise AnsibleError("compose_file_args: compose.files.compose is required")
parts = [f"{flag} {base}"]
if _role_provides_override(application_id=application_id, templar=templar):
if not _as_str(override):
raise AnsibleError(
"compose_file_args: compose.files.compose_override is required "
"when the role provides an override file"
)
parts.append(f"{flag} {override}")
if include_ca:
injected = lookup_loader.get(
"ca_injected", loader=self._loader, templar=templar
).run([application_id], variables=variables)[0]
if injected:
if not _as_str(ca_override):
raise AnsibleError(
"compose_file_args: compose.files.compose_ca_override is "
"required when the CA is injected"
)
parts.append(f"{flag} {ca_override}")
return [" ".join(parts)]