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
+32
View File
@@ -81,6 +81,38 @@ EDGES = {
"E3b": dict(app="bookstack", note="AUTHORED step: engine half alone",
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"}),
# --- 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
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(),
}