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:
+2
-1
@@ -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
@@ -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
@@ -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 → ошибка, без автофоллбека.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
+148
-13
@@ -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
|
||||||
continue
|
if _id(flavor) not in seen
|
||||||
if match_label(label, flavor):
|
and not is_disabled(flavor)
|
||||||
ranked.append(flavor_info(flavor, label))
|
and flavor_in_az(flavor, az)
|
||||||
seen.add(fid)
|
and match_label(label, flavor)
|
||||||
break
|
]
|
||||||
|
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
|
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}")
|
||||||
|
|||||||
@@ -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")
|
||||||
+119
-21
@@ -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(
|
||||||
best_s = (
|
az=offer.recommended_az or f"{offer.region}a",
|
||||||
f" → [green]лучший {best.name} ({best.label})[/green]" if best else ""
|
matched=offer.matched,
|
||||||
)
|
gpu_labels_found=offer.gpu_labels_found,
|
||||||
lines.append(
|
gpu_types=offer.gpu_types,
|
||||||
f" preference: {', '.join(offer.gpu_labels_found)}{best_s}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
lines.append(
|
|
||||||
f" [yellow]preference: нет совпадений с {','.join(preference)}[/yellow]"
|
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
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:
|
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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user