"""Prove a built Console release image talks to the API origin it was built for. This gate exists because v1.2.0 passed every other one and still shipped a console that could not reach its own API. Vite inlines ``VITE_API_BASE_URL`` at build time, so the defect lived in the *artifact*, not the source: the unit tests were green, the release manifest was correct, the image labels were correct, and the bundle inside the image pointed at ``http://localhost:8000``. Nothing that inspects source, labels or checksums can see that. So this reads the built image. python scripts/release_image_acceptance.py --image modelforge-web:1.2.1 \ --expect-origin http://192.0.2.10:18000 It is deliberately cheap — it extracts the served assets and the rendered nginx policy from the image and asserts on their contents — so it can run in the release gate on any host with Docker and without standing up a browser. A full browser pass against a disposable Compose stack remains the acceptance step for a deployment; this is the step that stops a wrong artifact being published at all. Exit status is 0 when the image is coherent, 1 when it is not. """ from __future__ import annotations import argparse import json import re import subprocess # noqa: S404 - fixed argv, never a shell import sys from dataclasses import dataclass from urllib.parse import urlparse #: Origins that are never correct in a published release artifact. A console built for a laptop is #: fine; a console *published* for a laptop is the v1.2.0 defect. DEVELOPMENT_ORIGINS = ("http://localhost:8000", "http://127.0.0.1:8000") @dataclass class Check: name: str passed: bool detail: str def run(*args: str) -> str: completed = subprocess.run( list(args), capture_output=True, text=True, encoding="utf-8", errors="replace", check=False ) if completed.returncode != 0: raise RuntimeError(f"{' '.join(args)} failed: {completed.stderr.strip()[:400]}") return completed.stdout def image_shell(image: str, script: str) -> str: """Run a read-only shell snippet inside the image without starting the server.""" return run("docker", "run", "--rm", "--entrypoint", "sh", image, "-c", script) def bundle_origins(image: str) -> set[str]: output = image_shell( image, "grep -ohoE 'https?://[a-zA-Z0-9._:-]+' /usr/share/nginx/html/assets/*.js 2>/dev/null " "| sort -u", ) return {line.strip() for line in output.splitlines() if line.strip()} def csp_connect_src(image: str) -> str: output = image_shell( image, "grep -o \"connect-src[^;]*;\" /etc/nginx/conf.d/security-headers.inc 2>/dev/null || true", ) return output.strip() def image_labels(image: str) -> dict[str, str]: raw = run("docker", "image", "inspect", "--format", "{{json .Config.Labels}}", image) return json.loads(raw or "{}") or {} def evaluate(image: str, expected: str, version: str | None) -> list[Check]: expected = expected.rstrip("/") parsed = urlparse(expected) checks: list[Check] = [] origins = bundle_origins(image) checks.append( Check( "bundle targets the configured origin", expected in origins, f"expected {expected}; bundle references {sorted(origins) or 'none'}", ) ) # The whole point of the gate: a development fallback must never reach a published artifact, # unless the artifact is deliberately a development one. leaked = sorted(o for o in DEVELOPMENT_ORIGINS if o in origins and o != expected) checks.append( Check( "bundle contains no development API fallback", not leaked, f"development origins compiled into the bundle: {leaked}" if leaked else "none present", ) ) connect = csp_connect_src(image) checks.append( Check( "CSP connect-src names the same origin", bool(connect) and expected in connect, f"connect-src is {connect!r}", ) ) directive = connect.split("connect-src", 1)[-1].split(";")[0] if connect else "" checks.append( Check( "CSP connect-src is not widened", "*" not in directive and "data:" not in directive, f"directive is {directive.strip()!r}", ) ) checks.append( Check( "CSP has no unsubstituted placeholder", "__API_ORIGIN__" not in connect, "placeholder still present" if "__API_ORIGIN__" in connect else "substituted", ) ) # A console that resolves its API relative to itself would make the origin irrelevant; assert # the compiled form actually is absolute, so the CSP coupling above is meaningful. checks.append( Check( "configured origin is absolute", parsed.scheme in {"http", "https"} and bool(parsed.netloc), f"origin parsed as scheme={parsed.scheme!r} netloc={parsed.netloc!r}", ) ) labels = image_labels(image) for field in ( "org.opencontainers.image.version", "org.opencontainers.image.revision", "org.opencontainers.image.created", "org.opencontainers.image.source", "org.opencontainers.image.title", ): value = (labels.get(field) or "").strip() checks.append(Check(f"label {field} is populated", bool(value), value or "EMPTY")) if version: actual = (labels.get("org.opencontainers.image.version") or "").strip() checks.append( Check( "label version matches the release", actual == version, f"label {actual!r} vs release {version!r}", ) ) revision = (labels.get("org.opencontainers.image.revision") or "").strip() checks.append( Check( "label revision looks like a commit sha", bool(re.fullmatch(r"[0-9a-f]{40}", revision)), revision or "EMPTY", ) ) return checks def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--image", required=True, help="the built Console image to inspect") parser.add_argument( "--expect-origin", required=True, help="the API origin this image was built for, for example https://modelforge.example.com", ) parser.add_argument("--version", default=None, help="release version the labels must declare") parser.add_argument("--report", default=None, help="write the result as JSON") args = parser.parse_args(argv) print(f"Console release-image acceptance: {args.image}", flush=True) checks = evaluate(args.image, args.expect_origin, args.version) for check in checks: print(f" {'OK ' if check.passed else 'FAIL'} {check.name:44} {check.detail}", flush=True) failed = [check for check in checks if not check.passed] if args.report: from pathlib import Path Path(args.report).write_text( json.dumps( { "image": args.image, "expected_origin": args.expect_origin, "verdict": "PASS" if not failed else "FAIL", "checks": [vars(check) for check in checks], }, indent=2, ) + "\n", encoding="utf-8", newline="\n", ) if failed: print(f"\n{len(failed)} check(s) failed; this image must not be published.", file=sys.stderr) return 1 print(f"\nAll {len(checks)} checks passed.", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())