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:
Leonid Pershin
2026-08-21 04:03:28 +03:00
parent a563ae06c4
commit 28019d1d09
11 changed files with 299 additions and 12 deletions
+15 -3
View File
@@ -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)
+2
View File
@@ -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),
+12 -1
View File
@@ -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)
+223
View File
@@ -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
+16
View File
@@ -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 = (