mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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>
410 lines
15 KiB
Python
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]
|