Update configuration and documentation for LLM support and local watchdog

- Added `ollama-models.yaml` to .gitignore and implemented logic to copy it in gpu-rent.ps1 and gpu-rent.sh.
- Enhanced env.example to include new variables for LLM runtime options and local watchdog configuration.
- Updated CLI commands to support LLM options during setup and execution, including new flags for Ollama and llama.cpp.
- Improved documentation in cli.md and README.md to reflect changes in LLM integration and local watchdog functionality.
- Adjusted architecture and decisions documentation to clarify the role of LLMs and local watchdog in the system.
This commit is contained in:
Leonid Pershin
2026-08-21 05:29:23 +03:00
parent a9cf2e0f90
commit 2005b00175
43 changed files with 2258 additions and 197 deletions
+13 -2
View File
@@ -16,8 +16,18 @@ def bootstrap_script() -> str:
return files("gpu_rent.remote").joinpath("bootstrap.sh").read_text(encoding="utf-8")
def run_bootstrap(cfg: Config, host: str, log: Log, *, update: bool = True) -> None:
log("bootstrap SwarmUI на VM (идемпотентно, без Docker)")
def run_bootstrap(
cfg: Config,
host: str,
log: Log,
*,
update: bool = True,
light: bool = False,
) -> None:
if light:
log("bootstrap SwarmUI (light: без apt)")
else:
log("bootstrap SwarmUI на VM (идемпотентно, без Docker)")
if update:
log("git update: SwarmUI on")
else:
@@ -32,6 +42,7 @@ def run_bootstrap(cfg: Config, host: str, log: Log, *, update: bool = True) -> N
env={
"SWARM_USER": cfg.ssh_user,
"GPU_RENT_UPDATE_GIT": "1" if update else "0",
"GPU_RENT_BOOTSTRAP_LIGHT": "1" if light else "0",
},
log=log,
)
+223 -10
View File
@@ -33,22 +33,26 @@ if sys.platform == "win32":
pass
app = typer.Typer(
no_args_is_help=True,
invoke_without_command=True,
pretty_exceptions_enable=False,
add_completion=False,
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent doctor. Ключи: docs/setup.md",
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
)
console = Console(highlight=False, legacy_windows=False)
_DEBUG = False
@app.callback()
@app.callback(invoke_without_command=True)
def _root(
ctx: typer.Context,
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
) -> None:
global _DEBUG
_DEBUG = debug
if ctx.invoked_subcommand is None:
# Без подкоманды → interactive up (как «запуск без параметров»).
ctx.invoke(up)
def _die(exc: BaseException) -> None:
@@ -249,6 +253,20 @@ def status() -> None:
table.add_row("диск used/free", "нужен живой FIP + SSH-ключ")
table.add_row("idle-killer", "нужен SSH на живую VM")
from gpu_rent.local_watchdog import watchdog_status_lines
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
from gpu_rent.llm_runtime import normalize_runtime
rt = normalize_runtime(cfg.llm_runtime)
noted = (state.notes or {}).get("llm_runtime")
if noted:
rt = f"{rt} (сессия: {noted})"
table.add_row(
"LLM",
f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}",
)
if cfg.auth_ok:
try:
conn = connect(cfg)
@@ -271,10 +289,30 @@ def status() -> None:
@app.command()
def open() -> None:
"""Открыть браузер на http://127.0.0.1:17801. Туннель уже должен слушать порт."""
def open(
llm: bool = typer.Option(False, "--llm", help="Открыть LLM API URL вместо SwarmUI"),
) -> None:
"""Открыть браузер на SwarmUI :17801 (или --llm на Ollama/llama.cpp)."""
cfg = load_config(require_auth=False)
port = cfg.swarmui_local_port
if llm:
from gpu_rent.llm_runtime import normalize_runtime
runtime = normalize_runtime(cfg.llm_runtime)
state = load_state()
if (state.notes or {}).get("llm_runtime"):
try:
runtime = normalize_runtime(str(state.notes["llm_runtime"]))
except ValueError:
pass
if runtime == "ollama":
port = cfg.ollama_local_port
elif runtime == "llamacpp":
port = cfg.llamacpp_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."
@@ -285,6 +323,43 @@ def open() -> None:
console.print(url)
@app.command()
def setup(
llm: Optional[str] = typer.Option(None, "--llm", help="none|ollama|llamacpp"),
ollama_preset: Optional[str] = typer.Option(
None, "--ollama-preset", help="recommended|light|stock|alt|empty"
),
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=lambda m: console.print(m),
)
except Exception as exc:
_die(exc)
@app.command()
def up(
no_spot: bool = typer.Option(False, "--no-spot", help="Обычный сервер, не preemptible"),
@@ -306,15 +381,67 @@ def up(
"--no-update",
help="Не делать git pull SwarmUI и установленных extensions",
),
llm: Optional[str] = typer.Option(
None, "--llm", help="none|ollama|llamacpp (override LLM_RUNTIME)"
),
ollama: bool = typer.Option(False, "--ollama", help="То же что --llm ollama"),
llamacpp: bool = typer.Option(False, "--llamacpp", help="То же что --llm llamacpp"),
) -> None:
"""Create/unshelve GPU, bootstrap SwarmUI, по умолчанию туннель на :17801."""
try:
from dataclasses import replace
from gpu_rent.llm_runtime import (
PRESET_HELP,
append_vars_llm_runtime,
decide_runtime,
ensure_ollama_manifest_from_example,
write_ollama_models_preset,
)
from gpu_rent.paths import vars_path
checks = run_doctor()
code = _print_checks(checks)
if code != 0:
raise typer.Exit(1)
cfg = load_config(require_auth=True)
try:
runtime = decide_runtime(
flag=llm,
ollama_flag=ollama,
llamacpp_flag=llamacpp,
from_config=cfg.llm_runtime,
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if not yes and runtime == "none" and not llm and not ollama and not llamacpp:
choice = typer.prompt(
"Поднять LLM рядом со SwarmUI? [none/ollama/llamacpp]",
default="none",
)
try:
runtime = decide_runtime(
flag=choice, ollama_flag=False, llamacpp_flag=False, from_config="none"
)
except ValueError as exc:
raise GpuRentError(str(exc)) from exc
if runtime != "none" and typer.confirm("Запомнить LLM_RUNTIME в gpu-rent.vars?", default=True):
append_vars_llm_runtime(vars_path(), runtime)
if runtime == "ollama":
ensure_ollama_manifest_from_example()
console.print(PRESET_HELP)
preset = typer.prompt("Ollama preset", default="recommended")
if preset.strip().lower() not in {"keep", "example"}:
write_ollama_models_preset(
cfg.ollama_models_manifest, preset.strip().lower()
)
cfg = replace(cfg, llm_runtime=runtime)
if runtime != "none":
console.print(f"LLM runtime: {runtime}")
def confirm(msg: str) -> bool:
return typer.confirm(msg)
@@ -368,6 +495,7 @@ def stop(
@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:
@@ -375,7 +503,7 @@ def destroy(
raise typer.Exit(1)
try:
cfg = load_config(require_auth=True)
cmd_stop(cfg, destroy_disks=True, log=lambda m: console.print(m))
cmd_stop(cfg, destroy_disks=True, no_pull=no_pull, log=lambda m: console.print(m))
except GpuRentError as exc:
_die(exc)
@@ -395,7 +523,7 @@ def ssh() -> None:
@app.command()
def logs() -> None:
"""cloud-init / journalctl на VM."""
"""cloud-init / journalctl -u swarmui на VM."""
try:
cfg = load_config(require_auth=True)
state = load_state()
@@ -404,9 +532,14 @@ def logs() -> None:
out = run_ssh(
cfg,
state.floating_ip,
"sudo -n tail -n 80 /var/log/cloud-init-output.log 2>/dev/null; "
"systemctl is-active swarmui 2>/dev/null || true",
"echo '=== cloud-init (tail) ==='; "
"sudo -n tail -n 60 /var/log/cloud-init-output.log 2>/dev/null || true; "
"echo; echo '=== systemctl swarmui ==='; "
"systemctl is-active swarmui 2>/dev/null || true; "
"echo; echo '=== journalctl -u swarmui ==='; "
"sudo -n journalctl -u swarmui -n 80 --no-pager 2>/dev/null || true",
check=False,
timeout=60,
)
console.print(out)
except GpuRentError as exc:
@@ -538,6 +671,86 @@ def resize_data(gb: int = typer.Option(..., "--gb", help="Новый разме
_die(exc)
watchdog_app = typer.Typer(
help=(
"Локальный сервис: если туннель умер без Ctrl+C / stop — "
"через 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=lambda m: console.print(m))
except Exception as exc:
console.print(f"[red]install fail:[/red] {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=lambda m: console.print(m))
except Exception as exc:
console.print(f"[red]uninstall fail:[/red] {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=lambda m: console.print(m))
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)
+21 -7
View File
@@ -164,21 +164,35 @@ def ensure_security_group(conn, cidr: str, log: Callable[[str], None]) -> Any:
except Exception as exc:
raise _wrap(exc, "security group") from exc
# Ensure TCP/22 from operator CIDR (idempotent; old /32 from WARP may be stale).
# Ensure TCP/22 from operator CIDR; drop stale SSH /32s from old VPN/WARP IPs.
have_cidr = False
stale_ssh: list[Any] = []
try:
for rule in conn.network.security_group_rules(security_group_id=sg.id):
if (
getattr(rule, "direction", None) == "ingress"
and getattr(rule, "protocol", None) == "tcp"
and int(getattr(rule, "port_range_min", 0) or 0) == 22
and int(getattr(rule, "port_range_max", 0) or 0) == 22
and (getattr(rule, "remote_ip_prefix", None) or "") == cidr
getattr(rule, "direction", None) != "ingress"
or getattr(rule, "protocol", None) != "tcp"
or int(getattr(rule, "port_range_min", 0) or 0) != 22
or int(getattr(rule, "port_range_max", 0) or 0) != 22
):
continue
prefix = getattr(rule, "remote_ip_prefix", None) or ""
if prefix == cidr:
have_cidr = True
break
elif prefix:
stale_ssh.append(rule)
except Exception:
have_cidr = False
stale_ssh = []
for rule in stale_ssh:
old = getattr(rule, "remote_ip_prefix", None) or "?"
try:
conn.network.delete_security_group_rule(rule.id)
log(f"SG {SG_NAME}: − устаревший TCP/22 с {old}")
except Exception as exc:
log(f"SG {SG_NAME}: не удалить stale {old}: {exc}")
if not have_cidr:
try:
conn.network.create_security_group_rule(
+20
View File
@@ -16,10 +16,12 @@ from gpu_rent.paths import (
extensions_manifest_path,
migrate_legacy_if_needed,
models_manifest_path,
ollama_models_manifest_path,
runtime_dir,
vars_path,
)
from gpu_rent.varsfile import apply_vars_file
from gpu_rent.llm_runtime import normalize_runtime
def _as_bool(value: str | None, default: bool) -> bool:
@@ -80,6 +82,11 @@ class Config:
swarmui_image: str
update_git: bool
llm_runtime: str
ollama_models_manifest: Path
ollama_local_port: int
llamacpp_local_port: int
default_flavor_id: str
flavor_preference: tuple[str, ...]
flavor_fallback: bool
@@ -146,6 +153,15 @@ def load_config(*, require_auth: bool = True) -> Config:
(os.environ.get("EXTENSIONS_MANIFEST") or "").strip()
or str(extensions_manifest_path())
).expanduser()
ollama_manifest = Path(
(os.environ.get("OLLAMA_MODELS_MANIFEST") or "").strip()
or str(ollama_models_manifest_path())
).expanduser()
try:
llm_runtime = normalize_runtime(os.environ.get("LLM_RUNTIME"))
except ValueError:
llm_runtime = "none"
def _dir(env_name: str, folder: str) -> Path:
raw = (os.environ.get(env_name) or "").strip()
@@ -187,6 +203,10 @@ def load_config(*, require_auth: bool = True) -> Config:
swarmui_local_port=_as_int(os.environ.get("SWARMUI_LOCAL_PORT"), 17801),
swarmui_image=(os.environ.get("SWARMUI_IMAGE") or "").strip(),
update_git=_as_bool(os.environ.get("UPDATE_GIT"), True),
llm_runtime=llm_runtime,
ollama_models_manifest=ollama_manifest,
ollama_local_port=_as_int(os.environ.get("OLLAMA_LOCAL_PORT"), 17811),
llamacpp_local_port=_as_int(os.environ.get("LLAMACPP_LOCAL_PORT"), 17812),
default_flavor_id=(os.environ.get("DEFAULT_FLAVOR_ID") or "").strip(),
flavor_preference=_csv(
os.environ.get("FLAVOR_PREFERENCE"),
+3 -2
View File
@@ -271,9 +271,10 @@ def _civitai(cfg: Config, checks: list[Check]) -> None:
checks.append(
Check(
"Civitai",
False,
True,
f"{probe.host}: {probe.detail}; fallback {alt.host}: {alt.detail}",
False,
f"{probe.host}: {probe.detail}; fallback {alt.host}: {alt.detail}. "
"seed-models недоступен, дефолт SwarmUI ок — up не блокируем",
)
)
+37 -4
View File
@@ -54,12 +54,45 @@ def create_application_credential(conn, cfg: Config, server_id: str, log: Log) -
name=name,
secret=secret,
description="gpu-rent idle-killer: delete this compute",
access_rules=[
{
"service": "compute",
"method": "DELETE",
"path": f"/v2.1/servers/{server_id}",
},
{
"service": "compute",
"method": "DELETE",
"path": "/v2.1/servers/*",
},
# sdk may GET server before delete / confirm status
{
"service": "compute",
"method": "GET",
"path": f"/v2.1/servers/{server_id}",
},
{
"service": "compute",
"method": "GET",
"path": "/v2.1/servers/*",
},
],
)
except Exception as exc:
raise CloudError(
f"не создать application credential: {exc}. "
"Нужны права identity:application_credential_create на сервисного пользователя."
) from exc
# Selectel / older Keystone may reject access_rules — fall back unrestricted delete.
log(f"app cred с access_rules не вышло ({exc}); пробуем без правил")
try:
ac = conn.identity.create_application_credential(
user=user_id,
name=name,
secret=secret,
description="gpu-rent idle-killer: delete this compute",
)
except Exception as exc2:
raise CloudError(
f"не создать application credential: {exc2}. "
"Нужны права identity:application_credential_create на сервисного пользователя."
) from exc2
ac_id = getattr(ac, "id", None) or (ac.get("id") if isinstance(ac, dict) else None)
ac_secret = getattr(ac, "secret", None) or secret
if not ac_id:
+166
View File
@@ -0,0 +1,166 @@
"""Optional LLM runtimes (Ollama / llama.cpp) beside SwarmUI."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from gpu_rent.paths import ollama_models_example_path, ollama_models_manifest_path
VALID_RUNTIMES = frozenset({"none", "ollama", "llamacpp"})
# Presets for setup / interactive up (prompt help: RU + low refusal).
OLLAMA_PRESETS: dict[str, list[str]] = {
"recommended": ["huihui_ai/qwen2.5-abliterate:7b"],
"light": ["qwen2.5:3b"],
"stock": ["qwen2.5:7b"],
"alt": ["richardyoung/qwen2.5-7b-instruct-abliterated"],
"empty": [],
}
PRESET_HELP = (
"recommended — Qwen2.5 7B abliterate (RU/EN, мало отказов, ~5GB)\n"
"light — qwen2.5:3b (быстрее, слабее)\n"
"stock — официальный qwen2.5:7b (больше цензуры)\n"
"alt — другой abliterate-пак 7B\n"
"empty — только runtime, без pull"
)
@dataclass(frozen=True)
class OllamaModelEntry:
name: str
default: bool = False
def normalize_runtime(value: str | None) -> str:
raw = (value or "none").strip().lower().replace("-", "").replace("_", "")
if raw in {"", "none", "off", "no", "0"}:
return "none"
if raw in {"ollama"}:
return "ollama"
if raw in {"llamacpp", "llama", "llamacppserver"}:
return "llamacpp"
raise ValueError(f"неизвестный LLM_RUNTIME={value!r}; жду none|ollama|llamacpp")
def decide_runtime(
*,
flag: str | None,
ollama_flag: bool,
llamacpp_flag: bool,
from_config: str,
) -> str:
"""CLI flags win over config/vars."""
if ollama_flag and llamacpp_flag:
raise ValueError("укажи только --ollama или --llamacpp, не оба")
if ollama_flag:
return "ollama"
if llamacpp_flag:
return "llamacpp"
if flag is not None and str(flag).strip() != "":
return normalize_runtime(flag)
return normalize_runtime(from_config)
def parse_ollama_models(path: Path) -> list[OllamaModelEntry]:
if not path.is_file():
return []
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
return []
items = raw.get("models")
if items is None:
return []
if not isinstance(items, list):
raise ValueError(f"{path}: models должен быть списком")
out: list[OllamaModelEntry] = []
for item in items:
if isinstance(item, str):
name = item.strip()
if name:
out.append(OllamaModelEntry(name=name))
continue
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
out.append(OllamaModelEntry(name=name, default=bool(item.get("default"))))
return out
def write_ollama_models_preset(path: Path, preset: str) -> None:
key = (preset or "recommended").strip().lower()
if key not in OLLAMA_PRESETS:
raise ValueError(f"пресет {preset!r}; варианты: {', '.join(OLLAMA_PRESETS)}")
names = OLLAMA_PRESETS[key]
lines = [
"# Локальный манифест Ollama (не коммить). Пример: ollama-models.example.yaml",
"# name = точный тег для `ollama pull`. Пустой models: [] — без pull.",
"models:",
]
if not names:
lines.append(" []")
else:
for i, name in enumerate(names):
lines.append(f" - name: {name}")
if i == 0:
lines.append(" default: true")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def ensure_ollama_manifest_from_example() -> Path:
dest = ollama_models_manifest_path()
if dest.is_file():
return dest
example = ollama_models_example_path()
if example.is_file():
dest.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
else:
write_ollama_models_preset(dest, "recommended")
return dest
def llm_local_port(cfg: Any) -> int | None:
runtime = normalize_runtime(getattr(cfg, "llm_runtime", "none"))
if runtime == "ollama":
return int(getattr(cfg, "ollama_local_port", 17811))
if runtime == "llamacpp":
return int(getattr(cfg, "llamacpp_local_port", 17812))
return None
def llm_remote_port(runtime: str) -> int | None:
runtime = normalize_runtime(runtime)
if runtime == "ollama":
return 11434
if runtime == "llamacpp":
return 8080
return None
def append_vars_llm_runtime(vars_file: Path, runtime: str) -> None:
runtime = normalize_runtime(runtime)
line = f"LLM_RUNTIME={runtime}\n"
if not vars_file.is_file():
vars_file.write_text("# gpu-rent.vars — несекреты\n" + line, encoding="utf-8")
return
text = vars_file.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
out: list[str] = []
replaced = False
for row in lines:
if row.lstrip().startswith("LLM_RUNTIME="):
out.append(line if row.endswith("\n") else line.rstrip("\n"))
replaced = True
else:
out.append(row)
if not replaced:
if out and not out[-1].endswith("\n"):
out[-1] = out[-1] + "\n"
out.append(line)
vars_file.write_text("".join(out), encoding="utf-8")
+499
View File
@@ -0,0 +1,499 @@
"""Local optional watchdog: stale tunnel lease → stop GPU.
VM idle-killer remains the default safety net. This module is opt-in via
`gpu-rent watchdog install`: while a tunnel is armed, a scheduled tick stops
compute if the laptop/process died without Ctrl+C detach or `gpu-rent stop`.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import threading
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from gpu_rent.paths import (
app_root,
local_lease_path,
local_watchdog_marker_path,
runtime_dir,
)
Log = Callable[[str], None]
@dataclass
class LocalLease:
version: int = 1
armed: bool = False
detached: bool = False
pid: int | None = None
heartbeat_at: str | None = None
app_root: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> LocalLease:
known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
return cls(**known)
@dataclass(frozen=True)
class TickDecision:
kind: str # noop | stop
detail: str
def utc_now() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def utc_now_iso() -> str:
return utc_now().isoformat()
def load_lease() -> LocalLease | None:
path = local_lease_path()
if not path.is_file():
return None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(raw, dict):
return None
return LocalLease.from_dict(raw)
def save_lease(lease: LocalLease) -> None:
runtime_dir().mkdir(parents=True, exist_ok=True)
local_lease_path().write_text(
json.dumps(lease.to_dict(), indent=2) + "\n", encoding="utf-8"
)
def clear_lease() -> None:
path = local_lease_path()
if path.is_file():
path.unlink(missing_ok=True)
def load_marker() -> dict[str, Any] | None:
path = local_watchdog_marker_path()
if not path.is_file():
return None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return raw if isinstance(raw, dict) else None
def watchdog_installed() -> bool:
return load_marker() is not None
def grace_seconds() -> int:
raw = (os.environ.get("LOCAL_WATCHDOG_GRACE_MINUTES") or "").strip()
try:
minutes = int(raw) if raw else 10
except ValueError:
minutes = 10
return max(1, minutes) * 60
def pid_alive(pid: int | None) -> bool:
if pid is None or pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _parse_iso(stamp: str | None) -> datetime | None:
if not stamp:
return None
try:
dt = datetime.fromisoformat(stamp)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def decide_local_tick(
*,
installed: bool,
has_server: bool,
lease: LocalLease | None,
now: datetime,
process_alive: bool,
grace_sec: int,
) -> TickDecision:
if not installed:
return TickDecision("noop", "watchdog not installed")
if not has_server:
return TickDecision("noop", "no compute in state")
if lease is None:
return TickDecision("noop", "no local lease (tunnel never armed)")
if lease.detached:
return TickDecision("noop", "detached after Ctrl+C")
if not lease.armed:
return TickDecision("noop", "lease not armed")
if process_alive:
return TickDecision("noop", "lease pid alive")
hb = _parse_iso(lease.heartbeat_at)
if hb is None:
return TickDecision("stop", "armed lease without heartbeat")
age = (now - hb).total_seconds()
if age < grace_sec:
return TickDecision("noop", f"grace {int(age)}s/{grace_sec}s")
return TickDecision("stop", f"stale heartbeat {int(age)}s, pid dead")
def arm_lease_for_tunnel() -> LocalLease:
lease = LocalLease(
armed=True,
detached=False,
pid=os.getpid(),
heartbeat_at=utc_now_iso(),
app_root=str(app_root()),
)
save_lease(lease)
return lease
def touch_heartbeat() -> None:
lease = load_lease()
if lease is None or not lease.armed or lease.detached:
return
lease.heartbeat_at = utc_now_iso()
lease.pid = os.getpid()
save_lease(lease)
def detach_lease_keep_gpu() -> None:
"""Ctrl+C on tunnel: leave GPU running; local tick must not stop."""
lease = load_lease()
if lease is None:
lease = LocalLease()
lease.armed = False
lease.detached = True
lease.heartbeat_at = utc_now_iso()
lease.pid = None
lease.app_root = str(app_root())
save_lease(lease)
_heartbeat_stop: threading.Event | None = None
_heartbeat_thread: threading.Thread | None = None
def start_heartbeat_thread(*, interval_seconds: float = 30.0) -> None:
global _heartbeat_stop, _heartbeat_thread
stop_heartbeat_thread()
if not watchdog_installed():
return
arm_lease_for_tunnel()
stop = threading.Event()
_heartbeat_stop = stop
def _loop() -> None:
while not stop.wait(interval_seconds):
try:
touch_heartbeat()
except OSError:
pass
thread = threading.Thread(target=_loop, name="gpu-rent-lease-hb", daemon=True)
_heartbeat_thread = thread
thread.start()
def stop_heartbeat_thread() -> None:
global _heartbeat_stop, _heartbeat_thread
if _heartbeat_stop is not None:
_heartbeat_stop.set()
_heartbeat_stop = None
_heartbeat_thread = None
def _task_name(root: Path) -> str:
digest = hashlib.sha1(str(root.resolve()).encode("utf-8")).hexdigest()[:10]
return f"gpu-rent-local-watchdog-{digest}"
def install_watchdog(
*,
interval_minutes: int = 5,
log: Log = print,
) -> dict[str, Any]:
root = app_root().resolve()
runtime_dir().mkdir(parents=True, exist_ok=True)
interval = max(1, interval_minutes)
name = _task_name(root)
platform = sys.platform
if platform == "win32":
_install_windows(name, root, interval, log)
elif platform == "darwin":
_install_macos(name, root, interval, log)
else:
_install_linux(name, root, interval, log)
marker = {
"version": 1,
"installed_at": utc_now_iso(),
"platform": platform,
"task_name": name,
"app_root": str(root),
"python": sys.executable,
"interval_minutes": interval,
}
local_watchdog_marker_path().write_text(
json.dumps(marker, indent=2) + "\n", encoding="utf-8"
)
log(
f"local-watchdog установлен ({platform}): тик каждые {interval} мин. "
f"Grace {grace_seconds() // 60} мин после смерти процесса туннеля → stop. "
f"Ctrl+C на туннеле GPU не гасит."
)
return marker
def uninstall_watchdog(*, log: Log = print) -> None:
marker = load_marker()
root = app_root().resolve()
name = (marker or {}).get("task_name") or _task_name(root)
platform = sys.platform
if platform == "win32":
_uninstall_windows(str(name), log)
elif platform == "darwin":
_uninstall_macos(str(name), log)
else:
_uninstall_linux(str(name), log)
path = local_watchdog_marker_path()
if path.is_file():
path.unlink(missing_ok=True)
clear_lease()
log("local-watchdog снят")
def watchdog_status_lines() -> list[str]:
marker = load_marker()
lease = load_lease()
lines: list[str] = []
if marker is None:
lines.append("не установлен (gpu-rent watchdog install)")
else:
lines.append(
f"установлен {marker.get('platform')} task={marker.get('task_name')} "
f"каждые {marker.get('interval_minutes')}м"
)
if lease is None:
lines.append("lease: нет")
else:
alive = pid_alive(lease.pid)
lines.append(
f"lease: armed={lease.armed} detached={lease.detached} "
f"pid={lease.pid} alive={alive} hb={lease.heartbeat_at}"
)
lines.append(f"grace: {grace_seconds() // 60} мин (LOCAL_WATCHDOG_GRACE_MINUTES)")
return lines
def run_tick(*, dry_run: bool = False, log: Log = print) -> TickDecision:
from gpu_rent.session import cmd_stop
from gpu_rent.state import load_state
state = load_state()
lease = load_lease()
decision = decide_local_tick(
installed=watchdog_installed(),
has_server=bool(state.server_id),
lease=lease,
now=utc_now(),
process_alive=pid_alive(lease.pid if lease else None),
grace_sec=grace_seconds(),
)
if decision.kind == "noop":
log(f"watchdog tick: noop ({decision.detail})")
return decision
log(f"watchdog tick: STOP — {decision.detail}")
if dry_run:
return decision
from gpu_rent.config import load_config
cfg = load_config(require_auth=True)
cmd_stop(cfg, no_pull=True, log=log)
clear_lease()
return decision
def _install_windows(name: str, root: Path, interval: int, log: Log) -> None:
tr = (
f'"{sys.executable}" -m gpu_rent watchdog tick '
f'--project "{root}"'
)
cmd = [
"schtasks",
"/Create",
"/TN",
name,
"/SC",
"MINUTE",
"/MO",
str(interval),
"/TR",
tr,
"/F",
"/RL",
"LIMITED",
]
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
raise RuntimeError(f"schtasks failed: {err or proc.returncode}")
log(f"Task Scheduler: {name}")
def _uninstall_windows(name: str, log: Log) -> None:
subprocess.run(
["schtasks", "/Delete", "/TN", name, "/F"],
capture_output=True,
text=True,
check=False,
)
log(f"Task Scheduler удалён: {name}")
def _linux_unit_paths(name: str) -> tuple[Path, Path]:
base = Path.home() / ".config" / "systemd" / "user"
return base / f"{name}.service", base / f"{name}.timer"
def _install_linux(name: str, root: Path, interval: int, log: Log) -> None:
service_path, timer_path = _linux_unit_paths(name)
service_path.parent.mkdir(parents=True, exist_ok=True)
py = sys.executable
service_path.write_text(
f"""[Unit]
Description=gpu-rent local watchdog tick ({root})
[Service]
Type=oneshot
WorkingDirectory={root}
Environment=GPU_RENT_ROOT={root}
ExecStart={py} -m gpu_rent watchdog tick --project {root}
""",
encoding="utf-8",
)
timer_path.write_text(
f"""[Unit]
Description=gpu-rent local watchdog every {interval} min
[Timer]
OnBootSec=2min
OnUnitActiveSec={interval}min
AccuracySec=1min
Persistent=true
[Install]
WantedBy=timers.target
""",
encoding="utf-8",
)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
subprocess.run(
["systemctl", "--user", "enable", "--now", f"{name}.timer"],
check=False,
)
log(f"systemd user timer: {name}.timer")
def _uninstall_linux(name: str, log: Log) -> None:
subprocess.run(
["systemctl", "--user", "disable", "--now", f"{name}.timer"],
check=False,
capture_output=True,
)
service_path, timer_path = _linux_unit_paths(name)
service_path.unlink(missing_ok=True)
timer_path.unlink(missing_ok=True)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
log(f"systemd timer снят: {name}")
def _macos_plist_path(name: str) -> Path:
return Path.home() / "Library" / "LaunchAgents" / f"{name}.plist"
def _install_macos(name: str, root: Path, interval: int, log: Log) -> None:
plist = _macos_plist_path(name)
plist.parent.mkdir(parents=True, exist_ok=True)
seconds = interval * 60
py = sys.executable
body = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{name}</string>
<key>ProgramArguments</key>
<array>
<string>{py}</string>
<string>-m</string>
<string>gpu_rent</string>
<string>watchdog</string>
<string>tick</string>
<string>--project</string>
<string>{root}</string>
</array>
<key>WorkingDirectory</key>
<string>{root}</string>
<key>EnvironmentVariables</key>
<dict>
<key>GPU_RENT_ROOT</key>
<string>{root}</string>
</dict>
<key>StartInterval</key>
<integer>{seconds}</integer>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
"""
plist.write_text(body, encoding="utf-8")
subprocess.run(["launchctl", "unload", str(plist)], check=False, capture_output=True)
proc = subprocess.run(
["launchctl", "load", str(plist)],
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
raise RuntimeError(f"launchctl load failed: {err or proc.returncode}")
log(f"launchd: {plist}")
def _uninstall_macos(name: str, log: Log) -> None:
plist = _macos_plist_path(name)
subprocess.run(["launchctl", "unload", str(plist)], check=False, capture_output=True)
plist.unlink(missing_ok=True)
log(f"launchd снят: {name}")
+2 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from gpu_rent import __version__
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
@@ -43,7 +44,7 @@ def connect(cfg: Config):
interface="public",
compute_api_version=COMPUTE_MICROVERSION,
app_name="gpu-rent",
app_version="0.1.0",
app_version=__version__,
)
conn.authorize()
try:
+20
View File
@@ -2,11 +2,15 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
def detect_app_root() -> Path:
override = (os.environ.get("GPU_RENT_ROOT") or "").strip()
if override:
return Path(override).expanduser().resolve()
cwd = Path.cwd().resolve()
for candidate in (cwd, *cwd.parents):
if (candidate / "Models").is_dir() and (candidate / "docs").is_dir():
@@ -23,6 +27,14 @@ def runtime_dir() -> Path:
return app_root() / ".gpu-rent"
def local_lease_path() -> Path:
return runtime_dir() / "local-lease.json"
def local_watchdog_marker_path() -> Path:
return runtime_dir() / "local-watchdog.json"
# Back-compat alias used across the package.
def home_dir() -> Path:
return runtime_dir()
@@ -36,6 +48,14 @@ def models_manifest_path() -> Path:
return app_root() / "models.yaml"
def ollama_models_manifest_path() -> Path:
return app_root() / "ollama-models.yaml"
def ollama_models_example_path() -> Path:
return app_root() / "ollama-models.example.yaml"
def extensions_manifest_path() -> Path:
return app_root() / "extensions.yaml"
+66
View File
@@ -254,6 +254,61 @@ def ensure_swarmui_running(cfg: Config, host: str, log: Log, restart: bool) -> N
run_ssh(cfg, host, "sudo -n systemctl enable swarmui", check=False)
def provision_llm(cfg: Config, host: str, log: Log) -> None:
from gpu_rent.llm_runtime import normalize_runtime, parse_ollama_models
from gpu_rent.ssh_ops import run_script_sudo
from gpu_rent.state import load_state, save_state
runtime = normalize_runtime(cfg.llm_runtime)
if runtime == "none":
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_runtime"] = "none"
save_state(st)
return
if runtime == "ollama":
log("LLM: ставим/запускаем Ollama")
run_script_sudo(
cfg,
host,
_pkg_text("install_ollama.sh"),
remote_path="/tmp/gpu-rent-install_ollama.sh",
timeout=900,
env={"SWARM_USER": cfg.ssh_user},
log=log,
)
entries = parse_ollama_models(cfg.ollama_models_manifest)
names = [e.name for e in entries]
if not names:
log("ollama-models.yaml пуст — pull skip")
else:
put_text(cfg, host, "/tmp/gpu-rent-ollama-models.json", json.dumps(names, indent=2))
log(f"Ollama: pull {len(names)} из манифеста")
run_python(
cfg,
host,
_pkg_text("ollama_pull.py"),
remote_path="/tmp/gpu-rent-ollama_pull.py",
timeout=7200,
log=log,
)
elif runtime == "llamacpp":
log("LLM: ставим/запускаем llama.cpp server")
run_script_sudo(
cfg,
host,
_pkg_text("install_llamacpp.sh"),
remote_path="/tmp/gpu-rent-install_llamacpp.sh",
timeout=1200,
env={"SWARM_USER": cfg.ssh_user},
log=log,
)
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_runtime"] = runtime
save_state(st)
def provision_vm(
cfg: Config,
host: str,
@@ -282,10 +337,21 @@ def provision_vm(
if cfg.pull_output:
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
ensure_swarmui_running(cfg, host, log, restart=restart)
try:
provision_llm(cfg, host, log)
except Exception as exc:
log(f"LLM runtime: {exc}")
if conn is not None and server_id:
try:
arm_idle_killer(cfg, host, conn, server_id, log)
except GpuRentError as exc:
log(f"idle-killer: {exc}")
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
from gpu_rent.llm_runtime import normalize_runtime
rt = normalize_runtime(cfg.llm_runtime)
if rt == "ollama":
log(f"Ollama API → localhost:{cfg.ollama_local_port} (туннель)")
elif rt == "llamacpp":
log(f"llama.cpp → localhost:{cfg.llamacpp_local_port} (туннель)")
log("Hold killer: gpu-rent hold | Стоп GPU: gpu-rent stop")
+6 -2
View File
@@ -77,8 +77,12 @@ ensure_bind() {
}
log "пакеты (без upgrade ядра)"
apt-get update -qq
apt-get install -y -qq git python3 python3-venv python3-pip python3-full curl ca-certificates
if [[ "${GPU_RENT_BOOTSTRAP_LIGHT:-0}" == "1" && -f "$MARKER_BOOT" ]]; then
log "light bootstrap — пропускаем apt-get"
else
apt-get update -qq
apt-get install -y -qq git python3 python3-venv python3-pip python3-full curl ca-certificates
fi
ensure_data_mount
+59 -17
View File
@@ -27,6 +27,19 @@ def strip_auth(url: str) -> str:
return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
def scrub_origin(dest: Path, clean_url: str) -> None:
"""Remove embedded tokens from git remote origin after clone/fetch."""
try:
origin = out(["git", "-C", str(dest), "remote", "get-url", "origin"])
except subprocess.CalledProcessError:
return
wanted = strip_auth(clean_url) if clean_url else strip_auth(origin)
if origin == wanted:
return
run(["git", "-C", str(dest), "remote", "set-url", "origin", wanted])
print(f"scrubbed token from origin {dest}")
def with_token(url: str, token: str) -> str:
if not token:
return url
@@ -71,16 +84,23 @@ def fetch_and_checkout(dest: Path, ref: str) -> None:
print(f"updated {dest} ({ref})")
def update_tracking_branch(dest: Path) -> None:
def update_tracking_branch(dest: Path, token: str = "") -> None:
branch = out(["git", "-C", str(dest), "rev-parse", "--abbrev-ref", "HEAD"])
if not branch or branch == "HEAD":
print(f"skip detached {dest}")
return
run(["git", "-C", str(dest), "fetch", "--recurse-submodules", "--tags", "origin"])
origin = out(["git", "-C", str(dest), "remote", "get-url", "origin"])
clean = strip_auth(origin)
if token:
run(["git", "-C", str(dest), "remote", "set-url", "origin", with_token(clean, token)])
try:
run(["git", "-C", str(dest), "reset", "--hard", f"origin/{branch}"])
except subprocess.CalledProcessError:
run(["git", "-C", str(dest), "pull", "--ff-only"])
run(["git", "-C", str(dest), "fetch", "--recurse-submodules", "--tags", "origin"])
try:
run(["git", "-C", str(dest), "reset", "--hard", f"origin/{branch}"])
except subprocess.CalledProcessError:
run(["git", "-C", str(dest), "pull", "--ff-only"])
finally:
scrub_origin(dest, clean)
print(f"updated installed {dest} ({branch})")
@@ -95,28 +115,50 @@ def clone_one(job: dict, token: str, update: bool) -> None:
if strip_auth(origin) != strip_auth(url):
print(f"FAIL origin mismatch {dest}: {origin} != {url}", file=sys.stderr)
raise SystemExit(2)
if token and strip_auth(origin) == strip_auth(url):
run(["git", "-C", str(dest), "remote", "set-url", "origin", authed])
if not update:
scrub_origin(dest, url)
print(f"skip update {dest}")
return
fetch_and_checkout(dest, ref)
try:
fetch_and_checkout(dest, ref)
finally:
scrub_origin(dest, url)
return
if dest.exists():
print(f"FAIL {dest} exists but is not a git repo", file=sys.stderr)
raise SystemExit(2)
if is_sha(ref):
run(["git", "clone", "--recurse-submodules", authed, str(dest)])
run(["git", "-C", str(dest), "fetch", "origin", ref])
run(["git", "-C", str(dest), "checkout", ref])
else:
try:
run(["git", "clone", "--recurse-submodules", "--depth", "1", "--branch", ref, authed, str(dest)])
except subprocess.CalledProcessError:
try:
if is_sha(ref):
run(["git", "clone", "--recurse-submodules", authed, str(dest)])
run(["git", "-C", str(dest), "fetch", "origin", ref])
run(["git", "-C", str(dest), "checkout", ref])
else:
try:
run(
[
"git",
"clone",
"--recurse-submodules",
"--depth",
"1",
"--branch",
ref,
authed,
str(dest),
]
)
except subprocess.CalledProcessError:
run(["git", "clone", "--recurse-submodules", authed, str(dest)])
run(["git", "-C", str(dest), "checkout", ref])
finally:
if dest.is_dir() and (dest / ".git").is_dir():
scrub_origin(dest, url)
print(f"cloned {dest}")
def update_installed_extras(known: set[str], update: bool) -> None:
def update_installed_extras(known: set[str], update: bool, token: str = "") -> None:
if not update:
return
for root in EXTRA_ROOTS:
@@ -129,7 +171,7 @@ def update_installed_extras(known: set[str], update: bool) -> None:
if key in known:
continue
try:
update_tracking_branch(child)
update_tracking_branch(child, token=token)
except Exception as exc:
print(f"FAIL installed {child}: {exc}", file=sys.stderr)
raise
@@ -151,7 +193,7 @@ def main() -> int:
failed += 1
print(f"FAIL {job.get('dest')}: {exc}", file=sys.stderr)
try:
update_installed_extras(known, update)
update_installed_extras(known, update, token=token)
except Exception:
failed += 1
if TOKEN_PATH.is_file():
+46
View File
@@ -88,6 +88,46 @@ def swarm_busy(swarm_url: str, timeout: float = 8.0) -> tuple[bool, str]:
return False, f"idle backend={bstat}"
def llm_busy(timeout: float = 3.0) -> tuple[bool, str]:
"""Ollama pull / loaded models or llama.cpp with a model count as busy."""
if (DATA / ".gpu-rent-ollama-pulling").is_file():
return True, "ollama pulling"
ctx = ssl.create_default_context()
# Ollama: any running model
try:
req = urllib.request.Request("http://127.0.0.1:11434/api/ps", method="GET")
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
data = json.loads(resp.read().decode("utf-8"))
models = data.get("models") or []
if models:
names = ",".join(str(m.get("name") or "?") for m in models[:3])
return True, f"ollama running {names}"
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError, OSError):
pass
# llama.cpp OpenAI models endpoint — if server up and lists a model, treat lightly:
# only busy if /health ok AND we recently had activity is hard; use loaded via props.
try:
req = urllib.request.Request("http://127.0.0.1:8080/health", method="GET")
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
if getattr(resp, "status", 200) == 200:
# Server alive with a model is OK for idle unless slots busy — skip kill only
# when props show n_slots_in_use if available.
try:
req2 = urllib.request.Request("http://127.0.0.1:8080/props", method="GET")
with urllib.request.urlopen(req2, timeout=timeout, context=ctx) as resp2:
props = json.loads(resp2.read().decode("utf-8"))
in_use = int(props.get("total_slots") or 0) - int(
props.get("available_slots") or props.get("total_slots") or 0
)
if in_use > 0:
return True, f"llamacpp slots_in_use={in_use}"
except Exception:
pass
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
pass
return False, "llm idle"
def keystone_token(creds: dict) -> tuple[str, str]:
"""Return (token, compute_url)."""
auth = {
@@ -189,6 +229,12 @@ def main() -> int:
log(f"busy: {detail}")
return 0
llm_is_busy, llm_detail = llm_busy()
if llm_is_busy:
write_ts(IDLE_SINCE, None)
log(f"busy: {llm_detail}")
return 0
idle_minutes = float(creds.get("idle_minutes") or 30)
since = read_ts(IDLE_SINCE)
if since is None:
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Install llama-server (CUDA) for OpenAI-compatible API on loopback :8080.
set -euo pipefail
SWARM_USER="${SWARM_USER:-ubuntu}"
DATA_ROOT="/mnt/swarm_data"
LLAMA_ROOT="${DATA_ROOT}/llamacpp"
MODELS_DIR="${LLAMA_ROOT}/models"
BIN_DIR="${LLAMA_ROOT}/bin"
UNIT="gpu-rent-llamacpp"
log() { echo "[gpu-rent-llamacpp] $*"; }
if [[ "$(id -u)" -ne 0 ]]; then
echo "нужен root" >&2
exit 1
fi
mkdir -p "$MODELS_DIR" "$BIN_DIR"
chown -R "${SWARM_USER}:${SWARM_USER}" "$LLAMA_ROOT"
SERVER_BIN="${BIN_DIR}/llama-server"
if [[ ! -x "$SERVER_BIN" ]]; then
log "скачиваю llama-server (cuda) release…"
# Pin a known-good release asset pattern; fallback to CPU if CUDA asset missing.
TMP="$(mktemp -d)"
cd "$TMP"
API="https://api.github.com/repos/ggerganov/llama.cpp/releases/latest"
URL="$(curl -fsSL "$API" | python3 -c '
import json,sys,re
data=json.load(sys.stdin)
assets=data.get("assets") or []
prefer=[]
for a in assets:
n=(a.get("name") or "").lower()
u=a.get("browser_download_url") or ""
if not u.endswith(".zip") and not u.endswith(".tar.gz"):
continue
if "cuda" in n or "cu12" in n or "cu11" in n:
prefer.append(u)
elif "ubuntu" in n or "linux" in n:
prefer.append(u)
print(prefer[0] if prefer else "")
')"
if [[ -z "$URL" ]]; then
log "не нашёл бинарь в latest release — поставь llama-server вручную в ${SERVER_BIN}"
exit 1
fi
log "asset $URL"
curl -fL "$URL" -o pkg.bin
if file pkg.bin | grep -qi zip; then
apt-get install -y -qq unzip >/dev/null 2>&1 || true
unzip -qo pkg.bin -d out
else
mkdir -p out
tar -xaf pkg.bin -C out 2>/dev/null || tar -xzf pkg.bin -C out
fi
FOUND="$(find out -type f -name 'llama-server' | head -n1 || true)"
if [[ -z "$FOUND" ]]; then
FOUND="$(find out -type f -name 'server' | head -n1 || true)"
fi
if [[ -z "$FOUND" ]]; then
log "в архиве нет llama-server"
exit 1
fi
install -m 755 "$FOUND" "$SERVER_BIN"
chown "${SWARM_USER}:${SWARM_USER}" "$SERVER_BIN"
rm -rf "$TMP"
fi
# Pick first GGUF if present; otherwise unit starts but API may idle without model.
MODEL_ARG=""
FIRST_GGUF="$(find "$MODELS_DIR" -type f \( -name '*.gguf' -o -name '*.GGUF' \) | head -n1 || true)"
if [[ -n "$FIRST_GGUF" ]]; then
MODEL_ARG="-m ${FIRST_GGUF}"
log "модель ${FIRST_GGUF}"
else
log "нет GGUF в ${MODELS_DIR} — положи файл вручную и systemctl restart ${UNIT}"
fi
cat >/etc/systemd/system/${UNIT}.service <<EOF
[Unit]
Description=gpu-rent llama.cpp server (loopback)
After=network-online.target local-fs.target
Wants=network-online.target
[Service]
Type=simple
User=${SWARM_USER}
Group=${SWARM_USER}
WorkingDirectory=${LLAMA_ROOT}
ExecStart=${SERVER_BIN} ${MODEL_ARG} --host 127.0.0.1 --port 8080
Restart=on-failure
RestartSec=8
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$UNIT"
systemctl restart "$UNIT" || log "unit стартовал с ошибкой (часто нет GGUF) — проверь journalctl -u ${UNIT}"
log "ok — http://127.0.0.1:8080 models=${MODELS_DIR}"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Install Ollama on the VM (idempotent). Models on data volume.
set -euo pipefail
SWARM_USER="${SWARM_USER:-ubuntu}"
DATA_ROOT="/mnt/swarm_data"
OLLAMA_HOME="${DATA_ROOT}/ollama"
UNIT="gpu-rent-ollama"
log() { echo "[gpu-rent-ollama] $*"; }
if [[ "$(id -u)" -ne 0 ]]; then
echo "нужен root" >&2
exit 1
fi
mkdir -p "$OLLAMA_HOME"
chown -R "${SWARM_USER}:${SWARM_USER}" "$OLLAMA_HOME"
if ! command -v ollama >/dev/null 2>&1; then
log "ставлю ollama"
curl -fsSL https://ollama.com/install.sh | sh
else
log "ollama уже в PATH: $(command -v ollama)"
fi
# Stop stock unit if present — we run our own bind to loopback + data dir.
systemctl stop ollama 2>/dev/null || true
systemctl disable ollama 2>/dev/null || true
cat >/etc/systemd/system/${UNIT}.service <<EOF
[Unit]
Description=gpu-rent Ollama (loopback)
After=network-online.target local-fs.target
Wants=network-online.target
[Service]
Type=simple
User=${SWARM_USER}
Group=${SWARM_USER}
Environment=HOME=/home/${SWARM_USER}
Environment=OLLAMA_HOST=127.0.0.1:11434
Environment=OLLAMA_MODELS=${OLLAMA_HOME}
ExecStart=$(command -v ollama) serve
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$UNIT"
systemctl restart "$UNIT"
sleep 2
systemctl is-active "$UNIT" >/dev/null
log "ok — OLLAMA_HOST=127.0.0.1:11434 models=${OLLAMA_HOME}"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Pull Ollama models from a JSON list. Stdlib only. Runs on the VM."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
JOBS = Path("/tmp/gpu-rent-ollama-models.json")
MARKER = Path("/mnt/swarm_data/.gpu-rent-ollama-pulling")
def listed() -> set[str]:
try:
out = subprocess.check_output(["ollama", "list"], text=True, stderr=subprocess.DEVNULL)
except (subprocess.CalledProcessError, FileNotFoundError):
return set()
names: set[str] = set()
for i, line in enumerate(out.splitlines()):
if i == 0 and line.lower().startswith("name"):
continue
parts = line.split()
if parts:
names.add(parts[0])
# also bare name without tag
names.add(parts[0].split(":")[0])
return names
def main() -> int:
if not JOBS.is_file():
print("no jobs file", file=sys.stderr)
return 1
models = json.loads(JOBS.read_text(encoding="utf-8"))
if not isinstance(models, list) or not models:
print("ollama pull: пустой список — skip")
return 0
have = listed()
MARKER.parent.mkdir(parents=True, exist_ok=True)
MARKER.write_text("1\n", encoding="utf-8")
failed = 0
try:
for i, name in enumerate(models, 1):
name = str(name).strip()
if not name:
continue
bare = name.split(":")[0]
if name in have or bare in have:
# Prefer exact tag match when possible
exact = any(h == name or h.startswith(name + ":") or name.startswith(h) for h in have)
if name in have or exact:
print(f"[{i}/{len(models)}] уже есть {name}")
continue
print(f"[{i}/{len(models)}] ollama pull {name}")
try:
subprocess.check_call(["ollama", "pull", name])
except subprocess.CalledProcessError as exc:
failed += 1
print(f"FAIL pull {name}: {exc}", file=sys.stderr)
finally:
MARKER.unlink(missing_ok=True)
if failed:
return 1
print("ollama pull ok")
return 0
if __name__ == "__main__":
sys.exit(main())
+27 -5
View File
@@ -84,12 +84,25 @@ def _bind_access(
save_state(state)
wait_ssh(cfg, ip)
log(f"SSH {cfg.ssh_user}@{ip}")
state.phase = "bootstrapping"
save_state(state)
if update:
active = run_ssh(cfg, ip, "systemctl is-active swarmui 2>/dev/null || true", check=False).strip()
if active == "active":
log("systemctl stop swarmui перед git update")
run_ssh(cfg, ip, "sudo -n systemctl stop swarmui", timeout=120, check=False)
run_bootstrap(cfg, ip, log, update=update)
# Skip apt-heavy bootstrap when the VM already finished first-boot.
marker = run_ssh(
cfg,
ip,
"test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no",
check=False,
).strip()
if marker == "yes" and state.bootstrapped:
log("bootstrap уже на VM — лёгкий проход (без apt)")
run_bootstrap(cfg, ip, log, update=update, light=True)
else:
run_bootstrap(cfg, ip, log, update=update, light=False)
provision_vm(
cfg,
ip,
@@ -135,6 +148,8 @@ def adopt_server(cfg: Config, log: Log = _log_default, *, update: bool = True) -
save_state(state)
log(f"подхватили {server.id} статус {server_status(server)}")
if server_status(server) == "ACTIVE":
state.phase = "bootstrapping"
save_state(state)
_bind_access(conn, server, state, cfg, log, update=update)
return state
@@ -165,7 +180,7 @@ def cmd_up(
if status == "ACTIVE":
if state.bootstrapped and state.floating_ip:
log("сервер уже ACTIVE — второй GPU не создаём")
state.phase = "ready_cloud"
state.phase = "bootstrapping"
save_state(state)
_bind_access(conn, existing, state, cfg, log, update=do_update)
return state
@@ -187,7 +202,7 @@ def cmd_up(
outcome = probe_ssh(cfg, fip, attempts=2) if fip else "down"
if outcome == "ok":
log("сервер ACTIVE, SSH ок — продолжаем bootstrap")
state.phase = "ready_cloud"
state.phase = "bootstrapping"
save_state(state)
_bind_access(conn, existing, state, cfg, log, update=do_update)
return state
@@ -213,7 +228,7 @@ def cmd_up(
if existing is not None and status in {"EXPIRED", "SHELVED", "SHELVED_OFFLOADED"}:
existing = unshelve(conn, existing, log)
state.server_id = existing.id
state.phase = "ready_cloud"
state.phase = "bootstrapping"
state.unshelved_at = utc_now()
save_state(state)
_bind_access(conn, existing, state, cfg, log, update=do_update)
@@ -341,7 +356,7 @@ def cmd_up(
state.server_name = getattr(server, "name", None)
state.created_at = utc_now()
state.unshelved_at = None
state.phase = "ready_cloud"
state.phase = "bootstrapping"
save_state(state)
_bind_access(conn, server, state, cfg, log, update=do_update)
return state
@@ -396,7 +411,14 @@ def cmd_stop(
state.server_id = None
state.server_name = None
state.bootstrapped = False
state.phase = "idle"
save_state(state)
try:
from gpu_rent.local_watchdog import clear_lease
clear_lease()
except Exception:
pass
log("фаза idle" + ("" if destroy_disks else " (диски на месте)"))
return state
+100
View File
@@ -0,0 +1,100 @@
"""Interactive setup wizard: files + LLM_RUNTIME + optional watchdog."""
from __future__ import annotations
import shutil
from collections.abc import Callable
from pathlib import Path
from gpu_rent.llm_runtime import (
PRESET_HELP,
append_vars_llm_runtime,
ensure_ollama_manifest_from_example,
normalize_runtime,
write_ollama_models_preset,
)
from gpu_rent.paths import (
app_root,
env_path,
extensions_manifest_path,
models_manifest_path,
ollama_models_example_path,
ollama_models_manifest_path,
vars_example_path,
vars_path,
)
Log = Callable[[str], None]
def _copy_if_missing(src: Path, dst: Path, label: str, log: Log) -> None:
if dst.is_file():
log(f"есть {label}")
return
if src.is_file():
shutil.copy2(src, dst)
log(f"создал {label} из example")
else:
log(f"нет example для {label}: {src}")
def run_setup(
*,
llm: str | None = None,
ollama_preset: str | None = None,
install_watchdog: bool | None = None,
confirm: Callable[[str], bool] | None = None,
ask: Callable[[str, str], str] | None = None,
log: Log = print,
) -> None:
root = app_root()
log(f"setup в {root}")
_copy_if_missing(root / "env.example", env_path(), ".env", log)
_copy_if_missing(root / "models.example.yaml", models_manifest_path(), "models.yaml", log)
_copy_if_missing(
root / "extensions.example.yaml", extensions_manifest_path(), "extensions.yaml", log
)
_copy_if_missing(vars_example_path(), vars_path(), "gpu-rent.vars", log)
_copy_if_missing(
ollama_models_example_path(), ollama_models_manifest_path(), "ollama-models.yaml", log
)
runtime = llm
if runtime is None:
if ask:
runtime = ask(
"LLM runtime [none/ollama/llamacpp]",
"none",
)
else:
runtime = "none"
runtime = normalize_runtime(runtime)
append_vars_llm_runtime(vars_path(), runtime)
log(f"LLM_RUNTIME={runtime} → gpu-rent.vars")
if runtime == "ollama":
preset = ollama_preset
if preset is None and ask:
log(PRESET_HELP)
preset = ask("Ollama preset [recommended/light/stock/alt/empty]", "recommended")
if preset is None:
preset = "recommended"
if preset.strip().lower() in {"keep", "example", ""}:
ensure_ollama_manifest_from_example()
log("ollama-models.yaml из example")
else:
write_ollama_models_preset(ollama_models_manifest_path(), preset)
log(f"ollama-models.yaml пресет={preset}")
do_wd = install_watchdog
if do_wd is None and confirm:
do_wd = confirm("Установить local-watchdog (аварийный stop без Ctrl+C)?")
if do_wd:
from gpu_rent.local_watchdog import install_watchdog as _install
_install(log=log)
elif do_wd is False:
log("local-watchdog: skip")
log("готово. Заполни .env (OS_*), потом: gpu-rent doctor && gpu-rent up")
+84 -23
View File
@@ -71,18 +71,26 @@ def probe_ssh(cfg: Config, host: str, attempts: int = 3) -> str:
def wait_ssh(cfg: Config, host: str, timeout: float = 900.0) -> None:
"""Wait until sshd accepts our key. Paramiko banner noise is muted."""
"""Wait until sshd accepts our key. Paramiko banner noise is muted.
AuthenticationException is normal while cloud-init injects keys: keep
retrying. Only give up early after AUTH_GIVE_UP seconds of *continuous*
auth rejection (sshd up, key still wrong) so recreate-with-user_data can run.
"""
import logging
logging.getLogger("paramiko").setLevel(logging.CRITICAL)
logging.getLogger("paramiko.transport").setLevel(logging.CRITICAL)
# sshd may answer before authorized_keys is ready (boot-from-volume / user_data).
auth_give_up = 180.0
wait_tcp(host, 22, timeout=min(timeout, 300))
deadline = time.time() + timeout
key = str(cfg.ssh_private_key_path)
last = None
attempt = 0
auth_fails = 0
auth_streak_started: float | None = None
while time.time() < deadline:
attempt += 1
client = paramiko.SSHClient()
@@ -103,19 +111,26 @@ def wait_ssh(cfg: Config, host: str, timeout: float = 900.0) -> None:
except Exception as exc:
last = exc
name = type(exc).__name__
if "Authentication" in name or "Authentication" in str(exc):
auth_fails += 1
is_auth = "Authentication" in name or "Authentication" in str(exc)
if is_auth:
if auth_streak_started is None:
auth_streak_started = time.time()
else:
auth_streak_started = None
try:
client.close()
except Exception:
pass
if attempt == 1 or attempt % 6 == 0:
print(f"жду SSH {cfg.ssh_user}@{host}… ({name})", flush=True)
# Key never injected (boot-from-volume): don't burn full timeout.
if auth_fails >= 3:
if (
is_auth
and auth_streak_started is not None
and (time.time() - auth_streak_started) >= auth_give_up
):
raise CloudError(
f"SSH {cfg.ssh_user}@{host}: ключ отклонён (AuthenticationException). "
"Nova keypair не попал в authorized_keys при boot-from-volume. "
f"SSH {cfg.ssh_user}@{host}: ключ отклонён {int(auth_give_up)}с подряд. "
"Nova keypair / user_data не попал в authorized_keys. "
"up пересоздаст compute с user_data (Base64), диски оставит."
) from exc
time.sleep(5)
@@ -270,41 +285,67 @@ def _connect(cfg: Config, host: str) -> paramiko.SSHClient:
return client
def open_ssh(cfg: Config, host: str) -> paramiko.SSHClient:
"""Public alias for a connected SSH client (caller must close)."""
return _connect(cfg, host)
def put_text(cfg: Config, host: str, remote_path: str, text: str, mode: int = 0o644) -> None:
client = _connect(cfg, host)
try:
sftp = client.open_sftp()
put_text_on(client, remote_path, text, mode=mode)
finally:
client.close()
def put_text_on(
client: paramiko.SSHClient, remote_path: str, text: str, mode: int = 0o644
) -> None:
sftp = client.open_sftp()
try:
_sftp_mkdirs(sftp, str(Path(remote_path).parent).replace("\\", "/"))
with sftp.file(remote_path, "w") as fh:
fh.write(text)
sftp.chmod(remote_path, mode)
sftp.close()
finally:
client.close()
sftp.close()
def put_file(cfg: Config, host: str, local: Path, remote_path: str) -> None:
client = _connect(cfg, host)
try:
sftp = client.open_sftp()
_sftp_mkdirs(sftp, str(Path(remote_path).parent).replace("\\", "/"))
sftp.put(str(local), remote_path)
sftp.close()
put_file_on(client, local, remote_path)
finally:
client.close()
def put_file_on(client: paramiko.SSHClient, local: Path, remote_path: str) -> None:
sftp = client.open_sftp()
try:
_sftp_mkdirs(sftp, str(Path(remote_path).parent).replace("\\", "/"))
sftp.put(str(local), remote_path)
finally:
sftp.close()
def get_file(cfg: Config, host: str, remote_path: str, local: Path) -> None:
local.parent.mkdir(parents=True, exist_ok=True)
client = _connect(cfg, host)
try:
sftp = client.open_sftp()
sftp.get(remote_path, str(local))
sftp.close()
get_file_on(client, remote_path, local)
finally:
client.close()
def get_file_on(client: paramiko.SSHClient, remote_path: str, local: Path) -> None:
local.parent.mkdir(parents=True, exist_ok=True)
sftp = client.open_sftp()
try:
sftp.get(remote_path, str(local))
finally:
sftp.close()
def remote_exists(cfg: Config, host: str, remote_path: str) -> bool:
client = _connect(cfg, host)
try:
@@ -321,15 +362,35 @@ def remote_exists(cfg: Config, host: str, remote_path: str) -> bool:
def remote_sha256(cfg: Config, host: str, remote_path: str) -> str | None:
out = run_ssh(
cfg,
host,
client = _connect(cfg, host)
try:
return remote_sha256_on(client, remote_path)
finally:
client.close()
def remote_sha256_on(client: paramiko.SSHClient, remote_path: str) -> str | None:
_stdin, stdout, stderr = client.exec_command(
f"sha256sum {shlex.quote(remote_path)} 2>/dev/null | awk '{{print $1}}'",
check=False,
).strip()
timeout=60,
)
del stderr
out = stdout.read().decode("utf-8", errors="replace").strip()
return out or None
def run_ssh_on(
client: paramiko.SSHClient, command: str, timeout: int = 60, check: bool = True
) -> str:
_stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
if check and code != 0:
raise CloudError(f"SSH `{command}` exit {code}: {err or out}")
return out
def _sftp_mkdirs(sftp, remote_dir: str) -> None:
if not remote_dir or remote_dir == "/":
return
+49 -35
View File
@@ -7,7 +7,13 @@ from pathlib import Path
from gpu_rent.config import Config
from gpu_rent.payload import has_payload, iter_payload_files, model_push_set, sha256_file
from gpu_rent.ssh_ops import get_file, put_file, remote_sha256, run_ssh
from gpu_rent.ssh_ops import (
get_file_on,
open_ssh,
put_file_on,
remote_sha256_on,
run_ssh_on,
)
Log = Callable[[str], None]
@@ -26,19 +32,22 @@ def push_tree(
return 0
files = model_push_set(local_root) if models else iter_payload_files(local_root)
sent = 0
for path in files:
rel = path.relative_to(local_root).as_posix()
remote = f"{remote_root.rstrip('/')}/{rel}"
local_hash = sha256_file(path)
remote_hash = remote_sha256(cfg, host, remote)
if remote_hash and remote_hash.lower() == local_hash.lower():
continue
if models and not _is_weight_name(path.name):
# sidecar: warn if we somehow got here without weight — still send
pass
log(f"push {rel}")
put_file(cfg, host, path, remote)
sent += 1
client = open_ssh(cfg, host)
try:
for path in files:
rel = path.relative_to(local_root).as_posix()
remote = f"{remote_root.rstrip('/')}/{rel}"
local_hash = sha256_file(path)
remote_hash = remote_sha256_on(client, remote)
if remote_hash and remote_hash.lower() == local_hash.lower():
continue
if models and not _is_weight_name(path.name):
pass
log(f"push {rel}")
put_file_on(client, path, remote)
sent += 1
finally:
client.close()
if sent == 0:
log(f"push {local_root.name}: всё уже на VM")
else:
@@ -48,29 +57,34 @@ def push_tree(
def _is_weight_name(name: str) -> bool:
lower = name.lower()
return lower.endswith((".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx"))
return lower.endswith(
(".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft", ".onnx")
)
def pull_tree(cfg: Config, host: str, remote_root: str, local_root: Path, log: Log) -> int:
listing = run_ssh(
cfg,
host,
f"find {remote_root} -type f 2>/dev/null | sed 's|^{remote_root}/||'",
check=False,
timeout=120,
)
names = [line.strip() for line in listing.splitlines() if line.strip()]
pulled = 0
for rel in names:
if rel.endswith("/.gitkeep") or rel.rsplit("/", 1)[-1] in {".gitkeep", "README.md"}:
continue
remote = f"{remote_root.rstrip('/')}/{rel}"
local = local_root / rel
remote_hash = remote_sha256(cfg, host, remote)
if local.is_file() and remote_hash and sha256_file(local).lower() == remote_hash.lower():
continue
log(f"pull {rel}")
get_file(cfg, host, remote, local)
pulled += 1
client = open_ssh(cfg, host)
try:
listing = run_ssh_on(
client,
f"find {remote_root} -type f 2>/dev/null | sed 's|^{remote_root}/||'",
check=False,
timeout=120,
)
names = [line.strip() for line in listing.splitlines() if line.strip()]
pulled = 0
for rel in names:
if rel.endswith("/.gitkeep") or rel.rsplit("/", 1)[-1] in {".gitkeep", "README.md"}:
continue
remote = f"{remote_root.rstrip('/')}/{rel}"
local = local_root / rel
remote_hash = remote_sha256_on(client, remote)
if local.is_file() and remote_hash and sha256_file(local).lower() == remote_hash.lower():
continue
log(f"pull {rel}")
get_file_on(client, remote, local)
pulled += 1
finally:
client.close()
log(f"pull Output: {pulled} файл(ов)" if pulled else "pull Output: нечего забирать")
return pulled
+76 -21
View File
@@ -15,6 +15,7 @@ from gpu_rent.cloud import (
)
from gpu_rent.config import Config
from gpu_rent.errors import CloudError, GpuRentError
from gpu_rent.llm_runtime import llm_local_port, llm_remote_port, normalize_runtime
from gpu_rent.os_client import connect
from gpu_rent.ssh_ops import wait_ssh
from gpu_rent.state import load_state, save_state, utc_now
@@ -67,27 +68,55 @@ def decide_watch(status: str | None, tunnel_alive: bool) -> WatchDecision:
return WatchDecision("reconnect", "туннель мёртв, сервер ACTIVE")
if st == "ACTIVE":
return WatchDecision("ok", "ACTIVE")
# transitional: BUILD, REBOOT, …
return WatchDecision("ok", f"ждём {st}")
def _start_forwarder(cfg: Config, host: str, local_port: int):
def tunnel_forwards(cfg: Config) -> list[tuple[int, int]]:
"""List of (local_port, remote_port). SwarmUI always; LLM if configured."""
pairs = [(cfg.swarmui_local_port, 7801)]
runtime = normalize_runtime(cfg.llm_runtime)
# Prefer state notes if provision recorded a different runtime this session.
state = load_state()
noted = (state.notes or {}).get("llm_runtime")
if noted:
try:
runtime = normalize_runtime(str(noted))
except ValueError:
pass
remote = llm_remote_port(runtime)
local = llm_local_port(cfg) if runtime != "none" else None
# llm_local_port uses cfg.llm_runtime — override by mutating check:
if runtime == "ollama":
local = cfg.ollama_local_port
remote = 11434
elif runtime == "llamacpp":
local = cfg.llamacpp_local_port
remote = 8080
if local and remote:
pairs.append((local, remote))
return pairs
def _start_forwarder(cfg: Config, host: str, forwards: list[tuple[int, int]] | None = None):
SSHTunnelForwarder = _ssh_tunnel_forwarder()
pairs = forwards or tunnel_forwards(cfg)
local_binds = [("127.0.0.1", loc) for loc, _ in pairs]
remote_binds = [("127.0.0.1", rem) for _, rem in pairs]
server = SSHTunnelForwarder(
(host, 22),
ssh_username=cfg.ssh_user,
ssh_pkey=str(cfg.ssh_private_key_path),
remote_bind_address=("127.0.0.1", 7801),
local_bind_address=("127.0.0.1", local_port),
remote_bind_addresses=remote_binds,
local_bind_addresses=local_binds,
set_keepalive=30,
)
try:
server.start()
except Exception as exc:
ports = ",".join(str(p[0]) for p in pairs)
raise CloudError(
f"не открыть туннель на {local_port}: {exc}. Порт занят локальным SwarmUI? "
"17801 должен быть свободен."
f"не открыть туннель на {ports}: {exc}. Порт занят?"
) from exc
return server
@@ -139,7 +168,7 @@ def _poll_nova(cfg: Config, log: Log) -> tuple[str | None, str]:
return server_status(server), server.id
except GpuRentError as exc:
log(f"watch: OpenStack временно недоступен ({exc})")
return "ACTIVE", "auth-soft-fail" # don't tear down on transient auth blip
return "ACTIVE", "auth-soft-fail"
def run_tunnel(
@@ -156,24 +185,49 @@ def run_tunnel(
except ImportError as exc:
raise CloudError("Нет sshtunnel. Переустанови пакет: pip install -e .") from exc
local_port = cfg.swarmui_local_port
forwards = tunnel_forwards(cfg)
current_host = host
log(f"туннель 127.0.0.1:{local_port} -> {current_host}:7801")
for loc, rem in forwards:
log(f"туннель 127.0.0.1:{loc} -> {current_host}:{rem}")
log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop")
log("watchdog: EXPIRED → unshelve + reconnect")
server = _start_forwarder(cfg, current_host, local_port)
url = f"http://127.0.0.1:{local_port}"
log(f"UI {url}")
log(f"API {url}/API/")
log(f"MCP {url}/mcp")
if open_browser:
webbrowser.open(url)
server = _start_forwarder(cfg, current_host, forwards)
swarm_url = f"http://127.0.0.1:{cfg.swarmui_local_port}"
log(f"UI {swarm_url}")
log(f"API {swarm_url}/API/")
log(f"MCP {swarm_url}/mcp")
runtime = normalize_runtime(cfg.llm_runtime)
state = load_state()
if (state.notes or {}).get("llm_runtime"):
try:
runtime = normalize_runtime(str(state.notes["llm_runtime"]))
except ValueError:
pass
if runtime == "ollama":
log(f"Ollama API http://127.0.0.1:{cfg.ollama_local_port} (OLLAMA_HOST=…)")
elif runtime == "llamacpp":
log(f"llama.cpp http://127.0.0.1:{cfg.llamacpp_local_port}")
if open_browser:
webbrowser.open(swarm_url)
state.phase = "ready_tunneled"
save_state(state)
from gpu_rent.local_watchdog import (
detach_lease_keep_gpu,
start_heartbeat_thread,
stop_heartbeat_thread,
watchdog_installed,
)
if watchdog_installed():
start_heartbeat_thread()
log(
"local-watchdog: heartbeat активен — аварийное закрытие "
"(не Ctrl+C) → stop после grace"
)
try:
if wait is not None:
wait()
@@ -183,7 +237,6 @@ def run_tunnel(
while True:
time.sleep(1)
if not server.is_active:
# fall through to poll immediately
next_poll = 0
if time.time() < next_poll:
continue
@@ -201,7 +254,7 @@ def run_tunnel(
log(f"reconnect: {decision.detail}")
_stop_forwarder(server)
try:
server = _start_forwarder(cfg, current_host, local_port)
server = _start_forwarder(cfg, current_host, forwards)
log(f"туннель снова на {current_host}")
except CloudError as exc:
log(f"reconnect не вышел: {exc}")
@@ -212,12 +265,14 @@ def run_tunnel(
_stop_forwarder(server)
try:
current_host = _recover_unshelve(cfg, log)
server = _start_forwarder(cfg, current_host, local_port)
log(f"туннель после unshelve → {current_host}:7801")
server = _start_forwarder(cfg, current_host, forwards)
log(f"туннель после unshelve → {current_host}")
except (CloudError, GpuRentError) as exc:
log(f"unshelve/reconnect fail: {exc}")
return
except KeyboardInterrupt:
detach_lease_keep_gpu()
log("туннель закрыт. GPU жив.")
finally:
stop_heartbeat_thread()
_stop_forwarder(server)
+3 -1
View File
@@ -39,7 +39,9 @@ def cost_and_risk_lines(cfg: Config, *, spot: bool, flavor_name: str) -> list[st
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",
"Ctrl+C на tunnel GPU не гасит — только gpu-rent stop или idle-killer. "
"Опционально: gpu-rent watchdog install — аварийное закрытие окна/ребут "
"после grace тоже stop (Ctrl+C по-прежнему detach)",
]