Files
app-catalog-felhom.eu/audits/persistence-sweep-2026-08-02/state/survive2.py
T
admin 2b22a23d60 persistence sweep: 53 templates measured; gramps-web + wishlist fixed; runtime gate added
Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — a backup that completes, verifies, and contains
nothing. papra was never the point: nothing anywhere checked that the folder a template preserves
is the folder the app writes to. All 53 templates have now been measured live.

43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason,
and never folded into CLEAN.

FIXED (neither app is deployed anywhere, so nothing was stranded):
- gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never
  writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while
  gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's
  own environment names, matching upstream's reference compose. Proven: users.sqlite and the
  family-tree files survive a redeploy byte-identical, same inode.
- wishlist mounted wishlist_data:/data, another path the app never writes; prod.db landed in the
  ANONYMOUS volume from the image's VOLUME directive — absent from ResolveDockerVolumeNames, so
  never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads.
  Proven: prod.db byte-identical, same inode, across a redeploy.

Every corrected path confirmed by two independent sources — the shipped image's own
environment/Config.Volumes and upstream's reference compose — never inferred from a directory name.

papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up
recreate the container and destroy the writable layer its documents live in. The fix is prepared
and proven in the scratch guest (current: db.sqlite differs after a redeploy, so a real account
created via the API is lost; fixed: byte-identical, it survives). Referred to the operator with the
two options; no migration written.

NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one.
This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes
reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. It
refuses to report at all unless it has just re-proven itself in both directions against two canary
templates that differ only in which path the volume mounts at, so every run carries a live
demonstration of R-156 and of its fix. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests
driving check(), the function __main__ calls; every rule red-proofed.

Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it
is proposed as R-160.

Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu
is fenced this session): audits/persistence-sweep-2026-08-02/
2026-08-02 12:21:30 +02:00

147 lines
6.7 KiB
Python

#!/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 <app> [<app> …] (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)