"""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 Progress is reported via JSON lines on stderr (parsed by the Node bridge). Final result is a JSON object on stdout. """ import errno import glob import hashlib import json import os import platform import shutil import subprocess import sys import tarfile import time import urllib.error import urllib.request from datetime import datetime, timezone # -- 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 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) max_retries = 3 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: mode = "ab" if bytes_downloaded > 0 else "wb" with open(partial_path, mode) as f: while True: chunk = resp.read(65536) 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 (every 10 MB) if bytes_downloaded % (10 * 1024 * 1024) < 65536: with open(meta_path, "w") as mf: json.dump({"bytesDownloaded": bytes_downloaded}, mf) # Download complete os.rename(partial_path, dest) if os.path.exists(meta_path): os.unlink(meta_path) return except Exception as e: # Write meta for resume on next attempt with open(meta_path, "w") as mf: json.dump({"bytesDownloaded": bytes_downloaded}, mf) if attempt < max_retries - 1: delay = 10 * (2 ** attempt) emit_progress( progress_start, f"Download failed (attempt {attempt + 1}/{max_retries}), " f"retrying in {delay}s: {e}", ) time.sleep(delay) else: # Clean up on final failure for p in (partial_path, meta_path): if os.path.exists(p): os.unlink(p) raise RuntimeError( f"Failed to download after {max_retries} attempts: {e}" ) # -- 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, overwriting existing files. Renames entries where possible so that on the same filesystem no copy (and thus no transient doubling of the payload on disk) occurs; falls back to a copy only across filesystems. The old copytree+rmtree approach duplicated the whole tree on disk during the move, which could exhaust the host on a tight-disk node.""" 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 if os.path.exists(d): if os.path.isdir(d): shutil.rmtree(d) else: os.remove(d) try: os.rename(s, d) except OSError as e: if getattr(e, "errno", None) == errno.EXDEV: # Cross-filesystem: rename isn't allowed, fall back to a copy. if os.path.isdir(s): shutil.copytree(s, d) else: shutil.copy2(s, d) else: raise # Remove whatever remains of src (emptied by renames, or copied originals). shutil.rmtree(src, ignore_errors=True) # -- 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: if len(sys.argv) < 4: fail( f"Usage: {sys.argv[0]} \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...") try: 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: 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) 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. On the same filesystem the move # is a rename (no extra space needed, just a safety floor); across # filesystems it is a copy that transiently needs the payload's size again. # Running here (after the local/remote branches merge) also covers the # offline-import path, which skipped the upfront check entirely. staging_real = dir_size(staging_dir) if same_filesystem(staging_dir, venv_path): recheck_needed = 1024 ** 3 # 1 GB floor for fixups / installed.json / slack else: recheck_needed = staging_real + 1024 ** 3 check_disk_space(ai_dir, recheck_needed) # -- Move site-packages -- emit_progress(92, "Installing packages...") site_packages_dir = get_site_packages_dir(venv_path) staging_sp = os.path.join(staging_dir, "site-packages") try: if os.path.isdir(staging_sp) and site_packages_dir: move_tree(staging_sp, site_packages_dir) # -- 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) 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) # -- 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 -- 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()