Files
computer-playbook/plugins/lookup/database.py
T
kevinveenbirkenbachandClaude Opus 5 f2fedfc677 Merge branch 'main' into feature/store-i18n
Fourteen real conflicts plus the 66 generated READMEs.

utils/cache/files.py and its test: both sides added a skip-dir rule, resolved
as a union so the root `build/` prune and the per-root walk key coexist.

plugins/lookup/database.py and postgres_libraries.py: main's added lookups kept,
with get_entity_name renamed to entity_name as this branch renamed it.

roles/web-app-{mailu,matrix}/meta/services.yml: main's `covers: [email]` and its
MAIL_PROVIDER-gated email entry, plus this branch's gitlab/jira/n8n/jitsi
consumers, which the merge base carried on neither side.

group_vars/all/17_resource.yml: this branch's lookup('resource', ...) refactor;
main's single added literal is already covered by _POSTGRES_MAX_LOCKS_PER_TRANSACTION.

docs/requirements/035-mcp-proxy-expansion.md: this branch moved the design into
ADR 0003, so the requirement keeps only the user story and the criteria. Main's
delta inside the moved block was two new roles, carried into the ADR audit:
web-app-stalwart as adapter_candidate, svc-net-tor-smtp as privileged_control_plane.
The no_surface total said 99 while its own subcategories summed to 112.

inventories/development/default.yml: biber keeps no explicit email. Both sides fixed
the same CI failure; this branch fixed it in _apply_account_defaults, which renders
biber@{{ DOMAIN_PRIMARY }} (utils/cache/users/__init__.py:68) — byte-identical to
the address main hardcoded, so the inventory line is a second copy of one fact.

roles/web-app-{gitlab,keycloak}/files/playwright/playwright.spec.js and
roles/web-app-gitlab/templates/playwright.env.j2: union of both import sets, with
requireDotenvValue kept over decodeDotenvQuotedValue for CANONICAL_DOMAIN because
an empty value must fail rather than match every substring.

The 66 role READMEs take this side: aceb5295ef moved Cosmos, Quick Setup and
Credits to the documentation build, and test_readme.py now rejects them in a
committed README. Main's prose edits inside them are carried over by hand (the
Mailu deprecation banner, Mailu -> Stalwart in erpnext and zammad), and the two
READMEs main added fresh are stripped of the three generated sections.
roles/web-app-sphinx/README.md stays deleted; 6eb9a59d14 merged that role into
web-app-docs. The root README's generated role table was regenerated from the
merged metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 01:57:17 +02:00

292 lines
9.6 KiB
Python

