Files
computer-playbook/plugins/lookup/compose_volumes.py
kevinveenbirkenbachandClaude Opus 5 a650801b35 fix(lookups): name file-backed extra secrets by content and hand plain types to PyYAML
compose_volumes copied extra_configs and extra_secrets through verbatim.
A file-backed extra without a name therefore reached swarm under a fixed
name, and swarm secrets are immutable: a new content under the same name
is rejected on the next stack deploy. Such extras now get the same
{role}_{key}_{sha8(content)} name that _config_secret_name already gives
meta-declared entries. An explicit name is kept.

container_volumes appended extras as handed over. Ansible passes one
lookup's result to another as proxy containers, and a TaggedInt mode
inside a proxy mapping made PyYAML raise "cannot represent an object".
The private _to_plain of compose_volumes moves to utils/templating as
to_plain, now coercing bool, int and float to the exact builtin types
(bool before int, since bool subclasses int), and container_volumes runs
its extras through it.

Only web-app-matrix passes extra secrets or configs today; the
extra_volumes path that web-app-nextcloud uses is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:20:26 +02:00

410 lines
15 KiB
Python

"""Lookup `compose_volumes`: render the top-level `volumes:` / `configs:` /
`secrets:` block. Auto-wires `application_id`, the `applications` registry,
DEPLOYMENT_MODE, and the `storage` mapping from the templating context.
"""
from __future__ import annotations
import contextlib
from typing import Any
from ansible.errors import AnsibleError, AnsibleFilterError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from plugins.filter.docker.service_enabled import (
FilterModule as _DockerServiceEnabledFilter,
)
from plugins.filter.get.entity_name import get_entity_name
# nocheck: lookup-cache-import (raw-volume accessor: canonical meta/volumes.yml shape)
from utils.cache.applications import get_canonical_volumes
from utils.cache.yaml import dump_yaml_str
from utils.roles.applications.config import get
from utils.roles.applications.mounts import (
content_hash,
mount_when_passes,
normalize_volumes_meta,
)
from utils.roles.applications.services.database import (
REALIGN_CONFIG_KEY,
get_database_service_config,
resolve_database_service_key,
)
from utils.roles.applications.services.sso import get_sso_config
from utils.storage.nfs import swarm_nfs_backed
from utils.templating.ansible import _trust_as_template, to_plain
def _resolve_database_volume_name(
applications: dict[str, Any], application_id: str, dbtype: str
) -> str:
consumer_entity = get_entity_name(application_id)
db_id = f"svc-db-{dbtype}"
central_name = get(
applications=applications,
application_id=db_id,
config_path=f"services.{dbtype}.name",
strict=False,
default="",
skip_missing_app=True,
)
central_name = (str(central_name) if central_name is not None else "").strip()
service_cfg = get_database_service_config(applications, application_id)
central_enabled = bool(service_cfg.get("shared", False))
host = central_name if central_enabled else "database"
volume_prefix = "" if central_enabled else f"{consumer_entity}_"
return f"{volume_prefix}{host}"
def _swarm_nfs_driver_opts(dir_var_lib: str, volume_name: str) -> dict[str, Any]:
return {
"driver": "local",
"driver_opts": {
"type": "none",
"o": "bind",
"device": f"{dir_var_lib.rstrip('/')}/{volume_name}",
},
}
def _read_file_for_hash(source: str) -> str:
"""Read a file's content for hash-based naming. Returns the path as a
stable fallback when the file isn't materialised yet (lint runs)."""
try:
from utils.cache.files import read_text
return read_text(source)
except (OSError, ValueError):
return source
def _config_secret_name(role_entity: str, user_name: str, source: str) -> str:
"""Build a swarm-rotation-safe name: ``{role}_{name}_{sha8(content)}``."""
digest = content_hash(_read_file_for_hash(source))
return f"{role_entity}_{user_name}_{digest}"
def _maybe_render(value: Any, render_jinja: Any) -> str:
text = str(value)
if render_jinja is None or ("{{" not in text and "{%" not in text):
return text
try:
return str(render_jinja(text))
except AnsibleError:
return text
def compose_volumes(
applications: dict[str, Any],
application_id: str,
*,
extra_volumes: dict[str, dict[str, Any]] | None = None,
extra_configs: dict[str, dict[str, Any]] | None = None,
extra_secrets: dict[str, dict[str, Any]] | None = None,
deployment_mode: str = "compose",
storage: dict[str, Any] | None = None,
dir_var_lib: str,
render_jinja: Any = None,
) -> str:
"""Render the top-level ``volumes:`` / ``configs:`` / ``secrets:`` block.
Reads the canonical dict-of-dicts shape from
``roles/<role>/meta/volumes.yml`` (YAML key = semantic short name;
optional ``name:`` field = docker volume name). Entries with
``type: config`` / ``type: secret`` emit their respective top-level
sections; everything else flows through the volumes section.
A ``type: volume`` entry may set ``nfs: false`` to opt out of the
swarm NFS rewrite: the volume then stays a plain node-local named
volume even when ``storage.backend`` is ``nfs`` (e.g. gitaly
repository storage, which upstream forbids on NFS). Any other
``nfs`` value (absent, ``true``, or the uid/gid/mode dict consumed
by the NFS subdir pre-creation) keeps the rewrite.
"""
if applications is None:
raise AnsibleFilterError("compose_volumes: 'applications' must not be None")
if not isinstance(applications, dict):
raise AnsibleFilterError("compose_volumes: 'applications' must be a dict")
if not application_id or not isinstance(application_id, str):
raise AnsibleFilterError(
"compose_volumes: 'application_id' must be a non-empty string"
)
if application_id not in applications:
raise AnsibleFilterError(
f"compose_volumes: unknown application_id '{application_id}'"
)
role_entity = get_entity_name(application_id)
volumes: dict[str, Any] = {}
configs: dict[str, Any] = {}
secrets: dict[str, Any] = {}
try:
database_service_key = resolve_database_service_key(
applications, application_id
)
except ValueError as exc:
raise AnsibleFilterError(
"compose_volumes: "
f"{exc}. Simultaneous postgres + mariadb on the same role "
"is not supported (the embedded service templates collide "
"on the `database` service key, host name, and volume "
"key); pick one dbtype per role."
) from exc
database_service = get_database_service_config(applications, application_id)
database_needed = bool(database_service_key) and not bool(
database_service.get("shared", False)
)
if database_needed:
volumes["database"] = {
"name": _resolve_database_volume_name(
applications, application_id, database_service_key
)
}
for engine in ("seaweedfs", "minio"):
engine_enabled = bool(
get(
applications=applications,
application_id=application_id,
config_path=f"services.{engine}.enabled",
strict=False,
default=False,
)
)
engine_shared = bool(
get(
applications=applications,
application_id=application_id,
config_path=f"services.{engine}.shared",
strict=False,
default=False,
)
)
if engine_enabled and not engine_shared:
volumes[engine] = {"name": f"{get_entity_name(application_id)}_{engine}"}
sso = get_sso_config(applications, application_id)
if (
_DockerServiceEnabledFilter.is_docker_service_enabled(
applications, application_id, "redis"
)
or sso["is_proxy_gated"]
):
volumes["redis"] = {"name": f"{role_entity}_redis"}
if extra_volumes:
volumes.update(extra_volumes)
for section, extra in ((configs, extra_configs), (secrets, extra_secrets)):
for key, spec in (extra or {}).items():
named = spec
if isinstance(spec, dict) and spec.get("file") and not spec.get("name"):
named = {
**spec,
"name": _config_secret_name(role_entity, key, str(spec["file"])),
}
section[key] = named
role_data = applications.get(application_id) or {}
raw_meta_volumes = (
get_canonical_volumes(application_id) or role_data.get("volumes") or {}
)
canonical_entries = normalize_volumes_meta(raw_meta_volumes)
for semantic_name, entry in canonical_entries.items():
vtype = entry.get("type", "volume")
if vtype == "volume":
if semantic_name in volumes:
continue
spec: dict[str, Any] = {}
docker_name = entry.get("name")
if docker_name:
spec["name"] = docker_name
if "nfs" in entry:
spec["nfs"] = entry["nfs"]
volumes[semantic_name] = spec
continue
if vtype in ("config", "secret") and not any(
mount_when_passes(mount, render_jinja)
for mount in entry.get("mounts") or []
if isinstance(mount, dict)
):
continue
if vtype == "config":
source = _maybe_render(entry.get("source", ""), render_jinja)
configs[semantic_name] = {
"name": _maybe_render(entry.get("name", ""), render_jinja)
or _config_secret_name(role_entity, semantic_name, source),
"file": source,
}
continue
if vtype == "secret":
source = _maybe_render(entry.get("source", ""), render_jinja)
secrets[semantic_name] = {
"name": _maybe_render(entry.get("name", ""), render_jinja)
or _config_secret_name(role_entity, semantic_name, source),
"file": source,
}
continue
storage_backend = (storage or {}).get("backend", "local")
for vol_name, vol_spec in list(volumes.items()):
if not isinstance(vol_spec, dict):
continue
nfs_meta = vol_spec.pop("nfs", None)
if swarm_nfs_backed(
{"nfs": nfs_meta},
application_id=application_id,
deployment_mode=deployment_mode,
storage_backend=storage_backend,
):
named = vol_spec.get("name", vol_name)
vol_spec.update(_swarm_nfs_driver_opts(dir_var_lib, str(named)))
if isinstance(nfs_meta, dict):
vol_spec["x-infinito-nfs"] = dict(nfs_meta)
payload: dict[str, Any] = {"volumes": to_plain(volumes)}
if configs:
payload["configs"] = to_plain(configs)
if secrets:
payload["secrets"] = to_plain(secrets)
return dump_yaml_str(payload).rstrip()
class LookupModule(LookupBase):
def _with_database_realign(
self,
applications: dict[str, Any],
application_id: str,
vars_: dict[str, Any],
extra_configs: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""Add the realignment statements an app's own mariadb reads at start.
Args:
applications: the merged applications mapping.
application_id: the app whose stack is being rendered.
vars_: the variables in scope for the lookup.
extra_configs: configs a caller already supplied.
The file lands as a config rather than a bind mount because a swarm task
may run on a node the source path does not exist on.
A role declaring two engines is left to ``compose_volumes``, which turns
the same ValueError into the message that says which one to drop.
"""
try:
database_service_key = resolve_database_service_key(
applications, application_id
)
except ValueError:
return extra_configs
if database_service_key != "mariadb":
return extra_configs
if get_database_service_config(applications, application_id).get("shared"):
return extra_configs
source = lookup_loader.get(
"database", loader=self._loader, templar=getattr(self, "_templar", None)
).run([application_id, "realign_sql"], variables=vars_)[0]
if not source:
return extra_configs
entity = get_entity_name(application_id)
return {
**(extra_configs or {}),
REALIGN_CONFIG_KEY: {
"name": _config_secret_name(entity, REALIGN_CONFIG_KEY, source),
"file": source,
},
}
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[str]:
if terms:
raise AnsibleError(
"compose_volumes lookup expects no terms; application_id "
"is read from the templating context"
)
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
templar = getattr(self, "_templar", None)
application_id = vars_.get("application_id")
if templar is not None and application_id is not None:
application_id = templar.template(application_id)
application_id = str(application_id or "").strip()
if not application_id:
raise AnsibleError(
"compose_volumes lookup: 'application_id' is not set in the "
"templating context"
)
applications = lookup_loader.get(
"applications", loader=self._loader, templar=getattr(self, "_templar", None)
).run([], variables=vars_)[0]
deployment_mode = kwargs.get("deployment_mode")
if deployment_mode is None:
mode_force = vars_.get("compose_mode_force", "")
if templar is not None:
with contextlib.suppress(Exception):
mode_force = templar.template(mode_force)
deployment_mode = mode_force or vars_.get("DEPLOYMENT_MODE", "compose")
storage = kwargs.get("storage")
if storage is None:
storage = vars_.get("storage")
dir_var_lib = kwargs.get("dir_var_lib")
if dir_var_lib is None:
dir_var_lib = vars_["DIR_VAR_LIB"]
if templar is not None:
with contextlib.suppress(Exception):
deployment_mode = templar.template(deployment_mode)
with contextlib.suppress(Exception):
storage = templar.template(storage)
with contextlib.suppress(Exception):
dir_var_lib = templar.template(dir_var_lib)
render_jinja = None
if templar is not None:
def render_jinja(expr: str) -> Any:
if not isinstance(expr, str):
return expr
return templar.template(
_trust_as_template(expr),
fail_on_undefined=False,
)
rendered = compose_volumes(
applications,
application_id,
extra_volumes=kwargs.get("extra_volumes"),
extra_configs=self._with_database_realign(
applications, application_id, vars_, kwargs.get("extra_configs")
),
extra_secrets=kwargs.get("extra_secrets"),
deployment_mode=str(deployment_mode).strip(),
storage=storage,
dir_var_lib=str(dir_var_lib).strip(),
render_jinja=render_jinja,
)
return [rendered]