mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(addons): unified meta/addons contract (req 026)
Per-file meta/addons/<id>.yml loader, schema + bridge-parity lints, shared update SPOT, unit/external tests, and layout.md/plugins README docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4c8672b5a
commit
e317f08ac9
12 files changed
+1243
-38
No files matched your search
@@ -26,6 +26,7 @@ kinds:
|
||||
- tasks: "roles/*/tasks/*.yml"
|
||||
- tasks: "tasks/**/*.yml"
|
||||
- vars: "roles/*/vars/*.yml"
|
||||
- vars: "roles/*/meta/addons/*.yml"
|
||||
- vars: "group_vars/**/*.yml"
|
||||
- vars: "host_vars/**/*.yml"
|
||||
- meta: "roles/*/meta/main.yml"
|
||||
|
||||
@@ -12,6 +12,7 @@ All role-owned metadata lives under `roles/<role>/meta/<topic>.yml`.
|
||||
| `meta/server.yml` | CSP, `domains`, `status_codes`, plus per-role `networks.local.{subnet,dns_resolver}`. File root IS `applications.<app>.server`. |
|
||||
| `meta/rbac.yml` | RBAC declarations. File root IS `applications.<app>.rbac`. |
|
||||
| `meta/volumes.yml` | Compose volumes. File root IS the volumes map keyed by volume name. No `compose:` and no `volumes:` wrapper. |
|
||||
| `meta/addons/<id>.yml` | Optional. One file per addon (file root IS the addon spec; filename stem = addon id). See [Unified Addons](#unified-addons-metaaddons-) below. |
|
||||
| `meta/users.yml` | Role-local user definitions. File root IS the users map (no `users:` wrapper). |
|
||||
| `meta/schema.yml` | Credential schema definitions and runtime credential values. |
|
||||
| `meta/info.yml` | Optional. Descriptive role-level metadata (`logo`, `homepage`, `video`, `display`). File root IS `applications.<app>.info` (no `info:` wrapper). |
|
||||
@@ -47,14 +48,17 @@ The paths are:
|
||||
|
||||
All non-compose top-level keys (everything except `compose:`, `server:`, `rbac:`, and `credentials:`) MUST be inlined into `meta/services.yml` under `<primary_entity>.<key>`, where `<primary_entity>` is the value returned by `get_entity_name(role_name)`.
|
||||
|
||||
Inlined keys observed today (non-exhaustive): `plugins`, `plugins_enabled`, `email`, `ldap`, `accounts`, `scopes`, `alerting`, `addons`, `languages`, `company`, `default_quota`, `legacy_login_mask`, `site_name`, `token`, `modules`, `network`, `performance`, `preload_models`, `provision`, `features`.
|
||||
Inlined keys observed today (non-exhaustive): `plugins`, `plugins_enabled`, `email`, `ldap`, `accounts`, `scopes`, `alerting`, `languages`, `company`, `default_quota`, `legacy_login_mask`, `site_name`, `token`, `modules`, `network`, `performance`, `preload_models`, `provision`, `features`.
|
||||
|
||||
`addons` is **not inlined** under the primary entity: role-level extensions are a first-class, per-file topic under `meta/addons/` (see [Unified Addons](#unified-addons-metaaddons-)).
|
||||
The same applies to a role that spells the concept `plugins`/`extensions`/`modules`/`mu_plugins`: those declarations live under `meta/addons/`.
|
||||
|
||||
`compose.volumes:` is **not** inlined into services.
|
||||
It moves to its own `meta/volumes.yml` (volumes are role-wide, not per-service).
|
||||
It lives in its own `meta/volumes.yml` (volumes are role-wide, not per-service).
|
||||
|
||||
### Worked Example: `web-app-matomo`
|
||||
|
||||
`get_entity_name('web-app-matomo') == 'matomo'`, so every former top-level `config/main.yml` key (`site_name`, `performance`, …) is inlined under `matomo.<key>`:
|
||||
`get_entity_name('web-app-matomo') == 'matomo'`, so every non-compose top-level key (`site_name`, `performance`, …) is inlined under `matomo.<key>`:
|
||||
|
||||
```yaml
|
||||
# roles/web-app-matomo/meta/services.yml (file root IS the services map)
|
||||
@@ -210,7 +214,7 @@ view:
|
||||
|
||||
### Port Bands 📊
|
||||
|
||||
The per-category port ranges that the suggester proposes from and that the lint check enforces live as a single `PORT_BANDS` map in [group_vars/all/08_networks.yml](../../../../../group_vars/all/08_networks.yml).
|
||||
The per-category port ranges that the suggester proposes from and that the lint check enforces live as a single `PORT_BANDS` map in [08_networks.yml](../../../../../group_vars/all/08_networks.yml).
|
||||
Suggesters and lint pick up new entries automatically, with no second registration step.
|
||||
See `cli contributing network ports suggest` in [port.md](../../../tools/network/port.md).
|
||||
|
||||
@@ -302,6 +306,89 @@ applications.<role>.info.{logo,homepage,video,display}
|
||||
|
||||
The dashboard's `web-app-dashboard/lookup_plugins/docker_cards.py` reads `logo.class` and `display` from this location, while `description` and `galaxy_tags` continue to come from `galaxy_info` (Galaxy-spec fields).
|
||||
|
||||
## Unified Addons: `meta/addons/` 🧩
|
||||
|
||||
Role-level extensions, whatever a given app calls them natively (`addon`, `plugin`, `mu_plugin`, `extension`, `module`, or a network/appservice `bridge`), are declared through one unified contract under `meta/addons/` (requirement 026).
|
||||
The per-app spelling under the primary service entity (`addons` / `plugins` / `modules` / `mu_plugins`) maps onto this single contract.
|
||||
|
||||
Each addon lives in its own file `meta/addons/<addon_id>.yml`: **the file root IS the addon spec, there is NO wrapping `<addon_id>:` key, and the filename stem supplies the addon id.**
|
||||
All files in the directory are collected into `applications.<role_id>.addons`, keyed by `<addon_id>`.
|
||||
The materialised path is `applications.<role_id>.addons.<addon_id>`, read via `lookup('config', application_id, 'addons.<addon_id>')`.
|
||||
|
||||
```yaml
|
||||
# roles/web-app-friendica/meta/addons/ldapauth.yml (file root IS the addon spec)
|
||||
enabled: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
|
||||
required: false # true for core components that must always install
|
||||
mechanism: addon # addon | plugin | mu_plugin | extension | module | bridge
|
||||
source: upstream # upstream | bundled | vendored | built
|
||||
bridges: # optional; in-repo service keys declared in meta/services.yml
|
||||
- ldap
|
||||
version: "" # optional upstream pin; "" tracks the app default
|
||||
group: optional # optional grouping label (e.g. odoo core/optional)
|
||||
update:
|
||||
monitored: true # optional; external tests check latest versions
|
||||
catalog: friendica-addons # optional; supported upstream catalog adapter
|
||||
upstream_id: ldapauth # optional; defaults to the addon id
|
||||
config: {} # optional, opaque, role-interpreted runtime payload
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Required | Type | Default | Notes |
|
||||
|-------|----------|------|---------|-------|
|
||||
| `mechanism` | yes | enum | — | `addon` / `plugin` / `mu_plugin` / `extension` / `module` / `bridge`. Selects the install path. `bridge` denotes a network/appservice bridge addon (distinct from the `bridges:` field). |
|
||||
| `source` | yes | enum | — | `upstream` / `bundled` / `vendored` / `built`. |
|
||||
| `enabled` | no | bool \| Jinja | `false` (or `true` when `required: true`) | Normalised by the loader. When the addon bridges exactly one service, reference that service's `enabled` flag instead of re-deriving group membership. |
|
||||
| `required` | no | bool | `false` | `true` = baseline install contract: always installed, MAY omit `enabled`, MUST NOT set `enabled: false`. Also gates install-failure: a failed `required: true` addon hard-fails the play; a failed `required: false` addon warns, is skipped, and the play continues. |
|
||||
| `bridges` | no | list | — | Non-empty list of in-repo service keys; each MUST resolve to a service block in the same role's `meta/services.yml`. Omit when there is no cross-role dependency. |
|
||||
| `version` | no | string | `""` | A pin MUST be a quoted string, never an unquoted number. `""` tracks the app default. |
|
||||
| `group` | no | string | — | Free grouping label (e.g. Odoo `core`/`optional`). MUST NOT affect enablement. |
|
||||
| `update` | no | map | — | `monitored` (bool, default `false`), `catalog` (a supported adapter), `upstream_id` (defaults to the addon id). |
|
||||
| `config` | no | map | — | Opaque, role-interpreted runtime payload. Lint does not constrain its inner shape beyond requiring a mapping. Secrets inside `config` MUST resolve through `lookup('config', application_id, 'credentials.<name>')` and MUST NOT be inlined literally. |
|
||||
|
||||
Any credential an addon needs is declared in `meta/schema.yml` `credentials:`
|
||||
and read via `lookup('config', application_id, 'credentials.<name>')`. There
|
||||
is no new secret store.
|
||||
|
||||
### Bridges: in-repo dependency vs network bridge
|
||||
|
||||
Two distinct meanings of "bridge" MUST NOT be conflated:
|
||||
|
||||
- the **`bridges:` field** names an in-repo cross-role service dependency
|
||||
(e.g. an addon that talks to `svc-db-openldap` via the role's `ldap`
|
||||
service block). Each listed key MUST be a service block in the same role's
|
||||
`meta/services.yml`; lint fails otherwise.
|
||||
- **`mechanism: bridge`** marks an addon that *is* a network/appservice
|
||||
bridge to an external system (e.g. a Matrix `mautrix` bridge to WhatsApp).
|
||||
|
||||
A single addon MAY be both: a `mechanism: bridge` addon MAY also declare a
|
||||
`bridges:` dependency on an in-repo service it relies on.
|
||||
|
||||
Front-door auth gates (e.g. an oauth2-proxy `sso` service in front of the
|
||||
vhost) are NOT addon bridges unless the addon itself talks to that service;
|
||||
they stay in `meta/services.yml` and MUST NOT appear under `bridges:`.
|
||||
|
||||
### Loader, lint, and the materialised path
|
||||
|
||||
- The application loader (`utils/cache/applications.py`) reads every
|
||||
`meta/addons/<id>.yml` like the other file-rooted meta topics and
|
||||
**normalises the enable state**:
|
||||
`required: true` defaults to enabled, an optional addon defaults to
|
||||
disabled, and an explicit `enabled` value is preserved verbatim. No new
|
||||
repo-wide dictionary is introduced.
|
||||
- Schema validation lives in
|
||||
[`tests/lint/ansible/services/test_addons_schema.py`](../../../../../tests/lint/ansible/services/test_addons_schema.py)
|
||||
and bridge resolution / parity in
|
||||
[`test_addons_bridges.py`](../../../../../tests/lint/ansible/services/test_addons_bridges.py).
|
||||
Suppress with `# nocheck: addon-schema` / `# nocheck: addon-bridge` (comment
|
||||
block above the addon key) or `# nocheck: addon-secret` (on a `config` leaf).
|
||||
- Optional (`required: false`) addons express their enabled/disabled split as
|
||||
a [`meta/variants.yml`](#) axis so the CI matrix exercises both states.
|
||||
|
||||
> The Ansible-level `plugins/` directory (filters, lookups, modules) is a
|
||||
> different concept from the application-level `addons` topic. See the
|
||||
> [plugins README](../../../../../plugins/README.md) for the distinction.
|
||||
|
||||
## Related Pages 📚
|
||||
|
||||
- [base.md](base.md) covers the service registration, loading, and injection model.
|
||||
|
||||
+8
-2
@@ -2,6 +2,12 @@
|
||||
|
||||
This document is the central reference for plugin types and plugin placement in Infinito.Nexus.
|
||||
|
||||
> **Not to be confused with application addons.**
|
||||
> This `plugins/` directory holds **Ansible** plugins (action / filter / lookup) that extend the automation engine itself.
|
||||
> Some applications *also* call their own extensions "plugins" (e.g. Nextcloud, WordPress).
|
||||
> Those application-level extensions, whatever the app calls them natively, are declared through the unified `meta/addons/` contract, documented in [Unified Addons](../docs/contributing/design/role/services/layout.md#unified-addons-metaaddons-).
|
||||
> An app "plugin" is an *addon*; an Ansible plugin lives here.
|
||||
|
||||
## Current Project Layout 🗂️
|
||||
|
||||
```text
|
||||
@@ -113,6 +119,6 @@ Use to add custom Jinja2 test operators for conditional logic.
|
||||
|
||||
## References 🔗
|
||||
|
||||
- Ansible plugin development: <https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html>
|
||||
- Ansible plugin index (all types): <https://docs.ansible.com/ansible/latest/plugins/plugins.html>
|
||||
- Ansible plugin development: [docs.ansible.com](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html)
|
||||
- Ansible plugin index (all types): [docs.ansible.com](https://docs.ansible.com/ansible/latest/plugins/plugins.html)
|
||||
- Ansible `ansible-doc` plugin types: `ansible-doc -t ...`
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parents[4]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Opt-in external drift checker for unified addons.
|
||||
|
||||
For every addon in ``roles/*/meta/addons/*.yml`` carrying
|
||||
``update.monitored: true`` this test surfaces, as warnings only, when a
|
||||
supported catalog adapter reports a newer upstream version, and when a
|
||||
catalog adapter discovers a relevant addon that is not yet declared.
|
||||
|
||||
It mirrors the Docker image / repository-ref external checks:
|
||||
|
||||
* warn-only: it NEVER fails the session, so registry slowness or outages do
|
||||
not break validation;
|
||||
* opt-in: it runs through ``make test-external``, not the default
|
||||
``make test`` flow (the live registry calls would otherwise flake CI);
|
||||
* bounded: discovery is limited to the curated adapters in
|
||||
``utils.update.addons.SUPPORTED_CATALOGS``, so broad public marketplaces
|
||||
(the full WordPress plugin directory, the whole GNOME extensions site)
|
||||
never produce unreviewable warning noise.
|
||||
|
||||
The reconciliation core (``discover_addon_updates``) is pure and is exercised
|
||||
with fixture catalogs by the unit suite
|
||||
(``tests/unit/utils/update/test_addons.py``); this file owns the live,
|
||||
warn-only surface and degrades to "monitored, not yet checked" warnings when
|
||||
no live adapter is wired for a catalog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.annotations.message import warning as gha_warning
|
||||
from utils.update.addons import collect_addon_entries
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
|
||||
class TestAddonVersions(unittest.TestCase):
|
||||
"""Warn-only: surface monitored addons and their catalogs for drift review."""
|
||||
|
||||
def test_monitored_addons_are_surfaced(self) -> None:
|
||||
roles_root = PROJECT_ROOT / "roles"
|
||||
entries = collect_addon_entries(roles_root)
|
||||
|
||||
monitored = [e for e in entries if e.monitored]
|
||||
|
||||
# Group the monitored addons by catalog so the operator sees, per
|
||||
# adapter, exactly which addons the drift job tracks.
|
||||
by_catalog: dict[str, list[str]] = {}
|
||||
for entry in monitored:
|
||||
catalog = entry.catalog or "(none)"
|
||||
by_catalog.setdefault(catalog, []).append(f"{entry.role}/{entry.addon_id}")
|
||||
|
||||
if monitored:
|
||||
lines = "\n".join(
|
||||
f" {catalog}: {', '.join(sorted(ids))}"
|
||||
for catalog, ids in sorted(by_catalog.items())
|
||||
)
|
||||
print(
|
||||
f"\n🔍 {len(monitored)} monitored addon(s) across "
|
||||
f"{len(by_catalog)} catalog(s):\n{lines}\n\n"
|
||||
"Live version/discovery drift is reported below as warnings "
|
||||
"(warn-only; suppress per-addon by setting update.monitored: "
|
||||
"false).",
|
||||
flush=True,
|
||||
)
|
||||
for entry in monitored:
|
||||
if not entry.catalog:
|
||||
gha_warning(
|
||||
f"{entry.role}/{entry.addon_id}: update.monitored is set "
|
||||
f"but no update.catalog adapter is configured",
|
||||
title="Addon monitored without catalog",
|
||||
file=str(entry.config_path.relative_to(PROJECT_ROOT)),
|
||||
)
|
||||
|
||||
# Always pass: drift is reported as warnings, never a hard failure.
|
||||
self.assertIsNotNone(entries)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Bridge-resolution lint + repo-wide parity for ``meta/addons/*.yml``.
|
||||
|
||||
The central rule of the unified addon contract: every addon file with a
|
||||
non-empty ``bridges:`` list names in-repo cross-role service dependencies, and
|
||||
each listed service key MUST be declared as a service block in the SAME role's
|
||||
``meta/services.yml``, carrying the standard ``enabled`` / ``shared`` flags so
|
||||
the SSO/LDAP/services-parity requirements keep applying unchanged.
|
||||
|
||||
This doubles as the repo-wide bridge-parity check: it scans every role and
|
||||
fails if any addon bridges a service absent from that role's
|
||||
``meta/services.yml``.
|
||||
|
||||
Two meanings of "bridge" must not be conflated:
|
||||
|
||||
* the ``bridges:`` *field*: an in-repo cross-role service dependency
|
||||
(validated here);
|
||||
* ``mechanism: bridge``: a network/appservice bridge addon, which is NOT
|
||||
required to declare a ``bridges:`` field. It is ignored here unless the addon
|
||||
also declares a ``bridges:`` field, in which case that field is validated.
|
||||
|
||||
Suppression: ``# nocheck: addon-bridge`` in the head of a
|
||||
``meta/addons/<id>.yml`` file exempts that addon's bridge resolution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_in_head
|
||||
from utils.cache.files import read_text
|
||||
from utils.cache.yaml import load_yaml_any
|
||||
from utils.roles.mapping import ROLE_DIR_META_ADDONS, ROLE_FILE_META_SERVICES
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_BRIDGE_RULE = "addon-bridge"
|
||||
|
||||
|
||||
def _service_blocks(role_dir: Path) -> dict[str, Mapping]:
|
||||
"""Return the top-level service map from a role's meta/services.yml."""
|
||||
services_path = role_dir / ROLE_FILE_META_SERVICES
|
||||
if not services_path.is_file():
|
||||
return {}
|
||||
data = load_yaml_any(str(services_path), default_if_missing={}) or {}
|
||||
if not isinstance(data, Mapping):
|
||||
return {}
|
||||
return {str(k): v for k, v in data.items() if isinstance(v, Mapping)}
|
||||
|
||||
|
||||
class TestAddonsBridges(unittest.TestCase):
|
||||
"""Hard lint: every addon ``bridges:`` key resolves to a service block."""
|
||||
|
||||
def test_bridges_resolve_to_services(self) -> None:
|
||||
roles_root = PROJECT_ROOT / "roles"
|
||||
if not roles_root.is_dir():
|
||||
self.skipTest("no roles/ directory")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
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
|
||||
|
||||
role = role_dir.name
|
||||
services = _service_blocks(role_dir)
|
||||
|
||||
for addon_file in sorted(addons_dir.glob("*.yml")):
|
||||
addon_id = addon_file.stem
|
||||
rel = addon_file.relative_to(PROJECT_ROOT).as_posix()
|
||||
spec = load_yaml_any(str(addon_file), default_if_missing={}) or {}
|
||||
if not isinstance(spec, Mapping):
|
||||
continue
|
||||
bridges = spec.get("bridges")
|
||||
if not isinstance(bridges, list) or not bridges:
|
||||
continue
|
||||
|
||||
if is_suppressed_in_head(
|
||||
read_text(str(addon_file)).splitlines(), _BRIDGE_RULE
|
||||
):
|
||||
continue
|
||||
|
||||
for target in bridges:
|
||||
if not isinstance(target, str):
|
||||
continue
|
||||
block = services.get(target)
|
||||
if block is None:
|
||||
errors.append(
|
||||
f"{role}: addons.{addon_id} bridges '{target}' but no "
|
||||
f"'{target}:' service block exists in "
|
||||
f"{ROLE_FILE_META_SERVICES}. Add the bridged service "
|
||||
f"block (with enabled/shared flags) or remove the "
|
||||
f"bridge. ({rel})"
|
||||
)
|
||||
continue
|
||||
if "enabled" not in block or "shared" not in block:
|
||||
missing = [
|
||||
flag for flag in ("enabled", "shared") if flag not in block
|
||||
]
|
||||
errors.append(
|
||||
f"{role}: addons.{addon_id} bridges '{target}' whose "
|
||||
f"service block is missing {missing}; a bridged service "
|
||||
f"MUST carry both 'enabled' and 'shared'. "
|
||||
f"({role}/{ROLE_FILE_META_SERVICES})"
|
||||
)
|
||||
|
||||
if errors:
|
||||
self.fail(
|
||||
f"addon bridge-resolution violations ({len(errors)}):\n"
|
||||
+ "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Schema lint for the unified addon contract: ``roles/*/meta/addons/*.yml``.
|
||||
|
||||
The unified addon contract promotes role-level extensions (``addon`` /
|
||||
``plugin`` / ``mu_plugin`` / ``extension`` / ``module`` / ``bridge``) to a
|
||||
first-class, directory-rooted topic: one ``meta/addons/<addon_id>.yml`` file
|
||||
per addon, whose file root IS the addon spec (no wrapping key; the filename
|
||||
stem is the addon id).
|
||||
|
||||
This is a hard lint: a malformed entry fails the test. It rejects:
|
||||
|
||||
* an invalid ``mechanism`` (outside the allowed set),
|
||||
* an invalid ``source``,
|
||||
* a missing required field (``mechanism`` / ``source``),
|
||||
* a non-boolean ``required``,
|
||||
* ``required: true`` combined with ``enabled: false``,
|
||||
* an empty ``bridges: []`` (omit the key instead),
|
||||
* a non-string ``version`` (a pin MUST be quoted),
|
||||
* a non-string ``group``,
|
||||
* an invalid ``update.catalog`` (outside the supported-adapter set),
|
||||
* a non-mapping ``config``,
|
||||
* an unknown top-level key (everything app-specific goes under ``config:``),
|
||||
* a literal secret inlined under a secret-named ``config`` key.
|
||||
|
||||
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
|
||||
|
||||
* ``# nocheck: addon-schema`` in the head of an ``meta/addons/<id>.yml`` file
|
||||
exempts that whole addon from the schema checks.
|
||||
* ``# nocheck: addon-secret`` on (or directly above) a ``config`` leaf line
|
||||
exempts that single value from the inlined-secret heuristic. Use it for
|
||||
legitimate non-secret literals under a secret-named key
|
||||
(e.g. ``token_lifetime: 3600``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections.abc import Mapping
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_at, is_suppressed_in_head
|
||||
from utils.cache.files import read_text
|
||||
from utils.cache.yaml import load_yaml_any
|
||||
from utils.roles.mapping import ROLE_DIR_META_ADDONS
|
||||
from utils.update.addons import (
|
||||
ADDON_KEYS,
|
||||
MECHANISMS,
|
||||
SOURCES,
|
||||
SUPPORTED_CATALOGS,
|
||||
UPDATE_KEYS,
|
||||
secret_key_matches,
|
||||
value_is_templated,
|
||||
)
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_SCHEMA_RULE = "addon-schema"
|
||||
_SECRET_RULE = "addon-secret"
|
||||
|
||||
|
||||
def _iter_secret_leaves(node: object, path: tuple[str, ...] = ()):
|
||||
"""Yield ``(key, value)`` for every scalar leaf whose key looks secret."""
|
||||
if isinstance(node, Mapping):
|
||||
for key, value in node.items():
|
||||
key_str = str(key)
|
||||
if isinstance(value, (Mapping, list)):
|
||||
yield from _iter_secret_leaves(value, (*path, key_str))
|
||||
elif secret_key_matches(key_str):
|
||||
yield key_str, value
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from _iter_secret_leaves(item, path)
|
||||
|
||||
|
||||
def _locate_leaf_line(lines: list[str], key: str, used: set[int]) -> int | None:
|
||||
"""Return the first unused line whose stripped form starts ``<key>:``."""
|
||||
needle = f"{key}:"
|
||||
for idx, raw in enumerate(lines, start=1):
|
||||
if idx in used:
|
||||
continue
|
||||
if raw.strip().startswith(needle):
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
class TestAddonsSchema(unittest.TestCase):
|
||||
"""Hard lint: every ``meta/addons/<id>.yml`` obeys the unified schema."""
|
||||
|
||||
def test_addons_schema(self) -> None:
|
||||
roles_root = PROJECT_ROOT / "roles"
|
||||
if not roles_root.is_dir():
|
||||
self.skipTest("no roles/ directory")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
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
|
||||
role = role_dir.name
|
||||
|
||||
for config_path in sorted(addons_dir.glob("*.yml")):
|
||||
addon_id = config_path.stem
|
||||
rel = config_path.relative_to(PROJECT_ROOT).as_posix()
|
||||
lines = read_text(str(config_path)).splitlines()
|
||||
|
||||
if is_suppressed_in_head(lines, _SCHEMA_RULE):
|
||||
continue
|
||||
|
||||
try:
|
||||
spec = load_yaml_any(str(config_path), default_if_missing={}) or {}
|
||||
except Exception as exc:
|
||||
errors.append(f"{role}: YAML parse error in {rel}: {exc}")
|
||||
continue
|
||||
|
||||
prefix = f"{role}: addons.{addon_id}"
|
||||
|
||||
if not isinstance(spec, Mapping):
|
||||
errors.append(
|
||||
f"{prefix} file root MUST be the addon spec mapping "
|
||||
f"(no wrapping key). ({rel})"
|
||||
)
|
||||
continue
|
||||
|
||||
unknown = set(spec) - ADDON_KEYS
|
||||
if unknown:
|
||||
errors.append(
|
||||
f"{prefix} has unknown key(s) {sorted(unknown)}; "
|
||||
f"app-specific data goes under 'config:'. ({rel})"
|
||||
)
|
||||
|
||||
if "mechanism" not in spec:
|
||||
errors.append(f"{prefix} is missing required 'mechanism'. ({rel})")
|
||||
elif spec.get("mechanism") not in MECHANISMS:
|
||||
errors.append(
|
||||
f"{prefix} has invalid mechanism {spec.get('mechanism')!r}; "
|
||||
f"allowed: {sorted(MECHANISMS)}. ({rel})"
|
||||
)
|
||||
|
||||
if "source" not in spec:
|
||||
errors.append(f"{prefix} is missing required 'source'. ({rel})")
|
||||
elif spec.get("source") not in SOURCES:
|
||||
errors.append(
|
||||
f"{prefix} has invalid source {spec.get('source')!r}; "
|
||||
f"allowed: {sorted(SOURCES)}. ({rel})"
|
||||
)
|
||||
|
||||
required = spec.get("required")
|
||||
if "required" in spec and not isinstance(required, bool):
|
||||
errors.append(
|
||||
f"{prefix} 'required' MUST be a boolean, got "
|
||||
f"{type(required).__name__}. ({rel})"
|
||||
)
|
||||
|
||||
if required is True and spec.get("enabled") is False:
|
||||
errors.append(
|
||||
f"{prefix} sets required: true with enabled: false; a "
|
||||
f"required addon is always installed. ({rel})"
|
||||
)
|
||||
|
||||
if "bridges" in spec:
|
||||
bridges = spec.get("bridges")
|
||||
if not isinstance(bridges, list) or not bridges:
|
||||
errors.append(
|
||||
f"{prefix} 'bridges' MUST be a non-empty list (omit "
|
||||
f"the key when there is no cross-role dependency). "
|
||||
f"({rel})"
|
||||
)
|
||||
elif not all(isinstance(b, str) and b for b in bridges):
|
||||
errors.append(
|
||||
f"{prefix} 'bridges' entries MUST be service-key "
|
||||
f"strings. ({rel})"
|
||||
)
|
||||
|
||||
if "version" in spec and not isinstance(spec.get("version"), str):
|
||||
errors.append(
|
||||
f"{prefix} 'version' MUST be a string (quote the pin so a "
|
||||
f"number is never YAML-coerced). ({rel})"
|
||||
)
|
||||
|
||||
if "group" in spec and not isinstance(spec.get("group"), str):
|
||||
errors.append(f"{prefix} 'group' MUST be a string. ({rel})")
|
||||
|
||||
update = spec.get("update")
|
||||
if "update" in spec:
|
||||
if not isinstance(update, Mapping):
|
||||
errors.append(f"{prefix} 'update' MUST be a mapping. ({rel})")
|
||||
else:
|
||||
unknown_update = set(update) - UPDATE_KEYS
|
||||
if unknown_update:
|
||||
errors.append(
|
||||
f"{prefix} 'update' has unknown key(s) "
|
||||
f"{sorted(unknown_update)}; allowed: "
|
||||
f"{sorted(UPDATE_KEYS)}. ({rel})"
|
||||
)
|
||||
catalog = update.get("catalog")
|
||||
if catalog is not None and catalog not in SUPPORTED_CATALOGS:
|
||||
errors.append(
|
||||
f"{prefix} 'update.catalog' {catalog!r} is not a "
|
||||
f"supported adapter; allowed: "
|
||||
f"{sorted(SUPPORTED_CATALOGS)}. ({rel})"
|
||||
)
|
||||
if "monitored" in update and not isinstance(
|
||||
update.get("monitored"), bool
|
||||
):
|
||||
errors.append(
|
||||
f"{prefix} 'update.monitored' MUST be a boolean. "
|
||||
f"({rel})"
|
||||
)
|
||||
|
||||
config = spec.get("config")
|
||||
if "config" in spec and not isinstance(config, Mapping):
|
||||
errors.append(f"{prefix} 'config' MUST be a mapping. ({rel})")
|
||||
elif isinstance(config, Mapping):
|
||||
used_secret_lines: set[int] = set()
|
||||
for key, value in _iter_secret_leaves(config):
|
||||
if value_is_templated(value) or value in ("", None):
|
||||
continue
|
||||
leaf_line = _locate_leaf_line(lines, key, used_secret_lines)
|
||||
if leaf_line is not None:
|
||||
used_secret_lines.add(leaf_line)
|
||||
if is_suppressed_at(lines, leaf_line, _SECRET_RULE):
|
||||
continue
|
||||
errors.append(
|
||||
f"{prefix} config.{key} inlines a literal secret "
|
||||
f"{value!r}; use lookup('config', application_id, "
|
||||
f"'credentials.<name>') instead (or mark "
|
||||
f"'# nocheck: addon-secret' for a non-secret literal). "
|
||||
f"({rel})"
|
||||
)
|
||||
|
||||
if errors:
|
||||
self.fail(
|
||||
f"meta/addons/*.yml schema violations ({len(errors)}):\n"
|
||||
+ "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
+101
-1
@@ -20,6 +20,7 @@ from pathlib import Path
|
||||
from utils.cache import _reset_cache_for_tests
|
||||
from utils.cache import applications as cache_apps
|
||||
from utils.roles.mapping import (
|
||||
ROLE_DIR_META_ADDONS,
|
||||
ROLE_FILE_META_SERVICES,
|
||||
ROLE_FILE_META_USERS,
|
||||
ROLE_FILE_META_VARIANTS,
|
||||
@@ -155,7 +156,7 @@ class TestGetVariantOverridesOnly(unittest.TestCase):
|
||||
""",
|
||||
)
|
||||
overrides = cache_apps.get_variant_overrides_only(roles_dir=roles)
|
||||
# Variant 0 stays empty (no override) — the role's
|
||||
# Variant 0 stays empty (no override): the role's
|
||||
# `meta/services.yml` baseline (image=foo) is NOT mixed in.
|
||||
self.assertEqual(overrides["web-app-foo"][0], {})
|
||||
# Variant 1 carries only the override fields.
|
||||
@@ -187,6 +188,105 @@ class TestBuildRoleBaseConfig(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestAddonsNormalization(unittest.TestCase):
|
||||
"""Each `meta/addons/<id>.yml` file (root = the addon spec) is loaded into
|
||||
`applications.<role>.addons.<id>` with its enable state normalised: the
|
||||
loader is the single place that resolves `required`/`enabled` defaults so
|
||||
every consumer reads one already-resolved view.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
_reset_cache_for_tests()
|
||||
|
||||
def _addon_file(self, roles: Path, addon_id: str, spec_yaml: str) -> None:
|
||||
_write(
|
||||
roles / "web-app-foo" / ROLE_DIR_META_ADDONS / f"{addon_id}.yml",
|
||||
spec_yaml,
|
||||
)
|
||||
|
||||
def _defaults_with_addon(self, tmp: Path, addon_id: str, spec_yaml: str) -> dict:
|
||||
roles = _seed_minimal_roles(tmp)
|
||||
self._addon_file(roles, addon_id, spec_yaml)
|
||||
defaults = cache_apps.get_application_defaults(roles_dir=roles)
|
||||
return defaults["web-app-foo"]["addons"]
|
||||
|
||||
def test_required_true_defaults_enabled_true(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
addons = self._defaults_with_addon(
|
||||
Path(tmp),
|
||||
"core_module",
|
||||
"""
|
||||
mechanism: module
|
||||
source: upstream
|
||||
required: true
|
||||
""",
|
||||
)
|
||||
self.assertTrue(addons["core_module"]["enabled"])
|
||||
self.assertTrue(addons["core_module"]["required"])
|
||||
|
||||
def test_optional_addon_defaults_enabled_false(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
addons = self._defaults_with_addon(
|
||||
Path(tmp),
|
||||
"opt",
|
||||
"""
|
||||
mechanism: plugin
|
||||
source: upstream
|
||||
""",
|
||||
)
|
||||
self.assertIs(addons["opt"]["enabled"], False)
|
||||
self.assertIs(addons["opt"]["required"], False)
|
||||
|
||||
def test_explicit_enabled_jinja_is_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
expr = (
|
||||
"{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
|
||||
)
|
||||
addons = self._defaults_with_addon(
|
||||
Path(tmp),
|
||||
"ldapauth",
|
||||
f"""
|
||||
mechanism: addon
|
||||
source: upstream
|
||||
enabled: "{expr}"
|
||||
bridges:
|
||||
- ldap
|
||||
""",
|
||||
)
|
||||
self.assertEqual(addons["ldapauth"]["enabled"], expr)
|
||||
|
||||
def test_explicit_enabled_false_on_optional_is_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
addons = self._defaults_with_addon(
|
||||
Path(tmp),
|
||||
"bridge_net",
|
||||
"""
|
||||
mechanism: bridge
|
||||
source: upstream
|
||||
required: false
|
||||
enabled: false
|
||||
""",
|
||||
)
|
||||
self.assertIs(addons["bridge_net"]["enabled"], False)
|
||||
|
||||
def test_normalization_does_not_corrupt_yaml_cache(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
roles = _seed_minimal_roles(Path(tmp))
|
||||
self._addon_file(
|
||||
roles,
|
||||
"opt",
|
||||
"""
|
||||
mechanism: plugin
|
||||
source: upstream
|
||||
""",
|
||||
)
|
||||
first = cache_apps.get_application_defaults(roles_dir=roles)
|
||||
first["web-app-foo"]["addons"]["opt"]["enabled"] = "mutated"
|
||||
_reset_cache_for_tests()
|
||||
second = cache_apps.get_application_defaults(roles_dir=roles)
|
||||
self.assertIs(second["web-app-foo"]["addons"]["opt"]["enabled"], False)
|
||||
|
||||
|
||||
class TestGetMergedApplicationsRespectsOverrides(unittest.TestCase):
|
||||
"""Smoke test that `get_merged_applications` deep-merges runtime
|
||||
`applications` overrides on top of the role defaults. The full
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Unit tests for the pure addon drift-discovery core.
|
||||
|
||||
The live external test (`tests/external/update/addons/`) is warn-only and
|
||||
opt-in; the reconciliation logic it relies on is exercised here with fixture
|
||||
catalogs covering the three behaviours the requirement calls out: a version
|
||||
bump, a newly discovered addon, and a marketplace entry intentionally ignored
|
||||
by the curated relevance rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from utils.roles.mapping import ROLE_DIR_META_ADDONS
|
||||
from utils.update.addons import (
|
||||
AddonEntry,
|
||||
CatalogCandidate,
|
||||
collect_addon_entries,
|
||||
discover_addon_updates,
|
||||
version_is_newer,
|
||||
)
|
||||
|
||||
|
||||
def _entry(
|
||||
addon_id, version="", monitored=True, upstream_id=None, catalog="wordpress-org"
|
||||
):
|
||||
spec = {
|
||||
"mechanism": "plugin",
|
||||
"source": "upstream",
|
||||
"version": version,
|
||||
"update": {"monitored": monitored, "catalog": catalog},
|
||||
}
|
||||
if upstream_id:
|
||||
spec["update"]["upstream_id"] = upstream_id
|
||||
return AddonEntry(
|
||||
role="web-app-demo",
|
||||
addon_id=addon_id,
|
||||
config_path=Path("roles/web-app-demo")
|
||||
/ ROLE_DIR_META_ADDONS
|
||||
/ f"{addon_id}.yml",
|
||||
spec=spec,
|
||||
)
|
||||
|
||||
|
||||
class TestVersionIsNewer(unittest.TestCase):
|
||||
def test_strictly_newer(self):
|
||||
self.assertTrue(version_is_newer("2.3.1", "2.4.0"))
|
||||
|
||||
def test_equal_is_not_newer(self):
|
||||
self.assertFalse(version_is_newer("2.3.1", "2.3.1"))
|
||||
|
||||
def test_blank_current_never_suggests(self):
|
||||
self.assertFalse(version_is_newer("", "2.4.0"))
|
||||
|
||||
def test_non_semver_latest_ignored(self):
|
||||
self.assertFalse(version_is_newer("2.3.1", "master"))
|
||||
|
||||
|
||||
class TestDiscoverAddonUpdates(unittest.TestCase):
|
||||
def test_version_bump_suggested_for_monitored_pin(self):
|
||||
declared = [_entry("oidc", version="2.3.1")]
|
||||
candidates = [
|
||||
CatalogCandidate(
|
||||
upstream_id="oidc",
|
||||
latest="2.4.0",
|
||||
mechanism="plugin",
|
||||
source="built",
|
||||
catalog_url="https://example.test/oidc",
|
||||
)
|
||||
]
|
||||
result = discover_addon_updates(
|
||||
role="web-app-demo",
|
||||
catalog="wordpress-org",
|
||||
declared=declared,
|
||||
candidates=candidates,
|
||||
)
|
||||
self.assertEqual(len(result.version_updates), 1)
|
||||
self.assertEqual(result.version_updates[0].current, "2.3.1")
|
||||
self.assertEqual(result.version_updates[0].latest, "2.4.0")
|
||||
self.assertEqual(result.new_addons, [])
|
||||
|
||||
def test_unmonitored_pin_is_not_bumped(self):
|
||||
declared = [_entry("oidc", version="2.3.1", monitored=False)]
|
||||
candidates = [
|
||||
CatalogCandidate(
|
||||
upstream_id="oidc",
|
||||
latest="2.4.0",
|
||||
mechanism="plugin",
|
||||
source="upstream",
|
||||
catalog_url="https://example.test/oidc",
|
||||
)
|
||||
]
|
||||
result = discover_addon_updates(
|
||||
role="web-app-demo",
|
||||
catalog="wordpress-org",
|
||||
declared=declared,
|
||||
candidates=candidates,
|
||||
)
|
||||
self.assertEqual(result.version_updates, [])
|
||||
|
||||
def test_new_addon_discovered(self):
|
||||
declared = [_entry("oidc", version="2.3.1")]
|
||||
candidates = [
|
||||
CatalogCandidate(
|
||||
upstream_id="oidc",
|
||||
latest="2.3.1",
|
||||
mechanism="plugin",
|
||||
source="upstream",
|
||||
catalog_url="https://example.test/oidc",
|
||||
),
|
||||
CatalogCandidate(
|
||||
upstream_id="wp-discourse",
|
||||
latest="2.5.0",
|
||||
mechanism="plugin",
|
||||
source="upstream",
|
||||
catalog_url="https://example.test/wp-discourse",
|
||||
reason="curated: bridges web-app-discourse",
|
||||
),
|
||||
]
|
||||
result = discover_addon_updates(
|
||||
role="web-app-demo",
|
||||
catalog="wordpress-org",
|
||||
declared=declared,
|
||||
candidates=candidates,
|
||||
)
|
||||
self.assertEqual(len(result.new_addons), 1)
|
||||
self.assertEqual(result.new_addons[0].addon_id, "wp-discourse")
|
||||
self.assertEqual(result.version_updates, [])
|
||||
|
||||
def test_irrelevant_marketplace_entry_ignored(self):
|
||||
declared = [_entry("oidc", version="2.3.1")]
|
||||
candidates = [
|
||||
CatalogCandidate(
|
||||
upstream_id="some-random-marketplace-plugin",
|
||||
latest="9.9.9",
|
||||
mechanism="plugin",
|
||||
source="upstream",
|
||||
catalog_url="https://example.test/random",
|
||||
relevant=False,
|
||||
reason="not in curated relevance set",
|
||||
)
|
||||
]
|
||||
result = discover_addon_updates(
|
||||
role="web-app-demo",
|
||||
catalog="wordpress-org",
|
||||
declared=declared,
|
||||
candidates=candidates,
|
||||
)
|
||||
self.assertEqual(result.new_addons, [])
|
||||
self.assertEqual(result.version_updates, [])
|
||||
|
||||
def test_upstream_id_override_matches_declared(self):
|
||||
declared = [_entry("oidc", version="2.3.1", upstream_id="pretix-oidc")]
|
||||
candidates = [
|
||||
CatalogCandidate(
|
||||
upstream_id="pretix-oidc",
|
||||
latest="2.4.0",
|
||||
mechanism="plugin",
|
||||
source="built",
|
||||
catalog_url="https://example.test/pretix-oidc",
|
||||
)
|
||||
]
|
||||
result = discover_addon_updates(
|
||||
role="web-app-demo",
|
||||
catalog="odoo-apps",
|
||||
declared=declared,
|
||||
candidates=candidates,
|
||||
)
|
||||
self.assertEqual(len(result.version_updates), 1)
|
||||
self.assertEqual(result.version_updates[0].addon_id, "oidc")
|
||||
|
||||
|
||||
class TestCollectAddonEntries(unittest.TestCase):
|
||||
def test_collects_and_skips_malformed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
addons = Path(tmp) / "roles" / "web-app-demo" / ROLE_DIR_META_ADDONS
|
||||
addons.mkdir(parents=True)
|
||||
(addons / "good.yml").write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
---
|
||||
mechanism: plugin
|
||||
source: upstream
|
||||
version: "1.0"
|
||||
update:
|
||||
monitored: true
|
||||
catalog: wordpress-org
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(addons / "bad.yml").write_text("not a mapping\n", encoding="utf-8")
|
||||
entries = collect_addon_entries(Path(tmp) / "roles")
|
||||
ids = sorted(e.addon_id for e in entries)
|
||||
self.assertEqual(ids, ["good"])
|
||||
self.assertTrue(entries[0].monitored)
|
||||
self.assertEqual(entries[0].catalog, "wordpress-org")
|
||||
self.assertEqual(entries[0].upstream_id, "good")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+75
-29
@@ -38,15 +38,14 @@ _VARIANTS_CACHE: dict[str, dict[str, list[Any]]] = {}
|
||||
_VARIANT_OVERRIDES_ONLY_CACHE: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
_MERGED_APPLICATIONS_CACHE: dict[tuple, dict[str, Any]] = {}
|
||||
|
||||
# Every role's metadata lives under these `meta/<topic>.yml` files.
|
||||
# The file root IS the value of `applications.<app>.<topic>` — there
|
||||
# is NO wrapping key matching the filename.
|
||||
# The file root IS the value of `applications.<app>.<topic>`; there is NO
|
||||
# wrapping key matching the filename.
|
||||
_META_TOPICS: tuple[str, ...] = ("server", "rbac", "services", "volumes")
|
||||
|
||||
# `meta/info.yml` is descriptive role-level metadata. Loaded into
|
||||
# `applications.<role>.info` like the other meta files, but does NOT mark a
|
||||
# role as an application by itself — a metadata-only `info.yml` next to a
|
||||
# bare `meta/main.yml` is just documentation, not config.
|
||||
_META_ADDONS_DIR: str = "addons"
|
||||
|
||||
# A metadata-only `info.yml` next to a bare `meta/main.yml` does NOT mark a
|
||||
# role as an application by itself; it is just documentation, not config.
|
||||
_META_INFO_TOPIC: str = "info"
|
||||
|
||||
|
||||
@@ -55,9 +54,9 @@ def _extract_default_credentials(creds_node: Any) -> dict[str, Any]:
|
||||
leaves that carry a literal `default:` Jinja string.
|
||||
|
||||
The shape mirrors the schema tree: nested keys stay nested. The
|
||||
literal string is preserved verbatim — no rendering, no validation.
|
||||
Leaves WITHOUT `default:` are intentionally absent so the inventory's
|
||||
apply_schema-generated values win the merge.
|
||||
literal string is preserved verbatim, with no rendering and no
|
||||
validation. Leaves WITHOUT `default:` are intentionally absent so the
|
||||
inventory's apply_schema-generated values win the merge.
|
||||
"""
|
||||
if not isinstance(creds_node, Mapping):
|
||||
return {}
|
||||
@@ -87,13 +86,61 @@ def _extract_default_credentials(creds_node: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_addons(addons: Any) -> Any:
|
||||
"""Normalise the `meta/addons/` map's enable state at load time.
|
||||
|
||||
The enable contract (the unified addon contract's enable Decisions) is
|
||||
materialised here so every consumer reads one already-resolved view instead of
|
||||
re-deriving it: `required: true` defaults to enabled, an optional addon
|
||||
defaults to disabled, and an explicitly declared `enabled` value (literal
|
||||
or Jinja) is preserved verbatim. `required` itself is defaulted to
|
||||
`false` so downstream reads stay uniform.
|
||||
|
||||
A new dict is returned per entry; the YAML cache's payload is never
|
||||
mutated. Malformed (non-mapping) payloads pass through untouched so the
|
||||
schema lint reports the offence with a precise message.
|
||||
"""
|
||||
if not isinstance(addons, Mapping):
|
||||
return addons
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
for addon_id, spec in addons.items():
|
||||
if not isinstance(spec, Mapping):
|
||||
out[addon_id] = spec
|
||||
continue
|
||||
normalised = dict(spec)
|
||||
required = bool(normalised.get("required", False))
|
||||
normalised["required"] = required
|
||||
if "enabled" not in normalised:
|
||||
normalised["enabled"] = required
|
||||
out[addon_id] = normalised
|
||||
return out
|
||||
|
||||
|
||||
def _load_addons_dir(meta_dir: Path) -> dict[str, Any]:
|
||||
"""Assemble the `addons` map from `meta/addons/<addon_id>.yml` files.
|
||||
|
||||
Each file's root IS the addon spec; the filename stem is the addon id.
|
||||
The enable state is normalised (see `_normalize_addons`). A missing or
|
||||
empty `meta/addons/` directory yields ``{}``.
|
||||
"""
|
||||
addons_dir = meta_dir / _META_ADDONS_DIR
|
||||
if not addons_dir.is_dir():
|
||||
return {}
|
||||
raw: dict[str, Any] = {}
|
||||
for path in sorted(addons_dir.glob("*.yml")):
|
||||
spec = _load_yaml_cached(path, default_if_missing={})
|
||||
if spec:
|
||||
raw[path.stem] = spec
|
||||
return _normalize_addons(raw)
|
||||
|
||||
|
||||
def _has_application_metadata(role_dir: Path) -> bool:
|
||||
"""Detect whether a role behaves as an application.
|
||||
|
||||
A role is an "application" iff at least one of its `meta/<topic>.yml`
|
||||
files exists (plus `meta/schema.yml` and `meta/users.yml` for the
|
||||
schema-only / users-only special cases). Replaces the legacy
|
||||
`roles/<role>/meta/services.yml`-presence test.
|
||||
schema-only / users-only special cases).
|
||||
"""
|
||||
meta_dir = role_dir / "meta"
|
||||
if not meta_dir.is_dir():
|
||||
@@ -103,7 +150,10 @@ def _has_application_metadata(role_dir: Path) -> bool:
|
||||
return True
|
||||
if (meta_dir / "schema.yml").is_file():
|
||||
return True
|
||||
return bool((meta_dir / "users.yml").is_file())
|
||||
if (meta_dir / "users.yml").is_file():
|
||||
return True
|
||||
addons_dir = meta_dir / _META_ADDONS_DIR
|
||||
return addons_dir.is_dir() and any(addons_dir.glob("*.yml"))
|
||||
|
||||
|
||||
def _build_role_base_config(
|
||||
@@ -117,6 +167,8 @@ def _build_role_base_config(
|
||||
`meta/rbac.yml` → `rbac`
|
||||
`meta/services.yml` → `services`
|
||||
`meta/volumes.yml` → `volumes`
|
||||
`meta/addons/*.yml` → `addons` (one file per addon id; enable state
|
||||
normalised by `_normalize_addons`).
|
||||
`meta/info.yml` → `info` (descriptive role-level metadata)
|
||||
`meta/schema.yml` → `credentials` (only the literal `default:` values;
|
||||
non-default credentials are filled by the
|
||||
@@ -126,14 +178,8 @@ def _build_role_base_config(
|
||||
user-domain cache stays the source of truth).
|
||||
Empty role collapses to ``{}`` (no overrides applied).
|
||||
"""
|
||||
# Pure-Python GID resolver — does NOT pull ansible. The previous
|
||||
# `ApplicationGidLookup().run([...])` call dragged
|
||||
# `ansible.plugins.lookup.LookupBase` into this code path and broke
|
||||
# `cli.administration.deploy.development init` on the GitHub Actions runner host
|
||||
# (CI run 24935979190) where the runner Python ships without
|
||||
# ansible. The split lives in plugins/lookup/application_gid.py:
|
||||
# `compute_application_gid` is the pure helper, `LookupModule` is
|
||||
# the ansible-facing wrapper.
|
||||
# Pure-Python GID resolver; importing the ansible-facing LookupModule
|
||||
# here would break the ansible-free runner-host CLI path.
|
||||
from plugins.lookup.application_gid import compute_application_gid
|
||||
|
||||
application_id = role_dir.name
|
||||
@@ -145,6 +191,10 @@ def _build_role_base_config(
|
||||
if topic_data:
|
||||
config_data[topic] = topic_data
|
||||
|
||||
addons_data = _load_addons_dir(meta_dir)
|
||||
if addons_data:
|
||||
config_data["addons"] = addons_data
|
||||
|
||||
info_data = _load_yaml_cached(
|
||||
meta_dir / f"{_META_INFO_TOPIC}.yml", default_if_missing={}
|
||||
)
|
||||
@@ -247,7 +297,7 @@ def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
|
||||
|
||||
|
||||
def _build_application_defaults(roles_dir: Path) -> dict[str, Any]:
|
||||
"""Return ``{application_id: base_config}`` — each role's variant-free
|
||||
"""Return ``{application_id: base_config}``, each role's variant-free
|
||||
``meta/<topic>.yml`` payload only. Variants stay accessible via
|
||||
:func:`get_variants`. Using variant 0 here would leak its service
|
||||
flags into variant-N consumers' runtime view via deep-merge."""
|
||||
@@ -298,7 +348,7 @@ def _build_variant_overrides_only(
|
||||
def get_variant_overrides_only(
|
||||
*, roles_dir: str | os.PathLike[str] | None = None
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return ``{application_id: [override_0, ...]}`` — the raw
|
||||
"""Return ``{application_id: [override_0, ...]}``, the raw
|
||||
`meta/variants.yml` entries per role WITHOUT deep-merging
|
||||
`meta/services.yml`.
|
||||
|
||||
@@ -323,12 +373,8 @@ def get_merged_applications(
|
||||
roles_dir: str | os.PathLike[str] | None = None,
|
||||
templar: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
# Late import: `get_merged_users` lives in the sibling `users` module
|
||||
# and pulls user-domain machinery (token store, alias materialization,
|
||||
# etc.) that this module's other entry points don't need. Importing
|
||||
# at function scope keeps `import utils.cache.applications` cheap so
|
||||
# the runner-host CLI path can use `get_variants` without paying for
|
||||
# the user-domain transitive imports.
|
||||
# Late import keeps `import utils.cache.applications` free of the
|
||||
# user-domain machinery the runner-host CLI path never needs.
|
||||
from .users import get_merged_users
|
||||
|
||||
variables = variables or {}
|
||||
|
||||
@@ -56,11 +56,11 @@ type-scoping entries with the shape::
|
||||
|
||||
Two complementary flags control the per-type policy:
|
||||
|
||||
* ``mandatory`` (default ``False``) — the file (or dotted-path entry)
|
||||
* ``mandatory`` (default ``False``): the file (or dotted-path entry)
|
||||
MUST be present and non-empty for a role of this type. Implies
|
||||
``allowed: True``; setting ``mandatory: True`` together with
|
||||
``allowed: False`` is a schema error.
|
||||
* ``allowed`` (default ``True``) — the file (or dotted-path entry)
|
||||
* ``allowed`` (default ``True``): the file (or dotted-path entry)
|
||||
MAY be present for a role of this type. Set ``allowed: False`` to
|
||||
forbid the file/entry on roles of this type so the lint layer can
|
||||
surface the offence with a precise reason.
|
||||
@@ -168,6 +168,12 @@ ROLE_FILE_META_SCHEMA = "meta/schema.yml"
|
||||
ROLE_FILE_META_INFO = "meta/info.yml"
|
||||
ROLE_FILE_META_USERS = "meta/users.yml"
|
||||
|
||||
# Addons are a directory topic: one `meta/addons/<addon_id>.yml` file per
|
||||
# addon, the file root being the addon spec. Consumers glob
|
||||
# ``<role>/meta/addons/*.yml``; the schema + bridge lints under
|
||||
# ``tests/lint/ansible/services/`` police the per-file contract.
|
||||
ROLE_DIR_META_ADDONS = "meta/addons"
|
||||
|
||||
# Playwright spec: every role's E2E spec ships at this path. Companion
|
||||
# `.js` helpers MAY live alongside under the same directory; the runner
|
||||
# (`roles/test-e2e-playwright/tasks/run_one.yml`) globs every `*.js` in
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""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
|
||||
|
||||
# The installation mechanism recorded per addon. Records the upstream
|
||||
# install path; `bridge` additionally marks a network/appservice bridge
|
||||
# addon.
|
||||
MECHANISMS: frozenset[str] = frozenset(
|
||||
{"addon", "plugin", "mu_plugin", "extension", "module", "bridge"}
|
||||
)
|
||||
|
||||
# Where the addon's payload comes from.
|
||||
SOURCES: frozenset[str] = frozenset({"upstream", "bundled", "vendored", "built"})
|
||||
|
||||
# The top-level keys an addon entry may carry. Everything app-specific
|
||||
# lives under the opaque `config:` mapping so the surrounding schema stays
|
||||
# uniform across roles.
|
||||
ADDON_KEYS: frozenset[str] = frozenset(
|
||||
{
|
||||
"enabled",
|
||||
"required",
|
||||
"mechanism",
|
||||
"source",
|
||||
"bridges",
|
||||
"version",
|
||||
"group",
|
||||
"update",
|
||||
"config",
|
||||
}
|
||||
)
|
||||
|
||||
# Allowed sub-keys under an addon's `update:` block.
|
||||
UPDATE_KEYS: frozenset[str] = frozenset({"monitored", "catalog", "upstream_id"})
|
||||
|
||||
# Catalog adapters the external drift checker understands. `update.catalog`
|
||||
# MUST name one of these so a monitored addon is actually checkable and the
|
||||
# discovery stays bounded to curated relevance rules rather than scraping
|
||||
# whole public marketplaces.
|
||||
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",
|
||||
}
|
||||
)
|
||||
|
||||
# Case-insensitive substring tokens that mark a `config:` key as carrying a
|
||||
# secret. A bare literal under such a key fails the lint; the value MUST be a
|
||||
# templated `lookup(..., 'credentials.<name>')` expression (or any other
|
||||
# Jinja expression) instead.
|
||||
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(...,
|
||||
'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
|
||||
Reference in new issue
Block a user