diff --git a/scripts/upgrade-test.py b/scripts/upgrade-test.py index bda7c55..03e5e2d 100755 --- a/scripts/upgrade-test.py +++ b/scripts/upgrade-test.py @@ -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) diff --git a/scripts/upgrade_boxport.py b/scripts/upgrade_boxport.py index 2e0891b..8abc57f 100644 --- a/scripts/upgrade_boxport.py +++ b/scripts/upgrade_boxport.py @@ -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}"] diff --git a/scripts/upgrade_fixtures_box.py b/scripts/upgrade_fixtures_box.py index 2a2dbe8..5cc605e 100644 --- a/scripts/upgrade_fixtures_box.py +++ b/scripts/upgrade_fixtures_box.py @@ -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(), } diff --git a/scripts/upgrade_fixtures_box28.py b/scripts/upgrade_fixtures_box28.py index 832a896..6b28e1b 100644 --- a/scripts/upgrade_fixtures_box28.py +++ b/scripts/upgrade_fixtures_box28.py @@ -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