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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user