- 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.
527 lines
18 KiB
Python
527 lines
18 KiB
Python
"""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
|