Files
cc-ci/runner/harness/discovery.py
autonomic-bot ef44d4658b feat(1d): G0 — generic install + deploy-once orchestrator (DG1 green on hedgedoc)
- harness/generic.py: recipe-agnostic assert_serving (converged + real HTTP, 404-excluded +
  not Traefik 404 body + CA-verified trusted wildcard cert), op helpers, backup_capable detect
- harness/discovery.py: per-op overlay resolution (repo-local > cc-ci > generic), custom + hook
- tests/_generic/: assertion-only tiers (install/upgrade/backup/restore) on the shared deployment
- run_recipe_ci.py: deploy-ONCE orchestrator, per-op summary, deploy-count guard (DG4.1)
- conftest live_app fixture; lifecycle deploy-count + install-steps hook + pin DOMAIN to run domain

DG1 cold-verified green on hedgedoc (pure generic, deploy-count=1, clean teardown). G0 CLAIMED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:27:55 +01:00

72 lines
2.9 KiB
Python

"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d, plan §2.5, DG4/DG5).
The generic is the default for each lifecycle op; a recipe's `test_<op>.py` OVERRIDES it. Sources,
in precedence order (machine-docs/DECISIONS.md):
lifecycle op (install/upgrade/backup/restore) — exactly ONE assertion file runs:
repo-local tests/test_<op>.py (upstream-authoritative, wins same-name collisions)
> cc-ci tests/<recipe>/test_<op>.py
> generic tests/_generic/test_<op>.py <- always present; the floor
custom (non-lifecycle) test_*.py — ALL run, additively, from BOTH locations (opt-in).
install-steps hook — install_steps.sh: repo-local > cc-ci, or none.
Repo-local = the recipe repo's own tests/ dir, snapshotted after fetch (it survives abra
re-checking-out the recipe to a version tag — see the run orchestrator).
"""
from __future__ import annotations
import glob
import os
LIFECYCLE_OPS = ("install", "upgrade", "backup", "restore")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
GENERIC_DIR = os.path.join(ROOT, "tests", "_generic")
def cc_ci_dir(recipe: str) -> str:
return os.path.join(ROOT, "tests", recipe)
def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]:
"""Return (source, path) for the single assertion file to run for `op`:
source in {"repo-local","cc-ci","generic"}. The generic file is the floor and always exists."""
fname = f"test_{op}.py"
if repo_local_dir:
p = os.path.join(repo_local_dir, fname)
if os.path.isfile(p):
return ("repo-local", p)
p = os.path.join(cc_ci_dir(recipe), fname)
if os.path.isfile(p):
return ("cc-ci", p)
return ("generic", os.path.join(GENERIC_DIR, fname))
def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]:
"""All non-lifecycle test_*.py from cc-ci's tests/<recipe>/ and the recipe's repo-local tests/.
These have no generic equivalent and run only when present (opt-in), additively from both."""
lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS}
found: list[tuple[str, str]] = []
for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", repo_local_dir)):
if not d or not os.path.isdir(d):
continue
for p in sorted(glob.glob(os.path.join(d, "test_*.py"))):
if os.path.basename(p) not in lifecycle_names:
found.append((source, p))
return found
def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] | None:
"""The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci."""
if repo_local_dir:
p = os.path.join(repo_local_dir, "install_steps.sh")
if os.path.isfile(p):
return ("repo-local", p)
p = os.path.join(cc_ci_dir(recipe), "install_steps.sh")
if os.path.isfile(p):
return ("cc-ci", p)
return None