Initial public ModelForge release
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user