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:
Leonid Pershin
2026-08-21 05:40:22 +03:00
parent 82e36129cd
commit dc1fde9e3e
17 changed files with 464 additions and 152 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ Hold killer: gpu-rent hold
В `gpu-rent.vars` (создаётся из example при первом запуске):
```env
# Двойной клик / запуск без аргументов:
# Двойной клик / запуск лаунчера без аргументов (CLI сам по себе показывает help):
GPU_RENT_DEFAULT_ARGS=up --yes
# Дописать ко всем вызовам:
+22 -2
View File
@@ -12,7 +12,7 @@ gpu-rent up --llm llamacpp
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 + туннель)
@@ -56,4 +56,24 @@ Community abliterate-модели без гарантий безопасност
## 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.
+90
View File
@@ -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 113] 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
View File
@@ -25,14 +25,17 @@ class AccessLink:
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)
if runtime != "none":
return runtime
try:
noted = (load_state().notes or {}).get("llm_runtime")
if noted:
runtime = normalize_runtime(str(noted))
return normalize_runtime(str(noted))
except Exception:
pass
return runtime
return "none"
def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]:
@@ -141,17 +144,35 @@ def render_access_panel(
if host:
subtitle.append(f" · FIP {host}", style="dim")
body = Group(
subtitle,
Text(""),
table,
Text(""),
Text("команды", style="bold"),
cmds,
Text(""),
Text("Cursor MCP (вставь в mcp.json — файл сам не трогаем)", style="bold"),
mcp,
warn_bits: list[str] = []
try:
notes = load_state().notes or {}
if notes.get("idle_killer") == "failed":
warn_bits.append(
"idle-killer НЕ вооружён — GPU может крутиться без авто-stop"
)
if notes.get("llm_error"):
warn_bits.append(f"LLM ошибка: {str(notes['llm_error'])[:120]}")
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(
body,
title=f"[bold]{title}[/bold]",
+44 -24
View File
@@ -33,7 +33,7 @@ if sys.platform == "win32":
pass
app = typer.Typer(
invoke_without_command=True,
no_args_is_help=True,
pretty_exceptions_enable=False,
add_completion=False,
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
@@ -43,16 +43,12 @@ console = Console(highlight=False, legacy_windows=False)
_DEBUG = False
@app.callback(invoke_without_command=True)
@app.callback()
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:
@@ -62,7 +58,24 @@ def _die(exc: BaseException) -> None:
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.add_column("ok")
table.add_column("проверка")
@@ -73,7 +86,6 @@ def _print_checks(checks) -> int:
block = "да" if check.blocking else "нет"
table.add_row(mark, check.name, block, check.detail)
console.print(table)
failed = blocking_failed(checks)
if failed:
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
_print_next_steps(failed)
@@ -246,6 +258,12 @@ def status() -> None:
from gpu_rent.idle_killer import killer_status_lines
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:
table.add_row("диск used/free", f"SSH: {exc}")
table.add_row("idle-killer", "нет SSH")
@@ -256,16 +274,17 @@ def status() -> None:
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
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")
if noted:
rt = f"{rt} (сессия: {noted})"
table.add_row(
"LLM",
f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}",
)
llm_err = (state.notes or {}).get("llm_error")
detail = f"{rt}; ollama :{cfg.ollama_local_port} / llamacpp :{cfg.llamacpp_local_port}"
if noted and noted != rt:
detail += f" (notes: {noted})"
if llm_err:
detail += f" [red]err: {llm_err[:80]}[/red]"
table.add_row("LLM", detail)
if cfg.auth_ok:
try:
@@ -296,14 +315,9 @@ def open(
cfg = load_config(require_auth=False)
if llm:
from gpu_rent.llm_runtime import normalize_runtime
from gpu_rent.access_card import resolve_llm_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
runtime = resolve_llm_runtime(cfg)
if runtime == "ollama":
port = cfg.ollama_local_port
elif runtime == "llamacpp":
@@ -381,6 +395,12 @@ def up(
"--no-update",
help="Не делать git pull SwarmUI и установленных extensions",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Полный doctor-таблица на up (по умолчанию кратко)",
),
llm: Optional[str] = typer.Option(
None, "--llm", help="none|ollama|llamacpp (override LLM_RUNTIME)"
),
@@ -401,7 +421,7 @@ def up(
from gpu_rent.paths import vars_path
checks = run_doctor()
code = _print_checks(checks)
code = _print_checks(checks, quiet=not verbose)
if code != 0:
raise typer.Exit(1)
cfg = load_config(require_auth=True)
+21 -39
View File
@@ -48,56 +48,38 @@ def create_application_credential(conn, cfg: Config, server_id: str, log: Log) -
revoke_old_credentials(conn, log)
secret = secrets.token_urlsafe(32)
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:
ac = conn.identity.create_application_credential(
user=user_id,
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/*",
},
],
description="gpu-rent idle-killer: delete this compute only",
access_rules=access_rules,
)
except Exception as 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
raise CloudError(
f"не создать application credential с access_rules (только этот server): {exc}. "
"Без узких правил idle-killer не вооружаем (fail closed). "
"Нужны права identity:application_credential_create на сервисного пользователя."
) from exc
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:
raise CloudError("application credential создан без id")
log(f"application credential {name}")
log(f"application credential {name} (DELETE/GET только {server_id[:12]}…)")
return {
"auth_url": cfg.os_auth_url,
"project_id": cfg.os_project_id,
+46 -1
View File
@@ -260,13 +260,29 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
from gpu_rent.state import load_state, save_state
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":
log("LLM: none — останавливаю gpu-rent-ollama / gpu-rent-llamacpp если были")
_stop_units("gpu-rent-ollama", "gpu-rent-llamacpp")
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_runtime"] = "none"
st.notes.pop("llm_error", None)
save_state(st)
return
if runtime == "ollama":
_stop_units("gpu-rent-llamacpp")
log("LLM: ставим/запускаем Ollama")
run_script_sudo(
cfg,
@@ -278,6 +294,9 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
log=log,
)
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]
if not names:
log("ollama-models.yaml пуст — pull skip")
@@ -293,6 +312,7 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
log=log,
)
elif runtime == "llamacpp":
_stop_units("gpu-rent-ollama")
log("LLM: ставим/запускаем llama.cpp server")
run_script_sudo(
cfg,
@@ -306,6 +326,7 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_runtime"] = runtime
st.notes.pop("llm_error", None)
save_state(st)
@@ -337,15 +358,39 @@ 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)
from gpu_rent.state import load_state, save_state
try:
provision_llm(cfg, host, log)
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:
try:
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:
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")
from gpu_rent.llm_runtime import normalize_runtime
+26 -23
View File
@@ -113,8 +113,7 @@ def clone_one(job: dict, token: str, update: bool) -> None:
if dest.is_dir() and (dest / ".git").is_dir():
origin = out(["git", "-C", str(dest), "remote", "get-url", "origin"])
if strip_auth(origin) != strip_auth(url):
print(f"FAIL origin mismatch {dest}: {origin} != {url}", file=sys.stderr)
raise SystemExit(2)
raise RuntimeError(f"origin mismatch {dest}: {origin} != {url}")
if token and strip_auth(origin) == strip_auth(url):
run(["git", "-C", str(dest), "remote", "set-url", "origin", authed])
if not update:
@@ -127,8 +126,7 @@ def clone_one(job: dict, token: str, update: bool) -> None:
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)
raise RuntimeError(f"{dest} exists but is not a git repo")
try:
if is_sha(ref):
run(["git", "clone", "--recurse-submodules", authed, str(dest)])
@@ -184,26 +182,31 @@ def main() -> int:
print(f"extensions update={'on' if update else 'off'}")
failed = 0
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:
update_installed_extras(known, update, token=token)
except Exception:
failed += 1
if TOKEN_PATH.is_file():
TOKEN_PATH.unlink()
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
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:
update_installed_extras(known, update, token=token)
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__":
+22 -9
View File
@@ -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]:
"""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"
pull_marker = DATA / ".gpu-rent-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()
# Ollama: any running model
try:
@@ -104,21 +120,18 @@ def llm_busy(timeout: float = 3.0) -> tuple[bool, str]:
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.
# llama.cpp: slots in use
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
)
total = int(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:
return True, f"llamacpp slots_in_use={in_use}"
except Exception:
+38 -6
View File
@@ -22,19 +22,26 @@ 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.
# Pin: LLAMACPP_TAG=b4690 LLAMACPP_ASSET_URL=... LLAMACPP_SHA256=...
# Без pin — latest release (supply-chain risk; docs/llm.md).
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
LLAMACPP_TAG="${LLAMACPP_TAG:-}"
LLAMACPP_ASSET_URL="${LLAMACPP_ASSET_URL:-}"
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)
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"):
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)
@@ -42,12 +49,37 @@ for a in assets:
prefer.append(u)
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
log "не нашёл бинарь в latest release — поставь llama-server вручную в ${SERVER_BIN}"
log "не нашёл бинарь в release — поставь llama-server вручную в ${SERVER_BIN}"
exit 1
fi
log "asset $URL"
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
apt-get install -y -qq unzip >/dev/null 2>&1 || true
unzip -qo pkg.bin -d out
+29 -2
View File
@@ -18,8 +18,35 @@ 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
# Supply-chain: prefer a pinned GitHub release. Official install.sh is curl|sh without checksum.
# 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
log "ollama уже в PATH: $(command -v ollama)"
fi
+19 -10
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
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]:
"""Exact tags from `ollama list` (NAME column), e.g. qwen2.5:7b."""
try:
out = subprocess.check_output(["ollama", "list"], text=True, stderr=subprocess.DEVNULL)
except (subprocess.CalledProcessError, FileNotFoundError):
@@ -23,11 +25,21 @@ def listed() -> set[str]:
parts = line.split()
if parts:
names.add(parts[0])
# also bare name without tag
names.add(parts[0].split(":")[0])
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:
if not JOBS.is_file():
print("no jobs file", file=sys.stderr)
@@ -38,23 +50,20 @@ def main() -> int:
return 0
have = listed()
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
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
if already_have(have, name):
print(f"[{i}/{len(models)}] уже есть {name}")
continue
print(f"[{i}/{len(models)}] ollama pull {name}")
try:
subprocess.check_call(["ollama", "pull", name])
have.add(name)
except subprocess.CalledProcessError as exc:
failed += 1
print(f"FAIL pull {name}: {exc}", file=sys.stderr)
+13 -2
View File
@@ -98,8 +98,12 @@ def _bind_access(
"test -f /opt/swarmui/.gpu-rent-bootstrapped && echo yes || echo no",
check=False,
).strip()
if marker == "yes" and state.bootstrapped:
log("bootstrap уже на VM — лёгкий проход (без apt)")
if marker == "yes":
# 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)
else:
run_bootstrap(cfg, ip, log, update=update, light=False)
@@ -391,6 +395,13 @@ def cmd_stop(
else:
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:
delete_floating_ip(conn, state.floating_ip_id, state.floating_ip, log)
state.floating_ip = None
-9
View File
@@ -41,8 +41,6 @@ def push_tree(
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
@@ -55,13 +53,6 @@ def push_tree(
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:
client = open_ssh(cfg, host)
try:
+4 -8
View File
@@ -72,16 +72,12 @@ def decide_watch(status: str | None, tunnel_alive: bool) -> WatchDecision:
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)]
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":
pairs.append((cfg.ollama_local_port, 11434))
elif runtime == "llamacpp":
+27
View File
@@ -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")
+29 -4
View File
@@ -38,12 +38,37 @@ def test_tunnel_forwards_swarm_only(monkeypatch):
assert tunnel_forwards(Cfg()) == [(17801, 7801)]
def test_tunnel_forwards_ollama(monkeypatch):
def test_tunnel_forwards_prefers_cfg_over_stale_notes(monkeypatch):
class Cfg:
swarmui_local_port = 17801
llm_runtime = "ollama"
llm_runtime = "none"
ollama_local_port = 17811
llamacpp_local_port = 17812
monkeypatch.setattr("gpu_rent.tunnel.load_state", lambda: type("S", (), {"notes": {}})())
assert tunnel_forwards(Cfg()) == [(17801, 7801), (17811, 11434)]
monkeypatch.setattr(
"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"