Files
cc-ci/tests/unit/test_meta.py
autonomic-bot 472a68b32c
All checks were successful
continuous-integration/drone/push Build is passing
feat(harness): P1 — single registry-backed meta loader (rcust)
One loader: runner/harness/meta.py::load(recipe) -> RecipeMeta (frozen dataclass,
attribute access), backed by the declarative KEYS registry (14 final keys + 3
P2-deprecated). The ONLY exec() of tests/<recipe>/recipe_meta.py. Validation per
the locked decision: unknown ALL-CAPS top-level name or type mismatch = MetaError
(hard error at load); underscore-prefixed names recipe-private; callables only on
hook-typed keys.

Migrated all six legacy loaders (spec §4 L1–L6):
- run_recipe_ci.py::_load_meta deleted; orchestrator loads once, passes meta down
- tests/conftest.py::_recipe_meta deleted; meta fixture returns full RecipeMeta (R3)
- lifecycle.py::_recipe_extra_env/_recipe_meta_flag deleted; deploy_app takes meta
- deps.py::declared_deps deleted; callers read meta.DEPS
- canonical.py::is_enrolled reads through meta.load()
- screenshot.py now actually receives SCREENSHOT through the orchestrator path (R2
  fix; proven by unit test through the real load path)

Mumble private constants underscore-prefixed (_WELCOME_TEXT_MARKER/_MAX_USERS) +
importers fixed. New tests/unit/test_meta.py (all-recipes-load-clean typo gate,
MetaError cases, spec §2 baseline defaults, underscore exemption, doc sync). Docs
§4 key table now GENERATED from the registry (scripts/gen-meta-docs.py); drift
fails CI.

Verified on cc-ci: cc-ci-run -m pytest tests/unit -q -> 175 passed; scripts/lint.sh -> PASS.
2026-06-10 16:46:58 +00:00

205 lines
7.8 KiB
Python

