Files
computer-playbook/utils/update/addons.py
T
kevinveenbirkenbachandClaude Opus 5 f713ba8350 refactor(credentials): address them at secrets.credentials, like every other meta file
meta/schema.yml became meta/secrets.yml, but its content still landed at
applications.<app>.credentials — the one meta file whose payload did not sit
under a root named after the file. Every other topic obeys that rule:
meta/volumes.yml goes to volumes, meta/rbac.yml to rbac. Credentials now go to
secrets.credentials.

The path exists exactly once, in utils/manager/credential_key.py, and
OVERRIDE_SECTION is derived from its two segments rather than spelled beside
them, so the dotted form used by --set and the nested form used by the
inventory cannot drift. Six consumers read it: the loader, the generator, the
CLI override parser, the two emit helpers, and the four lookup validators.

--set now accepts exactly one key shape,
applications.<app>.secrets.credentials.<key>. The three short forms are
rejected instead of guessed at: the same short key names different credentials
on different apps. That also retired primary_app_id, which only existed to
disambiguate them.

Three defects surfaced while wiring this up. _apply_one_role_schema still wrote
to the old root, so an app's own credentials would have moved while its
providers' stayed behind. web-app-pgadmin reads through
lookup('applications', ...).credentials.<key>, an access path no regex over
lookup('config', ...) could see, and would have deployed with an empty postgres
password. And _META_NON_GENERIC_TOPICS still listed 'schema' — a bare topic
name, invisible to any path search — so the generic file-root loop assigned the
raw schema under secrets and the follow-up write landed inside the cached
document, truncating keycloak's four credentials to one for every later
reader.

tests/integration/ansible/lookups/config/test_credentials_roundtrip.py pins both
directions of the seam and the cache invariant that last defect broke.

Existing inventories still carry the pre-rename root; migrating them is
deliberately out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:51:56 +02:00

299 lines
9.1 KiB
Python

