Files
felhom.eu/documentation/audits/catalog-currency-2026-09-30/04-analyse.py
T
admin 25cb3eb9c1
gates / gates (push) Successful in 29s
The last six PostgreSQL apps decided; the gate's wait seen live by day; catalog currency; stale rows
- R-463 CLOSED: rallly, outline, sparkyfitness -> PostgreSQL 18 (catalog 25ffd89 / aeb0cd6 / 1666572),
  each proven on the bench and on 9202 through the guarded Update with one undo case; zipline,
  adventurelog, immich stay on 16 by 09 decision 42's rule (upstream runs 16 / 16 / 14).
- Part F: bookstack, kimai, audiobookshelf, n8n, navidrome, grafana, komga moved on both venues;
  immich not (R-732: its first start OOM-killed its database on the bench).
- R-687 item 4 PROVEN LIVE on demo-hp: two deferrals while the leg stepped two apps, the whole-guest
  backup on the first poll after, success; config + window put back and read back.
- Catalog currency audit (Part D): 25/53 behind inside a major, 19 across; night-updatable 28 -> 31 (+1).
- R-446 and R-440 narrowed (measured on demo-hp); R-624 corrected (outline, rallly, zipline have routes);
  R-548 note (demo-hp's local tier refused for space since 09-27).
- New rows: R-730 (the ISO 1.29.0 build commit cannot be proven -> no tag; installer-v* is the script's
  line), R-731 (tag-shape switches), R-732. Register 361 -> 364.
- 09 §3: the 2026-09-30 operator notes (by day; Tester-2 pre-checks done; decision 52 not needed, not
  recorded); §6.4 dated currency note. STATUS, CONTEXT, REPORT-pg-last-six-2026-09-30.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 14:47:12 +02:00

219 lines
11 KiB
Python

#!/usr/bin/env python3
"""Turn 01-currency-raw.json + the catalog's ladders + the fixture registries into the audit tables.
Offline: reads files only. Usage: python3 04-analyse.py <catalog-root> <outdir>
THE NIGHT RULE, as the controller codes it (felhom-controller d48da6c, internal/stacks/unattended.go
legCandidate L392-463, with nextLadderStep ladder.go L152-199). Catalog-side part only — a box can
still skip for its own reasons (held, stopped, switch off, failed_before, no fresh whole copy, W+5h,
or a guarded-update refusal: no_backup / memory / disk / busy):
* the template has an update_ladder (else no_test_record, L423-424);
* the NEWEST entry whose `from` equals the box's pinned images EXACTLY, every service (sameRefs,
L426-432) — none: older_than_ladder, or no_test_record when the box is at the head (L433-439);
* that entry's verdict is "proven" (L441), and marks.needs_person is null/blank (L444);
* marks.files_may_change → only with a fresh whole copy on the box (L450-461) — "conditional".
"""
import json
import os
import sys
CAT, OUT = sys.argv[1], sys.argv[2]
sys.path.insert(0, os.path.join(CAT, "scripts"))
import ladder # noqa: E402
import upgrade_fixtures as fx1 # noqa: E402 (bench-native fixtures)
# the box fixture modules import nothing heavy; read their registries as the bench does
import upgrade_fixtures_box as fxb # noqa: E402
import upgrade_fixtures_box28 as fxb28 # noqa: E402
rows = json.load(open(os.path.join(OUT, "01-currency-raw.json")))
# SHAPE SWITCHES found by the control (09-shape-switch-check.txt, detail in 10-shape-switch-detail.txt):
# the upstream now releases under a DIFFERENT tag shape, so the shape rule read "up to date". Applied
# here by hand, each with the tag list that shows it. sonarr (a `5.14-2.0.0...-ls5` develop-style tag)
# and tandoor (a `dependabot-...` branch tag) were flagged by the same control and are NOT releases —
# left as measured.
SHAPE_SWITCH = {
"ghcr.io/gramps-project/grampsweb:v25.6.0": dict(newest_major="v25.6.0", newest_all="26.9.1",
behind_within_major=False, behind_across_major=True,
shape_note="upstream dropped the `v` prefix in 2026 (26.x.y); calendar versions, so the 25→26 step is a new year, counted as across"),
"jellyfin/jellyfin:10.11.11": dict(newest_major="10.11.11", newest_all="12.1",
behind_within_major=False, behind_across_major=True,
shape_note="upstream 12.x publishes two-part tags (`12.1`); 12.0 went through rc1..rc7"),
"kimai/kimai2:apache-2.57.0": dict(newest_major="2.67.0", newest_all="2.67.0",
behind_within_major=True, behind_across_major=False,
shape_note="upstream stopped publishing `apache-` tags after 2.57.0; plain `2.67.0` exists (whether it is the same apache variant is NOT checked)"),
}
for r in rows:
if r["ref"] in SHAPE_SWITCH:
r.update(SHAPE_SWITCH[r["ref"]])
tdir = os.path.join(CAT, "templates")
apps = sorted(os.listdir(tdir))
DB_MARKERS = ("postgres", "mariadb", "mysql", "postgis", "mongo")
NO_SEED_BY_DESIGN = {"vaultwarden": "closed sign-up (R-624)", "zipline": "closed sign-up (R-624)"}
def fixture_state(app):
box = fxb.FIXTURES.get(app) or fxb28.FIXTURES28.get(app)
if app in NO_SEED_BY_DESIGN:
return "no route by design: " + NO_SEED_BY_DESIGN[app]
if box is not None and type(box).__name__ == "NoRoute":
return "no route: " + box.tried.split(";")[0][:70]
if app == "gitea":
return "fixture exists, fails at the web installer (R-624, fixable)"
if box is not None or app in fx1.FIXTURES:
return "fixture exists"
return "fixture needed"
def data_tier(app, imgs, fy_text):
engines = [s for s, r in imgs.items() if any(m in r.split(":")[0].lower() for m in DB_MARKERS)]
mandatory = "class: mandatory" in fy_text
if engines or mandatory:
why = []
if engines:
why.append("DB engine: " + ", ".join(engines))
if mandatory:
why.append("household files (backup class mandatory)")
return 1, "; ".join(why)
if app in ("bentopdf", "onlyoffice", "homepage", "glance"):
return 3, "little or no household data (stateless or config only)"
return 2, "household data in the app's own volume (SQLite / files)"
img_by_app = {}
for r in rows:
img_by_app.setdefault(r["app"], []).append(r)
ladder_rows, plan_rows = [], []
count_a_uncond = count_a_cond = count_b = 0
a_apps, a_cond_apps, b_apps = [], [], []
for app in apps:
d = os.path.join(tdir, app)
fy_text = open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read()
imgs = ladder.images_in(open(os.path.join(d, "docker-compose.yml"), encoding="utf-8").read())
entries, _r, errs = ladder.parse(fy_text)
n = len(entries)
proven = sum(1 for e in entries if e.get("verdict") == "proven")
backfilled = sum(1 for e in entries if e.get("backfilled"))
needs = [e["marks"]["needs_person"] for e in entries if (e.get("marks") or {}).get("needs_person")]
fmc = sum(1 for e in entries if (e.get("marks") or {}).get("files_may_change"))
conv = sum(1 for e in entries if e.get("engine_conversion"))
head_eq = (entries[-1]["to"] == imgs) if entries else None
# the step a box AT THE PREVIOUS PIN (= head.from) would be offered tonight, catalog-side
night = "no ladder: never (no_test_record)"
if entries:
pinned = entries[-1]["from"]
idx = max(i for i, e in enumerate(entries) if e["from"] == pinned)
e = entries[idx]
np_ = (e.get("marks") or {}).get("needs_person")
if e.get("verdict") != "proven":
night = "skip: not_proven"
elif np_ and str(np_).strip():
night = "skip: needs_person"
elif (e.get("marks") or {}).get("files_may_change"):
night = "conditional: files_may_change (needs a fresh whole copy)"
count_a_cond += 1
a_cond_apps.append(app)
else:
night = "yes"
count_a_uncond += 1
a_apps.append(app)
if proven:
count_b += 1
b_apps.append(app)
since = ""
for l in fy_text.splitlines():
if l.startswith("catalog_since:"):
since = l.split(":", 1)[1].strip().strip('"')
ladder_rows.append((app, n, proven, backfilled, len(needs), fmc, conv, head_eq, night, since))
tier, why = data_tier(app, imgs, fy_text)
ir = img_by_app.get(app, [])
wm = sum(1 for r in ir if r.get("behind_within_major"))
am = sum(1 for r in ir if r.get("behind_across_major"))
plan_rows.append((tier, -(1 if n == 0 else 0), -wm, -am, app, why, n, wm, am, fixture_state(app)))
# ---- per-image table
def yn(v):
return "yes" if v else ("no" if v is False else "?")
lines = ["| app | service | pinned | newest same major | newest at all | behind in major | behind across major | note |",
"|---|---|---|---|---|---|---|---|"]
stats = {"images": 0, "exact": 0, "floating": 0, "internal": 0, "unmeasured": 0}
apps_in, apps_across, apps_float_only = set(), set(), set()
img_in = img_across = 0
for r in rows:
stats["images"] += 1
note = []
if r["status"] == "internal":
stats["internal"] += 1
lines.append(f"| {r['app']} | {r['service']} | `{r['tag']}` | — | — | n/a | n/a | Felhom's own image, not upstream |")
continue
if r["status"] != "ok":
stats["unmeasured"] += 1
lines.append(f"| {r['app']} | {r['service']} | `{r['tag']}` | ? | ? | ? | ? | NOT MEASURED: {r.get('error','')[:60]} |")
continue
kind = "floating (line)" if r["floating"] else "exact"
stats["floating" if r["floating"] else "exact"] += 1
if r["floating"]:
note.append("floating (line)")
if r.get("moved_since_tested") is False:
note.append("digest = ladder's tested digest")
elif r.get("moved_since_tested"):
note.append("digest MOVED since tested")
elif r.get("current_digest"):
note.append("no ladder digest to compare")
if r.get("shape_note"):
note.append("SHAPE SWITCH: " + r["shape_note"])
if r.get("retried_after_429"):
note.append("measured on retry after ghcr 429")
wm, am = r.get("behind_within_major"), r.get("behind_across_major")
if wm:
img_in += 1
apps_in.add(r["app"])
if am:
img_across += 1
apps_across.add(r["app"])
lines.append(f"| {r['app']} | {r['service']} | `{r['tag']}` | `{r.get('newest_major') or '—'}` | `{r.get('newest_all') or '—'}` | {yn(wm)} | {yn(am)} | {'; '.join(note)} |")
exact_rows = [r for r in rows if r["status"] == "ok" and not r["floating"]]
exact_in = {r["app"] for r in exact_rows if r.get("behind_within_major")}
exact_across = {r["app"] for r in exact_rows if r.get("behind_across_major")}
float_rows = [r for r in rows if r["status"] == "ok" and r["floating"]]
float_in = {r["app"] for r in float_rows if r.get("behind_within_major")}
float_across = {r["app"] for r in float_rows if r.get("behind_across_major")}
behind_any = apps_in | apps_across
summary = {
"unique_pins": len({r["ref"] for r in rows}), "unique_floating_pins": sorted({r["ref"] for r in rows if r.get("floating")}),
"images": stats, "image_rows_behind_within_major": img_in, "image_rows_behind_across_major": img_across,
"apps_behind_within_major_any_image": sorted(apps_in),
"apps_behind_across_major_any_image": sorted(apps_across),
"apps_behind_within_major_exact_pins_only": sorted(exact_in),
"apps_behind_across_major_exact_pins_only": sorted(exact_across),
"apps_behind_within_major_floating_only": sorted(float_in),
"apps_behind_across_major_floating_only": sorted(float_across),
"apps_behind_at_all": sorted(behind_any),
"night_a_unconditional": a_apps, "night_a_conditional": a_cond_apps, "night_b_any_proven": b_apps,
}
json.dump(summary, open(os.path.join(OUT, "05-summary.json"), "w"), indent=1)
open(os.path.join(OUT, "06-image-table.md"), "w").write("\n".join(lines) + "\n")
ll = ["| app | entries | proven | of which backfilled | needs_person | files_may_change | engine conversion | head = compose | box one step behind, tonight | catalog_since |",
"|---|---|---|---|---|---|---|---|---|---|"]
for (app, n, p, bf, np_, fmc, conv, heq, night, since) in ladder_rows:
ll.append(f"| {app} | {n} | {p} | {bf} | {np_} | {fmc} | {conv} | {'—' if heq is None else ('yes' if heq else 'NO')} | {night} | {since} |")
open(os.path.join(OUT, "07-ladder-table.md"), "w").write("\n".join(ll) + "\n")
plan_rows.sort()
pl = ["| rank | app | data at risk | ladder entries | images behind in major | across major | fixture |",
"|---|---|---|---|---|---|---|"]
for i, (tier, _nl, _wm, _am, app, why, n, wm, am, fs) in enumerate(plan_rows, 1):
pl.append(f"| {i} | {app} | T{tier}: {why} | {n} | {wm} | {am} | {fs} |")
open(os.path.join(OUT, "08-plan-table.md"), "w").write("\n".join(pl) + "\n")
print(json.dumps({k: (len(v) if isinstance(v, list) else v) for k, v in summary.items()}, indent=1))
print("night (a) unconditional", count_a_uncond, a_apps)
print("night (a) conditional", count_a_cond, a_cond_apps)
print("night (b) any proven", count_b)