Upgrade harness: four fixtures and seven real upstream edges from the update night (R-462)
gates / gates (push) Successful in 1s

Test code only — no template changed and no image: line moved.

The update night walked real within-a-major upstream edges on scratch guest 9202 through the
product's own guarded Update, against a PRIVATE DRILL CATALOG; the live catalog was never
touched. This brings the expensive half of that work — the seed routes — back into the harness
so the same edges can be run here WITH their ABORT step, which the box deliberately does not
offer (09 6.1: whether the old image starts on migrated data is per-app and unpredictable).

- upgrade_fixtures.py: ActualBudget, Navidrome, AudiobookShelf, Vikunja. Each seeds through the
  app's OWN interface (R-156); each carries a negative control run on every verify(), so a
  readback that has broken into always succeeding fails instead of passing everything.
- upgrade-test.py: edges U1..U7, all real upstream moves existing 2026-09-21 that this catalog
  has NOT made, each holding its database engine constant.
- Limitations kept: Navidrome and AudiobookShelf seed the DATABASE half only, and say so.

OWED, stated so it is not mistaken for done: the U1..U7 harness RUNS, and with them the per-app
ABORT answers. The code is in; the runs are not.

Gates: catalog_gates.py --fast — image-pins, engine-major, catalog-since, copy-i18n all OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-21 21:00:50 +02:00
parent f5f6a152b5
commit 4463243f2e
4 changed files with 320 additions and 61 deletions
+28
View File
@@ -1,3 +1,31 @@
## Upgrade harness: four new fixtures and seven real upstream EDGES (2026-09-21, R-462)
**Test code only. No template changed, and no `image:` line moved.** The update night
(`felhom.eu/documentation/audits/DRILL-update-night-2026-09-21.md`) walked real within-a-major
upstream edges on scratch guest 9202 **through the product's own guarded Update**, each app seeded
and read back through its own front door. This commit brings that work back into the harness so the
same edges can be run here **with their ABORT step**, which the box deliberately does not offer
(`09` §6.1: the box never puts the old version back by itself, because whether the old image starts
on migrated data is per-app and cannot be predicted).
- **`upgrade_fixtures.py` — four new fixtures**: `ActualBudget` (its own bootstrap + login),
`Navidrome` (`/auth/createAdmin` + `/auth/login`), `AudiobookShelf` (`/init` + `/login`) and
`Vikunja` (register → login → create project → authenticated readback). Every one goes in through
the app's OWN interface (R-156) and every one carries its own negative control, run on every
`verify()`: a wrong password, or an id that cannot exist, must NOT read back — so a readback that
has broken into always succeeding fails instead of passing everything.
- **`upgrade-test.py` — seven new edges, `U1`–`U7`**, all REAL upstream moves that existed on
2026-09-21 and that this catalog has NOT made: privatebin 2.0.5→2.0.6, docmost 0.95.0→0.96.0,
bookstack 26.05.2→26.05.5, actualbudget 26.7.0→26.9.0, navidrome 0.63.2→0.64.0,
audiobookshelf 2.35.1→2.36.1, vikunja 2.3.0→2.6.0. Each holds its database engine CONSTANT, per
the standing rule that an engine change gets its own edge.
- **Limitations kept rather than papered over.** `Navidrome` and `AudiobookShelf` seed the DATABASE
half only — the library on the drive is not populated — and both say so in their docstrings, as
`BookStack` already does for its file half (R-460).
**Owed, and stated so it is not mistaken for done: the harness RUNS.** The code is in; the U1–U7
runs, and with them the per-app ABORT answers, have not been performed.
## RULE LIFT: a MariaDB major may cross, as its OWN EDGE; PostgreSQL may not (2026-09-21, R-469 + R-450) ## RULE LIFT: a MariaDB major may cross, as its OWN EDGE; PostgreSQL may not (2026-09-21, R-469 + R-450)
The engine-major rule of 2026-09-13 named its own expiry — *until the Update button takes a verified The engine-major rule of 2026-09-13 named its own expiry — *until the Update button takes a verified
+30 -60
View File
@@ -1,70 +1,40 @@
# REPORT — the engine-major rule, half lifted (R-469 + R-450) # REPORT — upgrade harness widened from the update night (2026-09-21)
**Base `18a6d2d8243e` → `5ff36d098cbc`.** No template moved. Architecture read first and named: **Test code only.** No template was changed; no `image:` line moved; `git diff` touches exactly
`felhom.eu/documentation/architecture/09-update-architecture.md` §3 decision 5 (the 2026-09-13 ruling `scripts/upgrade_fixtures.py` and `scripts/upgrade-test.py`.
and its three precautions) and §6.1 (slice 4 as shipped).
## Why now ## What was done
The rule of 2026-09-13 named its own expiry — *until the Update button takes a verified backup as its The update night (`felhom.eu/documentation/audits/DRILL-update-night-2026-09-21.md`) walked real
precondition* — **precisely so it would be removed deliberately rather than forgotten.** That within-a-major upstream edges on scratch guest 9202 **through the product's own guarded Update**,
condition was met the same day: update arc Slice 4 shipped (controller v0.237.0/v0.238.0; any backup each app seeded and read back through its own front door, against a **private drill catalog** — the
tier since v0.239.0). R-469 exists to make the removal a reviewed diff. This is it, and it removes live catalog was never touched. This commit brings the expensive half of that work (the seed routes)
exactly half. back into the harness, so the same edges can also be run here **with their ABORT step**, which the
box deliberately does not offer.
## What changed - Four new fixtures: `ActualBudget`, `Navidrome`, `AudiobookShelf`, `Vikunja`. Each seeds through the
app's OWN interface (R-156) and each carries a negative control that runs on every `verify()`.
- Seven new edges `U1`–`U7`, all real upstream moves that existed on 2026-09-21 and that this catalog
has NOT made. Every one holds its database engine constant.
- **LIFTED — MariaDB.** The four `mariadb:` sidecars (`bookstack-db`, `kimai-db`, `nextcloud-db`, ## What was proven, and where
`romm-db`) may cross a major. They now have both halves: a verified backup in front of the Update,
and `MARIADB_AUTO_UPGRADE=1` (R-459) whose conversion the harness WATCHED run on the E3/E3b edges
with the seeded data read back after.
- **NOT LIFTED — PostgreSQL and MySQL.** Postgres performs no `pg_upgrade` and REFUSES to start on an
older major's datadir, across eleven templates (R-463). **A backup is a route BACK, not a
conversion** — the app would simply not come up. MySQL has nothing measured at all. The refusal
text now says this, instead of citing the shipped R-448.
- **NEW — one edge, one migration (R-450's second half, recorded 2026-09-02, enforced now).** 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 together: two migrations behind
one edge, and an unreadable failure when it breaks. The refusal names what it was bundled with.
**Within a major is unaffected** and may still ride with anything.
- **The gate PRINTS what it allowed**, by name and with the reason, rather than passing in silence. A
lifted rule that goes quiet is a lifted rule nobody can audit.
## Red-proofs — two, each SEEN to fail Box-side, through the guarded Update, with the data read back before and after — evidence per app in
`felhom.eu/documentation/audits/update-night-2026-09-21/apps/<app>/`. The harness-side runs of
U1–U7 are **owed**: the code is in, the runs are not.
| the mutation | what failed | ## Gates
|---|---|
| drop the own-edge check (`others = []`) | *FACT: kimai-db 11.6 → 12.3 BUNDLED with the kimai app bump: rc=0 expected 1* — the bookstack shape passes |
| empty `LIFTED` | *GENUINE: kimai-db 11.6 → 12.3 ALONE (the R-469 lift): rc=1 expected 0* — the lift is undone |
Both reverted; **40 decoy cases green**. The old *"a lone MariaDB major is REFUSED"* case is now the `python3 scripts/catalog_gates.py --fast` — image-pins OK, engine-major OK, catalog-since OK,
*"…is ALLOWED"* case — that inversion is the lift itself. A third case pins the bundled PostgreSQL copy-i18n OK. `all catalog gates OK`, exit 0. The two slow gates need a container runtime and were
shape. not run; no template changed, which is what they inspect.
`catalog_gates.py --fast`: image-pins, engine-major, catalog-since, copy-i18n — **all OK**. The ## A catalog defect found by the night, filed and NOT fixed here
pre-push hook ran and passed; no `--no-verify`.
## Measured while here, and it belongs in this repo's record Two apps are presented to the household as UNHEALTHY while working perfectly: `tandoor`'s
`.felhom.yml` probe names port 8080 where the container listens only on 80, and `zipline`'s names
A read-only sweep of all 66 unique pins against the public registries, from DooPlex, never from a box `/api/health` where that app answers 404 — **while the compose healthcheck in the same file uses
(`felhom.eu/documentation/audits/UPDATE-ARC-STATE-2026-09-21.md` §3): `/api/healthcheck` and is correct**. A static sweep of all 53 templates comparing the two health
checks against each other finds both, plus `wger` and `home-assistant` as unmeasured candidates and
- **46 of 58 exact pins are behind upstream today**; 39 within a major, 7 across one. The seven were `adventurelog` as a false positive. Filed as **R-618** with the proposed gate; deliberately not
all pinned 2026-07-18: nextcloud 34→35, paperless-ngx 2.20→3.2, claper 2.5→3.0, gokapi 1.9→2.2, fixed in the same commit as the harness change.
homepage 1.13→2.4, sparkyfitness 0.17→1.7 (two images).
- **6 of the 7 measurable floating pins have been repushed upstream since the catalog set them** —
`postgres:16-alpine`, `postgres:15-alpine`, `redis:7-alpine`, `mariadb:11.4`, `mariadb:12.3`,
`postgis:16-3.5-alpine`. Only `mariadb:11.6` has not. This is R-446 measured rather than theorised,
and it is the case for recording digests at push time (put to the operator as `09` §3b Q6).
- **`msdeluise/plant-it:0.10.0` is gone upstream.** The repo's own gate says INCONCLUSIVE (it refuses
to read a `denied` stderr as "dead", correctly); two independent signals say it really is gone —
Docker Hub's catalog API answers `object not found` for the whole repository, and the upstream
GitHub repo 404s. The app is already `lifecycle: abandoned`, so this is the expected end state, not
an incident. A deployed plant-it survives; it can never be redeployed.
## Rows
**R-469 PARTLY CLOSED** (MariaDB lifted; the PostgreSQL half stands until R-463).
**R-450 half SHIPPED** (the own-edge clause; the automatic-within-a-major half is Slice 6 and awaits
`09` §3b Q1–Q4). **R-605 filed** — a catalog gate that refused to run and one that ran and could not
decide print the same word.
+32
View File
@@ -81,6 +81,38 @@ EDGES = {
"E3b": dict(app="bookstack", note="AUTHORED step: engine half alone", "E3b": dict(app="bookstack", note="AUTHORED step: engine half alone",
frm={"bookstack": "lscr.io/linuxserver/bookstack:26.05.2", "bookstack-db": "mariadb:11.6"}, frm={"bookstack": "lscr.io/linuxserver/bookstack:26.05.2", "bookstack-db": "mariadb:11.6"},
to={"bookstack": "lscr.io/linuxserver/bookstack:26.05.2", "bookstack-db": "mariadb:12.3"}), to={"bookstack": "lscr.io/linuxserver/bookstack:26.05.2", "bookstack-db": "mariadb:12.3"}),
# --- added 2026-09-21 by the update night (R-462's widening) -------------------------------
# Every one of these was run BOX-SIDE FIRST, through the product's own guarded Update on guest
# 9202, with the data read back through the app's own front door before and after. They are
# here so the same edge also gets its ABORT step, which the box deliberately does not offer
# (`09` §6.1: the box never puts the old version back by itself, because whether the old image
# starts on migrated data is per-app and cannot be predicted).
#
# These are REAL UPSTREAM MOVES that existed on 2026-09-21 and that the catalog has NOT made.
# They are candidates the operator may promote; the harness is where the abort answer for each
# of them comes from.
"U1": dict(app="privatebin", note="real upstream move 2.0.5 -> 2.0.6; file-backed, no database",
frm={"privatebin": "privatebin/pdo:2.0.5"},
to={"privatebin": "privatebin/pdo:2.0.6"}),
"U2": dict(app="docmost", note="real upstream move 0.95.0 -> 0.96.0; PostgreSQL CONSTANT across it",
frm={"docmost": "docmost/docmost:0.95.0", "docmost-postgres": "postgres:16-alpine"},
to={"docmost": "docmost/docmost:0.96.0", "docmost-postgres": "postgres:16-alpine"}),
"U3": dict(app="bookstack", note="real upstream move 26.05.2 -> 26.05.5; MariaDB CONSTANT across it",
frm={"bookstack": "lscr.io/linuxserver/bookstack:26.05.2", "bookstack-db": "mariadb:12.3"},
to={"bookstack": "lscr.io/linuxserver/bookstack:26.05.5", "bookstack-db": "mariadb:12.3"}),
"U4": dict(app="actualbudget", note="real upstream move 26.7.0 -> 26.9.0; SQLite in its own volume",
frm={"actualbudget": "actualbudget/actual-server:26.7.0"},
to={"actualbudget": "actualbudget/actual-server:26.9.0"}),
"U5": dict(app="navidrome", note="real upstream move 0.63.2 -> 0.64.0; DATABASE HALF ONLY",
frm={"navidrome": "deluan/navidrome:0.63.2"},
to={"navidrome": "deluan/navidrome:0.64.0"}),
"U6": dict(app="audiobookshelf", note="real upstream move 2.35.1 -> 2.36.1; DATABASE HALF ONLY",
frm={"audiobookshelf": "ghcr.io/advplyr/audiobookshelf:2.35.1"},
to={"audiobookshelf": "ghcr.io/advplyr/audiobookshelf:2.36.1"}),
"U7": dict(app="vikunja", note="real upstream move 2.3.0 -> 2.6.0; the app migrates its own SQLite",
frm={"vikunja": "vikunja/vikunja:2.3.0"},
to={"vikunja": "vikunja/vikunja:2.6.0"}),
} }
+230 -1
View File
@@ -265,4 +265,233 @@ class BookStack:
return found is True return found is True
FIXTURES = {"privatebin": PrivateBin(), "docmost": Docmost(), "bookstack": BookStack()} # ---------------------------------------------------------------------------------------------
# Added 2026-09-21 by the update night (R-462's widening). Each of these was written and PROVEN
# box-side first, on guest 9202 through the product's own guarded Update, and then ported here so
# the same edge can be run on the harness venue with its ABORT step. The box-side evidence is
# `felhom.eu/documentation/audits/update-night-2026-09-21/apps/<app>/`.
#
# The port is mechanical and one thing changes: box-side the app is reached through traefik with a
# `Host:` header, here through the container's own IP. The SEED ROUTE is identical, and that is the
# expensive half.
# ---------------------------------------------------------------------------------------------
class ActualBudget:
"""Actual's own bootstrap API sets the server password; its own login proves it survived.
Actual keeps its data in SQLite inside its own volume and performs its own schema migration on
start, so this single seed is the whole data half.
"""
port = 5006
container = "actualbudget"
def _base(self, ipfn):
ip = ipfn(self.container)
return f"http://{ip}:{self.port}" if ip else ""
def seed(self, ipfn, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/", {"200", "302"}, say=say):
say(" actualbudget: no container IP, or the app never answered")
return None
pw = "Spike-" + secrets.token_hex(10)
rc, code, out = _curl(base + "/account/bootstrap", "-H", "Content-Type: application/json",
data=json.dumps({"password": pw}), method="POST")
say(f" actualbudget: /account/bootstrap http={code} :: {out[:140]}")
if rc != 0 or '"status":"ok"' not in out:
return None
return {"pw": pw}
def verify(self, ipfn, seeded, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/", {"200", "302"}, tries=24, say=say):
return False
def login(p):
return _curl(base + "/account/login", "-H", "Content-Type: application/json",
data=json.dumps({"loginMethod": "password", "password": p}),
method="POST")
# the fixture's own negative control, run on every verify
rc, code, out = login("wrong-" + secrets.token_hex(6))
if '"status":"ok"' in out:
say(" actualbudget: READBACK IS UNUSABLE — a wrong password authenticated")
return False
rc, code, out = login(seeded["pw"])
ok = '"status":"ok"' in out
say(f" actualbudget: login with the seeded password http={code} ok={ok}")
return ok
class Navidrome:
"""Navidrome's own /auth/createAdmin makes the first account; its own /auth/login proves it
survived. LIMITATION: this is the DATABASE half. Navidrome's other half is the music library on
the drive, which the harness does not populate."""
port = 4533
container = "navidrome"
def _base(self, ipfn):
ip = ipfn(self.container)
return f"http://{ip}:{self.port}" if ip else ""
def seed(self, ipfn, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/", {"200", "302"}, say=say):
say(" navidrome: no container IP, or the app never answered")
return None
user = "spike" + secrets.token_hex(3)
pw = "Spike-" + secrets.token_hex(10)
rc, code, out = _curl(base + "/auth/createAdmin", "-H", "Content-Type: application/json",
data=json.dumps({"username": user, "password": pw}), method="POST")
say(f" navidrome: createAdmin http={code}")
if rc != 0 or code not in ("200", "201"):
say(f" navidrome: refused {out[:200]}")
return None
return {"user": user, "pw": pw}
def verify(self, ipfn, seeded, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/", {"200", "302"}, tries=24, say=say):
return False
def login(p):
return _curl(base + "/auth/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": seeded["user"], "password": p}),
method="POST")
rc, code, _ = login("wrong-" + secrets.token_hex(6))
if code in ("200", "201"):
say(" navidrome: READBACK IS UNUSABLE — a wrong password authenticated")
return False
rc, code, out = login(seeded["pw"])
ok = code in ("200", "201")
say(f" navidrome: login as the seeded user http={code} ok={ok}")
return ok
class AudiobookShelf:
"""audiobookshelf's own /init creates the first root account; its own /login proves it
survived. LIMITATION: the DATABASE half only — the library on the drive is not populated."""
port = 80
container = "audiobookshelf"
def _base(self, ipfn):
ip = ipfn(self.container)
return f"http://{ip}:{self.port}" if ip else ""
def seed(self, ipfn, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/status", {"200"}, say=say):
say(" audiobookshelf: no container IP, or the app never answered /status")
return None
user = "spike" + secrets.token_hex(3)
pw = "Spike-" + secrets.token_hex(10)
rc, code, out = _curl(base + "/init", "-H", "Content-Type: application/json",
data=json.dumps({"newRoot": {"username": user, "password": pw}}),
method="POST")
say(f" audiobookshelf: /init http={code}")
if rc != 0 or code not in ("200", "204"):
say(f" audiobookshelf: refused {out[:200]}")
return None
return {"user": user, "pw": pw}
def verify(self, ipfn, seeded, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/status", {"200"}, tries=24, say=say):
return False
def login(p):
return _curl(base + "/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": seeded["user"], "password": p}),
method="POST")
rc, code, _ = login("wrong-" + secrets.token_hex(6))
if code == "200":
say(" audiobookshelf: READBACK IS UNUSABLE — a wrong password authenticated")
return False
rc, code, out = login(seeded["pw"])
ok = code == "200" and seeded["user"] in out
say(f" audiobookshelf: login as the seeded root http={code} ok={ok}")
return ok
class Vikunja:
"""Vikunja's own REST API: register, log in, create a project, read the project back. Four
calls, all the app's own front door, and the readback is a real authenticated GET."""
port = 3456
container = "vikunja"
def _base(self, ipfn):
ip = ipfn(self.container)
return f"http://{ip}:{self.port}" if ip else ""
def _token(self, base, seeded, pw=None):
rc, code, out = _curl(base + "/api/v1/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": seeded["user"],
"password": pw or seeded["pw"]}), method="POST")
if code != "200":
return None
try:
return json.loads(out)["token"]
except Exception:
return None
def seed(self, ipfn, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/api/v1/info", {"200"}, say=say):
say(" vikunja: no container IP, or the app never answered /api/v1/info")
return None
user = "spike" + secrets.token_hex(3)
pw = "Spike-" + secrets.token_hex(10)
rc, code, out = _curl(base + "/api/v1/register", "-H", "Content-Type: application/json",
data=json.dumps({"username": user, "password": pw,
"email": f"{user}@gate.invalid"}), method="POST")
say(f" vikunja: register http={code}")
if code not in ("200", "201"):
say(f" vikunja: refused {out[:200]}")
return None
seeded = {"user": user, "pw": pw}
tok = self._token(base, seeded)
if not tok:
say(" vikunja: could not log in after registering")
return None
title = "spike-" + secrets.token_hex(5)
rc, code, out = _curl(base + "/api/v1/projects", "-H", f"Authorization: Bearer {tok}",
"-H", "Content-Type: application/json",
data=json.dumps({"title": title}), method="POST")
say(f" vikunja: create project http={code}")
if code not in ("200", "201"):
say(f" vikunja: project refused {out[:200]}")
return None
seeded["title"] = title
seeded["pid"] = json.loads(out).get("id")
return seeded
def verify(self, ipfn, seeded, say):
base = self._base(ipfn)
if not base or not _wait_http(base + "/api/v1/info", {"200"}, tries=24, say=say):
return False
if self._token(base, seeded, pw="wrong-" + secrets.token_hex(6)):
say(" vikunja: READBACK IS UNUSABLE — a wrong password authenticated")
return False
tok = self._token(base, seeded)
if not tok:
say(" vikunja: the seeded account no longer authenticates")
return False
rc, code, out = _curl(base + f"/api/v1/projects/{seeded['pid']}",
"-H", f"Authorization: Bearer {tok}")
ok = code == "200" and seeded["title"] in out
say(f" vikunja: readback of the seeded project http={code} ok={ok}")
return ok
FIXTURES = {
"privatebin": PrivateBin(),
"docmost": Docmost(),
"bookstack": BookStack(),
"actualbudget": ActualBudget(),
"navidrome": Navidrome(),
"audiobookshelf": AudiobookShelf(),
"vikunja": Vikunja(),
}