feat(3 U0.2+U0.3): per-test results + results.json with computed level
harness/results.py: JUnit-XML parsing (stdlib) → per-stage/per-test rows; derive_rungs (documented
tier+deps/SSO → rung mapping); build_results assembles results.json {recipe,version,pr,ref,run_id,
stages[],level,level_cap_reason,rungs,flags{clean_teardown,no_secret_leak},screenshot,summary_card};
write_results (atomic). run_recipe_ci.py: tiers emit --junitxml + append {tier,source,file,rc,junit}
records; main() assembles+writes results.json wrapped so a failure NEVER changes the verdict (R7),
incl. a narrow leak-scan of the serialised artifact. 17 new unit tests (test_results.py).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -49,6 +49,7 @@ from harness import ( # noqa: E402
|
||||
generic,
|
||||
lifecycle,
|
||||
naming,
|
||||
results as results_mod,
|
||||
warm,
|
||||
warmsnap,
|
||||
)
|
||||
@ -194,7 +195,15 @@ def _load_meta(recipe: str) -> dict:
|
||||
ns: dict = {}
|
||||
with open(path) as fh:
|
||||
exec(compile(fh.read(), path, "exec"), ns) # noqa: S102 (trusted, in-repo)
|
||||
for k in list(meta) + ["BACKUP_CAPABLE", "SKIP_GENERIC", "OIDC_AT_INSTALL", "READY_PROBE", "UPGRADE_BASE_VERSION", "BACKUP_VERIFY", "UPGRADE_EXTRA_ENV"]:
|
||||
for k in list(meta) + [
|
||||
"BACKUP_CAPABLE",
|
||||
"SKIP_GENERIC",
|
||||
"OIDC_AT_INSTALL",
|
||||
"READY_PROBE",
|
||||
"UPGRADE_BASE_VERSION",
|
||||
"BACKUP_VERIFY",
|
||||
"UPGRADE_EXTRA_ENV",
|
||||
]:
|
||||
if k in ns:
|
||||
meta[k] = ns[k]
|
||||
return meta
|
||||
@ -240,7 +249,12 @@ def _run_pre_hook(recipe: str, op: str, repo_local: str | None, domain: str, met
|
||||
|
||||
|
||||
def _perform_op(
|
||||
op: str, domain: str, recipe: str, head_ref: str | None, op_state: dict, deploy_timeout: int = 900,
|
||||
op: str,
|
||||
domain: str,
|
||||
recipe: str,
|
||||
head_ref: str | None,
|
||||
op_state: dict,
|
||||
deploy_timeout: int = 900,
|
||||
meta: dict | None = None,
|
||||
) -> None:
|
||||
"""Perform the single mutating op ONCE (the harness owns the op, HC3). install has no op. Records
|
||||
@ -250,7 +264,9 @@ def _perform_op(
|
||||
upgrade chaos redeploy so a heavy reconverge isn't SIGKILLed by the 900s default mid-wait; `meta`
|
||||
lets the upgrade op own a recipe-aware convergence+health wait (F2-12, READY_PROBE)."""
|
||||
if op == "upgrade":
|
||||
before = generic.perform_upgrade(domain, recipe, head_ref, deploy_timeout=deploy_timeout, meta=meta)
|
||||
before = generic.perform_upgrade(
|
||||
domain, recipe, head_ref, deploy_timeout=deploy_timeout, meta=meta
|
||||
)
|
||||
op_state["upgrade"] = {"before": before, "head_ref": head_ref}
|
||||
elif op == "backup":
|
||||
# Backup integrity + retry (F2-14b). A recipe may define BACKUP_VERIFY(domain) -> bool that
|
||||
@ -273,7 +289,10 @@ def _perform_op(
|
||||
)
|
||||
snap = generic.perform_backup(domain)
|
||||
if callable(verify) and not verify(domain):
|
||||
print(f" !! backup-verify still FAILED after {attempt} attempts — backup is incomplete", flush=True)
|
||||
print(
|
||||
f" !! backup-verify still FAILED after {attempt} attempts — backup is incomplete",
|
||||
flush=True,
|
||||
)
|
||||
op_state["backup"] = {"snapshot_id": snap}
|
||||
elif op == "restore":
|
||||
generic.perform_restore(domain)
|
||||
@ -288,11 +307,17 @@ def run_lifecycle_tier(
|
||||
meta: dict,
|
||||
head_ref: str | None,
|
||||
op_state: dict,
|
||||
records: list[dict] | None = None,
|
||||
junit_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Additive lifecycle tier (HC3): seed (pre-op hook) → perform the op ONCE → run the generic
|
||||
assertion file (unless opted out) AND the overlay assertion file, both against the shared post-op
|
||||
deployment. The upgrade op redeploys the PR head (head_ref) via chaos (HC1). Returns
|
||||
'pass' | 'fail' | 'skip'."""
|
||||
'pass' | 'fail' | 'skip'.
|
||||
|
||||
Phase 3 (R1/R3): when `records`/`junit_dir` are given, each pytest file is run with --junitxml and
|
||||
a {tier,source,file,rc,junit} record appended, so the run can assemble per-stage/per-test
|
||||
results.json + the level afterwards. Purely additive — does not change the verdict."""
|
||||
overlay = discovery.resolve_overlay_op(recipe, op, repo_local)
|
||||
skip_gen = _skip_generic(op, meta)
|
||||
files: list[tuple[str, str]] = []
|
||||
@ -314,8 +339,13 @@ def run_lifecycle_tier(
|
||||
try:
|
||||
_run_pre_hook(recipe, op, repo_local, domain, meta)
|
||||
_perform_op(
|
||||
op, domain, recipe, head_ref, op_state,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)), meta=meta,
|
||||
op,
|
||||
domain,
|
||||
recipe,
|
||||
head_ref,
|
||||
op_state,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
|
||||
meta=meta,
|
||||
)
|
||||
with open(os.environ["CCCI_OP_STATE_FILE"], "w") as f:
|
||||
json.dump(op_state, f)
|
||||
@ -328,9 +358,22 @@ def run_lifecycle_tier(
|
||||
rc_all = 0
|
||||
for source, path in files:
|
||||
print(f" assert ({source}): {os.path.relpath(path, ROOT)}", flush=True)
|
||||
rc = run_redacted(
|
||||
[sys.executable, "-m", "pytest", "-v", "-rA", path], env=_tier_env(domain)
|
||||
)
|
||||
cmd = [sys.executable, "-m", "pytest", "-v", "-rA", path]
|
||||
jx = None
|
||||
if junit_dir is not None:
|
||||
jx = results_mod.junit_file(junit_dir, op, source, path)
|
||||
cmd.append(f"--junitxml={jx}")
|
||||
rc = run_redacted(cmd, env=_tier_env(domain))
|
||||
if records is not None:
|
||||
records.append(
|
||||
{
|
||||
"tier": op,
|
||||
"source": source,
|
||||
"file": os.path.relpath(path, ROOT),
|
||||
"rc": rc,
|
||||
"junit": jx,
|
||||
}
|
||||
)
|
||||
if rc != 0:
|
||||
rc_all = rc
|
||||
return "pass" if rc_all == 0 else "fail"
|
||||
@ -390,7 +433,9 @@ def _enrich_deps_with_sso(parent_recipe: str, parent_domain: str, deps_list) ->
|
||||
return out
|
||||
|
||||
|
||||
def _provision_deps(recipe: str, domain: str, ref: str | None, declared: list[str]) -> dict[str, dict]:
|
||||
def _provision_deps(
|
||||
recipe: str, domain: str, ref: str | None, declared: list[str]
|
||||
) -> dict[str, dict]:
|
||||
"""Provision a run's declared deps and write `$CCCI_DEPS_FILE`; return the recipe→entry deps_state.
|
||||
|
||||
Splits deps into live-warm (shared provider at a stable domain + a per-run realm) vs cold
|
||||
@ -438,7 +483,10 @@ def _run_setup_custom_tests_hook(recipe: str, domain: str, deps_file: str) -> No
|
||||
if not os.path.isfile(path):
|
||||
# No hook = recipe doesn't need post-deps wiring; deps are deployed + creds available
|
||||
# via deps_apps fixture as-is.
|
||||
print(f" setup_custom_tests: no hook at {os.path.relpath(path, ROOT)} (deps creds ready in $CCCI_DEPS_FILE)", flush=True)
|
||||
print(
|
||||
f" setup_custom_tests: no hook at {os.path.relpath(path, ROOT)} (deps creds ready in $CCCI_DEPS_FILE)",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
print(f" setup_custom_tests hook: {os.path.relpath(path, ROOT)}", flush=True)
|
||||
rc = subprocess.run(
|
||||
@ -452,9 +500,15 @@ def _run_setup_custom_tests_hook(recipe: str, domain: str, deps_file: str) -> No
|
||||
)
|
||||
|
||||
|
||||
def run_custom(recipe: str, repo_local: str | None, domain: str) -> str:
|
||||
def run_custom(
|
||||
recipe: str,
|
||||
repo_local: str | None,
|
||||
domain: str,
|
||||
records: list[dict] | None = None,
|
||||
junit_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Run all discovered non-lifecycle custom test_*.py (both locations, additive). Returns
|
||||
'skip' if none defined, else 'pass'/'fail'."""
|
||||
'skip' if none defined, else 'pass'/'fail'. Phase 3: emits JUnit + records when given."""
|
||||
customs = discovery.custom_tests(recipe, repo_local)
|
||||
if not customs:
|
||||
return "skip"
|
||||
@ -463,9 +517,14 @@ def run_custom(recipe: str, repo_local: str | None, domain: str) -> str:
|
||||
for source, path in customs:
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
print(f" custom ({source}): {rel}", flush=True)
|
||||
rc = run_redacted(
|
||||
[sys.executable, "-m", "pytest", "-v", "-rA", path], env=_tier_env(domain)
|
||||
)
|
||||
cmd = [sys.executable, "-m", "pytest", "-v", "-rA", path]
|
||||
jx = None
|
||||
if junit_dir is not None:
|
||||
jx = results_mod.junit_file(junit_dir, "custom", source, path)
|
||||
cmd.append(f"--junitxml={jx}")
|
||||
rc = run_redacted(cmd, env=_tier_env(domain))
|
||||
if records is not None:
|
||||
records.append({"tier": "custom", "source": source, "file": rel, "rc": rc, "junit": jx})
|
||||
if rc != 0:
|
||||
rc_all = rc
|
||||
return "pass" if rc_all == 0 else "fail"
|
||||
@ -482,8 +541,9 @@ def _wait_undeployed(domain: str, timeout: int = 120) -> None:
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None,
|
||||
meta: dict) -> int:
|
||||
def run_quick(
|
||||
recipe: str, ref: str | None, head_ref: str | None, repo_local: str | None, meta: dict
|
||||
) -> int:
|
||||
"""WC4 `--quick` opt-in fast lane (plan §2). Reattach the data-warm canonical (known-good volume)
|
||||
→ upgrade IN PLACE to the PR head (chaos) → assert generic UPGRADE (reconverge+moved+serving) +
|
||||
overlay + custom. PASS → undeploy-keep-volume, **known-good UNCHANGED (NEVER promote)**; FAIL →
|
||||
@ -532,8 +592,11 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
try:
|
||||
canonical.deploy_canonical(recipe, timeout=int(meta.get("DEPLOY_TIMEOUT", 900)))
|
||||
lifecycle.wait_healthy(
|
||||
domain, ok_codes=tuple(meta["HEALTH_OK"]), path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"], http_timeout=meta["HTTP_TIMEOUT"],
|
||||
domain,
|
||||
ok_codes=tuple(meta["HEALTH_OK"]),
|
||||
path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"],
|
||||
http_timeout=meta["HTTP_TIMEOUT"],
|
||||
)
|
||||
warm_ok = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
@ -550,9 +613,11 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
(warm_deps if (wd and warm.is_warm_up(d, wd)) else cold_deps).append(d)
|
||||
dep_metas = {d: _load_meta(d) for d in cold_deps}
|
||||
deps_list = (
|
||||
deps_mod.deploy_deps(recipe, os.environ.get("PR", "0"), ref, cold_deps,
|
||||
meta_for=dep_metas)
|
||||
if cold_deps else []
|
||||
deps_mod.deploy_deps(
|
||||
recipe, os.environ.get("PR", "0"), ref, cold_deps, meta_for=dep_metas
|
||||
)
|
||||
if cold_deps
|
||||
else []
|
||||
)
|
||||
for d in warm_deps:
|
||||
wd = warm.warm_domain(d)
|
||||
@ -565,8 +630,10 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
except Exception as e: # noqa: BLE001
|
||||
deps_ready = False
|
||||
deps_not_ready_reason = _scrub(str(e))[:300]
|
||||
print(f"!! setup_custom_tests failed (deps-not-ready): {deps_not_ready_reason}",
|
||||
flush=True)
|
||||
print(
|
||||
f"!! setup_custom_tests failed (deps-not-ready): {deps_not_ready_reason}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# 3) UPGRADE to PR head (chaos) + assert (generic reconverge+moved+serving + overlay)
|
||||
results["upgrade"] = run_lifecycle_tier(
|
||||
@ -589,19 +656,28 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
pass
|
||||
sso_unverified = sso_dep_unverified(declared, deps_ready, requires_deps_skipped)
|
||||
passed = (
|
||||
warm_ok and bool(results) and all(v != "fail" for v in results.values())
|
||||
warm_ok
|
||||
and bool(results)
|
||||
and all(v != "fail" for v in results.values())
|
||||
and not sso_unverified
|
||||
)
|
||||
|
||||
# dep teardown: delete per-run warm realms; undeploy cold deps (mirrors cold)
|
||||
if deps_state:
|
||||
ordered = ([deps_state[d] for d in declared if d in deps_state]
|
||||
if isinstance(deps_state, dict) else deps_state)
|
||||
ordered = (
|
||||
[deps_state[d] for d in declared if d in deps_state]
|
||||
if isinstance(deps_state, dict)
|
||||
else deps_state
|
||||
)
|
||||
for e in [x for x in ordered if x.get("warm")]:
|
||||
try:
|
||||
from harness import sso
|
||||
|
||||
sso.delete_keycloak_realm(e["domain"], e["realm"])
|
||||
print(f" dep: deleted per-run realm {e['realm']} on warm {e['recipe']}", flush=True)
|
||||
print(
|
||||
f" dep: deleted per-run realm {e['realm']} on warm {e['recipe']}",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
dep_teardown_error = f"warm realm delete failed for {e.get('realm')}: {ex}"
|
||||
print(f"!! {dep_teardown_error}", flush=True)
|
||||
@ -617,10 +693,14 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
try:
|
||||
if warm_ok and passed:
|
||||
canonical.undeploy_keep_volume(recipe)
|
||||
print(" quick PASS → canonical undeployed, volume retained, known-good UNCHANGED",
|
||||
flush=True)
|
||||
print(
|
||||
" quick PASS → canonical undeployed, volume retained, known-good UNCHANGED",
|
||||
flush=True,
|
||||
)
|
||||
elif warm_ok:
|
||||
print(" quick FAIL → rolling back canonical to last-known-good snapshot", flush=True)
|
||||
print(
|
||||
" quick FAIL → rolling back canonical to last-known-good snapshot", flush=True
|
||||
)
|
||||
abra.undeploy(domain)
|
||||
_wait_undeployed(domain)
|
||||
warmsnap.restore(recipe, domain)
|
||||
@ -630,8 +710,10 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
abra.env_set(domain, "TYPE", f"{recipe}:{reg['version']}")
|
||||
canonical._set_status(recipe, "idle") # noqa: SLF001
|
||||
rolled_back = True
|
||||
print(" quick FAIL → restored known-good data; canonical idle (NOT promoted)",
|
||||
flush=True)
|
||||
print(
|
||||
" quick FAIL → restored known-good data; canonical idle (NOT promoted)",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
dep_teardown_error = (dep_teardown_error or "") + f" | quick teardown/rollback: {e}"
|
||||
print(f"!! quick teardown/rollback error: {e}", flush=True)
|
||||
@ -644,8 +726,10 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
os.remove(skipfile)
|
||||
|
||||
print("\n===== RUN SUMMARY =====", flush=True)
|
||||
print(f"mode = quick (LOWER-CONFIDENCE; opt-in; does not gate merge)")
|
||||
print(f"canonical = {domain} known-good = {reg.get('version')} (UNCHANGED; quick never promotes)")
|
||||
print("mode = quick (LOWER-CONFIDENCE; opt-in; does not gate merge)")
|
||||
print(
|
||||
f"canonical = {domain} known-good = {reg.get('version')} (UNCHANGED; quick never promotes)"
|
||||
)
|
||||
if rolled_back:
|
||||
print("rolled-back = yes (restored last-known-good snapshot)")
|
||||
for op in ("upgrade", "custom"):
|
||||
@ -659,8 +743,11 @@ def run_quick(recipe: str, ref: str | None, head_ref: str | None, repo_local: st
|
||||
if any(v == "fail" for v in results.values()) or not warm_ok:
|
||||
overall = 1
|
||||
if sso_unverified:
|
||||
print(f"!! DEPS={declared} but setup_custom_tests failed and {requires_deps_skipped} "
|
||||
"requires_deps SKIPPED — SSO NOT verified (F2-11)", file=sys.stderr)
|
||||
print(
|
||||
f"!! DEPS={declared} but setup_custom_tests failed and {requires_deps_skipped} "
|
||||
"requires_deps SKIPPED — SSO NOT verified (F2-11)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
overall = 1
|
||||
if dep_teardown_error:
|
||||
print(f"!! teardown leaked/erred: {dep_teardown_error}", file=sys.stderr)
|
||||
@ -695,16 +782,31 @@ def promote_canonical(recipe: str, head_ref: str | None) -> None:
|
||||
meta = _load_meta(recipe)
|
||||
# The cold run's deploy-count was already asserted + the countfile removed; don't perturb it.
|
||||
os.environ.pop("CCCI_DEPLOY_COUNT_FILE", None)
|
||||
print(f"\n===== WC5 promote-on-green-cold: (re)seed canonical {recipe} @ {latest} =====", flush=True)
|
||||
lifecycle.deploy_app(recipe, domain, version=latest, secrets=True,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)))
|
||||
lifecycle.wait_healthy(domain, ok_codes=tuple(meta["HEALTH_OK"]), path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"], http_timeout=meta["HTTP_TIMEOUT"])
|
||||
print(
|
||||
f"\n===== WC5 promote-on-green-cold: (re)seed canonical {recipe} @ {latest} =====",
|
||||
flush=True,
|
||||
)
|
||||
lifecycle.deploy_app(
|
||||
recipe,
|
||||
domain,
|
||||
version=latest,
|
||||
secrets=True,
|
||||
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
|
||||
)
|
||||
lifecycle.wait_healthy(
|
||||
domain,
|
||||
ok_codes=tuple(meta["HEALTH_OK"]),
|
||||
path=meta["HEALTH_PATH"],
|
||||
deploy_timeout=meta["DEPLOY_TIMEOUT"],
|
||||
http_timeout=meta["HTTP_TIMEOUT"],
|
||||
)
|
||||
abra.undeploy(domain)
|
||||
_wait_undeployed(domain)
|
||||
canonical.seed_canonical(recipe, latest, commit=head_ref)
|
||||
print(f"WC5 promote: canonical {recipe} advanced to known-good {latest} (idle, volume retained)",
|
||||
flush=True)
|
||||
print(
|
||||
f"WC5 promote: canonical {recipe} advanced to known-good {latest} (idle, volume retained)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@ -750,7 +852,11 @@ def main() -> int:
|
||||
# newest published tag, where the correct base is [-1] (the newest published), not [-2]. The
|
||||
# override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.)
|
||||
want_upgrade = "upgrade" in stages
|
||||
prev = (meta.get("UPGRADE_BASE_VERSION") or lifecycle.previous_version(recipe)) if want_upgrade else None
|
||||
prev = (
|
||||
(meta.get("UPGRADE_BASE_VERSION") or lifecycle.previous_version(recipe))
|
||||
if want_upgrade
|
||||
else None
|
||||
)
|
||||
base = prev or target
|
||||
backup_cap = generic.backup_capable(recipe, meta)
|
||||
hook = discovery.install_steps(recipe, repo_local)
|
||||
@ -761,6 +867,15 @@ def main() -> int:
|
||||
f.write("0")
|
||||
os.environ["CCCI_DEPLOY_COUNT_FILE"] = countfile
|
||||
|
||||
# Phase 3 (R1/R3): per-run artifact dir + JUnit dir. The tiers emit JUnit per file and append a
|
||||
# {tier,source,file,rc,junit} record; after the run we assemble results.json (per-stage/per-test +
|
||||
# level) into the artifact dir. Best-effort — never changes the verdict (R7).
|
||||
run_artifact_dir = os.path.join(results_mod.runs_dir(), results_mod.run_id())
|
||||
junit_dir = os.path.join(run_artifact_dir, "junit")
|
||||
records: list[dict] = []
|
||||
with contextlib.suppress(OSError):
|
||||
os.makedirs(junit_dir, exist_ok=True)
|
||||
|
||||
# Run-scoped op state (HC3): the orchestrator records op results (pre-upgrade identity, backup
|
||||
# snapshot_id) here for the assertion tiers (generic + overlay) to read via generic.op_state().
|
||||
statefile = os.path.join(tempfile.gettempdir(), f"ccci-opstate-{domain}.json")
|
||||
@ -805,14 +920,23 @@ def main() -> int:
|
||||
# failure we mark deps-not-ready but STILL deploy the recipe alone (install_steps.sh no-ops
|
||||
# on an empty deps file) so the generic tiers run; the OIDC custom test then skips → F2-11. ----
|
||||
if oidc_at_install:
|
||||
print(f"\n===== install-time OIDC: provisioning deps {declared} BEFORE deploy =====", flush=True)
|
||||
print(
|
||||
f"\n===== install-time OIDC: provisioning deps {declared} BEFORE deploy =====",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
deps_state = _provision_deps(recipe, domain, ref, declared)
|
||||
print(" install-time OIDC: deps provisioned; install_steps.sh will wire OIDC env", flush=True)
|
||||
print(
|
||||
" install-time OIDC: deps provisioned; install_steps.sh will wire OIDC env",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — isolated; recipe still deploys, OIDC test skips
|
||||
deps_ready = False
|
||||
deps_not_ready_reason = _scrub(str(e))[:300]
|
||||
print(f"!! install-time dep provisioning failed (deps-not-ready): {deps_not_ready_reason}", flush=True)
|
||||
print(
|
||||
f"!! install-time dep provisioning failed (deps-not-ready): {deps_not_ready_reason}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ---- deploy RECIPE FIRST, alone (no deps yet — generic tiers run recipe-only) ----
|
||||
try:
|
||||
@ -842,7 +966,17 @@ def main() -> int:
|
||||
# ---- INSTALL tier (always; additive generic + overlay, no op) ----
|
||||
if "install" in stages:
|
||||
results["install"] = (
|
||||
run_lifecycle_tier(recipe, "install", repo_local, domain, meta, head_ref, op_state)
|
||||
run_lifecycle_tier(
|
||||
recipe,
|
||||
"install",
|
||||
repo_local,
|
||||
domain,
|
||||
meta,
|
||||
head_ref,
|
||||
op_state,
|
||||
records=records,
|
||||
junit_dir=junit_dir,
|
||||
)
|
||||
if deploy_ok
|
||||
else "fail"
|
||||
)
|
||||
@ -852,7 +986,15 @@ def main() -> int:
|
||||
if "upgrade" in stages:
|
||||
results["upgrade"] = (
|
||||
run_lifecycle_tier(
|
||||
recipe, "upgrade", repo_local, domain, meta, head_ref, op_state
|
||||
recipe,
|
||||
"upgrade",
|
||||
repo_local,
|
||||
domain,
|
||||
meta,
|
||||
head_ref,
|
||||
op_state,
|
||||
records=records,
|
||||
junit_dir=junit_dir,
|
||||
)
|
||||
if prev
|
||||
else "skip" # only one published version → nothing to upgrade from
|
||||
@ -861,7 +1003,15 @@ def main() -> int:
|
||||
if "backup" in stages:
|
||||
results["backup"] = (
|
||||
run_lifecycle_tier(
|
||||
recipe, "backup", repo_local, domain, meta, head_ref, op_state
|
||||
recipe,
|
||||
"backup",
|
||||
repo_local,
|
||||
domain,
|
||||
meta,
|
||||
head_ref,
|
||||
op_state,
|
||||
records=records,
|
||||
junit_dir=junit_dir,
|
||||
)
|
||||
if backup_cap
|
||||
else "skip"
|
||||
@ -869,7 +1019,15 @@ def main() -> int:
|
||||
if "restore" in stages:
|
||||
results["restore"] = (
|
||||
run_lifecycle_tier(
|
||||
recipe, "restore", repo_local, domain, meta, head_ref, op_state
|
||||
recipe,
|
||||
"restore",
|
||||
repo_local,
|
||||
domain,
|
||||
meta,
|
||||
head_ref,
|
||||
op_state,
|
||||
records=records,
|
||||
junit_dir=junit_dir,
|
||||
)
|
||||
if backup_cap
|
||||
else "skip"
|
||||
@ -916,7 +1074,9 @@ def main() -> int:
|
||||
# tests when CCCI_DEPS_READY=0.
|
||||
os.environ["CCCI_DEPS_READY"] = "1" if deps_ready else "0"
|
||||
os.environ["CCCI_DEPS_NOT_READY_REASON"] = deps_not_ready_reason
|
||||
results["custom"] = run_custom(recipe, repo_local, domain)
|
||||
results["custom"] = run_custom(
|
||||
recipe, repo_local, domain, records=records, junit_dir=junit_dir
|
||||
)
|
||||
else:
|
||||
# install failed → the shared deployment is dead; remaining tiers cannot run on it.
|
||||
for op in ("upgrade", "backup", "restore", "custom"):
|
||||
@ -945,7 +1105,10 @@ def main() -> int:
|
||||
from harness import sso
|
||||
|
||||
sso.delete_keycloak_realm(e["domain"], e["realm"])
|
||||
print(f" dep: deleted per-run realm {e['realm']} on warm {e['recipe']}", flush=True)
|
||||
print(
|
||||
f" dep: deleted per-run realm {e['realm']} on warm {e['recipe']}",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as ex: # noqa: BLE001 — a leaked realm is a teardown failure (§9)
|
||||
dep_teardown_error = f"warm realm delete failed for {e.get('realm')}: {ex}"
|
||||
print(f"!! {dep_teardown_error}", flush=True)
|
||||
@ -980,13 +1143,16 @@ def main() -> int:
|
||||
# WC1: a live-warm dep (keycloak) is NOT deployed by the run — it only gets a per-run realm — so
|
||||
# warm deps contribute 0. So expected = 1 + (number of COLD deps that actually got deployed).
|
||||
_dep_entries = deps_state.values() if isinstance(deps_state, dict) else (deps_state or [])
|
||||
deps_deployed_count = sum(1 for e in _dep_entries if not (isinstance(e, dict) and e.get("warm")))
|
||||
deps_deployed_count = sum(
|
||||
1 for e in _dep_entries if not (isinstance(e, dict) and e.get("warm"))
|
||||
)
|
||||
expected_deploy_count = 1 + deps_deployed_count
|
||||
print("\n===== RUN SUMMARY =====", flush=True)
|
||||
print(f"deploy-count = {deploy_count} (expect {expected_deploy_count})")
|
||||
if deps_state:
|
||||
deps_list_for_summary = (
|
||||
list(deps_state.keys()) if isinstance(deps_state, dict)
|
||||
list(deps_state.keys())
|
||||
if isinstance(deps_state, dict)
|
||||
else [d.get("recipe", "?") for d in deps_state]
|
||||
)
|
||||
print(f" deps deployed: {deps_list_for_summary}")
|
||||
@ -1029,6 +1195,47 @@ def main() -> int:
|
||||
print("no tiers ran", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ---- Phase 3 (R1/R3): assemble results.json (per-stage/per-test + computed level). Best-effort:
|
||||
# a failure here NEVER changes `overall` (R7 — cosmetics never block the pipeline). ----
|
||||
try:
|
||||
sso_unverified = sso_dep_unverified(declared, deps_ready, requires_deps_skipped)
|
||||
clean_teardown = (deploy_count == expected_deploy_count) and not dep_teardown_error
|
||||
data = results_mod.build_results(
|
||||
recipe=recipe,
|
||||
version=target or (head_ref[:12] if head_ref else None),
|
||||
pr=os.environ.get("PR", "0"),
|
||||
ref=ref,
|
||||
records=records,
|
||||
results=results,
|
||||
backup_capable=backup_cap,
|
||||
declared=declared,
|
||||
deps_ready=deps_ready,
|
||||
sso_unverified=sso_unverified,
|
||||
clean_teardown=clean_teardown,
|
||||
no_secret_leak=True, # narrowed below by an actual scan of the serialised artifact
|
||||
finished_ts=time.time(),
|
||||
)
|
||||
# Real (if narrow) leak check: no known infra-secret value may appear in the artifact (R7).
|
||||
blob = json.dumps(data)
|
||||
leaked = any(v in blob for v in _REDACT)
|
||||
data["flags"]["no_secret_leak"] = not leaked
|
||||
if leaked:
|
||||
print(
|
||||
"!! results.json leak-scan: a known secret value appeared — scrubbing flag set False",
|
||||
file=sys.stderr,
|
||||
)
|
||||
path = results_mod.write_results(data)
|
||||
print(
|
||||
f"results.json written: {path} (level={data['level']}"
|
||||
f"{' — ' + data['level_cap_reason'] if data['level_cap_reason'] else ''})",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — results assembly is cosmetic; never fail a run on it (R7)
|
||||
print(
|
||||
f"!! results.json assembly failed (non-fatal, verdict unaffected): {_scrub(str(e))}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# WC5 promote-on-green-cold: a GREEN COLD run on LATEST (no PR head) of an enrolled
|
||||
# (WARM_CANONICAL) recipe advances/seeds the canonical. ONLY cold-on-latest advances it (a PR
|
||||
# `!testme` carries REF and must NOT promote; `--quick` never promotes — handled in run_quick).
|
||||
@ -1037,8 +1244,10 @@ def main() -> int:
|
||||
try:
|
||||
promote_canonical(recipe, head_ref)
|
||||
except Exception as e: # noqa: BLE001 — promote is a post-green bonus; never fail a green run
|
||||
print(f"!! WC5 promote failed (non-fatal; known-good unchanged): {_scrub(str(e))}",
|
||||
flush=True)
|
||||
print(
|
||||
f"!! WC5 promote failed (non-fatal; known-good unchanged): {_scrub(str(e))}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return overall
|
||||
|
||||
|
||||
Reference in New Issue
Block a user