catalog: lifecycle field replaces the retired/ directory move

Moving a template out of templates/ un-offers it but also makes the
controller's orphan detector see it as GONE for anyone already running the
app - flagging their working install Elavult with a Torles button. Withdrawing
an app must never take a working app away from a customer.

Optional lifecycle: available|hidden|abandoned in .felhom.yml instead.
plant-it returns to templates/ as the first abandoned app; retired/ removed.
Resolvability gate skips (and reports) non-available apps.
This commit is contained in:
2026-07-21 16:19:49 +02:00
parent 857ba53233
commit a32541684a
8 changed files with 161 additions and 32 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## 2026-07-21 (later) — app lifecycle replaces the `retired/` directory move
**The `retired/` mechanism shipped earlier today was wrong and is withdrawn.** Moving a template out
of `templates/` does un-offer it — but it also makes the controller's orphan detector see the
template as GONE for anyone already running the app, flagging their working install `Elavult` and
offering a Törlés button. Withdrawing an app must never take a working app away from a customer.
Replaced by an optional top-level `lifecycle:` field in `.felhom.yml` (controller v0.158.0):
- `available` — default. Absent or empty means this, so all existing templates are unchanged.
- `hidden` — not offered for new installs; nothing shown to anyone already running it.
- `abandoned` — not offered for new installs, and every box already running it shows a permanent
„Nem karbantartott" badge plus a notice that updates and security fixes will no longer arrive.
Deployed instances keep full function in every state; the controller refuses a deploy of a
non-available template server-side. An unknown value degrades to `available` with one WARN.
- **`plant-it` returns to `templates/`** with `lifecycle: abandoned` — the first user of the
mechanism, and the case that motivated it. Its compose is deliberately unchanged: it pins
`msdeluise/plant-it:0.10.0`, a repository that does not exist (the real one is `-server`), and the
app is not installable, so rewriting it would imply it is. `retired/` is removed.
- **The resolvability gate is now lifecycle-aware.** Non-available apps are skipped by default and
REPORTED, not silently dropped; `--all` includes them. An abandoned app's dead image is the
expected end state, not a finding — counting it would leave the gate permanently red for something
nobody intends to fix, and a gate that is always red is a gate nobody reads. 6 new fixture tests
(19 total), including one asserting an all-skipped run is a pass rather than an error.
Catalog is back to **53 apps** (52 offered + plant-it abandoned).
## 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
+9 -3
View File
@@ -36,6 +36,12 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
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.
- **Taking an app out of circulation — use `lifecycle:`, never a directory move.** `.felhom.yml`
gains an optional `lifecycle:` field: `available` (default; absent/empty means this), `hidden`
(not offered for new installs, no explanation owed), `abandoned` (upstream stopped developing it
not offered for new installs, and every box already running it shows a permanent "Nem karbantartott"
notice). **Deployed instances keep working in full either way** — the state affects what is
OFFERED, never what already runs, and the controller REFUSES a deploy of a non-available template
server-side. An unknown value degrades to `available` with one WARN, so a typo can never brick a
template. This supersedes the short-lived `retired/` directory move, which was wrong: removing a
template orphans every customer already running it.
+1
View File
@@ -16,6 +16,7 @@ None — this repo is templates/config, not code. See §2/§5.
| `.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. |
| 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. |
| App lifecycle (`available`/`hidden`/`abandoned`) | `templates/plant-it/.felhom.yml` (`lifecycle:` block) | Optional top-level `lifecycle:` in `.felhom.yml`. Absent/empty ≡ `available`. `hidden` = not offered for new installs; `abandoned` = same, PLUS a permanent "Nem karbantartott" badge + notice on every box already running it. **Deployed instances keep full function in both states** — lifecycle governs what is OFFERED, never what runs; the controller refuses a deploy of a non-available template server-side (fail-closed, so a stale link or direct POST cannot install one). Unknown value → treated as `available` + one WARN, never a broken template. **Do NOT take an app out of circulation by deleting or moving its directory** — that orphans every customer already running it, which is what the 2026-07-21 `retired/` experiment got wrong. The resolvability gate skips non-available apps, so an abandoned app's dead image is not a standing red. |
| 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). |
-17
View File
@@ -1,17 +0,0 @@
# 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. |
+50 -7
View File
@@ -11,7 +11,8 @@ 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 # every AVAILABLE app
python3 scripts/check-image-resolvable.py --all # include hidden/abandoned apps too
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
@@ -37,18 +38,47 @@ IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)") # same shape as check
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."""
# `lifecycle:` at the top level of .felhom.yml. Matched with a line regex rather than a YAML parse so
# this gate keeps working with no dependencies (the repo ships no requirements file).
LIFECYCLE_RE = re.compile(r"""^lifecycle:\s*["']?([a-z]+)""", re.MULTILINE)
def app_lifecycle(app_dir: Path) -> str:
"""available / hidden / abandoned. Absent, empty or unknown ≡ available (see CLAUDE.md)."""
f = app_dir / ".felhom.yml"
if not f.is_file():
return "available"
m = LIFECYCLE_RE.search(f.read_text(encoding="utf-8"))
if not m:
return "available"
v = m.group(1)
return v if v in ("available", "hidden", "abandoned") else "available"
def collect_images(root: Path, only: list[str] | None = None,
include_unavailable: bool = False) -> tuple[dict[str, list[str]], list[str]]:
"""Map each unique image ref -> the ['app:line'] sites that pin it. Pure; no network.
Returns (sites, skipped_apps). Apps whose `lifecycle:` is not `available` are SKIPPED by default:
they are not offered for new installs, so a dead upstream image is the expected end state, not a
finding. Including them would leave the gate permanently red for a reason nobody intends to fix —
and a gate that is always red is a gate nobody reads. They are reported, never silently dropped.
"""
sites: dict[str, list[str]] = {}
skipped: list[str] = []
for f in sorted(root.glob("templates/*/docker-compose.yml")):
app = f.parent.name
if only and app not in only:
continue
lc = app_lifecycle(f.parent)
if lc != "available" and not include_unavailable:
skipped.append(f"{app} ({lc})")
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
return sites, skipped
OK, ABSENT, INCONCLUSIVE = "ok", "absent", "inconclusive"
@@ -129,9 +159,18 @@ def check_images(sites: dict[str, list[str]], resolver) -> tuple[list[str], list
return absent, inconclusive
def check(root: Path, only: list[str] | None = None, resolver=docker_resolver) -> int:
sites = collect_images(root, only)
def check(root: Path, only: list[str] | None = None, resolver=docker_resolver,
include_unavailable: bool = False) -> int:
sites, skipped = collect_images(root, only, include_unavailable)
if skipped:
print(f"skipping {len(skipped)} app(s) not offered for new installs: {', '.join(skipped)}")
print(" (their images are not expected to resolve; re-run with --all to check them anyway)")
if not sites:
if skipped:
# Everything in scope was deliberately skipped. That is a clean result, not a broken
# catalog — saying "ERROR: no images found" here would be a false alarm of its own.
print("nothing to check — every app in scope is out of circulation")
return 0
print(f"ERROR: no images found under {root}/templates/", file=sys.stderr)
return 2
@@ -174,4 +213,8 @@ def check(root: Path, only: list[str] | None = None, resolver=docker_resolver) -
if __name__ == "__main__":
sys.exit(check(Path(__file__).resolve().parent.parent, only=sys.argv[1:] or None))
argv = sys.argv[1:]
all_apps = "--all" in argv
argv = [a for a in argv if a != "--all"]
sys.exit(check(Path(__file__).resolve().parent.parent, only=argv or None,
include_unavailable=all_apps))
+59 -5
View File
@@ -45,7 +45,7 @@ class TestResolvabilityGate(unittest.TestCase):
"""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)
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, [])
@@ -55,12 +55,12 @@ class TestResolvabilityGate(unittest.TestCase):
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), ([], []))
self.assertEqual(cir.check_images(cir.collect_images(root)[0], 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)
sites, _ = cir.collect_images(root)
calls = []
def counting(ref):
@@ -77,7 +77,7 @@ class TestResolvabilityGate(unittest.TestCase):
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)
sites, _ = cir.collect_images(root)
self.assertEqual(sorted(sites), sorted([GOOD, DEAD]))
absent, _ = cir.check_images(sites, fake_resolver)
self.assertEqual(absent, [DEAD])
@@ -93,7 +93,7 @@ class TestResolvabilityGate(unittest.TestCase):
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])
self.assertEqual(sorted(cir.collect_images(root, only=["a"])[0]), [GOOD])
def test_check_returns_1_end_to_end_on_a_dead_pin(self):
with tempfile.TemporaryDirectory() as td:
@@ -144,5 +144,59 @@ class TestClassifyGuardsAgainstFalseAlarms(unittest.TestCase):
"and not a pass (0)")
class TestLifecycleSkipping(unittest.TestCase):
"""An abandoned app's image is EXPECTED to be gone — it is not offered for new installs. Counting
it as a failure would leave the gate permanently red for something nobody intends to fix, and a
gate that is always red is a gate nobody reads."""
def _catalog(self, td):
root = make_catalog(Path(td), {"alive": [GOOD], "dead-app": [DEAD]})
(root / "templates" / "dead-app" / ".felhom.yml").write_text(
'slug: "dead-app"\nlifecycle: abandoned\n', encoding="utf-8")
(root / "templates" / "alive" / ".felhom.yml").write_text('slug: "alive"\n', encoding="utf-8")
return root
def test_abandoned_app_is_skipped_and_reported(self):
with tempfile.TemporaryDirectory() as td:
sites, skipped = cir.collect_images(self._catalog(td))
self.assertEqual(sorted(sites), [GOOD], "an abandoned app's images must not be checked")
self.assertEqual(skipped, ["dead-app (abandoned)"],
"a skipped app must be REPORTED, never silently dropped")
def test_abandoned_app_does_not_fail_the_gate(self):
with tempfile.TemporaryDirectory() as td:
self.assertEqual(cir.check(self._catalog(td), resolver=fake_resolver), 0)
def test_all_flag_includes_it_again(self):
with tempfile.TemporaryDirectory() as td:
root = self._catalog(td)
sites, skipped = cir.collect_images(root, include_unavailable=True)
self.assertEqual(sorted(sites), sorted([GOOD, DEAD]))
self.assertEqual(skipped, [])
self.assertEqual(cir.check(root, resolver=fake_resolver, include_unavailable=True), 1,
"--all must surface the abandoned app's dead image again")
def test_everything_skipped_is_a_pass_not_an_error(self):
"""All-skipped is a legitimate outcome (e.g. `… plant-it`), not a broken catalog."""
with tempfile.TemporaryDirectory() as td:
root = self._catalog(td)
self.assertEqual(cir.check(root, only=["dead-app"], resolver=fake_resolver), 0)
def test_lifecycle_parsing(self):
with tempfile.TemporaryDirectory() as td:
d = Path(td); d.mkdir(exist_ok=True)
def lc(body):
(d / ".felhom.yml").write_text(body, encoding="utf-8")
return cir.app_lifecycle(d)
self.assertEqual(lc('slug: "x"\n'), "available", "absent field ≡ available")
self.assertEqual(lc('lifecycle: hidden\n'), "hidden")
self.assertEqual(lc('lifecycle: "abandoned"\n'), "abandoned", "quoted value")
self.assertEqual(lc('lifecycle: bogus\n'), "available",
"an unknown value must degrade to available, never brick the template")
self.assertEqual(lc(' lifecycle: abandoned\n'), "available",
"indented => not a top-level key, must not match")
self.assertEqual(cir.app_lifecycle(Path(td) / "nope"), "available", "no file ≡ available")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -9,6 +9,19 @@ category: "home"
subdomain: "plants"
slug: "plant-it"
# --- Lifecycle ---
# abandoned: upstream stopped developing the self-hosted edition. NOT offered for new installs;
# anyone already running it keeps running it, with a permanent notice that no updates or security
# fixes will arrive. Evidence (2026-07-21): the `backend/` and `deployment/` directories are DELETED
# from upstream `main` (the project is now an Android app on F-Droid/Obtainium); 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; and it requires MySQL 8.0 + Redis, which this template never had.
#
# The compose below is deliberately LEFT AS-IS (it pins `msdeluise/plant-it:0.10.0`, a repository
# that does not exist — the real one is `-server`). It is not worth fixing: the app is not
# installable, and rewriting it would imply it is.
lifecycle: abandoned
# --- Resource hints (displayed on deploy screen) ---
resources:
mem_request: "50M"