from __future__ import annotations
import contextlib
import shlex
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils.manager.credential_key import OVERRIDE_SECTION
from utils.roles.applications.config import get
from utils.roles.applications.services.database import (
REALIGN_CONFIG_KEY,
REALIGN_TARGET,
get_database_service_config,
resolve_database_service_key,
)
from utils.roles.entity.name import entity_name
def _swarm_address(bin_resolver: str, stack_name: str, service_key: str) -> str:
return (
f'"$({shlex.quote(bin_resolver)} '
f'{shlex.quote(stack_name)} {shlex.quote(service_key)})"'
)
class LookupModule(LookupBase):
"""
Resolve database values for a given database_consumer_id.
API (STRICT):
- {{ lookup('database', database_consumer_id) }}
- {{ lookup('database', database_consumer_id, 'url_full') }}
Notes:
- want-path is optional and MUST be the second positional argument if used
- kwarg want= is NOT supported (use positional want-path)
"""
def run(
self,
terms: list[Any],
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[Any]:
terms = terms or []
if len(terms) not in (1, 2):
raise AnsibleError("database: requires database_consumer_id [, want_path]")
if "want" in kwargs and str(kwargs.get("want") or "").strip():
raise AnsibleError(
"database: kwarg 'want=' is not supported; use positional want_path "
"like lookup('database', <id>, 'url_full')"
)
consumer_id = str(terms[0]).strip()
if not consumer_id:
raise AnsibleError("database: database_consumer_id must not be empty")
want = str(terms[1]).strip() if len(terms) == 2 else ""
if not want:
want = "all"
vars_ = variables or self._templar.available_variables
applications = lookup_loader.get(
"applications", loader=self._loader, templar=getattr(self, "_templar", None)
).run([], variables=vars_)[0]
path_instances = self._require_var(vars_, "DIR_COMPOSITIONS")
if (
isinstance(path_instances, str)
and "{{" in path_instances
and self._templar is not None
):
path_instances = self._templar.template(path_instances)
consumer_entity = entity_name(consumer_id)
try:
dbtype = resolve_database_service_key(applications, consumer_id)
except ValueError as exc:
raise AnsibleError(f"database: {exc}") from exc
database_service = get_database_service_config(applications, consumer_id)
enabled = bool(database_service.get("enabled", False))
shared = bool(database_service.get("shared", False))
name_override = str(database_service.get("name") or "").strip()
managed = bool(enabled and not shared and not name_override)
if not dbtype:
resolved = {
"id": "",
"enabled": enabled,
"shared": shared,
"local": bool(enabled and not shared),
"managed": managed,
"type": "",
"name": consumer_entity,
"instance": "",
"address": "",
"service_name": "",
"host": "",
"container": "",
"network": "",
"username": consumer_entity,
"password": "",
"port": "",
"env": "",
"realign_sql": "",
"realign_target": REALIGN_TARGET,
"realign_config": REALIGN_CONFIG_KEY,
"initdb_dir": "",
"build_dir": "",
"url_jdbc": "",
"url_full": "",
"volume": "",
"image": "",
"version": "",
"reach_host": "127.0.0.1",
}
return [resolved if want == "all" else resolved.get(want, "")]
central_enabled = shared
db_id = f"svc-db-{dbtype}"
central_name = get(
applications,
db_id,
f"services.{dbtype}.name",
strict=False,
default="",
skip_missing_app=True,
)
central_name = (str(central_name) if central_name is not None else "").strip()
declared_name = get(
applications,
consumer_id,
f"services.{dbtype}.name",
strict=False,
default="",
)
declared_name = (
str(declared_name) if declared_name is not None else ""
).strip()
name = consumer_entity
instance = central_name if central_enabled else name
host = central_name if central_enabled else "database"
dedicated_container = declared_name or f"{consumer_entity}-database"
container = dbtype if central_enabled else dedicated_container
network = dbtype if central_enabled else consumer_entity
username = consumer_entity
password = get(
applications,
consumer_id,
f"{OVERRIDE_SECTION}.database_password",
strict=False,
default="",
)
port = get(
applications,
db_id,
f"services.{dbtype}.ports.local.{dbtype}",
strict=False,
default="",
skip_missing_app=True,
)
default_version = get(
applications,
db_id,
f"services.{dbtype}.version",
strict=False,
default="",
skip_missing_app=True,
)
version = get(
applications,
consumer_id,
f"services.{dbtype}.version",
strict=False,
default=default_version,
)
default_image = get(
applications,
db_id,
f"services.{dbtype}.image",
strict=False,
default=dbtype,
skip_missing_app=True,
)
image = get(
applications,
consumer_id,
f"services.{dbtype}.image",
strict=False,
default=default_image,
)
env_dir = f"{path_instances}{entity_name(consumer_id)}/.env/"
env = f"{env_dir}{dbtype}.env"
realign_sql = f"{env_dir}{dbtype}-realign.sql"
initdb_dir = f"{path_instances}{entity_name(consumer_id)}/.initdb.d/"
build_dir = f"{path_instances}{entity_name(consumer_id)}/.postgres-build/"
jdbc_scheme = dbtype if dbtype == "mariadb" else "postgresql"
url_jdbc = f"jdbc:{jdbc_scheme}://{host}:{port}/{name}"
url_full = f"{dbtype}://{username}:{password}@{host}:{port}/{name}"
volume_prefix = f"{consumer_entity}_" if not central_enabled else ""
volume = f"{volume_prefix}{host}"
templar = getattr(self, "_templar", None)
def _templated(value: Any) -> str:
if templar is not None:
with contextlib.suppress(Exception):
value = templar.template(value)
return str(value or "").strip()
cluster_mode = _templated(vars_.get("DEPLOYMENT_MODE", "compose"))
forced = _templated(vars_.get("compose_mode_force", ""))
deployment_mode = cluster_mode if central_enabled else (forced or cluster_mode)
db_stack = dbtype if central_enabled else consumer_entity
db_service_key = dbtype if central_enabled else "database"
service_name = (
f"{db_stack}_{db_service_key}"
if deployment_mode == "swarm"
else db_service_key
)
if deployment_mode == "swarm":
bin_resolver = vars_.get("BIN_RESOLVE_CONTAINER_ID")
if templar is not None and bin_resolver is not None:
with contextlib.suppress(Exception):
bin_resolver = templar.template(bin_resolver)
bin_resolver = (
str(bin_resolver).strip()
if bin_resolver
else "/usr/bin/resolve-container-id"
)
address = _swarm_address(bin_resolver, db_stack, db_service_key)
else:
address = container
resolved = {
"id": db_id,
"enabled": enabled,
"shared": shared,
"local": bool(enabled and not shared),
"managed": managed,
"type": dbtype,
"name": name,
"instance": instance,
"address": address,
"service_name": service_name,
"host": host,
"container": container,
"network": network,
"username": username,
"password": password,
"port": port,
"env": env,
"realign_sql": realign_sql,
"realign_target": REALIGN_TARGET,
"realign_config": REALIGN_CONFIG_KEY,
"initdb_dir": initdb_dir,
"build_dir": build_dir,
"url_jdbc": url_jdbc,
"url_full": url_full,
"volume": volume,
"image": image,
"version": version,
"reach_host": "127.0.0.1",
}
return [resolved if want == "all" else resolved.get(want, "")]
@staticmethod
def _require_var(vars_: dict[str, Any], key: str) -> Any:
if key not in vars_:
raise AnsibleError(f"database: required variable '{key}' is not set")
return vars_[key]