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:
+28
-1
@@ -130,6 +130,14 @@ def gate_cookie(sub):
|
||||
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip()
|
||||
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {sub}.{DOMAIN}", f"{BASE}/"])
|
||||
loc = _loc(r.stdout)
|
||||
st = re.match(r"HTTP/\S+ (\d+)", r.stdout or "")
|
||||
if not loc and (not st or st.group(1) in ("404", "502", "503", "504")):
|
||||
# the app is not routed at this moment (a restart, an update's stop): NOT the same as "not gated" — a cached ""
|
||||
# here sent every later call past nothing and read the gate's 401 as the app's (2026-10-02, grim-step)
|
||||
say(f" gate: {sub} not routed right now ({st.group(1) if st else 'no answer'}) — not cached, asked again next call")
|
||||
return ""
|
||||
if "/__family/start" in loc: # v0.287.0: the FAMILY gate — the household's dashboard session vouches, as for
|
||||
return family_cookie(sub, loc, sess) # the setup gate (decisions 63/64)
|
||||
if "/__gate/start" not in loc:
|
||||
GATE[sub] = ""
|
||||
return "" # not gated (open, or no gate for this app)
|
||||
@@ -148,6 +156,25 @@ def gate_cookie(sub):
|
||||
return GATE[sub]
|
||||
|
||||
|
||||
def family_cookie(sub, loc, sess):
|
||||
"""Pass the FAMILY gate (controller >= 0.287.0) as the household: /__family/start on the dashboard host with the
|
||||
dashboard session → the app host's /__felhom_gate/fcb → `felhom_famgate`. Never printed."""
|
||||
import urllib.parse
|
||||
u = urllib.parse.urlsplit(loc)
|
||||
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", "-H", f"Cookie: {sess}",
|
||||
f"{BASE}{u.path}?{u.query}"])
|
||||
u = urllib.parse.urlsplit(_loc(r.stdout))
|
||||
if "/__felhom_gate/fcb" not in u.path:
|
||||
say(f" family gate: the dashboard did not hand back a callback for {sub}")
|
||||
GATE[sub] = ""
|
||||
return ""
|
||||
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", f"{BASE}{u.path}?{u.query}"])
|
||||
m = re.search(r"(?im)^set-cookie:\s*(felhom_famgate=[^;\r\n]+)", r.stdout or "")
|
||||
GATE[sub] = m.group(1) if m else ""
|
||||
say(f" family gate: {sub} is family-gated — passed as the household (cookie {'set' if GATE[sub] else 'NOT set'})")
|
||||
return GATE[sub]
|
||||
|
||||
|
||||
def app_curl(sub, path, *extra, method=None, data=None, timeout=45, _retry=True):
|
||||
"""A call to the APP's own front door on 9202 — the household's route, not ours. Carries the setup
|
||||
gate's cookie when the app is gated, merged into a fixture's own Cookie header (never a second one)."""
|
||||
@@ -172,7 +199,7 @@ def app_curl(sub, path, *extra, method=None, data=None, timeout=45, _retry=True)
|
||||
r = sh(args, timeout=timeout + 30, inp=data)
|
||||
body, _, tail = (r.stdout or "").rpartition("\n")
|
||||
code, _, redir = tail.strip().partition(" ")
|
||||
if _retry and "/__gate/start" in redir:
|
||||
if _retry and ("/__gate/start" in redir or "/__family/start" in redir):
|
||||
GATE.pop(sub, None) # the gate cookie expired or was never taken — log in as the household again
|
||||
return app_curl(sub, path, *raw, method=method, data=data, timeout=timeout, _retry=False)
|
||||
return r.returncode, code.strip(), body
|
||||
|
||||
@@ -114,6 +114,9 @@ GATES = [
|
||||
# exists. Static, files only, so it is --fast and bites in the hook AND in CI. The 53 apps published before
|
||||
# the checklist are exempt by name inside the script.
|
||||
("onboarding", "check-onboarding.py", False, True, False),
|
||||
# 2026-10-02 (`09` §3 decisions 63/64): a family-gated template's exceptions are literal prefixes, it declares
|
||||
# min_controller >= 0.287.0, and the newest baked golden knows the gate. Static, files only.
|
||||
("family-gate", "check-family-gate.py", False, True, False),
|
||||
]
|
||||
|
||||
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""check-family-gate.py — the family gate's template fields are safe to publish (`09` §3 decisions 63/64, controller 0.287.0).
|
||||
|
||||
A template with `family_gate: true` is published ONLY behind the household's family gate. Three ways that goes wrong, each
|
||||
refused here:
|
||||
|
||||
1. an exception (`family_gate_except:`) that is not a LITERAL path prefix — a regex, a traefik matcher, `..`, `//`, or `/`
|
||||
itself. The controller anchors each prefix at a path-segment boundary (`^/prefix(/|$)`, finding F1 of the spike: an
|
||||
unanchored `PathPrefix(/api/v1/opds)` let `/api/v1/opdsx` past the gate) and refuses the install on anything else —
|
||||
this gate refuses it at push time instead of at a household's install;
|
||||
2. `family_gate: true` without `min_controller: "0.287.0"` (or newer) — the controller refuses a template that needs a
|
||||
newer box, but only if the template SAYS so;
|
||||
3. `family_gate: true` while the newest baked golden is older than 0.287.0 — a box installed from that golden runs a
|
||||
controller that does not know `family_gate` and would publish the app OPEN. Read from the sibling `felhom.eu`
|
||||
checkout (`documentation/tests/golden-<VER>-<DATE>/` holding a bake log with a `GOLDEN_SHA256=` line — a directory
|
||||
NAME is not a bake, R-410). The sibling absent (CI's single clone) → printed as NOT CHECKED, never as a pass of rule 3.
|
||||
|
||||
Also refused: `family_gate_except:` on a template without `family_gate: true` (an exception list with no gate is a label
|
||||
without the fact).
|
||||
|
||||
Line-based on purpose: the catalog's CI has NO PyYAML. Only TOP-LEVEL, uncommented keys count; a key inside a comment, a
|
||||
README or a tagline is not the field.
|
||||
|
||||
Run from the repo root: python3 scripts/check-family-gate.py [--root=<catalog>] [--felhom-eu=<sibling>]
|
||||
Exit 0 clean · 1 refused. Decoys: scripts/test_gate_decoys.py (family-gate).
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
MIN_VERSION = (0, 287, 0)
|
||||
LITERAL = re.compile(r"^/[A-Za-z0-9._~/-]*$")
|
||||
TOP_TRUE = re.compile(r"^family_gate:\s*true\s*(#.*)?$")
|
||||
TOP_EXCEPT = re.compile(r"^family_gate_except:\s*(#.*)?$")
|
||||
TOP_MINC = re.compile(r'^min_controller:\s*"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*(#.*)?$')
|
||||
ITEM = re.compile(r'^\s+-\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))\s*(#.*)?$')
|
||||
GOLDEN_DIR = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$")
|
||||
GOLDEN_SHA = re.compile(r"GOLDEN_SHA256=[0-9a-f]{64}")
|
||||
BAKE_LOGS = ("bake.log", "06-bake.log", "bake-clean.log", "06-bake-clean.log")
|
||||
|
||||
|
||||
def arg(name, default):
|
||||
for a in sys.argv[1:]:
|
||||
if a.startswith(name + "="):
|
||||
return a.split("=", 1)[1]
|
||||
return default
|
||||
|
||||
|
||||
def parse(text):
|
||||
"""(family_gate, excepts or None, min_controller tuple or None) from a .felhom.yml text."""
|
||||
gate, excepts, minc = False, None, None
|
||||
lines = text.splitlines()
|
||||
for i, ln in enumerate(lines):
|
||||
if TOP_TRUE.match(ln):
|
||||
gate = True
|
||||
m = TOP_MINC.match(ln)
|
||||
if m:
|
||||
minc = tuple(int(x) for x in m.group(1).split("."))
|
||||
if TOP_EXCEPT.match(ln):
|
||||
excepts = []
|
||||
for nxt in lines[i + 1:]:
|
||||
if not nxt.strip() or nxt.lstrip().startswith("#"):
|
||||
continue
|
||||
mi = ITEM.match(nxt)
|
||||
if not mi:
|
||||
break
|
||||
excepts.append(next(g for g in mi.groups()[:3] if g is not None))
|
||||
return gate, excepts, minc
|
||||
|
||||
|
||||
def literal_ok(p):
|
||||
if not LITERAL.match(p) or "//" in p or "/../" in p or p.endswith("/..") or p.strip("/") == "":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def newest_golden(sibling):
|
||||
tests = os.path.join(sibling, "documentation", "tests")
|
||||
if not os.path.isdir(tests):
|
||||
return None
|
||||
best = None
|
||||
for name in os.listdir(tests):
|
||||
m = GOLDEN_DIR.match(name)
|
||||
if not m:
|
||||
continue
|
||||
d = os.path.join(tests, name)
|
||||
baked = False
|
||||
for log in BAKE_LOGS:
|
||||
p = os.path.join(d, log)
|
||||
if os.path.isfile(p) and GOLDEN_SHA.search(io.open(p, encoding="utf-8", errors="replace").read()):
|
||||
baked = True
|
||||
break
|
||||
if baked:
|
||||
v = tuple(int(x) for x in m.groups())
|
||||
best = v if best is None or v > best else best
|
||||
return best
|
||||
|
||||
|
||||
def main():
|
||||
root = arg("--root", os.getcwd())
|
||||
sibling = arg("--felhom-eu", os.path.join(os.path.dirname(os.path.abspath(root)), "felhom.eu"))
|
||||
tdir = os.path.join(root, "templates")
|
||||
fails, gated, unchecked = [], [], False
|
||||
for app in sorted(os.listdir(tdir)):
|
||||
fy = os.path.join(tdir, app, ".felhom.yml")
|
||||
if not os.path.isfile(fy):
|
||||
continue
|
||||
gate, excepts, minc = parse(io.open(fy, encoding="utf-8").read())
|
||||
if excepts is not None and not gate:
|
||||
fails.append("%s: family_gate_except without family_gate: true — an exception list with no gate" % app)
|
||||
if not gate:
|
||||
continue
|
||||
gated.append(app)
|
||||
for p in excepts or []:
|
||||
if not literal_ok(p):
|
||||
fails.append("%s: family_gate_except %r is not a literal path prefix (the controller anchors "
|
||||
"^/prefix(/|$) and refuses anything else — F1)" % (app, p))
|
||||
if minc is None or minc < MIN_VERSION:
|
||||
fails.append("%s: family_gate needs min_controller: \"%d.%d.%d\" or newer (got %s)"
|
||||
% ((app,) + MIN_VERSION + (minc,)))
|
||||
if gated:
|
||||
g = newest_golden(sibling)
|
||||
if g is None:
|
||||
print("family-gate: rule 3 NOT CHECKED — no felhom.eu sibling with a baked golden at %s" % sibling)
|
||||
unchecked = True
|
||||
elif g < MIN_VERSION:
|
||||
fails.append("family_gate on %s while the newest baked golden is %s — a box installed from it would publish "
|
||||
"the app OPEN; bake a golden >= %d.%d.%d first" % ((", ".join(gated), "%d.%d.%d" % g) + MIN_VERSION))
|
||||
else:
|
||||
print("family-gate: newest baked golden %d.%d.%d >= %d.%d.%d" % (g + MIN_VERSION))
|
||||
for f in fails:
|
||||
print(" REFUSED " + f)
|
||||
if fails:
|
||||
print("family-gate gate: %d refusal(s)" % len(fails))
|
||||
return 1
|
||||
# The summary line carries the gap: an "OK" read alone must not stand for rule 3 when rule 3 was not checked (the
|
||||
# 2026-10-02 bench run read exactly that). Exit stays 0 — CI's single clone can never check it; the hook does.
|
||||
print("family-gate gate OK: %d family-gated template(s) %s%s" % (len(gated), gated,
|
||||
" — rule 3 (golden >= 0.287.0) NOT CHECKED here" if unchecked else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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():
|
||||
|
||||
@@ -407,6 +407,32 @@
|
||||
"deploy_fields[SUBDOMAIN].label": "Aldomain",
|
||||
"description": "Családfa készítő és genealógiai szoftver"
|
||||
},
|
||||
"grimmory": {
|
||||
"app_info.add_people": "A családtagot először a vezérlőpult Beállítások, Biztonság oldalán, a Család kártyán add hozzá; utána a Grimmory Beállítások, Felhasználók oldalán hozd létre a fiókját.",
|
||||
"app_info.first_steps[0]": "Nyisd meg a library.DOMAIN címet, és hozd létre az admin fiókodat",
|
||||
"app_info.first_steps[1]": "A családtagjaidat a vezérlőpult Beállítások, Biztonság oldalán, a Család kártyán add hozzá - idegen el sem éri a Grimmoryt",
|
||||
"app_info.first_steps[2]": "Hozz létre egy könyvtárat a /books mappával - ez a meghajtódon a userdata/media/grimmory mappa",
|
||||
"app_info.first_steps[3]": "Töltsd fel a könyveidet, vagy másold őket a „Beolvasandó e-könyvek (Grimmory)\" mappába",
|
||||
"app_info.first_steps[4]": "Az e-könyv-olvasódhoz kapcsold be az OPDS-t a Beállításokban, és hozz létre OPDS-felhasználót",
|
||||
"app_info.first_steps[5]": "A könyvadatokat a Grimmory külső szolgáltatóktól kéri le (Google Books, Open Library); a Kobo-szinkron a Kobo áruházán át is megy",
|
||||
"app_info.tagline": "E-könyvtár - olvasd böngészőben, Kobón, KOReaderrel vagy bármely OPDS-olvasóval",
|
||||
"app_info.use_cases[0]": "E-könyvek és képregények rendezett könyvtárban, borítóval és adatokkal",
|
||||
"app_info.use_cases[1]": "Olvasás a böngészőben, haladás mentése; Kobo- és KOReader-szinkron",
|
||||
"app_info.use_cases[2]": "OPDS-katalógus az e-könyv-olvasó alkalmazásoknak (a Beállításokban kapcsold be)",
|
||||
"app_info.use_cases[3]": "A Calibre-Web helyett vagy mellett: ez modernebb felület, a Calibre-Web a Calibre-könyvtárakhoz jó",
|
||||
"data_paths[grimmory].label": "Beolvasandó e-könyvek (Grimmory)",
|
||||
"data_paths[media/grimmory].label": "E-könyvtár (Grimmory)",
|
||||
"deploy_fields[DB_PASSWORD].label": "Adatbázis jelszó",
|
||||
"deploy_fields[DB_ROOT_PASSWORD].label": "Adatbázis root jelszó",
|
||||
"deploy_fields[DOMAIN].description": "A szerver domain neve",
|
||||
"deploy_fields[DOMAIN].label": "Domain",
|
||||
"deploy_fields[HDD_PATH].description": "A meghajtó, amelyen a könyveid lesznek",
|
||||
"deploy_fields[HDD_PATH].label": "E-könyvtár meghajtója",
|
||||
"deploy_fields[HDD_PATH].placeholder": "/mnt/felhom-drives/hdd_1",
|
||||
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
|
||||
"deploy_fields[SUBDOMAIN].label": "Aldomain",
|
||||
"description": "E-könyvtár böngészőben olvasóval, OPDS-sel, Kobo és KOReader szinkronnal"
|
||||
},
|
||||
"home-assistant": {
|
||||
"app_info.first_steps[0]": "Nyisd meg a ha.DOMAIN címet a böngészőben",
|
||||
"app_info.first_steps[1]": "Hozd létre a tulajdonos fiókot az onboarding során",
|
||||
@@ -600,6 +626,27 @@
|
||||
"deploy_fields[SUBDOMAIN].label": "Aldomain",
|
||||
"description": "Receptkezelő és étkezéstervező"
|
||||
},
|
||||
"metube": {
|
||||
"app_info.add_people": "A családtagokat a Beállítások, Biztonság oldal Család kártyáján adod hozzá; mindenki a saját nevével és jelszavával lép be.",
|
||||
"app_info.first_steps[0]": "Add hozzá a családtagjaidat a Beállítások, Biztonság oldal Család kártyáján",
|
||||
"app_info.first_steps[1]": "Nyisd meg a video.DOMAIN címet, és lépj be a családi neveddel",
|
||||
"app_info.first_steps[2]": "Illessz be egy linket, válaszd ki a minőséget, és indítsd el a letöltést",
|
||||
"app_info.first_steps[3]": "Csak saját használatra: csak olyan videót tölts le, amelyhez jogod van (például a sajátodat vagy szabad felhasználásút). A letöltés a háztartásod internetcíméről történik.",
|
||||
"app_info.prerequisites[0]": "A letöltés a háztartásod internetkapcsolatát használja; egy videószolgáltató ritkán korlátozhatja a sok letöltést egy címről",
|
||||
"app_info.tagline": "Videók és hanganyagok letöltése egy linkből - csak a családodnak",
|
||||
"app_info.use_cases[0]": "Egy videó vagy lejátszási lista letöltése a linkjéből, videóként vagy csak hangként",
|
||||
"app_info.use_cases[1]": "A letöltések a meghajtódra kerülnek, a fájlböngészőben és a médialejátszódban is látod őket",
|
||||
"app_info.use_cases[2]": "Csak a családtagjaid érik el: idegen nem tud letölteni a háztartásod internetcíméről",
|
||||
"data_paths[media/metube].label": "Letöltött videók (MeTube)",
|
||||
"deploy_fields[DOMAIN].description": "A szerver domain neve",
|
||||
"deploy_fields[DOMAIN].label": "Domain",
|
||||
"deploy_fields[HDD_PATH].description": "A meghajtó, amelyre a letöltések kerülnek",
|
||||
"deploy_fields[HDD_PATH].label": "Letöltések meghajtója",
|
||||
"deploy_fields[HDD_PATH].placeholder": "/mnt/felhom-drives/hdd_1",
|
||||
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
|
||||
"deploy_fields[SUBDOMAIN].label": "Aldomain",
|
||||
"description": "Videók és hanganyagok letöltése a meghajtódra, a család számára"
|
||||
},
|
||||
"n8n": {
|
||||
"app_info.first_steps[0]": "Nyisd meg az auto.DOMAIN címet a böngészőben",
|
||||
"app_info.first_steps[1]": "Hozd létre az admin fiókot",
|
||||
@@ -1229,7 +1276,9 @@
|
||||
},
|
||||
"reasons": {
|
||||
"dawarich": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules",
|
||||
"grimmory": "new app 2026-10-02 (family gate); Hungarian reviewed: informal te, no kerjuk (ASCII scan with a positive control)",
|
||||
"karakeep": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules",
|
||||
"metube": "new app 2026-10-02 (family gate); Hungarian reviewed: informal te, no kerjuk (ASCII scan with a positive control)",
|
||||
"radicale": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,5 +463,21 @@ class TestEnvBuilding(unittest.TestCase):
|
||||
self.assertEqual([f["env_var"] for f in cvp.parse_deploy_fields(felhom)], ["A"])
|
||||
|
||||
|
||||
|
||||
class TestRoutedPorts(unittest.TestCase):
|
||||
def test_routed_ports_reads_the_mapping_form(self):
|
||||
"""R-801: `compose config --format json` gives labels as a mapping; the port is in the KEY."""
|
||||
resolved = {"services": {"metube": {"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.services.metube.loadbalancer.server.port": "8081"}}}}
|
||||
self.assertEqual(cvp.routed_ports(resolved), [8081])
|
||||
|
||||
def test_routed_ports_list_form_still_read(self):
|
||||
resolved = {"services": {"a": {"labels": ["traefik.http.services.a.loadbalancer.server.port=3000"]}}}
|
||||
self.assertEqual(cvp.routed_ports(resolved), [3000])
|
||||
|
||||
def test_no_routed_port(self):
|
||||
self.assertEqual(cvp.routed_ports({"services": {"db": {"labels": {"x": "y"}}}}), [])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -55,6 +55,7 @@ COVERS = {
|
||||
"test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)",
|
||||
"test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)",
|
||||
"probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)",
|
||||
"family-gate": "family_gate written only in a COMMENT (not gated, no min_controller owed); min_controller only in a comment; a golden DIRECTORY named 0.287.0 with no bake log (the mkdir shape, R-410); a sibling with no golden (stated NOT CHECKED, never a pass of rule 3) - vs the facts: an unanchorable exception (regex, '/', '..'), an exception list with no gate, min_controller below 0.287.0, the newest baked golden below 0.287.0; and a genuine family app passes (decisions 63/64, finding F1)",
|
||||
"onboarding": "a NEW template with no record; a record missing an id, or carrying it only inside an HTML comment; a `done` whose path does not exist, is an EMPTY directory (the mkdir shape, R-410) or names an absent sibling-repo file; an `n/a` with an empty or two-word reason; an `open` row; `opened:` backdated before the checklist; the template a new app copies lacking a new id - vs a complete record, an id added after `opened:`, and an exempt app's record with open rows (NEW-APP-CHECKLIST.md)",
|
||||
"copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
|
||||
}
|
||||
@@ -618,6 +619,52 @@ def onboarding_cases():
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
def family_gate_cases():
|
||||
"""check-family-gate.py reads FILES: templates/*/.felhom.yml and the sibling felhom.eu's golden bake records."""
|
||||
global ran
|
||||
import tempfile
|
||||
ws = tempfile.mkdtemp(prefix="catalog-familygate-")
|
||||
try:
|
||||
cat, sib = os.path.join(ws, "cat"), os.path.join(ws, "felhom.eu")
|
||||
def golden(ver, baked=True):
|
||||
d = os.path.join(sib, "documentation", "tests", "golden-%s-2026-10-02" % ver)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
if baked:
|
||||
io.open(os.path.join(d, "bake.log"), "w").write("GOLDEN_SHA256=" + "a" * 64 + "\n")
|
||||
def app(body, name="famapp"):
|
||||
d = os.path.join(cat, "templates", name)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
io.open(os.path.join(d, ".felhom.yml"), "w").write("display_name: X\n" + body)
|
||||
GOOD = 'family_gate: true\nfamily_gate_except:\n - "/api/v1/opds" # e-readers\n - "/api/kobo/"\nmin_controller: "0.287.0"\n'
|
||||
def run(name, setup, expect_rc, must=(), sibling=True):
|
||||
global ran
|
||||
shutil.rmtree(cat, ignore_errors=True); shutil.rmtree(sib, ignore_errors=True)
|
||||
os.makedirs(os.path.join(cat, "templates"))
|
||||
setup()
|
||||
args = [sys.executable, os.path.join(ROOT, "scripts", "check-family-gate.py"), "--root=" + cat,
|
||||
"--felhom-eu=" + (sib if sibling else os.path.join(ws, "absent"))]
|
||||
r = sh(args, ROOT); out = r.stdout + r.stderr; ran += 1
|
||||
ok = r.returncode == expect_rc and all(m in out for m in must)
|
||||
print(" %s %-70s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc))
|
||||
if not ok:
|
||||
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (name, r.returncode, expect_rc, [m for m in must if m not in out], out[-400:]))
|
||||
print("\n-- family-gate: genuine and decoys")
|
||||
run("GENUINE: a family app, literal exceptions, min 0.287.0, golden 0.287.0", lambda: (app(GOOD), golden("0.287.0")), 0, ("family-gate gate OK",))
|
||||
run("DECOY: family_gate only in a COMMENT -> not gated, nothing owed", lambda: (app("# family_gate: true\n"), golden("0.286.1")), 0, ("0 family-gated",))
|
||||
run("DECOY: no sibling -> rule 3 stated NOT CHECKED", lambda: app(GOOD), 0, ("NOT CHECKED",), sibling=False)
|
||||
print("-- family-gate: the facts (each MUST be refused)")
|
||||
run("FACT: an exception that is a regex", lambda: (app(GOOD.replace('"/api/kobo/"', '"/api/(.*)"')), golden("0.287.0")), 1, ("not a literal path prefix",))
|
||||
run("FACT: the exception '/' (the whole app)", lambda: (app(GOOD.replace('"/api/kobo/"', '"/"')), golden("0.287.0")), 1, ("not a literal",))
|
||||
run("FACT: an exception with '..'", lambda: (app(GOOD.replace('"/api/kobo/"', '"/api/../admin"')), golden("0.287.0")), 1, ("not a literal",))
|
||||
run("FACT: an exception list with no gate", lambda: (app('family_gate_except:\n - "/x"\n'), golden("0.287.0")), 1, ("with no gate",))
|
||||
run("FACT: min_controller only in a comment", lambda: (app(GOOD.replace('min_controller: "0.287.0"', '# min_controller: "0.287.0"')), golden("0.287.0")), 1, ("needs min_controller",))
|
||||
run("FACT: min_controller below the release", lambda: (app(GOOD.replace('0.287.0', '0.286.1')), golden("0.287.0")), 1, ("needs min_controller",))
|
||||
run("FACT: newest baked golden below the release", lambda: (app(GOOD), golden("0.286.1")), 1, ("publish the app OPEN",))
|
||||
run("FACT: a golden DIRECTORY 0.287.0 with no bake log (a name is not a bake)", lambda: (app(GOOD), golden("0.286.1"), golden("0.287.0", baked=False)), 1, ("0.286.1",))
|
||||
finally:
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
|
||||
if not os.path.isfile(gate):
|
||||
@@ -1095,6 +1142,7 @@ i18n:
|
||||
shutil.rmtree(clone, ignore_errors=True)
|
||||
|
||||
onboarding_cases()
|
||||
family_gate_cases()
|
||||
|
||||
if fails:
|
||||
print()
|
||||
|
||||
@@ -2159,12 +2159,66 @@ class Grimmory:
|
||||
return found
|
||||
|
||||
|
||||
class MeTube:
|
||||
"""MeTube (2026-10-02, new app through NEW-APP-CHECKLIST.md, published behind the family gate). THE FRONT DOOR is its
|
||||
own web API, the one its page calls: `POST /add` a link, then `GET /history` until the item is `finished`; the file is
|
||||
served back at `GET /download/<filename>`. The link is a 1 MB public test video (yt-dlp's generic extractor — no
|
||||
video-service account, no cookies). Negative control on every readback: a file never downloaded answers 404, so a
|
||||
read that says "found" cannot be a catch-all. On the box the family gate is passed as the household (box_walk)."""
|
||||
sub = "video"
|
||||
URL = "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4"
|
||||
|
||||
def _hist(self, w, sub):
|
||||
rc, code, out = w.app_curl(sub, "/history")
|
||||
try:
|
||||
return code, json.loads(out)
|
||||
except Exception:
|
||||
return code, {}
|
||||
|
||||
def seed(self, w, sub, say):
|
||||
if not w.wait_app(sub, "/", want=("200",), tries=60):
|
||||
self.tried = "/ never answered 200"
|
||||
return None
|
||||
rc, code, out = w.app_curl(sub, "/add", "-H", "Content-Type: application/json", "-H", f"Origin: https://{sub}.{w.DOMAIN}",
|
||||
data=json.dumps({"url": self.URL, "quality": "best", "format": "any", "auto_start": True}), method="POST")
|
||||
say(f" metube: POST /add http={code} {(out or '')[:40]}")
|
||||
if code != "200":
|
||||
self.tried = f"add -> {code} {(out or '')[:80]}"
|
||||
return None
|
||||
for _ in range(60):
|
||||
code, h = self._hist(w, sub)
|
||||
done = [d for d in h.get("done", []) if d.get("url") == self.URL and d.get("status") == "finished"]
|
||||
if done:
|
||||
fn = done[0].get("filename") or (done[0].get("title", "") + ".mp4")
|
||||
say(f" metube: the download finished ({done[0].get('size')} bytes)")
|
||||
return {"filename": fn, "size": done[0].get("size")}
|
||||
time.sleep(3)
|
||||
self.tried = "the download never finished"
|
||||
return None
|
||||
|
||||
def verify(self, w, sub, t, say):
|
||||
if not w.wait_app(sub, "/", want=("200",), tries=60):
|
||||
say(" metube: / never answered")
|
||||
return False
|
||||
import urllib.parse
|
||||
rc, c_absent, _ = w.app_curl(sub, "/download/never-" + secrets.token_hex(4) + ".mp4")
|
||||
if c_absent != "404":
|
||||
say(f" metube: READBACK UNUSABLE — a file never downloaded answered {c_absent}")
|
||||
return False
|
||||
code, h = self._hist(w, sub)
|
||||
in_hist = any(d.get("url") == self.URL and d.get("status") == "finished" for d in h.get("done", []))
|
||||
rc, code, out = w.app_curl(sub, "/download/" + urllib.parse.quote(t["filename"]), "-o", "/dev/null")
|
||||
say(f" metube: history has it={in_hist}; GET /download/<file> http={code}")
|
||||
return code == "200" and in_hist
|
||||
|
||||
|
||||
FIXTURES = {
|
||||
"uptime-kuma": UptimeKuma(),
|
||||
"crafty-controller": Crafty(),
|
||||
"wger": Wger(),
|
||||
"calibre-web": CalibreWeb(),
|
||||
"sparkyfitness": Sparkyfitness(),
|
||||
"metube": MeTube(),
|
||||
"rallly": Rallly(),
|
||||
"outline": Outline(),
|
||||
"home-assistant": HomeAssistant(),
|
||||
|
||||
Reference in New Issue
Block a user