"""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