mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +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>
274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
"""Unit tests for the per-role `meta/variants.yml` matrix-deploy
|
|
loader (`utils.cache.applications.get_variants` and the variant-zero
|
|
default in `get_merged_applications`)."""
|
|
|
|
import os
|
|
import sys
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from utils.cache import _reset_cache_for_tests # noqa: E402
|
|
from utils.cache.applications import ( # noqa: E402
|
|
_build_variants,
|
|
_load_variants_overrides as _load_yaml_variant_list,
|
|
get_application_defaults,
|
|
get_merged_applications,
|
|
get_variants,
|
|
)
|
|
|
|
|
|
def _write_role(
|
|
roles_dir: Path,
|
|
name: str,
|
|
*,
|
|
config: str | None = None,
|
|
meta: str | None = None,
|
|
server: str | None = None,
|
|
) -> None:
|
|
"""Write a synthetic role tree under <roles_dir>/<name>/meta/.
|
|
|
|
``config`` -> meta/services.yml content (the file root IS the services
|
|
map post req-008)
|
|
``server`` -> meta/server.yml content
|
|
``meta`` -> meta/variants.yml content (matrix-deploy variant list)
|
|
"""
|
|
role = roles_dir / name
|
|
(role / "meta").mkdir(parents=True, exist_ok=True)
|
|
if config is not None:
|
|
(role / "meta" / "services.yml").write_text(textwrap.dedent(config))
|
|
if server is not None:
|
|
(role / "meta" / "server.yml").write_text(textwrap.dedent(server))
|
|
if meta is not None:
|
|
(role / "meta" / "variants.yml").write_text(textwrap.dedent(meta))
|
|
|
|
|
|
class TestLoadYamlVariantList(unittest.TestCase):
|
|
def test_missing_file_yields_single_empty_variant(self):
|
|
self.assertEqual(_load_yaml_variant_list(Path("/nonexistent")), [{}])
|
|
|
|
def test_empty_file_yields_single_empty_variant(self):
|
|
path = Path(self._write_tmp(""))
|
|
self.assertEqual(_load_yaml_variant_list(path), [{}])
|
|
|
|
def test_empty_list_yields_single_empty_variant(self):
|
|
path = Path(self._write_tmp("[]\n"))
|
|
self.assertEqual(_load_yaml_variant_list(path), [{}])
|
|
|
|
def test_null_entry_normalised_to_empty_dict(self):
|
|
path = Path(self._write_tmp("- null\n- {a: 1}\n"))
|
|
self.assertEqual(_load_yaml_variant_list(path), [{}, {"a": 1}])
|
|
|
|
def test_non_list_root_rejected(self):
|
|
path = Path(self._write_tmp("a: 1\n"))
|
|
with self.assertRaises(ValueError):
|
|
_load_yaml_variant_list(path)
|
|
|
|
def test_non_mapping_entry_rejected(self):
|
|
path = Path(self._write_tmp("- 42\n"))
|
|
with self.assertRaises(ValueError):
|
|
_load_yaml_variant_list(path)
|
|
|
|
def _write_tmp(self, content: str) -> str:
|
|
import tempfile
|
|
|
|
f = tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False)
|
|
f.write(content)
|
|
f.flush()
|
|
f.close()
|
|
self.addCleanup(os.remove, f.name)
|
|
return f.name
|
|
|
|
|
|
class TestApplicationVariants(unittest.TestCase):
|
|
def setUp(self):
|
|
import tempfile
|
|
|
|
self.tmp = tempfile.mkdtemp(prefix="variants-")
|
|
self.roles_dir = Path(self.tmp)
|
|
_reset_cache_for_tests()
|
|
|
|
def tearDown(self):
|
|
import shutil
|
|
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
_reset_cache_for_tests()
|
|
|
|
def test_role_without_meta_yields_single_variant(self):
|
|
_write_role(self.roles_dir, "web-app-foo", config="foo:\n hello: world\n")
|
|
variants = _build_variants(self.roles_dir)
|
|
self.assertEqual(list(variants.keys()), ["web-app-foo"])
|
|
self.assertEqual(len(variants["web-app-foo"]), 1)
|
|
self.assertEqual(
|
|
variants["web-app-foo"][0]["services"]["foo"]["hello"], "world"
|
|
)
|
|
|
|
def test_role_with_two_variants_deep_merges_each_entry(self):
|
|
# Per req-008, the role's meta is split: server -> meta/server.yml,
|
|
# services -> meta/services.yml. The variants file overrides the
|
|
# assembled payload (top-level keys: server, services, ...).
|
|
role = self.roles_dir / "web-app-bar"
|
|
(role / "meta").mkdir(parents=True, exist_ok=True)
|
|
(role / "meta" / "services.yml").write_text(
|
|
textwrap.dedent(
|
|
"""
|
|
bar:
|
|
enabled: false
|
|
"""
|
|
)
|
|
)
|
|
(role / "meta" / "server.yml").write_text(
|
|
textwrap.dedent(
|
|
"""
|
|
domains:
|
|
canonical: ["bar.example"]
|
|
"""
|
|
)
|
|
)
|
|
(role / "meta" / "variants.yml").write_text(
|
|
textwrap.dedent(
|
|
"""
|
|
- {}
|
|
- services:
|
|
bar:
|
|
enabled: true
|
|
server:
|
|
domains:
|
|
canonical: ["bar.example", "shop.bar.example"]
|
|
"""
|
|
)
|
|
)
|
|
variants = _build_variants(self.roles_dir)["web-app-bar"]
|
|
self.assertEqual(len(variants), 2)
|
|
self.assertEqual(variants[0]["services"]["bar"]["enabled"], False)
|
|
self.assertEqual(variants[0]["server"]["domains"]["canonical"], ["bar.example"])
|
|
self.assertEqual(variants[1]["services"]["bar"]["enabled"], True)
|
|
self.assertEqual(
|
|
variants[1]["server"]["domains"]["canonical"],
|
|
["bar.example", "shop.bar.example"],
|
|
)
|
|
|
|
def test_legacy_get_application_defaults_returns_first_variant(self):
|
|
_write_role(
|
|
self.roles_dir,
|
|
"web-app-baz",
|
|
config="baz:\n value: 1\n",
|
|
meta=textwrap.dedent(
|
|
"""
|
|
- {}
|
|
- services:
|
|
baz:
|
|
value: 2
|
|
"""
|
|
),
|
|
)
|
|
defaults = get_application_defaults(roles_dir=self.roles_dir)
|
|
self.assertEqual(defaults["web-app-baz"]["services"]["baz"]["value"], 1)
|
|
|
|
def test_get_variants_caches_per_roles_dir(self):
|
|
_write_role(self.roles_dir, "web-app-cache", config="cache:\n x: 1\n")
|
|
first = get_variants(roles_dir=self.roles_dir)
|
|
second = get_variants(roles_dir=self.roles_dir)
|
|
self.assertEqual(first, second)
|
|
# Mutating the returned copy MUST NOT corrupt the cache.
|
|
second["web-app-cache"][0]["services"]["cache"]["x"] = 999
|
|
self.assertEqual(
|
|
get_variants(roles_dir=self.roles_dir)["web-app-cache"][0]["services"][
|
|
"cache"
|
|
]["x"],
|
|
1,
|
|
)
|
|
|
|
|
|
class TestMergedApplicationsAlwaysVariantZero(unittest.TestCase):
|
|
"""The runtime loader no longer reads any active-variant selector.
|
|
Variant N data is baked into the inventory by
|
|
`cli.deploy.development.inventory.build_dev_inventory` at init time
|
|
and reaches the merged payload as an `applications.<app>` override.
|
|
These tests pin that contract: the loader's defaults are ALWAYS
|
|
variant 0, and inventory-level overrides win as before."""
|
|
|
|
def setUp(self):
|
|
import tempfile
|
|
|
|
self.tmp = tempfile.mkdtemp(prefix="variants-merged-")
|
|
self.roles_dir = Path(self.tmp)
|
|
_reset_cache_for_tests()
|
|
_write_role(
|
|
self.roles_dir,
|
|
"web-app-multi",
|
|
server=textwrap.dedent(
|
|
"""
|
|
domains:
|
|
canonical: ["multi.example"]
|
|
"""
|
|
),
|
|
meta=textwrap.dedent(
|
|
"""
|
|
- {}
|
|
- server:
|
|
domains:
|
|
canonical:
|
|
- "blog.multi.example"
|
|
- "shop.multi.example"
|
|
"""
|
|
),
|
|
)
|
|
|
|
def tearDown(self):
|
|
import shutil
|
|
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
_reset_cache_for_tests()
|
|
|
|
def test_no_inventory_overrides_yield_variant_zero(self):
|
|
merged = get_merged_applications(
|
|
variables={},
|
|
roles_dir=str(self.roles_dir),
|
|
)
|
|
self.assertEqual(
|
|
merged["web-app-multi"]["server"]["domains"]["canonical"],
|
|
["multi.example"],
|
|
)
|
|
|
|
def test_inventory_override_swaps_in_variant_one_payload(self):
|
|
# This mirrors what the init step bakes into host_vars when
|
|
# round 1 selects variant 1 for `web-app-multi`. Override wins
|
|
# via deep merge.
|
|
variant_one = {
|
|
"server": {
|
|
"domains": {
|
|
"canonical": ["blog.multi.example", "shop.multi.example"],
|
|
},
|
|
},
|
|
}
|
|
merged = get_merged_applications(
|
|
variables={"applications": {"web-app-multi": variant_one}},
|
|
roles_dir=str(self.roles_dir),
|
|
)
|
|
self.assertEqual(
|
|
merged["web-app-multi"]["server"]["domains"]["canonical"],
|
|
["blog.multi.example", "shop.multi.example"],
|
|
)
|
|
|
|
def test_active_variants_marker_is_ignored_by_loader(self):
|
|
# The previous `_active_variants` runtime selector has been
|
|
# retired; the loader MUST NOT honour it any more. This test
|
|
# guards against accidentally re-introducing the runtime path.
|
|
merged = get_merged_applications(
|
|
variables={"_active_variants": {"web-app-multi": 1}},
|
|
roles_dir=str(self.roles_dir),
|
|
)
|
|
self.assertEqual(
|
|
merged["web-app-multi"]["server"]["domains"]["canonical"],
|
|
["multi.example"],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|