#!/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 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)