Files
computer-playbook/utils/cache/base.py
T
kevinveenbirkenbachandClaude Opus 5.5 67f6c8d7e8 fix(cache): keep the applications carrier valid across token store writes
Since 778c18fb23 the carrier key holds the tokens file's mtime and size.
sys-token-store changed that stat on every inclusion (00_core.yml
touched the file) and on every persisted token, and nothing re-parked a
carrier afterwards. Every later task's fresh worker then rendered the
applications SPOT cold, 30 to 80 s each. On main fea6f31d9f (run
38812964308) seven heavy jobs logged 194 to 287 such tasks and ran out
of the 20400 s budget; the same jobs before 778c18fb23 logged 65 to 83,
all of them real waits.

- 00_core.yml touches the store with modification_time and access_time
  preserved, so it only creates a missing file. In swarm the controller
  sees the store over NFS with attribute caching, and the touch alone
  made the second pass go cold without any write.
- write.yml re-parks the carrier when the persist or the controller
  mirror changed the store. Both run once, so every host that ran the
  include gets the same verdict.
- tasks/utils/setup/role.yml re-parks after every role on every host,
  so hosts that did not run the writing role recover too. Where the
  key still matches this serves the carried payload; measured 0.1 s per
  role in a local compose deploy.
- The tokens file enters the key as the SHA-256 of its content,
  memoised per inode, mtime and size, so a touch no longer changes the
  key. A stat that still matches serves the memo (1.67 us against
  1.41 us for the old stat signature), and a stale stat keeps key and
  YAML cache on the same old content instead of pairing new content
  with an old key.
- applications_carrier opens the tokens file before it computes the
  key, so on the NFS-mounted controller the parked key reflects the
  revalidated attributes rather than a cached stat that changes a
  minute later.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 11:11:47 +02:00

321 lines
11 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 hashlib
import os
import threading
from collections.abc import Mapping
from pathlib import Path
from typing import 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
_TOKENS_DIGESTS: dict[tuple, str] = {}
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.
`group_names` belongs in the key because a service flag may be written as
`{{ '<role>' in group_names }}`, so two hosts of one play render different
payloads from identical host_vars. Ansible forks its workers, so a payload
rendered in the strategy's main process for one host is inherited by every
later worker; without this component that payload answers for hosts whose
groups say otherwise.
See `_fingerprint_mapping` for why id()-only keys don't work reliably.
"""
if not variables:
return ("0", "0", "", "", "")
groups = variables.get("group_names")
return (
_fingerprint_mapping(variables.get("applications")),
_fingerprint_mapping(variables.get("users")),
str(variables.get("DOMAIN_PRIMARY") or ""),
str(variables.get("SYSTEM_EMAIL_DOMAIN") or ""),
",".join(sorted(str(group) for group in groups)) if groups else "",
)
def _stat_identity(st: os.stat_result) -> tuple:
return (st.st_ino, st.st_mtime_ns, st.st_size)
def _tokens_file_signature(path: Path) -> tuple:
"""Return ``(path, sha256 of the content)`` for the tokens file.
Args:
path: the tokens file.
Returns:
``(str(path), "")`` when the file cannot be read.
"""
try:
identity = (str(path), *_stat_identity(path.stat()))
except OSError:
return (str(path), "")
digest = _TOKENS_DIGESTS.get(identity)
if digest is None:
try:
with path.open("rb") as handle:
identity = (str(path), *_stat_identity(os.fstat(handle.fileno())))
digest = hashlib.sha256(handle.read()).hexdigest()
except OSError:
return (str(path), "")
_TOKENS_DIGESTS[identity] = digest
return (str(path), digest)
def _revalidate_tokens_file(path: Path) -> None:
"""Open and close the tokens file so an NFS client refreshes its attributes.
Args:
path: the tokens file.
"""
try:
with path.open("rb"):
pass
except OSError:
return
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:
"""Clear the tokens-file digest memo."""
_TOKENS_DIGESTS.clear()