186546d562
gates / gates (push) Successful in 27s
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
100 lines
4.4 KiB
Python
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()
|