diff --git a/scripts/check-image-resolvable.py b/scripts/check-image-resolvable.py index 79b6be5..13a7d75 100644 --- a/scripts/check-image-resolvable.py +++ b/scripts/check-image-resolvable.py @@ -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. diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 8f37c1f..38fe42f 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -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 `, 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()