#!/usr/bin/env python3 """Phase 3 — legs B6, B8, B9. Usage: phase3_legs2.py [args]""" import json, os, re, sys, time from datetime import datetime HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import walk as w # noqa: E402 from phase3_legs import out, sentences, snap # noqa: E402 DRILL = w.DRILL # ---------------------------------------------------------------------------- B6 def b6(app="glance"): """B6 — THE WAY OUT, FORWARDS. B1 left this app HELD on an image that never serves. The drill catalog now publishes a FIXED next version. Does a held app accept the newer Update, or is a restore the only route? WHATEVER IT DOES, the question is whether it matches `09` §6.1 — a design decision is not a defect (R-370). §6.1 says the hold is `settings.RestoreHold` with `reason: update_failed`, and that **a successful unit restore lifts an update hold (only that kind)**. It does NOT say a newer catalog version lifts one. So a refusal here is the DESIGN, and the finding it produces belongs to Q4 — "is the household stuck until an operator acts?" — not to a bug list. """ d = out("B6-way-out-forwards") FIXED = "localhost:5000/drill/glance:1.0.2" BADIMG = "localhost:5000/drill/glance:1.0.1" w.say("==== B6: a held app meets a FIXED newer version") before = snap(app, "held-before") w.say(f" before: state={before['state']} phase={before['update_phase']} " f"hold={before['hold_reason']!r} err={before['update_error']!r}") h = w.drill_bump(app, BADIMG, FIXED) w.sync_rescan() bdg = w.badges(app) w.say(f" badge HU after the fix is published: {bdg['hu']}") w.say(f" badge EN after the fix is published: {bdg['en']}") code, body = w.ctl("POST", f"/api/stacks/{app}/update") reason = (body.get("data") or {}).get("reason") if isinstance(body, dict) else None w.say(f" press Update on the HELD app: http={code} reason={reason!r} :: " f"{(body.get('error') if isinstance(body,dict) else '') or ''}") res = None if code in ("200", "202"): res = w.press_update(app) w.say(f" it RAN: phases={[p['phase'] for p in res['phases']]} final={res['final_phase']}") after = snap(app, "after") rc, hcode, _ = w.app_curl("dashboard", "/", timeout=20) w.say(f" the household's own door answers http={hcode}") json.dump({"leg": "B6", "app": app, "fixed_image": FIXED, "drill_commit": h, "badges": bdg, "press": {"http": code, "reason": reason, "body": body}, "run": res, "before": before, "after": after, "front_door_http": hcode, "sentences": sentences(app), "design_reference": "09 §6.1 — only a successful unit restore lifts an update hold"}, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False) open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n") # ---------------------------------------------------------------------------- B8 def b8(app, service): """B8 — a FLOATING pin. The badge reads „Naprakész" because the two REFERENCES are equal, while the image behind the reference has been repushed upstream (R-446 measured six). The fact this leg is after is the one Q6 needs: what does `installed_images`' DIGEST say, and does the product offer to move at all? The box never queries a registry (§8.1), so the comparison it can make is reference-to-reference — the question is what that costs. """ d = out("B8-floating-pin") w.say(f"==== B8: the floating pin {service} on {app}") st = w.stack(app) ac = st.get("app_config") or {} inst = ac.get("installed_images") or {} entry = inst.get(service) or {} local_ref = entry.get("ref") if isinstance(entry, dict) else entry local_digest = entry.get("digest") if isinstance(entry, dict) else None cat = (st.get("catalog_images") or {}).get(service) bdg = w.badges(app) w.say(f" installed {service} = {local_ref} digest={local_digest}") w.say(f" catalog {service} = {cat}") w.say(f" badge HU {bdg['hu']}") w.say(f" badge EN {bdg['en']}") # what the RUNNING container actually is, asked of docker — not of our record insp = w.guest(f"docker inspect {service} --format " f"'{{{{.Config.Image}}}} {{{{.Image}}}}' 2>/dev/null; " f"docker image inspect {local_ref} --format " f"'{{{{index .RepoDigests 0}}}}' 2>/dev/null") w.say(f" docker says: {' | '.join(x for x in insp.split(chr(10)) if x.strip())}") # the UPSTREAM digest measured by tonight's drift re-run — no box ever asks a registry up = None try: res = json.load(open("/tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/" "d029e2e6-1762-440e-956d-0760c8aea4b3/scratchpad/results.json")) up = (res.get(local_ref) or {}).get("current_digest") except Exception as e: w.say(f" (upstream digest unavailable: {e})") w.say(f" upstream digest for {local_ref} (measured from DooPlex, never from the box) = {up}") code, body = w.ctl("POST", f"/api/stacks/{app}/update") reason = (body.get("data") or {}).get("reason") if isinstance(body, dict) else None w.say(f" press Update anyway: http={code} reason={reason!r} :: " f"{(body.get('error') if isinstance(body,dict) else '') or ''}") res2 = w.press_update(app) if code in ("200", "202") else None after = snap(app, "after") st2 = w.stack(app) inst2 = ((st2.get("app_config") or {}).get("installed_images") or {}).get(service) or {} w.say(f" installed {service} AFTER = {inst2}") json.dump({"leg": "B8", "app": app, "service": service, "installed_before": entry, "catalog": cat, "badges": bdg, "docker_inspect": insp, "upstream_digest_measured_on_dooplex": up, "press": {"http": code, "reason": reason, "body": body}, "run": res2, "installed_after": inst2, "after": after}, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False) open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n") # ---------------------------------------------------------------------------- B9 def b9(app, sub): """B9 — a FROZEN app receives a newer `.felhom.yml` (R-458). §5.4's deliberate asymmetry: while the catalog is ahead the compose file is frozen, but `.felhom.yml` KEEPS FLOWING, because it carries `catalog_since` which the badge needs. So a frozen app can receive a health check written for a version it is not running and read as degraded. **R-458 says the failure direction is a false alarm, never data loss.** This leg measures exactly that: the app must stay UP and its data must stay readable while the box reports it unhealthy. """ d = out("B9-frozen-app-newer-felhomyml") w.say(f"==== B9: a frozen {app} receives a newer .felhom.yml") st = w.stack(app) inst = {k: (v.get("ref") if isinstance(v, dict) else v) for k, v in ((st.get("app_config") or {}).get("installed_images") or {}).items()} cat = st.get("catalog_images") or {} frozen = bool(inst) and bool(cat) and inst != cat w.say(f" installed={inst}") w.say(f" catalog ={cat}") w.say(f" FROZEN (catalog ahead of the pin)? {frozen}") if not frozen: w.say(" the app is not frozen — B9 needs the catalog AHEAD of the pin. Aborting this leg.") return fy = f"{DRILL}/templates/{app}/.felhom.yml" orig = open(fy).read() open(f"{d}/felhomyml-before.txt", "w").write(orig) before = snap(app, "before") rc, code0, _ = w.app_curl(sub, "/", timeout=20) w.say(f" before: state={before['state']} front door http={code0}") # a health check written for a NEWER version: a path this version does not serve probe = "/__drill_only_in_the_newer_version__" new = re.sub(r"(healthcheck:\s*\n\s*checks:\s*\n)", r"\1 - type: http\n port: 8080\n path: " + probe + "\n", orig, count=1) if new == orig: new = orig + f"\n# DRILL B9: a health check written for a newer version\nhealthcheck:\n checks:\n - type: http\n port: 8080\n path: {probe}\n" open(fy, "w").write(new) w.sh(["git", "-C", DRILL, "add", "-A"]) w.sh(["git", "-C", DRILL, "commit", "-q", "-m", f"DRILL B9: {app} .felhom.yml gains a health check for a NEWER version (R-458)"]) w.sh(["git", "-C", DRILL, "push", "-q", "origin", "main"], timeout=120) w.say(" pushed a .felhom.yml-only change (no image line touched)") w.sync_rescan() time.sleep(20) live = w.guest(f"grep -A6 'healthcheck' /opt/docker/stacks/{app}/.felhom.yml | head -12; " f"echo '---compose image lines---'; " f"grep -E '^\\s+image:' /opt/docker/stacks/{app}/docker-compose.yml | sed 's/^ *//'") w.say(" on the box now: " + " | ".join(x for x in live.split("\n") if x.strip())[:400]) states = [] for _ in range(10): s = w.stack(app) rc, code1, _ = w.app_curl(sub, "/", timeout=15) states.append({"state": s.get("state"), "health": s.get("health"), "front_door": code1, "at": datetime.now().isoformat(timespec="seconds")}) time.sleep(12) w.say(f" states over 2 minutes: {[ (x['state'], x['health'], x['front_door']) for x in states ]}") after = snap(app, "after") sent = sentences(app) w.say(f" household sentences HU: {sent['hu'][:4]}") open(fy, "w").write(orig) w.sh(["git", "-C", DRILL, "add", "-A"]) w.sh(["git", "-C", DRILL, "commit", "-q", "-m", f"DRILL B9: revert {app} .felhom.yml"]) w.sh(["git", "-C", DRILL, "push", "-q", "origin", "main"], timeout=120) w.sync_rescan() w.say(" .felhom.yml reverted in the drill catalog") json.dump({"leg": "B9", "app": app, "frozen": frozen, "installed": inst, "catalog": cat, "probe_path": probe, "on_the_box": live, "states_over_2min": states, "before": before, "after": after, "sentences": sent, "front_door_before": code0}, open(f"{d}/result.json", "w"), indent=2, ensure_ascii=False) open(f"{d}/log.txt", "w").write("\n".join(w.LOG) + "\n") if __name__ == "__main__": w.login() leg = sys.argv[1] if leg == "b6": b6(*sys.argv[2:]) elif leg == "b8": b8(sys.argv[2], sys.argv[3]) elif leg == "b9": b9(sys.argv[2], sys.argv[3]) else: sys.exit(f"unknown leg {leg}")