probe target: explicit container for paperless-ngx and immich; ambiguity refused (R-630)
gates / gates (push) Successful in 1s
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user