da20722e76
gates / gates (push) Successful in 27s
INTERIM CHECKPOINT — evidence off the machine at the end of the phase that produced it (R-320), not at the end of the session. Phases 2-5 follow in a later commit. Phase 0, all three mechanisms proven with their controls: - the fleet floor to 0.261.0 with its declared MinAgent — both demo boxes in 13 s, the hub logging `managed floor SERVED ... from declared (golden 0.258.0)`. - a PRIVATE DRILL CATALOG (admin/app-catalog-drill), so that broken, dummy, cross-repo and engine-major edges can be measured without the live catalog ever carrying one. Positive control quoted, and two negative controls: the live catalog's main and both real boxes' caches unchanged. - a throwaway image store on the scratch guest, which is what makes an UNATTENDED HOLD measurable at all: an edge that PASSES the within-a-major test and still fails. CompareImageRefs was proven to order host:port/ references by RUNNING it (4 positive cases + 1 negative control), not by reading it. Phase 1: real within-a-major upstream edges walked on guest 9202 through the product's own guarded Update, each app seeded and read back through its OWN front door (R-156), with a per-edge verdict record in 09's shape. `inconclusive` is never collapsed into `failed`. TWO INSTRUMENT FIXES, both in this repo's own evidence code: - 00-api-recipe.md said the app page is /app/<n>; it is /apps/<n>, and every call it described 404s. Corrected, with the session-expiry note that cost the same time. - unattended-caller.py's follow() read update_phase/updating off the API ENVELOPE, so both were always None and EVERY followed update ran to its 900 s timeout and was then recorded `timeout` and never-press-again. Fixed before B1 relied on it. R-623. No controller, agent or hub code was written. The live catalog carries no broken reference. Gates: repo_gates.py --fast — all 15 OK, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
207 lines
10 KiB
Python
207 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Phase 3 — legs B6, B8, B9. Usage: phase3_legs2.py <leg> [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}")
|