Files
computer-playbook/utils/cache/applications.py
T
kevinveenbirkenbachandClaude Opus 5.5 0cb8bae88d perf(cache): let nested lookups reuse the unrendered applications view
Every cold render of the applications SPOT walks the templates of all
269 roles, and those templates call lookup('config'), lookup('tls'),
lookup('domain') and others. Each of these re-enters
get_merged_applications while the render guard is set, and each one
rebuilt the unrendered view from scratch: a deepcopy of all role
defaults plus the override merge, about 8 ms, never cached. A local
cold render of the development inventory made 351 such calls, 2.82 s
of a 6.86 s render.

The outer render now keeps a copy of its unrendered view and its cache
key on the render guard, taken before the inventory volumes are
popped, so it is the same view a nested call used to build. A nested
call with the same key returns it; any other key still builds its own.
The guard drops it when the render ends.

Measured on the same inventory: the nested calls took 0.07 s instead
of 2.82 s and the cold render 3.60 s instead of 6.86 s. The rendered
SPOT is unchanged: the old and new dumps differ in 69 user passwords,
which the development inventory regenerates on every read (two runs of
the old code differ in 68), and in one absolute checkout path, the only
other difference.
In CI each cold render took 40 to 77 s and the heaviest swarm cell of
#950 missed its budget by a few minutes (#984).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 19:52:34 +02:00

488 lines
18 KiB
Python

