- Updated the `_print_checks` function to replace console prints with logging functions for better traceability. - Introduced timing functionality in the `doctor`, `dry_run`, and `up` functions to log the duration of preflight checks. - Modified the `wait_ssh` function to accept a logging callback, improving SSH wait feedback. - Enhanced the `mark` method in `PhaseTimes` to log phase durations, aiding in performance analysis. - Updated various remote scripts to ensure error messages are printed to stderr for better error handling.
149 lines
5.0 KiB
Python
149 lines
5.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Pull Ollama models from a JSON list. Stdlib only. Runs on the VM.
|
|
|
|
Uses POST /api/pull with stream JSON for completed/total + speed lines.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
JOBS = Path("/tmp/gpu-rent-ollama-models.json")
|
|
MARKER = Path("/mnt/swarm_data/.gpu-rent-ollama-pulling")
|
|
OLLAMA = "http://127.0.0.1:11434"
|
|
|
|
|
|
def fmt_bytes(n: float) -> str:
|
|
n = float(n)
|
|
for unit, div in (("GB", 1024**3), ("MB", 1024**2), ("KB", 1024), ("B", 1)):
|
|
if n >= div or unit == "B":
|
|
if unit == "B":
|
|
return f"{int(n)}B"
|
|
return f"{n / div:.1f}{unit}"
|
|
return f"{n:.0f}B"
|
|
|
|
|
|
def progress_line(label: str, done: int, total: int | None, speed: float, *, width: int = 22) -> str:
|
|
if total and total > 0:
|
|
pct = min(100.0, 100.0 * done / total)
|
|
filled = min(width, max(0, int(width * done / total)))
|
|
bar = "#" * filled + "-" * (width - filled)
|
|
return (
|
|
f"{label} [{bar}] {pct:5.1f}% "
|
|
f"{fmt_bytes(done)}/{fmt_bytes(total)} {fmt_bytes(speed)}/s"
|
|
)
|
|
return f"{label} {fmt_bytes(done)} {fmt_bytes(speed)}/s"
|
|
|
|
|
|
def listed() -> set[str]:
|
|
"""Exact tags from `ollama list` (NAME column), e.g. qwen2.5:7b."""
|
|
try:
|
|
out = subprocess.check_output(["ollama", "list"], text=True, stderr=subprocess.DEVNULL)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return set()
|
|
names: set[str] = set()
|
|
for i, line in enumerate(out.splitlines()):
|
|
if i == 0 and line.lower().startswith("name"):
|
|
continue
|
|
parts = line.split()
|
|
if parts:
|
|
names.add(parts[0])
|
|
return names
|
|
|
|
|
|
def already_have(have: set[str], wanted: str) -> bool:
|
|
"""Exact tag match only — qwen2.5:3b must not satisfy qwen2.5:7b."""
|
|
if wanted in have:
|
|
return True
|
|
if ":" not in wanted and f"{wanted}:latest" in have:
|
|
return True
|
|
if wanted.endswith(":latest") and wanted.rsplit(":", 1)[0] in have:
|
|
return True
|
|
return False
|
|
|
|
|
|
def pull_stream(name: str, label: str) -> None:
|
|
body = json.dumps({"name": name, "stream": True}).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
f"{OLLAMA}/api/pull",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
t0 = time.monotonic()
|
|
last_print = 0.0
|
|
with urllib.request.urlopen(req, timeout=7200) as resp:
|
|
while True:
|
|
raw = resp.readline()
|
|
if not raw:
|
|
break
|
|
try:
|
|
ev = json.loads(raw.decode("utf-8"))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if ev.get("error"):
|
|
raise RuntimeError(str(ev["error"]))
|
|
status = str(ev.get("status") or "")
|
|
total = int(ev.get("total") or 0) or None
|
|
done = int(ev.get("completed") or 0)
|
|
now = time.monotonic()
|
|
if total and done:
|
|
if now - last_print >= 1.0 or done >= total:
|
|
elapsed = max(now - t0, 0.001)
|
|
speed = done / elapsed
|
|
line = progress_line(label, done, total, speed)
|
|
end = "\n" if done >= total else "\r"
|
|
print(line, end=end, flush=True)
|
|
last_print = now
|
|
elif status and status not in {"success"} and now - last_print >= 2.0:
|
|
print(f"{label} {status}", flush=True)
|
|
last_print = now
|
|
if status == "success":
|
|
break
|
|
print(f"{label} ok", flush=True)
|
|
|
|
|
|
def main() -> int:
|
|
if not JOBS.is_file():
|
|
print("no jobs file")
|
|
return 1
|
|
models = json.loads(JOBS.read_text(encoding="utf-8"))
|
|
if not isinstance(models, list) or not models:
|
|
print("ollama pull: пустой список — skip")
|
|
return 0
|
|
have = listed()
|
|
MARKER.parent.mkdir(parents=True, exist_ok=True)
|
|
MARKER.write_text(f"{int(time.time())}\n", encoding="utf-8")
|
|
failed = 0
|
|
try:
|
|
for i, name in enumerate(models, 1):
|
|
name = str(name).strip()
|
|
if not name:
|
|
continue
|
|
prefix = f"[{i}/{len(models)}]"
|
|
if already_have(have, name):
|
|
print(f"{prefix} уже есть {name}")
|
|
continue
|
|
print(f"{prefix} ollama pull {name}", flush=True)
|
|
try:
|
|
pull_stream(name, label=f"{prefix} {name}")
|
|
have.add(name)
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError, RuntimeError) as exc:
|
|
failed += 1
|
|
print(f"FAIL pull {name}: {exc}")
|
|
finally:
|
|
MARKER.unlink(missing_ok=True)
|
|
if failed:
|
|
return 1
|
|
print("ollama pull ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|