upgrade-test.py: record the engine's own view of its datadir, beside the verdict
gates / gates (push) Successful in 1s

R-459. The harness returned proven for E3b while MariaDB was logging that the
conversion it requires had been skipped. The verdict was right - the app's data
survived, which is what it asked - but the harness watched the app and the
migration log, and neither looks at engine state.

engine_state_after now carries each database service's own answer: MariaDB's
datadir version plus 'mariadb-upgrade --check-if-upgrade-is-needed', and
PostgreSQL's PG_VERSION.

It sits BESIDE the verdict and is never folded into it. An unconverted datadir is
not known to be a failure - 5 of 5 restarts showed no degradation - so a verdict
that called it failed would encode an unproven judgement, which is worse than
reporting a fact and letting a person read both.

No template changed. Nothing with MARIADB_ in it is committed by this work: that
is a fleet-wide decision the operator owns, and it affects four apps.
This commit is contained in:
2026-09-06 17:38:50 +02:00
parent 0474ce387e
commit b7ef0c4a09
2 changed files with 82 additions and 0 deletions
+27
View File
@@ -1,3 +1,30 @@
## upgrade-test.py records the ENGINE's own view of itself (2026-09-06, R-459) — NOT A RELEASE
**Scripts only. No template changed, and deliberately so** — nothing with `MARIADB_` in it is
committed by this work; that is a fleet-wide decision the operator owns.
The harness returned **`proven`** for edge E3b while MariaDB was logging that the datadir conversion it
requires had been **skipped**. The verdict was not wrong — the app's data did survive, which is what it
asked — but nothing here could see that the engine had been left in a state the engine itself calls
incomplete. It watched the app and the migration log; **neither looks at engine state.**
`engine_state_after` now carries, per database service, the engine's own answer. On the E3b re-run:
```
verdict: proven
engine_state_after.bookstack-db.answer:
"11.6.2-MariaDB| Major version upgrade detected from 11.6.2-MariaDB to 12.3.3-MariaDB. Check required! [exit=0]"
```
**It is reported BESIDE the verdict and never folded into it.** An unconverted datadir is not known to
be a failure — `SPIKE-r459-mariadb-upgrade-2026-09-06.md` measured 5 of 5 restarts with no degradation
— so a verdict that called it `failed` would encode an unproven judgement, which is worse than
reporting a fact and letting a person read both.
**Both engines are covered and they fail differently:** MariaDB starts anyway and skips the conversion
quietly; PostgreSQL refuses to start on a datadir from an older major. "The engine would not start" is
as much an engine-state observation as "the engine says it needs a check".
## upgrade-test.py — a harness that measures whether an upgrade keeps the data (2026-09-06, R-449) — NOT A RELEASE
**No template changed. No image moved. Scripts only.** `scripts/upgrade-test.py` +
+55
View File
@@ -156,6 +156,53 @@ def settle(project: str, workdir: Path, wait=420):
return False, round(time.time() - t0, 1), states
# --- the ENGINE's own view of itself (R-459) ------------------------------------------------------
#
# WHY THIS EXISTS. On edge E3b this harness returned `proven` — correctly: the app's data survived,
# which is what it asked. But MariaDB was at that moment logging that the datadir conversion it
# requires had been SKIPPED, and nothing here could see it. The harness watched the app and the
# migration log; neither looks at engine state.
#
# IT IS REPORTED BESIDE THE VERDICT, NEVER FOLDED INTO IT. An unconverted datadir is not known to be
# a failure — SPIKE-r459-mariadb-upgrade-2026-09-06 measured 5 of 5 restarts with no degradation — so
# a verdict that called it `failed` would encode an unproven judgement, which is worse than reporting
# a fact and letting a person read both.
#
# The two engines fail differently and the field carries both: MariaDB starts anyway and skips the
# conversion quietly; PostgreSQL REFUSES to start on a datadir from an older major. So "the engine
# would not start" is as much an engine-state observation as "the engine says it needs a check".
ENGINE_PROBES = {
# image-name fragment -> (probe command inside the container, what the answer means)
"mariadb": (
"cat /var/lib/mysql/mariadb_upgrade_info 2>&1; echo '|'; "
"mariadb-upgrade --check-if-upgrade-is-needed --user=root "
"--password=$MYSQL_ROOT_PASSWORD 2>&1; echo \"[exit=$?]\"",
"datadir version | the engine's own upgrade verdict",
),
"postgres": (
"cat /var/lib/postgresql/data/PG_VERSION 2>&1", "datadir major version",
),
}
def engine_state(images: dict):
"""Ask every database engine in this stack what it thinks of its own datadir.
Best-effort and never fatal: a probe that cannot run records why, because "we could not ask" and
"the engine is content" are different facts and only one of them is about the engine.
"""
out = {}
for svc, ref in images.items():
for frag, (cmd, meaning) in ENGINE_PROBES.items():
if frag not in ref:
continue
r = cvp._sh(["docker", "exec", svc, "sh", "-c", cmd], timeout=120)
out[svc] = {"image": ref, "probe": meaning,
"answer": " ".join(((r.stdout or "") + (r.stderr or "")).split())[:600],
"probe_rc": r.returncode}
return out
MIGRATION_RE = re.compile(
r"migrat|upgrad|schema|alter table|CREATE TABLE|InnoDB: Upgrad|mysql_upgrade|"
r"mariadb-upgrade|Running .* migration|Applying|db:migrate",
@@ -189,6 +236,8 @@ def run_edge(edge_id: str) -> dict:
"from": e["frm"], "to": e["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 is a REPORT, not a judgement — see ENGINE_PROBES.
"engine_state_after": None,
"duration_s": 0, "measured_at": None, "evidence": f"evidence/{edge_id}"}
t0 = time.time()
log = []
@@ -254,6 +303,12 @@ def run_edge(edge_id: str) -> dict:
full_to = compose(workdir, project, "logs", "--no-color", timeout=180)
(ev / "to-full.log").write_text((full_to.stdout + full_to.stderr)[-400000:])
rec["engine_state_after"] = engine_state(e["to"]) or None
if rec["engine_state_after"]:
for svc, st in rec["engine_state_after"].items():
say(f"engine state {svc}: {st['answer'][:180]}")
(ev / "engine-state.json").write_text(json.dumps(rec["engine_state_after"], indent=2))
mig = migration_lines(project, workdir, swap_at)
rec["migration_observed"] = mig[0] if mig else None
(ev / "migration-lines.txt").write_text("\n".join(mig))