Grocy and LubeLogger: two new apps, each with its complete record
gates / gates (push) Failing after 14m4s

60 template directories, 58 offered. Records: onboarding/grocy.md and
onboarding/lubelogger.md, all 61 checks answered, none open. Evidence:
felhom.eu/documentation/audits/new-apps-2026-10-10/.

Grocy 4.7.1 from lscr.io/linuxserver/grocy (grocy publishes no image of its
own). SQLite in one volume, no HDD, ~30 MiB idle, amd64 + arm64, Hungarian UI
91.6 %. First admin class 3: it starts with its documented admin/admin and
after_install replaces that password with a generated one.

LubeLogger v1.7.3 from ghcr.io/hargata/lubelogger. LiteDB in one volume, no
HDD, ~60 MiB idle, amd64 + arm64. First admin class 1 — and it has to be: the
image ships EnableAuth=false, and with that the middleware mints a ticket with
the IsRootUser role for every visitor. Measured on a default start: a stranger
got 200 on /, on /api/vehicles and on /Home/Settings and CREATED A VEHICLE.
The compose entrypoint exports EnableAuth=true and the SHA-256 of a generated
name and password, so the app's own login is on before its first byte (at t+1 s
nothing listening, at t+2 s /api/vehicles already 401).

Two defects found by the walk and fixed before publishing:

1. An after_install command may not contain `$`. The controller runs every
   element through os.Expand and refuses one naming anything outside env:, so
   PHP cannot be inlined. On 9202 the first attempt came back
   `[pw argv dsn db i t e s n q h] not declared in env or has no value — not
   run` and the app sat behind its install hold with admin/admin in place. The
   code now lives in a file the compose entrypoint writes. Written into
   REUSE.md's after_install row as a trap.

2. Grocy's persisted config.php does not follow the image. The image copies
   config-dist.php only when that file is absent, so a volume written by 4.6.0
   and started under 4.7.1 answered HTTP 500 on every page — AUTH_CLASS names a
   class 4.7 moved — while its log said migrations done. The entrypoint now
   deletes the file at every start. The 4.6.0 -> 4.7.1 edge failed before this
   and is proven after it, on both venues.

Ladders, written by upgrade-test.py --write-ladder from both verdicts:
  grocy       4.6.0 -> 4.7.1   bench proven, box proven (guarded Update, 41 s)
  lubelogger  v1.7.2 -> v1.7.3 bench proven, box proven (25.6 s)
Checklist 6.3 came from a real failure, not a forced one: before the config.php
fix the product undid the same step in 346 s with the data intact.

REUSE.md: a fifth healthcheck family (bash /dev/tcp) for an image with no HTTP
client at all, measured in both directions.

