Files
felhom.eu/documentation/audits/update-night-2026-09-21/engine_edge.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

219 lines
12 KiB
Python

#!/usr/bin/env python3
"""engine_edge.py — Phase 2: a DATABASE ENGINE across a major, through the real Update button.
engine_edge.py nextcloud mariadb:11.6 mariadb:12.3 --engine mariadb --container nextcloud-db
engine_edge.py docmost postgres:16-alpine postgres:17-alpine --engine postgres --container docmost-postgres
The app image does NOT move. Only the engine sidecar. `09` §3b Q3: the within-a-major test is per
SERVICE, so this edge is ACROSS whatever the app's own number says — and it carries exactly one
migration, which is the whole reason the arc's standing rule gives an engine change its own edge.
THE TWO ENGINES FAIL IN OPPOSITE DIRECTIONS, so one check will not do (`09` §"Database engines"):
MariaDB starts anyway and can skip the conversion QUIETLY -> ASK THE ENGINE, not the log
PostgreSQL REFUSES to start on an older major's datadir -> the refusal line, verbatim
`engine_state_after` is reported BESIDE the verdict, never inside it: an unconverted datadir is not
*known* to be a failure and a verdict that said so would encode an unproven judgement.
"""
import argparse, json, os, re, 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 mariadb_observables(container, tag):
"""The FOUR observables of SPIKE-r459-mariadb-upgrade-2026-09-06, asked of the ENGINE."""
return w.guest(f"""
echo "=== [1] mariadb_upgrade_info (the datadir's own record of which version converted it)"
docker exec {container} sh -c 'cat /var/lib/mysql/mariadb_upgrade_info 2>/dev/null || echo "(absent)"' 2>&1
echo "=== [2] the engine's OWN check — NOT the log line (R-464)"
docker exec {container} sh -c 'mariadb-upgrade --check-if-upgrade-is-needed --user=root --password="$MARIADB_ROOT_PASSWORD$MYSQL_ROOT_PASSWORD" 2>&1; echo "exit=$?"' 2>&1
echo "=== [3] the entrypoint's line, from the container log"
docker logs {container} 2>&1 | grep -iE 'upgrade|MARIADB_AUTO_UPGRADE|skipped' | tail -12
echo "=== [4] the engine's own pre-upgrade backup file"
docker exec {container} sh -c 'ls -la /var/lib/mysql/*upgrade*backup* /var/lib/mysql/system_mysql_backup*.sql* 2>/dev/null || echo "(no pre-upgrade backup file present)"' 2>&1
echo "=== [5] the version actually running"
docker exec {container} sh -c 'mariadbd --version 2>/dev/null || mysqld --version 2>/dev/null' 2>&1
echo "=== [6] container state"
docker inspect {container} --format 'image={{{{.Config.Image}}}} running={{{{.State.Running}}}} restarts={{{{.RestartCount}}}} exit={{{{.State.ExitCode}}}}' 2>&1
""", timeout=420)
def postgres_observables(container, tag):
return w.guest(f"""
echo "=== [1] PG_VERSION — the datadir's own record of its major"
docker exec {container} sh -c 'cat /var/lib/postgresql/data/PG_VERSION 2>/dev/null || echo "(exec refused: the container is not running)"' 2>&1
docker run --rm -v $(docker inspect {container} --format '{{{{range .Mounts}}}}{{{{if eq .Destination "/var/lib/postgresql/data"}}}}{{{{.Name}}}}{{{{end}}}}{{{{end}}}}'):/d alpine:3.20 sh -c 'cat /d/PG_VERSION 2>/dev/null || cat /d/pgdata/PG_VERSION 2>/dev/null || echo "(no PG_VERSION found)"' 2>&1
echo "=== [2] the engine's REFUSAL, verbatim (the whole point of this leg)"
docker logs {container} 2>&1 | tail -25
echo "=== [3] container state"
docker inspect {container} --format 'image={{{{.Config.Image}}}} running={{{{.State.Running}}}} restarts={{{{.RestartCount}}}} exit={{{{.State.ExitCode}}}}' 2>&1
echo "=== [4] the version binary in the image"
docker exec {container} sh -c 'postgres --version' 2>&1 || echo "(cannot exec — not running)"
""", timeout=420)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("app")
ap.add_argument("frm")
ap.add_argument("to")
ap.add_argument("--engine", required=True, choices=["mariadb", "postgres"])
ap.add_argument("--container", required=True)
ap.add_argument("--sub", default=None)
ap.add_argument("--restore-after", action="store_true",
help="when the edge ends held or the data does not read back, walk\n the household's own way out: the restore the sentence names")
a = ap.parse_args()
app = a.app
appdir = os.path.join(HERE, "apps", f"{app}-engine-{a.engine}")
os.makedirs(appdir, exist_ok=True)
fx = FIXTURES.get(app)
sub = a.sub or (fx.sub if fx else app)
t0 = time.time()
w.say(f"==== ENGINE EDGE {app}: {a.frm} -> {a.to} (app image does NOT move)")
w.login()
rec = {"harness_version": 1, "app": app, "leg": f"engine-major-{a.engine}",
"venue": "guest 9202 demo-hp-scratch, controller 0.261.0",
"from": {a.container: a.frm}, "to": {a.container: a.to},
"verdict": "inconclusive", "seed_read_before": False, "seed_read_after": False,
"healthy_after": False, "migration_observed": None,
"abort": "not-attempted", "abort_detail": None,
"engine_state_before": None, "engine_state_after": None,
"duration_s": 0, "measured_at": datetime.now(timezone.utc).isoformat(),
"evidence": f"apps/{app}-engine-{a.engine}/", "notes": []}
def save():
rec["duration_s"] = round(time.time() - t0, 1)
json.dump(rec, open(f"{appdir}/verdict.json", "w"), indent=2, ensure_ascii=False)
open(f"{appdir}/log.txt", "w").write("\n".join(w.LOG) + "\n")
w.say(f" verdict {rec['verdict']} -> {appdir}/verdict.json")
obs = mariadb_observables if a.engine == "mariadb" else postgres_observables
# ---- deploy + seed + C1 ----------------------------------------------------------------
if not w.deploy(app, sub):
rec["notes"].append("deploy never reached running")
return save()
if fx is None:
rec["notes"].append("no fixture for this app — recorded inconclusive rather than faked")
return save()
w.say(" [2] seeding through the app's own route")
tok = fx.seed(w, sub, w.say)
if tok is None:
rec["notes"].append("seed refused — see log.txt for what was tried")
return save()
w.say(" [3] control C1 — reading the seed back BEFORE the engine moves")
if not fx.verify(w, sub, tok, w.say):
rec["notes"].append("C1 FAILED — a fixture that cannot prove itself first proves nothing after")
return save()
rec["seed_read_before"] = True
before = obs(a.container, a.frm)
open(f"{appdir}/engine-before.txt", "w").write(before)
rec["engine_state_before"] = before[:4000]
w.say(" [3b] engine observables BEFORE written to engine-before.txt")
# ---- backup, then the engine bump --------------------------------------------------------
w.backup_now(app)
h = w.drill_bump(app, a.frm, a.to)
if h is None:
rec["notes"].append("the engine bump could not be committed")
return save()
rec["badge_catchup_seconds"] = w.sync_rescan(expect_app=app, expect_ref=a.to)
b = w.badges(app)
json.dump(b, open(f"{appdir}/badges.json", "w"), indent=2, ensure_ascii=False)
w.say(f" [5] badge HU: {b['hu']}")
w.say(f" [5] badge EN: {b['en']}")
# ---- the real Update button ---------------------------------------------------------------
res = w.press_update(app)
json.dump(res, open(f"{appdir}/phases.json", "w"), indent=2, ensure_ascii=False)
rec["phases"] = res.get("phases")
rec["final_phase"] = res.get("final_phase")
rec["hold_reason"] = res.get("hold_reason")
rec["update_error"] = res.get("update_error")
if not res["accepted"]:
rec["notes"].append(f"REFUSED before anything moved: {json.dumps(res['refusal'], ensure_ascii=False)[:400]}")
return save()
after = obs(a.container, a.to)
open(f"{appdir}/engine-after.txt", "w").write(after)
rec["engine_state_after"] = after[:6000]
w.say(" [6b] engine observables AFTER written to engine-after.txt")
# the hold sentence as the HOUSEHOLD reads it, both languages
holds = {}
for lang, sfx in (("hu", ""), ("en", "?lang=en")):
html = w.page(f"/apps/{app}{sfx}")
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"<[^>]+>", " ", txt if False else 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"))]
holds[lang] = keep[:14]
json.dump(holds, open(f"{appdir}/hold-sentences.json", "w"), indent=2, ensure_ascii=False)
rec["hold_sentences"] = holds
w.say(f" [6c] hold sentences HU: {holds['hu'][:3]}")
w.say(f" [6c] hold sentences EN: {holds['en'][:3]}")
# ---- read the seed back -------------------------------------------------------------------
w.say(" [7] reading the seed back AFTER the engine moved")
rec["seed_read_after"] = fx.verify(w, sub, tok, w.say)
logs = w.app_logs(app, 600)
open(f"{appdir}/app-logs-after.txt", "w").write(logs)
post = w.observables(app)
json.dump(post, open(f"{appdir}/observables-after.json", "w"), indent=2, ensure_ascii=False)
rec["observables_after"] = post
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']}")
# ---- the household's WAY OUT, when the edge ended badly -------------------------------
# `09` §4: after a migration has run the only shape available is RESTORE FROM A COPY, and
# §6.1 says a successful unit restore lifts an update hold. The sentence on the page tells
# the household to press that button; this presses it and then asks the APP whether the data
# came back — never the filesystem.
st = w.stack(app)
if a.restore_after and (st.get("hold_reason") or res["final_phase"] == "failed"
or not rec["seed_read_after"]):
w.say(" [W] the edge ended badly — walking the household's way out (restore)")
rec["way_out"] = w.restore(app)
rec["seed_read_after_restore"] = fx.verify(w, sub, tok, w.say)
w.say(f" [W] the seed read back after the restore: {rec['seed_read_after_restore']}")
rec["engine_state_after_restore"] = obs(a.container, a.frm)
open(f"{appdir}/engine-after-restore.txt", "w").write(rec["engine_state_after_restore"])
rec["hold_sentences_after_restore"] = {}
for lang, sfx in (("hu", ""), ("en", "?lang=en")):
html = w.page(f"/apps/{app}{sfx}")
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))
rec["hold_sentences_after_restore"][lang] = [
s.strip() for s in re.split(r"(?<=[.!?]) ", txt)
if any(k in s.lower() for k in ("friss", "update", "vissza", "restore"))][:8]
st = w.stack(app)
rec["healthy_after"] = (st.get("state") == "running" and not st.get("hold_reason"))
if res["final_phase"] == "done" and rec["seed_read_after"] and rec["healthy_after"]:
rec["verdict"] = "proven"
elif res.get("hold_reason") or res["final_phase"] == "failed":
rec["verdict"] = "failed"
rec["notes"].append("ended HELD or failed — a RESULT, not an error of the run")
else:
rec["verdict"] = "inconclusive"
save()
if __name__ == "__main__":
main()