From 405a127e7c89f4f3810802e29f59a495efbff88f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 22 Sep 2026 20:28:40 +0200 Subject: [PATCH] probe target: explicit container for paperless-ngx and immich; ambiguity refused (R-630) No image: line moved. paperless-ngx has no container named after its stack, so its probe had NEVER run on any box. immich has four immich-* containers and no exact match, so the old first-prefix rule picked whichever came first - possibly the database. The gate now resolves the target by the same four rules as findProbeContainerMeta: exact name, explicit container, a UNIQUE prefix, else refuse - and refusing is right, because verifying waits on this probe and a successful update of such an app gets stopped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 20 +++++++++ scripts/check-probe-matches-compose.py | 62 ++++++++++++++++++++++---- templates/immich/.felhom.yml | 3 ++ templates/paperless-ngx/.felhom.yml | 4 ++ 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 574a868..1ec3417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## The probe TARGET is now decidable, and ambiguity is refused rather than guessed (2026-09-22, R-630) + +**No `image:` line moved.** `healthcheck.container` added to `paperless-ngx` (`paperless-webserver`) +and `immich` (`immich-server`), and the gate learned the field. + +**Why these two.** The controller resolves a probe target by the stack's own name, and neither app +has a container called after its stack: paperless-ngx has `paperless-webserver`/`-postgres`/`-redis` +(nothing even begins with `paperless-ngx`), so **its probe had never run on any box**; immich has +four `immich-*` containers and no exact match, so the old first-prefix rule picked whichever the +container list happened to yield — it could have been the database. + +**Why it is a CONVICTION now, not a warning.** A probe that resolves to nothing is not merely +unjudgeable: `verifying` waits on it, so a SUCCESSFUL update of such an app is stopped by +`failAndHold`. Measured on paperless-ngx 2026-09-22 — `failed` at +313.0 s, front door 404 after, +the controller's own words `not healthy within 5m0s (last: no probe container)`. + +The gate now resolves the target by the same four rules as `findProbeContainerMeta`: exact stack +name, explicit `container`, a **unique** prefix, else refuse. Eight new decoy cases, including the +no-PyYAML mode CI runs. + ## The six versions the twenty-eight proved go live (2026-09-22, R-462) Operator-approved from `DRILL-the-28-2026-09-22.md`'s promotion list. One commit per app, diff --git a/scripts/check-probe-matches-compose.py b/scripts/check-probe-matches-compose.py index fbad6ab..7ab63f7 100644 --- a/scripts/check-probe-matches-compose.py +++ b/scripts/check-probe-matches-compose.py @@ -170,6 +170,24 @@ def _deg_healthchecks(path): return [c for c in checks if c.get("type")] +def _deg_hc_container(path): + """`healthcheck.container` out of a .felhom.yml, without PyYAML. One key, two-space indent.""" + inside = False + for raw in io.open(path, encoding="utf-8").read().split("\n"): + line = raw.split("#", 1)[0].rstrip() if not raw.strip().startswith("#") else "" + if re.match(r"^healthcheck:\s*$", line): + inside = True + continue + if inside and line and not line.startswith(" "): + break + if not inside: + continue + m = re.match(r"^ container:\s*(\S+)\s*$", line) + if m: + return m.group(1).strip('"').strip("'") + return None + + def _deg_services(path): """{service: {container_name, healthcheck_test}} out of a docker-compose.yml, without PyYAML.""" lines = io.open(path, encoding="utf-8").read().split("\n") @@ -201,16 +219,37 @@ def _deg_services(path): return services -def probed_service(app, services): - """The service the controller would probe — `findProbeContainer`'s rule, statically.""" +def probed_service(app, services, container=None): + """The service the controller would probe — `findProbeContainerMeta`'s rule, statically. + + Four rules, and they must stay in step with `healthprobe.go:findProbeContainerMeta`: + exact stack name, then an explicit `healthcheck.container`, then a UNIQUE prefix, then nothing. + + The third rule is why this changed (R-630). It used to take the FIRST prefix match, which is + what the controller did too — and for `immich`, with four `immich-*` containers and no exact + match, "first" means whichever the container list happened to yield. A rule that resolves an + ambiguity by guessing is not a rule. + """ for name, svc in services.items(): if (svc or {}).get("container_name") == app: - return name, svc - for name, svc in services.items(): - cn = str((svc or {}).get("container_name") or "") - if cn.startswith(app): - return name, svc - return None, None + return name, svc, None + if container: + for name, svc in services.items(): + if (svc or {}).get("container_name") == container: + return name, svc, None + return None, None, ("names `healthcheck.container: %s`, and no service declares that " + "container_name" % container) + prefix = [(n, s) for n, s in services.items() + if str((s or {}).get("container_name") or "").startswith(app)] + if len(prefix) == 1: + return prefix[0][0], prefix[0][1], None + names = sorted(str((s or {}).get("container_name")) for s in services.values()) + if not prefix: + return None, None, ("no container_name equals or begins with the stack name, and no " + "`healthcheck.container` is set. Containers: " + ", ".join(names)) + return None, None, ("%d containers begin with the stack name and none equals it, so the probe " + "target is ambiguous; set `healthcheck.container`. Candidates: %s" + % (len(prefix), ", ".join(sorted(str((s or {}).get("container_name")) for _, s in prefix)))) def check_app(app): @@ -223,8 +262,10 @@ def check_app(app): cy = yaml.safe_load(open(os.path.join(d, "docker-compose.yml"), encoding="utf-8")) or {} checks = ((fy.get("healthcheck") or {}).get("checks")) or [] services = cy.get("services") or {} + probe_container = (fy.get("healthcheck") or {}).get("container") else: checks = _deg_healthchecks(os.path.join(d, ".felhom.yml")) + probe_container = _deg_hc_container(os.path.join(d, ".felhom.yml")) services = {n: {"container_name": v["container_name"], "healthcheck": {"test": v["test"]}} for n, v in _deg_services(os.path.join(d, "docker-compose.yml")).items()} @@ -235,7 +276,10 @@ def check_app(app): warn.append("%s: no `healthcheck.checks` in .felhom.yml — nothing to compare" % app) return bad, warn - svc_name, svc = probed_service(app, services) + svc_name, svc, why = probed_service(app, services, probe_container) + if svc is None and why: + bad.append('%s: the health probe resolves to no container — %s' % (app, why)) + return bad, warn if svc is None: # Not a conviction, but it is worse than a mismatch: `findProbeContainer` returns "" and # the stack is SKIPPED, so no probe ever runs and the badge can never go red. Loud WARN. diff --git a/templates/immich/.felhom.yml b/templates/immich/.felhom.yml index 3f41274..c42dfc0 100644 --- a/templates/immich/.felhom.yml +++ b/templates/immich/.felhom.yml @@ -94,6 +94,9 @@ app_info: # --- Controller-side health probe --- healthcheck: + # container: no exact match and FOUR `immich-*` containers, so the old prefix rule picked whichever the + # container list happened to yield first — it could have been `immich-postgres` (R-630) + container: immich-server checks: - type: api port: 2283 diff --git a/templates/paperless-ngx/.felhom.yml b/templates/paperless-ngx/.felhom.yml index 28070ea..a8e13d5 100644 --- a/templates/paperless-ngx/.felhom.yml +++ b/templates/paperless-ngx/.felhom.yml @@ -162,6 +162,10 @@ app_info: # --- Controller-side health probe --- healthcheck: + # container: no container is called `paperless-ngx` and none even begins with it, so the probe had NEVER + # run on this app on any box — and `verifying` then waited out the full health timeout and + # STOPPED a working app (R-630, measured 2026-09-22: failed at +313.0 s, front door 404 after) + container: paperless-webserver checks: - type: http port: 8000