Files
gpu-rent/src/gpu_rent/cli.py
T
Leonid PershinandCursor 20ae7bd83a Support Assistent persona packs via git and local sync.
Replace assistent-personas overlay seed with assistent-extensions SFTP and an assistent: section in extensions.yaml so personalities install like other extensions without private URLs in the public repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 08:28:31 +03:00

1255 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Typer entry: gpu-rent."""
from __future__ import annotations
import socket
import sys
import traceback
import webbrowser
from datetime import datetime, timezone
from typing import Optional
import typer
from rich.table import Table
from gpu_rent import __version__
from gpu_rent.config import load_config
from gpu_rent.doctor import blocking_failed, dry_run_plan, run_doctor
from gpu_rent.errors import GpuRentError
from gpu_rent.os_client import connect, find_snapshot_by_name, find_tagged_servers
from gpu_rent.session import cmd_stop, cmd_up
from gpu_rent.ssh_ops import interactive_ssh, run_ssh
from gpu_rent.state import load_state, preempt_window_end
from gpu_rent.provision import ensure_swarmui_running, seed_civitai, seed_extensions
from gpu_rent.sync_files import pull_tree, push_tree
from gpu_rent.term import console, err, log, ok, warn
from gpu_rent.tunnel import run_tunnel
app = typer.Typer(
no_args_is_help=True,
pretty_exceptions_enable=False,
add_completion=False,
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
)
_DEBUG = False
@app.callback()
def _root(
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
) -> None:
global _DEBUG
_DEBUG = debug
def _die(exc: BaseException) -> None:
if _DEBUG:
traceback.print_exc()
err(str(exc))
hint = (
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop "
"(Ctrl+C / Ctrl+D на туннеле гасят GPU, диски остаются)"
)
msg = str(exc)
if "gpu-rent status" not in msg and "Дальше:" not in msg:
err(hint)
raise typer.Exit(1)
def _stop_after_failed_up(cfg, cause: BaseException) -> None:
"""Delete compute after a failed up so billing does not continue unnoticed."""
warn(
"up упал — гашу GPU (UP_STOP_ON_FAIL; оставить: --keep-on-fail / UP_STOP_ON_FAIL=false)"
)
try:
from gpu_rent.state import load_state
from gpu_rent.vm_logs import print_log_digest
fip = load_state().floating_ip
if fip:
print_log_digest(cfg, fip, console=console, log=log)
except Exception as dig_exc:
warn(f"дайджест логов перед stop: {dig_exc}")
try:
cmd_stop(cfg, no_pull=True, log=log)
ok("compute остановлен, диски на месте")
except Exception as stop_exc:
err(
f"auto-stop не удался: {stop_exc} — срочно: gpu-rent stop "
f"(причина up: {cause})"
)
def _print_checks(checks, *, quiet: bool = False) -> int:
failed = blocking_failed(checks)
if quiet:
if failed:
err("doctor: блокирующие проблемы")
for check in failed:
log(f" • {check.name}: {check.detail}")
_print_next_steps(failed)
return 1
warns = [c for c in checks if not c.ok and not c.blocking]
if warns:
warn(f"doctor ok ({len(checks)}), предупреждения:")
for check in warns:
log(f" • {check.name}: {check.detail}")
else:
ok(f"doctor ok ({len(checks)} проверок)")
return 0
from gpu_rent.timing import clock_prefix
table = Table(title="gpu-rent doctor", show_lines=False)
table.add_column("ok")
table.add_column("проверка")
table.add_column("блок?")
table.add_column("деталь")
for check in checks:
mark = "[green]yes[/green]" if check.ok else "[red]NO[/red]"
block = "да" if check.blocking else "нет"
table.add_row(mark, check.name, block, check.detail)
console.print(f"[dim]{clock_prefix()}[/dim]таблица doctor ↓")
console.print(table)
if failed:
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
_print_next_steps(failed)
return 1
console.print("\n[green]Можно идти дальше.[/green] Дальше: gpu-rent up --yes")
console.print(" (туннель :17801 по умолчанию; только облако: --no-tunnel)")
console.print("Чеклист spike: docs/spike-notes.md")
return 0
def _print_next_steps(failed) -> None:
names = {c.name for c in failed}
console.print("\n[bold]Что сделать дальше[/bold]")
if "env file" in names or any("OS_" in (c.detail or "") for c in failed):
console.print(" 1. copy env.example .env → заполни OS_* (docs/setup.md §3)")
if any(
"квота" in (c.detail or "").lower()
or "quota" in c.name.lower()
or c.name == "GPU quota"
for c in failed
):
console.print(" 2. Тикет в поддержку Selectel — лимит GPU (docs/setup.md §2.3)")
if any(c.name == "flavor" for c in failed):
console.print(" 3. gpu-rent flavors — проверь пул / FLAVOR_PREFERENCE")
console.print(" • Чеклист живого прогона: docs/spike-notes.md")
def _live():
cfg = load_config(require_auth=True)
state = load_state()
if not state.floating_ip:
raise GpuRentError("нет floating IP — сначала gpu-rent up")
return cfg, state.floating_ip
@app.command()
def version() -> None:
"""Версия пакета."""
console.print(__version__)
@app.command()
def doctor() -> None:
"""Preflight без create: Keystone, квота, flavor, диск, Civitai, манифесты."""
try:
from gpu_rent.timing import clock_reset, format_duration, clock_elapsed
clock_reset()
log("запускаю проверку…")
checks = run_doctor()
log(f"проверка заняла {format_duration(clock_elapsed())}")
except GpuRentError as exc:
_die(exc)
code = _print_checks(checks)
raise typer.Exit(code)
@app.command("dry-run")
def dry_run() -> None:
"""План без mutating-вызовов."""
try:
from gpu_rent.timing import clock_reset
clock_reset()
log("запускаю проверку…")
checks = run_doctor()
_print_checks(checks)
console.print("\n[bold]План[/bold]")
for line in dry_run_plan(checks):
console.print(f" • {line}")
cfg = load_config(require_auth=False)
if cfg.auth_ok:
try:
from gpu_rent.inventory import looks_like_gpu, rank_flavors
from gpu_rent.os_client import iter_flavors
from gpu_rent.ux import format_flavor_lines
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,
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}")
except GpuRentError as exc:
console.print(f" flavors: {exc}")
if blocking_failed(checks):
raise typer.Exit(1)
except GpuRentError as exc:
_die(exc)
@app.command()
def flavors(
scan: bool = typer.Option(True, "--scan/--no-scan", help="Скан пулов SCAN_POOLS (ru-6 multizone…)"),
) -> 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
from gpu_rent.ux import format_flavor_lines
cfg = load_config(require_auth=True)
if scan:
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,
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,
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
console.print(f"[bold]в пуле {cfg.os_region_name}[/bold] (то, что возьмёт up):")
for line in format_flavor_lines(ranked, picked):
console.print(line)
console.print(
f"\nspot по умолчанию: {cfg.default_spot} "
f"(обычный: gpu-rent up --no-spot)"
)
console.print("₽ в API нет — смотри панель. Серые кнопки панели ≠ disabled в Nova.")
except GpuRentError as exc:
_die(exc)
@app.command()
def status() -> None:
"""Локальный state + OpenStack, если .env есть. Туннель не нужен."""
state = load_state()
notes = dict(state.notes or {})
table = Table(title="status")
table.add_column("поле")
table.add_column("значение")
table.add_row("фаза", state.phase)
table.add_row("server", state.server_id or "—")
table.add_row("flavor", state.flavor_name or state.flavor_id or "—")
table.add_row("boot volume", state.boot_volume_id or "—")
table.add_row("data volume", state.data_volume_id or "—")
table.add_row("FIP", state.floating_ip or "—")
end = preempt_window_end(state)
if end:
left = end - datetime.now(timezone.utc)
hours = max(int(left.total_seconds() // 3600), 0)
mins = max(int((left.total_seconds() % 3600) // 60), 0)
table.add_row("preempt 24ч", f"до {end.isoformat()} (осталось {hours}h {mins}m)")
else:
table.add_row("preempt 24ч", "нет create/unshelve timestamp")
cfg = load_config(require_auth=False)
from gpu_rent.tunnel import tunnel_forwards
forwards = tunnel_forwards(cfg)
if forwards:
bits = []
for loc, _rem in forwards:
bits.append(f"{loc}{'✓' if _port_open(loc) else '✗'}")
table.add_row("туннель", "localhost " + " ".join(bits))
else:
table.add_row("туннель", "нет forwards")
table.add_row(
"₽ / риски",
f"панель Selectel; диск {cfg.data_volume_size_gb}GB 24/7; "
f"killer {cfg.idle_minutes}м (+{cfg.idle_grace_minutes}м льгота)",
)
if state.floating_ip and cfg.ssh_private_key_path.is_file():
try:
df = run_ssh(
cfg,
state.floating_ip,
"df -h /mnt/swarm_data 2>/dev/null | tail -1",
check=False,
timeout=15,
).strip()
table.add_row("диск used/free", df or "нет df")
from gpu_rent.idle_killer import killer_status_lines
killer_line = "; ".join(killer_status_lines(cfg, state.floating_ip))
note_k = notes.get("idle_killer")
if note_k == "failed":
err_k = notes.get("idle_killer_error") or ""
table.add_row(
"idle-killer",
f"[red]FAILED arm[/red] · {killer_line} · {err_k}"[:160],
)
elif note_k == "armed":
table.add_row("idle-killer", f"{killer_line} · arm ok (сессия)")
else:
table.add_row("idle-killer", killer_line)
try:
from gpu_rent.provision import count_wanted_models_on_vm
wanted_n = count_wanted_models_on_vm(cfg, state.floating_ip)
table.add_row(
"Assistent wanted",
f"{wanted_n} в очереди" if wanted_n else "пусто",
)
except Exception:
table.add_row("Assistent wanted", "—")
except GpuRentError as exc:
table.add_row("диск used/free", f"SSH: {exc}")
table.add_row("idle-killer", "нет SSH")
else:
table.add_row("диск used/free", "нужен живой FIP + SSH-ключ")
table.add_row("idle-killer", "нужен SSH на живую VM")
# Last verify snapshots (no new SSH)
if notes.get("stack_vm_error"):
table.add_row("стек VM", f"[red]FAIL[/red] {notes['stack_vm_error']}"[:140])
elif notes.get("stack_vm"):
bits = notes["stack_vm"]
if isinstance(bits, list):
ok_n = sum(1 for x in bits if isinstance(x, dict) and x.get("ok"))
table.add_row("стек VM", f"ok {ok_n}/{len(bits)} (последний up)")
else:
table.add_row("стек VM", str(bits)[:120])
if notes.get("gpu_env_error"):
table.add_row("GPU-стек", f"[red]FAIL[/red] {notes['gpu_env_error']}"[:140])
elif notes.get("gpu_env"):
bits = notes["gpu_env"]
if isinstance(bits, list):
summary = ", ".join(
f"{x.get('name')}={'ok' if x.get('ok') else 'FAIL'}"
for x in bits
if isinstance(x, dict)
)
table.add_row("GPU-стек", summary[:140] or "—")
if notes.get("up_timing"):
table.add_row("тайминг up", str(notes["up_timing"])[:140])
from gpu_rent.local_watchdog import watchdog_status_lines
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
from gpu_rent.access_card import resolve_llm_runtime
rt = resolve_llm_runtime(cfg)
noted = notes.get("llm_runtime")
llm_err = notes.get("llm_error")
detail = f"{rt}; ollama :{cfg.ollama_local_port}"
if noted and noted != rt:
detail += f" (notes: {noted})"
if llm_err:
detail += f" [red]err: {llm_err[:80]}[/red]"
swarm_note = notes.get("enable_swarmui")
if swarm_note is False or not cfg.enable_swarmui:
detail += " · llm-only"
table.add_row("LLM / workload", detail)
if cfg.auth_ok:
try:
conn = connect(cfg)
servers = find_tagged_servers(conn)
if servers:
table.add_row(
"Nova",
", ".join(f"{s.name} {s.status}" for s in servers),
)
else:
table.add_row("Nova", "нет tagged server")
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
table.add_row("snapshot", cfg.boot_snapshot_name if snap else "нет")
except Exception as exc:
table.add_row("Nova", f"ошибка: {exc}"[:120])
else:
table.add_row("Nova", "нет .env — только локальный state")
console.print(table)
@app.command()
def open(
llm: bool = typer.Option(False, "--llm", help="Открыть LLM API URL вместо SwarmUI"),
) -> None:
"""Открыть браузер на SwarmUI :17801 (или --llm / llm-only на Ollama)."""
cfg = load_config(require_auth=False)
use_llm = llm or not bool(getattr(cfg, "enable_swarmui", True))
if use_llm:
from gpu_rent.access_card import resolve_llm_runtime
runtime = resolve_llm_runtime(cfg)
if runtime == "ollama":
port = cfg.ollama_local_port
else:
console.print("[red]LLM не выбран[/red] (LLM_RUNTIME / gpu-rent setup)")
raise typer.Exit(1)
else:
port = cfg.swarmui_local_port
if not _port_open(port):
console.print(
f"[red]localhost:{port} молчит.[/red] Сначала `gpu-rent tunnel`, потом open."
)
raise typer.Exit(1)
url = f"http://127.0.0.1:{port}"
webbrowser.open(url)
console.print(url)
@app.command()
def setup(
llm: Optional[str] = typer.Option(None, "--llm", help="none|ollama"),
ollama_preset: Optional[str] = typer.Option(
None, "--ollama-preset", help="recommended|light|text|big|empty (все — abliterate + RU)"
),
watchdog: Optional[bool] = typer.Option(
None, "--watchdog/--no-watchdog", help="Поставить local-watchdog"
),
yes: bool = typer.Option(False, "--yes", help="Без вопросов (дефолты)"),
) -> None:
"""Интерактивная установка: файлы конфига, LLM, опционально watchdog."""
try:
from gpu_rent.setup_wizard import run_setup
def confirm(msg: str) -> bool:
if yes:
return False if watchdog is False else bool(watchdog)
return typer.confirm(msg)
def ask(msg: str, default: str) -> str:
if yes:
return default
return typer.prompt(msg, default=default)
run_setup(
llm=llm if llm is not None else ("none" if yes else None),
ollama_preset=ollama_preset if ollama_preset is not None else ("recommended" if yes else None),
install_watchdog=watchdog if watchdog is not None else (False if yes else None),
confirm=None if yes and watchdog is None else confirm,
ask=None if yes and llm is not None else ask,
log=log,
)
except Exception as exc:
_die(exc)
@app.command()
def up(
no_spot: bool = typer.Option(False, "--no-spot", help="Обычный сервер, не preemptible"),
flavor: Optional[str] = typer.Option(None, "--flavor", help="Flavor id, без фоллбека"),
yes: bool = typer.Option(False, "--yes", help="Без вопросов"),
adopt: bool = typer.Option(False, "--adopt", help="Подхватить тег gpu-rent без state"),
no_tunnel: bool = typer.Option(
False,
"--no-tunnel",
help="Только облако + bootstrap, без локального туннеля",
),
open_browser: bool = typer.Option(
True,
"--open/--no-open",
help="После туннеля открыть браузер на 17801 (по умолчанию да)",
),
no_update: bool = typer.Option(
False,
"--no-update",
help="Не делать git pull SwarmUI и установленных extensions",
),
force_update: bool = typer.Option(
False,
"--update",
help="Принудительно git pull даже на warm ACTIVE (по умолчанию warm без pull)",
),
keep_on_fail: bool = typer.Option(
False,
"--keep-on-fail",
help="Не гасить GPU если up упал (по умолчанию stop; UP_STOP_ON_FAIL=false)",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Полный doctor-таблица на up (по умолчанию кратко)",
),
llm: Optional[str] = typer.Option(
None, "--llm", help="none|ollama (override LLM_RUNTIME)"
),
ollama: bool = typer.Option(False, "--ollama", help="То же что --llm ollama"),
no_swarm: bool = typer.Option(
False,
"--no-swarm",
"--llm-only",
help="Только LLM на GPU, без установки SwarmUI",
),
) -> None:
"""Create/unshelve GPU; SwarmUI и/или LLM; по умолчанию туннель."""
cfg = None
up_ok = False
debug_srv = None
active_log = log
try:
from dataclasses import replace
from gpu_rent.debug_api import start_debug_server, stop_debug_server
from gpu_rent.llm_runtime import (
decide_runtime,
ensure_ollama_manifest_from_example,
write_ollama_models_preset,
)
from gpu_rent.paths import vars_path
from gpu_rent.prompts import prompt_menu
from gpu_rent.timing import clock_elapsed, clock_reset, format_duration
from gpu_rent.varsfile import upsert_vars
clock_reset()
# Sidecar before doctor so an agent can watch installer hang from second 0.
early_cfg = load_config(require_auth=False)
debug_srv = start_debug_server(early_cfg, log=log, print_urls=True)
if debug_srv is not None:
active_log = debug_srv.wrap_log(log)
debug_srv.set_step("doctor")
active_log("запускаю проверку…")
checks = run_doctor()
active_log(f"проверка заняла {format_duration(clock_elapsed())}")
code = _print_checks(checks, quiet=not verbose)
if code != 0:
raise typer.Exit(1)
cfg = load_config(require_auth=True)
if debug_srv is not None:
debug_srv.hub.cfg = cfg
debug_srv.set_step("up")
try:
runtime = decide_runtime(
flag=llm,
ollama_flag=ollama,
from_config=cfg.llm_runtime,
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
enable_swarm = False if no_swarm else cfg.enable_swarmui
asked_model_preset = False
llm_flags = bool(llm or ollama or no_swarm)
if not yes and not llm_flags:
from gpu_rent.llm_runtime import (
ollama_preset_menu,
workload_menu,
)
def _ask(msg: str, default: str = "") -> str:
return typer.prompt(msg, default=default)
default_stack = (
"llm"
if not cfg.enable_swarmui
else ("both" if runtime != "none" else "swarm")
)
try:
stack = prompt_menu(
"Что поднять на GPU",
workload_menu(),
default=default_stack,
ask=_ask,
show=log,
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if stack == "swarm":
enable_swarm = True
runtime = "none"
elif stack == "both":
enable_swarm = True
if runtime == "none":
runtime = "ollama"
else:
enable_swarm = False
if runtime == "none":
runtime = "ollama"
if typer.confirm("Запомнить стек в gpu-rent.vars?", default=True):
upsert_vars(
vars_path(),
{
"ENABLE_SWARMUI": "true" if enable_swarm else "false",
"LLM_RUNTIME": runtime,
},
)
if runtime == "ollama":
ensure_ollama_manifest_from_example()
try:
preset = prompt_menu(
"Ollama preset",
ollama_preset_menu(include_keep=False),
default="recommended",
ask=_ask,
show=active_log,
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if preset not in {"keep", "example"}:
write_ollama_models_preset(cfg.ollama_models_manifest, preset)
asked_model_preset = True
# Runtime уже в vars — спросить пресет, default=keep.
if not yes and not asked_model_preset and runtime == "ollama":
from gpu_rent.llm_runtime import ollama_preset_menu
def _ask2(msg: str, default: str = "") -> str:
return typer.prompt(msg, default=default)
try:
ensure_ollama_manifest_from_example()
key = prompt_menu(
"Ollama preset",
ollama_preset_menu(include_keep=True),
default="keep",
ask=_ask2,
show=active_log,
)
if key not in {"keep", "example", ""}:
write_ollama_models_preset(cfg.ollama_models_manifest, key)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if not enable_swarm and runtime == "none":
raise GpuRentError(
"llm-only требует --ollama / --llm ollama "
"(или убери --no-swarm / ENABLE_SWARMUI=true)"
)
cfg = replace(cfg, llm_runtime=runtime, enable_swarmui=enable_swarm)
if debug_srv is not None:
debug_srv.hub.cfg = cfg
if enable_swarm:
ok("стек: SwarmUI" + (f" + {runtime}" if runtime != "none" else ""))
else:
ok(f"стек: llm-only ({runtime})")
def confirm(msg: str) -> bool:
return typer.confirm(msg)
def ask(msg: str, default: str = "") -> str:
return typer.prompt(msg, default=default)
if debug_srv is not None:
debug_srv.set_step("provisioning")
state = cmd_up(
cfg,
no_spot=no_spot,
flavor=flavor,
yes=yes,
adopt=adopt,
update=(False if no_update else True if force_update else None),
confirm=confirm,
ask=None if yes else ask,
log=active_log,
)
up_ok = True
if no_tunnel:
from gpu_rent.access_card import print_access_card
print_access_card(cfg, tunneled=False, host=state.floating_ip)
console.print(
f"[bold]готово[/bold] (без туннеля). "
f"Доступы: gpu-rent tunnel --open | stop: gpu-rent stop | "
f"debug: gpu-rent debug"
)
if state.floating_ip:
console.print(f"FIP {state.floating_ip}")
from gpu_rent.vm_logs import print_log_digest
print_log_digest(cfg, state.floating_ip, console=console)
return
if not state.floating_ip:
raise GpuRentError("нет floating IP после up — туннель не открыть")
if debug_srv is not None:
debug_srv.set_step("tunnel")
run_tunnel(
cfg,
state.floating_ip,
open_browser=open_browser,
log=active_log,
)
except KeyboardInterrupt as exc:
if (
not up_ok
and cfg is not None
and not keep_on_fail
and bool(getattr(cfg, "up_stop_on_fail", True))
):
_stop_after_failed_up(cfg, exc)
err("прервано (Ctrl+C)")
raise typer.Exit(130) from exc
except GpuRentError as exc:
if (
not up_ok
and cfg is not None
and not keep_on_fail
and bool(getattr(cfg, "up_stop_on_fail", True))
):
_stop_after_failed_up(cfg, exc)
_die(exc)
finally:
if debug_srv is not None:
from gpu_rent.debug_api import stop_debug_server
stop_debug_server(debug_srv)
@app.command()
def stop(
no_pull: bool = typer.Option(False, "--no-pull"),
) -> None:
"""Удалить compute и FIP, диски оставить."""
try:
cfg = load_config(require_auth=True)
cmd_stop(cfg, no_pull=no_pull, log=log)
except GpuRentError as exc:
_die(exc)
@app.command()
def destroy(
i_understand_data_loss: bool = typer.Option(False, "--i-understand-data-loss"),
no_pull: bool = typer.Option(False, "--no-pull"),
) -> None:
"""stop + диски."""
if not i_understand_data_loss:
console.print("Нужен флаг --i-understand-data-loss")
raise typer.Exit(1)
try:
cfg = load_config(require_auth=True)
cmd_stop(cfg, destroy_disks=True, no_pull=no_pull, log=log)
except GpuRentError as exc:
_die(exc)
@app.command()
def ssh() -> None:
"""Оболочка на VM (нужен живой compute и FIP)."""
try:
cfg = load_config(require_auth=True)
state = load_state()
if not state.floating_ip:
raise GpuRentError("нет floating IP в state — сначала gpu-rent up")
raise typer.Exit(interactive_ssh(cfg, state.floating_ip))
except GpuRentError as exc:
_die(exc)
@app.command("diag")
def diag_cmd(
lines: int = typer.Option(80, "--lines", "-n", help="Строк journal в отчёте"),
) -> None:
"""Снять диагностику SwarmUI/Comfy с VM (API + journal + paths)."""
try:
cfg = load_config(require_auth=True)
state = load_state()
if not state.floating_ip:
raise GpuRentError("нет IP — VM не поднята")
from gpu_rent.ready import collect_swarm_diagnostics
# lines reserved for future; swarm_diag uses fixed 80 for now
_ = lines
collect_swarm_diagnostics(cfg, state.floating_ip, console.print)
console.print(
"[dim]/mnt/swarm_data/.gpu-rent-last-diag.txt на VM[/dim]"
)
except GpuRentError as exc:
_die(exc)
@app.command()
def logs(
unit: Optional[str] = typer.Option(
None,
"--unit",
"-u",
help="swarm|ollama|killer|cloud-init (по умолчанию — всё)",
),
lines: int = typer.Option(80, "--lines", "-n", help="Строк journalctl"),
) -> None:
"""cloud-init / journalctl юнитов на VM."""
try:
from gpu_rent.vm_logs import fetch_logs_for_cli
cfg = load_config(require_auth=True)
state = load_state()
if not state.floating_ip:
raise GpuRentError("нет IP — VM не поднята")
try:
out = fetch_logs_for_cli(
cfg, state.floating_ip, unit=unit, lines=lines
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
# markup=False: пути вида [/opt/swarmui/...] в выводе MSBuild ломают rich-разметку
console.print(out, markup=False, highlight=False)
except GpuRentError as exc:
_die(exc)
@app.command()
def tunnel(
open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"),
) -> None:
"""SSH localhost:17801 -> VM :7801. Ctrl+C / Ctrl+D — stop GPU (диски остаются)."""
debug_srv = None
try:
from gpu_rent.debug_api import start_debug_server, stop_debug_server
cfg = load_config(require_auth=True)
state = load_state()
if not state.floating_ip:
raise GpuRentError("нет floating IP — сначала gpu-rent up")
debug_srv = start_debug_server(cfg, log=log, print_urls=True)
active_log = debug_srv.wrap_log(log) if debug_srv is not None else log
if debug_srv is not None:
debug_srv.set_step("tunnel")
run_tunnel(
cfg,
state.floating_ip,
open_browser=open_browser,
log=active_log,
)
except GpuRentError as exc:
_die(exc)
finally:
if debug_srv is not None:
from gpu_rent.debug_api import stop_debug_server
stop_debug_server(debug_srv)
@app.command("debug")
def debug_cmd(
port: Optional[int] = typer.Option(
None,
"--port",
"-p",
help="Локальный порт (по умолчанию DEBUG_LOCAL_PORT / 17821)",
),
) -> None:
"""Локальный read-only Debug API (для агента), пока VM уже есть / после --no-tunnel."""
try:
from gpu_rent.debug_api import run_debug_blocking
cfg = load_config(require_auth=False)
run_debug_blocking(cfg, port=port, log=log)
except RuntimeError as exc:
_die(GpuRentError(str(exc)))
except GpuRentError as exc:
_die(exc)
@app.command()
def hold(
minutes: Optional[int] = typer.Option(None, "--minutes"),
until: Optional[str] = typer.Option(None, "--until"),
clear: bool = typer.Option(False, "--clear"),
) -> None:
"""Отложить idle-killer на VM (файл .gpu-rent-hold-until)."""
try:
from gpu_rent.hold import clear_hold, set_hold
cfg, host = _live()
if clear:
clear_hold(cfg, host, log=log)
return
set_hold(cfg, host, minutes=minutes, until=until, log=log)
except GpuRentError as exc:
_die(exc)
@app.command("seed-models")
def seed_models() -> None:
"""Докачать Civitai-манифест на живой диск."""
try:
cfg, host = _live()
seed_civitai(cfg, host, log=log)
except GpuRentError as exc:
_die(exc)
@app.command("push")
def push_all() -> None:
"""SFTP Models + Wildcards + CustomWorkflows + assistent-extensions."""
try:
from gpu_rent.paths import assistent_extensions_dir
from gpu_rent.provision import seed_assistent_personas
cfg, host = _live()
push_tree(cfg, host, cfg.local_models_dir, "/mnt/swarm_data/Models", log, models=True)
push_tree(cfg, host, cfg.local_wildcards_dir, "/mnt/swarm_data/Data/Wildcards", log, models=False)
push_tree(cfg, host, cfg.local_workflows_dir, "/mnt/swarm_data/CustomWorkflows", log, models=False)
if assistent_extensions_dir().is_dir():
seed_assistent_personas(cfg, host, log)
except GpuRentError as exc:
_die(exc)
@app.command("push-models")
def push_models() -> None:
"""SFTP только ./Models."""
try:
cfg, host = _live()
push_tree(
cfg,
host,
cfg.local_models_dir,
"/mnt/swarm_data/Models",
log,
models=True,
)
except GpuRentError as exc:
_die(exc)
@app.command("pull-output")
def pull_output_cmd() -> None:
"""Забрать новые файлы Output/ с VM."""
try:
cfg, host = _live()
pull_tree(
cfg,
host,
"/mnt/swarm_data/Output",
cfg.local_output_dir,
log,
)
except GpuRentError as exc:
_die(exc)
@app.command("seed-extensions")
def seed_extensions_cmd() -> None:
"""Clone/fetch extensions.yaml, затем restart swarmui."""
try:
from gpu_rent.provision import verify_assistent_sqlite_bins
cfg, host = _live()
seed_extensions(cfg, host, log)
ensure_swarmui_running(cfg, host, log, restart=True)
# Build lands Sqlite private deps beside the extension DLL (≥0.13.1).
verify_assistent_sqlite_bins(cfg, host, log)
except GpuRentError as exc:
_die(exc)
@app.command("seed-personas")
def seed_personas_cmd() -> None:
"""Push assistent-extensions/ → VM Assistent/extensions (+ _base)."""
try:
from gpu_rent.provision import seed_assistent_personas
cfg, host = _live()
seed_assistent_personas(cfg, host, log)
except GpuRentError as exc:
_die(exc)
capture_app = typer.Typer(
help=(
"Снять с VM инвентарь → локальные манифесты (только ссылки, без весов). "
"Merge в models.yaml / extensions.yaml."
),
no_args_is_help=False,
)
app.add_typer(capture_app, name="capture")
@capture_app.callback(invoke_without_command=True)
def capture_root(
ctx: typer.Context,
dry_run: bool = typer.Option(False, "--dry-run", help="Не писать yaml, только отчёт"),
kind: Optional[str] = typer.Option(
None, "--kind", help="Только models: checkpoint|lora|vae|…"
),
) -> None:
"""Без подкоманды — capture all."""
if ctx.invoked_subcommand is not None:
return
try:
from gpu_rent.capture import capture_all, print_report
from gpu_rent.manifests import MODEL_TYPES
if kind and kind not in MODEL_TYPES:
raise GpuRentError(f"--kind: жду один из {', '.join(MODEL_TYPES)}")
cfg, host = _live()
report = capture_all(
cfg,
host,
dry_run=dry_run,
kind_filter=kind,
log=log,
)
print_report(report, log, dry_run=dry_run)
except GpuRentError as exc:
_die(exc)
@capture_app.command("models")
def capture_models_cmd(
dry_run: bool = typer.Option(False, "--dry-run"),
kind: Optional[str] = typer.Option(
None, "--kind", help="checkpoint|lora|vae|embedding|controlnet|upscaler|clip"
),
) -> None:
"""Models на VM → merge Civitai url в models.yaml."""
try:
from gpu_rent.capture import capture_models, print_report
from gpu_rent.manifests import MODEL_TYPES
if kind and kind not in MODEL_TYPES:
raise GpuRentError(f"--kind: жду один из {', '.join(MODEL_TYPES)}")
cfg, host = _live()
report = capture_models(
cfg,
host,
None,
dry_run=dry_run,
kind_filter=kind,
log=log,
)
print_report(
report,
log,
dry_run=dry_run,
show_models=True,
show_extensions=False,
)
except GpuRentError as exc:
_die(exc)
@capture_app.command("wanted")
def capture_wanted_cmd() -> None:
"""Только очередь Assistent (.gpu-rent-wanted-models.yaml) → models.yaml."""
try:
from gpu_rent.capture import merge_wanted_models_from_vm
cfg, host = _live()
n = merge_wanted_models_from_vm(cfg, host, log)
if n:
log(f"wanted→models.yaml: +{n}")
else:
log("wanted→models.yaml: пусто или уже есть")
except GpuRentError as exc:
_die(exc)
@capture_app.command("extensions")
def capture_extensions_cmd(
dry_run: bool = typer.Option(False, "--dry-run"),
) -> None:
"""Extensions/DLNodes на VM → merge git url в extensions.yaml."""
try:
from gpu_rent.capture import capture_extensions, print_report
cfg, host = _live()
report = capture_extensions(
cfg,
host,
None,
dry_run=dry_run,
log=log,
)
print_report(
report,
log,
dry_run=dry_run,
show_models=False,
show_extensions=True,
)
except GpuRentError as exc:
_die(exc)
@capture_app.command("all")
def capture_all_cmd(
dry_run: bool = typer.Option(False, "--dry-run"),
kind: Optional[str] = typer.Option(None, "--kind", help="Фильтр только для models"),
) -> None:
"""models + extensions."""
try:
from gpu_rent.capture import capture_all, print_report
from gpu_rent.manifests import MODEL_TYPES
if kind and kind not in MODEL_TYPES:
raise GpuRentError(f"--kind: жду один из {', '.join(MODEL_TYPES)}")
cfg, host = _live()
report = capture_all(
cfg,
host,
dry_run=dry_run,
kind_filter=kind,
log=log,
)
print_report(report, log, dry_run=dry_run)
except GpuRentError as exc:
_die(exc)
@app.command("resize-data")
def resize_data(gb: int = typer.Option(..., "--gb", help="Новый размер data volume, GB (только вверх)")) -> None:
"""Cinder extend data volume + resize2fs на VM."""
try:
from gpu_rent.resize import resize_data_volume
cfg = load_config(require_auth=True)
resize_data_volume(cfg, gb, log=log)
except GpuRentError as exc:
_die(exc)
watchdog_app = typer.Typer(
help=(
"Локальный сервис: если процесс туннеля умер без stop (kill/reboot) — "
"через grace удалить compute. Не путать с idle-killer на VM."
),
no_args_is_help=True,
)
app.add_typer(watchdog_app, name="watchdog")
def _apply_project_root(project: Optional[str]) -> None:
if not project:
return
import os
from pathlib import Path
root = Path(project).expanduser().resolve()
os.environ["GPU_RENT_ROOT"] = str(root)
try:
os.chdir(root)
except OSError:
pass
@watchdog_app.command("install")
def watchdog_install(
interval: int = typer.Option(5, "--interval", help="Минуты между тиками"),
) -> None:
"""Поставить Task Scheduler / systemd user / launchd."""
try:
from gpu_rent.local_watchdog import install_watchdog
install_watchdog(interval_minutes=interval, log=log)
except Exception as exc:
err(f"install fail: {exc}")
raise typer.Exit(1) from exc
@watchdog_app.command("uninstall")
def watchdog_uninstall() -> None:
"""Снять локальный watchdog."""
try:
from gpu_rent.local_watchdog import uninstall_watchdog
uninstall_watchdog(log=log)
except Exception as exc:
err(f"uninstall fail: {exc}")
raise typer.Exit(1) from exc
@watchdog_app.command("status")
def watchdog_status_cmd() -> None:
"""Состояние установки и local lease."""
from gpu_rent.local_watchdog import watchdog_status_lines
for line in watchdog_status_lines():
console.print(line)
@watchdog_app.command("tick")
def watchdog_tick(
project: Optional[str] = typer.Option(
None,
"--project",
help="Корень репо (для планировщика; выставляет GPU_RENT_ROOT)",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Только решение, без stop"),
) -> None:
"""Один тик (вызывает планировщик)."""
_apply_project_root(project)
try:
from gpu_rent.local_watchdog import run_tick
decision = run_tick(dry_run=dry_run, log=log)
if decision.kind == "stop" and not dry_run:
raise typer.Exit(0)
except GpuRentError as exc:
_die(exc)
def _port_open(port: int) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.4)
try:
return sock.connect_ex(("127.0.0.1", port)) == 0
finally:
sock.close()
def main() -> None:
try:
app()
except GpuRentError as exc:
_die(exc)
if __name__ == "__main__":
sys.exit(main())