mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
jenkins-1.1.0.tar.gz carries plugin-linux-amd64, plugin-darwin-amd64 and
plugin-windows-amd64.exe, and nothing for linux/arm64 (read from the tarball
itself). Mattermost installs and enables the plugin anyway, then logs
jenkins: failed to apply option: backend executable not found for
environment: linux/arm64
and drops it from the active set, so addons/mattermost-plugin-jenkins.spec.js
asserted against a plugin the server had already refused. v1.1.0 is the last
release since 2020-06-19, so there is nothing to wait for upstream.
Turning the addon off outright would lose it on amd64 too. The architecture is
knowable at deploy time - ansible.cfg gathers facts and tasks/utils/addon.yml
already resolves MATTERMOST_HOST_NODE and delegates to it, so even a mixed
swarm cluster names the node whose kernel decides - but not where the addon
declares itself: test_addon_enabled_renders_to_bool renders every `enabled`
against group_vars plus role names only, and an expression that cannot resolve
there does not fail, it coerces to False and switches the addon off in every
run. Run 34893363518 lost web-app-nextcloud that way.
So the declaration stays static and the gate moves to the task:
meta/addons/*.yml architectures: [amd64] what the addon can run on
tasks/utils/addon.yml skips provision, enable and
the integration hook when the
hosting node is something else
playwright.env.j2 MATTERMOST_ARCHITECTURE so the spec skips with it
HOST_ARCHITECTURE_BY_KERNEL maps ansible_architecture to the tokens the deploy
matrix rotates. It is deliberately not SYS_SVC_CONTAINER_DPKG_ARCH_BY_ANSIBLE_ARCH:
that table speaks dpkg (armhf, ppc64el), and an OCI/matrix token is a different
namespace that happens to agree on two entries.
The schema check rejects anything outside ARCHITECTURES rather than narrowing
silently, mirroring get_role_architectures: an unknown token matches no host
and would drop the addon from every deploy while every run stayed green.
Rendered through Templar, the gate yields a real bool in all six combinations
of {no architectures, [amd64]} x {x86_64, aarch64, armv7l}, and the six other
Mattermost addons - which declare none - stay True throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
322 lines
9.5 KiB
Python
322 lines
9.5 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
|
|
from utils.update.base import is_maintained
|
|
|
|
MECHANISMS: frozenset[str] = frozenset(
|
|
{"addon", "plugin", "mu_plugin", "extension", "module", "bridge", "theme"}
|
|
)
|
|
|
|
SOURCES: frozenset[str] = frozenset({"upstream", "bundled", "vendored", "built"})
|
|
|
|
ADDON_KEYS: frozenset[str] = frozenset(
|
|
{
|
|
"enabled",
|
|
"required",
|
|
"mechanism",
|
|
"source",
|
|
"bridges",
|
|
"version",
|
|
"group",
|
|
"update",
|
|
"config",
|
|
"architectures",
|
|
}
|
|
)
|
|
|
|
VERSION_SOURCE_KEYS: frozenset[str] = frozenset(
|
|
{
|
|
"key",
|
|
"type",
|
|
"repository",
|
|
"image",
|
|
"package",
|
|
"url",
|
|
"pattern",
|
|
"path",
|
|
"match",
|
|
"strip",
|
|
}
|
|
)
|
|
|
|
UPDATE_KEYS: frozenset[str] = (
|
|
frozenset({"monitored", "catalog", "upstream_id"}) | VERSION_SOURCE_KEYS
|
|
)
|
|
|
|
GITHUB_RELEASES_CATALOG = "github-releases"
|
|
|
|
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_CATALOG,
|
|
}
|
|
)
|
|
|
|
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):
|
|
if not is_maintained(roles_root, role_name):
|
|
continue
|
|
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
|