first commit

This commit is contained in:
Leonid Pershin
2026-08-21 02:42:48 +03:00
commit 167d07a733
46 changed files with 3334 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""gpu-rent: Selectel GPU session CLI."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from gpu_rent.cli import app
if __name__ == "__main__":
app()
+58
View File
@@ -0,0 +1,58 @@
"""Civitai site API. Bearer only on civitai.com / .red / .green."""
from __future__ import annotations
from dataclasses import dataclass
import httpx
ALLOWED_HOSTS = ("civitai.com", "civitai.red", "civitai.green")
@dataclass
class CivitaiProbe:
host: str
ok: bool
status: int | None
detail: str
def _normalize_host(host: str) -> str:
h = host.strip().lower().removeprefix("https://").removeprefix("http://").split("/")[0]
if h.startswith("www."):
h = h[4:]
return h
def other_host(host: str) -> str:
h = _normalize_host(host)
if h.endswith(".red") or h == "civitai.red":
return "civitai.com"
return "civitai.red"
def probe_me(token: str, host: str, timeout: float = 15.0) -> CivitaiProbe:
host = _normalize_host(host)
if host not in ALLOWED_HOSTS:
return CivitaiProbe(host=host, ok=False, status=None, detail="хост не из allow-list")
url = f"https://{host}/api/v1/me"
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
response = client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
return CivitaiProbe(host=host, ok=False, status=None, detail=str(exc))
if response.status_code == 200:
return CivitaiProbe(host=host, ok=True, status=200, detail="токен принят")
if response.status_code in {401, 403}:
return CivitaiProbe(
host=host,
ok=False,
status=response.status_code,
detail="токен отвергнут — перевыпусти ключ на civitai.com/user/account",
)
return CivitaiProbe(
host=host,
ok=False,
status=response.status_code,
detail=response.text[:200] or response.reason_phrase,
)
+334
View File
@@ -0,0 +1,334 @@
"""Typer entry: gpu-rent."""
from __future__ import annotations
import socket
import sys
import traceback
import webbrowser
from datetime import datetime, timezone
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from gpu_rent import __version__
from gpu_rent.config import load_config
from gpu_rent.doctor import blocking_failed, dry_run_plan, run_doctor
from gpu_rent.errors import GpuRentError, NotReadyError
from gpu_rent.os_client import connect, find_snapshot_by_name, find_tagged_servers
from gpu_rent.state import load_state, preempt_window_end
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, OSError):
pass
app = typer.Typer(
no_args_is_help=True,
pretty_exceptions_enable=False,
add_completion=False,
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent doctor. Ключи: docs/setup.md",
)
console = Console(highlight=False, legacy_windows=False)
_DEBUG = False
@app.callback()
def _root(
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
) -> None:
global _DEBUG
_DEBUG = debug
def _die(exc: BaseException) -> None:
if _DEBUG:
traceback.print_exc()
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1)
def _nyi(name: str) -> None:
raise NotReadyError(
f"`{name}` ещё не создаёт/не гасит GPU. Сейчас работают: doctor, dry-run, status, open.\n"
"1) Заполни ~/.gpu-rent/.env по docs/setup.md\n"
"2) gpu-rent doctor\n"
"3) Когда doctor зелёный и квота GPU > 0 — можно писать up."
)
def _print_checks(checks) -> int:
table = Table(title="gpu-rent doctor", show_lines=False)
table.add_column("ok")
table.add_column("проверка")
table.add_column("блок?")
table.add_column("деталь")
for check in checks:
mark = "[green]yes[/green]" if check.ok else "[red]NO[/red]"
block = "да" if check.blocking else "нет"
table.add_row(mark, check.name, block, check.detail)
console.print(table)
failed = blocking_failed(checks)
if failed:
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
return 1
console.print("\n[green]Можно идти дальше.[/green] mutating up пока не подключён.")
return 0
@app.command()
def version() -> None:
"""Версия пакета."""
console.print(__version__)
@app.command()
def doctor() -> None:
"""Preflight без create: Keystone, квота, flavor, диск, Civitai, манифесты."""
try:
checks = run_doctor()
except GpuRentError as exc:
_die(exc)
code = _print_checks(checks)
raise typer.Exit(code)
@app.command("dry-run")
def dry_run() -> None:
"""План без mutating-вызовов."""
try:
checks = run_doctor()
_print_checks(checks)
console.print("\n[bold]План[/bold]")
for line in dry_run_plan(checks):
console.print(f"{line}")
if blocking_failed(checks):
raise typer.Exit(1)
except GpuRentError as exc:
_die(exc)
@app.command()
def status() -> None:
"""Локальный state + OpenStack, если .env есть. Туннель не нужен."""
state = load_state()
table = Table(title="status")
table.add_column("поле")
table.add_column("значение")
table.add_row("фаза", state.phase)
table.add_row("server", state.server_id or "")
table.add_row("flavor", state.flavor_name or state.flavor_id or "")
table.add_row("boot volume", state.boot_volume_id or "")
table.add_row("data volume", state.data_volume_id or "")
table.add_row("FIP", state.floating_ip or "")
end = preempt_window_end(state)
if end:
left = end - datetime.now(timezone.utc)
hours = max(int(left.total_seconds() // 3600), 0)
mins = max(int((left.total_seconds() % 3600) // 60), 0)
table.add_row("preempt 24ч", f"до {end.isoformat()} (осталось {hours}h {mins}m)")
else:
table.add_row("preempt 24ч", "нет create/unshelve timestamp")
cfg = load_config(require_auth=False)
listening = _port_open(cfg.swarmui_local_port)
table.add_row("туннель", f"localhost:{cfg.swarmui_local_port} {'слушает' if listening else 'нет'}")
table.add_row("₽/час", "нет цены в API — смотри панель / spike")
table.add_row("диск used/free", "нужен SSH на живую VM")
table.add_row("idle-killer", "на VM; локально не видно без SSH")
if cfg.auth_ok:
try:
conn = connect(cfg)
servers = find_tagged_servers(conn)
if servers:
table.add_row(
"Nova",
", ".join(f"{s.name} {s.status}" for s in servers),
)
else:
table.add_row("Nova", "нет сервера gpu-rent")
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
table.add_row("snapshot", cfg.boot_snapshot_name if snap else "нет")
except GpuRentError as exc:
table.add_row("Nova", f"не достучались: {exc}")
else:
table.add_row("Nova", "нет .env — только локальный state")
console.print(table)
@app.command()
def open() -> None:
"""Открыть браузер на http://127.0.0.1:17801. Туннель уже должен слушать порт."""
cfg = load_config(require_auth=False)
port = cfg.swarmui_local_port
if not _port_open(port):
console.print(
f"[red]localhost:{port} молчит.[/red] Сначала `gpu-rent tunnel`, потом open."
)
raise typer.Exit(1)
url = f"http://127.0.0.1:{port}"
webbrowser.open(url)
console.print(url)
@app.command()
def up(
no_spot: bool = typer.Option(False, "--no-spot", help="Обычный сервер, не preemptible"),
flavor: Optional[str] = typer.Option(None, "--flavor", help="Flavor id, без фоллбека"),
yes: bool = typer.Option(False, "--yes", help="Без вопросов"),
adopt: bool = typer.Option(False, "--adopt", help="Подхватить тег gpu-rent без state"),
) -> None:
"""Create/unshelve GPU. Пока: doctor, затем стоп — mutating ещё не подключён."""
del no_spot, flavor, yes, adopt
try:
checks = run_doctor()
code = _print_checks(checks)
if code != 0:
raise typer.Exit(1)
_nyi("up")
except GpuRentError as exc:
_die(exc)
@app.command()
def tunnel(
open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"),
) -> None:
"""SSH localhost:17801 -> VM :7801. Ctrl+C закрывает туннель, GPU оставляет."""
del open_browser
try:
_nyi("tunnel")
except GpuRentError as exc:
_die(exc)
@app.command()
def hold(
minutes: Optional[int] = typer.Option(None, "--minutes"),
until: Optional[str] = typer.Option(None, "--until"),
clear: bool = typer.Option(False, "--clear"),
) -> None:
"""Отложить idle-killer на VM."""
del minutes, until, clear
try:
_nyi("hold")
except GpuRentError as exc:
_die(exc)
@app.command()
def stop(
no_pull: bool = typer.Option(False, "--no-pull"),
) -> None:
"""Удалить compute и FIP, диски оставить."""
del no_pull
try:
_nyi("stop")
except GpuRentError as exc:
_die(exc)
@app.command()
def destroy(
i_understand_data_loss: bool = typer.Option(False, "--i-understand-data-loss"),
) -> None:
"""stop + диски."""
if not i_understand_data_loss:
console.print("Нужен флаг --i-understand-data-loss")
raise typer.Exit(1)
try:
_nyi("destroy")
except GpuRentError as exc:
_die(exc)
@app.command()
def logs() -> None:
try:
_nyi("logs")
except GpuRentError as exc:
_die(exc)
@app.command()
def ssh() -> None:
try:
_nyi("ssh")
except GpuRentError as exc:
_die(exc)
@app.command("seed-models")
def seed_models() -> None:
try:
_nyi("seed-models")
except GpuRentError as exc:
_die(exc)
@app.command("push")
def push_all() -> None:
try:
_nyi("push")
except GpuRentError as exc:
_die(exc)
@app.command("push-models")
def push_models() -> None:
try:
_nyi("push-models")
except GpuRentError as exc:
_die(exc)
@app.command("pull-output")
def pull_output() -> None:
try:
_nyi("pull-output")
except GpuRentError as exc:
_die(exc)
@app.command("seed-extensions")
def seed_extensions() -> None:
try:
_nyi("seed-extensions")
except GpuRentError as exc:
_die(exc)
@app.command("resize-data")
def resize_data(gb: int = typer.Option(..., "--gb")) -> None:
del gb
try:
_nyi("resize-data")
except GpuRentError as exc:
_die(exc)
def _port_open(port: int) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.4)
try:
return sock.connect_ex(("127.0.0.1", port)) == 0
finally:
sock.close()
def main() -> None:
try:
app()
except GpuRentError as exc:
_die(exc)
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
"""Load ~/.gpu-rent/.env. No secrets in git."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
from gpu_rent.errors import ConfigError
from gpu_rent.paths import (
default_ssh_key_path,
detect_app_root,
env_path,
home_dir,
)
def _as_bool(value: str | None, default: bool) -> bool:
if value is None or value.strip() == "":
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _as_int(value: str | None, default: int) -> int:
if value is None or value.strip() == "":
return default
return int(value)
def _csv(value: str | None, default: tuple[str, ...]) -> tuple[str, ...]:
if value is None or value.strip() == "":
return default
return tuple(part.strip() for part in value.split(",") if part.strip())
@dataclass
class Config:
os_auth_url: str
os_user_domain_name: str
os_username: str
os_password: str
os_project_id: str
os_region_name: str
gpu_rent_az: str
ssh_private_key_path: Path
ssh_user: str
boot_volume_id: str
data_volume_id: str
data_volume_size_gb: int
boot_snapshot_name: str
civitai_api_token: str
civitai_api_host: str
models_manifest: Path
extensions_manifest: Path
git_token: str
local_models_dir: Path
local_wildcards_dir: Path
local_workflows_dir: Path
local_output_dir: Path
app_root: Path
autocomplete_enabled: bool
autocomplete_github_repo: str
autocomplete_github_path: str
autocomplete_github_ref: str
autocomplete_filename: str
swarmui_local_port: int
swarmui_image: str
default_flavor_id: str
flavor_preference: tuple[str, ...]
flavor_fallback: bool
default_spot: bool
keep_floating_ip: bool
idle_minutes: int
idle_grace_minutes: int
pull_output: bool
notify_ready: bool
missing: list[str] = field(default_factory=list)
@property
def auth_ok(self) -> bool:
return not self.missing
def _required(name: str) -> str:
return (os.environ.get(name) or "").strip()
def load_config(*, require_auth: bool = True) -> Config:
home_dir().mkdir(parents=True, exist_ok=True)
env_file = env_path()
if env_file.is_file():
load_dotenv(env_file, override=False)
app_root = detect_app_root()
missing: list[str] = []
required = (
"OS_AUTH_URL",
"OS_USER_DOMAIN_NAME",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"GPU_RENT_AZ",
)
values = {name: _required(name) for name in required}
for name, value in values.items():
if not value:
missing.append(name)
if require_auth and missing:
raise ConfigError(
"В ~/.gpu-rent/.env не хватает: "
+ ", ".join(missing)
+ ". Как заполнить: docs/setup.md (сервисный пользователь, не X-Token)."
)
key_override = (os.environ.get("SSH_PRIVATE_KEY_PATH") or "").strip()
ssh_key = Path(key_override).expanduser() if key_override else default_ssh_key_path()
models_manifest = Path(
(os.environ.get("MODELS_MANIFEST") or "").strip() or (home_dir() / "models.yaml")
).expanduser()
extensions_manifest = Path(
(os.environ.get("EXTENSIONS_MANIFEST") or "").strip()
or (home_dir() / "extensions.yaml")
).expanduser()
def _dir(env_name: str, folder: str) -> Path:
raw = (os.environ.get(env_name) or "").strip()
return Path(raw).expanduser() if raw else (app_root / folder)
return Config(
os_auth_url=values["OS_AUTH_URL"] or "https://cloud.api.selcloud.ru/identity/v3",
os_user_domain_name=values["OS_USER_DOMAIN_NAME"],
os_username=values["OS_USERNAME"],
os_password=values["OS_PASSWORD"],
os_project_id=values["OS_PROJECT_ID"],
os_region_name=values["OS_REGION_NAME"],
gpu_rent_az=values["GPU_RENT_AZ"],
ssh_private_key_path=ssh_key,
ssh_user=(os.environ.get("SSH_USER") or "ubuntu").strip(),
boot_volume_id=(os.environ.get("BOOT_VOLUME_ID") or "").strip(),
data_volume_id=(os.environ.get("DATA_VOLUME_ID") or "").strip(),
data_volume_size_gb=_as_int(os.environ.get("DATA_VOLUME_SIZE_GB"), 100),
boot_snapshot_name=(os.environ.get("BOOT_SNAPSHOT_NAME") or "gpu-rent-boot-ok").strip(),
civitai_api_token=(os.environ.get("CIVITAI_API_TOKEN") or "").strip(),
civitai_api_host=(os.environ.get("CIVITAI_API_HOST") or "civitai.red").strip().lower(),
models_manifest=models_manifest,
extensions_manifest=extensions_manifest,
git_token=(os.environ.get("GIT_TOKEN") or "").strip(),
local_models_dir=_dir("LOCAL_MODELS_DIR", "Models"),
local_wildcards_dir=_dir("LOCAL_WILDCARDS_DIR", "Wildcards"),
local_workflows_dir=_dir("LOCAL_WORKFLOWS_DIR", "CustomWorkflows"),
local_output_dir=_dir("LOCAL_OUTPUT_DIR", "Output"),
app_root=app_root,
autocomplete_enabled=_as_bool(os.environ.get("AUTOCOMPLETE_ENABLED"), True),
autocomplete_github_repo=(
os.environ.get("AUTOCOMPLETE_GITHUB_REPO") or "DominikDoom/a1111-sd-webui-tagcomplete"
).strip(),
autocomplete_github_path=(
os.environ.get("AUTOCOMPLETE_GITHUB_PATH") or "tags/danbooru.csv"
).strip(),
autocomplete_github_ref=(os.environ.get("AUTOCOMPLETE_GITHUB_REF") or "main").strip(),
autocomplete_filename=(os.environ.get("AUTOCOMPLETE_FILENAME") or "danbooru.csv").strip(),
swarmui_local_port=_as_int(os.environ.get("SWARMUI_LOCAL_PORT"), 17801),
swarmui_image=(os.environ.get("SWARMUI_IMAGE") or "").strip(),
default_flavor_id=(os.environ.get("DEFAULT_FLAVOR_ID") or "").strip(),
flavor_preference=_csv(
os.environ.get("FLAVOR_PREFERENCE"),
("4090-24", "4090-48", "a5000", "a100-40"),
),
flavor_fallback=_as_bool(os.environ.get("FLAVOR_FALLBACK"), True),
default_spot=_as_bool(os.environ.get("DEFAULT_SPOT"), True),
keep_floating_ip=_as_bool(os.environ.get("KEEP_FLOATING_IP"), False),
idle_minutes=_as_int(os.environ.get("IDLE_MINUTES"), 30),
idle_grace_minutes=_as_int(os.environ.get("IDLE_GRACE_MINUTES"), 45),
pull_output=_as_bool(os.environ.get("PULL_OUTPUT"), False),
notify_ready=_as_bool(os.environ.get("NOTIFY_READY"), True),
missing=missing,
)
+391
View File
@@ -0,0 +1,391 @@
"""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.paths import env_path, home_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
SKIP = {".gitkeep", "README.md", ".gitignore"}
def _folder_bytes(root: Path) -> int:
if not root.is_dir():
return 0
total = 0
for path in root.rglob("*"):
if path.is_file() and path.name not in SKIP:
total += path.stat().st_size
return total
def _has_payload(root: Path) -> bool:
if not root.is_dir():
return False
for path in root.rglob("*"):
if path.is_file() and path.name not in SKIP:
return True
return False
def run_doctor() -> list[Check]:
checks: list[Check] = []
home_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)
_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",
False,
True,
f"{probe.host}: {probe.detail}; fallback {alt.host}: {alt.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 cfg.extensions_manifest.is_file():
checks.append(
Check(
"extensions.yaml",
True,
True,
f"{len(repos)} git-реп",
)
)
else:
checks.append(Check("extensions.yaml", True, False, "файла нет — стоковый 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} мин",
f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801",
"сейчас mutating up/stop ещё не подключены — только doctor / dry-run / status / open",
]
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
+19
View File
@@ -0,0 +1,19 @@
"""User-facing errors without tracebacks."""
from __future__ import annotations
class GpuRentError(Exception):
"""Printed as a short message; process exits 1."""
class ConfigError(GpuRentError):
pass
class CloudError(GpuRentError):
pass
class NotReadyError(GpuRentError):
"""Command exists in the spec but is not implemented yet."""
+169
View File
@@ -0,0 +1,169 @@
"""Flavor preference matching and volume-type pick for the AZ."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class FlavorInfo:
id: str
name: str
vcpus: int | None
ram_mb: int | None
disabled: bool
extra: dict[str, Any]
label: str | None = None
def _name(obj: Any) -> str:
return (getattr(obj, "name", None) or "").strip()
def _id(obj: Any) -> str:
return str(getattr(obj, "id", "") or "")
def is_disabled(flavor: Any) -> bool:
if getattr(flavor, "is_disabled", False):
return True
extra = extra_specs(flavor)
flag = extra.get("OS-FLV-DISABLED:disabled") or extra.get("disabled")
if flag in (True, "True", "true", "1"):
return True
return False
def extra_specs(flavor: Any) -> dict[str, Any]:
extra = getattr(flavor, "extra_specs", None)
if isinstance(extra, dict):
return extra
blob = getattr(flavor, "get", None)
if callable(blob):
got = flavor.get("extra_specs")
if isinstance(got, dict):
return got
return {}
def looks_like_gpu(flavor: Any) -> bool:
name = _name(flavor).lower()
extra = extra_specs(flavor)
blob = " ".join(f"{k}={v}" for k, v in extra.items()).lower()
hay = f"{name} {blob}"
needles = ("gpu", "4090", "a5000", "a100", "a6000", "l40", "h100")
return any(n in hay for n in needles)
def match_label(label: str, flavor: Any) -> bool:
"""Match FLAVOR_PREFERENCE tokens to a live flavor name/extra specs."""
name = _name(flavor).lower()
extra = extra_specs(flavor)
hay = name + " " + " ".join(str(v).lower() for v in extra.values())
token = label.strip().lower()
if token in {"4090-24", "4090_24", "rtx4090-24"}:
return "4090" in hay and "48" not in hay
if token in {"4090-48", "4090_48", "rtx4090-48"}:
return "4090" in hay and "48" in hay
if token in {"a5000", "rtx-a5000"}:
return "a5000" in hay or "rtx a5000" in hay
if token in {"a100-40", "a100_40"}:
return "a100" in hay and "80" not in hay
if token in {"a100-80", "a100_80"}:
return "a100" in hay and "80" in hay
return token.replace("_", "-") in hay or token.replace("-", " ") in hay
def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo:
ram = getattr(flavor, "ram", None)
vcpus = getattr(flavor, "vcpus", None)
return FlavorInfo(
id=_id(flavor),
name=_name(flavor) or _id(flavor),
vcpus=int(vcpus) if vcpus is not None else None,
ram_mb=int(ram) if ram is not None else None,
disabled=is_disabled(flavor),
extra=extra_specs(flavor),
label=label,
)
def rank_flavors(flavors: list[Any], preference: tuple[str, ...]) -> list[FlavorInfo]:
ranked: list[FlavorInfo] = []
seen: set[str] = set()
for label in preference:
for flavor in flavors:
fid = _id(flavor)
if fid in seen or is_disabled(flavor):
continue
if match_label(label, flavor):
ranked.append(flavor_info(flavor, label))
seen.add(fid)
break
return ranked
def pick_volume_type(types: list[Any], az: str) -> str | None:
az_l = az.lower()
names = [_name(t) for t in types if _name(t)]
for name in names:
if az_l in name.lower() and "fast" in name.lower():
return name
for name in names:
if az_l in name.lower():
return name
return names[0] if names else None
def gpu_quota_from_compute(quota: dict[str, Any]) -> int | None:
"""Return GPU limit if the quota dict exposes it; else None."""
keys = []
for key in quota:
if "gpu" in str(key).lower():
keys.append(key)
if not keys:
return None
values = []
for key in keys:
raw = quota[key]
if isinstance(raw, dict):
raw = raw.get("limit", raw.get("in_use"))
try:
values.append(int(raw))
except (TypeError, ValueError):
continue
if not values:
return None
return max(values)
def gpu_boot_image_score(name: str) -> int:
"""Higher is better. Canonical: Ubuntu 24.04 + driver 580, no Docker."""
n = name.lower()
if "gpu" not in n:
return 0
if "data science" in n or "analytics" in n:
return 1
score = 10
if "docker" in n:
score -= 30
if "24.04" in n:
score += 20
elif "22.04" in n:
score += 5
if "580" in n:
score += 15
elif "535" in n:
score += 4
return score
def pick_boot_image(images: list[Any]) -> Any | None:
ranked = [(gpu_boot_image_score(_name(img)), img) for img in images]
ranked = [item for item in ranked if item[0] > 0]
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1]
+102
View File
@@ -0,0 +1,102 @@
"""Parse models.yaml / extensions.yaml. version_id 0 is a placeholder."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from gpu_rent.errors import ConfigError
MODEL_TYPES = (
"checkpoint",
"lora",
"vae",
"embedding",
"controlnet",
"upscaler",
)
@dataclass
class ModelEntry:
kind: str
version_id: int | None
url: str | None
@dataclass
class GitRepo:
kind: str
url: str
ref: str
directory: str | None
def _load_yaml(path: Path) -> Any:
if not path.is_file():
return None
text = path.read_text(encoding="utf-8")
if not text.strip():
return {}
try:
return yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ConfigError(f"Не разобрать YAML {path}: {exc}") from exc
def parse_models(path: Path) -> list[ModelEntry]:
data = _load_yaml(path)
if data is None:
return []
if data == {} or data is None:
return []
if not isinstance(data, dict):
raise ConfigError(f"{path}: корень должен быть mapping типов моделей")
entries: list[ModelEntry] = []
for kind, items in data.items():
if kind not in MODEL_TYPES:
continue
if not items:
continue
if not isinstance(items, list):
raise ConfigError(f"{path}: {kind} должен быть списком")
for item in items:
if not isinstance(item, dict):
raise ConfigError(f"{path}: элемент {kind} — объект с version_id или url")
vid = item.get("version_id")
url = item.get("url")
if vid in (0, "0", None) and not url:
continue
version_id = int(vid) if vid not in (None, "", 0, "0") else None
entries.append(ModelEntry(kind=kind, version_id=version_id, url=str(url) if url else None))
return entries
def parse_extensions(path: Path) -> list[GitRepo]:
data = _load_yaml(path)
if not data:
return []
if not isinstance(data, dict):
raise ConfigError(f"{path}: корень swarmui: / comfy:")
repos: list[GitRepo] = []
for kind in ("swarmui", "comfy"):
items = data.get(kind) or []
if not items:
continue
if not isinstance(items, list):
raise ConfigError(f"{path}: {kind} должен быть списком")
for item in items:
if not isinstance(item, dict) or not item.get("url"):
raise ConfigError(f"{path}: у {kind} нужен url")
repos.append(
GitRepo(
kind=kind,
url=str(item["url"]),
ref=str(item.get("ref") or "main"),
directory=str(item["dir"]) if item.get("dir") else None,
)
)
return repos
+118
View File
@@ -0,0 +1,118 @@
"""openstacksdk connection. IAM token is 24h; sdk refreshes on authorize()."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
RESOURCE_TAG = "gpu-rent"
COMPUTE_MICROVERSION = "2.72"
def connect(cfg: Config):
try:
import openstack
except ImportError as exc:
raise CloudError("Нет openstacksdk. Переустанови пакет: pip install -e .") from exc
try:
conn = openstack.connect(
auth_url=cfg.os_auth_url,
project_id=cfg.os_project_id,
username=cfg.os_username,
password=cfg.os_password,
user_domain_name=cfg.os_user_domain_name,
project_domain_name=cfg.os_user_domain_name,
region_name=cfg.os_region_name,
identity_api_version="3",
interface="public",
compute_api_version=COMPUTE_MICROVERSION,
app_name="gpu-rent",
app_version="0.1.0",
)
conn.authorize()
except Exception as exc:
raise CloudError(
"Keystone не выдал токен. Проверь OS_USERNAME / OS_PASSWORD / "
"OS_PROJECT_ID / OS_USER_DOMAIN_NAME (номер аккаунта). "
"Не используй X-Token панели. Подробности: docs/setup.md. "
f"Ошибка SDK: {exc}"
) from exc
return conn
def _obj_dict(obj: Any) -> dict[str, Any]:
if obj is None:
return {}
if hasattr(obj, "to_dict"):
try:
return obj.to_dict(computed=False)
except TypeError:
return obj.to_dict()
if isinstance(obj, dict):
return obj
return {"repr": repr(obj)}
def compute_quotas(conn) -> dict[str, Any]:
project = conn.current_project_id
try:
quota = conn.compute.get_quota_set(project)
return _obj_dict(quota)
except Exception:
try:
return dict(conn.get_compute_quotas(project) or {})
except Exception as exc:
raise CloudError(f"Не прочитать compute quota: {exc}") from exc
def volume_quotas(conn) -> dict[str, Any]:
project = conn.current_project_id
try:
quota = conn.block_storage.get_quota_set(project)
return _obj_dict(quota)
except Exception:
try:
return dict(conn.get_volume_quotas(project) or {})
except Exception as exc:
return {"error": str(exc)}
def iter_flavors(conn) -> Iterator[Any]:
yield from conn.compute.flavors(details=True)
def iter_volume_types(conn) -> Iterator[Any]:
yield from conn.block_storage.types()
def iter_images(conn) -> Iterator[Any]:
yield from conn.image.images()
def find_tagged_servers(conn) -> list[Any]:
servers = []
for server in conn.compute.servers(details=True):
tags = set(getattr(server, "tags", None) or [])
name = (getattr(server, "name", "") or "").lower()
if RESOURCE_TAG in tags or name.startswith("gpu-rent"):
servers.append(server)
return servers
def find_volumes_by_name(conn, name: str) -> list[Any]:
found = []
for volume in conn.block_storage.volumes():
if getattr(volume, "name", None) == name:
found.append(volume)
return found
def find_snapshot_by_name(conn, name: str) -> Any | None:
for snap in conn.block_storage.snapshots():
if getattr(snap, "name", None) == name:
return snap
return None
+33
View File
@@ -0,0 +1,33 @@
"""Paths: ~/.gpu-rent, app tree, SSH key."""
from __future__ import annotations
from pathlib import Path
def home_dir() -> Path:
return Path.home() / ".gpu-rent"
def env_path() -> Path:
return home_dir() / ".env"
def state_path() -> Path:
return home_dir() / "state.json"
def lock_path() -> Path:
return home_dir() / "gpu-rent.lock"
def default_ssh_key_path() -> Path:
return home_dir() / "id_ed25519"
def detect_app_root() -> Path:
cwd = Path.cwd().resolve()
for candidate in (cwd, *cwd.parents):
if (candidate / "Models").is_dir() and (candidate / "docs").is_dir():
return candidate
return cwd
+46
View File
@@ -0,0 +1,46 @@
"""Ed25519 key at ~/.gpu-rent/id_ed25519 (no passphrase)."""
from __future__ import annotations
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from gpu_rent.paths import default_ssh_key_path
def public_path(private: Path) -> Path:
return private.with_suffix(private.suffix + ".pub") if private.suffix else Path(str(private) + ".pub")
def ensure_ed25519(path: Path | None = None) -> tuple[Path, Path]:
private = path or default_ssh_key_path()
pub = public_path(private)
if private.is_file() and pub.is_file():
return private, pub
private.parent.mkdir(parents=True, exist_ok=True)
key = Ed25519PrivateKey.generate()
private_bytes = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption(),
)
public_bytes = key.public_key().public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
) + b" gpu-rent\n"
private.write_bytes(private_bytes)
try:
private.chmod(0o600)
except OSError:
pass
pub.write_bytes(public_bytes)
return private, pub
def key_ready(path: Path | None = None) -> bool:
private = path or default_ssh_key_path()
return private.is_file() and public_path(private).is_file()
+73
View File
@@ -0,0 +1,73 @@
"""Local session state. No passwords."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from gpu_rent.paths import home_dir, state_path
CURRENT_VERSION = 1
@dataclass
class SessionState:
version: int = CURRENT_VERSION
phase: str = "idle"
server_id: str | None = None
server_name: str | None = None
flavor_id: str | None = None
flavor_name: str | None = None
boot_volume_id: str | None = None
data_volume_id: str | None = None
floating_ip: str | None = None
keypair_name: str | None = None
created_at: str | None = None
unshelved_at: str | None = None
notes: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> SessionState:
known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
return cls(**known)
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def load_state() -> SessionState:
path = state_path()
if not path.is_file():
return SessionState()
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return SessionState()
return SessionState.from_dict(raw)
def save_state(state: SessionState) -> None:
home_dir().mkdir(parents=True, exist_ok=True)
path = state_path()
path.write_text(json.dumps(state.to_dict(), indent=2) + "\n", encoding="utf-8")
def preempt_window_end(state: SessionState) -> datetime | None:
stamp = state.unshelved_at or state.created_at
if not stamp:
return None
try:
start = datetime.fromisoformat(stamp)
except ValueError:
return None
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
from datetime import timedelta
return start + timedelta(hours=24)