mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
req-011 — descriptive role-level metadata
* Move logo / homepage / video / display out of
meta/main.yml.galaxy_info into a new project-owned
roles/<role>/meta/info.yml (file-root convention, no `info:` wrapper).
* 78 role meta/main.yml files migrated via ruamel round-trip
(key-order / quoting / inline-comments preserved). Roles with none
of the four fields are left untouched (no empty info.yml created).
* web-app-dashboard/lookup_plugins/docker_cards.py reads logo +
display from meta/info.yml; description / galaxy_tags continue to
come from galaxy_info (Galaxy-spec fields).
* docker_cards.py now routes ALL its file reads through the
process-wide caches: load_yaml_any for vars/main.yml,
meta/main.yml, meta/info.yml; read_text from utils.cache.files for
README.md. The orphan `import yaml` is gone.
New utils/cache/files (renamed from tests/utils/fs)
* The process-wide filesystem cache (read_text, iter_project_files,
iter_project_files_with_content, _all_project_files) is not
test-specific; it now lives under utils/cache/files.py next to the
other process-wide caches. 34 consumer imports rewritten across
tests/ and docs/. The empty tests/utils/ package is removed.
* New unit suite tests/unit/utils/cache/test_files.py — 12 cases
covering read_text, iter_project_files (skip-dirs, extensions,
exclude_tests, exclude_dirs, walk-cache invariants), the combined
iterator, non-UTF-8 silent skip, and _reset.
utils/cache redundancy cleanup
* applications.py + users.py + cli/create/inventory/services_disabler.py
drop their direct yaml.safe_load calls and their private
_load_yaml_mapping/_load_yaml_variant_list copies; everything routes
through utils.cache.yaml.load_yaml / load_yaml_any. The dead
utils.cache.base._load_yaml_mapping is removed.
* users.py:_load_store_users (tokens.yml read) likewise migrated.
* _load_yaml_variant_list moves out of base.py to applications.py
(its only consumer) and is renamed _load_variants_overrides — also
routes through the cached loader now.
* utils.cache.yaml._reset_cache_for_tests is renamed _reset (kept as
alias) for parity with the per-domain reset helpers.
* utils.cache.files grows a _reset() that clears its two LRU caches.
* utils.cache.__init__._reset_cache_for_tests now orchestrates ALL
six caches (applications, users, domains, base, yaml, files) so
tests get clean state across cache layers — needed because
applications/users now share the yaml cache and would otherwise
serve stale parses across reset boundaries.
* utils.cache.files imports PROJECT_ROOT from base instead of
redefining it.
* tests/unit/utils/cache/test_data.py + test_base.py: obsolete
TestLoadYamlMapping / TestLoadYamlVariantList classes deleted
(covered by test_yaml.py + test_variants.py).
* README.md updated for the new module list (files, yaml) and the
six-module orchestrator.
Galaxy schema lint
* tests/lint/repository/test_role_meta_main_galaxy_schema.py: split
galaxy_info field requirements into hard-required {author,
description, license} and soft-recommended {min_ansible_version,
platforms}. Soft-recommended -> emit a GitHub Actions ::warning::
annotation via utils.annotations.message.warning (titled
"meta/main.yml galaxy_info" with file= pointing at the offender),
no test failure. Rationale: min_ansible_version + platforms are
only enforced by Galaxy when *publishing*, which Infinito.Nexus
does not do.
* Galaxy lint goes green (was 226 offenders before req-011 +
license_url/repository/documentation strip; was 62 before this
relaxation; now 0 hard offenders, 62 soft warnings).
* roles/svc-opt-keyboard-color/meta/main.yml: backfill `license`
(only role missing the hard-required field).
New lint
* tests/lint/repository/test_role_meta_info.py — enforces meta/info.yml
file-root convention, the four-field allowlist (logo, homepage,
video, display), and shape (logo.class string, homepage/video
non-empty strings, display bool). Also fails if any
meta/main.yml.galaxy_info still carries one of the four migrated
fields.
Docs
* docs/contributing/design/services/layout.md: new section
"Descriptive Role-Level Metadata — meta/info.yml" with allowed-
fields table, optionality rule, materialised path, and consumer
note. File-Layout table grows the meta/info.yml row;
meta/main.yml row gets the explicit "no logo/homepage/video/display"
note. req-011 added to header and Related Pages.
* utils/cache/README.md: files.py + yaml.py rows added; six-module
reset orchestrator documented.
Validation
* Unit: 1573 passed.
* Lint: 55 passed.
* Integration: 161 passed (1 skipped) via pre-commit hook.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
13 KiB
Python
413 lines
13 KiB
Python
"""User-domain cache: defs, hydration, materialization, merged lookup.
|
|
|
|
Owns `_USERS_DEFAULTS_CACHE` and `_MERGED_USERS_CACHE`. Public API:
|
|
`get_user_defaults`, `get_merged_users`. The Ansible-facing `users`
|
|
lookup plugin consumes `get_merged_users` directly from this module
|
|
(`from utils.cache.users import get_merged_users`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import glob
|
|
import os
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
from . import base as _base
|
|
from .base import (
|
|
_RENDER_GUARD,
|
|
_cache_key,
|
|
_deep_merge,
|
|
_render_with_templar,
|
|
_resolve_override_mapping,
|
|
_resolve_roles_dir,
|
|
_stable_variables_signature,
|
|
_tokens_file_signature,
|
|
)
|
|
from .yaml import load_yaml as _load_yaml_cached
|
|
|
|
|
|
_USERS_DEFAULTS_CACHE: dict[str, dict[str, Any]] = {}
|
|
_MERGED_USERS_CACHE: dict[tuple, dict[str, Any]] = {}
|
|
|
|
|
|
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 _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), "*/meta/users.yml")
|
|
files = sorted(glob.glob(pattern))
|
|
merged: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
|
|
|
for filepath in files:
|
|
users = _load_yaml_cached(Path(filepath), default_if_missing={})
|
|
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 = _load_yaml_cached(path, default_if_missing={})
|
|
users = data.get("users", {}) if isinstance(data, dict) else {}
|
|
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")
|
|
|
|
# Read at call time so tests that patch
|
|
# `utils.cache.base.DEFAULT_TOKENS_FILE` take effect on the very
|
|
# next call rather than being shadowed by an import-time binding.
|
|
candidates.append(_base.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]:
|
|
# Lazy import: pulls `ansible.errors.AnsibleError` transitively, see the
|
|
# base module note on why this stays out of the import block.
|
|
from utils.templating import _templar_render_best_effort
|
|
|
|
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 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_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 _reset() -> None:
|
|
_USERS_DEFAULTS_CACHE.clear()
|
|
_MERGED_USERS_CACHE.clear()
|