da20722e76
gates / gates (push) Successful in 27s
INTERIM CHECKPOINT — evidence off the machine at the end of the phase that produced it (R-320), not at the end of the session. Phases 2-5 follow in a later commit. Phase 0, all three mechanisms proven with their controls: - the fleet floor to 0.261.0 with its declared MinAgent — both demo boxes in 13 s, the hub logging `managed floor SERVED ... from declared (golden 0.258.0)`. - a PRIVATE DRILL CATALOG (admin/app-catalog-drill), so that broken, dummy, cross-repo and engine-major edges can be measured without the live catalog ever carrying one. Positive control quoted, and two negative controls: the live catalog's main and both real boxes' caches unchanged. - a throwaway image store on the scratch guest, which is what makes an UNATTENDED HOLD measurable at all: an edge that PASSES the within-a-major test and still fails. CompareImageRefs was proven to order host:port/ references by RUNNING it (4 positive cases + 1 negative control), not by reading it. Phase 1: real within-a-major upstream edges walked on guest 9202 through the product's own guarded Update, each app seeded and read back through its OWN front door (R-156), with a per-edge verdict record in 09's shape. `inconclusive` is never collapsed into `failed`. TWO INSTRUMENT FIXES, both in this repo's own evidence code: - 00-api-recipe.md said the app page is /app/<n>; it is /apps/<n>, and every call it described 404s. Corrected, with the session-expiry note that cost the same time. - unattended-caller.py's follow() read update_phase/updating off the API ENVELOPE, so both were always None and EVERY followed update ran to its 900 s timeout and was then recorded `timeout` and never-press-again. Fixed before B1 relied on it. R-623. No controller, agent or hub code was written. The live catalog carries no broken reference. Gates: repo_gates.py --fast — all 15 OK, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
201 lines
9.7 KiB
Python
201 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
||
"""Phase 3 leg B5 — a power cut in `backing-up`, and in `safety-dump`.
|
||
|
||
R-520 cut in `pulling` (nothing had run). R-610 cut after `starting` (the migration had run). The
|
||
two phases NOBODY has cut in are the two EARLY ones, and they are the ones that touch the
|
||
customer's copy rather than their data:
|
||
|
||
backing-up `RunAppBackupNow` is making the copy the update will lean on
|
||
safety-dump `WriteUpdateSafetyDump` is writing R-361's undo copy, BEFORE the pin moves
|
||
|
||
Source says both should end with *nothing moved, the journal entry dropped, and the interrupted
|
||
sentence shown*. THE CLAIM THIS LEG EXISTS TO TEST is narrower and nastier than that: **a backup
|
||
artefact that was half-written must not be left looking whole**, because the next update's
|
||
precondition will believe it.
|
||
|
||
INSTRUMENT LIMITS, STATED UP FRONT because they bound every claim below:
|
||
* the poll is 200 ms and `pct stop` returns in 3–4 s, so the phase at the DECISION is observed
|
||
and the phase at the FREEZE is inferred. Said every time, never glossed (R-520's own lesson).
|
||
* `backing-up` only happens when no tier holds a copy younger than `update.backup_max_age`, so
|
||
this leg lowers that knob — an operator-owned setting on `controller.yaml`, restored at teardown.
|
||
* `pct mount` shows an EMPTY STUB for the guest's inner mounts, so every post-crash read is taken
|
||
from the BOOTED guest, or with `find` over the whole rootfs and a positive control. An empty
|
||
directory is not evidence of an absent file.
|
||
"""
|
||
import json, os, re, subprocess, sys, time
|
||
from datetime import datetime
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
import walk as w # noqa: E402
|
||
from fixtures import FIXTURES # noqa: E402
|
||
from phase3_legs import out, sentences # noqa: E402
|
||
|
||
V = "/var/lib/docker/volumes/felhom-controller-data/_data"
|
||
|
||
|
||
def hostsh(cmd, timeout=300):
|
||
return (w.sh(["ssh", "-o", "ConnectTimeout=20", w.HP, cmd], timeout=timeout).stdout or "")
|
||
|
||
|
||
def set_knob(key, value):
|
||
"""Set one operator-owned knob in controller.yaml and restart. Restored at teardown."""
|
||
scr = f"""
|
||
python3 - <<'PY'
|
||
import re
|
||
p="{V}/controller.yaml"
|
||
s=open(p).read()
|
||
if re.search(r'^update:', s, re.M):
|
||
if re.search(r'^(update:\\n(?: .*\\n)*?) {key}:.*$', s, re.M):
|
||
s=re.sub(r'^(update:\\n(?: .*\\n)*?) {key}:.*$', r'\\g<1> {key}: {value}', s, count=1, flags=re.M)
|
||
else:
|
||
s=re.sub(r'^update:\\n', 'update:\\n {key}: {value}\\n', s, count=1, flags=re.M)
|
||
else:
|
||
s += "\\nupdate:\\n {key}: {value}\\n"
|
||
open(p,'w').write(s)
|
||
PY
|
||
sed -n '/^update:/,/^[a-z]/p' {V}/controller.yaml
|
||
systemctl restart felhom-controller-bootstrap.service
|
||
sleep 22
|
||
docker ps --filter name=felhom-controller --format '{{{{.Status}}}}'
|
||
"""
|
||
o = w.guest(scr, timeout=400)
|
||
w.say(f" [knob] {key}={value} :: " + " | ".join(x for x in o.split("\n") if x.strip())[:260])
|
||
w.login()
|
||
return o
|
||
|
||
|
||
def cut_during(app, want_phase, poll=0.2, cap_s=300):
|
||
"""Press Update, poll at 200 ms, and pull the plug the moment the wanted phase is observed."""
|
||
code, body = w.ctl("POST", f"/api/stacks/{app}/update")
|
||
w.say(f" [cut] Update -> {code} {str(body)[:120]}")
|
||
if code not in ("202", "200"):
|
||
return {"pressed": False, "http": code, "body": body}
|
||
seen, t0, decided = [], time.time(), None
|
||
while time.time() - t0 < cap_s:
|
||
st = w.stack(app)
|
||
ph = st.get("update_phase")
|
||
if not seen or seen[-1]["phase"] != ph:
|
||
seen.append({"t": round(time.time() - t0, 3), "phase": ph})
|
||
w.say(f" +{seen[-1]['t']:>7.3f}s phase={ph}")
|
||
if ph == want_phase:
|
||
decided = datetime.utcnow().isoformat(timespec="milliseconds") + "Z"
|
||
w.say(f" [cut] phase {want_phase!r} OBSERVED at {decided} — pulling the plug NOW")
|
||
t1 = time.time()
|
||
r = hostsh("pct stop 9202", timeout=180)
|
||
w.say(f" [cut] `pct stop 9202` returned after {round(time.time()-t1,2)}s :: {r.strip()[:120]}")
|
||
return {"pressed": True, "phases_seen": seen, "cut_decided_at": decided,
|
||
"pct_stop_returned_after_s": round(time.time() - t1, 2),
|
||
"instrument_limit": "the phase at the DECISION is observed; the phase at the "
|
||
"FREEZE is inferred — pct stop is not instantaneous"}
|
||
if not st.get("updating") and ph in ("done", "failed"):
|
||
w.say(f" [cut] the update ENDED at phase {ph} before {want_phase!r} was ever seen")
|
||
return {"pressed": True, "phases_seen": seen, "cut_decided_at": None,
|
||
"missed": want_phase, "ended_phase": ph}
|
||
time.sleep(poll)
|
||
return {"pressed": True, "phases_seen": seen, "cut_decided_at": None, "timeout": True}
|
||
|
||
|
||
def boot_and_read(app):
|
||
hostsh("pct start 9202", timeout=300)
|
||
w.say(" [boot] guest 9202 starting")
|
||
for _ in range(90):
|
||
time.sleep(5)
|
||
o = hostsh("pct exec 9202 -- docker ps --filter name=felhom-controller "
|
||
"--format '{{.Status}}' 2>/dev/null")
|
||
if "Up" in o:
|
||
w.say(f" [boot] controller back: {o.strip()}")
|
||
break
|
||
time.sleep(20)
|
||
w.login()
|
||
recovery = w.guest(
|
||
"docker logs felhom-controller 2>&1 | grep -iE 'recover|interrupted|journal|pin .*back|"
|
||
f"resum' | tail -20; echo '--- journal file:'; ls -la {V}/data/update-journal.json 2>&1")
|
||
w.say(" [boot] recovery lines: " + " | ".join(
|
||
x for x in recovery.split("\n") if x.strip())[:500])
|
||
st = w.stack(app)
|
||
return {"recovery_lines": recovery, "state": st.get("state"),
|
||
"update_phase": st.get("update_phase"), "update_error": st.get("update_error"),
|
||
"hold_reason": st.get("hold_reason"), "observables": w.observables(app)}
|
||
|
||
|
||
def backup_artefacts(app):
|
||
"""Is a half-written backup artefact left LOOKING WHOLE? The question the leg exists for."""
|
||
return w.guest(f"""
|
||
echo "=== the app's own recovery unit + its db dumps, with sizes and times"
|
||
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' -type f -printf '%TY-%Tm-%Td %TH:%TM %10s %p\\n' 2>/dev/null | sort | tail -25
|
||
echo "=== any temp/partial names left behind"
|
||
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' \\( -name '*.tmp' -o -name '*.part' -o -name '*partial*' -o -name '*.inprogress' \\) 2>/dev/null | head -10 || true
|
||
echo "=== the unit manifest, if there is one"
|
||
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' -name 'manifest*.json' -exec sh -c 'echo "--- {{}}"; head -c 700 "{{}}"; echo' \\; 2>/dev/null | head -40
|
||
echo "=== ZERO-BYTE files under this app's backups (a half-write that still looks like a file)"
|
||
find /mnt/sys_drive/felhom-data/backups -path '*{app}*' -type f -size 0 2>/dev/null | head -10 || echo "(none)"
|
||
""", timeout=420)
|
||
|
||
|
||
def run(app, sub, phase, label):
|
||
d = out(f"B5-{label}")
|
||
w.say(f"==== B5: a power cut during `{phase}` on {app}")
|
||
rec = {"leg": f"B5-{label}", "app": app, "phase_targeted": phase}
|
||
|
||
# B5 needs a PENDING edge or there is nothing for the cut to interrupt. B4 leaves these apps
|
||
# level with the catalog, so publish one here — the same image under the next tag, so the pull
|
||
# is instant and the cut lands in the EARLY phases this leg is about rather than in `pulling`.
|
||
repo = {"privatebin": "paste", "bentopdf": "pdf"}.get(app)
|
||
if repo:
|
||
cur = None
|
||
for line in open(f"{w.DRILL}/templates/{app}/docker-compose.yml"):
|
||
if line.strip().startswith("image:"):
|
||
cur = line.strip().split("image:", 1)[1].strip()
|
||
break
|
||
nxt = f"localhost:5000/drill/{repo}:2.0.2"
|
||
if cur and cur != nxt:
|
||
w.drill_bump(app, cur, nxt)
|
||
w.sync_rescan()
|
||
st = w.stack(app)
|
||
w.say(f" [0] pending edge for the cut: installed="
|
||
f"{ {k:(v.get('ref') if isinstance(v,dict) else v) for k,v in ((st.get('app_config') or {}).get('installed_images') or {}).items()} } "
|
||
f"catalog={st.get('catalog_images')}")
|
||
|
||
before = w.observables(app)
|
||
rec["observables_before"] = before
|
||
rec["backup_artefacts_before"] = backup_artefacts(app)
|
||
open(f"{d}/00-backups-before.txt", "w").write(rec["backup_artefacts_before"])
|
||
w.say(f" [0] pinned before = {before['pinned_images']}")
|
||
|
||
rec["cut"] = cut_during(app, phase)
|
||
if not rec["cut"].get("cut_decided_at"):
|
||
w.say(" [!] the phase was never observed — the cut did NOT happen. Recorded as a MISS, "
|
||
"not as a pass.")
|
||
json.dump(rec, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False)
|
||
open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n")
|
||
return
|
||
|
||
rec["after_boot"] = boot_and_read(app)
|
||
rec["backup_artefacts_after"] = backup_artefacts(app)
|
||
open(f"{d}/01-backups-after.txt", "w").write(rec["backup_artefacts_after"])
|
||
rec["sentences"] = sentences(app)
|
||
w.say(f" [2] household sentence HU: {rec['sentences']['hu'][:3]}")
|
||
w.say(f" [2] household sentence EN: {rec['sentences']['en'][:3]}")
|
||
|
||
fx = FIXTURES.get(app)
|
||
if fx:
|
||
tok = fx.seed(w, sub, w.say)
|
||
rec["app_usable_after"] = bool(tok) and fx.verify(w, sub, tok, w.say)
|
||
w.say(f" [3] the app works and holds data after the cut: {rec['app_usable_after']}")
|
||
|
||
a, b = rec["observables_before"], rec["after_boot"]["observables"]
|
||
rec["pin_moved"] = a["pinned_images"] != b["pinned_images"]
|
||
w.say(f" [4] pinned after = {b['pinned_images']} (moved: {rec['pin_moved']})")
|
||
json.dump(rec, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False)
|
||
open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
w.login()
|
||
cmd = sys.argv[1]
|
||
if cmd == "knob":
|
||
set_knob(sys.argv[2], sys.argv[3])
|
||
else:
|
||
run(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
|