216 lines
7.6 KiB
Python
216 lines
7.6 KiB
Python
"""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())
|