harness: five new front-door fixtures, gitea's installer, HTTPS backends, per-file change names (R-462, R-624, R-735)
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
This commit is contained in:
2026-09-30 18:16:45 +02:00
parent 53a4a1ddf7
commit 5b1972b7f9
7 changed files with 565 additions and 21 deletions
+56 -2
View File
@@ -370,12 +370,14 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
paths = LOAD_PATHS.get(app, ["/"])
native = fx.FIXTURES.get(app)
cname, port = getattr(native, "container", app), getattr(native, "port", 80)
scheme = "http"
if native is None and boxport is not None:
# a box-fixture app: load the container traefik routes `/` to, at its own port
rts = boxport.routes((workdir / "docker-compose.yml").read_text())
root = [r for r in rts if not r[0]] or rts
if root:
cname, port = root[0][1], root[0][2]
scheme = root[0][3] if len(root[0]) > 3 else "http" # an HTTPS backend (crafty-controller)
target = container_ip(cname)
stop = threading.Event()
hits = {"n": 0, "codes": {}}
@@ -383,7 +385,8 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
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)
import ssl
opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
host = None
try:
envtxt = (workdir / ".env").read_text()
@@ -398,7 +401,7 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
def worker(i):
k = 0
while not stop.is_set():
url = f"http://{target}:{port}{paths[(i + k) % len(paths)]}"
url = f"{scheme}://{target}:{port}{paths[(i + k) % len(paths)]}"
k += 1
try:
req = urllib.request.Request(url, headers={"Host": host, "X-Forwarded-Proto": "https"} if host else {})
@@ -565,6 +568,51 @@ def bind_tree_hash(project: str, workdir: Path) -> dict:
return out
def bind_tree_files(project: str, workdir: Path) -> dict:
"""{bind source dir: {relpath: "size:sha256[:16]"}} — the same walk as bind_tree_hash, per FILE, so a
`files_may_change` mark can NAME the files it saw change (R-734's method, 2026-09-30: the tree hash alone
could not say which file moved). Files above 64 MiB carry size and mtime only, as in bind_tree_hash."""
import hashlib
r = compose(workdir, project, "ps", "-aq", timeout=120)
srcs = set()
for cid in (r.stdout or "").split():
info = cvp._inspect(cid) or {}
for m in info.get("Mounts") or []:
if m.get("Type") == "bind" and os.path.isdir(m.get("Source") or "") \
and not (m.get("Source") or "").startswith(("/var/run", "/run", "/etc", "/proc", "/sys")):
srcs.add(m["Source"])
out = {}
for src in sorted(srcs):
files = {}
for dp, _, fn in os.walk(src):
for f in fn:
fp = os.path.join(dp, f)
try:
st = os.lstat(fp)
if st.st_size <= 64 * 1024 * 1024 and os.path.isfile(fp) and not os.path.islink(fp):
with open(fp, "rb") as fh:
d = hashlib.sha256(fh.read()).hexdigest()[:16]
else:
d = "mtime-%d" % int(st.st_mtime)
files[os.path.relpath(fp, src)] = "%d:%s" % (st.st_size, d)
except OSError:
files[os.path.relpath(fp, src)] = "unreadable"
out[src] = files
return out
def changed_files(before: dict, after: dict) -> list:
"""["<src>/<relpath> (added|removed|changed)"] between two bind_tree_files results."""
out = []
for src in sorted(set(before) | set(after)):
b, a = before.get(src) or {}, after.get(src) or {}
for rel in sorted(set(b) | set(a)):
if b.get(rel) != a.get(rel):
kind = "added" if rel not in b else "removed" if rel not in a else "changed"
out.append(f"{src}/{rel} ({kind})")
return out
# --- the PostgreSQL major conversion on the bench (`09` §6.4 part 10, harness v4) ---------------------
#
# The postgres image converts nothing and refuses an older major's datadir (R-463); 18 refuses even an
@@ -793,6 +841,8 @@ def run_edge(edge_id: str) -> dict:
files_before = bind_tree_hash(project, workdir)
(ev / "files-before.json").write_text(json.dumps(files_before, indent=2))
detail_before = bind_tree_files(project, workdir)
(ev / "files-before-detail.json").write_text(json.dumps(detail_before, indent=2))
# --- 4. TO ---
swap_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -846,8 +896,12 @@ def run_edge(edge_id: str) -> dict:
(ev / "files-after.json").write_text(json.dumps(files_after, indent=2))
rec["files_changed"] = sorted(k for k in set(files_before) | set(files_after)
if files_before.get(k) != files_after.get(k))
detail_after = bind_tree_files(project, workdir)
(ev / "files-after-detail.json").write_text(json.dumps(detail_after, indent=2))
rec["files_changed_detail"] = changed_files(detail_before, detail_after)[:200]
if rec["files_changed"]:
rec["marks"] = sorted(set(rec["marks"]) | {"files_may_change"})
say(f"files_may_change — the files: {rec['files_changed_detail'][:20]}")
say(f"files_may_change: the bind-mounted tree changed under {rec['files_changed']}")
# --- 5b. the MEMORY WATCH — only for an edge that just read back; a failed one is decided ---