"""Unit tests for the single recipe-meta loader + key registry (rcust P1; spec §8 R1/R6).
Covers: every in-repo recipe_meta.py loads clean through the registry (THE typo gate), validation
hard-errors (unknown key, wrong type, callable on a data key), the zero-config baseline defaults
(spec §2), the underscore exemption for recipe-private constants, and the registry↔generated-doc
sync (P1.5; drift fails CI). Run: cc-ci-run -m pytest tests/unit/test_meta.py -q
"""
from __future__ import annotations
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import meta as meta_mod # noqa: E402
from harness.meta import KEYS, MetaError, RecipeMeta # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _recipes_with_meta() -> list[str]:
tests_dir = os.path.join(ROOT, "tests")
return sorted(
n
for n in os.listdir(tests_dir)
if os.path.isfile(os.path.join(tests_dir, n, "recipe_meta.py"))
)
# ---- the typo gate: every in-repo recipe meta must validate against the registry --------------
@pytest.mark.parametrize("recipe", _recipes_with_meta())
def test_every_recipe_meta_loads_clean(recipe):
"""All tests/*/recipe_meta.py in the repo load + validate through the registry. A typo'd or
unregistered ALL-CAPS key in any recipe meta fails HERE, at PR time — not silently at run
time (the R6 failure mode this restructure kills)."""
meta = meta_mod.load(recipe)
assert isinstance(meta, RecipeMeta)
# sanity: the 4 base keys always materialize with usable types
assert isinstance(meta.HEALTH_PATH, str)
assert isinstance(meta.HEALTH_OK, tuple) and meta.HEALTH_OK
assert isinstance(meta.DEPLOY_TIMEOUT, int) and isinstance(meta.HTTP_TIMEOUT, int)
# ---- zero-config baseline (spec §2) ------------------------------------------------------------
def test_missing_meta_yields_spec_baseline(tmp_path):
meta = meta_mod.load("no-such-recipe", tests_dir=str(tmp_path))
assert meta.HEALTH_PATH == "/"
assert meta.HEALTH_OK == (200, 301, 302)
assert meta.DEPLOY_TIMEOUT == 600
assert meta.HTTP_TIMEOUT == 300
assert meta.BACKUP_CAPABLE is None # None = auto-detect (tri-state, not False)
assert meta.EXPECTED_NA is None
assert meta.READY_PROBE is None
assert meta.UPGRADE_BASE_VERSION is None
assert meta.BACKUP_VERIFY is None
assert meta.UPGRADE_EXTRA_ENV is None
assert meta.EXTRA_ENV == {}
assert meta.DEPS == []
assert meta.WARM_CANONICAL is False
assert meta.SCREENSHOT is None
assert meta_mod.non_default(meta) == {}
def test_registry_field_set_matches_dataclass():
"""The RecipeMeta field set is generated from KEYS — no drift possible, pinned anyway."""
import dataclasses
assert [f.name for f in dataclasses.fields(RecipeMeta)] == [k.name for k in KEYS]
# the 14 final keys + the 3 P2-deprecated ones, no more
assert len([k for k in KEYS if not k.deprecated]) == 14
assert sorted(k.name for k in KEYS if k.deprecated) == [
"CHAOS_BASE_DEPLOY",
"OIDC_AT_INSTALL",
"SKIP_GENERIC",
]
# ---- validation hard errors (locked decision: fail fast at load) -------------------------------
def _write_meta(tmp_path, body: str, recipe: str = "r") -> str:
d = tmp_path / recipe
d.mkdir(exist_ok=True)
(d / "recipe_meta.py").write_text(body)
return recipe
def test_unknown_key_raises_with_suggestion(tmp_path):
r = _write_meta(tmp_path, "READINESS_PROBE = None\n") # the R6 typo example
with pytest.raises(MetaError) as ei:
meta_mod.load(r, tests_dir=str(tmp_path))
msg = str(ei.value)
assert "READINESS_PROBE" in msg and "READY_PROBE" in msg # names the typo + nearest key
def test_unknown_key_without_near_match_lists_registry(tmp_path):
r = _write_meta(tmp_path, "TOTALLY_BOGUS_KNOB = 1\n")
with pytest.raises(MetaError) as ei:
meta_mod.load(r, tests_dir=str(tmp_path))
assert "HEALTH_PATH" in str(ei.value) # registered keys listed for the reader
def test_wrong_type_raises(tmp_path):
r = _write_meta(tmp_path, 'DEPLOY_TIMEOUT = "900"\n')
with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"):
meta_mod.load(r, tests_dir=str(tmp_path))
def test_bool_not_accepted_as_int(tmp_path):
r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = True\n")
with pytest.raises(MetaError, match="DEPLOY_TIMEOUT"):
meta_mod.load(r, tests_dir=str(tmp_path))
def test_callable_on_data_key_rejected(tmp_path):
r = _write_meta(tmp_path, "def HEALTH_PATH():\n return '/'\n")
with pytest.raises(MetaError, match="hook-typed"):
meta_mod.load(r, tests_dir=str(tmp_path))
def test_non_callable_on_hook_key_rejected(tmp_path):
r = _write_meta(tmp_path, "READY_PROBE = ['not', 'a', 'callable']\n")
with pytest.raises(MetaError, match="READY_PROBE"):
meta_mod.load(r, tests_dir=str(tmp_path))
def test_underscore_names_are_private_and_exempt(tmp_path):
r = _write_meta(
tmp_path,
"_WELCOME_TEXT_MARKER = 'marker-xyz'\n_MAX_USERS = 42\n"
"EXTRA_ENV = {'WELCOME_TEXT': _WELCOME_TEXT_MARKER, 'USERS': str(_MAX_USERS)}\n",
)
meta = meta_mod.load(r, tests_dir=str(tmp_path))
assert meta.EXTRA_ENV == {"WELCOME_TEXT": "marker-xyz", "USERS": "42"}
def test_lowercase_helpers_ignored(tmp_path):
r = _write_meta(
tmp_path,
"def _helper(d):\n return {'K': d}\n\ndef EXTRA_ENV(domain):\n return _helper(domain)\n",
)
meta = meta_mod.load(r, tests_dir=str(tmp_path))
assert meta_mod.extra_env(meta, "x.example") == {"K": "x.example"}
# ---- normalization + helpers --------------------------------------------------------------------
def test_health_ok_list_normalized_to_tuple(tmp_path):
r = _write_meta(tmp_path, "HEALTH_OK = [200, 302]\n")
assert meta_mod.load(r, tests_dir=str(tmp_path)).HEALTH_OK == (200, 302)
def test_extra_env_dict_and_callable_forms(tmp_path):
r = _write_meta(tmp_path, "EXTRA_ENV = {'A': 1}\n")
meta = meta_mod.load(r, tests_dir=str(tmp_path))
assert meta_mod.extra_env(meta, "d") == {"A": "1"} # values stringified
r2 = _write_meta(
tmp_path, "UPGRADE_EXTRA_ENV = lambda domain: {'COMPOSE_FILE': domain}\n", recipe="r2"
)
meta2 = meta_mod.load(r2, tests_dir=str(tmp_path))
assert meta_mod.upgrade_extra_env(meta2, "dom.x") == {"COMPOSE_FILE": "dom.x"}
assert meta_mod.extra_env(meta2, "dom.x") == {} # unset EXTRA_ENV resolves to {}
def test_non_default_reports_only_customized_keys(tmp_path):
r = _write_meta(tmp_path, "DEPLOY_TIMEOUT = 1500\nDEPS = ['keycloak']\n")
nd = meta_mod.non_default(meta_mod.load(r, tests_dir=str(tmp_path)))
assert nd == {"DEPLOY_TIMEOUT": 1500, "DEPS": ["keycloak"]}
def test_meta_is_frozen():
import dataclasses
meta = meta_mod.load("custom-html")
with pytest.raises(dataclasses.FrozenInstanceError):
meta.DEPLOY_TIMEOUT = 1
# ---- doc generation sync (P1.5: the committed §4 table == the registry rendering) ---------------
def test_generated_doc_table_in_sync():
"""docs/recipe-customization.md's key reference table is GENERATED from the registry
(scripts/gen-meta-docs.py). If this fails: re-run `python3 scripts/gen-meta-docs.py` and
commit the result — the table must never drift from the registry (R5)."""
gen = os.path.join(ROOT, "scripts", "gen-meta-docs.py")
doc = os.path.join(ROOT, "docs", "recipe-customization.md")
rendered = subprocess.run(
[sys.executable, gen, "--print"], capture_output=True, text=True, check=True
).stdout
with open(doc) as f:
committed = f.read()
assert rendered.strip() in committed, (
"docs/recipe-customization.md key table is out of sync with the harness.meta registry — "
"run `python3 scripts/gen-meta-docs.py` and commit"
)