Files
cc-ci/runner/harness/canonical.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

215 lines
9.6 KiB
Python

"""Data-warm canonical registry + lifecycle (Phase 2w / WC2, with WC3 snapshots).
A **canonical** is a per-recipe known-good deployment kept at the STABLE domain
`warm-<recipe>.ci.commoninternet.net`, **data-warm**: deployed while in use, **undeployed-when-idle
with its data volume retained**, so a later `--quick` run (W2) reattaches the volume and boots warm
(skipping fresh DB-init/first-boot). A small declarative registry tracks which recipes are canonical
and **at which known-good commit/version**.
Distinct from W0's *live-warm* keycloak (always running, shared SSO dep). Both use the
`warm-<recipe>` scheme + warmsnap snapshots; the difference is the idle lifecycle (live = up,
data = undeployed-keep-volume).
- **Enrollment (declarative):** `tests/<recipe>/recipe_meta.py` sets `WARM_CANONICAL = True`
(consistent with DEPS/EXTRA_ENV — enrolling stays a tests/<recipe>/ change, D5).
- **Registry state (per recipe), under `/var/lib/ci-warm/<recipe>/canonical.json`:**
`{recipe, domain, version, commit, status, ts}`. The retained data volume + the warmsnap
`snapshot/` live alongside. All of this is **cache, excluded from the D8 closure** (WC8) —
re-seeded by cold runs (WC5), not restored on a VM rebuild.
W1 builds the registry + the data-warm lifecycle and proves it (seed → undeploy-keep-volume →
redeploy-reattach → data survives). The automatic **promote-on-green-cold** seeding/advancement (WC5)
+ nightly refresh (WC6) are W3; here `seed_canonical` is the primitive they will call.
"""
from __future__ import annotations
import json
import os
import subprocess
import time
from . import abra, warm, warmsnap
from . import meta as meta_mod
def is_enrolled(recipe: str) -> bool:
"""True if `tests/<recipe>/recipe_meta.py` sets `WARM_CANONICAL = True`. Missing meta → False.
Reads through the single meta loader (rcust P1 — no per-module exec)."""
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 (`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]:
"""All recipes enrolled as data-warm canonicals (recipe_meta.WARM_CANONICAL=True), sorted. Used
by the WC6 nightly sweep to know which canonicals to refresh via a green cold run on latest."""
tests_dir = meta_mod.TESTS_DIR
out = []
try:
for name in sorted(os.listdir(tests_dir)):
if os.path.isfile(os.path.join(tests_dir, name, "recipe_meta.py")) and is_enrolled(
name
):
out.append(name)
except OSError:
pass
return out
def registry_path(recipe: str) -> str:
return os.path.join(warmsnap.app_dir(canonical_slot(recipe)), "canonical.json")
def read_registry(recipe: str) -> dict | None:
try:
with open(registry_path(recipe)) as f:
return json.load(f)
except (OSError, ValueError):
return 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(canonical_slot(recipe)), exist_ok=True)
rec = {
"recipe": recipe,
"domain": canonical_domain(recipe),
"version": version,
"commit": commit,
"status": status, # "warm" (deployed/in-use) | "idle" (undeployed, volume retained)
"ts": time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()),
}
tmp = registry_path(recipe) + ".tmp"
with open(tmp, "w") as f:
json.dump(rec, f, indent=2)
os.replace(tmp, registry_path(recipe))
return rec
def has_canonical(recipe: str) -> bool:
"""True iff a registry record exists AND the data volume(s) are retained on the host (so a
redeploy can reattach them). Mirrors WC2's 'data-warm: volume retained'."""
rec = read_registry(recipe)
if not rec:
return False
return bool(warmsnap.stack_volumes(canonical_domain(recipe)))
def _set_status(recipe: str, status: str) -> None:
rec = read_registry(recipe)
if rec:
write_registry(recipe, version=rec.get("version"), commit=rec.get("commit"), status=status)
def deploy_canonical(recipe: str, timeout: int = 900) -> None:
"""Bring a data-warm canonical UP at its known-good version, reattaching the retained data
volume (warm boot). Requires an existing registry record (seeded by a cold run / W1 proof)."""
rec = read_registry(recipe)
if not rec:
raise RuntimeError(f"no canonical registry for {recipe} — seed one first (cold run)")
domain, version = rec["domain"], rec["version"]
# The .env + retained volume already exist; redeploy the recorded known-good version. Reset the
# recorded TYPE=<recipe>:<version> FIRST so abra can resolve the "current deployment" even if a
# prior --quick upgrade left TYPE pointing at a since-removed/broken PR commit (otherwise abra
# FATALs "unable to resolve <commit>"). Then checkout the tag + idempotent (-f) redeploy.
abra.env_set(domain, "TYPE", f"{recipe}:{version}")
abra.recipe_checkout(recipe, version)
r = subprocess.run(
["abra", "app", "deploy", domain, version, "-o", "-n", "-f"],
capture_output=True,
text=True,
timeout=timeout,
)
if r.returncode != 0:
raise RuntimeError(
f"deploy canonical {domain} {version} failed: "
f"{(r.stderr + ' ' + r.stdout).strip()[:300]}"
)
_set_status(recipe, "warm")
def undeploy_keep_volume(recipe: str) -> None:
"""Make the canonical idle: undeploy (free RAM) but RETAIN the data volume (data-warm). Does NOT
remove volumes/secrets/.env — only `abra app undeploy`."""
domain = canonical_domain(recipe)
abra.undeploy(domain)
_set_status(recipe, "idle")
def prune_stale() -> list[str]:
"""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 = {canonical_slot(r) for r in enrolled_recipes()}
pruned: list[str] = []
try:
entries = sorted(os.listdir(root))
except OSError:
return pruned
for name in entries:
d = os.path.join(root, name)
if not os.path.isdir(d) or name in keep:
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-<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)
return pruned
def seed_canonical(recipe: str, version: str, commit: str | None = None) -> dict:
"""Record <warm-domain> (already deployed at `version`) as the recipe's canonical: write the
registry, then (app must be UNDEPLOYED) take the known-good snapshot. Caller deploys + verifies
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(
canonical_slot(recipe), canonical_domain(recipe), commit=commit, version=version
)
return rec