Files
app-catalog-felhom.eu/scripts/onboarding_gaps.py
T
admin dc0ab8b2a8
gates / gates (push) Successful in 2s
New-app checklist: reviewed, a gate (onboarding), the wger pilot record, the existing apps' gap page
NEW-APP-CHECKLIST.md: the reviewer's draft reviewed - 60 rows in 10 groups, each with how/why and a since date;
7 rows added, 16 sharpened, 9 wrong claims fixed. onboarding/_TEMPLATE.md (one line per id), onboarding/wger.md
(the pilot, exempt app, 11 open rows each a register row), onboarding/EXISTING-APPS-GAPS.md (read only, from
scripts/onboarding_gaps.py). Gate onboarding (scripts/check-onboarding.py) in --fast: a template directory not
among the 53 published before 2026-10-01 needs a complete record; decoys in test_gate_decoys.py (16 cases, 5 gate
mutants seen red). CLAUDE.md, REUSE.md 5, README point to it. No template changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 15:20:52 +02:00

248 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""onboarding_gaps.py — what the catalog's FILES already show, per checklist group, for the 53 exempt apps.
Writes onboarding/EXISTING-APPS-GAPS.md. READ ONLY: no network, no containers, no re-testing. It answers "which of
the NEW-APP-CHECKLIST.md groups does the catalog already hold a record for, per old app" from what is committed:
the templates, the ladder entries, the fixture tables, FIRST-ADMIN.md, README.md and the 2026-08-02 persistence
sweep. A cell is a signal the files carry, not a measurement made today — "shown" means a file says it, never
that it is still true. This is information, not work (operator default 2026-10-01).
Run from the repo root (PyYAML needed — this is a local report, not a gate):
python3 scripts/onboarding_gaps.py # rewrite onboarding/EXISTING-APPS-GAPS.md
python3 scripts/onboarding_gaps.py --check # exit 1 if the committed page differs from a fresh run
"""
import datetime
import io
import json
import os
import re
import subprocess
import sys
import yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "onboarding", "EXISTING-APPS-GAPS.md")
SWEEP = os.path.join(ROOT, "audits", "persistence-sweep-2026-08-02", "state", "gate.log")
sys.path.insert(0, os.path.join(ROOT, "scripts"))
def exempt():
src = io.open(os.path.join(ROOT, "scripts", "check-onboarding.py"), encoding="utf-8").read()
return sorted(re.search(r'EXEMPT = frozenset\("""(.*?)"""', src, re.S).group(1).split())
def mb(v):
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*([MG])", str(v or ""))
if not m:
return None
return int(float(m.group(1)) * (1024 if m.group(2) == "G" else 1))
def fixture_apps():
names = set()
for f in ("upgrade_fixtures.py", "upgrade_fixtures_box.py", "upgrade_fixtures_box28.py"):
src = io.open(os.path.join(ROOT, "scripts", f), encoding="utf-8").read()
for block in re.findall(r"FIXTURES(?:28)?(?: = |\.update\()\{(.*?)\n\}", src, re.S):
names |= set(re.findall(r'^\s+"([a-z0-9-]+)":', block, re.M))
return names
def sweep_verdicts():
"""The FIRST run's lists in the 2026-08-02 sweep (53 in scope). BROKEN and UNDETERMINED are named; the rest
of the 53 were CLEAN."""
out = {}
if not os.path.isfile(SWEEP):
return out
text = io.open(SWEEP, encoding="utf-8").read()
first = text.split("of 53 in scope")[0]
sec = None
for line in first.splitlines():
if line.startswith("BROKEN"):
sec = "broken"
elif line.startswith("UNDETERMINED"):
sec = "undetermined"
elif sec and re.match(r"^ ([a-z0-9-]+)(:|$)", line):
out[re.match(r"^ ([a-z0-9-]+)", line).group(1)] = sec
return out
def first_admin_rows():
rows = {}
for line in io.open(os.path.join(ROOT, "FIRST-ADMIN.md"), encoding="utf-8"):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) >= 6 and re.match(r"^\**[a-z0-9-]+\**$", cells[0]) and cells[1][:1].isdigit():
rows[cells[0].strip("*")] = {"class": cells[1], "measured": "**M" in cells[5] or cells[5].startswith("M")}
return rows
def main(argv):
apps = exempt()
fx = fixture_apps()
sweep = sweep_verdicts()
fa = first_admin_rows()
readme = io.open(os.path.join(ROOT, "README.md"), encoding="utf-8").read()
rows, tally = [], {g: 0 for g in range(9)}
notes = {"mem_mismatch": [], "no_limit": [], "maria": [], "pg18": []}
for app in apps:
d = os.path.join(ROOT, "templates", app)
fy_text = io.open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read()
fy = yaml.safe_load(fy_text) or {}
dc = yaml.safe_load(io.open(os.path.join(d, "docker-compose.yml"), encoding="utf-8")) or {}
svcs = dc.get("services") or {}
ai = fy.get("app_info") or {}
res = fy.get("resources") or {}
cell = {}
# 0 Fit
life = (fy.get("lifecycle") or "available")
g0 = life == "available" and bool(ai.get("use_cases")) and "pi_compatible" in res
cell[0] = ("yes" if g0 else "—") + ("" if life == "available" else " (%s)" % life)
# 1 Images, start command, database — the files show the ENGINE rules only
engines, ok1 = [], True
for sn, s in svcs.items():
img = str(s.get("image", ""))
env = s.get("environment") or []
env = env if isinstance(env, list) else ["%s=%s" % kv for kv in env.items()]
if img.startswith("mariadb:"):
engines.append("mariadb")
if not any(str(e).replace(" ", "") in ("MARIADB_AUTO_UPGRADE=1", "MARIADB_AUTO_UPGRADE=\"1\"") for e in env):
ok1 = False
notes["maria"].append(app)
if re.match(r"^(postgres|postgis/postgis|ghcr\.io/immich-app/postgres):", img):
engines.append("pg")
if re.match(r"^postgres:18", img) and any(":/var/lib/postgresql/data" in str(v) for v in s.get("volumes") or []):
ok1 = False
notes["pg18"].append(app)
if ":latest" in img or ":" not in img.split("/")[-1]:
ok1 = False
cell[1] = ("engine rules hold" if engines else "no DB sidecar") if ok1 else "ENGINE RULE BROKEN"
g1 = ok1
# 2 Storage and backup
sv = sweep.get(app, "clean" if sweep else None)
hdd = bool(res.get("needs_hdd"))
g2 = sv == "clean" and (not hdd or "backup" in fy)
cell[2] = "%s%s" % ("sweep %s" % sv if sv else "no sweep",
(", backup classes" if "backup" in fy else ", HDD w/o classes") if hdd else "")
# 3 Accounts and strangers
r = fa.get(app)
mech = [k for k in ("after_install", "setup_gate", "signup_block", "after_setup") if fy.get(k)]
g3 = bool(r and r["measured"])
cell[3] = ("class %s, %s" % (r["class"], "measured" if r["measured"] else "read only") if r else "no row") + \
((" + " + "/".join(mech)) if mech else "")
# 4 Health
all_hc = all(s.get("healthcheck") for s in svcs.values())
probe = bool((fy.get("healthcheck") or {}).get("checks"))
# the probe dials the container named like the stack, or the one its `container:` names (R-630)
targets = {c.get("container") for c in (fy.get("healthcheck") or {}).get("checks") or [] if c.get("container")}
names = {s.get("container_name") for s in svcs.values()}
named = (app in names) if not targets else targets <= names
g4 = all_hc and probe and named
cell[4] = "yes" if g4 else ", ".join(x for x, ok in (("hc missing", all_hc), ("no probe", probe),
("probe container not in compose", named)) if not ok)
# 5 Resources
lims = [mb(((s.get("deploy") or {}).get("resources") or {}).get("limits", {}).get("memory")) for s in svcs.values()]
every = all(lims)
total = sum(x for x in lims if x)
ml = mb(res.get("mem_limit"))
if ml != total:
notes["mem_mismatch"].append("%s (%s vs %d)" % (app, res.get("mem_limit"), total))
if not every:
notes["no_limit"].append(app)
ladder = [json.loads(l.strip()[2:]) for l in fy_text.splitlines() if l.strip().startswith('- {"from"')]
watched = [e for e in ladder if e.get("memory_peak_pct") is not None]
g5 = every and ml == total and bool(watched)
cell[5] = ("watched %.0f%%" % watched[-1]["memory_peak_pct"] if watched else "no watch") + \
("" if ml == total else ", mem_limit≠sum") + ("" if every else ", a service w/o limit")
# 6 Updates
proven = [e for e in ladder if e.get("verdict") == "proven" and not e.get("backfilled")]
g6 = bool(proven) and app in fx
cell[6] = "%d proven step(s)%s" % (len(proven), ", fixture" if app in fx else ", no fixture")
# 7 Mail — the files cannot say whether an app WANTS mail; only whether it is mapped
cell[7] = "smtp mapped" if fy.get("smtp_mapping") else "—"
# 8 Text and listing
en = bool((fy.get("i18n") or {}).get("en"))
txt = all(ai.get(k) for k in ("tagline", "use_cases", "first_steps"))
# README's App Catalog table names an app by display name, so the row is found by its subdomain cell
listed = re.search(r"\|\s*%s\.\*\s*\|" % re.escape(str(fy.get("subdomain", "\0"))), readme) is not None
g8 = en and txt and listed and bool(r)
cell[8] = "yes" if g8 else ", ".join(x for x, ok in (("no en", en), ("app_info gap", txt),
("not in README", listed), ("no FIRST-ADMIN row", bool(r))) if not ok)
for g, v in enumerate((g0, g1, g2, g3, g4, g5, g6, None, g8)):
if v:
tally[g] += 1
rows.append((app, cell))
n = len(apps)
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip()
L = []
L.append("# EXISTING APPS — what the catalog already shows, per checklist group")
L.append("")
L.append("> Generated by `scripts/onboarding_gaps.py` from committed files (catalog `%s`). **Do not edit by hand.**" % sha)
L.append("> Read only: nothing was re-tested. A cell is what a FILE says, not a measurement made today. The 53 apps")
L.append("> published before the checklist (2026-10-01) are exempt from the onboarding gate; this page is information,")
L.append("> not work (operator default 2026-10-01, may be reversed).")
L.append("")
L.append("## Headline — apps whose files show the group covered (of %d)" % n)
L.append("")
L.append("| group | covered | what \"covered\" means here (the signal read) |")
L.append("|---|---|---|")
defs = [
("0 Fit", "`lifecycle` available, `use_cases` and `pi_compatible` present — licence, telemetry, internet need and phone apps are recorded nowhere"),
("1 Images, start command, DB", "pins clean and the engine rules hold (MariaDB auto-upgrade, PG 18 mount) — entrypoint switches, the production server, migrations and secrets read (1.4–1.9) are recorded for NO app"),
("2 Storage and backup", "the 2026-08-02 persistence sweep read the app CLEAN, and an HDD app carries `backup:` classes — no restore round trip is recorded per app"),
("3 Accounts and strangers", "a FIRST-ADMIN.md row whose source is MEASURED on a box — lock-out (3.6) is recorded only for the R-752 apps"),
("4 Health", "every service has a compose healthcheck, a controller probe exists, the exposed container is named like the stack — no negative control is recorded"),
("5 Resources", "every service limited, `mem_limit` = the sum, and a ladder entry carries a measured memory watch — no first-start-from-birth watch is recorded except immich's"),
("6 Updates", "a proven (not backfilled) ladder step AND an upgrade fixture"),
("7 Mail", "not countable from files: whether an app WANTS mail is not recorded; the mapped count is below"),
("8 Text and listing", "English block, tagline + use_cases + first_steps, listed in README, a FIRST-ADMIN row"),
]
for g, (name, d) in enumerate(defs):
L.append("| %s | %s | %s |" % (name, "—" if g == 7 else "%d / %d" % (tally[g], n), d))
L.append("")
L.append("Mail: %d app(s) carry `smtp_mapping`." % sum(1 for _a, c in rows if c[7] == "smtp mapped"))
L.append("")
L.append("## Found while computing this page")
L.append("")
L.append("- `mem_limit` differs from the sum of the compose limits (REUSE.md §2 says equal): %d — %s."
% (len(notes["mem_mismatch"]), ", ".join(notes["mem_mismatch"]) or "none"))
L.append("- A service with no memory limit: %s." % (", ".join(notes["no_limit"]) or "none"))
L.append("- A MariaDB sidecar without `MARIADB_AUTO_UPGRADE=1`: %s." % (", ".join(notes["maria"]) or "none"))
L.append("- A PostgreSQL 18 data mount at the old path: %s." % (", ".join(notes["pg18"]) or "none"))
L.append("")
L.append("## Per app")
L.append("")
L.append("| app | 0 fit | 1 images/DB | 2 storage | 3 accounts | 4 health | 5 resources | 6 updates | 7 mail | 8 text |")
L.append("|---|---|---|---|---|---|---|---|---|---|")
for app, c in rows:
L.append("| %s | %s |" % (app, " | ".join(c[g] for g in range(9))))
L.append("")
body = "\n".join(L)
if "--check" in argv:
cur = io.open(OUT, encoding="utf-8").read() if os.path.isfile(OUT) else ""
strip = lambda t: re.sub(r"catalog `[0-9a-f]+`", "catalog `X`", t)
if strip(cur) != strip(body):
print("EXISTING-APPS-GAPS.md is stale — run python3 scripts/onboarding_gaps.py")
return 1
print("EXISTING-APPS-GAPS.md is current")
return 0
io.open(OUT, "w", encoding="utf-8").write(body)
print("wrote %s — %d apps; covered per group: %s" % (os.path.relpath(OUT, ROOT), n,
", ".join("%d:%s" % (g, "-" if g == 7 else tally[g]) for g in range(9))))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))