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 doctor` | Preflight **без** create. Exit ≠ 0 → сессию начинать нельзя |
|
||||
| `gpu-rent flavors` | Скан `SCAN_POOLS` × `FLAVOR_PREFERENCE`, список в текущем регионе |
|
||||
| `gpu-rent flavors` | Скан `SCAN_POOLS` по сегментам a/b/c × `FLAVOR_PREFERENCE` × `FLAVOR_SIZE_PRESET`, список в текущем регионе |
|
||||
| `gpu-rent dry-run` | План без mutating-вызовов |
|
||||
| `gpu-rent up` | Без `--yes`: меню LLM (если runtime none) → пресет → **Selectel**: flavor # / data GB / preemptible → confirm → create → туннель. Цветной лог (ошибки/warn/ok) |
|
||||
| `gpu-rent up --yes` | Без вопросов; flavor из `FLAVOR_PREFERENCE` / `DEFAULT_FLAVOR_ID` |
|
||||
@@ -198,6 +198,7 @@ UPDATE_GIT=true
|
||||
DEFAULT_FLAVOR_ID=
|
||||
FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
|
||||
FLAVOR_FALLBACK=true
|
||||
FLAVOR_SIZE_PRESET=cheap
|
||||
SCAN_POOLS=ru-6,ru-7
|
||||
DEFAULT_SPOT=true
|
||||
KEEP_FLOATING_IP=false
|
||||
|
||||
+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` |
|
||||
| Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем |
|
||||
| Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена |
|
||||
| Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`). `ru-6` — мультизональный: ходим на `https://ru-6.cloud.api.selcloud.ru/compute/` тем же токеном (SDK-каталог часто знает только RC-пул). Собираем типы GPU из extra_specs и совпадения с `FLAVOR_PREFERENCE`. Автоматом `.env` не пишем — печатаем рекомендацию `OS_REGION_NAME` / `GPU_RENT_AZ` |
|
||||
| Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`) по compute URL. Сегменты `ru-6a/b/c` берём из Nova `availability_zones`, не отдельными пулами. Пресет размера `FLAVOR_SIZE_PRESET` (дефолт `cheap` = 1×GPU, min vCPU/RAM). Диск уже есть — AZ диска, не дешёвый чужой сегмент. Первый запуск — дешёвый SKU в сегменте, где есть карта из `FLAVOR_PREFERENCE`. `.env` автоматом не пишем |
|
||||
| Манифест моделей | `<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` |
|
||||
| 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, не чере
|
||||
|
||||
Поведение:
|
||||
|
||||
- Предпочтительный доступен — берём его, печатаем ₽/час.
|
||||
- Нет — показать следующий доступный из списка с ценой и отличием (VRAM / ₽). Интерактив: подтвердить. `--yes` — взять первый доступный из списка без вопроса, цену всё равно напечатать.
|
||||
- Предпочтительный доступен — берём самый дешёвый SKU этого типа (`FLAVOR_SIZE_PRESET=cheap`: 1×GPU, min vCPU/RAM, без локального диска). Печатаем ₽/час из панели (в API нет).
|
||||
- Диски уже есть — сегмент зафиксирован (диск из `ru-6a` нельзя прицепить к VM в `ru-6b`). Flavor только из этого AZ.
|
||||
- Первый запуск (дисков нет) — сегмент, где есть карта из `FLAVOR_PREFERENCE` (4090 ≈ `ru-6a` / `ru-7b`, не `ru-6b/c`).
|
||||
- Нет — показать следующий доступный из списка с отличием (VRAM / размер). Интерактив: пресет размера + номер flavor. `--yes` — первый доступный из списка × пресет, без вопроса.
|
||||
- Ни один из списка не доступен — стоп с текстом «нет GPU в сегменте», не молчаливый CPU-flavor.
|
||||
- Явный `--flavor <id>` бьёт список: нет этого id → ошибка, без автофоллбека.
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ UPDATE_GIT=true
|
||||
DEFAULT_FLAVOR_ID=
|
||||
FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
|
||||
FLAVOR_FALLBACK=true
|
||||
# cheap = 1×GPU min vCPU/RAM; balanced ≈ 8/32GB; ram = max RAM among 1×GPU
|
||||
FLAVOR_SIZE_PRESET=cheap
|
||||
# Pools to probe before pick (ru-6 = Selectel multi-zone). Comma-separated.
|
||||
SCAN_POOLS=ru-6,ru-7
|
||||
DEFAULT_SPOT=true
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# DEFAULT_SPOT=true
|
||||
# SCAN_POOLS=ru-6,ru-7
|
||||
# FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
|
||||
# FLAVOR_SIZE_PRESET=cheap # cheap | balanced | ram (1×GPU; cheap = min vCPU/RAM)
|
||||
# SWARMUI_LOCAL_PORT=17801
|
||||
# DATA_VOLUME_SIZE_GB=100
|
||||
# ENABLE_SWARMUI=true
|
||||
|
||||
@@ -67,6 +67,7 @@ _VARS_PREFERRED = {
|
||||
"DEFAULT_SPOT",
|
||||
"SCAN_POOLS",
|
||||
"FLAVOR_PREFERENCE",
|
||||
"FLAVOR_SIZE_PRESET",
|
||||
"SWARMUI_LOCAL_PORT",
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -183,7 +183,12 @@ def dry_run() -> None:
|
||||
conn = connect(cfg)
|
||||
flavors = list(iter_flavors(conn))
|
||||
gpu = [f for f in flavors if looks_like_gpu(f)]
|
||||
ranked = rank_flavors(gpu or flavors, cfg.flavor_preference)
|
||||
ranked = rank_flavors(
|
||||
gpu or flavors,
|
||||
cfg.flavor_preference,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
console.print("\n[bold]Flavors[/bold]")
|
||||
for line in format_flavor_lines(ranked):
|
||||
console.print(f" {line}")
|
||||
@@ -201,6 +206,7 @@ def flavors(
|
||||
) -> None:
|
||||
"""GPU flavors: скан пулов + список в текущем OS_REGION_NAME."""
|
||||
try:
|
||||
from gpu_rent.flavor_presets import format_char_impact
|
||||
from gpu_rent.inventory import looks_like_gpu, rank_flavors, resolve_flavor
|
||||
from gpu_rent.os_client import iter_flavors
|
||||
from gpu_rent.pools import format_pool_scan, scan_pools
|
||||
@@ -211,20 +217,34 @@ def flavors(
|
||||
console.print(f"[bold]region сейчас[/bold]: {cfg.os_region_name} / AZ {cfg.gpu_rent_az}")
|
||||
console.print(f"[bold]SCAN_POOLS[/bold]: {cfg.scan_pools or 'ru-6,ru-7'}")
|
||||
offers = scan_pools(cfg)
|
||||
for line in format_pool_scan(offers, cfg.flavor_preference):
|
||||
for line in format_pool_scan(
|
||||
offers,
|
||||
cfg.flavor_preference,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
):
|
||||
console.print(line)
|
||||
console.print("")
|
||||
for line in format_char_impact():
|
||||
console.print(line)
|
||||
console.print("")
|
||||
|
||||
conn = connect(cfg)
|
||||
all_f = list(iter_flavors(conn))
|
||||
gpu = [f for f in all_f if looks_like_gpu(f)]
|
||||
ranked = rank_flavors(gpu or all_f, cfg.flavor_preference)
|
||||
ranked = rank_flavors(
|
||||
gpu or all_f,
|
||||
cfg.flavor_preference,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
try:
|
||||
picked = resolve_flavor(
|
||||
all_f,
|
||||
cfg.flavor_preference,
|
||||
default_id=cfg.default_flavor_id or None,
|
||||
fallback=cfg.flavor_fallback,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
except ValueError:
|
||||
picked = None
|
||||
|
||||
@@ -22,6 +22,7 @@ from gpu_rent.paths import (
|
||||
)
|
||||
from gpu_rent.varsfile import apply_vars_file
|
||||
from gpu_rent.llm_runtime import normalize_runtime
|
||||
from gpu_rent.flavor_presets import normalize_size_preset
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool) -> bool:
|
||||
@@ -111,6 +112,7 @@ class Config:
|
||||
default_flavor_id: str
|
||||
flavor_preference: tuple[str, ...]
|
||||
flavor_fallback: bool
|
||||
flavor_size_preset: str
|
||||
scan_pools: str
|
||||
default_spot: bool
|
||||
keep_floating_ip: bool
|
||||
@@ -244,6 +246,7 @@ def load_config(*, require_auth: bool = True) -> Config:
|
||||
("4090-24", "4090-48", "a5000", "a100-40"),
|
||||
),
|
||||
flavor_fallback=_as_bool(os.environ.get("FLAVOR_FALLBACK"), True),
|
||||
flavor_size_preset=normalize_size_preset(os.environ.get("FLAVOR_SIZE_PRESET")),
|
||||
scan_pools=(os.environ.get("SCAN_POOLS") or "ru-6,ru-7").strip(),
|
||||
default_spot=_as_bool(os.environ.get("DEFAULT_SPOT"), True),
|
||||
keep_floating_ip=_as_bool(os.environ.get("KEEP_FLOATING_IP"), False),
|
||||
|
||||
@@ -125,7 +125,12 @@ def run_doctor() -> list[Check]:
|
||||
|
||||
flavors = list(iter_flavors(conn))
|
||||
gpu_flavors = [f for f in flavors if looks_like_gpu(f)]
|
||||
ranked = rank_flavors(gpu_flavors or flavors, cfg.flavor_preference)
|
||||
ranked = rank_flavors(
|
||||
gpu_flavors or flavors,
|
||||
cfg.flavor_preference,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
if cfg.default_flavor_id:
|
||||
hit = next((f for f in flavors if getattr(f, "id", None) == cfg.default_flavor_id), None)
|
||||
if hit:
|
||||
|
||||
@@ -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
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
_GPU_COUNT_NAME = re.compile(r"-(\d+)GPU\b", re.I)
|
||||
_GPU_COUNT_X = re.compile(r"(\d+)\s*[x×]", re.I)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlavorInfo:
|
||||
@@ -15,6 +19,10 @@ class FlavorInfo:
|
||||
disabled: bool
|
||||
extra: dict[str, Any]
|
||||
label: str | None = None
|
||||
gpu_count: int = 1
|
||||
disk_gb: int = 0
|
||||
azs: tuple[str, ...] = ()
|
||||
available_count: int | None = None
|
||||
|
||||
|
||||
def _name(obj: Any) -> str:
|
||||
@@ -87,9 +95,65 @@ def match_label(label: str, flavor: Any) -> bool:
|
||||
return token.replace("_", "-") in hay or token.replace("-", " ") in hay
|
||||
|
||||
|
||||
def gpu_count(flavor: Any) -> int:
|
||||
name = _name(flavor)
|
||||
matched = _GPU_COUNT_NAME.search(name)
|
||||
if matched:
|
||||
return max(1, int(matched.group(1)))
|
||||
matched = _GPU_COUNT_X.search(name)
|
||||
if matched:
|
||||
return max(1, int(matched.group(1)))
|
||||
extra = extra_specs(flavor)
|
||||
alias = str(extra.get("pci_passthrough:alias") or "")
|
||||
alias_n = re.search(r":(\d+)\s*$", alias)
|
||||
if alias_n:
|
||||
return max(1, int(alias_n.group(1)))
|
||||
return 1
|
||||
|
||||
|
||||
def local_disk_gb(flavor: Any) -> int:
|
||||
disk = getattr(flavor, "disk", None)
|
||||
if disk is None and isinstance(flavor, FlavorInfo):
|
||||
return int(flavor.disk_gb or 0)
|
||||
try:
|
||||
return max(0, int(disk or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def flavor_azs(flavor: Any) -> tuple[str, ...]:
|
||||
if isinstance(flavor, FlavorInfo) and flavor.azs:
|
||||
return tuple(flavor.azs)
|
||||
raw = getattr(flavor, "availability_zones", None)
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return tuple(str(item) for item in raw if item)
|
||||
return ()
|
||||
|
||||
|
||||
def flavor_in_az(flavor: Any, az: str | None) -> bool:
|
||||
if not az:
|
||||
return True
|
||||
zones = flavor_azs(flavor)
|
||||
if not zones:
|
||||
return True
|
||||
return az in zones
|
||||
|
||||
|
||||
def available_count(flavor: Any) -> int | None:
|
||||
extra = extra_specs(flavor)
|
||||
raw = extra.get("available_count")
|
||||
if raw is None and isinstance(flavor, FlavorInfo):
|
||||
return flavor.available_count
|
||||
try:
|
||||
return int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo:
|
||||
ram = getattr(flavor, "ram", None)
|
||||
vcpus = getattr(flavor, "vcpus", None)
|
||||
count = available_count(flavor)
|
||||
return FlavorInfo(
|
||||
id=_id(flavor),
|
||||
name=_name(flavor) or _id(flavor),
|
||||
@@ -98,21 +162,75 @@ def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo:
|
||||
disabled=is_disabled(flavor),
|
||||
extra=extra_specs(flavor),
|
||||
label=label,
|
||||
gpu_count=gpu_count(flavor),
|
||||
disk_gb=local_disk_gb(flavor),
|
||||
azs=flavor_azs(flavor),
|
||||
available_count=count,
|
||||
)
|
||||
|
||||
|
||||
def rank_flavors(flavors: list[Any], preference: tuple[str, ...]) -> list[FlavorInfo]:
|
||||
def pick_size_preset(flavors: list[Any], preset: str) -> Any | None:
|
||||
"""Pick one SKU: cheap = 1×GPU min vCPU/RAM/disk; balanced ≈8/32GB; ram = max RAM."""
|
||||
if not flavors:
|
||||
return None
|
||||
key = (preset or "cheap").strip().lower()
|
||||
if key not in {"cheap", "balanced", "ram"}:
|
||||
key = "cheap"
|
||||
|
||||
def _vcpu(item: Any) -> int:
|
||||
value = getattr(item, "vcpus", None)
|
||||
return int(value) if value is not None else 0
|
||||
|
||||
def _ram(item: Any) -> int:
|
||||
value = getattr(item, "ram", None)
|
||||
if value is None and isinstance(item, FlavorInfo):
|
||||
value = item.ram_mb
|
||||
return int(value) if value is not None else 0
|
||||
|
||||
def cheap_key(item: Any) -> tuple:
|
||||
return (gpu_count(item), _vcpu(item), _ram(item), local_disk_gb(item), _name(item))
|
||||
|
||||
if key == "ram":
|
||||
return min(flavors, key=lambda item: (gpu_count(item), -_ram(item), _vcpu(item), local_disk_gb(item)))
|
||||
if key == "balanced":
|
||||
return min(
|
||||
flavors,
|
||||
key=lambda item: (
|
||||
gpu_count(item),
|
||||
abs(_vcpu(item) - 8),
|
||||
abs(_ram(item) - 32768),
|
||||
_vcpu(item),
|
||||
_ram(item),
|
||||
local_disk_gb(item),
|
||||
),
|
||||
)
|
||||
return min(flavors, key=cheap_key)
|
||||
|
||||
|
||||
def rank_flavors(
|
||||
flavors: list[Any],
|
||||
preference: tuple[str, ...],
|
||||
*,
|
||||
size_preset: str = "cheap",
|
||||
az: str | None = None,
|
||||
) -> list[FlavorInfo]:
|
||||
ranked: list[FlavorInfo] = []
|
||||
seen: set[str] = set()
|
||||
for label in preference:
|
||||
for flavor in flavors:
|
||||
fid = _id(flavor)
|
||||
if fid in seen or is_disabled(flavor):
|
||||
continue
|
||||
if match_label(label, flavor):
|
||||
ranked.append(flavor_info(flavor, label))
|
||||
seen.add(fid)
|
||||
break
|
||||
candidates = [
|
||||
flavor
|
||||
for flavor in flavors
|
||||
if _id(flavor) not in seen
|
||||
and not is_disabled(flavor)
|
||||
and flavor_in_az(flavor, az)
|
||||
and match_label(label, flavor)
|
||||
]
|
||||
picked = pick_size_preset(candidates, size_preset)
|
||||
if picked is None:
|
||||
continue
|
||||
fid = _id(picked)
|
||||
ranked.append(flavor_info(picked, label))
|
||||
seen.add(fid)
|
||||
return ranked
|
||||
|
||||
|
||||
@@ -187,6 +305,8 @@ def resolve_flavor(
|
||||
explicit: str | None = None,
|
||||
default_id: str | None = None,
|
||||
fallback: bool = True,
|
||||
size_preset: str = "cheap",
|
||||
az: str | None = None,
|
||||
) -> FlavorInfo:
|
||||
if explicit:
|
||||
for flavor in flavors:
|
||||
@@ -198,11 +318,26 @@ def resolve_flavor(
|
||||
if not fallback:
|
||||
if not default_id:
|
||||
raise ValueError("FLAVOR_FALLBACK=false требует DEFAULT_FLAVOR_ID или --flavor")
|
||||
return resolve_flavor(flavors, preference, explicit=default_id, fallback=True)
|
||||
return resolve_flavor(
|
||||
flavors,
|
||||
preference,
|
||||
explicit=default_id,
|
||||
fallback=True,
|
||||
size_preset=size_preset,
|
||||
az=az,
|
||||
)
|
||||
gpu = [f for f in flavors if looks_like_gpu(f)]
|
||||
ranked = rank_flavors(gpu or flavors, preference)
|
||||
ranked = rank_flavors(gpu or flavors, preference, size_preset=size_preset, az=az)
|
||||
if ranked:
|
||||
return ranked[0]
|
||||
if default_id:
|
||||
return resolve_flavor(flavors, preference, explicit=default_id, fallback=True)
|
||||
raise ValueError("нет доступного GPU flavor из FLAVOR_PREFERENCE")
|
||||
return resolve_flavor(
|
||||
flavors,
|
||||
preference,
|
||||
explicit=default_id,
|
||||
fallback=True,
|
||||
size_preset=size_preset,
|
||||
az=az,
|
||||
)
|
||||
where = f" в сегменте {az}" if az else ""
|
||||
raise ValueError(f"нет доступного GPU flavor из FLAVOR_PREFERENCE{where}")
|
||||
|
||||
@@ -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.inventory import (
|
||||
FlavorInfo,
|
||||
flavor_azs,
|
||||
is_disabled,
|
||||
looks_like_gpu,
|
||||
match_label,
|
||||
@@ -30,6 +31,14 @@ DEFAULT_SCAN_POOLS = ("ru-6", "ru-7")
|
||||
COMPUTE_URL = "https://{pool}.cloud.api.selcloud.ru/compute/v2.1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AzGpuOffer:
|
||||
az: str
|
||||
matched: list[FlavorInfo] = field(default_factory=list)
|
||||
gpu_labels_found: list[str] = field(default_factory=list)
|
||||
gpu_types: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PoolGpuOffer:
|
||||
region: str
|
||||
@@ -38,6 +47,9 @@ class PoolGpuOffer:
|
||||
gpu_labels_found: list[str] = field(default_factory=list)
|
||||
gpu_types: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
segments: list[AzGpuOffer] = field(default_factory=list)
|
||||
recommended_az: str | None = None
|
||||
raw_gpu: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
def scan_pool_list(cfg: Config) -> tuple[str, ...]:
|
||||
@@ -69,6 +81,7 @@ def _flavor_ns(raw: dict[str, Any], specs: dict[str, Any]) -> SimpleNamespace:
|
||||
is_disabled=disabled,
|
||||
extra_specs=specs or {},
|
||||
availability_zones=raw.get("availability_zones") or [],
|
||||
disk=raw.get("disk") or 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -162,10 +175,63 @@ def _labels_present(flavors: list[Any], preference: tuple[str, ...]) -> list[str
|
||||
return found
|
||||
|
||||
|
||||
def _segment_offers(
|
||||
flavors: list[Any],
|
||||
region: str,
|
||||
preference: tuple[str, ...],
|
||||
size_preset: str,
|
||||
) -> list[AzGpuOffer]:
|
||||
by_az: dict[str, list[Any]] = {}
|
||||
for flavor in flavors:
|
||||
zones = flavor_azs(flavor)
|
||||
if not zones:
|
||||
continue
|
||||
for az in zones:
|
||||
by_az.setdefault(az, []).append(flavor)
|
||||
out: list[AzGpuOffer] = []
|
||||
for az in sorted(by_az):
|
||||
gpu = [f for f in by_az[az] if looks_like_gpu(f)]
|
||||
pool = gpu or by_az[az]
|
||||
out.append(
|
||||
AzGpuOffer(
|
||||
az=az,
|
||||
matched=rank_flavors(pool, preference, size_preset=size_preset, az=az),
|
||||
gpu_labels_found=_labels_present(pool, preference),
|
||||
gpu_types=_gpu_type_keys(pool),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
gpu = [f for f in flavors if looks_like_gpu(f)]
|
||||
pool = gpu or flavors
|
||||
out.append(
|
||||
AzGpuOffer(
|
||||
az=f"{region}a",
|
||||
matched=rank_flavors(pool, preference, size_preset=size_preset),
|
||||
gpu_labels_found=_labels_present(pool, preference),
|
||||
gpu_types=_gpu_type_keys(pool),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _pick_recommended_az(segments: list[AzGpuOffer], preference: tuple[str, ...]) -> str | None:
|
||||
for label in preference:
|
||||
for seg in segments:
|
||||
if label in seg.gpu_labels_found:
|
||||
return seg.az
|
||||
for seg in segments:
|
||||
if seg.matched:
|
||||
return seg.az
|
||||
return segments[0].az if segments else None
|
||||
|
||||
|
||||
def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGpuOffer]:
|
||||
from gpu_rent.flavor_presets import normalize_size_preset
|
||||
|
||||
targets = pools or scan_pool_list(cfg)
|
||||
mz = set(DEFAULT_MULTIZONE_POOLS)
|
||||
token = _auth_token(cfg)
|
||||
size_preset = normalize_size_preset(getattr(cfg, "flavor_size_preset", None))
|
||||
out: list[PoolGpuOffer] = []
|
||||
for region in targets:
|
||||
offer = PoolGpuOffer(
|
||||
@@ -175,18 +241,30 @@ def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGp
|
||||
try:
|
||||
flavors = list_flavors_in_pool(region, token)
|
||||
gpu = [f for f in flavors if looks_like_gpu(f)]
|
||||
offer.matched = rank_flavors(gpu or flavors, cfg.flavor_preference)
|
||||
offer.gpu_labels_found = _labels_present(gpu or flavors, cfg.flavor_preference)
|
||||
offer.gpu_types = _gpu_type_keys(gpu or flavors)
|
||||
pool = gpu or flavors
|
||||
offer.matched = rank_flavors(pool, cfg.flavor_preference, size_preset=size_preset)
|
||||
offer.gpu_labels_found = _labels_present(pool, cfg.flavor_preference)
|
||||
offer.gpu_types = _gpu_type_keys(pool)
|
||||
offer.raw_gpu = list(pool)
|
||||
offer.segments = _segment_offers(
|
||||
pool, region, cfg.flavor_preference, size_preset
|
||||
)
|
||||
offer.recommended_az = _pick_recommended_az(offer.segments, cfg.flavor_preference)
|
||||
except Exception as exc:
|
||||
offer.error = str(exc)
|
||||
out.append(offer)
|
||||
return out
|
||||
|
||||
|
||||
def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) -> list[str]:
|
||||
def format_pool_scan(
|
||||
offers: list[PoolGpuOffer],
|
||||
preference: tuple[str, ...],
|
||||
*,
|
||||
pin_az: str | None = None,
|
||||
size_preset: str = "cheap",
|
||||
) -> list[str]:
|
||||
lines = [
|
||||
"[bold cyan]скан пулов[/bold cyan] (мультизональные + кандидаты) × характеристики GPU:",
|
||||
"[bold cyan]скан пулов[/bold cyan] (сегменты a/b/c из Nova availability_zones):",
|
||||
" [dim](серые кнопки панели ≠ Nova; available_count бывает только у части flavors)[/dim]",
|
||||
]
|
||||
for offer in offers:
|
||||
@@ -197,26 +275,46 @@ def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) ->
|
||||
continue
|
||||
types_s = ", ".join(offer.gpu_types) if offer.gpu_types else "—"
|
||||
lines.append(f"{prefix} типы: {types_s}")
|
||||
if offer.gpu_labels_found:
|
||||
best = offer.matched[0] if offer.matched else None
|
||||
best_s = (
|
||||
f" → [green]лучший {best.name} ({best.label})[/green]" if best else ""
|
||||
)
|
||||
lines.append(
|
||||
f" preference: {', '.join(offer.gpu_labels_found)}{best_s}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" [yellow]preference: нет совпадений с {','.join(preference)}[/yellow]"
|
||||
segs = offer.segments or [
|
||||
AzGpuOffer(
|
||||
az=offer.recommended_az or f"{offer.region}a",
|
||||
matched=offer.matched,
|
||||
gpu_labels_found=offer.gpu_labels_found,
|
||||
gpu_types=offer.gpu_types,
|
||||
)
|
||||
]
|
||||
for seg in segs:
|
||||
pin = " [green]← диск[/green]" if pin_az and seg.az == pin_az else ""
|
||||
if seg.gpu_labels_found:
|
||||
best = seg.matched[0] if seg.matched else None
|
||||
best_s = (
|
||||
f" → {best.name} ({best.label}, {size_preset})" if best else ""
|
||||
)
|
||||
lines.append(
|
||||
f" [bold]{seg.az}[/bold]{pin}: "
|
||||
f"{', '.join(seg.gpu_labels_found)}{best_s}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" [bold]{seg.az}[/bold]{pin}: "
|
||||
f"[yellow]нет {','.join(preference)}[/yellow]"
|
||||
+ (f" типы: {', '.join(seg.gpu_types)}" if seg.gpu_types else "")
|
||||
)
|
||||
rec = None
|
||||
for offer in offers:
|
||||
if offer.matched and not offer.error:
|
||||
lines.append(
|
||||
f"[green]рекомендация:[/green] OS_REGION_NAME={offer.region} "
|
||||
f"GPU_RENT_AZ={offer.region}a "
|
||||
f"[dim](сегмент a/b/c — в панели; диски и VM в одном AZ)[/dim]"
|
||||
)
|
||||
rec = offer
|
||||
break
|
||||
if rec:
|
||||
az = pin_az or rec.recommended_az or f"{rec.region}a"
|
||||
if pin_az:
|
||||
why = f"диск уже в {pin_az} — VM только здесь"
|
||||
else:
|
||||
why = f"первый запуск, пресет {size_preset}; диск и VM в одном AZ"
|
||||
lines.append(
|
||||
f"[green]рекомендация:[/green] OS_REGION_NAME={rec.region} "
|
||||
f"GPU_RENT_AZ={az} [dim]({why})[/dim]"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
+43
-16
@@ -41,6 +41,7 @@ from gpu_rent.inventory import (
|
||||
resolve_flavor,
|
||||
)
|
||||
from gpu_rent.ux import print_up_preview, prompt_server_plan
|
||||
from gpu_rent.placement import apply_az_policy, existing_disk_az
|
||||
from gpu_rent.pools import best_offer, format_pool_scan, scan_pools
|
||||
from gpu_rent.lock import SessionLock
|
||||
from gpu_rent.os_client import (
|
||||
@@ -458,7 +459,46 @@ def cmd_up(
|
||||
state.phase = "idle"
|
||||
save_state(state)
|
||||
|
||||
flavors = list(iter_flavors(conn))
|
||||
disk_az = existing_disk_az(conn, cfg, state)
|
||||
offers: list = []
|
||||
best = None
|
||||
try:
|
||||
offers = scan_pools(cfg)
|
||||
for line in format_pool_scan(
|
||||
offers,
|
||||
cfg.flavor_preference,
|
||||
pin_az=disk_az,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
):
|
||||
log(line)
|
||||
best = best_offer(offers)
|
||||
if disk_az:
|
||||
log(f"диск уже в {disk_az} — сервер только в этом сегменте")
|
||||
elif best and best.region != cfg.os_region_name:
|
||||
rec_az = best.recommended_az or f"{best.region}a"
|
||||
log(
|
||||
f"! сейчас OS_REGION_NAME={cfg.os_region_name}, "
|
||||
f"а предпочтение лучше закрывается в {best.region} — "
|
||||
f"поставь OS_REGION_NAME={best.region} и GPU_RENT_AZ={rec_az} "
|
||||
f"в .env (диски ещё не созданы) и повтори up"
|
||||
)
|
||||
except Exception as exc:
|
||||
log(f"скан пулов: {exc}")
|
||||
|
||||
placed = apply_az_policy(cfg, disk_az=disk_az, best=best)
|
||||
cfg = placed.cfg
|
||||
if placed.reason == "cheap" and not disk_az:
|
||||
log(
|
||||
f"первый запуск: дешёвый тариф в {cfg.gpu_rent_az} "
|
||||
f"(пресет {cfg.flavor_size_preset})"
|
||||
)
|
||||
|
||||
current = next(
|
||||
(item for item in offers if item.region == cfg.os_region_name and not item.error),
|
||||
None,
|
||||
)
|
||||
flavors = list(current.raw_gpu) if current and current.raw_gpu else list(iter_flavors(conn))
|
||||
|
||||
try:
|
||||
picked = resolve_flavor(
|
||||
flavors,
|
||||
@@ -466,6 +506,8 @@ def cmd_up(
|
||||
explicit=flavor,
|
||||
default_id=cfg.default_flavor_id or None,
|
||||
fallback=cfg.flavor_fallback,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise CloudError(str(exc)) from exc
|
||||
@@ -476,21 +518,6 @@ def cmd_up(
|
||||
vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az)
|
||||
spot = cfg.default_spot and not no_spot
|
||||
|
||||
try:
|
||||
offers = scan_pools(cfg)
|
||||
for line in format_pool_scan(offers, cfg.flavor_preference):
|
||||
log(line)
|
||||
best = best_offer(offers)
|
||||
if best and best.region != cfg.os_region_name:
|
||||
log(
|
||||
f"! сейчас OS_REGION_NAME={cfg.os_region_name}, "
|
||||
f"а предпочтение лучше закрывается в {best.region} — "
|
||||
f"поставь OS_REGION_NAME={best.region} и GPU_RENT_AZ={best.region}a "
|
||||
f"в .env (диски ещё не созданы) и повтори up"
|
||||
)
|
||||
except Exception as exc:
|
||||
log(f"скан пулов: {exc}")
|
||||
|
||||
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
|
||||
|
||||
if not yes and ask is not None and flavor is None:
|
||||
|
||||
+46
-7
@@ -9,6 +9,11 @@ from typing import Any
|
||||
from rich.markup import escape
|
||||
|
||||
from gpu_rent.config import Config
|
||||
from gpu_rent.flavor_presets import (
|
||||
format_char_impact,
|
||||
normalize_size_preset,
|
||||
size_preset_menu,
|
||||
)
|
||||
from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
|
||||
|
||||
Log = Callable[[str], None]
|
||||
@@ -16,15 +21,26 @@ Ask = Callable[[str, str], str]
|
||||
Confirm = Callable[[str], bool]
|
||||
|
||||
|
||||
def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]:
|
||||
def list_ranked_flavors(
|
||||
flavors: list[Any],
|
||||
cfg: Config,
|
||||
*,
|
||||
size_preset: str | None = None,
|
||||
) -> list[FlavorInfo]:
|
||||
gpu = [f for f in flavors if looks_like_gpu(f)]
|
||||
return rank_flavors(gpu or flavors, cfg.flavor_preference)
|
||||
preset = normalize_size_preset(size_preset or cfg.flavor_size_preset)
|
||||
return rank_flavors(
|
||||
gpu or flavors,
|
||||
cfg.flavor_preference,
|
||||
size_preset=preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
|
||||
|
||||
def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = None) -> list[str]:
|
||||
if not ranked:
|
||||
return ["[yellow]flavors: пусто (проверь регион / FLAVOR_PREFERENCE)[/yellow]"]
|
||||
lines = ["[bold cyan]flavors (по FLAVOR_PREFERENCE):[/bold cyan]"]
|
||||
lines = ["[bold cyan]flavors (пресет размера × FLAVOR_PREFERENCE):[/bold cyan]"]
|
||||
for i, info in enumerate(ranked, 1):
|
||||
mark = " [green]← выберем[/green]" if picked and info.id == picked.id else ""
|
||||
ram = f"{info.ram_mb // 1024}GB" if info.ram_mb else "?"
|
||||
@@ -32,9 +48,12 @@ def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = No
|
||||
label = escape(info.label or "—")
|
||||
name = escape(info.name)
|
||||
fid = escape(info.id)
|
||||
gpus = f"{info.gpu_count}×" if info.gpu_count else ""
|
||||
az = ",".join(info.azs) if info.azs else ""
|
||||
az_s = f" az={az}" if az else ""
|
||||
lines.append(
|
||||
f" [cyan]{i}.[/cyan] [bold]{label}[/bold] {name} "
|
||||
f"vCPU={vcpu} RAM={ram} id={fid}{mark}"
|
||||
f"{gpus}vCPU={vcpu} RAM={ram} id={fid}{az_s}{mark}"
|
||||
)
|
||||
return lines
|
||||
|
||||
@@ -62,8 +81,11 @@ def print_up_preview(
|
||||
picked: FlavorInfo,
|
||||
spot: bool,
|
||||
log: Log,
|
||||
size_preset: str | None = None,
|
||||
) -> None:
|
||||
ranked = list_ranked_flavors(flavors, cfg)
|
||||
for line in format_char_impact():
|
||||
log(line)
|
||||
ranked = list_ranked_flavors(flavors, cfg, size_preset=size_preset)
|
||||
for line in format_flavor_lines(ranked, picked):
|
||||
log(line)
|
||||
log("")
|
||||
@@ -92,11 +114,26 @@ def prompt_server_plan(
|
||||
if not ranked:
|
||||
return ServerPlan(flavor=picked, data_gb=cfg.data_volume_size_gb, spot=spot)
|
||||
|
||||
from gpu_rent.prompts import MenuItem, prompt_menu
|
||||
|
||||
preset = prompt_menu(
|
||||
"размер flavor",
|
||||
size_preset_menu(),
|
||||
default=normalize_size_preset(cfg.flavor_size_preset),
|
||||
ask=ask,
|
||||
)
|
||||
ranked = list_ranked_flavors(flavors, cfg, size_preset=preset)
|
||||
if not ranked:
|
||||
ranked = [picked]
|
||||
|
||||
default_idx = 1
|
||||
for i, info in enumerate(ranked, 1):
|
||||
if info.id == picked.id:
|
||||
default_idx = i
|
||||
break
|
||||
if preset != cfg.flavor_size_preset and ranked:
|
||||
default_idx = 1
|
||||
picked = ranked[0]
|
||||
|
||||
raw_idx = ask(
|
||||
f"Flavor Selectel [1-{len(ranked)}] (Enter = рекомендация)",
|
||||
@@ -121,8 +158,6 @@ def prompt_server_plan(
|
||||
if data_gb < 20:
|
||||
raise ValueError("Data disk GB: минимум 20")
|
||||
|
||||
from gpu_rent.prompts import MenuItem, prompt_menu
|
||||
|
||||
spot_key = prompt_menu(
|
||||
"Тариф GPU",
|
||||
[
|
||||
@@ -138,6 +173,7 @@ def prompt_server_plan(
|
||||
chosen.id != picked.id
|
||||
or data_gb != cfg.data_volume_size_gb
|
||||
or use_spot != spot
|
||||
or preset != cfg.flavor_size_preset
|
||||
)
|
||||
if confirm and changed and confirm("Запомнить flavor/disk/spot в gpu-rent.vars?"):
|
||||
from gpu_rent.paths import vars_path
|
||||
@@ -149,6 +185,7 @@ def prompt_server_plan(
|
||||
"DEFAULT_FLAVOR_ID": chosen.id,
|
||||
"DATA_VOLUME_SIZE_GB": str(data_gb),
|
||||
"DEFAULT_SPOT": "true" if use_spot else "false",
|
||||
"FLAVOR_SIZE_PRESET": preset,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -169,6 +206,8 @@ def resolve_and_preview(
|
||||
explicit=explicit,
|
||||
default_id=cfg.default_flavor_id or None,
|
||||
fallback=cfg.flavor_fallback,
|
||||
size_preset=cfg.flavor_size_preset,
|
||||
az=cfg.gpu_rent_az,
|
||||
)
|
||||
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
|
||||
return picked
|
||||
|
||||
+55
-3
@@ -9,13 +9,25 @@ from gpu_rent.inventory import (
|
||||
|
||||
|
||||
class FakeFlavor:
|
||||
def __init__(self, flavor_id: str, name: str, extra=None, disabled: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
flavor_id: str,
|
||||
name: str,
|
||||
extra=None,
|
||||
disabled: bool = False,
|
||||
vcpus: int = 8,
|
||||
ram: int = 32768,
|
||||
disk: int = 0,
|
||||
availability_zones=None,
|
||||
):
|
||||
self.id = flavor_id
|
||||
self.name = name
|
||||
self.extra_specs = extra or {}
|
||||
self.is_disabled = disabled
|
||||
self.vcpus = 8
|
||||
self.ram = 32768
|
||||
self.vcpus = vcpus
|
||||
self.ram = ram
|
||||
self.disk = disk
|
||||
self.availability_zones = list(availability_zones or [])
|
||||
|
||||
|
||||
def test_match_4090_24_not_48():
|
||||
@@ -74,6 +86,46 @@ def test_resolve_flavor_no_fallback_requires_default():
|
||||
assert picked.id == "ok"
|
||||
|
||||
|
||||
def test_rank_cheap_prefers_1gpu_min_sku():
|
||||
extra = {"aggregate_instance_extra_specs:gpu": "RTX4090", "gpu_memory": "24"}
|
||||
flavors = [
|
||||
FakeFlavor("dual", "GL10.8-65536-0-2GPU", extra, vcpus=8, ram=65536),
|
||||
FakeFlavor("fat", "GL10.8-32768-0-1GPU", extra, vcpus=8, ram=32768),
|
||||
FakeFlavor("tiny", "GL10.4-16384-0-1GPU", extra, vcpus=4, ram=16384),
|
||||
]
|
||||
ranked = rank_flavors(flavors, ("4090-24",), size_preset="cheap")
|
||||
assert ranked[0].id == "tiny"
|
||||
assert ranked[0].gpu_count == 1
|
||||
balanced = rank_flavors(flavors, ("4090-24",), size_preset="balanced")
|
||||
assert balanced[0].id == "fat"
|
||||
|
||||
|
||||
def test_rank_filters_availability_zone():
|
||||
extra = {"aggregate_instance_extra_specs:gpu": "RTX4090", "gpu_memory": "24"}
|
||||
flavors = [
|
||||
FakeFlavor(
|
||||
"a",
|
||||
"GL10.4-16384-0-1GPU",
|
||||
extra,
|
||||
vcpus=4,
|
||||
ram=16384,
|
||||
availability_zones=["ru-6a"],
|
||||
),
|
||||
FakeFlavor(
|
||||
"h200",
|
||||
"GL-H200",
|
||||
{"aggregate_instance_extra_specs:gpu": "H200"},
|
||||
vcpus=12,
|
||||
ram=120000,
|
||||
availability_zones=["ru-6b"],
|
||||
),
|
||||
]
|
||||
in_a = rank_flavors(flavors, ("4090-24", "a5000"), az="ru-6a")
|
||||
assert [x.id for x in in_a] == ["a"]
|
||||
in_b = rank_flavors(flavors, ("4090-24",), az="ru-6b")
|
||||
assert in_b == []
|
||||
|
||||
|
||||
def test_pick_boot_image_prefers_24_580_without_docker():
|
||||
class Img:
|
||||
def __init__(self, name):
|
||||
|
||||
@@ -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():
|
||||
@@ -14,9 +14,41 @@ def test_format_pool_scan_recommends_first_hit():
|
||||
matched=[
|
||||
FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24"),
|
||||
],
|
||||
recommended_az="ru-6a",
|
||||
segments=[
|
||||
AzGpuOffer(
|
||||
az="ru-6a",
|
||||
gpu_labels_found=["4090-24"],
|
||||
gpu_types=["RTX4090"],
|
||||
matched=[FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24")],
|
||||
),
|
||||
AzGpuOffer(az="ru-6b", gpu_labels_found=[], gpu_types=["H200"], matched=[]),
|
||||
AzGpuOffer(az="ru-6c", gpu_labels_found=[], gpu_types=["H200"], matched=[]),
|
||||
],
|
||||
),
|
||||
]
|
||||
text = "\n".join(format_pool_scan(offers, ("4090-24", "4090-48")))
|
||||
assert "ru-6" in text and "multizone" in text
|
||||
assert "типы:" in text
|
||||
assert "ru-6a" in text and "ru-6b" in text and "ru-6c" in text
|
||||
assert "рекомендация:" in text and "OS_REGION_NAME=ru-6" in text
|
||||
assert "GPU_RENT_AZ=ru-6a" in text
|
||||
|
||||
|
||||
def test_format_pool_scan_pins_disk_az():
|
||||
from gpu_rent.inventory import FlavorInfo
|
||||
|
||||
offers = [
|
||||
PoolGpuOffer(
|
||||
region="ru-6",
|
||||
multizone=True,
|
||||
gpu_labels_found=["4090-24"],
|
||||
gpu_types=["RTX4090"],
|
||||
matched=[FlavorInfo("1", "GL-4090", 4, 16384, False, {}, "4090-24")],
|
||||
recommended_az="ru-6a",
|
||||
)
|
||||
]
|
||||
text = "\n".join(
|
||||
format_pool_scan(offers, ("4090-24",), pin_az="ru-6a", size_preset="cheap")
|
||||
)
|
||||
assert "диск уже в ru-6a" in text
|
||||
|
||||
@@ -29,9 +29,9 @@ def test_prompt_server_plan_with_ranked(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gpu_rent.ux.list_ranked_flavors",
|
||||
lambda flavors, cfg: [f1, f2],
|
||||
lambda flavors, cfg, **kw: [f1, f2],
|
||||
)
|
||||
answers = iter(["2", "200", "2"])
|
||||
answers = iter(["1", "2", "200", "2"])
|
||||
|
||||
def ask(msg: str, default: str = "") -> str:
|
||||
return next(answers)
|
||||
|
||||
Reference in New Issue
Block a user