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
130 lines
5.8 KiB
Python
130 lines
5.8 KiB
Python
"""Unit tests for the WC3 snapshot helper's pure path/meta logic (runner/harness/warmsnap.py).
|
|
|
|
The docker/tar snapshot+restore round-trip is integration (proven live on cc-ci against a real
|
|
keycloak volume, W0.5). Here we cover the layout, meta read, and the has_snapshot completeness check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
|
from harness import warmsnap # noqa: E402
|
|
|
|
|
|
def test_warm_root_env_override(monkeypatch):
|
|
monkeypatch.setenv("CCCI_WARM_ROOT", "/tmp/ci-warm-test")
|
|
assert warmsnap.warm_root() == "/tmp/ci-warm-test"
|
|
assert warmsnap.app_dir("keycloak") == "/tmp/ci-warm-test/keycloak"
|
|
# snapshot lives in a subdir so sibling state (last_good) survives the atomic swap.
|
|
assert warmsnap.snap_dir("keycloak") == "/tmp/ci-warm-test/keycloak/snapshot"
|
|
assert warmsnap.meta_path("keycloak") == "/tmp/ci-warm-test/keycloak/snapshot/meta.json"
|
|
assert warmsnap.volumes_dir("keycloak") == "/tmp/ci-warm-test/keycloak/snapshot/volumes"
|
|
|
|
|
|
def test_warm_root_default(monkeypatch):
|
|
monkeypatch.delenv("CCCI_WARM_ROOT", raising=False)
|
|
assert warmsnap.warm_root() == "/var/lib/ci-warm"
|
|
|
|
|
|
def test_read_meta_absent(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
|
|
assert warmsnap.read_meta("keycloak") is None
|
|
assert warmsnap.has_snapshot("keycloak") is False
|
|
|
|
|
|
def _write_snapshot(tmp_path, recipe, volumes):
|
|
snapdir = tmp_path / recipe / "snapshot"
|
|
(snapdir / "volumes").mkdir(parents=True)
|
|
(snapdir / "meta.json").write_text(json.dumps({"recipe": recipe, "volumes": volumes}))
|
|
for v in volumes:
|
|
(snapdir / "volumes" / f"{v}.tar").write_bytes(b"fake")
|
|
|
|
|
|
def test_has_snapshot_complete(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
|
|
_write_snapshot(tmp_path, "keycloak", ["a_mariadb", "a_providers"])
|
|
assert warmsnap.has_snapshot("keycloak") is True
|
|
meta = warmsnap.read_meta("keycloak")
|
|
assert meta["volumes"] == ["a_mariadb", "a_providers"]
|
|
|
|
|
|
def test_last_good_survives_sibling_of_snapshot(monkeypatch, tmp_path):
|
|
# The reconciler's last_good lives in <recipe>/ (sibling of snapshot/) — a test that the layout
|
|
# keeps them separate so a snapshot swap can't clobber last_good.
|
|
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
|
|
appdir = tmp_path / "keycloak"
|
|
assert warmsnap.snap_dir("keycloak").startswith(str(appdir))
|
|
assert os.path.dirname(warmsnap.snap_dir("keycloak")) == str(appdir)
|
|
|
|
|
|
def test_has_snapshot_incomplete_missing_tar(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
|
|
# meta lists two volumes but only one tar present -> incomplete -> not usable.
|
|
snapdir = tmp_path / "keycloak" / "snapshot"
|
|
(snapdir / "volumes").mkdir(parents=True)
|
|
(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"
|