#!/usr/bin/env python3 """Campaign 10 — Phase B soak runner. Runs on DooPlex (outside the VM it abuses). Shape (brief B1): a set of atomic operations composed into cycles in varying order, with EVERY invariant checked EVERY cycle — drift at iteration 38 is the point. Every atom and every invariant here was proven BY HAND before automation (see the audit §5); nothing in this file asserts a behaviour that was not first observed live. Writes: journal.tsv (one row per invariant check), runner.log, status.txt (heartbeat). Halts cleanly on: disk floor breach, VM gone, I11 breach (secret leak). """ import json, os, random, subprocess, sys, time, traceback import c10lib as L EV = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/tests/campaign10-evidence-2026-07-31" STATE = os.path.join(EV, "state") os.makedirs(STATE, exist_ok=True) JOURNAL = os.path.join(STATE, "journal.tsv") RUNLOG = os.path.join(STATE, "runner.log") STATUS = os.path.join(STATE, "status.txt") APPS = ["rallly", "homebox", "grafana", "papra"] # docker-volume apps DRIVE_APP = "calibre-web" # binds /mnt/felhom-drives/adatok TARGET_DISK, TARGET_VOL = "scsi2", "c10-scratch:311/vm-311-disk-3.qcow2" # mentes = backup target NONTGT_DISK, NONTGT_VOL = "scsi1", "c10-scratch:311/vm-311-disk-2.qcow2" # adatok FLOOR_GB = 120 # halt if /mnt/nvme-1tb free drops below this MAX_CYCLES = int(os.environ.get("C10_CYCLES", "40")) DEADLINE = time.time() + float(os.environ.get("C10_HOURS", "6")) * 3600 CYCLE = 0 VIOL = [] def log(msg): line = "%s %s" % (time.strftime("%H:%M:%S"), msg) print(line, flush=True) with open(RUNLOG, "a") as fh: fh.write(line + "\n") def rec(inv, ok, detail=""): """One journal row per invariant check. Violations do NOT stop the run (brief B2).""" with open(JOURNAL, "a") as fh: fh.write("%d\t%s\t%s\t%s\t%s\n" % (CYCLE, time.strftime("%FT%TZ", time.gmtime()), inv, "PASS" if ok else "VIOLATION", detail[:400])) if not ok: VIOL.append((CYCLE, inv, detail[:300])) log(" !! %s VIOLATION: %s" % (inv, detail[:220])) def heartbeat(phase): with open(STATUS, "w") as fh: fh.write("cycle=%d\nphase=%s\nutc=%s\nviolations=%d\n" % (CYCLE, phase, time.strftime("%FT%TZ", time.gmtime()), len(VIOL))) # ------------------------------------------------------------------ helpers def hub_events(since="3m"): """Hub-side event stream: type + severity, the observable I1/I2 assert on.""" p = subprocess.run(["sudo", "kubectl", "-n", "felhom-system", "logs", "deploy/hub", "--since=" + since], capture_output=True, text=True, timeout=120) out = [] for line in p.stdout.splitlines(): if "Event from c10-soak:" in line: seg = line.split("Event from c10-soak:", 1)[1].strip() typ = seg.split()[0] sev = seg.split("(", 1)[1].split(")", 1)[0] if "(" in seg else "?" out.append((typ, sev, seg)) return out def target_state(): return (L.jparse(L.api("GET", "/api/storage/backup-target")) or {}).get("data", {}) or {} def disks(): return ((L.jparse(L.api("GET", "/api/disks")) or {}).get("data", {}) or {}).get("disks", []) def wait_backup(timeout=900): t0 = time.time() while time.time() - t0 < timeout: d = ((L.jparse(L.api("GET", "/api/backup/status")) or {}).get("data") or {}) if not d.get("running"): return d time.sleep(10) return {"timeout": True} def wait_restore(timeout=900): t0 = time.time() while time.time() - t0 < timeout: d = ((L.jparse(L.api("GET", "/api/backup/restore-status")) or {}).get("data") or {}) if d and not d.get("running"): return d time.sleep(10) return {"timeout": True} def settle(seconds=130): """Agent tick + host-report + hub event propagation. Measured at ~25-60s; 130 gives margin.""" time.sleep(seconds) # ------------------------------------------------------------------ atoms def atom_backup(): L.api("POST", "/api/backup/run", {}) d = wait_backup() ok = bool(d.get("db_dump", {}).get("success", True)) and not d.get("timeout") rec("BACKUP", ok, json.dumps(d)[:300]) return ok def atom_restore_verify(): """I7 — the whole point: a restore must return the discriminator asked for.""" app = "rallly" # the DB app; its canary is a real DB row # PRECONDITION, not politeness: a canary lives inside an app container. Reading or writing # one before the app is back measures the harness, not the product. Run 2a produced a fake # "stale restore" this exact way, after a hard reset left rallly down. if not L.apps_ready([app]): rec("I7-SKIP", True, "apps not ready before seed — atom skipped, NOT counted as a violation") return genA = "C10-C%03d-A-%s" % (CYCLE, time.strftime("%H%M%S")) sa = L.seed(genA) if not sa.get(app): rec("I7-SKIP", True, "seed of %s did not land (app unhealthy) — atom skipped, not a violation" % genA) return if not atom_backup(): return genB = "C10-C%03d-B-%s" % (CYCLE, time.strftime("%H%M%S")) sb = L.seed(genB) # must NOT survive the restore if not sb.get(app): rec("I7-SKIP", True, "seed of %s did not land — cannot distinguish stale from unwritten" % genB) return snaps = L.jparse(L.api("GET", "/api/backup/snapshots?stack=%s" % app)) or {} pts = snaps.get("data") or [] if not pts: rec("I7", False, "no restore points for %s" % app) return sid = pts[0].get("short_id") t0 = time.time() L.guest('/root/c10api.sh POST /backup/restore "stack_name=%s&snapshot_id=%s" 2>/dev/null' % (app, sid)) d = wait_restore() rto = time.time() - t0 got = L.read_canaries().get(app) rec("I7", got == genA, "app=%s want=%s got=%s snap=%s restore_ok=%s rto=%.1fs" % (app, genA, got, sid, (d.get("last") or {}).get("ok"), rto)) # RTO/RPO byproduct (brief B3): to app-serving-correct-data, not to "restore returned" with open(os.path.join(STATE, "rto.tsv"), "a") as fh: fh.write("%d\t%s\t%s\ttier1\t%.1f\t%s\n" % (CYCLE, time.strftime("%FT%TZ", time.gmtime()), app, rto, got == genA)) def _detach(disk, vol, label, expect_type, expect_recover): """I1/I2 — the absent event must be the RIGHT one, and the pair must match.""" before_boot = L.guest("stat -c %Y /proc/1")[1].strip() L.hp("qm set 311 --delete %s" % disk) settle() evs = hub_events("4m") absent = [e for e in evs if e[0] in ("backup_target_absent", "storage_disconnected")] got = absent[-1][0] if absent else None sev = absent[-1][1] if absent else None rec("I1" if expect_type == "backup_target_absent" else "I2", got == expect_type and sev == "error", "%s absent -> got=%s sev=%s (want %s/error)" % (label, got, sev, expect_type)) # I3: an unusable namespace must not read as bound for d in disks(): if d.get("guest_path", "") and label in str(d.get("guest_path", "")): rec("I3", d.get("bound_under_parent") is False, "%s bound_under_parent=%s while absent" % (label, d.get("bound_under_parent"))) L.hp("qm set 311 --%s %s" % (disk, vol)) settle() evs2 = hub_events("4m") back = [e for e in evs2 if e[0] in ("backup_target_restored", "storage_reconnected")] got2 = back[-1][0] if back else None sev2 = back[-1][1] if back else None rec("I1-pair" if expect_type == "backup_target_absent" else "I2-pair", got2 == expect_recover and sev2 == "info", "%s return -> got=%s sev=%s (want %s/info)" % (label, got2, sev2, expect_recover)) # I4: the bind repairs WITHOUT the guest restarting after_boot = L.guest("stat -c %Y /proc/1")[1].strip() rec("I4", before_boot == after_boot and before_boot != "", "guest init boot before=%s after=%s (must be equal)" % (before_boot, after_boot)) def atom_detach_target(): _detach(TARGET_DISK, TARGET_VOL, "mentes", "backup_target_absent", "backup_target_restored") def atom_detach_nontarget(): _detach(NONTGT_DISK, NONTGT_VOL, "adatok", "storage_disconnected", "storage_reconnected") def atom_reboot_guest(): L.box("pct reboot 9201", timeout=300) for _ in range(40): time.sleep(15) if "felhom-controller" in L.containers(): break rec("REBOOT", "felhom-controller" in L.containers(), "controller back after guest reboot") def atom_kill_controller(): L.guest("docker restart felhom-controller >/dev/null 2>&1 &") time.sleep(45) rec("KILL-CTRL", "felhom-controller" in L.containers(), "controller recovered after kill") def atom_redeploy_app(): app = random.choice(["homebox", "grafana"]) L.api("POST", "/api/stacks/%s/remove" % app, {}) time.sleep(20) sec = L.SECRETS.get(app, {}).get("values", {}) if sec: L.api("POST", "/api/stacks/%s/deploy" % app, {"values": sec}) for _ in range(20): time.sleep(15) if app in L.containers(): break rec("REDEPLOY", app in L.containers(), "%s redeployed" % app) RAW_ADATOK = "/mnt/adatok" ADATOK_UUID = "13a43656-b393-4879-8d05-37de560def5f" def _adatok_row(): for d in disks(): if str(d.get("guest_path", "")).endswith("adatok"): return d return {} def atom_abort_fs_inplace(): """R-117 Q7 / I4 second clause — the filesystem dies WITHOUT the device disappearing. Before agent v0.117.0 this state reported healthy and restarted apps onto it with nothing emitted on any channel. Assert it SURFACES and does not silently retry.""" L.box("mount -o remount,abort %s" % RAW_ADATOK) opts = L.box("grep ' %s ' /proc/mounts" % RAW_ADATOK)[1].strip() if not ("abort" in opts or "emergency_ro" in opts): rec("I4-abort", False, "could not abort the fs in place; /proc/mounts=%s" % opts[:120]) return settle(150) row = _adatok_row() dev_present = "sd" in L.box("lsblk -dno NAME | tr '\\n' ' '")[1] rec("I4-abort", row.get("bound_under_parent") is False and dev_present, "aborted-in-place: bound=%s (want False), device still present=%s, opts=%s" % (row.get("bound_under_parent"), dev_present, opts.split("ext4")[-1][:60])) # the gate must STOP the app whose data is on the dead namespace, not restart it onto it rec("I3-abort", DRIVE_APP not in L.containers(), "%s must be stopped while its namespace is aborted; running=%s" % (DRIVE_APP, DRIVE_APP in L.containers())) # recovery needs a full device close — a remount lands on the same aborted superblock L.hp("qm set 311 --delete %s" % NONTGT_DISK) time.sleep(8) L.hp("qm set 311 --%s %s" % (NONTGT_DISK, NONTGT_VOL)) settle(150) row = _adatok_row() rec("I4-abort-recover", row.get("bound_under_parent") is True and DRIVE_APP in L.containers(), "after re-seat: bound=%s app_up=%s" % (row.get("bound_under_parent"), DRIVE_APP in L.containers())) def atom_kill_agent_mid_backup(): """Kill the AGENT while a backup runs, then prove the next backup still succeeds.""" L.api("POST", "/api/backup/run", {}) time.sleep(6) L.box("systemctl kill -s KILL felhom-agent || true") time.sleep(20) L.box("systemctl start felhom-agent || true") wait_backup() time.sleep(20) up = "active" in L.box("systemctl is-active felhom-agent")[1] ok2 = atom_backup() rec("KILL-AGENT", up and ok2, "agent active after kill=%s; subsequent backup ok=%s" % (up, ok2)) def atom_hard_reset_mid_write(): """Hard-reset the VM mid-backup. Assert it returns AND the canaries survive intact.""" before = L.read_canaries() L.api("POST", "/api/backup/run", {}) time.sleep(8) L.hp("qm reset 311") ok = False for _ in range(40): time.sleep(20) if "felhom-controller" in L.containers(): ok = True break # the canaries live in the APP containers — wait for those, or this measures startup latency ready = L.apps_ready() if ok else False after = L.read_canaries() if ready else {} intact = all(after.get(a) == before.get(a) for a in before) if ready else None detail = "VM returned=%s apps_ready=%s canaries_intact=%s" % (ok, ready, intact) if not ready: rec("HARD-RESET", False, detail + " — apps did not return within 10 min (that IS the failure)") else: rec("HARD-RESET", bool(intact), detail + " (a hard reset mid-write must not corrupt them)") def atom_reboot_vm(): L.hp("qm reboot 311") ok = False for _ in range(40): time.sleep(20) if "felhom-controller" in L.containers(): ok = True break rec("REBOOT-VM", ok, "controller back after full VM reboot") def atom_concurrent_backup_restore(): """The controller must REFUSE the second op, not run both over the same data.""" L.api("POST", "/api/backup/run", {}) time.sleep(3) out = L.guest('/root/c10api.sh POST /backup/restore "stack_name=rallly&snapshot_id=helyi" 2>/dev/null')[1] st = ((L.jparse(L.api("GET", "/api/backup/restore-status")) or {}).get("data") or {}) wait_backup(); wait_restore() # either the restore was refused, or it queued behind the backup — never both mutating at once rec("CONCURRENCY", True, "backup+restore raced; restore-status op=%s running=%s (recorded, not asserted)" % (st.get("op"), st.get("running"))) def atom_concurrent_backup_detach(): """Pull the backup TARGET out from under a running backup.""" L.api("POST", "/api/backup/run", {}) time.sleep(5) L.hp("qm set 311 --delete %s" % TARGET_DISK) settle() evs = [e for e in hub_events("4m") if e[0] in ("backup_target_absent", "storage_disconnected")] got = evs[-1][0] if evs else None rec("I1-under-load", got == "backup_target_absent", "target pulled DURING a backup -> got=%s (want backup_target_absent)" % got) L.hp("qm set 311 --%s %s" % (TARGET_DISK, TARGET_VOL)) settle() wait_backup() ts = target_state() rec("I1-under-load-recover", ts.get("degraded") is False, "after return degraded=%s target=%s" % (ts.get("degraded"), ts.get("target"))) def atom_fill_drive(): """Fill the backup target to near-full and back up. A refusal is a PASS; a silent success that writes nothing is the failure this looks for.""" avail = L.box("df --output=avail -BM /mnt/mentes | tail -1")[1].strip().rstrip("M") try: keep = max(int(avail) - 300, 50) except Exception: keep = 50 L.box("fallocate -l %dM /mnt/mentes/c10-filler 2>&1 || dd if=/dev/zero of=/mnt/mentes/c10-filler bs=1M count=%d 2>/dev/null" % (keep, keep)) free_after = L.box("df -h /mnt/mentes | tail -1")[1].strip() d = ((L.jparse(L.api("GET", "/api/backup/status")) or {}).get("data") or {}) L.api("POST", "/api/backup/run", {}) res = wait_backup() L.box("rm -f /mnt/mentes/c10-filler") ok = res.get("db_dump", {}).get("success") is not None rec("FILL-DRIVE", ok, "near-full (%s) backup outcome recorded: %s" % (free_after[-24:], json.dumps(res)[:200])) # ------------------------------------------------------------------ per-cycle invariants def check_healthy_baseline(): """I5/I6 — the over-correction check: healthy must render nothing.""" ts = target_state() degraded = ts.get("degraded") evs = [e for e in hub_events("3m") if e[0] in ("backup_target_absent", "storage_disconnected")] rec("I5", degraded is False and not evs, "degraded=%s absent_events=%d target=%s" % (degraded, len(evs), ts.get("target"))) if degraded is False: rec("I6", "message" not in ts or not ts.get("message"), "healthy state must carry no degraded message; got=%s" % str(ts.get("message"))[:120]) UNIT = "/mnt/sys_drive/felhom-data/backups/primary/%s/compose/app.yaml" def check_secret_class_travel(): """I10 — `type: secret` travels to the local unit; `type: password` NEVER does. Asserted in BOTH directions: the withheld class staying withheld is the half that silently rots. Key NAMES only; no value is ever read.""" for app, meta in L.SECRETS.items(): classes = meta.get("secret_classes", {}) if not classes: continue rc, keys, _ = L.guest("grep -oE '^ *[A-Z_0-9]+:' " + (UNIT % app) + " 2>/dev/null | tr -d ' :' | tr '\\n' ' '") present = set(keys.split()) if not present: rec("I10", False, "%s: recovery unit unreadable/absent" % app) continue for ev, c in classes.items(): if c["type"] == "secret": rec("I10", ev in present, "%s/%s type=secret must travel; in_unit=%s" % (app, ev, ev in present)) elif c["type"] == "password": rec("I10", ev not in present, "%s/%s type=password must NEVER travel; in_unit=%s" % (app, ev, ev in present)) def check_secrets_absent(): """I11 — no secret value in any log, event or hub payload. Breach STOPS the run.""" vals = [v for a in L.SECRETS.values() for k, v in a.get("values", {}).items() if k not in ("DOMAIN", "SUBDOMAIN") and v and len(v) > 8] hay = L.guest("docker logs felhom-controller --since 20m 2>&1 | tail -c 200000")[1] p = subprocess.run(["sudo", "kubectl", "-n", "felhom-system", "logs", "deploy/hub", "--since=20m"], capture_output=True, text=True, timeout=120) hay += p.stdout leaked = [v[:6] + "..." for v in vals if v in hay] rec("I11", not leaked, "leaked=%s" % leaked) return not leaked def watchdog(): rc, out, _ = L.hp("df --output=avail -BG /mnt/nvme-1tb | tail -1") try: free = int(out.strip().rstrip("G")) except Exception: free = 9999 rc2, vm, _ = L.hp("qm status 311") alive = "running" in vm if free < FLOOR_GB: log("WATCHDOG: free %dG < floor %dG — halting cleanly" % (free, FLOOR_GB)); return False if not alive: log("WATCHDOG: VM 311 not running (%s) — halting cleanly" % vm.strip()); return False return True # ------------------------------------------------------------------ main # LIGHT atoms run every cycle. HEAVY ones are slow (VM reboots, fills, aborts), so a # rotating slice joins each cycle and is SHUFFLED IN with the rest — the v1 runner appended # reboot after the shuffle, so it never interleaved with a detach. That is fixed here. ATOMS = [atom_backup, atom_restore_verify, atom_detach_target, atom_detach_nontarget, atom_kill_controller, atom_redeploy_app] HEAVY = [atom_abort_fs_inplace, atom_kill_agent_mid_backup, atom_hard_reset_mid_write, atom_reboot_vm, atom_reboot_guest, atom_concurrent_backup_restore, atom_concurrent_backup_detach, atom_fill_drive] def main(): global CYCLE log("=== Campaign 10 Phase B start — max %d cycles, deadline %s ===" % (MAX_CYCLES, time.strftime("%H:%M", time.localtime(DEADLINE)))) if not os.path.exists(JOURNAL): with open(JOURNAL, "w") as fh: fh.write("cycle\tutc\tinvariant\tverdict\tdetail\n") rng = random.Random(20260801) while CYCLE < MAX_CYCLES and time.time() < DEADLINE: CYCLE += 1 heartbeat("start") if not watchdog(): break order = ATOMS[:] # ONE heavy atom per cycle: every heavy atom is seen every 8 cycles, and the cycle # stays short enough to reach the tail the brief cares about (drift at ~38). order += [HEAVY[CYCLE % len(HEAVY)]] rng.shuffle(order) # permutation INCLUDING the heavy atoms log("cycle %d: %s" % (CYCLE, " -> ".join(a.__name__.replace("atom_", "") for a in order))) for a in order: heartbeat(a.__name__) try: a() except Exception as e: rec(a.__name__, False, "EXCEPTION %s" % e) log(" atom %s raised: %s" % (a.__name__, traceback.format_exc().splitlines()[-1])) heartbeat("invariants") try: check_healthy_baseline() check_secret_class_travel() if not check_secrets_absent(): log("I11 BREACH — stopping the run immediately per brief B2"); break except Exception as e: log("invariant pass raised: %s" % e) log("cycle %d done — violations so far: %d" % (CYCLE, len(VIOL))) heartbeat("finished") log("=== finished after %d cycles, %d violations ===" % (CYCLE, len(VIOL))) for c, i, d in VIOL: log(" cycle %d %s: %s" % (c, i, d)) if __name__ == "__main__": main()