Files
cc-ci/tests/unit/test_warmsnap.py
autonomic-bot b5f2b104e6
Some checks failed
continuous-integration/drone/push Build is failing
fix(keycloak): key warm state by stack namespace, not bare recipe (F-redfix-4)
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
2026-07-09 00:11:28 +00:00

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"