9c69b3ff07
gates / gates (push) Successful in 27s
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
167 lines
7.3 KiB
Python
167 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""run_edge.py <app> <from-ref> <to-ref> [--sub X] [--keep] [--no-remove]
|
|
|
|
One app's full walk on guest 9202. Writes everything under
|
|
`documentation/audits/update-night-2026-09-21/apps/<app>/`:
|
|
|
|
log.txt every line this run printed, as it printed it
|
|
badges.json the „Frissítés elérhető" badge in BOTH languages, before and after
|
|
phases.json every update phase with its timestamp
|
|
observables.json the four version observables side by side
|
|
verdict.json `09`'s verdict-record shape
|
|
|
|
`inconclusive` is never collapsed into `failed`.
|
|
"""
|
|
import argparse, json, os, sys, time
|
|
from datetime import datetime, timezone
|
|
|
|
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
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("app")
|
|
ap.add_argument("frm")
|
|
ap.add_argument("to")
|
|
ap.add_argument("--sub", default=None)
|
|
ap.add_argument("--no-remove", action="store_true")
|
|
ap.add_argument("--class", dest="cls", default="other")
|
|
a = ap.parse_args()
|
|
|
|
app = a.app
|
|
appdir = os.path.join(HERE, "apps", app)
|
|
os.makedirs(appdir, exist_ok=True)
|
|
fx = FIXTURES.get(app)
|
|
sub = a.sub or (fx.sub if fx else app)
|
|
|
|
t_start = time.time()
|
|
w.say(f"==== {app}: {a.frm} -> {a.to} (sub={sub}, class={a.cls})")
|
|
w.login()
|
|
|
|
rec = {"harness_version": 1, "app": app, "venue": "guest 9202 demo-hp-scratch, controller 0.261.0",
|
|
"class": a.cls, "from": {}, "to": {}, "verdict": "inconclusive",
|
|
"seed_read_before": False, "seed_read_after": False, "healthy_after": False,
|
|
"migration_observed": None, "abort": "not-attempted", "abort_detail": None,
|
|
"duration_s": 0, "measured_at": datetime.now(timezone.utc).isoformat(),
|
|
"evidence": f"apps/{app}/", "notes": []}
|
|
|
|
def bail(why, verdict="inconclusive"):
|
|
rec["verdict"] = verdict
|
|
rec["notes"].append(why)
|
|
rec["duration_s"] = round(time.time() - t_start, 1)
|
|
finish(rec, appdir)
|
|
sys.exit(0 if verdict != "failed" else 0)
|
|
|
|
# ---- 1 deploy at the LIVE pin -------------------------------------------------------
|
|
if not w.deploy(app, sub):
|
|
bail("deploy never reached running — nothing else could be measured")
|
|
pre = w.observables(app)
|
|
rec["from"] = pre["pinned_images"] or {}
|
|
json.dump(pre, open(f"{appdir}/observables-before.json", "w"), indent=2, ensure_ascii=False)
|
|
|
|
# ---- 2+3 seed and read it back (control C1) ------------------------------------------
|
|
if fx is None:
|
|
rec["notes"].append("no fixture: no non-browser seed route was written for this app tonight")
|
|
bail("no fixture — recorded inconclusive rather than faked (R-156)")
|
|
w.say(" [2] seeding through the app's own front door")
|
|
tok = fx.seed(w, sub, w.say)
|
|
if tok is None:
|
|
rec["notes"].append("seed refused through the app's own route — see log.txt for what was tried")
|
|
bail("seed route did not work tonight")
|
|
w.say(" [3] control C1 — reading the seed back BEFORE the update")
|
|
if not fx.verify(w, sub, tok, w.say):
|
|
rec["notes"].append("C1 FAILED: the fixture could not prove itself before the update, "
|
|
"so it can prove nothing after")
|
|
bail("C1 failed — a fixture that cannot prove itself first proves nothing after")
|
|
rec["seed_read_before"] = True
|
|
|
|
# ---- 4 „Mentés most" -----------------------------------------------------------------
|
|
w.backup_now(app)
|
|
|
|
# ---- 5 the drill bump, sync, rescan, badge -------------------------------------------
|
|
b_before = w.badges(app)
|
|
h = w.drill_bump(app, a.frm, a.to)
|
|
if h is None:
|
|
bail("the drill bump could not be committed — the FROM ref did not match the template")
|
|
waited = w.sync_rescan(expect_app=app, expect_ref=a.to.split(",")[0].strip())
|
|
rec["badge_catchup_seconds"] = waited
|
|
b_after = w.badges(app)
|
|
json.dump({"before": b_before, "after": b_after, "drill_commit": h},
|
|
open(f"{appdir}/badges.json", "w"), indent=2, ensure_ascii=False)
|
|
w.say(f" [5] badge HU: {b_after['hu']}")
|
|
w.say(f" [5] badge EN: {b_after['en']}")
|
|
st = w.stack(app)
|
|
rec["to"] = st.get("catalog_images") or {}
|
|
|
|
# ---- 6 the guarded Update ------------------------------------------------------------
|
|
res = w.press_update(app)
|
|
json.dump(res, open(f"{appdir}/phases.json", "w"), indent=2, ensure_ascii=False)
|
|
rec["duration_s"] = res["duration_s"]
|
|
if not res["accepted"]:
|
|
rec["notes"].append(f"the Update was REFUSED before anything moved: {json.dumps(res['refusal'], ensure_ascii=False)[:400]}")
|
|
bail("refused at the preflight — nothing moved")
|
|
|
|
# ---- 7 read the seed back ------------------------------------------------------------
|
|
w.say(" [7] reading the seed back AFTER the update")
|
|
after_ok = fx.verify(w, sub, tok, w.say)
|
|
rec["seed_read_after"] = after_ok
|
|
|
|
# ---- migration line, quoted verbatim, never inferred from timing ---------------------
|
|
logs = w.app_logs(app, 500)
|
|
open(f"{appdir}/app-logs-after.txt", "w").write(logs)
|
|
for pat in ("migrat", "Migrat", "MIGRAT", "upgrade", "Upgrade", "schema"):
|
|
for line in logs.splitlines():
|
|
if pat in line and len(line) < 400:
|
|
rec["migration_observed"] = line.strip()
|
|
break
|
|
if rec["migration_observed"]:
|
|
break
|
|
|
|
# ---- 8 the four observables ----------------------------------------------------------
|
|
post = w.observables(app)
|
|
json.dump({"before": pre, "after": post},
|
|
open(f"{appdir}/observables.json", "w"), indent=2, ensure_ascii=False)
|
|
w.say(f" [8] pinned = {post['pinned_images']}")
|
|
w.say(f" [8] installed = {post['installed_images']}")
|
|
w.say(f" [8] compose = {post['live_compose_image_lines']}")
|
|
w.say(f" [8] inspect = {post['docker_inspect']}")
|
|
rec["observables_after"] = post
|
|
|
|
st = w.stack(app)
|
|
rec["healthy_after"] = (st.get("state") == "running" and not st.get("hold_reason"))
|
|
rec["final_phase"] = res["final_phase"]
|
|
rec["hold_reason"] = res.get("hold_reason")
|
|
rec["update_error"] = res.get("update_error")
|
|
|
|
# ---- 9 the verdict -------------------------------------------------------------------
|
|
moved = post["pinned_images"] != pre["pinned_images"]
|
|
if res["final_phase"] == "done" and after_ok and rec["healthy_after"] and moved:
|
|
rec["verdict"] = "proven"
|
|
elif res.get("hold_reason") or res["final_phase"] == "failed":
|
|
rec["verdict"] = "failed"
|
|
rec["notes"].append("the edge ended HELD or failed — this is a RESULT, not an error of the run")
|
|
elif not after_ok:
|
|
rec["verdict"] = "failed"
|
|
rec["notes"].append("the app came up but the seeded data did not read back")
|
|
else:
|
|
rec["verdict"] = "inconclusive"
|
|
rec["notes"].append(f"final_phase={res['final_phase']} moved={moved} healthy={rec['healthy_after']}")
|
|
|
|
finish(rec, appdir)
|
|
|
|
if not a.no_remove and rec["verdict"] != "failed":
|
|
w.remove(app)
|
|
|
|
|
|
def finish(rec, appdir):
|
|
rec["duration_s"] = rec.get("duration_s", 0)
|
|
w.write_verdict(rec, appdir)
|
|
open(f"{appdir}/log.txt", "w").write("\n".join(w.LOG) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|