Files
computer-playbook/utils/cache/data.py
T
kevinveenbirkenbach 12dba01156 refactor(utils): consolidate cache modules into utils/cache/ package
Both in-process cache layers — the cached YAML loader and the cached
runtime-data getters — now live next to each other under
`utils/cache/`. The package gains a README that documents the
"orthogonal layers, no third cache" rule so future contributors hit
the right primitive on the first look.

Moves:
- `utils/yaml_cache.py` -> `utils/cache/yaml.py`
- `utils/runtime_data.py` -> `utils/cache/data.py`
- `tests/unit/utils/test_yaml_cache.py` -> `tests/unit/utils/cache/test_yaml.py`
- `tests/unit/utils/test_runtime_data.py` -> `tests/unit/utils/cache/test_data.py`
- `tests/unit/utils/test_variants.py` -> `tests/unit/utils/cache/test_variants.py`

New:
- `utils/cache/__init__.py` (empty per project convention)
- `utils/cache/README.md` describing the two layers, when to reach for
  which, lifetime, test-only helpers, and design guidelines (RFC 2119
  keywords, emoji-after-headline, no self-SPOT declaration, no
  slash-in-link-text).
- `tests/unit/utils/cache/__init__.py`

Mechanical updates:
- All imports across cli/, plugins/lookup/, tests/, and roles/ rewritten
  from `utils.yaml_cache` -> `utils.cache.yaml` and
  `utils.runtime_data` -> `utils.cache.data` (40+ files).
- `REPO_ROOT` parents-index in the moved tests bumped from `parents[3]`
  to `parents[4]` to reflect the deeper nesting.
- Six broken markdown links in docs/ updated to the new module paths
  (test_markdown_links lint test stays green).
- Two test files that used the `from utils import runtime_data` form
  now use `from utils.cache import data as runtime_data` so the local
  alias remains.

Tests: 848 passed across unit + lint suites (1 pre-existing collection
error in test_host_vars.py unrelated to this change).
2026-04-25 17:17:14 +02:00

902 lines
31 KiB
Python

