"""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 15–40 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")