- Removed deprecated console usage in favor of structured logging functions for error handling and user prompts. - Enhanced CLI prompts for LLM runtime and preset selection, utilizing menu helpers for better user interaction. - Updated GPU pool scanning output with improved formatting and error indication for clarity. - Refactored setup wizard to streamline LLM runtime and preset configuration, ensuring a more intuitive setup process. - Improved documentation and user feedback in CLI outputs to enhance overall usability.
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""User-facing prints: flavor list, cost warnings (no invented ₽)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from rich.markup import escape
|
|
|
|
from gpu_rent.config import Config
|
|
from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
|
|
|
|
Log = Callable[[str], None]
|
|
Ask = Callable[[str, str], str]
|
|
Confirm = Callable[[str], bool]
|
|
|
|
|
|
def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]:
|
|
gpu = [f for f in flavors if looks_like_gpu(f)]
|
|
return rank_flavors(gpu or flavors, cfg.flavor_preference)
|
|
|
|
|
|
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]"]
|
|
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 "?"
|
|
vcpu = str(info.vcpus) if info.vcpus is not None else "?"
|
|
label = escape(info.label or "—")
|
|
name = escape(info.name)
|
|
fid = escape(info.id)
|
|
lines.append(
|
|
f" [cyan]{i}.[/cyan] [bold]{label}[/bold] {name} "
|
|
f"vCPU={vcpu} RAM={ram} id={fid}{mark}"
|
|
)
|
|
return lines
|
|
|
|
|
|
def cost_and_risk_lines(cfg: Config, *, spot: bool, flavor_name: str) -> list[str]:
|
|
"""Honest billing notes — OpenStack has no ₽ prices."""
|
|
return [
|
|
f"план: {'preemptible ' if spot else ''}{flavor_name}, data {cfg.data_volume_size_gb} GB",
|
|
"₽: цены в OpenStack API нет — смотри панель Selectel (GPU ₽/час + диск ₽/мес).",
|
|
"диск data тарифицируется 24/7, даже когда GPU выключен (stop).",
|
|
f"idle-killer: {cfg.idle_grace_minutes} мин льготы после boot, потом "
|
|
f"{cfg.idle_minutes} мин пустой очереди → delete compute. Отложить: gpu-rent hold",
|
|
"preemptible: хостер может усыпить (~24 ч окно) → EXPIRED; tunnel сам unshelve, "
|
|
"или gpu-rent up",
|
|
"Ctrl+C на tunnel GPU не гасит — только gpu-rent stop или idle-killer. "
|
|
"Опционально: gpu-rent watchdog install — аварийное закрытие окна/ребут "
|
|
"после grace тоже stop (Ctrl+C по-прежнему detach)",
|
|
]
|
|
|
|
|
|
def print_up_preview(
|
|
cfg: Config,
|
|
flavors: list[Any],
|
|
*,
|
|
picked: FlavorInfo,
|
|
spot: bool,
|
|
log: Log,
|
|
) -> None:
|
|
ranked = list_ranked_flavors(flavors, cfg)
|
|
for line in format_flavor_lines(ranked, picked):
|
|
log(line)
|
|
log("")
|
|
for line in cost_and_risk_lines(cfg, spot=spot, flavor_name=picked.name):
|
|
log(f"! {line}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServerPlan:
|
|
flavor: FlavorInfo
|
|
data_gb: int
|
|
spot: bool
|
|
|
|
|
|
def prompt_server_plan(
|
|
cfg: Config,
|
|
flavors: list[Any],
|
|
*,
|
|
picked: FlavorInfo,
|
|
spot: bool,
|
|
ask: Ask,
|
|
confirm: Confirm | None = None,
|
|
) -> ServerPlan:
|
|
"""Interactive Selectel VM knobs before create (flavor / disk / spot)."""
|
|
ranked = list_ranked_flavors(flavors, cfg)
|
|
if not ranked:
|
|
return ServerPlan(flavor=picked, data_gb=cfg.data_volume_size_gb, spot=spot)
|
|
|
|
default_idx = 1
|
|
for i, info in enumerate(ranked, 1):
|
|
if info.id == picked.id:
|
|
default_idx = i
|
|
break
|
|
|
|
raw_idx = ask(
|
|
f"Flavor Selectel [1-{len(ranked)}] (Enter = рекомендация)",
|
|
str(default_idx),
|
|
).strip()
|
|
try:
|
|
idx = int(raw_idx)
|
|
except ValueError as exc:
|
|
raise ValueError(f"номер flavor: жду 1…{len(ranked)}, получили {raw_idx!r}") from exc
|
|
if idx < 1 or idx > len(ranked):
|
|
raise ValueError(f"номер flavor: жду 1…{len(ranked)}, получили {idx}")
|
|
chosen = ranked[idx - 1]
|
|
|
|
raw_gb = ask(
|
|
"Data disk GB (тариф 24/7; рост только вверх)",
|
|
str(cfg.data_volume_size_gb),
|
|
).strip()
|
|
try:
|
|
data_gb = int(raw_gb)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Data disk GB: жду число, получили {raw_gb!r}") from exc
|
|
if data_gb < 20:
|
|
raise ValueError("Data disk GB: минимум 20")
|
|
|
|
from gpu_rent.prompts import MenuItem, prompt_menu
|
|
|
|
spot_key = prompt_menu(
|
|
"Тариф GPU",
|
|
[
|
|
MenuItem("yes", "preemptible (дешевле, могут усыпить ~24ч)"),
|
|
MenuItem("no", "обычный on-demand"),
|
|
],
|
|
default="yes" if spot else "no",
|
|
ask=ask,
|
|
)
|
|
use_spot = spot_key == "yes"
|
|
|
|
changed = (
|
|
chosen.id != picked.id
|
|
or data_gb != cfg.data_volume_size_gb
|
|
or use_spot != spot
|
|
)
|
|
if confirm and changed and confirm("Запомнить flavor/disk/spot в gpu-rent.vars?"):
|
|
from gpu_rent.paths import vars_path
|
|
from gpu_rent.varsfile import upsert_vars
|
|
|
|
upsert_vars(
|
|
vars_path(),
|
|
{
|
|
"DEFAULT_FLAVOR_ID": chosen.id,
|
|
"DATA_VOLUME_SIZE_GB": str(data_gb),
|
|
"DEFAULT_SPOT": "true" if use_spot else "false",
|
|
},
|
|
)
|
|
|
|
return ServerPlan(flavor=chosen, data_gb=data_gb, spot=use_spot)
|
|
|
|
|
|
def resolve_and_preview(
|
|
cfg: Config,
|
|
flavors: list[Any],
|
|
*,
|
|
explicit: str | None,
|
|
spot: bool,
|
|
log: Log,
|
|
) -> FlavorInfo:
|
|
picked = resolve_flavor(
|
|
flavors,
|
|
cfg.flavor_preference,
|
|
explicit=explicit,
|
|
default_id=cfg.default_flavor_id or None,
|
|
fallback=cfg.flavor_fallback,
|
|
)
|
|
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
|
|
return picked
|