from __future__ import annotations
import copy
import glob
import os
import threading
from collections import OrderedDict
from pathlib import Path
from typing import Any, Mapping, Optional
from urllib.parse import urlparse
import yaml
from plugins.filter.merge_with_defaults import merge_with_defaults
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
from utils.templating import _templar_render_best_effort
try:
from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString
except Exception:
_AnsibleEncryptedString = None
def _decrypt_ansible_encrypted_strings(value: Any) -> Any:
"""Recursively convert Ansible EncryptedString values to plaintext str.
Ansible 2.19+ refuses to store EncryptedString as an intermediate variable
during task arg finalization, so decrypt at the lookup boundary.
"""
if _AnsibleEncryptedString is not None and isinstance(
value, _AnsibleEncryptedString
):
try:
return str(value)
except Exception:
return value
if isinstance(value, Mapping):
return {k: _decrypt_ansible_encrypted_strings(v) for k, v in value.items()}
if isinstance(value, list):
return [_decrypt_ansible_encrypted_strings(v) for v in value]
if isinstance(value, tuple):
return tuple(_decrypt_ansible_encrypted_strings(v) for v in value)
return value
PROJECT_ROOT = Path(__file__).resolve().parents[1]
ROLES_DIR = PROJECT_ROOT / "roles"
DEFAULT_TOKENS_FILE = Path("/var/lib/infinito/secrets/tokens.yml")
_APPLICATIONS_DEFAULTS_CACHE: dict[str, dict[str, Any]] = {}
_VARIANTS_CACHE: dict[str, dict[str, list[Any]]] = {}
_USERS_DEFAULTS_CACHE: dict[str, dict[str, Any]] = {}
# Single-slot rendered caches. lookup('config'), lookup('applications') and
# lookup('users') are called many times per play (sanity checks, env.j2, etc.).
# With Ansible 2.19+ trust-tagging actually firing templar rendering, rebuilding
# the merged+rendered payload each call dominates runtime. We cache the
# rendered result keyed on (roles_dir_str, _stable_variables_signature(...)).
# The signature uses id() of applications/users sub-dicts (stable across tasks
# unless a set_fact replaces them) plus DOMAIN_PRIMARY/SYSTEM_EMAIL_DOMAIN
# string values. templar is NOT in the key: rendering is a pure function of
# input+variables; the templar instance is churned per-task by Ansible.
_MERGED_APPLICATIONS_CACHE: dict[tuple, dict[str, Any]] = {}
_MERGED_USERS_CACHE: dict[tuple, dict[str, Any]] = {}
_MERGED_DOMAINS_CACHE: dict[tuple, dict[str, Any]] = {}
# Re-entry guards. Cross-lookups ({{ lookup('users', ...) }} inside applications
# and vice versa) can otherwise drive unbounded recursion once strings are
# trust-tagged and actually rendered (Ansible 2.19+). When a re-entrant call is
# detected, callers return the pre-render (still-templated) payload, which the
# caller's own templar will resolve lazily at use-site.
_RENDER_GUARD = threading.local()
def _cache_key(roles_dir: Path) -> str:
return str(roles_dir.resolve())
_FINGERPRINT_BY_ID: "dict[int, str]" = {}
def _fingerprint_mapping(obj: Any) -> str:
"""Cheap-ish content fingerprint for cache keying.
Ansible 2.19+ composes a fresh `variables` mapping per task via
VariableManager.get_vars() and — empirically — often reconstructs the
inventory-level `applications`/`users` dicts too, so keying on `id(obj)`
misses the cache across tasks. A content fingerprint hits across tasks
whenever the inventory payload is unchanged.
Fast path: id()-keyed memo (within a single task the same dict instance is
typically reused for multiple lookups, so we avoid re-hashing).
Slow path: repr-based MD5. Non-mapping values collapse to an "id:..." tag
so we don't accidentally collide across unrelated types.
"""
if obj is None:
return "0"
obj_id = id(obj)
cached = _FINGERPRINT_BY_ID.get(obj_id)
if cached is not None:
return cached
try:
import hashlib
data = repr(sorted(obj.items())) if isinstance(obj, Mapping) else repr(obj)
digest = hashlib.md5(data.encode("utf-8", errors="replace")).hexdigest()
except Exception:
digest = f"id:{obj_id}"
_FINGERPRINT_BY_ID[obj_id] = digest
return digest
def _stable_variables_signature(variables: Optional[Mapping[str, Any]]) -> tuple:
"""Build a content-based cache signature from the subset of `variables`
that influences the merged applications/users payload.
See `_fingerprint_mapping` for why id()-only keys don't work reliably.
"""
if not variables:
return ("0", "0", "", "")
return (
_fingerprint_mapping(variables.get("applications")),
_fingerprint_mapping(variables.get("users")),
str(variables.get("DOMAIN_PRIMARY") or ""),
str(variables.get("SYSTEM_EMAIL_DOMAIN") or ""),
)
def _tokens_file_signature(path: Path) -> tuple:
"""Return a cheap stat-based signature for the tokens file.
The merged-users cache must invalidate whenever sys-token-store persists a
new token — otherwise downstream `lookup('users', ...)` returns stale tokens
within the same play. stat() is cheap and captures in-place writes.
"""
try:
st = path.stat()
except (FileNotFoundError, OSError):
return (str(path), 0, 0)
return (str(path), st.st_mtime_ns, st.st_size)
def _load_yaml_mapping(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} must contain a YAML mapping")
return data
def _load_yaml_variant_list(path: Path) -> list[dict[str, Any]]:
"""Load a `roles/<role>/meta/variants.yml` variant list.
Each entry is a deep-merge override for the role's
`config/main.yml`; `null` and `{}` are valid no-op entries. Missing
file or empty list collapses to a single empty variant so the
role behaves exactly like before this layer was introduced.
"""
if not path.exists():
return [{}]
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if raw is None:
return [{}]
if not isinstance(raw, list):
raise ValueError(
f"{path} must contain a YAML list of override mappings (or be empty)."
)
if not raw:
return [{}]
normalised: list[dict[str, Any]] = []
for index, entry in enumerate(raw):
if entry is None:
normalised.append({})
elif isinstance(entry, Mapping):
normalised.append(dict(entry))
else:
raise ValueError(
f"{path}[{index}] must be a mapping (or null); got {type(entry).__name__}"
)
return normalised
def _deep_merge(base: Any, override: Any) -> Any:
if isinstance(base, Mapping) and isinstance(override, Mapping):
merged = {k: copy.deepcopy(v) for k, v in base.items()}
for key, value in override.items():
merged[key] = _deep_merge(merged.get(key), value)
return merged
return copy.deepcopy(override)
def _merge_users(
defaults: Mapping[str, Any],
overrides: Optional[Mapping[str, Any]],
) -> dict[str, Any]:
merged = {key: copy.deepcopy(value) for key, value in defaults.items()}
for key, value in (overrides or {}).items():
merged[key] = _deep_merge(merged.get(key, {}), value)
return merged
def _resolve_roles_dir(*, roles_dir: Optional[str | os.PathLike[str]] = None) -> Path:
return Path(roles_dir).resolve() if roles_dir else ROLES_DIR.resolve()
def _compute_reserved_usernames(roles_dir: Path) -> list[str]:
reserved: set[str] = set()
for role_dir in roles_dir.iterdir():
if not role_dir.is_dir():
continue
candidate = role_dir.name.rsplit("-", 1)[-1]
if candidate.isalnum() and candidate.islower():
reserved.add(candidate)
return sorted(reserved)
def _load_user_defs(roles_dir: Path) -> OrderedDict[str, dict[str, Any]]:
pattern = os.path.join(str(roles_dir), "*/users/main.yml")
files = sorted(glob.glob(pattern))
merged: OrderedDict[str, dict[str, Any]] = OrderedDict()
for filepath in files:
data = _load_yaml_mapping(Path(filepath))
users = data.get("users", {})
if not isinstance(users, dict):
continue
for key, overrides in users.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid definition for user '{key}' in {filepath}")
if key not in merged:
merged[key] = copy.deepcopy(overrides)
continue
existing = merged[key]
for field, value in overrides.items():
if field in existing and existing[field] != value:
raise ValueError(
f"Conflict for user '{key}': field '{field}' has existing value "
f"'{existing[field]}', tried to set '{value}' in {filepath}"
)
existing.update(copy.deepcopy(overrides))
return merged
def _build_users(
defs: OrderedDict[str, dict[str, Any]],
primary_domain: str,
start_id: int,
become_pwd: str,
) -> OrderedDict[str, dict[str, Any]]:
users: OrderedDict[str, dict[str, Any]] = OrderedDict()
used_uids = set()
for key, overrides in defs.items():
if "uid" in overrides:
uid = overrides["uid"]
if uid in used_uids:
raise ValueError(f"Duplicate uid {uid} for user '{key}'")
used_uids.add(uid)
next_uid = start_id
def allocate_uid() -> int:
nonlocal next_uid
while next_uid in used_uids:
next_uid += 1
free_uid = next_uid
used_uids.add(free_uid)
next_uid += 1
return free_uid
for key, overrides in defs.items():
username = overrides.get("username", key)
firstname = overrides.get("firstname", f"{username}")
lastname = overrides.get("lastname", f"{primary_domain}")
email = overrides.get("email", f"{username}@{primary_domain}")
description = overrides.get(
"description", f"Created by Infinito.Nexus Ansible for {primary_domain}"
)
roles = overrides.get("roles", [])
password = overrides.get("password", become_pwd)
reserved = overrides.get("reserved", False)
tokens = overrides.get("tokens", {})
authorized_keys = overrides.get("authorized_keys", [])
uid = overrides["uid"] if "uid" in overrides else allocate_uid()
gid = overrides.get("gid", uid)
users[key] = {
"username": username,
"firstname": firstname,
"lastname": lastname,
"email": email,
"password": password,
"uid": uid,
"gid": gid,
"roles": roles,
"tokens": tokens,
"authorized_keys": authorized_keys,
"reserved": reserved,
"description": description,
}
seen_usernames: set[str] = set()
seen_emails: set[str] = set()
for key, entry in users.items():
username = entry["username"]
email = entry["email"]
if username in seen_usernames:
raise ValueError(f"Duplicate username '{username}' in merged users")
if email in seen_emails:
raise ValueError(f"Duplicate email '{email}' in merged users")
seen_usernames.add(username)
seen_emails.add(email)
return users
def _load_store_users(file_tokens: Optional[str | os.PathLike[str]]) -> dict[str, Any]:
if not file_tokens:
return {}
path = Path(file_tokens)
if not path.exists():
return {}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return {}
users = data.get("users", {})
return users if isinstance(users, dict) else {}
def _resolve_tokens_file(variables: Optional[Mapping[str, Any]]) -> Path:
candidates: list[Path] = []
def _add_candidate(value: Any) -> None:
if value is None:
return
text = str(value).strip()
if text:
candidates.append(Path(text))
variables = variables or {}
_add_candidate(variables.get("FILE_TOKENS"))
dir_secrets = variables.get("DIR_SECRETS")
if dir_secrets:
_add_candidate(Path(str(dir_secrets)) / "tokens.yml")
dir_var_lib = variables.get("DIR_VAR_LIB")
if dir_var_lib:
_add_candidate(Path(str(dir_var_lib)) / "secrets" / "tokens.yml")
_add_candidate(os.environ.get("FILE_TOKENS"))
env_dir_secrets = os.environ.get("DIR_SECRETS")
if env_dir_secrets:
_add_candidate(Path(env_dir_secrets) / "tokens.yml")
env_dir_var_lib = os.environ.get("DIR_VAR_LIB")
if env_dir_var_lib:
_add_candidate(Path(env_dir_var_lib) / "secrets" / "tokens.yml")
candidates.append(DEFAULT_TOKENS_FILE)
seen: set[Path] = set()
for candidate in candidates:
if candidate in seen:
continue
seen.add(candidate)
if candidate.exists():
return candidate
return candidates[0]
def _hydrate_users_tokens(
users: Optional[Mapping[str, Any]],
store_users: Optional[Mapping[str, Any]],
) -> dict[str, Any]:
def _as_stripped(value: Any) -> Optional[str]:
if value is None:
return None
return str(value).strip()
def _is_effectively_empty(value: Any) -> bool:
stripped = _as_stripped(value)
return stripped is None or stripped == ""
out: dict[str, Any] = copy.deepcopy(dict(users or {}))
if not store_users:
return out
for user_key, user_data in store_users.items():
if not isinstance(user_data, Mapping):
continue
store_tokens = user_data.get("tokens", {})
if not isinstance(store_tokens, Mapping):
continue
out_user = copy.deepcopy(dict(out.get(user_key, {}) or {}))
out_tokens = copy.deepcopy(dict(out_user.get("tokens", {}) or {}))
for app_id, store_token in store_tokens.items():
token = _as_stripped(store_token)
if token is None or token == "":
continue
if _is_effectively_empty(out_tokens.get(app_id)):
out_tokens[app_id] = token
out_user["tokens"] = out_tokens
out[user_key] = out_user
return out
def _materialize_builtin_user_aliases(
users: Optional[Mapping[str, Any]],
variables: Optional[Mapping[str, Any]],
templar: Any = None,
) -> dict[str, Any]:
def _normalize_domain_candidate(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
if ("{{" in text or "{%" in text) and variables:
text = str(
_templar_render_best_effort(templar, text, dict(variables))
).strip()
if "://" in text:
parsed = urlparse(text)
text = parsed.hostname or text
text = text.split("/", 1)[0].split(":", 1)[0].strip()
return text
def _to_primary_domain(value: Any) -> str:
text = _normalize_domain_candidate(value)
if not text:
return ""
labels = [label for label in text.split(".") if label]
if len(labels) >= 2:
return ".".join(labels[-2:])
return text
out: dict[str, Any] = copy.deepcopy(dict(users or {}))
variables = variables or {}
primary_domain = ""
for candidate_key, extractor in (
("DOMAIN_PRIMARY", _normalize_domain_candidate),
("SYSTEM_EMAIL_DOMAIN", _normalize_domain_candidate),
("KEYCLOAK_DOMAIN", _to_primary_domain),
("domain", _to_primary_domain),
):
primary_domain = extractor(variables.get(candidate_key))
if primary_domain:
break
if not primary_domain:
return out
labels = [label for label in primary_domain.split(".") if label]
alias_values = {
"sld": labels[0] if labels else primary_domain,
"tld": (labels[1] if len(labels) > 1 else (primary_domain + "_tld ")),
}
for alias_key, alias_value in alias_values.items():
raw_user = out.get(alias_key)
if not isinstance(raw_user, Mapping):
continue
raw_username = str(raw_user.get("username", ""))
if "DOMAIN_PRIMARY.split" not in raw_username:
continue
updated_user = copy.deepcopy(dict(raw_user))
updated_user["username"] = alias_value
out[alias_key] = updated_user
return out
def _resolve_override_mapping(
variables: Optional[Mapping[str, Any]],
key: str,
templar: Any = None,
) -> dict[str, Any]:
"""Return runtime override mappings defensively.
Nested `lookup('template', ...)` renders sometimes expose top-level lookup
inputs like `applications`/`users` as non-mapping placeholder values instead
of the original inventory overrides. Try to coerce via templar before
falling back to aggregated defaults.
"""
variables = variables or {}
value = variables.get(key, {})
if value is None:
value = {}
if not isinstance(value, Mapping) and templar is not None:
try:
rendered = templar.template(value, fail_on_undefined=False)
except TypeError:
try:
rendered = templar.template(value)
except Exception:
rendered = value
except Exception:
rendered = value
if isinstance(rendered, Mapping):
value = rendered
if not isinstance(value, Mapping):
raw_key = {
"applications": "_INFINITO_APPLICATIONS_RAW",
"users": "_INFINITO_USERS_RAW",
}.get(key)
if raw_key:
raw = variables.get(raw_key)
if isinstance(raw, Mapping):
value = raw
if isinstance(value, Mapping):
return dict(value)
return {}
def _render_with_templar(
value: Any,
*,
templar: Any,
variables: Optional[dict[str, Any]],
raw_applications: Optional[dict[str, Any]] = None,
raw_users: Optional[dict[str, Any]] = None,
max_rounds: int = 4,
) -> Any:
if templar is None:
return value
# Start from whatever the templar already had available so that
# ansible_facts/hostvars stay accessible during nested renders. Overlay the
# caller-supplied variables on top, then inject our raw.*_RAW helpers.
prev_templar_avail = getattr(templar, "available_variables", None)
base_variables: dict[str, Any] = (
dict(prev_templar_avail) if prev_templar_avail else {}
)
if variables:
base_variables.update(variables)
if raw_applications is not None:
base_variables["_INFINITO_APPLICATIONS_RAW"] = raw_applications
if raw_users is not None:
base_variables["_INFINITO_USERS_RAW"] = raw_users
def _render_scalar(raw: Any) -> Any:
if isinstance(raw, str) and "{{" not in raw and "{%" not in raw:
return raw
data = copy.deepcopy(raw)
if isinstance(data, str):
for _ in range(max_rounds):
try:
rendered = _templar_render_best_effort(
templar, data, base_variables
)
except Exception:
return data
if rendered == data:
break
data = rendered
return data
for _ in range(max_rounds):
try:
rendered = templar.template(data, fail_on_undefined=False)
except TypeError:
rendered = templar.template(data)
except Exception:
return data
if rendered == data:
break
data = rendered
return data
def _render_deep(raw: Any) -> Any:
if isinstance(raw, Mapping):
return {key: _render_deep(item) for key, item in raw.items()}
if isinstance(raw, list):
return [_render_deep(item) for item in raw]
if isinstance(raw, tuple):
return tuple(_render_deep(item) for item in raw)
return _render_scalar(raw)
try:
if hasattr(templar, "available_variables"):
templar.available_variables = base_variables
data = _render_deep(value)
finally:
if hasattr(templar, "available_variables"):
templar.available_variables = prev_templar_avail
return _decrypt_ansible_encrypted_strings(data)
def _build_role_base_config(
role_dir: Path,
roles_dir: Path,
gid_lookup: ApplicationGidLookup,
) -> dict[str, Any]:
"""Return the post-augmentation `config/main.yml` payload for a
role: `group_id` resolved, `users` references rewritten to
`lookup('users', ...)`. Empty config collapses to `{}` (no
overrides applied)."""
application_id = role_dir.name
config_data = _load_yaml_mapping(role_dir / "config" / "main.yml")
if not config_data:
return {}
group_id = gid_lookup.run([application_id], roles_dir=str(roles_dir))[0]
config_data["group_id"] = group_id
users_meta = _load_yaml_mapping(role_dir / "users" / "main.yml")
users_data = users_meta.get("users", {})
if isinstance(users_data, dict) and users_data:
config_data["users"] = {
user_key: "{{ lookup('users', " + repr(user_key) + ") }}"
for user_key in users_data
}
return config_data
def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
"""Return ``{application_id: [variant_0, variant_1, ...]}``.
Each variant is the role's `config/main.yml` payload deep-merged
with the corresponding entry from
`roles/<role>/meta/variants.yml`. A missing/empty
`meta/variants.yml` collapses to a single empty variant so the
role keeps its pre-variant behaviour. The single-variant case is
equivalent to the legacy `_build_application_defaults` output.
"""
gid_lookup = ApplicationGidLookup()
variants: dict[str, list[Any]] = {}
for config_file in sorted(roles_dir.glob("*/config/main.yml")):
role_dir = config_file.parents[1]
application_id = role_dir.name
base_config = _build_role_base_config(role_dir, roles_dir, gid_lookup)
meta_path = role_dir / "meta" / "variants.yml"
override_list = _load_yaml_variant_list(meta_path)
role_variants: list[Any] = []
for override in override_list:
if base_config:
role_variants.append(_deep_merge(base_config, override))
else:
# Role has no config/main.yml payload, but a variant list
# MAY still legitimately produce an override-only result
# (e.g. when a role declares its inventory entirely from
# meta/variants.yml). Fall back to a deep copy of the
# override so callers never observe shared mutable state.
role_variants.append(copy.deepcopy(override))
variants[application_id] = role_variants
return {key: variants[key] for key in sorted(variants)}
def _build_application_defaults(roles_dir: Path) -> dict[str, Any]:
"""Backward-compatible shim: every consumer that historically saw
one mapping per application now sees the FIRST variant (index 0).
The full list is exposed via :func:`get_variants`."""
return {
application_id: copy.deepcopy(variant_list[0])
for application_id, variant_list in _build_variants(roles_dir).items()
}
def get_application_defaults(
*, roles_dir: Optional[str | os.PathLike[str]] = None
) -> dict[str, Any]:
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
key = _cache_key(resolved_roles_dir)
cached = _APPLICATIONS_DEFAULTS_CACHE.get(key)
if cached is None:
cached = _build_application_defaults(resolved_roles_dir)
_APPLICATIONS_DEFAULTS_CACHE[key] = cached
return copy.deepcopy(cached)
def get_variants(
*, roles_dir: Optional[str | os.PathLike[str]] = None
) -> dict[str, list[Any]]:
"""Return ``{application_id: [variant_0, ...]}`` cached per
``roles_dir``. Each variant is the role's effective configuration
after the corresponding `meta/variants.yml` override has been
deep-merged on top of `config/main.yml`."""
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
key = _cache_key(resolved_roles_dir)
cached = _VARIANTS_CACHE.get(key)
if cached is None:
cached = _build_variants(resolved_roles_dir)
_VARIANTS_CACHE[key] = cached
return copy.deepcopy(cached)
def get_user_defaults(
*, roles_dir: Optional[str | os.PathLike[str]] = None
) -> dict[str, Any]:
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
key = _cache_key(resolved_roles_dir)
cached = _USERS_DEFAULTS_CACHE.get(key)
if cached is None:
definitions = _load_user_defs(resolved_roles_dir)
for reserved_username in _compute_reserved_usernames(resolved_roles_dir):
if reserved_username not in definitions:
definitions[reserved_username] = {"reserved": True}
built = _build_users(
definitions,
primary_domain="{{ DOMAIN_PRIMARY }}",
start_id=1001,
become_pwd="{{ 42 | strong_password }}",
)
cached = {key: built[key] for key in sorted(built)}
_USERS_DEFAULTS_CACHE[key] = cached
return copy.deepcopy(cached)
def get_merged_applications(
*,
variables: Optional[dict[str, Any]] = None,
roles_dir: Optional[str | os.PathLike[str]] = None,
templar: Any = None,
) -> dict[str, Any]:
variables = variables or {}
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
cache_key = (
_cache_key(resolved_roles_dir),
_stable_variables_signature(variables),
)
cached = _MERGED_APPLICATIONS_CACHE.get(cache_key)
if cached is not None:
return cached
# Defaults always come from variant 0 (= the legacy `config/main.yml`
# payload, deep-merged with the empty `{}` entry). Per-round variant
# overrides are baked into the inventory's `applications.<app>` block at
# init time (see `cli.deploy.development.inventory.build_dev_inventory`)
# and applied below as overrides on top of these defaults, so the deploy
# stage needs no runtime variant selector: the inventory itself is
# variant-resolved.
defaults = get_application_defaults(roles_dir=resolved_roles_dir)
overrides = _resolve_override_mapping(variables, "applications", templar=templar)
merged = merge_with_defaults(defaults, overrides)
if getattr(_RENDER_GUARD, "applications", False):
# Re-entry via cross-lookup: return unrendered merged payload; the
# outer templar will resolve remaining Jinja at use-site.
return merged
_RENDER_GUARD.applications = True
try:
raw_users = get_merged_users(
variables=variables,
roles_dir=roles_dir,
templar=None,
)
rendered = _render_with_templar(
merged,
templar=templar,
variables=variables,
raw_applications=merged,
raw_users=raw_users,
)
finally:
_RENDER_GUARD.applications = False
_MERGED_APPLICATIONS_CACHE[cache_key] = rendered
return rendered
def get_merged_users(
*,
variables: Optional[dict[str, Any]] = None,
roles_dir: Optional[str | os.PathLike[str]] = None,
templar: Any = None,
) -> dict[str, Any]:
source_variables = variables
variables = dict(variables or {})
if not variables.get("DOMAIN_PRIMARY") and variables.get("SYSTEM_EMAIL_DOMAIN"):
variables["DOMAIN_PRIMARY"] = variables["SYSTEM_EMAIL_DOMAIN"]
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
tokens_file = _resolve_tokens_file(variables)
cache_key = (
_cache_key(resolved_roles_dir),
_stable_variables_signature(source_variables),
_tokens_file_signature(tokens_file),
)
cached = _MERGED_USERS_CACHE.get(cache_key)
if cached is not None:
return cached
defaults = get_user_defaults(roles_dir=roles_dir)
overrides = _resolve_override_mapping(variables, "users", templar=templar)
merged = _merge_users(defaults, overrides)
hydrated = _hydrate_users_tokens(
merged,
_load_store_users(tokens_file),
)
if getattr(_RENDER_GUARD, "users", False):
# Re-entry via cross-lookup: skip the heavy materialize+render pass.
return hydrated
_RENDER_GUARD.users = True
try:
materialized = _materialize_builtin_user_aliases(
hydrated,
variables,
templar=templar,
)
rendered = _render_with_templar(
materialized,
templar=templar,
variables=variables,
raw_users=materialized,
)
finally:
_RENDER_GUARD.users = False
_MERGED_USERS_CACHE[cache_key] = rendered
return rendered
def get_merged_domains(
*,
variables: Optional[dict[str, Any]] = None,
roles_dir: Optional[str | os.PathLike[str]] = None,
templar: Any = None,
) -> dict[str, Any]:
"""Build the canonical-domain map lazily from the merged applications view.
The result is canonical_domains_map(applications, DOMAIN_PRIMARY).
Per-app domain overrides belong in `applications.<app>.server.domains`
(canonical/aliases) — they flow through the regular applications-merge
pipeline rather than a parallel top-level `domains` escape hatch.
Cached keyed on (roles_dir, variables_signature).
"""
from plugins.filter.canonical_domains_map import (
FilterModule as _CanonicalDomainsFilter,
)
variables = variables or {}
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
cache_key = (
_cache_key(resolved_roles_dir),
_stable_variables_signature(variables),
)
cached = _MERGED_DOMAINS_CACHE.get(cache_key)
if cached is not None:
return cached
primary_domain = (
variables.get("DOMAIN_PRIMARY") or variables.get("SYSTEM_EMAIL_DOMAIN") or ""
)
if not primary_domain:
raise ValueError(
"get_merged_domains: DOMAIN_PRIMARY (or SYSTEM_EMAIL_DOMAIN fallback) "
"must be set in variables."
)
apps = get_merged_applications(
variables=variables,
roles_dir=roles_dir,
templar=templar,
)
filter_instance = _CanonicalDomainsFilter()
merged = filter_instance.canonical_domains_map(apps, primary_domain)
_MERGED_DOMAINS_CACHE[cache_key] = merged
return merged
def _reset_cache_for_tests() -> None:
_APPLICATIONS_DEFAULTS_CACHE.clear()
_VARIANTS_CACHE.clear()
_USERS_DEFAULTS_CACHE.clear()
_MERGED_APPLICATIONS_CACHE.clear()
_MERGED_USERS_CACHE.clear()
_MERGED_DOMAINS_CACHE.clear()
_FINGERPRINT_BY_ID.clear()