Compare commits

...

3 Commits

Author SHA1 Message Date
b5f2b104e6 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
2026-07-09 00:11:28 +00:00
07fc6d4af5 fix(mumble): widen handshake readiness budget 60s->180s (load flake stabilization)
Some checks failed
continuous-integration/drone/push Build is failing
The TCP READY_PROBE proves 64738 is listening, but the murmur control channel needs more warmup to
complete a full TLS+ServerSync handshake; under concurrent sweep load that exceeded the 60s budget
(green in isolation, red under load). Longer budget absorbs the delay; assertions unchanged (a dead
server still fails after all retries).
2026-06-18 01:58:16 +00:00
61211dba70 fix(keycloak): collision-free canonical domain for live-warm providers; enroll keycloak
canonical_domain() routes any recipe in warm.WARM_DOMAINS (keycloak) to a distinct warm-canon-<recipe>
domain so the data-warm canonical promote can never collide with the live-warm OIDC provider at
warm-keycloak. keycloak WARM_CANONICAL=True (full canonical coverage without risking live SSO).
2026-06-18 01:58:16 +00:00
8 changed files with 286 additions and 65 deletions

View File

@ -39,9 +39,35 @@ def is_enrolled(recipe: str) -> bool:
return bool(meta_mod.load(recipe).WARM_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 canonical MUST be namespaced apart
from the live provider: otherwise the sweep's promote deploy/teardown at `warm-<recipe>` 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/<recipe>/` snapshot slot, so each
destroys the other's known-good. Every other recipe keeps the plain `<recipe>` namespace (zero
blast radius on the 15 existing canonicals, whose on-disk layout is unchanged)."""
if recipe in warm.WARM_DOMAINS:
return f"canon-{recipe}"
return recipe
def canonical_domain(recipe: str) -> str:
"""Stable data-warm domain for the recipe's canonical."""
return warm.stable_domain(recipe)
"""Stable data-warm domain for the recipe's canonical (`warm-<ns>`)."""
return warm.stable_domain(canonical_ns(recipe))
def canonical_slot(recipe: str) -> str:
"""The canonical's warm-state slot: `$CCCI_WARM_ROOT/<ns>/` — holding `canonical.json` and the
known-good `snapshot/`.
DISJOINT from `warmsnap.live_slot(recipe)` (the live-warm reconciler's `<recipe>/`, 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]:
@ -61,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:
@ -74,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),
@ -141,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/<recipe>/`
that carries a `canonical.json` but whose recipe is no longer enrolled (WARM_CANONICAL dropped).
Drops the dir (snapshot + registry) AND the retained `warm-<recipe>` 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/<ns>/`
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-<ns>` 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-<recipe>/`, so `<recipe>/` 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))
@ -162,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-<recipe> volumes, then the snapshot/registry dir
for vol in warmsnap.stack_volumes(canonical_domain(name)):
# drop the retained warm-<ns> 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)
@ -176,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

View File

@ -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/<recipe>/` 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/<slot>/` 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 `<recipe>/` and is NOT clobbered by the swap:
$CCCI_WARM_ROOT/<recipe>/
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)` (`<recipe>/`)
- data-warm canonical → `canonical.canonical_slot(r)` (`<recipe>/`, or `canon-<recipe>/` when the
recipe is also a live-warm provider)
Every slot key `<ns>` maps 1:1 to the stack at `warm.stable_domain(<ns>)`, 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 `<slot>/` and is NOT clobbered by the swap:
$CCCI_WARM_ROOT/<slot>/
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/<volname>.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-<recipe>`).
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 <domain>'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 <domain>'s stack into <slot>. 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 <domain>'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 <slot>'s last-known-good snapshot into <domain>'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)}"

View File

@ -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"):

View File

@ -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(

View File

@ -7,10 +7,14 @@ DEPLOY_TIMEOUT = (
)
HTTP_TIMEOUT = 900
# canon §2.B EXCEPTION (recorded in DECISIONS): keycloak is NOT a data-warm canonical. It is the
# project's LIVE-WARM OIDC dep provider — an always-on shared service at the SAME stable domain a
# data-warm canonical would use (warm-keycloak.ci.commoninternet.net). Enrolling it would make the
# sweep's promote deploy/teardown collide with the live provider that lasuite-*/drone depend on for
# SSO. keycloak is instead kept current by the sweep's roll_warm_infra step (the health-gated
# warm/infra reconciler, WC1.1) — so it never lacks coverage. WARM_CANONICAL stays False.
WARM_CANONICAL = False
# 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. 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-<recipe>` 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

View File

@ -19,7 +19,14 @@ import _mumble_proto # noqa: E402
def test_handshake_completes_with_channel_presence(live_app):
r = _mumble_proto.retry_handshake(attempts=12, interval=5.0)
# Readiness budget: 36×5s = 180s. The TCP READY_PROBE (recipe_meta) only proves port 64738 is
# LISTENING; the murmur control channel needs additional warmup before it completes a full
# TLS+Version+ServerSync handshake. Under concurrent node load (the canon sweep) that warmup
# exceeded the old 60s budget and flaked this test RED, while it is reliably GREEN in isolation
# (phase redfix M1: 3× isolation green, 0 isolation reds). The longer budget absorbs the
# load-induced readiness delay WITHOUT weakening the assertion — a genuinely non-responsive
# server still exhausts all retries and FAILs (the asserts below are unchanged).
r = _mumble_proto.retry_handshake(attempts=36, interval=5.0)
assert r["tls_connect"], f"TLS connection to 127.0.0.1:64738 failed — {r.get('error')}"
assert r["server_version"] is not None, "server did not send a Version message"

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"