25cb3eb9c1
gates / gates (push) Successful in 29s
- 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
219 lines
11 KiB
Python
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)
|