"""Customization manifest (rcust P5; spec §8 R4 mitigation). One block at run start answering "what does this recipe customize?" across ALL the surfaces (recipe_meta keys, hook files, file-presence, run-time env overrides) — printed to the run log and embedded verbatim in results.json under "customization". PURE PRESENTATION: building or printing the manifest must never influence any verdict (R7-class invariant). """ from __future__ import annotations import os import re from . import discovery, lifecycle from . import meta as meta_mod _PRE_OP_RE = re.compile(r"^def (pre_[a-z]+)\(", re.MULTILINE) # Meta values are repo-public by construction (recipe_meta.py is committed; real secrets are # class-B generated, never meta), but the manifest lands on the dashboard — mask values whose # key NAME is secret-shaped so a field literally called SECRET_KEY_BASE never shows a value # (defense in depth + keeps dashboard secret-scans quiet). `KEY` matches only as a word segment # (API_KEY yes, KEYCLOAK_URL no). _SENSITIVE_NAME_RE = re.compile(r"SECRET|PASSWORD|TOKEN|CREDENTIAL|(^|_)KEY(_|$)", re.IGNORECASE) def _jsonable(v, name=""): """Manifest values must be JSON-serializable + deterministic: hooks render as '', tuples become lists, secret-named entries (by key name, incl. nested dict keys) as ''.""" if callable(v): return "" if name and _SENSITIVE_NAME_RE.search(name): return "" if isinstance(v, tuple): return list(v) if isinstance(v, dict): return {k: _jsonable(x, name=str(k)) for k, x in v.items()} return v def _pre_ops(path: str) -> list[str]: """The pre_ hook names an ops.py defines (cheap source scan, same approach as discovery._module_defines — no import).""" try: with open(path) as fh: return sorted(set(_PRE_OP_RE.findall(fh.read()))) except OSError: return [] def _custom_counts(recipe: str, repo_local: str | None) -> dict[str, dict[str, int]]: out: dict[str, dict[str, int]] = {} for source, path in discovery.custom_tests(recipe, repo_local): sub = os.path.basename(os.path.dirname(path)) # functional | playwright out.setdefault(source, {}).setdefault(sub, 0) out[source][sub] += 1 return out def build(recipe: str, meta, repo_local: str | None) -> dict: """Collect the run's resolved customization into one deterministic, JSON-serializable dict. Keys: meta_non_default (explicitly-customized recipe_meta keys), hooks (ops.py pre-ops + install_steps.sh + compose.ccci.yml with their source), overlays (lifecycle overlay files by op + source), custom_tests (counts per source/subdir), env_overrides (active CCCI_SKIP_GENERIC* — the dev-only escape hatch, flagged when riding a CI run).""" hooks: dict = {} pre_ops: dict[str, list[str]] = {} for source, d in ( ("cc-ci", discovery.cc_ci_dir(recipe)), ("repo-local", discovery._gated(recipe, repo_local)), # noqa: SLF001 — same HC2 gate ): if not d: continue p = os.path.join(d, "ops.py") if os.path.isfile(p): ops = _pre_ops(p) if ops: pre_ops[source] = ops if pre_ops: hooks["ops.py"] = pre_ops ist = discovery.install_steps(recipe, repo_local) if ist: hooks["install_steps.sh"] = ist[0] if lifecycle.has_ccci_overlay(recipe): hooks["compose.ccci.yml"] = "cc-ci" overlays = {} for op in discovery.LIFECYCLE_OPS: ov = discovery.resolve_overlay_op(recipe, op, repo_local) if ov: overlays[op] = ov[0] env_overrides = sorted( k for k in os.environ if k.startswith("CCCI_SKIP_GENERIC") and str(os.environ.get(k) or "").strip().lower() in ("1", "true", "yes", "on") ) return { "meta_non_default": { k: _jsonable(v, name=k) for k, v in sorted(meta_mod.non_default(meta).items()) }, "hooks": hooks, "overlays": overlays, "custom_tests": _custom_counts(recipe, repo_local), "env_overrides": env_overrides, } def render(recipe: str, manifest: dict) -> str: """The human block printed at run start (same content as the results.json key).""" lines = [f"===== customization manifest: {recipe} ====="] nd = manifest["meta_non_default"] lines.append( "meta (non-default): " + (" ".join(f"{k}={v!r}" for k, v in nd.items()) if nd else "(none — zero-config floor)") ) hk = manifest["hooks"] parts = [] for source, ops in hk.get("ops.py", {}).items(): parts.append(f"ops.py[{','.join(ops)}]({source})") if "install_steps.sh" in hk: parts.append(f"install_steps.sh({hk['install_steps.sh']})") if "compose.ccci.yml" in hk: parts.append(f"compose.ccci.yml({hk['compose.ccci.yml']})") lines.append("hooks: " + (" ".join(parts) if parts else "(none)")) ov = manifest["overlays"] lines.append( "overlays: " + (" ".join(f"test_{op}.py({src})" for op, src in ov.items()) if ov else "(none)") ) ct = manifest["custom_tests"] lines.append( "custom tests: " + ( " ".join( " ".join(f"{sub}/={n}" for sub, n in sorted(counts.items())) + f" ({source})" for source, counts in sorted(ct.items()) ) if ct else "(none)" ) ) eo = manifest["env_overrides"] if eo: suffix = " !! dev-only override active in CI" if os.environ.get("DRONE") else "" lines.append("env overrides: " + " ".join(f"{k}=1" for k in eo) + suffix) else: lines.append("env overrides: (none)") return "\n".join(lines)