mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
565 lines
22 KiB
Bash
Executable File
565 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# build-bundle.sh -- Build a pre-built AI bundle tar.gz inside the Docker image
|
|
#
|
|
# Usage: build-bundle.sh <bundleId> <arch> <outputDir>
|
|
#
|
|
# bundleId - One of the bundle IDs in feature-manifest.json
|
|
# arch - Architecture variant: amd64-gpu or arm64-cpu
|
|
# outputDir - Directory for the output .tar.gz and .sha256 files
|
|
#
|
|
# Runs as root inside the SnapOtter Docker container. The venv at /opt/venv
|
|
# must be activated. Produces:
|
|
# <outputDir>/<bundleId>-<arch>.tar.gz
|
|
# <outputDir>/<bundleId>-<arch>.tar.gz.sha256
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
export BUNDLE_ID="${1:?Usage: build-bundle.sh <bundleId> <arch> <outputDir>}"
|
|
export ARCH="${2:?Usage: build-bundle.sh <bundleId> <arch> <outputDir>}"
|
|
OUTPUT_DIR="${3:?Usage: build-bundle.sh <bundleId> <arch> <outputDir>}"
|
|
|
|
# OCR uses the v3 complete-runtime format. Keep legacy aliases at this boundary
|
|
# until every release caller has moved to explicit capability target names.
|
|
if [[ "${BUNDLE_ID}" == "ocr" ]]; then
|
|
case "${ARCH}" in
|
|
amd64-gpu | amd64-cpu | linux-amd64-cpu-py312)
|
|
OCR_TARGET="linux-amd64-cpu-py312"
|
|
;;
|
|
arm64-cpu | linux-arm64-cpu-py311)
|
|
OCR_TARGET="linux-arm64-cpu-py311"
|
|
;;
|
|
*)
|
|
echo "ERROR: Unsupported OCR runtime target alias: ${ARCH}" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
OCR_BUILDER="${OCR_RUNTIME_BUILDER:-${SCRIPT_DIR}/build-ocr-runtime.sh}"
|
|
if [[ ! -x "${OCR_BUILDER}" ]]; then
|
|
echo "ERROR: OCR runtime builder is missing or not executable: ${OCR_BUILDER}" >&2
|
|
exit 2
|
|
fi
|
|
exec "${OCR_BUILDER}" "${OCR_TARGET}" "${OUTPUT_DIR}"
|
|
fi
|
|
|
|
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}"
|
|
if [[ ! "${SOURCE_DATE_EPOCH}" =~ ^[0-9]+$ ]]; then
|
|
echo "ERROR: SOURCE_DATE_EPOCH must be a non-negative integer" >&2
|
|
exit 2
|
|
fi
|
|
|
|
MANIFEST="/app/docker/feature-manifest.json"
|
|
VENV_PATH="${PYTHON_VENV_PATH:-/opt/venv}"
|
|
export MODELS_DIR="/tmp/bundle-models"
|
|
export BUILD_DIR="/tmp/bundle-build"
|
|
|
|
# When running with --entrypoint bash (bypassing entrypoint.sh), the venv
|
|
# at /data/ai/venv won't exist yet. Use /opt/venv directly -- it's the base
|
|
# venv baked into the Docker image, and that's exactly what we want as the
|
|
# starting point for building bundle deltas.
|
|
if [[ ! -f "${VENV_PATH}/bin/activate" && -f "/opt/venv/bin/activate" ]]; then
|
|
VENV_PATH="/opt/venv"
|
|
fi
|
|
|
|
# Activate the venv so pip/python3 use it (not system Python)
|
|
if [[ -f "${VENV_PATH}/bin/activate" ]]; then
|
|
# shellcheck disable=SC1091
|
|
source "${VENV_PATH}/bin/activate"
|
|
fi
|
|
|
|
SITE_PACKAGES="$("${VENV_PATH}/bin/python3" -c 'import site; print(site.getsitepackages()[0])')"
|
|
|
|
# Parse platform from arch: amd64-gpu -> amd64, arm64-cpu -> arm64
|
|
export PLATFORM="${ARCH%%-*}"
|
|
|
|
echo "=== Building bundle: ${BUNDLE_ID} arch=${ARCH} platform=${PLATFORM} ==="
|
|
echo "Venv: ${VENV_PATH}"
|
|
echo "Site-packages: ${SITE_PACKAGES}"
|
|
|
|
# Validate manifest exists
|
|
if [[ ! -f "${MANIFEST}" ]]; then
|
|
echo "ERROR: Manifest not found at ${MANIFEST}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Validate bundle exists in manifest
|
|
python3 -c "
|
|
import json, sys
|
|
with open('${MANIFEST}') as f:
|
|
m = json.load(f)
|
|
if '${BUNDLE_ID}' not in m['bundles']:
|
|
print(f'ERROR: Bundle \"${BUNDLE_ID}\" not found in manifest', file=sys.stderr)
|
|
print(f'Available: {list(m[\"bundles\"].keys())}', file=sys.stderr)
|
|
sys.exit(1)
|
|
"
|
|
|
|
# ── Step 0: Write pip constraints (numpy-1.x-ABI closure lock) ────────────
|
|
# Bundle installs below can otherwise pull the latest transitive scientific
|
|
# stack: numpy 2.x plus scipy/scikit-learn/scikit-image/pandas wheels built
|
|
# against the numpy 2.x ABI. A legacy bundle's transitive dependency can drag
|
|
# numpy 1.26.4 to 2.x plus a newer scipy. The Step 4 re-pin snaps numpy back to
|
|
# 1.26.4 but leaves
|
|
# those numpy-2.x wheels behind; the Step 6 diff (by dir name, not version)
|
|
# then ships them, and once installed they strand on the numpy==1.26.4 base and
|
|
# raise "numpy.dtype size changed" on import, crashing the dispatcher for
|
|
# EVERY AI tool (e.g. rembg's scipy), not just this bundle. Applying the
|
|
# manifest's constraints to every pip install keeps the whole closure on
|
|
# numpy-1.x-ABI versions so no bundle can strand a numpy-2.x wheel.
|
|
CONSTRAINTS_FILE="/tmp/bundle-constraints.txt"
|
|
python3 -c "
|
|
import json
|
|
with open('${MANIFEST}') as f:
|
|
m = json.load(f)
|
|
with open('${CONSTRAINTS_FILE}', 'w') as f:
|
|
f.write('\n'.join(m.get('constraints', [])) + '\n')
|
|
"
|
|
if [[ -s "${CONSTRAINTS_FILE}" ]] && [[ -n "$(tr -d '[:space:]' < "${CONSTRAINTS_FILE}")" ]]; then
|
|
export PIP_CONSTRAINT="${CONSTRAINTS_FILE}"
|
|
echo " Build constraints: $(tr '\n' ' ' < "${CONSTRAINTS_FILE}")"
|
|
else
|
|
echo " No build constraints in manifest"
|
|
fi
|
|
|
|
# Clean previous build artifacts
|
|
rm -rf "${MODELS_DIR}" "${BUILD_DIR}"
|
|
mkdir -p "${MODELS_DIR}" "${BUILD_DIR}/site-packages" "${BUILD_DIR}/models" "${OUTPUT_DIR}"
|
|
|
|
# ── Step 1: Record base site-packages ────────────────────────────────────
|
|
echo "=== Recording base site-packages ==="
|
|
find "${SITE_PACKAGES}" -maxdepth 1 -mindepth 1 | sort > /tmp/base-packages.txt
|
|
echo " Base entries: $(wc -l < /tmp/base-packages.txt)"
|
|
|
|
# ── Step 2: Install packages ─────────────────────────────────────────────
|
|
echo "=== Installing packages ==="
|
|
python3 << 'PYINSTALL'
|
|
import json, shlex, subprocess, sys, os
|
|
|
|
with open("/app/docker/feature-manifest.json") as f:
|
|
manifest = json.load(f)
|
|
|
|
bundle_id = os.environ["BUNDLE_ID"]
|
|
platform = os.environ["PLATFORM"]
|
|
bundle = manifest["bundles"][bundle_id]
|
|
pip_flags = bundle.get("pipFlags", {})
|
|
|
|
# Collect all packages: common + arch-specific
|
|
packages = list(bundle["packages"].get("common", []))
|
|
packages.extend(bundle["packages"].get(platform, []))
|
|
|
|
print(f" Installing {len(packages)} package(s) for {bundle_id} ({platform})")
|
|
|
|
for pkg_string in packages:
|
|
# Check if any pipFlags key matches the start of this package string
|
|
extra_flags = ""
|
|
for flag_key, flag_val in pip_flags.items():
|
|
if pkg_string.startswith(flag_key):
|
|
extra_flags = flag_val
|
|
break
|
|
|
|
# pkg_string may contain embedded flags (e.g. --index-url), so pass as-is
|
|
cmd = f"{sys.executable} -m pip install --no-cache-dir {extra_flags} {pkg_string}".strip()
|
|
print(f" > {cmd}", flush=True)
|
|
result = subprocess.run(shlex.split(cmd))
|
|
if result.returncode != 0:
|
|
print(f"ERROR: pip install failed for: {pkg_string}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(" Package installation complete")
|
|
PYINSTALL
|
|
|
|
# ── Step 3: Post-install fixups ───────────────────────────────────────────
|
|
echo "=== Running post-install fixups ==="
|
|
python3 << 'PYPOST'
|
|
import json, shlex, subprocess, sys, os
|
|
|
|
with open("/app/docker/feature-manifest.json") as f:
|
|
manifest = json.load(f)
|
|
|
|
bundle = manifest["bundles"][os.environ["BUNDLE_ID"]]
|
|
post_install = bundle.get("postInstall", [])
|
|
|
|
if not post_install:
|
|
print(" No post-install fixups")
|
|
sys.exit(0)
|
|
|
|
for pkg in post_install:
|
|
cmd = f"{sys.executable} -m pip install --no-cache-dir --force-reinstall {pkg}"
|
|
print(f" > {cmd}", flush=True)
|
|
result = subprocess.run(shlex.split(cmd))
|
|
if result.returncode != 0:
|
|
print(f"ERROR: post-install fixup failed for: {pkg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(" Post-install fixups complete")
|
|
PYPOST
|
|
|
|
# ── Step 4: Re-pin base packages ─────────────────────────────────────────
|
|
echo "=== Re-pinning base packages ==="
|
|
python3 << 'PYREPIN'
|
|
import json, shlex, subprocess, sys
|
|
|
|
with open("/app/docker/feature-manifest.json") as f:
|
|
manifest = json.load(f)
|
|
|
|
base_packages = manifest.get("basePackages", [])
|
|
if not base_packages:
|
|
print(" No base packages to re-pin")
|
|
sys.exit(0)
|
|
|
|
pkgs = " ".join(base_packages)
|
|
cmd = f"{sys.executable} -m pip install --no-cache-dir --force-reinstall {pkgs}"
|
|
print(f" > {cmd}", flush=True)
|
|
result = subprocess.run(shlex.split(cmd))
|
|
if result.returncode != 0:
|
|
print("ERROR: base package re-pin failed", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(" Base packages re-pinned")
|
|
PYREPIN
|
|
|
|
# ── Step 4.5: Apply source patches ───────────────────────────────────────
|
|
echo "=== Applying source patches ==="
|
|
python3 << 'PYPATCH'
|
|
import json, os, site, sys
|
|
|
|
with open("/app/docker/feature-manifest.json") as f:
|
|
manifest = json.load(f)
|
|
|
|
bundle = manifest["bundles"][os.environ["BUNDLE_ID"]]
|
|
patches = bundle.get("patches", [])
|
|
|
|
if not patches:
|
|
print(" No patches")
|
|
sys.exit(0)
|
|
|
|
site_packages = site.getsitepackages()[0]
|
|
for patch in patches:
|
|
target = os.path.join(site_packages, patch["file"])
|
|
if not os.path.exists(target):
|
|
print(f" WARNING: patch target not found: {patch['file']}", file=sys.stderr)
|
|
continue
|
|
with open(target, "r") as fh:
|
|
content = fh.read()
|
|
if patch["search"] not in content:
|
|
print(f" WARNING: search text not found in {patch['file']} (already patched?)", file=sys.stderr)
|
|
continue
|
|
with open(target, "w") as fh:
|
|
fh.write(content.replace(patch["search"], patch["replace"]))
|
|
print(f" Patched {patch['file']}")
|
|
|
|
print(" Source patches applied")
|
|
PYPATCH
|
|
|
|
# ── Step 4.6: ONNX Runtime flavor hygiene ────────────────────────────────
|
|
# A pinned onnxruntime-gpu plus a transitive CPU onnxruntime (rembg and
|
|
# faster-whisper both depend on plain `onnxruntime`) leaves BOTH dist-infos in
|
|
# site-packages, with the package files belonging to whichever installed last.
|
|
# Force the GPU build's files back and drop the CPU metadata, so a bundle can
|
|
# never ship CPU files under GPU metadata, or two flavors at once (#490).
|
|
echo "=== Reconciling ONNX Runtime flavor ==="
|
|
python3 << 'PYONNX'
|
|
import os, shlex, shutil, site, subprocess, sys
|
|
|
|
site_packages = site.getsitepackages()[0]
|
|
names = os.listdir(site_packages)
|
|
cpu = sorted(n for n in names if n.startswith("onnxruntime-") and n.endswith(".dist-info"))
|
|
gpu = sorted(n for n in names if n.startswith("onnxruntime_gpu-") and n.endswith(".dist-info"))
|
|
|
|
if not (cpu and gpu):
|
|
print(" Single ONNX Runtime flavor (or none); nothing to reconcile")
|
|
sys.exit(0)
|
|
|
|
version = gpu[-1][len("onnxruntime_gpu-"):-len(".dist-info")]
|
|
cmd = (f"{sys.executable} -m pip install --no-cache-dir --force-reinstall "
|
|
f"--no-deps onnxruntime-gpu=={version}")
|
|
print(f" > {cmd}", flush=True)
|
|
if subprocess.run(shlex.split(cmd)).returncode != 0:
|
|
print("ERROR: onnxruntime-gpu reinstall failed", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
for name in cpu:
|
|
shutil.rmtree(os.path.join(site_packages, name), ignore_errors=True)
|
|
print(f" Kept onnxruntime-gpu=={version}; removed CPU dist-info: {', '.join(cpu)}")
|
|
PYONNX
|
|
|
|
# ── Step 5: Download models ──────────────────────────────────────────────
|
|
echo "=== Downloading models ==="
|
|
python3 << 'PYMODELS'
|
|
import hashlib, hmac, json, os, re, sys, urllib.request
|
|
|
|
with open("/app/docker/feature-manifest.json") as f:
|
|
manifest = json.load(f)
|
|
|
|
bundle = manifest["bundles"][os.environ["BUNDLE_ID"]]
|
|
models = bundle.get("models", [])
|
|
models_dir = os.environ["MODELS_DIR"]
|
|
|
|
# Point rembg's model home at the staging dir so downloaded ONNX models
|
|
# end up inside the tarball (default U2NET_HOME is outside MODELS_DIR).
|
|
rembg_home = os.path.join(models_dir, "rembg")
|
|
os.makedirs(rembg_home, exist_ok=True)
|
|
os.environ["U2NET_HOME"] = rembg_home
|
|
|
|
if not models:
|
|
print(" No models to download")
|
|
sys.exit(0)
|
|
|
|
print(f" Downloading {len(models)} model(s)")
|
|
|
|
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
REVISION_RE = re.compile(r"^[a-f0-9]{40}$")
|
|
|
|
|
|
def verify_sha256(path, expected, model_id):
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
actual = digest.hexdigest()
|
|
if hmac.compare_digest(actual, expected):
|
|
return
|
|
try:
|
|
os.unlink(path)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise RuntimeError(
|
|
f"{model_id} SHA-256 mismatch: expected {expected}, got {actual}"
|
|
)
|
|
|
|
|
|
def verify_min_size(path, min_size, model_id):
|
|
size = os.path.getsize(path)
|
|
if min_size and size < min_size:
|
|
raise RuntimeError(
|
|
f"{model_id} is {size:,} bytes, expected at least {min_size:,}"
|
|
)
|
|
return size
|
|
|
|
for model in models:
|
|
model_id = model["id"]
|
|
download_fn = model.get("downloadFn")
|
|
url = model.get("url")
|
|
expected_sha256 = model.get("sha256")
|
|
|
|
if url:
|
|
if not isinstance(expected_sha256, str) or not SHA256_RE.fullmatch(expected_sha256):
|
|
raise RuntimeError(f"{model_id} must declare a lowercase SHA-256 digest")
|
|
# Direct URL download via urllib
|
|
dest = os.path.join(models_dir, model["path"])
|
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
print(f" [{model_id}] URL -> {model['path']}", flush=True)
|
|
urllib.request.urlretrieve(url, dest)
|
|
size = verify_min_size(dest, model.get("minSize", 0), model_id)
|
|
verify_sha256(dest, expected_sha256, model_id)
|
|
print(f" [{model_id}] Done ({size:,} bytes)")
|
|
|
|
elif download_fn == "hf_snapshot":
|
|
from huggingface_hub import snapshot_download
|
|
args = model["args"]
|
|
repo_id = args[0]
|
|
local_dir = os.path.join(models_dir, args[1])
|
|
revision = model.get("revision")
|
|
if not isinstance(revision, str) or not REVISION_RE.fullmatch(revision):
|
|
raise RuntimeError(f"{model_id} must pin a 40-character Hugging Face commit")
|
|
|
|
kwargs = {"repo_id": repo_id, "local_dir": local_dir}
|
|
kwargs["revision"] = revision
|
|
|
|
# Restrict the snapshot when specified. "file" pins one file (single-file
|
|
# models like an ONNX weight); "allowPatterns" narrows a multi-file model
|
|
# (e.g. a diffusers pipeline) to the fp16 weight variant + configs so the
|
|
# bundle does not ship unused fp32/.bin weights.
|
|
if "file" in model:
|
|
kwargs["allow_patterns"] = [model["file"]]
|
|
elif "allowPatterns" in model:
|
|
kwargs["allow_patterns"] = model["allowPatterns"]
|
|
|
|
# Handle non-default repo types (e.g. "space")
|
|
if "repoType" in model:
|
|
kwargs["repo_type"] = model["repoType"]
|
|
|
|
print(f" [{model_id}] HF snapshot: {repo_id} -> {args[1]}", flush=True)
|
|
snapshot_download(**kwargs)
|
|
expected_path = model.get("path")
|
|
if expected_path:
|
|
verify_min_size(
|
|
os.path.join(models_dir, expected_path),
|
|
model.get("minSize", 0),
|
|
model_id,
|
|
)
|
|
print(f" [{model_id}] Done")
|
|
|
|
elif download_fn == "rembg_session":
|
|
if not isinstance(expected_sha256, str) or not SHA256_RE.fullmatch(expected_sha256):
|
|
raise RuntimeError(f"{model_id} must declare a lowercase SHA-256 digest")
|
|
args = model["args"]
|
|
session_name = args[0]
|
|
dest = os.path.join(rembg_home, f"{session_name}.onnx")
|
|
print(f" [{model_id}] rembg session: {session_name}", flush=True)
|
|
|
|
# birefnet-matting and birefnet-hr-matting are custom sessions
|
|
# registered at runtime in remove_bg.py (not in rembg's built-in
|
|
# session registry). new_session() cannot resolve them, so
|
|
# download their ONNX files directly using the same URLs and
|
|
# filenames the custom session classes use.
|
|
CUSTOM_BIREFNET = {
|
|
"birefnet-matting": "https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet-matting-epoch_100.onnx",
|
|
"birefnet-hr-matting": "https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
|
|
}
|
|
if session_name in CUSTOM_BIREFNET:
|
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
print(f" [{model_id}] Custom BiRefNet -> rembg/{session_name}.onnx", flush=True)
|
|
urllib.request.urlretrieve(CUSTOM_BIREFNET[session_name], dest)
|
|
else:
|
|
from rembg import new_session
|
|
# Force CPU-only provider: onnxruntime-gpu segfaults trying to load
|
|
# TensorRT libs that aren't present in build containers (no GPU).
|
|
new_session(session_name, providers=["CPUExecutionProvider"])
|
|
size = os.path.getsize(dest)
|
|
verify_sha256(dest, expected_sha256, model_id)
|
|
print(f" [{model_id}] Done ({size:,} bytes)")
|
|
|
|
else:
|
|
raise RuntimeError(f"Unknown download method for {model_id}")
|
|
|
|
print(" Model downloads complete")
|
|
PYMODELS
|
|
|
|
# ── Step 6: Diff site-packages ────────────────────────────────────────────
|
|
echo "=== Diffing site-packages ==="
|
|
find "${SITE_PACKAGES}" -maxdepth 1 -mindepth 1 | sort > /tmp/after-packages.txt
|
|
DELTA_DIRS="$(comm -13 /tmp/base-packages.txt /tmp/after-packages.txt)"
|
|
DELTA_COUNT="$(echo "${DELTA_DIRS}" | grep -c . || true)"
|
|
echo " New top-level dirs: ${DELTA_COUNT}"
|
|
|
|
if [[ "${DELTA_COUNT}" -eq 0 ]]; then
|
|
echo "WARNING: No new site-packages detected. Bundle may be empty." >&2
|
|
fi
|
|
|
|
# Copy delta dirs to build staging
|
|
while IFS= read -r dir; do
|
|
[[ -z "${dir}" ]] && continue
|
|
cp -a "${dir}" "${BUILD_DIR}/site-packages/"
|
|
done <<< "${DELTA_DIRS}"
|
|
|
|
# Copy models to build staging
|
|
if [[ -d "${MODELS_DIR}" ]] && [[ -n "$(ls -A "${MODELS_DIR}" 2>/dev/null)" ]]; then
|
|
cp -a "${MODELS_DIR}"/* "${BUILD_DIR}/models/"
|
|
fi
|
|
|
|
# ── Step 7: Torch NCCL fixup ─────────────────────────────────────────────
|
|
echo "=== Checking for torch NCCL fixup ==="
|
|
python3 << 'PYNCCL'
|
|
import importlib.metadata, subprocess, os, sys
|
|
|
|
try:
|
|
reqs = importlib.metadata.requires("torch")
|
|
except importlib.metadata.PackageNotFoundError:
|
|
print(" torch not installed, skipping NCCL check")
|
|
sys.exit(0)
|
|
|
|
if reqs is None:
|
|
print(" No torch requirements found")
|
|
sys.exit(0)
|
|
|
|
nccl_pkgs = [r.split(";")[0].strip() for r in reqs if "nccl" in r.lower()]
|
|
if not nccl_pkgs:
|
|
print(" No NCCL requirements found")
|
|
sys.exit(0)
|
|
|
|
fixups_dir = os.path.join(os.environ["BUILD_DIR"], "fixups")
|
|
os.makedirs(fixups_dir, exist_ok=True)
|
|
|
|
for pkg in nccl_pkgs:
|
|
print(f" Downloading NCCL wheel: {pkg}", flush=True)
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "pip", "download", "--no-cache-dir", "-d", fixups_dir, pkg]
|
|
)
|
|
if result.returncode != 0:
|
|
print(f" WARNING: Failed to download NCCL wheel: {pkg}", file=sys.stderr)
|
|
|
|
print(" NCCL fixup complete")
|
|
PYNCCL
|
|
|
|
# ── Step 8: Write bundle.json ─────────────────────────────────────────────
|
|
echo "=== Writing bundle.json ==="
|
|
python3 << 'PYBUNDLE'
|
|
import json, os, sys
|
|
|
|
with open("/app/docker/feature-manifest.json") as f:
|
|
manifest = json.load(f)
|
|
|
|
bundle_id = os.environ["BUNDLE_ID"]
|
|
arch = os.environ["ARCH"]
|
|
bundle = manifest["bundles"][bundle_id]
|
|
model_ids = [m["id"] for m in bundle.get("models", [])]
|
|
|
|
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
|
|
# Uncompressed bundle size: BUILD_DIR holds site-packages + models at this point.
|
|
# Surfaces the real extractedSize for docker/feature-manifest.json (used by the
|
|
# disk-space pre-check in install_feature.py); several manifest entries were 0 before.
|
|
extracted_size = sum(
|
|
os.path.getsize(os.path.join(root, name))
|
|
for root, _dirs, files in os.walk(os.environ["BUILD_DIR"])
|
|
for name in files
|
|
)
|
|
|
|
bundle_meta = {
|
|
"bundleId": bundle_id,
|
|
"version": manifest["imageVersion"],
|
|
"arch": arch,
|
|
"imageVersion": manifest["imageVersion"],
|
|
"pythonVersion": py_ver,
|
|
"models": model_ids,
|
|
"extractedSize": extracted_size,
|
|
}
|
|
|
|
out_path = os.path.join(os.environ["BUILD_DIR"], "bundle.json")
|
|
with open(out_path, "w") as f:
|
|
json.dump(bundle_meta, f, indent=2)
|
|
f.write("\n")
|
|
|
|
print(f" {json.dumps(bundle_meta, indent=2)}")
|
|
PYBUNDLE
|
|
|
|
# ── Step 9: Create archive ────────────────────────────────────────────────
|
|
echo "=== Creating archive ==="
|
|
ARCHIVE_NAME="${BUNDLE_ID}-${ARCH}.tar.gz"
|
|
ARCHIVE_PATH="${OUTPUT_DIR}/${ARCHIVE_NAME}"
|
|
|
|
find "${BUILD_DIR}" -mindepth 1 -printf '%P\0' \
|
|
| LC_ALL=C sort -z \
|
|
| tar \
|
|
--create \
|
|
--file=- \
|
|
--directory="${BUILD_DIR}" \
|
|
--sort=name \
|
|
--mtime="@${SOURCE_DATE_EPOCH}" \
|
|
--owner=0 \
|
|
--group=0 \
|
|
--numeric-owner \
|
|
--format=gnu \
|
|
--no-recursion \
|
|
--null \
|
|
--verbatim-files-from \
|
|
--files-from=- \
|
|
| gzip -n > "${ARCHIVE_PATH}"
|
|
|
|
sha256sum "${ARCHIVE_PATH}" | awk '{print $1}' > "${ARCHIVE_PATH}.sha256"
|
|
|
|
ARCHIVE_SIZE="$(stat -c%s "${ARCHIVE_PATH}" 2>/dev/null || stat -f%z "${ARCHIVE_PATH}")"
|
|
SHA256="$(cat "${ARCHIVE_PATH}.sha256")"
|
|
|
|
echo " Archive: ${ARCHIVE_PATH}"
|
|
echo " Size: ${ARCHIVE_SIZE} bytes"
|
|
echo " SHA256: ${SHA256}"
|
|
|
|
# ── Cleanup ───────────────────────────────────────────────────────────────
|
|
echo "=== Cleaning up ==="
|
|
rm -rf "${MODELS_DIR}" "${BUILD_DIR}" /tmp/base-packages.txt /tmp/after-packages.txt
|
|
|
|
echo "=== Done: ${BUNDLE_ID} ${ARCH} ==="
|