"""Shared SPOT for the unified addon contract.
Both the role-meta lint (`tests/lint/ansible/services/test_addons_schema.py`)
and the opt-in external drift checker (`tests/external/update/addons/`) read
the constants here so the two layers never drift apart: the lint validates
that an addon's declared `mechanism`/`source`/`update.catalog` is one the
checker actually understands, and the checker only walks catalogs it has an
adapter for.
This module is intentionally ansible-free and dependency-light so it can be
imported from the lint layer, the external test layer, and the update
automation without dragging deploy-time machinery in.
"""
from __future__ import annotations
import os
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_DIR_META_ADDONS
MECHANISMS: frozenset[str] = frozenset(
{"addon", "plugin", "mu_plugin", "extension", "module", "bridge"}
)
SOURCES: frozenset[str] = frozenset({"upstream", "bundled", "vendored", "built"})
ADDON_KEYS: frozenset[str] = frozenset(
{
"enabled",
"required",
"mechanism",
"source",
"bridges",
"version",
"group",
"update",
"config",
}
)
UPDATE_KEYS: frozenset[str] = frozenset({"monitored", "catalog", "upstream_id"})
SUPPORTED_CATALOGS: frozenset[str] = frozenset(
{
"friendica-addons",
"nextcloud-appstore",
"wordpress-org",
"mediawiki-extension",
"xwiki-extensions",
"discourse-meta",
"odoo-apps",
"mautrix",
"gnome-extensions",
"chrome-webstore",
"firefox-amo",
"github-releases",
}
)
SECRET_KEY_TOKENS: tuple[str, ...] = (
"secret",
"password",
"token",
"api_key",
"apikey",
)
def secret_key_matches(key: str) -> bool:
"""Return True iff *key* looks like a secret-bearing config key."""
lowered = key.lower()
return any(token in lowered for token in SECRET_KEY_TOKENS)
def value_is_templated(value: Any) -> bool:
"""Return True iff *value* is a Jinja expression rather than a bare literal.
A templated value (`"{{ ... }}"`), whether a `lookup(...,
'secrets.credentials.<name>')` call or another variable reference, is accepted;
a bare scalar literal (string without `{{`, or a raw int/bool) is a
hard-coded secret and is rejected.
"""
return isinstance(value, str) and "{{" in value
@dataclass(frozen=True)
class AddonEntry:
"""A single addon declaration materialised from a role's meta/addons/<id>.yml."""
role: str
addon_id: str
config_path: Path
spec: Mapping[str, Any]
@property
def mechanism(self) -> str | None:
value = self.spec.get("mechanism")
return value if isinstance(value, str) else None
@property
def source(self) -> str | None:
value = self.spec.get("source")
return value if isinstance(value, str) else None
@property
def version(self) -> str:
value = self.spec.get("version")
return value if isinstance(value, str) else ""
@property
def update(self) -> Mapping[str, Any]:
value = self.spec.get("update")
return value if isinstance(value, Mapping) else {}
@property
def monitored(self) -> bool:
return bool(self.update.get("monitored", False))
@property
def catalog(self) -> str | None:
value = self.update.get("catalog")
return value if isinstance(value, str) else None
@property
def upstream_id(self) -> str:
value = self.update.get("upstream_id")
return value if isinstance(value, str) and value else self.addon_id
def iter_addon_files(roles_root: Path) -> Iterator[tuple[str, Path]]:
"""Yield ``(role_name, meta/addons/<addon_id>.yml path)`` for every addon
file across all roles. The filename stem is the addon id."""
if not roles_root.is_dir():
return
for role_dir in sorted(p for p in roles_root.iterdir() if p.is_dir()):
addons_dir = role_dir / ROLE_DIR_META_ADDONS
if not addons_dir.is_dir():
continue
for addon_file in sorted(addons_dir.glob("*.yml")):
yield role_dir.name, addon_file
def collect_addon_entries(roles_root: Path) -> list[AddonEntry]:
"""Return every declared addon across all roles' ``meta/addons/*.yml``.
Each file's root IS the addon spec; the filename stem is the addon id.
Malformed (non-mapping) payloads are skipped here; the lint layer is the
one that reports them, so the checker stays robust against in-progress
files.
"""
entries: list[AddonEntry] = []
for role_name, addon_file in iter_addon_files(roles_root):
spec = load_yaml_any(str(addon_file), default_if_missing={})
if isinstance(spec, Mapping):
entries.append(
AddonEntry(
role=role_name,
addon_id=addon_file.stem,
config_path=addon_file,
spec=spec,
)
)
return entries
@dataclass
class CatalogSuggestion:
"""A new addon surfaced by a catalog adapter that is not yet declared."""
role: str
addon_id: str
upstream_id: str
mechanism: str
source: str
catalog: str
catalog_url: str
reason: str
@dataclass
class VersionSuggestion:
"""An addon whose pinned version is behind the latest upstream release."""
role: str
addon_id: str
current: str
latest: str
config_path: Path
line: int | None = None
catalog: str = ""
@dataclass
class CatalogResult:
"""What a catalog adapter returned for one discovery/version pass."""
version_updates: list[VersionSuggestion] = field(default_factory=list)
new_addons: list[CatalogSuggestion] = field(default_factory=list)
def worker_fetch_limit(default: int = 8) -> int:
"""Thread-pool size for live catalog fetches, from INFINITO_WORKER_FETCH."""
raw = os.environ.get("INFINITO_WORKER_FETCH")
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
@dataclass(frozen=True)
class CatalogCandidate:
"""A single entry a catalog adapter surfaced for relevance + version review."""
upstream_id: str
latest: str
mechanism: str
source: str
catalog_url: str
relevant: bool = True
reason: str = ""
def version_is_newer(current: str, latest: str) -> bool:
"""Return True iff *latest* is a strictly newer semver than *current*.
A blank ``current`` (the addon tracks the app default) never produces a
suggestion, and a non-semver ``latest`` is ignored so the checker never
proposes a non-comparable tag.
"""
from utils.update.base import is_semver, version_key
if not current or not is_semver(current) or not is_semver(latest):
return False
return version_key(latest) > version_key(current)
def discover_addon_updates(
*,
role: str,
catalog: str,
declared: list[AddonEntry],
candidates: list[CatalogCandidate],
) -> CatalogResult:
"""Reconcile a catalog's live candidates against the declared addons.
Pure, fixture-friendly core of the external drift checker (no I/O): the
live adapter fans the registry queries out and hands the results here.
* A monitored declared addon whose pinned ``version`` is behind the
candidate's ``latest`` becomes a :class:`VersionSuggestion`.
* A *relevant* candidate with no matching declared addon becomes a
:class:`CatalogSuggestion` (added disabled by update automation).
* A candidate the curated relevance rules reject is silently dropped so
broad marketplaces never produce unreviewable noise.
"""
result = CatalogResult()
by_upstream = {entry.upstream_id: entry for entry in declared}
for candidate in candidates:
if not candidate.relevant:
continue
entry = by_upstream.get(candidate.upstream_id)
if entry is None:
result.new_addons.append(
CatalogSuggestion(
role=role,
addon_id=candidate.upstream_id,
upstream_id=candidate.upstream_id,
mechanism=candidate.mechanism,
source=candidate.source,
catalog=catalog,
catalog_url=candidate.catalog_url,
reason=candidate.reason
or f"catalog {catalog} lists {candidate.upstream_id}",
)
)
elif entry.monitored and version_is_newer(entry.version, candidate.latest):
result.version_updates.append(
VersionSuggestion(
role=role,
addon_id=entry.addon_id,
current=entry.version,
latest=candidate.latest,
config_path=entry.config_path,
catalog=catalog,
)
)
return result