Files
app-catalog-felhom.eu/scripts/upgrade_boxport.py
T
admin 1263773786 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
2026-09-23 21:32:45 +02:00

166 lines
6.7 KiB
Python

# -*- coding: utf-8 -*-
"""upgrade_boxport.py — run the BOX walk's seed/verify fixtures on the test bench (R-462, 2026-09-23).
The box walk (guest 9202, through the controller) and the bench (`upgrade-test.py`, raw compose, no
controller) used to carry two separate fixture sets: 20+ apps box-side, 8 bench-side. R-462 measured
that FIXTURES are the cost of widening the test, so the second set is not rewritten — the box
fixtures (`upgrade_fixtures_box*.py`, ported verbatim) run here through `Venue`, a stand-in for the
four things they use from walk.py:
app_curl(sub, path, …) → the app's OWN HTTP interface: the container that serves the path, at the
port the template's traefik labels name, with the Host header the app was
configured for. There is no traefik on the bench; the app is the same.
wait_app(sub, path, …) → the same, polled.
guest(script) → a local bash on the bench (fixtures use it for `docker exec <app> <cli>`,
the app's own CLI inside its own container — R-156's allowed route).
sh(args) / GENERATED → as in walk.py; GENERATED holds the deploy secrets this run generated.
THE RULE IS UNCHANGED (R-156): nothing is planted in a volume; every seed goes in through the app.
An app whose fixture returns None is `inconclusive`, with what was tried.
"""
import os
import re
import subprocess
import time
import upgrade_fixtures_box as _box
import upgrade_fixtures_box28 as _box28
ROUTE_RULE_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.rule[=:]\s*[\"']?(.+?)[\"']?\s*$")
ROUTE_SVC_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.service[=:]\s*[\"']?([A-Za-z0-9_-]+)")
LB_PORT_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.port[=:]\s*[\"']?(\d+)")
PATH_RE = re.compile(r"PathPrefix\(`([^`]+)`\)")
SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$")
CNAME_RE = re.compile(r"^\s+container_name:\s*[\"']?([^\s\"']+)")
def routes(compose_text):
"""[(path_prefixes, container, port)] for every compose service traefik routes to."""
out, cur, cname, labels = [], None, {}, {}
for line in compose_text.splitlines():
m = SERVICE_RE.match(line)
if m:
cur = m.group(1)
continue
if cur is None:
continue
mc = CNAME_RE.match(line)
if mc:
cname[cur] = mc.group(1)
if "traefik." in line:
labels.setdefault(cur, []).append(line.strip().lstrip("- ").strip())
for svc, ls in labels.items():
rules, rsvc, ports = {}, {}, {}
for l in ls:
for rx, d in ((ROUTE_RULE_RE, rules), (ROUTE_SVC_RE, rsvc), (LB_PORT_RE, ports)):
mm = rx.search(l)
if mm:
d[mm.group(1)] = mm.group(2)
if not ports:
continue
port = int(next(iter(ports.values())))
prefixes = []
for r, rule in rules.items():
prefixes += PATH_RE.findall(rule)
out.append((prefixes, cname.get(svc, svc), port))
return out
class Venue:
"""walk.py's interface, on the bench."""
def __init__(self, compose_text, env, ipfn):
self.routes = routes(compose_text)
self.env = env
self.ipfn = ipfn
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:
if not prefixes and blen < 0:
best, blen = (container, port), 0
for p in prefixes:
if path.startswith(p) and len(p) > blen:
best, blen = (container, port), len(p)
return best
def sh(self, args, timeout=300, inp=None):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=inp)
except (subprocess.TimeoutExpired, OSError) as e:
return subprocess.CompletedProcess(args, 124, "", str(e))
def guest(self, script, timeout=600):
return self.sh(["bash", "-c", script], timeout=timeout).stdout or ""
def app_curl(self, sub, path, *extra, method=None, data=None, timeout=45):
t = self._target(path)
if not t:
return 7, "000", "no routed container in the template"
ip = self.ipfn(t[0])
if not ip:
return 7, "000", "container %s has no IP" % t[0]
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}"]
if method:
args += ["-X", method]
if data is not None:
args += ["--data-binary", "@-"]
args += list(extra) + ["http://%s:%d%s" % (ip, t[1], path)]
r = self.sh(args, timeout=timeout + 30, inp=data)
body, _, code = (r.stdout or "").rpartition("\n")
return r.returncode, code.strip(), body
def wait_app(self, sub, path="/", want=("200", "302", "303", "401", "403"), tries=60, delay=5):
for _ in range(tries):
rc, code, _ = self.app_curl(sub, path, timeout=15)
if rc == 0 and code in want:
return True
time.sleep(delay)
return False
class BoxFixture:
"""A box fixture in the bench's shape: seed(ipfn, say) / verify(ipfn, seeded, say)."""
def __init__(self, app, box, compose_text, env):
self.app, self.box, self.compose_text, self.env = app, box, compose_text, env
self.tried = getattr(box, "tried", None)
def _venue(self, ipfn):
v = Venue(self.compose_text, self.env, ipfn)
v.GENERATED[self.app] = dict(self.env)
return v
def seed(self, ipfn, say):
v = self._venue(ipfn)
sub = getattr(self.box, "sub", self.app)
out = self.box.seed(v, sub, say)
self.tried = getattr(self.box, "tried", self.tried)
return out
def verify(self, ipfn, seeded, say):
v = self._venue(ipfn)
return bool(self.box.verify(v, getattr(self.box, "sub", self.app), seeded, say))
def get(app, compose_text, env):
"""The ported box fixture for `app`, wrapped for the bench, or None."""
box = _box.FIXTURES.get(app) or _box28.FIXTURES28.get(app)
if box is None:
return None
return BoxFixture(app, box, compose_text, env)
def available():
return sorted(set(_box.FIXTURES) | set(_box28.FIXTURES28))