Files
gpu-rent/src/gpu_rent/provision.py
T
Leonid Pershin adba4976ee Enhance SwarmUI integration and GPU environment verification
- Updated CLI documentation to reflect the new handling of `CIVITAI_API_TOKEN`, which is now automatically passed to SwarmUI user settings during startup.
- Improved the `render_access_panel` function to include additional warnings for idle-killer failures and stack errors, enhancing user feedback.
- Introduced a new function `seed_swarmui_api_keys` to manage API key injection into SwarmUI, ensuring seamless integration with the Model Downloader.
- Enhanced GPU environment verification logic to include fail-fast checks for critical components like CUDA, improving error handling and user notifications.
- Updated tests to validate the new API key handling and access panel behavior, ensuring robustness in the integration process.
2026-08-21 07:09:20 +03:00

610 lines
22 KiB
Python

"""After OS bootstrap: extensions, autocomplete, Civitai, then start SwarmUI."""
from __future__ import annotations
import json
import shlex
from collections.abc import Callable
from datetime import datetime, timezone
from importlib.resources import files
from pathlib import Path
import httpx
from gpu_rent.civitai import fetch_model_version, pick_primary_file
from gpu_rent.config import Config
from gpu_rent.errors import CloudError, GpuRentError
from gpu_rent.manifests import (
MODEL_DIRS,
extract_version_id,
parse_extensions,
parse_models,
remote_root_for,
repo_dirname,
repo_matches_runtime,
)
from gpu_rent.ssh_ops import put_text, remote_exists, run_python, run_ssh
from gpu_rent.sync_files import pull_tree, push_tree
Log = Callable[[str], None]
DATA = "/mnt/swarm_data"
def _pkg_text(name: str) -> str:
return files("gpu_rent.remote").joinpath(name).read_text(encoding="utf-8")
def probe_gpu(cfg: Config, host: str, log: Log) -> dict:
"""Write /mnt/swarm_data/.gpu-rent-gpu.json; return parsed dict."""
out = run_python(
cfg,
host,
_pkg_text("gpu_probe.py"),
remote_path="/tmp/gpu-rent-gpu_probe.py",
timeout=60,
log=log,
)
# Last JSON line from script stdout
data: dict = {}
for line in reversed(out.splitlines()):
line = line.strip()
if line.startswith("{"):
try:
data = json.loads(line)
break
except json.JSONDecodeError:
continue
if data.get("ok"):
log(
f"GPU: {data.get('name')} "
f"{data.get('vram_mib')} MiB cap={data.get('compute_cap')} "
f"tier={data.get('tier')}"
)
else:
log(f"GPU probe: {data.get('error') or 'нет данных'}")
return data
def tune_swarm_perf(cfg: Config, host: str, log: Log) -> bool:
"""Install sage/triton into Comfy venv + ExtraArgs. Returns True if SwarmUI restart needed."""
out = run_python(
cfg,
host,
_pkg_text("tune_swarm_perf.py"),
remote_path="/tmp/gpu-rent-tune_swarm_perf.py",
timeout=900,
log=log,
)
return "RESTART_SWARMUI=1" in out
def seed_extensions(cfg: Config, host: str, log: Log, *, update: bool = True) -> bool:
from gpu_rent.llm_runtime import normalize_runtime
runtime = normalize_runtime(cfg.llm_runtime)
all_repos = parse_extensions(cfg.extensions_manifest)
repos = [r for r in all_repos if repo_matches_runtime(r, runtime)]
skipped = [r for r in all_repos if r not in repos]
for repo in skipped:
log(
f"extensions: пропуск {repo_dirname(repo)} "
f"(requires={repo.requires}, LLM_RUNTIME={runtime})"
)
if not repos and not update:
if all_repos:
log("extensions: все строки отфильтрованы по requires — стоковый SwarmUI")
else:
log("extensions.yaml пуст — стоковый SwarmUI")
return False
jobs = []
for repo in repos:
jobs.append(
{
"kind": repo.kind,
"url": repo.url,
"ref": repo.ref,
"dest": remote_root_for(repo),
}
)
put_text(cfg, host, "/tmp/gpu-rent-ext.json", json.dumps(jobs, indent=2))
put_text(cfg, host, "/tmp/gpu-rent-update-git", "1\n" if update else "0\n")
if cfg.git_token:
put_text(cfg, host, "/tmp/gpu-rent-git.token", cfg.git_token + "\n", mode=0o600)
if update:
log(f"extensions: clone/update {len(jobs)} из yaml + установленные на data")
else:
log(f"extensions: только недостающие из yaml ({len(jobs)}), без git pull (--no-update)")
out = run_python(
cfg,
host,
_pkg_text("clone_ext.py"),
remote_path="/tmp/gpu-rent-clone_ext.py",
timeout=1800,
log=log,
)
return "cloned " in out or "updated " in out
def _github_blob(cfg: Config) -> dict | None:
url = (
f"https://api.github.com/repos/{cfg.autocomplete_github_repo}/contents/"
f"{cfg.autocomplete_github_path}?ref={cfg.autocomplete_github_ref}"
)
headers = {"Accept": "application/vnd.github+json", "User-Agent": "gpu-rent"}
if cfg.git_token:
headers["Authorization"] = f"Bearer {cfg.git_token}"
try:
with httpx.Client(timeout=20.0, follow_redirects=True) as client:
response = client.get(url, headers=headers)
except httpx.HTTPError as exc:
log_skip = str(exc)
return {"error": log_skip}
if response.status_code == 403:
return {"error": "GitHub rate limit — autocomplete не обновляю"}
if response.status_code != 200:
return {"error": f"GitHub HTTP {response.status_code}"}
data = response.json()
if not isinstance(data, dict):
return {"error": "неожиданный JSON GitHub"}
return data
def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool:
if not cfg.autocomplete_enabled:
log("autocomplete выключен")
return False
dest_dir = f"{DATA}/Data/Autocompletions"
dest = f"{dest_dir}/{cfg.autocomplete_filename}"
meta_path = f"{dest}.gpu-rent-meta.json"
blob = _github_blob(cfg)
if blob is None:
return False
if blob.get("error"):
log(str(blob["error"]))
return False
sha = str(blob.get("sha") or "")
download_url = str(blob.get("download_url") or "")
old_sha = ""
if remote_exists(cfg, host, meta_path):
raw = run_ssh(cfg, host, f"cat {meta_path}", check=False)
try:
old_sha = str(json.loads(raw).get("github_blob_sha") or "")
except json.JSONDecodeError:
old_sha = ""
changed = sha != old_sha or not remote_exists(cfg, host, dest)
if changed:
if not download_url:
log("GitHub не дал download_url")
return False
log(f"качаю {cfg.autocomplete_filename}")
run_ssh(
cfg,
host,
"mkdir -p {dir} && curl -fsSL -o {part} {url} && mv {part} {dest}".format(
dir=shlex.quote(dest_dir),
part=shlex.quote(dest + ".partial"),
url=shlex.quote(download_url),
dest=shlex.quote(dest),
),
timeout=180,
)
meta = {
"repo": cfg.autocomplete_github_repo,
"path": cfg.autocomplete_github_path,
"ref": cfg.autocomplete_github_ref,
"github_blob_sha": sha,
"filename": cfg.autocomplete_filename,
"fetched_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"settings_applied": True,
}
put_text(cfg, host, meta_path, json.dumps(meta, indent=2) + "\n")
settings = f"{DATA}/Data/Settings.fds"
applied = False
if remote_exists(cfg, host, meta_path):
raw = run_ssh(cfg, host, f"cat {meta_path}", check=False)
try:
applied = bool(json.loads(raw).get("settings_applied"))
except json.JSONDecodeError:
applied = False
if not remote_exists(cfg, host, settings) or not applied:
fds = (
"DefaultUser:\n"
" AutoComplete:\n"
f" Source: {cfg.autocomplete_filename}\n"
" EscapeParens: true\n"
)
put_text(cfg, host, settings, fds)
log(f"Settings.fds AutoComplete.Source = {cfg.autocomplete_filename}")
return changed
def _download_url(host: str, version_id: int, file_info: dict) -> str:
raw = str(file_info.get("downloadUrl") or "")
if "civitai." in raw and "/api/download/" in raw:
# NSFW downloadUrl often points at .com — use the host that had files.
return f"https://{host}/api/download/models/{version_id}"
if raw.startswith("https://"):
return raw
return f"https://{host}/api/download/models/{version_id}"
def seed_swarmui_api_keys(cfg: Config, host: str, log: Log) -> None:
"""Write CIVITAI_API_TOKEN (and HF if set) into SwarmUI user keys via SetAPIKey.
Swarm stores them in Users.ldb GenericData — needed for Model Downloader in the UI.
Call after SwarmUI HTTP is up (after wait_backend / verify).
"""
import os
keys: dict[str, str] = {}
if cfg.civitai_api_token:
keys["civitai_api"] = cfg.civitai_api_token
hf = (
os.environ.get("HF_TOKEN")
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or ""
).strip()
if hf:
keys["huggingface_api"] = hf
if not keys:
log("SwarmUI API keys: нет CIVITAI_API_TOKEN / HF_TOKEN — skip")
return
put_text(
cfg,
host,
"/tmp/gpu-rent-swarm-api-keys.json",
json.dumps(keys) + "\n",
mode=0o600,
)
names = ", ".join(keys)
log(f"SwarmUI: прокидываю API keys ({names})")
try:
run_python(
cfg,
host,
_pkg_text("swarmui_set_api_keys.py"),
remote_path="/tmp/gpu-rent-swarmui_set_api_keys.py",
timeout=180,
log=log,
)
except CloudError as exc:
log(f"⚠ SwarmUI API keys: {exc}")
run_ssh(cfg, host, "rm -f /tmp/gpu-rent-swarm-api-keys.json", check=False)
def seed_civitai(cfg: Config, host: str, log: Log) -> None:
entries = parse_models(cfg.models_manifest)
if not cfg.civitai_api_token:
log("Civitai-seed пропущен: нет CIVITAI_API_TOKEN — дефолт SwarmUI")
return
if not entries:
log("Civitai-seed пропущен: манифест пуст — дефолт SwarmUI")
return
jobs = []
for entry in entries:
vid = entry.version_id or (extract_version_id(entry.url) if entry.url else None)
if not vid:
log(f"пропуск {entry.kind}: нет version_id")
continue
try:
api_host, version = fetch_model_version(cfg.civitai_api_token, cfg.civitai_api_host, vid)
except CloudError as exc:
log(str(exc))
continue
info = pick_primary_file(version)
if not info:
log(f"version {vid}: нет files[]")
continue
name = str(info.get("name") or f"{vid}.safetensors")
folder = MODEL_DIRS.get(entry.kind, entry.kind)
dest = f"{DATA}/Models/{folder}/{name}"
hashes = info.get("hashes") or {}
sha = str((hashes.get("SHA256") or hashes.get("sha256") or "")).lower()
stem = Path(name).stem
civitai_json = json.dumps(version, ensure_ascii=False, indent=2)
trained = version.get("trainedWords") or []
phrase = trained[0] if isinstance(trained, list) and trained else ""
swarm = {
"name": stem,
"title": version.get("name") or stem,
"description": (version.get("description") or "")[:2000],
"trigger_phrase": phrase,
"author": ((version.get("model") or {}) if isinstance(version.get("model"), dict) else {}).get("name"),
"tags": version.get("tags") or [],
}
jobs.append(
{
"dest": dest,
"url": _download_url(api_host, vid, info),
"sha256": sha,
"sidecars": {
f"{stem}.civitai.json": civitai_json,
f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2),
},
}
)
if not jobs:
log("Civitai-seed: ни одной скачиваемой строки")
return
if not any(e.kind == "checkpoint" for e in entries):
log("в манифесте нет checkpoint — генерация может не стартовать")
put_text(cfg, host, "/tmp/gpu-rent-civitai-jobs.json", json.dumps(jobs, indent=2))
put_text(cfg, host, "/tmp/gpu-rent-civitai.token", cfg.civitai_api_token + "\n", mode=0o600)
log(
f"Civitai: {len(jobs)} в манифесте — на VM качаю отсутствующие "
f"(уже есть + sha → skip; прогресс [N/{len(jobs)}])"
)
run_python(
cfg,
host,
_pkg_text("civitai_fetch.py"),
remote_path="/tmp/gpu-rent-civitai_fetch.py",
timeout=7200,
log=log,
)
def ensure_swarmui_running(cfg: Config, host: str, log: Log, restart: bool) -> None:
active = run_ssh(cfg, host, "systemctl is-active swarmui 2>/dev/null || true", check=False).strip()
if restart and active == "active":
log("systemctl restart swarmui (новые extensions/autocomplete)")
run_ssh(cfg, host, "sudo -n systemctl restart swarmui", timeout=120)
return
if active != "active":
log("systemctl start swarmui")
run_ssh(cfg, host, "sudo -n systemctl start swarmui", timeout=120)
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)
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,
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)
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")
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":
_stop_units("gpu-rent-ollama")
from gpu_rent.llm_runtime import (
gguf_filename_from_url,
parse_llamacpp_models,
)
import os
entries = parse_llamacpp_models(cfg.llamacpp_models_manifest)
defaults = [e for e in entries if e.default]
if defaults:
log(
"llama.cpp preferred: "
f"{defaults[0].filename or gguf_filename_from_url(defaults[0].url)}"
)
if entries:
jobs = [
{
"url": e.url,
"filename": e.filename or gguf_filename_from_url(e.url),
}
for e in entries
]
put_text(
cfg, host, "/tmp/gpu-rent-llamacpp-models.json", json.dumps(jobs, indent=2)
)
hf = (
os.environ.get("HF_TOKEN")
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or ""
).strip()
if hf:
put_text(cfg, host, "/tmp/gpu-rent-hf.token", hf + "\n", mode=0o600)
log(f"llama.cpp: скачиваю {len(jobs)} GGUF из манифеста")
run_python(
cfg,
host,
_pkg_text("llamacpp_fetch.py"),
remote_path="/tmp/gpu-rent-llamacpp_fetch.py",
timeout=7200,
log=log,
)
else:
log("llamacpp-models.yaml пуст — GGUF skip (положи вручную)")
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
st.notes.pop("llm_error", None)
save_state(st)
def _try_arm_idle_killer(
cfg: Config,
host: str,
log: Log,
*,
conn,
server_id: str | None,
) -> bool:
"""Arm idle-killer; update state notes. Returns True if armed."""
from gpu_rent.idle_killer import arm_idle_killer
from gpu_rent.state import load_state, save_state
if conn is None or not server_id:
return False
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)
return True
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. "
"Сделай gpu-rent stop или почини identity/application_credential_create."
)
return False
def provision_vm(
cfg: Config,
host: str,
log: Log,
*,
conn=None,
server_id: str | None = None,
update: bool = True,
) -> None:
from gpu_rent.llm_runtime import normalize_runtime
from gpu_rent.state import load_state, save_state
swarm = bool(getattr(cfg, "enable_swarmui", True))
rt = normalize_runtime(cfg.llm_runtime)
if not swarm and rt == "none":
raise CloudError(
"llm-only: нужен LLM_RUNTIME=ollama|llamacpp (или --ollama / --llamacpp)"
)
# Arm ASAP so mid-provision failures still leave auto-stop on the VM.
armed = _try_arm_idle_killer(cfg, host, log, conn=conn, server_id=server_id)
restart = bool(update)
try:
if swarm:
try:
if seed_extensions(cfg, host, log, update=update):
restart = True
except GpuRentError as exc:
log(f"extensions: {exc}")
raise
try:
if seed_autocomplete(cfg, host, log):
restart = True
except GpuRentError as exc:
log(f"autocomplete: {exc}")
seed_civitai(cfg, host, log)
push_tree(cfg, host, cfg.local_models_dir, f"{DATA}/Models", log, models=True)
push_tree(
cfg, host, cfg.local_wildcards_dir, f"{DATA}/Data/Wildcards", log, models=False
)
push_tree(
cfg,
host,
cfg.local_workflows_dir,
f"{DATA}/CustomWorkflows",
log,
models=False,
)
if cfg.pull_output:
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
else:
log("SwarmUI: skip (llm-only)")
run_ssh(
cfg,
host,
"sudo -n systemctl stop swarmui 2>/dev/null; "
"sudo -n systemctl disable swarmui 2>/dev/null || true; "
"echo llm-only | sudo -n tee /mnt/swarm_data/.gpu-rent-llm-only >/dev/null",
check=False,
)
try:
probe_gpu(cfg, host, log)
except Exception as exc:
log(f"GPU probe: {exc}")
if swarm:
ensure_swarmui_running(cfg, host, log, restart=restart)
run_ssh(
cfg,
host,
"sudo -n rm -f /mnt/swarm_data/.gpu-rent-llm-only",
check=False,
)
try:
provision_llm(cfg, host, log)
except Exception as exc:
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_error"] = str(exc)[:500]
st.notes["llm_runtime"] = "none"
save_state(st)
raise CloudError(f"LLM runtime: {exc}") from exc
finally:
# If first arm failed (SSH race), retry once after seeds.
if not armed:
_try_arm_idle_killer(cfg, host, log, conn=conn, server_id=server_id)
if swarm:
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
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")