Grocy and LubeLogger: two new apps, each with its complete record
gates / gates (push) Failing after 14m4s
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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¬es=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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user