Refactor LLM runtime handling and enhance CLI documentation
- Updated `resolve_llm_runtime` to prioritize live configuration over legacy notes, ensuring accurate runtime resolution. - Enhanced `tunnel_forwards` to prefer current configuration for LLM runtime, improving tunnel setup logic. - Improved idle-killer logic to handle stale markers and provide clearer warnings in the status output. - Updated CLI documentation in `cli.md` to reflect changes in command behavior and runtime handling. - Enhanced tests to validate new runtime resolution logic and ensure proper handling of configuration states.
This commit is contained in:
+1
-1
@@ -130,7 +130,7 @@ Hold killer: gpu-rent hold
|
|||||||
В `gpu-rent.vars` (создаётся из example при первом запуске):
|
В `gpu-rent.vars` (создаётся из example при первом запуске):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Двойной клик / запуск без аргументов:
|
# Двойной клик / запуск лаунчера без аргументов (CLI сам по себе показывает help):
|
||||||
GPU_RENT_DEFAULT_ARGS=up --yes
|
GPU_RENT_DEFAULT_ARGS=up --yes
|
||||||
|
|
||||||
# Дописать ко всем вызовам:
|
# Дописать ко всем вызовам:
|
||||||
|
|||||||
+22
-2
@@ -12,7 +12,7 @@ gpu-rent up --llm llamacpp
|
|||||||
LLM_RUNTIME=ollama
|
LLM_RUNTIME=ollama
|
||||||
```
|
```
|
||||||
|
|
||||||
Без параметров `gpu-rent` / `gpu-rent up` (без `--yes`) спросит про LLM, если в vars ещё `none`.
|
Без параметров `gpu-rent` показывает help (не `up`). Double-click лаунчеры: `GPU_RENT_DEFAULT_ARGS=up --yes`. `gpu-rent up` без `--yes` спросит про LLM, если в vars ещё `none`. Полный doctor: `gpu-rent up -v`.
|
||||||
|
|
||||||
## Порты (только loopback + туннель)
|
## Порты (только loopback + туннель)
|
||||||
|
|
||||||
@@ -56,4 +56,24 @@ Community abliterate-модели без гарантий безопасност
|
|||||||
|
|
||||||
## Idle-killer
|
## Idle-killer
|
||||||
|
|
||||||
Busy также если идёт `ollama pull`, в Ollama есть loaded model, или llama.cpp занимает слоты.
|
Busy также если идёт `ollama pull` (маркер младше ~45 мин), в Ollama есть loaded model, или llama.cpp занимает слоты. Зависший маркер `.gpu-rent-ollama-pulling` старше 45 мин idle-killer сбрасывает.
|
||||||
|
|
||||||
|
## Supply-chain (install на VM)
|
||||||
|
|
||||||
|
Скрипты `install_ollama.sh` / `install_llamacpp.sh` по умолчанию тянут upstream **без pin** (Ollama: `curl|sh`; llama.cpp: GitHub `latest`). Это риск подмены артефакта.
|
||||||
|
|
||||||
|
Рекомендуется задать pin через env при bootstrap (или патч vars / future hooks):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ollama — GitHub release + checksum
|
||||||
|
OLLAMA_VERSION=0.6.5
|
||||||
|
OLLAMA_SHA256=<sha256 of ollama-linux-amd64.tgz>
|
||||||
|
|
||||||
|
# llama.cpp — tag или прямой URL + checksum
|
||||||
|
LLAMACPP_TAG=b4690
|
||||||
|
# или:
|
||||||
|
LLAMACPP_ASSET_URL=https://github.com/ggerganov/llama.cpp/releases/download/...
|
||||||
|
LLAMACPP_SHA256=<sha256 of archive>
|
||||||
|
```
|
||||||
|
|
||||||
|
Без этих переменных в логе будет `WARN` про отсутствие pin/checksum.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Project Review — 2026-08-21 (2)
|
||||||
|
|
||||||
|
## Prior Reviews Summary
|
||||||
|
|
||||||
|
> Based on the last 3 review files analysed in Phase 0 (only `2026-08-21-review-1.md` exists).
|
||||||
|
|
||||||
|
### Still Open (carried forward)
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Resolved Since Last Review
|
||||||
|
- [x] [source: 2026-08-21-review-1, tasks 1–13] All prior tasks closed (wait_ssh, bootstrapped clear, access_rules attempt, GIT scrub, SG prune, CIDR example, SSH reuse in sync, light bootstrap flag, phase bootstrapping, Civitai non-blocking, session tests, journalctl logs, architecture docs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Code Quality
|
||||||
|
|
||||||
|
### SOLID
|
||||||
|
- `session.cmd_up` / `_bind_access` still orchestrate cloud + SSH + bootstrap + provision + LLM + snapshot + notify (god-flow). Acceptable for a small CLI, but growing with LLM.
|
||||||
|
- New modules (`llm_runtime`, `setup_wizard`, `access_card`) are reasonably scoped.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- `arm_idle_killer` still opens many short SSH sessions (`put_text` / `run_ssh` per step) — sync_files was fixed in review-1; idle-arm was not.
|
||||||
|
- Every `up` still runs full `doctor` (Keystone + Glance + …) even when user already ran doctor.
|
||||||
|
|
||||||
|
### Correctness & Bugs
|
||||||
|
- Ollama pull skip can treat a different tag of the same model as “already present” (`listed()` stores bare name; match uses `startswith`).
|
||||||
|
- Pull marker `.gpu-rent-ollama-pulling` can stick after SSH timeout → idle-killer forever busy.
|
||||||
|
- `provision_llm` exceptions swallowed → tunnel/access card can show LLM URLs while install failed; `notes.llm_runtime` may stay stale.
|
||||||
|
- Light bootstrap requires `state.bootstrapped` **and** VM marker; `stop` clears local `bootstrapped` → almost always full apt after stop→up (conflicts with review-1 perf goal).
|
||||||
|
- `clone_ext` `SystemExit(2)` on origin mismatch skips token file unlink in `main()`.
|
||||||
|
- App cred `access_rules` include `/v2.1/servers/*` (any server) plus unrestricted fallback; not revoked on `stop`.
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Dead branch in `push_tree` (`_is_weight_name` + empty `pass`).
|
||||||
|
- `OllamaModelEntry.default` written/parsed but unused in pull.
|
||||||
|
- Invalid `LLM_RUNTIME` in env silently coerced to `none`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Logical Consistency
|
||||||
|
|
||||||
|
### Domain & Application Layer
|
||||||
|
- Docs/architecture updated for LLM; product still SwarmUI-first with opt-in LLM — consistent with decisions.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
- Tunnel / `open --llm` / access card prefer `state.notes["llm_runtime"]` over live config — desync if provision failed or vars changed without re-provision.
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- `LLM_RUNTIME=none` does not stop/disable remote LLM systemd units → GPU may stay occupied.
|
||||||
|
- Soft-fail `wait_backend_idle` / soft-fail `arm_idle_killer` still yield `ready_cloud` / “ready” UX while killer may be blind.
|
||||||
|
|
||||||
|
### Consistency
|
||||||
|
- Error handling: mix of hard `CloudError` (bootstrap) vs soft log (LLM, killer, ready). Uneven.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: UI/UX (CLI)
|
||||||
|
|
||||||
|
### Usability
|
||||||
|
- Bare `gpu-rent` invokes full interactive `up` (expensive surprise).
|
||||||
|
- Full doctor table on every `up` is noisy.
|
||||||
|
- Interactive Ollama preset typo raises raw `ValueError`.
|
||||||
|
- Access card at tunnel end is a clear UX win.
|
||||||
|
|
||||||
|
### Visual & Consistency
|
||||||
|
- Rich access panel is coherent; toast text still SwarmUI-only (minor).
|
||||||
|
|
||||||
|
### Interaction & Feedback
|
||||||
|
- `--no-tunnel` still shows access card with tunnel hint — good.
|
||||||
|
- Soft LLM failure gives little signal beyond a log line.
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
- N/A (CLI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. [Security] Narrow idle-killer access_rules to this `server_id` only (drop `/servers/*`); fail closed or warn loudly instead of unrestricted fallback; revoke app cred on `stop`/`destroy` — `src/gpu_rent/idle_killer.py` line 57
|
||||||
|
- [x] 2. [Security] Ensure `GIT_TOKEN` file is always removed (`try/finally` around `main`, convert `SystemExit` paths to raised errors) — `src/gpu_rent/remote/clone_ext.py` line 116
|
||||||
|
- [x] 3. [Bug] Fix Ollama pull skip: match exact tag only (do not skip `foo:7b` because `foo:3b` exists) — `src/gpu_rent/remote/ollama_pull.py` line 14
|
||||||
|
- [x] 4. [Bug] Age out or clear stale `.gpu-rent-ollama-pulling` in idle-killer (e.g. ignore marker older than N minutes) — `src/gpu_rent/remote/idle_killer.py` line 93
|
||||||
|
- [x] 5. [Logic] Do not swallow `provision_llm` failures; update `notes.llm_runtime` only after success; prefer cfg over stale notes when they disagree — `src/gpu_rent/provision.py` line 340
|
||||||
|
- [x] 6. [Logic] When `LLM_RUNTIME=none` (or switching runtime), stop/disable previous `gpu-rent-ollama` / `gpu-rent-llamacpp` units — `src/gpu_rent/provision.py` line 262
|
||||||
|
- [x] 7. [Performance] Light bootstrap when VM marker exists even if local `bootstrapped=False` after stop (or restore bootstrapped from marker after SSH) — `src/gpu_rent/session.py` line 101
|
||||||
|
- [x] 8. [UX] Bare `gpu-rent` should show help or a short menu, not auto-`up`; keep launcher `GPU_RENT_DEFAULT_ARGS` for double-click — `src/gpu_rent/cli.py` line 53
|
||||||
|
- [x] 9. [UX] Quiet doctor on `up` (summary / only failures) unless `--verbose` — `src/gpu_rent/cli.py` line 403
|
||||||
|
- [x] 10. [Logic] Surface idle-killer arm failure as warning in status/access card, not silent soft-log only — `src/gpu_rent/provision.py` line 344
|
||||||
|
- [x] 11. [CodeQuality] Remove dead `_is_weight_name` pass in `push_tree`; use `OllamaModelEntry.default` or drop the field — `src/gpu_rent/sync_files.py` line 44
|
||||||
|
- [x] 12. [Security] Pin/checksum Ollama install script and llama.cpp release asset (or document supply-chain risk) — `src/gpu_rent/remote/install_ollama.sh` line 20
|
||||||
+33
-12
@@ -25,14 +25,17 @@ class AccessLink:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_llm_runtime(cfg: Config) -> str:
|
def resolve_llm_runtime(cfg: Config) -> str:
|
||||||
|
"""Live config wins; notes only if cfg is none (legacy session hint)."""
|
||||||
runtime = normalize_runtime(cfg.llm_runtime)
|
runtime = normalize_runtime(cfg.llm_runtime)
|
||||||
|
if runtime != "none":
|
||||||
|
return runtime
|
||||||
try:
|
try:
|
||||||
noted = (load_state().notes or {}).get("llm_runtime")
|
noted = (load_state().notes or {}).get("llm_runtime")
|
||||||
if noted:
|
if noted:
|
||||||
runtime = normalize_runtime(str(noted))
|
return normalize_runtime(str(noted))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return runtime
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
|
def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
|
||||||
@@ -141,17 +144,35 @@ def render_access_panel(
|
|||||||
if host:
|
if host:
|
||||||
subtitle.append(f" · FIP {host}", style="dim")
|
subtitle.append(f" · FIP {host}", style="dim")
|
||||||
|
|
||||||
body = Group(
|
warn_bits: list[str] = []
|
||||||
subtitle,
|
try:
|
||||||
Text(""),
|
notes = load_state().notes or {}
|
||||||
table,
|
if notes.get("idle_killer") == "failed":
|
||||||
Text(""),
|
warn_bits.append(
|
||||||
Text("команды", style="bold"),
|
"idle-killer НЕ вооружён — GPU может крутиться без авто-stop"
|
||||||
cmds,
|
)
|
||||||
Text(""),
|
if notes.get("llm_error"):
|
||||||
Text("Cursor MCP (вставь в mcp.json — файл сам не трогаем)", style="bold"),
|
warn_bits.append(f"LLM ошибка: {str(notes['llm_error'])[:120]}")
|
||||||
mcp,
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parts: list = [subtitle, Text("")]
|
||||||
|
if warn_bits:
|
||||||
|
for w in warn_bits:
|
||||||
|
parts.append(Text(f"⚠ {w}", style="bold red"))
|
||||||
|
parts.append(Text(""))
|
||||||
|
parts.extend(
|
||||||
|
[
|
||||||
|
table,
|
||||||
|
Text(""),
|
||||||
|
Text("команды", style="bold"),
|
||||||
|
cmds,
|
||||||
|
Text(""),
|
||||||
|
Text("Cursor MCP (вставь в mcp.json — файл сам не трогаем)", style="bold"),
|
||||||
|
mcp,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
body = Group(*parts)
|
||||||
return Panel(
|
return Panel(
|
||||||
body,
|
body,
|
||||||
title=f"[bold]{title}[/bold]",
|
title=f"[bold]{title}[/bold]",
|
||||||
|
|||||||
+44
-24
@@ -33,7 +33,7 @@ if sys.platform == "win32":
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
invoke_without_command=True,
|
no_args_is_help=True,
|
||||||
pretty_exceptions_enable=False,
|
pretty_exceptions_enable=False,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
|
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
|
||||||
@@ -43,16 +43,12 @@ console = Console(highlight=False, legacy_windows=False)
|
|||||||
_DEBUG = False
|
_DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
@app.callback(invoke_without_command=True)
|
@app.callback()
|
||||||
def _root(
|
def _root(
|
||||||
ctx: typer.Context,
|
|
||||||
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
|
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
|
||||||
) -> None:
|
) -> None:
|
||||||
global _DEBUG
|
global _DEBUG
|
||||||
_DEBUG = debug
|
_DEBUG = debug
|
||||||
if ctx.invoked_subcommand is None:
|
|
||||||
# Без подкоманды → interactive up (как «запуск без параметров»).
|
|
||||||
ctx.invoke(up)
|
|
||||||
|
|
||||||
|
|
||||||
def _die(exc: BaseException) -> None:
|
def _die(exc: BaseException) -> None:
|
||||||
@@ -62,7 +58,24 @@ def _die(exc: BaseException) -> None:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _print_checks(checks) -> int:
|
def _print_checks(checks, *, quiet: bool = False) -> int:
|
||||||
|
failed = blocking_failed(checks)
|
||||||
|
if quiet:
|
||||||
|
if failed:
|
||||||
|
console.print("[red]doctor: блокирующие проблемы[/red]")
|
||||||
|
for check in failed:
|
||||||
|
console.print(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:
|
||||||
|
console.print(f"[yellow]doctor ok[/yellow] ({len(checks)}), предупреждения:")
|
||||||
|
for check in warns:
|
||||||
|
console.print(f" • {check.name}: {check.detail}")
|
||||||
|
else:
|
||||||
|
console.print(f"[green]doctor ok[/green] ({len(checks)} проверок)")
|
||||||
|
return 0
|
||||||
|
|
||||||
table = Table(title="gpu-rent doctor", show_lines=False)
|
table = Table(title="gpu-rent doctor", show_lines=False)
|
||||||
table.add_column("ok")
|
table.add_column("ok")
|
||||||
table.add_column("проверка")
|
table.add_column("проверка")
|
||||||
@@ -73,7 +86,6 @@ def _print_checks(checks) -> int:
|
|||||||
block = "да" if check.blocking else "нет"
|
block = "да" if check.blocking else "нет"
|
||||||
table.add_row(mark, check.name, block, check.detail)
|
table.add_row(mark, check.name, block, check.detail)
|
||||||
console.print(table)
|
console.print(table)
|
||||||
failed = blocking_failed(checks)
|
|
||||||
if failed:
|
if failed:
|
||||||
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
|
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
|
||||||
_print_next_steps(failed)
|
_print_next_steps(failed)
|
||||||
@@ -246,6 +258,12 @@ def status() -> None:
|
|||||||
from gpu_rent.idle_killer import killer_status_lines
|
from gpu_rent.idle_killer import killer_status_lines
|
||||||
|
|
||||||
table.add_row("idle-killer", "; ".join(killer_status_lines(cfg, state.floating_ip)))
|
table.add_row("idle-killer", "; ".join(killer_status_lines(cfg, state.floating_ip)))
|
||||||
|
note_k = (state.notes or {}).get("idle_killer")
|
||||||
|
if note_k == "failed":
|
||||||
|
err = (state.notes or {}).get("idle_killer_error") or ""
|
||||||
|
table.add_row("idle-killer arm", f"[red]FAILED[/red] {err}"[:120])
|
||||||
|
elif note_k == "armed":
|
||||||
|
table.add_row("idle-killer arm", "ok (в сессии)")
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
table.add_row("диск used/free", f"SSH: {exc}")
|
table.add_row("диск used/free", f"SSH: {exc}")
|
||||||
table.add_row("idle-killer", "нет SSH")
|
table.add_row("idle-killer", "нет SSH")
|
||||||
@@ -256,16 +274,17 @@ def status() -> None:
|
|||||||
from gpu_rent.local_watchdog import watchdog_status_lines
|
from gpu_rent.local_watchdog import watchdog_status_lines
|
||||||
|
|
||||||
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
|
table.add_row("local-watchdog", "; ".join(watchdog_status_lines()))
|
||||||
from gpu_rent.llm_runtime import normalize_runtime
|
from gpu_rent.access_card import resolve_llm_runtime
|
||||||
|
|
||||||
rt = normalize_runtime(cfg.llm_runtime)
|
rt = resolve_llm_runtime(cfg)
|
||||||
noted = (state.notes or {}).get("llm_runtime")
|
noted = (state.notes or {}).get("llm_runtime")
|
||||||
if noted:
|
llm_err = (state.notes or {}).get("llm_error")
|
||||||
rt = f"{rt} (сессия: {noted})"
|
detail = f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}"
|
||||||
table.add_row(
|
if noted and noted != rt:
|
||||||
"LLM",
|
detail += f" (notes: {noted})"
|
||||||
f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}",
|
if llm_err:
|
||||||
)
|
detail += f" [red]err: {llm_err[:80]}[/red]"
|
||||||
|
table.add_row("LLM", detail)
|
||||||
|
|
||||||
if cfg.auth_ok:
|
if cfg.auth_ok:
|
||||||
try:
|
try:
|
||||||
@@ -296,14 +315,9 @@ def open(
|
|||||||
cfg = load_config(require_auth=False)
|
cfg = load_config(require_auth=False)
|
||||||
if llm:
|
if llm:
|
||||||
from gpu_rent.llm_runtime import normalize_runtime
|
from gpu_rent.llm_runtime import normalize_runtime
|
||||||
|
from gpu_rent.access_card import resolve_llm_runtime
|
||||||
|
|
||||||
runtime = normalize_runtime(cfg.llm_runtime)
|
runtime = resolve_llm_runtime(cfg)
|
||||||
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":
|
if runtime == "ollama":
|
||||||
port = cfg.ollama_local_port
|
port = cfg.ollama_local_port
|
||||||
elif runtime == "llamacpp":
|
elif runtime == "llamacpp":
|
||||||
@@ -381,6 +395,12 @@ def up(
|
|||||||
"--no-update",
|
"--no-update",
|
||||||
help="Не делать git pull SwarmUI и установленных extensions",
|
help="Не делать git pull SwarmUI и установленных extensions",
|
||||||
),
|
),
|
||||||
|
verbose: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--verbose",
|
||||||
|
"-v",
|
||||||
|
help="Полный doctor-таблица на up (по умолчанию кратко)",
|
||||||
|
),
|
||||||
llm: Optional[str] = typer.Option(
|
llm: Optional[str] = typer.Option(
|
||||||
None, "--llm", help="none|ollama|llamacpp (override LLM_RUNTIME)"
|
None, "--llm", help="none|ollama|llamacpp (override LLM_RUNTIME)"
|
||||||
),
|
),
|
||||||
@@ -401,7 +421,7 @@ def up(
|
|||||||
from gpu_rent.paths import vars_path
|
from gpu_rent.paths import vars_path
|
||||||
|
|
||||||
checks = run_doctor()
|
checks = run_doctor()
|
||||||
code = _print_checks(checks)
|
code = _print_checks(checks, quiet=not verbose)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
cfg = load_config(require_auth=True)
|
cfg = load_config(require_auth=True)
|
||||||
|
|||||||
+21
-39
@@ -48,56 +48,38 @@ def create_application_credential(conn, cfg: Config, server_id: str, log: Log) -
|
|||||||
revoke_old_credentials(conn, log)
|
revoke_old_credentials(conn, log)
|
||||||
secret = secrets.token_urlsafe(32)
|
secret = secrets.token_urlsafe(32)
|
||||||
name = f"{CRED_NAME_PREFIX}-{server_id[:8]}"
|
name = f"{CRED_NAME_PREFIX}-{server_id[:8]}"
|
||||||
|
# Only this compute — never /servers/* (would allow deleting any VM in the project).
|
||||||
|
access_rules = [
|
||||||
|
{
|
||||||
|
"service": "compute",
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": f"/v2.1/servers/{server_id}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "compute",
|
||||||
|
"method": "GET",
|
||||||
|
"path": f"/v2.1/servers/{server_id}",
|
||||||
|
},
|
||||||
|
]
|
||||||
try:
|
try:
|
||||||
ac = conn.identity.create_application_credential(
|
ac = conn.identity.create_application_credential(
|
||||||
user=user_id,
|
user=user_id,
|
||||||
name=name,
|
name=name,
|
||||||
secret=secret,
|
secret=secret,
|
||||||
description="gpu-rent idle-killer: delete this compute",
|
description="gpu-rent idle-killer: delete this compute only",
|
||||||
access_rules=[
|
access_rules=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:
|
except Exception as exc:
|
||||||
# Selectel / older Keystone may reject access_rules — fall back unrestricted delete.
|
raise CloudError(
|
||||||
log(f"app cred с access_rules не вышло ({exc}); пробуем без правил")
|
f"не создать application credential с access_rules (только этот server): {exc}. "
|
||||||
try:
|
"Без узких правил idle-killer не вооружаем (fail closed). "
|
||||||
ac = conn.identity.create_application_credential(
|
"Нужны права identity:application_credential_create на сервисного пользователя."
|
||||||
user=user_id,
|
) from exc
|
||||||
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_id = getattr(ac, "id", None) or (ac.get("id") if isinstance(ac, dict) else None)
|
||||||
ac_secret = getattr(ac, "secret", None) or secret
|
ac_secret = getattr(ac, "secret", None) or secret
|
||||||
if not ac_id:
|
if not ac_id:
|
||||||
raise CloudError("application credential создан без id")
|
raise CloudError("application credential создан без id")
|
||||||
log(f"application credential {name}")
|
log(f"application credential {name} (DELETE/GET только {server_id[:12]}…)")
|
||||||
return {
|
return {
|
||||||
"auth_url": cfg.os_auth_url,
|
"auth_url": cfg.os_auth_url,
|
||||||
"project_id": cfg.os_project_id,
|
"project_id": cfg.os_project_id,
|
||||||
|
|||||||
@@ -260,13 +260,29 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
|
|||||||
from gpu_rent.state import load_state, save_state
|
from gpu_rent.state import load_state, save_state
|
||||||
|
|
||||||
runtime = normalize_runtime(cfg.llm_runtime)
|
runtime = normalize_runtime(cfg.llm_runtime)
|
||||||
|
|
||||||
|
def _stop_units(*names: str) -> None:
|
||||||
|
for name in names:
|
||||||
|
run_ssh(
|
||||||
|
cfg,
|
||||||
|
host,
|
||||||
|
f"sudo -n systemctl stop {name} 2>/dev/null; "
|
||||||
|
f"sudo -n systemctl disable {name} 2>/dev/null || true",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always drop the other runtime so VRAM is not held by a leftover unit.
|
||||||
if runtime == "none":
|
if runtime == "none":
|
||||||
|
log("LLM: none — останавливаю gpu-rent-ollama / gpu-rent-llamacpp если были")
|
||||||
|
_stop_units("gpu-rent-ollama", "gpu-rent-llamacpp")
|
||||||
st = load_state()
|
st = load_state()
|
||||||
st.notes = dict(st.notes or {})
|
st.notes = dict(st.notes or {})
|
||||||
st.notes["llm_runtime"] = "none"
|
st.notes["llm_runtime"] = "none"
|
||||||
|
st.notes.pop("llm_error", None)
|
||||||
save_state(st)
|
save_state(st)
|
||||||
return
|
return
|
||||||
if runtime == "ollama":
|
if runtime == "ollama":
|
||||||
|
_stop_units("gpu-rent-llamacpp")
|
||||||
log("LLM: ставим/запускаем Ollama")
|
log("LLM: ставим/запускаем Ollama")
|
||||||
run_script_sudo(
|
run_script_sudo(
|
||||||
cfg,
|
cfg,
|
||||||
@@ -278,6 +294,9 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
|
|||||||
log=log,
|
log=log,
|
||||||
)
|
)
|
||||||
entries = parse_ollama_models(cfg.ollama_models_manifest)
|
entries = parse_ollama_models(cfg.ollama_models_manifest)
|
||||||
|
defaults = [e.name for e in entries if e.default]
|
||||||
|
if defaults:
|
||||||
|
log(f"Ollama preferred: {defaults[0]}")
|
||||||
names = [e.name for e in entries]
|
names = [e.name for e in entries]
|
||||||
if not names:
|
if not names:
|
||||||
log("ollama-models.yaml пуст — pull skip")
|
log("ollama-models.yaml пуст — pull skip")
|
||||||
@@ -293,6 +312,7 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
|
|||||||
log=log,
|
log=log,
|
||||||
)
|
)
|
||||||
elif runtime == "llamacpp":
|
elif runtime == "llamacpp":
|
||||||
|
_stop_units("gpu-rent-ollama")
|
||||||
log("LLM: ставим/запускаем llama.cpp server")
|
log("LLM: ставим/запускаем llama.cpp server")
|
||||||
run_script_sudo(
|
run_script_sudo(
|
||||||
cfg,
|
cfg,
|
||||||
@@ -306,6 +326,7 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
|
|||||||
st = load_state()
|
st = load_state()
|
||||||
st.notes = dict(st.notes or {})
|
st.notes = dict(st.notes or {})
|
||||||
st.notes["llm_runtime"] = runtime
|
st.notes["llm_runtime"] = runtime
|
||||||
|
st.notes.pop("llm_error", None)
|
||||||
save_state(st)
|
save_state(st)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,15 +358,39 @@ def provision_vm(
|
|||||||
if cfg.pull_output:
|
if cfg.pull_output:
|
||||||
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
|
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
|
||||||
ensure_swarmui_running(cfg, host, log, restart=restart)
|
ensure_swarmui_running(cfg, host, log, restart=restart)
|
||||||
|
|
||||||
|
from gpu_rent.state import load_state, save_state
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provision_llm(cfg, host, log)
|
provision_llm(cfg, host, log)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"LLM runtime: {exc}")
|
st = load_state()
|
||||||
|
st.notes = dict(st.notes or {})
|
||||||
|
st.notes["llm_error"] = str(exc)[:500]
|
||||||
|
# Do not claim success — leave previous notes.llm_runtime or clear to none.
|
||||||
|
st.notes["llm_runtime"] = "none"
|
||||||
|
save_state(st)
|
||||||
|
raise CloudError(f"LLM runtime: {exc}") from exc
|
||||||
|
|
||||||
if conn is not None and server_id:
|
if conn is not None and server_id:
|
||||||
try:
|
try:
|
||||||
arm_idle_killer(cfg, host, conn, server_id, log)
|
arm_idle_killer(cfg, host, conn, server_id, log)
|
||||||
|
st = load_state()
|
||||||
|
st.notes = dict(st.notes or {})
|
||||||
|
st.notes["idle_killer"] = "armed"
|
||||||
|
st.notes.pop("idle_killer_error", None)
|
||||||
|
save_state(st)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
log(f"idle-killer: {exc}")
|
log(f"idle-killer: {exc}")
|
||||||
|
st = load_state()
|
||||||
|
st.notes = dict(st.notes or {})
|
||||||
|
st.notes["idle_killer"] = "failed"
|
||||||
|
st.notes["idle_killer_error"] = str(exc)[:500]
|
||||||
|
save_state(st)
|
||||||
|
log(
|
||||||
|
"⚠ idle-killer НЕ вооружён — GPU может тарифицироваться без авто-stop. "
|
||||||
|
"См. status / docs/setup.md"
|
||||||
|
)
|
||||||
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
|
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
|
||||||
from gpu_rent.llm_runtime import normalize_runtime
|
from gpu_rent.llm_runtime import normalize_runtime
|
||||||
|
|
||||||
|
|||||||
@@ -113,8 +113,7 @@ def clone_one(job: dict, token: str, update: bool) -> None:
|
|||||||
if dest.is_dir() and (dest / ".git").is_dir():
|
if dest.is_dir() and (dest / ".git").is_dir():
|
||||||
origin = out(["git", "-C", str(dest), "remote", "get-url", "origin"])
|
origin = out(["git", "-C", str(dest), "remote", "get-url", "origin"])
|
||||||
if strip_auth(origin) != strip_auth(url):
|
if strip_auth(origin) != strip_auth(url):
|
||||||
print(f"FAIL origin mismatch {dest}: {origin} != {url}", file=sys.stderr)
|
raise RuntimeError(f"origin mismatch {dest}: {origin} != {url}")
|
||||||
raise SystemExit(2)
|
|
||||||
if token and strip_auth(origin) == strip_auth(url):
|
if token and strip_auth(origin) == strip_auth(url):
|
||||||
run(["git", "-C", str(dest), "remote", "set-url", "origin", authed])
|
run(["git", "-C", str(dest), "remote", "set-url", "origin", authed])
|
||||||
if not update:
|
if not update:
|
||||||
@@ -127,8 +126,7 @@ def clone_one(job: dict, token: str, update: bool) -> None:
|
|||||||
scrub_origin(dest, url)
|
scrub_origin(dest, url)
|
||||||
return
|
return
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
print(f"FAIL {dest} exists but is not a git repo", file=sys.stderr)
|
raise RuntimeError(f"{dest} exists but is not a git repo")
|
||||||
raise SystemExit(2)
|
|
||||||
try:
|
try:
|
||||||
if is_sha(ref):
|
if is_sha(ref):
|
||||||
run(["git", "clone", "--recurse-submodules", authed, str(dest)])
|
run(["git", "clone", "--recurse-submodules", authed, str(dest)])
|
||||||
@@ -184,26 +182,31 @@ def main() -> int:
|
|||||||
print(f"extensions update={'on' if update else 'off'}")
|
print(f"extensions update={'on' if update else 'off'}")
|
||||||
failed = 0
|
failed = 0
|
||||||
known: set[str] = set()
|
known: set[str] = set()
|
||||||
for job in jobs:
|
|
||||||
try:
|
|
||||||
dest = str(Path(job["dest"]))
|
|
||||||
known.add(dest)
|
|
||||||
clone_one(job, token, update)
|
|
||||||
except Exception as exc:
|
|
||||||
failed += 1
|
|
||||||
print(f"FAIL {job.get('dest')}: {exc}", file=sys.stderr)
|
|
||||||
try:
|
try:
|
||||||
update_installed_extras(known, update, token=token)
|
for job in jobs:
|
||||||
except Exception:
|
try:
|
||||||
failed += 1
|
dest = str(Path(job["dest"]))
|
||||||
if TOKEN_PATH.is_file():
|
known.add(dest)
|
||||||
TOKEN_PATH.unlink()
|
clone_one(job, token, update)
|
||||||
if failed:
|
except Exception as exc:
|
||||||
return 1
|
failed += 1
|
||||||
MARKER.parent.mkdir(parents=True, exist_ok=True)
|
print(f"FAIL {job.get('dest')}: {exc}", file=sys.stderr)
|
||||||
MARKER.write_text("ok\n", encoding="utf-8")
|
try:
|
||||||
print("extensions ok")
|
update_installed_extras(known, update, token=token)
|
||||||
return 0
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
if failed:
|
||||||
|
return 1
|
||||||
|
MARKER.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
MARKER.write_text("ok\n", encoding="utf-8")
|
||||||
|
print("extensions ok")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
if TOKEN_PATH.is_file():
|
||||||
|
try:
|
||||||
|
TOKEN_PATH.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -90,8 +90,24 @@ def swarm_busy(swarm_url: str, timeout: float = 8.0) -> tuple[bool, str]:
|
|||||||
|
|
||||||
def llm_busy(timeout: float = 3.0) -> tuple[bool, str]:
|
def llm_busy(timeout: float = 3.0) -> tuple[bool, str]:
|
||||||
"""Ollama pull / loaded models or llama.cpp with a model count as busy."""
|
"""Ollama pull / loaded models or llama.cpp with a model count as busy."""
|
||||||
if (DATA / ".gpu-rent-ollama-pulling").is_file():
|
pull_marker = DATA / ".gpu-rent-ollama-pulling"
|
||||||
return True, "ollama pulling"
|
if pull_marker.is_file():
|
||||||
|
try:
|
||||||
|
ts = float(pull_marker.read_text(encoding="utf-8").strip().split()[0])
|
||||||
|
age = time.time() - ts
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
age = 0.0
|
||||||
|
ts = 0.0
|
||||||
|
# Stale marker after SSH kill / crash — don't block billing forever.
|
||||||
|
max_age = 45 * 60
|
||||||
|
if age > max_age:
|
||||||
|
try:
|
||||||
|
pull_marker.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
log(f"cleared stale ollama-pulling marker age={int(age)}s")
|
||||||
|
else:
|
||||||
|
return True, f"ollama pulling ({int(age)}s)"
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
# Ollama: any running model
|
# Ollama: any running model
|
||||||
try:
|
try:
|
||||||
@@ -104,21 +120,18 @@ def llm_busy(timeout: float = 3.0) -> tuple[bool, str]:
|
|||||||
return True, f"ollama running {names}"
|
return True, f"ollama running {names}"
|
||||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError, OSError):
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
# llama.cpp OpenAI models endpoint — if server up and lists a model, treat lightly:
|
# llama.cpp: slots in use
|
||||||
# only busy if /health ok AND we recently had activity is hard; use loaded via props.
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request("http://127.0.0.1:8080/health", method="GET")
|
req = urllib.request.Request("http://127.0.0.1:8080/health", method="GET")
|
||||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||||
if getattr(resp, "status", 200) == 200:
|
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:
|
try:
|
||||||
req2 = urllib.request.Request("http://127.0.0.1:8080/props", method="GET")
|
req2 = urllib.request.Request("http://127.0.0.1:8080/props", method="GET")
|
||||||
with urllib.request.urlopen(req2, timeout=timeout, context=ctx) as resp2:
|
with urllib.request.urlopen(req2, timeout=timeout, context=ctx) as resp2:
|
||||||
props = json.loads(resp2.read().decode("utf-8"))
|
props = json.loads(resp2.read().decode("utf-8"))
|
||||||
in_use = int(props.get("total_slots") or 0) - int(
|
total = int(props.get("total_slots") or 0)
|
||||||
props.get("available_slots") or props.get("total_slots") or 0
|
avail = int(props.get("available_slots") or total)
|
||||||
)
|
in_use = total - avail if total else 0
|
||||||
if in_use > 0:
|
if in_use > 0:
|
||||||
return True, f"llamacpp slots_in_use={in_use}"
|
return True, f"llamacpp slots_in_use={in_use}"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -22,19 +22,26 @@ chown -R "${SWARM_USER}:${SWARM_USER}" "$LLAMA_ROOT"
|
|||||||
SERVER_BIN="${BIN_DIR}/llama-server"
|
SERVER_BIN="${BIN_DIR}/llama-server"
|
||||||
if [[ ! -x "$SERVER_BIN" ]]; then
|
if [[ ! -x "$SERVER_BIN" ]]; then
|
||||||
log "скачиваю llama-server (cuda) release…"
|
log "скачиваю llama-server (cuda) release…"
|
||||||
# Pin a known-good release asset pattern; fallback to CPU if CUDA asset missing.
|
# Pin: LLAMACPP_TAG=b4690 LLAMACPP_ASSET_URL=... LLAMACPP_SHA256=...
|
||||||
|
# Без pin — latest release (supply-chain risk; docs/llm.md).
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
cd "$TMP"
|
cd "$TMP"
|
||||||
API="https://api.github.com/repos/ggerganov/llama.cpp/releases/latest"
|
LLAMACPP_TAG="${LLAMACPP_TAG:-}"
|
||||||
URL="$(curl -fsSL "$API" | python3 -c '
|
LLAMACPP_ASSET_URL="${LLAMACPP_ASSET_URL:-}"
|
||||||
import json,sys,re
|
LLAMACPP_SHA256="${LLAMACPP_SHA256:-}"
|
||||||
|
if [[ -n "$LLAMACPP_ASSET_URL" ]]; then
|
||||||
|
URL="$LLAMACPP_ASSET_URL"
|
||||||
|
elif [[ -n "$LLAMACPP_TAG" ]]; then
|
||||||
|
API="https://api.github.com/repos/ggerganov/llama.cpp/releases/tags/${LLAMACPP_TAG}"
|
||||||
|
URL="$(curl -fsSL "$API" | python3 -c '
|
||||||
|
import json,sys
|
||||||
data=json.load(sys.stdin)
|
data=json.load(sys.stdin)
|
||||||
assets=data.get("assets") or []
|
assets=data.get("assets") or []
|
||||||
prefer=[]
|
prefer=[]
|
||||||
for a in assets:
|
for a in assets:
|
||||||
n=(a.get("name") or "").lower()
|
n=(a.get("name") or "").lower()
|
||||||
u=a.get("browser_download_url") or ""
|
u=a.get("browser_download_url") or ""
|
||||||
if not u.endswith(".zip") and not u.endswith(".tar.gz"):
|
if not (u.endswith(".zip") or u.endswith(".tar.gz")):
|
||||||
continue
|
continue
|
||||||
if "cuda" in n or "cu12" in n or "cu11" in n:
|
if "cuda" in n or "cu12" in n or "cu11" in n:
|
||||||
prefer.append(u)
|
prefer.append(u)
|
||||||
@@ -42,12 +49,37 @@ for a in assets:
|
|||||||
prefer.append(u)
|
prefer.append(u)
|
||||||
print(prefer[0] if prefer else "")
|
print(prefer[0] if prefer else "")
|
||||||
')"
|
')"
|
||||||
|
else
|
||||||
|
log "WARN: LLAMACPP_TAG/ASSET_URL не заданы — берём latest (нет pin). См. docs/llm.md"
|
||||||
|
API="https://api.github.com/repos/ggerganov/llama.cpp/releases/latest"
|
||||||
|
URL="$(curl -fsSL "$API" | python3 -c '
|
||||||
|
import json,sys
|
||||||
|
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") or 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 "")
|
||||||
|
')"
|
||||||
|
fi
|
||||||
if [[ -z "$URL" ]]; then
|
if [[ -z "$URL" ]]; then
|
||||||
log "не нашёл бинарь в latest release — поставь llama-server вручную в ${SERVER_BIN}"
|
log "не нашёл бинарь в release — поставь llama-server вручную в ${SERVER_BIN}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
log "asset $URL"
|
log "asset $URL"
|
||||||
curl -fL "$URL" -o pkg.bin
|
curl -fL "$URL" -o pkg.bin
|
||||||
|
if [[ -n "$LLAMACPP_SHA256" ]]; then
|
||||||
|
echo "${LLAMACPP_SHA256} pkg.bin" | sha256sum -c -
|
||||||
|
else
|
||||||
|
log "WARN: LLAMACPP_SHA256 не задан — checksum skip"
|
||||||
|
fi
|
||||||
if file pkg.bin | grep -qi zip; then
|
if file pkg.bin | grep -qi zip; then
|
||||||
apt-get install -y -qq unzip >/dev/null 2>&1 || true
|
apt-get install -y -qq unzip >/dev/null 2>&1 || true
|
||||||
unzip -qo pkg.bin -d out
|
unzip -qo pkg.bin -d out
|
||||||
|
|||||||
@@ -18,8 +18,35 @@ mkdir -p "$OLLAMA_HOME"
|
|||||||
chown -R "${SWARM_USER}:${SWARM_USER}" "$OLLAMA_HOME"
|
chown -R "${SWARM_USER}:${SWARM_USER}" "$OLLAMA_HOME"
|
||||||
|
|
||||||
if ! command -v ollama >/dev/null 2>&1; then
|
if ! command -v ollama >/dev/null 2>&1; then
|
||||||
log "ставлю ollama"
|
# Supply-chain: prefer a pinned GitHub release. Official install.sh is curl|sh without checksum.
|
||||||
curl -fsSL https://ollama.com/install.sh | sh
|
# Override: OLLAMA_VERSION=0.6.5 OLLAMA_SHA256=<sha256 of ollama-linux-amd64.tgz>
|
||||||
|
OLLAMA_VERSION="${OLLAMA_VERSION:-}"
|
||||||
|
OLLAMA_SHA256="${OLLAMA_SHA256:-}"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) O_ARCH="amd64" ;;
|
||||||
|
aarch64|arm64) O_ARCH="arm64" ;;
|
||||||
|
*) O_ARCH="amd64" ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$OLLAMA_VERSION" ]]; then
|
||||||
|
log "ставлю ollama ${OLLAMA_VERSION} (pinned release)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
TGZ="${TMP}/ollama.tgz"
|
||||||
|
URL="https://github.com/ollama/ollama/releases/download/v${OLLAMA_VERSION}/ollama-linux-${O_ARCH}.tgz"
|
||||||
|
curl -fL "$URL" -o "$TGZ"
|
||||||
|
if [[ -n "$OLLAMA_SHA256" ]]; then
|
||||||
|
echo "${OLLAMA_SHA256} ${TGZ}" | sha256sum -c -
|
||||||
|
else
|
||||||
|
log "WARN: OLLAMA_SHA256 не задан — checksum skip (см. docs/llm.md)"
|
||||||
|
fi
|
||||||
|
tar -xzf "$TGZ" -C /usr/local/bin --strip-components=0 ollama 2>/dev/null \
|
||||||
|
|| tar -xzf "$TGZ" -C /usr/local --strip-components=1
|
||||||
|
rm -rf "$TMP"
|
||||||
|
command -v ollama >/dev/null || { log "ollama binary не найден после unpack"; exit 1; }
|
||||||
|
else
|
||||||
|
log "WARN: OLLAMA_VERSION не задан — curl|sh с ollama.com (нет pin/checksum). См. docs/llm.md"
|
||||||
|
curl -fsSL https://ollama.com/install.sh | sh
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
log "ollama уже в PATH: $(command -v ollama)"
|
log "ollama уже в PATH: $(command -v ollama)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
JOBS = Path("/tmp/gpu-rent-ollama-models.json")
|
JOBS = Path("/tmp/gpu-rent-ollama-models.json")
|
||||||
@@ -12,6 +13,7 @@ MARKER = Path("/mnt/swarm_data/.gpu-rent-ollama-pulling")
|
|||||||
|
|
||||||
|
|
||||||
def listed() -> set[str]:
|
def listed() -> set[str]:
|
||||||
|
"""Exact tags from `ollama list` (NAME column), e.g. qwen2.5:7b."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(["ollama", "list"], text=True, stderr=subprocess.DEVNULL)
|
out = subprocess.check_output(["ollama", "list"], text=True, stderr=subprocess.DEVNULL)
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
@@ -23,11 +25,21 @@ def listed() -> set[str]:
|
|||||||
parts = line.split()
|
parts = line.split()
|
||||||
if parts:
|
if parts:
|
||||||
names.add(parts[0])
|
names.add(parts[0])
|
||||||
# also bare name without tag
|
|
||||||
names.add(parts[0].split(":")[0])
|
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def already_have(have: set[str], wanted: str) -> bool:
|
||||||
|
"""Exact tag match only — qwen2.5:3b must not satisfy qwen2.5:7b."""
|
||||||
|
if wanted in have:
|
||||||
|
return True
|
||||||
|
# ollama list sometimes omits :latest
|
||||||
|
if ":" not in wanted and f"{wanted}:latest" in have:
|
||||||
|
return True
|
||||||
|
if wanted.endswith(":latest") and wanted.rsplit(":", 1)[0] in have:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if not JOBS.is_file():
|
if not JOBS.is_file():
|
||||||
print("no jobs file", file=sys.stderr)
|
print("no jobs file", file=sys.stderr)
|
||||||
@@ -38,23 +50,20 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
have = listed()
|
have = listed()
|
||||||
MARKER.parent.mkdir(parents=True, exist_ok=True)
|
MARKER.parent.mkdir(parents=True, exist_ok=True)
|
||||||
MARKER.write_text("1\n", encoding="utf-8")
|
MARKER.write_text(f"{int(time.time())}\n", encoding="utf-8")
|
||||||
failed = 0
|
failed = 0
|
||||||
try:
|
try:
|
||||||
for i, name in enumerate(models, 1):
|
for i, name in enumerate(models, 1):
|
||||||
name = str(name).strip()
|
name = str(name).strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
bare = name.split(":")[0]
|
if already_have(have, name):
|
||||||
if name in have or bare in have:
|
print(f"[{i}/{len(models)}] уже есть {name}")
|
||||||
# Prefer exact tag match when possible
|
continue
|
||||||
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}")
|
print(f"[{i}/{len(models)}] ollama pull {name}")
|
||||||
try:
|
try:
|
||||||
subprocess.check_call(["ollama", "pull", name])
|
subprocess.check_call(["ollama", "pull", name])
|
||||||
|
have.add(name)
|
||||||
except subprocess.CalledProcessError as exc:
|
except subprocess.CalledProcessError as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
print(f"FAIL pull {name}: {exc}", file=sys.stderr)
|
print(f"FAIL pull {name}: {exc}", file=sys.stderr)
|
||||||
|
|||||||
+13
-2
@@ -98,8 +98,12 @@ def _bind_access(
|
|||||||
"test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no",
|
"test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no",
|
||||||
check=False,
|
check=False,
|
||||||
).strip()
|
).strip()
|
||||||
if marker == "yes" and state.bootstrapped:
|
if marker == "yes":
|
||||||
log("bootstrap уже на VM — лёгкий проход (без apt)")
|
# VM marker survives stop; local bootstrapped is cleared on stop — still light.
|
||||||
|
if not state.bootstrapped:
|
||||||
|
log("маркер bootstrap на VM — лёгкий проход (локальный bootstrapped был сброшен)")
|
||||||
|
else:
|
||||||
|
log("bootstrap уже на VM — лёгкий проход (без apt)")
|
||||||
run_bootstrap(cfg, ip, log, update=update, light=True)
|
run_bootstrap(cfg, ip, log, update=update, light=True)
|
||||||
else:
|
else:
|
||||||
run_bootstrap(cfg, ip, log, update=update, light=False)
|
run_bootstrap(cfg, ip, log, update=update, light=False)
|
||||||
@@ -391,6 +395,13 @@ def cmd_stop(
|
|||||||
else:
|
else:
|
||||||
log("compute уже нет")
|
log("compute уже нет")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gpu_rent.idle_killer import revoke_old_credentials
|
||||||
|
|
||||||
|
revoke_old_credentials(conn, log)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"revoke idle-killer app cred: {exc}")
|
||||||
|
|
||||||
if not cfg.keep_floating_ip:
|
if not cfg.keep_floating_ip:
|
||||||
delete_floating_ip(conn, state.floating_ip_id, state.floating_ip, log)
|
delete_floating_ip(conn, state.floating_ip_id, state.floating_ip, log)
|
||||||
state.floating_ip = None
|
state.floating_ip = None
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ def push_tree(
|
|||||||
remote_hash = remote_sha256_on(client, remote)
|
remote_hash = remote_sha256_on(client, remote)
|
||||||
if remote_hash and remote_hash.lower() == local_hash.lower():
|
if remote_hash and remote_hash.lower() == local_hash.lower():
|
||||||
continue
|
continue
|
||||||
if models and not _is_weight_name(path.name):
|
|
||||||
pass
|
|
||||||
log(f"push {rel}")
|
log(f"push {rel}")
|
||||||
put_file_on(client, path, remote)
|
put_file_on(client, path, remote)
|
||||||
sent += 1
|
sent += 1
|
||||||
@@ -55,13 +53,6 @@ def push_tree(
|
|||||||
return sent
|
return sent
|
||||||
|
|
||||||
|
|
||||||
def _is_weight_name(name: str) -> bool:
|
|
||||||
lower = name.lower()
|
|
||||||
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:
|
def pull_tree(cfg: Config, host: str, remote_root: str, local_root: Path, log: Log) -> int:
|
||||||
client = open_ssh(cfg, host)
|
client = open_ssh(cfg, host)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -72,16 +72,12 @@ def decide_watch(status: str | None, tunnel_alive: bool) -> WatchDecision:
|
|||||||
|
|
||||||
|
|
||||||
def tunnel_forwards(cfg: Config) -> list[tuple[int, int]]:
|
def tunnel_forwards(cfg: Config) -> list[tuple[int, int]]:
|
||||||
"""List of (local_port, remote_port). SwarmUI always; LLM if configured."""
|
"""List of (local_port, remote_port). SwarmUI always; LLM if configured.
|
||||||
|
|
||||||
|
Prefer live config (`LLM_RUNTIME`) over stale state.notes.
|
||||||
|
"""
|
||||||
pairs = [(cfg.swarmui_local_port, 7801)]
|
pairs = [(cfg.swarmui_local_port, 7801)]
|
||||||
runtime = normalize_runtime(cfg.llm_runtime)
|
runtime = normalize_runtime(cfg.llm_runtime)
|
||||||
state = load_state()
|
|
||||||
noted = (state.notes or {}).get("llm_runtime")
|
|
||||||
if noted:
|
|
||||||
try:
|
|
||||||
runtime = normalize_runtime(str(noted))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if runtime == "ollama":
|
if runtime == "ollama":
|
||||||
pairs.append((cfg.ollama_local_port, 11434))
|
pairs.append((cfg.ollama_local_port, 11434))
|
||||||
elif runtime == "llamacpp":
|
elif runtime == "llamacpp":
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Unit tests for remote ollama_pull matching (stdlib helpers)."""
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_SPEC = spec_from_file_location(
|
||||||
|
"ollama_pull_remote",
|
||||||
|
_ROOT / "src" / "gpu_rent" / "remote" / "ollama_pull.py",
|
||||||
|
)
|
||||||
|
assert _SPEC and _SPEC.loader
|
||||||
|
_mod = module_from_spec(_SPEC)
|
||||||
|
_SPEC.loader.exec_module(_mod)
|
||||||
|
already_have = _mod.already_have
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_tag_only():
|
||||||
|
have = {"qwen2.5:3b", "qwen2.5:7b"}
|
||||||
|
assert already_have(have, "qwen2.5:7b")
|
||||||
|
assert not already_have(have, "qwen2.5:14b")
|
||||||
|
assert already_have(have, "qwen2.5:3b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_alias():
|
||||||
|
assert already_have({"foo:latest"}, "foo")
|
||||||
|
assert already_have({"foo"}, "foo:latest")
|
||||||
|
assert not already_have({"foo:3b"}, "foo")
|
||||||
@@ -38,12 +38,37 @@ def test_tunnel_forwards_swarm_only(monkeypatch):
|
|||||||
assert tunnel_forwards(Cfg()) == [(17801, 7801)]
|
assert tunnel_forwards(Cfg()) == [(17801, 7801)]
|
||||||
|
|
||||||
|
|
||||||
def test_tunnel_forwards_ollama(monkeypatch):
|
def test_tunnel_forwards_prefers_cfg_over_stale_notes(monkeypatch):
|
||||||
class Cfg:
|
class Cfg:
|
||||||
swarmui_local_port = 17801
|
swarmui_local_port = 17801
|
||||||
llm_runtime = "ollama"
|
llm_runtime = "none"
|
||||||
ollama_local_port = 17811
|
ollama_local_port = 17811
|
||||||
llamacpp_local_port = 17812
|
llamacpp_local_port = 17812
|
||||||
|
|
||||||
monkeypatch.setattr("gpu_rent.tunnel.load_state", lambda: type("S", (), {"notes": {}})())
|
monkeypatch.setattr(
|
||||||
assert tunnel_forwards(Cfg()) == [(17801, 7801), (17811, 11434)]
|
"gpu_rent.tunnel.load_state",
|
||||||
|
lambda: type("S", (), {"notes": {"llm_runtime": "ollama"}})(),
|
||||||
|
)
|
||||||
|
assert tunnel_forwards(Cfg()) == [(17801, 7801)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_llm_notes_only_when_cfg_none(monkeypatch):
|
||||||
|
from gpu_rent.access_card import resolve_llm_runtime
|
||||||
|
|
||||||
|
class Cfg:
|
||||||
|
llm_runtime = "none"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gpu_rent.access_card.load_state",
|
||||||
|
lambda: type("S", (), {"notes": {"llm_runtime": "ollama"}})(),
|
||||||
|
)
|
||||||
|
assert resolve_llm_runtime(Cfg()) == "ollama"
|
||||||
|
|
||||||
|
class Cfg2:
|
||||||
|
llm_runtime = "llamacpp"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gpu_rent.access_card.load_state",
|
||||||
|
lambda: type("S", (), {"notes": {"llm_runtime": "ollama"}})(),
|
||||||
|
)
|
||||||
|
assert resolve_llm_runtime(Cfg2()) == "llamacpp"
|
||||||
|
|||||||
Reference in New Issue
Block a user