harness v4 converts a PostgreSQL major; the engine-major gate passes one proven, marked app (09 decision 35)
gates / gates (push) Successful in 2s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-25 13:04:01 +02:00
parent f238a24cd9
commit 6a4a5f0cf7
7 changed files with 364 additions and 9 deletions
+15
View File
@@ -1,3 +1,18 @@
## The harness converts a PostgreSQL major; the gate lets ONE proven app through (2026-09-25 evening, Part C)
`09` §3 decision 35 (docmost first). **No template moved in this commit.**
- `upgrade-test.py` harness **v4**: a step that moves a PostgreSQL major is CONVERTED on the bench the box's way
(old engine alone → check → `pg_dumpall` with its completion line → volume emptied → new engine alone → the
entrypoint's empty databases dropped, existing roles' `CREATE ROLE` skipped → load with `ON_ERROR_STOP` → check
again + `PG_VERSION`). `render` points a PostgreSQL 18+ data volume at `/var/lib/postgresql` (18 refuses even an
empty volume at `/var/lib/postgresql/data`). The engine probe reads `$PGDATA/PG_VERSION`.
- `--write-ladder` writes `engine_conversion {service, engine, from, to}` only when the bench converted it AND the
box converted it through the product; it moves the data mount line with the image.
- `check-engine-major.py`: a PostgreSQL major passes only with its template's proven, two-venue, marked ladder entry
for that step, as the only image move in its commit. The postgis family is judged now (it was not). Decoys: one
genuine, three look-alikes (no mark, one venue, the mark in a comment), one bundled, postgis — red-proofed.
- `ladder.py` validates the mark; `test_pg_conversion.py` covers the harness pieces that run without Docker.
## Rules: Peti's box retired (2026-09-25)
The unprompted-work rule's fence names DooPlex and ep0 only (operator ruling: Peti's box retired). No template changed.
+11 -1
View File
@@ -105,7 +105,17 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
**What is NOT lifted.** The **eleven PostgreSQL** services stay refused: the image performs no
`pg_upgrade` and REFUSES to start on an older major's datadir (R-463). A backup is a route back,
not a conversion — the app simply would not come up. MySQL is refused too, with nothing measured
at all. This half is removed when R-463 has a scripted `pg_upgrade` edge proven on all eleven.
at all. **ONE APP AT A TIME, since 2026-09-25 (`09` §3 decision 35):** the BOX converts a PostgreSQL
major itself (controller v0.273.0 — dump from the old engine, load into the new, check, undo on any
failure), but ONLY on a step whose ladder entry carries `engine_conversion {service, engine: postgres,
from, to}`. So a PostgreSQL major passes the gate only when the template's ladder entry for that step
is `proven`, cites BOTH venues (`evidence` + `box_evidence`) and carries the mark — written by
`upgrade-test.py --write-ladder`, which refuses unless the bench converted it (harness v4) AND the box
converted it through the product — and only as the ONLY image move in its commit. Every other
PostgreSQL app stays refused until it has its own proof. **PostgreSQL 18 moves the data mount** to
`/var/lib/postgresql` (18 refuses even an empty volume at `/var/lib/postgresql/data`); the writer and
the harness move that line with the image (`pg_mounts_for`). The postgis family is judged too (it was
not before 2026-09-25).
**The new clause — one edge, one migration (R-450).** A MariaDB major must be the ONLY image move
in its template in that commit. bookstack's `0b73e5e` moved the application 25.02.2 → 26.05.2 **and**
MariaDB 11.6 → 12.3 in one commit: two migrations behind one edge, and an unreadable failure when it
+58 -4
View File
@@ -56,7 +56,10 @@ import subprocess
import sys
# Repository basenames that are database engines. A match here means "judge this service's major".
ENGINES = ("mariadb", "mysql", "postgres", "postgresql")
ENGINES = ("mariadb", "mysql", "postgres", "postgresql", "postgis", "pgvector")
# The Postgres family, for the conversion clause below (postgis/postgis:16-3.5 is PostgreSQL 16; it was
# not judged at all before 2026-09-25 — adventurelog's engine could have crossed a major unseen).
PG_FAMILY = ("postgres", "postgresql", "postgis", "pgvector")
# R-469, 2026-09-21 — THE MARIADB HALF OF THE RULE IS LIFTED; THE POSTGRESQL HALF IS NOT.
#
@@ -117,10 +120,40 @@ def engine_of(ref):
def major_of(tag):
m = re.match(r"^v?(\d+)", tag)
m = re.match(r"^(?:v|pg)?(\d+)", tag)
return int(m.group(1)) if m else None
def pg_conversion_proof(rev, compose_path, svc, mb, ma, after):
"""None when the template at `rev` carries a PROVEN two-venue ladder entry for exactly this step with
the matching engine_conversion mark; else the reason, as a sentence fragment."""
import json as _json
fy = compose_path.rsplit("/", 1)[0] + "/.felhom.yml"
rc, text, _ = git("show", "%s:%s" % (rev, fy))
if rc != 0:
return "%s cannot be read" % fy
entries = []
for line in text.splitlines():
m = re.match(r"^ - (\{.*\})\s*$", line)
if m:
try:
entries.append(_json.loads(m.group(1)))
except ValueError:
pass
step = [e for e in entries if e.get("to") == after]
if not step:
return "no ladder entry has `to` = this commit's images"
e = step[-1]
want = {"service": svc, "engine": "postgres", "from": mb, "to": ma}
if e.get("verdict") != "proven":
return "the step's entry is %r, not proven" % e.get("verdict")
if e.get("engine_conversion") != want:
return "the step's entry carries engine_conversion %r, want %r" % (e.get("engine_conversion"), want)
if not (e.get("evidence") and e.get("box_evidence")):
return "the step's entry does not cite BOTH venues' evidence (evidence + box_evidence)"
return None
def resolve_range(spec):
"""'A..B' -> (A, B, note). An all-zero A (a new remote ref) falls back to origin/main."""
if not spec or ".." not in spec:
@@ -195,8 +228,19 @@ def main(argv):
if mb == ma:
continue
row = (path, svc, eng_after[0], mb, ma, before[svc], img_after)
if eng_after[0] in PG_FAMILY:
# `09` §3 decision 35 (2026-09-25): a PostgreSQL major passes ONLY for a template whose ladder
# entry for THIS step is proven on both venues and carries the conversion mark, and only as
# the only image move in its commit (the MariaDB rule). Every other app stays refused.
why = pg_conversion_proof(b, path, svc, mb, ma, after)
others = [o for o in moves if o != svc]
if why is None and not others:
allowed.append(row)
continue
refused.append(row + ((why or "it is bundled with %s" % ", ".join(sorted(others))),))
continue
if eng_after[0] not in LIFTED:
refused.append(row)
refused.append(row + (None,))
continue
# R-469 + R-450: lifted, but it must be the ONLY image this commit moves in this
# template. `others` is named so the refusal can say WHAT it was bundled with.
@@ -211,9 +255,14 @@ def main(argv):
if refused or bundled:
print("")
for path, svc, eng, mb, ma, ib, ia in refused:
for path, svc, eng, mb, ma, ib, ia, pgwhy in refused:
print("ENGINE-MAJOR GATE FAILED: %s service %s moves %s %d -> %d (%s -> %s)."
% (path, svc, eng, mb, ma, ib, ia))
if pgwhy:
print(" NOT PROVEN FOR THIS APP: %s. A PostgreSQL major passes only with a ladder entry for "
"this step that is proven on BOTH venues and carries engine_conversion {service, "
"engine: postgres, from, to} (written by upgrade-test.py --write-ladder), and only as "
"the only image move in its commit (`09` §3 decision 35)." % pgwhy)
print(" WHY: %s performs no datadir conversion of its own and REFUSES to start on an "
"older major's datadir (R-463, eleven templates). The Update takes a backup first "
"since Slice 4, but a backup is a route BACK, not a conversion — the app would "
@@ -239,6 +288,11 @@ def main(argv):
return 2
for path, svc, eng, mb, ma, ib, ia in allowed:
if eng in PG_FAMILY:
print("engine-major gate ALLOWED: %s service %s moves %s %d -> %d (%s -> %s) as its own edge — "
"its ladder entry is proven on both venues and carries the conversion mark (`09` §3 "
"decision 35; the box converts it, controller v0.273.0)." % (path, svc, eng, mb, ma, ib, ia))
continue
print("engine-major gate ALLOWED: %s service %s moves %s %d -> %d (%s -> %s) as its own edge "
"— permitted since R-469 (Slice 4 shipped; MARIADB_AUTO_UPGRADE=1 converts the datadir)."
% (path, svc, eng, mb, ma, ib, ia))
+9
View File
@@ -27,6 +27,9 @@ AN ENTRY (all keys required unless marked):
memory_peak_pct the memory watch's worst container peak in % of its limit; null only on a
backfilled entry (harness v1 had no watch)
marks {"files_may_change": bool, "needs_person": null | "<why>", "memory_tight": bool}
engine_conversion (optional) {"service", "engine": "postgres", "from": int, "to": int} — the box
CONVERTS this PostgreSQL major (controller v0.273.0, `09` §6.4 part 10); written by
`upgrade-test.py --write-ladder` only when the bench AND the box both converted it
backfilled (optional) "YYYY-MM-DD" — written by the backfill from an EXISTING record,
never by a new test; a new move may not carry it
@@ -135,6 +138,12 @@ def check_entry(e):
for svc in e["digest"]:
if svc not in e["to"]:
p.append("digest names a service %r that `to` does not" % svc)
conv = e.get("engine_conversion")
if conv is not None: # `09` §6.4 part 10 — the box converts ONLY on this mark
if not (isinstance(conv, dict) and set(conv) == {"service", "engine", "from", "to"}
and conv["engine"] == "postgres" and isinstance(conv["from"], int) and isinstance(conv["to"], int)
and conv["from"] < conv["to"] and conv["service"] in e["to"]):
p.append("engine_conversion must be {service (in `to`), engine: postgres, from < to (ints)}")
marks = e["marks"]
if not isinstance(marks, dict) or set(marks) != {"files_may_change", "needs_person", "memory_tight"}:
p.append("marks must be exactly {files_may_change, needs_person, memory_tight}")
+40
View File
@@ -33,6 +33,7 @@ Run from the repo root: python3 scripts/test_gate_decoys.py
Exit 0 every decoy judged correctly · 1 a decoy passed or a genuine article was refused.
"""
import io
import json
import os
import re
import shutil
@@ -501,6 +502,45 @@ def main():
[(KIMAI, swap_image("kimai-db", KDB, "mariadb:12.3"))],
expect_rc=0, must_contain=("ALLOWED", "kimai-db", "mariadb 11 -> 12", "R-469"))
# ── `09` §3 decision 35 (2026-09-25): a PostgreSQL major passes ONLY with its proven,
# two-venue, MARKED ladder entry, alone in its commit. The fact is the entry's CONTENT for
# THIS step — never the word "engine_conversion" somewhere, never one venue.
DOCMOST_FY = "templates/docmost/.felhom.yml"
DM, DR = cur_image(clone, DOCMOST, "docmost"), cur_image(clone, DOCMOST, "docmost-redis")
DPG = cur_image(clone, DOCMOST, "docmost-postgres")
if not DPG.startswith("postgres:16"):
raise SystemExit("docmost-postgres is %s — the cases below assume 16; fixture drifted" % DPG)
def pg_entry(mark=True, box=True, to_pg="postgres:18-alpine", extra=""):
e = {"from": {"docmost": DM, "docmost-postgres": DPG, "docmost-redis": DR},
"to": {"docmost": DM, "docmost-postgres": to_pg, "docmost-redis": DR},
"digest": {"docmost": "sha256:" + "a" * 64, "docmost-postgres": "sha256:" + "b" * 64, "docmost-redis": "sha256:" + "c" * 64},
"verdict": "proven", "tested_at": "2026-09-25T20:00:00Z", "harness_version": 4,
"evidence": "x/bench.json", "box_evidence": "x/box.json" if box else None, "memory_peak_pct": 20.0,
"marks": {"files_may_change": False, "needs_person": None, "memory_tight": False}}
if mark:
e["engine_conversion"] = {"service": "docmost-postgres", "engine": "postgres", "from": 16, "to": 18}
return lambda t: t.rstrip("\n") + "\n - " + json.dumps(e) + "\n" + extra
pg18 = swap_image("docmost-postgres", DPG, "postgres:18-alpine")
case("GENUINE: docmost-postgres 16 -> 18 ALONE with its proven two-venue MARKED entry", clone,
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry())],
expect_rc=0, must_contain=("ALLOWED", "docmost-postgres", "postgres 16 -> 18", "decision 35"))
case("DECOY: the entry is proven on both venues but carries NO conversion mark", clone,
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry(mark=False))],
expect_rc=1, must_contain=("NOT PROVEN FOR THIS APP", "engine_conversion None"))
case("DECOY: the marked entry cites ONE venue only (no box_evidence)", clone,
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry(box=False))],
expect_rc=1, must_contain=("NOT PROVEN FOR THIS APP", "BOTH venues"))
case("DECOY: the mark sits in a COMMENT, the entry has none", clone,
[(DOCMOST, pg18), (DOCMOST_FY, pg_entry(mark=False, extra='# engine_conversion: {"service": "docmost-postgres", "engine": "postgres", "from": 16, "to": 18}\n'))],
expect_rc=1, must_contain=("NOT PROVEN FOR THIS APP",))
case("DECOY: the marked entry, but the move is BUNDLED with the app's own bump", clone,
[(DOCMOST, lambda t: swap_image("docmost", DM, DM + "-next")(pg18(t))), (DOCMOST_FY, pg_entry())],
expect_rc=1, must_contain=("ENGINE-MAJOR GATE FAILED", "docmost-postgres"))
case("FACT: adventurelog's postgis 16 -> 17 (the postgis family was never judged before)", clone,
[("templates/adventurelog/docker-compose.yml",
swap_image("adventurelog-postgres", cur_image(clone, "templates/adventurelog/docker-compose.yml", "adventurelog-postgres"), "postgis/postgis:17-3.5-alpine"))],
expect_rc=1, must_contain=("adventurelog-postgres", "postgis 16 -> 17"))
# ── THE DECOYS: the label moves, the fact does not — these must pass ─────────────────
def comment_and_env(text):
# the version string moves in a COMMENT and in kimai's serverVersion env, image untouched
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Harness v4 (`09` §6.4 part 10): the pieces of the PostgreSQL conversion that run without Docker.
Run: python3 scripts/test_pg_conversion.py (exit 0 = all pass)."""
import importlib.util, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
spec = importlib.util.spec_from_file_location("ut", os.path.join(HERE, "upgrade-test.py"))
ut = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ut)
import ladder
OLD = """services:
docmost-postgres:
image: postgres:16-alpine
volumes:
- docmost_postgres_data:/var/lib/postgresql/data
docmost-redis:
image: redis:7-alpine
volumes:
- docmost_redis_data:/data
"""
fails = []
def check(name, cond):
print((" ok " if cond else "FAIL ") + name)
if not cond:
fails.append(name)
new = ut.pg_mounts_for(OLD.replace("postgres:16-alpine", "postgres:18-alpine"))
check("18 moves the data mount to /var/lib/postgresql", "docmost_postgres_data:/var/lib/postgresql\n" in new)
check("18 leaves the redis mount alone", "docmost_redis_data:/data" in new)
check("16 keeps /var/lib/postgresql/data", ut.pg_mounts_for(OLD) == OLD)
check("back from 18 to 16 restores /data", ut.pg_mounts_for(new.replace("postgres:18-alpine", "postgres:16-alpine")) == OLD)
check("pg_conversion names the one moving service",
ut.pg_conversion({"a": "x:1", "db": "postgres:16-alpine"}, {"a": "x:2", "db": "postgres:18-alpine"}) ==
{"service": "db", "engine": "postgres", "from": 16, "to": 18})
check("within a major is no conversion", ut.pg_conversion({"db": "postgres:16-alpine"}, {"db": "postgres:16.4-alpine"}) is None)
check("pgvector's pg16 tag reads 16", ut.pg_major("pgvector/pgvector:pg16") == 16)
base = {"from": {"db": "postgres:16-alpine"}, "to": {"db": "postgres:18-alpine"}, "digest": {"db": "sha256:" + "a" * 64},
"verdict": "proven", "tested_at": "2026-09-25T20:00:00Z", "harness_version": 4, "evidence": "x",
"memory_peak_pct": 10.0, "marks": {"files_may_change": False, "needs_person": None, "memory_tight": False}}
check("an entry with a good mark is well-formed",
ladder.check_entry(dict(base, engine_conversion={"service": "db", "engine": "postgres", "from": 16, "to": 18})) == [])
check("a mark naming a service outside `to` is refused",
ladder.check_entry(dict(base, engine_conversion={"service": "web", "engine": "postgres", "from": 16, "to": 18})) != [])
check("a mark going DOWN is refused",
ladder.check_entry(dict(base, engine_conversion={"service": "db", "engine": "postgres", "from": 18, "to": 16})) != [])
print("pg conversion tests: %d failed" % len(fails))
sys.exit(1 if fails else 0)
+183 -4
View File
@@ -47,7 +47,8 @@ import importlib.util, json, os, re, shutil, subprocess, sys, time
from datetime import datetime, timezone
from pathlib import Path
HARNESS_VERSION = 3 # 2: the memory watch (R-635); 3: box fixtures on the bench + files_may_change (2026-09-23 night)
HARNESS_VERSION = 4 # 2: the memory watch (R-635); 3: box fixtures on the bench + files_may_change (2026-09-23 night);
# 4: a PostgreSQL major is CONVERTED on the bench, and the ladder carries the mark (`09` §6.4 part 10)
ROOT = Path("/opt/upg")
TEMPLATES = ROOT / "templates"
EVIDENCE = ROOT / "evidence"
@@ -175,7 +176,7 @@ def render(app: str, images: dict, workdir: Path, env: dict, template: str = Non
line = mi.group(1) + images[cur]
out.append(line)
workdir.mkdir(parents=True, exist_ok=True)
(workdir / "docker-compose.yml").write_text("\n".join(out) + "\n")
(workdir / "docker-compose.yml").write_text(pg_mounts_for("\n".join(out) + "\n"))
(workdir / ".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
return workdir / "docker-compose.yml"
@@ -251,8 +252,10 @@ ENGINE_PROBES = {
"--password=$MYSQL_ROOT_PASSWORD 2>&1; echo \"[exit=$?]\"",
"datadir version | the engine's own upgrade verdict",
),
# $PGDATA, not a fixed path: 18 keeps its datadir at /var/lib/postgresql/18/docker (measured
# 2026-09-25, audits/night-2026-09-26/A/A1-images.txt).
"postgres": (
"cat /var/lib/postgresql/data/PG_VERSION 2>&1", "datadir major version",
'cat "$PGDATA/PG_VERSION" 2>&1', "datadir major version",
),
}
@@ -562,6 +565,157 @@ def bind_tree_hash(project: str, workdir: Path) -> dict:
return out
# --- the PostgreSQL major conversion on the bench (`09` §6.4 part 10, harness v4) ---------------------
#
# The postgres image converts nothing and refuses an older major's datadir (R-463); 18 refuses even an
# EMPTY volume at /var/lib/postgresql/data and wants the mount at /var/lib/postgresql (measured
# 2026-09-25). The bench converts the SAME WAY the box does (felhom-controller stacks/pgconvert.go):
# the old engine alone → the check → pg_dumpall (completion line) → the volume emptied → the new engine
# alone → the entrypoint's empty databases dropped, the existing roles' CREATE lines skipped → the load
# with ON_ERROR_STOP → the check again + PG_VERSION. The BOX venue runs the product's own code.
PG_FAMILY = ("postgres", "postgis", "pgvector", "timescaledb")
PG_DUMPALL_DONE = "PostgreSQL database cluster dump complete"
def is_pg(ref: str) -> bool:
return any(f in ref.lower() for f in PG_FAMILY)
def pg_major(ref: str):
tag = ref.split("@", 1)[0].rsplit("/", 1)[-1]
tag = tag.rsplit(":", 1)[1] if ":" in tag else ""
m = re.match(r"^(?:pg)?(\d+)", tag.lower())
return int(m.group(1)) if m else None
def pg_conversion(frm: dict, to: dict):
"""The one PostgreSQL service whose major moves between frm and to, as the mark's shape; None."""
moved = [s for s, r in to.items() if s in frm and is_pg(r) and pg_major(frm[s]) != pg_major(r)]
if not moved:
return None
if len(moved) > 1:
raise SystemExit(f"two PostgreSQL majors move at once: {moved} — one conversion per step")
s = moved[0]
return {"service": s, "engine": "postgres", "from": pg_major(frm[s]), "to": pg_major(to[s])}
def pg_mounts_for(compose_text: str) -> str:
"""Point each PostgreSQL service's data volume where ITS major wants it: /var/lib/postgresql for 18+,
/var/lib/postgresql/data below. Only a line that already mounts one of the two is touched."""
out, cur, major = [], None, None
for line in compose_text.splitlines():
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
if m:
cur, major = m.group(1), None
mi = re.match(r"^\s+image:\s*(\S+)\s*$", line)
if mi and is_pg(mi.group(1)):
major = pg_major(mi.group(1))
mv = re.match(r"^(\s+-\s+[A-Za-z0-9_.-]+:)/var/lib/postgresql(?:/data)?(\s*)$", line)
if mv and major:
line = mv.group(1) + ("/var/lib/postgresql" if major >= 18 else "/var/lib/postgresql/data") + mv.group(2)
out.append(line)
return "\n".join(out) + ("\n" if compose_text.endswith("\n") else "")
PG_CHECK_SQL = ("SELECT n.nspname||'.'||c.relname||'='||(xpath('/row/c/text()', query_to_xml(format('select count(*) as c "
"from %I.%I', n.nspname, c.relname), false, true, '')))[1]::text FROM pg_class c JOIN pg_namespace n ON "
"n.oid=c.relnamespace WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema') "
"AND n.nspname NOT LIKE 'pg_toast%' ORDER BY 1")
def _psql(cid, user, db, sql):
r = cvp._sh(["docker", "exec", cid, "psql", "-h", "127.0.0.1", "-U", user, "-d", db, "-v", "ON_ERROR_STOP=1",
"-Atc", sql], timeout=600)
if r.returncode != 0:
raise RuntimeError(f"psql {db}: {(r.stderr or '').strip()[:300]}")
return [l.strip() for l in r.stdout.splitlines() if l.strip()]
def pg_check(cid, user):
out = ["db:" + l for l in _psql(cid, user, "postgres", "select datname||' owner='||pg_get_userbyid(datdba)||' enc='||"
"pg_encoding_to_char(encoding)||' coll='||datcollate from pg_database where not datistemplate order by 1")]
out += ["role:" + l for l in _psql(cid, user, "postgres", "select rolname||' super='||rolsuper||' login='||rolcanlogin||"
"' pw='||(rolpassword is not null) from pg_roles where rolname !~ '^pg_' order by 1")]
for db in _psql(cid, user, "postgres", "select datname from pg_database where not datistemplate and datname<>'postgres'"):
out += [f"ext:{db}:" + l for l in _psql(cid, user, db, "select extname from pg_extension order by 1")]
out += [f"rows:{db}:" + l for l in _psql(cid, user, db, PG_CHECK_SQL)]
return sorted(out)
def pg_wait(cid, user, wait=300):
t0 = time.time()
while time.time() - t0 < wait:
r = cvp._sh(["docker", "exec", cid, "psql", "-h", "127.0.0.1", "-U", user, "-d", "postgres", "-Atc", "select 1"], timeout=30)
if r.returncode == 0 and r.stdout.strip() == "1":
return round(time.time() - t0, 1)
time.sleep(2)
raise RuntimeError(f"{cid} never answered over TCP in {wait}s")
def bench_convert(conv, e, app, project, workdir, env, ev, say):
"""Convert conv['service'] from its FROM major to its TO major, the box's way. Returns the record's
`engine_conversion` dict; raises on any failure (the edge is then `failed`)."""
svc, t0, rec = conv["service"], time.time(), dict(conv)
compose(workdir, project, "stop", timeout=600)
cid = compose(workdir, project, "ps", "-aq", svc).stdout.strip()
env_lines = cvp._sh(["docker", "inspect", cid, "--format", "{{range .Config.Env}}{{println .}}{{end}}"]).stdout
user = next((l.split("=", 1)[1] for l in env_lines.splitlines() if l.startswith("POSTGRES_USER=")), "postgres")
cvp._sh(["docker", "start", cid])
pg_wait(cid, user)
before = pg_check(cid, user)
(ev / "convert-check-before.txt").write_text("\n".join(before) + "\n")
dump = ev / "convert-dumpall.sql"
td = time.time()
with open(dump, "w") as f:
r = subprocess.run(["docker", "exec", cid, "pg_dumpall", "-h", "127.0.0.1", "-U", user], stdout=f,
stderr=subprocess.PIPE, text=True, timeout=7200)
if r.returncode != 0 or PG_DUMPALL_DONE not in dump.read_text()[-4096:]:
raise RuntimeError(f"pg_dumpall rc={r.returncode} / no completion line: {r.stderr[-300:]}")
rec["dump_s"], rec["dump_bytes"] = round(time.time() - td, 2), dump.stat().st_size
vols = json.loads(cvp._sh(["docker", "inspect", cid, "--format", "{{json .Mounts}}"]).stdout or "[]")
vol = next((m["Name"] for m in vols if m.get("Type") == "volume" and m.get("Destination", "").startswith("/var/lib/postgresql")), None)
if not vol:
raise RuntimeError(f"no named volume under /var/lib/postgresql on {cid}: {vols}")
cvp._sh(["docker", "stop", "-t", "60", cid])
cvp._sh(["docker", "run", "--rm", "-v", vol + ":/v", "alpine", "sh", "-c", "find /v -mindepth 1 -delete"])
render(app, e["to"], workdir, env, e.get("template"))
up = compose(workdir, project, "up", "-d", "--no-deps", svc)
if up.returncode != 0:
raise RuntimeError(f"the new engine did not start: {up.stderr[-400:]}")
cid2 = compose(workdir, project, "ps", "-aq", svc).stdout.strip()
rec["new_engine_ready_s"] = pg_wait(cid2, user)
dropped = []
for db in _psql(cid2, user, "postgres", "select datname from pg_database where not datistemplate and datname<>'postgres'"):
n = _psql(cid2, user, db, "select count(*) from pg_class c join pg_namespace n on n.oid=c.relnamespace "
"where c.relkind in ('r','p') and n.nspname not in ('pg_catalog','information_schema')")
if n != ["0"]:
raise RuntimeError(f"database {db} on the new engine is not empty ({n})")
_psql(cid2, user, "postgres", f'DROP DATABASE "{db}"')
dropped.append(db)
skip = {f"CREATE ROLE {r};" for r in _psql(cid2, user, "postgres", "select quote_ident(rolname) from pg_roles where rolname !~ '^pg_'")}
body = "".join(l for l in dump.read_text().splitlines(True) if l.rstrip("\r\n") not in skip)
tl = time.time()
r = subprocess.run(["docker", "exec", "-i", cid2, "psql", "-h", "127.0.0.1", "-U", user, "-d", "postgres",
"-v", "ON_ERROR_STOP=1", "-q"], input=body, capture_output=True, text=True, timeout=7200)
(ev / "convert-load.err").write_text(r.stderr)
if r.returncode != 0:
raise RuntimeError(f"the load stopped (rc={r.returncode}): {r.stderr.strip()[:400]}")
rec["load_s"], rec["dropped"], rec["skipped_create_role"] = round(time.time() - tl, 2), dropped, sorted(skip)
after = pg_check(cid2, user)
(ev / "convert-check-after.txt").write_text("\n".join(after) + "\n")
if after != before:
raise RuntimeError("the check differs after the load: " + "; ".join(sorted(set(before) ^ set(after)))[:400])
ver = cvp._sh(["docker", "exec", cid2, "sh", "-c", 'cat "$PGDATA/PG_VERSION"']).stdout.strip()
if ver != str(conv["to"]):
raise RuntimeError(f"PG_VERSION {ver!r}, want {conv['to']}")
rec.update(result="converted", check_equal=True, pg_version=ver, tables=sum(1 for l in after if l.startswith("rows:")),
convert_s=round(time.time() - t0, 1))
say(f"CONVERTED {svc} PostgreSQL {conv['from']} -> {conv['to']} in {rec['convert_s']}s "
f"(dump {rec['dump_s']}s / {rec['dump_bytes']} B, load {rec['load_s']}s, check equal over {rec['tables']} tables)")
return rec
def run_edge(edge_id: str) -> dict:
e = EDGES[edge_id]
app = e["app"]
@@ -640,6 +794,16 @@ def run_edge(edge_id: str) -> dict:
# --- 4. TO ---
swap_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
conv = pg_conversion(e["frm"], e["to"])
if conv: # harness v4: the engine is converted first, the box's way
say(f"{edge_id}: PostgreSQL major move {conv} — converting on the bench before the swap")
try:
rec["engine_conversion"] = bench_convert(conv, e, app, project, workdir, env, ev, say)
except Exception as ex: # noqa: BLE001 — every failure is the edge's verdict, stated
rec["engine_conversion"] = dict(conv, result="failed", detail=str(ex)[:600])
rec["verdict"], rec["abort_detail"] = "failed", f"the conversion failed: {ex}"
say(f"CONVERSION FAILED: {ex}")
return rec
say(f"{edge_id}: swapping to TO {e['to']}")
render(app, e["to"], workdir, env, e.get("template"))
up2 = compose(workdir, project, "up", "-d")
@@ -810,6 +974,19 @@ def write_ladder(argv) -> int:
if peak is None:
print(f"REFUSED {app}: the memory watch recorded no peak")
return 1
# harness v4 (`09` §6.4 part 10): a PostgreSQL major is written ONLY when BOTH venues converted it —
# the bench by its own conversion, the box by the PRODUCT's (the walk records the controller's
# `CONVERTED` line). The mark is what lets the box convert at all, and the catalog gate reads it.
conv = pg_conversion(bench["from"], bench["to"])
if conv:
bc, xc = bench.get("engine_conversion") or {}, box.get("engine_conversion") or {}
want = {k: conv[k] for k in ("service", "engine", "from", "to")}
if bc.get("result") != "converted" or {k: bc.get(k) for k in want} != want:
print(f"REFUSED {app}: the bench did not convert {want} (its record: {bc})")
return 1
if xc.get("result") != "converted" or {k: xc.get(k) for k in want} != want:
print(f"REFUSED {app}: the box did not convert {want} through the product (its record: {xc})")
return 1
marks = set(bench.get("marks") or [])
entry = {"from": bench["from"], "to": bench["to"], "digest": digests, "verdict": "proven",
"tested_at": bench["measured_at"], "harness_version": bench["harness_version"],
@@ -818,6 +995,8 @@ def write_ladder(argv) -> int:
"memory_cgroup_peak_pct": cg_peak,
"marks": {"files_may_change": "files_may_change" in marks,
"needs_person": None, "memory_tight": peak > ladder.MEMORY_TIGHT_PCT}}
if conv:
entry["engine_conversion"] = {k: conv[k] for k in ("service", "engine", "from", "to")}
probs = ladder.check_entry(entry)
if probs:
print(f"REFUSED {app}: the entry would not be well-formed: {probs}")
@@ -846,7 +1025,7 @@ def write_ladder(argv) -> int:
if mi and svc in bench["to"] and mi.group(2) == bench["from"][svc]:
line = mi.group(1) + bench["to"][svc]
out.append(line)
comp_p.write_text("\n".join(out) + "\n")
comp_p.write_text(pg_mounts_for("\n".join(out) + "\n") if conv else "\n".join(out) + "\n")
if ladder.images_in(comp_p.read_text()) != bench["to"]:
comp_p.write_text(comp)
print(f"REFUSED {app}: the compose could not be moved line by line — restored")