Grimmory and MeTube published behind the family gate (controller >= 0.287.0, decisions 63/64), with complete records
gates / gates (push) Successful in 3s
gates / gates (push) Successful in 3s
- family_gate / family_gate_except / min_controller format (README, REUSE); gate family-gate + decoys - templates/grimmory (v3.5.0, exceptions OPDS/Kobo/KOReader/Komga) + onboarding/grimmory.md - templates/metube (2026.09.29, no exceptions; ladder .28 -> .29) + onboarding/metube.md; fixture MeTube - volume-persistence gate: routed port read from the label NAME (R-801, red-proofed); APP_EXERCISE (R-788) - box_walk: family_cookie; no cached "not gated" while an app has no router 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:
@@ -583,6 +583,51 @@ def _exercise(cids, ports, deep=False):
|
||||
return hits
|
||||
|
||||
|
||||
def routed_ports(resolved):
|
||||
"""The ports traefik routes to, from `docker compose config --format json`.
|
||||
|
||||
That output gives `labels` as a MAPPING (`{"traefik.http.services.x.loadbalancer.server.port": "8081"}`), where the
|
||||
port's NAME is the key — so each label is read as `key=value`, the shape PORT_RE matches. Reading the values alone
|
||||
found NO port for any template (2026-10-02, R-801): the gate's HTTP exercise never ran, and every verdict came from
|
||||
what an app writes at start by itself. Pinned by test_routed_ports_reads_the_mapping_form."""
|
||||
out = set()
|
||||
for svc in (resolved.get("services") or {}).values():
|
||||
labels = svc.get("labels") or {}
|
||||
items = [f"{k}={v}" for k, v in labels.items()] if isinstance(labels, dict) else [str(l) for l in labels]
|
||||
for lbl in items:
|
||||
m = PORT_RE.search(lbl)
|
||||
if m:
|
||||
out.add(int(m.group(1)))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
# APP_EXERCISE — the app's OWN write path, for an app whose GET pages write nothing (R-788, 2026-10-02): MeTube writes
|
||||
# only when it downloads, so a GET-only exercise leaves both of its mounts empty and the honest verdict is UNDETERMINED.
|
||||
# Each entry is (method, path, json body); it is sent to every running container's routed port, like `_exercise`, and
|
||||
# the gate then observes where the data landed as for any app. A request the app refuses writes nothing and the verdict
|
||||
# stays UNDETERMINED — the exercise cannot turn a non-answer into a pass.
|
||||
APP_EXERCISE = {
|
||||
"metube": [("POST", "/add", {"url": "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
|
||||
"quality": "best", "format": "any", "download_type": "video", "auto_start": True})],
|
||||
}
|
||||
|
||||
|
||||
def _exercise_app(app, cids, ports):
|
||||
hits = []
|
||||
for method, path, body in APP_EXERCISE.get(app, []):
|
||||
for cid in cids:
|
||||
info = _inspect(cid) or {}
|
||||
for net in ((info.get("NetworkSettings") or {}).get("Networks") or {}).values():
|
||||
ip = net.get("IPAddress")
|
||||
for port in (ports if ip else []):
|
||||
code = _sh(["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "30", "-X", method,
|
||||
"-H", "Content-Type: application/json", "--data", json.dumps(body),
|
||||
f"http://{ip}:{port}{path}"], timeout=60).stdout.strip()
|
||||
if code and code != "000":
|
||||
hits.append(f"{method} {ip}:{port}{path} -> {code} (the app's own write path)")
|
||||
return hits
|
||||
|
||||
|
||||
def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) -> dict:
|
||||
"""Deploy the template, exercise it, and report WHERE the data landed. The Docker seam.
|
||||
|
||||
@@ -615,12 +660,7 @@ def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) ->
|
||||
return {"app": app, "error": f"compose config failed: "
|
||||
f"{(cfg.stderr or cfg.stdout)[:300]}"}
|
||||
declared = set((resolved.get("volumes") or {}).keys())
|
||||
ports = sorted({int(m.group(1))
|
||||
for svc in (resolved.get("services") or {}).values()
|
||||
for lbl in ((svc.get("labels") or {}).values()
|
||||
if isinstance(svc.get("labels"), dict)
|
||||
else (svc.get("labels") or []))
|
||||
for m in [PORT_RE.search(str(lbl))] if m})
|
||||
ports = routed_ports(resolved)
|
||||
|
||||
up = _sh(base + ["up", "-d"], timeout=1800)
|
||||
cids = [c for c in _sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c]
|
||||
@@ -643,6 +683,8 @@ def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) ->
|
||||
running = [c for c in cids
|
||||
if ((_inspect(c) or {}).get("State") or {}).get("Status") == "running"]
|
||||
hits = _exercise(running, ports) if (running and ports) else []
|
||||
if running and ports and app in APP_EXERCISE:
|
||||
hits += _exercise_app(app, running, ports)
|
||||
time.sleep(settle)
|
||||
|
||||
def observe():
|
||||
|
||||
Reference in New Issue
Block a user