feat(harness): P1 — single registry-backed meta loader (rcust)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
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.
This commit is contained in:
@ -13,6 +13,7 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import canonical, warm # noqa: E402
|
||||
from harness import meta as harness_meta # noqa: E402
|
||||
|
||||
|
||||
def test_canonical_domain():
|
||||
@ -33,11 +34,9 @@ def test_is_enrolled_reads_flag(tmp_path, monkeypatch):
|
||||
tests_dir = tmp_path / "tests" / recipe
|
||||
tests_dir.mkdir(parents=True)
|
||||
(tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = True\n")
|
||||
# canonical.is_enrolled builds the path from canonical.__file__/../../tests/<recipe>; emulate by
|
||||
# creating the layout under a fake harness dir and pointing __file__ there.
|
||||
fake_harness = tmp_path / "runner" / "harness"
|
||||
fake_harness.mkdir(parents=True)
|
||||
monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py"))
|
||||
# is_enrolled reads through the single meta loader (rcust P1); point its tests/ root at the
|
||||
# temp layout.
|
||||
monkeypatch.setattr(harness_meta, "TESTS_DIR", str(tmp_path / "tests"))
|
||||
assert canonical.is_enrolled(recipe) is True
|
||||
(tests_dir / "recipe_meta.py").write_text("WARM_CANONICAL = False\n")
|
||||
assert canonical.is_enrolled(recipe) is False
|
||||
@ -65,9 +64,7 @@ def test_registry_roundtrip(tmp_path, monkeypatch):
|
||||
|
||||
def test_enrolled_recipes_scans_meta(tmp_path, monkeypatch):
|
||||
# enrolled_recipes() lists recipes whose tests/<r>/recipe_meta.py sets WARM_CANONICAL=True.
|
||||
fake_harness = tmp_path / "runner" / "harness"
|
||||
fake_harness.mkdir(parents=True)
|
||||
monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py"))
|
||||
monkeypatch.setattr(harness_meta, "TESTS_DIR", str(tmp_path / "tests"))
|
||||
for name, body in (
|
||||
("aaa", "WARM_CANONICAL = True\n"),
|
||||
("bbb", "DEPS=['x']\n"),
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""Unit tests for runner/harness/deps.py (Phase 2 §4.2 / Q2.3).
|
||||
|
||||
Pure-Python: no real deploys. Tests the declarative parts of the dep resolver — declared_deps
|
||||
reading from `tests/<recipe>/recipe_meta.py`, the per-dep domain derivation, and write/load of the
|
||||
run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against cc-ci
|
||||
(Q2.4 acceptance).
|
||||
Pure-Python: no real deploys. Tests the declarative parts of the dep resolver — DEPS declaration
|
||||
(read through the single meta loader since rcust P1), the per-dep domain derivation, and write/load
|
||||
of the run state file. The deploy_deps + teardown_deps integration is exercised by real e2e against
|
||||
cc-ci (Q2.4 acceptance).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -13,42 +13,23 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import deps # noqa: E402
|
||||
from harness import meta as meta_mod # noqa: E402
|
||||
|
||||
|
||||
def test_declared_deps_returns_empty_for_no_meta(monkeypatch, tmp_path):
|
||||
"""A recipe with no recipe_meta.py returns []."""
|
||||
fake_recipe = "ccci-no-meta"
|
||||
# No file at tests/<fake_recipe>/recipe_meta.py -> declared_deps reads nothing -> []
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert deps.declared_deps(fake_recipe) == []
|
||||
def test_declared_deps_empty_for_no_meta(monkeypatch, tmp_path):
|
||||
"""A recipe with no recipe_meta.py declares no deps (rcust P1: DEPS via meta.load)."""
|
||||
monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests"))
|
||||
assert meta_mod.load("ccci-no-meta").DEPS == []
|
||||
|
||||
|
||||
def test_declared_deps_reads_DEPS_list(tmp_path, monkeypatch):
|
||||
"""A recipe_meta.py with `DEPS = [...]` returns the list."""
|
||||
fake_recipe = "ccci-with-deps"
|
||||
# Build a fake repo layout under tmp_path
|
||||
recipe_dir = tmp_path / "tests" / fake_recipe
|
||||
"""A recipe_meta.py with `DEPS = [...]` surfaces the list on the loaded meta (the orchestrator
|
||||
reads meta.DEPS — the successor of the deleted deps.declared_deps loader)."""
|
||||
recipe_dir = tmp_path / "tests" / "ccci-with-deps"
|
||||
recipe_dir.mkdir(parents=True)
|
||||
(recipe_dir / "recipe_meta.py").write_text('HEALTH_PATH = "/"\nDEPS = ["keycloak", "redis"]\n')
|
||||
# Patch the deps module's idea of "where the repo is" by monkey-patching __file__ for the
|
||||
# function indirectly: declared_deps uses `os.path.dirname(__file__), "..", "..", "tests"` —
|
||||
# which resolves to the real repo's `tests/`. So instead, override that with a symlink/dir
|
||||
# under tmp_path: deps.__file__ points at the runner module. We can't easily relocate that.
|
||||
# Instead, mock the path by writing the fake recipe under the REAL tests/ dir.
|
||||
real_tests = os.path.join(os.path.dirname(deps.__file__), "..", "..", "tests")
|
||||
target_dir = os.path.join(real_tests, fake_recipe)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
target_meta = os.path.join(target_dir, "recipe_meta.py")
|
||||
try:
|
||||
with open(target_meta, "w") as f:
|
||||
f.write('DEPS = ["keycloak", "redis"]\n')
|
||||
result = deps.declared_deps(fake_recipe)
|
||||
assert result == ["keycloak", "redis"]
|
||||
finally:
|
||||
if os.path.exists(target_meta):
|
||||
os.remove(target_meta)
|
||||
if os.path.isdir(target_dir):
|
||||
os.rmdir(target_dir)
|
||||
monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path / "tests"))
|
||||
assert meta_mod.load("ccci-with-deps").DEPS == ["keycloak", "redis"]
|
||||
|
||||
|
||||
def test_dep_domain_distinct_per_dep():
|
||||
|
||||
@ -14,6 +14,7 @@ So `-c` + owned-wait is non-vacuous: a genuinely-broken upgrade stays RED.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import sys
|
||||
|
||||
@ -21,6 +22,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import lifecycle as lc # noqa: E402
|
||||
from harness import meta as harness_meta # noqa: E402
|
||||
|
||||
|
||||
def _fake_clock(monkeypatch):
|
||||
@ -31,11 +33,13 @@ def _fake_clock(monkeypatch):
|
||||
return state
|
||||
|
||||
|
||||
_DRIVE_META = {
|
||||
"READY_PROBE": lambda d: [
|
||||
{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)}
|
||||
]
|
||||
}
|
||||
# RecipeMeta (rcust P1: wait_ready_probes reads meta.READY_PROBE off the loaded object); defaults
|
||||
# + the drive-style probe hook.
|
||||
_DRIVE_META = dataclasses.replace(
|
||||
harness_meta.load("ccci-no-such-recipe"),
|
||||
READY_PROBE=lambda d: [{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)}],
|
||||
)
|
||||
_NO_PROBE_META = harness_meta.load("ccci-no-such-recipe")
|
||||
|
||||
|
||||
def test_wait_ready_probes_raises_when_never_ready(monkeypatch):
|
||||
@ -57,7 +61,7 @@ def test_wait_ready_probes_returns_when_ready(monkeypatch):
|
||||
def test_wait_ready_probes_noop_without_probe(monkeypatch):
|
||||
"""A recipe with no READY_PROBE is a clean no-op (default behavior preserved for all recipes)."""
|
||||
monkeypatch.setattr(lc, "http_get", lambda *a, **k: 599) # would fail if it were consulted
|
||||
lc.wait_ready_probes({}, "x.ci.commoninternet.net", timeout=1) # no raise, no call
|
||||
lc.wait_ready_probes(_NO_PROBE_META, "x.ci.commoninternet.net", timeout=1) # no raise, no call
|
||||
|
||||
|
||||
def test_wait_healthy_raises_when_services_never_converge(monkeypatch):
|
||||
|
||||
204
tests/unit/test_meta.py
Normal file
204
tests/unit/test_meta.py
Normal file
@ -0,0 +1,204 @@
|
||||
"""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"
|
||||
)
|
||||
@ -11,6 +11,7 @@ import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import meta as meta_mod # noqa: E402
|
||||
from harness import screenshot as S # noqa: E402
|
||||
|
||||
|
||||
@ -29,3 +30,19 @@ def test_hook_returned_when_callable():
|
||||
pass
|
||||
|
||||
assert S._load_screenshot_hook({"SCREENSHOT": hook}) is hook
|
||||
|
||||
|
||||
def test_screenshot_reachable_through_real_load_path(tmp_path):
|
||||
"""R2 proof (rcust P1): a recipe SCREENSHOT hook declared in recipe_meta.py arrives at
|
||||
screenshot._load_screenshot_hook through the REAL orchestrator load path (meta.load — the
|
||||
object run_recipe_ci passes to capture()). Under the old six-loader world the orchestrator's
|
||||
L1 allowlist dropped SCREENSHOT, so the hook was unreachable (spec §8 R2)."""
|
||||
d = tmp_path / "shotrecipe"
|
||||
d.mkdir()
|
||||
(d / "recipe_meta.py").write_text(
|
||||
"def SCREENSHOT(page, ctx):\n return None\n",
|
||||
)
|
||||
meta = meta_mod.load("shotrecipe", tests_dir=str(tmp_path))
|
||||
hook = S._load_screenshot_hook(meta)
|
||||
assert callable(hook), "SCREENSHOT hook did not survive the orchestrator load path (R2)"
|
||||
assert S._load_screenshot_hook(meta_mod.load("no-such", tests_dir=str(tmp_path))) is None
|
||||
|
||||
Reference in New Issue
Block a user