fix(keycloak): key warm state by stack namespace, not bare recipe (F-redfix-4)
Some checks failed
continuous-integration/drone/push Build is failing

The M2 keycloak enrollment made the canonical collision-free at the DOMAIN layer
(warm-canon-keycloak vs warm-keycloak) but warm STATE stayed keyed by bare recipe:
warmsnap.app_dir("keycloak") resolved both the live-warm reconciler's last_good and
the data-warm canonical's canonical.json + snapshot/ into /var/lib/ci-warm/keycloak/.
snapshot() atomically REPLACES that slot, so the two deployments destroyed each
other's known-good; restore() then raised SnapshotError (fails closed, no cross-stack
data write). Worst case: a sweep promote landing inside the reconciler's
snapshot->wait_healthy window makes its rollback restore() raise after
abra.undeploy(live), leaving the shared OIDC provider undeployed.

Fix: canonical.canonical_ns() is now the single namespace from which BOTH the
canonical's domain and its warm-state slot derive, so they cannot drift apart. A
live-warm provider gets ns "canon-<recipe>": domain warm-canon-keycloak (unchanged)
and slot /var/lib/ci-warm/canon-keycloak/. Every other recipe keeps ns "<recipe>" —
zero on-disk change for the 15 existing canonicals, and no migration on cc-ci
(keycloak's canonical was never seeded: its dir holds only last_good).

- warmsnap: functions take a SLOT, not a recipe; add live_slot(); meta records "slot".
- warmsnap: _assert_slot_not_foreign() refuses to snapshot/restore a slot recorded
  against a different domain -- defence in depth, naming-scheme-independent, fails
  before the destructive swap rather than at the next restore.
- canonical: registry_path/seed_canonical/prune_stale go through canonical_slot().
- prune_stale: the "reconciler dirs are never pruned" invariant is now STRUCTURAL --
  <recipe>/ never gains a canonical.json, so de-enrolling keycloak can no longer
  rmtree the reconciler's last_good (consequence 4).
- warm_reconcile: last_good + snapshot/restore go through warmsnap.live_slot().
- run_recipe_ci: canonical rollback restores from canonical_slot(recipe).
- Correct the two comments that claimed the deployments "can never touch each other".

Tests: 10 new (slot disjointness for every WARM_DOMAINS recipe, slot<->stack 1:1,
registry not in the reconciler dir, prune spares last_good, foreign-slot refusal in
both snapshot and restore). Unit suite 315 -> 325, no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS8p1esg57UAC69riNvuBX
This commit is contained in:
2026-07-09 00:11:28 +00:00
parent 07fc6d4af5
commit b5f2b104e6
7 changed files with 272 additions and 70 deletions

View File

@ -12,7 +12,7 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import canonical, warm # noqa: E402
from harness import canonical, warm, warmsnap # noqa: E402
from harness import meta as harness_meta # noqa: E402
@ -96,3 +96,78 @@ def test_prune_stale_drops_deenrolled_only(tmp_path, monkeypatch):
assert (tmp_path / "keepme").exists()
assert (tmp_path / "keycloak").exists() # no canonical.json → not a canonical → kept
assert (tmp_path / "alerts").exists()
# ------------------------------------------- F-redfix-4: canonical ns/slot vs live-warm provider
def test_canonical_ns_and_domain_for_live_warm_provider():
# keycloak is a live-warm provider → its canonical is namespaced apart on BOTH axes.
assert canonical.canonical_ns("keycloak") == "canon-keycloak"
assert canonical.canonical_domain("keycloak") == "warm-canon-keycloak.ci.commoninternet.net"
assert canonical.canonical_slot("keycloak") == "canon-keycloak"
# ...and an ordinary recipe is untouched (zero blast radius on existing canonicals).
assert canonical.canonical_ns("cryptpad") == "cryptpad"
assert canonical.canonical_domain("cryptpad") == "warm-cryptpad.ci.commoninternet.net"
assert canonical.canonical_slot("cryptpad") == "cryptpad"
def test_live_and_canonical_slots_are_disjoint():
"""F-redfix-4: for EVERY live-warm provider, the reconciler's slot and the canonical's slot must
differ — they are two deployments and each `snapshot()` atomically replaces its slot."""
for recipe in warm.WARM_DOMAINS:
assert canonical.canonical_slot(recipe) != warmsnap.live_slot(recipe)
# every non-provider recipe keeps one slot (there is only one deployment)
assert canonical.canonical_slot("cryptpad") == warmsnap.live_slot("cryptpad")
def test_slot_maps_1to1_to_its_stack():
# The invariant warmsnap relies on: distinct slot ⇔ distinct stack (domain = warm-<ns>).
for recipe in warm.WARM_DOMAINS:
assert warm.stable_domain(canonical.canonical_slot(recipe)) == canonical.canonical_domain(
recipe
)
assert warm.stable_domain(warmsnap.live_slot(recipe)) == warm.WARM_DOMAINS[recipe]
def test_registry_path_of_live_warm_provider_is_not_the_reconciler_dir(tmp_path, monkeypatch):
"""The canonical registry must NOT land in `<recipe>/`, where the reconciler keeps last_good."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
assert canonical.registry_path("keycloak") == str(
tmp_path / "canon-keycloak" / "canonical.json"
)
canonical.write_registry("keycloak", version="10.7.1+26.6.2", commit=None, status="idle")
assert (tmp_path / "canon-keycloak" / "canonical.json").is_file()
assert not (tmp_path / "keycloak").exists() # reconciler dir untouched
def test_prune_stale_keeps_enrolled_provider_canonical_and_reconciler(tmp_path, monkeypatch):
"""Enrolled keycloak: `canon-keycloak/` is its canonical slot (keep), `keycloak/` is the
reconciler's (keep — no canonical.json)."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
monkeypatch.setattr(canonical, "enrolled_recipes", lambda: ["keycloak"])
monkeypatch.setattr(canonical.warmsnap, "stack_volumes", lambda d: [])
(tmp_path / "canon-keycloak").mkdir()
(tmp_path / "canon-keycloak" / "canonical.json").write_text('{"recipe":"keycloak"}')
(tmp_path / "keycloak").mkdir()
(tmp_path / "keycloak" / "last_good").write_text("10.7.1+26.6.2")
assert canonical.prune_stale() == []
assert (tmp_path / "canon-keycloak").exists()
assert (tmp_path / "keycloak" / "last_good").read_text() == "10.7.1+26.6.2"
def test_prune_stale_deenrolled_provider_spares_reconciler_last_good(tmp_path, monkeypatch):
"""F-redfix-4 consequence 4: de-enrolling keycloak must drop `canon-keycloak/` ONLY — never the
reconciler's `keycloak/last_good`. It also targets the canon stack's volumes, not the live ones."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
monkeypatch.setattr(canonical, "enrolled_recipes", lambda: []) # de-enrolled
seen = []
monkeypatch.setattr(canonical.warmsnap, "stack_volumes", lambda d: seen.append(d) or [])
(tmp_path / "canon-keycloak").mkdir()
(tmp_path / "canon-keycloak" / "canonical.json").write_text('{"recipe":"keycloak"}')
(tmp_path / "keycloak").mkdir()
(tmp_path / "keycloak" / "last_good").write_text("10.7.1+26.6.2")
assert canonical.prune_stale() == ["canon-keycloak"]
assert not (tmp_path / "canon-keycloak").exists()
assert (tmp_path / "keycloak" / "last_good").read_text() == "10.7.1+26.6.2"
assert seen == ["warm-canon-keycloak.ci.commoninternet.net"] # never the LIVE provider's stack

View File

@ -68,3 +68,62 @@ def test_has_snapshot_incomplete_missing_tar(monkeypatch, tmp_path):
(snapdir / "meta.json").write_text(json.dumps({"recipe": "keycloak", "volumes": ["a", "b"]}))
(snapdir / "volumes" / "a.tar").write_bytes(b"fake")
assert warmsnap.has_snapshot("keycloak") is False
# --------------------------------------------------------------- F-redfix-4: slot ≠ recipe
def test_live_slot_is_the_recipe():
assert warmsnap.live_slot("keycloak") == "keycloak"
def test_snapshot_refuses_to_clobber_another_domains_slot(monkeypatch, tmp_path):
"""F-redfix-4 regression: a slot holding domain A's known-good must not be overwritten by a
snapshot of domain B. Before the fix this silently destroyed the other deployment's snapshot."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
snapdir = tmp_path / "keycloak" / "snapshot"
(snapdir / "volumes").mkdir(parents=True)
(snapdir / "meta.json").write_text(
json.dumps({"slot": "keycloak", "domain": "warm-keycloak.ci.x", "volumes": []})
)
# Never reaches docker: the foreign-slot guard fires before _assert_undeployed.
try:
warmsnap.snapshot("keycloak", "warm-canon-keycloak.ci.x")
except warmsnap.SnapshotError as e:
assert "warm-keycloak.ci.x" in str(e) and "refusing to clobber" in str(e)
else:
raise AssertionError("snapshot() overwrote a foreign slot")
# the original known-good is intact
assert warmsnap.read_meta("keycloak")["domain"] == "warm-keycloak.ci.x"
def test_snapshot_may_reclaim_its_own_slot(monkeypatch, tmp_path):
# Same domain → not foreign → the guard must not fire (it would break every re-snapshot).
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
snapdir = tmp_path / "keycloak" / "snapshot"
(snapdir / "volumes").mkdir(parents=True)
(snapdir / "meta.json").write_text(
json.dumps({"slot": "keycloak", "domain": "warm-keycloak.ci.x", "volumes": []})
)
warmsnap._assert_slot_not_foreign("keycloak", "warm-keycloak.ci.x") # no raise
warmsnap._assert_slot_not_foreign("fresh-slot", "anything.ci.x") # empty slot is free to claim
def test_restore_refuses_a_foreign_slot(monkeypatch, tmp_path):
"""restore() must reject a slot recorded against another domain BEFORE touching volumes."""
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
monkeypatch.setattr(warmsnap, "_assert_undeployed", lambda d: None)
_write_snapshot(tmp_path, "keycloak", ["warm-keycloak_ci_x_mariadb"])
snapdir = tmp_path / "keycloak" / "snapshot"
meta = json.loads((snapdir / "meta.json").read_text())
meta["domain"] = "warm-keycloak.ci.x"
(snapdir / "meta.json").write_text(json.dumps(meta))
called = []
monkeypatch.setattr(warmsnap, "stack_volumes", lambda d: called.append(d) or [])
try:
warmsnap.restore("keycloak", "warm-canon-keycloak.ci.x")
except warmsnap.SnapshotError as e:
assert "refusing to clobber" in str(e)
else:
raise AssertionError("restore() accepted a foreign slot")
assert called == [], "restore() must fail before inspecting the target stack's volumes"