Grimmory and MeTube published behind the family gate (controller >= 0.287.0, decisions 63/64), with complete records
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:
2026-10-02 09:36:49 +02:00
parent de0a9bd29f
commit 96829d0d0f
20 changed files with 1078 additions and 20 deletions
+48 -6
View File
@@ -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():