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 <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 00:06:44 +03:00
co-authored by Cursor
parent 5d0c30f66b
commit 789fa26918
19 changed files with 666 additions and 71 deletions
+2 -1
View File
@@ -53,7 +53,7 @@ gpu-rent up --yes --ollama
| --- | --- | | --- | --- |
| `gpu-rent setup` | Wizard: манифесты (в т.ч. ollama-models), `LLM_RUNTIME` (нумерованное меню), пресет, опционально local-watchdog | | `gpu-rent setup` | Wizard: манифесты (в т.ч. ollama-models), `LLM_RUNTIME` (нумерованное меню), пресет, опционально local-watchdog |
| `gpu-rent doctor` | Preflight **без** create. Exit ≠ 0 → сессию начинать нельзя | | `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 dry-run` | План без mutating-вызовов |
| `gpu-rent up` | Без `--yes`: меню LLM (если runtime none) → пресет → **Selectel**: flavor # / data GB / preemptible → confirm → create → туннель. Цветной лог (ошибки/warn/ok) | | `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` | | `gpu-rent up --yes` | Без вопросов; flavor из `FLAVOR_PREFERENCE` / `DEFAULT_FLAVOR_ID` |
@@ -198,6 +198,7 @@ UPDATE_GIT=true
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
FLAVOR_SIZE_PRESET=cheap
SCAN_POOLS=ru-6,ru-7 SCAN_POOLS=ru-6,ru-7
DEFAULT_SPOT=true DEFAULT_SPOT=true
KEEP_FLOATING_IP=false KEEP_FLOATING_IP=false
+1 -1
View File
@@ -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` | | Сессия | 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 отдельно. С сервера не удаляем | | Модели | Пустой `./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` | | Пул 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` автоматом не пишем |
| Манифест моделей | `<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. Поле `requires: none\|ollama\|any-llm` фильтрует по `LLM_RUNTIME`. Пример: swarm-assistent с `requires: ollama` | | Расширения | `<repo>/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) | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) |
+4 -2
View File
@@ -63,8 +63,10 @@ Custom GPU flavor создаётся только через API, не чере
Поведение: Поведение:
- Предпочтительный доступен — берём его, печатаем ₽/час. - Предпочтительный доступен — берём самый дешёвый SKU этого типа (`FLAVOR_SIZE_PRESET=cheap`: 1×GPU, min vCPU/RAM, без локального диска). Печатаем ₽/час из панели (в API нет).
- Нет — показать следующий доступный из списка с ценой и отличием (VRAM / ₽). Интерактив: подтвердить. `--yes` — взять первый доступный из списка без вопроса, цену всё равно напечатать. - Диски уже есть — сегмент зафиксирован (диск из `ru-6a` нельзя прицепить к VM в `ru-6b`). Flavor только из этого AZ.
- Первый запуск (дисков нет) — сегмент, где есть карта из `FLAVOR_PREFERENCE` (4090 ≈ `ru-6a` / `ru-7b`, не `ru-6b/c`).
- Нет — показать следующий доступный из списка с отличием (VRAM / размер). Интерактив: пресет размера + номер flavor. `--yes` — первый доступный из списка × пресет, без вопроса.
- Ни один из списка не доступен — стоп с текстом «нет GPU в сегменте», не молчаливый CPU-flavor. - Ни один из списка не доступен — стоп с текстом «нет GPU в сегменте», не молчаливый CPU-flavor.
- Явный `--flavor <id>` бьёт список: нет этого id → ошибка, без автофоллбека. - Явный `--flavor <id>` бьёт список: нет этого id → ошибка, без автофоллбека.
+2
View File
@@ -57,6 +57,8 @@ UPDATE_GIT=true
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
# 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. # Pools to probe before pick (ru-6 = Selectel multi-zone). Comma-separated.
SCAN_POOLS=ru-6,ru-7 SCAN_POOLS=ru-6,ru-7
DEFAULT_SPOT=true DEFAULT_SPOT=true
+1
View File
@@ -16,6 +16,7 @@
# DEFAULT_SPOT=true # DEFAULT_SPOT=true
# SCAN_POOLS=ru-6,ru-7 # SCAN_POOLS=ru-6,ru-7
# FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40 # 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 # SWARMUI_LOCAL_PORT=17801
# DATA_VOLUME_SIZE_GB=100 # DATA_VOLUME_SIZE_GB=100
# ENABLE_SWARMUI=true # ENABLE_SWARMUI=true
+1
View File
@@ -67,6 +67,7 @@ _VARS_PREFERRED = {
"DEFAULT_SPOT", "DEFAULT_SPOT",
"SCAN_POOLS", "SCAN_POOLS",
"FLAVOR_PREFERENCE", "FLAVOR_PREFERENCE",
"FLAVOR_SIZE_PRESET",
"SWARMUI_LOCAL_PORT", "SWARMUI_LOCAL_PORT",
} }
+23 -3
View File
@@ -183,7 +183,12 @@ def dry_run() -> None:
conn = connect(cfg) conn = connect(cfg)
flavors = list(iter_flavors(conn)) flavors = list(iter_flavors(conn))
gpu = [f for f in flavors if looks_like_gpu(f)] 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]") console.print("\n[bold]Flavors[/bold]")
for line in format_flavor_lines(ranked): for line in format_flavor_lines(ranked):
console.print(f" {line}") console.print(f" {line}")
@@ -201,6 +206,7 @@ def flavors(
) -> None: ) -> None:
"""GPU flavors: скан пулов + список в текущем OS_REGION_NAME.""" """GPU flavors: скан пулов + список в текущем OS_REGION_NAME."""
try: 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.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.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]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'}") console.print(f"[bold]SCAN_POOLS[/bold]: {cfg.scan_pools or 'ru-6,ru-7'}")
offers = scan_pools(cfg) 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(line)
console.print("") 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)]
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: try:
picked = resolve_flavor( picked = resolve_flavor(
all_f, all_f,
cfg.flavor_preference, cfg.flavor_preference,
default_id=cfg.default_flavor_id or None, default_id=cfg.default_flavor_id or None,
fallback=cfg.flavor_fallback, fallback=cfg.flavor_fallback,
size_preset=cfg.flavor_size_preset,
az=cfg.gpu_rent_az,
) )
except ValueError: except ValueError:
picked = None picked = None
+3
View File
@@ -22,6 +22,7 @@ from gpu_rent.paths import (
) )
from gpu_rent.varsfile import apply_vars_file from gpu_rent.varsfile import apply_vars_file
from gpu_rent.llm_runtime import normalize_runtime 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: def _as_bool(value: str | None, default: bool) -> bool:
@@ -111,6 +112,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
flavor_size_preset: str
scan_pools: str scan_pools: str
default_spot: bool default_spot: bool
keep_floating_ip: bool keep_floating_ip: bool
@@ -244,6 +246,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),
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(), 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),
+6 -1
View File
@@ -125,7 +125,12 @@ def run_doctor() -> list[Check]:
flavors = list(iter_flavors(conn)) flavors = list(iter_flavors(conn))
gpu_flavors = [f for f in flavors if looks_like_gpu(f)] 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: if cfg.default_flavor_id:
hit = next((f for f in flavors if getattr(f, "id", None) == cfg.default_flavor_id), None) hit = next((f for f in flavors if getattr(f, "id", None) == cfg.default_flavor_id), None)
if hit: if hit:
+43
View File
@@ -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
+146 -11
View File
@@ -2,9 +2,13 @@
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any 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 @dataclass
class FlavorInfo: class FlavorInfo:
@@ -15,6 +19,10 @@ class FlavorInfo:
disabled: bool disabled: bool
extra: dict[str, Any] extra: dict[str, Any]
label: str | None = None 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: 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 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: def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo:
ram = getattr(flavor, "ram", None) ram = getattr(flavor, "ram", None)
vcpus = getattr(flavor, "vcpus", None) vcpus = getattr(flavor, "vcpus", None)
count = available_count(flavor)
return FlavorInfo( return FlavorInfo(
id=_id(flavor), id=_id(flavor),
name=_name(flavor) or _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), disabled=is_disabled(flavor),
extra=extra_specs(flavor), extra=extra_specs(flavor),
label=label, 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] = [] ranked: list[FlavorInfo] = []
seen: set[str] = set() seen: set[str] = set()
for label in preference: for label in preference:
for flavor in flavors: candidates = [
fid = _id(flavor) flavor
if fid in seen or is_disabled(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 continue
if match_label(label, flavor): fid = _id(picked)
ranked.append(flavor_info(flavor, label)) ranked.append(flavor_info(picked, label))
seen.add(fid) seen.add(fid)
break
return ranked return ranked
@@ -187,6 +305,8 @@ def resolve_flavor(
explicit: str | None = None, explicit: str | None = None,
default_id: str | None = None, default_id: str | None = None,
fallback: bool = True, fallback: bool = True,
size_preset: str = "cheap",
az: str | None = None,
) -> FlavorInfo: ) -> FlavorInfo:
if explicit: if explicit:
for flavor in flavors: for flavor in flavors:
@@ -198,11 +318,26 @@ def resolve_flavor(
if not fallback: if not fallback:
if not default_id: if not default_id:
raise ValueError("FLAVOR_FALLBACK=false требует DEFAULT_FLAVOR_ID или --flavor") 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)] 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: if ranked:
return ranked[0] return ranked[0]
if default_id: if default_id:
return resolve_flavor(flavors, preference, explicit=default_id, fallback=True) return resolve_flavor(
raise ValueError("нет доступного GPU flavor из FLAVOR_PREFERENCE") 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}")
+76
View File
@@ -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")
+113 -15
View File
@@ -18,6 +18,7 @@ from gpu_rent.config import Config
from gpu_rent.errors import CloudError from gpu_rent.errors import CloudError
from gpu_rent.inventory import ( from gpu_rent.inventory import (
FlavorInfo, FlavorInfo,
flavor_azs,
is_disabled, is_disabled,
looks_like_gpu, looks_like_gpu,
match_label, match_label,
@@ -30,6 +31,14 @@ DEFAULT_SCAN_POOLS = ("ru-6", "ru-7")
COMPUTE_URL = "https://{pool}.cloud.api.selcloud.ru/compute/v2.1" 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 @dataclass
class PoolGpuOffer: class PoolGpuOffer:
region: str region: str
@@ -38,6 +47,9 @@ class PoolGpuOffer:
gpu_labels_found: list[str] = field(default_factory=list) gpu_labels_found: list[str] = field(default_factory=list)
gpu_types: list[str] = field(default_factory=list) gpu_types: list[str] = field(default_factory=list)
error: str | None = None 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, ...]: 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, is_disabled=disabled,
extra_specs=specs or {}, extra_specs=specs or {},
availability_zones=raw.get("availability_zones") 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 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]: 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) targets = pools or scan_pool_list(cfg)
mz = set(DEFAULT_MULTIZONE_POOLS) mz = set(DEFAULT_MULTIZONE_POOLS)
token = _auth_token(cfg) token = _auth_token(cfg)
size_preset = normalize_size_preset(getattr(cfg, "flavor_size_preset", None))
out: list[PoolGpuOffer] = [] out: list[PoolGpuOffer] = []
for region in targets: for region in targets:
offer = PoolGpuOffer( offer = PoolGpuOffer(
@@ -175,18 +241,30 @@ def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGp
try: try:
flavors = list_flavors_in_pool(region, token) flavors = list_flavors_in_pool(region, token)
gpu = [f for f in flavors if looks_like_gpu(f)] gpu = [f for f in flavors if looks_like_gpu(f)]
offer.matched = rank_flavors(gpu or flavors, cfg.flavor_preference) pool = gpu or flavors
offer.gpu_labels_found = _labels_present(gpu or flavors, cfg.flavor_preference) offer.matched = rank_flavors(pool, cfg.flavor_preference, size_preset=size_preset)
offer.gpu_types = _gpu_type_keys(gpu or flavors) 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: except Exception as exc:
offer.error = str(exc) offer.error = str(exc)
out.append(offer) out.append(offer)
return out 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 = [ lines = [
"[bold cyan]скан пулов[/bold cyan] (мультизональные + кандидаты) × характеристики GPU:", "[bold cyan]скан пулов[/bold cyan] (сегменты a/b/c из Nova availability_zones):",
" [dim](серые кнопки панели ≠ Nova; available_count бывает только у части flavors)[/dim]", " [dim](серые кнопки панели ≠ Nova; available_count бывает только у части flavors)[/dim]",
] ]
for offer in offers: for offer in offers:
@@ -197,26 +275,46 @@ def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) ->
continue continue
types_s = ", ".join(offer.gpu_types) if offer.gpu_types else "" types_s = ", ".join(offer.gpu_types) if offer.gpu_types else ""
lines.append(f"{prefix} типы: {types_s}") lines.append(f"{prefix} типы: {types_s}")
if offer.gpu_labels_found: segs = offer.segments or [
best = offer.matched[0] if offer.matched else None 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 = ( best_s = (
f" [green]лучший {best.name} ({best.label})[/green]" if best else "" f"{best.name} ({best.label}, {size_preset})" if best else ""
) )
lines.append( lines.append(
f" preference: {', '.join(offer.gpu_labels_found)}{best_s}" f" [bold]{seg.az}[/bold]{pin}: "
f"{', '.join(seg.gpu_labels_found)}{best_s}"
) )
else: else:
lines.append( lines.append(
f" [yellow]preference: нет совпадений с {','.join(preference)}[/yellow]" 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: for offer in offers:
if offer.matched and not offer.error: if offer.matched and not offer.error:
lines.append( rec = offer
f"[green]рекомендация:[/green] OS_REGION_NAME={offer.region} "
f"GPU_RENT_AZ={offer.region}a "
f"[dim](сегмент a/b/c — в панели; диски и VM в одном AZ)[/dim]"
)
break 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 return lines
+43 -16
View File
@@ -41,6 +41,7 @@ from gpu_rent.inventory import (
resolve_flavor, resolve_flavor,
) )
from gpu_rent.ux import print_up_preview, prompt_server_plan 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.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 (
@@ -458,7 +459,46 @@ def cmd_up(
state.phase = "idle" state.phase = "idle"
save_state(state) 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: try:
picked = resolve_flavor( picked = resolve_flavor(
flavors, flavors,
@@ -466,6 +506,8 @@ def cmd_up(
explicit=flavor, explicit=flavor,
default_id=cfg.default_flavor_id or None, default_id=cfg.default_flavor_id or None,
fallback=cfg.flavor_fallback, fallback=cfg.flavor_fallback,
size_preset=cfg.flavor_size_preset,
az=cfg.gpu_rent_az,
) )
except ValueError as exc: except ValueError as exc:
raise CloudError(str(exc)) from 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) 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)
if not yes and ask is not None and flavor is None: if not yes and ask is not None and flavor is None:
+46 -7
View File
@@ -9,6 +9,11 @@ from typing import Any
from rich.markup import escape from rich.markup import escape
from gpu_rent.config import Config 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 from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
Log = Callable[[str], None] Log = Callable[[str], None]
@@ -16,15 +21,26 @@ Ask = Callable[[str, str], str]
Confirm = Callable[[str], bool] 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)] 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]: def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = None) -> list[str]:
if not ranked: if not ranked:
return ["[yellow]flavors: пусто (проверь регион / FLAVOR_PREFERENCE)[/yellow]"] 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): for i, info in enumerate(ranked, 1):
mark = " [green]← выберем[/green]" if picked and info.id == picked.id else "" mark = " [green]← выберем[/green]" if picked and info.id == picked.id else ""
ram = f"{info.ram_mb // 1024}GB" if info.ram_mb 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 "") label = escape(info.label or "")
name = escape(info.name) name = escape(info.name)
fid = escape(info.id) 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( lines.append(
f" [cyan]{i}.[/cyan] [bold]{label}[/bold] {name} " 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 return lines
@@ -62,8 +81,11 @@ def print_up_preview(
picked: FlavorInfo, picked: FlavorInfo,
spot: bool, spot: bool,
log: Log, log: Log,
size_preset: str | None = 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): for line in format_flavor_lines(ranked, picked):
log(line) log(line)
log("") log("")
@@ -92,11 +114,26 @@ def prompt_server_plan(
if not ranked: if not ranked:
return ServerPlan(flavor=picked, data_gb=cfg.data_volume_size_gb, spot=spot) 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 default_idx = 1
for i, info in enumerate(ranked, 1): for i, info in enumerate(ranked, 1):
if info.id == picked.id: if info.id == picked.id:
default_idx = i default_idx = i
break break
if preset != cfg.flavor_size_preset and ranked:
default_idx = 1
picked = ranked[0]
raw_idx = ask( raw_idx = ask(
f"Flavor Selectel [1-{len(ranked)}] (Enter = рекомендация)", f"Flavor Selectel [1-{len(ranked)}] (Enter = рекомендация)",
@@ -121,8 +158,6 @@ def prompt_server_plan(
if data_gb < 20: if data_gb < 20:
raise ValueError("Data disk GB: минимум 20") raise ValueError("Data disk GB: минимум 20")
from gpu_rent.prompts import MenuItem, prompt_menu
spot_key = prompt_menu( spot_key = prompt_menu(
"Тариф GPU", "Тариф GPU",
[ [
@@ -138,6 +173,7 @@ def prompt_server_plan(
chosen.id != picked.id chosen.id != picked.id
or data_gb != cfg.data_volume_size_gb or data_gb != cfg.data_volume_size_gb
or use_spot != spot or use_spot != spot
or preset != cfg.flavor_size_preset
) )
if confirm and changed and confirm("Запомнить flavor/disk/spot в gpu-rent.vars?"): if confirm and changed and confirm("Запомнить flavor/disk/spot в gpu-rent.vars?"):
from gpu_rent.paths import vars_path from gpu_rent.paths import vars_path
@@ -149,6 +185,7 @@ def prompt_server_plan(
"DEFAULT_FLAVOR_ID": chosen.id, "DEFAULT_FLAVOR_ID": chosen.id,
"DATA_VOLUME_SIZE_GB": str(data_gb), "DATA_VOLUME_SIZE_GB": str(data_gb),
"DEFAULT_SPOT": "true" if use_spot else "false", "DEFAULT_SPOT": "true" if use_spot else "false",
"FLAVOR_SIZE_PRESET": preset,
}, },
) )
@@ -169,6 +206,8 @@ def resolve_and_preview(
explicit=explicit, explicit=explicit,
default_id=cfg.default_flavor_id or None, default_id=cfg.default_flavor_id or None,
fallback=cfg.flavor_fallback, 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) print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
return picked return picked
+55 -3
View File
@@ -9,13 +9,25 @@ from gpu_rent.inventory import (
class FakeFlavor: 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.id = flavor_id
self.name = name self.name = name
self.extra_specs = extra or {} self.extra_specs = extra or {}
self.is_disabled = disabled self.is_disabled = disabled
self.vcpus = 8 self.vcpus = vcpus
self.ram = 32768 self.ram = ram
self.disk = disk
self.availability_zones = list(availability_zones or [])
def test_match_4090_24_not_48(): def test_match_4090_24_not_48():
@@ -74,6 +86,46 @@ def test_resolve_flavor_no_fallback_requires_default():
assert picked.id == "ok" 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(): def test_pick_boot_image_prefers_24_580_without_docker():
class Img: class Img:
def __init__(self, name): def __init__(self, name):
+58
View File
@@ -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"
+33 -1
View File
@@ -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(): def test_format_pool_scan_recommends_first_hit():
@@ -14,9 +14,41 @@ def test_format_pool_scan_recommends_first_hit():
matched=[ matched=[
FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24"), 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"))) text = "\n".join(format_pool_scan(offers, ("4090-24", "4090-48")))
assert "ru-6" in text and "multizone" in text assert "ru-6" in text and "multizone" in text
assert "типы:" 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 "рекомендация:" 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
+2 -2
View File
@@ -29,9 +29,9 @@ def test_prompt_server_plan_with_ranked(monkeypatch, tmp_path):
monkeypatch.setattr( monkeypatch.setattr(
"gpu_rent.ux.list_ranked_flavors", "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: def ask(msg: str, default: str = "") -> str:
return next(answers) return next(answers)