fix(keycloak): key warm state by stack namespace, not bare recipe (F-redfix-4)
Some checks failed
continuous-integration/drone/push Build is failing
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:
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user