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:
Leonid Pershin
2026-08-21 07:09:20 +03:00
parent 1ec615c03e
commit adba4976ee
20 changed files with 657 additions and 58 deletions
+6 -2
View File
@@ -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).
+2
View File
@@ -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.
+1
View File
@@ -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
+19 -3
View File
@@ -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
View File
@@ -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,
) )
+44
View File
@@ -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:
+33 -4
View File
@@ -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:
@@ -408,6 +433,7 @@ def verify_gpu_env(
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:
+14 -2
View File
@@ -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
+4 -2
View File
@@ -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())
+5 -1
View File
@@ -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
View File
@@ -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
+25 -9
View File
@@ -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
+65
View File
@@ -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
+15 -1
View File
@@ -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"
+5 -2
View File
@@ -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
+52
View File
@@ -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
+33
View File
@@ -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"]
+47
View File
@@ -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")
+29
View File
@@ -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