- Clarified the behavior of `Ctrl+C` and `Ctrl+D` in the README and other documentation, specifying that `Ctrl+C` only stops the tunnel while keeping the GPU active, and `Ctrl+D` stops the GPU while preserving disk data. - Enhanced the CLI documentation to reflect these changes, ensuring users understand the implications of these commands during GPU operations. - Improved the handling of data bindings and remounting logic in the codebase to prevent issues with empty model tabs in the UI. - Added tests to validate the new command behaviors and ensure proper documentation alignment.
433 lines
16 KiB
Python
433 lines
16 KiB
Python
"""Preflight without creating a GPU. All checks print; exit 1 if session cannot start."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from gpu_rent.civitai import other_host, probe_me
|
||
from gpu_rent.config import Config, load_config
|
||
from gpu_rent.errors import CloudError, ConfigError
|
||
from gpu_rent.inventory import (
|
||
gpu_quota_from_compute,
|
||
looks_like_gpu,
|
||
pick_boot_image,
|
||
pick_volume_type,
|
||
rank_flavors,
|
||
)
|
||
from gpu_rent.manifests import parse_extensions, parse_models
|
||
from gpu_rent.os_client import (
|
||
compute_quotas,
|
||
connect,
|
||
find_snapshot_by_name,
|
||
find_tagged_servers,
|
||
iter_flavors,
|
||
iter_images,
|
||
iter_volume_types,
|
||
volume_quotas,
|
||
)
|
||
from gpu_rent.payload import folder_bytes as _folder_bytes, has_payload as _has_payload
|
||
from gpu_rent.paths import env_path, runtime_dir
|
||
from gpu_rent.ssh_keys import key_ready
|
||
from gpu_rent.state import load_state
|
||
|
||
|
||
@dataclass
|
||
class Check:
|
||
name: str
|
||
ok: bool
|
||
blocking: bool
|
||
detail: str
|
||
|
||
|
||
def run_doctor() -> list[Check]:
|
||
checks: list[Check] = []
|
||
runtime_dir().mkdir(parents=True, exist_ok=True)
|
||
|
||
env_file = env_path()
|
||
if env_file.is_file():
|
||
checks.append(Check("env file", True, True, str(env_file)))
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"env file",
|
||
False,
|
||
True,
|
||
f"нет {env_file}. Скопируй env.example и заполни по docs/setup.md",
|
||
)
|
||
)
|
||
return checks
|
||
|
||
try:
|
||
cfg = load_config(require_auth=True)
|
||
except ConfigError as exc:
|
||
checks.append(Check("OS_*", False, True, str(exc)))
|
||
return checks
|
||
checks.append(
|
||
Check(
|
||
"OS_*",
|
||
True,
|
||
True,
|
||
f"project={cfg.os_project_id} region={cfg.os_region_name} az={cfg.gpu_rent_az}",
|
||
)
|
||
)
|
||
|
||
if key_ready(cfg.ssh_private_key_path):
|
||
checks.append(Check("SSH key", True, True, f"есть {cfg.ssh_private_key_path}"))
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"SSH key",
|
||
True,
|
||
True,
|
||
f"ключа нет — CLI создаст {cfg.ssh_private_key_path} на первом up",
|
||
)
|
||
)
|
||
|
||
conn = None
|
||
try:
|
||
conn = connect(cfg)
|
||
checks.append(Check("Keystone", True, True, "IAM-токен выдан (TTL ~24 ч, sdk обновит)"))
|
||
except CloudError as exc:
|
||
checks.append(Check("Keystone", False, True, str(exc)))
|
||
_local_manifests(cfg, checks)
|
||
_local_folders(cfg, checks)
|
||
return checks
|
||
|
||
try:
|
||
quota = compute_quotas(conn)
|
||
gpu_limit = gpu_quota_from_compute(quota)
|
||
if gpu_limit is None:
|
||
checks.append(
|
||
Check(
|
||
"квота GPU",
|
||
True,
|
||
False,
|
||
"в compute quota нет поля gpu — смотри панель IAM -> проект -> квоты. "
|
||
"Если там 0, напишите в поддержку Selectel (docs/setup.md).",
|
||
)
|
||
)
|
||
elif gpu_limit <= 0:
|
||
checks.append(
|
||
Check(
|
||
"квота GPU",
|
||
False,
|
||
True,
|
||
"квота GPU = 0. Напиши в поддержку Selectel: 1× RTX 4090 24 GB "
|
||
f"в {cfg.os_region_name}/{cfg.gpu_rent_az}, проект {cfg.os_project_id}. "
|
||
"Текст тикета — docs/setup.md. CLI сервер не создаст, пока лимит 0.",
|
||
)
|
||
)
|
||
else:
|
||
checks.append(Check("квота GPU", True, True, f"limit={gpu_limit}"))
|
||
except CloudError as exc:
|
||
checks.append(Check("квота GPU", False, True, str(exc)))
|
||
|
||
flavors = list(iter_flavors(conn))
|
||
gpu_flavors = [f for f in flavors if looks_like_gpu(f)]
|
||
ranked = rank_flavors(gpu_flavors or flavors, cfg.flavor_preference)
|
||
if cfg.default_flavor_id:
|
||
hit = next((f for f in flavors if getattr(f, "id", None) == cfg.default_flavor_id), None)
|
||
if hit:
|
||
checks.append(Check("flavor", True, True, f"DEFAULT_FLAVOR_ID={cfg.default_flavor_id}"))
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"flavor",
|
||
False,
|
||
True,
|
||
f"DEFAULT_FLAVOR_ID={cfg.default_flavor_id} в регионе нет",
|
||
)
|
||
)
|
||
elif not ranked:
|
||
names = ", ".join(getattr(f, "name", "?") for f in gpu_flavors[:8]) or "нет GPU-flavors"
|
||
checks.append(
|
||
Check(
|
||
"flavor",
|
||
False,
|
||
True,
|
||
"ни один flavor из FLAVOR_PREFERENCE не найден в этом пуле. "
|
||
f"Видно: {names}. Смени GPU_RENT_AZ / OS_REGION_NAME по матрице GPU.",
|
||
)
|
||
)
|
||
else:
|
||
first = ranked[0]
|
||
rest = ", ".join(f"{x.label}:{x.name}" for x in ranked[1:3])
|
||
extra = f"; дальше {rest}" if rest else ""
|
||
checks.append(
|
||
Check(
|
||
"flavor",
|
||
True,
|
||
True,
|
||
f"первый доступный {first.label} -> {first.name} ({first.id}){extra}",
|
||
)
|
||
)
|
||
|
||
types = list(iter_volume_types(conn))
|
||
vtype = pick_volume_type(types, cfg.gpu_rent_az)
|
||
gigabytes = volume_quotas(conn).get("gigabytes")
|
||
if vtype:
|
||
disk_note = f"type={vtype}, data {cfg.data_volume_size_gb} GB"
|
||
if isinstance(gigabytes, int) and gigabytes >= 0:
|
||
disk_note += f", quota gigabytes={gigabytes}"
|
||
if gigabytes < cfg.data_volume_size_gb:
|
||
checks.append(
|
||
Check(
|
||
"диск",
|
||
False,
|
||
True,
|
||
f"{disk_note} — квота меньше {cfg.data_volume_size_gb} GB",
|
||
)
|
||
)
|
||
else:
|
||
checks.append(Check("диск", True, True, disk_note))
|
||
else:
|
||
checks.append(Check("диск", True, False, disk_note + " (квоту дисков API не отдал)"))
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"диск",
|
||
False,
|
||
True,
|
||
f"нет volume type для AZ {cfg.gpu_rent_az}. volume type list пуст?",
|
||
)
|
||
)
|
||
|
||
chosen = pick_boot_image(list(iter_images(conn)))
|
||
if chosen:
|
||
name = getattr(chosen, "name", str(chosen))
|
||
dockerish = "docker" in name.lower()
|
||
checks.append(
|
||
Check(
|
||
"образ GPU",
|
||
True,
|
||
False,
|
||
name
|
||
+ (
|
||
" (это Docker-образ: в пуле нет варианта без Docker)"
|
||
if dockerish
|
||
else ""
|
||
),
|
||
)
|
||
)
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"образ GPU",
|
||
False,
|
||
True,
|
||
"не нашёл GPU-образ в Glance (ожидаем Ubuntu 24.04 Driver 580 без Docker)",
|
||
)
|
||
)
|
||
|
||
servers = find_tagged_servers(conn)
|
||
if servers:
|
||
names = ", ".join(f"{s.name}:{s.status}" for s in servers)
|
||
checks.append(Check("живой gpu-rent", True, False, names))
|
||
else:
|
||
checks.append(Check("живой gpu-rent", True, False, "серверов с тегом нет (это норма)"))
|
||
|
||
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
|
||
if snap:
|
||
checks.append(Check("boot snapshot", True, False, cfg.boot_snapshot_name))
|
||
else:
|
||
checks.append(
|
||
Check("boot snapshot", True, False, f"{cfg.boot_snapshot_name} ещё нет — будет после первого bootstrap")
|
||
)
|
||
|
||
_civitai(cfg, checks)
|
||
_huggingface(cfg, checks)
|
||
_local_manifests(cfg, checks)
|
||
_local_folders(cfg, checks)
|
||
return checks
|
||
|
||
|
||
def _civitai(cfg: Config, checks: list[Check]) -> None:
|
||
if not cfg.civitai_api_token:
|
||
checks.append(
|
||
Check(
|
||
"Civitai",
|
||
True,
|
||
False,
|
||
"токена нет — на первом диске будет дефолт SwarmUI. Ключ: docs/setup.md §4",
|
||
)
|
||
)
|
||
return
|
||
probe = probe_me(cfg.civitai_api_token, cfg.civitai_api_host)
|
||
if probe.ok:
|
||
checks.append(Check("Civitai", True, True, f"{probe.host}: {probe.detail}"))
|
||
return
|
||
alt = probe_me(cfg.civitai_api_token, other_host(cfg.civitai_api_host))
|
||
if alt.ok:
|
||
checks.append(
|
||
Check(
|
||
"Civitai",
|
||
True,
|
||
False,
|
||
f"{probe.host} не ответил ({probe.detail}); {alt.host} принял токен. "
|
||
"Для NSFW оставь CIVITAI_API_HOST=civitai.red",
|
||
)
|
||
)
|
||
return
|
||
checks.append(
|
||
Check(
|
||
"Civitai",
|
||
True,
|
||
False,
|
||
f"{probe.host}: {probe.detail}; fallback {alt.host}: {alt.detail}. "
|
||
"seed-models недоступен, дефолт SwarmUI ок — up не блокируем",
|
||
)
|
||
)
|
||
|
||
|
||
def _huggingface(cfg: Config, checks: list[Check]) -> None:
|
||
from gpu_rent.huggingface import is_huggingface_url, probe_whoami
|
||
from gpu_rent.manifests import parse_models
|
||
|
||
needs_hf = False
|
||
try:
|
||
for e in parse_models(cfg.models_manifest):
|
||
if e.url and is_huggingface_url(e.url):
|
||
needs_hf = True
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
if not cfg.hf_token:
|
||
checks.append(
|
||
Check(
|
||
"Hugging Face",
|
||
True,
|
||
False,
|
||
(
|
||
"HF_TOKEN нет — gated HF URL в models.yaml дадут 401. "
|
||
"https://huggingface.co/settings/tokens"
|
||
if needs_hf
|
||
else "токена нет (опционально для HF URL / capture fallback)"
|
||
),
|
||
)
|
||
)
|
||
return
|
||
probe = probe_whoami(cfg.hf_token)
|
||
if probe.ok:
|
||
who = f" ({probe.name})" if probe.name else ""
|
||
checks.append(Check("Hugging Face", True, True, f"токен ок{who}"))
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"Hugging Face",
|
||
True,
|
||
False,
|
||
f"токен не принят: {probe.detail}",
|
||
)
|
||
)
|
||
|
||
|
||
def _local_manifests(cfg: Config, checks: list[Check]) -> None:
|
||
try:
|
||
models = parse_models(cfg.models_manifest)
|
||
if cfg.models_manifest.is_file():
|
||
checks.append(
|
||
Check(
|
||
"models.yaml",
|
||
True,
|
||
True,
|
||
f"{cfg.models_manifest} — записей (без заглушек 0): {len(models)}",
|
||
)
|
||
)
|
||
else:
|
||
checks.append(
|
||
Check(
|
||
"models.yaml",
|
||
True,
|
||
False,
|
||
f"нет {cfg.models_manifest} — seed Civitai пропустится",
|
||
)
|
||
)
|
||
except ConfigError as exc:
|
||
checks.append(Check("models.yaml", False, True, str(exc)))
|
||
|
||
try:
|
||
repos = parse_extensions(cfg.extensions_manifest)
|
||
if not cfg.extensions_manifest.is_file():
|
||
checks.append(Check("extensions.yaml", True, False, "файла нет — стоковый SwarmUI"))
|
||
elif repos:
|
||
checks.append(Check("extensions.yaml", True, True, f"{len(repos)} git-реп"))
|
||
else:
|
||
example = cfg.extensions_manifest.with_name("extensions.example.yaml")
|
||
n_ex = 0
|
||
if example.is_file():
|
||
try:
|
||
n_ex = len(parse_extensions(example))
|
||
except ConfigError:
|
||
n_ex = 0
|
||
if n_ex:
|
||
checks.append(
|
||
Check(
|
||
"extensions.yaml",
|
||
True,
|
||
False,
|
||
f"пустой; в example {n_ex} реп — скопируй и gpu-rent seed-extensions",
|
||
)
|
||
)
|
||
else:
|
||
checks.append(
|
||
Check("extensions.yaml", True, False, "0 реп — стоковый SwarmUI")
|
||
)
|
||
except ConfigError as exc:
|
||
checks.append(Check("extensions.yaml", False, True, str(exc)))
|
||
|
||
|
||
def _local_folders(cfg: Config, checks: list[Check]) -> None:
|
||
size = _folder_bytes(cfg.local_models_dir)
|
||
gi = size / (1024**3)
|
||
payload = _has_payload(cfg.local_models_dir)
|
||
if payload and gi > cfg.data_volume_size_gb * 0.8:
|
||
checks.append(
|
||
Check(
|
||
"Models/",
|
||
False,
|
||
True,
|
||
f"локально ~{gi:.1f} GB, диск {cfg.data_volume_size_gb} GB — не влезет. "
|
||
"Урежь папку или gpu-rent resize-data после первого диска.",
|
||
)
|
||
)
|
||
elif payload:
|
||
checks.append(Check("Models/", True, False, f"есть файлы, ~{gi:.2f} GB — уедут на up"))
|
||
else:
|
||
checks.append(Check("Models/", True, False, "пусто — на up ничего не грузим"))
|
||
|
||
for label, folder in (
|
||
("Wildcards/", cfg.local_wildcards_dir),
|
||
("CustomWorkflows/", cfg.local_workflows_dir),
|
||
):
|
||
if _has_payload(folder):
|
||
checks.append(Check(label, True, False, "не пусто — push на up"))
|
||
else:
|
||
checks.append(Check(label, True, False, "пусто — skip"))
|
||
|
||
|
||
def blocking_failed(checks: list[Check]) -> list[Check]:
|
||
return [c for c in checks if c.blocking and not c.ok]
|
||
|
||
|
||
def dry_run_plan(checks: list[Check]) -> list[str]:
|
||
cfg = load_config(require_auth=False)
|
||
state = load_state()
|
||
lines = [
|
||
f"фаза state: {state.phase}",
|
||
f"пул {cfg.os_region_name} / AZ {cfg.gpu_rent_az}",
|
||
f"data volume: {cfg.data_volume_size_gb} GB (рост только вверх)",
|
||
f"preemptible: {cfg.default_spot} (обычный сервер: gpu-rent up --no-spot)",
|
||
f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин",
|
||
"₽: в API нет — смотри панель; диск 24/7 даже после stop",
|
||
f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801",
|
||
"gpu-rent up --yes создаст GPU + SwarmUI и откроет туннель :17801 "
|
||
"(Ctrl+C не гасит GPU, Ctrl+D гасит)",
|
||
"только облако без туннеля: gpu-rent up --yes --no-tunnel",
|
||
]
|
||
flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None)
|
||
if flavor:
|
||
lines.insert(2, f"flavor: {flavor}")
|
||
return lines
|