probe target: explicit container for paperless-ngx and immich; ambiguity refused (R-630)
gates / gates (push) Successful in 1s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-22 20:28:40 +02:00
parent dcb6b1db68
commit 405a127e7c
4 changed files with 80 additions and 9 deletions
+20
View File
@@ -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,
+52 -8
View File
@@ -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
return name, svc, None
if container:
for name, svc in services.items():
cn = str((svc or {}).get("container_name") or "")
if cn.startswith(app):
return name, svc
return None, None
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.
+3
View File
@@ -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
+4
View File
@@ -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