feat(1e): HC3 additive generic + op/assertion split (orchestrator owns the op)

- orchestrator: per mutating tier, run optional pre-op seed hook (ops.py pre_<op>) → perform the op
  ONCE (harness-owned) → run generic assertion (unless opted out) AND overlay assertion, both against
  the shared post-op deployment. Op results passed op→assertion via run-scoped CCCI_OP_STATE_FILE.
- opt-out: CCCI_SKIP_GENERIC / CCCI_SKIP_GENERIC_<OP> / recipe_meta.SKIP_GENERIC (declarative).
- generic.py: split do_* into op primitives (perform_upgrade/backup/restore) + assertions
  (assert_upgraded/backup_artifact/restore_healthy) reading op_state(); deployed_identity now returns
  {version,image,chaos} (chaos label ready for HC1).
- generic test_<op>.py + all 6 recipe overlays migrated to assertion-only; pre-op seeding moved to
  per-recipe ops.py (pre_upgrade/pre_backup/pre_restore). install overlays unchanged (no op).
- deploy-count stays 1 (op primitives never call deploy_app). lint PASS; 8 unit tests PASS on cc-ci.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 03:12:04 +01:00
parent 6a59343996
commit b7e6cbd7be
31 changed files with 623 additions and 412 deletions

View File

@ -26,6 +26,7 @@ def teardown_function():
# ---- HC3: generic is the floor; overlay resolution is separate + additive --------------------
def test_no_overlay_means_generic_floor():
# hedgedoc ships no tests/hedgedoc/ overlay and no repo-local -> no overlay; generic floor exists
assert discovery.resolve_overlay_op("hedgedoc", "install", None) is None
@ -40,11 +41,15 @@ def test_cc_ci_overlay_found_for_each_op():
# custom-html ships cc-ci overlays for all four ops -> resolve_overlay_op returns the cc-ci file
for op in discovery.LIFECYCLE_OPS:
res = discovery.resolve_overlay_op("custom-html", op, None)
assert res == ("cc-ci", os.path.join(discovery.cc_ci_dir("custom-html"), f"test_{op}.py")), op
assert res == (
"cc-ci",
os.path.join(discovery.cc_ci_dir("custom-html"), f"test_{op}.py"),
), op
# ---- HC2: repo-local approval gate (default-deny) --------------------------------------------
def test_repo_local_ignored_when_not_approved(tmp_path):
# default-deny: a repo-local overlay is NOT consulted for an unapproved recipe -> cc-ci wins
_approve(tmp_path) # empty allowlist
@ -97,18 +102,20 @@ def test_install_steps_repo_local_gated(tmp_path):
def test_pre_op_hook_repo_local_gated(tmp_path):
# hedgedoc has no cc-ci ops.py, so this isolates the repo-local gate (custom-html now ships a
# real cc-ci tests/custom-html/ops.py, which would mask the gate).
rl = tmp_path / "repo"
rl.mkdir()
(rl / "ops.py").write_text("def pre_upgrade(domain, meta):\n pass\n")
_approve(tmp_path) # not approved -> repo-local ops.py ignored
assert discovery.pre_op_hook("custom-html", "upgrade", str(rl)) is None
_approve(tmp_path) # not approved -> repo-local ops.py ignored (no cc-ci ops.py either)
assert discovery.pre_op_hook("hedgedoc", "upgrade", str(rl)) is None
_approve(tmp_path, "custom-html") # approved -> repo-local pre-op hook honored
hook = discovery.pre_op_hook("custom-html", "upgrade", str(rl))
_approve(tmp_path, "hedgedoc") # approved -> repo-local pre-op hook honored
hook = discovery.pre_op_hook("hedgedoc", "upgrade", str(rl))
assert hook == ("repo-local", str(rl / "ops.py"))
# an ops.py that does NOT define pre_<op> is not a hook for that op
assert discovery.pre_op_hook("custom-html", "backup", str(rl)) is None
assert discovery.pre_op_hook("hedgedoc", "backup", str(rl)) is None
def test_default_allowlist_is_empty():