Files
felhom.eu/documentation/audits/update-night-2026-09-21/phase3_legs.py
T
admin 9c69b3ff07
gates / gates (push) Successful in 27s
Update night: Phases 2-4 evidence — both engines, the unattended HOLD, and five new findings
Evidence off the machine at the end of the phases that produced it (R-320). Teardown follows.

PHASE 2 — the two database engines, through the REAL Update button:
- MariaDB 11.6 -> 12.3 on nextcloud: PROVEN, and pressed through the button for the first time.
  All four SPIKE-r459 observables: the datadir's own record moved 11.6.2 -> 12.3.3; the engine
  itself says "already upgraded ... no need to run mariadb-upgrade again"; the entrypoint says
  "Major version upgrade detected ... Check required!" and then STARTED and FINISHED it (not the
  `skipped due to $MARIADB_AUTO_UPGRADE` line R-459 feared); and the engine took its own
  pre-upgrade backup, 631 905 B. The seeded Nextcloud account read back.
- PostgreSQL 16 -> 17 on docmost: FAILED exactly as R-463 predicted and nobody had measured.
  5.1 s to held; the pin named 17 while nothing ran; the restore brought it back in 29.1 s.
  The engine's REFUSAL LINE was destroyed by failAndHold before any probe could read it, so it
  was REPRODUCED INDEPENDENTLY with a control on every step (R-320).

PHASE 3 — the bad days. B1 produced THE UNATTENDED HOLD, which this project has never had: the
caller pressed once with nobody watching, the app held after 312.9 s, and passes 2 and 3 pressed
nothing. B2 put the pin back on a pull failure in 1.0 s. B3 refused `busy` six times. B4 showed
there is NO single-flight — 5 of 5 updates ran at once and all ended honest. B5 cut the power in
`backing-up` and the box recovered itself and said so. B7 refused under the 2 GB floor. B9 found
R-458's risk narrower than the row states.

PHASE 4 — every badge on the box is TRUE, and the held app answers all four of Q4's questions.

FINDINGS, five new and three corrections to existing rows. The one that matters: R-618 is P1 —
two templates name a health probe the app does not answer, and because the guarded update waits
on that same probe, a SUCCESSFUL update ends by STOPPING a working app. Measured: tandoor served
HTTP 200 on the new version at four samples across five minutes and was then stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 22:13:57 +02:00

247 lines
12 KiB
Python

