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.
1309 lines
53 KiB
Python
1309 lines
53 KiB
Python
"""Pre-built AI bundle installer for SnapOtter.
|
|
|
|
Downloads a pre-built tar.gz archive (or uses a local file), verifies its
|
|
SHA256 checksum, extracts site-packages and models, and writes installed.json.
|
|
|
|
Invoked by the Node.js backend as a subprocess.
|
|
|
|
Usage:
|
|
python3 install_feature.py <bundleId> <manifestPath> <modelsDir>
|
|
|
|
Progress is reported via JSON lines on stderr (parsed by the Node bridge).
|
|
Final result is a JSON object on stdout.
|
|
"""
|
|
|
|
import csv
|
|
import errno
|
|
import glob
|
|
import hashlib
|
|
import importlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
DOWNLOAD_CHUNK_BYTES = 4 * 1024 * 1024
|
|
DOWNLOAD_META_BYTES = 64 * 1024 * 1024
|
|
|
|
|
|
# -- Helpers --
|
|
|
|
def emit_progress(percent: int, stage: str) -> None:
|
|
"""Emit a progress update via stderr JSON line."""
|
|
sys.stderr.write(json.dumps({"progress": percent, "stage": stage}) + "\n")
|
|
sys.stderr.flush()
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
"""Print error to stderr and exit non-zero."""
|
|
sys.stderr.write(json.dumps({"error": message}) + "\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
|
|
|
|
# -- Architecture detection --
|
|
|
|
def detect_arch() -> str:
|
|
"""Return the bundle archive key for this host.
|
|
|
|
Only two archive variants are currently published to the bundle repo:
|
|
'amd64-gpu' and 'arm64-cpu' (see deepsafe/feature-bundles). There is no
|
|
CPU-only amd64 variant yet, so amd64 hosts always resolve to 'amd64-gpu'
|
|
even when no GPU is present: this downloads working CUDA-capable
|
|
packages, just larger than a CPU-only host strictly needs. Do not change
|
|
this to branch on GPU presence without first publishing an 'amd64-cpu'
|
|
archive for every bundle; requesting a key that doesn't exist in the
|
|
manifest fails the install outright (see the archives.get(arch) lookup
|
|
below), which would be worse than the current oversized-but-working
|
|
download.
|
|
"""
|
|
machine = platform.machine().lower()
|
|
if machine in ("aarch64", "arm64"):
|
|
return "arm64-cpu"
|
|
return "amd64-gpu"
|
|
|
|
|
|
# -- Disk space --
|
|
|
|
def _existing_ancestor(path: str) -> str:
|
|
"""Nearest existing ancestor of path (so disk_usage never raises on a
|
|
not-yet-created dir like the venv)."""
|
|
p = os.path.abspath(path)
|
|
while p and not os.path.exists(p):
|
|
parent = os.path.dirname(p)
|
|
if parent == p:
|
|
break
|
|
p = parent
|
|
return p or "/"
|
|
|
|
|
|
def check_disk_space(path: str, needed_bytes: int) -> None:
|
|
"""Fail if insufficient disk space on the filesystem holding path."""
|
|
usage = shutil.disk_usage(_existing_ancestor(path))
|
|
if usage.free < needed_bytes:
|
|
free_gb = usage.free / (1024 ** 3)
|
|
need_gb = needed_bytes / (1024 ** 3)
|
|
fail(
|
|
f"Insufficient disk space: need {need_gb:.1f} GB, "
|
|
f"have {free_gb:.1f} GB free. "
|
|
f"Free up space and retry."
|
|
)
|
|
|
|
|
|
def estimate_extracted(compressed: int, extracted: int) -> int:
|
|
"""Extracted-size estimate for the disk preflight. When the manifest omits
|
|
extractedSize (0), the budget would otherwise collapse to just the
|
|
compressed size and under-reserve for the extracted payload; fall back to a
|
|
conservative 3x of compressed (measured extracted/compressed ratios reach
|
|
~3x). This is only the early sanity bail; the accurate guard is the
|
|
real-on-disk re-check just before the destructive venv write."""
|
|
return extracted if extracted > 0 else compressed * 3
|
|
|
|
|
|
def dir_size(path: str) -> int:
|
|
"""Total size in bytes of all files under path (best-effort)."""
|
|
total = 0
|
|
for root, _dirs, files in os.walk(path):
|
|
for f in files:
|
|
try:
|
|
total += os.path.getsize(os.path.join(root, f))
|
|
except OSError:
|
|
pass
|
|
return total
|
|
|
|
|
|
def same_filesystem(a: str, b: str) -> bool:
|
|
"""True if paths a and b live on the same filesystem (so a rename between
|
|
them is a cheap metadata op rather than a full copy)."""
|
|
try:
|
|
return os.stat(_existing_ancestor(a)).st_dev == os.stat(_existing_ancestor(b)).st_dev
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
# -- Venv site-packages discovery --
|
|
|
|
def get_site_packages_dir(venv_path: str) -> str:
|
|
"""Find the site-packages directory inside a Python venv."""
|
|
matches = glob.glob(os.path.join(venv_path, "lib", "python*", "site-packages"))
|
|
if matches:
|
|
return matches[0]
|
|
return ""
|
|
|
|
|
|
# -- SHA256 verification --
|
|
|
|
def verify_sha256(filepath: str, expected: str) -> bool:
|
|
"""Stream-hash a file and compare to expected hex digest."""
|
|
h = hashlib.sha256()
|
|
with open(filepath, "rb") as f:
|
|
while True:
|
|
chunk = f.read(8192)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest() == expected
|
|
|
|
|
|
# -- Download with resume --
|
|
|
|
def _set_env_temporarily(key: str, value: str):
|
|
previous = os.environ.get(key)
|
|
os.environ[key] = value
|
|
return previous
|
|
|
|
|
|
def _restore_env(key: str, previous) -> None:
|
|
if previous is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = previous
|
|
|
|
|
|
def _cleanup_hf_local_dir(local_dir: str, archive_file: str) -> None:
|
|
if "/" in archive_file:
|
|
top_level = archive_file.split("/", 1)[0]
|
|
shutil.rmtree(os.path.join(local_dir, top_level), ignore_errors=True)
|
|
shutil.rmtree(os.path.join(local_dir, ".cache"), ignore_errors=True)
|
|
|
|
|
|
def ensure_hf_hub(venv_path: str) -> None:
|
|
"""Guarantee the accelerated Hugging Face client is importable before the
|
|
download so the multi-GB bundle transfer takes the fast Xet path.
|
|
|
|
The installer runs under the on-disk venv (PYTHON_VENV_PATH, i.e.
|
|
/data/ai/venv in Docker). That venv is normally seeded from the image's
|
|
/opt/venv, which bakes huggingface-hub[hf_xet]. But an install whose venv
|
|
predates the base package (an upgrade where the reseed stamp didn't move, a
|
|
hand-copied or offline-imported venv) would import-fail in
|
|
download_with_hf_hub and silently fall back to the slow single-stream urllib
|
|
downloader. Self-heal by pip-installing the client into this same venv.
|
|
|
|
A bundle install already requires network and lifts the offline guard (see
|
|
main()), so this adds no new offline dependency; if the pip install fails we
|
|
fall through to the resumable urllib downloader, the correct degraded path.
|
|
"""
|
|
try:
|
|
import huggingface_hub # noqa: F401
|
|
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
python_path = os.path.join(venv_path, "bin", "python3")
|
|
if not os.path.exists(python_path):
|
|
return
|
|
|
|
emit_progress(1, "Preparing accelerated download client...")
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
python_path, "-m", "pip", "install", "--quiet",
|
|
"huggingface-hub[hf_xet,hf_transfer]==0.36.2",
|
|
],
|
|
capture_output=True, text=True, timeout=300, check=True,
|
|
)
|
|
# The finder caches the venv's site-packages listing; drop it so the
|
|
# just-installed package is visible to the import in download_with_hf_hub.
|
|
importlib.invalidate_caches()
|
|
except Exception as e:
|
|
emit_progress(1, f"Accelerated client unavailable ({e}); using resumable download.")
|
|
|
|
|
|
def download_with_hf_hub(
|
|
bundle_repo: str,
|
|
archive_file: str,
|
|
dest: str,
|
|
expected_size: int,
|
|
progress_start: int,
|
|
progress_end: int,
|
|
force_download: bool = False,
|
|
) -> bool:
|
|
"""Download through huggingface_hub when available.
|
|
|
|
huggingface_hub 0.32+ can use hf_xet for faster large-file transfers and
|
|
manages retries/resume internally. Return False when the client is missing
|
|
or fails so callers can fall back to the manual urllib downloader.
|
|
"""
|
|
try:
|
|
from huggingface_hub import hf_hub_download
|
|
except Exception:
|
|
return False
|
|
|
|
# Enable hf_transfer (Rust multi-connection downloader) ONLY when the
|
|
# package is actually importable. For a Xet-backed repo hf_xet takes
|
|
# precedence and this is a no-op, but if the Xet CAS endpoint is unreachable
|
|
# (e.g. a firewall that allows huggingface.co but blocks transfer.xethub.hf.co)
|
|
# hf_hub_download falls back to plain HTTP, and hf_transfer makes that
|
|
# fallback multi-connection instead of single-stream. Gating on the import
|
|
# avoids the "HF_HUB_ENABLE_HF_TRANSFER set but package missing" hard error
|
|
# on a venv that only has hf_xet.
|
|
try:
|
|
import hf_transfer # noqa: F401
|
|
|
|
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
|
|
except Exception:
|
|
pass
|
|
|
|
local_dir = os.path.dirname(dest)
|
|
os.makedirs(local_dir, exist_ok=True)
|
|
emit_progress(progress_start, "Downloading with accelerated Hugging Face client...")
|
|
|
|
previous_progress = _set_env_temporarily("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
try:
|
|
downloaded_path = hf_hub_download(
|
|
repo_id=bundle_repo,
|
|
filename=archive_file,
|
|
repo_type="model",
|
|
local_dir=local_dir,
|
|
force_download=force_download,
|
|
)
|
|
except Exception as e:
|
|
emit_progress(
|
|
progress_start,
|
|
f"Accelerated download unavailable, using resumable fallback: {e}",
|
|
)
|
|
# Reclaim any partial blob/metadata hf_hub_download staged under
|
|
# local_dir/.cache so the urllib fallback starts clean and disk is freed.
|
|
_cleanup_hf_local_dir(local_dir, archive_file)
|
|
return False
|
|
finally:
|
|
_restore_env("HF_HUB_DISABLE_PROGRESS_BARS", previous_progress)
|
|
|
|
try:
|
|
if not os.path.exists(downloaded_path):
|
|
emit_progress(
|
|
progress_start,
|
|
"Accelerated download did not produce an archive, using resumable fallback...",
|
|
)
|
|
return False
|
|
|
|
if os.path.abspath(downloaded_path) != os.path.abspath(dest):
|
|
if os.path.exists(dest):
|
|
os.unlink(dest)
|
|
os.replace(downloaded_path, dest)
|
|
|
|
size = os.path.getsize(dest)
|
|
if expected_size > 0:
|
|
pct = min(size / expected_size, 1.0)
|
|
progress = int(progress_start + pct * (progress_end - progress_start))
|
|
else:
|
|
progress = progress_end
|
|
emit_progress(
|
|
min(progress, progress_end),
|
|
f"Downloaded with accelerated client ({size / (1024**3):.1f} GB)",
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
emit_progress(
|
|
progress_start,
|
|
f"Accelerated download post-processing failed, using resumable fallback: {e}",
|
|
)
|
|
return False
|
|
finally:
|
|
# Always drop the hf staging tree (local_dir/<top>, local_dir/.cache).
|
|
# On success the archive is already moved to dest; on any failure this
|
|
# stops the transient hf cache copy from leaking across the fallback.
|
|
_cleanup_hf_local_dir(local_dir, archive_file)
|
|
|
|
|
|
def download_with_resume(
|
|
url: str,
|
|
dest: str,
|
|
expected_size: int,
|
|
progress_start: int,
|
|
progress_end: int,
|
|
) -> None:
|
|
"""Download a file with resume support via Range headers.
|
|
|
|
Uses .partial and .meta sidecar files for crash recovery.
|
|
"""
|
|
partial_path = dest + ".partial"
|
|
meta_path = dest + ".meta"
|
|
|
|
# Check for existing partial download
|
|
bytes_downloaded = 0
|
|
if os.path.exists(partial_path) and os.path.exists(meta_path):
|
|
try:
|
|
with open(meta_path, "r") as f:
|
|
meta = json.load(f)
|
|
bytes_downloaded = meta.get("bytesDownloaded", 0)
|
|
if bytes_downloaded > 0:
|
|
actual_size = os.path.getsize(partial_path)
|
|
if actual_size != bytes_downloaded:
|
|
bytes_downloaded = 0 # Mismatch, restart
|
|
except (json.JSONDecodeError, OSError):
|
|
bytes_downloaded = 0
|
|
|
|
if bytes_downloaded == 0 and os.path.exists(partial_path):
|
|
os.unlink(partial_path)
|
|
|
|
def _cleanup_partial() -> None:
|
|
for p in (partial_path, meta_path):
|
|
if os.path.exists(p):
|
|
try:
|
|
os.unlink(p)
|
|
except OSError:
|
|
pass
|
|
|
|
max_retries = 5
|
|
for attempt in range(max_retries):
|
|
try:
|
|
headers = {"User-Agent": "snapotter-installer/2.0"}
|
|
if bytes_downloaded > 0:
|
|
headers["Range"] = f"bytes={bytes_downloaded}-"
|
|
emit_progress(
|
|
progress_start,
|
|
f"Resuming download from {bytes_downloaded / (1024**3):.1f} GB...",
|
|
)
|
|
|
|
req = urllib.request.Request(url, headers=headers)
|
|
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
status = getattr(resp, "status", None) or resp.getcode()
|
|
# If we asked to resume (sent a Range) but the server sent the
|
|
# whole file back (200 instead of 206 Partial Content -- a proxy
|
|
# or CDN that ignores Range), restart from byte 0. Appending a
|
|
# full body onto the existing partial would corrupt the archive
|
|
# and fail the checksum on every retry.
|
|
if bytes_downloaded > 0 and status != 206:
|
|
bytes_downloaded = 0
|
|
mode = "ab" if bytes_downloaded > 0 else "wb"
|
|
next_meta_at = bytes_downloaded + DOWNLOAD_META_BYTES
|
|
with open(partial_path, mode) as f:
|
|
while True:
|
|
chunk = resp.read(DOWNLOAD_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk)
|
|
bytes_downloaded += len(chunk)
|
|
|
|
# Update progress
|
|
if expected_size > 0:
|
|
pct = bytes_downloaded / expected_size
|
|
progress = int(
|
|
progress_start + pct * (progress_end - progress_start)
|
|
)
|
|
progress = min(progress, progress_end)
|
|
stage = f"Downloading... {bytes_downloaded / (1024**3):.1f} GB"
|
|
emit_progress(progress, stage)
|
|
|
|
# Write meta periodically so a crash can resume.
|
|
if bytes_downloaded >= next_meta_at:
|
|
with open(meta_path, "w") as mf:
|
|
json.dump({"bytesDownloaded": bytes_downloaded}, mf)
|
|
next_meta_at = bytes_downloaded + DOWNLOAD_META_BYTES
|
|
|
|
# Guard against a truncated body or an error page served as the
|
|
# archive: the completed size must match what the manifest expects.
|
|
# A mismatch is retryable (transient truncation / a stale CDN edge).
|
|
if expected_size > 0 and bytes_downloaded != expected_size:
|
|
raise RuntimeError(
|
|
f"incomplete download: got {bytes_downloaded} bytes, "
|
|
f"expected {expected_size} (truncated response or error page)"
|
|
)
|
|
|
|
# Download complete
|
|
os.rename(partial_path, dest)
|
|
if os.path.exists(meta_path):
|
|
os.unlink(meta_path)
|
|
return
|
|
|
|
except urllib.error.HTTPError as e:
|
|
# HTTPError subclasses OSError, so it MUST be caught before the
|
|
# OSError clause below. 4xx (except 408 Timeout / 429 Too Many
|
|
# Requests) won't fix on retry -- a wrong URL, a private repo, or a
|
|
# removed archive -- so fail fast with the manual-download hint.
|
|
if 400 <= e.code < 500 and e.code not in (408, 429):
|
|
_cleanup_partial()
|
|
raise RuntimeError(
|
|
f"Download failed with HTTP {e.code} ({e.reason}). The archive "
|
|
f"URL may be wrong or access-restricted."
|
|
)
|
|
_retry_or_raise(e, attempt, max_retries, bytes_downloaded, meta_path,
|
|
progress_start, _cleanup_partial)
|
|
except OSError as e:
|
|
# Disk full is not transient: retrying can't create space. Fail fast
|
|
# with an actionable message instead of burning the backoff budget.
|
|
# (URLError/connection errors also land here; their errno is None, so
|
|
# they fall through to the retry path.)
|
|
if getattr(e, "errno", None) == errno.ENOSPC:
|
|
_cleanup_partial()
|
|
raise RuntimeError(
|
|
"Ran out of disk space while downloading the bundle. "
|
|
"Free up space and retry."
|
|
)
|
|
_retry_or_raise(e, attempt, max_retries, bytes_downloaded, meta_path,
|
|
progress_start, _cleanup_partial)
|
|
except Exception as e:
|
|
_retry_or_raise(e, attempt, max_retries, bytes_downloaded, meta_path,
|
|
progress_start, _cleanup_partial)
|
|
|
|
|
|
def _retry_or_raise(err, attempt, max_retries, bytes_downloaded, meta_path,
|
|
progress_start, cleanup) -> None:
|
|
"""Shared transient-failure handler for download_with_resume: persist resume
|
|
metadata and back off, or clean up and raise on the final attempt."""
|
|
try:
|
|
with open(meta_path, "w") as mf:
|
|
json.dump({"bytesDownloaded": bytes_downloaded}, mf)
|
|
except OSError:
|
|
pass
|
|
|
|
if attempt < max_retries - 1:
|
|
delay = min(60, 5 * (2 ** attempt))
|
|
emit_progress(
|
|
progress_start,
|
|
f"Download failed (attempt {attempt + 1}/{max_retries}), "
|
|
f"retrying in {delay}s: {err}",
|
|
)
|
|
time.sleep(delay)
|
|
else:
|
|
cleanup()
|
|
raise RuntimeError(f"Failed to download after {max_retries} attempts: {err}")
|
|
|
|
|
|
# -- Safe tar extraction --
|
|
|
|
def safe_extract(tar_path: str, staging_dir: str) -> None:
|
|
"""Extract a tar.gz with security guards."""
|
|
os.makedirs(staging_dir, exist_ok=True)
|
|
with tarfile.open(tar_path, "r:gz") as tf:
|
|
for member in tf.getmembers():
|
|
# Block symlinks, hardlinks, devices
|
|
if not member.isfile() and not member.isdir():
|
|
raise RuntimeError(f"Blocked unsafe tar entry type: {member.name}")
|
|
# Block absolute paths and traversal
|
|
if member.name.startswith("/") or ".." in member.name.split("/"):
|
|
raise RuntimeError(f"Blocked unsafe tar path: {member.name}")
|
|
# The filter= kwarg was added in Python 3.12; the manual guards above
|
|
# already block unsafe entries on older interpreters (e.g. 3.11).
|
|
if sys.version_info >= (3, 12):
|
|
tf.extractall(staging_dir, filter="data")
|
|
else:
|
|
tf.extractall(staging_dir)
|
|
|
|
|
|
# -- File move --
|
|
|
|
def move_tree(src: str, dst: str) -> None:
|
|
"""Merge src into dst, replacing entries crash-atomically where possible.
|
|
|
|
This writes into the SHARED /data/ai/venv site-packages, so a crash mid-move
|
|
must never leave a package in a half-replaced state (that tears the venv and
|
|
breaks every other AI tool). For a file replacing a file, os.replace swaps in
|
|
place with NO delete-then-write window, so an interruption leaves either the
|
|
old or the new file intact, never a missing one. Cross-filesystem copies go
|
|
through a temp sibling then an atomic rename for the same reason. Renames
|
|
(vs copytree) also avoid transiently doubling the payload on disk."""
|
|
if not os.path.isdir(src):
|
|
return
|
|
os.makedirs(dst, exist_ok=True)
|
|
for name in os.listdir(src):
|
|
s = os.path.join(src, name)
|
|
d = os.path.join(dst, name)
|
|
if os.path.isdir(s) and os.path.isdir(d):
|
|
# Both dirs exist: merge recursively rather than replace.
|
|
move_tree(s, d)
|
|
continue
|
|
try:
|
|
# A type mismatch (dir<->file) can't be atomically swapped by rename,
|
|
# so clear the destination first. A file-over-file or new entry needs
|
|
# no pre-delete: os.replace is atomic and leaves no torn window.
|
|
if os.path.exists(d) and os.path.isdir(d) != os.path.isdir(s):
|
|
if os.path.isdir(d):
|
|
shutil.rmtree(d)
|
|
else:
|
|
os.remove(d)
|
|
os.replace(s, d)
|
|
continue
|
|
except OSError as e:
|
|
if getattr(e, "errno", None) != errno.EXDEV:
|
|
raise
|
|
# Cross-filesystem: rename isn't allowed. Copy to a temp sibling and then
|
|
# atomically replace, so a mid-copy ENOSPC never leaves a truncated file
|
|
# where a working one used to be.
|
|
if os.path.isdir(s):
|
|
if os.path.exists(d):
|
|
shutil.rmtree(d)
|
|
shutil.copytree(s, d)
|
|
else:
|
|
tmp = d + ".part"
|
|
shutil.copy2(s, tmp)
|
|
os.replace(tmp, d)
|
|
# Remove whatever remains of src (emptied by renames, or copied originals).
|
|
shutil.rmtree(src, ignore_errors=True)
|
|
|
|
|
|
# -- ONNX Runtime flavor reconciliation (GPU wins) --
|
|
|
|
def _onnx_dist_infos(sp_dir: str) -> tuple:
|
|
"""Return (cpu, gpu) lists of onnxruntime dist-info directory names.
|
|
|
|
Wheel metadata dirs are `<normalized-name>-<version>.dist-info`, and name
|
|
normalization turns every other onnxruntime distribution into
|
|
`onnxruntime_<suffix>` (onnxruntime-gpu -> onnxruntime_gpu), so a directory
|
|
starting with exactly "onnxruntime-" can only be the CPU build.
|
|
"""
|
|
if not os.path.isdir(sp_dir):
|
|
return ([], [])
|
|
names = os.listdir(sp_dir)
|
|
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"))
|
|
return (cpu, gpu)
|
|
|
|
|
|
def reconcile_onnxruntime(staging_sp: str, site_packages_dir: str) -> None:
|
|
"""Never let a bundle downgrade the venv's ONNX Runtime from GPU to CPU.
|
|
|
|
`onnxruntime` (CPU) and `onnxruntime-gpu` unpack into the SAME package
|
|
directory (`onnxruntime/`), so whichever bundle lands last wins
|
|
file-by-file. A CPU build arriving after the GPU build (e.g. transcription's
|
|
faster-whisper dependency, after background-removal put onnxruntime-gpu in
|
|
place) silently strips CUDAExecutionProvider while the surviving
|
|
onnxruntime_gpu dist-info keeps claiming it is installed (#490). The GPU
|
|
build ships CPUExecutionProvider too, so it satisfies every consumer of the
|
|
CPU build; the reverse is not true. Hence: GPU wins, in both install orders.
|
|
"""
|
|
staging_cpu, staging_gpu = _onnx_dist_infos(staging_sp)
|
|
venv_cpu, venv_gpu = _onnx_dist_infos(site_packages_dir)
|
|
|
|
if staging_cpu and (venv_gpu or staging_gpu):
|
|
# Incoming CPU flavor while a GPU build exists: drop the CPU metadata,
|
|
# and when the staged package files ARE the CPU build (no GPU flavor in
|
|
# this same bundle), drop them too so they cannot clobber the venv's
|
|
# GPU libraries.
|
|
for name in staging_cpu:
|
|
shutil.rmtree(os.path.join(staging_sp, name), ignore_errors=True)
|
|
if not staging_gpu:
|
|
pkg_dir = os.path.join(staging_sp, "onnxruntime")
|
|
if os.path.isdir(pkg_dir):
|
|
shutil.rmtree(pkg_dir, ignore_errors=True)
|
|
sys.stderr.write(
|
|
"[install] kept the GPU build of onnxruntime: dropped this bundle's CPU "
|
|
"onnxruntime so it cannot disable CUDA for other AI tools (#490)\n"
|
|
)
|
|
sys.stderr.flush()
|
|
|
|
if staging_gpu and venv_cpu:
|
|
# Incoming GPU flavor over a CPU install: the GPU files win the merge;
|
|
# clear the venv's stale CPU metadata so pip reflects reality. This also
|
|
# makes reinstalling any GPU bundle repair a venv clobbered before this
|
|
# guard existed.
|
|
for name in venv_cpu:
|
|
shutil.rmtree(os.path.join(site_packages_dir, name), ignore_errors=True)
|
|
sys.stderr.write(
|
|
"[install] replacing the CPU build of onnxruntime with the GPU build; "
|
|
"removed its stale metadata\n"
|
|
)
|
|
sys.stderr.flush()
|
|
|
|
|
|
# -- Distribution reconciliation (one version per distribution) --
|
|
|
|
DIST_INFO_SUFFIX = ".dist-info"
|
|
|
|
|
|
def canonical_dist_name(name: str) -> str:
|
|
"""Normalize a distribution name the way PEP 503 does, so the same project
|
|
compares equal however its wheel spelled it (`hf-xet`, `hf_xet`, `HF.Xet`)."""
|
|
return re.sub(r"[-_.]+", "-", name).lower()
|
|
|
|
|
|
def list_distributions(sp_dir: str) -> dict:
|
|
"""Map canonical distribution name -> [(version, dist-info dir name), ...].
|
|
|
|
A healthy site-packages holds exactly one entry per name. A venv merged by
|
|
an installer that never uninstalls can hold several, which is the corruption
|
|
this section exists to prevent: `move_tree` copies file over file, so the
|
|
two versions overlay and CPython can end up loading one version's compiled
|
|
extension underneath the other's Python modules.
|
|
"""
|
|
found = {}
|
|
if not os.path.isdir(sp_dir):
|
|
return found
|
|
for entry in os.listdir(sp_dir):
|
|
if not entry.endswith(DIST_INFO_SUFFIX):
|
|
continue
|
|
name, sep, version = entry[: -len(DIST_INFO_SUFFIX)].rpartition("-")
|
|
if not sep or not name:
|
|
continue
|
|
found.setdefault(canonical_dist_name(name), []).append((version, entry))
|
|
return found
|
|
|
|
|
|
def distribution_files(sp_dir: str, dist_info: str) -> list:
|
|
"""Relative paths a distribution owns, read from its RECORD.
|
|
|
|
Entries that point outside site-packages (console scripts are recorded as
|
|
`../../bin/<name>`) are skipped: they cannot shadow an import, and deleting
|
|
outside the directory we were handed is not this function's business.
|
|
"""
|
|
record = os.path.join(sp_dir, dist_info, "RECORD")
|
|
if not os.path.exists(record):
|
|
return []
|
|
paths = []
|
|
with open(record, newline="", errors="replace") as f:
|
|
for row in csv.reader(f):
|
|
if not row:
|
|
continue
|
|
rel = row[0].strip().replace("\\", "/")
|
|
if not rel or rel.startswith("/") or ".." in rel.split("/"):
|
|
continue
|
|
paths.append(rel)
|
|
return paths
|
|
|
|
|
|
def paths_claimed_by_others(sp_dir: str, exclude: set) -> set:
|
|
"""Every file some OTHER installed distribution says it owns.
|
|
|
|
Distributions are supposed to own disjoint files, and almost all of them do.
|
|
The exceptions are the ones that package the same import under different
|
|
project names: `opencv-python`, `opencv-python-headless` and
|
|
`opencv-contrib-python` all write `cv2/`, exactly as `onnxruntime` and
|
|
`onnxruntime-gpu` both write `onnxruntime/`. Uninstalling one of those by its
|
|
RECORD would take the shared import away from the siblings that are still
|
|
installed, and the merge only puts back the files of the distribution it is
|
|
placing, so `cv2` would simply vanish.
|
|
|
|
Shared files are therefore left where they are and let the merge overwrite
|
|
them, which is what pip does with these packages too. The version metadata
|
|
still ends up single-valued, which is the invariant that matters.
|
|
"""
|
|
claimed = set()
|
|
if not os.path.isdir(sp_dir):
|
|
return claimed
|
|
for entry in os.listdir(sp_dir):
|
|
if entry.endswith(DIST_INFO_SUFFIX) and entry not in exclude:
|
|
claimed.update(distribution_files(sp_dir, entry))
|
|
return claimed
|
|
|
|
|
|
def _is_inside(root: str, path: str) -> bool:
|
|
return not os.path.relpath(os.path.abspath(path), os.path.abspath(root)).startswith("..")
|
|
|
|
|
|
def _relocate(src_root: str, dst_root: str, rel_path: str) -> bool:
|
|
"""Move one relative entry between two trees, keeping its relative position."""
|
|
src = os.path.join(src_root, rel_path)
|
|
if not os.path.exists(src) and not os.path.islink(src):
|
|
return False
|
|
dst = os.path.join(dst_root, rel_path)
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
try:
|
|
os.replace(src, dst)
|
|
except OSError as e:
|
|
if getattr(e, "errno", None) != errno.EXDEV:
|
|
raise
|
|
shutil.move(src, dst)
|
|
return True
|
|
|
|
|
|
def _prune_empty_parents(root: str, rel_path: str) -> None:
|
|
"""Drop directories emptied by a removal, stopping at root and at the first
|
|
directory that still holds something (another distribution's files)."""
|
|
directory = os.path.dirname(os.path.join(root, rel_path))
|
|
while _is_inside(root, directory) and os.path.abspath(directory) != os.path.abspath(root):
|
|
try:
|
|
os.rmdir(directory)
|
|
except OSError:
|
|
return
|
|
directory = os.path.dirname(directory)
|
|
|
|
|
|
def _discard_stale_bytecode(root: str, rel_path: str) -> None:
|
|
"""Remove the cached bytecode of a source file that just left the tree.
|
|
|
|
A `.pyc` whose `.py` is gone is not importable, but it does keep the package
|
|
directory non-empty, which would stop `_prune_empty_parents` from clearing
|
|
the way for the incoming version.
|
|
"""
|
|
if not rel_path.endswith(".py"):
|
|
return
|
|
directory, base = os.path.split(os.path.join(root, rel_path))
|
|
cache = os.path.join(directory, "__pycache__")
|
|
for stale in glob.glob(os.path.join(cache, base[:-3] + ".*.pyc")):
|
|
try:
|
|
os.unlink(stale)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.rmdir(cache)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def plan_reconciliation(staging_sp: str, sp_dir: str) -> list:
|
|
"""Work out, per distribution the bundle is about to place, what it displaces.
|
|
|
|
One entry per staged distribution that will actually change the venv:
|
|
|
|
{"name", "version", "dist_info", "files", "superseded": [(version, dir)]}
|
|
|
|
`superseded` holds every copy already installed under a different version.
|
|
A distribution already present at the staged version is left out entirely:
|
|
placing it is a no-op, so there is nothing to remove and nothing to undo.
|
|
"""
|
|
staged = list_distributions(staging_sp)
|
|
installed = list_distributions(sp_dir)
|
|
plan = []
|
|
for name in sorted(staged):
|
|
version, dist_info = sorted(staged[name])[-1]
|
|
existing = installed.get(name, [])
|
|
superseded = [copy for copy in existing if copy[0] != version]
|
|
if existing and not superseded:
|
|
continue
|
|
# RECORD describes the wheel, not the archive. A bundle's copy of
|
|
# setuptools lists `_distutils_hack/` and `distutils-precedence.pth`
|
|
# while carrying neither, so trusting RECORD alone would clear ground
|
|
# that nothing is going to cover. Keep only what is really staged.
|
|
files = [
|
|
rel
|
|
for rel in distribution_files(staging_sp, dist_info)
|
|
if os.path.exists(os.path.join(staging_sp, rel))
|
|
]
|
|
plan.append(
|
|
{
|
|
"name": name,
|
|
"version": version,
|
|
"dist_info": dist_info,
|
|
"files": files,
|
|
"replaces": {rel.split("/")[0] for rel in files},
|
|
"superseded": superseded,
|
|
}
|
|
)
|
|
return plan
|
|
|
|
|
|
def supersede_distributions(sp_dir: str, plan: list, quarantine_dir: str) -> int:
|
|
"""Uninstall every superseded version, holding its files for a rollback.
|
|
|
|
Files move to `quarantine_dir/<name>/<version>/` rather than being deleted
|
|
outright, so a failed verification can put the venv back exactly as it was
|
|
instead of leaving it half-way between two bundles.
|
|
"""
|
|
removed = 0
|
|
superseded_dist_infos = {info for item in plan for _v, info in item["superseded"]}
|
|
shared = paths_claimed_by_others(sp_dir, superseded_dist_infos)
|
|
for item in plan:
|
|
for version, dist_info in item["superseded"]:
|
|
destination = os.path.join(quarantine_dir, item["name"], version)
|
|
# Only clear ground the incoming copy is going to cover. A bundle
|
|
# archive is not always a complete wheel: upscale-enhance carries
|
|
# setuptools 74.1.3 as `setuptools/` and its dist-info, without the
|
|
# `_distutils_hack/` and `distutils-precedence.pth` that the version
|
|
# it replaces owns. Removing everything the old RECORD listed took
|
|
# the distutils shim with it and basicsr, realesrgan and gfpgan all
|
|
# stopped importing, because nothing was going to write those files
|
|
# back. Anything the incoming copy does not carry stays put.
|
|
replaces = item["replaces"] | {dist_info}
|
|
for rel in distribution_files(sp_dir, dist_info):
|
|
if rel in shared or rel.split("/")[0] not in replaces:
|
|
continue
|
|
if _relocate(sp_dir, destination, rel):
|
|
_discard_stale_bytecode(sp_dir, rel)
|
|
_prune_empty_parents(sp_dir, rel)
|
|
# RECORD does not always list every file pip wrote into the metadata
|
|
# directory (INSTALLER and REQUESTED are frequently absent), so sweep
|
|
# whatever is left before dropping the directory itself.
|
|
leftover = os.path.join(sp_dir, dist_info)
|
|
if os.path.isdir(leftover):
|
|
for root, _dirs, files in os.walk(leftover):
|
|
for name in files:
|
|
rel = os.path.relpath(os.path.join(root, name), sp_dir)
|
|
_relocate(sp_dir, destination, rel)
|
|
shutil.rmtree(leftover, ignore_errors=True)
|
|
removed += 1
|
|
sys.stderr.write(
|
|
f"[install] replacing {item['name']} {version} with {item['version']}: "
|
|
f"removed the superseded version so only one stays in the venv\n"
|
|
)
|
|
if removed:
|
|
sys.stderr.flush()
|
|
return removed
|
|
|
|
|
|
def restore_superseded(sp_dir: str, quarantine_dir: str) -> None:
|
|
"""Put every quarantined version back where it came from."""
|
|
if not os.path.isdir(quarantine_dir):
|
|
return
|
|
for name in os.listdir(quarantine_dir):
|
|
holder = os.path.join(quarantine_dir, name)
|
|
if not os.path.isdir(holder):
|
|
continue
|
|
for version in os.listdir(holder):
|
|
version_root = os.path.join(holder, version)
|
|
if not os.path.isdir(version_root):
|
|
continue
|
|
for root, _dirs, files in os.walk(version_root):
|
|
for entry in files:
|
|
rel = os.path.relpath(os.path.join(root, entry), version_root)
|
|
_relocate(version_root, sp_dir, rel)
|
|
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
|
|
|
|
def discard_placed_distributions(sp_dir: str, plan: list) -> None:
|
|
"""Remove the files this bundle placed, for the distributions it changed.
|
|
|
|
Only distributions the plan actually touched are removed. One already
|
|
present at the staged version never entered the plan, so a rollback cannot
|
|
delete a version that was there before this install and is still correct.
|
|
"""
|
|
placed_dist_infos = {item["dist_info"] for item in plan}
|
|
shared = paths_claimed_by_others(sp_dir, placed_dist_infos)
|
|
for item in plan:
|
|
for rel in item["files"]:
|
|
if rel in shared:
|
|
continue
|
|
target = os.path.join(sp_dir, rel)
|
|
try:
|
|
if os.path.isdir(target) and not os.path.islink(target):
|
|
shutil.rmtree(target, ignore_errors=True)
|
|
else:
|
|
os.unlink(target)
|
|
except OSError:
|
|
pass
|
|
_prune_empty_parents(sp_dir, rel)
|
|
|
|
|
|
def rollback_reconciliation(sp_dir: str, plan: list, quarantine_dir: str) -> None:
|
|
"""Undo a merge: drop what this bundle placed, restore what it displaced."""
|
|
discard_placed_distributions(sp_dir, plan)
|
|
restore_superseded(sp_dir, quarantine_dir)
|
|
|
|
|
|
def mark_venv_writing(marker_path: str, bundle_id: str) -> None:
|
|
"""Breadcrumb a destructive write to the shared venv.
|
|
|
|
If the process dies while the marker exists, recoverInterruptedInstalls
|
|
reseeds the venv from the image base on next boot. That is the blunt
|
|
fallback for a tear this installer could not undo itself.
|
|
"""
|
|
with open(marker_path, "w") as f:
|
|
json.dump({"bundleId": bundle_id, "startedAt": datetime.now(timezone.utc).isoformat()}, f)
|
|
|
|
|
|
def clear_venv_writing(marker_path: str) -> None:
|
|
if os.path.exists(marker_path):
|
|
os.unlink(marker_path)
|
|
|
|
|
|
def abandon_merge(sp_dir, plan, quarantine_dir, marker_path, bundle_id) -> None:
|
|
"""Put the venv back the way it was before this bundle was merged.
|
|
|
|
A refused install must leave nothing behind: without this, a bundle that
|
|
fails verification would keep the versions it displaced from the bundles
|
|
that were already working, so one bad install would break tools that used
|
|
to run. Runs under the crash breadcrumb, and deliberately leaves the
|
|
breadcrumb in place if the rollback itself fails, so a venv this installer
|
|
could not repair still gets reseeded on next boot.
|
|
"""
|
|
if not sp_dir or (not plan and not os.path.isdir(quarantine_dir)):
|
|
return
|
|
try:
|
|
mark_venv_writing(marker_path, bundle_id)
|
|
rollback_reconciliation(sp_dir, plan, quarantine_dir)
|
|
clear_venv_writing(marker_path)
|
|
sys.stderr.write("[install] rolled the venv back to its state before this bundle\n")
|
|
except OSError as e:
|
|
sys.stderr.write(
|
|
f"[install] could not roll the venv back ({e}); the AI environment will be "
|
|
f"reseeded on next restart\n"
|
|
)
|
|
sys.stderr.flush()
|
|
|
|
|
|
# -- Fixups (NCCL wheel) --
|
|
|
|
def apply_fixups(staging_dir: str, venv_path: str) -> None:
|
|
"""Install any wheels from fixups/ directory (local only, no network)."""
|
|
fixups_dir = os.path.join(staging_dir, "fixups")
|
|
if not os.path.isdir(fixups_dir):
|
|
return
|
|
wheels = [f for f in os.listdir(fixups_dir) if f.endswith(".whl")]
|
|
if not wheels:
|
|
return
|
|
python_path = os.path.join(venv_path, "bin", "python3")
|
|
if not os.path.exists(python_path):
|
|
return
|
|
for wheel in wheels:
|
|
pkg_name = wheel.split("-")[0]
|
|
try:
|
|
subprocess.run(
|
|
[python_path, "-m", "pip", "install", "--no-index",
|
|
f"--find-links={fixups_dir}", pkg_name],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
except Exception:
|
|
pass # Non-fatal
|
|
|
|
|
|
# -- installed.json management --
|
|
|
|
def read_installed(ai_dir: str) -> dict:
|
|
"""Read the current installed.json, returning empty structure if missing."""
|
|
path = os.path.join(ai_dir, "installed.json")
|
|
if not os.path.exists(path):
|
|
return {"bundles": {}}
|
|
try:
|
|
with open(path, "r") as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
return {"bundles": {}}
|
|
|
|
|
|
def write_installed_atomic(ai_dir: str, data: dict) -> None:
|
|
"""Write installed.json atomically (write .tmp then rename)."""
|
|
path = os.path.join(ai_dir, "installed.json")
|
|
tmp_path = path + ".tmp"
|
|
with open(tmp_path, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
f.write("\n")
|
|
os.rename(tmp_path, path)
|
|
|
|
|
|
# -- Main --
|
|
|
|
def main() -> None:
|
|
"""Run the install with runtime-download restrictions lifted.
|
|
|
|
In strict offline mode (SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0) the sidecar
|
|
runs with HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE=1; a bundle install is an
|
|
explicitly user-initiated download, so those flags are lifted here
|
|
regardless. The previous values are restored in the finally block because
|
|
this script can run in-process inside the long-lived dispatcher, where
|
|
os.environ changes would otherwise leak into every later request.
|
|
"""
|
|
saved = {key: os.environ.get(key) for key in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")}
|
|
os.environ["HF_HUB_OFFLINE"] = "0"
|
|
os.environ["TRANSFORMERS_OFFLINE"] = "0"
|
|
try:
|
|
_install()
|
|
finally:
|
|
for key, value in saved.items():
|
|
if value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = value
|
|
|
|
|
|
def _install() -> None:
|
|
if len(sys.argv) < 4:
|
|
fail(
|
|
f"Usage: {sys.argv[0]} <bundleId> <manifestPath> <modelsDir>\n"
|
|
f"Got {len(sys.argv) - 1} argument(s)"
|
|
)
|
|
|
|
bundle_id = sys.argv[1]
|
|
manifest_path = sys.argv[2]
|
|
models_dir = sys.argv[3]
|
|
ai_dir = os.path.dirname(models_dir)
|
|
staging_base = os.path.join(ai_dir, "staging")
|
|
venv_path = os.environ.get("PYTHON_VENV_PATH", os.path.join(ai_dir, "venv"))
|
|
|
|
# -- Load manifest --
|
|
emit_progress(0, "Reading manifest...")
|
|
|
|
try:
|
|
with open(manifest_path, "r") as f:
|
|
manifest = json.load(f)
|
|
except Exception as e:
|
|
fail(f"Failed to read manifest: {e}")
|
|
|
|
bundles = manifest.get("bundles", {})
|
|
if bundle_id not in bundles:
|
|
fail(f"Unknown bundle: {bundle_id}")
|
|
|
|
bundle = bundles[bundle_id]
|
|
archives = bundle.get("archives")
|
|
if not archives:
|
|
fail(f"Bundle '{bundle_id}' has no archives in manifest (v2 required)")
|
|
|
|
# -- Detect architecture --
|
|
arch = detect_arch()
|
|
archive = archives.get(arch)
|
|
if not archive:
|
|
fail(f"No archive for architecture '{arch}' in bundle '{bundle_id}'")
|
|
|
|
archive_file = archive["file"]
|
|
expected_sha256 = archive["sha256"]
|
|
compressed_size = archive.get("compressedSize", 0)
|
|
extracted_size = archive.get("extractedSize", 0)
|
|
|
|
# -- Check for local file override (testing / offline) --
|
|
local_path = os.environ.get("SNAPOTTER_BUNDLE_LOCAL_PATH")
|
|
|
|
if local_path:
|
|
# Local mode: use the file directly, verify checksum
|
|
emit_progress(5, "Using local bundle archive...")
|
|
tar_path = local_path
|
|
|
|
if not os.path.exists(tar_path):
|
|
fail(f"Local bundle file not found: {tar_path}")
|
|
|
|
# Verify checksum
|
|
emit_progress(10, "Verifying checksum...")
|
|
if not verify_sha256(tar_path, expected_sha256):
|
|
fail(
|
|
f"SHA256 checksum mismatch for local file.\n"
|
|
f"Expected: {expected_sha256}\n"
|
|
f"This usually means the manifest and archive are out of sync."
|
|
)
|
|
else:
|
|
# Remote mode: download from HuggingFace
|
|
bundle_repo = manifest.get("bundleRepo", "deepsafe/feature-bundles")
|
|
url = f"https://huggingface.co/{bundle_repo}/resolve/main/{archive_file}"
|
|
|
|
# Disk space check (early sanity bail before a multi-GB download).
|
|
# estimate_extracted covers the extractedSize:0 case so the budget can't
|
|
# collapse to just the compressed size; the accurate guard is the
|
|
# real-on-disk re-check just before the destructive venv write below.
|
|
needed = compressed_size + estimate_extracted(compressed_size, extracted_size)
|
|
needed += 500 * 1024 * 1024 # 500 MB buffer
|
|
if needed > 0:
|
|
check_disk_space(ai_dir, needed)
|
|
|
|
# Download
|
|
os.makedirs(staging_base, exist_ok=True)
|
|
tar_path = os.path.join(staging_base, f"{bundle_id}-{arch}.tar.gz")
|
|
|
|
emit_progress(2, f"Downloading {bundle.get('name', bundle_id)} bundle...")
|
|
|
|
# Make sure the accelerated Xet client is importable in this venv, so a
|
|
# drifted/upgraded venv doesn't silently fall back to slow urllib.
|
|
ensure_hf_hub(venv_path)
|
|
|
|
try:
|
|
if not download_with_hf_hub(
|
|
bundle_repo,
|
|
archive_file,
|
|
tar_path,
|
|
compressed_size,
|
|
2,
|
|
85,
|
|
):
|
|
download_with_resume(url, tar_path, compressed_size, 2, 85)
|
|
except RuntimeError as e:
|
|
fail(
|
|
f"{e}\n\n"
|
|
f"You can download the bundle manually from:\n"
|
|
f" {url}\n"
|
|
f"Then upload it via Settings > AI Features > Offline Import."
|
|
)
|
|
|
|
# Verify checksum
|
|
emit_progress(86, "Verifying integrity...")
|
|
if not verify_sha256(tar_path, expected_sha256):
|
|
# Delete and retry once from scratch
|
|
os.unlink(tar_path)
|
|
emit_progress(86, "Checksum mismatch, retrying download...")
|
|
try:
|
|
if not download_with_hf_hub(
|
|
bundle_repo,
|
|
archive_file,
|
|
tar_path,
|
|
compressed_size,
|
|
2,
|
|
85,
|
|
force_download=True,
|
|
):
|
|
download_with_resume(url, tar_path, compressed_size, 2, 85)
|
|
except RuntimeError as e:
|
|
fail(str(e))
|
|
|
|
if not verify_sha256(tar_path, expected_sha256):
|
|
os.unlink(tar_path)
|
|
fail(
|
|
f"SHA256 checksum mismatch after re-download.\n"
|
|
f"Expected: {expected_sha256}\n"
|
|
f"The archive may be corrupted. Try again later."
|
|
)
|
|
|
|
# -- Extract to staging --
|
|
staging_dir = os.path.join(ai_dir, f"staging-{bundle_id}")
|
|
emit_progress(88, "Extracting packages and models...")
|
|
|
|
try:
|
|
if os.path.exists(staging_dir):
|
|
shutil.rmtree(staging_dir)
|
|
safe_extract(tar_path, staging_dir)
|
|
except Exception as e:
|
|
if os.path.exists(staging_dir):
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
if isinstance(e, OSError) and getattr(e, "errno", None) == errno.ENOSPC:
|
|
fail(
|
|
"Ran out of disk space while extracting the bundle. "
|
|
"Free up space and retry."
|
|
)
|
|
fail(f"Failed to extract archive: {e}")
|
|
|
|
# -- Read bundle.json from tar --
|
|
bundle_json_path = os.path.join(staging_dir, "bundle.json")
|
|
if not os.path.exists(bundle_json_path):
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
fail("Archive is missing bundle.json")
|
|
|
|
try:
|
|
with open(bundle_json_path, "r") as f:
|
|
bundle_meta = json.load(f)
|
|
except Exception as e:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
fail(f"Invalid bundle.json: {e}")
|
|
|
|
version = bundle_meta.get("version", manifest.get("imageVersion", "unknown"))
|
|
model_ids = bundle_meta.get("models", [])
|
|
|
|
# -- Disk re-check before the first destructive venv write --
|
|
# The upfront check ran before the download and used an estimate; now the
|
|
# payload is really on disk, so measure it and verify there's room to place
|
|
# it before we start writing into the venv. Running here (after the
|
|
# local/remote branches merge) also covers the offline-import path, which
|
|
# skipped the upfront check entirely. Each budget is checked against the
|
|
# filesystem the bytes actually land on: when the venv lives on a
|
|
# different filesystem than staging, the site-packages payload is COPIED
|
|
# onto the venv's disk, so that disk (not ai_dir's) must hold it. Models
|
|
# stay under ai_dir either way, moving by rename.
|
|
disk_floor = 1024 ** 3 # 1 GB for fixups / installed.json / slack
|
|
staging_sp = os.path.join(staging_dir, "site-packages")
|
|
if not same_filesystem(staging_dir, venv_path):
|
|
sp_bytes = dir_size(staging_sp) if os.path.isdir(staging_sp) else 0
|
|
check_disk_space(venv_path, sp_bytes + disk_floor)
|
|
check_disk_space(ai_dir, disk_floor)
|
|
|
|
# -- Move site-packages --
|
|
emit_progress(92, "Installing packages...")
|
|
site_packages_dir = get_site_packages_dir(venv_path)
|
|
venv_writing_marker = os.path.join(ai_dir, "venv.writing")
|
|
# Superseded versions wait here until the install is verified. It sits inside
|
|
# the venv so every move is a same-filesystem rename rather than a copy of
|
|
# several GB, and outside site-packages so a quarantined dist-info cannot be
|
|
# picked up by importlib.metadata while it waits.
|
|
quarantine_dir = os.path.join(venv_path, ".superseded")
|
|
reconciliation_plan = []
|
|
|
|
try:
|
|
if os.path.isdir(staging_sp) and site_packages_dir:
|
|
# Breadcrumb the destructive shared-venv write. If the process is
|
|
# killed mid-move (OOM/SIGKILL/power loss), move_tree can leave the
|
|
# venv torn, which breaks OTHER installed tools. The marker survives
|
|
# the crash; on next boot recoverInterruptedInstalls sees it and
|
|
# reseeds the venv back to a known-good base. We clear it the instant
|
|
# the site-packages move completes, since the venv is consistent
|
|
# again then (a later models-move failure can't tear the venv).
|
|
mark_venv_writing(venv_writing_marker, bundle_id)
|
|
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
# Flavor first: `onnxruntime` and `onnxruntime-gpu` are two different
|
|
# distributions writing the same package directory, so which one wins
|
|
# is not a version question and the general pass below cannot decide
|
|
# it. Running it first also means that when the CPU build is dropped
|
|
# from staging, the general pass never sees it and so never uninstalls
|
|
# the GPU build the venv is keeping (#490).
|
|
reconcile_onnxruntime(staging_sp, site_packages_dir)
|
|
reconciliation_plan = plan_reconciliation(staging_sp, site_packages_dir)
|
|
supersede_distributions(site_packages_dir, reconciliation_plan, quarantine_dir)
|
|
move_tree(staging_sp, site_packages_dir)
|
|
clear_venv_writing(venv_writing_marker)
|
|
|
|
# -- Move models --
|
|
emit_progress(95, "Installing models...")
|
|
staging_models = os.path.join(staging_dir, "models")
|
|
if os.path.isdir(staging_models):
|
|
os.makedirs(models_dir, exist_ok=True)
|
|
move_tree(staging_models, models_dir)
|
|
except OSError as e:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
abandon_merge(
|
|
site_packages_dir, reconciliation_plan, quarantine_dir,
|
|
venv_writing_marker, bundle_id,
|
|
)
|
|
if getattr(e, "errno", None) == errno.ENOSPC:
|
|
fail("Ran out of disk space while installing the bundle. Free up space and retry.")
|
|
fail(f"Failed to install bundle files: {e}")
|
|
|
|
# -- Apply fixups --
|
|
emit_progress(97, "Finalizing...")
|
|
apply_fixups(staging_dir, venv_path)
|
|
|
|
# -- Verify the bundle actually imports --
|
|
# File-copy completion does NOT prove the bundle works: an incomplete
|
|
# extraction or an ABI mismatch (e.g. a numpy/torch/protobuf skew) can leave
|
|
# every file present yet the module unimportable, so the tool "installs" but
|
|
# fails at first use. Import the bundle's key native libraries in the venv
|
|
# now; if that fails, refuse to mark the bundle installed so the user gets a
|
|
# clear retry instead of a silently broken tool.
|
|
smoke_imports = bundle.get("smokeImports") or []
|
|
if smoke_imports and os.environ.get("SNAPOTTER_SKIP_INSTALL_SMOKE") != "1":
|
|
emit_progress(99, "Verifying installation...")
|
|
venv_python = os.path.join(venv_path, "bin", "python3")
|
|
if os.path.exists(venv_python):
|
|
import_stmt = "\n".join(f"import {mod}" for mod in smoke_imports)
|
|
try:
|
|
proc = subprocess.run(
|
|
[venv_python, "-c", import_stmt],
|
|
capture_output=True, text=True, timeout=300,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
abandon_merge(
|
|
site_packages_dir, reconciliation_plan, quarantine_dir,
|
|
venv_writing_marker, bundle_id,
|
|
)
|
|
fail("Installation verification timed out. Please retry the install.")
|
|
if proc.returncode != 0:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
abandon_merge(
|
|
site_packages_dir, reconciliation_plan, quarantine_dir,
|
|
venv_writing_marker, bundle_id,
|
|
)
|
|
tail = "\n".join((proc.stderr or "").strip().splitlines()[-6:])
|
|
fail(
|
|
"Installation verification failed: the bundle installed but its "
|
|
"libraries could not be loaded, so the tool would not work.\n"
|
|
f"{tail}\n\n"
|
|
"This usually means an interrupted or corrupted install. Retry the "
|
|
"install; if it keeps failing, use Settings > AI Features > Reset AI "
|
|
"Environment, then reinstall."
|
|
)
|
|
|
|
# -- Write installed.json --
|
|
emit_progress(98, "Recording installation...")
|
|
installed = read_installed(ai_dir)
|
|
installed["bundles"][bundle_id] = {
|
|
"version": version,
|
|
"installedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
"models": model_ids,
|
|
}
|
|
write_installed_atomic(ai_dir, installed)
|
|
|
|
# -- Cleanup --
|
|
# The install is recorded, so the versions this bundle displaced are never
|
|
# coming back and their quarantine copy is just disk.
|
|
shutil.rmtree(quarantine_dir, ignore_errors=True)
|
|
if os.path.exists(staging_dir):
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
# Clean up downloaded tar (but not if local override)
|
|
if not local_path and os.path.exists(tar_path):
|
|
os.unlink(tar_path)
|
|
|
|
# -- Done --
|
|
emit_progress(100, "Complete")
|
|
|
|
result = {
|
|
"success": True,
|
|
"bundleId": bundle_id,
|
|
"version": version,
|
|
"models": model_ids,
|
|
}
|
|
print(json.dumps(result))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|