Files
felhom.eu/documentation/audits/the-28-2026-09-22/sidejob_631.py
T
admin 186546d562
gates / gates (push) Successful in 27s
THE TWENTY-EIGHT: every app no drill had touched, walked in one night
All 28 walked on scratch guest 9202 against the private drill catalog. 26 deployed, 6 proven,
5 inconclusive, 14 with no upstream edge, 1 failed honestly (outline 1.9.1->1.10.1, HELD with the
right sentence), 2 undeployable - one (plant-it) by design, refused by the lifecycle gate, proven
live for the first time. Each app also got the half the update night skipped: a restore from its own
copy with the seed read back again - 21 restored, 2 correctly REFUSED per 07 6.2.

R-630 RAISED TO P1 by measurement: a stack with NO probe container does not skip verifying - it
waits out the full health timeout and HOLDS, stopping an app whose three containers read healthy.
The controller's own words: "not healthy within 5m0s (last: no probe container)".

R-633 opened: a remove sent during a restore reports success and leaves a container restarting with
a live public route. The product already refuses that clash for update and for restore, naming the
blocker; remove has no such guard.

R-634 opened: an app can be running, healthy and serving while recorded as deployed=false, and is
then unremovable. Reproducible alone on sparkyfitness; concurrency-linked on two others.

R-631 and R-632 CLOSED. Register 321 -> 323. Seven interventions, six of them my own harness -
named, with what each cost. No product code. The live catalog's image: lines are byte-identical to
the start of the night.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 16:00:56 +02:00

100 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""R-631 — one LIVE reading for each template the static probe gate cannot judge.
The gate compares the `.felhom.yml` probe against the same service's compose healthcheck. For five
templates there is no such oracle (the healthcheck runs inside a script, or there is none), and for
one (home-assistant) the paths differ but the check type cannot fail on it. A static rule cannot
settle any of them. This asks the CONTAINER what it actually listens on, which can.
Method, per app: deploy, wait for the front door, then inside the probed container read
`ss -ltn` (or `/proc/net/tcp` when `ss` is absent — busybox images have neither `ss` nor `netstat`),
and compare with the probe. Remove afterwards.
"""
import json, os, sys, time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(HERE), "update-night-2026-09-21"))
import walk as w # noqa: E402
import yaml # noqa: E402
T = "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/templates"
APPS = { # app: (subdomain used by its fixture/template, the container the controller probes)
# crafty-controller is in tonight's walk queue and would collide; its reading is taken from
# its own walk instead, and the record says so rather than leaving a blank.
"mealie": ("mealie", "mealie"),
"uptime-kuma": ("uptime", "uptime-kuma"),
"vikunja": ("vikunja", "vikunja"),
"home-assistant": ("ha", "home-assistant"),
}
LISTEN = r'''C=%s
if docker exec $C sh -c 'command -v ss' >/dev/null 2>&1; then
echo "== ss -ltn"; docker exec $C ss -ltn 2>&1 | head -20
elif docker exec $C sh -c 'command -v netstat' >/dev/null 2>&1; then
echo "== netstat -ltn"; docker exec $C netstat -ltn 2>&1 | head -20
else
echo "== /proc/net/tcp (no ss, no netstat in the image) — local_address is hex ip:port"
docker exec $C sh -c 'cat /proc/net/tcp /proc/net/tcp6 2>/dev/null' | awk '$4=="0A"{print $2}' | sort -u | head -20
fi'''
def hexports(txt):
out = set()
for line in txt.split("\n"):
line = line.strip()
if ":" in line and len(line.split(":")[-1]) == 4:
try:
out.add(int(line.split(":")[-1], 16))
except ValueError:
pass
return sorted(out)
def main():
w.login()
res = {}
for app, (sub, cont) in APPS.items():
print(f"\n==== {app}")
fy = yaml.safe_load(open(f"{T}/{app}/.felhom.yml"))
probe = (fy.get("healthcheck") or {}).get("checks")
r = {"app": app, "probe": probe, "probed_container": cont}
try:
ok = w.deploy(app, sub)
r["deployed"] = ok
if ok:
w.wait_app(sub, "/", tries=40)
listen = w.guest(LISTEN % cont)
r["listen_raw"] = listen.strip()
r["listen_ports_from_proc"] = hexports(listen)
st = w.stack(app)
r["controller_state"] = st.get("state")
r["front_door"] = w.app_curl(sub, "/")[1]
# what the probe dials, asked from INSIDE the compose network
for c in (probe or []):
p, path = c.get("port"), c.get("path") or "/"
probe_try = w.guest(
f"docker run --rm --network container:{cont} curlimages/curl:8.11.1 "
f"-s -o /dev/null -w '%{{http_code}}' --max-time 5 "
f"http://127.0.0.1:{p}{path} 2>&1 | tail -1")
r.setdefault("probe_dial", []).append(
{"port": p, "path": path, "type": c.get("type"),
"http_code": probe_try.strip()[:20]})
print(json.dumps({k: r[k] for k in
("probe", "controller_state", "front_door", "probe_dial")
if k in r}, ensure_ascii=False, indent=2))
print(r["listen_raw"][:600])
except Exception as e:
r["error"] = f"{type(e).__name__}: {e}"
print(" !!", r["error"])
finally:
try:
w.remove(app)
except Exception as e:
r["remove_error"] = str(e)
res[app] = r
json.dump(res, open(os.path.join(HERE, "sidejobs", "r631.json"), "w"),
ensure_ascii=False, indent=2)
print("\nwritten sidejobs/r631.json")
main()