"""Application-domain cache: variants, defaults, merged lookup.
Owns `_APPLICATIONS_DEFAULTS_CACHE`, `_VARIANTS_CACHE`, and
`_MERGED_APPLICATIONS_CACHE`. Public API: `get_application_defaults`,
`get_variants`, `get_merged_applications`. Strictly ansible-free at
import time so the GitHub Actions runner-host CLI path
(`cli.administration.deploy.development.init` -> `plan_dev_inventory_matrix` ->
`get_variants`) keeps working without ansible installed.
"""
from __future__ import annotations
import copy
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from plugins.filter.merge.with_defaults import merge_with_defaults
from utils.roles.applications.mcp import derive_mcp_presence
from utils.roles.mapping import ROLE_FILE_META_VARIANTS, ROLE_FILE_META_VOLUMES
from .base import (
_RENDER_GUARD,
_cache_key,
_deep_merge,
_render_with_templar,
_resolve_override_mapping,
_resolve_roles_dir,
)
from .carrier import _carried_applications, merged_applications_cache_key
from .schema_credentials import extract_default_credentials
from .yaml import load_yaml as _load_yaml_cached
from .yaml import load_yaml_any as _load_yaml_any_cached
if TYPE_CHECKING:
import os
from pathlib import Path
_APPLICATIONS_DEFAULTS_CACHE: dict[str, dict[str, Any]] = {}
_VARIANTS_CACHE: dict[str, dict[str, list[Any]]] = {}
_VARIANT_OVERRIDES_ONLY_CACHE: dict[str, dict[str, list[dict[str, Any]]]] = {}
_MERGED_APPLICATIONS_CACHE: dict[tuple, dict[str, Any]] = {}
_CANONICAL_VOLUMES_BY_ROLE: dict[str, dict[str, Any]] = {}
_META_TOPICS: tuple[str, ...] = (
"server",
"rbac",
"services",
"volumes",
"csp",
"domains",
"networks",
)
_META_ADDONS_DIR: str = "addons"
_META_NON_GENERIC_TOPICS: frozenset[str] = frozenset(
{"main", "variants", "volumes", "secrets", "users"}
)
def _normalize_addons(addons: Any) -> Any:
"""Normalise the `meta/addons/` map's enable state at load time.
The enable contract (the unified addon contract's enable Decisions) is
materialised here so every consumer reads one already-resolved view instead of
re-deriving it: `required: true` defaults to enabled, an optional addon
defaults to disabled, and an explicitly declared `enabled` value (literal
or Jinja) is preserved verbatim. `required` itself is defaulted to
`false` so downstream reads stay uniform.
A new dict is returned per entry; the YAML cache's payload is never
mutated. Malformed (non-mapping) payloads pass through untouched so the
schema lint reports the offence with a precise message.
"""
if not isinstance(addons, Mapping):
return addons
out: dict[str, Any] = {}
for addon_id, spec in addons.items():
if not isinstance(spec, Mapping):
out[addon_id] = spec
continue
normalised = dict(spec)
required = bool(normalised.get("required", False))
normalised["required"] = required
if "enabled" not in normalised:
normalised["enabled"] = required
out[addon_id] = normalised
return out
def _load_addons_dir(meta_dir: Path) -> dict[str, Any]:
"""Assemble the `addons` map from `meta/addons/<addon_id>.yml` files.
Each file's root IS the addon spec; the filename stem is the addon id.
The enable state is normalised (see `_normalize_addons`). A missing or
empty `meta/addons/` directory yields ``{}``.
"""
addons_dir = meta_dir / _META_ADDONS_DIR
if not addons_dir.is_dir():
return {}
raw: dict[str, Any] = {}
for path in sorted(addons_dir.glob("*.yml")):
spec = _load_yaml_cached(path, default_if_missing={})
if spec:
raw[path.stem] = spec
return _normalize_addons(raw)
def _has_application_metadata(role_dir: Path) -> bool:
"""Detect whether a role behaves as an application.
A role is an "application" iff at least one of its `meta/<topic>.yml`
files exists (plus `meta/secrets.yml` and `meta/users.yml` for the
schema-only / users-only special cases).
"""
meta_dir = role_dir / "meta"
if not meta_dir.is_dir():
return False
for topic in _META_TOPICS:
if (meta_dir / f"{topic}.yml").is_file():
return True
if (meta_dir / "secrets.yml").is_file():
return True
if (meta_dir / "users.yml").is_file():
return True
addons_dir = meta_dir / _META_ADDONS_DIR
return addons_dir.is_dir() and any(addons_dir.glob("*.yml"))
def _assign_nested(root: dict[str, Any], parts: tuple[str, ...], value: Any) -> None:
"""Place *value* at the nested key path *parts* inside *root*, mirroring
the `meta/` directory layout onto `applications.<app>`: ``("addons", "x")``
lands at ``root["addons"]["x"]``. A non-dict value already sitting on an
intermediate segment is replaced by a fresh dict so a deeper file always
wins its own path.
"""
node = root
for key in parts[:-1]:
child = node.get(key)
if not isinstance(child, dict):
child = {}
node[key] = child
node = child
node[parts[-1]] = value
def _build_role_base_config(
role_dir: Path,
roles_dir: Path,
) -> dict[str, Any]:
"""Assemble a role's effective `applications.<app>` payload by walking its
entire `meta/` tree.
Every `meta/**/*.yml` maps to `applications.<app>.<relpath>` (directory
segments become nested keys, the filename stem the leaf), so nested topics
work without per-topic wiring:
`meta/server.yml` → `server`
`meta/rbac.yml` → `rbac`
`meta/services.yml` → `services`
`meta/info.yml` → `info`
`meta/addons/x.yml` → `addons.x`
`meta/a/b/c.yml` → `a.b.c` (any depth)
Bespoke topics are reshaped instead of placed verbatim:
`meta/main.yml`, `meta/variants.yml` → skipped (ansible/build mechanism).
`meta/volumes.yml` → canonical dict-of-dicts parked in
`_CANONICAL_VOLUMES_BY_ROLE` (kept OUT of this payload
so its raw Jinja `source:` strings never hit the
templar; `get_merged_applications` re-attaches them).
`meta/addons/*.yml` → `addons` (enable state normalised by
`_normalize_addons`).
`meta/secrets.yml` → `credentials` (only literal `default:` values;
non-defaults come from the inventory apply_schema step
and are merged in by the caller).
`meta/users.yml` → `users` (rewritten to `lookup('users', ...)` so the
user-domain cache stays the source of truth).
Empty role collapses to ``{}`` (no overrides applied).
"""
from plugins.lookup.application_gid import compute_application_gid
application_id = role_dir.name
config_data: dict[str, Any] = {}
meta_dir = role_dir / "meta"
if not meta_dir.is_dir():
return {}
for path in sorted(meta_dir.rglob("*.yml")):
parts = path.relative_to(meta_dir).with_suffix("").parts
if parts[0] == _META_ADDONS_DIR:
continue
if len(parts) == 1 and parts[0] in _META_NON_GENERIC_TOPICS:
continue
data = _load_yaml_cached(path, default_if_missing={})
if data:
_assign_nested(config_data, parts, data)
volumes_data = _load_yaml_any_cached(
meta_dir / "volumes.yml", default_if_missing={}
)
if isinstance(volumes_data, dict) and volumes_data:
_CANONICAL_VOLUMES_BY_ROLE[role_dir.name] = volumes_data
addons_data = _load_addons_dir(meta_dir)
if addons_data:
config_data["addons"] = addons_data
schema_data = _load_yaml_cached(meta_dir / "secrets.yml", default_if_missing={})
if schema_data:
creds_defaults = extract_default_credentials(
schema_data.get("credentials") or {}
)
if creds_defaults:
config_data.setdefault("secrets", {})["credentials"] = creds_defaults
users_meta = _load_yaml_cached(meta_dir / "users.yml", default_if_missing={})
if isinstance(users_meta, dict) and users_meta:
config_data["users"] = {
user_key: "{{ lookup('users', " + repr(user_key) + ") }}"
for user_key in users_meta
}
if not config_data:
return {}
config_data["group_id"] = compute_application_gid(application_id, str(roles_dir))
return config_data
def _iter_application_role_dirs(roles_dir: Path):
"""Yield application role directories in deterministic alphabetical order."""
for child in sorted(p for p in roles_dir.iterdir() if p.is_dir()):
if _has_application_metadata(child):
yield child
def _load_variants_overrides(path: Path) -> list[dict[str, Any]]:
"""Load a `roles/<role>/meta/variants.yml` variant list through the
shared YAML cache.
Each entry is a deep-merge override for the role's
`meta/services.yml`; ``null`` and ``{}`` are valid no-op entries.
Missing file or empty list collapses to a single empty variant so
the role keeps its pre-variant behaviour.
"""
if not path.exists():
return [{}]
raw = _load_yaml_any_cached(path)
if raw in (None, {}):
return [{}]
if not isinstance(raw, list):
raise TypeError(
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 _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
"""Return ``{application_id: [variant_0, variant_1, ...]}``.
Each variant is the role's assembled `meta/<topic>.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.
"""
variants: dict[str, list[Any]] = {}
bases: dict[str, Any] = {}
for role_dir in _iter_application_role_dirs(roles_dir):
application_id = role_dir.name
base_config = _build_role_base_config(role_dir, roles_dir)
bases[application_id] = base_config
meta_path = role_dir / ROLE_FILE_META_VARIANTS
override_list = _load_variants_overrides(meta_path)
role_variants: list[Any] = []
for override in override_list:
if base_config:
role_variants.append(_deep_merge(base_config, override))
else:
role_variants.append(copy.deepcopy(override))
variants[application_id] = role_variants
derive_mcp_presence(bases)
for application_id, role_variants in variants.items():
derived = (bases.get(application_id) or {}).get("mcp")
if not isinstance(derived, dict):
continue
for variant_config in role_variants:
block = (
variant_config.get("mcp") if isinstance(variant_config, dict) else None
)
if isinstance(block, dict):
block.setdefault("enabled", derived["enabled"])
block.setdefault("shared", derived["shared"])
return {key: variants[key] for key in sorted(variants)}
def _build_application_defaults(roles_dir: Path) -> dict[str, Any]:
"""Return ``{application_id: base_config}``, each role's variant-free
``meta/<topic>.yml`` payload only. Variants stay accessible via
:func:`get_variants`. Using variant 0 here would leak its service
flags into variant-N consumers' runtime view via deep-merge."""
defaults: dict[str, Any] = {}
for role_dir in _iter_application_role_dirs(roles_dir):
defaults[role_dir.name] = _build_role_base_config(role_dir, roles_dir)
derive_mcp_presence(defaults)
return {key: defaults[key] for key in sorted(defaults)}
def get_application_defaults(
*, roles_dir: str | os.PathLike[str] | None = 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: str | os.PathLike[str] | None = 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 `meta/services.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 _build_variant_overrides_only(
roles_dir: Path,
) -> dict[str, list[dict[str, Any]]]:
overrides: dict[str, list[dict[str, Any]]] = {}
for role_dir in _iter_application_role_dirs(roles_dir):
meta_path = role_dir / ROLE_FILE_META_VARIANTS
overrides[role_dir.name] = _load_variants_overrides(meta_path)
return {key: overrides[key] for key in sorted(overrides)}
def get_variant_overrides_only(
*, roles_dir: str | os.PathLike[str] | None = None
) -> dict[str, list[dict[str, Any]]]:
"""Return ``{application_id: [override_0, ...]}``, the raw
`meta/variants.yml` entries per role WITHOUT deep-merging
`meta/services.yml`.
Used by matrix-deploy's `--vars` bake so the host_vars payload
stays sparse: only the variant-specific overrides land there,
leaving `meta/services.yml` fields (notably `image`/`version`)
blank in host_vars so `apply_mirror_overrides` can populate them
from `mirrors.yml`.
"""
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
key = _cache_key(resolved_roles_dir)
cached = _VARIANT_OVERRIDES_ONLY_CACHE.get(key)
if cached is None:
cached = _build_variant_overrides_only(resolved_roles_dir)
_VARIANT_OVERRIDES_ONLY_CACHE[key] = cached
return copy.deepcopy(cached)
def get_merged_applications(
*,
variables: dict[str, Any] | None = None,
roles_dir: str | os.PathLike[str] | None = None,
templar: Any = None,
) -> dict[str, Any]:
from .users import get_merged_users
variables = variables or {}
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
cache_key = merged_applications_cache_key(variables, roles_dir=resolved_roles_dir)
cached = _MERGED_APPLICATIONS_CACHE.get(cache_key)
if cached is not None:
return cached
carried = _carried_applications(variables, cache_key)
if carried is not None:
_MERGED_APPLICATIONS_CACHE[cache_key] = carried
return carried
guarded = getattr(_RENDER_GUARD, "applications", False)
if guarded:
unrendered = getattr(_RENDER_GUARD, "unrendered_applications", None)
if unrendered is not None and unrendered[0] == cache_key:
return unrendered[1]
defaults = get_application_defaults(roles_dir=resolved_roles_dir)
overrides = _resolve_override_mapping(variables, "applications", templar=templar)
merged = merge_with_defaults(defaults, overrides)
if guarded:
return merged
_RENDER_GUARD.unrendered_applications = (cache_key, copy.deepcopy(merged))
for app_cfg in merged.values():
if isinstance(app_cfg, dict):
app_cfg.pop("volumes", None)
_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
_RENDER_GUARD.unrendered_applications = None
for app_id in rendered:
raw_volumes = get_canonical_volumes(app_id)
if raw_volumes and isinstance(rendered[app_id], dict):
rendered[app_id]["volumes"] = copy.deepcopy(raw_volumes)
_MERGED_APPLICATIONS_CACHE[cache_key] = rendered
return rendered
def get_canonical_volumes(application_id: str) -> dict[str, Any]:
"""Return the canonical dict-of-dicts `meta/volumes.yml` for *application_id*.
Lives outside the applications payload so its embedded Jinja `source:`
strings stay raw — see the `_CANONICAL_VOLUMES_BY_ROLE` doc-comment.
An empty result is cached under its id too, or a role without the file
would re-stat it on every call.
"""
cached = _CANONICAL_VOLUMES_BY_ROLE.get(application_id)
if cached is not None:
return cached
data = _load_yaml_any_cached(
_resolve_roles_dir(roles_dir=None) / application_id / ROLE_FILE_META_VOLUMES,
default_if_missing={},
)
volumes = data if isinstance(data, dict) else {}
_CANONICAL_VOLUMES_BY_ROLE[application_id] = volumes
return volumes
def _reset() -> None:
from plugins.lookup.application_gid import _discover_sorted_application_ids
_APPLICATIONS_DEFAULTS_CACHE.clear()
_VARIANTS_CACHE.clear()
_VARIANT_OVERRIDES_ONLY_CACHE.clear()
_MERGED_APPLICATIONS_CACHE.clear()
_CANONICAL_VOLUMES_BY_ROLE.clear()
_discover_sorted_application_ids.cache_clear()