From b7f0f7cef55869d5131634bab8565745340d7ee3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 10 Oct 2026 12:31:34 +0200 Subject: [PATCH] Grocy and LubeLogger: two new apps, each with its complete record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 39 ++++ CONTEXT.md | 12 ++ FIRST-ADMIN.md | 2 + README.md | 4 + REPORT.md | 149 +++++++------- REUSE.md | 5 +- onboarding/grocy.md | 76 +++++++ onboarding/lubelogger.md | 76 +++++++ scripts/check-onboarding.py | 10 + scripts/copy_freeze/hu.json | 47 +++++ scripts/upgrade-test.py | 100 ++++----- scripts/upgrade_fixtures_box.py | 256 ++++++++++++++++++++++++ templates/grocy/.felhom.yml | 139 +++++++++++++ templates/grocy/docker-compose.yml | 161 +++++++++++++++ templates/lubelogger/.felhom.yml | 129 ++++++++++++ templates/lubelogger/docker-compose.yml | 112 +++++++++++ 16 files changed, 1190 insertions(+), 127 deletions(-) create mode 100644 onboarding/grocy.md create mode 100644 onboarding/lubelogger.md create mode 100644 templates/grocy/.felhom.yml create mode 100644 templates/grocy/docker-compose.yml create mode 100644 templates/lubelogger/.felhom.yml create mode 100644 templates/lubelogger/docker-compose.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index cfeb2ba..967d845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +## 2026-10-10 (later) — Grocy added: household stock, shopping list and chores + +**What runs on a box changed:** one new app is offered, `kamra.`. Nothing already installed +is touched. Record: `onboarding/grocy.md` (all 61 checks). 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** + (`grocy/grocy` on Docker Hub is 404; its README's only Docker link is linuxserver.io). SQLite in one + named volume, no HDD, no sidecar, ~30 MiB idle, amd64 + arm64. First admin **class 3**: the app starts + with its documented `admin`/`admin` and `after_install` replaces that password with a generated one. +- **Two defects were found by the walk and fixed before publishing, both measured:** + 1. **An `after_install` command may not contain `$`.** The controller runs every element through + `os.Expand` and refuses a command naming anything outside `env:`, so PHP — which is nothing but + `$` — 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`, the install hold + stayed closed and the default password stayed in place. The code now lives in a file the compose + entrypoint writes and the command passes only the password. **Written into `REUSE.md`'s + `after_install` row as a trap**, because mealie and dawarich only avoid it by language. + 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** — `Configured AUTH_CLASS "Grocy\Middleware\DefaultAuthMiddleware" + does not exist` — while its log said the migrations had run and the init was done. The template's + entrypoint now deletes that file at every start, so the image re-copies its own current defaults; + every instance setting a household needs is a `GROCY_*` environment variable, and each person's + own choices live in grocy's database. The 4.6.0 → 4.7.1 edge failed before this and is `proven` + after it, on the bench and on 9202. +- **A fifth healthcheck family** in `REUSE.md` §2 for an image with no HTTP client at all (bash + `/dev/tcp`), earned by LubeLogger and measured in both directions. +- **The ladder**: 4.6.0 → 4.7.1 `proven` on the bench (soak 605 s, 10343 requests, peak anon 6.7 % of + the 384 M limit, 0 kills) and on 9202 through the product's own guarded Update (done in 41 s). + **The undo was measured on a real failure**, not a forced one: before the fix the same step went + backing-up → pulling → copying → verifying → **undone** in 346 s with the data intact. +- `scripts/upgrade_fixtures_box.py`: a `Grocy` fixture that seeds and reads back through grocy's own + login, product and stock routes, with three controls on every verify. It sends **both** password + field names on purpose — 4.7 renamed the form field and 4.6, the FROM side, still reads the old one. +- Two tool fixes found while doing this, both Windows-only and both red-proofed: `check-onboarding.py` + crashed with `UnicodeEncodeError` while printing which ids were open (an informative exit 1 arrived + as a traceback), and `upgrade-test.py`'s `read_text`/`write_text` used the platform encoding, which + wrote a cp1250 em dash into a template full of Hungarian. ## 2026-10-10 — PikaPods read against the catalog (read-only scan, no template touched) **What runs on a box changed: nothing.** No template, no `templates.json`, no website. The scan is diff --git a/CONTEXT.md b/CONTEXT.md index d19c235..18d19e8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,6 +2,18 @@ > Created with the REUSE.md rollout (2026-07-03). History: `CHANGELOG.md`; format spec: `README.md`. +- **2026-10-10 — Grocy and LubeLogger added; Monica STOPPED at checklist 0.2 (operator decision, R-927).** + **The operator's decision of 2026-10-10** was to add Grocy, Monica and LubeLogger, chosen from + `audits/pikapods-scan-2026-10-10/FINDINGS.md` §2 because no catalog app covers what they do. Grocy and + LubeLogger are published with complete records (`onboarding/grocy.md`, `onboarding/lubelogger.md`); Monica is + not built — no release in 17 months, no stable release in 29, the stable branch dead since 2024-05-04, and an + upstream notice that the hosted instance and its data go at the end of December 2026 before a rewrite. + **60 template directories, 58 offered.** + Two conventions earned here and written into `REUSE.md`: **an `after_install` command may not contain `$`** + (the controller expands every `$name` and refuses one it was not given — PHP cannot be inlined, and the way out + is a file the compose entrypoint writes), and **a fifth healthcheck family** for an image with no HTTP client at + all (bash `/dev/tcp`, measured in both directions). Grocy also needed its persisted `config.php` made a derived + file: the image copies it once and never again, so a 4.6 → 4.7 move answered HTTP 500 on every page. - **2026-10-02 (afternoon) — the persistence re-sweep:** the gate now sends requests (R-801), an empty declared volume is UNDETERMINED (R-788), and it calls the app's own fixture seed. All 58: CLEAN 40 · UNDETERMINED 18 · BROKEN 0. papra 768M (R-803). Remove keeps the household's userdata — `09` §3 decision 67 (controller v0.288.0 names it). diff --git a/FIRST-ADMIN.md b/FIRST-ADMIN.md index ec38481..c21d9ff 100644 --- a/FIRST-ADMIN.md +++ b/FIRST-ADMIN.md @@ -52,6 +52,7 @@ asks the probe first where there is one. Open sign-up is closed by the box after | grafana | 1 | `GF_SECURITY_ADMIN_PASSWORD` — **falls back to `admin` if empty** (R-708) | – | fine while the field is set | R | | **gramps-web** | 4 | first-run onboarding | **setup gate**, opened by the household's press („Kész, beállítottam", confirm first); sign-up closed by the box after the setup: `/api/users//register/`; its status answers HTTP 405 after the setup — the box reads only 200 answers (measured: the press was then refused, fail closed) → button; self-registration answered 500, blocked anyway | **GATED** — catalog, 2026-09-29 (decisions 46–47) | **M 9202**: stranger → gate page / 401; household reached the first-setup screen; gate opened after the setup; the app answered after (`felhom.eu/documentation/audits/gate-rollout-2026-09-29/`) | | **grimmory** | 4 | first visitor creates the admin (`POST /api/v1/setup`) | **setup gate**, probe `GET /api/v1/setup/status` → `data` (`false` → `true`), **and the family gate** for good (decision 64): strangers reach nothing but the e-reader exceptions, which keep Grimmory's own login | **NEW 2026-10-02** — catalog, `onboarding/grimmory.md` | **M 9202**: the household made the admin through both gates; a second setup 403; R-775's lock cannot be aimed from outside (`audits/family-gate-2026-10-02/A/items.txt`) | +| **grocy** | 3 | `admin` / `admin`, created by grocy's own first-request migration | (b) the box replaces the password at install: `after_install` runs a PHP helper the compose entrypoint writes, which hashes with grocy's OWN `password_hash(..., PASSWORD_ARGON2ID)` and PROVES the result with `password_verify` before saying so. The code cannot live in the command itself — the controller expands every `$name` in an after_install command — and grocy's image has no other interpreter | **NEW 2026-10-10** — catalog, `onboarding/grocy.md` | **M 9202** (drill catalog): the documented default POSTed once a second as a stranger from before the install press, 65 attempts, **0 signed in** (404, then 401 behind the install hold, then `invalid=true`); after the install the default is refused, the generated password signs in, a wrong one is refused; 25 wrong tries then the household in at once (no lock); a family member added through `POST /api/users` signs in (`felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/`) | | **home-assistant** | 4 | onboarding | **setup gate**, opened by the household's press („Kész, beállítottam", confirm first); the app itself refuses a stranger's second first-admin / sign-up call; its status is a list (`/api/onboarding`) → button | **GATED** — catalog, 2026-09-29 (decisions 46–47) | **M 9202**: stranger → gate page / 401; household reached the first-setup screen; gate opened after the setup; the app answered after (`felhom.eu/documentation/audits/gate-rollout-2026-09-29/`) | | **homebox** | 4 | open registration | **setup gate**, opened by the household's press („Kész, beállítottam", confirm first); sign-up closed by the box after the setup: `/api/v1/users/register` | **GATED** — catalog, 2026-09-29 (decisions 46–47) | **M 9202**: stranger → gate page / 401; household reached the first-setup screen; gate opened after the setup; the app answered after (`felhom.eu/documentation/audits/gate-rollout-2026-09-29/`) | | homepage | 5 | static start page | – | fine | R | @@ -60,6 +61,7 @@ asks the probe first where there is one. Open sign-up is closed by the box after | **karakeep** | 4 | the first account that signs up becomes the admin (`users.create`) | **setup gate**, opened by the household's press („Kész, beállítottam"); sign-up closed twice after the setup: `after_setup` → `DISABLE_SIGNUPS=true` (it also refuses the FIRST account, so never at install) + a case-insensitive block of `/signup` and any tRPC call naming `users.create` (batched too) | **NEW 2026-10-01** — catalog, `onboarding/karakeep.md` | **M 9202** (drill catalog): stranger 401 before the press; after it users.create / batched / upper-case / `//` / `/signup` all 403; 12 wrong key exchanges → the right one at once; after remove + restore the app's own switch still refuses, the route block is gone (R-773) (`felhom.eu/documentation/audits/new-apps-2026-10-01/box/karakeep/`) | | kimai | 1 | `ADMIN_PASSWORD` → `ADMINPASS` | – | fine | R | | **komga** | 4 | first visitor claims | **setup gate**, opened by probe `/api/v1/claim` → `isClaimed`; the app itself refuses a stranger's second first-admin / sign-up call | **GATED** — catalog, 2026-09-29 (decisions 46–47) | **M 9202**: stranger → gate page / 401; household reached the first-setup screen; gate opened after the setup; the app answered after (`felhom.eu/documentation/audits/gate-rollout-2026-09-29/`) | +| **lubelogger** | 1 | the image ships **NO LOGIN AT ALL** (`EnableAuth: false`) and the middleware mints a ticket with the `IsRootUser` role for every visitor | the compose entrypoint exports `EnableAuth=true` and the SHA-256 of the generated name and password (`StaticHelper.GetHash`), so the app's own login is on **before its first byte**; nothing to run after the install | **NEW 2026-10-10** — catalog, `onboarding/lubelogger.md` | **M bench + M 9202**: on a DEFAULT start a stranger got 200 on `/`, on `/api/vehicles` and on `/Home/Settings` and **created a vehicle**; with the template, the first answer the app ever gave a stranger was 401 / 302 → `/Login/Index`. Sign-up is closed by the app itself (`Invalid Token`); 25 wrong passwords then the household in at once (`felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/`) | | **mealie** | 3 | `changeme@example.com / MyPassword` | (b) its own user repository (`update_password`), password as `sys.argv[1]` | **FIXED** — catalog, 2026-09-29 | **M 9202**: fresh install — default 401, generated 200, wrong 401 (`felhom.eu/documentation/audits/login-gate-2026-09-29/D/`) | | **metube** | 5 | NO login at all, by design | **the family gate** only (decision 64), no exceptions | **NEW 2026-10-02** — catalog, `onboarding/metube.md` | **M 9202**: a stranger's every path, the websocket included, 401/302 from the gate (`audits/family-gate-2026-10-02/A/items.txt`) | | **n8n** | 4 | owner setup | **setup gate**, probe `GET /rest/settings` → `data.userManagement.showSetupOnFirstLoad` = false | **GATED** — catalog, 2026-09-29 | **M 9202**: as immich; opened by the probe ~20 s after the owner setup | diff --git a/README.md b/README.md index bff4794..d7ddcba 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,7 @@ block + the matching compose `${VAR}` lines. | Gokapi | None (file) | 30M / 128M | yes | -- | share.* | | Grafana | None (file) | 100M / 512M | yes | -- | grafana.* | | Gramps Web | None (file) | 100M / 384M | yes | -- | family.* | +| Grocy | None (SQLite) | 60M / 384M | yes | -- | kamra.* | | Grimmory | MariaDB | 600M / 1408M | yes | `${USERDATA_PATH}/media/grimmory/` | library.* | | Home Assistant | None (file) | 256M / 1024M | yes | -- | ha.* | | Homebox | None (SQLite) | 50M / 256M | yes | -- | inventory.* | @@ -336,6 +337,7 @@ block + the matching compose `${VAR}` lines. | Karakeep | SQLite + Meilisearch + Chrome | 700M / 2816M | no | -- | bookmarks.* | | Kimai | MariaDB | 100M / 640M | yes | -- | time.* | | Komga | None (file) | 200M / 512M | yes | `${HDD_PATH}/media/comics/` | comics.* | +| LubeLogger | None (LiteDB) | 80M / 512M | yes | -- | garazs.* | | Mealie | None (SQLite) | 200M / 1000M | yes | -- | recipes.* | | MeTube | None (file) | 128M / 768M | yes | `${USERDATA_PATH}/media/metube/` | video.* | | n8n | None (file) | 150M / 512M | no | -- | auto.* | @@ -390,6 +392,7 @@ block + the matching compose `${VAR}` lines. | Gokapi | yes | -- | -- | | Grafana | yes | -- | GF_SECURITY_ADMIN_PASSWORD | | Gramps Web | yes | -- | GRAMPSWEB_SECRET_KEY | +| Grocy | yes | -- | ADMIN_PASSWORD (password) | | Home Assistant | yes | -- | -- | | Homebox | yes | -- | -- | | Homepage | yes | -- | -- | @@ -398,6 +401,7 @@ block + the matching compose `${VAR}` lines. | Karakeep | yes | -- | NEXTAUTH_SECRET, MEILI_MASTER_KEY, OPENAI_API_KEY (optional, secret_input) | | Kimai | yes | -- | DB_PASSWORD, ADMIN_EMAIL, ADMIN_PASSWORD | | Komga | yes | yes | -- | +| LubeLogger | yes | -- | ROOT_PASSWORD (password) | | Mealie | yes | -- | -- | | n8n | yes | -- | N8N_ENCRYPTION_KEY | | Navidrome | yes | yes | -- | diff --git a/REPORT.md b/REPORT.md index faa94d9..46ca8c0 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,90 +1,87 @@ -# REPORT — 2026-10-10: PikaPods read against the catalog (read-only scan) +# REPORT — 2026-10-10: Grocy and LubeLogger published; Monica stopped at the fit check -**Nothing in the catalog was changed.** No template, no `.felhom.yml`, no `templates.json`, no image -pin, no website. Output is `audits/pikapods-scan-2026-10-10/` only. +**What runs on a box changed:** two new apps are offered — `kamra.` (Grocy) and +`garazs.` (LubeLogger). Nothing already installed is touched. **60 template directories, 58 +offered.** Records: `onboarding/grocy.md`, `onboarding/lubelogger.md`, every one of the 61 checks +answered `done` or `n/a`, none `open`. Evidence: +`felhom.eu/documentation/audits/new-apps-2026-10-10/`. -## What the green heart means +## The three apps -Hovering it shows PikaPods' own tooltip: **"Project has a revenue sharing agreement."** -(, hovered 2026-10-10, `A1-heart-tooltip.jpg`). Their FAQ answers -*"How much do open source developers get?"* with **"Open source developers have the option to enter -into a revenue sharing agreement to receive 20% of revenues their app generates."** -(, read 2026-10-10); the home page says **"20% revenue share with project -authors where possible."** +| app | result | what decided it | +|---|---|---| +| **Grocy** 4.7.1 | **published** | MIT, alive (3 releases in 2026), SQLite in one volume, ~30 MiB idle, Hungarian UI 91.6 % | +| **LubeLogger** v1.7.3 | **published** | MIT, very alive (17 releases in 2026), LiteDB in one volume, ~60 MiB idle | +| **Monica** | **stopped — checklist 0.2** | no release of any kind in 17 months, no stable release in 29, the `4.x` branch untouched since 2024-05-04, and an upstream notice that the hosted instance and its data go at the end of December 2026 before a rewrite. **The operator decides** (R-927) | -**A mark gives Felhom no permission.** It is an agreement between an author and PikaPods, on terms we -have not seen. Its only value to us is a signal that the author is not opposed in principle to -commercial hosting — which tells us whether asking is worth the effort. +## The two faults the walk found, both fixed before publishing -## Numbers +**1. An `after_install` command may not contain `$`.** The controller runs every element of the command +through Go's `os.Expand` and refuses one that names anything outside `env:`. Grocy's replacement has to +use grocy's own `password_hash(..., PASSWORD_ARGON2ID)`, and PHP is the only interpreter in its image +(measured: no python, no perl, no ruby, no node) — so the code can never be an argument. On 9202 the +first attempt came back -| | | -|---|---| -| PikaPods apps listed (all read; the page shows all at once) | **126** | -| with the revenue-share mark | **39** | -| without | 87 | -| Felhom template directories | 58 (56 offered + `plant-it` abandoned + `wger` hidden) | -| on both lists | **25** | -| ours, not on PikaPods | 33 | -| PikaPods', not ours | 101 | -| shared apps carrying the mark | 11 | -| candidates after the household filter | ~20 | -| recommended, ranked | **10** | -| shortlisted then dropped on a licence read | **1** (Joplin) | + after_install: [pw argv dsn db i t e s n q h] not declared in env or has no value — not run -## The restricted apps we ship — on PikaPods, with a mark? +and the app sat behind its install hold with `admin`/`admin` still in place. The code now lives in a +file the compose entrypoint writes and the command passes only `${ADMIN_PASSWORD}`. **Written into +`REUSE.md`'s `after_install` row as a trap** — mealie and dawarich avoid it only by language. -| app | on PikaPods | mark | what it is worth | +**2. Grocy could not be updated at all.** The image copies `config-dist.php` to +`/config/data/config.php` *only if that file is absent*, so the file is written once and never follows +the image. A volume written by 4.6.0 and started under 4.7.1 answered **HTTP 500 on every page** — +`Configured AUTH_CLASS "Grocy\Middleware\DefaultAuthMiddleware" does not exist` — while its own log said +the migrations had run and the init was done. The entrypoint now deletes that file at every start, so +the image re-copies its current defaults; every setting a household needs is a `GROCY_*` environment +variable and each person's own choices live in the database. **The edge failed before the fix and is +`proven` after it, on both venues** — and the failure bought something: the product's own guarded Update +was watched undoing a REAL failure (backing-up → pulling → copying → verifying → **undone** in 346 s, +the app back on 4.6.0, the data read back), which is checklist 6.3 without manufacturing a fault. + +## What LubeLogger ships as, and what we ship + +`Middleware/Authen.cs` reads `EnableAuth` with a default of `false`, and `appsettings.json` ships it +false. With it false the middleware **mints a ticket with the `IsRootUser` role for every request**. +Measured on a default start: an anonymous stranger got 200 on `/`, on `/api/vehicles` and on +`/Home/Settings`, and `POST /Vehicle/SaveVehicle` answered `{"success":true}` — the vehicle came back +from the API. Our template's 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**: measured from the container's +creation, at t+1 s nothing was listening and at t+2 s `/api/vehicles` was already 401. No install +window to close. Sign-up is closed by the app itself (`Invalid Token`). + +## Catalogue conventions added + +- **`REUSE.md` §2, a fifth healthcheck family** — bash `/dev/tcp`, for an image with no HTTP client at + all. `ghcr.io/hargata/lubelogger` has no curl, wget, nc, python or node; it has bash. Measured in both + directions (rc 0 against the app with an HTTP 200 line, rc 1 against a dead port) before it was + written down, with its traps: it needs **bash**, not `sh`, and the connect must sit outside the + pipeline or its failure is invisible. +- **`REUSE.md` `after_install` row** — the `$` trap above, with the way out. + +## The ladders + +| app | step | bench | box 9202 | |---|---|---|---| -| **sparkyfitness** | yes | **YES** | The one row this scan moves. Consistent with the author's own answer to R-784. | -| **tandoor** | no | – | nothing learned | -| **emby** | no | – | nothing learned | -| **plex** | no | – | nothing learned | -| **outline** | no | – | nothing learned | -| **calcom** | no | – | nothing learned | -| **wanderer** | no | – | nothing learned | -| **n8n** | yes | **no** | hosted commercially there without a mark — informative, not exculpatory | -| **docmost** | yes | **no** | same | +| grocy | 4.6.0 → 4.7.1 | **proven** — soak 605 s, 10343 requests, peak anon 6.7 % of 384 M, 0 kills | **proven** — the product's guarded Update, done in 41 s | +| lubelogger | v1.7.2 → v1.7.3 | **proven** — soak 605 s, 11988 requests, peak anon 12.3 % of 512 M, 0 kills | **proven** — done in 25.6 s | -PikaPods' own listing criteria are *"Have a license that allows self-hosting"* and *"Not compete with -the author's own paid hosting service…"* (, read 2026-10-10). The -absence of Emby, Plex and Tandoor from a list applying those rules is at least consistent with our own -2026-10-02 read of them. +Both written by `upgrade-test.py --write-ladder` from the two verdicts, never by hand. -## The ten candidates (ranked, none added) +## Tool fixes made on the way (no rows — fixed here, per the size rule) -Licences read at each project's own repository on 2026-10-10; full citations in `FINDINGS.md` §2. +- `scripts/check-onboarding.py` crashed with `UnicodeEncodeError` on a Windows console **while printing + which ids were still open**, so an informative exit 1 arrived as a traceback. Guarded. +- `scripts/upgrade-test.py` used `Path.read_text()`/`write_text()`, which take the platform encoding; on + this workstation the ladder writer put a cp1250 em dash into a template full of Hungarian and the file + stopped being valid UTF-8. All 26 calls now say `encoding="utf-8"`. +- The same console trap in **nineteen** of `felhom.eu`'s gate scripts and in `repo_gates.py` itself, + where it **aborted the whole runner at the first gate**. Guarded there too; `reuse-refs` went from a + traceback to green. Red-proof for the claim that this was not my doing: `test_due_checks_gate.py` + fails the same 5 of 42 with my change reverted. -1. **Grocy** (MIT) — runs the household: food in the house, what runs out, shopping list, chores. No overlap. -2. **Firefly III** (AGPL-3.0, marked) — household budgeting; different model from ActualBudget. -3. **Monica** (AGPL-3.0) — a private address book for family life: birthdays, who said what. No overlap. -4. **LubeLogger** (MIT) — the family car's services, repairs and fuel. No overlap. -5. **Storyteller** (MIT, marked) — pairs an ebook with its audiobook, synced. Nothing we have does this. -6. **PdfDing** (AGPL-3.0, marked) — a library for the household's PDFs; bentopdf is a toolbox, not a library. -7. **Memos** (MIT) — quick private notes; **its argument is the licence**: outline is BUSL and docmost has EE parts, this is plain MIT. -8. **Wealthfolio** (AGPL-3.0, marked) — savings and investments, beside ActualBudget rather than instead of it. -9. **Kavita** (GPL-3.0, marked) — ebooks and comics in one reader; overlaps komga + calibre-web. -10. **PhotoPrism** (AGPL-3.0, marked) — photo library; **overlaps Immich heavily**, which is why it is last. +## Gates -## The one that was nearly recommended - -**Joplin.** Its repository root is AGPL-3.0-or-later *"unless a directory contains a LICENSE or -LICENSE.md file"*. `packages/server` — the part we would host — has one: the **Joplin Server Personal -Use License**, *"the Software may be used for personal non-commercial purposes only"*, and the -licensee may not *"grant others the right to use the Software for a fee"* -(, read 2026-10-10). - -Same shape as SparkyFitness: open-source client, restricted server. A repository-level licence badge -reading "AGPL-3.0" would have hidden it — **the licence of the thing we run is not always the licence -of the repository**. - -## Register - -**No rows opened, none closed.** The brief expected a row only if a licence problem turned up in an app -we already ship; none did. Joplin is not in the catalog, so its licence is research, not a defect. The -nine restricted apps above already have their rows (R-784, R-789, R-790–R-794). - -## Teardown - -Read-only throughout. No contact with PikaPods or any author. No box, no hub, no DooPlex, no ep0. The -browser tabs opened for the scan were closed. +`python3 scripts/catalog_gates.py grocy lubelogger` — all twelve green, the two runtime ones included +(`image-resolvable` and `volume-persistence`, run on bench 9401 where there is Docker): +`felhom.eu/documentation/audits/new-apps-2026-10-10/box/*/gates.txt`. diff --git a/REUSE.md b/REUSE.md index ed50162..bcb38e5 100644 --- a/REUSE.md +++ b/REUSE.md @@ -23,7 +23,7 @@ Templates are config; the few script helpers other scripts must REUSE, never re- | **The one canonical example app** | `templates/paperless-ngx/` (both files) | Multi-container (app + postgres + redis), HDD + userdata mounts, full deploy_fields spectrum (domain/subdomain/secret/password/text/path/select). Copy this structure for any new app. | | `.felhom.yml` required fields | `templates/paperless-ngx/.felhom.yml` | All 53 apps: `display_name`, `description` (Hungarian), `category`, `subdomain`, `slug`, `resources{mem_request, mem_limit, pi_compatible, needs_hdd}`, `deploy_fields`, `app_info{tagline, use_cases, first_steps, ...}`, `healthcheck`. Optional: `smtp_mapping` (email-capable apps), `open_path` (non-root landing page, e.g. ghost). | | deploy_fields conventions | `templates/paperless-ngx/.felhom.yml` (`deploy_fields:` block) | Every app starts with `DOMAIN` (type `domain`) + `SUBDOMAIN` (type `subdomain`, `locked_after_deploy: true`). Secrets: `type: secret` + `generate:` — dominant generators `password:24` (DB passwords) and `hex:32` (app secret keys); `password:16` for shown admin passwords (`type: password`). HDD apps add `HDD_PATH` (`type: path`, placeholder `/mnt/felhom-drives/hdd_1`, locked). Labels/descriptions in Hungarian. **A key that ENCRYPTS STORED DATA gets `data_key: true`** + a comment saying why (the restore recovers it and refuses without it; the box never regenerates it) — gated by `scripts/check-data-key.py` (R-127): an env var naming `ENCRYPTION_KEY`/`PEPPER` must be flagged, any other data key goes in its `REGISTRY` with the reason, and an unexplained flag is refused. A key that only SIGNS sessions is not one, whatever its label says. | -| **Known default login → `after_install:`** (decision 45, controller ≥ 0.279.0) | `templates/bookstack/.felhom.yml` (`ADMIN_PASSWORD` field + `after_install:` block); `FIRST-ADMIN.md` for every app | An app that starts with a known admin login gets a generated `type: password` field (`generate: "password:24"`, `locked_after_deploy: true`) and ONE `after_install: {service, env: [ADMIN_PASSWORD], command: [...], success: ""}` through the app's OWN CLI, run once after a FRESH install. Keep `app_info.default_creds` — the page hides it once the command succeeded and warns while it is in effect. **Prove on 9202 (drill catalog) before live: the default fails, the generated password works, a wrong one fails.** TRAPS: `success:` is required because a CLI can exit 0 on an error (claper's `rpc`); **pass the password as its own argument, never inside program code** (`sys.argv[1]` — mealie, wger; security review 2026-09-29); a special-character policy uses `generate: "password:24:special"` (controller ≥ 0.280.0, calibre-web); a Hungarian first-steps change needs `check-copy-i18n.py --capture-freeze`. | +| **Known default login → `after_install:`** (decision 45, controller ≥ 0.279.0) | `templates/bookstack/.felhom.yml` (`ADMIN_PASSWORD` field + `after_install:` block); `FIRST-ADMIN.md` for every app | An app that starts with a known admin login gets a generated `type: password` field (`generate: "password:24"`, `locked_after_deploy: true`) and ONE `after_install: {service, env: [ADMIN_PASSWORD], command: [...], success: ""}` through the app's OWN CLI, run once after a FRESH install. Keep `app_info.default_creds` — the page hides it once the command succeeded and warns while it is in effect. **Prove on 9202 (drill catalog) before live: the default fails, the generated password works, a wrong one fails.** TRAPS: `success:` is required because a CLI can exit 0 on an error (claper's `rpc`); **pass the password as its own argument, never inside program code** (`sys.argv[1]` — mealie, wger; security review 2026-09-29); a special-character policy uses `generate: "password:24:special"` (controller ≥ 0.280.0, calibre-web); a Hungarian first-steps change needs `check-copy-i18n.py --capture-freeze`. **THE COMMAND TEXT MAY NOT CONTAIN `$` (2026-10-10, grocy).** The controller runs every element of `command:` through Go's `os.Expand` (`internal/stacks/after_install.go` `expandAfterInstall`) and REFUSES the command when it names anything outside `env:` — so a `$`-sigil language cannot be inlined. Measured on 9202: a `php -r` command came back `after_install: [pw argv dsn db i t e s n q h] not declared in env or has no value — not run`, the hold stayed closed and the default password stayed in place. mealie (`sys.argv[1]`) and dawarich (`ARGV[0]`) avoid it by language; PHP and shell cannot. The way out, when the image has no `$`-free interpreter: the compose entrypoint writes the code to a FILE (ours, outside the app's tree, no secret in it) and `after_install` passes only `${PASSWORD}` — `templates/grocy/` does both halves and its compose header explains them. | | **Open first-run screen → `setup_gate:`** (decision 46, controller ≥ 0.280.0) | `templates/n8n/.felhom.yml` (probe), `templates/uptime-kuma/.felhom.yml` (no probe → the household's button); `FIRST-ADMIN.md` | `setup_gate: true` + optional `setup_done_probe: {url: http://:/, field: , done: ""}` | TRAPS: the probe must FLIP on the setup — measure it before and after on 9202; an app with open sign-up after setup (R-711) is not closed by the gate; `url` is read on the docker network, so it names the container, not the subdomain. | | **Open sign-up after the setup → `signup_block:`** (decision 47, controller ≥ 0.281.0) | `templates/opengist/.felhom.yml`, `templates/calcom/.felhom.yml` | `signup_block: ""` + `app_info.add_people` (hu) / `i18n.en.app_info.add_people` | TRAPS: block the app's API sign-up call, not only the page; an app's own invite link often uses the same address (the household's 15-minute window covers it); `add_people` is copy — `--capture-freeze`. | | **The visitor's address — read from the RIGHT, never the leftmost** (R-753, controller ≥ 0.286.0) | `templates/docmost/docker-compose.yml` (the reset), `templates/home-assistant/` (a right-walking reader) | traefik keeps the tunnel's chain: `, , 172.16.253.2`; the LAN gives one entry. An app that reads the LEFTMOST entry gets the router reset: `traefik.http.middlewares.-xff.headers.customrequestheaders.X-Forwarded-For=` + `traefik.http.routers..middlewares=-xff` (defined on the router's own container) — it then reads X-Real-Ip or its peer. A right-walking reader gets `172.16.0.0/12` as its trusted proxies. A FIXED count is wrong for one of the two paths — leave count readers alone. **Never turn on** a leftmost switch: glance `proxied`, karakeep `RATE_LIMITING_ENABLED`, vaultwarden `IP_HEADER=X-Forwarded-For`, PocketBase `UseLeftmostIP`, navidrome's reverse-proxy whitelist, Plex `ALLOWED_NETWORKS`. Sweep of all 56: `felhom.eu/documentation/audits/visitors-2026-10-01/A/sweep/`. Checklist 3.10 | @@ -42,6 +42,9 @@ Templates are config; the few script helpers other scripts must REUSE, never re- | Docker healthcheck — curl-capable images | `templates/paperless-ngx/docker-compose.yml` (~L76) | `test: ["CMD", "curl", "-f", "http://localhost:/"]` (~18 apps: jellyfin, immich, sonarr…). | | Docker healthcheck — Node images (no wget/curl) | `templates/rallly/docker-compose.yml` (~L49) | `test: ["CMD", "node", "-e", "require('http').get(...)"]` — used when the image lacks wget (that was rallly's actual bug). | | Docker healthcheck — Python images | `templates/mealie/docker-compose.yml` (~L47) | `test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.create_connection(('localhost',),2); s.close()\""]` (mealie, crafty-controller). tandoor/wger use `urllib.request` variants for real HTTP checks. | +| **Docker healthcheck — an image with NO HTTP client at all** | `templates/lubelogger/docker-compose.yml` (`healthcheck.test`) | `test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/ && printf 'GET HTTP/1.0 + +' >&3 && head -1 <&3 | grep -q 200"]` — the fifth family, added 2026-10-10. `ghcr.io/hargata/lubelogger` carries **no curl, no wget, no nc, no python and no node** (measured), so none of the four families above exists in it; it does carry bash, and bash's `/dev/tcp` speaks TCP without any helper binary. **Measured in BOTH directions before it was written down**: rc 0 against the running app (`HTTP/1.1 200 OK`), rc 1 against a dead port. TRAPS: it needs **bash**, not `sh` — Debian's `sh` is dash and has no `/dev/tcp`, so check `command -v bash` in the image first; `head -1 … | grep -q` is a pipeline, so the FIRST command's failure is invisible — the `&&` chain in front of it is what makes a refused connection fail the check, and that is why the connect is not inside the pipeline. Prefer a real HTTP line (`200`) over a bare TCP connect: a port that is open while the app refuses every request is the uptime-kuma shape (R-613). | | Docker healthcheck — DB/Redis sidecars | `templates/paperless-ngx/docker-compose.yml` (~L107, L129) | postgres: `pg_isready -U -d `; mariadb: `healthcheck.sh --connect --innodb_initialized`; redis: `redis-cli ping`. App container gets `depends_on: : condition: service_healthy`. | | MariaDB sidecar — `MARIADB_AUTO_UPGRADE=1` | `templates/bookstack/docker-compose.yml` (`bookstack-db` `environment:`), also kimai/nextcloud/romm | **Every `mariadb:` sidecar carries `MARIADB_AUTO_UPGRADE=1`** (operator ruling 2026-09-13, `felhom.eu/documentation/audits/SPIKE-r459-mariadb-upgrade-2026-09-06.md`). Without it a major engine move starts on the old datadir, logs that the conversion was **skipped**, and says `Check required!` on every start forever (R-459); with it the engine converts in ~7 s and backs its system tables up first (`system_mysql_backup_.sql.zst` left in the datadir). `MARIADB_DISABLE_UPGRADE_BACKUP` stays UNSET — that backup is the precaution. **Not an image change, so `catalog_since` does not move.** TRAP (R-464): the entrypoint prints `MariaDB upgrade not required` on an UNSUPPORTED downgrade too — ask `mariadb-upgrade --check-if-upgrade-is-needed` (exit 0 = needed, 1 = not), never the log line. PostgreSQL sidecars have NO equivalent (the image runs no `pg_upgrade`, R-463). **Until Slice 4 (R-448) ships, no template may move a `mariadb:`/`postgres:` pin across a MAJOR** — `scripts/check-engine-major.py` refuses it in the pre-push hook. | | Memory convention | `templates/paperless-ngx/docker-compose.yml` (~L71) + `.felhom.yml resources:` | EVERY service has `deploy.resources.limits.memory` (compose is the enforcement). NO `reservations` anywhere. `.felhom.yml mem_limit` = SUM of all containers' limits (see paperless header comment: 768+256+128=1152M); `mem_request` = expected steady-state usage, display-only. **Gated since 2026-10-05 (R-758):** `scripts/check-mem-limit-sum.py` (`--fast`, stdlib only) refuses a `mem_limit` that is not the sum and a service with no limit; `steps/` files are not judged. | diff --git a/onboarding/grocy.md b/onboarding/grocy.md new file mode 100644 index 0000000..e34ee0a --- /dev/null +++ b/onboarding/grocy.md @@ -0,0 +1,76 @@ +# Onboarding record — grocy + +app: grocy +opened: 2026-10-10 +template_at: b38aa923177db40bcb399546c625167fa029f025 + + + +0.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md — MIT. GitHub's licence endpoint answers `spdx_id: MIT` for grocy/grocy and the file is LICENSE.md; we pull linuxserver.io's image and redistribute nothing. +0.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md — v4.7.1 on 2026-09-04; three releases in 2026, 83 since 2017, issues answered, not archived. +0.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md — lscr.io/linuxserver/grocy:4.7.1, amd64 and arm64, read from the manifest. There is NO image published by grocy itself: grocy/grocy on Docker Hub is 404 and grocy's own README answers "How to run using Docker" with one link, to linuxserver.io. +0.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — a repo-wide grep at v4.7.1 finds two outbound hosts in the whole codebase: world.openfoodfacts.org for the barcode lookup the household asks for, and releases.grocy.info for demo pictures when MODE is not production. No update check, no analytics, nothing at start. Switch: GROCY_STOCK_BARCODE_LOOKUP_PLUGIN empty. +0.5 | n/a | it needs no internet at runtime at all; the first start downloads nothing and no feature requires a remote service +0.6 | n/a | the image exposes HTTP only and grocy speaks nothing else, so the tunnel costs the household nothing +0.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — grocy has no native app; it is an installable web app. Third-party phone apps use its REST API with the GROCY-API-KEY header. Measured on 9202 through traefik: no credentials 401, a wrong key 401, and a key minted through grocy's own page answered 200 on /api/system/info and on /api/stock. +0.8 | done | app-catalog-felhom.eu/templates/grocy/.felhom.yml — the Hungarian description and app_info.tagline say what a household gets. +0.9 | n/a | grocy never calls itself server-side and the bench ran it with no environment override at all +1.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/gates.txt — catalog_gates.py grocy green, image-pins and image-resolvable included. +1.2 | n/a | grocy has no database service: SQLite inside its own volume is what upstream packaging runs +1.3 | n/a | no MariaDB and no PostgreSQL sidecar exists in this template +1.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/box-verdict-grocy-FAILED-prefix.json — the step INTO the pin, measured: the previous upstream release 4.6.0 installed and seeded, then moved to 4.7.1, migration observed, seed read back, healthy after. The first run of this edge FAILED and is kept: grocy's persisted config.php did not follow the image. +1.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — nginx master and php-fpm 8.5 master, workers as abc. PID 1 is s6-svscan, the image's supervisor, which is what the box probe reads. +1.6 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — grocy's own settings file names no key, secret, token or PEM at all; the only credential it has is the admin password the box generates, and every login route was then exercised. +1.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — the image's s6 oneshots listed and init-grocy-config read in full. It has one start-time decision: it copies config-dist.php to /config/data/config.php only if that file is absent. That line is the 4.6 to 4.7 break, and the template's entrypoint answers it. +1.8 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — MODE production and DISABLE_AUTH false as the running app reads them; an unknown page 404 with zero stack-trace markers, through traefik too. +1.9 | n/a | no secret here encrypts stored data or signs a long-lived token; the admin password is a login grocy hashes +2.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/volume-persistence.txt — the runtime gate deployed grocy, exercised it and compared where the data landed against what the compose mounts. +2.2 | done | app-catalog-felhom.eu/templates/grocy/docker-compose.yml ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — one named volume on NVMe holds the database, the settings and the pictures the household uploads. No HDD and no userdata path, deliberately: a fresh install is under a megabyte and there is no bulk data to browse. +2.3 | n/a | the template declares no HDD path, so there is no per-path backup class to set +2.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — PUID and PGID 1000; everything under /config belongs to abc and php-fpm's workers run as abc, which is why after_install runs as abc too. +2.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/restore.txt — on 9202: the per-app backup the guarded Update took, then remove keeping the backups, then the household's own restore button, then the seed read back through grocy's own API with its three controls. +2.6 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/remove.txt — remove with data measured three times: 200, the named volume gone, the backup paths removed. What is left in the stack directory afterwards is the template pair the syncer keeps for all templates, not deployed state. +2.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/offsite-size.txt — the whole app is under a megabyte on a seeded install; grocy never decides how big an off-site snapshot is. +2.8 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — through traefik over https, as the household: PUT a file through grocy's own file API 204, GET it back 200 with the content matching, and the same GET with no credentials 401. +3.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/install.txt — class 3, measured: a fresh install's users table holds exactly one row, admin, and admin/admin signs in. No first-run screen; an unauthenticated page is 302 to /login and /api is 401. +3.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — on 9202 through traefik: the documented default refused, the box's generated password signs in, a wrong password refused. Proven on the bench against both 4.7.1 and 4.6.0 first. +3.3 | n/a | grocy has no open first-run screen to gate; its first admin already exists with a password, which is 3.2's case +3.4 | n/a | grocy has no registration route at all and only a signed-in admin can add a user, measured on 9202 +3.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/defaultpoll.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/poll.txt — the documented default login POSTed once a second as a stranger from before the install press: 65 attempts, 0 signed in. 404 while nothing answered, then 401 behind the install hold, then invalid once the hold opened, which it only does after after_install has replaced the password. +3.6 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — 25 wrong passwords for the public name through traefik, then the household's right one: in at once, and the first member's session still live. No lock-out and no throttle, which is why the name is not generated here. +3.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — the household added a family member through grocy's own POST /api/users and that member signed in. app_info.add_people says where the page is, in both languages. +3.8 | done | app-catalog-felhom.eu/templates/grocy/.felhom.yml — the password is its own argument to the helper script; no value is pasted into program text. +3.9 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — the browser form with its https Origin and Referer signs in through traefik; the API route a phone app uses refuses without a key and with a wrong key, and answers a key minted through grocy's own page. +3.10 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — a repo-wide grep at v4.7.1 for REMOTE_ADDR, HTTP_X_FORWARDED_FOR and getClientIp finds nothing, and a failed sign-in carrying a forged leftmost X-Forwarded-For through traefik left that address nowhere in the app's log. Nothing is decided on it, so no router reset is needed. +4.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — the image carries BusyBox wget and no curl, so the probe is the wget family, dialling 127.0.0.1 and /login which answers 200. Measured both ways: rc 0 against the app, rc 1 against a dead port. +4.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/gates.txt — gate probe-matches-compose green: the controller probe is api on port 80 at /login, which is what the compose healthcheck dials. +4.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/install.txt — on a cold first install on 9202: deployed in 30 s, every container healthy 36 s after the press, RestartCount 0. +4.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt — the negative control: docker stop, and the controller read stopped within 5 s with the front door 404; docker start and it was running again 10 s later. +4.5 | done | app-catalog-felhom.eu/templates/grocy/docker-compose.yml — container_name grocy is exactly the stack name, and there are no sidecars to be mistaken for it. +5.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/mem-grocy.csv ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/remove.txt — on the bench with swap 0, sampled from the container's birth every 2 s: peak anon 27.2 MiB, 6.7 per cent of the 384 M limit, peak swap 0, oom_kill 0, restarts 0. On the box the same app peaked at 45.7 and 86.6 MiB across two installs, 0 swap, 0 kills. +5.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json — the 10-minute soak after the readback: 605 s, 10343 requests all answered, peak anon 6.7 per cent of the limit, cgroup peak including the page cache 25.4 per cent, 0 oom_kills, 0 restarts. +5.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/gates.txt — gate mem-limit-sum green: one service at 384M and mem_limit 384M, and the compose header says the same. +5.4 | n/a | grocy is PHP behind nginx, not a Node or Java runtime that sizes a heap from the limit +5.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — amd64 and arm64, so pi_compatible true; the image is about 275 MB to pull. +6.1 | done | app-catalog-felhom.eu/scripts/upgrade_fixtures_box.py ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json — the Grocy fixture seeds and reads back through grocy's own routes: its login, a product, and the stock route, then the stock read back for the name and the amount. It proves itself on every verify: no cookie must be 401, a session from a wrong password must be 401, and a product id that cannot exist must be 404, or the readback is declared unusable. +6.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/box-verdict-grocy.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/step.txt — bench proven and box proven, the box through the product's own guarded Update in 41 s. Written into the template by upgrade-test.py --write-ladder, never by hand. +6.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/box-verdict-grocy-FAILED-prefix.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/step.txt — the undo was measured on a REAL failure, not a forced one: before the config.php fix the same step went backing-up, pulling, copying, verifying, undoing, undone in 346 s, the app came back on 4.6.0 and the seed read back with all three controls. The bench's own bare abort, which takes no backup, loses the data; only the product's backup-first undo returns it. +6.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json — files_changed is empty: grocy rewrites no file at start, so there is no files_may_change mark to carry. +6.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/partA-probes.txt — linuxserver.io rebuilds weekly and re-pushes the plain version tag: 4.7.1 changed from ls342 to ls343 with the same grocy inside. The catalog pins the plain tag as its four other linuxserver apps do, and retest-floating.py is the monthly instrument for that class. The tag shape itself is stable. +7.1 | n/a | grocy sends no mail at all; its own settings file names no SMTP, sendmail or mail function +8.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/gates.txt — gate copy-i18n green with grocy in the freeze: 20 Hungarian strings, 20 with English, informal te, no keruk. +8.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/install.txt — every claim on the app page was followed on 9202: the tagline, the use cases, the first steps, default_creds which the page hides once after_install has replaced it, and add_people, since a family member really can be added that way and signs in. +8.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/shots — grocy's own icon with its single fill made white like the other logos, and three screenshots of the app's own Hungarian UI taken on the bench from this template; installed as felhom.eu/website/assets/grocy-logo.svg and grocy-screenshot-1..3.webp. +8.4 | done | app-catalog-felhom.eu/README.md ; app-catalog-felhom.eu/FIRST-ADMIN.md ; app-catalog-felhom.eu/templates/grocy/.felhom.yml — both README tables, the FIRST-ADMIN row, category home and catalog_since 2026-10-10. +8.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/site-count.txt — the count moved in every place the website states it, both language sets, with a control that no old count is left. +9.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/gates.txt — catalog_gates.py grocy exit 0, every gate in the table. +9.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/install.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/checks.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/step.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/restore.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/remove.txt — a fresh install on 9202 from the drill catalog, walked as the household and as a stranger, start to finish, twice: the second time after the after_install defect was fixed. +9.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/README.md — this record; the two defects found are in the register and in the catalog CHANGELOG. +9.4 | done | app-catalog-felhom.eu/templates/grocy ; app-catalog-felhom.eu/onboarding/grocy.md — published to the live catalog in one commit. diff --git a/onboarding/lubelogger.md b/onboarding/lubelogger.md new file mode 100644 index 0000000..a7bf8bd --- /dev/null +++ b/onboarding/lubelogger.md @@ -0,0 +1,76 @@ +# Onboarding record — lubelogger + +app: lubelogger +opened: 2026-10-10 +template_at: b38aa923177db40bcb399546c625167fa029f025 + + + +0.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md — MIT. GitHub's licence endpoint answers `spdx_id: MIT` for hargata/lubelog and the file is LICENSE; we pull the project's own image and redistribute nothing. +0.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md — v1.7.3 on 2026-09-12, and SEVENTEEN releases in 2026 alone (v1.6.1 in February through v1.7.3 in September); 74 releases since 2024, issues answered, not archived. +0.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md — ghcr.io/hargata/lubelogger:v1.7.3, amd64 and arm64, read from the manifest. This is the image upstream's own docker-compose.yml names; Docker Hub carries the same tags. +0.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — nothing at start: the first log lines are the banner and `Now listening`. Three outbound fetches exist and each is on a page the household opens on purpose: the sponsors list and the language pack from hargata.github.io, and the release check from api.github.com behind a `checkForUpdate` request flag. +0.5 | n/a | it needs no internet at runtime; everything it does works with no network at all +0.6 | n/a | HTTP only on 8080. Its SignalR websocket at /api/ws passes the tunnel like any request, as MeTube's measured 101 showed +0.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — no official native app; it is a PWA. Its API takes HTTP Basic or an `x-api-key` on /api, /kiosk, /images, /documents and /temp. Measured on 9202 through traefik: Basic right 200, Basic wrong 401, a bogus api key 401. +0.8 | done | app-catalog-felhom.eu/templates/lubelogger/.felhom.yml — the Hungarian description and app_info.tagline say what a household gets. +0.9 | n/a | it never calls itself server-side and the bench ran it with no environment override at all +1.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/gates.txt — catalog_gates.py lubelogger green, image-pins and image-resolvable included. +1.2 | n/a | LiteDB inside its own volume, which is exactly what upstream's own default compose runs; PostgreSQL is optional and not used here +1.3 | n/a | no MariaDB and no PostgreSQL sidecar exists in this template +1.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json — the step INTO the pin, measured: v1.7.2 installed and seeded, then moved to v1.7.3, seed read back, healthy after. LiteDB performs no schema migration, so there is no migration switch to set. +1.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — PID 1 is `./CarCareTracker`, Kestrel, and its own log says `Hosting environment: Production`. +1.6 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — the image's appsettings.json is printed in full in the probe file; the only keys that are secrets are UserNameHash and UserPasswordHash, which ship EMPTY and which the template's entrypoint fills from the generated password. Every login route was then exercised. +1.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — the image has NO entrypoint script at all: it execs the app. Its start-time switches are configuration keys, and the probe prints the shipped appsettings.json in full with each one decided. The one that matters is `EnableAuth: false`. +1.8 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — `Hosting environment: Production` in the app's own log, and an unknown page 404 with no debug page, through traefik. +1.9 | n/a | the DataProtection key signs the login cookie and nothing else; losing it signs everyone out and destroys no data +2.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/volume-persistence.txt — the runtime gate deployed lubelogger, exercised it and compared where the data landed against what the compose mounts. +2.2 | done | app-catalog-felhom.eu/templates/lubelogger/docker-compose.yml ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — two named volumes on NVMe: the data tree (database, documents, images, temp, themes, translations) and the DataProtection keys. No HDD and no userdata path, deliberately: the uploads are receipts and a few photos, and an attached receipt is only ever opened from the record it belongs to. +2.3 | n/a | the template declares no HDD path, so there is no per-path backup class to set +2.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — the image runs as root (its APP_UID is not used as USER) and writes only inside its own two volumes; there is no PUID/PGID to set. +2.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/restore.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/restore-password.txt — remove keeping the backups then the household's own restore button: the app came back running in 22.3 s with the household's vehicle and its service record. The fixture reported False, and chasing that is the useful part: a `type: password` field is not in PortableSecretEnvVars, so the install's password does not travel in the unit and the box mints a new one. MEASURED after the restore: the install's password is refused, the value the box now holds signs in, and the data is there. +2.6 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/remove.txt — remove with data on 9202: 200, both named volumes gone, the Hungarian note saying there was nothing on an external drive. What is left in the stack directory is the template pair the syncer keeps for all templates, not deployed state. +2.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/offsite-size.txt — 140 KB of data and 8 KB of keys on a seeded install; LubeLogger never decides how big an off-site snapshot is. +2.8 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/member-and-upload.txt — through traefik as the household: a PDF uploaded through the app's own file route, fetched back 200 with the content matching, and the same fetch with NO credentials 302 to the login, not 200. +3.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/install.txt — class 1. THE IMAGE'S OWN DEFAULT IS NO LOGIN AT ALL, measured: a stranger got 200 on /, on /api/vehicles and on /Home/Settings and CREATED A VEHICLE that came back from the API. With the template's entrypoint the first answer the app ever gives a stranger is 401 / 302 to the login. +3.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — on 9202 through traefik: the generated login signs in, a wrong password is refused with the app's own message, and the API refuses both ways. There is no shared default to replace, because the template's login did not exist before we made it. +3.3 | n/a | there is no open first-run screen: with the template the app demands a login from its first byte, so there is no setup window to gate +3.4 | n/a | sign-up is closed by the app itself: RegisterNewUser refuses without a token the root user mints, measured twice on 9202 with an empty and a guessed token +3.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/poll.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — a stranger's poll of /api/vehicles once a second from the install press: 404 while nothing answered, then 401, never 200. On the bench the same was watched from the container's creation: at t+1 s nothing was listening, at t+2 s /api/vehicles was already 401. There is no window. +3.6 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — 25 wrong passwords for the public name through traefik, each refused with the app's own message, then the household's right one: in at once. No lock-out and no throttle, which is why the NAME may stay a plain word. +3.7 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/member-and-upload.txt — the admin minted a registration token for a family member on the app's own Admin page with autoNotify false, so no mail server is needed; the member registered with it and SIGNED IN, and the same member with a wrong password was refused. The first attempt used /Login/SendRegistrationToken, which is the self-service path and correctly answers `Open Registration Disabled`; `app_info.add_people` was corrected to the route that works. +3.8 | n/a | there is no after_install command at all; the generated password reaches the app as an environment variable the entrypoint hashes, and is unset from PID 1 afterwards +3.9 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — the browser form signs in through traefik and again with a browser's https Origin and Referer; the API routes a phone or a script uses answer 200 on the right Basic credentials and 401 on the wrong ones. +3.10 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — LubeLogger reads the client address in ONE place: LoginController takes the real peer from Connection.RemoteIpAddress and APPENDS the X-Forwarded-For string to a log line for a failed sign-in. Nothing is decided on it. Measured through traefik on 9202 with a forged leftmost entry, and the forged address did not even reach the app: the log line carried the real chain. +4.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt ; app-catalog-felhom.eu/REUSE.md — this image has NO curl, NO wget, NO nc, NO python and NO node, so none of REUSE.md's four healthcheck families exists in it. It has bash, and bash's /dev/tcp speaks TCP without a helper binary. Measured both ways: rc 0 against the app with an HTTP 200 line, rc 1 against a dead port. Written up as the fifth family. +4.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/gates.txt — gate probe-matches-compose green: the controller probe is api on port 8080 at /Login/Index, which is what the compose healthcheck dials. +4.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/install.txt — on a cold first install on 9202: deployed in 20 s, every container healthy 27 s after the press, RestartCount 0. +4.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt — the negative control: docker stop, the controller read the app down and the front door stopped answering; docker start and it came back. +4.5 | done | app-catalog-felhom.eu/templates/lubelogger/docker-compose.yml — container_name lubelogger is exactly the stack name, and there are no sidecars. +5.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/mem-lubelogger.csv ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/remove.txt — on the bench with swap 0, sampled from the container's birth every 2 s: peak anon 66.2 MiB, 12.3 per cent of the 512 M limit, peak swap 0, oom_kill 0, restarts 0. On the box three installs peaked at 22.1, 35.1 and 46.6 MiB, 0 swap, 0 kills. +5.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json — the 10-minute soak after the readback: 605 s, 11988 requests all answered, peak anon 12.3 per cent of the limit, cgroup peak including the page cache 17.9 per cent, 0 oom_kills, 0 restarts. +5.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/gates.txt — gate mem-limit-sum green: one service at 512M and mem_limit 512M, and the compose header says the same. +5.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/mem-lubelogger.csv ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json — it is .NET, which DOES size its heap from the cgroup limit, and the measurement says the limit is not the binding constraint: anon peaked at 12.3 per cent of 512 M under a 605-second soak with no kill and no restart, and the box's three installs peaked lower still on the same limit. +5.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — amd64 and arm64, so pi_compatible true; the image is about 267 MB to pull. +6.1 | done | app-catalog-felhom.eu/scripts/upgrade_fixtures_box.py ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json — the LubeLogger fixture seeds and reads back through the app's own routes: its login, a vehicle with a marker plate, and a service record with a marker description, then both read back from the API. It proves itself on every verify: no cookie must be 401, a session from a wrong password must be 401, and a vehicleId that cannot exist must answer an empty list. +6.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/box-verdict-lubelogger.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/step.txt — bench proven and box proven, the box through the product's own guarded Update in 25.6 s: backing-up, safety-dump, pulling, copying, verifying, done. Written into the template by upgrade-test.py --write-ladder, never by hand. +6.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/step.txt — no forced-fail case was manufactured for this app, and one was not needed to learn the answer: the harness's ABORT leg put v1.7.2 back and recorded `starts-and-serves`, so an undo on this app returns a working app with its data (LiteDB performs no destructive migration). The product's own undo on a REAL failure was measured in this same session on grocy, where it restored the pre-update backup and the data read back. +6.4 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json — files_changed is empty: it rewrites no file at start, so there is no files_may_change mark to carry. +6.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/FIT.md ; felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/partA-probes.txt — the tag shape is `v..` and has not changed across 74 releases; `latest` and `edge` exist beside it and are not used. The publisher does not re-push a version tag: each release is a new tag, seventeen of them in 2026. +7.1 | n/a | it sends mail only when MailConfig is set, which this template does not set; its own startup banner says SMTP Not Configured and it boots +8.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/gates.txt — gate copy-i18n green with lubelogger in the freeze: 21 Hungarian strings, 21 with English, informal te, no keruk. +8.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/member-and-upload.txt — every claim on the app page was followed on 9202: the tagline, the use cases, the first steps, and add_people, which was CORRECTED after the first route turned out to be the self-service one. +8.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/shots — the app's own icon with its dark background dropped and the mark made white, the rule SparkyFitness's PNG follows, and three screenshots of the app with a household's own car and its service history; installed as felhom.eu/website/assets/lubelogger-logo.png and lubelogger-screenshot-1..3.webp. +8.4 | done | app-catalog-felhom.eu/README.md ; app-catalog-felhom.eu/FIRST-ADMIN.md ; app-catalog-felhom.eu/templates/lubelogger/.felhom.yml — both README tables, the FIRST-ADMIN row, category home and catalog_since 2026-10-10. +8.5 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/site-count.txt — the count moved in every place the website states it, both language sets, with a control that no old count is left. +9.1 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/gates.txt — catalog_gates.py lubelogger exit 0, every gate in the table. +9.2 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/install.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/checks.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/step.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/restore.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/member-and-upload.txt ; felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/remove.txt — a fresh install on 9202 from the drill catalog, walked as the household and as a stranger, start to finish. +9.3 | done | felhom.eu/documentation/audits/new-apps-2026-10-10/README.md — this record; the findings are in the register and in the catalog CHANGELOG. +9.4 | done | app-catalog-felhom.eu/templates/lubelogger ; app-catalog-felhom.eu/onboarding/lubelogger.md — published to the live catalog in one commit. diff --git a/scripts/check-onboarding.py b/scripts/check-onboarding.py index e15a60c..69ea3cb 100644 --- a/scripts/check-onboarding.py +++ b/scripts/check-onboarding.py @@ -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" diff --git a/scripts/copy_freeze/hu.json b/scripts/copy_freeze/hu.json index 37aeb3a..874a7bc 100644 --- a/scripts/copy_freeze/hu.json +++ b/scripts/copy_freeze/hu.json @@ -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)'", diff --git a/scripts/upgrade-test.py b/scripts/upgrade-test.py index f5f34d4..9792aa9 100755 --- a/scripts/upgrade-test.py +++ b/scripts/upgrade-test.py @@ -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/.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 diff --git a/scripts/upgrade_fixtures_box.py b/scripts/upgrade_fixtures_box.py index f5238bd..dbe51ed 100644 --- a/scripts/upgrade_fixtures_box.py +++ b/scripts/upgrade_fixtures_box.py @@ -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=` — 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//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/` 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=` 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(), } diff --git a/templates/grocy/.felhom.yml b/templates/grocy/.felhom.yml new file mode 100644 index 0000000..0f5c03b --- /dev/null +++ b/templates/grocy/.felhom.yml @@ -0,0 +1,139 @@ +# ============================================================================= +# .felhom.yml - App metadata for felhom-controller +# ============================================================================= +# Grocy — linuxserver.io's image, because grocy publishes none of its own (see the compose header). +# Onboarding record: onboarding/grocy.md (NEW-APP-CHECKLIST.md). First admin: CLASS 3 — grocy starts with +# admin/admin, and after_install below replaces that password with a generated one. No setup gate: there is no +# first-run screen, and a request without a session answers 302 → /login (401 on /api), measured 2026-10-10. + +# --- Display info (shown on dashboard) --- +display_name: "Grocy" +description: "Háztartásvezetés: mi van otthon, mi fogy el, bevásárlólista és házimunkák" +category: "home" +subdomain: "kamra" +slug: "grocy" +# catalog_since: the date THIS repo last changed this app's pinned images. Any commit that +# changes an image: line must set this to the same day (see CLAUDE.md). +catalog_since: "2026-10-10" + +# --- Resource hints (displayed on deploy screen) --- +resources: + mem_request: "60M" + mem_limit: "384M" # one service: grocy 384M + pi_compatible: true + needs_hdd: false + +# --- Deploy wizard fields --- +deploy_fields: + - env_var: DOMAIN + label: "Domain" + type: domain + description: "A szerver domain neve" + locked_after_deploy: true + + - env_var: SUBDOMAIN + label: "Aldomain" + type: subdomain + default: "kamra" + required: true + locked_after_deploy: true + description: "Az alkalmazás aldomainje" + + # Grocy's own README: "Default login is user admin with password admin, please change the password + # immediately". The box does that for the household: after_install below sets THIS password, and the app + # page shows it. Grocy has no lock-out (25 wrong tries then the right one, in at once — measured + # 2026-10-10), so unlike calibre-web the NAME need not be generated: it stays `admin`. + - env_var: ADMIN_PASSWORD + label: "Admin jelszó" + type: password + generate: "password:24" + locked_after_deploy: true + description: "Az első bejelentkezéshez: az admin név és ez a jelszó. Telepítéskor készül, csak te látod." + +# --- Customer-facing info --- +app_info: + tagline: "Háztartásvezetés: mi van otthon, mi fogy el, mit kell megvenni, kinek mi a dolga" + default_creds: "admin / admin" + docs_url: "https://grocy.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." + use_cases: + - 'Nyilvántartja, mi van otthon, és szól, ha valami elfogyott vagy lejár' + - 'Bevásárlólista, ami magától felveszi azt, ami a minimum alá csökkent' + - 'Vonalkóddal gyorsan beírod, mit vettél, és mit használtál el - a telefon kamerájával is' + - 'Recept és heti étrend, ami levonja a hozzávalókat a készletből' + - 'Házimunkák és ismétlődő feladatok, hogy látszódjon, kinek mi a dolga' + first_steps: + - 'Nyisd meg a kamra.DOMAIN címet a böngészőben' + - 'Jelentkezz be az admin névvel és a Beállítások oldalon látható jelszóval' + - 'Vedd fel az első pár terméket (Termékek, majd Új termék), és állíts be nekik minimum mennyiséget' + - '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' + prerequisites: + - 'Nem kell hozzá külső merevlemez' + +# --- After a fresh install (controller >= 0.279.0; decision 45) --- +# Grocy ships no CLI, no admin env var and no headless setup. The replacement has to use grocy's own hashing +# (`password_hash($password, PASSWORD_ARGON2ID)`, services/UsersService.php) and PHP is the only interpreter in +# the image, so the code CANNOT live here: the controller expands every `$name` in an after_install command and +# refuses one naming anything it was not given. Measured on 9202 2026-10-10, the first attempt at this template: +# after_install: [pw argv dsn db i t e s n q h] not declared in env or has no value - not run +# with the app left behind its install hold and the default password still in place. So the code lives in the +# file the compose entrypoint writes (see the compose header, which explains the whole arrangement), and this +# command passes only the password - as its OWN argument, so a quote in it cannot be read as code +# (security review 2026-09-29). +# `user: abc` because every file under /config belongs to abc (PUID 1000) and sqlite leaves journal files. +# Idempotent: a retry sets the same password and verifies it again. +after_install: + service: grocy + user: abc + env: [ADMIN_PASSWORD] + command: ["php", "/config/felhom/set-admin-password.php", "${ADMIN_PASSWORD}"] + success: "FELHOM_AFTER_INSTALL_OK" + +# --- Controller health probe (dials what the compose healthcheck dials) --- +healthcheck: + checks: + - type: api + port: 80 + path: "/login" + expect: + status: 200 + +# --- English copy (localisation slice 5, R-560) -------------------------------------------- +# The Hungarian above is UNCHANGED. A box on English reads this block field by field; a missing +# field shows the Hungarian one; a controller older than 0.257.0 ignores the block entirely. +i18n: + en: + description: 'Running the household: what is in the house, what runs out, the shopping list and the chores' + app_info: + tagline: 'Running the household: what is in the house, what runs out, what to buy, whose turn it is' + default_creds: 'admin / admin' + add_people: "In Grocy, sign in as admin and add a family member on the Users page, with their own name and password. Nobody can register on their own." + use_cases: + - 'Keeps track of what is in the house, and tells you when something has run out or is going off' + - 'A shopping list that adds what has dropped below its minimum on its own' + - 'Barcodes to enter what you bought and what you used - with your phone camera too' + - 'Recipes and a weekly meal plan that subtract the ingredients from your stock' + - 'Chores and recurring jobs, so whose turn it is can be seen' + first_steps: + - 'Open kamra.DOMAIN in your browser' + - 'Sign in with the name admin and the password shown on the settings page' + - 'Add your first few products (Products, then New product) and give them a minimum amount' + - 'On your phone, add it to the home screen from the browser menu - it then opens like an app and can read barcodes with the camera' + prerequisites: + - 'No external hard drive is needed' + deploy_fields: + - env_var: DOMAIN + label: 'Domain' + description: 'The server domain name' + - env_var: SUBDOMAIN + label: 'Subdomain' + description: 'The subdomain this app answers on' + - env_var: ADMIN_PASSWORD + label: 'Admin password' + description: 'For the first sign-in: the name admin and this password. It is made at install, and only you see it.' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"grocy": "lscr.io/linuxserver/grocy:4.6.0"}, "to": {"grocy": "lscr.io/linuxserver/grocy:4.7.1"}, "digest": {"grocy": "sha256:c0d0d9d22d3a54fe3f779a743baa2d7bab96b73fad5597ff1a631e2be4b863c8"}, "verdict": "proven", "tested_at": "2026-10-10T09:30:23Z", "harness_version": 5, "evidence": "felhom.eu/documentation/audits/new-apps-2026-10-10/bench/grocy/evidence/MV-grocy/verdict.json", "box_evidence": "felhom.eu/documentation/audits/new-apps-2026-10-10/box/grocy/step.txt", "memory_peak_pct": 6.7, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "memory_basis": "anon", "memory_cgroup_peak_pct": 25.4} diff --git a/templates/grocy/docker-compose.yml b/templates/grocy/docker-compose.yml new file mode 100644 index 0000000..b2eaa1e --- /dev/null +++ b/templates/grocy/docker-compose.yml @@ -0,0 +1,161 @@ +# Grocy - Háztartásvezetés: készlet, bevásárlólista, házimunkák +# Domain: ${SUBDOMAIN}.${DOMAIN} +# Database: None (SQLite inside the app's own config volume — /config/data/grocy.db) +# RAM: ~30M (mem_limit: 384M) | Pi-compatible: Yes (amd64, arm64) +# +# Environment variables: +# DOMAIN - Your domain (e.g., demo-felhom.eu) +# ADMIN_PASSWORD - Generated at install; after_install replaces grocy's admin/admin with it +# +# Storage layout: +# Everything → grocy_config:/config (named volume, NVMe) +# /config/data/grocy.db the whole household's stock, recipes, chores +# /config/data/config.php settings (copied from config-dist.php on the first start) +# /config/data/storage product and recipe pictures the household uploads +# /config/felhom/ the one helper script the entrypoint writes (see below) +# /config/nginx /config/log /config/php /config/keys /config/www the image's own files +# No HDD mount: grocy keeps no bulk data. Measured 2026-10-10 — a fresh install is ~600 KB of +# database and nothing else, so the tier-1 backup unit (database + volumes) holds all of it. +# +# WHY linuxserver.io and not an image from grocy itself: grocy publishes NO image. `grocy/grocy` on +# Docker Hub is 404, and grocy's own README answers "How to run using Docker" with one link, to +# hub.docker.com/r/linuxserver/grocy. Four other catalog apps already come from this publisher. +# TRAP (checklist 6.5): linuxserver REBUILDS WEEKLY and re-pushes the plain version tag, so +# `4.7.1` changed digest from ls342 (2026-09-27) to ls343 (2026-10-04) with the same app inside. +# That is the same-tag re-push class — scripts/retest-floating.py covers it monthly. +# +# Run-identity: PUID/PGID 1000. nginx and php-fpm run their workers as `abc` (= 1000), measured. +# +# First-time setup: +# Default login: admin / admin — the box REPLACES that password at install (after_install in +# .felhom.yml) and shows the generated one on the app page. Measured on the bench 2026-10-10: +# default refused, generated signs in, a wrong one refused. +# +# WHY THE ENTRYPOINT WRITES A SCRIPT, and it is not the gokapi pattern (REUSE.md §3). +# The replacement has to run grocy's own hashing (`password_hash($password, PASSWORD_ARGON2ID)`, +# services/UsersService.php), and PHP is the only interpreter in this image — measured: no python, no +# perl, no ruby, no node. But an `after_install` command may not contain PHP code, because the +# controller EXPANDS every `$name` in it (`internal/stacks/after_install.go` `expandAfterInstall`, +# Go's `os.Expand`) and refuses the command naming anything it was not handed. Measured on 9202 +# 2026-10-10, the first attempt at this template: +# after_install: [pw argv dsn db i t e s n q h] not declared in env or has no value — not run +# and the app stayed behind its install hold with the default password still in place. So the code +# lives in a FILE that the entrypoint writes, and `after_install` passes only the password. +# Unlike gokapi's entrypoint this seeds nothing of the app's own: the file is ours, outside grocy's +# tree, carries no secret and no version-pinned format, so an image update cannot collide with it. +# It is rewritten at every start, which is deliberate — a template fix reaches an installed app on +# its next restart instead of only on a fresh install. +# Every `$` in that block is written `$$`: docker compose interpolates the compose file before bash +# ever sees it. Verified by reading the file back inside the container (single `$`, no `$$` left). +# +# WHY THE ENTRYPOINT DELETES /config/data/config.php, and why that is not data loss. +# The image copies its own `config-dist.php` to `/config/data/config.php` ONLY IF THAT FILE IS ABSENT +# (`init-grocy-config`), so the file is written once on the first install and then NEVER FOLLOWS THE +# IMAGE AGAIN. Measured on the bench 2026-10-10: a volume written by 4.6.0, started under 4.7.1, +# answered **HTTP 500 on every page** — +# Invalid setting in config.php: Configured AUTH_CLASS "Grocy\Middleware\DefaultAuthMiddleware" does not exist +# because 4.7 moved that class to `Grocy\Middleware\Auth\DefaultAuthMiddleware`. The app had started, +# its migrations had run and its log said `[ls.io-init] done` — and it served nothing. That is the whole +# 4.6.0 → 4.7.1 upgrade, and it failed on the harness before this line existed. +# Removing the file makes it a DERIVED file instead of state: the image re-copies its own current +# defaults at every start, so config.php can never name a class the running code does not have. Nothing +# is lost — every instance setting grocy has can be set by a `GROCY_` environment variable (its +# own config-dist.php documents that as a higher priority than the file), and the four a Hungarian +# household cares about are set above; each person's own choices (night mode, the start page) live in +# grocy's DATABASE, not in this file. Fixing the class name with `GROCY_AUTH_CLASS` instead was also +# measured and also serves — but it hard-codes an upstream class path that the next rename would break, +# and it would leave every other stale setting stale. +# +# Outbound traffic: none at start and none in the background. The only host the code can reach is +# world.openfoodfacts.org, and only when the household looks an unknown barcode up on purpose +# ("External barcode lookup"). Camera barcode SCANNING is ZXing in the browser and sends nothing. +# Turn the lookup off with GROCY_STOCK_BARCODE_LOOKUP_PLUGIN= (empty) if a household wants that. + +services: + grocy: + image: lscr.io/linuxserver/grocy:4.7.1 + container_name: grocy + restart: unless-stopped + environment: + - TZ=Europe/Budapest + - PUID=1000 + - PGID=1000 + # grocy picks the browser's locale first; this is the fallback. GROCY_* env beats + # /config/data/config.php (second priority vs third), measured 2026-10-10. + - GROCY_DEFAULT_LOCALE=hu + - GROCY_CURRENCY=HUF + - GROCY_ENERGY_UNIT=kcal + - GROCY_CALENDAR_FIRST_DAY_OF_WEEK=1 + entrypoint: + - /bin/bash + - -c + - | + set -e + rm -f /config/data/config.php + mkdir -p /config/felhom + cat > /config/felhom/set-admin-password.php <<'FELHOM_PHP' + setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $$t->query('SELECT 1 FROM users LIMIT 1'); + $$db = $$t; + break; + } catch (Exception $$e) { sleep(1); } + } + if (!$$db) { fwrite(STDERR, "grocy schema never appeared\n"); exit(1); } + $$s = $$db->prepare('UPDATE users SET password = ? WHERE username = ?'); + $$s->execute(array(password_hash($$pw, PASSWORD_ARGON2ID), 'admin')); + $$n = $$s->rowCount(); + // PROVE it before saying so: a plain UPDATE can match no row and still exit 0, and then the + // app page would show a password that does not work. + $$q = $$db->prepare('SELECT password FROM users WHERE username = ?'); + $$q->execute(array('admin')); + $$h = $$q->fetchColumn(); + if ($$n === 1 && $$h && password_verify($$pw, $$h)) { echo "FELHOM_AFTER_INSTALL_OK\n"; } + else { fwrite(STDERR, "grocy admin password NOT set, rows=" . $$n . "\n"); exit(1); } + FELHOM_PHP + chmod 0644 /config/felhom/set-admin-password.php + exec /init + volumes: + - grocy_config:/config + networks: + - traefik-public + deploy: + resources: + limits: + memory: 384M + # BusyBox wget is the only HTTP client in this image (no curl). `/` answers 302, so the probe + # dials /login, which answers 200. Measured both ways 2026-10-10: rc=0 against the app, + # rc=1 against a dead port. + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80/login"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + labels: + - "traefik.enable=true" + - "traefik.http.routers.grocy.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)" + - "traefik.http.routers.grocy.entrypoints=websecure" + - "traefik.http.routers.grocy.tls=true" + - "traefik.http.routers.grocy.tls.certresolver=letsencrypt" + - "traefik.http.services.grocy.loadbalancer.server.port=80" + +volumes: + grocy_config: + +networks: + traefik-public: + external: true diff --git a/templates/lubelogger/.felhom.yml b/templates/lubelogger/.felhom.yml new file mode 100644 index 0000000..bfaa374 --- /dev/null +++ b/templates/lubelogger/.felhom.yml @@ -0,0 +1,129 @@ +# ============================================================================= +# .felhom.yml - App metadata for felhom-controller +# ============================================================================= +# LubeLogger — the project's own image (ghcr.io/hargata/lubelogger, the one upstream's docker-compose.yml +# names). Onboarding record: onboarding/lubelogger.md (NEW-APP-CHECKLIST.md). First admin: CLASS 1 — the box +# generates the password, and the container's entrypoint turns the app's login ON from it before the first byte +# (see the compose header; the app ships with NO login at all and hands a stranger root). No setup gate: there +# is no first-run screen, and a request without the right login answers 401/302 from the first second. + +# --- Display info (shown on dashboard) --- +display_name: "LubeLogger" +description: "A családi autó szerviz-, javítás- és tankolásnyilvántartása" +category: "home" +subdomain: "garazs" +slug: "lubelogger" +# catalog_since: the date THIS repo last changed this app's pinned images. Any commit that +# changes an image: line must set this to the same day (see CLAUDE.md). +catalog_since: "2026-10-10" + +# --- Resource hints (displayed on deploy screen) --- +resources: + mem_request: "80M" + mem_limit: "512M" # one service: lubelogger 512M + pi_compatible: true + needs_hdd: false + +# --- Deploy wizard fields --- +deploy_fields: + - env_var: DOMAIN + label: "Domain" + type: domain + description: "A szerver domain neve" + locked_after_deploy: true + + - env_var: SUBDOMAIN + label: "Aldomain" + type: subdomain + default: "garazs" + required: true + locked_after_deploy: true + description: "Az alkalmazás aldomainje" + + - env_var: ROOT_USER + label: "Felhasználónév" + type: text + default: "csalad" + required: true + locked_after_deploy: true + description: "Ezzel a névvel jelentkezel be. A család többi tagját már bentről tudod meghívni." + + # The app has no login of its own by default — the entrypoint turns one on from this password. There is + # no lock-out (25 wrong tries then the right one, in at once — measured 2026-10-10), so the NAME above + # may stay a plain word: a stranger cannot lock the household out by guessing at it. + - env_var: ROOT_PASSWORD + label: "Jelszó" + type: password + generate: "password:24" + locked_after_deploy: true + description: "Telepítéskor generált jelszó. Enélkül az alkalmazás senkit nem engedne be." + +# --- Customer-facing info --- +app_info: + tagline: "A családi autó teljes története egy helyen: szerviz, javítás, tankolás, költségek" + docs_url: "https://docs.lubelogger.com" + 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." + use_cases: + - 'Minden szerviz, javítás és műszaki vizsga egy helyen, számlával együtt' + - 'Tankolások és a tényleges fogyasztás - látszik, mennyibe kerül az autó' + - 'Emlékeztetők: olajcsere, műszaki, biztosítás, gumicsere' + - 'Több autó és akár motor vagy kerti gép külön nyilvántartása' + - 'Számlák és papírok a rekord mellé csatolva, nem egy fiókban' + first_steps: + - 'Nyisd meg a garazs.DOMAIN címet, és jelentkezz be a Beállítások oldalon látható névvel és jelszóval' + - 'Vedd fel az első autót (Add Vehicle): évjárat, gyártó, modell, rendszám' + - 'Magyar nyelvhez: Settings, majd a nyelvek listájából töltsd le a hu_HU csomagot - az alkalmazás angolul indul' + - 'Írd be az aktuális kilométeróra-állást és az utolsó szervizt, hogy az emlékeztetők számolni tudjanak' + prerequisites: + - 'Nem kell hozzá külső merevlemez' + +# --- Controller health probe (dials what the compose healthcheck dials) --- +healthcheck: + checks: + - type: api + port: 8080 + path: "/Login/Index" + expect: + status: 200 + +# --- English copy (localisation slice 5, R-560) -------------------------------------------- +# The Hungarian above is UNCHANGED. A box on English reads this block field by field; a missing +# field shows the Hungarian one; a controller older than 0.257.0 ignores the block entirely. +i18n: + en: + description: "The family car's services, repairs and fuel-ups in one place" + app_info: + tagline: "The whole history of the family car in one place: services, repairs, fuel, costs" + add_people: "Sign in and generate an invite token for your family member on the Admin page. Give them the token: they register with it, with their own name and password. Nobody can register without one." + use_cases: + - 'Every service, repair and inspection in one place, with the invoice attached' + - 'Fuel-ups and the real consumption - what the car actually costs shows up' + - 'Reminders: oil change, inspection, insurance, tyre change' + - 'Several cars, and a motorbike or a garden machine, kept apart' + - 'Invoices and papers attached to the record they belong to, not in a drawer' + first_steps: + - 'Open garazs.DOMAIN and sign in with the name and password shown on the settings page' + - 'Add your first vehicle (Add Vehicle): year, make, model, plate' + - 'Switch the language on the Settings page if you want: Settings, then download the language you need' + - 'Enter the current odometer reading and the last service, so the reminders can count from them' + prerequisites: + - 'No external hard drive is needed' + deploy_fields: + - env_var: DOMAIN + label: 'Domain' + description: 'The server domain name' + - env_var: SUBDOMAIN + label: 'Subdomain' + description: 'The subdomain this app answers on' + - env_var: ROOT_USER + label: 'User name' + description: 'You sign in with this name. You can invite the rest of the family from inside.' + - env_var: ROOT_PASSWORD + label: 'Password' + description: 'Generated at install. Without it the app would let nobody in.' + +# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings, +# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand; +# gated by scripts/check-test-record.py. An image: move without a proven entry here is refused. +update_ladder: + - {"from": {"lubelogger": "ghcr.io/hargata/lubelogger:v1.7.2"}, "to": {"lubelogger": "ghcr.io/hargata/lubelogger:v1.7.3"}, "digest": {"lubelogger": "sha256:c9d2bbb48c7f84d90e54496e2cd60422e56a5f2345c8a539ebe09f77e629d321"}, "verdict": "proven", "tested_at": "2026-10-10T09:53:27Z", "harness_version": 5, "evidence": "felhom.eu/documentation/audits/new-apps-2026-10-10/bench/lubelogger/evidence/MV-lubelogger/verdict.json", "box_evidence": "felhom.eu/documentation/audits/new-apps-2026-10-10/box/lubelogger/step.txt", "memory_peak_pct": 12.3, "marks": {"files_may_change": false, "needs_person": null, "memory_tight": false}, "memory_basis": "anon", "memory_cgroup_peak_pct": 17.9} diff --git a/templates/lubelogger/docker-compose.yml b/templates/lubelogger/docker-compose.yml new file mode 100644 index 0000000..ca9256d --- /dev/null +++ b/templates/lubelogger/docker-compose.yml @@ -0,0 +1,112 @@ +# LubeLogger - A családi autó: szervizek, javítások, tankolás, költségek +# Domain: ${SUBDOMAIN}.${DOMAIN} +# Database: None (LiteDB inside the app's own data volume — /App/data/cartracker.db) +# RAM: ~60M (mem_limit: 512M) | Pi-compatible: Yes (amd64, arm64) +# +# Environment variables: +# DOMAIN - Your domain (e.g., demo-felhom.eu) +# ROOT_USER - A bejelentkezési név (az űrlapon megadható, alapból "csalad") +# ROOT_PASSWORD - A jelszó (telepítéskor generálva, az alkalmazás oldalán látható) +# +# Storage layout: +# Everything → lubelogger_data:/App/data (named volume, NVMe) +# cartracker.db the vehicles, services, fuel-ups, costs +# documents/ receipts and papers the household attaches to a record +# images/ vehicle photos +# config/, themes/, translations/, temp/ +# Keys → lubelogger_keys:/root/.aspnet/DataProtection-Keys (named volume, NVMe) +# No HDD mount, and that is a deliberate choice: the uploads are receipts and a few photos, so the +# whole app fits in the tier-1 backup unit (database + volumes). An attached receipt is only ever +# opened from the record it belongs to, so there is nothing for the household to browse on a drive. +# The DataProtection key signs the LOGIN COOKIE and nothing else — losing it signs everyone out and +# destroys no data, so it is NOT a `data_key` (checklist 1.9). +# +# WHY THE CUSTOM ENTRYPOINT — the one thing this app cannot do without it. +# `Middleware/Authen.cs` reads `bool.Parse(configuration["EnableAuth"] ?? "false")`, and +# `appsettings.json` ships `"EnableAuth": false`. When it is false the middleware MINTS A TICKET for +# every request with the role `IsRootUser`. Measured on the bench 2026-10-10 on a default start: an +# anonymous stranger got 200 on /, 200 on /api/vehicles, 200 on /Home/Settings, and +# `POST /Vehicle/SaveVehicle` answered `{"success":true}` — a stranger READS AND WRITES the family's +# car records as root. So the app may never be published with its own default. +# The root login is not a database row: `AuthenticateRootUser` compares the submitted name and +# password, each SHA-256 hex (`StaticHelper.GetHash`), against the config keys `UserNameHash` and +# `UserPasswordHash`. The box can only hand the app a PLAINTEXT generated password, so something has +# to hash it — and ASP.NET reads configuration from the environment, so the entrypoint exports the two +# hashes and `EnableAuth=true` before exec'ing the app. Auth is therefore on BEFORE THE FIRST BYTE: +# measured from the container's creation, /api/vehicles was 401 and / was 302 → /Login/Index on the +# first second the app answered at all. No install window to close (checklist 3.5). +# The same shape as templates/radicale (an image whose generated login cannot be passed as plaintext); +# unlike templates/gokapi's entrypoint (REUSE.md §3) it seeds no config file, so an image update cannot +# strand it. It deliberately writes NOTHING: Program.cs adds `data/config/userConfig.json` AFTER the +# environment, and that file is where the household's own settings live — writing our keys into it +# would wipe their dark-mode and tab choices on every start. `ConfigHelper.SaveUserConfig` re-reads +# these three keys from the merged configuration when it saves, so the household cannot turn auth off +# from the UI either (measured: /App/data/config stayed empty). +# +# Sign-up is closed by the app itself: `RegisterNewUser` refuses without a token minted by the root +# user, measured `POST /Login/Register` → {"success":false,"message":"Invalid Token"}. So there is no +# `signup_block` to add, and LUBELOGGER_OPEN_REGISTRATION stays unset. +# +# Outbound traffic: none at start. Three fetches, each on a page the household opens on purpose — the +# sponsors list and the language pack from hargata.github.io, and the release check from +# api.github.com behind a `checkForUpdate` request flag. +# +# Run-identity: the image runs as root (its APP_UID=1654 is not used as USER) — measured; everything it +# writes is inside its own two volumes. + +services: + lubelogger: + image: ghcr.io/hargata/lubelogger:v1.7.3 + container_name: lubelogger + restart: unless-stopped + environment: + - TZ=Europe/Budapest + - ROOT_USER=${ROOT_USER} + - ROOT_PASSWORD=${ROOT_PASSWORD} + # Without this the app logs "WARNING: No Locale or Culture Configured" and formats dates and + # numbers the invariant way. The UI LANGUAGE is a separate thing the household picks in + # Settings (it downloads hu_HU from the project's own GitHub Pages). + - LUBELOGGER_LOCALE_OVERRIDE=hu-HU + entrypoint: + - /bin/bash + - -c + - | + set -e + export EnableAuth=true + export UserNameHash=$$(printf %s "$$ROOT_USER" | sha256sum | cut -d' ' -f1) + export UserPasswordHash=$$(printf %s "$$ROOT_PASSWORD" | sha256sum | cut -d' ' -f1) + unset ROOT_PASSWORD + exec ./CarCareTracker + volumes: + - lubelogger_data:/App/data + - lubelogger_keys:/root/.aspnet/DataProtection-Keys + networks: + - traefik-public + deploy: + resources: + limits: + memory: 512M + # This image has NO curl, NO wget, NO nc, NO python and NO node — none of REUSE.md §2's four + # healthcheck families exists in it. It does have bash, and bash's /dev/tcp works: measured both + # ways 2026-10-10, rc=0 against the app (HTTP/1.1 200 OK) and rc=1 against a dead port. + healthcheck: + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /Login/Index HTTP/1.0\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q 200"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + labels: + - "traefik.enable=true" + - "traefik.http.routers.lubelogger.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)" + - "traefik.http.routers.lubelogger.entrypoints=websecure" + - "traefik.http.routers.lubelogger.tls=true" + - "traefik.http.routers.lubelogger.tls.certresolver=letsencrypt" + - "traefik.http.services.lubelogger.loadbalancer.server.port=8080" + +volumes: + lubelogger_data: + lubelogger_keys: + +networks: + traefik-public: + external: true