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:
2026-05-28 07:41:56 +01:00
parent 0d3232409d
commit 4d6b040ba7
5 changed files with 596 additions and 19 deletions

View File

@ -40,7 +40,7 @@ import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "runner"))
from harness import discovery, generic, lifecycle, naming # noqa: E402
from harness import deps as deps_mod, discovery, generic, lifecycle, naming # noqa: E402
ALL_STAGES = ("install", "upgrade", "backup", "restore", "custom")
@ -344,25 +344,53 @@ def main() -> int:
os.environ["CCCI_OP_STATE_FILE"] = statefile
op_state: dict = {}
# Run-scoped dep state (Phase 2 Q2.3): if this recipe declares DEPS in recipe_meta, the
# orchestrator deploys each dep BEFORE the recipe under test, persists their per-run identity
# here for dependent tests to read via the `deps_apps` fixture, and tears them down LAST in
# finally (reverse order). Empty list when no deps declared.
depsfile = os.path.join(tempfile.gettempdir(), f"ccci-deps-{domain}.json")
with open(depsfile, "w") as f:
json.dump([], f)
os.environ["CCCI_DEPS_FILE"] = depsfile
declared = deps_mod.declared_deps(recipe)
if declared:
print(f"\n===== DEPS: {declared} =====", flush=True)
deps_state: list[dict] = []
results: dict[str, str] = {}
lifecycle.janitor()
dep_deploy_failed = False
try:
# ---- deps deploy FIRST (sequentially), if declared (Q2.3) ----
if declared:
try:
# Build a per-dep meta map for readiness waits (timeouts/health-path/codes)
dep_metas = {d: _load_meta(d) for d in declared}
deps_state = deps_mod.deploy_deps(
recipe, os.environ.get("PR", "0"), ref, declared, meta_for=dep_metas
)
except Exception as e: # noqa: BLE001 — failed dep deploy is a recipe install failure
print(f"!! dep deploy failed: {_scrub(str(e))}", flush=True)
dep_deploy_failed = True
# ---- deploy ONCE + wait ready (the single deployment all tiers share) ----
try:
lifecycle.deploy_app(
recipe, domain, version=base, secrets=True, install_steps_hook=hook
)
lifecycle.wait_healthy(
domain,
ok_codes=tuple(meta["HEALTH_OK"]),
path=meta["HEALTH_PATH"],
deploy_timeout=meta["DEPLOY_TIMEOUT"],
http_timeout=meta["HTTP_TIMEOUT"],
)
deploy_ok = True
except Exception as e: # noqa: BLE001 — a failed deploy is a reported INSTALL failure, not a crash
print(f"!! deploy/readiness failed: {e}", flush=True)
if dep_deploy_failed:
deploy_ok = False
else:
try:
lifecycle.deploy_app(
recipe, domain, version=base, secrets=True, install_steps_hook=hook
)
lifecycle.wait_healthy(
domain,
ok_codes=tuple(meta["HEALTH_OK"]),
path=meta["HEALTH_PATH"],
deploy_timeout=meta["DEPLOY_TIMEOUT"],
http_timeout=meta["HTTP_TIMEOUT"],
)
deploy_ok = True
except Exception as e: # noqa: BLE001 — a failed deploy is a reported INSTALL failure, not a crash
print(f"!! deploy/readiness failed: {e}", flush=True)
deploy_ok = False
# ---- INSTALL tier (always; additive generic + overlay, no op) ----
if "install" in stages:
@ -408,7 +436,11 @@ def main() -> int:
if op in stages:
results[op] = "skip"
finally:
# Teardown the recipe under test FIRST, then deps in reverse declaration order.
lifecycle.teardown_app(domain, verify=False)
if deps_state:
print("\n===== DEPS teardown =====", flush=True)
deps_mod.teardown_deps(deps_state)
# ---- deploy-count assertion (DG4.1) ----
with open(countfile) as f:
@ -416,17 +448,27 @@ def main() -> int:
os.remove(countfile)
with contextlib.suppress(OSError):
os.remove(statefile)
with contextlib.suppress(OSError):
os.remove(depsfile)
# ---- per-op summary (DG6 feed) ----
# Phase 2 Q2.3: deps each `deploy_app` once, so the expected count = 1 (recipe under test) +
# len(deps). DG4.1 still holds — no extra deploys per recipe — just accommodates declared deps.
expected_deploy_count = 1 + len(deps_state)
print("\n===== RUN SUMMARY =====", flush=True)
print(f"deploy-count = {deploy_count} (expect 1)")
print(f"deploy-count = {deploy_count} (expect {expected_deploy_count})")
if deps_state:
print(f" deps deployed: {[d['recipe'] for d in deps_state]}")
order = [s for s in ALL_STAGES if s in results]
for op in order:
print(f" {op:8s}: {results[op]}")
overall = 0
if deploy_count != 1:
print(f"!! deploy-count {deploy_count} != 1 (DG4.1 violation)", file=sys.stderr)
if deploy_count != expected_deploy_count:
print(
f"!! deploy-count {deploy_count} != {expected_deploy_count} (DG4.1 violation)",
file=sys.stderr,
)
overall = 1
if any(v == "fail" for v in results.values()):
overall = 1