Files
Leonid Pershin 5832c5cf75 Enhance Ollama model management and performance tuning
- Updated the `provision_llm` function to utilize the `/api/tags` endpoint for verifying available models, improving accuracy in model management.
- Introduced a new `already_have_ollama_tag` function to ensure exact tag matching, preventing mismatches during model checks.
- Enhanced the `pull_stream` function to require a successful status from the API before proceeding, ensuring reliable model downloads.
- Added logic to handle unwritten blob files, improving the robustness of the model pulling process.
- Updated documentation and tests to reflect these changes, ensuring clarity and reliability in Ollama model operations.
2026-08-21 14:20:06 +03:00

738 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Wait until SwarmUI HTTP is up and backend is Idle (on the VM).
Also end-of-up stack verification: every enabled service must answer.
"""
from __future__ import annotations
import json
import socket
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
from gpu_rent.llm_runtime import normalize_runtime
from gpu_rent.ssh_ops import run_python, run_ssh
from gpu_rent.timing import WaitLog
Log = Callable[[str], None]
_REMOTE_POLL = r"""
import json, time, urllib.request
url = "http://127.0.0.1:7801"
deadline = time.time() + 25
while time.time() < deadline:
try:
req = urllib.request.Request(
url + "/API/GetNewSession",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
session = json.loads(resp.read().decode())
sid = session.get("session_id")
if not sid:
time.sleep(2)
continue
body = json.dumps({"session_id": sid}).encode()
req2 = urllib.request.Request(
url + "/API/GetCurrentStatus",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req2, timeout=5) as resp:
data = json.loads(resp.read().decode())
st = data.get("status") or {}
be = data.get("backend_status") or {}
waiting = int(st.get("waiting_gens") or 0)
live = int(st.get("live_gens") or 0)
loading = int(st.get("loading_models") or 0)
bstat = str(be.get("status") or "unknown").lower()
any_loading = bool(be.get("any_loading"))
# SwarmUI: "running" = backends healthy & ready to generate.
# "idle" = suspended / cannot generate. "loading" = still starting.
if waiting or live or loading:
print(f"BUSY queue w={waiting} live={live} load={loading}")
elif bstat == "empty":
print("BUSY backend=empty (нужен first-install Comfy)")
elif bstat in ("loading", "some_loading") or any_loading:
print(f"BUSY backend={bstat} (Comfy стартует)")
elif bstat == "running":
print("READY backend=running")
elif bstat == "idle":
# Suspended backends still installed — first gen wakes them.
# Do not burn 2400s waiting for running after AllowIdle.
print("READY backend=idle (backends present, suspended)")
elif bstat in ("disabled", "all_disabled"):
print(f"BUSY backend={bstat}")
elif bstat == "errored":
print("BUSY backend=errored")
else:
print(f"BUSY backend={bstat}")
raise SystemExit(0)
except Exception as exc:
print(f"WAIT {exc}")
time.sleep(3)
print("WAIT timeout-slice")
"""
# One-shot probe of configured stack endpoints on the VM (JSON line).
_REMOTE_STACK_PROBE = r'''
import json, time, urllib.error, urllib.request, subprocess
from pathlib import Path
def http_ok(url, timeout=4.0):
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
code = getattr(resp, "status", 200) or 200
body = resp.read(256)
return True, f"HTTP {code} ({len(body)}b)"
except Exception as exc:
return False, str(exc)[:160]
def unit_active(name):
try:
out = subprocess.check_output(
["systemctl", "is-active", name],
text=True,
stderr=subprocess.DEVNULL,
).strip()
return out
except Exception:
return "unknown"
def pulling_age():
p = Path("/mnt/swarm_data/.gpu-rent-ollama-pulling")
try:
if p.is_file():
return max(0.0, time.time() - p.stat().st_mtime)
except OSError:
return None
return None
checks = []
want_swarm = WANT_SWARM
want_ollama = WANT_OLLAMA
want_ollama_models = WANT_OLLAMA_MODELS
if want_swarm:
ok, detail = http_ok("http://127.0.0.1:7801/")
if not ok:
# API may answer when / does not
ok2, d2 = False, detail
try:
req = urllib.request.Request(
"http://127.0.0.1:7801/API/GetNewSession",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
ok2 = True
d2 = f"API session HTTP {getattr(resp, 'status', 200)}"
except Exception as exc:
d2 = str(exc)[:160]
ok, detail = ok2, d2
checks.append({
"name": "swarmui",
"ok": ok,
"detail": detail,
"unit": unit_active("swarmui"),
"retry": not ok,
})
if want_ollama:
retry = True
try:
req = urllib.request.Request("http://127.0.0.1:11434/api/tags", method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
raw = resp.read().decode("utf-8", "replace")
payload = json.loads(raw)
models = payload.get("models") if isinstance(payload, dict) else None
names = []
if isinstance(models, list):
for m in models:
if isinstance(m, dict) and m.get("name"):
names.append(str(m["name"]))
elif isinstance(m, str) and m.strip():
names.append(m.strip())
if names:
preview = ", ".join(names[:3])
extra = "" if len(names) <= 3 else f" +{len(names) - 3}"
ok, detail = True, f"{len(names)} models ({preview}{extra})"
retry = False
elif want_ollama_models:
age = pulling_age()
if age is not None and age < 2700:
ok, detail = False, f"Ollama up, 0 models — pull идёт ({int(age)}s)"
retry = True
else:
ok, detail = True, "WARN 0 models — Assistent empty (GPU не гасим)"
retry = False
else:
ok, detail = True, "0 models (манифест пуст)"
retry = False
except Exception as exc:
ok, detail = False, str(exc)[:160]
retry = True
checks.append({
"name": "ollama",
"ok": ok,
"detail": detail,
"unit": unit_active("gpu-rent-ollama"),
"retry": retry,
})
print(json.dumps({"checks": checks}, ensure_ascii=False))
'''
@dataclass(frozen=True)
class ServiceCheck:
name: str
ok: bool
detail: str
where: str = "vm" # vm | local
retry: bool = True
Log = Callable[[str], None]
def collect_swarm_diagnostics(cfg: Config, host: str, log: Log) -> str:
"""Run remote swarm_diag.py; stream to log; return full report text."""
from importlib.resources import files
log("диагностика SwarmUI/Comfy…")
try:
script = files("gpu_rent.remote").joinpath("swarm_diag.py").read_text(
encoding="utf-8"
)
out = run_python(
cfg,
host,
script,
remote_path="/tmp/gpu-rent-swarm_diag.py",
timeout=90,
log=log,
)
return out or ""
except Exception as exc:
# Best-effort fallback when full script fails
log(f"diag script fail: {exc}")
try:
fb = run_ssh(
cfg,
host,
"echo '=== systemctl swarmui ==='; systemctl is-active swarmui; "
"echo; echo '=== journalctl -u swarmui -n 60 ==='; "
"sudo -n journalctl -u swarmui -n 60 --no-pager 2>/dev/null || true",
check=False,
timeout=45,
)
for line in (fb or "").splitlines():
log(line)
return fb or ""
except Exception as exc2:
log(f"diag fallback fail: {exc2}")
return ""
def wait_backend_idle(
cfg: Config,
host: str,
log: Log,
*,
timeout: float = 2400.0,
poll_every: float = 15.0,
errored_fail_sec: float = 120.0,
disabled_fail_sec: float = 90.0,
loading_fail_sec: float = 900.0,
) -> None:
"""Block until SwarmUI backends are ready (status=running, no queue).
Note: SwarmUI ``idle`` means suspended backends (cannot generate).
Ready-to-use is ``running``. Sustained ``errored`` / ``disabled`` / long
``loading`` fail-fasts.
"""
deadline = time.time() + timeout
log("жду ready backend (running)…")
last = ""
errored_since: float | None = None
disabled_since: float | None = None
loading_since: float | None = None
last_loading_note = 0.0
pretty = {
"BUSY backend=loading (Comfy стартует)": "… Comfy стартует",
"BUSY backend=some_loading (Comfy стартует)": "… Comfy стартует (часть бэкендов)",
"BUSY backend=empty (нужен first-install Comfy)": "… backend пуст — нужен install",
"BUSY backend=errored": "… backend errored — смотри journalctl -u swarmui",
"BUSY backend=disabled": "… backend disabled (пустой StartScript?)",
"BUSY backend=all_disabled": "… все backends disabled",
"READY backend=running": "backend ready (running)",
"READY backend=idle (backends present, suspended)": "backend ready (idle/suspended)",
}
while time.time() < deadline:
try:
out = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + _REMOTE_POLL + "\nPY",
check=False,
timeout=40,
).strip()
except Exception as exc:
out = f"WAIT ssh: {exc}"
line = out.splitlines()[-1] if out else "WAIT empty"
shown = pretty.get(line, line)
if "BUSY backend=loading" in line or "BUSY backend=some_loading" in line:
now = time.time()
if loading_since is None:
loading_since = now
elapsed = int(now - loading_since)
shown = f"… Comfy стартует ({elapsed // 60}м {elapsed % 60}с)"
if now - last_loading_note >= 60:
last_loading_note = now
try:
j = run_ssh(
cfg,
host,
"sudo -n journalctl -u swarmui -n 8 --no-pager -o cat 2>/dev/null "
"| tail -n 8 || true",
check=False,
timeout=25,
).strip()
if j:
for jl in j.splitlines()[-4:]:
log(f" journal: {jl[:160]}")
except Exception:
pass
if elapsed >= loading_fail_sec:
collect_swarm_diagnostics(cfg, host, log)
raise CloudError(
f"backend=loading уже {elapsed} с — похоже завис "
"(часто FrontendVersion / pip / сеть). Диагностика выше. "
"Попробуй gpu-rent up снова или Server → Backends → Restart."
)
else:
loading_since = None
if shown != last:
log(shown)
last = shown
if line.startswith("READY"):
return
if "BUSY backend=errored" in line:
now = time.time()
if errored_since is None:
errored_since = now
elif now - errored_since >= errored_fail_sec:
collect_swarm_diagnostics(cfg, host, log)
raise CloudError(
f"backend=errored уже {int(now - errored_since)} с — не заживёт само. "
"Диагностика выше (+ /mnt/swarm_data/.gpu-rent-last-diag.txt на VM). "
"Server → Backends → Restart или gpu-rent stop / up после фикса."
)
else:
errored_since = None
if "BUSY backend=disabled" in line or "BUSY backend=all_disabled" in line:
now = time.time()
if disabled_since is None:
disabled_since = now
elif now - disabled_since >= disabled_fail_sec:
collect_swarm_diagnostics(cfg, host, log)
raise CloudError(
f"backend=disabled уже {int(now - disabled_since)} с — "
"обычно пустой StartScript (нужен dlbackend/ComfyUI/main.py). "
"Диагностика выше. Server → Backends → Edit StartScript или re-run up."
)
else:
disabled_since = None
time.sleep(poll_every)
collect_swarm_diagnostics(cfg, host, log)
raise CloudError(
f"backend не стал ready (running) за {int(timeout)} с. "
"Диагностика выше (+ .gpu-rent-last-diag.txt на data volume). GPU ещё жив."
)
def _expected_services(cfg: Config) -> tuple[bool, bool]:
swarm = bool(getattr(cfg, "enable_swarmui", True))
rt = normalize_runtime(getattr(cfg, "llm_runtime", "none"))
return swarm, rt == "ollama"
def _want_ollama_models(cfg: Config) -> bool:
"""True when ollama-models.yaml lists tags that must appear in /api/tags."""
if normalize_runtime(getattr(cfg, "llm_runtime", "none")) != "ollama":
return False
from gpu_rent.llm_runtime import parse_ollama_models
path = getattr(cfg, "ollama_models_manifest", None)
if path is None:
return False
try:
return bool(parse_ollama_models(path))
except (OSError, ValueError):
return False
def _probe_vm_once(cfg: Config, host: str) -> list[ServiceCheck]:
want_swarm, want_ollama = _expected_services(cfg)
script = (
_REMOTE_STACK_PROBE.replace("WANT_SWARM", "True" if want_swarm else "False")
.replace(
"WANT_OLLAMA_MODELS",
"True" if (want_ollama and _want_ollama_models(cfg)) else "False",
)
.replace("WANT_OLLAMA", "True" if want_ollama else "False")
)
out = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + script + "\nPY",
check=False,
timeout=60,
).strip()
line = ""
for row in reversed(out.splitlines()):
row = row.strip()
if row.startswith("{"):
line = row
break
if not line:
return [
ServiceCheck("stack", False, f"нет JSON от probe: {out[-200:]}", "vm")
]
try:
data = json.loads(line)
except json.JSONDecodeError:
return [ServiceCheck("stack", False, f"битый JSON: {line[:200]}", "vm")]
checks: list[ServiceCheck] = []
for item in data.get("checks") or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "?")
detail = str(item.get("detail") or "")
unit = str(item.get("unit") or "")
if unit and unit != "unknown":
detail = f"{detail}; unit={unit}"
checks.append(
ServiceCheck(
name=name,
ok=bool(item.get("ok")),
detail=detail,
where="vm",
retry=bool(item.get("retry", True)),
)
)
return checks
def verify_stack_on_vm(
cfg: Config,
host: str,
log: Log,
*,
timeout: float = 300.0,
poll_every: float = 8.0,
raise_on_fail: bool = True,
) -> list[ServiceCheck]:
"""Poll until every enabled service answers on the VM loopback."""
want_swarm, want_ollama = _expected_services(cfg)
if not (want_swarm or want_ollama):
log("проверка стека: нечего ждать (swarm off, LLM none)")
return []
names = []
if want_swarm:
names.append("SwarmUI :7801")
if want_ollama:
names.append("Ollama :11434")
log(f"проверка на VM: {', '.join(names)}")
deadline = time.time() + timeout
last: list[ServiceCheck] = []
wait = WaitLog(log, every=30.0)
while time.time() < deadline:
try:
last = _probe_vm_once(cfg, host)
except Exception as exc:
last = [ServiceCheck("ssh", False, str(exc)[:200], "vm")]
if last and all(c.ok for c in last):
for c in last:
mark = "warn" if c.detail.startswith("WARN") else "ok"
log(f" [{mark}] {c.name}: {c.detail}")
log("проверка VM: всё отвечает")
return last
stuck = [c for c in last if not c.ok and not c.retry]
if stuck:
for c in last:
mark = "ok" if c.ok else "FAIL"
log(f" [{mark}] {c.name}: {c.detail}")
if raise_on_fail:
failed = [c.name for c in stuck]
raise CloudError(
f"{', '.join(failed)} не готов и ждать бесполезно: "
f"{stuck[0].detail}. GPU жив — gpu-rent logs / повторный up (pull)"
)
return last
bad = ", ".join(f"{c.name}={c.detail}" for c in last if not c.ok) or "пусто"
wait.tick(f" … ещё нет: {bad}")
time.sleep(poll_every)
for c in last:
mark = "ok" if c.ok else "FAIL"
log(f" [{mark}] {c.name}: {c.detail}")
if raise_on_fail and any(not c.ok for c in last):
failed = [c.name for c in last if not c.ok]
raise CloudError(
f"сервисы не ответили на VM за {int(timeout)} с: {', '.join(failed)}. "
"GPU жив — смотри journalctl / gpu-rent logs"
)
return last
def _tcp_ok(port: int, host: str = "127.0.0.1", timeout: float = 0.8) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
return sock.connect_ex((host, port)) == 0
finally:
sock.close()
def _http_local(url: str, timeout: float = 3.0) -> tuple[bool, str]:
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
code = getattr(resp, "status", 200) or 200
return True, f"HTTP {code}"
except Exception as exc:
return False, str(exc)[:160]
def verify_gpu_env(
cfg: Config,
host: str,
log: Log,
*,
timeout: float = 600.0,
poll_every: float = 15.0,
raise_on_fail: bool = True,
) -> list[ServiceCheck]:
"""nvidia-smi / CUDA / torch(+cuda) in Comfy venv when SwarmUI is on.
Driver/CUDA missing → fail immediately (won't appear later).
Torch/Comfy venv → poll until timeout (first Comfy start installs them).
"""
from importlib.resources import files
from gpu_rent.ssh_ops import run_python
# These never "appear later" on a broken image — don't burn the poll budget.
# Empty-backend / missing install also won't self-heal without InstallConfirmWS.
instant_fail_names = {"nvidia-smi", "cuda"}
instant_fail_detail_substrings = (
"dlbackend пуст",
"backend=empty",
"Install не прогоняли",
"available=false",
"cuda=none",
"без cuda",
)
want_swarm = bool(getattr(cfg, "enable_swarmui", True))
script = files("gpu_rent.remote").joinpath("stack_env_probe.py").read_text(
encoding="utf-8"
)
swarm_flag = "1" if want_swarm else "0"
log(
"проверка GPU-стека: nvidia-smi, CUDA"
+ (", torch в Comfy venv" if want_swarm else " (llm-only — без torch)")
)
deadline = time.time() + timeout
last: list[ServiceCheck] = []
wait = WaitLog(log, every=30.0)
while time.time() < deadline:
try:
out = run_python(
cfg,
host,
script,
remote_path="/tmp/gpu-rent-stack_env_probe.py",
timeout=180,
log=None,
env={"GPU_RENT_CHECK_SWARM": swarm_flag},
)
except Exception as exc:
last = [ServiceCheck("gpu-env", False, str(exc)[:200], "vm")]
wait.tick(f" … gpu-env: {exc}")
time.sleep(poll_every)
continue
data: dict = {}
for line in reversed(out.splitlines()):
line = line.strip()
if line.startswith("{"):
try:
data = json.loads(line)
break
except json.JSONDecodeError:
continue
checks_raw = data.get("checks") if isinstance(data, dict) else None
if not isinstance(checks_raw, list):
last = [ServiceCheck("gpu-env", False, f"нет JSON: {out[-180:]}", "vm")]
wait.tick(f" … gpu-env: нет JSON")
time.sleep(poll_every)
continue
last = []
for item in checks_raw:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "?")
ok = bool(item.get("ok"))
detail = str(item.get("detail") or "")
required = bool(item.get("required", True))
if not required:
if not ok:
log(f" [warn] {name}: {detail}")
last.append(
ServiceCheck(
name, True, detail if ok else f"(optional) {detail}", "vm"
)
)
else:
last.append(ServiceCheck(name, ok, detail, "vm"))
hard = [c for c in last if not c.ok]
if not hard:
for c in last:
log(f" [ok] {c.name}: {c.detail}")
log("проверка GPU-стека: ок")
return last
instant = [c for c in hard if c.name in instant_fail_names]
if not instant:
for c in hard:
detail_l = (c.detail or "").lower()
if any(s.lower() in detail_l for s in instant_fail_detail_substrings):
instant.append(c)
if instant:
for c in last:
mark = "ok" if c.ok else "FAIL"
log(f" [{mark}] {c.name}: {c.detail}")
if raise_on_fail:
failed = [c.name for c in instant]
raise CloudError(
f"GPU-стек: нет {', '.join(failed)} (fail-fast). "
"Проверь образ Driver / nvidia на VM; для SwarmUI — "
"first-install Comfy (InstallConfirmWS / dlbackend). "
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop"
)
return last
bad = ", ".join(f"{c.name}={c.detail}" for c in hard)
wait.tick(f" … ждём torch/Comfy: {bad}")
time.sleep(poll_every)
for c in last:
mark = "ok" if c.ok else "FAIL"
log(f" [{mark}] {c.name}: {c.detail}")
if raise_on_fail and any(not c.ok for c in last):
failed = [c.name for c in last if not c.ok]
raise CloudError(
f"GPU-стек не готов за {int(timeout)} с: {', '.join(failed)}. "
"Нужны nvidia-smi, CUDA; для SwarmUI — torch с cuda в Comfy venv "
"(journalctl -u swarmui / первый старт backend). "
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop"
)
return last
def verify_stack_local(
cfg: Config,
log: Log,
*,
timeout: float = 60.0,
poll_every: float = 2.0,
raise_on_fail: bool = True,
) -> list[ServiceCheck]:
"""After tunnel: local ports + light HTTP for enabled services."""
want_swarm, want_ollama = _expected_services(cfg)
targets: list[tuple[str, int, str | None]] = []
if want_swarm:
targets.append(("swarmui", int(cfg.swarmui_local_port), None))
if want_ollama:
targets.append(
(
"ollama",
int(cfg.ollama_local_port),
f"http://127.0.0.1:{cfg.ollama_local_port}/api/tags",
)
)
if not targets:
return []
log(
"проверка туннеля (localhost): "
+ ", ".join(f"{n}:{port}" for n, port, _ in targets)
)
deadline = time.time() + timeout
last: list[ServiceCheck] = []
wait = WaitLog(log, every=15.0)
while time.time() < deadline:
last = []
for name, port, url in targets:
if not _tcp_ok(port):
last.append(ServiceCheck(name, False, f"порт {port} закрыт", "local"))
continue
if url:
ok, detail = _http_local(url)
last.append(ServiceCheck(name, ok, detail, "local"))
else:
ok, detail = _http_local(f"http://127.0.0.1:{port}/")
if not ok:
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/API/GetNewSession",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=4) as resp:
ok = True
detail = f"API HTTP {getattr(resp, 'status', 200)}"
except Exception as exc:
ok = False
detail = f"HTTP/API fail (TCP open): {str(exc)[:100]}"
last.append(ServiceCheck(name, ok, detail, "local"))
if last and all(c.ok for c in last):
for c in last:
log(f" [ok] localhost {c.name}: {c.detail}")
log("проверка туннеля: всё доступно")
return last
bad = ", ".join(f"{c.name}={c.detail}" for c in last if not c.ok) or "пусто"
wait.tick(f" … localhost ещё нет: {bad}")
time.sleep(poll_every)
for c in last:
mark = "ok" if c.ok else "FAIL"
log(f" [{mark}] localhost {c.name}: {c.detail}")
if raise_on_fail and any(not c.ok for c in last):
failed = [c.name for c in last if not c.ok]
raise CloudError(
f"туннель поднят, но локально не отвечает: {', '.join(failed)}"
)
return last