mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
_fingerprint_mapping memoised digests under id(obj) while holding no reference to obj, so CPython reused the address of a freed mapping and served its digest for an unrelated live one. Measured 398/400 wrong digests for short-lived mappings; in-place mutation was invisible and the domain-index collision guard could be bypassed entirely. Two unit tests pinned that behaviour as the contract — test_id_cache_short_circuits_recompute asserted that mutating a dict in place leaves its fingerprint unchanged — so the defect shipped green. Both now assert the opposite. build_domain_index rebuilt the whole reverse index on every lookup, scanning every application's domains and aliases to answer one key. It is now memoised on that fingerprint, which is only sound because of the fix above. 4x on a 100-app payload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
283 lines
9.5 KiB
Python
283 lines
9.5 KiB
Python
"""Cross-cutting helpers for the `utils.cache.*` package.
|
|
|
|
Holds primitives that the domain modules (`users`, `applications`,
|
|
`domains`) all need but no domain owns: filesystem constants, YAML
|
|
loaders, deep-merge, cache-key + content-fingerprint signatures,
|
|
templar-render machinery, and the cross-domain re-entry guard.
|
|
|
|
Stays import-cheap so that `utils.cache.applications` (consumed on
|
|
GitHub Actions runner hosts that ship without ansible — see CI runs
|
|
24934007615 / 24935979190) can pull this module without dragging
|
|
ansible in. The single ansible-coupled symbol exposed from here is
|
|
`_render_with_templar`, which lazy-imports `utils.templating.ansible`
|
|
only when actually invoked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import threading
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from plugins.filter.merge.with_defaults import (
|
|
merge_with_defaults, # noqa: F401 re-exported
|
|
)
|
|
from utils.paths import FILE_TOKENS
|
|
|
|
from . import PROJECT_ROOT, ROLES_DIR # noqa: F401
|
|
|
|
if TYPE_CHECKING:
|
|
import os
|
|
|
|
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
|
|
|
|
|
|
DEFAULT_TOKENS_FILE = FILE_TOKENS
|
|
|
|
|
|
_RENDER_GUARD = threading.local()
|
|
|
|
|
|
def _cache_key(roles_dir: Path) -> str:
|
|
return str(roles_dir.resolve())
|
|
|
|
|
|
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.
|
|
|
|
repr-based MD5 over the mapping's sorted items.
|
|
|
|
Exception: memoising the digest under ``id(obj)`` is unsound and MUST NOT
|
|
be reintroduced. The memo would hold no reference to *obj*, so CPython
|
|
recycles the address of a freed mapping and serves its digest for an
|
|
unrelated live one — measured at 398/400 wrong digests for short-lived
|
|
mappings, which silently defeats every cache keyed on this function.
|
|
"""
|
|
if obj is None:
|
|
return "0"
|
|
try:
|
|
import hashlib
|
|
|
|
data = repr(sorted(obj.items())) if isinstance(obj, Mapping) else repr(obj)
|
|
return hashlib.md5(
|
|
data.encode("utf-8", errors="replace"),
|
|
usedforsecurity=False,
|
|
).hexdigest()
|
|
except Exception:
|
|
return "unhashable"
|
|
|
|
|
|
def _stable_variables_signature(variables: Mapping[str, Any] | None) -> 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 _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 _resolve_roles_dir(*, roles_dir: str | os.PathLike[str] | None = None) -> Path:
|
|
return Path(roles_dir).resolve() if roles_dir else ROLES_DIR.resolve()
|
|
|
|
|
|
def _resolve_override_mapping(
|
|
variables: Mapping[str, Any] | None,
|
|
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: dict[str, Any] | None,
|
|
raw_applications: dict[str, Any] | None = None,
|
|
raw_users: dict[str, Any] | None = None,
|
|
max_rounds: int = 4,
|
|
) -> Any:
|
|
if templar is None:
|
|
return value
|
|
|
|
from utils.templating.ansible import _templar_render_best_effort
|
|
|
|
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):
|
|
stripped = data.strip()
|
|
if (
|
|
stripped.startswith("{{")
|
|
and stripped.endswith("}}")
|
|
and "{{" not in stripped[2:]
|
|
and "{%" not in stripped
|
|
):
|
|
from utils.templating.ansible import _templar_render_preserve_type
|
|
|
|
try:
|
|
rendered = _templar_render_preserve_type(
|
|
templar, data, base_variables
|
|
)
|
|
except Exception:
|
|
rendered = None
|
|
if rendered is not None and not isinstance(rendered, str):
|
|
return _render_deep(rendered)
|
|
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 _reset() -> None:
|
|
"""No-op kept so the `data._reset_cache_for_tests` facade can call every
|
|
domain module uniformly. This module owns no cache."""
|