From 6a4a5f0cf772b1c5a80d961955a31da955c80f35 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 25 Sep 2026 13:04:01 +0200 Subject: [PATCH] harness v4 converts a PostgreSQL major; the engine-major gate passes one proven, marked app (09 decision 35) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 15 +++ CLAUDE.md | 12 ++- scripts/check-engine-major.py | 62 ++++++++++- scripts/ladder.py | 9 ++ scripts/test_gate_decoys.py | 40 ++++++++ scripts/test_pg_conversion.py | 48 +++++++++ scripts/upgrade-test.py | 187 +++++++++++++++++++++++++++++++++- 7 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 scripts/test_pg_conversion.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1301d..e3a344d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 18bbbac..a3023e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/scripts/check-engine-major.py b/scripts/check-engine-major.py index 0c84af2..39fe4eb 100644 --- a/scripts/check-engine-major.py +++ b/scripts/check-engine-major.py @@ -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)) diff --git a/scripts/ladder.py b/scripts/ladder.py index 8970cfb..454c175 100644 --- a/scripts/ladder.py +++ b/scripts/ladder.py @@ -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 | "", "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}") diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 9c9c09f..143add1 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -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 diff --git a/scripts/test_pg_conversion.py b/scripts/test_pg_conversion.py new file mode 100644 index 0000000..4dd6421 --- /dev/null +++ b/scripts/test_pg_conversion.py @@ -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) diff --git a/scripts/upgrade-test.py b/scripts/upgrade-test.py index e77021a..9166b9e 100755 --- a/scripts/upgrade-test.py +++ b/scripts/upgrade-test.py @@ -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")