Enhance SwarmUI integration and GPU environment verification
- Updated CLI documentation to reflect the new handling of `CIVITAI_API_TOKEN`, which is now automatically passed to SwarmUI user settings during startup. - Improved the `render_access_panel` function to include additional warnings for idle-killer failures and stack errors, enhancing user feedback. - Introduced a new function `seed_swarmui_api_keys` to manage API key injection into SwarmUI, ensuring seamless integration with the Model Downloader. - Enhanced GPU environment verification logic to include fail-fast checks for critical components like CUDA, improving error handling and user notifications. - Updated tests to validate the new API key handling and access panel behavior, ensuring robustness in the integration process.
This commit is contained in:
+6
-2
@@ -233,8 +233,12 @@ Application credential для idle-killer CLI создаёт на `up` (узко
|
|||||||
2. TCP 22 / SSH
|
2. TCP 22 / SSH
|
||||||
3. Backend Idle (если SwarmUI) → toast (если `NOTIFY_READY`)
|
3. Backend Idle (если SwarmUI) → toast (если `NOTIFY_READY`)
|
||||||
4. На VM: HTTP сервисов стека (SwarmUI `:7801` / Ollama `:11434` / llama.cpp `:8080`) — `verify_stack_on_vm`
|
4. На VM: HTTP сервисов стека (SwarmUI `:7801` / Ollama `:11434` / llama.cpp `:8080`) — `verify_stack_on_vm`
|
||||||
5. На VM: **nvidia-smi / CUDA** (+ **torch+cuda** в Comfy venv, если SwarmUI) — `verify_gpu_env`
|
5. На VM: **nvidia-smi / CUDA** (fail-fast) + **torch+cuda** в Comfy venv при SwarmUI (ждём) — `verify_gpu_env`
|
||||||
6. Туннель + проверка **localhost** тех же сервисов → access-card
|
6. В логе: строка **`тайминг up:`** (SSH / bootstrap / Idle / verify / …)
|
||||||
|
7. Туннель + проверка **localhost** тех же сервисов → access-card (красная рамка, если killer failed)
|
||||||
|
|
||||||
|
`gpu-rent logs --unit swarm|ollama|llamacpp|killer` — фильтр journalctl.
|
||||||
|
`gpu-rent status` — killer/hold, последний стек/GPU-env, тайминг up.
|
||||||
|
|
||||||
Локальный порт UI: **17801** (на VM по-прежнему 7801 на loopback).
|
Локальный порт UI: **17801** (на VM по-прежнему 7801 на loopback).
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ copy models.example.yaml models.yaml
|
|||||||
| В `.env` есть `CIVITAI_API_TOKEN` **и** в манифесте есть хотя бы одна запись | Скачать перечисленные модели с Civitai **на VM** (не через ноут). Рядом положить метаданные. **Не** качать дефолтный чекпоинт установщика SwarmUI |
|
| В `.env` есть `CIVITAI_API_TOKEN` **и** в манифесте есть хотя бы одна запись | Скачать перечисленные модели с Civitai **на VM** (не через ноут). Рядом положить метаданные. **Не** качать дефолтный чекпоинт установщика SwarmUI |
|
||||||
| Токена нет, или манифест пуст/отсутствует | Обычный первый запуск SwarmUI: пусть ставит свою стандартную модель. В лог — почему Civitai-seed пропущен |
|
| Токена нет, или манифест пуст/отсутствует | Обычный первый запуск SwarmUI: пусть ставит свою стандартную модель. В лог — почему Civitai-seed пропущен |
|
||||||
|
|
||||||
|
После старта SwarmUI тот же `CIVITAI_API_TOKEN` прокидывается в UI через `/API/SetAPIKey` (`civitai_api`) — Model Downloader и gated-модели в браузере. При наличии `HF_TOKEN` / `HUGGING_FACE_HUB_TOKEN` — ещё `huggingface_api`.
|
||||||
|
|
||||||
Токен без манифеста = предупреждение и ветка SwarmUI default, не «пустой диск без моделей».
|
Токен без манифеста = предупреждение и ветка SwarmUI default, не «пустой диск без моделей».
|
||||||
|
|
||||||
ComfyUI в `dlbackend` качается всегда. Речь только о **модели весов** установщика SwarmUI, не о backend.
|
ComfyUI в `dlbackend` качается всегда. Речь только о **модели весов** установщика SwarmUI, не о backend.
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ notepad .env
|
|||||||
2. [Account settings](https://civitai.com/user/account) → **API Keys** → **Add**.
|
2. [Account settings](https://civitai.com/user/account) → **API Keys** → **Add**.
|
||||||
3. Токен показывают **один раз** → в `.env`: `CIVITAI_API_TOKEN=...`
|
3. Токен показывают **один раз** → в `.env`: `CIVITAI_API_TOKEN=...`
|
||||||
4. Хост по умолчанию **`civitai.red`** (полный каталог). С `.com` NSFW часто 404.
|
4. Хост по умолчанию **`civitai.red`** (полный каталог). С `.com` NSFW часто 404.
|
||||||
|
5. На `up` токен уходит и в seed на VM, и в SwarmUI User Settings (Civitai API Key) — вручную в UI вводить не нужно.
|
||||||
5. Манифест:
|
5. Манифест:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -161,8 +161,12 @@ def render_access_panel(
|
|||||||
notes = load_state().notes or {}
|
notes = load_state().notes or {}
|
||||||
if notes.get("idle_killer") == "failed":
|
if notes.get("idle_killer") == "failed":
|
||||||
warn_bits.append(
|
warn_bits.append(
|
||||||
"idle-killer НЕ вооружён — GPU может крутиться без авто-stop"
|
"idle-killer НЕ вооружён — GPU может крутиться без авто-stop → gpu-rent stop"
|
||||||
)
|
)
|
||||||
|
if notes.get("stack_vm_error"):
|
||||||
|
warn_bits.append(f"стек VM: {str(notes['stack_vm_error'])[:140]}")
|
||||||
|
if notes.get("gpu_env_error"):
|
||||||
|
warn_bits.append(f"GPU-стек: {str(notes['gpu_env_error'])[:140]}")
|
||||||
if notes.get("llm_error"):
|
if notes.get("llm_error"):
|
||||||
warn_bits.append(f"LLM ошибка: {str(notes['llm_error'])[:120]}")
|
warn_bits.append(f"LLM ошибка: {str(notes['llm_error'])[:120]}")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -170,8 +174,9 @@ def render_access_panel(
|
|||||||
|
|
||||||
parts: list = [subtitle, Text("")]
|
parts: list = [subtitle, Text("")]
|
||||||
if warn_bits:
|
if warn_bits:
|
||||||
|
parts.append(Text("⚠ ВНИМАНИЕ — биллинг / готовность", style="bold white on red"))
|
||||||
for w in warn_bits:
|
for w in warn_bits:
|
||||||
parts.append(Text(f"⚠ {w}", style="bold red"))
|
parts.append(Text(f" • {w}", style="bold red"))
|
||||||
parts.append(Text(""))
|
parts.append(Text(""))
|
||||||
parts.extend(
|
parts.extend(
|
||||||
[
|
[
|
||||||
@@ -185,10 +190,11 @@ def render_access_panel(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
body = Group(*parts)
|
body = Group(*parts)
|
||||||
|
border = "red" if warn_bits else "bright_blue"
|
||||||
return Panel(
|
return Panel(
|
||||||
body,
|
body,
|
||||||
title=f"[bold]{title}[/bold]",
|
title=f"[bold]{title}[/bold]",
|
||||||
border_style="bright_blue",
|
border_style=border,
|
||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,6 +218,16 @@ def print_access_card(
|
|||||||
# Plain fallback for non-Rich loggers
|
# Plain fallback for non-Rich loggers
|
||||||
log("")
|
log("")
|
||||||
log("══ gpu-rent · доступы ══")
|
log("══ gpu-rent · доступы ══")
|
||||||
|
try:
|
||||||
|
notes = load_state().notes or {}
|
||||||
|
if notes.get("idle_killer") == "failed":
|
||||||
|
log("⚠ idle-killer НЕ вооружён — GPU без авто-stop → gpu-rent stop")
|
||||||
|
if notes.get("stack_vm_error"):
|
||||||
|
log(f"⚠ стек VM: {notes['stack_vm_error']}")
|
||||||
|
if notes.get("gpu_env_error"):
|
||||||
|
log(f"⚠ GPU-стек: {notes['gpu_env_error']}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for link in collect_access_links(cfg, tunneled=tunneled):
|
for link in collect_access_links(cfg, tunneled=tunneled):
|
||||||
extra = f" ({link.note})" if link.note else ""
|
extra = f" ({link.note})" if link.note else ""
|
||||||
log(f" {link.label:14} {link.url}{extra}")
|
log(f" {link.label:14} {link.url}{extra}")
|
||||||
|
|||||||
+102
-19
@@ -47,6 +47,13 @@ def _die(exc: BaseException) -> None:
|
|||||||
if _DEBUG:
|
if _DEBUG:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
err(str(exc))
|
err(str(exc))
|
||||||
|
hint = (
|
||||||
|
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop "
|
||||||
|
"(Ctrl+C на туннеле GPU не гасит)"
|
||||||
|
)
|
||||||
|
msg = str(exc)
|
||||||
|
if "gpu-rent status" not in msg and "Дальше:" not in msg:
|
||||||
|
err(hint)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,6 +217,7 @@ def flavors(
|
|||||||
def status() -> None:
|
def status() -> None:
|
||||||
"""Локальный state + OpenStack, если .env есть. Туннель не нужен."""
|
"""Локальный state + OpenStack, если .env есть. Туннель не нужен."""
|
||||||
state = load_state()
|
state = load_state()
|
||||||
|
notes = dict(state.notes or {})
|
||||||
table = Table(title="status")
|
table = Table(title="status")
|
||||||
table.add_column("поле")
|
table.add_column("поле")
|
||||||
table.add_column("значение")
|
table.add_column("значение")
|
||||||
@@ -249,13 +257,18 @@ def status() -> None:
|
|||||||
table.add_row("диск used/free", df or "нет df")
|
table.add_row("диск used/free", df or "нет df")
|
||||||
from gpu_rent.idle_killer import killer_status_lines
|
from gpu_rent.idle_killer import killer_status_lines
|
||||||
|
|
||||||
table.add_row("idle-killer", "; ".join(killer_status_lines(cfg, state.floating_ip)))
|
killer_line = "; ".join(killer_status_lines(cfg, state.floating_ip))
|
||||||
note_k = (state.notes or {}).get("idle_killer")
|
note_k = notes.get("idle_killer")
|
||||||
if note_k == "failed":
|
if note_k == "failed":
|
||||||
err = (state.notes or {}).get("idle_killer_error") or ""
|
err_k = notes.get("idle_killer_error") or ""
|
||||||
table.add_row("idle-killer arm", f"[red]FAILED[/red] {err}"[:120])
|
table.add_row(
|
||||||
|
"idle-killer",
|
||||||
|
f"[red]FAILED arm[/red] · {killer_line} · {err_k}"[:160],
|
||||||
|
)
|
||||||
elif note_k == "armed":
|
elif note_k == "armed":
|
||||||
table.add_row("idle-killer arm", "ok (в сессии)")
|
table.add_row("idle-killer", f"{killer_line} · arm ok (сессия)")
|
||||||
|
else:
|
||||||
|
table.add_row("idle-killer", killer_line)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
table.add_row("диск used/free", f"SSH: {exc}")
|
table.add_row("диск used/free", f"SSH: {exc}")
|
||||||
table.add_row("idle-killer", "нет SSH")
|
table.add_row("idle-killer", "нет SSH")
|
||||||
@@ -263,20 +276,47 @@ def status() -> None:
|
|||||||
table.add_row("диск used/free", "нужен живой FIP + SSH-ключ")
|
table.add_row("диск used/free", "нужен живой FIP + SSH-ключ")
|
||||||
table.add_row("idle-killer", "нужен SSH на живую VM")
|
table.add_row("idle-killer", "нужен SSH на живую VM")
|
||||||
|
|
||||||
|
# Last verify snapshots (no new SSH)
|
||||||
|
if notes.get("stack_vm_error"):
|
||||||
|
table.add_row("стек VM", f"[red]FAIL[/red] {notes['stack_vm_error']}"[:140])
|
||||||
|
elif notes.get("stack_vm"):
|
||||||
|
bits = notes["stack_vm"]
|
||||||
|
if isinstance(bits, list):
|
||||||
|
ok_n = sum(1 for x in bits if isinstance(x, dict) and x.get("ok"))
|
||||||
|
table.add_row("стек VM", f"ok {ok_n}/{len(bits)} (последний up)")
|
||||||
|
else:
|
||||||
|
table.add_row("стек VM", str(bits)[:120])
|
||||||
|
if notes.get("gpu_env_error"):
|
||||||
|
table.add_row("GPU-стек", f"[red]FAIL[/red] {notes['gpu_env_error']}"[:140])
|
||||||
|
elif notes.get("gpu_env"):
|
||||||
|
bits = notes["gpu_env"]
|
||||||
|
if isinstance(bits, list):
|
||||||
|
summary = ", ".join(
|
||||||
|
f"{x.get('name')}={'ok' if x.get('ok') else 'FAIL'}"
|
||||||
|
for x in bits
|
||||||
|
if isinstance(x, dict)
|
||||||
|
)
|
||||||
|
table.add_row("GPU-стек", summary[:140] or "—")
|
||||||
|
if notes.get("up_timing"):
|
||||||
|
table.add_row("тайминг up", str(notes["up_timing"])[:140])
|
||||||
|
|
||||||
from gpu_rent.local_watchdog import watchdog_status_lines
|
from gpu_rent.local_watchdog import watchdog_status_lines
|
||||||
|
|
||||||
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
|
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
|
||||||
from gpu_rent.access_card import resolve_llm_runtime
|
from gpu_rent.access_card import resolve_llm_runtime
|
||||||
|
|
||||||
rt = resolve_llm_runtime(cfg)
|
rt = resolve_llm_runtime(cfg)
|
||||||
noted = (state.notes or {}).get("llm_runtime")
|
noted = notes.get("llm_runtime")
|
||||||
llm_err = (state.notes or {}).get("llm_error")
|
llm_err = notes.get("llm_error")
|
||||||
detail = f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}"
|
detail = f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}"
|
||||||
if noted and noted != rt:
|
if noted and noted != rt:
|
||||||
detail += f" (notes: {noted})"
|
detail += f" (notes: {noted})"
|
||||||
if llm_err:
|
if llm_err:
|
||||||
detail += f" [red]err: {llm_err[:80]}[/red]"
|
detail += f" [red]err: {llm_err[:80]}[/red]"
|
||||||
table.add_row("LLM", detail)
|
swarm_note = notes.get("enable_swarmui")
|
||||||
|
if swarm_note is False or not cfg.enable_swarmui:
|
||||||
|
detail += " · llm-only"
|
||||||
|
table.add_row("LLM / workload", detail)
|
||||||
|
|
||||||
if cfg.auth_ok:
|
if cfg.auth_ok:
|
||||||
try:
|
try:
|
||||||
@@ -288,11 +328,11 @@ def status() -> None:
|
|||||||
", ".join(f"{s.name} {s.status}" for s in servers),
|
", ".join(f"{s.name} {s.status}" for s in servers),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
table.add_row("Nova", "нет сервера gpu-rent")
|
table.add_row("Nova", "нет tagged server")
|
||||||
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
|
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
|
||||||
table.add_row("snapshot", cfg.boot_snapshot_name if snap else "нет")
|
table.add_row("snapshot", cfg.boot_snapshot_name if snap else "нет")
|
||||||
except GpuRentError as exc:
|
except Exception as exc:
|
||||||
table.add_row("Nova", f"не достучались: {exc}")
|
table.add_row("Nova", f"ошибка: {exc}"[:120])
|
||||||
else:
|
else:
|
||||||
table.add_row("Nova", "нет .env — только локальный state")
|
table.add_row("Nova", "нет .env — только локальный state")
|
||||||
|
|
||||||
@@ -695,22 +735,65 @@ def ssh() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def logs() -> None:
|
def logs(
|
||||||
"""cloud-init / journalctl -u swarmui на VM."""
|
unit: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--unit",
|
||||||
|
"-u",
|
||||||
|
help="swarm|ollama|llamacpp|killer|cloud-init (по умолчанию — всё)",
|
||||||
|
),
|
||||||
|
lines: int = typer.Option(80, "--lines", "-n", help="Строк journalctl"),
|
||||||
|
) -> None:
|
||||||
|
"""cloud-init / journalctl юнитов на VM."""
|
||||||
try:
|
try:
|
||||||
cfg = load_config(require_auth=True)
|
cfg = load_config(require_auth=True)
|
||||||
state = load_state()
|
state = load_state()
|
||||||
if not state.floating_ip:
|
if not state.floating_ip:
|
||||||
raise GpuRentError("нет IP — VM не поднята")
|
raise GpuRentError("нет IP — VM не поднята")
|
||||||
|
key = (unit or "all").strip().lower().replace("_", "-")
|
||||||
|
aliases = {
|
||||||
|
"all": "all",
|
||||||
|
"swarm": "swarmui",
|
||||||
|
"swarmui": "swarmui",
|
||||||
|
"ollama": "ollama",
|
||||||
|
"llamacpp": "llamacpp",
|
||||||
|
"llama": "llamacpp",
|
||||||
|
"killer": "gpu-rent-idle-killer",
|
||||||
|
"idle-killer": "gpu-rent-idle-killer",
|
||||||
|
"idle": "gpu-rent-idle-killer",
|
||||||
|
"cloud-init": "cloud-init",
|
||||||
|
"cloud": "cloud-init",
|
||||||
|
}
|
||||||
|
if key not in aliases:
|
||||||
|
raise GpuRentError(
|
||||||
|
f"неизвестный --unit={unit!r}; "
|
||||||
|
"ожидаю: swarm|ollama|llamacpp|killer|cloud-init|all"
|
||||||
|
)
|
||||||
|
target = aliases[key]
|
||||||
|
n = max(10, min(int(lines), 500))
|
||||||
|
parts: list[str] = []
|
||||||
|
if target in {"all", "cloud-init"}:
|
||||||
|
parts.append(
|
||||||
|
"echo '=== cloud-init (tail) ==='; "
|
||||||
|
"sudo -n tail -n 60 /var/log/cloud-init-output.log 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
journal_units = []
|
||||||
|
if target == "all":
|
||||||
|
journal_units = ["swarmui", "ollama", "llamacpp", "gpu-rent-idle-killer"]
|
||||||
|
elif target != "cloud-init":
|
||||||
|
journal_units = [target]
|
||||||
|
for ju in journal_units:
|
||||||
|
parts.append(
|
||||||
|
f"echo; echo '=== systemctl {ju} ==='; "
|
||||||
|
f"systemctl is-active {ju} 2>/dev/null || true; "
|
||||||
|
f"echo; echo '=== journalctl -u {ju} ==='; "
|
||||||
|
f"sudo -n journalctl -u {ju} -n {n} --no-pager 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
cmd = "; ".join(parts)
|
||||||
out = run_ssh(
|
out = run_ssh(
|
||||||
cfg,
|
cfg,
|
||||||
state.floating_ip,
|
state.floating_ip,
|
||||||
"echo '=== cloud-init (tail) ==='; "
|
cmd,
|
||||||
"sudo -n tail -n 60 /var/log/cloud-init-output.log 2>/dev/null || true; "
|
|
||||||
"echo; echo '=== systemctl swarmui ==='; "
|
|
||||||
"systemctl is-active swarmui 2>/dev/null || true; "
|
|
||||||
"echo; echo '=== journalctl -u swarmui ==='; "
|
|
||||||
"sudo -n journalctl -u swarmui -n 80 --no-pager 2>/dev/null || true",
|
|
||||||
check=False,
|
check=False,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -228,6 +228,50 @@ def _download_url(host: str, version_id: int, file_info: dict) -> str:
|
|||||||
return f"https://{host}/api/download/models/{version_id}"
|
return f"https://{host}/api/download/models/{version_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def seed_swarmui_api_keys(cfg: Config, host: str, log: Log) -> None:
|
||||||
|
"""Write CIVITAI_API_TOKEN (and HF if set) into SwarmUI user keys via SetAPIKey.
|
||||||
|
|
||||||
|
Swarm stores them in Users.ldb GenericData — needed for Model Downloader in the UI.
|
||||||
|
Call after SwarmUI HTTP is up (after wait_backend / verify).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
keys: dict[str, str] = {}
|
||||||
|
if cfg.civitai_api_token:
|
||||||
|
keys["civitai_api"] = cfg.civitai_api_token
|
||||||
|
hf = (
|
||||||
|
os.environ.get("HF_TOKEN")
|
||||||
|
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if hf:
|
||||||
|
keys["huggingface_api"] = hf
|
||||||
|
if not keys:
|
||||||
|
log("SwarmUI API keys: нет CIVITAI_API_TOKEN / HF_TOKEN — skip")
|
||||||
|
return
|
||||||
|
put_text(
|
||||||
|
cfg,
|
||||||
|
host,
|
||||||
|
"/tmp/gpu-rent-swarm-api-keys.json",
|
||||||
|
json.dumps(keys) + "\n",
|
||||||
|
mode=0o600,
|
||||||
|
)
|
||||||
|
names = ", ".join(keys)
|
||||||
|
log(f"SwarmUI: прокидываю API keys ({names})")
|
||||||
|
try:
|
||||||
|
run_python(
|
||||||
|
cfg,
|
||||||
|
host,
|
||||||
|
_pkg_text("swarmui_set_api_keys.py"),
|
||||||
|
remote_path="/tmp/gpu-rent-swarmui_set_api_keys.py",
|
||||||
|
timeout=180,
|
||||||
|
log=log,
|
||||||
|
)
|
||||||
|
except CloudError as exc:
|
||||||
|
log(f"⚠ SwarmUI API keys: {exc}")
|
||||||
|
run_ssh(cfg, host, "rm -f /tmp/gpu-rent-swarm-api-keys.json", check=False)
|
||||||
|
|
||||||
|
|
||||||
def seed_civitai(cfg: Config, host: str, log: Log) -> None:
|
def seed_civitai(cfg: Config, host: str, log: Log) -> None:
|
||||||
entries = parse_models(cfg.models_manifest)
|
entries = parse_models(cfg.models_manifest)
|
||||||
if not cfg.civitai_api_token:
|
if not cfg.civitai_api_token:
|
||||||
|
|||||||
+34
-5
@@ -17,6 +17,7 @@ from gpu_rent.config import Config
|
|||||||
from gpu_rent.errors import CloudError
|
from gpu_rent.errors import CloudError
|
||||||
from gpu_rent.llm_runtime import normalize_runtime
|
from gpu_rent.llm_runtime import normalize_runtime
|
||||||
from gpu_rent.ssh_ops import run_ssh
|
from gpu_rent.ssh_ops import run_ssh
|
||||||
|
from gpu_rent.timing import WaitLog
|
||||||
|
|
||||||
Log = Callable[[str], None]
|
Log = Callable[[str], None]
|
||||||
|
|
||||||
@@ -266,6 +267,7 @@ def verify_stack_on_vm(
|
|||||||
|
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
last: list[ServiceCheck] = []
|
last: list[ServiceCheck] = []
|
||||||
|
wait = WaitLog(log, every=30.0)
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
last = _probe_vm_once(cfg, host)
|
last = _probe_vm_once(cfg, host)
|
||||||
@@ -277,7 +279,7 @@ def verify_stack_on_vm(
|
|||||||
log("проверка VM: всё отвечает")
|
log("проверка VM: всё отвечает")
|
||||||
return last
|
return last
|
||||||
bad = ", ".join(f"{c.name}={c.detail}" for c in last if not c.ok) or "пусто"
|
bad = ", ".join(f"{c.name}={c.detail}" for c in last if not c.ok) or "пусто"
|
||||||
log(f" … ещё нет: {bad}")
|
wait.tick(f" … ещё нет: {bad}")
|
||||||
time.sleep(poll_every)
|
time.sleep(poll_every)
|
||||||
|
|
||||||
for c in last:
|
for c in last:
|
||||||
@@ -320,11 +322,18 @@ def verify_gpu_env(
|
|||||||
poll_every: float = 15.0,
|
poll_every: float = 15.0,
|
||||||
raise_on_fail: bool = True,
|
raise_on_fail: bool = True,
|
||||||
) -> list[ServiceCheck]:
|
) -> list[ServiceCheck]:
|
||||||
"""nvidia-smi / CUDA / torch(+cuda) in Comfy venv when SwarmUI is on."""
|
"""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 importlib.resources import files
|
||||||
|
|
||||||
from gpu_rent.ssh_ops import run_python
|
from gpu_rent.ssh_ops import run_python
|
||||||
|
|
||||||
|
# These never "appear later" on a broken image — don't burn the poll budget.
|
||||||
|
instant_fail_names = {"nvidia-smi", "cuda"}
|
||||||
|
|
||||||
want_swarm = bool(getattr(cfg, "enable_swarmui", True))
|
want_swarm = bool(getattr(cfg, "enable_swarmui", True))
|
||||||
script = files("gpu_rent.remote").joinpath("stack_env_probe.py").read_text(
|
script = files("gpu_rent.remote").joinpath("stack_env_probe.py").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
@@ -338,6 +347,7 @@ def verify_gpu_env(
|
|||||||
|
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
last: list[ServiceCheck] = []
|
last: list[ServiceCheck] = []
|
||||||
|
wait = WaitLog(log, every=30.0)
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
try:
|
||||||
out = run_python(
|
out = run_python(
|
||||||
@@ -350,7 +360,7 @@ def verify_gpu_env(
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last = [ServiceCheck("gpu-env", False, str(exc)[:200], "vm")]
|
last = [ServiceCheck("gpu-env", False, str(exc)[:200], "vm")]
|
||||||
log(f" … gpu-env: {exc}")
|
wait.tick(f" … gpu-env: {exc}")
|
||||||
time.sleep(poll_every)
|
time.sleep(poll_every)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -366,6 +376,7 @@ def verify_gpu_env(
|
|||||||
checks_raw = data.get("checks") if isinstance(data, dict) else None
|
checks_raw = data.get("checks") if isinstance(data, dict) else None
|
||||||
if not isinstance(checks_raw, list):
|
if not isinstance(checks_raw, list):
|
||||||
last = [ServiceCheck("gpu-env", False, f"нет JSON: {out[-180:]}", "vm")]
|
last = [ServiceCheck("gpu-env", False, f"нет JSON: {out[-180:]}", "vm")]
|
||||||
|
wait.tick(f" … gpu-env: нет JSON")
|
||||||
time.sleep(poll_every)
|
time.sleep(poll_every)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -395,8 +406,22 @@ def verify_gpu_env(
|
|||||||
log("проверка GPU-стека: ок")
|
log("проверка GPU-стека: ок")
|
||||||
return last
|
return last
|
||||||
|
|
||||||
|
instant = [c for c in hard if c.name in instant_fail_names]
|
||||||
|
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. "
|
||||||
|
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop"
|
||||||
|
)
|
||||||
|
return last
|
||||||
|
|
||||||
bad = ", ".join(f"{c.name}={c.detail}" for c in hard)
|
bad = ", ".join(f"{c.name}={c.detail}" for c in hard)
|
||||||
log(f" … ещё нет: {bad}")
|
wait.tick(f" … ждём torch/Comfy: {bad}")
|
||||||
time.sleep(poll_every)
|
time.sleep(poll_every)
|
||||||
|
|
||||||
for c in last:
|
for c in last:
|
||||||
@@ -407,7 +432,8 @@ def verify_gpu_env(
|
|||||||
raise CloudError(
|
raise CloudError(
|
||||||
f"GPU-стек не готов за {int(timeout)} с: {', '.join(failed)}. "
|
f"GPU-стек не готов за {int(timeout)} с: {', '.join(failed)}. "
|
||||||
"Нужны nvidia-smi, CUDA; для SwarmUI — torch с cuda в Comfy venv "
|
"Нужны nvidia-smi, CUDA; для SwarmUI — torch с cuda в Comfy venv "
|
||||||
"(journalctl -u swarmui / первый старт backend)."
|
"(journalctl -u swarmui / первый старт backend). "
|
||||||
|
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop"
|
||||||
)
|
)
|
||||||
return last
|
return last
|
||||||
|
|
||||||
@@ -446,6 +472,7 @@ def verify_stack_local(
|
|||||||
)
|
)
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
last: list[ServiceCheck] = []
|
last: list[ServiceCheck] = []
|
||||||
|
wait = WaitLog(log, every=15.0)
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
last = []
|
last = []
|
||||||
for name, port, url in targets:
|
for name, port, url in targets:
|
||||||
@@ -479,6 +506,8 @@ def verify_stack_local(
|
|||||||
log(f" [ok] localhost {c.name}: {c.detail}")
|
log(f" [ok] localhost {c.name}: {c.detail}")
|
||||||
log("проверка туннеля: всё доступно")
|
log("проверка туннеля: всё доступно")
|
||||||
return last
|
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)
|
time.sleep(poll_every)
|
||||||
|
|
||||||
for c in last:
|
for c in last:
|
||||||
|
|||||||
@@ -77,9 +77,21 @@ ensure_bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log "пакеты (без upgrade ядра)"
|
log "пакеты (без upgrade ядра)"
|
||||||
if [[ "${GPU_RENT_BOOTSTRAP_LIGHT:-0}" == "1" && -f "$MARKER_BOOT" ]]; then
|
# Light: skip apt when Swarm already bootstrapped, or llm-only data disk already ready.
|
||||||
log "light bootstrap — пропускаем apt-get"
|
_light_ok=0
|
||||||
|
if [[ "${GPU_RENT_BOOTSTRAP_LIGHT:-0}" == "1" ]]; then
|
||||||
|
if [[ -f "$MARKER_BOOT" ]]; then
|
||||||
|
_light_ok=1
|
||||||
|
elif [[ "${GPU_RENT_SKIP_SWARMUI:-0}" == "1" && -f "$MARKER_DATA" ]]; then
|
||||||
|
_light_ok=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$_light_ok" == "1" ]]; then
|
||||||
|
log "light bootstrap — пропускаем apt-get (маркер уже есть)"
|
||||||
else
|
else
|
||||||
|
if [[ "${GPU_RENT_BOOTSTRAP_LIGHT:-0}" == "1" ]]; then
|
||||||
|
log "light запрошен, но маркера нет — полный apt"
|
||||||
|
fi
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq git python3 python3-venv python3-pip python3-full curl ca-certificates
|
apt-get install -y -qq git python3 python3-venv python3-pip python3-full curl ca-certificates
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -179,14 +179,16 @@ def main() -> int:
|
|||||||
token = TOKEN_PATH.read_text(encoding="utf-8").strip() if TOKEN_PATH.is_file() else ""
|
token = TOKEN_PATH.read_text(encoding="utf-8").strip() if TOKEN_PATH.is_file() else ""
|
||||||
jobs = json.loads(JOBS_PATH.read_text(encoding="utf-8"))
|
jobs = json.loads(JOBS_PATH.read_text(encoding="utf-8"))
|
||||||
update = do_update()
|
update = do_update()
|
||||||
print(f"extensions update={'on' if update else 'off'}")
|
print(f"extensions update={'on' if update else 'off'} jobs={len(jobs)}")
|
||||||
failed = 0
|
failed = 0
|
||||||
known: set[str] = set()
|
known: set[str] = set()
|
||||||
try:
|
try:
|
||||||
for job in jobs:
|
total = len(jobs)
|
||||||
|
for i, job in enumerate(jobs, start=1):
|
||||||
try:
|
try:
|
||||||
dest = str(Path(job["dest"]))
|
dest = str(Path(job["dest"]))
|
||||||
known.add(dest)
|
known.add(dest)
|
||||||
|
print(f"extensions [{i}/{total}] {dest}")
|
||||||
clone_one(job, token, update)
|
clone_one(job, token, update)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Push upstream API keys into SwarmUI (user GenericData via SetAPIKey).
|
||||||
|
|
||||||
|
Stdlib only. Keys file: /tmp/gpu-rent-swarm-api-keys.json (mode 600), shape:
|
||||||
|
{"civitai_api": "...", "huggingface_api": "..."} # omit empty
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
KEYS_PATH = Path("/tmp/gpu-rent-swarm-api-keys.json")
|
||||||
|
SWARM = "http://127.0.0.1:7801"
|
||||||
|
ACCEPTED = ("civitai_api", "huggingface_api", "stability_api")
|
||||||
|
|
||||||
|
|
||||||
|
def post(path: str, payload: dict, timeout: float = 15.0) -> dict:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{SWARM}{path}",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_session(deadline: float) -> str:
|
||||||
|
last = ""
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
data = post("/API/GetNewSession", {})
|
||||||
|
sid = str(data.get("session_id") or "")
|
||||||
|
if sid:
|
||||||
|
return sid
|
||||||
|
last = "no session_id"
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||||
|
last = str(exc)[:160]
|
||||||
|
time.sleep(2)
|
||||||
|
raise SystemExit(f"SwarmUI session unavailable: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not KEYS_PATH.is_file():
|
||||||
|
print("no keys file — skip")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
raw = json.loads(KEYS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"bad keys file: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
KEYS_PATH.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
keys = {k: str(v).strip() for k, v in (raw or {}).items() if k in ACCEPTED and str(v).strip()}
|
||||||
|
if not keys:
|
||||||
|
print("no api keys to set")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sid = wait_session(time.time() + 120)
|
||||||
|
for key_type, value in keys.items():
|
||||||
|
try:
|
||||||
|
resp = post(
|
||||||
|
"/API/SetAPIKey",
|
||||||
|
{"session_id": sid, "keyType": key_type, "key": value},
|
||||||
|
)
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"SetAPIKey {key_type} failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if resp.get("error"):
|
||||||
|
print(f"SetAPIKey {key_type}: {resp['error']}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not resp.get("success"):
|
||||||
|
print(f"SetAPIKey {key_type}: unexpected {resp}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"SetAPIKey {key_type}=ok")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -129,11 +129,15 @@ def main() -> int:
|
|||||||
prev = json.loads(MARKER.read_text(encoding="utf-8"))
|
prev = json.loads(MARKER.read_text(encoding="utf-8"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
prev = {}
|
prev = {}
|
||||||
if prev.get("uuid") and prev.get("uuid") == plan["uuid"] and prev.get("extra_args") == plan["extra_args"]:
|
same_gpu = bool(prev.get("uuid") and prev.get("uuid") == plan["uuid"])
|
||||||
|
if same_gpu and prev.get("extra_args") == plan["extra_args"]:
|
||||||
if prev.get("pip_ok") or not plan["use_sage"]:
|
if prev.get("pip_ok") or not plan["use_sage"]:
|
||||||
print(f"perf tune already applied for {plan['name']} ({plan['tier']})")
|
print(f"perf tune already applied for {plan['name']} ({plan['tier']})")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if same_gpu and plan["use_sage"] and prev.get("pip_ok") is False:
|
||||||
|
print("perf tune: retry (previous pip_ok=false — sage/triton ещё не встали)")
|
||||||
|
|
||||||
print(f"perf tune: {plan['name']} tier={plan['tier']} sage={plan['use_sage']}")
|
print(f"perf tune: {plan['name']} tier={plan['tier']} sage={plan['use_sage']}")
|
||||||
pip_ok = not plan["use_sage"]
|
pip_ok = not plan["use_sage"]
|
||||||
restarted_needed = False
|
restarted_needed = False
|
||||||
|
|||||||
+64
-11
@@ -21,7 +21,7 @@ from gpu_rent.cloud import (
|
|||||||
wait_volume,
|
wait_volume,
|
||||||
)
|
)
|
||||||
from gpu_rent.bootstrap import run_bootstrap
|
from gpu_rent.bootstrap import run_bootstrap
|
||||||
from gpu_rent.provision import provision_vm, tune_swarm_perf
|
from gpu_rent.provision import provision_vm, seed_swarmui_api_keys, tune_swarm_perf
|
||||||
from gpu_rent.ready import verify_gpu_env, verify_stack_on_vm, wait_backend_idle
|
from gpu_rent.ready import verify_gpu_env, verify_stack_on_vm, wait_backend_idle
|
||||||
from gpu_rent.snapshot import ensure_boot_snapshot
|
from gpu_rent.snapshot import ensure_boot_snapshot
|
||||||
from gpu_rent.notify import notify_ready
|
from gpu_rent.notify import notify_ready
|
||||||
@@ -48,6 +48,7 @@ from gpu_rent.os_client import (
|
|||||||
from gpu_rent.ssh_keys import ensure_ed25519
|
from gpu_rent.ssh_keys import ensure_ed25519
|
||||||
from gpu_rent.ssh_ops import probe_ssh, run_ssh, wait_ssh
|
from gpu_rent.ssh_ops import probe_ssh, run_ssh, wait_ssh
|
||||||
from gpu_rent.state import SessionState, load_state, save_state, utc_now
|
from gpu_rent.state import SessionState, load_state, save_state, utc_now
|
||||||
|
from gpu_rent.timing import PhaseTimes
|
||||||
|
|
||||||
Log = Callable[[str], None]
|
Log = Callable[[str], None]
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ def _bind_access(
|
|||||||
log: Log,
|
log: Log,
|
||||||
*,
|
*,
|
||||||
update: bool = True,
|
update: bool = True,
|
||||||
|
phases: PhaseTimes | None = None,
|
||||||
) -> SessionState:
|
) -> SessionState:
|
||||||
|
clock = phases or PhaseTimes()
|
||||||
ip, fip_id = ensure_floating_ip(
|
ip, fip_id = ensure_floating_ip(
|
||||||
conn, server, state.floating_ip_id, state.floating_ip, log
|
conn, server, state.floating_ip_id, state.floating_ip, log
|
||||||
)
|
)
|
||||||
@@ -83,6 +86,7 @@ def _bind_access(
|
|||||||
state.floating_ip_id = fip_id
|
state.floating_ip_id = fip_id
|
||||||
save_state(state)
|
save_state(state)
|
||||||
wait_ssh(cfg, ip)
|
wait_ssh(cfg, ip)
|
||||||
|
clock.mark("SSH")
|
||||||
log(f"SSH {cfg.ssh_user}@{ip}")
|
log(f"SSH {cfg.ssh_user}@{ip}")
|
||||||
state.phase = "bootstrapping"
|
state.phase = "bootstrapping"
|
||||||
save_state(state)
|
save_state(state)
|
||||||
@@ -92,20 +96,49 @@ def _bind_access(
|
|||||||
if active == "active":
|
if active == "active":
|
||||||
log("systemctl stop swarmui перед git update")
|
log("systemctl stop swarmui перед git update")
|
||||||
run_ssh(cfg, ip, "sudo -n systemctl stop swarmui", timeout=120, check=False)
|
run_ssh(cfg, ip, "sudo -n systemctl stop swarmui", timeout=120, check=False)
|
||||||
# Skip apt-heavy bootstrap when the VM already finished first-boot.
|
|
||||||
|
# Detect existing markers so light/full choice is explicit (swarm ↔ llm-only).
|
||||||
|
probe = run_ssh(
|
||||||
|
cfg,
|
||||||
|
ip,
|
||||||
|
"echo swarm=$(test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no); "
|
||||||
|
"echo data=$(test -f /mnt/swarm_data/.gpu-rent-ready && echo yes || echo no); "
|
||||||
|
"echo llm=$(test -f /mnt/swarm_data/.gpu-rent-llm-only && echo yes || echo no)",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
flags: dict[str, str] = {}
|
||||||
|
for line in probe.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.strip().split("=", 1)
|
||||||
|
flags[k] = v
|
||||||
|
has_swarm = flags.get("swarm") == "yes"
|
||||||
|
has_data = flags.get("data") == "yes"
|
||||||
|
was_llm_only = flags.get("llm") == "yes"
|
||||||
|
|
||||||
if swarm:
|
if swarm:
|
||||||
marker_cmd = "test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no"
|
light = has_swarm
|
||||||
|
if was_llm_only and not has_swarm:
|
||||||
|
log("bootstrap: был llm-only → полный проход (ставим SwarmUI)")
|
||||||
|
elif light:
|
||||||
|
why = (
|
||||||
|
"локальный bootstrapped был сброшен, маркер на VM есть"
|
||||||
|
if not state.bootstrapped
|
||||||
|
else "маркер /opt/swarmui/.gpu-rent-bootstrapped"
|
||||||
|
)
|
||||||
|
log(f"bootstrap: LIGHT (без apt) — {why}")
|
||||||
else:
|
else:
|
||||||
marker_cmd = "test -f /mnt/swarm_data/.gpu-rent-ready && echo yes || echo no"
|
log("bootstrap: FULL (apt + SwarmUI) — маркера bootstrapped нет")
|
||||||
marker = run_ssh(cfg, ip, marker_cmd, check=False).strip()
|
|
||||||
if marker == "yes":
|
|
||||||
if not state.bootstrapped:
|
|
||||||
log("маркер bootstrap на VM — лёгкий проход (локальный bootstrapped был сброшен)")
|
|
||||||
else:
|
else:
|
||||||
log("bootstrap уже на VM — лёгкий проход (без apt)")
|
light = has_data
|
||||||
run_bootstrap(cfg, ip, log, update=update and swarm, light=True)
|
if has_swarm and not was_llm_only:
|
||||||
|
log("bootstrap: llm-only на диске со SwarmUI — LIGHT data, Swarm unit stop")
|
||||||
|
if light:
|
||||||
|
log("bootstrap: LIGHT llm-only (без apt) — есть .gpu-rent-ready")
|
||||||
else:
|
else:
|
||||||
run_bootstrap(cfg, ip, log, update=update and swarm, light=False)
|
log("bootstrap: FULL llm-only — маркера data ready нет")
|
||||||
|
|
||||||
|
run_bootstrap(cfg, ip, log, update=update and swarm, light=light)
|
||||||
|
clock.mark("bootstrap")
|
||||||
provision_vm(
|
provision_vm(
|
||||||
cfg,
|
cfg,
|
||||||
ip,
|
ip,
|
||||||
@@ -114,17 +147,31 @@ def _bind_access(
|
|||||||
server_id=getattr(server, "id", None) or state.server_id,
|
server_id=getattr(server, "id", None) or state.server_id,
|
||||||
update=update,
|
update=update,
|
||||||
)
|
)
|
||||||
|
clock.mark("provision")
|
||||||
if swarm:
|
if swarm:
|
||||||
try:
|
try:
|
||||||
wait_backend_idle(cfg, ip, log)
|
wait_backend_idle(cfg, ip, log)
|
||||||
except CloudError as exc:
|
except CloudError as exc:
|
||||||
log(f"ready: {exc}")
|
log(f"ready: {exc}")
|
||||||
|
clock.mark("Idle")
|
||||||
try:
|
try:
|
||||||
if tune_swarm_perf(cfg, ip, log):
|
if tune_swarm_perf(cfg, ip, log):
|
||||||
log("systemctl restart swarmui (perf ExtraArgs)")
|
log("systemctl restart swarmui (perf ExtraArgs)")
|
||||||
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
|
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
|
||||||
|
try:
|
||||||
|
wait_backend_idle(cfg, ip, log)
|
||||||
|
except CloudError as exc:
|
||||||
|
log(f"ready after perf: {exc}")
|
||||||
|
else:
|
||||||
|
log("perf tune: restart не нужен")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"perf tune: {exc}")
|
log(f"perf tune: {exc}")
|
||||||
|
clock.mark("perf")
|
||||||
|
try:
|
||||||
|
seed_swarmui_api_keys(cfg, ip, log)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"SwarmUI API keys: {exc}")
|
||||||
|
clock.mark("api-keys")
|
||||||
else:
|
else:
|
||||||
log("ready: llm-only (без ожидания SwarmUI Idle)")
|
log("ready: llm-only (без ожидания SwarmUI Idle)")
|
||||||
|
|
||||||
@@ -134,11 +181,13 @@ def _bind_access(
|
|||||||
state.notes["stack_vm"] = [
|
state.notes["stack_vm"] = [
|
||||||
{"name": c.name, "ok": c.ok, "detail": c.detail} for c in checks
|
{"name": c.name, "ok": c.ok, "detail": c.detail} for c in checks
|
||||||
]
|
]
|
||||||
|
state.notes.pop("stack_vm_error", None)
|
||||||
except CloudError as exc:
|
except CloudError as exc:
|
||||||
state.notes = dict(state.notes or {})
|
state.notes = dict(state.notes or {})
|
||||||
state.notes["stack_vm_error"] = str(exc)[:500]
|
state.notes["stack_vm_error"] = str(exc)[:500]
|
||||||
save_state(state)
|
save_state(state)
|
||||||
raise
|
raise
|
||||||
|
clock.mark("verify")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
gpu_checks = verify_gpu_env(cfg, ip, log, timeout=600.0)
|
gpu_checks = verify_gpu_env(cfg, ip, log, timeout=600.0)
|
||||||
@@ -146,11 +195,13 @@ def _bind_access(
|
|||||||
state.notes["gpu_env"] = [
|
state.notes["gpu_env"] = [
|
||||||
{"name": c.name, "ok": c.ok, "detail": c.detail} for c in gpu_checks
|
{"name": c.name, "ok": c.ok, "detail": c.detail} for c in gpu_checks
|
||||||
]
|
]
|
||||||
|
state.notes.pop("gpu_env_error", None)
|
||||||
except CloudError as exc:
|
except CloudError as exc:
|
||||||
state.notes = dict(state.notes or {})
|
state.notes = dict(state.notes or {})
|
||||||
state.notes["gpu_env_error"] = str(exc)[:500]
|
state.notes["gpu_env_error"] = str(exc)[:500]
|
||||||
save_state(state)
|
save_state(state)
|
||||||
raise
|
raise
|
||||||
|
clock.mark("gpu-env")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ensure_boot_snapshot(
|
ensure_boot_snapshot(
|
||||||
@@ -187,7 +238,9 @@ def _bind_access(
|
|||||||
state.phase = "ready_cloud"
|
state.phase = "ready_cloud"
|
||||||
state.notes = dict(state.notes or {})
|
state.notes = dict(state.notes or {})
|
||||||
state.notes["enable_swarmui"] = swarm
|
state.notes["enable_swarmui"] = swarm
|
||||||
|
state.notes["up_timing"] = clock.summary_line()
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
log(f"тайминг up: {clock.summary_line()}")
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,25 +31,29 @@ def push_tree(
|
|||||||
log(f"push {local_root.name}: пусто — skip")
|
log(f"push {local_root.name}: пусто — skip")
|
||||||
return 0
|
return 0
|
||||||
files = model_push_set(local_root) if models else iter_payload_files(local_root)
|
files = model_push_set(local_root) if models else iter_payload_files(local_root)
|
||||||
|
file_list = list(files)
|
||||||
|
total = len(file_list)
|
||||||
sent = 0
|
sent = 0
|
||||||
|
skipped = 0
|
||||||
client = open_ssh(cfg, host)
|
client = open_ssh(cfg, host)
|
||||||
try:
|
try:
|
||||||
for path in files:
|
for i, path in enumerate(file_list, start=1):
|
||||||
rel = path.relative_to(local_root).as_posix()
|
rel = path.relative_to(local_root).as_posix()
|
||||||
remote = f"{remote_root.rstrip('/')}/{rel}"
|
remote = f"{remote_root.rstrip('/')}/{rel}"
|
||||||
local_hash = sha256_file(path)
|
local_hash = sha256_file(path)
|
||||||
remote_hash = remote_sha256_on(client, remote)
|
remote_hash = remote_sha256_on(client, remote)
|
||||||
if remote_hash and remote_hash.lower() == local_hash.lower():
|
if remote_hash and remote_hash.lower() == local_hash.lower():
|
||||||
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
log(f"push {rel}")
|
log(f"push [{i}/{total}] {rel}")
|
||||||
put_file_on(client, path, remote)
|
put_file_on(client, path, remote)
|
||||||
sent += 1
|
sent += 1
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
if sent == 0:
|
if sent == 0:
|
||||||
log(f"push {local_root.name}: всё уже на VM")
|
log(f"push {local_root.name}: всё уже на VM ({total} файл(ов), skip={skipped})")
|
||||||
else:
|
else:
|
||||||
log(f"push {local_root.name}: {sent} файл(ов)")
|
log(f"push {local_root.name}: {sent}/{total} отправлено (skip={skipped})")
|
||||||
return sent
|
return sent
|
||||||
|
|
||||||
|
|
||||||
@@ -63,19 +67,31 @@ def pull_tree(cfg: Config, host: str, remote_root: str, local_root: Path, log: L
|
|||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
names = [line.strip() for line in listing.splitlines() if line.strip()]
|
names = [line.strip() for line in listing.splitlines() if line.strip()]
|
||||||
|
work = [
|
||||||
|
rel
|
||||||
|
for rel in names
|
||||||
|
if not (
|
||||||
|
rel.endswith("/.gitkeep")
|
||||||
|
or rel.rsplit("/", 1)[-1] in {".gitkeep", "README.md"}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
total = len(work)
|
||||||
pulled = 0
|
pulled = 0
|
||||||
for rel in names:
|
skipped = 0
|
||||||
if rel.endswith("/.gitkeep") or rel.rsplit("/", 1)[-1] in {".gitkeep", "README.md"}:
|
for i, rel in enumerate(work, start=1):
|
||||||
continue
|
|
||||||
remote = f"{remote_root.rstrip('/')}/{rel}"
|
remote = f"{remote_root.rstrip('/')}/{rel}"
|
||||||
local = local_root / rel
|
local = local_root / rel
|
||||||
remote_hash = remote_sha256_on(client, remote)
|
remote_hash = remote_sha256_on(client, remote)
|
||||||
if local.is_file() and remote_hash and sha256_file(local).lower() == remote_hash.lower():
|
if local.is_file() and remote_hash and sha256_file(local).lower() == remote_hash.lower():
|
||||||
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
log(f"pull {rel}")
|
log(f"pull [{i}/{total}] {rel}")
|
||||||
get_file_on(client, remote, local)
|
get_file_on(client, remote, local)
|
||||||
pulled += 1
|
pulled += 1
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
log(f"pull Output: {pulled} файл(ов)" if pulled else "pull Output: нечего забирать")
|
if pulled:
|
||||||
|
log(f"pull Output: {pulled}/{total} (skip={skipped})")
|
||||||
|
else:
|
||||||
|
log(f"pull Output: нечего забирать ({total} файл(ов), skip={skipped})")
|
||||||
return pulled
|
return pulled
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Elapsed-time helpers for long `up` flows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
sec = max(0, int(round(seconds)))
|
||||||
|
if sec < 60:
|
||||||
|
return f"{sec}s"
|
||||||
|
minutes, rem = divmod(sec, 60)
|
||||||
|
if minutes < 60:
|
||||||
|
return f"{minutes}m {rem}s" if rem else f"{minutes}m"
|
||||||
|
hours, rem_m = divmod(minutes, 60)
|
||||||
|
return f"{hours}h {rem_m}m" if rem_m else f"{hours}h"
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseTimes:
|
||||||
|
"""Record named milestones from a shared start."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._t0 = time.monotonic()
|
||||||
|
self._marks: list[tuple[str, float]] = []
|
||||||
|
|
||||||
|
def mark(self, name: str) -> float:
|
||||||
|
elapsed = time.monotonic() - self._t0
|
||||||
|
self._marks.append((name, elapsed))
|
||||||
|
return elapsed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> float:
|
||||||
|
return time.monotonic() - self._t0
|
||||||
|
|
||||||
|
def deltas(self) -> list[tuple[str, float]]:
|
||||||
|
"""Per-phase duration (from previous mark or start)."""
|
||||||
|
out: list[tuple[str, float]] = []
|
||||||
|
prev = 0.0
|
||||||
|
for name, at in self._marks:
|
||||||
|
out.append((name, at - prev))
|
||||||
|
prev = at
|
||||||
|
return out
|
||||||
|
|
||||||
|
def summary_line(self) -> str:
|
||||||
|
parts = [f"{name} {format_duration(dt)}" for name, dt in self.deltas()]
|
||||||
|
parts.append(f"всего {format_duration(self.total)}")
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class WaitLog:
|
||||||
|
"""Log first wait message, then at most every `every` seconds."""
|
||||||
|
|
||||||
|
def __init__(self, log: Callable[[str], None], *, every: float = 30.0) -> None:
|
||||||
|
self._log = log
|
||||||
|
self._every = every
|
||||||
|
self._last = 0.0
|
||||||
|
self._n = 0
|
||||||
|
|
||||||
|
def tick(self, msg: str) -> None:
|
||||||
|
self._n += 1
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._n == 1 or (now - self._last) >= self._every:
|
||||||
|
self._log(msg)
|
||||||
|
self._last = now
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from gpu_rent.access_card import collect_access_links, mcp_snippet_lines
|
from gpu_rent.access_card import collect_access_links, mcp_snippet_lines, render_access_panel
|
||||||
|
|
||||||
|
|
||||||
class _Cfg:
|
class _Cfg:
|
||||||
@@ -6,6 +6,7 @@ class _Cfg:
|
|||||||
llm_runtime = "ollama"
|
llm_runtime = "ollama"
|
||||||
ollama_local_port = 17811
|
ollama_local_port = 17811
|
||||||
llamacpp_local_port = 17812
|
llamacpp_local_port = 17812
|
||||||
|
enable_swarmui = True
|
||||||
|
|
||||||
|
|
||||||
def test_collect_links_swarm_and_ollama(monkeypatch):
|
def test_collect_links_swarm_and_ollama(monkeypatch):
|
||||||
@@ -29,3 +30,16 @@ def test_collect_links_no_tunnel():
|
|||||||
def test_mcp_snippet_json():
|
def test_mcp_snippet_json():
|
||||||
lines = mcp_snippet_lines(_Cfg())
|
lines = mcp_snippet_lines(_Cfg())
|
||||||
assert any("17801/mcp" in line for line in lines)
|
assert any("17801/mcp" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_panel_red_when_killer_failed(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gpu_rent.access_card.load_state",
|
||||||
|
lambda: type(
|
||||||
|
"S",
|
||||||
|
(),
|
||||||
|
{"notes": {"idle_killer": "failed", "idle_killer_error": "no cred"}},
|
||||||
|
)(),
|
||||||
|
)
|
||||||
|
panel = render_access_panel(_Cfg(), tunneled=True)
|
||||||
|
assert panel.border_style == "red"
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ def test_bootstrap_script_is_native_swarmui():
|
|||||||
assert "Data/Autocompletions" in script
|
assert "Data/Autocompletions" in script
|
||||||
assert "mkfs.ext4" in script
|
assert "mkfs.ext4" in script
|
||||||
assert "apt-get upgrade" not in script
|
assert "apt-get upgrade" not in script
|
||||||
assert ".gpu-rent-ready" in script
|
assert "GPU_RENT_BOOTSTRAP_LIGHT" in script
|
||||||
assert "src/BuiltinExtensions/ComfyUIBackend/DLNodes" in script
|
assert "GPU_RENT_SKIP_SWARMUI" in script
|
||||||
|
assert "light bootstrap — пропускаем apt-get" in script
|
||||||
|
# llm-only re-up can skip apt when data marker exists (not only Swarm boot marker)
|
||||||
|
assert 'MARKER_DATA' in script or ".gpu-rent-ready" in script
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for remote swarmui_set_api_keys."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REMOTE = ROOT / "src" / "gpu_rent" / "remote" / "swarmui_set_api_keys.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
spec = importlib.util.spec_from_file_location("swarmui_set_api_keys", REMOTE)
|
||||||
|
assert spec and spec.loader
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_civitai_key(tmp_path, monkeypatch):
|
||||||
|
mod = _load()
|
||||||
|
keys = tmp_path / "keys.json"
|
||||||
|
keys.write_text(json.dumps({"civitai_api": "tok-123"}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(mod, "KEYS_PATH", keys)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(path, payload, timeout=15.0):
|
||||||
|
calls.append((path, payload))
|
||||||
|
if path == "/API/GetNewSession":
|
||||||
|
return {"session_id": "sid-1"}
|
||||||
|
if path == "/API/SetAPIKey":
|
||||||
|
assert payload["session_id"] == "sid-1"
|
||||||
|
assert payload["keyType"] == "civitai_api"
|
||||||
|
assert payload["key"] == "tok-123"
|
||||||
|
return {"success": True}
|
||||||
|
raise AssertionError(path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "post", fake_post)
|
||||||
|
monkeypatch.setattr(mod, "wait_session", lambda _d: "sid-1")
|
||||||
|
assert mod.main() == 0
|
||||||
|
assert not keys.exists()
|
||||||
|
assert any(c[0] == "/API/SetAPIKey" for c in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_empty_keys(tmp_path, monkeypatch):
|
||||||
|
mod = _load()
|
||||||
|
keys = tmp_path / "keys.json"
|
||||||
|
keys.write_text("{}", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(mod, "KEYS_PATH", keys)
|
||||||
|
assert mod.main() == 0
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from gpu_rent.timing import PhaseTimes, WaitLog, format_duration
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_duration():
|
||||||
|
assert format_duration(5) == "5s"
|
||||||
|
assert format_duration(65) == "1m 5s"
|
||||||
|
assert format_duration(60) == "1m"
|
||||||
|
assert format_duration(3661) == "1h 1m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase_times_summary(monkeypatch):
|
||||||
|
times = iter([100.0, 110.0, 130.0, 190.0])
|
||||||
|
monkeypatch.setattr("gpu_rent.timing.time.monotonic", lambda: next(times))
|
||||||
|
clock = PhaseTimes()
|
||||||
|
clock.mark("SSH")
|
||||||
|
clock.mark("bootstrap")
|
||||||
|
line = clock.summary_line()
|
||||||
|
assert "SSH 10s" in line
|
||||||
|
assert "bootstrap 20s" in line
|
||||||
|
assert "всего" in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_log_throttles(monkeypatch):
|
||||||
|
logs: list[str] = []
|
||||||
|
t = {"now": 0.0}
|
||||||
|
monkeypatch.setattr("gpu_rent.timing.time.monotonic", lambda: t["now"])
|
||||||
|
w = WaitLog(logs.append, every=30.0)
|
||||||
|
w.tick("a")
|
||||||
|
t["now"] = 10.0
|
||||||
|
w.tick("b")
|
||||||
|
t["now"] = 31.0
|
||||||
|
w.tick("c")
|
||||||
|
assert logs == ["a", "c"]
|
||||||
@@ -86,3 +86,50 @@ def test_pip_ok_patches_extra_args(tmp_path, monkeypatch):
|
|||||||
assert marker["pip_ok"] is True
|
assert marker["pip_ok"] is True
|
||||||
assert "--use-sage-attention" in marker["extra_args"]
|
assert "--use-sage-attention" in marker["extra_args"]
|
||||||
assert "--use-sage-attention" in backends.read_text(encoding="utf-8")
|
assert "--use-sage-attention" in backends.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pip_fail_retries_next_run(tmp_path, monkeypatch):
|
||||||
|
mod = _load()
|
||||||
|
data = tmp_path
|
||||||
|
backends = data / "Data" / "Backends.fds"
|
||||||
|
backends.parent.mkdir(parents=True)
|
||||||
|
backends.write_text("ExtraArgs: \n", encoding="utf-8")
|
||||||
|
gpu_json = data / ".gpu-rent-gpu.json"
|
||||||
|
gpu_json.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"vram_mib": 24576,
|
||||||
|
"compute_cap": "8.9",
|
||||||
|
"uuid": "gpu-1",
|
||||||
|
"name": "RTX",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
marker = data / ".gpu-rent-perf-tuned"
|
||||||
|
marker.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"uuid": "gpu-1",
|
||||||
|
"extra_args": "",
|
||||||
|
"pip_ok": False,
|
||||||
|
"tier": "high",
|
||||||
|
"name": "RTX",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
pip = data / "fake-pip"
|
||||||
|
pip.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod, "DATA", data)
|
||||||
|
monkeypatch.setattr(mod, "GPU_JSON", gpu_json)
|
||||||
|
monkeypatch.setattr(mod, "MARKER", marker)
|
||||||
|
monkeypatch.setattr(mod, "BACKENDS", backends)
|
||||||
|
monkeypatch.setattr(mod, "find_pip", lambda: pip)
|
||||||
|
monkeypatch.setattr(mod, "pip_install_sage", lambda _p: True)
|
||||||
|
|
||||||
|
assert mod.main() == 0
|
||||||
|
new_m = json.loads(marker.read_text(encoding="utf-8"))
|
||||||
|
assert new_m["pip_ok"] is True
|
||||||
|
assert "--use-sage-attention" in backends.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -95,6 +95,35 @@ def test_verify_gpu_env_ok(monkeypatch):
|
|||||||
assert any("GPU-стека" in line for line in logs)
|
assert any("GPU-стека" in line for line in logs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_gpu_env_fail_fast_cuda(monkeypatch):
|
||||||
|
import json
|
||||||
|
|
||||||
|
import gpu_rent.ssh_ops as ssh_ops
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"ok": False,
|
||||||
|
"checks": [
|
||||||
|
{"name": "nvidia-smi", "required": True, "ok": True, "detail": "ok"},
|
||||||
|
{"name": "cuda", "required": True, "ok": False, "detail": "нет libcuda"},
|
||||||
|
{"name": "torch", "required": True, "ok": False, "detail": "no venv"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake(*a, **k):
|
||||||
|
calls["n"] += 1
|
||||||
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ssh_ops, "run_python", fake)
|
||||||
|
logs: list[str] = []
|
||||||
|
try:
|
||||||
|
verify_gpu_env(_Cfg(), "1.2.3.4", logs.append, timeout=600.0, poll_every=0.1)
|
||||||
|
assert False, "expected CloudError"
|
||||||
|
except CloudError as exc:
|
||||||
|
assert "fail-fast" in str(exc).lower() or "cuda" in str(exc).lower()
|
||||||
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_verify_gpu_env_fails_without_cuda(monkeypatch):
|
def test_verify_gpu_env_fails_without_cuda(monkeypatch):
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user