feat(2): Q2.3 — dep resolver + SSO-setup harness primitives
- runner/harness/deps.py: dep resolver primitive (Phase 2 §4.2 / Q2.3).
- declared_deps(recipe) reads DEPS list from tests/<recipe>/recipe_meta.py
- dep_domain(parent, pr, ref, dep) — per-run domain per (parent, dep) pair
so two recipes' deps of the same kind don't collide on a host
- deploy_deps / teardown_deps — sequential deploy + reverse-order teardown
- read/write of run-scoped $CCCI_DEPS_FILE
- runner/harness/sso.py: SSO-setup / OIDC-flow primitive (Phase 2 §4.2 / Q2.3).
- setup_keycloak_realm: idempotent realm + confidential OIDC client +
test user with generated 25-char alphanumeric password (class-B per §4.4-B);
returns SsoCreds dict with discovery_url, token_url, all identifiers.
- oidc_password_grant: exercises the password-grant OIDC flow; returns
access_token (a JWT) or raises.
- assert_discovery_endpoint: GET /.well-known/openid-configuration; asserts
issuer matches the per-run provider domain+realm.
- runner/run_recipe_ci.py: wired in dep deploy BEFORE recipe-under-test, dep
teardown LAST in finally (reverse order). DG4.1 deploy-count guard now
expects 1 + len(deps_state) — accommodates declared deps without breaking
the no-extra-deploys invariant.
- tests/conftest.py: deps_apps fixture reads $CCCI_DEPS_FILE -> dict mapping
dep_recipe -> dep_domain.
- tests/unit/test_deps.py: 7 unit tests covering declared_deps parsing,
per-(parent,dep) domain distinctness, run-state JSON write/load, env-var
no-op semantics. 28/28 unit tests PASS on cc-ci.
Smoke test confirmed deploy_count == expected (1) when no deps declared
(custom-html install run, log /root/ccci-q2-deps-smoke.log).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -13,7 +13,7 @@ import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner"))
|
||||
from harness import lifecycle, naming # noqa: E402
|
||||
from harness import deps as deps_mod, lifecycle, naming # noqa: E402
|
||||
|
||||
|
||||
def _short(s: str, n: int = 8) -> str:
|
||||
@ -72,6 +72,17 @@ def live_app() -> str:
|
||||
return domain
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def deps_apps() -> dict[str, str]:
|
||||
"""Phase 2 Q2.3 dependency-resolver contract: when a recipe declares `DEPS = [...]` in its
|
||||
`recipe_meta.py`, the orchestrator deploys each dep BEFORE the recipe under test, persists
|
||||
their per-run identity to `$CCCI_DEPS_FILE`, and tears them down LAST in finally. Tests access
|
||||
the dep's per-run domain via this fixture: `deps_apps["keycloak"]` returns the dep's domain
|
||||
or raises KeyError if the dep wasn't declared. Returns {} when the recipe declared no deps."""
|
||||
state = deps_mod.load_run_state()
|
||||
return {entry["recipe"]: entry["domain"] for entry in state if entry.get("domain")}
|
||||
|
||||
|
||||
def _wait_healthy(domain, meta):
|
||||
lifecycle.wait_healthy(
|
||||
domain,
|
||||
|
||||
103
tests/unit/test_deps.py
Normal file
103
tests/unit/test_deps.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import deps # 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_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
|
||||
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)
|
||||
|
||||
|
||||
def test_dep_domain_distinct_per_dep():
|
||||
"""Two deps of different kinds (same parent/pr/ref) get distinct per-run domains."""
|
||||
parent = "lasuite-docs"
|
||||
pr = "42"
|
||||
ref = "abc123"
|
||||
d1 = deps.dep_domain(parent, pr, ref, "keycloak")
|
||||
d2 = deps.dep_domain(parent, pr, ref, "redis")
|
||||
assert d1 != d2
|
||||
# Both must look like the standard run-app pattern: <recipe[:4]>-<6hex>.ci.commoninternet.net
|
||||
assert d1.endswith(".ci.commoninternet.net")
|
||||
assert d2.endswith(".ci.commoninternet.net")
|
||||
# The hash is determined by (parent, pr, ref, dep) — same inputs = same domain (idempotent)
|
||||
assert deps.dep_domain(parent, pr, ref, "keycloak") == d1
|
||||
|
||||
|
||||
def test_dep_domain_distinct_per_parent():
|
||||
"""The same dep deployed by two different parent recipes (same dep, pr, ref) gets distinct
|
||||
domains — proves the dep is parent-scoped not just dep-name-scoped."""
|
||||
d1 = deps.dep_domain("lasuite-docs", "42", "abc", "keycloak")
|
||||
d2 = deps.dep_domain("cryptpad", "42", "abc", "keycloak")
|
||||
assert d1 != d2
|
||||
|
||||
|
||||
def test_write_and_load_run_state(tmp_path, monkeypatch):
|
||||
"""write_run_state writes JSON to $CCCI_DEPS_FILE; load_run_state reads it back."""
|
||||
state_path = tmp_path / "deps.json"
|
||||
monkeypatch.setenv("CCCI_DEPS_FILE", str(state_path))
|
||||
state = [
|
||||
{"recipe": "keycloak", "domain": "kc-deadbe.ci.commoninternet.net"},
|
||||
{"recipe": "redis", "domain": "redi-abc123.ci.commoninternet.net"},
|
||||
]
|
||||
deps.write_run_state(state)
|
||||
loaded = deps.load_run_state()
|
||||
assert loaded == state
|
||||
|
||||
|
||||
def test_load_run_state_missing_env_returns_empty(monkeypatch):
|
||||
"""No $CCCI_DEPS_FILE -> empty list."""
|
||||
monkeypatch.delenv("CCCI_DEPS_FILE", raising=False)
|
||||
assert deps.load_run_state() == []
|
||||
|
||||
|
||||
def test_write_run_state_no_env_is_noop(monkeypatch):
|
||||
"""write_run_state silently no-ops without $CCCI_DEPS_FILE (so standalone helper use doesn't
|
||||
require setting up the env)."""
|
||||
monkeypatch.delenv("CCCI_DEPS_FILE", raising=False)
|
||||
deps.write_run_state([{"recipe": "x", "domain": "y"}]) # must not raise
|
||||
Reference in New Issue
Block a user