Files
gpu-rent/src/gpu_rent/ready.py
T
Leonid Pershin 281ae15b12 Enhance access link collection and backend status reporting
- Updated the `collect_access_links` function to provide clearer user-facing endpoint labels and notes, particularly for non-tunneled scenarios.
- Improved logging messages in the provisioning process to reflect the status of the SwarmUI and Ollama API, enhancing user feedback during setup.
- Added human-readable status messages for backend loading and running states, improving clarity during the waiting process.
- Updated tests to verify the new behavior and ensure accurate reporting of access links and backend statuses.
2026-08-21 09:34:13 +03:00

527 lines
18 KiB
Python
Raw 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_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()
if waiting or live or loading:
print(f"BUSY queue w={waiting} live={live} load={loading}")
elif bstat == "empty":
# No backends registered — Comfy never installed (Install wizard).
# Not "Idle": treat as wait, never ready.
print("BUSY backend=empty (нужен first-install Comfy)")
elif bstat == "loading":
print("BUSY backend=loading (Comfy стартует, ждём Idle)")
elif bstat == "running":
# Self-start often reports running while still warming / first load.
print("BUSY backend=running (Comfy прогрев, ждём Idle)")
elif bstat in ("disabled", "all_disabled"):
print(f"BUSY backend={bstat}")
elif bstat == "idle":
print(f"READY backend={bstat}")
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, urllib.error, urllib.request, subprocess
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"
checks = []
want_swarm = WANT_SWARM
want_ollama = WANT_OLLAMA
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"),
})
if want_ollama:
ok, detail = http_ok("http://127.0.0.1:11434/api/tags")
checks.append({
"name": "ollama",
"ok": ok,
"detail": detail,
"unit": unit_active("gpu-rent-ollama"),
})
print(json.dumps({"checks": checks}, ensure_ascii=False))
'''
@dataclass(frozen=True)
class ServiceCheck:
name: str
ok: bool
detail: str
where: str = "vm" # vm | local
def wait_backend_idle(
cfg: Config,
host: str,
log: Log,
*,
timeout: float = 2400.0,
poll_every: float = 15.0,
) -> None:
"""Block until SwarmUI on the VM reports Idle backend (or timeout)."""
deadline = time.time() + timeout
log(
"жду Idle backend на VM (loading/running — норма, Comfy прогревается; "
"ссылки :17801 — после ready + tunnel)"
)
last = ""
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"
if line != last:
log(line)
last = line
if line.startswith("READY"):
log("backend Idle")
return
time.sleep(poll_every)
raise CloudError(
f"backend не стал Idle за {int(timeout)} с. "
"Проверь journalctl -u swarmui на VM; 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 _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", "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")
)
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:
log(f" [ok] {c.name}: {c.detail}")
log("проверка VM: всё отвечает")
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 не прогоняли",
)
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:
ok = True
detail = f"TCP :{port} open"
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