harness v3 fixes found on the bench tonight
- memory watch: load no longer follows redirects to the unresolvable test domain (every request had been 'err' on box-fixture apps), and sends the app's own Host; the app's own memory (anon) is sampled beside the cgroup peak, and memory_tight reads anon where measured (09 decision 22, CC). - ladder writer: memory_peak_pct = anon (else cgroup peak), with memory_basis and memory_cgroup_peak_pct beside it. - fixtures: opengist 1.15 serves under /-/ and marks its cookie Secure (readback = the account's own page + a never-created user 404); komga's user endpoint is /api/v2/users/me; a wishlist fixture (form actions). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+51
-7
@@ -316,6 +316,17 @@ def _oom_kills(cg: str):
|
||||
return None
|
||||
|
||||
|
||||
def _stat_anon(cg: str):
|
||||
try:
|
||||
for line in open(os.path.join(cg, "memory.stat")):
|
||||
k, _, v = line.partition(" ")
|
||||
if k == "anon":
|
||||
return int(v)
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def memory_snapshot(project: str, workdir: Path):
|
||||
"""One reading per container: usage, peak, limit, kernel OOM kills, restarts, OOMKilled."""
|
||||
out = {}
|
||||
@@ -332,6 +343,10 @@ def memory_snapshot(project: str, workdir: Path):
|
||||
"limit": limit or (_read_int(os.path.join(cg, "memory.max")) if cg else None),
|
||||
"current": _read_int(os.path.join(cg, "memory.current")) if cg else None,
|
||||
"peak": _read_int(os.path.join(cg, "memory.peak")) if cg else None,
|
||||
# the APP's own memory (anon), beside the peak: memory.peak counts the kernel's file cache
|
||||
# too, which the kernel reclaims before it kills anything (night 2026-09-23, nextcloud
|
||||
# read 100 % with 0 kills). Reported, not yet used for the verdict or the mark.
|
||||
"anon": _stat_anon(cg) if cg else None,
|
||||
"oom_kill": _oom_kills(cg) if cg else None,
|
||||
"restarts": (info.get("State") or {}).get("RestartCount", 0),
|
||||
"oomkilled_flag": (info.get("State") or {}).get("OOMKilled"),
|
||||
@@ -361,6 +376,20 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
||||
target = container_ip(cname)
|
||||
stop = threading.Event()
|
||||
hits = {"n": 0, "codes": {}}
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *a, **k):
|
||||
return None # a 30x is the app ANSWERING; following it to the unreachable domain is not load
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
host = None
|
||||
try:
|
||||
envtxt = (workdir / ".env").read_text()
|
||||
sub = re.search(r"^SUBDOMAIN=(.*)$", envtxt, re.M)
|
||||
dom = re.search(r"^DOMAIN=(.*)$", envtxt, re.M)
|
||||
if sub and dom:
|
||||
host = f"{sub.group(1)}.{dom.group(1)}"
|
||||
except OSError:
|
||||
pass
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker(i):
|
||||
@@ -369,7 +398,8 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
||||
url = f"http://{target}:{port}{paths[(i + k) % len(paths)]}"
|
||||
k += 1
|
||||
try:
|
||||
code = urllib.request.urlopen(url, timeout=20).status
|
||||
req = urllib.request.Request(url, headers={"Host": host, "X-Forwarded-Proto": "https"} if host else {})
|
||||
code = opener.open(req, timeout=20).status
|
||||
except urllib.error.HTTPError as e:
|
||||
code = e.code
|
||||
except Exception:
|
||||
@@ -416,14 +446,23 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
||||
for n, v in end.items():
|
||||
b = base0.get(n, {})
|
||||
pk, lim = v["peak"], v["limit"]
|
||||
anon_max = max([smp["containers"].get(n, {}).get("anon") or 0 for smp in samples] + [v.get("anon") or 0])
|
||||
per[n] = {"limit": lim, "peak": pk,
|
||||
"peak_pct": round(pk / lim, 3) if (pk and lim) else None,
|
||||
"anon_peak_sampled": anon_max or None,
|
||||
"anon_peak_pct": round(anon_max / lim, 3) if (anon_max and lim) else None,
|
||||
"oom_kills": None if v["oom_kill"] is None else v["oom_kill"] - (b.get("oom_kill") or 0),
|
||||
"restarts": (v["restarts"] or 0) - (b.get("restarts") or 0),
|
||||
"oomkilled_flag": v["oomkilled_flag"], "measured": v["cgroup"]}
|
||||
(ev / "memory-samples.json").write_text(json.dumps(samples, indent=2))
|
||||
killed = any((p["oom_kills"] or 0) > 0 or p["restarts"] > 0 or p["oomkilled_flag"] for p in per.values())
|
||||
tight = [n for n, p in per.items() if p["peak_pct"] is not None and p["peak_pct"] > MEMORY_TIGHT]
|
||||
# DECIDED 2026-09-23 night (CC, unattended — operator may reverse; `09` §3 decision 22): the mark
|
||||
# reads the APP's own memory (anon, sampled) where it was measured. memory.peak counts the file cache,
|
||||
# which the kernel drops before it kills anything: nextcloud and immich's postgres read 100 % with 0
|
||||
# kills. The cgroup peak stays in the record; a kill or a restart still FAILS the edge either way.
|
||||
def _tight_pct(p):
|
||||
return p.get("anon_peak_pct") if p.get("anon_peak_pct") is not None else p["peak_pct"]
|
||||
tight = [n for n, p in per.items() if _tight_pct(p) is not None and _tight_pct(p) > MEMORY_TIGHT]
|
||||
rec = {"soak_s": round(time.time() - t0, 1), "requested_s": seconds, "requests": hits["n"],
|
||||
"codes": hits["codes"], "first_kill": first_bad, "containers": per,
|
||||
"unmeasured": [n for n, p in per.items() if not p["measured"]]}
|
||||
@@ -716,10 +755,14 @@ def write_ladder(argv) -> int:
|
||||
print(f"INCONCLUSIVE {app}: {svc} {ref}: {why}")
|
||||
return 2
|
||||
digests[svc] = d
|
||||
peaks = [c.get("peak_pct") for c in (bench["memory"].get("containers") or {}).values()
|
||||
if isinstance(c.get("peak_pct"), (int, float))]
|
||||
# the watch records peak_pct as a FRACTION of the limit (0.81); the ladder carries PERCENT
|
||||
peak = round(max(peaks) * 100, 1) if peaks else None
|
||||
conts = (bench["memory"].get("containers") or {}).values()
|
||||
anon = [c.get("anon_peak_pct") for c in conts if isinstance(c.get("anon_peak_pct"), (int, float))]
|
||||
cg = [c.get("peak_pct") for c in conts if isinstance(c.get("peak_pct"), (int, float))]
|
||||
# the watch records FRACTIONS of the limit (0.81); the ladder carries PERCENT. memory_peak_pct is the
|
||||
# figure the mark is judged on: the app's own memory (anon) when the watch measured it (decision 22),
|
||||
# else the cgroup peak; the cgroup peak (file cache included) is carried beside it.
|
||||
peak = round(max(anon) * 100, 1) if anon else (round(max(cg) * 100, 1) if cg else None)
|
||||
cg_peak = round(max(cg) * 100, 1) if cg else None
|
||||
if peak is None:
|
||||
print(f"REFUSED {app}: the memory watch recorded no peak")
|
||||
return 1
|
||||
@@ -727,7 +770,8 @@ def write_ladder(argv) -> int:
|
||||
entry = {"from": bench["from"], "to": bench["to"], "digest": digests, "verdict": "proven",
|
||||
"tested_at": bench["measured_at"], "harness_version": bench["harness_version"],
|
||||
"evidence": arg("--evidence"), "box_evidence": arg("--box-evidence"),
|
||||
"memory_peak_pct": peak,
|
||||
"memory_peak_pct": peak, "memory_basis": "anon" if anon else "cgroup_peak",
|
||||
"memory_cgroup_peak_pct": cg_peak,
|
||||
"marks": {"files_may_change": "files_may_change" in marks,
|
||||
"needs_person": None, "memory_tight": peak > ladder.MEMORY_TIGHT_PCT}}
|
||||
probs = ladder.check_entry(entry)
|
||||
|
||||
@@ -76,6 +76,11 @@ class Venue:
|
||||
self.DOMAIN = env.get("DOMAIN", "gate.invalid")
|
||||
self.GENERATED = {}
|
||||
|
||||
def host(self, sub):
|
||||
"""The Host every request carries: the domain the app was DEPLOYED for (its env), so an app
|
||||
that checks trusted domains or a form's Origin sees its own name."""
|
||||
return "%s.%s" % (self.env.get("SUBDOMAIN", sub), self.DOMAIN)
|
||||
|
||||
def _target(self, path):
|
||||
best, blen = None, -1
|
||||
for prefixes, container, port in self.routes:
|
||||
@@ -102,7 +107,7 @@ class Venue:
|
||||
ip = self.ipfn(t[0])
|
||||
if not ip:
|
||||
return 7, "000", "container %s has no IP" % t[0]
|
||||
host = "%s.%s" % (self.env.get("SUBDOMAIN", sub), self.DOMAIN)
|
||||
host = self.host(sub)
|
||||
args = ["curl", "-sSk", "--max-time", str(timeout), "-H", "Host: " + host,
|
||||
"-H", "X-Forwarded-Proto: https", "-H", "X-Forwarded-Host: " + host,
|
||||
"-w", "\n%{http_code}"]
|
||||
|
||||
@@ -796,27 +796,48 @@ class OpenGist:
|
||||
# Wait for the LOGIN FORM, not for the root page. Measured 2026-09-21: immediately after a
|
||||
# successful update the root answers while /login does not yet carry its `_csrf`, so the
|
||||
# sign-in silently fails and the app looks like it lost the account. It had not.
|
||||
if not w.wait_app(sub, "/login", want=("200",), tries=72):
|
||||
say(" opengist: /login never came back after the update")
|
||||
# 1.15 moved every page under `/-/` (`/-/login`, `/-/all`; `/login` answers 404) — measured
|
||||
# 2026-09-23 night. Ask the app which shape it serves instead of assuming one.
|
||||
pre, home_path = "", "/"
|
||||
for _ in range(72):
|
||||
if w.app_curl(sub, "/-/login")[1] == "200":
|
||||
pre, home_path = "/-", "/-/all"
|
||||
break
|
||||
if w.app_curl(sub, "/login")[1] == "200":
|
||||
break
|
||||
time.sleep(5)
|
||||
else:
|
||||
say(" opengist: neither /login nor /-/login came back after the update")
|
||||
return False
|
||||
say(f" opengist: sign-in form at {pre}/login")
|
||||
for _ in range(24):
|
||||
rc, code, html = w.app_curl(sub, "/login")
|
||||
rc, code, html = w.app_curl(sub, pre + "/login")
|
||||
if code == "200" and '_csrf' in (html or ""):
|
||||
break
|
||||
time.sleep(5)
|
||||
jar = f"/tmp/og-{secrets.token_hex(4)}.jar"
|
||||
code, _ = self._form(w, sub, "/login", jar,
|
||||
code, _ = self._form(w, sub, pre + "/login", jar,
|
||||
{"username": t["user"], "password": "wrong-" + secrets.token_hex(5)})
|
||||
rc, c2, home = w.app_curl(sub, "/", "-b", jar)
|
||||
rc, c2, home = w.app_curl(sub, home_path, "-b", jar)
|
||||
if t["user"] in (home or ""):
|
||||
say(" opengist: READBACK UNUSABLE — a wrong password signed in")
|
||||
return False
|
||||
jar2 = f"/tmp/og-{secrets.token_hex(4)}.jar"
|
||||
code, _ = self._form(w, sub, "/login", jar2, {"username": t["user"], "password": t["pw"]})
|
||||
rc, c2, home = w.app_curl(sub, "/", "-b", jar2)
|
||||
ok = t["user"] in (home or "")
|
||||
say(f" opengist: sign-in as the seeded account http={code} name_on_page={ok}")
|
||||
return ok
|
||||
code, _ = self._form(w, sub, pre + "/login", jar2, {"username": t["user"], "password": t["pw"]})
|
||||
rc, c2, home = w.app_curl(sub, home_path, "-b", jar2)
|
||||
signed_in = t["user"] in (home or "")
|
||||
# The ACCOUNT's own public page is the readback that does not depend on a cookie: 1.15 marks its
|
||||
# session cookie Secure, so a plain-HTTP bench cannot send it back (measured 2026-09-23 night).
|
||||
# A user that was never created must 404 on the same call, or the readback proves nothing.
|
||||
rc, pc, prof = w.app_curl(sub, "/" + t["user"])
|
||||
rc, nc, _ = w.app_curl(sub, "/nobody" + secrets.token_hex(4))
|
||||
profile = pc == "200" and t["user"] in (prof or "")
|
||||
if nc == "200":
|
||||
say(" opengist: READBACK UNUSABLE — a never-created user's page answered 200")
|
||||
return None
|
||||
say(f" opengist: account page /{t['user']} http={pc} found={profile} (never-created user {nc}); "
|
||||
f"sign-in http={code} name_on_page={signed_in}")
|
||||
return profile
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
@@ -986,6 +1007,51 @@ class Romm:
|
||||
return ok
|
||||
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
class Wishlist:
|
||||
"""Wishlist's own SvelteKit FORM actions (added night 2026-09-23, R-612's app). Sign-up at
|
||||
/signup, then prove the account survived by signing in at /login — and by a wrong password being
|
||||
REFUSED on the same call, so a readback that always says "ok" fails instead of passing.
|
||||
SvelteKit refuses a cross-site form post: the Origin must be the app's own https origin."""
|
||||
sub = "wishlist"
|
||||
|
||||
def _post(self, w, sub, path, body):
|
||||
origin = "https://" + getattr(w, "host", lambda s: f"{s}.{w.DOMAIN}")(sub) # the Host the request carries
|
||||
rc, code, out = w.app_curl(sub, path, "-H", f"Origin: {origin}", "-H", "x-sveltekit-action: true",
|
||||
"-H", "Content-Type: application/x-www-form-urlencoded",
|
||||
data=body, method="POST")
|
||||
try:
|
||||
return code, json.loads(out)
|
||||
except Exception:
|
||||
return code, {"type": "unparsed", "raw": (out or "")[:200]}
|
||||
|
||||
def seed(self, w, sub, say):
|
||||
if not w.wait_app(sub, "/signup", want=("200",), tries=72):
|
||||
return None
|
||||
u = "drill" + secrets.token_hex(3)
|
||||
pw = "Drill-" + secrets.token_hex(8)
|
||||
code, j = self._post(w, sub, "/signup",
|
||||
f"name=Drill&username={u}&email={u}%40example.invalid&password={pw}&tokenId=")
|
||||
say(f" wishlist: /signup http={code} type={j.get('type')}")
|
||||
if j.get("type") not in ("success", "redirect"):
|
||||
self.tried = f"POST /signup -> {code} {str(j)[:150]}"
|
||||
return None
|
||||
return {"u": u, "pw": pw}
|
||||
|
||||
def verify(self, w, sub, t, say):
|
||||
if not w.wait_app(sub, "/login", want=("200",), tries=72):
|
||||
say(" wishlist: /login never came back")
|
||||
return False
|
||||
c1, bad = self._post(w, sub, "/login", f"username={t['u']}&password=wrong-{secrets.token_hex(5)}")
|
||||
if bad.get("type") != "failure":
|
||||
say(f" wishlist: READBACK UNUSABLE — a wrong password was not refused ({bad.get('type')})")
|
||||
return None
|
||||
c2, good = self._post(w, sub, "/login", f"username={t['u']}&password={t['pw']}")
|
||||
ok = good.get("type") in ("success", "redirect")
|
||||
say(f" wishlist: sign-in as the seeded user type={good.get('type')} ok={ok} (wrong password refused)")
|
||||
return ok
|
||||
|
||||
FIXTURES = {
|
||||
"home-assistant": HomeAssistant(),
|
||||
"romm": Romm(),
|
||||
@@ -1008,4 +1074,5 @@ FIXTURES = {
|
||||
"gitea": Gitea(),
|
||||
"navidrome": Navidrome(),
|
||||
"vaultwarden": Vaultwarden(),
|
||||
"wishlist": Wishlist(),
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ class Ghost:
|
||||
|
||||
|
||||
class Komga:
|
||||
sub = "komga"; route = "its own POST /api/v1/claim, then GET /api/v1/users/me"
|
||||
sub = "komga"; route = "its own POST /api/v1/claim, then GET /api/v2/users/me"
|
||||
|
||||
def seed(self, w, sub, say):
|
||||
if not w.wait_app(sub, "/", want=("200", "302", "401")):
|
||||
@@ -280,10 +280,10 @@ class Komga:
|
||||
def verify(self, w, sub, t, say):
|
||||
import base64 as _b
|
||||
a = _b.b64encode(f"{t['u']}:{t['pw']}".encode()).decode()
|
||||
rc, code, out = w.app_curl(sub, "/api/v1/users/me", "-H", f"Authorization: Basic {a}")
|
||||
rc, code, out = w.app_curl(sub, "/api/v2/users/me", "-H", f"Authorization: Basic {a}") # v2 since komga 1.x; v1 answers 404 (measured 2026-09-23)
|
||||
found = code == "200" and t["u"] in (out or "")
|
||||
bad = _b.b64encode(f"nope{secrets.token_hex(6)}:{t['pw']}".encode()).decode()
|
||||
rc2, code2, _ = w.app_curl(sub, "/api/v1/users/me", "-H", f"Authorization: Basic {bad}")
|
||||
rc2, code2, _ = w.app_curl(sub, "/api/v2/users/me", "-H", f"Authorization: Basic {bad}")
|
||||
if code2 == "200":
|
||||
say(" komga: READBACK UNUSABLE — an impossible user authenticated")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user