5b1972b7f9
gates / gates (push) Successful in 2s
- fixtures (upgrade_fixtures_box.py): calibre-web (Upload form -> OPDS readback + the served EPUB), wger (web login -> weight entry API), crafty-controller (API v2 roles), uptime-kuma (socket.io polling: setup, login, addStatusPage -> public /api/status-page/<slug>); gitea posts its own first-run installer form (R-624's fixable case) and keeps the admin CLI for an installed one. calibre-web and wger run the template's own after_install on the bench, which has none. - R-735: the bench's `password:N:special` now has the controller's shape (randomWithSpecial); test seen failing first (length 32, no special), then 45/45. - upgrade_boxport / the memory watch: a backend traefik reaches over https (loadbalancer.server.scheme) is reached over https on the bench too (crafty-controller). - upgrade-test: files-before/after-detail.json and `files_changed_detail` NAME the files behind a files_may_change mark (R-734's method, now in the harness); test ChangedFiles. - test_catalog_gates: the gate count was stale (9, the runner has 10) and red on main; now 10. Evidence: felhom.eu/documentation/audits/more-night-apps-2026-09-30/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
169 lines
7.1 KiB
Python
169 lines
7.1 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+)")
|
|
# 2026-09-30 (crafty-controller): a backend that speaks HTTPS says so to traefik; the bench must too.
|
|
LB_SCHEME_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.scheme[=:]\s*[\"']?(https?)")
|
|
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, scheme)] for every compose service traefik routes to. The scheme is
|
|
the 4th element so every reader of [0]..[2] is unchanged."""
|
|
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, schemes = {}, {}, {}, {}
|
|
for l in ls:
|
|
for rx, d in ((ROUTE_RULE_RE, rules), (ROUTE_SVC_RE, rsvc), (LB_PORT_RE, ports), (LB_SCHEME_RE, schemes)):
|
|
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, next(iter(schemes.values()), "http")))
|
|
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, scheme in self.routes:
|
|
if not prefixes and blen < 0:
|
|
best, blen = (container, port, scheme), 0
|
|
for p in prefixes:
|
|
if path.startswith(p) and len(p) > blen:
|
|
best, blen = (container, port, scheme), 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) + ["%s://%s:%d%s" % (t[2], 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))
|