#!/usr/bin/env python3 """Survives-a-redeploy prover — the second half of the proof the gate cannot give. `check-volume-persistence.py` answers *where did the data land*. This answers *is it still there after a redeploy*, which is the claim the customer actually cares about. Presence is not success: that a volume exists afterwards says nothing about whether the data is in it. Method, per app: 1. deploy from the template, exercise, settle 2. FINGERPRINT every file the app created inside every declared mount — (relpath, size, sha256, inode), read host-side from the volume's own directory 3. `docker compose down` — NO `-v`. That is a redeploy; `down --volumes` is the UNINSTALL path and destroys volumes deliberately (Campaign 7 §2) 4. `docker compose up -d`, wait, settle 5. RE-FINGERPRINT. A file SURVIVED only if sha256 AND inode both match — the same bytes in the same file object, not a fresh file the app recreated under the same name. Nothing is ever seeded into a volume by hand. R-156's own evidence shows a root-written canary making an empty volume read as populated, which is the confusion this work exists to remove. Usage: python3 survive2.py [ …] (templates under /opt/sweep/templates) """ import hashlib, importlib.util, json, os, shutil, sys, tempfile, time from pathlib import Path _spec = importlib.util.spec_from_file_location("cvp", "/opt/sweep/check-volume-persistence.py") cvp = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(cvp) TEMPLATES = Path("/opt/sweep/templates") EVIDENCE = Path("/opt/sweep/evidence") def fingerprint(mounts): fp = {} for m in mounts: src = m["source"] if not src or not os.path.isdir(src): continue for dp, _, fns in os.walk(src, onerror=lambda e: None): for fn in fns: p = os.path.join(dp, fn) if not os.path.isfile(p): continue try: st = os.lstat(p) h = hashlib.sha256(open(p, "rb").read()).hexdigest() except OSError: continue fp[f"{m['target']}::{os.path.relpath(p, src)}"] = { "size": st.st_size, "sha256": h, "inode": st.st_ino} return fp def bring_up(base, declared, project, ports, settle=45, wait=300): rc = cvp._sh(base + ["up", "-d"], timeout=1800).returncode cids = [c for c in cvp._sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c] deadline = time.time() + wait while time.time() < deadline: pend = False for cid in cids: st = (cvp._inspect(cid) or {}).get("State") or {} if st.get("Status") in ("created", "restarting") or \ (st.get("Health") or {}).get("Status") == "starting": pend = True if not pend: break time.sleep(10) running = [c for c in cids if ((cvp._inspect(c) or {}).get("State") or {}).get("Status") == "running"] if running and ports: cvp._exercise(running, ports) time.sleep(settle) mounts = [] for cid in cids: info = cvp._inspect(cid) if not info: continue nm = (info.get("Name") or cid).lstrip("/") for m in (info.get("Mounts") or []): cls = cvp._classify_mount(m, project, declared) if cls == "tmpfs": continue mounts.append({"ctr": nm, "target": m.get("Destination"), "class": cls, "source": m.get("Source"), "name": m.get("Name")}) return rc, cids, mounts def prove(app, tdir=None, label=None): tdir = Path(tdir) if tdir else TEMPLATES / app label = label or app edir = EVIDENCE / app edir.mkdir(parents=True, exist_ok=True) work = Path(tempfile.mkdtemp(prefix=f"surv-{label}-")) compose_text = (tdir / "docker-compose.yml").read_text() felhom_text = (tdir / ".felhom.yml").read_text() if (tdir / ".felhom.yml").is_file() else "" shutil.copy(tdir / "docker-compose.yml", work / "docker-compose.yml") env = cvp.build_env(app, felhom_text, compose_text) (work / ".env").write_text("".join(f"{k}={v}\n" for k, v in sorted(env.items()))) project = "sv" + "".join(ch for ch in label.lower() if ch.isalnum()) base = ["docker", "compose", "-p", project, "--project-directory", str(work), "-f", str(work / "docker-compose.yml")] os.makedirs(cvp.SCRATCH_HDD + "/userdata", exist_ok=True) os.makedirs(cvp.SCRATCH_IMPORT, exist_ok=True) cvp._sh(["docker", "network", "create", "traefik-public"], timeout=60) try: cfg = json.loads(cvp._sh(base + ["config", "--format", "json"], timeout=180).stdout) declared = set((cfg.get("volumes") or {}).keys()) ports = sorted({int(m.group(1)) for svc in (cfg.get("services") or {}).values() for lbl in ((svc.get("labels") or {}).values() if isinstance(svc.get("labels"), dict) else (svc.get("labels") or [])) for m in [cvp.PORT_RE.search(str(lbl))] if m}) rc1, _, mounts1 = bring_up(base, declared, project, ports) before = fingerprint(mounts1) cvp._sh(base + ["down"], timeout=900) # a REDEPLOY — no -v rc2, _, mounts2 = bring_up(base, declared, project, ports) after = fingerprint(mounts2) survived = sorted(k for k in before if k in after and after[k]["sha256"] == before[k]["sha256"] and after[k]["inode"] == before[k]["inode"]) changed = sorted(k for k in before if k in after and k not in survived) lost = sorted(k for k in before if k not in after) res = {"app": app, "label": label, "up1_rc": rc1, "up2_rc": rc2, "files_before": len(before), "files_after": len(after), "survived": survived, "changed_in_place": changed, "lost": lost, "mounts": mounts1, "verdict": ("SURVIVES" if (before and not lost and survived) else "NO-DATA-TO-LOSE" if not before else "DATA-LOST")} (edir / f"survive-{label}.json").write_text(json.dumps(res, indent=2, sort_keys=True)) return res finally: cvp._sh(base + ["down", "-v", "--remove-orphans"], timeout=900) shutil.rmtree(work, ignore_errors=True) if __name__ == "__main__": for a in sys.argv[1:]: r = prove(a) print("%-16s %-16s before=%d after=%d survived=%d changed=%d lost=%d %s" % (a, r["verdict"], r["files_before"], r["files_after"], len(r["survived"]), len(r["changed_in_place"]), len(r["lost"]), r["lost"][:3]), flush=True)