Tool fixes made on the way: check-onboarding.py crashed on a Windows console
while printing which ids were open; upgrade-test.py's read_text/write_text used
the platform encoding and wrote a cp1250 em dash into a template full of
Hungarian.
This commit is contained in:
2026-10-10 12:31:34 +02:00
parent b38aa92317
commit b7f0f7cef5
16 changed files with 1190 additions and 127 deletions
+10
View File
@@ -38,6 +38,16 @@ import os
import re
import sys
# A gate's own console must not decide its verdict. The checklist titles this gate quotes back carry
# non-ASCII characters (an arrow in 3.2/3.3, accented Hungarian), and a Windows console here defaults
# to cp1250: on 2026-10-10 this gate hit UnicodeEncodeError while printing WHICH ids were still open,
# so an informative exit 1 arrived as a traceback. Same trap class as poster_facts_gate.py's.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError): # pragma: no cover - old Python, or a pipe
pass
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG_PREFIX = "app-catalog-felhom.eu/"
CUTOFF = "2026-10-01"
+47
View File
@@ -433,6 +433,28 @@
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "E-könyvtár böngészőben olvasóval, OPDS-sel, Kobo és KOReader szinkronnal"
},
"grocy": {
"app_info.add_people": "A Grocy-ban az admin bejelentkezés után a Felhasználók oldalon tudsz új családtagot felvenni, saját névvel és jelszóval. Regisztrálni magától senki nem tud.",
"app_info.default_creds": "admin / admin",
"app_info.first_steps[0]": "Nyisd meg a kamra.DOMAIN címet a böngészőben",
"app_info.first_steps[1]": "Jelentkezz be az admin névvel és a Beállítások oldalon látható jelszóval",
"app_info.first_steps[2]": "Vedd fel az első pár terméket (Termékek, majd Új termék), és állíts be nekik minimum mennyiséget",
"app_info.first_steps[3]": "A telefonodon a böngésző menüjéből tedd ki a kezdőlapra - így alkalmazásként nyílik, és a kamerával vonalkódot is tud olvasni",
"app_info.prerequisites[0]": "Nem kell hozzá külső merevlemez",
"app_info.tagline": "Háztartásvezetés: mi van otthon, mi fogy el, mit kell megvenni, kinek mi a dolga",
"app_info.use_cases[0]": "Nyilvántartja, mi van otthon, és szól, ha valami elfogyott vagy lejár",
"app_info.use_cases[1]": "Bevásárlólista, ami magától felveszi azt, ami a minimum alá csökkent",
"app_info.use_cases[2]": "Vonalkóddal gyorsan beírod, mit vettél, és mit használtál el - a telefon kamerájával is",
"app_info.use_cases[3]": "Recept és heti étrend, ami levonja a hozzávalókat a készletből",
"app_info.use_cases[4]": "Házimunkák és ismétlődő feladatok, hogy látszódjon, kinek mi a dolga",
"deploy_fields[ADMIN_PASSWORD].description": "Az első bejelentkezéshez: az admin név és ez a jelszó. Telepítéskor készül, csak te látod.",
"deploy_fields[ADMIN_PASSWORD].label": "Admin jelszó",
"deploy_fields[DOMAIN].description": "A szerver domain neve",
"deploy_fields[DOMAIN].label": "Domain",
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "Háztartásvezetés: mi van otthon, mi fogy el, bevásárlólista és házimunkák"
},
"home-assistant": {
"app_info.first_steps[0]": "Nyisd meg a ha.DOMAIN címet a böngészőben",
"app_info.first_steps[1]": "Hozd létre a tulajdonos fiókot az onboarding során",
@@ -606,6 +628,29 @@
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "Képregény és manga szerver OPDS támogatással"
},
"lubelogger": {
"app_info.add_people": "Jelentkezz be, és az Admin oldalon generálj egy meghívó tokent a családtag e-mail címéhez. Add oda neki a tokent: azzal regisztrál saját névvel és jelszóval. Magától, meghívó nélkül senki nem tud regisztrálni.",
"app_info.first_steps[0]": "Nyisd meg a garazs.DOMAIN címet, és jelentkezz be a Beállítások oldalon látható névvel és jelszóval",
"app_info.first_steps[1]": "Vedd fel az első autót (Add Vehicle): évjárat, gyártó, modell, rendszám",
"app_info.first_steps[2]": "Magyar nyelvhez: Settings, majd a nyelvek listájából töltsd le a hu_HU csomagot - az alkalmazás angolul indul",
"app_info.first_steps[3]": "Írd be az aktuális kilométeróra-állást és az utolsó szervizt, hogy az emlékeztetők számolni tudjanak",
"app_info.prerequisites[0]": "Nem kell hozzá külső merevlemez",
"app_info.tagline": "A családi autó teljes története egy helyen: szerviz, javítás, tankolás, költségek",
"app_info.use_cases[0]": "Minden szerviz, javítás és műszaki vizsga egy helyen, számlával együtt",
"app_info.use_cases[1]": "Tankolások és a tényleges fogyasztás - látszik, mennyibe kerül az autó",
"app_info.use_cases[2]": "Emlékeztetők: olajcsere, műszaki, biztosítás, gumicsere",
"app_info.use_cases[3]": "Több autó és akár motor vagy kerti gép külön nyilvántartása",
"app_info.use_cases[4]": "Számlák és papírok a rekord mellé csatolva, nem egy fiókban",
"deploy_fields[DOMAIN].description": "A szerver domain neve",
"deploy_fields[DOMAIN].label": "Domain",
"deploy_fields[ROOT_PASSWORD].description": "Telepítéskor generált jelszó. Enélkül az alkalmazás senkit nem engedne be.",
"deploy_fields[ROOT_PASSWORD].label": "Jelszó",
"deploy_fields[ROOT_USER].description": "Ezzel a névvel jelentkezel be. A család többi tagját már bentről tudod meghívni.",
"deploy_fields[ROOT_USER].label": "Felhasználónév",
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "A családi autó szerviz-, javítás- és tankolásnyilvántartása"
},
"mealie": {
"app_info.default_creds": "changeme@example.com / MyPassword",
"app_info.first_steps[0]": "Nyisd meg a mealie.DOMAIN címet a böngészőben",
@@ -1280,7 +1325,9 @@
"reasons": {
"dawarich": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules",
"grimmory": "new app 2026-10-02 (family gate); Hungarian reviewed: informal te, no kerjuk (ASCII scan with a positive control)",
"grocy": "New app 2026-10-10 (NEW-APP-CHECKLIST.md): the Hungarian was written for this template and reviewed with the onboarding record; every string has its English twin.",
"karakeep": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules. R-774 (2026-10-06, 09 §3 decision 148): one new first step, deliberate Hungarian addition — the official phone app sends crash reports to its makers (Sentry); te-form",
"lubelogger": "New app 2026-10-10 (NEW-APP-CHECKLIST.md): the Hungarian was written for this template and reviewed with the onboarding record; add_people was corrected to the route measured on 9202; every string has its English twin.",
"mealie": "R-747 (2026-10-06, 09 §3 decision 144): one new first step, deliberate Hungarian addition — five wrong logins lock the account for 1-2 hours, wait; te-form, no kerjuk",
"metube": "new app 2026-10-02 (family gate); Hungarian reviewed: informal te, no kerjuk (ASCII scan with a positive control)",
"papra": "R-593 (2026-10-05): deliberate Hungarian rewrite, not a translation — AUTH_SECRET carried SUBDOMAIN's sentence 'Az alkalmazás aldomainje' (copy-paste one field too low); the sentence moved to SUBDOMAIN and AUTH_SECRET got its own: 'A bejelentkezéseket aláíró titkos kulcs (automatikusan generált)'",
+50 -50
View File
@@ -193,7 +193,7 @@ def render(app: str, images: dict, workdir: Path, env: dict, template: str = Non
Substitution is per service and only on that service's own `image:` line — never a blind
string replace, which would also rewrite an image name that appears in a comment or an env var.
"""
src = (TEMPLATES / (template or app) / "docker-compose.yml").read_text()
src = (TEMPLATES / (template or app) / "docker-compose.yml").read_text(encoding="utf-8")
out, cur = [], None
for line in src.splitlines():
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
@@ -204,7 +204,7 @@ def render(app: str, images: dict, workdir: Path, env: dict, template: str = Non
line = mi.group(1) + images[cur]
out.append(line)
workdir.mkdir(parents=True, exist_ok=True)
(workdir / "docker-compose.yml").write_text(apply_bench_overrides(app, pg_mounts_for("\n".join(out) + "\n")))
(workdir / "docker-compose.yml").write_text(apply_bench_overrides(app, pg_mounts_for("\n".join(out) + "\n")), encoding="utf-8")
write_secret_file(workdir / ".env", "".join(f"{k}={v}\n" for k, v in env.items()))
return workdir / "docker-compose.yml"
@@ -509,7 +509,7 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
scheme = "http"
if native is None and boxport is not None:
# a box-fixture app: load the container traefik routes `/` to, at its own port
rts = boxport.routes((workdir / "docker-compose.yml").read_text())
rts = boxport.routes((workdir / "docker-compose.yml").read_text(encoding="utf-8"))
root = [r for r in rts if not r[0]] or rts
if root:
cname, port = root[0][1], root[0][2]
@@ -525,7 +525,7 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
host = None
try:
envtxt = (workdir / ".env").read_text()
envtxt = (workdir / ".env").read_text(encoding="utf-8")
sub = re.search(r"^SUBDOMAIN=(.*)$", envtxt, re.M)
dom = re.search(r"^DOMAIN=(.*)$", envtxt, re.M)
if sub and dom:
@@ -597,7 +597,7 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
"oom_kills": None if v["oom_kill"] is None else v["oom_kill"] - (b.get("oom_kill") or 0),
"restarts": (v["restarts"] or 0) - (b.get("restarts") or 0),
"oomkilled_flag": v["oomkilled_flag"], "measured": v["cgroup"]}
(ev / "memory-samples.json").write_text(json.dumps(samples, indent=2))
(ev / "memory-samples.json").write_text(json.dumps(samples, indent=2), encoding="utf-8")
killed = any((p["oom_kills"] or 0) > 0 or p["restarts"] > 0 or p["oomkilled_flag"] for p in per.values())
# DECIDED 2026-09-23 night (CC, unattended — operator may reverse; `09` §3 decision 22): the mark
# reads the APP's own memory (anon, sampled) where it was measured. memory.peak counts the file cache,
@@ -906,7 +906,7 @@ def bench_convert(conv, e, app, project, workdir, env, ev, say):
cvp._sh(["docker", "start", cid])
pg_wait(cid, user)
before = pg_check(cid, user)
(ev / "convert-check-before.txt").write_text("\n".join(before) + "\n")
(ev / "convert-check-before.txt").write_text("\n".join(before) + "\n", encoding="utf-8")
# the dump carries the app's rows and its roles' password hashes: it stays in the WORK dir (removed with the
# edge), never in the evidence that is copied off the bench and committed.
dump = workdir / "convert-dumpall.sql"
@@ -914,7 +914,7 @@ def bench_convert(conv, e, app, project, workdir, env, ev, say):
with open(dump, "w") as f:
r = subprocess.run(["docker", "exec", cid, "pg_dumpall", "-h", "127.0.0.1", "-U", user], stdout=f,
stderr=subprocess.PIPE, text=True, timeout=7200)
if r.returncode != 0 or PG_DUMPALL_DONE not in dump.read_text()[-4096:]:
if r.returncode != 0 or PG_DUMPALL_DONE not in dump.read_text(encoding="utf-8")[-4096:]:
raise RuntimeError(f"pg_dumpall rc={r.returncode} / no completion line: {r.stderr[-300:]}")
rec["dump_s"], rec["dump_bytes"] = round(time.time() - td, 2), dump.stat().st_size
vols = json.loads(cvp._sh(["docker", "inspect", cid, "--format", "{{json .Mounts}}"]).stdout or "[]")
@@ -938,16 +938,16 @@ def bench_convert(conv, e, app, project, workdir, env, ev, say):
_psql(cid2, user, "postgres", f'DROP DATABASE "{db}"')
dropped.append(db)
skip = {f"CREATE ROLE {r};" for r in _psql(cid2, user, "postgres", "select quote_ident(rolname) from pg_roles where rolname !~ '^pg_'")}
body = "".join(l for l in dump.read_text().splitlines(True) if l.rstrip("\r\n") not in skip)
body = "".join(l for l in dump.read_text(encoding="utf-8").splitlines(True) if l.rstrip("\r\n") not in skip)
tl = time.time()
r = subprocess.run(["docker", "exec", "-i", cid2, "psql", "-h", "127.0.0.1", "-U", user, "-d", "postgres",
"-v", "ON_ERROR_STOP=1", "-q"], input=body, capture_output=True, text=True, timeout=7200)
(ev / "convert-load.err").write_text(r.stderr)
(ev / "convert-load.err").write_text(r.stderr, encoding="utf-8")
if r.returncode != 0:
raise RuntimeError(f"the load stopped (rc={r.returncode}): {r.stderr.strip()[:400]}")
rec["load_s"], rec["dropped"], rec["skipped_create_role"] = round(time.time() - tl, 2), dropped, sorted(skip)
after = pg_check(cid2, user)
(ev / "convert-check-after.txt").write_text("\n".join(after) + "\n")
(ev / "convert-check-after.txt").write_text("\n".join(after) + "\n", encoding="utf-8")
if after != before:
raise RuntimeError("the check differs after the load: " + "; ".join(sorted(set(before) ^ set(after)))[:400])
ver = cvp._sh(["docker", "exec", cid2, "sh", "-c", 'cat "$PGDATA/PG_VERSION"']).stdout.strip()
@@ -970,8 +970,8 @@ def run_edge(edge_id: str) -> dict:
shutil.rmtree(workdir, ignore_errors=True)
tdir = e.get("template") or app
felhom = (TEMPLATES / tdir / ".felhom.yml").read_text()
compose_text = (TEMPLATES / tdir / "docker-compose.yml").read_text()
felhom = (TEMPLATES / tdir / ".felhom.yml").read_text(encoding="utf-8")
compose_text = (TEMPLATES / tdir / "docker-compose.yml").read_text(encoding="utf-8")
env = cvp.build_env(app, felhom, compose_text)
rec = {"harness_version": HARNESS_VERSION, "edge": edge_id, "app": app, "note": e["note"],
@@ -1037,9 +1037,9 @@ def run_edge(edge_id: str) -> dict:
return rec
files_before = bind_tree_hash(project, workdir)
(ev / "files-before.json").write_text(json.dumps(files_before, indent=2))
(ev / "files-before.json").write_text(json.dumps(files_before, indent=2), encoding="utf-8")
detail_before = bind_tree_files(project, workdir)
(ev / "files-before-detail.json").write_text(json.dumps(detail_before, indent=2))
(ev / "files-before-detail.json").write_text(json.dumps(detail_before, indent=2), encoding="utf-8")
# --- 4. TO ---
swap_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -1061,7 +1061,7 @@ def run_edge(edge_id: str) -> dict:
rec["healthy_after"] = ok2
rec["duration_s"] = secs2
say(f"TO settled={ok2} in {secs2}s :: {json.dumps(states2)}")
(ev / "to-states.json").write_text(json.dumps(states2, indent=2))
(ev / "to-states.json").write_text(json.dumps(states2, indent=2), encoding="utf-8")
# CAPTURE THE WHOLE TO-STEP LOG *NOW*, not at the end.
# `docker compose logs` only shows the CONTAINERS THAT EXIST, and the abort below replaces
@@ -1070,17 +1070,17 @@ def run_edge(edge_id: str) -> dict:
# skipped") survived only because it had already been extracted. Same class as R-320: the
# intermediate teardown is the one that loses the evidence.
full_to = compose(workdir, project, "logs", "--no-color", timeout=180)
(ev / "to-full.log").write_text((full_to.stdout + full_to.stderr)[-400000:])
(ev / "to-full.log").write_text((full_to.stdout + full_to.stderr)[-400000:], encoding="utf-8")
rec["engine_state_after"] = engine_state(e["to"]) or None
if rec["engine_state_after"]:
for svc, st in rec["engine_state_after"].items():
say(f"engine state {svc}: {st['answer'][:180]}")
(ev / "engine-state.json").write_text(json.dumps(rec["engine_state_after"], indent=2))
(ev / "engine-state.json").write_text(json.dumps(rec["engine_state_after"], indent=2), encoding="utf-8")
mig = migration_lines(project, workdir, swap_at)
rec["migration_observed"] = mig[0] if mig else None
(ev / "migration-lines.txt").write_text("\n".join(mig))
(ev / "migration-lines.txt").write_text("\n".join(mig), encoding="utf-8")
say(f"migration lines observed: {len(mig)}")
# --- 5. THE RESULT ---
@@ -1090,11 +1090,11 @@ def run_edge(edge_id: str) -> dict:
# --- 5a. did the update rewrite the household's FILES? (decision 13's `files may change`) ---
files_after = bind_tree_hash(project, workdir)
(ev / "files-after.json").write_text(json.dumps(files_after, indent=2))
(ev / "files-after.json").write_text(json.dumps(files_after, indent=2), encoding="utf-8")
rec["files_changed"] = sorted(k for k in set(files_before) | set(files_after)
if files_before.get(k) != files_after.get(k))
detail_after = bind_tree_files(project, workdir)
(ev / "files-after-detail.json").write_text(json.dumps(detail_after, indent=2))
(ev / "files-after-detail.json").write_text(json.dumps(detail_after, indent=2), encoding="utf-8")
rec["files_changed_detail"] = changed_files(detail_before, detail_after)[:200]
counted, ignored = classify_changes(app, detail_before, detail_after)
rec["files_ignored"] = ignored[:200] # R-734: app-owned markers taken away, each with its reason
@@ -1125,12 +1125,12 @@ def run_edge(edge_id: str) -> dict:
render(app, e["frm"], workdir, env, e.get("template"))
up3 = compose(workdir, project, "up", "-d")
ok3, secs3, states3 = settle(project, workdir, wait=180)
(ev / "abort-states.json").write_text(json.dumps(states3, indent=2))
(ev / "abort-states.json").write_text(json.dumps(states3, indent=2), encoding="utf-8")
if not ok3:
rec["abort"] = "refuses"
lg = compose(workdir, project, "logs", "--tail", "40", "--no-color", timeout=120)
tail = (lg.stdout + lg.stderr).strip()
(ev / "abort-refusal.txt").write_text(tail)
(ev / "abort-refusal.txt").write_text(tail, encoding="utf-8")
rec["abort_detail"] = tail[-900:]
say(f"ABORT: the app did NOT come back (rc={up3.returncode}, {secs3}s)")
else:
@@ -1145,10 +1145,10 @@ def run_edge(edge_id: str) -> dict:
rec["total_s"] = round(time.time() - t0, 1)
# EVIDENCE FIRST, TEARDOWN SECOND (R-320): the intermediate teardown is the one that gets
# forgotten, so everything is written before a single container is removed.
(ev / "run.log").write_text("\n".join(log))
(ev / "run.log").write_text("\n".join(log), encoding="utf-8")
lg = compose(workdir, project, "logs", "--no-color", timeout=180)
(ev / "compose-final.log").write_text((lg.stdout + lg.stderr)[-400000:]) # post-abort state only — see to-full.log
(ev / "verdict.json").write_text(json.dumps(rec, indent=2))
(ev / "compose-final.log").write_text((lg.stdout + lg.stderr)[-400000:], encoding="utf-8") # post-abort state only — see to-full.log
(ev / "verdict.json").write_text(json.dumps(rec, indent=2), encoding="utf-8")
compose(workdir, project, "down", "-v", "--remove-orphans", timeout=900)
# R-624: no evidence file keeps a secret this run made, and the run's .env does not outlive it.
held = redact_tree(ev, secrets_)
@@ -1164,7 +1164,7 @@ def template_images(app: str, template_dir: Path) -> dict:
"""{service: image} of a catalog template — the per-service reading every gate makes."""
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ladder
return ladder.images_in((template_dir / app / "docker-compose.yml").read_text())
return ladder.images_in((template_dir / app / "docker-compose.yml").read_text(encoding="utf-8"))
def add_move_edge(app: str, moves: list) -> str:
@@ -1192,7 +1192,7 @@ def add_definition_edge(app: str, to_dir: str) -> str:
The FROM side is still read from the catalog, never typed. --write-ladder --to-definition writes such a step."""
frm = template_images(app, TEMPLATES)
to = template_images(to_dir, TEMPLATES)
if not to or to == frm and (TEMPLATES / to_dir / "docker-compose.yml").read_text() == (TEMPLATES / app / "docker-compose.yml").read_text():
if not to or to == frm and (TEMPLATES / to_dir / "docker-compose.yml").read_text(encoding="utf-8") == (TEMPLATES / app / "docker-compose.yml").read_text(encoding="utf-8"):
raise SystemExit(f"--move-to: {to_dir} defines nothing new against {app}")
eid = f"MV-{app}"
EDGES[eid] = dict(app=app, note=f"definition step to {to_dir}", frm=frm, to=to, to_template=to_dir)
@@ -1216,7 +1216,7 @@ def add_retest_edge(app: str, services: list) -> str:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ladder, image_digest
frm = template_images(app, TEMPLATES)
entries, _, errs = ladder.parse((TEMPLATES / app / ".felhom.yml").read_text())
entries, _, errs = ladder.parse((TEMPLATES / app / ".felhom.yml").read_text(encoding="utf-8"))
if errs or not entries or entries[-1].get("to") != frm:
raise SystemExit(f"--retest {app}: the template has no ladder whose newest entry is its compose ({errs or 'no entry'})")
tested = entries[-1].get("digest") or {}
@@ -1257,8 +1257,8 @@ def write_ladder(argv) -> int:
def arg(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
bench = json.loads(Path(argv[0]).read_text())
box = json.loads(Path(arg("--box")).read_text())
bench = json.loads(Path(argv[0]).read_text(encoding="utf-8"))
box = json.loads(Path(arg("--box")).read_text(encoding="utf-8"))
cat = Path(arg("--catalog"))
app = bench["app"]
if bench.get("verdict") != "proven" or box.get("verdict") != "proven":
@@ -1270,7 +1270,7 @@ def write_ladder(argv) -> int:
return 1
tdir = cat / "templates" / app
comp_p, fy_p = tdir / "docker-compose.yml", tdir / ".felhom.yml"
comp = comp_p.read_text()
comp = comp_p.read_text(encoding="utf-8")
cur = ladder.images_in(comp)
bfrom, bto = plain_refs(bench["from"]), plain_refs(bench["to"])
retest = bfrom == bto # `09` §3 decision 52: the same tags, tested at a new digest
@@ -1330,7 +1330,7 @@ def write_ladder(argv) -> int:
if conv:
entry["engine_conversion"] = {k: conv[k] for k in ("service", "engine", "from", "to")}
if retest:
head = (ladder.parse(fy_p.read_text())[0] or [{}])[-1]
head = (ladder.parse(fy_p.read_text(encoding="utf-8"))[0] or [{}])[-1]
entry["digest_from"] = {s: (ref_digest(bench["from"][s]) or (head.get("digest") or {}).get(s)) for s in bto}
if head.get("to") != bto:
print(f"REFUSED {app}: a re-test must follow an entry that names the same refs (the head is {head.get('to')})")
@@ -1342,29 +1342,29 @@ def write_ladder(argv) -> int:
# `09` §6.4 part 5: the step being SUPERSEDED keeps its own definition. When the ladder's head is the
# compose as it stands, that compose — the head's images with every fix that flowed since — becomes
# steps/<step_key(head.to)>.yml, the file a box one step behind will pin (check-test-record.py rule 4).
prior, _, _ = ladder.parse(fy_p.read_text())
prior, _, _ = ladder.parse(fy_p.read_text(encoding="utf-8"))
if prior and prior[-1].get("to") == cur:
sp = tdir / ladder.step_file(cur)
if not sp.exists():
sp.parent.mkdir(parents=True, exist_ok=True)
sp.write_text(comp)
sp.write_text(comp, encoding="utf-8")
print(f"STEP {app}: the superseded step {cur} keeps its definition at {ladder.step_file(cur)}")
smp = tdir / ladder.step_meta_file(cur)
if not smp.exists(): # R-664: and its own .felhom.yml, without the ladder
smp.write_text(ladder.strip_ladder_block(fy_p.read_text()))
smp.write_text(ladder.strip_ladder_block(fy_p.read_text(encoding="utf-8")))
print(f"STEP {app}: … and its .felhom.yml at {ladder.step_meta_file(cur)}")
todef = arg("--to-definition")
if todef:
# R-762: a definition step (a new service): the TO compose is that definition as a whole, and its images must
# be exactly what the bench tested TO. Never a hand edit — the file the bench ran is the file written.
newc = (Path(todef) / "docker-compose.yml").read_text()
newc = (Path(todef) / "docker-compose.yml").read_text(encoding="utf-8")
if ladder.images_in(newc) != bto:
print(f"REFUSED {app}: the definition names {ladder.images_in(newc)}, the bench tested TO {bto}")
return 1
comp_p.write_text(newc)
fy = fy_p.read_text()
comp_p.write_text(newc, encoding="utf-8")
fy = fy_p.read_text(encoding="utf-8")
fy = re.sub(r'^catalog_since:.*$', 'catalog_since: "%s"' % _dt.date.today().isoformat(), fy, count=1, flags=re.M)
fy_p.write_text(ladder.append_entry(fy, entry))
fy_p.write_text(ladder.append_entry(fy, entry), encoding="utf-8")
print(f"WROTE {app}: DEFINITION STEP {bfrom} -> {bto} peak {peak}% marks {entry['marks']}")
return 0
# move the compose, per service, on that service's own image: line
@@ -1377,14 +1377,14 @@ def write_ladder(argv) -> int:
if mi and svc in bto and mi.group(2) == bfrom[svc]:
line = mi.group(1) + bto[svc]
out.append(line)
comp_p.write_text(pg_mounts_for("\n".join(out) + "\n") if conv else "\n".join(out) + "\n")
if ladder.images_in(comp_p.read_text()) != bto:
comp_p.write_text(comp)
comp_p.write_text(pg_mounts_for("\n".join(out) + "\n") if conv else "\n".join(out) + "\n", encoding="utf-8")
if ladder.images_in(comp_p.read_text(encoding="utf-8")) != bto:
comp_p.write_text(comp, encoding="utf-8")
print(f"REFUSED {app}: the compose could not be moved line by line — restored")
return 1
fy = fy_p.read_text()
fy = fy_p.read_text(encoding="utf-8")
fy = re.sub(r'^catalog_since:.*$', 'catalog_since: "%s"' % _dt.date.today().isoformat(), fy, count=1, flags=re.M)
fy_p.write_text(ladder.append_entry(fy, entry))
fy_p.write_text(ladder.append_entry(fy, entry), encoding="utf-8")
print(f"WROTE {app}: {'RE-TEST ' if retest else ''}{bfrom} -> {bto} peak {peak}% marks {entry['marks']}"
+ (f" digest {entry['digest_from']} -> {digests}" if retest else ""))
return 0
@@ -1405,7 +1405,7 @@ def write_restep(argv) -> int:
def arg(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
bench = json.loads(Path(argv[0]).read_text())
bench = json.loads(Path(argv[0]).read_text(encoding="utf-8"))
dfn, cat, evid = Path(arg("--definition")), Path(arg("--catalog")), arg("--evidence")
app = bench["app"]
if bench.get("verdict") != "proven" or not bench.get("memory") or (bench.get("harness_version") or 0) < 2:
@@ -1416,7 +1416,7 @@ def write_restep(argv) -> int:
print(f"REFUSED {app}: the memory watch saw OOM kills in {kills}")
return 1
tdir = cat / "templates" / app
entries, _, errs = ladder.parse((tdir / ".felhom.yml").read_text())
entries, _, errs = ladder.parse((tdir / ".felhom.yml").read_text(encoding="utf-8"))
idx = [i for i, e in enumerate(entries or []) if e.get("from") == bench["from"] and e.get("to") == bench["to"]]
if not idx:
print(f"REFUSED {app}: no ladder entry is {bench['from']} -> {bench['to']}")
@@ -1425,7 +1425,7 @@ def write_restep(argv) -> int:
print(f"REFUSED {app}: that entry is the HEAD — its definition is the compose (use --write-ladder)")
return 1
e = entries[idx[-1]]
comp = (dfn / "docker-compose.yml").read_text()
comp = (dfn / "docker-compose.yml").read_text(encoding="utf-8")
if ladder.images_in(comp) != e["to"]:
print(f"REFUSED {app}: the definition names {ladder.images_in(comp)}, the step is {e['to']}")
return 1
@@ -1435,8 +1435,8 @@ def write_restep(argv) -> int:
return 1
head = (f"# RE-PROVEN {_dt.date.today().isoformat()} on the bench on THIS definition (upgrade-test.py --restep): "
f"{evid} — definition sha256 {hashlib.sha256(comp.encode()).hexdigest()[:16]}\n")
sp.write_text(head + comp)
smp.write_text(head + ladder.strip_ladder_block((dfn / ".felhom.yml").read_text()))
sp.write_text(head + comp, encoding="utf-8")
smp.write_text(head + ladder.strip_ladder_block((dfn / ".felhom.yml").read_text(encoding="utf-8")))
peaks = {n: c.get("anon_peak_pct") for n, c in (bench["memory"].get("containers") or {}).items()}
print(f"RESTEP {app}: {ladder.step_file(e['to'])} and its .felhom.yml rewritten from {dfn} (anon peaks {peaks})")
return 0
@@ -1470,7 +1470,7 @@ def main(argv):
rec = run_edge(edge_id)
results.append(rec)
print(json.dumps(rec, indent=2), flush=True)
(EVIDENCE / "summary.json").write_text(json.dumps(results, indent=2))
(EVIDENCE / "summary.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
return 0
+256
View File
@@ -2254,6 +2254,260 @@ class Dawarich:
return found
# =============================================================================================
class Grocy:
"""Grocy (2026-10-10, new app through NEW-APP-CHECKLIST.md). THE FRONT DOOR is the household's own
browser session plus grocy's REST API, which the same session authenticates — measured on the bench
2026-10-10 (4.7.1): `GET /api/system/info` answers 200 with the login cookie and **401 without it**, so
the API is not a second, open door.
Sign-in is `POST /login` with `username=admin` and `password_base64=<base64 of the password>` — grocy's
form base64-encodes the password client-side, and the server reads that field name (its login page's own
input is `password_base64`). The password is the one the box generated and `after_install` installed.
Seed, all through grocy's own routes: read a location and a quantity unit (a fresh install has one of
each), `POST /api/objects/products` with a marker name, then `POST /api/stock/products/<id>/add` — the
stock route, so the seed exercises what grocy is actually for, not just one table.
Readback asks the APP for the stock, not the file: `GET /api/stock/products/<id>` must return the marker
name AND `stock_amount` 5.
THE FIXTURE PROVES ITSELF ON EVERY VERIFY: a call with no cookie must be 401, a product id that cannot
exist must be 404, and a session minted from a WRONG password must be 401. If any control misreads, the
readback is declared unusable rather than passed — a readback that has broken into always saying "found"
would otherwise pass every edge.
"""
sub = "kamra"
# THE TWO VENUES DIFFER HERE, and saying so is the honest part. On the BOX the controller runs
# `after_install`, so the generated password is the live one. On the BENCH there is no controller and
# `after_install` never runs (check-volume-persistence.build_env generates the value, nothing applies
# it), so grocy still carries its image default. The fixture tries the generated password FIRST and
# falls back to the documented default, and records which one answered — a silent fallback would hide
# a box-side after_install that had stopped working.
DEFAULT_PW = "admin"
def _login_pw(self, w, sub, say):
g = w.GENERATED.get("grocy") or {}
for pw, src in ((g.get("ADMIN_PASSWORD") or "", "the box's generated password"),
(self.DEFAULT_PW, "grocy's image default (no after_install on the bench)")):
if not pw:
continue
if self._session(w, sub, pw):
say(" grocy: signed in with %s" % src)
return pw, src
return "", "neither the generated password nor the image default signed in"
def _session(self, w, sub, pw):
"""Grocy's own login route; returns the Cookie header value, or ''.
BOTH field names are sent on purpose, and that was measured, not guessed: grocy 4.7 renamed the
form field to `password_base64` (LoginController base64-decodes it into `password` and unsets it),
while 4.6 — the FROM side of the first ladder step — reads a plain `password` and ignores the new
name. Sending both signs in on 4.6.0 AND on 4.7.1 (measured 2026-10-10 on the bench, Set-Cookie
on both), so one fixture spans the edge. The two releases even name the cookie differently
(`grocy_session` vs `grocy_session_access_token`), which is why the cookie is read by pattern.
"""
body = ("username=admin&password=" + (pw or "")
+ "&password_base64=" + base64.b64encode((pw or "").encode()).decode())
rc, code, out = w.app_curl(sub, "/login", "-D", "-", "-o", "/dev/null",
"-H", "Content-Type: application/x-www-form-urlencoded",
data=body, method="POST")
return _set_cookies(out) if rc == 0 else ""
@staticmethod
def _first_id(out):
m = re.search(r'"id":\s*"?(\d+)"?', out or "")
return m.group(1) if m else None
def seed(self, w, sub, say):
# GET / is what runs grocy's migrations (measured on 4.6.0: /login answered 200 with a
# ZERO-BYTE database, / created the schema and the admin row), so the wait dials / first.
if not w.wait_app(sub, "/", want=("200", "302"), tries=72):
self.tried = "/ never answered"
return None
w.app_curl(sub, "/stockoverview")
pw, src = self._login_pw(w, sub, say)
if not pw:
self.tried = src
say(" grocy: " + src)
return None
ck = self._session(w, sub, pw)
if not ck:
self.tried = "POST /login gave no session cookie"
say(" grocy: login gave no session cookie")
return None
rc, code, out = w.app_curl(sub, "/api/objects/locations", "-H", "Cookie: " + ck)
loc = self._first_id(out)
rc, code, out = w.app_curl(sub, "/api/objects/quantity_units", "-H", "Cookie: " + ck)
qu = self._first_id(out)
if not loc or not qu:
self.tried = "no location (%s) or quantity unit (%s) to hang a product on" % (loc, qu)
say(" grocy: " + self.tried)
return None
mark = "felhom-upg-" + secrets.token_hex(4)
body = json.dumps({"name": mark, "location_id": int(loc), "qu_id_purchase": int(qu),
"qu_id_stock": int(qu), "min_stock_amount": 2})
rc, code, out = w.app_curl(sub, "/api/objects/products", "-H", "Cookie: " + ck,
"-H", "Content-Type: application/json", data=body, method="POST")
say(" grocy: POST /api/objects/products http=%s :: %s" % (code, (out or "")[:120]))
if code != "200":
self.tried = "POST /api/objects/products -> %s" % code
return None
m = re.search(r'"created_object_id":\s*"?(\d+)"?', out or "")
if not m:
self.tried = "the product POST answered 200 with no created_object_id"
return None
pid = m.group(1)
rc, code, out = w.app_curl(sub, "/api/stock/products/%s/add" % pid, "-H", "Cookie: " + ck,
"-H", "Content-Type: application/json",
data=json.dumps({"amount": 5, "transaction_type": "purchase"}), method="POST")
say(" grocy: POST /api/stock/products/%s/add http=%s" % (pid, code))
if code != "200":
self.tried = "stock add -> %s" % code
return None
return {"pw": pw, "pw_src": src, "pid": pid, "mark": mark, "amount": 5}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/login", want=("200",), tries=72):
say(" grocy: /login never answered")
return False
path = "/api/stock/products/%s" % t["pid"]
rc, c_none, _ = w.app_curl(sub, path)
bad = self._session(w, sub, t["pw"] + "x")
rc, c_wrong, _ = w.app_curl(sub, path, *(["-H", "Cookie: " + bad] if bad else []))
ck = self._session(w, sub, t["pw"])
if not ck:
say(" grocy: the household's own password no longer signs in after the step")
return False
rc, c_absent, _ = w.app_curl(sub, "/api/objects/products/999999", "-H", "Cookie: " + ck)
if c_none != "401" or c_wrong != "401" or c_absent != "404":
say(" grocy: READBACK UNUSABLE - no cookie %s, wrong password %s, absent id %s"
% (c_none, c_wrong, c_absent))
return False
rc, code, out = w.app_curl(sub, path, "-H", "Cookie: " + ck)
found = False
if code == "200":
try:
d = json.loads(out or "{}")
found = (str(d.get("stock_amount")) in (str(t["amount"]), "%s.0" % t["amount"])
and (d.get("product") or {}).get("name") == t["mark"])
except ValueError:
found = False
say(" grocy: readback http=%s found=%s (controls: no cookie %s, wrong %s, absent %s)"
% (code, found, c_none, c_wrong, c_absent))
return found
# =============================================================================================
class LubeLogger:
"""LubeLogger (2026-10-10, new app through NEW-APP-CHECKLIST.md). THE FRONT DOOR is the app's own login
form plus its REST API, both of which exist only because our template turns authentication ON: the image
ships `EnableAuth: false`, and with that an anonymous stranger is handed the `IsRootUser` role (measured
on the bench 2026-10-10: a stranger created a vehicle through `POST /Vehicle/SaveVehicle`). The container
entrypoint exports `EnableAuth=true` plus the SHA-256 of the generated name and password, so BOTH VENUES
behave the same here — unlike grocy, nothing depends on `after_install`.
Seed, through the app's own routes: sign in at `POST /Login/Login`, create a vehicle with a marker plate
at `POST /Vehicle/SaveVehicle`, then add a service record with a marker description at
`POST /api/vehicle/servicerecords/add` — so the seed covers the vehicle table AND a child record.
Readback asks the APP: `GET /api/vehicles` must still carry the plate, and
`GET /api/vehicle/servicerecords?vehicleId=<id>` must still carry the description and the cost.
THE FIXTURE PROVES ITSELF ON EVERY VERIFY: no cookie must be 401, a session minted from a WRONG password
must be 401, and a vehicleId that cannot exist must answer an EMPTY list (measured: 200 `[]`). If any
control misreads, the readback is unusable rather than passed.
"""
sub = "garazs"
@staticmethod
def _creds(w):
g = w.GENERATED.get("lubelogger") or {}
return g.get("ROOT_USER") or "csalad", g.get("ROOT_PASSWORD") or ""
def _session(self, w, sub, user, pw):
rc, code, out = w.app_curl(sub, "/Login/Login", "-D", "-", "-o", "/dev/null",
"-H", "Content-Type: application/x-www-form-urlencoded",
data="UserName=%s&Password=%s" % (user, pw), method="POST")
return _set_cookies(out) if rc == 0 else ""
def seed(self, w, sub, say):
if not w.wait_app(sub, "/Login/Index", want=("200",), tries=72):
self.tried = "/Login/Index never answered 200"
return None
user, pw = self._creds(w)
if not pw:
self.tried = "no generated ROOT_PASSWORD for lubelogger in this run"
return None
ck = self._session(w, sub, user, pw)
if not ck:
self.tried = "POST /Login/Login gave no session cookie"
say(" lubelogger: login gave no session cookie")
return None
plate = "FEL-" + secrets.token_hex(3).upper()
desc = "Felhom teszt szerviz " + secrets.token_hex(3)
rc, code, out = w.app_curl(sub, "/Vehicle/SaveVehicle", "-H", "Cookie: " + ck,
"-H", "Content-Type: application/x-www-form-urlencoded",
data=("Year=2019&Make=Skoda&Model=Octavia&LicensePlate=%s"
"&IsElectric=false&IsDiesel=true&UseHours=false" % plate),
method="POST")
say(" lubelogger: POST /Vehicle/SaveVehicle http=%s :: %s" % (code, (out or "")[:100]))
if code != "200" or '"success":true' not in (out or ""):
self.tried = "SaveVehicle -> %s %s" % (code, (out or "")[:120])
return None
rc, code, out = w.app_curl(sub, "/api/vehicles", "-H", "Cookie: " + ck)
vid = None
try:
for v in json.loads(out or "[]"):
if str(v.get("licensePlate")) == plate:
vid = str(v.get("id"))
except ValueError:
pass
if not vid:
self.tried = "the new vehicle did not come back from /api/vehicles"
say(" lubelogger: " + self.tried)
return None
rc, code, out = w.app_curl(sub, "/api/vehicle/servicerecords/add", "-H", "Cookie: " + ck,
"-H", "Content-Type: application/x-www-form-urlencoded",
data=("vehicleId=%s&date=2026-10-01&odometer=123456&description=%s"
"&cost=19900&notes=felhom-fixture"
% (vid, desc.replace(" ", "+"))), method="POST")
say(" lubelogger: POST servicerecords/add http=%s :: %s" % (code, (out or "")[:110]))
if code != "200" or '"success":true' not in (out or ""):
self.tried = "servicerecords/add -> %s" % code
return None
return {"user": user, "pw": pw, "plate": plate, "vid": vid, "desc": desc, "cost": "19900"}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/Login/Index", want=("200",), tries=72):
say(" lubelogger: /Login/Index never answered")
return False
rc, c_none, _ = w.app_curl(sub, "/api/vehicles")
bad = self._session(w, sub, t["user"], t["pw"] + "x")
rc, c_wrong, _ = w.app_curl(sub, "/api/vehicles", *(["-H", "Cookie: " + bad] if bad else []))
ck = self._session(w, sub, t["user"], t["pw"])
if not ck:
say(" lubelogger: the household's own password no longer signs in after the step")
return False
rc, c_absent, absent_body = w.app_curl(sub, "/api/vehicle/servicerecords?vehicleId=999999",
"-H", "Cookie: " + ck)
empty = c_absent == "200" and (absent_body or "").strip() in ("[]", "[ ]")
if c_none != "401" or c_wrong != "401" or not empty:
say(" lubelogger: READBACK UNUSABLE - no cookie %s, wrong password %s, absent vehicle %s %s"
% (c_none, c_wrong, c_absent, (absent_body or "")[:40]))
return False
rc, code, out = w.app_curl(sub, "/api/vehicles", "-H", "Cookie: " + ck)
plate_ok = code == "200" and ('"%s"' % t["plate"]) in (out or "")
rc, code2, out2 = w.app_curl(sub, "/api/vehicle/servicerecords?vehicleId=%s" % t["vid"],
"-H", "Cookie: " + ck)
rec_ok = code2 == "200" and t["desc"] in (out2 or "") and t["cost"] in (out2 or "")
say(" lubelogger: readback vehicle=%s (http=%s) service record=%s (http=%s) "
"(controls: no cookie %s, wrong %s, absent list empty)"
% (plate_ok, code, rec_ok, code2, c_none, c_wrong))
return bool(plate_ok and rec_ok)
# =============================================================================================
class Grimmory:
"""Grimmory (2026-10-01, new app through NEW-APP-CHECKLIST.md). THE FRONT DOOR is the app's own API, the one its web
@@ -2447,4 +2701,6 @@ FIXTURES = {
"karakeep": Karakeep(),
"dawarich": Dawarich(),
"grimmory": Grimmory(),
"grocy": Grocy(),
"lubelogger": LubeLogger(),
}