#!/usr/bin/env python3
"""Phase 3 — the bad days, legs B2..B8. Usage: phase3_legs.py <leg>
Every leg records the SAME five things (the brief §6):
what the household saw (BOTH languages) · what the box did by itself · time to steady ·
which alarm or event fired and whether it was true · which should have fired and did not.
Nothing here is product code. Every act is a button a person can press.
"""
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
BAD = os.path.join(HERE, "bad-days")
def out(leg):
d = os.path.join(BAD, leg)
os.makedirs(d, exist_ok=True)
return d
def sentences(app):
"""The household's own sentences on the app page, both languages, ASCII-fragment matched
(R-96 rule 8: Hungarian is searched by ASCII fragment, with a positive and a negative control)."""
res = {}
for lang, sfx in (("hu", ""), ("en", "?lang=en")):
html = w.page(f"/apps/{app}{sfx}")
# Strip <script> and <style> FIRST. Without this the page's inline JS becomes "sentences"
# and the evidence is unreadable — measured on the nextcloud leg, fixed here.
html = re.sub(r"<script.*?</script>", " ", html, flags=re.S)
html = re.sub(r"<style.*?</style>", " ", html, flags=re.S)
txt = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html))
keep = [s.strip() for s in re.split(r"(?<=[.!?]) ", txt)
if any(k in s.lower() for k in
("friss", "update", "vissza", "restore", "ment", "backup",
"hib", "error", "megsz", "nem "))]
res[lang] = keep[:12]
return res
def events(n=40):
for p in ("/api/events?limit=%d" % n, "/api/debug/dump"):
code, d = w.ctl("GET", p)
if code == "200":
return {"path": p, "body": d}
return {"path": None, "body": None}
def snap(app, label):
st = w.stack(app)
return {"label": label, "at": datetime.now().isoformat(timespec="seconds"),
"state": st.get("state"), "updating": st.get("updating"),
"update_phase": st.get("update_phase"), "update_error": st.get("update_error"),
"hold_reason": st.get("hold_reason"), "observables": w.observables(app)}
# ---------------------------------------------------------------------------- B2
def b2():
"""B2 — the new tag cannot be pulled. Phase `pulling` fails; §6.1 says the pin AND the
definition are PUT BACK and nothing ran."""
APP, SUB = "bentopdf", "pdf"
GOOD, GONE = "localhost:5000/drill/pdf:1.0.0", "localhost:5000/drill/pdf:1.0.1"
d = out("B2-pull-fails")
w.say("==== B2: the new tag cannot be pulled")
st = w.stack(APP)
if st.get("deployed"):
w.remove(APP)
for prev in ("ghcr.io/alam00000/bentopdf:v2.8.6", GONE, "ghcr.io/alam00000/bentopdf:v2.8.8"):
if w.drill_bump(APP, prev, GOOD) is not None:
break
w.sync_rescan()
if not w.deploy(APP, SUB):
w.say(" bentopdf never came up on the drill image — B2 cannot run")
return
w.backup_now(APP)
before = snap(APP, "before")
w.drill_bump(APP, GOOD, GONE)
w.sync_rescan()
bdg = w.badges(APP)
w.say(f" badge HU {bdg['hu']}")
t0 = time.time()
res = w.press_update(APP)
steady = round(time.time() - t0, 1)
after = snap(APP, "after")
# is the OLD version still serving? the household's own door
rc, code, _ = w.app_curl(SUB, "/", timeout=20)
serving = code in ("200", "302")
# what does an unattended caller see on the wire?
code2, ref = w.ctl("POST", f"/api/stacks/{APP}/update")
w.say(f" second press (to read the refusal on the wire): http={code2} {json.dumps(ref, ensure_ascii=False)[:300]}")
rec = {"leg": "B2", "app": APP, "edge": f"{GOOD} -> {GONE} (tag absent from the store)",
"badges": bdg, "phases": res, "before": before, "after": after,
"old_version_still_serving": serving, "time_to_steady_s": steady,
"sentences": sentences(APP), "second_press": {"http": code2, "body": ref},
"events": events()}
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")
w.say(f" phases={[p['phase'] for p in res['phases']]} error={after['update_error']!r} "
f"hold={after['hold_reason']!r} old_serving={serving}")
w.say(f" pinned after = {after['observables']['pinned_images']}")
w.say(f" compose after= {after['observables']['live_compose_image_lines']}")
# ---------------------------------------------------------------------------- B3
def b3(app="bentopdf"):
"""B3 — Update pressed WHILE a backup runs. Expect reason `busy`, then a later pass gets in."""
d = out("B3-busy-during-backup")
w.say("==== B3: Update pressed while a backup is running")
code, msg = w.ctl("POST", "/api/backup/run")
w.say(f" „Mentés most\" -> {code} {str(msg)[:120]}")
time.sleep(1)
tries = []
for i in range(6):
c, b = w.ctl("POST", f"/api/stacks/{app}/update")
reason = (b.get("data") or {}).get("reason") if isinstance(b, dict) else None
tries.append({"i": i, "http": c, "reason": reason,
"error": b.get("error") if isinstance(b, dict) else None})
w.say(f" press {i}: http={c} reason={reason!r} :: "
f"{(b.get('error') if isinstance(b,dict) else '') or ''}")
if c != "409":
break
time.sleep(3)
# then wait the backup out and press once more — the caller's "retry next pass"
for _ in range(90):
time.sleep(5)
_, s = w.ctl("GET", "/api/backup/status")
if not (s.get("data") or {}).get("running", False):
break
c, b = w.ctl("POST", f"/api/stacks/{app}/update")
after_reason = (b.get("data") or {}).get("reason") if isinstance(b, dict) else None
w.say(f" after the backup finished: http={c} reason={after_reason!r} "
f":: {(b.get('error') if isinstance(b,dict) else '') or ''}")
# If it was accepted, FOLLOW it to the end. Leaving an update in flight would make the next
# leg's first press read `updating` and measure this leg by accident.
ran = None
if c in ("200", "202"):
t0 = time.time()
while time.time() - t0 < 1200:
st = w.stack(app)
if not st.get("updating") and st.get("update_phase") in ("done", "failed"):
ran = {"final_phase": st.get("update_phase"),
"update_error": st.get("update_error"),
"hold_reason": st.get("hold_reason"),
"seconds": round(time.time() - t0, 1),
"observables": w.observables(app)}
break
time.sleep(2)
w.say(f" the update that got through ended: {ran}")
rec = {"leg": "B3", "app": app, "during_backup": tries,
"after_backup": {"http": c, "reason": after_reason, "body": b},
"the_update_that_got_through": ran,
"sentences": sentences(app)}
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")
# ---------------------------------------------------------------------------- B4
def b4(apps):
"""B4 — two Updates within one second, then five. Single-flight, or do they run together?"""
d = out("B4-concurrent-updates")
w.say(f"==== B4: concurrent Updates on {apps}")
import concurrent.futures as cf
results = {}
with cf.ThreadPoolExecutor(max_workers=len(apps)) as ex:
fut = {ex.submit(w.ctl, "POST", f"/api/stacks/{a}/update"): a for a in apps}
for f in cf.as_completed(fut):
a = fut[f]
c, b = f.result()
results[a] = {"http": c, "reason": (b.get("data") or {}).get("reason")
if isinstance(b, dict) else None, "body": b}
w.say(f" {a}: http={c} reason={results[a]['reason']!r}")
mem = w.guest("free -m | head -2; docker stats --no-stream --format "
"'{{.Name}} {{.MemUsage}}' | head -20")
w.say(" memory during: " + " | ".join(mem.split("\n")[:3]))
states = {}
t0 = time.time()
while time.time() - t0 < 1200:
states = {a: w.stack(a) for a in apps}
if all(not s.get("updating") for s in states.values()):
break
time.sleep(3)
fin = {a: {"state": s.get("state"), "update_phase": s.get("update_phase"),
"update_error": s.get("update_error"), "hold_reason": s.get("hold_reason"),
"observables": w.observables(a)} for a, s in states.items()}
for a, v in fin.items():
w.say(f" {a} ended phase={v['update_phase']} err={v['update_error']!r} hold={v['hold_reason']!r}")
json.dump({"leg": "B4", "apps": apps, "presses": results, "memory_during": mem,
"final": fin, "elapsed_s": round(time.time() - t0, 1)},
open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False)
open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n")
# ---------------------------------------------------------------------------- B7
def b7(app="bentopdf"):
"""B7 — the 2 GB disk floor and the memory refusal. Both refuse BEFORE anything is recorded
(§6.1 phase 0), so nothing moves — which is what makes them safe to probe at all."""
d = out("B7-disk-and-memory-refusals")
w.say("==== B7: the disk floor and the memory refusal")
free_before = w.guest("df -h /var/lib/felhom | tail -1; df -B1 --output=avail /var/lib/felhom | tail -1")
w.say(f" free before: {' '.join(free_before.split())}")
# eat the space down under the 2 GB floor with ONE file, on the scratch guest only
fill = w.guest("""
AVAIL=$(df -B1 --output=avail /var/lib/felhom | tail -1 | tr -d ' ')
KEEP=1500000000 # leave ~1.5 GB, i.e. UNDER the fixed 2 GB floor
EAT=$((AVAIL - KEEP))
echo "avail=$AVAIL eat=$EAT"
if [ "$EAT" -gt 0 ]; then fallocate -l "$EAT" /var/lib/felhom/DRILL-FILL.bin && echo filled; fi
df -h /var/lib/felhom | tail -1
""", timeout=600)
w.say(" " + " | ".join(x for x in fill.split("\n") if x.strip()))
c, b = w.ctl("POST", f"/api/stacks/{app}/update")
reason = (b.get("data") or {}).get("reason") if isinstance(b, dict) else None
w.say(f" Update under the floor: http={c} reason={reason!r} :: "
f"{(b.get('error') if isinstance(b,dict) else '') or ''}")
snap_low = snap(app, "under-the-floor")
sent = sentences(app)
# english refusal too — the 409 body is localised by errText since v0.260.0
c_en, b_en = w.ctl("POST", f"/api/stacks/{app}/update?lang=en")
w.say(f" refusal EN: http={c_en} :: {(b_en.get('error') if isinstance(b_en,dict) else '') or ''}")
freed = w.guest("rm -f /var/lib/felhom/DRILL-FILL.bin; sync; df -h /var/lib/felhom | tail -1")
w.say(f" freed: {' '.join(freed.split())}")
json.dump({"leg": "B7", "app": app, "free_before": free_before, "fill": fill,
"refusal_hu": {"http": c, "reason": reason, "body": b},
"refusal_en": {"http": c_en, "body": b_en},
"state_under_floor": snap_low, "sentences": sent, "freed": freed},
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()
leg = sys.argv[1]
if leg == "b2":
b2()
elif leg == "b3":
b3(*sys.argv[2:])
elif leg == "b4":
b4(sys.argv[2].split(","))
elif leg == "b7":
b7(*sys.argv[2:])
else:
sys.exit(f"unknown leg {leg}")