"""Shared pytest fixtures for recipe CI (plan §4.3). A run is parameterized by env: RECIPE, REF (PR head sha), PR, SRC (head repo). The harness computes a unique app domain per run so concurrent runs never collide, and GUARANTEES teardown (undeploy + volume + secret removal) via a finalizer, even on failure. """ from __future__ import annotations import os import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner")) from harness import deps as deps_mod # noqa: E402 from harness import meta as meta_mod # noqa: E402 @pytest.fixture(scope="session") def recipe() -> str: return os.environ.get("RECIPE", "custom-html") @pytest.fixture(scope="session") def meta(recipe): """The recipe's FULL validated customization (RecipeMeta, attribute access) via the single loader (rcust P1 — previously this fixture saw only the 4 base keys, spec §8 R3).""" return meta_mod.load(recipe) @pytest.fixture(scope="session") def live_app() -> str: """Phase 1d shared-deployment contract: the orchestrator deploys ONCE and runs each tier (generic or overlay) as its own pytest invocation against that single live deployment, passing its domain in CCCI_APP_DOMAIN. Tiers are assertion-only (and lifecycle ops mutate in place) — they NEVER deploy or tear down. This guarantees one deploy + one teardown per run (DG4.1).""" domain = os.environ.get("CCCI_APP_DOMAIN") assert domain, "CCCI_APP_DOMAIN not set — a tier must run under the deploy-once orchestrator" return domain @pytest.fixture def op_state() -> dict: """The orchestrator's run-scoped op context (rcust P4): versions, artifact paths — written to `$CCCI_OP_STATE_FILE` after each lifecycle op (e.g. `{"upgrade": {"before": {...}, "head_ref": ...}, "backup": {"snapshot_id": ...}}`). Overlay tests read op facts from here instead of hand-parsing env/JSON. Skips with a clear reason outside an orchestrator run.""" import json path = os.environ.get("CCCI_OP_STATE_FILE") if not path: pytest.skip( "CCCI_OP_STATE_FILE not set — op_state is only available under the orchestrator" ) if not os.path.exists(path): pytest.skip(f"op-state file missing ({path}) — orchestrator has not performed an op yet") try: with open(path) as f: return json.load(f) except ValueError: pytest.skip(f"op-state file unreadable/not JSON ({path})") class _DepEntry(dict): """One provisioned dep (full creds dict) with attribute sugar: `entry.domain`, `entry.realm`, `entry.client_secret`, ... — dict-style access works too (rcust P2d).""" def __getattr__(self, name): try: return self[name] except KeyError as e: raise AttributeError(name) from e @pytest.fixture(scope="session") def deps() -> dict[str, _DepEntry]: """The recipe's provisioned deps (rcust P2d — consolidates the old `deps_apps`+`deps_creds` pair). When a recipe declares `DEPS = [...]` in its `recipe_meta.py`, the orchestrator provisions each dep BEFORE the single deploy and persists per-run identity + SSO creds to `$CCCI_DEPS_FILE`. `deps["keycloak"]` carries domain/realm/client_id/client_secret/user/ password/email/admin_user/admin_password/discovery_url/token_url/... (`.domain` etc. work as attributes). Empty when no deps declared OR deps-not-ready — pair with `@pytest.mark.requires_deps` so the F2-11 skip-report keeps the green signal honest.""" state = deps_mod.deps_as_dict(deps_mod.load_run_state()) return {r: _DepEntry(e) for r, e in state.items()} def pytest_collection_modifyitems(config, items): """SSO-dep plan §4: tests marked `@pytest.mark.requires_deps` are skipped with reason `deps-not-ready: ` when the orchestrator's dep provisioning failed (orchestrator sets CCCI_DEPS_READY=0 in env). Non-deps custom tests are unaffected. This is failure-isolation per plan §1 — generic tiers cannot break the SSO-marked tests' skip status, and an SSO-setup failure cannot break the generic tiers (they run first).""" deps_ready_env = os.environ.get("CCCI_DEPS_READY", "1") if deps_ready_env == "1": return reason = os.environ.get("CCCI_DEPS_NOT_READY_REASON", "(no reason given)") skip_mark = pytest.mark.skip(reason=f"deps-not-ready: {reason}") skipped = 0 for item in items: if "requires_deps" in item.keywords: item.add_marker(skip_mark) skipped += 1 # F2-11: a skip-only pytest file exits 0, so without this the orchestrator can't tell # "SSO verified" from "SSO test silently skipped because deps weren't ready". Record the count # of requires_deps tests we skipped to a report file the orchestrator reads — it surfaces the # count in RUN SUMMARY and FAILS the recipe's SSO claim (a green exit must not mask an unrun # SSO test). Appended one line per pytest invocation (one per custom file); orchestrator sums. report = os.environ.get("CCCI_DEPS_SKIP_REPORT") if report and skipped: try: with open(report, "a") as f: f.write(f"{skipped}\n") except OSError: pass def pytest_configure(config): """Register the `requires_deps` marker so pytest doesn't warn about it.""" config.addinivalue_line( "markers", "requires_deps: test requires DEPS-declared services + dep provisioning success.", )