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
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:
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user