diff --git a/runner/harness/canonical.py b/runner/harness/canonical.py index aa68f85..13c6113 100644 --- a/runner/harness/canonical.py +++ b/runner/harness/canonical.py @@ -39,19 +39,35 @@ def is_enrolled(recipe: str) -> bool: return bool(meta_mod.load(recipe).WARM_CANONICAL) -def canonical_domain(recipe: str) -> str: - """Stable data-warm domain for the recipe's canonical. +def canonical_ns(recipe: str) -> str: + """The data-warm canonical's NAMESPACE for a recipe — the single source from which BOTH its + domain and its warm-state slot are derived, so the two can never drift apart (F-redfix-4). For a recipe that is ALSO a live-warm provider (in `warm.WARM_DOMAINS` — e.g. keycloak, whose - always-on shared OIDC instance lives at `warm-keycloak…`), the data-warm canonical MUST use a - DISTINCT domain: otherwise the sweep's promote deploy/teardown at `warm-` collides with — - and could disrupt — the live shared service that other recipes (lasuite-*/drone) depend on. Give - those recipes a collision-free `warm-canon-` namespace (a separate stack/domain that can - never touch the live provider); every other recipe keeps the plain `warm-` scheme - (zero blast radius on the 15 existing canonicals).""" + always-on shared OIDC instance lives at `warm-keycloak…`), the canonical MUST be namespaced apart + from the live provider: otherwise the sweep's promote deploy/teardown at `warm-` collides + with — and could disrupt — the live shared service that other recipes (lasuite-*/drone) depend on, + and (F-redfix-4) the two deployments share one `/var/lib/ci-warm//` snapshot slot, so each + destroys the other's known-good. Every other recipe keeps the plain `` namespace (zero + blast radius on the 15 existing canonicals, whose on-disk layout is unchanged).""" if recipe in warm.WARM_DOMAINS: - return f"warm-canon-{recipe}.ci.commoninternet.net" - return warm.stable_domain(recipe) + return f"canon-{recipe}" + return recipe + + +def canonical_domain(recipe: str) -> str: + """Stable data-warm domain for the recipe's canonical (`warm-`).""" + return warm.stable_domain(canonical_ns(recipe)) + + +def canonical_slot(recipe: str) -> str: + """The canonical's warm-state slot: `$CCCI_WARM_ROOT//` — holding `canonical.json` and the + known-good `snapshot/`. + + DISJOINT from `warmsnap.live_slot(recipe)` (the live-warm reconciler's `/`, holding its + `last_good`) exactly when the recipe is a live-warm provider. Asserted in + `tests/unit/test_canonical.py::test_live_and_canonical_slots_are_disjoint`.""" + return canonical_ns(recipe) def enrolled_recipes() -> list[str]: @@ -71,7 +87,7 @@ def enrolled_recipes() -> list[str]: def registry_path(recipe: str) -> str: - return os.path.join(warmsnap.app_dir(recipe), "canonical.json") + return os.path.join(warmsnap.app_dir(canonical_slot(recipe)), "canonical.json") def read_registry(recipe: str) -> dict | None: @@ -84,7 +100,7 @@ def read_registry(recipe: str) -> dict | None: def write_registry(recipe: str, *, version: str, commit: str | None, status: str) -> dict: """Atomically write the canonical registry record for a recipe.""" - os.makedirs(warmsnap.app_dir(recipe), exist_ok=True) + os.makedirs(warmsnap.app_dir(canonical_slot(recipe)), exist_ok=True) rec = { "recipe": recipe, "domain": canonical_domain(recipe), @@ -151,16 +167,22 @@ def undeploy_keep_volume(recipe: str) -> None: def prune_stale() -> list[str]: - """WC8 disk hygiene: remove warm data for DE-ENROLLED canonicals — a `/var/lib/ci-warm//` - that carries a `canonical.json` but whose recipe is no longer enrolled (WARM_CANONICAL dropped). - Drops the dir (snapshot + registry) AND the retained `warm-` data volumes. Leaves the - live-warm reconciler dirs (keycloak/traefik — they have a `last_good`, no `canonical.json`), - `alerts/`, and currently-enrolled canonicals untouched. Returns the recipes pruned.""" + """WC8 disk hygiene: remove warm data for DE-ENROLLED canonicals — a `/var/lib/ci-warm//` + that carries a `canonical.json` but whose namespace is no longer an enrolled canonical's slot + (WARM_CANONICAL dropped). Drops the dir (snapshot + registry) AND the retained `warm-` data + volumes. Leaves the live-warm reconciler dirs (keycloak/traefik — they have a `last_good`, no + `canonical.json`), `alerts/`, and currently-enrolled canonicals untouched. Returns the namespaces + pruned. + + The reconciler-dir invariant is STRUCTURAL, not incidental (F-redfix-4): a live-warm provider's + canonical registers under `canon-/`, so `/` never gains a `canonical.json` and is + never a prune candidate — de-enrolling keycloak can no longer `rmtree` the reconciler's + `last_good`. Dir name == namespace, so the stale stack's domain is `warm.stable_domain(name)`.""" import shutil import subprocess root = warmsnap.warm_root() - keep = set(enrolled_recipes()) + keep = {canonical_slot(r) for r in enrolled_recipes()} pruned: list[str] = [] try: entries = sorted(os.listdir(root)) @@ -172,8 +194,8 @@ def prune_stale() -> list[str]: continue if not os.path.isfile(os.path.join(d, "canonical.json")): continue # not a data-warm canonical (e.g. keycloak/traefik reconciler dir, alerts/) - # drop the retained warm- volumes, then the snapshot/registry dir - for vol in warmsnap.stack_volumes(canonical_domain(name)): + # drop the retained warm- volumes, then the snapshot/registry dir + for vol in warmsnap.stack_volumes(warm.stable_domain(name)): subprocess.run(["docker", "volume", "rm", vol], capture_output=True, text=True) shutil.rmtree(d, ignore_errors=True) pruned.append(name) @@ -186,5 +208,7 @@ def seed_canonical(recipe: str, version: str, commit: str | None = None) -> dict healthy first, then undeploys before calling this (WC3: snapshot while undeployed). The retained volume IS the canonical. Returns the registry record.""" rec = write_registry(recipe, version=version, commit=commit, status="idle") - warmsnap.snapshot(recipe, canonical_domain(recipe), commit=commit, version=version) + warmsnap.snapshot( + canonical_slot(recipe), canonical_domain(recipe), commit=commit, version=version + ) return rec diff --git a/runner/harness/warmsnap.py b/runner/harness/warmsnap.py index 4c97863..7d6e740 100644 --- a/runner/harness/warmsnap.py +++ b/runner/harness/warmsnap.py @@ -1,8 +1,8 @@ """Known-good snapshot/restore of an app's data volumes (Phase 2w / WC3). A snapshot is a **raw copy of every docker volume belonging to an app's stack, taken while the app is -UNDEPLOYED** (nothing is writing → consistent). Stored under `/var/lib/ci-warm//` as one -last-known-good per app, replaced atomically. Restore clears each volume and untars it back. +UNDEPLOYED** (nothing is writing → consistent). Stored under `/var/lib/ci-warm//` as one +last-known-good per slot, replaced atomically. Restore clears each volume and untars it back. Used by: - WC1.1 — snapshot keycloak's data volume BEFORE an auto-upgrade; restore on health-gate rollback @@ -12,12 +12,25 @@ Used by: Warm snapshots are **cache, excluded from the D8 reproducibility closure** (WC8) — re-seeded by cold runs, not restored on a VM rebuild. -Layout (atomic dir swap of the `snapshot/` subdir; one last-good per app). Sibling per-app state -(e.g. the reconciler's `last_good`) lives in `/` and is NOT clobbered by the swap: - $CCCI_WARM_ROOT// +SLOT, not recipe (F-redfix-4). Warm state belongs to a **deployed stack**, not to a recipe name. A +recipe can own TWO warm stacks at once — keycloak is both the live-warm shared OIDC provider +(`warm-keycloak…`) and a data-warm canonical (`warm-canon-keycloak…`) — and they must never share a +snapshot slot: `snapshot()` atomically REPLACES the slot, so a shared slot means each deployment +destroys the other's known-good, and `restore()` (which requires every recorded volume to exist in +the target stack) then raises. Callers therefore pass a **slot key**, not a bare recipe: +- live-warm reconciler → `live_slot(recipe)` (`/`) +- data-warm canonical → `canonical.canonical_slot(r)` (`/`, or `canon-/` when the + recipe is also a live-warm provider) +Every slot key `` maps 1:1 to the stack at `warm.stable_domain()`, so distinct slot ⇔ +distinct stack. `canonical.py` derives its domain and its slot from ONE namespace so the two cannot +drift apart. + +Layout (atomic dir swap of the `snapshot/` subdir; one last-good per slot). Sibling per-slot state +(e.g. the reconciler's `last_good`) lives in `/` and is NOT clobbered by the swap: + $CCCI_WARM_ROOT// last_good # (reconciler) the version known healthy — survives snapshot swaps snapshot/ - meta.json # {recipe, domain, commit, version, ts, volumes:[...]} + meta.json # {slot, domain, commit, version, ts, volumes:[...]} volumes/.tar # raw tar of the volume root, one per stack volume Implementation note: volumes are tarred from their host mountpoint @@ -46,38 +59,46 @@ def warm_root() -> str: return os.environ.get("CCCI_WARM_ROOT", DEFAULT_WARM_ROOT) -def app_dir(recipe: str) -> str: - return os.path.join(warm_root(), recipe) +def live_slot(recipe: str) -> str: + """Warm-state slot of the LIVE-warm reconciler's deployment (the stack at `warm-`). + + The canonical's slot is `canonical.canonical_slot()`, which diverges from this one exactly when + the recipe is also a live-warm provider (F-redfix-4).""" + return recipe -def snap_dir(recipe: str) -> str: +def app_dir(slot: str) -> str: + return os.path.join(warm_root(), slot) + + +def snap_dir(slot: str) -> str: """The snapshot subdir — atomically swapped on update. Kept SEPARATE from app_dir so sibling - per-app state (the reconciler's last_good) survives a snapshot swap.""" - return os.path.join(app_dir(recipe), "snapshot") + per-slot state (the reconciler's last_good) survives a snapshot swap.""" + return os.path.join(app_dir(slot), "snapshot") -def meta_path(recipe: str) -> str: - return os.path.join(snap_dir(recipe), "meta.json") +def meta_path(slot: str) -> str: + return os.path.join(snap_dir(slot), "meta.json") -def volumes_dir(recipe: str) -> str: - return os.path.join(snap_dir(recipe), "volumes") +def volumes_dir(slot: str) -> str: + return os.path.join(snap_dir(slot), "volumes") -def has_snapshot(recipe: str) -> bool: +def has_snapshot(slot: str) -> bool: """True iff a complete last-good snapshot (meta + at least its declared volume tars) exists.""" - meta = read_meta(recipe) + meta = read_meta(slot) if not meta: return False for v in meta.get("volumes", []): - if not os.path.isfile(os.path.join(volumes_dir(recipe), f"{v}.tar")): + if not os.path.isfile(os.path.join(volumes_dir(slot), f"{v}.tar")): return False return True -def read_meta(recipe: str) -> dict | None: +def read_meta(slot: str) -> dict | None: try: - with open(meta_path(recipe)) as f: + with open(meta_path(slot)) as f: return json.load(f) except (OSError, ValueError): return None @@ -113,18 +134,35 @@ def _assert_undeployed(domain: str) -> None: ) -def snapshot( - recipe: str, domain: str, commit: str | None = None, version: str | None = None -) -> dict: - """Take a last-known-good snapshot of every data volume of 's stack. The app MUST be - undeployed. Atomically replaces the prior last-good. Returns the written meta dict.""" +def _assert_slot_not_foreign(slot: str, domain: str) -> None: + """Refuse to touch a slot that already holds another DOMAIN's known-good (F-redfix-4). + + Defence in depth behind the slot separation itself: naming-scheme-independent, it catches any + future caller that pairs a slot with the wrong stack. Fails BEFORE the destructive swap rather + than at the next restore. A slot with no snapshot yet is free to claim.""" + meta = read_meta(slot) + if meta and meta.get("domain") and meta["domain"] != domain: + raise SnapshotError( + f"warm slot {slot!r} holds the known-good of {meta['domain']!r}, not {domain!r}: " + "refusing to clobber another deployment's snapshot (F-redfix-4). " + "Live-warm and data-warm deployments of one recipe need distinct slots." + ) + + +def snapshot(slot: str, domain: str, commit: str | None = None, version: str | None = None) -> dict: + """Take a last-known-good snapshot of every data volume of 's stack into . The app + MUST be undeployed. Atomically replaces the prior last-good in that slot. Returns the meta dict. + + `slot` must be the warm-state slot OWNING `domain` (see module docstring) — passing another + deployment's slot would destroy its known-good, so the pairing is asserted below.""" + _assert_slot_not_foreign(slot, domain) _assert_undeployed(domain) volumes = stack_volumes(domain) if not volumes: raise SnapshotError(f"no volumes found for {domain} — nothing to snapshot") - os.makedirs(app_dir(recipe), exist_ok=True) - staging = os.path.join(app_dir(recipe), ".snapshot.staging") + os.makedirs(app_dir(slot), exist_ok=True) + staging = os.path.join(app_dir(slot), ".snapshot.staging") shutil.rmtree(staging, ignore_errors=True) os.makedirs(os.path.join(staging, "volumes"), exist_ok=True) @@ -138,7 +176,7 @@ def snapshot( raise SnapshotError(f"tar of volume {vol} failed: {r.stderr.strip()}") meta = { - "recipe": recipe, + "slot": slot, "domain": domain, "commit": commit, "version": version, @@ -149,8 +187,8 @@ def snapshot( json.dump(meta, f) # Atomic-ish swap of the snapshot subdir only (sibling state like last_good is untouched). - target = snap_dir(recipe) - old = os.path.join(app_dir(recipe), ".snapshot.old") + target = snap_dir(slot) + old = os.path.join(app_dir(slot), ".snapshot.old") shutil.rmtree(old, ignore_errors=True) if os.path.exists(target): os.rename(target, old) @@ -159,17 +197,19 @@ def snapshot( return meta -def restore(recipe: str, domain: str) -> dict: - """Restore the last-known-good snapshot into 's stack volumes. The app MUST be undeployed. - Clears each volume then untars the snapshot back. Returns the snapshot meta. Raises if no - snapshot exists or a snapshot volume is missing from the current stack.""" +def restore(slot: str, domain: str) -> dict: + """Restore 's last-known-good snapshot into 's stack volumes. The app MUST be + undeployed. Clears each volume then untars the snapshot back. Returns the snapshot meta. Raises + if no snapshot exists, the slot belongs to another domain, or a snapshot volume is missing from + the current stack.""" _assert_undeployed(domain) - meta = read_meta(recipe) - if not meta or not has_snapshot(recipe): - raise SnapshotError(f"no complete snapshot for {recipe} to restore") + meta = read_meta(slot) + if not meta or not has_snapshot(slot): + raise SnapshotError(f"no complete snapshot in slot {slot!r} to restore") + _assert_slot_not_foreign(slot, domain) current = set(stack_volumes(domain)) for vol in meta.get("volumes", []): - tar_path = os.path.join(volumes_dir(recipe), f"{vol}.tar") + tar_path = os.path.join(volumes_dir(slot), f"{vol}.tar") if vol not in current: raise SnapshotError( f"snapshot volume {vol} absent from current stack {sorted(current)}" diff --git a/runner/run_recipe_ci.py b/runner/run_recipe_ci.py index 83514fc..20b7610 100644 --- a/runner/run_recipe_ci.py +++ b/runner/run_recipe_ci.py @@ -893,7 +893,7 @@ def run_quick( ) abra.undeploy(domain) _wait_undeployed(domain) - warmsnap.restore(recipe, domain) + warmsnap.restore(canonical.canonical_slot(recipe), domain) # reset recorded version to the known-good (the failed upgrade set TYPE to the broken # PR commit) so the idle canonical's .env agrees with the registry + re-warms cleanly. if reg.get("version"): diff --git a/runner/warm_reconcile.py b/runner/warm_reconcile.py index df1833c..636c0a8 100644 --- a/runner/warm_reconcile.py +++ b/runner/warm_reconcile.py @@ -384,7 +384,9 @@ def wait_undeployed(domain: str, timeout: int = 120) -> None: def last_good_path(recipe: str) -> str: - return os.path.join(warmsnap.app_dir(recipe), "last_good") + # live_slot, not the bare recipe: a recipe that is also a data-warm canonical keeps its canonical + # state in a DIFFERENT slot, so the reconciler's last_good is never clobbered (F-redfix-4). + return os.path.join(warmsnap.app_dir(warmsnap.live_slot(recipe)), "last_good") def read_last_good(recipe: str) -> str | None: @@ -396,7 +398,7 @@ def read_last_good(recipe: str) -> str | None: def write_last_good(recipe: str, version: str) -> None: - os.makedirs(warmsnap.app_dir(recipe), exist_ok=True) + os.makedirs(warmsnap.app_dir(warmsnap.live_slot(recipe)), exist_ok=True) tmp = last_good_path(recipe) + ".tmp" with open(tmp, "w") as f: f.write(version) @@ -509,7 +511,7 @@ def reconcile(app: str) -> str: if stateful: abra.undeploy(domain) wait_undeployed(domain) - warmsnap.snapshot(recipe, domain, version=last_good) + warmsnap.snapshot(warmsnap.live_slot(recipe), domain, version=last_good) # snapshot requires undeployed; now bring up latest. # A broken "latest" can fail in two ways: deploy_version raises (abra converge times out on a # crash-looping task) OR it deploys but never becomes healthy. BOTH must roll back, so treat a @@ -531,7 +533,7 @@ def reconcile(app: str) -> str: if stateful: abra.undeploy(domain) wait_undeployed(domain) - warmsnap.restore(recipe, domain) + warmsnap.restore(warmsnap.live_slot(recipe), domain) deploy_version(recipe, domain, last_good, dt) recovered = wait_healthy(spec) write_alert( diff --git a/tests/keycloak/recipe_meta.py b/tests/keycloak/recipe_meta.py index 9759eeb..a5b171e 100644 --- a/tests/keycloak/recipe_meta.py +++ b/tests/keycloak/recipe_meta.py @@ -10,9 +10,11 @@ HTTP_TIMEOUT = 900 # phase redfix: keycloak IS now a data-warm canonical. The original canon §2.B exception de-enrolled # it because its canonical would have used the SAME domain as the live-warm OIDC provider # (warm-keycloak.ci.commoninternet.net), so the sweep's promote deploy/teardown would collide with the -# live service lasuite-*/drone depend on. That collision is now structurally impossible: -# `canonical.canonical_domain()` routes any recipe in `warm.WARM_DOMAINS` (keycloak) to a distinct -# `warm-canon-` domain/stack, so the data-warm canonical and the live-warm provider are -# separate deployments that can never touch each other. keycloak therefore gets full data-warm -# canonical coverage (a real promote on its latest release) without risking the live OIDC service. +# live service lasuite-*/drone depend on. The canonical is now namespaced apart from the live provider +# on BOTH axes it shares with it — `canonical.canonical_ns()` gives any recipe in `warm.WARM_DOMAINS` +# (keycloak) a `canon-` namespace, from which BOTH its domain/stack (`warm-canon-keycloak…`) +# and its warm-state slot (`/var/lib/ci-warm/canon-keycloak/`) derive. Domain separation alone was NOT +# enough: warm state was keyed by bare recipe, so both deployments shared one snapshot slot and each +# destroyed the other's known-good (F-redfix-4). keycloak therefore gets full data-warm canonical +# coverage (a real promote on its latest release) without risking the live OIDC service. WARM_CANONICAL = True diff --git a/tests/unit/test_canonical.py b/tests/unit/test_canonical.py index 58896f4..af4d1f1 100644 --- a/tests/unit/test_canonical.py +++ b/tests/unit/test_canonical.py @@ -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-). + 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 `/`, 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 diff --git a/tests/unit/test_warmsnap.py b/tests/unit/test_warmsnap.py index 6e3788a..7f02015 100644 --- a/tests/unit/test_warmsnap.py +++ b/tests/unit/test_warmsnap.py @@ -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"