Files
SnapOtter/docker/build-bundle.sh
T

412 lines
16 KiB
Bash
Raw Normal View History

2026-06-13 16:41:31 +08:00
#!/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>}"
MANIFEST="/app/docker/feature-manifest.json"
VENV_PATH="${PYTHON_VENV_PATH:-/opt/venv}"
2026-06-13 16:41:31 +08:00
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])')"
2026-06-13 16:41:31 +08:00
# 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}"
2026-06-13 16:41:31 +08:00
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)
"
# 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
2026-06-13 16:41:31 +08:00
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()
2026-06-13 16:41:31 +08:00
print(f" > {cmd}", flush=True)
result = subprocess.run(shlex.split(cmd))
2026-06-13 16:41:31 +08:00
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
2026-06-13 16:41:31 +08:00
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}"
2026-06-13 16:41:31 +08:00
print(f" > {cmd}", flush=True)
result = subprocess.run(shlex.split(cmd))
2026-06-13 16:41:31 +08:00
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
2026-06-13 16:41:31 +08:00
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}"
2026-06-13 16:41:31 +08:00
print(f" > {cmd}", flush=True)
result = subprocess.run(shlex.split(cmd))
2026-06-13 16:41:31 +08:00
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
2026-06-13 16:41:31 +08:00
# ── Step 5: Download models ──────────────────────────────────────────────
echo "=== Downloading models ==="
python3 << 'PYMODELS'
import json, os, sys, urllib.request, pathlib
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
2026-06-13 16:41:31 +08:00
if not models:
print(" No models to download")
sys.exit(0)
print(f" Downloading {len(models)} model(s)")
for model in models:
model_id = model["id"]
download_fn = model.get("downloadFn")
url = model.get("url")
if url:
# 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 = os.path.getsize(dest)
min_size = model.get("minSize", 0)
if min_size and size < min_size:
print(f" WARNING: {model_id} is {size:,} bytes, expected >= {min_size:,}", file=sys.stderr)
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])
kwargs = {"repo_id": repo_id, "local_dir": local_dir}
# Only download specific file if specified
if "file" in model:
kwargs["allow_patterns"] = [model["file"]]
# 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)
print(f" [{model_id}] Done")
elif download_fn == "rembg_session":
args = model["args"]
session_name = args[0]
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:
dest = os.path.join(models_dir, "rembg", f"{session_name}.onnx")
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)
size = os.path.getsize(dest)
print(f" [{model_id}] Done ({size:,} bytes)")
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"])
print(f" [{model_id}] Done")
2026-06-13 16:41:31 +08:00
else:
print(f" WARNING: Unknown download method for {model_id}", file=sys.stderr)
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]
2026-06-13 16:41:31 +08:00
)
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
)
2026-06-13 16:41:31 +08:00
bundle_meta = {
"bundleId": bundle_id,
"version": manifest["imageVersion"],
"arch": arch,
"imageVersion": manifest["imageVersion"],
"pythonVersion": py_ver,
"models": model_ids,
"extractedSize": extracted_size,
2026-06-13 16:41:31 +08:00
}
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}"
tar -czf "${ARCHIVE_PATH}" -C "${BUILD_DIR}" .
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} ==="