burn-down round 2: R-593 papra field copy, R-760 vikunja healthcheck reason, R-594 English allow-list, R-605 refusal exit 3, R-781 onboarding decoy clone, R-806 scheme; stale runner test fixed
gates / gates (push) Successful in 4s

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-05 19:15:02 +02:00
parent 29ac711d26
commit 4828dc754d
16 changed files with 536 additions and 50 deletions
+24
View File
@@ -1,3 +1,27 @@
## 2026-10-05 — burn-down round 2: copy, gate and harness fixes (R-760, R-593, R-594, R-605, R-781, R-806) + a stale runner test
No image, version, ladder, environment or healthcheck that runs on a box changed.
- **R-593:** papra's deploy fields — „Az alkalmazás aldomainje" moves to SUBDOMAIN (it sat one field too low);
AUTH_SECRET gets its own sentence; English for both. Freeze re-captured; the English-missing ceiling 1 → 0.
- **R-760:** vikunja's compose says WHY it has no healthcheck (the image ships no shell; the box's own probe dials
`/api/v1/info`). `scripts/test_healthcheck_explained.py`: every service without a compose healthcheck carries the
reason (red-proof convicts).
- **R-594:** `check-copy-i18n.py` takes an English allow-list (`scripts/copy_freeze/allowlist_en.json`: app, path,
match, reason); unregistered convicts, a stale or reasonless entry convicts; 5 decoys.
- **R-605:** a harness that REFUSES to run exits 3 (separate from 2 = ran, undecided); the runner prints
DID-NOT-RUN. Decoys both ways.
- **R-781:** the onboarding decoys run against a scratch clone with the real onboarding records removed.
- **R-806:** the persistence harness's GET uses the traefik `scheme` label (https + `-k` for crafty-controller).
The gramps-web :5000 non-answer is a live look, still open.
- **Stale test fixed (found here, not filed):** `test_catalog_gates.py` expected 11 gates (12 since family-gate) and was
red on main; `test_catalog_since_ran_under_fast` sat after `if __name__`, outside any class, so it never ran — now
inside `CatalogGatesFastTest`, 11 tests pass.
Red-proofs: `felhom.eu/documentation/audits/burndown2-2026-10-05/catalog-red-proofs.txt`. **Not done:** R-469 (a
`CLAUDE.md` rewording) and the `CLAUDE.md` exit-code list for R-605's new 3 — the session's permission check refused
editing this repo's instruction file; the operator is asked.
## 2026-10-05 — burn-down: three small fixes (R-799, R-761, R-391)
- **R-799:** the MeTube fixture's `POST /add` sends `download_type: video` (upstream lists it as required);
+6 -7
View File
@@ -1,10 +1,9 @@
# REPORT — 2026-10-05 burn-down: three small fixes
# REPORT — 2026-10-05 burn-down round 2 (catalog)
Full session report: `felhom.eu/REPORT-burndown-2026-10-05.md`. Baseline `7a19491`.
Full session report: `felhom.eu/REPORT-burndown2-2026-10-05.md`. Baseline `29ac711`.
- R-799 — MeTube fixture `POST /add` body has `download_type`; `scripts/test_upgrade_fixtures_metube.py` (2 tests; red-proof
convicts). Not run against a live box (the fixture's next MeTube step will).
- R-761 — logo name in the template comment, `REUSE.md`, `NEW-APP-CHECKLIST.md`: `.svg` then `.png`.
- R-391 — `CLAUDE.md`: no observations section here, by convention.
Fixed: R-593, R-760, R-594, R-605, R-781, R-806 (scheme half), plus the stale `test_catalog_gates.py`. Tests and red-proofs:
`felhom.eu/documentation/audits/burndown2-2026-10-05/catalog-red-proofs.txt`. `catalog_gates.py --fast` green;
`test_catalog_gates.py` 11/11.
`catalog_gates.py --fast paperless-ngx` green. No template version, image or ladder moved.
Not done: R-469 and the R-605 exit-code line in `CLAUDE.md` — a permission check refused the instruction-file edit; asked.
+17 -4
View File
@@ -51,7 +51,7 @@ mandated in `CLAUDE.md` the way `site_gates.py` is.
**R-161 stays OPEN at reduced scope:** this is convention, run by a person. Real automatic
enforcement is owed when a second person touches templates.
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits **non-zero if
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive (3 = the harness refused to run, R-605). This runner exits **non-zero if
any gate is non-zero**, and reports 2 distinctly as INCONCLUSIVE — an undetermined result is never a
pass (an app that wrote nothing has not been shown correct; a throttled registry has not shown an
image alive), but it is also not a conviction, and the operator reading the summary needs to know
@@ -119,7 +119,9 @@ GATES = [
("family-gate", "check-family-gate.py", False, True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
# 3 (R-605): the gate's HARNESS refused to run — its canary failed or it had nothing to judge — so NO app was
# evaluated. It used to share 2's label, and the 2026-09-17 chaos night could not tell afterwards which had happened.
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE", 3: "DID-NOT-RUN"}
def run_gate(label, script, args):
@@ -195,6 +197,13 @@ def main(argv):
args.append("--range=" + rng)
results.append((label, run_gate(label, script, args)))
return summarise(results)
def summarise(results):
"""Print the summary for [(label, rc)] and return the runner's exit: 0 all OK, 1 any conviction, else 2. A gate
whose harness refused (3) is named apart from one that ran and could not decide (2) — R-605, pinned by
test_catalog_gates.py."""
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
@@ -208,11 +217,15 @@ def main(argv):
print("\nall catalog gates OK")
return 0
convicted = [l for l, rc in results if rc == 1]
undecided = [l for l, rc in results if rc not in (0, 1)]
refused = [l for l, rc in results if rc == 3]
undecided = [l for l, rc in results if rc not in (0, 1, 3)]
if convicted:
print("\nCONVICTED: %s" % ", ".join(convicted))
if undecided:
print("UNDETERMINED (never a pass): %s" % ", ".join(undecided))
print("UNDETERMINED (it ran; some results could not be decided — never a pass): %s" % ", ".join(undecided))
if refused:
print("DID NOT RUN (the harness refused — its canary failed or it had nothing to judge; NO app was "
"evaluated — never a pass): %s" % ", ".join(refused))
return worst
+64 -12
View File
@@ -52,10 +52,11 @@ def use_root(path):
"""Point the gate at another checkout. `scripts/test_gate_decoys.py` runs THIS script against a
scratch clone; without this it would read the real tree and judge the wrong files — the
"constant-for-measurement" decoy shape, committed by the gate itself."""
global ROOT, TEMPLATES, FREEZE_PATH
global ROOT, TEMPLATES, FREEZE_PATH, ALLOWLIST_EN_PATH
ROOT = os.path.abspath(path)
TEMPLATES = os.path.join(ROOT, "templates")
FREEZE_PATH = os.path.join(ROOT, "scripts", "copy_freeze", "hu.json")
ALLOWLIST_EN_PATH = os.path.join(ROOT, "scripts", "copy_freeze", "allowlist_en.json")
SUPPORTED_LANGS = ("en",)
@@ -69,12 +70,12 @@ SUPPORTED_LANGS = ("en",)
# Measured on 94bc5febaca2, before any translation: 1 032 copy strings, none with English.
# 1032 → 943 (pilot) → 624 (batch 1) → 307 (batch 2) → 1 (batch 3, 16 apps) — ALL 53 APPS DONE
#
# THE FLOOR IS 1, NOT 0, UNTIL R-593 IS FIXED. papra's `deploy_fields[AUTH_SECRET].description`
# is a Hungarian DEFECT — it describes a session-signing key as „the app's subdomain". A
# localisation release may not change Hungarian bytes, and translating a wrong sentence faithfully
# would ship the error in a second language, so that ONE field is deliberately left untranslated
# and falls back to the Hungarian. Fixing R-593 is what lets this reach 0.
EN_MISSING_CEILING = 1
# 1 → 0 with R-593 (2026-10-05): papra's `deploy_fields[AUTH_SECRET].description` was a Hungarian
# DEFECT — it described a session-signing key as „the app's subdomain" (SUBDOMAIN's sentence, one
# field too low). It was held untranslated rather than shipping the error in a second language; the
# Hungarian was rewritten deliberately (papra re-captured in the freeze with the reason) and both
# fields now carry English. EVERY copy string in the catalog has English.
EN_MISSING_CEILING = 0
# ── What counts as COPY ──────────────────────────────────────────────────────────────────────────
#
@@ -403,6 +404,39 @@ def freeze_only_check(freeze, apps, fails):
% (app, path, val))
# ── ALLOWLIST_EN — a TRUE retrieval promise, registered (R-594) ───────────────────────────────────
#
# The retrieval stems are NOT banned (customer_copy_vocab.py: "an occurrence must be REGISTERED with a reason in the
# consuming gate's allowlist"). Without a register the only ways past a conviction were to reword or to bypass the
# gate, and a catalog app whose English honestly says a file can be restored (a backup app, a versioned document
# store) had no third option. An entry is {app, path, match, reason}: `path` is the i18n.en copy path the gate prints,
# `match` a substring of the English sentence it registers (a rewritten sentence must be registered again), `reason`
# why the promise is TRUE (at least four words). An entry that matches no conviction is itself a failure (STALE —
# R-299's shape). Kept as JSON beside the freeze so `--root` (the decoy harness) reads the clone's own register.
ALLOWLIST_EN_PATH = os.path.join(ROOT, "scripts", "copy_freeze", "allowlist_en.json")
def read_allowlist_en(fails):
"""{(app, path): (match, reason)} from ALLOWLIST_EN_PATH; an absent file is an empty register."""
if not os.path.exists(ALLOWLIST_EN_PATH):
return {}
with io.open(ALLOWLIST_EN_PATH, encoding="utf-8") as fh:
data = json.load(fh)
out = {}
for e in data.get("entries") or []:
key = (e.get("app") or "", e.get("path") or "")
match, reason = e.get("match") or "", (e.get("reason") or "").strip()
if not all(key) or not match:
fails.append("ALLOWLIST_EN: an entry without app/path/match: %r" % (e,))
continue
if len(reason.split()) < 4:
fails.append("ALLOWLIST_EN: %s / %s has no reason worth the name (%r) — say why the promise is TRUE"
% (key[0], key[1], reason))
continue
out[key] = (match, reason)
return out
def app_dirs():
return sorted(d for d in os.listdir(TEMPLATES)
if os.path.isdir(os.path.join(TEMPLATES, d))
@@ -439,7 +473,7 @@ def capture(reasons=None):
# ── The checks ───────────────────────────────────────────────────────────────────────────────────
def check_language(app, path, val, hu_val, display_name, fails):
def check_language(app, path, val, hu_val, display_name, fails, allow=None, seen=None):
where = "%s / i18n.en.%s" % (app, path)
if HU_LETTER.search(val):
fails.append("%s: an accented Hungarian letter in the English text: %r" % (where, val))
@@ -456,8 +490,13 @@ def check_language(app, path, val, hu_val, display_name, fails):
for pat in RETRIEVAL_STEMS_EN:
if re.search(pat, val, re.I):
hu_promises = any(s in fold(hu_val) for s in [fold(x) for x in RETRIEVAL_STEMS_HU])
if not hu_promises:
fails.append("%s: an English retrieval promise the Hungarian does not make: %r"
reg = (allow or {}).get((app, path))
if not hu_promises and reg and reg[0] in val:
if seen is not None:
seen.add((app, path)) # REGISTERED as true (R-594) — passes, and the entry is live
elif not hu_promises:
fails.append("%s: an English retrieval promise the Hungarian does not make: %r — reword it, or, "
"if it is TRUE, register it in scripts/copy_freeze/allowlist_en.json with the reason"
% (where, val))
break
if "Felhom" in hu_val and "Felhom" not in val:
@@ -562,7 +601,8 @@ def main(argv):
if not have_yaml():
print("copy-i18n: DEGRADED - PyYAML is absent on this machine, so only the HUNGARIAN\n"
" FREEZE runs (%d apps). NOT checked here: the English block's\n"
" structure, its language, its credential tokens, and the coverage\n"
" structure, its language (and the ALLOWLIST_EN register), its\n"
" credential tokens, and the coverage\n"
" ratchet - those run in the pre-push hook, which is where they\n"
" refuse a push anyway." % len(apps))
freeze_only_check(freeze, apps, fails)
@@ -576,6 +616,8 @@ def main(argv):
en_missing_total = 0
per_app = []
allow = read_allowlist_en(fails)
allow_seen = set()
for app in apps:
meta = load_yaml(os.path.join(TEMPLATES, app, ".felhom.yml"))
@@ -627,7 +669,7 @@ def main(argv):
if scope and app not in scope:
continue
check_language(app, path, val, hu.get(path, ""),
meta.get("display_name") or "", fails)
meta.get("display_name") or "", fails, allow, allow_seen)
missing = [p for p in hu if p not in en_paths]
en_missing_total += len(missing)
@@ -641,6 +683,16 @@ def main(argv):
print("copy-i18n: translated so far — " +
", ".join("%s %d/%d" % (a, d, t) for a, d, t in done))
# ALLOWLIST_EN is live or it is a failure (R-594): an entry no conviction used is STALE. Judged only for apps whose
# language checks ran — a scoped run did not look at the others, so it cannot call their entries unused.
for key in sorted(allow):
if key not in allow_seen and (not scope or key[0] in scope):
fails.append("ALLOWLIST_EN: STALE entry %s / %s (match %r) — no English retrieval promise uses it any more; "
"remove it" % (key[0], key[1], allow[key][0]))
if allow:
print("copy-i18n: %d registered retrieval promise(s) in ALLOWLIST_EN, %d used"
% (len(allow), len(allow_seen)))
# CHECK 5 — the ratchet.
if en_missing_total != ceiling:
direction = "ABOVE" if en_missing_total > ceiling else "BELOW"
+12 -3
View File
@@ -9,7 +9,8 @@ perfectly well-formed and pointed at nothing (Campaign 7, §6.2). **Silent rot i
this repo, so nothing in a change-triggered gate would ever notice.
This resolves each unique `image:` pin against its registry with
`docker manifest inspect <ref>` and exits non-zero listing everything that did not resolve.
`docker manifest inspect <ref>` and exits non-zero listing everything that did not resolve
(1 GONE · 2 some pins could not be checked · 3 the harness REFUSED to run, nothing judged — R-605).
python3 scripts/check-image-resolvable.py # every AVAILABLE app
python3 scripts/check-image-resolvable.py --all # include hidden/abandoned apps too
@@ -159,6 +160,12 @@ def check_images(sites: dict[str, list[str]], resolver) -> tuple[list[str], list
return absent, inconclusive
# R-605: "the harness REFUSED to run" (its canary failed, or it found nothing to judge) is exit 3, distinct from exit 2
# "it ran and some pins could not be decided" (a throttle). Both are never a pass; catalog_gates.py prints them apart,
# so a summary can say whether the gate ran at all. Pinned by test_check_image_resolvable.py.
HARNESS_REFUSED = 3
def check(root: Path, only: list[str] | None = None, resolver=docker_resolver,
include_unavailable: bool = False) -> int:
sites, skipped = collect_images(root, only, include_unavailable)
@@ -172,7 +179,8 @@ def check(root: Path, only: list[str] | None = None, resolver=docker_resolver,
print("nothing to check — every app in scope is out of circulation")
return 0
print(f"ERROR: no images found under {root}/templates/", file=sys.stderr)
return 2
print("HARNESS REFUSED — nothing was judged")
return HARNESS_REFUSED
# Self-test the resolver before trusting a green result: if it says a ref that CANNOT exist
# resolves, it is broken (or something is intercepting the registry) and a clean run would be a
@@ -180,7 +188,8 @@ def check(root: Path, only: list[str] | None = None, resolver=docker_resolver,
if resolver(CANARY_REF)[0] == OK:
print(f"ERROR: resolver returned success for {CANARY_REF} — it is not trustworthy; "
"refusing to report a result", file=sys.stderr)
return 2
print("HARNESS REFUSED — the resolver failed its canary; nothing was judged")
return HARNESS_REFUSED
print(f"resolving {len(sites)} unique image pin(s)…")
absent, inconclusive = check_images(sites, resolver)
+59 -11
View File
@@ -22,7 +22,8 @@ when someone needs it.
python3 scripts/check-volume-persistence.py papra … # only these app dirs
Exit codes: 0 every app in scope CLEAN · 1 at least one BROKEN (the gate REFUSES) ·
2 nothing could be decided / the prober failed its own self-test.
2 some app(s) UNDETERMINED · 3 the harness REFUSED to run — the prober failed its own
self-test, or there was nothing to judge; no app was evaluated (R-605).
Requires Docker, network, and several minutes per app, so it is a PERIODIC gate like
`check-image-resolvable.py` — run it when a template's `volumes:` block or image tag changes, and
@@ -61,6 +62,10 @@ LIFECYCLE_RE = re.compile(r"""^lifecycle:\s*["']?([a-z]+)""", re.MULTILINE)
VAR_RE = re.compile(r"\$\{([A-Z0-9_]+)\}")
DIFF_RE = re.compile(r"^([ACD])\s+(.*)$")
PORT_RE = re.compile(r"loadbalancer\.server\.port=(\d+)")
# R-806: a backend that speaks HTTPS says so to traefik (crafty-controller :8443). The traefik SERVICE name ties a
# scheme to its port; the same label shape upgrade_boxport.LB_SCHEME_RE reads for the bench.
LB_PORT_NAMED_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.port=(\d+)")
LB_SCHEME_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.scheme=(https?)\s*$")
# Where the controller resolves the felhom path variables to at deploy time
# (felhom-controller `internal/stacks/deploy.go:567-582`). Any host path here is scratch.
@@ -564,7 +569,20 @@ PATHS_DEEP = ("/", "/login", "/setup", "/signup", "/register", "/install", "/adm
"/api/health", "/health", "/healthz", "/status", "/web", "/index.php", "/dashboard")
def _exercise(cids, ports, deep=False):
def exercise_argv(ip, port, path, scheme="http", deep=False):
"""The curl argv for one exercise request. R-806: an HTTPS backend is spoken to in HTTPS (`-k`: its certificate
is self-signed and the point is to reach application code, not to judge the certificate) — plain http to
crafty-controller's :8443 got no application answer, so the app reached data only through its fixture seed.
Pinned by TestRoutedSchemes."""
a = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "20"]
if scheme == "https":
a.append("-k")
if deep:
a += ["-L", "--max-redirs", "5"]
return a + [f"{scheme}://{ip}:{port}{path}"]
def _exercise(cids, ports, deep=False, schemes=None):
"""Minimum exercise: an HTTP request the app's OWN router answers.
A container that has only started may have written nothing, and health-check-passing is not
@@ -579,13 +597,11 @@ def _exercise(cids, ports, deep=False):
if not ip:
continue
for port in ports:
scheme = (schemes or {}).get(port, "http")
for path in (PATHS_DEEP if deep else PATHS_FIRST):
a = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "20"]
if deep:
a += ["-L", "--max-redirs", "5"]
code = _sh(a + [f"http://{ip}:{port}{path}"], timeout=40).stdout.strip()
code = _sh(exercise_argv(ip, port, path, scheme, deep), timeout=40).stdout.strip()
if code and code != "000":
hits.append(f"{ip}:{port}{path} -> {code}")
hits.append(f"{scheme}://{ip}:{port}{path} -> {code}")
if not deep:
break
return hits
@@ -609,6 +625,29 @@ def routed_ports(resolved):
return sorted(out)
def routed_schemes(resolved):
"""{port: scheme} for every routed port — `https` only where the SAME traefik service that names the port also
carries `loadbalancer.server.scheme=https`; every other port is `http` (traefik's own default). R-806."""
out = {}
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]
ports, schemes = {}, {}
for lbl in items:
m = LB_PORT_NAMED_RE.search(lbl)
if m:
ports[m.group(1)] = int(m.group(2))
m = LB_SCHEME_RE.search(lbl)
if m:
schemes[m.group(1)] = m.group(2)
for name, port in ports.items():
if schemes.get(name) == "https" or out.get(port) == "https":
out[port] = "https"
else:
out[port] = "http"
return out
def _container_ip(name):
info = _inspect(name) or {}
for net in ((info.get("NetworkSettings") or {}).get("Networks") or {}).values():
@@ -687,6 +726,7 @@ def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) ->
f"{(cfg.stderr or cfg.stdout)[:300]}"}
declared = set((resolved.get("volumes") or {}).keys())
ports = routed_ports(resolved)
schemes = routed_schemes(resolved)
up = _sh(base + ["up", "-d"], timeout=1800)
cids = [c for c in _sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c]
@@ -708,7 +748,7 @@ 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 []
hits = _exercise(running, ports, schemes=schemes) if (running and ports) else []
time.sleep(settle)
def observe():
@@ -761,7 +801,7 @@ def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) ->
# Second chance before declaring the question unanswerable: walk a wider path list
# following redirects, so a first-run wizard is actually reached.
if nothing_written(containers) and running and ports:
hits += _exercise(running, ports, deep=True)
hits += _exercise(running, ports, deep=True, schemes=schemes)
time.sleep(90)
containers = observe()
# Third: the app's own seed (its upgrade fixture), when it still wrote nothing or a declared volume is still
@@ -889,6 +929,12 @@ def collect_apps(root: Path, only=None, include_unavailable=False):
return apps, skipped
# R-605: "the harness REFUSED to run" (the prober failed its own canary, or there was nothing to judge) is exit 3,
# distinct from exit 2 "it ran and some apps were UNDETERMINED". Both are never a pass; catalog_gates.py prints them
# apart, so a summary can say whether the gate ran at all. Pinned by test_check_volume_persistence.py.
HARNESS_REFUSED = 3
def check(root: Path, only=None, prober=docker_prober, include_unavailable=False,
evidence: Path | None = None, skip_self_test=False) -> int:
apps, skipped = collect_apps(root, only, include_unavailable)
@@ -899,7 +945,8 @@ def check(root: Path, only=None, prober=docker_prober, include_unavailable=False
print("nothing to check — every app in scope is out of circulation")
return 0
print(f"ERROR: no templates found under {root}/templates/", file=sys.stderr)
return 2
print("HARNESS REFUSED — nothing was judged")
return HARNESS_REFUSED
if not skip_self_test:
print("self-testing the prober (both directions)…")
@@ -908,7 +955,8 @@ def check(root: Path, only=None, prober=docker_prober, include_unavailable=False
print(f"ERROR: the prober failed its own canary — {why}\n"
" refusing to report a verdict: a broken detector reporting CLEAN is worse "
"than no detector at all", file=sys.stderr)
return 2
print("HARNESS REFUSED — the prober failed its canary; no app was evaluated")
return HARNESS_REFUSED
print(" prober flags the R-156 signature and clears a correct template — trustworthy")
results = []
+4
View File
@@ -0,0 +1,4 @@
{
"_what": "ALLOWLIST_EN (R-594): English retrieval promises that are TRUE, registered with the reason. Read by scripts/check-copy-i18n.py. Each entry: app, path (the i18n.en copy path the gate prints), match (a substring of the registered English sentence), reason (why the promise is true). An entry no conviction uses is STALE and fails the gate.",
"entries": []
}
+3 -1
View File
@@ -831,10 +831,11 @@
"app_info.use_cases[1]": "Gyors keresés a dokumentumok között",
"app_info.use_cases[2]": "Minimalista felület felesleges funkciók nélkül",
"app_info.use_cases[3]": "Könnyű alternatíva a Paperless-ngx-hez",
"deploy_fields[AUTH_SECRET].description": "Az alkalmazás aldomainje",
"deploy_fields[AUTH_SECRET].description": "A bejelentkezéseket aláíró titkos kulcs (automatikusan generált)",
"deploy_fields[AUTH_SECRET].label": "Munkamenet-aláíró kulcs",
"deploy_fields[DOMAIN].description": "A szerver domain neve",
"deploy_fields[DOMAIN].label": "Domain",
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "Minimalista dokumentumtár és rendszerező"
},
@@ -1279,6 +1280,7 @@
"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)",
"papra": "R-593 (2026-10-05): deliberate Hungarian rewrite, not a translation — AUTH_SECRET carried SUBDOMAIN's sentence 'Az alkalmazás aldomainje' (copy-paste one field too low); the sentence moved to SUBDOMAIN and AUTH_SECRET got its own: 'A bejelentkezéseket aláíró titkos kulcs (automatikusan generált)'",
"radicale": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules"
}
}
+49 -5
View File
@@ -63,13 +63,19 @@ class CatalogGatesFastTest(unittest.TestCase):
# record's two halves; the slow pair stays out of --fast. STALE AGAIN until 2026-09-30: 10 since
# probe-measured joined; the test had been red on main (found by the more-night-apps session).
# 11 since 2026-10-01: onboarding (NEW-APP-CHECKLIST.md), fast and history-free, so it runs in CI too.
self.assertEqual(len(mod.GATES), 11)
# 12 since family-gate joined — STALE AGAIN until 2026-10-05 (found by the burn-down, round 2).
self.assertEqual(len(mod.GATES), 12)
self.assertIn("onboarding", [g[0] for g in mod.GATES if g[3] and not g[4]])
self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"])
self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
# the gates that need git history are the ones the CI half cannot run (R-452's shallow gap)
self.assertEqual([g[0] for g in mod.GATES if g[4]], ["engine-major", "catalog-since", "test-record-move"])
def test_catalog_since_ran_under_fast(self):
"""R-452's gate is fast (git reads only) and must be IN --fast, like engine-major. (Until 2026-10-05 this
method sat after `if __name__ == "__main__":`, outside any class, so it never ran.)"""
self.assertIn("catalog-since gate", self.out)
def test_engine_major_ran_under_fast(self):
"""The 2026-09-13 gate is fast (git reads only) and must be IN --fast, or the hook that
exists to enforce its rule never runs it."""
@@ -98,9 +104,47 @@ class CatalogGatesFastTest(unittest.TestCase):
class SummaryTellsRefusedFromUndecided(unittest.TestCase):
"""R-605: a gate whose HARNESS refused (exit 3 — its canary failed, nothing was judged) and a gate that ran and
could not decide (exit 2) used to print the same word. Decoys each way, on the runner's summary."""
@classmethod
def setUpClass(cls):
import importlib.util
spec = importlib.util.spec_from_file_location("catalog_gates_summary", ENTRY)
cls.mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.mod)
def _run(self, results):
import contextlib
import io
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = self.mod.summarise(results)
return rc, buf.getvalue()
def test_a_refused_harness_does_not_read_as_undetermined(self):
rc, out = self._run([("image-pins", 0), ("volume-persistence", 3)])
self.assertEqual(rc, 2, "a refused harness is never a pass")
self.assertIn("DID-NOT-RUN", out)
self.assertIn("DID NOT RUN", out)
self.assertIn("volume-persistence", out.split("DID NOT RUN", 1)[1])
self.assertNotIn("UNDETERMINED", out)
self.assertNotIn("INCONCLUSIVE", out)
def test_an_undetermined_run_does_not_read_as_refused(self):
rc, out = self._run([("image-pins", 0), ("image-resolvable", 2)])
self.assertEqual(rc, 2)
self.assertIn("INCONCLUSIVE", out)
self.assertIn("UNDETERMINED", out)
self.assertNotIn("DID NOT RUN", out)
self.assertNotIn("DID-NOT-RUN", out)
def test_a_conviction_still_outranks_both(self):
rc, out = self._run([("image-pins", 1), ("image-resolvable", 2), ("volume-persistence", 3)])
self.assertEqual(rc, 1)
self.assertIn("CONVICTED: image-pins", out)
if __name__ == "__main__":
unittest.main(verbosity=2)
def test_catalog_since_ran_under_fast(self):
"""R-452's gate is fast (git reads only) and must be IN --fast, like engine-major."""
self.assertIn("catalog-since gate", self.out)
+4 -2
View File
@@ -88,7 +88,9 @@ class TestResolvabilityGate(unittest.TestCase):
with tempfile.TemporaryDirectory() as td:
root = make_catalog(Path(td), {"rotten": [DEAD]})
rc = cir.check(root, resolver=lambda ref: (cir.OK, ""))
self.assertEqual(rc, 2, "a resolver that resolves the canary must abort, not pass")
self.assertEqual(rc, 3,
"a resolver that resolves the canary must abort (3, the harness refused), not pass — "
"and not 2, which reads as 'some pins were throttled' (R-605)")
def test_only_filter_restricts_to_named_apps(self):
with tempfile.TemporaryDirectory() as td:
@@ -103,7 +105,7 @@ class TestResolvabilityGate(unittest.TestCase):
def test_empty_catalog_is_an_error_not_a_pass(self):
with tempfile.TemporaryDirectory() as td:
(Path(td) / "templates").mkdir()
self.assertEqual(cir.check(Path(td), resolver=fake_resolver), 2)
self.assertEqual(cir.check(Path(td), resolver=fake_resolver), 3)
class TestClassifyGuardsAgainstFalseAlarms(unittest.TestCase):
+62 -3
View File
@@ -381,8 +381,20 @@ class TestCheckEntryPoint(unittest.TestCase):
root = self._catalog(td, ["idle"])
with redirect_stdout(io.StringIO()) as buf:
rc = cvp.check(root, prober=self._prober({"idle": IDLE}))
self.assertEqual(rc, 2)
self.assertEqual(rc, 2, "a per-app UNDETERMINED is 2 — the harness ran; it must not read as refused (R-605)")
self.assertIn("not a clean bill of health", buf.getvalue())
self.assertNotIn("HARNESS REFUSED", buf.getvalue())
def test_canary_failure_prints_the_harness_refused_marker(self):
"""R-605 decoy, the other way: a canary failure must NOT read as a per-app undetermined."""
blind = lambda app, app_dir, **kw: VAULTWARDEN # noqa: E731
with tempfile.TemporaryDirectory() as td:
root = self._catalog(td, ["papra"])
with redirect_stdout(io.StringIO()) as buf:
rc = cvp.check(root, prober=blind)
self.assertEqual(rc, 3)
self.assertIn("HARNESS REFUSED", buf.getvalue())
self.assertNotIn("UNDETERMINED", buf.getvalue())
def test_broken_wins_over_undetermined(self):
with tempfile.TemporaryDirectory() as td:
@@ -400,7 +412,9 @@ class TestCheckEntryPoint(unittest.TestCase):
err = io.StringIO()
with redirect_stdout(io.StringIO()), redirect_stdout(io.StringIO()):
rc = cvp.check(root, prober=blind)
self.assertEqual(rc, 2, "a blind prober must yield rc=2, never rc=0")
self.assertEqual(rc, 3,
"a blind prober must yield rc=3 (the harness refused), never 0 — and never 2, "
"which reads as 'it ran and some apps were undetermined' (R-605)")
def test_a_prober_that_flags_everything_is_refused(self):
"""The other direction — a prober that cannot clear a correct template is equally useless."""
@@ -409,7 +423,7 @@ class TestCheckEntryPoint(unittest.TestCase):
root = self._catalog(td, ["vaultwarden"])
with redirect_stdout(io.StringIO()):
rc = cvp.check(root, prober=crying_wolf)
self.assertEqual(rc, 2)
self.assertEqual(rc, 3)
def test_out_of_circulation_apps_are_skipped_and_named(self):
with tempfile.TemporaryDirectory() as td:
@@ -488,6 +502,51 @@ class TestRoutedPorts(unittest.TestCase):
self.assertEqual(cvp.routed_ports({"services": {"db": {"labels": {"x": "y"}}}}), [])
class TestRoutedSchemes(unittest.TestCase):
"""R-806: the GET exercise speaks the backend's own scheme. Plain http to crafty-controller's HTTPS :8443 got no
application answer, so the app reached data only through its fixture seed."""
@staticmethod
def _labels_of(app):
# the REAL template's traefik labels, read as text (no PyYAML on the catalog CI runner)
p = Path(__file__).resolve().parent.parent / "templates" / app / "docker-compose.yml"
return {k: v for k, v in (l.strip().strip("-").strip().strip("\"'").split("=", 1)
for l in p.read_text(encoding="utf-8").splitlines()
if l.strip().startswith(("- \"traefik.", "- traefik.", "- 'traefik.")) and "=" in l)}
def test_https_backend_gets_an_https_url_with_k(self):
labels = self._labels_of("crafty-controller")
resolved = {"services": {"crafty-controller": {"labels": labels}}}
schemes = cvp.routed_schemes(resolved)
port = cvp.routed_ports(resolved)[0]
self.assertEqual(schemes.get(port), "https", "crafty-controller's scheme=https label was not read: %r" % schemes)
argv = cvp.exercise_argv("10.0.0.5", port, "/", schemes[port])
self.assertEqual(argv[-1], "https://10.0.0.5:%d/" % port)
self.assertIn("-k", argv, "a self-signed backend refuses curl without -k: the exercise would read 000")
def test_no_scheme_label_stays_http_without_k(self):
resolved = {"services": {"vikunja": {"labels": self._labels_of("vikunja")}}}
schemes = cvp.routed_schemes(resolved)
self.assertEqual(schemes, {3456: "http"})
argv = cvp.exercise_argv("10.0.0.5", 3456, "/login", schemes[3456], deep=True)
self.assertEqual(argv[-1], "http://10.0.0.5:3456/login")
self.assertNotIn("-k", argv)
self.assertIn("-L", argv)
def test_decoy_scheme_on_ANOTHER_traefik_service_does_not_upgrade_this_port(self):
"""The label without the fact: `scheme=https` present, but on a different traefik service than the port."""
resolved = {"services": {"a": {"labels": {
"traefik.http.services.web.loadbalancer.server.port": "8080",
"traefik.http.services.admin.loadbalancer.server.scheme": "https"}}}}
self.assertEqual(cvp.routed_schemes(resolved), {8080: "http"})
def test_list_form_labels_read_too(self):
resolved = {"services": {"c": {"labels": [
"traefik.http.services.c.loadbalancer.server.port=8443",
"traefik.http.services.c.loadbalancer.server.scheme=https"]}}}
self.assertEqual(cvp.routed_schemes(resolved), {8443: "https"})
class TestEmptyDeclaredVolume(unittest.TestCase):
def test_empty_declared_volume_is_undetermined_even_when_another_mount_has_data(self):
"""R-788: one mount holds data, the DECLARED volume is empty — the old rule called this CLEAN."""
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""R-593: the subdomain sentence describes a SUBDOMAIN field, never another one.
papra's session-signing key (AUTH_SECRET) carried „Az alkalmazás aldomainje" — SUBDOMAIN's sentence, pasted one
field too low — so a household read "the app's subdomain" under a key it must never regenerate, and SUBDOMAIN itself
had no description. The copy gate could not see it: it freezes the Hungarian as it was, wrong sentence included.
This test reads every `.felhom.yml` as TEXT (catalog CI has no PyYAML) and asserts, catalog-wide:
* every field carrying the subdomain sentence (Hungarian or the English twin) is a SUBDOMAIN* field, and
* every SUBDOMAIN field carries a description.
Hungarian is matched by an ASCII-folded fragment (workspace rule), with a positive and a negative control.
COMPANION RED-PROOF: with papra's pre-R-593 .felhom.yml restored this test fails naming `papra AUTH_SECRET` and
`papra SUBDOMAIN`."""
import glob
import io
import os
import re
import unicodedata
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SENTENCES = ("az alkalmazas aldomainje", "the subdomain this app answers on")
def fold(s):
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn").lower()
def fields(text):
"""Yield (env_var, [description values]) for every `- env_var:` item, top-level and inside i18n blocks."""
cur, descs, ind = None, [], None
for line in text.splitlines():
m = re.match(r"^(\s*)- env_var:\s*(\S+)", line)
if m:
if cur:
yield cur, descs
cur, descs, ind = m.group(2).strip("'\""), [], len(m.group(1))
continue
if cur is None:
continue
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
lead = len(line) - len(line.lstrip())
if lead <= ind: # left the item (a sibling key, the next list, or a new top-level key)
yield cur, descs
cur, descs = None, []
continue
d = re.match(r"^\s*description:\s*(.*)$", line)
if d and lead == ind + 2:
descs.append(d.group(1).strip().strip("'\""))
if cur:
yield cur, descs
def findings(app, text):
out = []
for env, descs in fields(text):
if any(fold(x) in SENTENCES for x in descs) and not env.startswith("SUBDOMAIN"):
out.append("%s %s carries the subdomain sentence" % (app, env))
if env == "SUBDOMAIN" and not descs:
out.append("%s SUBDOMAIN has no description" % app)
return out
class SubdomainSentence(unittest.TestCase):
def test_controls(self):
self.assertEqual(fold("Az alkalmazás aldomainje"), SENTENCES[0], "positive control: folding failed")
self.assertNotIn(fold("A szerver domain neve"), SENTENCES, "negative control: a clean sentence matched")
decoy = ("deploy_fields:\n - env_var: SUBDOMAIN\n label: \"Aldomain\"\n\n"
" - env_var: AUTH_SECRET\n type: secret\n description: \"Az alkalmazás aldomainje\"\n")
self.assertEqual(findings("x", decoy), ["x SUBDOMAIN has no description",
"x AUTH_SECRET carries the subdomain sentence"])
def test_catalog(self):
paths = sorted(glob.glob(os.path.join(ROOT, "templates", "*", ".felhom.yml")))
self.assertGreater(len(paths), 40, "the template glob found too few files — the test would pass empty")
bad, seen = [], 0
for p in paths:
with io.open(p, encoding="utf-8") as f:
text = f.read()
app = os.path.basename(os.path.dirname(p))
seen += sum(1 for env, d in fields(text) if env == "SUBDOMAIN" and d)
bad += findings(app, text)
self.assertGreater(seen, 40, "the parser found too few described SUBDOMAIN fields — it is not reading")
self.assertEqual(bad, [], bad)
if __name__ == "__main__":
unittest.main()
+52 -1
View File
@@ -57,7 +57,7 @@ COVERS = {
"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)",
"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). A retrieval promise REGISTERED in ALLOWLIST_EN passes only for its own app+path+sentence with a real reason; an entry for another app, a rewritten sentence, a stale entry or a two-word reason convicts (R-594). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
}
fails = []
@@ -488,6 +488,26 @@ def test_record_cases(clone):
[(NF, tr_append(rt(TR_D1, TR_D1, box_evidence="x")))] + with_steps, 1, ("no new digest",), {old: TR_D1})
def isolate_onboarding_clone(cat):
"""R-781: the cases judge ONLY the records they build. The clone carries the REAL published records (the new apps',
and the exempt apps' shape-checked ones), and those cite evidence in the real felhom.eu — which the stand-in
sibling does not hold — so a genuine case read incomplete on an untouched tree (4 FAILs). Remove every real record
from the scratch clone, and every non-exempt template directory with it (without its record a real new app would
itself convict). The real tree is never touched; the real records stay judged by the real gate run."""
src = io.open(os.path.join(cat, "scripts", "check-onboarding.py"), encoding="utf-8").read()
exempt = set(re.search(r'EXEMPT = frozenset\("""(.*?)"""', src, re.S).group(1).split())
if len(exempt) < 40:
raise SystemExit("check-onboarding.py's EXEMPT list read as %d apps — the fixture drifted" % len(exempt))
onb = os.path.join(cat, "onboarding")
for name in os.listdir(onb):
if name.endswith(".md") and name != "_TEMPLATE.md":
os.remove(os.path.join(onb, name))
tdir = os.path.join(cat, "templates")
for name in os.listdir(tdir):
if os.path.isdir(os.path.join(tdir, name)) and name not in exempt:
shutil.rmtree(os.path.join(tdir, name))
def onboarding_cases():
"""The onboarding gate reads FILES — the checklist, the template, the records, and evidence paths that may live
in a SIBLING repository. So each case runs in its own scratch WORKSPACE: <ws>/app-catalog-felhom.eu (a clone,
@@ -500,6 +520,7 @@ def onboarding_cases():
for rel in ("NEW-APP-CHECKLIST.md", os.path.join("onboarding", "_TEMPLATE.md")):
os.makedirs(os.path.dirname(os.path.join(cat, rel)) or cat, exist_ok=True)
shutil.copy(os.path.join(ROOT, rel), os.path.join(cat, rel))
isolate_onboarding_clone(cat)
sib = os.path.join(ws, "felhom.eu", "documentation", "audits", "onb")
os.makedirs(sib)
with io.open(os.path.join(sib, "proof.txt"), "w", encoding="utf-8") as fh:
@@ -889,6 +910,36 @@ i18n:
[(PB, en_with("Password protection for extra safety",
"Deleted notes can still be restored later"))],
expect_rc=1, must_contain=("retrieval promise",))
# ALLOWLIST_EN (R-594): a TRUE retrieval promise can be REGISTERED with a reason; the label without the fact
# must not pass. The register lives beside the freeze so the clone's own copy is the one judged.
AL = "scripts/copy_freeze/allowlist_en.json"
PROMISE = ("Password protection for extra safety", "Deleted notes can still be restored later")
WHY = "privatebin keeps a burned paste for its expiry window, so it is true here (decoy)"
def allow(*entries):
return lambda t: json.dumps({"_what": "decoy", "entries": [
dict(zip(("app", "path", "match", "reason"), e)) for e in entries]}, indent=1) + "\n"
case_copy("GENUINE: a REGISTERED true retrieval promise passes", clone,
[(PB, en_with(*PROMISE)),
(AL, allow(("privatebin", "app_info.use_cases[4]", "can still be restored", WHY)))],
expect_rc=0, must_contain=("copy-i18n: OK", "1 registered retrieval promise(s) in ALLOWLIST_EN, 1 used"))
case_copy("FACT: registered for ANOTHER app - the promise still convicts, the entry is stale", clone,
[(PB, en_with(*PROMISE)),
(AL, allow(("vaultwarden", "app_info.use_cases[4]", "can still be restored", WHY)))],
expect_rc=1, must_contain=("retrieval promise the Hungarian does not make", "STALE entry vaultwarden"))
case_copy("FACT: registered on the path, but the sentence was rewritten (match gone)", clone,
[(PB, en_with(PROMISE[0], "Deleted notes can be recovered at any time")),
(AL, allow(("privatebin", "app_info.use_cases[4]", "can still be restored", WHY)))],
expect_rc=1, must_contain=("retrieval promise the Hungarian does not make", "STALE entry privatebin"))
case_copy("FACT: a STALE entry - nothing in the English promises it any more", clone,
[(AL, allow(("privatebin", "app_info.use_cases[4]", "can still be restored", WHY)))],
expect_rc=1, must_contain=("STALE entry privatebin",))
case_copy("FACT: a registered promise with a two-word reason", clone,
[(PB, en_with(*PROMISE)),
(AL, allow(("privatebin", "app_info.use_cases[4]", "can still be restored", "it's fine")))],
expect_rc=1, must_contain=("no reason worth the name", "retrieval promise the Hungarian does not make"))
# A credential is a LOGIN, not prose: gokapi's default_creds carries admin / adminadmin.
GK = "templates/gokapi/.felhom.yml"
case_copy("FACT: a credential token rewritten in translation", clone,
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""R-760: every catalog service that has NO compose `healthcheck:` says why, in a comment inside its own block.
A missing healthcheck is sometimes right (the image has no shell; the image brings its own — adventurelog-frontend,
R-655), but a silent one cannot be told apart from a forgotten one: vikunja carried none for months with nothing
saying whether that was a choice. This test reads the committed composes as TEXT (catalog CI has no PyYAML) and
fails for a service block with neither a `healthcheck:` key nor a `# No healthcheck` comment.
COMPANION RED-PROOF: with the R-760 comment removed from templates/vikunja/docker-compose.yml this test fails and
names `vikunja/vikunja`."""
import glob
import io
import os
import re
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EXPLAINED = re.compile(r"^\s*#\s*No healthcheck", re.I)
def service_blocks(text):
"""Yield (service name, [lines]) for each service under the top-level `services:` key."""
in_services = False
name, lines = None, []
for line in text.splitlines():
if re.match(r"^\S", line): # a top-level key (or a top-level comment) ends the services block
if name:
yield name, lines
name, lines = None, []
in_services = line.startswith("services:")
continue
if not in_services:
continue
m = re.match(r"^ ([A-Za-z0-9_.-]+):\s*$", line)
if m:
if name:
yield name, lines
name, lines = m.group(1), []
elif name:
lines.append(line)
if name:
yield name, lines
def unexplained(text):
out = []
for svc, lines in service_blocks(text):
if any(re.match(r"^ healthcheck:", l) for l in lines):
continue
if any(EXPLAINED.match(l) for l in lines):
continue
out.append(svc)
return out
class HealthcheckExplained(unittest.TestCase):
def test_parser_sees_a_missing_and_an_explained_service(self):
# decoys for the parser itself: a silent service convicts, an explained one and a checked one pass
text = ("services:\n a:\n image: x:1\n b:\n image: y:1\n # No healthcheck: no shell\n"
" c:\n image: z:1\n healthcheck:\n test: [\"CMD\", \"true\"]\nvolumes:\n d:\n")
self.assertEqual(unexplained(text), ["a"])
def test_every_catalog_service_has_a_healthcheck_or_says_why(self):
paths = sorted(glob.glob(os.path.join(ROOT, "templates", "*", "docker-compose.yml")))
self.assertGreater(len(paths), 40, "the template glob found too few composes — the test would pass empty")
bad = []
for p in paths:
app = os.path.basename(os.path.dirname(p))
with io.open(p, encoding="utf-8") as f:
text = f.read()
for svc in unexplained(text):
bad.append("%s/%s" % (app, svc))
self.assertEqual(bad, [], "service(s) with no compose healthcheck and no '# No healthcheck' comment "
"saying why: %s" % bad)
if __name__ == "__main__":
unittest.main()
+5 -1
View File
@@ -33,6 +33,7 @@ deploy_fields:
default: "papra"
required: true
locked_after_deploy: true
description: "Az alkalmazás aldomainje"
- env_var: AUTH_SECRET
label: "Munkamenet-aláíró kulcs"
@@ -44,7 +45,8 @@ deploy_fields:
# Ez írja alá a munkameneteket: ha újragenerálódik, minden bejelentkezés
# érvénytelenné válik, ezért visszaállításkor a régi kulcsot kell megtartani.
data_key: true
description: "Az alkalmazás aldomainje"
# R-593: this field carried SUBDOMAIN's sentence (a copy-paste one field too low); moved up, own sentence here.
description: "A bejelentkezéseket aláíró titkos kulcs (automatikusan generált)"
# --- The setup gate (controller >= 0.281.0, `09` §3 decisions 46-47) ---
# The first visitor would create the admin; a fresh install is closed to everyone but the household until the first setup is done.
@@ -106,8 +108,10 @@ i18n:
description: 'The server domain name'
- env_var: SUBDOMAIN
label: 'Subdomain'
description: 'The subdomain this app answers on'
- env_var: AUTH_SECRET
label: 'Session signing key'
description: 'The secret key that signs logins (generated for you)'
# update_ladder — the test record: one tested step per line, oldest first (JSON flow mappings,
# `09-update-architecture.md` §6.4 part 4). WRITTEN BY scripts/upgrade-test.py, never by hand;
+5
View File
@@ -28,6 +28,11 @@ services:
resources:
limits:
memory: 256M
# No healthcheck (R-760): the image ships no shell (scripts/upgrade-test.py reads its OOM counter host-side
# for that reason), so neither the CMD-SHELL nor the wget/curl family of REUSE.md §2 can run inside it. Not
# measured: whether the image declares its own HEALTHCHECK. The box is not blind — its own probe
# (.felhom.yml `healthcheck.checks`, api :3456 /api/v1/info expect 200) dials it from outside; only
# Docker's own health state is empty. A healthcheck here needs the image's config READ first, never guessed.
labels:
- "traefik.enable=true"
- "traefik.http.routers.vikunja.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"