Files
app-catalog-felhom.eu/scripts/check-probe-matches-compose.py
T
admin 405a127e7c
gates / gates (push) Successful in 1s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 20:28:40 +02:00

383 lines
18 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-probe-matches-compose.py — the `.felhom.yml` probe must dial where the app LISTENS (R-618).
WHAT WENT WRONG, and why a static gate can catch it.
`.felhom.yml`'s `healthcheck.checks` tells the controller where to knock: it dials
`<container-name>:<port><path>` on the compose network. The port is therefore **the port the
process listens on INSIDE the container** — not the published port, not the Traefik port. Three
templates named a port or a path the app does not answer. On its own that is only a wrong badge;
what makes it P1 is that **the guarded update's `verifying` phase waits on that same probe**, so a
SUCCESSFUL update ends with `failAndHold` stopping a working app. Measured on 2026-09-21: tandoor
served HTTP 200 on the new version at four samples over five minutes, docker's own healthcheck
green, and the controller stopped it at +361.9 s.
THE ORACLE. The compose `healthcheck.test` of the very same service already dials the app on
loopback — `wget http://127.0.0.1:80/accounts/login/`. That line is written by whoever added the
template, is exercised by docker on every start, and is therefore known-good. So the two can be
compared, statically, with no network and no container:
probe port == the port the compose healthcheck dials on 127.0.0.1
probe path == the path it dials (when the probe can FAIL on it)
WHICH SERVICE. The one the controller would actually probe. `findProbeContainer`
(`controller/internal/stacks/healthprobe.go:297`) takes the container whose name EQUALS the stack
name, else the first whose name has it as a PREFIX. This gate resolves the same way, off
`container_name`.
THE TWO VERDICTS, and why the path rule is narrower than the port rule — this is a measurement, not
a compromise:
* **A wrong PORT is always fatal.** Nothing listens there, the dial is refused, every check fails.
REFUSE, for every check type including `tcp`.
* **A wrong PATH is fatal only if the probe can fail on it.** `probeHTTP` returns healthy for ANY
response when the type is `http`, and also when the type is `api` with **no `expect` block**
(healthprobe.go:253-262). So a path mismatch bites exactly when the type is `api` AND `expect`
is present. REFUSE there; WARN otherwise. Run over all 53 on 2026-09-22, that is the difference
between convicting `zipline` (`api` + `expect.status: 200`, path `/api/health` vs the app's real
`/api/healthcheck`) and merely warning about `home-assistant` (`api`, no `expect`, `/api/` vs
`/manifest.json`) — which is honest: home-assistant's badge is right today and would break the
day someone adds an `expect`.
WHAT IT CANNOT SEE — named, not implied:
1. **A template whose compose healthcheck is ALSO wrong.** The oracle is the compose line; if both
agree and both are wrong, this gate passes. Only a live deploy decides that.
2. **A path that is right but answers the wrong STATUS.** `expect.status: 200` against an endpoint
that 302s is a real fault and looks identical to a correct template here.
3. **Anything about a service other than the probed one.**
Each of these three is a WARN category below rather than a silent pass, wherever it is visible.
USAGE
python3 scripts/check-probe-matches-compose.py # every app
python3 scripts/check-probe-matches-compose.py tandoor wger # only these
python3 scripts/check-probe-matches-compose.py --root=<dir> # judge another checkout
Exit: 0 clean (warnings do not convict) · 1 convicted · 2 inconclusive (unreadable template).
"""
import io
import os
import re
import sys
try:
import yaml
except ImportError: # pragma: no cover — this IS the CI path
yaml = None
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATES = os.path.join(ROOT, "templates")
def set_root(root):
"""Point the gate at a different checkout — `--root=<dir>`.
Without this the decoy suite would run the gate against the REAL repo while editing a clone, so
every case would read the same unchanged files and every case would pass. That is the
`constant-for-measurement` decoy shape this repo's own suite warns about, and it has fooled a
gate here before.
"""
global TEMPLATES
TEMPLATES = os.path.join(root, "templates")
# The names that mean "this container, from inside it". `0.0.0.0` appears as a dial target in a
# couple of templates; it is a bind address rather than a destination, but it reaches the same
# listener and the PORT is what this gate compares.
LOOPBACK = ("127.0.0.1", "localhost", "0.0.0.0", "::1", "[::1]")
def flatten_test(test):
"""The compose healthcheck test as one string, whatever shape it was written in.
Both `["CMD", "wget", …]` and `["CMD-SHELL", "curl … || exit 1"]` and a bare string occur in
this catalog. The leading CMD/CMD-SHELL word is dropped; everything else is joined, because the
URL may sit in any argument (node's `-e` script puts it inside quotes).
"""
if test is None:
return None
if isinstance(test, str):
return test
parts = list(test)
if parts and parts[0] in ("CMD", "CMD-SHELL", "NONE"):
parts = parts[1:]
return " ".join(str(p) for p in parts)
URL_RE = re.compile(r'https?://([A-Za-z0-9_.\-\[\]:]+?)(?::(\d+))?(/[^\s\'"`)\\,]*)?(?=[\s\'"`)\\,]|$)')
NC_RE = re.compile(r'\bnc\b[^\n;|&]*?(\S+)\s+(\d+)\b')
def endpoints(test_str):
"""Every loopback endpoint the compose healthcheck dials: (scheme, port, path)."""
found = []
for m in URL_RE.finditer(test_str):
host, port, path = m.group(1), m.group(2), m.group(3)
if host not in LOOPBACK:
continue
p = int(port) if port else (443 if m.group(0).startswith("https") else 80)
found.append(("http", p, path or "/"))
for m in NC_RE.finditer(test_str):
if m.group(1) in LOOPBACK:
found.append(("tcp", int(m.group(2)), None))
return found
# ── DEGRADED MODE — the shape CI actually runs ───────────────────────────────────────────────────
#
# The CI runner has no PyYAML. A gate that answers INCONCLUSIVE there is a gate that is red on every
# push, and a gate that is red on every push is bypassed within a week — so it would enforce
# nothing, which is the R-421 shape one level up. Both files this gate reads are written in a tiny,
# regular subset of YAML (two-space indent, one key per line, a flow-sequence `test:`), so the two
# facts it needs can be read without a YAML parser at all.
#
# It is NOT a YAML parser and does not pretend to be one. It reads exactly two shapes, and when a
# file does not match them it says so and the app becomes a WARN — never a silent pass.
def _deg_healthchecks(path):
"""`healthcheck: / checks: / - type: … ` out of a .felhom.yml, without PyYAML."""
lines = io.open(path, encoding="utf-8").read().split("\n")
checks, inside, cur = [], False, None
for raw in lines:
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 # a new top-level key ends the block
if not inside or not line.strip():
continue
m = re.match(r"^\s*-\s*(\w+):\s*(.*)$", line)
if m: # a new list item
cur = {}
checks.append(cur)
k, v = m.group(1), m.group(2)
else:
m2 = re.match(r"^\s*(\w+):\s*(.*)$", line)
if not m2 or cur is None:
continue
k, v = m2.group(1), m2.group(2)
v = v.strip().strip('"').strip("'")
if k == "port" and v.isdigit():
cur["port"] = int(v)
elif k in ("type", "path", "method"):
cur[k] = v
elif k == "expect":
cur["expect"] = True # presence is all this gate needs
elif k == "status" and cur.get("expect"):
pass
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")
services, cur, in_services = {}, None, False
for raw in lines:
if raw.strip().startswith("#") or not raw.strip():
continue
if re.match(r"^services:\s*$", raw):
in_services = True
continue
if in_services and raw[:1] not in (" ", "\t") and raw.strip():
break
if not in_services:
continue
m = re.match(r"^ ([A-Za-z0-9_.\-]+):\s*$", raw)
if m: # a service block begins
cur = m.group(1)
services[cur] = {"container_name": None, "test": None}
continue
if cur is None:
continue
m = re.match(r"^ container_name:\s*(\S+)", raw)
if m:
services[cur]["container_name"] = m.group(1).strip('"').strip("'")
continue
m = re.match(r"^\s+test:\s*(.+)$", raw)
if m and services[cur]["test"] is None:
services[cur]["test"] = m.group(1)
return services
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, 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):
"""Returns (verdicts, warnings) — each a list of one-line strings."""
d = os.path.join(TEMPLATES, app)
bad, warn = [], []
try:
if yaml is not None:
fy = yaml.safe_load(open(os.path.join(d, ".felhom.yml"), encoding="utf-8")) or {}
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()}
except Exception as e: # noqa: BLE001 — report, never crash
return None, ["%s: template unreadable (%s)" % (app, e)]
if not checks:
warn.append("%s: no `healthcheck.checks` in .felhom.yml — nothing to compare" % app)
return bad, warn
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.
warn.append("%s: NO container_name equals or begins with the stack name — the controller "
"would find no container to probe and SKIP this stack silently "
"(healthprobe.go:62). Containers: %s"
% (app, ", ".join(sorted(str((s or {}).get("container_name")) for s in services.values()))))
return bad, warn
test = flatten_test((svc.get("healthcheck") or {}).get("test"))
if not test:
warn.append("%s: service %r has no compose healthcheck — this gate has no oracle for it; "
"only a live deploy can judge the probe" % (app, svc_name))
return bad, warn
eps = endpoints(test)
if not eps:
has_ports = bool(svc.get("ports") or svc.get("expose"))
warn.append("%s: the compose healthcheck of %r dials no loopback URL or port (%s) — "
"no oracle.%s" % (app, svc_name, test.strip()[:90],
(" Only `expose:`/`ports:` are declared, and a published "
"port is NOT the port the app listens on inside the "
"container, so it cannot stand in.") if has_ports else ""))
return bad, warn
for c in checks:
ctype = (c or {}).get("type")
cport = (c or {}).get("port")
cpath = (c or {}).get("path") or "/"
# `tcp` is compared on the port alone — it dials a socket and has no path.
same_scheme = [e for e in eps if (e[0] == "tcp") == (ctype == "tcp")] or eps
ports = sorted({e[1] for e in same_scheme})
if cport not in ports:
bad.append("%s: probe %s port %s, but the compose healthcheck of %r dials %s on "
"loopback. Nothing listens on %s inside the container, so EVERY probe fails "
"and a successful update is stopped by failAndHold (R-618). test: %s"
% (app, ctype, cport, svc_name, "/".join(map(str, ports)), cport,
test.strip()[:90]))
continue
if ctype == "tcp":
continue
paths = sorted({e[2] for e in same_scheme if e[1] == cport and e[2] is not None})
if paths and cpath not in paths:
can_fail = ctype == "api" and (c or {}).get("expect")
msg = ("%s: probe %s path %r, but the compose healthcheck of %r dials %s on the same "
"port. test: %s" % (app, ctype, cpath, svc_name, " or ".join(repr(p) for p in paths),
test.strip()[:90]))
if can_fail:
bad.append(msg + " This probe CAN fail on it: type `api` with an `expect` block "
"compares the response (healthprobe.go:253-262).")
else:
warn.append(msg + " Harmless TODAY — type %r without `expect` calls ANY response "
"healthy — and fatal the day someone adds `expect`." % ctype)
return bad, warn
def main(argv):
for a in argv:
if a.startswith("--root="):
set_root(a[len("--root="):])
unknown = [a for a in argv if a.startswith("-") and not a.startswith("--root=")]
if unknown:
print("unknown option(s): %s" % " ".join(unknown))
return 2
apps = [a for a in argv if not a.startswith("-")]
if not apps:
apps = sorted(d for d in os.listdir(TEMPLATES)
if os.path.isdir(os.path.join(TEMPLATES, d)))
mode = "full" if yaml is not None else "DEGRADED (no PyYAML — line reader; this is the CI path)"
print("probe-matches-compose: %d app(s), mode: %s" % (len(apps), mode))
convicted, warnings, unreadable = [], [], 0
for app in sorted(apps):
bad, warn = check_app(app)
if bad is None:
unreadable += 1
warnings += warn
continue
convicted += bad
warnings += warn
if warnings:
print("\n%d WARNING(S) — not a conviction, and not a pass either:" % len(warnings))
for w in warnings:
print(" WARN " + w)
if unreadable:
print("\nprobe-matches-compose: %d template(s) unreadable — INCONCLUSIVE" % unreadable)
return 2
if convicted:
print("\nprobe-matches-compose: CONVICTED — %d probe(s) do not match the app" % len(convicted))
for b in convicted:
print(" FAIL " + b)
return 1
print("\nprobe-matches-compose: OK%s — every probe dials the port (and, where it can fail, the "
"path) that the app's own compose healthcheck dials" % (" (degraded)" if yaml is None else ""))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))