Files
felhom.eu/documentation/audits/night-2026-09-24/tools/unattended_caller_ladder.py
T
2026-09-24 13:30:30 +02:00

112 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""unattended_caller_ladder.py — Part C step 2 (night 2026-09-24): the update leg as it would run, updated for the
LADDER (`09` §3 decisions 11/12/14/15/20). Adapted from audits/update-arc-gaps-2026-09-21/unattended-caller.py.
EVIDENCE, NOT PRODUCT. It presses exactly the guarded Update a person presses (POST /api/stacks/<n>/update) and
nothing else, against scratch guest 9202 only.
What changed from the 2026-09-21 caller:
- no "within a major" judgement: decision 13 made the LADDER the only authority — the box refuses or climbs;
- ONE APP AT A TIME, ONE STEP PER PRESS: an app is pressed again only after its previous step ended `done`,
until `ladder_steps_left` is 0 and the badge no longer says behind;
- a failed step (`undone`, `failed`, a hold) is NEVER pressed again tonight (decision 15);
- the refusal's REASON decides (R-609): transient → next pass; held/downgrade → never; memory/disk/no_backup →
for a human; unknown (incl. not_found/not_deployed/guards_unwired) → never.
Measures per step: wall-clock, phases, outcome; per app: the page's hold/badge in hu+en and the events logged.
Usage: python3 unattended_caller_ladder.py --apps wishlist,navidrome,romm,vikunja --passes 8
"""
import argparse, json, sys, time
sys.path.insert(0, ".")
import walk as w
TRANSIENT = {"busy", "updating", "deploying", "migrating", "self_updating"}
TERMINAL = {"held", "downgrade"}
FOR_A_HUMAN = {"memory", "disk", "no_backup"}
def behind(name):
st = w.stack(name)
left = st.get("ladder_steps_left") or 0
b = w.badges(name)
txt = (b.get("en") or [{}])[0].get("text", "") if b.get("en") else ""
return st, left, ("Update available" in txt) or left > 0, b
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apps", required=True)
ap.add_argument("--passes", type=int, default=8)
ap.add_argument("--out", default="../C/30-night")
ap.add_argument("--every", type=int, default=60, help="seconds between passes")
a = ap.parse_args()
apps = a.apps.split(",")
w.login()
never, steps, t_night = {}, [], time.time()
for p in range(1, a.passes + 1):
w.say(f"== pass {p}")
w.sync_rescan()
pressed_any = False
for name in apps: # one app at a time
if name in never:
continue
while True: # one step per press, climb while each step ends done
st, left, is_behind, b = behind(name)
if not is_behind:
w.say(f" {name}: current (steps_left={left}) — nothing to press")
break
t0 = time.time()
r = w.press_update(name)
rec = {"pass": p, "app": name, "steps_left_before": left, "from": (st.get("app_config") or {}).get("pinned_images"),
"wall_s": round(time.time() - t0, 1), **r}
if not r.get("accepted"):
reason = ((r.get("refusal") or {}).get("data") or {}).get("reason", "?")
rec["reason"] = reason
steps.append(rec)
if reason in TRANSIENT:
w.say(f" {name}: refused ({reason}) — transient, next pass")
else:
never[name] = f"refused:{reason}"
w.say(f" {name}: refused ({reason}) — never again tonight")
break
pressed_any = True
# MEASURED 2026-09-24 (first run, C/30-night-run1.*): `ladder_steps_left` and the badge are STALE
# after a step ends `done` until the next scan — the first caller re-pressed a current app
# four times (each a full guarded update: dump, pull, restart). So: rescan before reading.
w.sync_rescan()
st2 = w.stack(name)
rec["to"] = (st2.get("app_config") or {}).get("pinned_images")
rec["steps_left_after"] = st2.get("ladder_steps_left")
steps.append(rec)
w.say(f" {name}: step {rec['final_phase']} in {rec['wall_s']} s, steps_left {left} -> {rec['steps_left_after']}")
if rec["final_phase"] == "done" and rec["to"] == rec["from"]:
never[name] = "noop"
w.say(f" {name}: the press changed nothing (already current) — stop")
break
if rec["final_phase"] != "done" or rec.get("hold_reason"):
never[name] = f"step:{rec['final_phase']}"
w.say(f" {name}: a failed step — never pressed again tonight")
break
if not pressed_any and all(n in never or not behind(n)[2] for n in apps):
w.say("all apps current or set aside — the leg ends")
break
time.sleep(a.every)
pages = {}
for name in apps:
st, left, is_b, b = behind(name)
pages[name] = {"state": st.get("state"), "steps_left": left, "badges": b, "hold_reason": st.get("hold_reason"),
"update_phase": st.get("update_phase"), "update_error": st.get("update_error"),
"pinned": (st.get("app_config") or {}).get("pinned_images")}
for lang in ("hu", "en"):
code, d = w.ctl("GET", f"/api/stacks/{name}?lang={lang}")
pages[name][f"update_error_{lang}"] = ((d or {}).get("data") or {}).get("update_error")
events = w.guest("docker logs --since 3h felhom-controller 2>&1 | grep -E 'event app_update_|DROPPED event app_update|dropped event app_update' | tail -30")
res = {"total_s": round(time.time() - t_night, 1), "steps": steps, "never": never, "pages": pages, "events": events}
w.say(f"night total {res['total_s']} s; never={never}")
json.dump(res, open(a.out + ".json", "w"), indent=2, ensure_ascii=False, default=str)
open(a.out + ".log", "w").write("\n".join(w.LOG) + "\n")
if __name__ == "__main__":
main()