- Removed deprecated console usage in favor of structured logging functions for error handling and user prompts. - Enhanced CLI prompts for LLM runtime and preset selection, utilizing menu helpers for better user interaction. - Updated GPU pool scanning output with improved formatting and error indication for clarity. - Refactored setup wizard to streamline LLM runtime and preset configuration, ensuring a more intuitive setup process. - Improved documentation and user feedback in CLI outputs to enhance overall usability.
228 lines
8.1 KiB
Python
228 lines
8.1 KiB
Python
"""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 = [
|
|
"[bold cyan]скан пулов[/bold cyan] (мультизональные + кандидаты) × характеристики GPU:",
|
|
" [dim](серые кнопки панели ≠ Nova; available_count бывает только у части flavors)[/dim]",
|
|
]
|
|
for offer in offers:
|
|
tag = "multizone" if offer.multizone else "pool"
|
|
prefix = f" [bold]{offer.region}[/bold] ({tag}):"
|
|
if offer.error:
|
|
lines.append(f"{prefix} [red]ОШИБКА[/red] — {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" → [green]лучший {best.name} ({best.label})[/green]" if best else ""
|
|
)
|
|
lines.append(
|
|
f" preference: {', '.join(offer.gpu_labels_found)}{best_s}"
|
|
)
|
|
else:
|
|
lines.append(
|
|
f" [yellow]preference: нет совпадений с {','.join(preference)}[/yellow]"
|
|
)
|
|
for offer in offers:
|
|
if offer.matched and not offer.error:
|
|
lines.append(
|
|
f"[green]рекомендация:[/green] OS_REGION_NAME={offer.region} "
|
|
f"GPU_RENT_AZ={offer.region}a "
|
|
f"[dim](сегмент a/b/c — в панели; диски и VM в одном AZ)[/dim]"
|
|
)
|
|
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
|