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.
This commit is contained in:
+1
-1
@@ -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 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` | Preflight → create/unshelve (с фоллбеком flavor) → extensions → autocomplete → Civitai seed → push локальных папок → ждать UI → toast/звук |
|
||||||
| `gpu-rent up --no-spot` | Обычный (не preemptible) сервер |
|
| `gpu-rent up --no-spot` | Обычный (не preemptible) сервер |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
| Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть |
|
| Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть |
|
||||||
| Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем |
|
| Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем |
|
||||||
| Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена |
|
| 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` |
|
||||||
| Манифест моделей | `<repo>/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` |
|
| Манифест моделей | `<repo>/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` |
|
||||||
| Расширения | `<repo>/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes. Клон на первом bootstrap до старта UI. Пустой файл — стоковый SwarmUI |
|
| Расширения | `<repo>/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) |
|
| Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) |
|
||||||
|
|||||||
+4
-6
@@ -6,13 +6,11 @@
|
|||||||
|
|
||||||
## 0.0 Квота GPU
|
## 0.0 Квота GPU
|
||||||
|
|
||||||
Аккаунт есть, квота скорее 0.
|
- [x] Квота GPU в аккаунте в порядке (подтверждено)
|
||||||
|
- [ ] Тикет в поддержку Selectel — не нужен, если лимит уже > 0
|
||||||
|
- [ ] Сервисный пользователь с правом `member` на проект + `.env` заполнен
|
||||||
|
|
||||||
- [ ] В панели: Облачные серверы → квоты / создание сервера с GPU
|
`doctor` / `flavors` / `dry-run` — дальше; `up` после зелёного doctor.
|
||||||
- [ ] Тикет в поддержку Selectel: 1× GPU (целевой: RTX 4090 24 GB или что дадут в пуле), проект gpu-rent
|
|
||||||
- [ ] Сервисный пользователь с правом только на этот проект
|
|
||||||
|
|
||||||
Пока квота 0: `doctor` / `dry-run` / `flavors` работают; `up` откажется с текстом про поддержку.
|
|
||||||
|
|
||||||
## 0. Ручной spike в панели
|
## 0. Ручной spike в панели
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ python -m gpu_rent --help
|
|||||||
### 2.2. Есть ли GPU в квоте
|
### 2.2. Есть ли GPU в квоте
|
||||||
|
|
||||||
1. **IAM** → **Projects** → твой проект → вкладка **Quotas and limits** / **Квоты и лимиты** → **Cloud platform**.
|
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 — дальше тикет.
|
3. Строка **GPU** (и при необходимости vCPU/RAM/network volumes). Если лимит 0 — дальше тикет.
|
||||||
|
|
||||||
Квоту внутри уже выданного лимита можно крутить в панели. **Сам лимит GPU поднимает только поддержка.**
|
Квоту внутри уже выданного лимита можно крутить в панели. **Сам лимит GPU поднимает только поддержка.**
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ SWARMUI_LOCAL_PORT=17801
|
|||||||
DEFAULT_FLAVOR_ID=
|
DEFAULT_FLAVOR_ID=
|
||||||
FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
|
FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
|
||||||
FLAVOR_FALLBACK=true
|
FLAVOR_FALLBACK=true
|
||||||
|
# Pools to probe before pick (ru-6 = Selectel multi-zone). Comma-separated.
|
||||||
|
SCAN_POOLS=ru-6,ru-7
|
||||||
DEFAULT_SPOT=true
|
DEFAULT_SPOT=true
|
||||||
KEEP_FLOATING_IP=false
|
KEEP_FLOATING_IP=false
|
||||||
IDLE_MINUTES=30
|
IDLE_MINUTES=30
|
||||||
|
|||||||
+15
-3
@@ -153,14 +153,25 @@ def dry_run() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def flavors() -> None:
|
def flavors(
|
||||||
"""Живой список GPU flavors по FLAVOR_PREFERENCE."""
|
scan: bool = typer.Option(True, "--scan/--no-scan", help="Скан пулов SCAN_POOLS (ru-6 multizone…)"),
|
||||||
|
) -> None:
|
||||||
|
"""GPU flavors: скан пулов + список в текущем OS_REGION_NAME."""
|
||||||
try:
|
try:
|
||||||
from gpu_rent.inventory import looks_like_gpu, rank_flavors, resolve_flavor
|
from gpu_rent.inventory import looks_like_gpu, rank_flavors, resolve_flavor
|
||||||
from gpu_rent.os_client import iter_flavors
|
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
|
from gpu_rent.ux import format_flavor_lines
|
||||||
|
|
||||||
cfg = load_config(require_auth=True)
|
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)
|
conn = connect(cfg)
|
||||||
all_f = list(iter_flavors(conn))
|
all_f = list(iter_flavors(conn))
|
||||||
gpu = [f for f in all_f if looks_like_gpu(f)]
|
gpu = [f for f in all_f if looks_like_gpu(f)]
|
||||||
@@ -174,13 +185,14 @@ def flavors() -> None:
|
|||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
picked = None
|
picked = None
|
||||||
|
console.print(f"[bold]в пуле {cfg.os_region_name}[/bold] (то, что возьмёт up):")
|
||||||
for line in format_flavor_lines(ranked, picked):
|
for line in format_flavor_lines(ranked, picked):
|
||||||
console.print(line)
|
console.print(line)
|
||||||
console.print(
|
console.print(
|
||||||
f"\nspot по умолчанию: {cfg.default_spot} "
|
f"\nspot по умолчанию: {cfg.default_spot} "
|
||||||
f"(обычный: gpu-rent up --no-spot)"
|
f"(обычный: gpu-rent up --no-spot)"
|
||||||
)
|
)
|
||||||
console.print("₽ в API нет — смотри панель Selectel.")
|
console.print("₽ в API нет — смотри панель. Серые кнопки панели ≠ disabled в Nova.")
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class Config:
|
|||||||
default_flavor_id: str
|
default_flavor_id: str
|
||||||
flavor_preference: tuple[str, ...]
|
flavor_preference: tuple[str, ...]
|
||||||
flavor_fallback: bool
|
flavor_fallback: bool
|
||||||
|
scan_pools: str
|
||||||
default_spot: bool
|
default_spot: bool
|
||||||
keep_floating_ip: bool
|
keep_floating_ip: bool
|
||||||
idle_minutes: int
|
idle_minutes: int
|
||||||
@@ -185,6 +186,7 @@ def load_config(*, require_auth: bool = True) -> Config:
|
|||||||
("4090-24", "4090-48", "a5000", "a100-40"),
|
("4090-24", "4090-48", "a5000", "a100-40"),
|
||||||
),
|
),
|
||||||
flavor_fallback=_as_bool(os.environ.get("FLAVOR_FALLBACK"), True),
|
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),
|
default_spot=_as_bool(os.environ.get("DEFAULT_SPOT"), True),
|
||||||
keep_floating_ip=_as_bool(os.environ.get("KEEP_FLOATING_IP"), False),
|
keep_floating_ip=_as_bool(os.environ.get("KEEP_FLOATING_IP"), False),
|
||||||
idle_minutes=_as_int(os.environ.get("IDLE_MINUTES"), 30),
|
idle_minutes=_as_int(os.environ.get("IDLE_MINUTES"), 30),
|
||||||
|
|||||||
@@ -52,7 +52,18 @@ def looks_like_gpu(flavor: Any) -> bool:
|
|||||||
extra = extra_specs(flavor)
|
extra = extra_specs(flavor)
|
||||||
blob = " ".join(f"{k}={v}" for k, v in extra.items()).lower()
|
blob = " ".join(f"{k}={v}" for k, v in extra.items()).lower()
|
||||||
hay = f"{name} {blob}"
|
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)
|
return any(n in hay for n in needles)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,6 +33,7 @@ from gpu_rent.inventory import (
|
|||||||
resolve_flavor,
|
resolve_flavor,
|
||||||
)
|
)
|
||||||
from gpu_rent.ux import print_up_preview
|
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.lock import SessionLock
|
||||||
from gpu_rent.os_client import (
|
from gpu_rent.os_client import (
|
||||||
KEYPAIR_NAME,
|
KEYPAIR_NAME,
|
||||||
@@ -172,6 +173,21 @@ def cmd_up(
|
|||||||
vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az)
|
vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az)
|
||||||
spot = cfg.default_spot and not no_spot
|
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)
|
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user