From 28019d1d0905f8dcdbb6dc478ae42c5916f096a5 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 04:03:28 +0300 Subject: [PATCH] Enhance GPU flavor scanning and update documentation - Introduced SCAN_POOLS configuration to specify pools for GPU flavor scanning. - Updated the `flavors` command to scan specified pools and list available GPU flavors based on `FLAVOR_PREFERENCE`. - Revised CLI documentation to reflect changes in the `flavors` command behavior. - Enhanced decision documentation to include details about GPU pool scanning. - Updated setup instructions to guide users on selecting appropriate GPU pools. --- docs/cli.md | 2 +- docs/decisions.md | 1 + docs/roadmap.md | 10 +- docs/setup.md | 2 +- env.example | 2 + src/gpu_rent/cli.py | 18 ++- src/gpu_rent/config.py | 2 + src/gpu_rent/inventory.py | 13 ++- src/gpu_rent/pools.py | 223 ++++++++++++++++++++++++++++++++++++++ src/gpu_rent/session.py | 16 +++ tests/test_pools.py | 22 ++++ 11 files changed, 299 insertions(+), 12 deletions(-) create mode 100644 src/gpu_rent/pools.py create mode 100644 tests/test_pools.py diff --git a/docs/cli.md b/docs/cli.md index 6a6c09b..01575a0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ | Команда | Поведение | | --- | --- | -| `gpu-rent flavors` | Живой список GPU flavors по `FLAVOR_PREFERENCE` + что выберет фоллбек | +| `gpu-rent flavors` | Скан `SCAN_POOLS` (ru-6 multizone…) × `FLAVOR_PREFERENCE`, затем список в текущем `OS_REGION_NAME` | | `gpu-rent doctor` | Preflight **без** create: Keystone, квота GPU, flavor в AZ, диски, Civitai token+`.red`, манифесты, SSH-ключ. Код выхода ≠ 0, если сессию нельзя начать | | `gpu-rent up` | Preflight → create/unshelve (с фоллбеком flavor) → extensions → autocomplete → Civitai seed → push локальных папок → ждать UI → toast/звук | | `gpu-rent up --no-spot` | Обычный (не preemptible) сервер | diff --git a/docs/decisions.md b/docs/decisions.md index c561a6f..951cad4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -8,6 +8,7 @@ | Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть | | Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем | | Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | +| Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`). `ru-6` — мультизональный: ходим на `https://ru-6.cloud.api.selcloud.ru/compute/` тем же токеном (SDK-каталог часто знает только RC-пул). Собираем типы GPU из extra_specs и совпадения с `FLAVOR_PREFERENCE`. Автоматом `.env` не пишем — печатаем рекомендацию `OS_REGION_NAME` / `GPU_RENT_AZ` | | Манифест моделей | `/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` | | Расширения | `/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes. Клон на первом bootstrap до старта UI. Пустой файл — стоковый SwarmUI | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) | diff --git a/docs/roadmap.md b/docs/roadmap.md index a26ec93..b7b166b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,13 +6,11 @@ ## 0.0 Квота GPU -Аккаунт есть, квота скорее 0. +- [x] Квота GPU в аккаунте в порядке (подтверждено) +- [ ] Тикет в поддержку Selectel — не нужен, если лимит уже > 0 +- [ ] Сервисный пользователь с правом `member` на проект + `.env` заполнен -- [ ] В панели: Облачные серверы → квоты / создание сервера с GPU -- [ ] Тикет в поддержку Selectel: 1× GPU (целевой: RTX 4090 24 GB или что дадут в пуле), проект gpu-rent -- [ ] Сервисный пользователь с правом только на этот проект - -Пока квота 0: `doctor` / `dry-run` / `flavors` работают; `up` откажется с текстом про поддержку. +`doctor` / `flavors` / `dry-run` — дальше; `up` после зелёного doctor. ## 0. Ручной spike в панели diff --git a/docs/setup.md b/docs/setup.md index 3ea9e41..a362758 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,7 +56,7 @@ python -m gpu_rent --help ### 2.2. Есть ли GPU в квоте 1. **IAM** → **Projects** → твой проект → вкладка **Quotas and limits** / **Квоты и лимиты** → **Cloud platform**. -2. Выбери пул и сегмент, где есть GPU (матрица: [GPU availability](https://docs.selectel.ru/en/cloud-servers/create/gpus/)). Для Москвы часто смотрят `ru-7` / `ru-7a` — **проверь матрицу**, GPU есть не везде. +2. Выбери пул и сегмент, где есть GPU (матрица: [GPU availability](https://docs.selectel.ru/en/cloud-servers/create/gpus/)). Для Москвы смотри **мультизональный `ru-6`** (`ru-6a`/`ru-6b`/`ru-6c`) и `ru-7`. CLI сам сканит `SCAN_POOLS` в `gpu-rent flavors` и подскажет `OS_REGION_NAME` / `GPU_RENT_AZ`. 3. Строка **GPU** (и при необходимости vCPU/RAM/network volumes). Если лимит 0 — дальше тикет. Квоту внутри уже выданного лимита можно крутить в панели. **Сам лимит GPU поднимает только поддержка.** diff --git a/env.example b/env.example index 5299971..7e0665f 100644 --- a/env.example +++ b/env.example @@ -39,6 +39,8 @@ SWARMUI_LOCAL_PORT=17801 DEFAULT_FLAVOR_ID= FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40 FLAVOR_FALLBACK=true +# Pools to probe before pick (ru-6 = Selectel multi-zone). Comma-separated. +SCAN_POOLS=ru-6,ru-7 DEFAULT_SPOT=true KEEP_FLOATING_IP=false IDLE_MINUTES=30 diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 28573ff..44da38f 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -153,14 +153,25 @@ def dry_run() -> None: @app.command() -def flavors() -> None: - """Живой список GPU flavors по FLAVOR_PREFERENCE.""" +def flavors( + scan: bool = typer.Option(True, "--scan/--no-scan", help="Скан пулов SCAN_POOLS (ru-6 multizone…)"), +) -> None: + """GPU flavors: скан пулов + список в текущем OS_REGION_NAME.""" try: from gpu_rent.inventory import looks_like_gpu, rank_flavors, resolve_flavor from gpu_rent.os_client import iter_flavors + from gpu_rent.pools import format_pool_scan, scan_pools from gpu_rent.ux import format_flavor_lines cfg = load_config(require_auth=True) + if scan: + console.print(f"[bold]region сейчас[/bold]: {cfg.os_region_name} / AZ {cfg.gpu_rent_az}") + console.print(f"[bold]SCAN_POOLS[/bold]: {cfg.scan_pools or 'ru-6,ru-7'}") + offers = scan_pools(cfg) + for line in format_pool_scan(offers, cfg.flavor_preference): + console.print(line) + console.print("") + conn = connect(cfg) all_f = list(iter_flavors(conn)) gpu = [f for f in all_f if looks_like_gpu(f)] @@ -174,13 +185,14 @@ def flavors() -> None: ) except ValueError: picked = None + console.print(f"[bold]в пуле {cfg.os_region_name}[/bold] (то, что возьмёт up):") for line in format_flavor_lines(ranked, picked): console.print(line) console.print( f"\nspot по умолчанию: {cfg.default_spot} " f"(обычный: gpu-rent up --no-spot)" ) - console.print("₽ в API нет — смотри панель Selectel.") + console.print("₽ в API нет — смотри панель. Серые кнопки панели ≠ disabled в Nova.") except GpuRentError as exc: _die(exc) diff --git a/src/gpu_rent/config.py b/src/gpu_rent/config.py index cb995d2..ef88e14 100644 --- a/src/gpu_rent/config.py +++ b/src/gpu_rent/config.py @@ -80,6 +80,7 @@ class Config: default_flavor_id: str flavor_preference: tuple[str, ...] flavor_fallback: bool + scan_pools: str default_spot: bool keep_floating_ip: bool idle_minutes: int @@ -185,6 +186,7 @@ def load_config(*, require_auth: bool = True) -> Config: ("4090-24", "4090-48", "a5000", "a100-40"), ), flavor_fallback=_as_bool(os.environ.get("FLAVOR_FALLBACK"), True), + scan_pools=(os.environ.get("SCAN_POOLS") or "ru-6,ru-7").strip(), 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), diff --git a/src/gpu_rent/inventory.py b/src/gpu_rent/inventory.py index 9c82f41..c76b555 100644 --- a/src/gpu_rent/inventory.py +++ b/src/gpu_rent/inventory.py @@ -52,7 +52,18 @@ def looks_like_gpu(flavor: Any) -> bool: 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") + needles = ( + "gpu", + "4090", + "a5000", + "a100", + "a6000", + "l40", + "h100", + "h200", + "rtx", + "tesla", + ) return any(n in hay for n in needles) diff --git a/src/gpu_rent/pools.py b/src/gpu_rent/pools.py new file mode 100644 index 0000000..19e50a5 --- /dev/null +++ b/src/gpu_rent/pools.py @@ -0,0 +1,223 @@ +"""Scan Selectel pools for GPU flavors before pick. + +openstacksdk only accepts regions from the project catalog (often just the RC +pool, e.g. ru-7). Multizone ru-6 still answers on +https://ru-6.cloud.api.selcloud.ru/compute/ — we probe that with the same token. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any + +import httpx + +from gpu_rent.config import Config +from gpu_rent.errors import CloudError +from gpu_rent.inventory import ( + FlavorInfo, + is_disabled, + looks_like_gpu, + match_label, + rank_flavors, +) +from gpu_rent.os_client import connect + +DEFAULT_MULTIZONE_POOLS = ("ru-6",) +DEFAULT_SCAN_POOLS = ("ru-6", "ru-7") +COMPUTE_URL = "https://{pool}.cloud.api.selcloud.ru/compute/v2.1" + + +@dataclass +class PoolGpuOffer: + region: str + multizone: bool + matched: list[FlavorInfo] = field(default_factory=list) + gpu_labels_found: list[str] = field(default_factory=list) + gpu_types: list[str] = field(default_factory=list) + error: str | None = None + + +def scan_pool_list(cfg: Config) -> tuple[str, ...]: + raw = (getattr(cfg, "scan_pools", None) or "") or "" + if isinstance(raw, str) and raw.strip(): + return tuple(p.strip() for p in raw.split(",") if p.strip()) + ordered: list[str] = [] + for p in (*DEFAULT_MULTIZONE_POOLS, cfg.os_region_name, *DEFAULT_SCAN_POOLS): + if p and p not in ordered: + ordered.append(p) + return tuple(ordered) + + +def _auth_token(cfg: Config) -> str: + conn = connect(cfg) + tok = conn.authorize() + if not tok: + raise CloudError("нет X-Auth-Token после authorize()") + return str(tok) + + +def _flavor_ns(raw: dict[str, Any], specs: dict[str, Any]) -> SimpleNamespace: + disabled = bool(raw.get("OS-FLV-DISABLED:disabled")) + return SimpleNamespace( + id=str(raw.get("id", "")), + name=str(raw.get("name") or ""), + vcpus=raw.get("vcpus"), + ram=raw.get("ram"), + is_disabled=disabled, + extra_specs=specs or {}, + availability_zones=raw.get("availability_zones") or [], + ) + + +def _fetch_extra_specs(pool: str, flavor_id: str, token: str) -> dict[str, Any]: + url = f"{COMPUTE_URL.format(pool=pool)}/flavors/{flavor_id}/os-extra_specs" + try: + r = httpx.get( + url, + headers={"X-Auth-Token": token, "Accept": "application/json"}, + timeout=30.0, + ) + if r.status_code >= 400: + return {} + return dict(r.json().get("extra_specs") or {}) + except httpx.HTTPError: + return {} + + +def list_flavors_in_pool(pool: str, token: str) -> list[SimpleNamespace]: + """Live flavor list for a pool via Selectel compute URL (bypasses SDK region).""" + url = f"{COMPUTE_URL.format(pool=pool)}/flavors/detail" + try: + r = httpx.get( + url, + headers={"X-Auth-Token": token, "Accept": "application/json"}, + timeout=60.0, + ) + except httpx.HTTPError as exc: + raise CloudError(f"пул {pool}: сеть — {exc}") from exc + if r.status_code == 404: + raise CloudError(f"пул {pool}: compute 404") + if r.status_code >= 400: + raise CloudError(f"пул {pool}: HTTP {r.status_code} {r.text[:180]}") + + raw_list = list(r.json().get("flavors") or []) + # Hydrate extra_specs (often empty in detail; GPU type lives there). + candidates = [ + f + for f in raw_list + if "gpu" in (f.get("name") or "").lower() + or (isinstance(f.get("extra_specs"), dict) and f.get("extra_specs")) + ] + if not candidates: + candidates = raw_list + + specs_by_id: dict[str, dict[str, Any]] = {} + with ThreadPoolExecutor(max_workers=16) as pool_ex: + futs = { + pool_ex.submit(_fetch_extra_specs, pool, str(f["id"]), token): str(f["id"]) + for f in candidates + if f.get("id") + } + for fut in as_completed(futs): + fid = futs[fut] + specs_by_id[fid] = fut.result() + + out: list[SimpleNamespace] = [] + for raw in raw_list: + fid = str(raw.get("id", "")) + embedded = raw.get("extra_specs") if isinstance(raw.get("extra_specs"), dict) else {} + specs = specs_by_id.get(fid) or embedded or {} + out.append(_flavor_ns(raw, specs)) + return out + + +def _gpu_type_keys(flavors: list[Any]) -> list[str]: + found: set[str] = set() + for flavor in flavors: + if is_disabled(flavor) or not looks_like_gpu(flavor): + continue + extra = getattr(flavor, "extra_specs", {}) or {} + g = extra.get("aggregate_instance_extra_specs:gpu") + if g: + found.add(str(g)) + continue + alias = extra.get("pci_passthrough:alias") + if alias: + found.add(str(alias).split(":")[0]) + return sorted(found) + + +def _labels_present(flavors: list[Any], preference: tuple[str, ...]) -> list[str]: + found: list[str] = [] + for label in preference: + for flavor in flavors: + if is_disabled(flavor): + continue + if match_label(label, flavor): + found.append(label) + break + return found + + +def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGpuOffer]: + targets = pools or scan_pool_list(cfg) + mz = set(DEFAULT_MULTIZONE_POOLS) + token = _auth_token(cfg) + out: list[PoolGpuOffer] = [] + for region in targets: + offer = PoolGpuOffer( + region=region, + multizone=region in mz or region.startswith("ru-6"), + ) + try: + flavors = list_flavors_in_pool(region, token) + gpu = [f for f in flavors if looks_like_gpu(f)] + offer.matched = rank_flavors(gpu or flavors, cfg.flavor_preference) + offer.gpu_labels_found = _labels_present(gpu or flavors, cfg.flavor_preference) + offer.gpu_types = _gpu_type_keys(gpu or flavors) + except Exception as exc: + offer.error = str(exc) + out.append(offer) + return out + + +def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) -> list[str]: + lines = [ + "скан пулов (мультизональные + кандидаты) × характеристики GPU:", + " (серые кнопки панели ≠ Nova; available_count бывает только у части flavors)", + ] + for offer in offers: + tag = "multizone" if offer.multizone else "pool" + prefix = f" {offer.region} ({tag}):" + if offer.error: + lines.append(f"{prefix} ОШИБКА — {offer.error}") + continue + types_s = ", ".join(offer.gpu_types) if offer.gpu_types else "—" + lines.append(f"{prefix} типы: {types_s}") + if offer.gpu_labels_found: + best = offer.matched[0] if offer.matched else None + best_s = f" → лучший {best.name} ({best.label})" if best else "" + lines.append( + f" preference: {', '.join(offer.gpu_labels_found)}{best_s}" + ) + else: + lines.append(f" preference: нет совпадений с {','.join(preference)}") + for offer in offers: + if offer.matched and not offer.error: + lines.append( + f"рекомендация: OS_REGION_NAME={offer.region} " + f"GPU_RENT_AZ={offer.region}a " + f"(сегмент a/b/c — в панели; диски и VM в одном AZ)" + ) + break + return lines + + +def best_offer(offers: list[PoolGpuOffer]) -> PoolGpuOffer | None: + for offer in offers: + if offer.matched and not offer.error: + return offer + return None diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index f7ac0d4..579d075 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -33,6 +33,7 @@ from gpu_rent.inventory import ( resolve_flavor, ) from gpu_rent.ux import print_up_preview +from gpu_rent.pools import best_offer, format_pool_scan, scan_pools from gpu_rent.lock import SessionLock from gpu_rent.os_client import ( KEYPAIR_NAME, @@ -172,6 +173,21 @@ def cmd_up( vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az) spot = cfg.default_spot and not no_spot + try: + offers = scan_pools(cfg) + for line in format_pool_scan(offers, cfg.flavor_preference): + log(line) + best = best_offer(offers) + if best and best.region != cfg.os_region_name: + log( + f"! сейчас OS_REGION_NAME={cfg.os_region_name}, " + f"а предпочтение лучше закрывается в {best.region} — " + f"поставь OS_REGION_NAME={best.region} и GPU_RENT_AZ={best.region}a " + f"в .env (диски ещё не созданы) и повтори up" + ) + except Exception as exc: + log(f"скан пулов: {exc}") + print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log) prompt = ( diff --git a/tests/test_pools.py b/tests/test_pools.py new file mode 100644 index 0000000..1fa7761 --- /dev/null +++ b/tests/test_pools.py @@ -0,0 +1,22 @@ +from gpu_rent.pools import PoolGpuOffer, format_pool_scan + + +def test_format_pool_scan_recommends_first_hit(): + from gpu_rent.inventory import FlavorInfo + + offers = [ + PoolGpuOffer(region="ru-7", multizone=False, gpu_labels_found=[], matched=[], gpu_types=[]), + PoolGpuOffer( + region="ru-6", + multizone=True, + gpu_labels_found=["4090-24", "4090-48"], + gpu_types=["4090", "H200"], + matched=[ + FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24"), + ], + ), + ] + text = "\n".join(format_pool_scan(offers, ("4090-24", "4090-48"))) + assert "ru-6" in text and "multizone" in text + assert "типы:" in text + assert "рекомендация: OS_REGION_NAME=ru-6" in text