first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""gpu-rent: Selectel GPU session CLI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from gpu_rent.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user