catalog: re-pin wanderer to the current upstream shape, retire plant-it, add the resolvability gate

wanderer: ghcr.io/flomp/wanderer:0.16.0 is a ghost - upstream split the app
into web+db images, moved registry and renamed the org. Restructured to
upstream's own v0.20.0 compose (3 services, new /data/plugins volume, second
public hostname for PocketBase, meilisearch pinned DOWN to upstream's v1.36.0
per the R-42 ruling).

plant-it: retired. The repo name was wrong (plant-it-server) but upstream has
DELETED self-hosting; last server image is 2024-12-10 and it needs MySQL+Redis
the template never had. Moved to retired/ rather than deleted - reversible.

R-41 slice 1: check-image-resolvable.py. Encodes two traps - manifest inspect
exits 0 while printing toomanyrequests, and the inverse, where the first sweep
called 24 of 65 pins dead because Hub throttled it. Ambiguity is INCONCLUSIVE,
never an accusation.
This commit is contained in:
2026-07-21 15:30:15 +02:00
parent 34d50a33ac
commit b3eabfd611
12 changed files with 575 additions and 71 deletions
+50
View File
@@ -1,5 +1,55 @@
# Changelog
## 2026-07-21 — catalog honesty: wanderer re-pinned, plant-it retired, and a standing rot gate (R-41 slice 1)
Campaign 7 left two apps sitting behind a working "Telepítés" button with images that did not
resolve at all, recorded as findings rather than fixed. Both are now diagnosed rather than hidden,
and the class of defect gets a gate so it cannot recur silently.
**wanderer — RE-PINNED. The project is alive; the template was pointing at a ghost.**
`ghcr.io/flomp/wanderer:0.16.0` does not resolve because upstream did three things at once: split
the app into two images, moved registry, and renamed the GitHub org (Flomp → open-wanderer). Current
shape, taken from upstream's own compose at tag v0.20.0 (2026-07-07):
- `flomp/wanderer-web:v0.20.0` — the SvelteKit web app, port 3000, `curl` on PATH.
- `flomp/wanderer-db:v0.20.0` — PocketBase, port 8090. Built FROM `scratch`: no shell, no package
manager, a static curl baked in at `/curl` — hence the absolute-path healthcheck.
- `getmeili/meilisearch:v1.36.0` — still a required sidecar; both other services wait on its health.
**Pinned DOWN from the v1.49 Campaign 7 had set**, per the R-42 ruling: a sidecar pin follows the
app template's own proposed pin, never the newest tag independently.
- **New required volume** `/data/plugins` on the db — v0.20.0 moved the Strava/Komoot/Hammerhead
integrations into a WASM plugin sandbox that lives there.
- **New: a second hostname** (`SUBDOMAIN_DB`, default `hike-db`). `PUBLIC_POCKETBASE_URL` is a
browser-side variable — the user's browser talks to PocketBase directly, so it cannot be an
internal address. Upstream's own proxy example uses two hostnames for the same reason.
- New generated secret `POCKETBASE_ENCRYPTION_KEY` (`hex:16` → exactly the 32 characters upstream
requires). `mem_limit` 384M → 1024M, matching the sum of the three services.
**plant-it — RETIRED to `retired/plant-it/` (operator ruling 2026-07-21).** The pin was only
slightly wrong — the repository is `msdeluise/plant-it-server`, and `0.10.0` was the right version —
but correcting the name would have been the wrong fix. Upstream has **discontinued self-hosting**:
`backend/` and `deployment/` are deleted from `main`, the project is now an Android app on
F-Droid/Obtainium, and the last server image was pushed **2024-12-10** (a security-frozen Spring
Boot 3.4.0). It also requires **MySQL 8.0 + Redis**, which the template never had — its header
claimed "Database: None (file-based)", which was never true. Ruling: do not ship unmaintained
software to customers. Retirement is reversible (`git mv retired/plant-it templates/plant-it`);
nothing is deleted. Catalog is now **52 apps**.
**`scripts/check-image-resolvable.py` — R-41 slice 1: the standing rot gate.** `check-image-pins.py`
is syntactic and proves only that a template pins *something* concrete; it cannot see that the thing
is gone. This resolves every unique pin with `docker manifest inspect`, one image at a time, and
exits 0 / 1 (GONE) / 2 (inconclusive). Two traps are encoded in it, both observed live during this
change:
- `docker manifest inspect` prints `toomanyrequests: …` and **still exits 0** — the same
exits-0-on-failure shape as the ISO tooling's `validate-answer`, so stderr is checked even on rc=0.
- The inverse, which the first full sweep actually did: it called **24 of 65 pins dead**, including
`postgres:16-alpine` and `redis:7-alpine`, purely because Docker Hub throttled it partway through.
Ambiguity now resolves to INCONCLUSIVE, never to an accusation — a gate that cries wolf gets
ignored, and then it protects nothing.
14 fixture tests (`scripts/test_check_image_resolvable.py`), no network — the resolver is injected.
## 2026-07-19 — docs: workspace-root pointer follows the CC move to DooPlex
**Docs only, no template change.** Claude Code now runs on DooPlex (192.168.0.180, Debian 13)
+8
View File
@@ -31,3 +31,11 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
anywhere in the fleet is pinned to the digest it is currently running (a pin must never cause a
version jump). Digest pins (`@sha256:`) also count. Gate: `python scripts/check-image-pins.py`
(run after any compose change; exit 1 on any floating/missing tag).
- **A pinned tag can still rot away upstream** — the pin gate is syntactic and cannot see that.
Second gate: `python3 scripts/check-image-resolvable.py` (exit 0 resolve / 1 GONE / 2 inconclusive),
run at the start of every catalog campaign and before any publish train that vouches the catalog.
Needs network + `docker`; unauthenticated Docker Hub throttles a full sweep, so `docker login`
first or expect exit 2. It reports a throttle as INCONCLUSIVE, never as a dead image.
- **Retiring an app**: `git mv templates/<app> retired/<app>` (see `retired/README.md`) — the sync
discovers apps by directory, so this un-offers it while keeping it revivable. Sync never removes a
stack dir it already copied, so retirement freezes the app on existing boxes rather than deleting it.
-1
View File
@@ -45,7 +45,6 @@ app-catalog-felhom.eu/ <- This repo (source of truth)
│ ├── outline/
│ ├── paperless-ngx/
│ ├── papra/
│ ├── plant-it/
│ ├── plex/
│ ├── privatebin/
│ ├── radarr/
+71 -52
View File
@@ -1,68 +1,87 @@
# REPORT — CAMPAIGN 7: full app-catalog sweep (2026-07-18 → 2026-07-19)
# REPORT — catalog honesty pass (TASK-E Part 3), 2026-07-21
**Scope:** every app under `templates/` — bump the pin, deploy it through the real pipeline on the
demo box, prove it works, audit its healthcheck, record, remove.
**Box:** demo guest 9201 (`demo-felhom` on `felhom-pve`), controller **0.146.0**.
**Full matrix + ranked findings:**
`felhom.eu/documentation/audits/CAMPAIGN-7-catalog-sweep-2026-07-19.md`
## What ran
## Outcome
Diagnose-and-re-pin for the two Campaign 7 apps whose images "do not resolve at all", plus R-41
slice 1 (a standing resolvability gate). Baseline `34d50a3`, clean tree.
| | count |
## wanderer — RE-PINNED (upstream is alive; the template pointed at a ghost)
| | |
|---|---|
| apps attempted | **53 / 53** (full coverage, no remainder) |
| passed end-to-end | **45** |
| still failing | **4** (glance, gokapi, plant-it, wanderer) |
| not automatable | **1** (plex — needs a real `PLEX_CLAIM` token) |
| template fixes committed + live-re-validated | **13** |
| broken healthchecks found and fixed | **7** |
| apps that had NEVER been deployable, now fixed | **5** |
| Old pin | `ghcr.io/flomp/wanderer:0.16.0`**does not resolve** (verified `docker manifest inspect`) |
| Failure class | **repo split + registry move + org rename**, all at once |
| New pins | `flomp/wanderer-web:v0.20.0`, `flomp/wanderer-db:v0.20.0`, `getmeili/meilisearch:v1.36.0` |
| Evidence | upstream `docker-compose.yml` at tag `v0.20.0` (github.com/open-wanderer/wanderer); latest release 2026-07-07; all three tags confirmed present |
## Pre-flight gate — PASSED
Compose restructured from 1 service to 3 (web + PocketBase + Meilisearch), per upstream. Notable
consequences, none of them cosmetic:
`artifact_golden_version` = 0.146.0 (sha `4834c703…e955`), `min_controller_version` = 0.146.0,
guest running `felhom-controller:0.146.0` healthy and reporting. The sweep validated on the version
customers will actually run.
- **A second public hostname was unavoidable.** `PUBLIC_POCKETBASE_URL` is browser-side, so
PocketBase must be publicly reachable; added `SUBDOMAIN_DB` (default `hike-db`). Upstream's own
proxy example does the same.
- **New required volume** `/data/plugins` — v0.20.0 moved the Strava/Komoot/Hammerhead integrations
into a WASM plugin sandbox.
- **New generated secret** `POCKETBASE_ENCRYPTION_KEY` (`hex:16` → exactly 32 chars, as required).
- **Meilisearch pinned DOWN v1.49 → v1.36.0**, applying the R-42 ruling (sidecar follows the app
template's proposed pin). Campaign 7 had bumped it while the app image was dead.
- `mem_limit` 384M → 1024M = the exact sum of the three services (REUSE.md §2 rule).
- Probe container name kept equal to the stack dir (`wanderer`), or the health probe would hit the DB.
## The headline finding
**Not deployed live.** This is a from-scratch 3-service restructure of an app that has never once
deployed successfully; it wants a real deploy on the demo box before anyone is invited to it.
**A lying healthcheck does not merely mislead — it takes the app off-line.** Traefik refuses to
route to a container in `unhealthy` state, so a probe that cannot execute (missing `wget`/`curl`,
wrong port, wrong path, HEAD-vs-GET) produces a permanent `unhealthy`, and the customer gets a
**404 while the app is serving 200 perfectly well on its own port**. Seven apps were in that state.
## plant-it — RETIRED (operator ruling 2026-07-21)
## Second finding
The pin was only slightly wrong — repository is `msdeluise/plant-it-server`, and `0.10.0` was the
right version. Correcting the name would have been the wrong fix:
**5 of 53 apps had never been deployable at all** — papra, zipline, wishlist, homebox, glance —
and none of it was caused by this sweep; the sweep is simply the first thing that ever tried to
deploy them. Four are fixed. The catalog has no standing "does every template still deploy?" check,
and that absence is the real gap.
- Upstream **discontinued self-hosting**: `backend/` and `deployment/` are deleted from `main`; the
project is now an Android app on F-Droid/Obtainium, maintainer notes development has slowed.
- Last server image pushed **2024-12-10** — a security-frozen Spring Boot 3.4.0.
- Requires **MySQL 8.0 + Redis**. The template claimed "Database: None (file-based)" — never true.
## End-state (verified against the dashboard's own state, not just `docker ps`)
Moved to `retired/plant-it/` rather than deleted: the controller discovers apps by directory, so this
un-offers it while keeping it revivable with one `git mv`. Catalog is now **52 apps**.
- Deployed customer apps: **bookstack, calibre-web, immich** — the trio, freshly redeployed on
current versions, all containers healthy, offsite toggles restored to their recorded
pre-uninstall state (`offbox: true` on all three).
- Protected stacks untouched and running: traefik, cloudflared, filebrowser, samba, controller.
- Orphaned stacks: **none**. Stuck `deploying` flags: **none**.
- `app.yaml` present for the trio only; every other campaign app removed via the real delete flow.
- Image prune run. Docker volume: **179 GB free** (pre-campaign 178 GB).
- No campaign residue in `/opt/docker/stacks/` beyond synced templates.
## R-41 slice 1 — `scripts/check-image-resolvable.py`
## Needs a decision or a follow-up task
Complements the syntactic pin gate, which cannot see rot. Resolves each unique pin one image at a
time; exit 0 / 1 (GONE) / 2 (inconclusive). 14 fixture tests, no network.
1. **Multi-hop major upgrades** — nextcloud states "You cannot skip major releases"; the template
moved 31 → 34. Fresh deploys are fine, but an existing customer's update button cannot express a
staged upgrade. Systemic; ROADMAP.
2. **DB/cache sidecar majors** — deliberately not bumped (postgres 16→18, redis 7→8, mariadb→12).
Rationale in the campaign doc §4. Wants an explicit ruling.
3. **gokapi v2 config migration** — parked on v1.9.6 until the seeded `config.json` is regenerated.
Security-relevant: it should not sit on a superseded line indefinitely.
4. **plant-it / wanderer** — images unresolvable; need upstream research (finding, not a deletion).
5. **glance** — needs a seeded default `glance.yml`; it has never started from this catalog.
**Two traps encoded, both hit live during this change:**
## Secrets
1. `docker manifest inspect` prints `toomanyrequests: …` and **still exits 0** — the same
exits-0-on-failure shape as the ISO tooling's `validate-answer`. stderr is therefore checked
even on rc=0.
2. The inverse, and the more dangerous one: the **first full sweep called 24 of 65 pins dead**,
including `postgres:16-alpine` and `redis:7-alpine`, purely because Docker Hub throttled it
partway through. Ambiguity now resolves to INCONCLUSIVE, never to an accusation.
No secret value was logged, printed, or written to evidence at any point. Generated deploy secrets
are recorded as `<generated-secret, not recorded>`; the trio's credentials live in their `app.yaml`
on the box, out-of-band as usual.
### Sweep result — INCOMPLETE, and that is the honest answer
The full 65-pin sweep **could not be completed**: DooPlex is not logged in to Docker Hub, and the
anonymous limit was exhausted partway. The pre-throttle probes are trustworthy and are what the
findings above rest on:
| Image | Result | When |
|---|---|---|
| `ghcr.io/flomp/wanderer:0.16.0` | **GONE** | pre-throttle |
| `msdeluise/plant-it:0.10.0` | **GONE** | pre-throttle |
| `getmeili/meilisearch:v1.49` | resolves | pre-throttle |
| `flomp/wanderer-web:latest`, `flomp/wanderer-db:latest` | resolve | pre-throttle |
| `msdeluise/plant-it-backend/-frontend:latest` | resolve | pre-throttle |
| `flomp/wanderer-{web,db}:v0.20.0`, `getmeili/meilisearch:v1.36.0`, `msdeluise/plant-it-server:0.10.0` | exist (Hub tag API, HTTP 200) | post-throttle cross-check |
**Follow-up for the operator:** re-run `python3 scripts/check-image-resolvable.py` after
`docker login` to get the complete 52-app table. The gate now reports the throttle as INCONCLUSIVE
(exit 2) instead of inventing 24 dead images, so a repeat is safe to trust.
## Observations (recorded, not acted on)
- `REUSE.md` cited `felhom-controller/internal/stacks/healthprobe.go`; the real path has an extra
`controller/` segment. Corrected, but `reuse_refs_check.py` still flags it — it resolves citations
against the repo root and cannot follow a cross-repo pointer. Pre-existing checker limitation.
- The `-app`-suffixed wanderer tags (`v0.20.0-app`) are undocumented upstream; deliberately not used.
- 6 apps' `.felhom.yml` still carry `mem_limit` values that do not equal the sum of their services'
limits. wanderer is now correct; the others were out of scope.
+2 -1
View File
@@ -17,6 +17,7 @@ None — this repo is templates/config, not code. See §2/§5.
| 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. |
| Controller-side health probe | `templates/vaultwarden/.felhom.yml` (`healthcheck:` block) | `healthcheck.checks[]` with `type: http` (port only), `type: api` (port + `path` + `expect.status: 200`), or `type: tcp` (port only — mealie, crafty-controller). Prefer `api` with a real health path when the app has one. |
| Image pinning | ALL `templates/*/docker-compose.yml` (`image:` line) | **Never `:latest` or untagged** (recovery-unit `ImagePins` pins the tag — `:latest` breaks restore fidelity). Pin a concrete version tag; an app deployed anywhere in the fleet pins to the digest it is RUNNING (pin ≠ upgrade); `@sha256:` digest pins also count. Gate: `python scripts/check-image-pins.py` after any compose change (swept 2026-07-12: 5 pins). TRAP: ghcr `tags/list` can be stale/partial — verify tag existence via `docker manifest inspect`, never the tag list. |
| Image RESOLVABILITY (does the pin still exist?) | `scripts/check-image-resolvable.py` + `scripts/test_check_image_resolvable.py` | The complement to the pin gate, which is purely syntactic and cannot see rot. Run it at the START of every catalog campaign and before any publish train that vouches the catalog: `python3 scripts/check-image-resolvable.py [app …]`. Exit **0** all resolve, **1** the registry says an image is GONE, **2** INCONCLUSIVE/harness error. **Two traps it encodes, both live-observed:** (a) `docker manifest inspect` prints `toomanyrequests` and **still exits 0** — never trust the exit code alone (same shape as the ISO `validate-answer` trap); (b) the inverse — the first sweep called 24 of 65 pins dead, `postgres:16-alpine` among them, because Docker Hub throttled it partway. Ambiguity therefore resolves to INCONCLUSIVE, never to an accusation; a gate that cries wolf gets ignored. Unauthenticated Hub lookups WILL throttle on a full 65-pin sweep — `docker login` first, or expect exit 2. |
| Docker healthcheck host | ALL `templates/*/docker-compose.yml` (`healthcheck.test:`) | **Always `127.0.0.1`, never `localhost`.** BusyBox `wget` (and node/python/curl one-shots) resolve `localhost`→IPv6 `::1` with NO cross-address-family fallback; an app that binds IPv4-only then reads docker-`unhealthy` while fully serving (vaultwarden, re-run 2026-07-06 — swept all 48 templates). |
| Docker healthcheck — BusyBox/wget images | `templates/vaultwarden/docker-compose.yml` (~L49) | `test: ["CMD", "wget", "--spider", "-q", "http://localhost:<port>/<path>"]`. Most common family (~20 apps, e.g. homebox, glance). |
| Docker healthcheck — curl-capable images | `templates/paperless-ngx/docker-compose.yml` (~L76) | `test: ["CMD", "curl", "-f", "http://localhost:<port>/<path>"]` (~18 apps: jellyfin, immich, sonarr…). |
@@ -26,7 +27,7 @@ None — this repo is templates/config, not code. See §2/§5.
| 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. |
| Compose file skeleton | `templates/paperless-ngx/docker-compose.yml` (header) | Header comment (app, domain, DB type, RAM math, Pi), `restart: unless-stopped`, `TZ=Europe/Budapest`, explicit `container_name`, `traefik-public` external network + `<app>-internal` for DBs, Traefik labels with ``Host(`${SUBDOMAIN}.${DOMAIN}`)``, named volumes for DB/config (NVMe), `${HDD_PATH}/appdata/<app>/...` for bulk data, `${USERDATA_PATH}/...` for customer-browsable content. |
| App-email (SMTP shim) opt-in | `templates/vaultwarden/.felhom.yml` (`smtp_mapping:`) + README.md §smtp_mapping | `smtp_mapping` maps shim host/port/security/from to the app's own env names; compose MUST reference the mapped `${VAR:-}` keys with empty defaults. STARTTLS if the app can accept self-signed certs, else `security_value: "NONE"` plaintext (or the :2526 plaintext listener for STARTTLS-insistent clients — see calcom/nextcloud). TRAP: an image that treats defined-but-EMPTY mail vars as "set" (vaultwarden — campaign F1 2026-07-06) needs its own enable-flag gated `false` in compose and flipped `"true"` via `smtp_mapping.extra`; boot-prove a fresh email-off deploy for every new smtp-mapped app. |
| Probe-container naming | `templates/vaultwarden/docker-compose.yml` (`container_name: vaultwarden`) + `templates/sparkyfitness/` | The controller-side `healthcheck.checks[]` probe dials the container whose **name equals the stack (directory) name exactly**; fallback = the FIRST running prefix-match, which in a multi-container stack can be the DB (verified: `felhom-controller/internal/stacks/healthprobe.go` `findProbeContainer`). So the Traefik-exposed service's `container_name` must be exactly the stack name; sidecars `<app>-db`, `<app>-redis`, …. |
| Probe-container naming | `templates/vaultwarden/docker-compose.yml` (`container_name: vaultwarden`) + `templates/sparkyfitness/` | The controller-side `healthcheck.checks[]` probe dials the container whose **name equals the stack (directory) name exactly**; fallback = the FIRST running prefix-match, which in a multi-container stack can be the DB (verified: `felhom-controller/controller/internal/stacks/healthprobe.go` `findProbeContainer`). So the Traefik-exposed service's `container_name` must be exactly the stack name; sidecars `<app>-db`, `<app>-redis`, …. |
## 3. Dangerous lookalikes — do NOT copy
+17
View File
@@ -0,0 +1,17 @@
# Retired templates
Apps that were removed from `templates/` and are therefore **no longer offered to customers**.
The controller discovers apps by directory name under `templates/` (`internal/sync/sync.go`
`copyTemplates`), so anything here is invisible to the catalog sync. The files are kept rather than
deleted so a retirement is reversible: `git mv retired/<app> templates/<app>` puts it back, and the
full history of the template is intact either way.
**Note on boxes that already synced the app:** the sync only ADDS and UPDATES — it never removes a
stack directory it previously copied. Retiring a template therefore stops it being offered to NEW
boxes and freezes it on existing ones; it does not reach out and delete anything. For every app
retired so far this is moot, because none of them was ever successfully deployable.
| App | Retired | Why |
|---|---|---|
| `plant-it` | 2026-07-21 | **Upstream discontinued self-hosting.** The pinned `msdeluise/plant-it:0.10.0` never resolved because the image repository is `msdeluise/plant-it-server` — but fixing the name was not the real answer. The `backend/` and `deployment/` directories have been DELETED from upstream `main`; the project is now an Android app distributed via F-Droid/Obtainium, with a maintainer note that active development has slowed. The last server image, `msdeluise/plant-it-server:0.10.0`, was pushed **2024-12-10** and is a security-frozen Spring Boot 3.4.0. It also requires **MySQL 8.0 + Redis**, which the template never had — its header claimed "Database: None (file-based)", which was wrong from the start. Operator ruling 2026-07-21: do not ship unmaintained software to customers. Revive only if upstream restores a maintained server edition. |
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""check-image-resolvable.py — catalog gate: every pinned image must still EXIST upstream.
The companion to `check-image-pins.py`, which is purely syntactic: it proves a template pins a
concrete tag, never that the tag is still there. That gap is how `plant-it` and `wanderer` sat behind
a working "Telepítés" button for months with images that did not resolve at all the templates were
perfectly well-formed and pointed at nothing (Campaign 7, §6.2). **Silent rot is the real risk**
(ROADMAP R-41): an upstream rename, a repo split, or a pruned tag breaks a template without touching
this repo, so nothing in a change-triggered gate would ever notice.
This resolves each unique `image:` pin against its registry with
`docker manifest inspect <ref>` and exits non-zero listing everything that did not resolve.
python3 scripts/check-image-resolvable.py # whole catalog
python3 scripts/check-image-resolvable.py wanderer # only these app dirs
THE TRAP THIS SCRIPT IS BUILT AROUND: gate on EACH `docker manifest inspect`'s OWN exit code, one
image at a time. Never pipe the run through anything that summarises (`| grep`, `| tee`, `&&` chains,
a wrapping shell) and then read the exit code of THAT you get the pipeline's status, which is the
last element's, and unresolvable images sail straight through reporting success. This is the same
class of defect as the `validate-answer` trap in the ISO tooling (exits 0 on failure, so the build
gates on its OUTPUT text instead).
Requires network + a working `docker`. It is therefore a PERIODIC/manual gate, not a pre-commit one
run it at the start of every catalog campaign, and before any publish train that vouches the
catalog. Unit tests inject `resolver` and never touch the network.
"""
import re
import subprocess
import sys
from pathlib import Path
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)") # same shape as check-image-pins.py
# A ref that must never resolve, for self-testing the resolver end of the gate. `.invalid` is
# reserved by RFC 2606 and can never be a real registry.
CANARY_REF = "felhom-nonexistent.invalid/no/such:image"
def collect_images(root: Path, only: list[str] | None = None) -> dict[str, list[str]]:
"""Map each unique image ref -> the ['app:line'] sites that pin it. Pure; no network."""
sites: dict[str, list[str]] = {}
for f in sorted(root.glob("templates/*/docker-compose.yml")):
app = f.parent.name
if only and app not in only:
continue
for lineno, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
m = IMAGE_RE.match(line)
if m:
sites.setdefault(m.group(1), []).append(f"{app}:{lineno}")
return sites
OK, ABSENT, INCONCLUSIVE = "ok", "absent", "inconclusive"
# Substrings that mean the registry positively answered "that image is not here". ONLY these
# justify failing the gate.
ABSENT_MARKERS = (
"manifest unknown", "not found", "no such manifest", "does not exist",
"repository name not known", "unknown: unknown", "manifest_unknown",
"name unknown", "no such host", "unsupported protocol scheme",
)
# Substrings that mean "we could not find out" — a throttle, an auth wall, a network fault. These
# must NEVER be reported as a dead image.
INCONCLUSIVE_MARKERS = (
"toomanyrequests", "rate limit", "too many requests",
"unauthorized", "authentication required", "denied",
"timeout", "timed out", "temporary failure", "connection refused",
"i/o timeout", "tls handshake", "service unavailable", "500 internal",
)
def classify(returncode: int, err: str) -> str:
"""Turn one `docker manifest inspect` result into ok / absent / inconclusive.
TWO TRAPS, BOTH LIVE-OBSERVED, BOTH LOAD-BEARING:
1. `docker manifest inspect` prints `toomanyrequests: You have reached your unauthenticated
pull rate limit` and **still exits 0**. Same shape as the ISO tooling's `validate-answer`.
So a non-empty stderr is checked even on rc=0, or a throttled run reports a clean bill of
health for images it never actually resolved.
2. The inverse, which is what a naive gate does: treating that throttle as a failure. On
2026-07-21 the first full sweep called 24 of 65 pins dead including `postgres:16-alpine`
and `redis:7-alpine` purely because Docker Hub started throttling partway through. A gate
that cries wolf gets ignored, and then it protects nothing.
Ambiguity therefore resolves to INCONCLUSIVE, never to a failure: this gate may only accuse an
image when the registry positively said it is gone.
"""
low = err.lower()
if any(m in low for m in INCONCLUSIVE_MARKERS):
return INCONCLUSIVE
if returncode == 0:
# rc=0 WITH error text is trap 1 — do not trust it as success.
return OK if not low.strip() else INCONCLUSIVE
if any(m in low for m in ABSENT_MARKERS):
return ABSENT
return INCONCLUSIVE # an unrecognised failure is not evidence of absence
def docker_resolver(ref: str) -> tuple[str, str]:
"""(status, detail) from `docker manifest inspect ref`.
LOAD-BEARING: the decision comes from THIS call, for THIS one image never from a piped or
aggregated summary, whose exit status is the last element's.
"""
try:
r = subprocess.run(
["docker", "manifest", "inspect", ref],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120, text=True,
)
except subprocess.TimeoutExpired:
return INCONCLUSIVE, "timed out after 120s"
except OSError as e:
return INCONCLUSIVE, f"could not run docker: {e}"
err = (r.stderr or "").strip()
return classify(r.returncode, err), err.splitlines()[0] if err else ""
def check_images(sites: dict[str, list[str]], resolver) -> tuple[list[str], list[tuple[str, str]]]:
"""Resolve every ref once. Returns (absent_refs, [(inconclusive_ref, why)]). No I/O of its own."""
absent, inconclusive = [], []
for ref in sorted(sites):
status, detail = resolver(ref)
if status == ABSENT:
absent.append(ref)
elif status != OK:
inconclusive.append((ref, detail))
return absent, inconclusive
def check(root: Path, only: list[str] | None = None, resolver=docker_resolver) -> int:
sites = collect_images(root, only)
if not sites:
print(f"ERROR: no images found under {root}/templates/", file=sys.stderr)
return 2
# Self-test the resolver before trusting a green result: if it says a ref that CANNOT exist
# resolves, it is broken (or something is intercepting the registry) and a clean run would be a
# false all-clear — the exact failure this gate exists to prevent.
if resolver(CANARY_REF)[0] == OK:
print(f"ERROR: resolver returned success for {CANARY_REF} — it is not trustworthy; "
"refusing to report a result", file=sys.stderr)
return 2
print(f"resolving {len(sites)} unique image pin(s)…")
absent, inconclusive = check_images(sites, resolver)
if absent:
print("\nUNRESOLVABLE IMAGES (the registry says these are GONE):")
for ref in absent:
print(f" {ref}")
for site in sites[ref]:
print(f" pinned at templates/{site}")
if inconclusive:
print("\nINCONCLUSIVE (could NOT be checked — this is not an accusation):")
for ref, why in inconclusive:
print(f" {ref} [{why}]")
print("\n Docker Hub throttles unauthenticated manifest lookups, and a large sweep will hit")
print(" the ceiling partway through. Re-run after `docker login`, or wait out the window —")
print(" the result above is NOT evidence that these images are missing.")
if absent:
print(f"\n{len(absent)} of {len(sites)} image pin(s) are GONE"
f"{f'; {len(inconclusive)} could not be checked' if inconclusive else ''}.")
return 1
if inconclusive:
print(f"\nINCOMPLETE: {len(sites) - len(inconclusive)} of {len(sites)} pins verified, "
f"{len(inconclusive)} unchecked. No dead images among those checked.")
return 2
print(f"image-resolvability gate OK — {len(sites)} unique pins, all resolve")
return 0
if __name__ == "__main__":
sys.exit(check(Path(__file__).resolve().parent.parent, only=sys.argv[1:] or None))
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Fixture tests for check-image-resolvable.py. NO NETWORK — the resolver is injected.
Run: python3 scripts/test_check_image_resolvable.py
"""
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"cir", Path(__file__).resolve().parent / "check-image-resolvable.py")
cir = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cir)
# One ref that a fake registry "has", one that is knowingly invalid — `.invalid` is RFC-2606
# reserved, so this can never accidentally succeed even if someone runs the suite online.
GOOD = "getmeili/meilisearch:v1.49"
DEAD = "flomp-nonexistent.invalid/wanderer:0.16.0"
def fake_resolver(ref: str):
"""Resolves exactly one ref. Everything else — including the canary — is reported GONE."""
return (cir.OK, "") if ref == GOOD else (cir.ABSENT, "manifest unknown")
def throttled_resolver(ref: str):
"""Docker Hub throttling: rc=0 AND an error on stderr — the live 2026-07-21 failure."""
return (cir.INCONCLUSIVE, "toomanyrequests: You have reached your unauthenticated pull rate limit")
def make_catalog(tmp: Path, apps: dict[str, list[str]]) -> Path:
for app, images in apps.items():
d = tmp / "templates" / app
d.mkdir(parents=True)
body = "services:\n" + "".join(
f" svc{i}:\n image: {img}\n" for i, img in enumerate(images))
(d / "docker-compose.yml").write_text(body, encoding="utf-8")
return tmp
class TestResolvabilityGate(unittest.TestCase):
def test_dead_ref_fails_and_is_named(self):
"""The whole point: one dead pin must fail the gate, and say which app pins it."""
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"alive": [GOOD], "rotten": [DEAD]})
sites = cir.collect_images(root)
absent, inconclusive = cir.check_images(sites, fake_resolver)
self.assertEqual(absent, [DEAD], "a dead image pin MUST fail the gate")
self.assertEqual(inconclusive, [])
self.assertEqual(sites[DEAD], ["rotten:3"],
"the failure must point at the app+line that pins it")
def test_all_resolvable_passes(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"alive": [GOOD], "also": [GOOD]})
self.assertEqual(cir.check_images(cir.collect_images(root), fake_resolver), ([], []))
def test_same_ref_in_two_apps_is_resolved_once_but_both_sites_reported(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"a": [DEAD], "b": [DEAD]})
sites = cir.collect_images(root)
calls = []
def counting(ref):
calls.append(ref)
return fake_resolver(ref)
absent, _ = cir.check_images(sites, counting)
self.assertEqual(calls, [DEAD], "each unique ref must be resolved exactly once")
self.assertEqual(sorted(sites[DEAD]), ["a:3", "b:3"])
self.assertEqual(absent, [DEAD])
def test_multi_service_app_collects_every_image(self):
"""A repo split (wanderer web+db) means one app pins several images — miss one and the
gate would pass an app that cannot start."""
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"wanderer": [GOOD, DEAD]})
sites = cir.collect_images(root)
self.assertEqual(sorted(sites), sorted([GOOD, DEAD]))
absent, _ = cir.check_images(sites, fake_resolver)
self.assertEqual(absent, [DEAD])
def test_untrustworthy_resolver_refuses_to_report(self):
"""RED-PROOF companion for the false-all-clear mode: a resolver that says yes to
everything (broken docker, an intercepting proxy) must NOT yield a clean bill of health."""
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"rotten": [DEAD]})
rc = cir.check(root, resolver=lambda ref: (cir.OK, ""))
self.assertEqual(rc, 2, "a resolver that resolves the canary must abort, not pass")
def test_only_filter_restricts_to_named_apps(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"a": [GOOD], "b": [DEAD]})
self.assertEqual(sorted(cir.collect_images(root, only=["a"])), [GOOD])
def test_check_returns_1_end_to_end_on_a_dead_pin(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"alive": [GOOD], "rotten": [DEAD]})
self.assertEqual(cir.check(root, resolver=fake_resolver), 1)
def test_empty_catalog_is_an_error_not_a_pass(self):
with tempfile.TemporaryDirectory() as td:
(Path(td) / "templates").mkdir()
self.assertEqual(cir.check(Path(td), resolver=fake_resolver), 2)
class TestClassifyGuardsAgainstFalseAlarms(unittest.TestCase):
"""RED-PROOF for the defect the first live sweep actually had: Docker Hub throttling was
reported as 24 dead images (postgres:16-alpine, redis:7-alpine among them). A gate that
accuses healthy images gets ignored, so ambiguity must resolve to INCONCLUSIVE."""
def test_rate_limit_on_rc0_is_inconclusive_not_ok(self):
"""The live shape: rc=0 WITH an error on stderr. Trusting rc alone reports a false PASS."""
self.assertEqual(
cir.classify(0, "toomanyrequests: You have reached your unauthenticated pull rate limit"),
cir.INCONCLUSIVE)
def test_rate_limit_is_never_absent(self):
for rc in (0, 1):
self.assertEqual(
cir.classify(rc, "toomanyrequests: rate limit exceeded"), cir.INCONCLUSIVE,
"a throttle must NEVER be reported as a missing image")
def test_genuine_absence_is_absent(self):
self.assertEqual(cir.classify(1, "manifest unknown"), cir.ABSENT)
self.assertEqual(cir.classify(1, "errors:\n denied: requested access to the resource is denied"),
cir.INCONCLUSIVE, "an auth wall is not proof of absence")
def test_clean_success_is_ok(self):
self.assertEqual(cir.classify(0, ""), cir.OK)
def test_unrecognised_failure_is_inconclusive(self):
self.assertEqual(cir.classify(7, "something nobody has seen before"), cir.INCONCLUSIVE)
def test_throttled_sweep_reports_incomplete_not_failure(self):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"a": [GOOD], "b": [DEAD]})
rc = cir.check(root, resolver=lambda ref: (
(cir.ABSENT, "manifest unknown") if ref == cir.CANARY_REF
else throttled_resolver(ref)))
self.assertEqual(rc, 2, "a fully-throttled sweep is INCOMPLETE (2), not a failure (1) "
"and not a pass (0)")
if __name__ == "__main__":
unittest.main(verbosity=2)
+17 -3
View File
@@ -11,8 +11,8 @@ slug: "wanderer"
# --- Resource hints (displayed on deploy screen) ---
resources:
mem_request: "100M"
mem_limit: "384M"
mem_request: "350M"
mem_limit: "1024M"
pi_compatible: true
needs_hdd: false
@@ -32,16 +32,30 @@ deploy_fields:
locked_after_deploy: true
description: "Az alkalmazás aldomainje"
- env_var: SUBDOMAIN_DB
label: "Adatbázis aldomain"
type: subdomain
default: "hike-db"
required: true
locked_after_deploy: true
description: "A PocketBase adatbázis aldomainje - a böngésző KÖZVETLENÜL ezt hívja, ezért saját nevet kap"
- env_var: MEILI_MASTER_KEY
label: "Keresőmotor kulcs"
type: secret
generate: "hex:16"
locked_after_deploy: true
- env_var: POCKETBASE_ENCRYPTION_KEY
label: "Adatbázis titkosítási kulcs"
type: secret
generate: "hex:16"
locked_after_deploy: true
# --- App info (info page content) ---
app_info:
tagline: "Túra tervező - útvonalak, GPX nyomok és domborzati térképek"
docs_url: "https://github.com/Flomp/wanderer"
docs_url: "https://wanderer.to/"
use_cases:
- 'Túra útvonalak tervezése és mentése'
+83 -12
View File
@@ -1,40 +1,65 @@
# Wanderer - Túra tervező és nyomkövetéssel
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: None (file-based)
# RAM: ~100M (mem_limit: 384M) | Pi-compatible: Yes
# Domain: ${SUBDOMAIN}.${DOMAIN} (web) + ${SUBDOMAIN_DB}.${DOMAIN} (PocketBase API)
# Database: PocketBase (wanderer-db) + Meilisearch (kereső)
# RAM: ~350M (mem_limit: 1024M összesen) | Pi-compatible: Yes
#
# RE-PINNED 2026-07-21. The old template pinned a SINGLE image `ghcr.io/flomp/wanderer:0.16.0`,
# which does not resolve — upstream split the app into two images AND moved registry:
# ghcr.io/flomp/wanderer → docker.io/flomp/wanderer-web + docker.io/flomp/wanderer-db
# (the GitHub org also renamed Flomp → open-wanderer). The project is alive: v0.20.0, 2026-07-07.
# Structure below follows upstream's own docker-compose.yml at tag v0.20.0.
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# SUBDOMAIN / SUBDOMAIN_DB - web and PocketBase hostnames
# MEILI_MASTER_KEY - Keresőmotor kulcs (auto-generated)
# POCKETBASE_ENCRYPTION_KEY - PocketBase adatbázis kulcs (auto-generated, PONTOSAN 32 karakter)
#
# WHY TWO HOSTNAMES: `PUBLIC_POCKETBASE_URL` is a PUBLIC (browser-side) variable — the user's
# browser talks to PocketBase directly, so it cannot be an internal-only address. Upstream's own
# reverse-proxy example uses two hostnames for exactly this reason.
services:
wanderer:
image: ghcr.io/flomp/wanderer:0.16.0
# container_name MUST equal the stack dir name — the controller's health probe finds the
# Traefik-exposed container by that exact name (REUSE.md §2).
image: flomp/wanderer-web:v0.20.0
container_name: wanderer
restart: unless-stopped
depends_on:
wanderer-search:
condition: service_healthy
wanderer-db:
condition: service_healthy
environment:
- TZ=Europe/Budapest
- MEILI_URL=http://wanderer-search:7700
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- ORIGIN=https://${SUBDOMAIN}.${DOMAIN}
- PUBLIC_POCKETBASE_URL=https://${SUBDOMAIN_DB}.${DOMAIN}
- BODY_SIZE_LIMIT=Infinity
- PUBLIC_DISABLE_SIGNUP=false
- UPLOAD_FOLDER=/app/uploads
- PUBLIC_MAP_MAX_POLYLINES=100
- OVERPASS_API_URL=https://overpass-api.de
- VALHALLA_URL=https://valhalla1.openstreetmap.de
- NOMINATIM_URL=https://nominatim.openstreetmap.org
volumes:
- wanderer_data:/app/pb_data
- wanderer_uploads:/app/uploads
networks:
- traefik-public
- wanderer-internal
deploy:
resources:
limits:
memory: 384M
memory: 512M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000"]
# The web image ships curl on PATH. No dedicated /health route exists — upstream probes root.
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.wanderer.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
@@ -43,8 +68,52 @@ services:
- "traefik.http.routers.wanderer.tls.certresolver=letsencrypt"
- "traefik.http.services.wanderer.loadbalancer.server.port=3000"
wanderer-db:
# PocketBase (custom upstream build). Built FROM scratch — no shell, no package manager; a
# static curl is baked in at /curl, which is why the healthcheck calls that absolute path.
image: flomp/wanderer-db:v0.20.0
container_name: wanderer-db
restart: unless-stopped
depends_on:
wanderer-search:
image: getmeili/meilisearch:v1.49
condition: service_healthy
environment:
- TZ=Europe/Budapest
- MEILI_URL=http://wanderer-search:7700
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- POCKETBASE_ENCRYPTION_KEY=${POCKETBASE_ENCRYPTION_KEY}
- ORIGIN=https://${SUBDOMAIN}.${DOMAIN}
volumes:
- wanderer_data:/pb_data
# /data/plugins is NEW and REQUIRED as of v0.20.0 — the Strava/Komoot/Hammerhead
# integrations moved into a WASM plugin sandbox that lives here.
- wanderer_plugins:/data/plugins
networks:
- traefik-public
- wanderer-internal
deploy:
resources:
limits:
memory: 256M
healthcheck:
test: ["CMD", "/curl", "--fail", "http://127.0.0.1:8090/health"]
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.http.routers.wanderer-db.rule=Host(`${SUBDOMAIN_DB}.${DOMAIN}`)"
- "traefik.http.routers.wanderer-db.entrypoints=websecure"
- "traefik.http.routers.wanderer-db.tls=true"
- "traefik.http.routers.wanderer-db.tls.certresolver=letsencrypt"
- "traefik.http.services.wanderer-db.loadbalancer.server.port=8090"
wanderer-search:
# Pinned to upstream's own v0.20.0 compose (R-42 ruling: a sidecar pin follows the app
# template's proposed pin, never the newest tag independently). Campaign 7 had bumped this to
# v1.49 while the app image was dead; upstream specifies v1.36.0.
image: getmeili/meilisearch:v1.36.0
container_name: wanderer-search
restart: unless-stopped
environment:
@@ -59,14 +128,16 @@ services:
limits:
memory: 256M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:7700/health"]
interval: 10s
test: ["CMD", "curl", "--fail", "http://127.0.0.1:7700/health"]
interval: 15s
timeout: 5s
retries: 5
retries: 10
start_period: 20s
volumes:
wanderer_data:
wanderer_plugins:
wanderer_uploads:
wanderer_meili_data:
networks: