harness v4 converts a PostgreSQL major; the engine-major gate passes one proven, marked app (09 decision 35)
gates / gates (push) Successful in 2s
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user