From 789fa269187dec4a147f21fd029b3b505f1ace77 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 22 Aug 2026 00:06:44 +0300 Subject: [PATCH] Pick cheapest GPU SKU and pin the VM to an existing disk AZ. Scan ru-6 by Nova availability zones (a/b/c). First boot uses FLAVOR_SIZE_PRESET=cheap; reruns stay on the disk segment. Co-authored-by: Cursor --- docs/cli.md | 3 +- docs/decisions.md | 2 +- docs/selectel.md | 6 +- env.example | 2 + gpu-rent.vars.example | 1 + scripts/migrate_local_configs.py | 1 + src/gpu_rent/cli.py | 26 ++++- src/gpu_rent/config.py | 3 + src/gpu_rent/doctor.py | 7 +- src/gpu_rent/flavor_presets.py | 43 +++++++++ src/gpu_rent/inventory.py | 161 ++++++++++++++++++++++++++++--- src/gpu_rent/placement.py | 76 +++++++++++++++ src/gpu_rent/pools.py | 140 +++++++++++++++++++++++---- src/gpu_rent/session.py | 59 ++++++++--- src/gpu_rent/ux.py | 53 ++++++++-- tests/test_inventory.py | 58 ++++++++++- tests/test_placement.py | 58 +++++++++++ tests/test_pools.py | 34 ++++++- tests/test_server_plan.py | 4 +- 19 files changed, 666 insertions(+), 71 deletions(-) create mode 100644 src/gpu_rent/flavor_presets.py create mode 100644 src/gpu_rent/placement.py create mode 100644 tests/test_placement.py diff --git a/docs/cli.md b/docs/cli.md index 70b780a..963ea21 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -53,7 +53,7 @@ gpu-rent up --yes --ollama | --- | --- | | `gpu-rent setup` | Wizard: манифесты (в т.ч. ollama-models), `LLM_RUNTIME` (нумерованное меню), пресет, опционально local-watchdog | | `gpu-rent doctor` | Preflight **без** create. Exit ≠ 0 → сессию начинать нельзя | -| `gpu-rent flavors` | Скан `SCAN_POOLS` × `FLAVOR_PREFERENCE`, список в текущем регионе | +| `gpu-rent flavors` | Скан `SCAN_POOLS` по сегментам a/b/c × `FLAVOR_PREFERENCE` × `FLAVOR_SIZE_PRESET`, список в текущем регионе | | `gpu-rent dry-run` | План без mutating-вызовов | | `gpu-rent up` | Без `--yes`: меню LLM (если runtime none) → пресет → **Selectel**: flavor # / data GB / preemptible → confirm → create → туннель. Цветной лог (ошибки/warn/ok) | | `gpu-rent up --yes` | Без вопросов; flavor из `FLAVOR_PREFERENCE` / `DEFAULT_FLAVOR_ID` | @@ -198,6 +198,7 @@ UPDATE_GIT=true DEFAULT_FLAVOR_ID= FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40 FLAVOR_FALLBACK=true +FLAVOR_SIZE_PRESET=cheap SCAN_POOLS=ru-6,ru-7 DEFAULT_SPOT=true KEEP_FLOATING_IP=false diff --git a/docs/decisions.md b/docs/decisions.md index 45f54db..dcafcb9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -8,7 +8,7 @@ | Сессия | GPU живёт до `stop` / **Ctrl+C или Ctrl+D на туннеле** **или** простоя (idle-killer **на VM**). Выход из туннеля гасит compute (диски остаются). Опционально: `gpu-rent watchdog install` — локальный тик; если процесс умер без stop, после `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` | | Модели | Пустой `./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` | +| Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`) по compute URL. Сегменты `ru-6a/b/c` берём из Nova `availability_zones`, не отдельными пулами. Пресет размера `FLAVOR_SIZE_PRESET` (дефолт `cheap` = 1×GPU, min vCPU/RAM). Диск уже есть — AZ диска, не дешёвый чужой сегмент. Первый запуск — дешёвый SKU в сегменте, где есть карта из `FLAVOR_PREFERENCE`. `.env` автоматом не пишем | | Манифест моделей | `/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` | | Расширения | `/extensions.yaml`: git-репы `swarmui` → `src/Extensions`, `comfy` → DLNodes. Поле `requires: none\|ollama\|any-llm` фильтрует по `LLM_RUNTIME`. Пример: swarm-assistent с `requires: ollama` | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) | diff --git a/docs/selectel.md b/docs/selectel.md index b1090cc..455eb39 100644 --- a/docs/selectel.md +++ b/docs/selectel.md @@ -63,8 +63,10 @@ Custom GPU flavor создаётся только через API, не чере Поведение: -- Предпочтительный доступен — берём его, печатаем ₽/час. -- Нет — показать следующий доступный из списка с ценой и отличием (VRAM / ₽). Интерактив: подтвердить. `--yes` — взять первый доступный из списка без вопроса, цену всё равно напечатать. +- Предпочтительный доступен — берём самый дешёвый SKU этого типа (`FLAVOR_SIZE_PRESET=cheap`: 1×GPU, min vCPU/RAM, без локального диска). Печатаем ₽/час из панели (в API нет). +- Диски уже есть — сегмент зафиксирован (диск из `ru-6a` нельзя прицепить к VM в `ru-6b`). Flavor только из этого AZ. +- Первый запуск (дисков нет) — сегмент, где есть карта из `FLAVOR_PREFERENCE` (4090 ≈ `ru-6a` / `ru-7b`, не `ru-6b/c`). +- Нет — показать следующий доступный из списка с отличием (VRAM / размер). Интерактив: пресет размера + номер flavor. `--yes` — первый доступный из списка × пресет, без вопроса. - Ни один из списка не доступен — стоп с текстом «нет GPU в сегменте», не молчаливый CPU-flavor. - Явный `--flavor ` бьёт список: нет этого id → ошибка, без автофоллбека. diff --git a/env.example b/env.example index fe11e41..4bb8196 100644 --- a/env.example +++ b/env.example @@ -57,6 +57,8 @@ UPDATE_GIT=true DEFAULT_FLAVOR_ID= FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40 FLAVOR_FALLBACK=true +# cheap = 1×GPU min vCPU/RAM; balanced ≈ 8/32GB; ram = max RAM among 1×GPU +FLAVOR_SIZE_PRESET=cheap # Pools to probe before pick (ru-6 = Selectel multi-zone). Comma-separated. SCAN_POOLS=ru-6,ru-7 DEFAULT_SPOT=true diff --git a/gpu-rent.vars.example b/gpu-rent.vars.example index 69b2b8e..99c7bb4 100644 --- a/gpu-rent.vars.example +++ b/gpu-rent.vars.example @@ -16,6 +16,7 @@ # DEFAULT_SPOT=true # SCAN_POOLS=ru-6,ru-7 # FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40 +# FLAVOR_SIZE_PRESET=cheap # cheap | balanced | ram (1×GPU; cheap = min vCPU/RAM) # SWARMUI_LOCAL_PORT=17801 # DATA_VOLUME_SIZE_GB=100 # ENABLE_SWARMUI=true diff --git a/scripts/migrate_local_configs.py b/scripts/migrate_local_configs.py index 0296a78..f91d79b 100644 --- a/scripts/migrate_local_configs.py +++ b/scripts/migrate_local_configs.py @@ -67,6 +67,7 @@ _VARS_PREFERRED = { "DEFAULT_SPOT", "SCAN_POOLS", "FLAVOR_PREFERENCE", + "FLAVOR_SIZE_PRESET", "SWARMUI_LOCAL_PORT", } diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 4404b51..4aa56a8 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -183,7 +183,12 @@ def dry_run() -> None: conn = connect(cfg) flavors = list(iter_flavors(conn)) gpu = [f for f in flavors if looks_like_gpu(f)] - ranked = rank_flavors(gpu or flavors, cfg.flavor_preference) + ranked = rank_flavors( + gpu or flavors, + cfg.flavor_preference, + size_preset=cfg.flavor_size_preset, + az=cfg.gpu_rent_az, + ) console.print("\n[bold]Flavors[/bold]") for line in format_flavor_lines(ranked): console.print(f" {line}") @@ -201,6 +206,7 @@ def flavors( ) -> None: """GPU flavors: скан пулов + список в текущем OS_REGION_NAME.""" try: + from gpu_rent.flavor_presets import format_char_impact 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 @@ -211,20 +217,34 @@ def flavors( 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): + for line in format_pool_scan( + offers, + cfg.flavor_preference, + size_preset=cfg.flavor_size_preset, + ): + console.print(line) + console.print("") + for line in format_char_impact(): 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)] - ranked = rank_flavors(gpu or all_f, cfg.flavor_preference) + ranked = rank_flavors( + gpu or all_f, + cfg.flavor_preference, + size_preset=cfg.flavor_size_preset, + az=cfg.gpu_rent_az, + ) try: picked = resolve_flavor( all_f, cfg.flavor_preference, default_id=cfg.default_flavor_id or None, fallback=cfg.flavor_fallback, + size_preset=cfg.flavor_size_preset, + az=cfg.gpu_rent_az, ) except ValueError: picked = None diff --git a/src/gpu_rent/config.py b/src/gpu_rent/config.py index 5e35df9..665bfe6 100644 --- a/src/gpu_rent/config.py +++ b/src/gpu_rent/config.py @@ -22,6 +22,7 @@ from gpu_rent.paths import ( ) from gpu_rent.varsfile import apply_vars_file from gpu_rent.llm_runtime import normalize_runtime +from gpu_rent.flavor_presets import normalize_size_preset def _as_bool(value: str | None, default: bool) -> bool: @@ -111,6 +112,7 @@ class Config: default_flavor_id: str flavor_preference: tuple[str, ...] flavor_fallback: bool + flavor_size_preset: str scan_pools: str default_spot: bool keep_floating_ip: bool @@ -244,6 +246,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), + flavor_size_preset=normalize_size_preset(os.environ.get("FLAVOR_SIZE_PRESET")), 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), diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index aa00990..3428b15 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -125,7 +125,12 @@ def run_doctor() -> list[Check]: flavors = list(iter_flavors(conn)) gpu_flavors = [f for f in flavors if looks_like_gpu(f)] - ranked = rank_flavors(gpu_flavors or flavors, cfg.flavor_preference) + ranked = rank_flavors( + gpu_flavors or flavors, + cfg.flavor_preference, + size_preset=cfg.flavor_size_preset, + az=cfg.gpu_rent_az, + ) if cfg.default_flavor_id: hit = next((f for f in flavors if getattr(f, "id", None) == cfg.default_flavor_id), None) if hit: diff --git a/src/gpu_rent/flavor_presets.py b/src/gpu_rent/flavor_presets.py new file mode 100644 index 0000000..b538570 --- /dev/null +++ b/src/gpu_rent/flavor_presets.py @@ -0,0 +1,43 @@ +"""Named SKU size presets and how each characteristic affects cost.""" + +from __future__ import annotations + +from gpu_rent.prompts import MenuItem + +SIZE_PRESETS = ("cheap", "balanced", "ram") + +SIZE_PRESET_LABELS = { + "cheap": "cheap — 1×GPU, min vCPU/RAM, сеть (почти только цена карты)", + "balanced": "balanced — 1×GPU, ~8 vCPU / 32GB (небольшой запас под Comfy+Ollama)", + "ram": "ram — 1×GPU, максимум RAM (RAM заметно дороже CPU)", +} + +CHAR_IMPACT = ( + ("GPU", "тип карты = основная цена; порядок в FLAVOR_PREFERENCE"), + ("карты", "2×GPU ≈ ×2 к карте; пресеты берут 1×"), + ("vCPU", "надбавка к карте, меньше чем GPU"), + ("RAM", "надбавка к карте; пресет ram максимизирует"), + ("локальный диск", "дороже сети; cheap берёт 0"), + ("сегмент", "не цена, а наличие карты + диск; 4090 ≈ ru-6a / ru-7b"), +) + + +def normalize_size_preset(raw: str | None, default: str = "cheap") -> str: + key = (raw or default or "cheap").strip().lower() + if key not in SIZE_PRESETS: + return default if default in SIZE_PRESETS else "cheap" + return key + + +def size_preset_menu() -> list[MenuItem]: + return [MenuItem(key, SIZE_PRESET_LABELS[key]) for key in SIZE_PRESETS] + + +def format_char_impact() -> list[str]: + lines = [ + "[bold cyan]как влияет на тариф[/bold cyan] " + "[dim](₽ в OpenStack API нет — относительный вес)[/dim]:" + ] + for name, effect in CHAR_IMPACT: + lines.append(f" [bold]{name}[/bold]: {effect}") + return lines diff --git a/src/gpu_rent/inventory.py b/src/gpu_rent/inventory.py index c76b555..a4696fd 100644 --- a/src/gpu_rent/inventory.py +++ b/src/gpu_rent/inventory.py @@ -2,9 +2,13 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import Any +_GPU_COUNT_NAME = re.compile(r"-(\d+)GPU\b", re.I) +_GPU_COUNT_X = re.compile(r"(\d+)\s*[x×]", re.I) + @dataclass class FlavorInfo: @@ -15,6 +19,10 @@ class FlavorInfo: disabled: bool extra: dict[str, Any] label: str | None = None + gpu_count: int = 1 + disk_gb: int = 0 + azs: tuple[str, ...] = () + available_count: int | None = None def _name(obj: Any) -> str: @@ -87,9 +95,65 @@ def match_label(label: str, flavor: Any) -> bool: return token.replace("_", "-") in hay or token.replace("-", " ") in hay +def gpu_count(flavor: Any) -> int: + name = _name(flavor) + matched = _GPU_COUNT_NAME.search(name) + if matched: + return max(1, int(matched.group(1))) + matched = _GPU_COUNT_X.search(name) + if matched: + return max(1, int(matched.group(1))) + extra = extra_specs(flavor) + alias = str(extra.get("pci_passthrough:alias") or "") + alias_n = re.search(r":(\d+)\s*$", alias) + if alias_n: + return max(1, int(alias_n.group(1))) + return 1 + + +def local_disk_gb(flavor: Any) -> int: + disk = getattr(flavor, "disk", None) + if disk is None and isinstance(flavor, FlavorInfo): + return int(flavor.disk_gb or 0) + try: + return max(0, int(disk or 0)) + except (TypeError, ValueError): + return 0 + + +def flavor_azs(flavor: Any) -> tuple[str, ...]: + if isinstance(flavor, FlavorInfo) and flavor.azs: + return tuple(flavor.azs) + raw = getattr(flavor, "availability_zones", None) + if isinstance(raw, (list, tuple)): + return tuple(str(item) for item in raw if item) + return () + + +def flavor_in_az(flavor: Any, az: str | None) -> bool: + if not az: + return True + zones = flavor_azs(flavor) + if not zones: + return True + return az in zones + + +def available_count(flavor: Any) -> int | None: + extra = extra_specs(flavor) + raw = extra.get("available_count") + if raw is None and isinstance(flavor, FlavorInfo): + return flavor.available_count + try: + return int(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo: ram = getattr(flavor, "ram", None) vcpus = getattr(flavor, "vcpus", None) + count = available_count(flavor) return FlavorInfo( id=_id(flavor), name=_name(flavor) or _id(flavor), @@ -98,21 +162,75 @@ def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo: disabled=is_disabled(flavor), extra=extra_specs(flavor), label=label, + gpu_count=gpu_count(flavor), + disk_gb=local_disk_gb(flavor), + azs=flavor_azs(flavor), + available_count=count, ) -def rank_flavors(flavors: list[Any], preference: tuple[str, ...]) -> list[FlavorInfo]: +def pick_size_preset(flavors: list[Any], preset: str) -> Any | None: + """Pick one SKU: cheap = 1×GPU min vCPU/RAM/disk; balanced ≈8/32GB; ram = max RAM.""" + if not flavors: + return None + key = (preset or "cheap").strip().lower() + if key not in {"cheap", "balanced", "ram"}: + key = "cheap" + + def _vcpu(item: Any) -> int: + value = getattr(item, "vcpus", None) + return int(value) if value is not None else 0 + + def _ram(item: Any) -> int: + value = getattr(item, "ram", None) + if value is None and isinstance(item, FlavorInfo): + value = item.ram_mb + return int(value) if value is not None else 0 + + def cheap_key(item: Any) -> tuple: + return (gpu_count(item), _vcpu(item), _ram(item), local_disk_gb(item), _name(item)) + + if key == "ram": + return min(flavors, key=lambda item: (gpu_count(item), -_ram(item), _vcpu(item), local_disk_gb(item))) + if key == "balanced": + return min( + flavors, + key=lambda item: ( + gpu_count(item), + abs(_vcpu(item) - 8), + abs(_ram(item) - 32768), + _vcpu(item), + _ram(item), + local_disk_gb(item), + ), + ) + return min(flavors, key=cheap_key) + + +def rank_flavors( + flavors: list[Any], + preference: tuple[str, ...], + *, + size_preset: str = "cheap", + az: str | None = None, +) -> list[FlavorInfo]: ranked: list[FlavorInfo] = [] seen: set[str] = set() for label in preference: - for flavor in flavors: - fid = _id(flavor) - if fid in seen or is_disabled(flavor): - continue - if match_label(label, flavor): - ranked.append(flavor_info(flavor, label)) - seen.add(fid) - break + candidates = [ + flavor + for flavor in flavors + if _id(flavor) not in seen + and not is_disabled(flavor) + and flavor_in_az(flavor, az) + and match_label(label, flavor) + ] + picked = pick_size_preset(candidates, size_preset) + if picked is None: + continue + fid = _id(picked) + ranked.append(flavor_info(picked, label)) + seen.add(fid) return ranked @@ -187,6 +305,8 @@ def resolve_flavor( explicit: str | None = None, default_id: str | None = None, fallback: bool = True, + size_preset: str = "cheap", + az: str | None = None, ) -> FlavorInfo: if explicit: for flavor in flavors: @@ -198,11 +318,26 @@ def resolve_flavor( if not fallback: if not default_id: raise ValueError("FLAVOR_FALLBACK=false требует DEFAULT_FLAVOR_ID или --flavor") - return resolve_flavor(flavors, preference, explicit=default_id, fallback=True) + return resolve_flavor( + flavors, + preference, + explicit=default_id, + fallback=True, + size_preset=size_preset, + az=az, + ) gpu = [f for f in flavors if looks_like_gpu(f)] - ranked = rank_flavors(gpu or flavors, preference) + ranked = rank_flavors(gpu or flavors, preference, size_preset=size_preset, az=az) if ranked: return ranked[0] if default_id: - return resolve_flavor(flavors, preference, explicit=default_id, fallback=True) - raise ValueError("нет доступного GPU flavor из FLAVOR_PREFERENCE") + return resolve_flavor( + flavors, + preference, + explicit=default_id, + fallback=True, + size_preset=size_preset, + az=az, + ) + where = f" в сегменте {az}" if az else "" + raise ValueError(f"нет доступного GPU flavor из FLAVOR_PREFERENCE{where}") diff --git a/src/gpu_rent/placement.py b/src/gpu_rent/placement.py new file mode 100644 index 0000000..ebbe6c0 --- /dev/null +++ b/src/gpu_rent/placement.py @@ -0,0 +1,76 @@ +"""Pin the VM to an existing disk AZ, or pick the cheapest AZ on first boot.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any + +from gpu_rent.config import Config +from gpu_rent.os_client import BOOT_VOLUME_NAME, DATA_VOLUME_NAME, find_volumes_by_name +from gpu_rent.pools import PoolGpuOffer +from gpu_rent.state import SessionState + + +@dataclass(frozen=True) +class Placement: + cfg: Config + disk_az: str | None + reason: str + + +def _vol_az(volume: Any) -> str | None: + raw = getattr(volume, "availability_zone", None) + if raw: + return str(raw) + return None + + +def existing_disk_az(conn: Any, cfg: Config, state: SessionState) -> str | None: + """AZ of gpu-rent boot/data volume, if any already exist.""" + ids = [ + state.boot_volume_id, + state.data_volume_id, + cfg.boot_volume_id, + cfg.data_volume_id, + ] + for vid in ids: + if not vid: + continue + try: + volume = conn.block_storage.get_volume(vid) + except Exception: + continue + az = _vol_az(volume) + if az: + return az + if conn is not None: + for name in (BOOT_VOLUME_NAME, DATA_VOLUME_NAME): + try: + found = find_volumes_by_name(conn, name) + except Exception: + found = [] + for volume in found: + az = _vol_az(volume) + if az: + return az + if state.availability_zone: + return str(state.availability_zone) + return None + + +def apply_az_policy( + cfg: Config, + *, + disk_az: str | None, + best: PoolGpuOffer | None, +) -> Placement: + """Disk AZ wins. First launch: cheapest matching segment in the current pool.""" + if disk_az: + if disk_az != cfg.gpu_rent_az: + cfg = replace(cfg, gpu_rent_az=disk_az) + return Placement(cfg=cfg, disk_az=disk_az, reason="disk") + rec_az = best.recommended_az if best is not None else None + if best and best.region == cfg.os_region_name and rec_az and rec_az != cfg.gpu_rent_az: + cfg = replace(cfg, gpu_rent_az=rec_az) + return Placement(cfg=cfg, disk_az=None, reason="cheap") + return Placement(cfg=cfg, disk_az=None, reason="cfg") diff --git a/src/gpu_rent/pools.py b/src/gpu_rent/pools.py index ed0a594..68c6a9e 100644 --- a/src/gpu_rent/pools.py +++ b/src/gpu_rent/pools.py @@ -18,6 +18,7 @@ from gpu_rent.config import Config from gpu_rent.errors import CloudError from gpu_rent.inventory import ( FlavorInfo, + flavor_azs, is_disabled, looks_like_gpu, match_label, @@ -30,6 +31,14 @@ DEFAULT_SCAN_POOLS = ("ru-6", "ru-7") COMPUTE_URL = "https://{pool}.cloud.api.selcloud.ru/compute/v2.1" +@dataclass +class AzGpuOffer: + az: str + matched: list[FlavorInfo] = field(default_factory=list) + gpu_labels_found: list[str] = field(default_factory=list) + gpu_types: list[str] = field(default_factory=list) + + @dataclass class PoolGpuOffer: region: str @@ -38,6 +47,9 @@ class PoolGpuOffer: gpu_labels_found: list[str] = field(default_factory=list) gpu_types: list[str] = field(default_factory=list) error: str | None = None + segments: list[AzGpuOffer] = field(default_factory=list) + recommended_az: str | None = None + raw_gpu: list[Any] = field(default_factory=list) def scan_pool_list(cfg: Config) -> tuple[str, ...]: @@ -69,6 +81,7 @@ def _flavor_ns(raw: dict[str, Any], specs: dict[str, Any]) -> SimpleNamespace: is_disabled=disabled, extra_specs=specs or {}, availability_zones=raw.get("availability_zones") or [], + disk=raw.get("disk") or 0, ) @@ -162,10 +175,63 @@ def _labels_present(flavors: list[Any], preference: tuple[str, ...]) -> list[str return found +def _segment_offers( + flavors: list[Any], + region: str, + preference: tuple[str, ...], + size_preset: str, +) -> list[AzGpuOffer]: + by_az: dict[str, list[Any]] = {} + for flavor in flavors: + zones = flavor_azs(flavor) + if not zones: + continue + for az in zones: + by_az.setdefault(az, []).append(flavor) + out: list[AzGpuOffer] = [] + for az in sorted(by_az): + gpu = [f for f in by_az[az] if looks_like_gpu(f)] + pool = gpu or by_az[az] + out.append( + AzGpuOffer( + az=az, + matched=rank_flavors(pool, preference, size_preset=size_preset, az=az), + gpu_labels_found=_labels_present(pool, preference), + gpu_types=_gpu_type_keys(pool), + ) + ) + if not out: + gpu = [f for f in flavors if looks_like_gpu(f)] + pool = gpu or flavors + out.append( + AzGpuOffer( + az=f"{region}a", + matched=rank_flavors(pool, preference, size_preset=size_preset), + gpu_labels_found=_labels_present(pool, preference), + gpu_types=_gpu_type_keys(pool), + ) + ) + return out + + +def _pick_recommended_az(segments: list[AzGpuOffer], preference: tuple[str, ...]) -> str | None: + for label in preference: + for seg in segments: + if label in seg.gpu_labels_found: + return seg.az + for seg in segments: + if seg.matched: + return seg.az + return segments[0].az if segments else None + + def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGpuOffer]: + from gpu_rent.flavor_presets import normalize_size_preset + targets = pools or scan_pool_list(cfg) mz = set(DEFAULT_MULTIZONE_POOLS) token = _auth_token(cfg) + size_preset = normalize_size_preset(getattr(cfg, "flavor_size_preset", None)) out: list[PoolGpuOffer] = [] for region in targets: offer = PoolGpuOffer( @@ -175,18 +241,30 @@ def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGp 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) + pool = gpu or flavors + offer.matched = rank_flavors(pool, cfg.flavor_preference, size_preset=size_preset) + offer.gpu_labels_found = _labels_present(pool, cfg.flavor_preference) + offer.gpu_types = _gpu_type_keys(pool) + offer.raw_gpu = list(pool) + offer.segments = _segment_offers( + pool, region, cfg.flavor_preference, size_preset + ) + offer.recommended_az = _pick_recommended_az(offer.segments, cfg.flavor_preference) 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]: +def format_pool_scan( + offers: list[PoolGpuOffer], + preference: tuple[str, ...], + *, + pin_az: str | None = None, + size_preset: str = "cheap", +) -> list[str]: lines = [ - "[bold cyan]скан пулов[/bold cyan] (мультизональные + кандидаты) × характеристики GPU:", + "[bold cyan]скан пулов[/bold cyan] (сегменты a/b/c из Nova availability_zones):", " [dim](серые кнопки панели ≠ Nova; available_count бывает только у части flavors)[/dim]", ] for offer in offers: @@ -197,26 +275,46 @@ def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) -> 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]" + segs = offer.segments or [ + AzGpuOffer( + az=offer.recommended_az or f"{offer.region}a", + matched=offer.matched, + gpu_labels_found=offer.gpu_labels_found, + gpu_types=offer.gpu_types, ) + ] + for seg in segs: + pin = " [green]← диск[/green]" if pin_az and seg.az == pin_az else "" + if seg.gpu_labels_found: + best = seg.matched[0] if seg.matched else None + best_s = ( + f" → {best.name} ({best.label}, {size_preset})" if best else "" + ) + lines.append( + f" [bold]{seg.az}[/bold]{pin}: " + f"{', '.join(seg.gpu_labels_found)}{best_s}" + ) + else: + lines.append( + f" [bold]{seg.az}[/bold]{pin}: " + f"[yellow]нет {','.join(preference)}[/yellow]" + + (f" типы: {', '.join(seg.gpu_types)}" if seg.gpu_types else "") + ) + rec = None 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]" - ) + rec = offer break + if rec: + az = pin_az or rec.recommended_az or f"{rec.region}a" + if pin_az: + why = f"диск уже в {pin_az} — VM только здесь" + else: + why = f"первый запуск, пресет {size_preset}; диск и VM в одном AZ" + lines.append( + f"[green]рекомендация:[/green] OS_REGION_NAME={rec.region} " + f"GPU_RENT_AZ={az} [dim]({why})[/dim]" + ) return lines diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index 0cfbe37..2bcca70 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -41,6 +41,7 @@ from gpu_rent.inventory import ( resolve_flavor, ) from gpu_rent.ux import print_up_preview, prompt_server_plan +from gpu_rent.placement import apply_az_policy, existing_disk_az from gpu_rent.pools import best_offer, format_pool_scan, scan_pools from gpu_rent.lock import SessionLock from gpu_rent.os_client import ( @@ -458,7 +459,46 @@ def cmd_up( state.phase = "idle" save_state(state) - flavors = list(iter_flavors(conn)) + disk_az = existing_disk_az(conn, cfg, state) + offers: list = [] + best = None + try: + offers = scan_pools(cfg) + for line in format_pool_scan( + offers, + cfg.flavor_preference, + pin_az=disk_az, + size_preset=cfg.flavor_size_preset, + ): + log(line) + best = best_offer(offers) + if disk_az: + log(f"диск уже в {disk_az} — сервер только в этом сегменте") + elif best and best.region != cfg.os_region_name: + rec_az = best.recommended_az or f"{best.region}a" + log( + f"! сейчас OS_REGION_NAME={cfg.os_region_name}, " + f"а предпочтение лучше закрывается в {best.region} — " + f"поставь OS_REGION_NAME={best.region} и GPU_RENT_AZ={rec_az} " + f"в .env (диски ещё не созданы) и повтори up" + ) + except Exception as exc: + log(f"скан пулов: {exc}") + + placed = apply_az_policy(cfg, disk_az=disk_az, best=best) + cfg = placed.cfg + if placed.reason == "cheap" and not disk_az: + log( + f"первый запуск: дешёвый тариф в {cfg.gpu_rent_az} " + f"(пресет {cfg.flavor_size_preset})" + ) + + current = next( + (item for item in offers if item.region == cfg.os_region_name and not item.error), + None, + ) + flavors = list(current.raw_gpu) if current and current.raw_gpu else list(iter_flavors(conn)) + try: picked = resolve_flavor( flavors, @@ -466,6 +506,8 @@ def cmd_up( explicit=flavor, default_id=cfg.default_flavor_id or None, fallback=cfg.flavor_fallback, + size_preset=cfg.flavor_size_preset, + az=cfg.gpu_rent_az, ) except ValueError as exc: raise CloudError(str(exc)) from exc @@ -476,21 +518,6 @@ 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) if not yes and ask is not None and flavor is None: diff --git a/src/gpu_rent/ux.py b/src/gpu_rent/ux.py index 29fce95..022c3a3 100644 --- a/src/gpu_rent/ux.py +++ b/src/gpu_rent/ux.py @@ -9,6 +9,11 @@ from typing import Any from rich.markup import escape from gpu_rent.config import Config +from gpu_rent.flavor_presets import ( + format_char_impact, + normalize_size_preset, + size_preset_menu, +) from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor Log = Callable[[str], None] @@ -16,15 +21,26 @@ Ask = Callable[[str, str], str] Confirm = Callable[[str], bool] -def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]: +def list_ranked_flavors( + flavors: list[Any], + cfg: Config, + *, + size_preset: str | None = None, +) -> list[FlavorInfo]: gpu = [f for f in flavors if looks_like_gpu(f)] - return rank_flavors(gpu or flavors, cfg.flavor_preference) + preset = normalize_size_preset(size_preset or cfg.flavor_size_preset) + return rank_flavors( + gpu or flavors, + cfg.flavor_preference, + size_preset=preset, + az=cfg.gpu_rent_az, + ) def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = None) -> list[str]: if not ranked: return ["[yellow]flavors: пусто (проверь регион / FLAVOR_PREFERENCE)[/yellow]"] - lines = ["[bold cyan]flavors (по FLAVOR_PREFERENCE):[/bold cyan]"] + lines = ["[bold cyan]flavors (пресет размера × FLAVOR_PREFERENCE):[/bold cyan]"] for i, info in enumerate(ranked, 1): mark = " [green]← выберем[/green]" if picked and info.id == picked.id else "" ram = f"{info.ram_mb // 1024}GB" if info.ram_mb else "?" @@ -32,9 +48,12 @@ def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = No label = escape(info.label or "—") name = escape(info.name) fid = escape(info.id) + gpus = f"{info.gpu_count}×" if info.gpu_count else "" + az = ",".join(info.azs) if info.azs else "" + az_s = f" az={az}" if az else "" lines.append( f" [cyan]{i}.[/cyan] [bold]{label}[/bold] {name} " - f"vCPU={vcpu} RAM={ram} id={fid}{mark}" + f"{gpus}vCPU={vcpu} RAM={ram} id={fid}{az_s}{mark}" ) return lines @@ -62,8 +81,11 @@ def print_up_preview( picked: FlavorInfo, spot: bool, log: Log, + size_preset: str | None = None, ) -> None: - ranked = list_ranked_flavors(flavors, cfg) + for line in format_char_impact(): + log(line) + ranked = list_ranked_flavors(flavors, cfg, size_preset=size_preset) for line in format_flavor_lines(ranked, picked): log(line) log("") @@ -92,11 +114,26 @@ def prompt_server_plan( if not ranked: return ServerPlan(flavor=picked, data_gb=cfg.data_volume_size_gb, spot=spot) + from gpu_rent.prompts import MenuItem, prompt_menu + + preset = prompt_menu( + "размер flavor", + size_preset_menu(), + default=normalize_size_preset(cfg.flavor_size_preset), + ask=ask, + ) + ranked = list_ranked_flavors(flavors, cfg, size_preset=preset) + if not ranked: + ranked = [picked] + default_idx = 1 for i, info in enumerate(ranked, 1): if info.id == picked.id: default_idx = i break + if preset != cfg.flavor_size_preset and ranked: + default_idx = 1 + picked = ranked[0] raw_idx = ask( f"Flavor Selectel [1-{len(ranked)}] (Enter = рекомендация)", @@ -121,8 +158,6 @@ def prompt_server_plan( if data_gb < 20: raise ValueError("Data disk GB: минимум 20") - from gpu_rent.prompts import MenuItem, prompt_menu - spot_key = prompt_menu( "Тариф GPU", [ @@ -138,6 +173,7 @@ def prompt_server_plan( chosen.id != picked.id or data_gb != cfg.data_volume_size_gb or use_spot != spot + or preset != cfg.flavor_size_preset ) if confirm and changed and confirm("Запомнить flavor/disk/spot в gpu-rent.vars?"): from gpu_rent.paths import vars_path @@ -149,6 +185,7 @@ def prompt_server_plan( "DEFAULT_FLAVOR_ID": chosen.id, "DATA_VOLUME_SIZE_GB": str(data_gb), "DEFAULT_SPOT": "true" if use_spot else "false", + "FLAVOR_SIZE_PRESET": preset, }, ) @@ -169,6 +206,8 @@ def resolve_and_preview( explicit=explicit, default_id=cfg.default_flavor_id or None, fallback=cfg.flavor_fallback, + size_preset=cfg.flavor_size_preset, + az=cfg.gpu_rent_az, ) print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log) return picked diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 6567880..dc2f62c 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -9,13 +9,25 @@ from gpu_rent.inventory import ( class FakeFlavor: - def __init__(self, flavor_id: str, name: str, extra=None, disabled: bool = False): + def __init__( + self, + flavor_id: str, + name: str, + extra=None, + disabled: bool = False, + vcpus: int = 8, + ram: int = 32768, + disk: int = 0, + availability_zones=None, + ): self.id = flavor_id self.name = name self.extra_specs = extra or {} self.is_disabled = disabled - self.vcpus = 8 - self.ram = 32768 + self.vcpus = vcpus + self.ram = ram + self.disk = disk + self.availability_zones = list(availability_zones or []) def test_match_4090_24_not_48(): @@ -74,6 +86,46 @@ def test_resolve_flavor_no_fallback_requires_default(): assert picked.id == "ok" +def test_rank_cheap_prefers_1gpu_min_sku(): + extra = {"aggregate_instance_extra_specs:gpu": "RTX4090", "gpu_memory": "24"} + flavors = [ + FakeFlavor("dual", "GL10.8-65536-0-2GPU", extra, vcpus=8, ram=65536), + FakeFlavor("fat", "GL10.8-32768-0-1GPU", extra, vcpus=8, ram=32768), + FakeFlavor("tiny", "GL10.4-16384-0-1GPU", extra, vcpus=4, ram=16384), + ] + ranked = rank_flavors(flavors, ("4090-24",), size_preset="cheap") + assert ranked[0].id == "tiny" + assert ranked[0].gpu_count == 1 + balanced = rank_flavors(flavors, ("4090-24",), size_preset="balanced") + assert balanced[0].id == "fat" + + +def test_rank_filters_availability_zone(): + extra = {"aggregate_instance_extra_specs:gpu": "RTX4090", "gpu_memory": "24"} + flavors = [ + FakeFlavor( + "a", + "GL10.4-16384-0-1GPU", + extra, + vcpus=4, + ram=16384, + availability_zones=["ru-6a"], + ), + FakeFlavor( + "h200", + "GL-H200", + {"aggregate_instance_extra_specs:gpu": "H200"}, + vcpus=12, + ram=120000, + availability_zones=["ru-6b"], + ), + ] + in_a = rank_flavors(flavors, ("4090-24", "a5000"), az="ru-6a") + assert [x.id for x in in_a] == ["a"] + in_b = rank_flavors(flavors, ("4090-24",), az="ru-6b") + assert in_b == [] + + def test_pick_boot_image_prefers_24_580_without_docker(): class Img: def __init__(self, name): diff --git a/tests/test_placement.py b/tests/test_placement.py new file mode 100644 index 0000000..8718be8 --- /dev/null +++ b/tests/test_placement.py @@ -0,0 +1,58 @@ +from gpu_rent.config import load_config +from gpu_rent.flavor_presets import format_char_impact, normalize_size_preset +from gpu_rent.placement import apply_az_policy, existing_disk_az +from gpu_rent.pools import PoolGpuOffer +from gpu_rent.state import SessionState + + +def test_normalize_size_preset(): + assert normalize_size_preset("RAM") == "ram" + assert normalize_size_preset("nope") == "cheap" + assert "GPU" in "\n".join(format_char_impact()) + + +def test_apply_az_policy_disk_wins(monkeypatch): + monkeypatch.setenv("OS_AUTH_URL", "https://example.invalid/identity/v3") + monkeypatch.setenv("OS_USER_DOMAIN_NAME", "1") + monkeypatch.setenv("OS_USERNAME", "u") + monkeypatch.setenv("OS_PASSWORD", "p") + monkeypatch.setenv("OS_PROJECT_ID", "proj") + monkeypatch.setenv("OS_REGION_NAME", "ru-6") + monkeypatch.setenv("GPU_RENT_AZ", "ru-6b") + cfg = load_config(require_auth=True) + best = PoolGpuOffer(region="ru-6", multizone=True, recommended_az="ru-6a") + placed = apply_az_policy(cfg, disk_az="ru-6a", best=best) + assert placed.reason == "disk" + assert placed.cfg.gpu_rent_az == "ru-6a" + + +def test_apply_az_policy_first_boot_cheap(monkeypatch): + monkeypatch.setenv("OS_AUTH_URL", "https://example.invalid/identity/v3") + monkeypatch.setenv("OS_USER_DOMAIN_NAME", "1") + monkeypatch.setenv("OS_USERNAME", "u") + monkeypatch.setenv("OS_PASSWORD", "p") + monkeypatch.setenv("OS_PROJECT_ID", "proj") + monkeypatch.setenv("OS_REGION_NAME", "ru-6") + monkeypatch.setenv("GPU_RENT_AZ", "ru-6b") + cfg = load_config(require_auth=True) + best = PoolGpuOffer(region="ru-6", multizone=True, recommended_az="ru-6a") + placed = apply_az_policy(cfg, disk_az=None, best=best) + assert placed.reason == "cheap" + assert placed.cfg.gpu_rent_az == "ru-6a" + + +def test_existing_disk_az_from_volume(): + class Vol: + availability_zone = "ru-6a" + + class Storage: + def get_volume(self, vid): + assert vid == "boot-1" + return Vol() + + class Conn: + block_storage = Storage() + + cfg = load_config(require_auth=False) + state = SessionState(boot_volume_id="boot-1") + assert existing_disk_az(Conn(), cfg, state) == "ru-6a" diff --git a/tests/test_pools.py b/tests/test_pools.py index e061050..353edc1 100644 --- a/tests/test_pools.py +++ b/tests/test_pools.py @@ -1,4 +1,4 @@ -from gpu_rent.pools import PoolGpuOffer, format_pool_scan +from gpu_rent.pools import AzGpuOffer, PoolGpuOffer, format_pool_scan def test_format_pool_scan_recommends_first_hit(): @@ -14,9 +14,41 @@ def test_format_pool_scan_recommends_first_hit(): matched=[ FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24"), ], + recommended_az="ru-6a", + segments=[ + AzGpuOffer( + az="ru-6a", + gpu_labels_found=["4090-24"], + gpu_types=["RTX4090"], + matched=[FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24")], + ), + AzGpuOffer(az="ru-6b", gpu_labels_found=[], gpu_types=["H200"], matched=[]), + AzGpuOffer(az="ru-6c", gpu_labels_found=[], gpu_types=["H200"], matched=[]), + ], ), ] text = "\n".join(format_pool_scan(offers, ("4090-24", "4090-48"))) assert "ru-6" in text and "multizone" in text assert "типы:" in text + assert "ru-6a" in text and "ru-6b" in text and "ru-6c" in text assert "рекомендация:" in text and "OS_REGION_NAME=ru-6" in text + assert "GPU_RENT_AZ=ru-6a" in text + + +def test_format_pool_scan_pins_disk_az(): + from gpu_rent.inventory import FlavorInfo + + offers = [ + PoolGpuOffer( + region="ru-6", + multizone=True, + gpu_labels_found=["4090-24"], + gpu_types=["RTX4090"], + matched=[FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24")], + recommended_az="ru-6a", + ) + ] + text = "\n".join( + format_pool_scan(offers, ("4090-24",), pin_az="ru-6a", size_preset="cheap") + ) + assert "диск уже в ru-6a" in text diff --git a/tests/test_server_plan.py b/tests/test_server_plan.py index 9c3bb4b..e4e1dec 100644 --- a/tests/test_server_plan.py +++ b/tests/test_server_plan.py @@ -29,9 +29,9 @@ def test_prompt_server_plan_with_ranked(monkeypatch, tmp_path): monkeypatch.setattr( "gpu_rent.ux.list_ranked_flavors", - lambda flavors, cfg: [f1, f2], + lambda flavors, cfg, **kw: [f1, f2], ) - answers = iter(["2", "200", "2"]) + answers = iter(["1", "2", "200", "2"]) def ask(msg: str, default: str = "") -> str: return next(answers)