R-426: image-resolvable decoys, end to end through a PATH-stub docker

The gate's unit tests inject `resolver`, so docker_resolver - where both
live traps sit - never ran under test. test_gate_decoys.py now copies the
working-tree gate into a scratch catalog and runs it with PATH = ONLY a
stub docker (the real runtime acts on DooPlex and cannot be reached; no
network). 9 cases: a `manifest unknown` pin is convicted naming the app;
rc=0 carrying a throttle or any error text, a docker that resolves the
.invalid canary too, no docker, nothing to judge are INCONCLUSIVE or
HARNESS REFUSED, never 0; a throttle or unrecognised error on rc=1 is
never an accusation. COVERS gains "image-resolvable".
check-image-resolvable.py: the "same shape as check-image-pins.py"
comment was made false by the image-pins fix; it now says why the
narrower regex is safe.

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-06 01:38:54 +02:00
parent d3e14eb961
commit 31d4f4e6de
2 changed files with 90 additions and 1 deletions
+3 -1
View File
@@ -32,7 +32,9 @@ import subprocess
import sys
from pathlib import Path
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)") # same shape as check-image-pins.py
# Narrower than check-image-pins.py since R-426: that gate also reads a QUOTED `"image":` key (and refuses an
# interpolated ref), so neither shape can reach a published template for this one to miss.
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)")
# A ref that must never resolve, for self-testing the resolver end of the gate. `.invalid` is
# reserved by RFC 2606 and can never be a real registry.
+87
View File
@@ -47,6 +47,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
# MUST have a decoy below that has been seen to fail.
COVERS = {
"image-resolvable": "END TO END through the gate's own `docker manifest inspect` call, with a PATH-STUB docker (PATH holds ONLY the stub, so the real runtime is unreachable): the label of success without the fact - rc=0 carrying a throttle or any error text, a docker that resolves EVERYTHING incl. the .invalid canary, no docker at all, nothing to judge - each INCONCLUSIVE or HARNESS REFUSED, never 0; the cry-wolf direction - a throttle or an unrecognised error on rc=1 is never an accusation; vs a registry that positively says `manifest unknown` (convicted, naming the app) and a clean run (R-426)",
"image-pins": "the pin's LABEL without the pin: a registry PORT (`:5000`) read as a tag, `@sha256:` with no digest behind it, a QUOTED `\"image\":` key, an interpolated `${APP_IMAGE:-nginx}`, `:LATEST`/`:latest` in quotes - vs the inert shapes that must pass (a commented `# image: nginx`, an `x-image:` extension field, a README line) and the genuine pins (a tag, a real 64-hex digest, `:latest@sha256:` pinned by its digest, a port with a tag) (R-426)",
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
"catalog-since": "the date bumped in a comment/README while .felhom.yml's field stayed; or only a comment/env moved, no image (R-452)",
@@ -750,6 +751,91 @@ def image_pins_cases():
shutil.rmtree(ws, ignore_errors=True)
STUB_DOCKER = """#!%s
# PATH-stub docker for the image-resolvable decoys. Answers ONLY `docker manifest inspect <ref>`, from a JSON table;
# logs every argv. It never runs anything.
import json, os, sys
table = json.load(open(os.environ["DECOY_DOCKER_TABLE"]))
with open(os.environ["DECOY_DOCKER_LOG"], "a") as fh:
fh.write(json.dumps(sys.argv[1:]) + "\\n")
if sys.argv[1:3] != ["manifest", "inspect"] or len(sys.argv) != 4:
sys.stderr.write("stub docker: unexpected call %%r\\n" %% (sys.argv[1:],))
sys.exit(97)
rc, err = table.get(sys.argv[3], table["*"])
sys.stderr.write(err)
sys.exit(rc)
"""
def image_resolvable_cases():
"""check-image-resolvable.py END TO END, its docker replaced by a PATH stub (R-426).
The gate's unit tests inject `resolver` and so never run `docker_resolver` — the function that turns a real
`docker manifest inspect` exit code and stderr into a verdict, which is where both of its live traps sit. Here the
working-tree script is copied into a scratch catalog and run with PATH = a directory holding ONLY the stub, so the
real docker (which acts on DooPlex) cannot be reached even by accident, and nothing touches the network.
"""
global ran
ws = tempfile.mkdtemp(prefix="catalog-resolvable-")
GOOD, DEAD = "example.org/alive/app:1.0", "example.org/rotten/app:2.0"
try:
stub = os.path.join(ws, "stubbin")
os.makedirs(stub)
io.open(os.path.join(stub, "docker"), "w", encoding="utf-8").write(STUB_DOCKER % sys.executable)
os.chmod(os.path.join(stub, "docker"), 0o755)
empty = os.path.join(ws, "nobin")
os.makedirs(empty)
def run(name, table, expect_rc, must=(), path=stub, templates=True, asked=()):
global ran
cat = os.path.join(ws, "cat")
shutil.rmtree(cat, ignore_errors=True)
os.makedirs(os.path.join(cat, "scripts"))
shutil.copy(os.path.join(ROOT, "scripts", "check-image-resolvable.py"), os.path.join(cat, "scripts"))
if templates:
for app, img in (("alive", GOOD), ("rotten", DEAD)):
d = os.path.join(cat, "templates", app)
os.makedirs(d)
io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write(
"services:\n %s:\n image: %s\n" % (app, img))
tab, log = os.path.join(ws, "table.json"), os.path.join(ws, "calls.log")
json.dump(table, io.open(tab, "w", encoding="utf-8"))
io.open(log, "w").close()
env = {"PATH": path, "DECOY_DOCKER_TABLE": tab, "DECOY_DOCKER_LOG": log}
r = subprocess.run([sys.executable, os.path.join(cat, "scripts", "check-image-resolvable.py")],
cwd=cat, env=env, capture_output=True, text=True, input="")
out = r.stdout + r.stderr
calls = io.open(log).read()
ran += 1
miss = [m for m in must if m not in out] + ["(never asked docker about %s)" % a for a in asked if a not in calls]
if r.returncode == expect_rc and not miss:
print(" ok %-52s rc=%d (expected %d)" % ("image-resolvable: " + name, r.returncode, expect_rc))
else:
fails.append("image-resolvable: %s: rc=%d expected %d%s; missing %s\n%s" % (
name, r.returncode, expect_rc, " - LIVE HOLE" if expect_rc != 0 and r.returncode == 0 else "",
miss, out[-600:]))
GONE = [1, "manifest unknown: manifest unknown\n"]
okall = {"*": [0, ""], "felhom-nonexistent.invalid/no/such:image": GONE}
run("GENUINE: every pin resolves", okall, 0, ("all resolve",), asked=(GOOD, DEAD))
run("FACT: the registry says one pin is gone", dict(okall, **{DEAD: GONE}), 1,
("GONE", "pinned at templates/rotten:3"), asked=(DEAD,))
run("LABEL: rc=0 carrying a throttle message", dict(okall, **{DEAD: [0, "toomanyrequests: rate limit\n"]}), 2,
("INCONCLUSIVE",))
run("LABEL: rc=0 carrying any error text", dict(okall, **{DEAD: [0, "error: half an answer\n"]}), 2,
("INCONCLUSIVE",))
run("CRY-WOLF: a throttle on rc=1 is not an accusation", dict(okall, **{DEAD: [1, "toomanyrequests: slow down\n"]}),
2, ("INCONCLUSIVE",))
run("CRY-WOLF: an unrecognised failure is not an accusation", dict(okall, **{DEAD: [1, "error: something odd\n"]}),
2, ("INCONCLUSIVE",))
run("LABEL: a docker that resolves EVERYTHING, the canary too", {"*": [0, ""]}, 3,
("HARNESS REFUSED",))
run("LABEL: no docker at all", okall, 2, ("could not run docker",), path=empty)
run("LABEL: nothing to judge", okall, 3, ("HARNESS REFUSED",), templates=False)
finally:
shutil.rmtree(ws, ignore_errors=True)
def mem_sum_cases():
"""check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758)."""
global ran
@@ -1374,6 +1460,7 @@ i18n:
shutil.rmtree(clone, ignore_errors=True)
image_pins_cases()
image_resolvable_cases()
onboarding_cases()
family_gate_cases()
mem_sum_cases()