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
219 lines
12 KiB
Python
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()
|