# -*- 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 `, 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))