Compare commits
3 Commits
feat/libre
...
redfix-m2-
| Author | SHA1 | Date | |
|---|---|---|---|
| b5f2b104e6 | |||
| 07fc6d4af5 | |||
| 61211dba70 |
@ -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
|
||||
|
||||
@ -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)}"
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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