Files
gpu-rent/src/gpu_rent/provision.py
T
Leonid PershinandCursor ec8bd28ce0 Add assistent-personas support and enhance model merging functionality
- Introduced `assistent-personas.yaml` to the project and updated the `.gitignore` accordingly.
- Implemented functions to seed and merge assistent personas from local files to the VM.
- Enhanced the model capture process to include merging of wanted models from the VM into `models.yaml`.
- Updated documentation to reflect changes in the assistent personas and model management processes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 21:21:53 +03:00

1359 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
file_size_bytes,
pick_preview_image,
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_script_sudo, run_ssh
from gpu_rent.sync_files import pull_tree, push_tree
Log = Callable[[str], None]
DATA = "/mnt/swarm_data"
# Forwarded to remote install_*.sh (from .env / gpu-rent.vars → os.environ).
_OLLAMA_INSTALL_ENV = ("OLLAMA_VERSION", "OLLAMA_SHA256")
def _models_manifest_fp(cfg: Config) -> str:
"""Stable fingerprint of models.yaml so warm seed can skip API+fetch."""
import hashlib
path = Path(cfg.models_manifest)
try:
raw = path.read_bytes()
except OSError:
raw = b""
return hashlib.sha256(raw).hexdigest()[:16]
def _remote_llm_env(cfg: Config, *keys: str) -> dict[str, str]:
import os
env: dict[str, str] = {"SWARM_USER": cfg.ssh_user}
for key in keys:
value = (os.environ.get(key) or "").strip()
if value:
env[key] = value
return env
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 ensure_data_binds(
cfg: Config, host: str, log: Log, *, stop_swarm: bool = True
) -> None:
"""Re-bind Models/Data/Output/dlbackend without lazy umount.
``umount -l`` while Comfy holds files makes the Models tab go empty later
(dropdown still shows the last checkpoint).
"""
env = None if stop_swarm else {"GPU_RENT_STOP_SWARM": "0"}
run_script_sudo(
cfg,
host,
_pkg_text("ensure_binds.sh"),
remote_path="/tmp/gpu-rent-ensure_binds.sh",
timeout=180,
env=env,
log=log,
)
def ensure_swarm_comfy_installed(cfg: Config, host: str, log: Log) -> None:
"""Headless Comfy install / recover errored backends before ready wait."""
# Diag script next to install so recover-fail can subprocess it on the VM.
put_text(cfg, host, "/tmp/gpu-rent-swarm_diag.py", _pkg_text("swarm_diag.py"))
log("SwarmUI Comfy: install если empty, RestartBackends если errored…")
run_python(
cfg,
host,
_pkg_text("install_swarm_comfy.py"),
remote_path="/tmp/gpu-rent-install_swarm_comfy.py",
# Cold: git clone Comfy + pip torch — often 1540 min.
timeout=3900,
log=log,
)
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
if not repos and update:
if not all_repos:
log("extensions.yaml пуст — только update уже установленных на data (если есть)")
else:
log(
"extensions: yaml отфильтрован по requires "
f"(LLM_RUNTIME={runtime}) — только update установленных"
)
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 and blob.get("error"):
log(str(blob["error"]))
github_ok = isinstance(blob, dict) and not blob.get("error")
sha = str(blob.get("sha") or "") if github_ok else ""
download_url = str(blob.get("download_url") or "") if github_ok else ""
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 = bool(github_ok) and (sha != old_sha or not remote_exists(cfg, host, dest))
if changed:
if not download_url:
log("GitHub не дал download_url")
changed = False
else:
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": False,
}
put_text(cfg, host, meta_path, json.dumps(meta, indent=2) + "\n")
settings = f"{DATA}/Data/Settings.fds"
settings_status = "noop"
if remote_exists(cfg, host, dest):
settings_status = _merge_autocomplete_into_settings(
cfg, host, settings, cfg.autocomplete_filename, log
)
if settings_status in ("changed", "already", "skip_user"):
if remote_exists(cfg, host, meta_path):
try:
meta_obj = json.loads(
run_ssh(cfg, host, f"cat {meta_path}", check=False)
)
except json.JSONDecodeError:
meta_obj = {}
if isinstance(meta_obj, dict):
meta_obj["settings_applied"] = True
put_text(cfg, host, meta_path, json.dumps(meta_obj, indent=2) + "\n")
return changed or settings_status == "changed"
_AUTOCOMPLETE_MERGE_PY = r'''
#!/usr/bin/env python3
"""Merge AutoComplete.Source into Settings.fds without wiping other keys."""
from __future__ import annotations
import os
import re
import sys
import time
from pathlib import Path
def comfy_on_disk() -> bool:
"""True only after first Comfy install — not on empty data volume."""
override = (os.environ.get("GPU_RENT_COMFY_PRESENT") or "").strip().lower()
if override in ("1", "true", "yes"):
return True
if override in ("0", "false", "no"):
return False
for cand in (
Path("/mnt/swarm_data/dlbackend/ComfyUI/venv/bin/python"),
Path("/mnt/swarm_data/dlbackend/ComfyUI/main.py"),
Path("/opt/swarmui/dlbackend/ComfyUI/venv/bin/python"),
Path("/opt/swarmui/dlbackend/ComfyUI/main.py"),
):
if cand.is_file():
return True
backends = Path("/mnt/swarm_data/Data/Backends.fds")
try:
if backends.is_file() and "StartScript" in backends.read_text(
encoding="utf-8", errors="replace"
):
return True
except OSError:
pass
return False
def sync_is_installed(text: str) -> tuple[str, str | None]:
"""Set IsInstalled true only when Comfy exists; else clear leftover true."""
if comfy_on_disk():
if re.search(r"(?im)^\s*IsInstalled:\s*true\s*$", text):
return text, None
installed_block = (
"IsInstalled: true\n"
f"InstallDate: {time.strftime('%Y-%m-%d')}\n"
"InstallVersion: gpu-rent\n"
)
if re.search(r"(?im)^\s*IsInstalled:\s*", text):
text = re.sub(r"(?im)^(\s*IsInstalled:\s*).*$", r"\1true", text, count=1)
else:
text = installed_block + text
return text, "patched IsInstalled: true (Comfy on disk)"
if re.search(r"(?im)^\s*IsInstalled:\s*true\s*$", text):
text = re.sub(r"(?im)^(\s*IsInstalled:\s*).*$", r"\1false", text, count=1)
return text, "cleared IsInstalled: false (нет Comfy — первый InstallConfirmWS)"
return text, None
def set_autocomplete_source(text: str, fname: str) -> tuple[str, str]:
"""Fill AutoComplete.Source when empty; keep a user-chosen non-empty file."""
lines = text.splitlines(keepends=True)
ac_idx = None
ac_indent = ""
for i, line in enumerate(lines):
m = re.match(r"^([ \t]*)AutoComplete:\s*$", line)
if m:
ac_idx = i
ac_indent = m.group(1)
break
if ac_idx is not None:
child = ac_indent + " "
src_idx = None
src_val = None
end = ac_idx + 1
while end < len(lines):
raw = lines[end]
if raw.strip() == "":
end += 1
continue
if (
raw.startswith(ac_indent)
and len(raw.rstrip("\n")) > len(ac_indent)
and raw[len(ac_indent)] in " \t"
):
sm = re.match(r"^[ \t]*Source:\s*(.*)$", raw)
if sm:
src_idx = end
src_val = sm.group(1).strip()
end += 1
continue
break
if src_val in ("\\x", "x"):
src_val = ""
if src_val == fname:
return text, f"ALREADY AutoComplete.Source={fname}"
if src_val:
return text, f"SKIP_USER AutoComplete.Source={src_val}"
src_line = f"{child}Source: {fname}\n"
if src_idx is not None:
nl = "\n" if lines[src_idx].endswith("\n") else ""
lines[src_idx] = f"{child}Source: {fname}{nl}"
return "".join(lines), f"CHANGED patched AutoComplete.Source={fname}"
lines.insert(ac_idx + 1, src_line)
return "".join(lines), f"CHANGED inserted AutoComplete.Source={fname}"
if re.search(r"^DefaultUser:\s*$", text, re.M):
new = re.sub(
r"^(DefaultUser:\s*\n)",
(
r"\1 AutoComplete:\n"
f" Source: {fname}\n"
" EscapeParens: true\n"
),
text,
count=1,
flags=re.M,
)
return new, f"CHANGED inserted AutoComplete under DefaultUser Source={fname}"
ac_block = (
"DefaultUser:\n"
" AutoComplete:\n"
f" Source: {fname}\n"
" EscapeParens: true\n"
)
return (
text.rstrip() + "\n\n" + ac_block,
f"CHANGED appended DefaultUser.AutoComplete Source={fname}",
)
p = Path(os.environ.get("GPU_RENT_SETTINGS_FDS") or "/mnt/swarm_data/Data/Settings.fds")
fname = (os.environ.get("GPU_RENT_AUTOCOMPLETE_FILE") or "").strip()
if not fname:
print("no GPU_RENT_AUTOCOMPLETE_FILE", file=sys.stderr)
raise SystemExit(1)
ac_block = (
"DefaultUser:\n"
" AutoComplete:\n"
f" Source: {fname}\n"
" EscapeParens: true\n"
)
installed_prefix = (
"IsInstalled: true\n"
f"InstallDate: {time.strftime('%Y-%m-%d')}\n"
"InstallVersion: gpu-rent\n"
)
if not p.is_file():
p.parent.mkdir(parents=True, exist_ok=True)
prefix = installed_prefix if comfy_on_disk() else ""
p.write_text(prefix + ac_block, encoding="utf-8")
extra = "+IsInstalled" if prefix else "без IsInstalled (первый Comfy install)"
print(f"CHANGED created Settings.fds AutoComplete.Source={fname} {extra}")
raise SystemExit(0)
text = p.read_text(encoding="utf-8", errors="replace")
text, note = sync_is_installed(text)
if note:
p.write_text(text, encoding="utf-8")
print(note)
text = p.read_text(encoding="utf-8", errors="replace")
text, src_note = set_autocomplete_source(text, fname)
print(src_note)
if src_note.startswith("CHANGED"):
p.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
'''
_ENSURE_INSTALLED_PY = r'''
#!/usr/bin/env python3
"""Sync Settings.fds IsInstalled with whether Comfy actually exists.
Warm re-up: backends/venv present → IsInstalled true (UI skips /Install).
First boot / empty dlbackend: do NOT set true — InstallConfirmWS refuses
with "Server is already installed!" and never clones ComfyUI.
"""
from __future__ import annotations
import os
import re
import sys
import time
from pathlib import Path
def comfy_on_disk() -> bool:
override = (os.environ.get("GPU_RENT_COMFY_PRESENT") or "").strip().lower()
if override in ("1", "true", "yes"):
return True
if override in ("0", "false", "no"):
return False
for cand in (
Path("/mnt/swarm_data/dlbackend/ComfyUI/venv/bin/python"),
Path("/mnt/swarm_data/dlbackend/ComfyUI/main.py"),
Path("/opt/swarmui/dlbackend/ComfyUI/venv/bin/python"),
Path("/opt/swarmui/dlbackend/ComfyUI/main.py"),
):
if cand.is_file():
return True
backends = Path("/mnt/swarm_data/Data/Backends.fds")
try:
if backends.is_file() and "StartScript" in backends.read_text(
encoding="utf-8", errors="replace"
):
return True
except OSError:
pass
return False
paths = []
env = (os.environ.get("GPU_RENT_SETTINGS_FDS") or "").strip()
if env:
paths.append(Path(env))
paths.extend(
[
Path("/mnt/swarm_data/Data/Settings.fds"),
Path("/opt/swarmui/Data/Settings.fds"),
]
)
# Unique while preserving order
seen = set()
uniq: list[Path] = []
for p in paths:
key = str(p)
if key in seen:
continue
seen.add(key)
uniq.append(p)
present = comfy_on_disk()
changed = False
for p in uniq:
try:
same = False
if p.is_file():
for q in uniq:
if q is not p and q.is_file():
try:
if p.resolve() == q.resolve():
same = True
break
except OSError:
pass
if same and changed:
continue
if not p.is_file():
# Don't invent IsInstalled=true on empty first boot.
if not present:
print(f"skip create {p}: Comfy ещё нет")
continue
if "/mnt/swarm_data/" not in str(p):
continue
if not Path("/mnt/swarm_data").is_dir():
print(f"skip create {p}: /mnt/swarm_data нет")
continue
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(
"IsInstalled: true\n"
f"InstallDate: {time.strftime('%Y-%m-%d')}\n"
"InstallVersion: gpu-rent\n"
"DefaultUser:\n"
" Theme: modern_dark\n",
encoding="utf-8",
)
print(f"created {p} with IsInstalled")
changed = True
continue
text = p.read_text(encoding="utf-8", errors="replace")
if not present:
if re.search(r"(?im)^\s*IsInstalled:\s*true\s*$", text):
text = re.sub(
r"(?im)^(\s*IsInstalled:\s*).*$", r"\1false", text, count=1
)
p.write_text(text, encoding="utf-8")
print(f"cleared {p}: IsInstalled false (нет Comfy — первый install)")
changed = True
else:
print(f"ok {p}: IsInstalled not true, Comfy отсутствует")
continue
if re.search(r"(?im)^\s*IsInstalled:\s*true\s*$", text):
print(f"ok {p}: IsInstalled true")
continue
if re.search(r"(?im)^\s*IsInstalled:\s*", text):
text = re.sub(r"(?im)^(\s*IsInstalled:\s*).*$", r"\1true", text, count=1)
else:
text = (
"IsInstalled: true\n"
f"InstallDate: {time.strftime('%Y-%m-%d')}\n"
"InstallVersion: gpu-rent\n"
+ text
)
if not re.search(r"(?im)^\s*InstallDate:\s*", text):
text = re.sub(
r"(?im)^(\s*IsInstalled:\s*true\s*\n)",
rf"\1InstallDate: {time.strftime('%Y-%m-%d')}\n",
text,
count=1,
)
p.write_text(text, encoding="utf-8")
print(f"patched {p}: IsInstalled true")
changed = True
except OSError as exc:
print(f"skip {p}: {exc}")
print("CHANGED" if changed else "NOOP")
'''
def _merge_autocomplete_into_settings(
cfg: Config, host: str, settings_path: str, filename: str, log: Log
) -> str:
"""Patch AutoComplete.Source. Returns changed|already|skip_user|noop."""
out = run_python(
cfg,
host,
_AUTOCOMPLETE_MERGE_PY,
remote_path="/tmp/gpu-rent-patch_autocomplete.py",
timeout=60,
log=None,
env={
"GPU_RENT_SETTINGS_FDS": settings_path,
"GPU_RENT_AUTOCOMPLETE_FILE": filename,
},
)
status = "noop"
for line in (out or "").splitlines():
line = line.strip()
if not line:
continue
log(line)
if line.startswith("CHANGED"):
status = "changed"
elif line.startswith("ALREADY") and status != "changed":
status = "already"
elif line.startswith("SKIP_USER") and status != "changed":
status = "skip_user"
return status
def ensure_settings_is_installed(cfg: Config, host: str, log: Log) -> bool:
"""Sync Settings.fds IsInstalled with Comfy on disk.
Warm: backends/venv exist → true (skip UI /Install). First boot / empty
dlbackend → leave false/missing so InstallConfirmWS can clone Comfy.
Returns True if the file was created/patched (caller may need SwarmUI restart).
"""
out = run_python(
cfg,
host,
_ENSURE_INSTALLED_PY,
remote_path="/tmp/gpu-rent-ensure_installed.py",
timeout=60,
log=None,
env={"GPU_RENT_SETTINGS_FDS": f"{DATA}/Data/Settings.fds"},
)
changed = False
for line in (out or "").splitlines():
line = line.strip()
if not line:
continue
log(f"Settings: {line}")
if line == "CHANGED":
changed = True
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).
"""
keys: dict[str, str] = {}
if cfg.civitai_api_token:
keys["civitai_api"] = cfg.civitai_api_token
hf = (cfg.hf_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_assistent_personas(cfg: Config, host: str, log: Log) -> None:
"""Push local assistent-personas.yaml → VM Assistent/personas.yaml + personas.json."""
from gpu_rent.paths import assistent_personas_example_path, assistent_personas_manifest_path
local = Path(
(getattr(cfg, "assistent_personas_manifest", None) or assistent_personas_manifest_path())
)
if not local.is_file():
example = assistent_personas_example_path()
if example.is_file():
local = example
else:
log("assistent-personas: нет yaml — skip")
return
text = local.read_text(encoding="utf-8")
if not text.strip():
log("assistent-personas: пустой файл — skip")
return
remote_dir = f"{DATA}/Assistent"
remote_yaml = f"{remote_dir}/personas.yaml"
remote_json = f"{remote_dir}/personas.json"
run_ssh(cfg, host, f"mkdir -p {shlex.quote(remote_dir)}", check=False)
put_text(cfg, host, remote_yaml, text if text.endswith("\n") else text + "\n")
try:
import yaml
data = yaml.safe_load(text) or {}
put_text(
cfg,
host,
remote_json,
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
)
except Exception as exc:
log(f"assistent-personas: json convert — {exc}")
log(f"assistent-personas → {remote_yaml}")
def merge_wanted_models_from_vm(cfg: Config, host: str, log: Log) -> int:
"""Pull VM wanted queue into local models.yaml. Returns count of new urls merged."""
from gpu_rent.capture import ModelCaptureItem, merge_models_yaml
remote = f"{DATA}/.gpu-rent-wanted-models.yaml"
raw = run_ssh(
cfg,
host,
f"test -f {shlex.quote(remote)} && cat {shlex.quote(remote)} || true",
check=False,
timeout=20,
).strip()
if not raw:
return 0
try:
import yaml
data = yaml.safe_load(raw) or {}
except Exception as exc:
log(f"wanted-models: bad yaml — {exc}")
return 0
if not isinstance(data, dict):
return 0
items: list[ModelCaptureItem] = []
for kind, rows in data.items():
if kind not in MODEL_DIRS or not isinstance(rows, list):
continue
for row in rows:
if not isinstance(row, dict):
continue
url = str(row.get("url") or "").strip()
if not url:
continue
vid = row.get("version_id")
try:
vid_i = int(vid) if vid not in (None, "", 0, "0") else extract_version_id(url)
except (TypeError, ValueError):
vid_i = extract_version_id(url)
items.append(
ModelCaptureItem(
kind=kind,
url=url,
title=str(row.get("title") or Path(url).name),
version_id=vid_i,
source="wanted",
)
)
if not items:
return 0
added, skipped = merge_models_yaml(Path(cfg.models_manifest), items, dry_run=False)
if added:
log(f"wanted→models.yaml: +{len(added)} (skip {len(skipped)})")
elif skipped:
log(f"wanted→models.yaml: уже есть ({len(skipped)})")
return len(added)
def _place_wanted_cards(cfg: Config, host: str, jobs: list[dict], log: Log) -> None:
"""Copy drafted .assistent.json from wanted-cards next to freshly seeded weights."""
cards_dir = f"{DATA}/.gpu-rent-wanted-cards"
exists = run_ssh(
cfg, host, f"test -d {shlex.quote(cards_dir)} && echo YES || true", check=False
).strip()
if "YES" not in exists:
return
placed = 0
for job in jobs:
dest = str(job.get("dest") or "")
vid = job.get("version_id")
if not dest or vid in (None, "", 0, "0"):
continue
stem = Path(dest).stem
card_src = f"{cards_dir}/{vid}.assistent.json"
card_dst = str(Path(dest).with_name(f"{stem}.assistent.json"))
out = run_ssh(
cfg,
host,
f"if test -f {shlex.quote(card_src)} && test -f {shlex.quote(dest)}; then "
f"cp -n {shlex.quote(card_src)} {shlex.quote(card_dst)} && echo PLACED; fi",
check=False,
).strip()
if "PLACED" in out:
placed += 1
if placed:
log(f"wanted-cards: положил {placed} .assistent.json рядом с весами")
def seed_civitai(cfg: Config, host: str, log: Log) -> None:
from gpu_rent.huggingface import is_huggingface_url
try:
merge_wanted_models_from_vm(cfg, host, log)
except Exception as exc:
log(f"wanted-models merge: {exc}")
entries = parse_models(cfg.models_manifest)
if not entries:
log("model-seed пропущен: манифест пуст — дефолт SwarmUI")
return
# Warm short-circuit: last successful jobs list still present on disk.
cache_remote = f"{DATA}/.gpu-rent-models-jobs.json"
try:
cached_raw = run_ssh(
cfg,
host,
f"test -f {cache_remote} && cat {cache_remote} || true",
check=False,
timeout=20,
).strip()
except Exception:
cached_raw = ""
if cached_raw.startswith("{"):
try:
cached = json.loads(cached_raw)
dests = [str(d) for d in (cached.get("dests") or []) if d]
fp = str(cached.get("fp") or "")
want_fp = _models_manifest_fp(cfg)
if dests and fp == want_fp:
check = " && ".join(f"test -f {shlex.quote(d)}" for d in dests)
ok = run_ssh(
cfg,
host,
f"if {check}; then echo ALL_OK; else echo MISSING; fi",
check=False,
timeout=30,
).strip()
if "ALL_OK" in ok:
log(
f"model-seed: skip — {len(dests)} файл(ов) уже на диске (cache)"
)
return
except (json.JSONDecodeError, TypeError, CloudError):
pass
jobs: list[dict] = []
for entry in entries:
url = (entry.url or "").strip()
if url and is_huggingface_url(url):
name = url.rstrip("/").rsplit("/", 1)[-1].split("?", 1)[0] or "model.safetensors"
folder = MODEL_DIRS.get(entry.kind, entry.kind)
dest = f"{DATA}/Models/{folder}/{name}"
stem = Path(name).stem
swarm = {
"name": stem,
"title": stem,
"description": f"Hugging Face: {url}",
"trigger_phrase": "",
"author": "",
"tags": ["huggingface"],
}
jobs.append(
{
"dest": dest,
"url": url,
"sha256": "",
"auth": "hf",
"version_id": None,
"sidecars": {
f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2),
},
}
)
continue
if not cfg.civitai_api_token:
continue
vid = entry.version_id or (extract_version_id(entry.url) if entry.url else None)
if not vid:
log(f"пропуск {entry.kind}: нет version_id / HF url")
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 [],
}
job: dict = {
"dest": dest,
"url": _download_url(api_host, vid, info),
"sha256": sha,
"auth": "civitai",
"version_id": int(vid),
"sidecars": {
f"{stem}.civitai.json": civitai_json,
f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2),
},
}
size_b = file_size_bytes(info)
if size_b:
job["size"] = size_b
preview = pick_preview_image(version)
if preview:
preview_url, preview_suffix = preview
job["preview_url"] = preview_url
job["preview_dest"] = f"{DATA}/Models/{folder}/{stem}{preview_suffix}"
jobs.append(job)
if not jobs:
if not cfg.civitai_api_token:
log("Civitai-seed пропущен: нет CIVITAI_API_TOKEN — дефолт SwarmUI")
else:
log("Civitai-seed: ни одной скачиваемой строки")
return
hf_n = sum(1 for j in jobs if j.get("auth") == "hf")
civ_n = len(jobs) - hf_n
if hf_n and not cfg.hf_token:
log(
"⚠ в манифесте есть Hugging Face URL, но нет HF_TOKEN — "
"gated/abliterated файлы дадут 401. Токен: huggingface.co/settings/tokens"
)
if civ_n and not cfg.civitai_api_token:
log("⚠ Civitai-строки пропущены: нет CIVITAI_API_TOKEN")
if not any(e.kind == "checkpoint" for e in entries):
log("в манифесте нет checkpoint — генерация может не стартовать")
dests = [str(j.get("dest") or "") for j in jobs if j.get("dest")]
if dests:
check = " && ".join(f"test -f {shlex.quote(d)}" for d in dests)
present = run_ssh(
cfg,
host,
f"if {check}; then echo ALL_OK; else echo MISSING; fi",
check=False,
timeout=30,
).strip()
if "ALL_OK" in present:
put_text(
cfg,
host,
cache_remote,
json.dumps({"fp": _models_manifest_fp(cfg), "dests": dests}, indent=2)
+ "\n",
)
log(
f"model-seed: skip fetch — {len(dests)} файл(ов) уже на диске "
f"(civitai={civ_n}, huggingface={hf_n})"
)
return
put_text(cfg, host, "/tmp/gpu-rent-civitai-jobs.json", json.dumps(jobs, indent=2))
if cfg.civitai_api_token:
put_text(
cfg, host, "/tmp/gpu-rent-civitai.token", cfg.civitai_api_token + "\n", mode=0o600
)
if cfg.hf_token:
put_text(cfg, host, "/tmp/gpu-rent-hf.token", cfg.hf_token + "\n", mode=0o600)
log(
f"model-seed: {len(jobs)} файл(ов) "
f"(civitai={civ_n}, huggingface={hf_n}) — прогресс [N/{len(jobs)}]"
)
run_python(
cfg,
host,
_pkg_text("civitai_fetch.py"),
remote_path="/tmp/gpu-rent-civitai_fetch.py",
timeout=7200,
log=log,
)
try:
_place_wanted_cards(cfg, host, jobs, log)
except Exception as exc:
log(f"wanted-cards: {exc}")
put_text(
cfg,
host,
cache_remote,
json.dumps({"fp": _models_manifest_fp(cfg), "dests": dests}, indent=2) + "\n",
)
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)
_OLLAMA_TAGS_PY = r"""
import json, urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=8) as r:
data = json.loads(r.read().decode())
except Exception as exc:
print("ERR " + str(exc)[:200])
raise SystemExit(0)
for m in data.get("models") or []:
if isinstance(m, dict):
for key in ("name", "model"):
n = m.get(key)
if n:
print(n)
elif isinstance(m, str) and m.strip():
print(m.strip())
"""
def _ollama_api_tags(cfg: Config, host: str) -> set[str]:
"""Names from Ollama /api/tags (same source as Assistent / verify)."""
out = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + _OLLAMA_TAGS_PY + "\nPY",
check=False,
timeout=20,
)
names: set[str] = set()
for ln in out.splitlines():
s = ln.strip()
if not s or s.startswith("ERR "):
continue
names.add(s)
return names
def provision_llm(cfg: Config, host: str, log: Log) -> None:
from gpu_rent.llm_runtime import (
already_have_ollama_tag,
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,
)
# Drop LLM units that should not hold VRAM for this runtime.
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
still: list[str] = []
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=_remote_llm_env(cfg, *_OLLAMA_INSTALL_ENV),
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]
still: list[str] = []
if not names:
log("ollama-models.yaml пуст — pull skip")
else:
have = _ollama_api_tags(cfg, host)
missing = [n for n in names if not already_have_ollama_tag(have, n)]
if not missing:
log(f"ollama pull: skip — /api/tags уже {sorted(have)}")
else:
put_text(
cfg, host, "/tmp/gpu-rent-ollama-models.json", json.dumps(missing, indent=2)
)
log(
f"Ollama: pull {len(missing)} из манифеста "
f"(/api/tags={len(have)})"
)
try:
run_python(
cfg,
host,
_pkg_text("ollama_pull.py"),
remote_path="/tmp/gpu-rent-ollama_pull.py",
timeout=7200,
log=log,
)
except Exception as exc:
log(f"⚠ Ollama pull: {exc}")
have = _ollama_api_tags(cfg, host)
still = [n for n in names if not already_have_ollama_tag(have, n)]
if still:
log(
"⚠ Ollama /api/tags без "
+ ", ".join(still[:5])
+ f" (есть: {sorted(have) or 'пусто'}). "
"SwarmUI ок — GPU не гасим; Assistent будет пустой."
)
warm = next(
(
n
for n in list(defaults) + names
if already_have_ollama_tag(have, n)
),
"",
)
if warm:
log(f"Ollama warmup {warm} (гружу в VRAM)")
put_text(
cfg,
host,
"/tmp/gpu-rent-ollama-warmup.json",
json.dumps({"model": warm}, indent=2),
)
try:
run_python(
cfg,
host,
_pkg_text("ollama_warmup.py"),
remote_path="/tmp/gpu-rent-ollama_warmup.py",
timeout=240,
log=log,
)
except Exception as exc:
log(f"⚠ Ollama warmup: {exc}")
else:
raise CloudError(f"неизвестный LLM_RUNTIME={runtime!r}")
st = load_state()
st.notes = dict(st.notes or {})
st.notes["llm_runtime"] = runtime
if still:
st.notes["llm_error"] = (
"нет в /api/tags: " + ", ".join(still[:5])
)[:500]
else:
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 (или --ollama / --llm ollama)"
)
# 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)
try:
seed_assistent_personas(cfg, host, log)
except Exception as exc:
log(f"assistent-personas: {exc}")
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)
# Warm: IsInstalled true if Comfy exists. First boot: keep false.
if ensure_settings_is_installed(cfg, host, log):
restart = True
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)
log("Hold killer: gpu-rent hold | Стоп GPU: gpu-rent stop")