Files
computer-playbook/utils/domains/application_domain_index.py
kevinveenbirkenbachandClaude Opus 5 064f2fc4c7 fix(cache): drop unsound id-keyed fingerprint memo
_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>
2026-08-06 10:27:04 +02:00

151 lines
4.0 KiB
Python

#
# Shared helpers for indexing application domains from
# applications[app_id].domains.{canonical,aliases}.
#
# Supports canonical/aliases being:
# - str
# - list
# - dict (possibly nested, like "web/api/view" variants)
#
# Note:
# This module does NOT template Jinja. It expects values to already
# be rendered at lookup runtime.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible.errors import AnsibleError
if TYPE_CHECKING:
from collections.abc import Iterable
_DOMAIN_INDEX_CACHE: dict[tuple, dict[str, str]] = {}
def _norm_domain(value: Any) -> str:
s = str(value).strip() if value is not None else ""
return s.lower()
def _flatten_str_values(value: Any) -> list[str]:
"""
Recursively flatten strings from value.
Accepts str | list | dict | nested combinations.
Returns list[str] preserving discovery order (best effort).
"""
out: list[str] = []
def walk(v: Any) -> None:
if v is None:
return
if isinstance(v, str):
s = v.strip()
if s:
out.append(s)
return
if isinstance(v, list):
for item in v:
walk(item)
return
if isinstance(v, dict):
for vv in v.values():
walk(vv)
return
walk(value)
return out
def iter_app_domains(app_conf: Any, include_aliases: bool = True) -> Iterable[str]:
"""
Yield all canonical + alias domains from an app config.
Expected structure:
applications[app_id].domains.canonical
applications[app_id].domains.aliases
"""
if not isinstance(app_conf, dict):
return []
domains = app_conf.get("domains", {})
if not isinstance(domains, dict):
return []
canonical = domains.get("canonical", [])
result: list[str] = []
result.extend(_flatten_str_values(canonical))
if include_aliases:
aliases = domains.get("aliases", [])
result.extend(_flatten_str_values(aliases))
return result
def build_domain_index(
applications: dict[str, Any], include_aliases: bool = True
) -> dict[str, str]:
"""
Build a case-insensitive domain -> application_id index.
If the same domain appears in multiple apps (case-insensitive), raises an error.
Cached keyed on (applications fingerprint, include_aliases); the result is
returned as-is, so callers MUST treat it as read-only.
"""
if not isinstance(applications, dict):
raise AnsibleError("application_domain_index: applications must be a dict")
from utils.cache.base import _fingerprint_mapping
cache_key = (_fingerprint_mapping(applications), bool(include_aliases))
cached = _DOMAIN_INDEX_CACHE.get(cache_key)
if cached is not None:
return cached
index: dict[str, str] = {}
collisions: dict[str, set[str]] = {}
for app_id, app_conf in applications.items():
for d in iter_app_domains(app_conf, include_aliases=include_aliases):
nd = _norm_domain(d)
if not nd:
continue
if nd in index and index[nd] != app_id:
collisions.setdefault(nd, set()).update({index[nd], app_id})
else:
index[nd] = app_id
if collisions:
parts = []
for domain, apps in sorted(collisions.items(), key=lambda x: x[0]):
parts.append(f"{domain}: {sorted(apps)}")
raise AnsibleError(
"application_domain_index: domain collision across applications (ambiguous mapping): "
+ "; ".join(parts)
)
_DOMAIN_INDEX_CACHE[cache_key] = index
return index
def _reset() -> None:
_DOMAIN_INDEX_CACHE.clear()
def resolve_app_id_for_domain(
applications: dict[str, Any],
domain: str,
) -> str | None:
"""
Resolve application_id for a given domain (canonical or alias) by scanning
applications[*].domains.
Returns None if not found.
"""
nd = _norm_domain(domain)
if not nd:
return None
idx = build_domain_index(applications)
return idx.get(nd)