467 lines
19 KiB
Python
467 lines
19 KiB
Python
"""Build a ModelForge release: validate, build, inventory, sign the inventory, package.
|
|
|
|
One command, so that what ships is what was tested rather than whatever happened to be in the
|
|
working tree. Everything it emits is derived from the repository and the built images — nothing is
|
|
typed in by hand, because a release manifest an operator cannot verify is decoration.
|
|
|
|
python scripts/release_build.py --output dist
|
|
|
|
The build refuses to run against a dirty working tree unless told otherwise. A release built from
|
|
uncommitted changes cannot be reproduced, and its recorded source commit would be a lie.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gzip
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "backend" / "src"))
|
|
|
|
from modelforge_api.domain.release import (
|
|
CURRENT_AGENT_PROTOCOL_VERSION,
|
|
MINIMUM_POSTGRES_MAJOR,
|
|
MINIMUM_UPGRADE_SOURCE,
|
|
PRODUCT_NAME,
|
|
PRODUCT_VERSION,
|
|
RELEASE_CHANNEL,
|
|
SUPPORTED_AGENT_PROTOCOL_VERSIONS,
|
|
SUPPORTED_SCHEMA_REVISIONS,
|
|
SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS,
|
|
TARGET_SCHEMA_REVISION,
|
|
)
|
|
|
|
#: image name -> (build context, Dockerfile path), both relative to the repository root. The Node
|
|
#: Agent and Runtime Worker build from the repository root because their Dockerfiles copy a
|
|
#: component directory from that shared context. Keeping the four distributable images in one
|
|
#: inventory makes it impossible for the private runtime boundary to disappear from a release.
|
|
IMAGES = {
|
|
"modelforge-api": ("backend", "backend/Dockerfile"),
|
|
"modelforge-web": ("frontend", "frontend/Dockerfile"),
|
|
"modelforge-node-agent": (".", "node-agent/Dockerfile"),
|
|
"modelforge-runtime-worker": (".", "runtime-worker/Dockerfile"),
|
|
}
|
|
|
|
#: What a release tarball contains. Deliberately no model weights, no .env, no database dump and no
|
|
#: credential of any kind — see the packaging test, which fails if that changes.
|
|
ARTIFACT_PATHS = (
|
|
"docker-compose.yml",
|
|
"docker-compose.production.yml",
|
|
"docker-compose.node-agent.yml",
|
|
"docker-compose.runtime-worker.yml",
|
|
"docker-compose.gpu.yml",
|
|
"docker-compose.backup.yml",
|
|
"docker-compose.dr.yml",
|
|
".env.example",
|
|
"VERSION",
|
|
"README.md",
|
|
"config",
|
|
"docs",
|
|
"scripts/bootstrap.py",
|
|
"scripts/preflight.py",
|
|
)
|
|
|
|
|
|
def run(*args: str, check: bool = True, cwd: Path | None = None) -> str:
|
|
completed = subprocess.run(
|
|
list(args),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
cwd=cwd or ROOT,
|
|
check=False,
|
|
)
|
|
if check and completed.returncode != 0:
|
|
raise RuntimeError(f"{' '.join(args)} failed: {completed.stderr.strip()[:500]}")
|
|
return (completed.stdout or "").strip()
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def source_identity(allow_dirty: bool) -> dict[str, Any]:
|
|
commit = run("git", "rev-parse", "HEAD")
|
|
dirty = bool(run("git", "status", "--porcelain"))
|
|
if dirty and not allow_dirty:
|
|
raise SystemExit(
|
|
"refusing to build a release from a dirty working tree: the recorded source commit "
|
|
"would not describe what was built. Commit first, or pass --allow-dirty for a "
|
|
"rehearsal build."
|
|
)
|
|
describe = run("git", "describe", "--tags", "--always", check=False)
|
|
return {
|
|
"commit": commit,
|
|
"describe": describe or None,
|
|
"dirty": dirty,
|
|
"repository": run("git", "remote", "get-url", "origin", check=False) or None,
|
|
"branch": run("git", "rev-parse", "--abbrev-ref", "HEAD", check=False) or None,
|
|
}
|
|
|
|
|
|
def source_date_epoch(built_at: str) -> int:
|
|
"""Translate the declared build time into BuildKit's reproducible timestamp contract."""
|
|
|
|
parsed = datetime.fromisoformat(built_at.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
raise ValueError("build timestamp must include a UTC offset")
|
|
return int(parsed.timestamp())
|
|
|
|
|
|
def manifest_digest(repo_digest: str | None) -> str | None:
|
|
"""Return the pullable OCI digest rather than Docker's local config/image identifier."""
|
|
|
|
if not repo_digest or "@" not in repo_digest:
|
|
return None
|
|
digest = repo_digest.rsplit("@", 1)[1]
|
|
return digest if digest.startswith("sha256:") else None
|
|
|
|
|
|
#: Images whose behaviour depends on the origin the console is served against. Vite inlines
|
|
#: VITE_* variables into the bundle at build time, so this is a property of the artifact rather
|
|
#: than of the deployment that runs it.
|
|
ORIGIN_DEPENDENT_IMAGES = frozenset({"modelforge-web"})
|
|
|
|
|
|
def normalise_public_api_origin(value: str) -> str:
|
|
"""Validate the console's compiled-in API origin, or refuse to build a release without one.
|
|
|
|
v1.2.0 shipped a console that could not reach its own API. The release build never passed
|
|
``VITE_API_BASE_URL``, so Vite compiled the Dockerfile's development default into an immutable
|
|
bundle, and the nginx CSP — derived from the same argument — hardcoded the same wrong origin.
|
|
A convenience default is exactly right for ``docker compose up`` on a laptop and exactly wrong
|
|
for a release artifact, so the release path refuses to guess.
|
|
"""
|
|
|
|
origin = value.strip().rstrip("/")
|
|
if not origin:
|
|
raise SystemExit(
|
|
"a release build needs the public API origin the console will be served against. "
|
|
"Pass --public-api-origin, or set MODELFORGE_PUBLIC_API_ORIGIN. It is compiled into "
|
|
"the bundle and into the CSP, so it cannot be corrected after the fact by the "
|
|
"deployment that runs the image."
|
|
)
|
|
parsed = urlparse(origin)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise SystemExit(
|
|
f"public API origin must be an absolute http(s) origin, got {value!r}. "
|
|
"Example: https://modelforge.example.com or http://192.0.2.10:18000"
|
|
)
|
|
if parsed.path or parsed.query or parsed.fragment:
|
|
raise SystemExit(
|
|
f"public API origin must be a bare origin with no path, query or fragment, "
|
|
f"got {value!r}. The console appends its own /api/v1 paths."
|
|
)
|
|
return origin
|
|
|
|
|
|
def build_images(
|
|
version: str, commit: str, built_at: str, public_api_origin: str
|
|
) -> dict[str, dict[str, Any]]:
|
|
built: dict[str, dict[str, Any]] = {}
|
|
epoch = source_date_epoch(built_at)
|
|
for image, (context, dockerfile) in IMAGES.items():
|
|
tag = f"{image}:{version}"
|
|
print(f" building {tag}", flush=True)
|
|
origin_args: tuple[str, ...] = ()
|
|
if image in ORIGIN_DEPENDENT_IMAGES:
|
|
origin_args = ("--build-arg", f"VITE_API_BASE_URL={public_api_origin}")
|
|
print(f" public API origin {public_api_origin}", flush=True)
|
|
with tempfile.TemporaryDirectory(prefix="modelforge-build-") as temporary:
|
|
metadata_path = Path(temporary) / "metadata.json"
|
|
run(
|
|
"docker",
|
|
"build",
|
|
# BuildKit attestations carry an exporter invocation timestamp and make the local
|
|
# manifest-list ID vary. ModelForge emits its own commit-bound CycloneDX and image
|
|
# provenance below, so suppress the duplicate nondeterministic attestations.
|
|
"--provenance=false",
|
|
"--sbom=false",
|
|
"--metadata-file",
|
|
str(metadata_path),
|
|
"-t",
|
|
tag,
|
|
"-f",
|
|
str(ROOT / dockerfile),
|
|
"--build-arg",
|
|
f"MODELFORGE_VERSION={version}",
|
|
"--build-arg",
|
|
f"MODELFORGE_COMMIT={commit}",
|
|
"--build-arg",
|
|
f"MODELFORGE_BUILT_AT={built_at}",
|
|
"--build-arg",
|
|
f"SOURCE_DATE_EPOCH={epoch}",
|
|
*origin_args,
|
|
str(ROOT / context),
|
|
)
|
|
build_metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
image_id = run("docker", "inspect", "--format", "{{.Id}}", tag)
|
|
architecture = run("docker", "inspect", "--format", "{{.Architecture}}", tag)
|
|
labels = json.loads(
|
|
run("docker", "inspect", "--format", "{{json .Config.Labels}}", tag) or "{}"
|
|
)
|
|
repo_digest = (
|
|
run("docker", "inspect", "--format", "{{index .RepoDigests 0}}", tag, check=False)
|
|
or None
|
|
)
|
|
built[image] = {
|
|
"tag": tag,
|
|
"image_id": image_id,
|
|
"image_digest": manifest_digest(repo_digest)
|
|
or build_metadata.get("containerimage.digest"),
|
|
"repo_digest": repo_digest,
|
|
"architecture": architecture,
|
|
"labels": {
|
|
key: value
|
|
for key, value in (labels or {}).items()
|
|
if key.startswith("org.opencontainers.image.")
|
|
},
|
|
}
|
|
return built
|
|
|
|
|
|
def package(output: Path, version: str, sbom_source: Path | None = None) -> Path:
|
|
"""Assemble the release tarball from tracked paths only."""
|
|
|
|
staging = output / f"modelforge-{version}"
|
|
if staging.exists():
|
|
shutil.rmtree(staging)
|
|
staging.mkdir(parents=True)
|
|
for relative in ARTIFACT_PATHS:
|
|
source = ROOT / relative
|
|
if not source.exists():
|
|
print(f" note: {relative} is absent and was not packaged", flush=True)
|
|
continue
|
|
target = staging / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if source.is_dir():
|
|
shutil.copytree(source, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
else:
|
|
shutil.copy2(source, target)
|
|
|
|
if sbom_source is not None:
|
|
sbom_target = staging / "docs" / "security" / "sbom"
|
|
if sbom_target.exists():
|
|
shutil.rmtree(sbom_target)
|
|
shutil.copytree(sbom_source, sbom_target)
|
|
|
|
archive = output / f"modelforge-{version}.tar.gz"
|
|
if archive.exists():
|
|
archive.unlink()
|
|
# A deterministic archive: sorted entries, and identity metadata normalised so two builds of the
|
|
# same tree produce the same bytes rather than differing by uid and mtime.
|
|
def normalise(info: tarfile.TarInfo) -> tarfile.TarInfo:
|
|
info.uid = info.gid = 0
|
|
info.uname = info.gname = "root"
|
|
info.mtime = 0
|
|
return info
|
|
|
|
# gzip writes the current time into its header, so "w:gz" alone produces a different digest on
|
|
# every build even when the contents are identical. Writing through a GzipFile with mtime=0
|
|
# makes two builds of the same tree byte-identical, which is what makes a published checksum
|
|
# worth anything.
|
|
with (
|
|
archive.open("wb") as raw,
|
|
gzip.GzipFile(fileobj=raw, mode="wb", compresslevel=9, mtime=0) as compressed,
|
|
tarfile.open(fileobj=compressed, mode="w") as tar,
|
|
):
|
|
for path in sorted(staging.rglob("*")):
|
|
# recursive=False matters: tar.add recurses into a directory by default, so adding the
|
|
# directory and then each of its children again put every file in the archive several
|
|
# times over.
|
|
tar.add(
|
|
path,
|
|
arcname=str(path.relative_to(output)),
|
|
filter=normalise,
|
|
recursive=False,
|
|
)
|
|
shutil.rmtree(staging)
|
|
return archive
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--output", default="dist")
|
|
parser.add_argument("--skip-images", action="store_true", help="package without building")
|
|
parser.add_argument("--allow-dirty", action="store_true")
|
|
parser.add_argument(
|
|
"--built-at",
|
|
default=None,
|
|
help="fixed ISO-8601 build timestamp; reuse it when proving archive reproducibility",
|
|
)
|
|
parser.add_argument(
|
|
"--public-api-origin",
|
|
default=os.environ.get("MODELFORGE_PUBLIC_API_ORIGIN", ""),
|
|
help=(
|
|
"absolute origin the console will reach its API on, for example "
|
|
"https://modelforge.example.com. Vite compiles it into the bundle and the CSP is "
|
|
"derived from it, so a release cannot be corrected here afterwards. Required unless "
|
|
"--skip-images. Also read from MODELFORGE_PUBLIC_API_ORIGIN."
|
|
),
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
output = (ROOT / args.output).resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
built_at = args.built_at or datetime.now(UTC).isoformat()
|
|
version = PRODUCT_VERSION
|
|
|
|
# Validated before anything is built: refusing after three images is worse than refusing first.
|
|
public_api_origin = (
|
|
"" if args.skip_images else normalise_public_api_origin(args.public_api_origin)
|
|
)
|
|
|
|
print(f"{PRODUCT_NAME} {version} — release build", flush=True)
|
|
source = source_identity(args.allow_dirty)
|
|
print(f" source commit {source['commit']}"
|
|
f"{' (DIRTY)' if source['dirty'] else ''}", flush=True)
|
|
|
|
images = (
|
|
{}
|
|
if args.skip_images
|
|
else build_images(version, source["commit"], built_at, public_api_origin)
|
|
)
|
|
|
|
sbom_output = output / "sbom"
|
|
run(
|
|
sys.executable,
|
|
str(ROOT / "scripts" / "m16_sbom.py"),
|
|
"--output",
|
|
str(sbom_output),
|
|
"--version",
|
|
version,
|
|
"--source-commit",
|
|
source["commit"],
|
|
"--generated-at",
|
|
built_at,
|
|
)
|
|
sbom_artifact = output / f"modelforge-{version}-cyclonedx.json"
|
|
provenance_artifact = output / f"modelforge-{version}-image-provenance.json"
|
|
shutil.copy2(sbom_output / "modelforge-cyclonedx.json", sbom_artifact)
|
|
shutil.copy2(sbom_output / "image-provenance.json", provenance_artifact)
|
|
|
|
# Read the artifact, not the source that produced it. v1.2.0 was published with correct
|
|
# labels, a correct manifest and a console bundle pointing at localhost; nothing upstream of
|
|
# here could see that, because the defect only exists once the image is built.
|
|
if images:
|
|
print(" verifying the console image against its declared API origin", flush=True)
|
|
acceptance = run(
|
|
sys.executable,
|
|
str(ROOT / "scripts" / "release_image_acceptance.py"),
|
|
"--image",
|
|
images["modelforge-web"]["tag"],
|
|
"--expect-origin",
|
|
public_api_origin,
|
|
"--version",
|
|
version,
|
|
check=False,
|
|
)
|
|
for line in acceptance.splitlines():
|
|
# A release build must not fail because the host console cannot encode a character in
|
|
# a subprocess's output. Windows defaults to cp1252 here.
|
|
safe = line.encode(sys.stdout.encoding or "utf-8", "replace").decode(
|
|
sys.stdout.encoding or "utf-8", "replace"
|
|
)
|
|
print(f" {safe}", flush=True)
|
|
if "All " not in acceptance:
|
|
raise SystemExit(
|
|
"the built console image does not match the API origin it was built for; "
|
|
"refusing to package a release that cannot reach its own API."
|
|
)
|
|
|
|
archive = package(output, version, sbom_output)
|
|
print(f" packaged {archive.name} ({archive.stat().st_size} bytes)", flush=True)
|
|
|
|
manifest: dict[str, Any] = {
|
|
"product": PRODUCT_NAME,
|
|
"version": version,
|
|
"channel": RELEASE_CHANNEL,
|
|
"built_at": built_at,
|
|
"source": source,
|
|
"compatibility": {
|
|
"schema_revision": TARGET_SCHEMA_REVISION,
|
|
"supported_schema_revisions": list(SUPPORTED_SCHEMA_REVISIONS),
|
|
"supported_upgrade_source_schema_revisions": list(
|
|
SUPPORTED_UPGRADE_SOURCE_SCHEMA_REVISIONS
|
|
),
|
|
"agent_protocol_version": CURRENT_AGENT_PROTOCOL_VERSION,
|
|
"supported_agent_protocol_versions": list(SUPPORTED_AGENT_PROTOCOL_VERSIONS),
|
|
"minimum_upgrade_source": MINIMUM_UPGRADE_SOURCE,
|
|
"minimum_postgres_major": MINIMUM_POSTGRES_MAJOR,
|
|
},
|
|
"external_dependencies": {
|
|
"postgresql": f">={MINIMUM_POSTGRES_MAJOR}",
|
|
"redis": ">=7",
|
|
"docker_engine": ">=24",
|
|
"nvidia_container_runtime": "required on any GPU node",
|
|
},
|
|
"images": images,
|
|
# The origin compiled into the console bundle and into its CSP. Recorded because it is a
|
|
# property of the artifact that an operator otherwise cannot see without unpacking the
|
|
# image, and because a console pointed at the wrong API is indistinguishable from a
|
|
# healthy one until someone opens it.
|
|
"console": {"public_api_origin": public_api_origin or None},
|
|
"artifacts": {},
|
|
}
|
|
|
|
manifest["sbom"] = {
|
|
"format": "CycloneDX 1.5",
|
|
"cyclonedx": sha256_file(sbom_artifact),
|
|
"provenance": sha256_file(provenance_artifact),
|
|
}
|
|
|
|
for path in sorted(output.glob(f"modelforge-{version}*")):
|
|
if path.suffix == ".json" or path.name.endswith(".sha256"):
|
|
continue
|
|
manifest["artifacts"][path.name] = {
|
|
"sha256": sha256_file(path),
|
|
"bytes": path.stat().st_size,
|
|
}
|
|
|
|
manifest_path = output / f"modelforge-{version}-release-manifest.json"
|
|
# newline="\n" on every release artifact. Python otherwise translates to the host's line ending,
|
|
# so a Windows-built release and a Linux-built release of the same commit would differ byte for
|
|
# byte — and the recorded hashes with them.
|
|
manifest_path.write_text(
|
|
json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8", newline="\n"
|
|
)
|
|
|
|
# The checksum file covers the manifest too, so a tampered manifest is as detectable as a
|
|
# tampered archive.
|
|
checksums = output / f"modelforge-{version}-SHA256SUMS"
|
|
lines = [
|
|
f"{sha256_file(path)} {path.name}"
|
|
for path in sorted(output.iterdir())
|
|
if path.is_file() and not path.name.endswith("SHA256SUMS")
|
|
]
|
|
# Must be LF. `sha256sum -c` treats a trailing CR as part of the filename, so a CRLF checksum
|
|
# file fails to verify every artifact it covers — on the very command an operator is told to run.
|
|
checksums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
|
|
|
print(f" manifest {manifest_path.name}", flush=True)
|
|
print(f" checksums {checksums.name} ({len(lines)} files)", flush=True)
|
|
for line in lines:
|
|
print(f" {line}", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|