Pick cheapest GPU SKU and pin the VM to an existing disk AZ.

Scan ru-6 by Nova availability zones (a/b/c). First boot uses FLAVOR_SIZE_PRESET=cheap; reruns stay on the disk segment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 00:06:44 +03:00
co-authored by Cursor
parent 5d0c30f66b
commit 789fa26918
19 changed files with 666 additions and 71 deletions
+23 -3
View File
@@ -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
+3
View File
@@ -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),
+6 -1
View File
@@ -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:
+43
View File
@@ -0,0 +1,43 @@
"""Named SKU size presets and how each characteristic affects cost."""
from __future__ import annotations
from gpu_rent.prompts import MenuItem
SIZE_PRESETS = ("cheap", "balanced", "ram")
SIZE_PRESET_LABELS = {
"cheap": "cheap — 1×GPU, min vCPU/RAM, сеть (почти только цена карты)",
"balanced": "balanced — 1×GPU, ~8 vCPU / 32GB (небольшой запас под Comfy+Ollama)",
"ram": "ram — 1×GPU, максимум RAM (RAM заметно дороже CPU)",
}
CHAR_IMPACT = (
("GPU", "тип карты = основная цена; порядок в FLAVOR_PREFERENCE"),
("карты", "2×GPU ≈ ×2 к карте; пресеты берут 1×"),
("vCPU", "надбавка к карте, меньше чем GPU"),
("RAM", "надбавка к карте; пресет ram максимизирует"),
("локальный диск", "дороже сети; cheap берёт 0"),
("сегмент", "не цена, а наличие карты + диск; 4090 ≈ ru-6a / ru-7b"),
)
def normalize_size_preset(raw: str | None, default: str = "cheap") -> str:
key = (raw or default or "cheap").strip().lower()
if key not in SIZE_PRESETS:
return default if default in SIZE_PRESETS else "cheap"
return key
def size_preset_menu() -> list[MenuItem]:
return [MenuItem(key, SIZE_PRESET_LABELS[key]) for key in SIZE_PRESETS]
def format_char_impact() -> list[str]:
lines = [
"[bold cyan]как влияет на тариф[/bold cyan] "
"[dim](₽ в OpenStack API нет — относительный вес)[/dim]:"
]
for name, effect in CHAR_IMPACT:
lines.append(f" [bold]{name}[/bold]: {effect}")
return lines
+148 -13
View File
@@ -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}")
+76
View File
@@ -0,0 +1,76 @@
"""Pin the VM to an existing disk AZ, or pick the cheapest AZ on first boot."""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Any
from gpu_rent.config import Config
from gpu_rent.os_client import BOOT_VOLUME_NAME, DATA_VOLUME_NAME, find_volumes_by_name
from gpu_rent.pools import PoolGpuOffer
from gpu_rent.state import SessionState
@dataclass(frozen=True)
class Placement:
cfg: Config
disk_az: str | None
reason: str
def _vol_az(volume: Any) -> str | None:
raw = getattr(volume, "availability_zone", None)
if raw:
return str(raw)
return None
def existing_disk_az(conn: Any, cfg: Config, state: SessionState) -> str | None:
"""AZ of gpu-rent boot/data volume, if any already exist."""
ids = [
state.boot_volume_id,
state.data_volume_id,
cfg.boot_volume_id,
cfg.data_volume_id,
]
for vid in ids:
if not vid:
continue
try:
volume = conn.block_storage.get_volume(vid)
except Exception:
continue
az = _vol_az(volume)
if az:
return az
if conn is not None:
for name in (BOOT_VOLUME_NAME, DATA_VOLUME_NAME):
try:
found = find_volumes_by_name(conn, name)
except Exception:
found = []
for volume in found:
az = _vol_az(volume)
if az:
return az
if state.availability_zone:
return str(state.availability_zone)
return None
def apply_az_policy(
cfg: Config,
*,
disk_az: str | None,
best: PoolGpuOffer | None,
) -> Placement:
"""Disk AZ wins. First launch: cheapest matching segment in the current pool."""
if disk_az:
if disk_az != cfg.gpu_rent_az:
cfg = replace(cfg, gpu_rent_az=disk_az)
return Placement(cfg=cfg, disk_az=disk_az, reason="disk")
rec_az = best.recommended_az if best is not None else None
if best and best.region == cfg.os_region_name and rec_az and rec_az != cfg.gpu_rent_az:
cfg = replace(cfg, gpu_rent_az=rec_az)
return Placement(cfg=cfg, disk_az=None, reason="cheap")
return Placement(cfg=cfg, disk_az=None, reason="cfg")
+119 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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