gates / gates (push) Successful in 3m27s
- scripts/image_runtime.py: Node / Python Brotli / brotlicffi / apk / dpkg read from an image's registry config and layers (no pull; HEAD for the nightly digest; zstd via the zstd program where present; quay.io, index.docker.io). - security_watch.py: a „runtime" source (security_watch_runtimes.json), cached by digest in state.json (154 images, filled on DooPlex tonight); ghcr.io tag lists are paged; the DooPlex group (own block, homelab next-step). - dooplex_inventory.py + documentation/security-watch/dooplex-workloads.txt (89 images, homelab-manifests 5aeec8d). - Tests W10–W12 (58 checks) + test_image_runtime.py (13), each red-proved. - audits/http2shell-2026-10-27: the Home Assistant lane walk (proven), HOMELAB-LIST.md (19 to update), the run log. - 08 §11 design line; 09 §3 decision 276 (HA monthly = same main version; decided by CC unattended).
96 lines
5.2 KiB
Python
96 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""test_image_runtime.py — the image reader's parsers (R-1327) and the DooPlex list maker (R-1328). No network.
|
|
|
|
Pinned by consequence: an Alpine and a Debian package database read back the packages the runtime table matches; Node's
|
|
version header gives the version; an image reference splits the way the watch's registry_of does (lscr.io is Docker
|
|
Hub; a bare name is library/); a tar layer streamed top-down returns the TOPMOST copy of a file and the Python
|
|
`.dist-info` versions; the DooPlex list keeps one line per image:tag with every file that names it, and skips templated
|
|
and variable images. Red-proof: a scan that keeps the FIRST (bottom) copy instead of the topmost fails `topmost`.
|
|
"""
|
|
import io
|
|
import os
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import image_runtime as ir # noqa: E402
|
|
import dooplex_inventory as di # noqa: E402
|
|
|
|
fails, ran = [], 0
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
global ran
|
|
ran += 1
|
|
print(" %s %s%s" % ("ok" if cond else "XX", name, "" if cond else " %s" % (detail,)))
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def tar_bytes(files):
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
|
for name, data in files.items():
|
|
ti = tarfile.TarInfo(name)
|
|
ti.size = len(data)
|
|
tf.addfile(ti, io.BytesIO(data))
|
|
return buf.getvalue()
|
|
|
|
|
|
class FakeReg:
|
|
def __init__(self, blobs):
|
|
self.blobs = blobs
|
|
|
|
def blob(self, digest, stream=False):
|
|
return io.BytesIO(self.blobs[digest])
|
|
|
|
|
|
def main():
|
|
apk = ir.parse_apk("P:musl\nV:1.2.5-r10\n\nP:nodejs\nV:24.18.1-r0\n\nP:brotli-libs\nV:1.2.0-r1\n")
|
|
check("apk database -> nodejs and brotli-libs", apk.get("nodejs") == "24.18.1-r0" and apk.get("brotli-libs") == "1.2.0-r1", apk)
|
|
dpkg = ir.parse_dpkg("Package: libbrotli1\nStatus: install ok installed\nVersion: 1.0.9-2+b6\n\nPackage: nodejs\nVersion: 20.20.0-1nodesource1\n")
|
|
check("dpkg database -> libbrotli1 and nodejs", dpkg == {"libbrotli1": "1.0.9-2+b6", "nodejs": "20.20.0-1nodesource1"}, dpkg)
|
|
h = "#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 22\n#define NODE_PATCH_VERSION 1\n"
|
|
check("node_version.h -> 24.22.1", ir.parse_node_h(h) == "24.22.1", ir.parse_node_h(h))
|
|
check("split_ref: lscr.io is Docker Hub, tag kept", ir.split_ref("lscr.io/linuxserver/qbittorrent:5.3.0") ==
|
|
("docker.io", "linuxserver/qbittorrent", "5.3.0"))
|
|
check("split_ref: a bare name is library/, no tag = latest", ir.split_ref("redis") == ("docker.io", "library/redis", "latest"))
|
|
check("split_ref: index.docker.io is Docker Hub", ir.split_ref("index.docker.io/plexinc/pms-docker:1.43") ==
|
|
("docker.io", "plexinc/pms-docker", "1.43"))
|
|
check("split_ref: ghcr.io with a port-free path", ir.split_ref("ghcr.io/home-assistant/home-assistant:2026.9.4") ==
|
|
("ghcr.io", "home-assistant/home-assistant", "2026.9.4"))
|
|
# two layers: the bottom one has an OLD apk db and brotli 1.1.0; the top one replaces the db and adds brotli 1.2.1
|
|
bottom = tar_bytes({"lib/apk/db/installed": b"P:nodejs\nV:24.18.1-r0\n",
|
|
"usr/lib/python3/site-packages/Brotli-1.1.0.dist-info/METADATA": b"x"})
|
|
top = tar_bytes({"lib/apk/db/installed": b"P:nodejs\nV:24.22.1-r0\n",
|
|
"usr/local/lib/python3.12/site-packages/brotli-1.2.1.dist-info/RECORD": b"x"})
|
|
layers = [{"digest": "sha256:bottom", "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": len(bottom)},
|
|
{"digest": "sha256:top", "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "size": len(top)}]
|
|
found, notes = ir.scan_layers(FakeReg({"sha256:bottom": bottom, "sha256:top": top}), layers)
|
|
check("topmost: the top layer's apk db wins", (found.get("apk") or {}).get("nodejs") == "24.22.1-r0", found.get("apk"))
|
|
check("topmost: the top layer's Brotli dist-info wins", (found.get("pydist") or {}).get("brotli") == "1.2.1", found.get("pydist"))
|
|
check("no note for readable gzip layers", notes == [], notes)
|
|
found, notes = ir.scan_layers(FakeReg({}), [{"digest": "sha256:z", "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
|
|
"size": 10}], budget=0)
|
|
check("a layer over the byte budget is a NOTE, never a silent pass", notes and ("budget" in notes[0] or "zstd" in notes[0]), notes)
|
|
d = tempfile.mkdtemp()
|
|
open(os.path.join(d, "a.yaml"), "w").write("spec:\n containers:\n - image: rommapp/romm:5.4.0\n name: x\n"
|
|
" - image: '{{ .Values.image }}'\n - image: $IMG\n")
|
|
open(os.path.join(d, "b.yml"), "w").write("image: rommapp/romm:5.4.0\nimage: busybox\n")
|
|
got = di.collect(d)
|
|
check("dooplex: one line per image:tag with every file", got.get("rommapp/romm:5.4.0") == {"a.yaml", "b.yml"}, got)
|
|
check("dooplex: a bare image becomes :latest; templated and variable images are skipped",
|
|
set(got) == {"rommapp/romm:5.4.0", "busybox:latest"}, set(got))
|
|
print()
|
|
if fails:
|
|
print("image_runtime tests FAILED: %s" % ", ".join(fails))
|
|
return 1
|
|
print("image_runtime tests OK — %d checks" % ran)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|