From f437cd0373426f5dcdb0f1136eae9ed6b18f5045 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 13:25:55 +0300 Subject: [PATCH] Update documentation and CLI behavior for GPU management - Clarified the behavior of `Ctrl+C` and `Ctrl+D` in the README and other documentation, specifying that `Ctrl+C` only stops the tunnel while keeping the GPU active, and `Ctrl+D` stops the GPU while preserving disk data. - Enhanced the CLI documentation to reflect these changes, ensuring users understand the implications of these commands during GPU operations. - Improved the handling of data bindings and remounting logic in the codebase to prevent issues with empty model tabs in the UI. - Added tests to validate the new command behaviors and ensure proper documentation alignment. --- README.md | 6 +- docs/architecture.md | 4 +- docs/autocomplete.md | 8 +- docs/cli.md | 5 +- docs/decisions.md | 6 +- docs/setup.md | 2 +- docs/spike-notes.md | 4 +- docs/swarmui.md | 6 + src/gpu_rent/access_card.py | 4 + src/gpu_rent/cli.py | 6 +- src/gpu_rent/doctor.py | 3 +- src/gpu_rent/local_watchdog.py | 2 +- src/gpu_rent/provision.py | 247 +++++++++++++-------- src/gpu_rent/ready.py | 22 +- src/gpu_rent/remote/bootstrap.sh | 7 +- src/gpu_rent/remote/ensure_binds.sh | 93 ++++++++ src/gpu_rent/remote/install_swarm_comfy.py | 34 ++- src/gpu_rent/remote/swarm_diag.py | 35 +++ src/gpu_rent/session.py | 29 +-- src/gpu_rent/tunnel.py | 81 ++++++- src/gpu_rent/ux.py | 2 +- tests/test_session.py | 8 + tests/test_tunnel_watch.py | 111 ++++++++- tests/test_verify_stack.py | 8 + tests/test_wait_backend_idle.py | 25 +++ tests/test_warm_up_skips.py | 96 ++++++++ 26 files changed, 712 insertions(+), 142 deletions(-) create mode 100644 src/gpu_rent/remote/ensure_binds.sh diff --git a/README.md b/README.md index bcb2502..0690ec3 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`). ```powershell .\gpu-rent.ps1 up --yes # GPU + SwarmUI + туннель :17801 # работаешь в браузере… -# Ctrl+C в терминале = только туннель off, GPU жив -.\gpu-rent.ps1 tunnel --open # снова UI +# Ctrl+C = туннель off, GPU жив +# Ctrl+D = stop GPU (диски остаются) +.\gpu-rent.ps1 tunnel --open # снова UI, если закрыл Ctrl+C .\gpu-rent.ps1 hold # отложить idle-killer .\gpu-rent.ps1 stop # погасить GPU, диски оставить ``` @@ -41,6 +42,7 @@ Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`). | Важно | | | --- | --- | | `Ctrl+C` на туннеле | **не** гасит GPU | +| `Ctrl+D` на туннеле | **stop** GPU, диски остаются | | Простой ~30 мин | idle-killer сам удалит compute | | Preemptible | ~24 ч → `EXPIRED`; `tunnel` / `up` поднимут снова | | Двойной клик лаунчера | задай `GPU_RENT_DEFAULT_ARGS=up --yes` в `gpu-rent.vars` | diff --git a/docs/architecture.md b/docs/architecture.md index 11550a7..513a2a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,6 +82,8 @@ `mkfs` только если нет маркера **и** `blkid` подтвердил пустое устройство по serial/by-id. Не хардкодить `scsi-0Selectel_Volume_…`. +Remount bind’ов — только после `systemctl stop swarmui` и обычного `umount`. `umount -l` на живом Comfy позже опустошает вкладку Models (веса на data volume остаются). + ## Стейт-машина Фазы в `state.json` (код пишет только эти): @@ -144,7 +146,7 @@ Killer молчит: 3. `ERROR` / нет GPU → выход, не бесконечный recreate. 4. Туннель мёртв при ACTIVE → reconnect. -`Ctrl+C` здесь закрывает туннель, **не** вызывает `stop`. +`Ctrl+C` здесь закрывает туннель, **не** вызывает `stop`. **Ctrl+D** — `stop` (диски остаются). ## Teardown (`gpu-rent stop`) diff --git a/docs/autocomplete.md b/docs/autocomplete.md index fa935cc..dbaf2c6 100644 --- a/docs/autocomplete.md +++ b/docs/autocomplete.md @@ -10,8 +10,8 @@ | Момент | Действие | | --- | --- | -| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта SwarmUI | -| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, `systemctl restart swarmui` если UI уже крутится | +| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/`. После `InstallConfirmWS` прописать `DefaultUser.AutoComplete.Source` (установщик затирает Settings) и при необходимости перезапустить SwarmUI | +| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл. Если `Source` в Settings пустой/`\\x` — снова прописать filename (не трогать, если пользователь выбрал другой файл). `systemctl restart swarmui` если csv или Source изменились | | `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) | Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH. @@ -79,9 +79,9 @@ DefaultUser: `Source` — имя файла **относительно** `Data/Autocompletions`, как в UI `AutoCompletionsSource`. -Если `Settings.fds` уже есть (повторный диск) и пользователь сменил список в UI — **не** перезаписывать `Source` на каждом `up`. Ставить Source только когда он пустой или мы его ещё ни разу не ставили (флаг в meta `settings_applied: true`). +`InstallConfirmWS` перезаписывает `Settings.fds`, поэтому gpu-rent проставляет `Source` **после** первого Comfy-install, не только до старта. Если `Source` уже непустой (пользователь выбрал другой csv) — не трогаем. Пустой / `\x` / нет ключа — пишем `danbooru.csv` (или `AUTOCOMPLETE_FILENAME`). -После замены csv на уже живой VM: `systemctl restart swarmui` (хелпер `Reload()` при старте перечитает каталог). +После замены csv или Source на уже живой VM: `systemctl restart swarmui` (хелпер `Reload()` при старте перечитает каталог). ## Конфиг diff --git a/docs/cli.md b/docs/cli.md index dbf8675..905dac5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,6 +24,7 @@ gpu-rent stop ```text gpu-rent up --yes # Ctrl+C → туннель off, GPU жив +# Ctrl+D → stop GPU gpu-rent tunnel --open gpu-rent hold gpu-rent stop @@ -65,7 +66,7 @@ gpu-rent up --yes --ollama | `gpu-rent up --no-tunnel` | Только облако | | `gpu-rent up --no-spot` | Не preemptible | | `gpu-rent up --flavor ID --yes` | Явный flavor; без `--flavor` + `--yes` — первый доступный из `FLAVOR_PREFERENCE` | -| `gpu-rent tunnel` / `tunnel --open` | Повторный проброс; `--open` сразу браузер. Ctrl+C GPU **не** гасит | +| `gpu-rent tunnel` / `tunnel --open` | Повторный проброс; `--open` сразу браузер. Ctrl+C GPU **не** гасит; **Ctrl+D** вызывает `stop` | | `gpu-rent open` / `open --llm` | Браузер на SwarmUI / LLM-порт (туннель уже должен слушать) | | `gpu-rent status` | State, Nova, диск, killer/hold, LLM, local-watchdog | | `gpu-rent hold` / `--minutes N` / `--until ISO` / `--clear` | Пауза idle-killer (нужны живая VM + SSH) | @@ -124,7 +125,7 @@ Exit 0 → можно `up`. Exit 1 → причина в таблице / кра 1. `gpu-rent watchdog install` (раз на машине, из корня репо) 2. Пока крутится `up`/`tunnel`, пишется heartbeat `.gpu-rent/local-lease.json` -3. **Ctrl+C** → detach, GPU **не** трогаем +3. **Ctrl+C** → detach, GPU **не** трогаем. **Ctrl+D** → `stop` (как `gpu-rent stop`) 4. Процесс умер / ребут → через `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` 5. `gpu-rent stop` чистит lease сам diff --git a/docs/decisions.md b/docs/decisions.md index a104b1a..680410f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,7 +5,7 @@ | Тема | Решение | | --- | --- | | Аудитория | Личный инструмент, репозиторий можно показать другим: MIT, без аккаунтных id и без имён локальных чекпоинтов | -| Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть. Опционально: `gpu-rent watchdog install` — локальный тик на Windows/macOS/Linux; если туннель умер без Ctrl+C/`stop`, после `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` | +| Сессия | GPU живёт до `stop` / **Ctrl+D на туннеле** **или** простоя (idle-killer **на VM**). **Ctrl+C** туннель закрывает, compute оставляет. Ноут можно закрыть — compute не обязан умереть. Опционально: `gpu-rent watchdog install` — локальный тик; если туннель умер без Ctrl+C/`Ctrl+D`/`stop`, после `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop` | | Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем | | Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | Пул GPU | Перед `up`/`flavors` сканируем `SCAN_POOLS` (дефолт `ru-6,ru-7`). `ru-6` — мультизональный: ходим на `https://ru-6.cloud.api.selcloud.ru/compute/` тем же токеном (SDK-каталог часто знает только RC-пул). Собираем типы GPU из extra_specs и совпадения с `FLAVOR_PREFERENCE`. Автоматом `.env` не пишем — печатаем рекомендацию `OS_REGION_NAME` / `GPU_RENT_AZ` | @@ -19,7 +19,7 @@ | Floating IP | `KEEP_FLOATING_IP=false`: выделить на `up`, удалить на `stop` | | Idle-killer | 30 минут пустой очереди генерации. Открытый браузер не продлевает жизнь | | Льгота после boot | Killer молчит во время clone/seed/push, hold, качалки в UI SwarmUI, пока backend не Idle, и 45 мин после ACTIVE/unshelve | -| `up` / `tunnel` | `up` по умолчанию после ready открывает туннель `:17801`, печатает URL и ждёт. `--no-tunnel` — только облако. Ctrl+C на туннеле GPU не гасит (`stop` отдельно). Команда `tunnel` остаётся для повторного входа | +| `up` / `tunnel` | `up` по умолчанию после ready открывает туннель `:17801`, печатает URL и ждёт. `--no-tunnel` — только облако. Ctrl+C на туннеле GPU не гасит. **Ctrl+D — `stop`** (диски остаются). Команда `tunnel` остаётся для повторного входа | | Interactive `up` | Без `--yes`: нумерованные меню (LLM, пресет, flavor #, data GB, preemptible) → confirm. Изменения можно сохранить в `gpu-rent.vars` | | Git update | На каждом `up` по умолчанию: `git pull` SwarmUI + репы из `extensions.yaml` + уже установленные на data (`Extensions`/`DLNodes`). `--no-update` или `UPDATE_GIT=false` — не тянуть | | LLM (opt-in) | `none` по умолчанию. Флаги / `LLM_RUNTIME` / меню. Манифест: `ollama-models.yaml`. Порт 17811. Prompt-help, не замена SwarmUI | @@ -49,6 +49,6 @@ 1. **Туннель ≠ жизнь GPU.** `stop` и idle-killer не зависят от того, открыт ли SSH с ноутбука. 2. **Локальный SwarmUI на 7801 не трогаем.** Туннель по умолчанию на **17801** (на VM по-прежнему 7801 на loopback). -3. **`Ctrl+C` на туннеле не удаляет VM.** Иначе «закрыл ноут» невозможно. Чтобы убить GPU — `gpu-rent stop` или простой. При установленном **local-watchdog** Ctrl+C по-прежнему detach; убийство процесса/ребут без detach → stop после grace. +3. **`Ctrl+C` на туннеле не удаляет VM.** Иначе «закрыл ноут» невозможно. **`Ctrl+D` вызывает `stop`** (диски на месте). При установленном **local-watchdog** Ctrl+C по-прежнему detach; убийство процесса/ребут без detach → stop после grace. 4. Idle-killer на VM **не может** быть `shutdown -h`: у Selectel остановленная изнутри VM часто продолжает тарифицировать ресурсы. Нужен вызов OpenStack: удалить **этот** compute, диски оставить. 5. Для этого на VM — OpenStack **application credential** с правом DELETE/GET **только этого** compute (не `/servers/*`, не unrestricted fallback). На `stop`/`destroy` cred отзывается. Компрометация SwarmUI в худшем случае сносит текущую GPU-сессию, а не произвольные машины проекта. diff --git a/docs/setup.md b/docs/setup.md index 3189a90..6346a80 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -235,7 +235,7 @@ copy models.example.yaml models.yaml 2. Create/unshelve GPU + диски (после confirm). 3. Bootstrap SwarmUI, extensions, autocomplete, Civitai-seed, push локальных папок; optional LLM. 4. Туннель на `localhost:17801` (или LLM-порт), **проверка** что сервисы отвечают, access-card. -5. Процесс ждёт: **Ctrl+C** закрывает только туннель, GPU остаётся. +5. Процесс ждёт: **Ctrl+C** закрывает только туннель (GPU остаётся); **Ctrl+D** — `stop` GPU (диски остаются). Полезные флаги: diff --git a/docs/spike-notes.md b/docs/spike-notes.md index 390f71c..861a087 100644 --- a/docs/spike-notes.md +++ b/docs/spike-notes.md @@ -63,12 +63,14 @@ ## 3. Туннель и API -Если закрыл туннель (Ctrl+C): +Если закрыл туннель (**Ctrl+C**, GPU жив): ```powershell .\gpu-rent.ps1 tunnel --open ``` +**Ctrl+D** на живом туннеле = `stop` GPU. + | Шаг | OK? | | --- | --- | | UI http://127.0.0.1:17801 | | diff --git a/docs/swarmui.md b/docs/swarmui.md index 5697803..e38c593 100644 --- a/docs/swarmui.md +++ b/docs/swarmui.md @@ -28,6 +28,12 @@ Рестарт UI: `systemctl restart swarmui`, не `docker restart`. +## Пустая вкладка Models + +Веса лежат на data volume (`/mnt/swarm_data/Models`), SwarmUI смотрит в `/opt/swarmui/Models` через bind. Если bind отвалился, Generate всё ещё показывает последнюю модель (это JS), а сетка Models пустая. + +Сначала кнопка refresh на вкладке Models и F5. Если пусто — файлы не удалились, нужен remount: `gpu-rent up --yes` (после фикса не используется `umount -l` на живом процессе). + ## ComfyUI Первый запуск качает backend в `/opt/swarmui/dlbackend` (это bind на data volume). Иначе каждый recreate потеряет часы. diff --git a/src/gpu_rent/access_card.py b/src/gpu_rent/access_card.py index 9f96d7d..0cce2a7 100644 --- a/src/gpu_rent/access_card.py +++ b/src/gpu_rent/access_card.py @@ -124,6 +124,9 @@ def render_access_panel( cmds.add_row("открыть LLM", "gpu-rent open --llm") cmds.add_row("hold killer", "gpu-rent hold") cmds.add_row("стоп GPU", "gpu-rent stop") + if tunneled: + cmds.add_row("Ctrl+C", "туннель off, GPU жив") + cmds.add_row("Ctrl+D", "stop GPU, диски остаются") if not tunneled: cmds.add_row("туннель", "gpu-rent tunnel --open") @@ -217,6 +220,7 @@ def print_access_card( for line in mcp_snippet_lines(cfg): log(line) log("hold: gpu-rent hold | stop: gpu-rent stop") + log("Ctrl+C — туннель off, GPU жив | Ctrl+D — stop GPU") log("") return from gpu_rent.term import console as default_console diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index b9032d9..bad6aa2 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -49,7 +49,7 @@ def _die(exc: BaseException) -> None: err(str(exc)) hint = ( "Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop " - "(Ctrl+C на туннеле GPU не гасит)" + "(Ctrl+C на туннеле GPU не гасит; Ctrl+D гасит)" ) msg = str(exc) if "gpu-rent status" not in msg and "Дальше:" not in msg: @@ -809,7 +809,7 @@ def logs( def tunnel( open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"), ) -> None: - """SSH localhost:17801 -> VM :7801. Ctrl+C закрывает туннель, GPU оставляет.""" + """SSH localhost:17801 -> VM :7801. Ctrl+C — туннель off; Ctrl+D — stop GPU.""" try: cfg = load_config(require_auth=True) state = load_state() @@ -1052,7 +1052,7 @@ def resize_data(gb: int = typer.Option(..., "--gb", help="Новый разме watchdog_app = typer.Typer( help=( - "Локальный сервис: если туннель умер без Ctrl+C / stop — " + "Локальный сервис: если туннель умер без Ctrl+C / Ctrl+D / stop — " "через grace удалить compute. Не путать с idle-killer на VM." ), no_args_is_help=True, diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index a5f0c7b..48733d9 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -422,7 +422,8 @@ def dry_run_plan(checks: list[Check]) -> list[str]: f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин", "₽: в API нет — смотри панель; диск 24/7 даже после stop", f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801", - "gpu-rent up --yes создаст GPU + SwarmUI и откроет туннель :17801 (Ctrl+C не гасит GPU)", + "gpu-rent up --yes создаст GPU + SwarmUI и откроет туннель :17801 " + "(Ctrl+C не гасит GPU, Ctrl+D гасит)", "только облако без туннеля: gpu-rent up --yes --no-tunnel", ] flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None) diff --git a/src/gpu_rent/local_watchdog.py b/src/gpu_rent/local_watchdog.py index 302908f..3fac64b 100644 --- a/src/gpu_rent/local_watchdog.py +++ b/src/gpu_rent/local_watchdog.py @@ -271,7 +271,7 @@ def install_watchdog( log( f"local-watchdog установлен ({platform}): тик каждые {interval} мин. " f"Grace {grace_seconds() // 60} мин после смерти процесса туннеля → stop. " - f"Ctrl+C на туннеле GPU не гасит. " + f"Ctrl+C на туннеле GPU не гасит; Ctrl+D — stop. " "При SELECTEL_API_TOKEN — toast каждые BALANCE_NOTIFY_STEP_RUB ₽ (дефолт 200)." ) return marker diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index 5efa6d0..33c8be5 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -28,7 +28,7 @@ from gpu_rent.manifests import ( repo_dirname, repo_matches_runtime, ) -from gpu_rent.ssh_ops import put_text, remote_exists, run_python, run_ssh +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] @@ -109,6 +109,26 @@ def tune_swarm_perf(cfg: Config, host: str, log: Log) -> bool: 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. @@ -212,13 +232,11 @@ def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool: 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"): + if blob and blob.get("error"): log(str(blob["error"])) - return False - sha = str(blob.get("sha") or "") - download_url = str(blob.get("download_url") or "") + 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) @@ -226,54 +244,52 @@ def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool: 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) + 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") - 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") + 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" - 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 applied: - _merge_autocomplete_into_settings(cfg, host, settings, cfg.autocomplete_filename, log) - 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 + 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''' @@ -335,6 +351,77 @@ def sync_is_installed(text: str) -> tuple[str, str | None]: 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: @@ -358,7 +445,7 @@ if not p.is_file(): 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"created Settings.fds AutoComplete.Source={fname} {extra}") + print(f"CHANGED created Settings.fds AutoComplete.Source={fname} {extra}") raise SystemExit(0) text = p.read_text(encoding="utf-8", errors="replace") @@ -368,40 +455,10 @@ if note: print(note) text = p.read_text(encoding="utf-8", errors="replace") -if re.search(rf"^\s*Source:\s*{re.escape(fname)}\s*$", text, re.M): - print(f"AutoComplete.Source already {fname}") - raise SystemExit(0) - -# Replace Source line if AutoComplete section exists -new, n = re.subn( - r"(^[ \t]*Source:\s*).*$", - rf"\1{fname}", - text, - count=1, - flags=re.M, -) -if n and "AutoComplete" in text: - p.write_text(new, encoding="utf-8") - print(f"patched AutoComplete.Source={fname}") - raise SystemExit(0) - -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, - ) - p.write_text(new, encoding="utf-8") - print(f"inserted AutoComplete under DefaultUser Source={fname}") -else: - p.write_text(text.rstrip() + "\n\n" + ac_block, encoding="utf-8") - print(f"appended DefaultUser.AutoComplete Source={fname}") +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") ''' @@ -548,8 +605,8 @@ print("CHANGED" if changed else "NOOP") def _merge_autocomplete_into_settings( cfg: Config, host: str, settings_path: str, filename: str, log: Log -) -> None: - """Patch AutoComplete.Source in Settings.fds without wiping the rest.""" +) -> str: + """Patch AutoComplete.Source. Returns changed|already|skip_user|noop.""" out = run_python( cfg, host, @@ -562,9 +619,19 @@ def _merge_autocomplete_into_settings( "GPU_RENT_AUTOCOMPLETE_FILE": filename, }, ) + status = "noop" for line in (out or "").splitlines(): - if line.strip(): - log(line.strip()) + 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: diff --git a/src/gpu_rent/ready.py b/src/gpu_rent/ready.py index ee018ea..0c02f50 100644 --- a/src/gpu_rent/ready.py +++ b/src/gpu_rent/ready.py @@ -137,7 +137,27 @@ if want_swarm: }) if want_ollama: - ok, detail = http_ok("http://127.0.0.1:11434/api/tags") + try: + req = urllib.request.Request("http://127.0.0.1:11434/api/tags", method="GET") + with urllib.request.urlopen(req, timeout=5) as resp: + raw = resp.read().decode("utf-8", "replace") + payload = json.loads(raw) + models = payload.get("models") if isinstance(payload, dict) else None + names = [] + if isinstance(models, list): + for m in models: + if isinstance(m, dict) and m.get("name"): + names.append(str(m["name"])) + elif isinstance(m, str) and m.strip(): + names.append(m.strip()) + if names: + preview = ", ".join(names[:3]) + extra = "" if len(names) <= 3 else f" +{len(names) - 3}" + ok, detail = True, f"{len(names)} models ({preview}{extra})" + else: + ok, detail = False, "Ollama up, 0 models — Assistent dropdown empty; ollama pull" + except Exception as exc: + ok, detail = False, str(exc)[:160] checks.append({ "name": "ollama", "ok": ok, diff --git a/src/gpu_rent/remote/bootstrap.sh b/src/gpu_rent/remote/bootstrap.sh index fa4ef1a..c8d292e 100644 --- a/src/gpu_rent/remote/bootstrap.sh +++ b/src/gpu_rent/remote/bootstrap.sh @@ -76,7 +76,12 @@ ensure_bind() { return 0 fi log "bind remount ${dst} (было: ${src_mnt:-?})" - umount "$dst" 2>/dev/null || umount -l "$dst" 2>/dev/null || true + umount "$dst" 2>/dev/null || true + if findmnt "$dst" >/dev/null 2>&1; then + # umount -l + bind on a live path empties Models later when FDs close. + log "WARN ${dst} busy — оставляю текущий mount, без umount -l" + return 0 + fi fi mount --bind "$src" "$dst" || log "WARN: mount --bind ${src} → ${dst} failed" if ! grep -Fq " ${dst} " /etc/fstab; then diff --git a/src/gpu_rent/remote/ensure_binds.sh b/src/gpu_rent/remote/ensure_binds.sh new file mode 100644 index 0000000..887c3c1 --- /dev/null +++ b/src/gpu_rent/remote/ensure_binds.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Remount SwarmUI data binds. Call after swarmui is stopped (or we stop it). +# +# Do NOT umount -l and immediately mount --bind on the same path while SwarmUI +# still has files open: when those FDs close, the lazy mount vanishes and +# /opt/swarmui/Models becomes an empty boot-disk folder. The Generate dropdown +# still shows the last model (JS), the Models tab lists disk → empty. +set -uo pipefail + +DATA="${DATA_ROOT:-/mnt/swarm_data}" +SWARM="${SWARM_ROOT:-/opt/swarmui}" +STOP="${GPU_RENT_STOP_SWARM:-1}" + +log() { echo "[gpu-rent-binds] $*" >&2; } + +count_weights() { + local dir="$1" + find "$dir" -type f \( -name '*.safetensors' -o -name '*.ckpt' -o -name '*.sft' \) 2>/dev/null | wc -l | tr -d ' ' +} + +if [[ "$STOP" == "1" ]]; then + systemctl stop swarmui 2>/dev/null || true + for _ in $(seq 1 40); do + if ! pgrep -f 'SwarmUI.dll' >/dev/null 2>&1 \ + && ! pgrep -f 'launch-linux.sh' >/dev/null 2>&1; then + break + fi + sleep 0.5 + done +fi + +unmount_clean() { + local dst="$1" + local i + for i in $(seq 1 20); do + if ! findmnt "$dst" >/dev/null 2>&1; then + return 0 + fi + umount "$dst" 2>/dev/null || true + sleep 0.25 + done + if findmnt "$dst" >/dev/null 2>&1; then + log "WARN still mounted $dst — not using umount -l (would drop Models later)" + return 1 + fi + return 0 +} + +bind_one() { + local src="$1" dst="$2" name="$3" + mkdir -p "$src" "$dst" + local cur + cur="$(findmnt -n -o SOURCE --target "$dst" 2>/dev/null || true)" + if [[ "$cur" == "$src" ]]; then + log "ok $name bind $src" + return 0 + fi + log "remount $name (${cur:-none} → $src)" + if ! unmount_clean "$dst"; then + log "ERROR $name: $dst busy, skip bind (SwarmUI still holding files?)" + return 1 + fi + if ! mount --bind "$src" "$dst"; then + log "ERROR $name: mount --bind failed" + return 1 + fi + cur="$(findmnt -n -o SOURCE --target "$dst" 2>/dev/null || true)" + if [[ "$cur" != "$src" ]]; then + log "ERROR $name: SOURCE='$cur' want='$src'" + return 1 + fi + log "ok $name remounted" + return 0 +} + +fail=0 +bind_one "${DATA}/Models" "${SWARM}/Models" Models || fail=1 +bind_one "${DATA}/Output" "${SWARM}/Output" Output || fail=1 +bind_one "${DATA}/Data" "${SWARM}/Data" Data || fail=1 +bind_one "${DATA}/dlbackend" "${SWARM}/dlbackend" dlbackend || fail=1 + +data_n="$(count_weights "${DATA}/Models")" +opt_n="$(count_weights "${SWARM}/Models")" +log "weights data=${data_n} swarm=${opt_n}" +if [[ "${data_n}" -gt 0 && "${opt_n}" -eq 0 ]]; then + log "ERROR Models bind empty but data volume has ${data_n} weights" + fail=1 +fi + +if [[ "$fail" -ne 0 ]]; then + exit 1 +fi +exit 0 diff --git a/src/gpu_rent/remote/install_swarm_comfy.py b/src/gpu_rent/remote/install_swarm_comfy.py index 6cfe3b1..ba93a53 100644 --- a/src/gpu_rent/remote/install_swarm_comfy.py +++ b/src/gpu_rent/remote/install_swarm_comfy.py @@ -540,17 +540,37 @@ def ensure_dlbackend_bind(*, stop_for_remount: bool = True) -> bool: print(f"{name} bind missing — mount --bind {src} → {dst}", flush=True) subprocess.run(["sudo", "-n", "mkdir", "-p", src, dst], check=False, timeout=15) if mounted.returncode == 0: - # Lazy umount if busy (open files from old SwarmUI process). - subprocess.run( - ["sudo", "-n", "umount", "-l", dst], - check=False, - timeout=15, - ) - subprocess.run( + umounted = False + for _ in range(20): + still = subprocess.run( + ["findmnt", "-n", "--target", dst], + check=False, + capture_output=True, + timeout=10, + ) + if still.returncode != 0: + umounted = True + break + subprocess.run( + ["sudo", "-n", "umount", dst], + check=False, + timeout=15, + ) + time.sleep(0.25) + if not umounted: + print( + f"WARN {name}: {dst} busy — skip remount " + "(no umount -l: Models tab would empty later)", + flush=True, + ) + continue + rc = subprocess.run( ["sudo", "-n", "mount", "--bind", src, dst], check=False, timeout=30, ) + if rc.returncode != 0: + print(f"WARN {name} bind failed rc={rc.returncode}", flush=True) except (OSError, subprocess.TimeoutExpired) as exc: print(f"WARN {name} bind: {exc}", flush=True) return stopped diff --git a/src/gpu_rent/remote/swarm_diag.py b/src/gpu_rent/remote/swarm_diag.py index 44050c2..7f61f9d 100644 --- a/src/gpu_rent/remote/swarm_diag.py +++ b/src/gpu_rent/remote/swarm_diag.py @@ -97,6 +97,23 @@ def swarm_api_bits() -> str: "current_model": val.get("current_model"), } chunks.append("ListBackends=" + json.dumps(summary, ensure_ascii=False, indent=2)) + try: + listed = _post( + "/API/ListModels", + { + "session_id": sid, + "path": "", + "depth": 3, + "subtype": "Stable-Diffusion", + }, + ) + files = listed.get("files") if isinstance(listed, dict) else None + folders = listed.get("folders") if isinstance(listed, dict) else None + nfiles = len(files) if isinstance(files, list) else "?" + nfolders = len(folders) if isinstance(folders, list) else "?" + chunks.append(f"ListModels Stable-Diffusion files={nfiles} folders={nfolders}") + except Exception as exc: + chunks.append(f"ListModels fail: {exc}") # Full settings for first backend (StartScript path matters) for key, val in (backends or {}).items(): if isinstance(val, dict) and val.get("settings"): @@ -125,6 +142,24 @@ def paths_bits() -> str: f"Backends.fds exists={BACKENDS_FDS.is_file()}", ] rows.append("findmnt /opt/swarmui/dlbackend:\n" + _run(["findmnt", "/opt/swarmui/dlbackend"])) + rows.append("findmnt /opt/swarmui/Models:\n" + _run(["findmnt", "/opt/swarmui/Models"])) + data_models = DATA / "Models" + opt_models = Path("/opt/swarmui/Models") + def _n_weights(root: Path) -> int: + if not root.is_dir(): + return 0 + n = 0 + try: + for p in root.rglob("*"): + if p.suffix.lower() in {".safetensors", ".ckpt", ".sft"}: + n += 1 + except OSError: + return -1 + return n + + rows.append( + f"weights data={_n_weights(data_models)} swarm={_n_weights(opt_models)}" + ) if SETTINGS.is_file(): try: for line in SETTINGS.read_text(encoding="utf-8", errors="replace").splitlines(): diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index 6386334..5a93c79 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -22,8 +22,10 @@ from gpu_rent.cloud import ( ) from gpu_rent.bootstrap import run_bootstrap from gpu_rent.provision import ( + ensure_data_binds, ensure_swarm_comfy_installed, provision_vm, + seed_autocomplete, seed_swarmui_api_keys, tune_swarm_perf, ) @@ -169,6 +171,12 @@ def _bind_access( log(f"diag: {diag_exc}") raise clock.mark("comfy-install", log) + try: + if seed_autocomplete(cfg, ip, log): + log("systemctl restart swarmui (AutoComplete.Source после install)") + run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120) + except Exception as exc: + log(f"autocomplete after comfy: {exc}") try: wait_backend_idle(cfg, ip, log) except CloudError as exc: @@ -186,23 +194,10 @@ def _bind_access( check=False, timeout=120, ) - run_ssh( - cfg, - ip, - "sudo -n bash -c '" - "for p in dlbackend Data Models Output; do " - "src=/mnt/swarm_data/$p; dst=/opt/swarmui/$p; " - "mkdir -p \"$src\" \"$dst\"; " - "cur=$(findmnt -n -o SOURCE --target \"$dst\" 2>/dev/null || true); " - "if [[ \"$cur\" != \"$src\" ]]; then " - "umount -l \"$dst\" 2>/dev/null || umount \"$dst\" 2>/dev/null || true; " - "mount --bind \"$src\" \"$dst\" || true; " - "echo remounted $dst; " - "fi; " - "done'", - check=False, - timeout=60, - ) + try: + ensure_data_binds(cfg, ip, log, stop_swarm=False) + except Exception as exc: + log(f"binds remount: {exc}") log("systemctl restart swarmui (perf ExtraArgs)") run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120) try: diff --git a/src/gpu_rent/tunnel.py b/src/gpu_rent/tunnel.py index 3cbfc75..7761cc2 100644 --- a/src/gpu_rent/tunnel.py +++ b/src/gpu_rent/tunnel.py @@ -1,7 +1,12 @@ -"""SSH local forward with Nova watchdog. Ctrl+C closes tunnel only.""" +"""SSH local forward with Nova watchdog. + +Ctrl+C closes the tunnel and leaves the GPU. Ctrl+D (EOF) runs ``stop``. +""" from __future__ import annotations +import os +import sys import time import webbrowser from collections.abc import Callable @@ -138,6 +143,57 @@ def _stop_forwarder(server) -> None: pass +def poll_ctrl_d(timeout: float = 1.0) -> bool: + """True if the user sent Ctrl+D / EOF. Ctrl+C stays KeyboardInterrupt. + + Windows console delivers Ctrl+D as ``\\x04`` (and Ctrl+Z as ``\\x1a``). + Those keys are ignored unless we read them — the old sleep-loop never did. + """ + try: + if not sys.stdin.isatty(): + if timeout > 0: + time.sleep(timeout) + return False + except Exception: + if timeout > 0: + time.sleep(timeout) + return False + + if os.name == "nt": + try: + import msvcrt + except ImportError: + if timeout > 0: + time.sleep(timeout) + return False + deadline = time.time() + max(timeout, 0.0) + while True: + if msvcrt.kbhit(): + ch = msvcrt.getch() + if ch in (b"\x00", b"\xe0") and msvcrt.kbhit(): + msvcrt.getch() + continue + if ch in (b"\x04", b"\x1a"): + return True + if ch == b"\x03": + raise KeyboardInterrupt + continue + if time.time() >= deadline: + return False + time.sleep(0.05) + + import select + + r, _, _ = select.select([sys.stdin], [], [], max(timeout, 0.0)) + if not r: + return False + try: + data = os.read(sys.stdin.fileno(), 64) + except OSError: + return False + return (not data) or (b"\x04" in data) + + def _recover_unshelve(cfg: Config, log: Log) -> str: """Unshelve EXPIRED VM, rebind FIP, wait SSH. Returns new host.""" conn = connect(cfg) @@ -174,6 +230,8 @@ def run_tunnel( log: Log = print, wait: Callable[[], None] | None = None, poll_seconds: float = 30.0, + stop_gpu: Callable[[], None] | None = None, + session_end_poll: Callable[[float], bool] | None = None, ) -> None: try: _ssh_tunnel_forwarder() @@ -184,7 +242,7 @@ def run_tunnel( current_host = host for loc, rem in forwards: log(f"туннель 127.0.0.1:{loc} -> {current_host}:{rem}") - log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop") + log("Ctrl+C — туннель off, GPU жив. Ctrl+D — stop GPU (диски остаются).") log("watchdog: EXPIRED → unshelve + reconnect") server = _start_forwarder(cfg, current_host, forwards) @@ -233,7 +291,7 @@ def run_tunnel( start_heartbeat_thread() log( "local-watchdog: heartbeat активен — аварийное закрытие " - "(не Ctrl+C) → stop после grace" + "(не Ctrl+C / не Ctrl+D) → stop после grace" ) try: @@ -241,9 +299,22 @@ def run_tunnel( wait() return + end_poll = session_end_poll or poll_ctrl_d next_poll = time.time() + poll_seconds while True: - time.sleep(1) + if end_poll(1.0): + log("Ctrl+D — гашу GPU (диски остаются)") + _stop_forwarder(server) + server = None + stop_heartbeat_thread() + if stop_gpu is not None: + stop_gpu() + else: + from gpu_rent.session import cmd_stop + + cmd_stop(cfg, log=log) + log("туннель закрыт. GPU остановлен.") + return if not server.is_active: next_poll = 0 if time.time() < next_poll: @@ -280,7 +351,7 @@ def run_tunnel( return except KeyboardInterrupt: detach_lease_keep_gpu() - log("туннель закрыт. GPU жив.") + log("Ctrl+C — туннель закрыт. GPU жив.") finally: stop_heartbeat_thread() _stop_forwarder(server) diff --git a/src/gpu_rent/ux.py b/src/gpu_rent/ux.py index 3b751a9..bec8a7d 100644 --- a/src/gpu_rent/ux.py +++ b/src/gpu_rent/ux.py @@ -49,7 +49,7 @@ def cost_and_risk_lines(cfg: Config, *, spot: bool, flavor_name: str) -> list[st f"{cfg.idle_minutes} мин пустой очереди → delete compute. Отложить: gpu-rent hold", "preemptible: хостер может усыпить (~24 ч окно) → EXPIRED; tunnel сам unshelve, " "или gpu-rent up", - "Ctrl+C на tunnel GPU не гасит — только gpu-rent stop или idle-killer. " + "Ctrl+C на tunnel — туннель off, GPU жив. Ctrl+D — stop GPU (диски остаются). " "Опционально: gpu-rent watchdog install — аварийное закрытие окна/ребут " "после grace тоже stop (Ctrl+C по-прежнему detach)", ] diff --git a/tests/test_session.py b/tests/test_session.py index 8dcfc57..e3d5ee1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -51,6 +51,10 @@ def _mock_bind(monkeypatch): "gpu_rent.session.ensure_swarm_comfy_installed", lambda cfg, host, log: None, ) + monkeypatch.setattr( + "gpu_rent.session.seed_autocomplete", + lambda cfg, host, log: False, + ) monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None) monkeypatch.setattr( "gpu_rent.session.verify_stack_on_vm", @@ -64,6 +68,10 @@ def _mock_bind(monkeypatch): "gpu_rent.session.tune_swarm_perf", lambda cfg, host, log: False, ) + monkeypatch.setattr( + "gpu_rent.session.ensure_data_binds", + lambda cfg, host, log, **kw: None, + ) monkeypatch.setattr( "gpu_rent.session.ensure_boot_snapshot", lambda conn, boot_volume_id, cfg, log: None, diff --git a/tests/test_tunnel_watch.py b/tests/test_tunnel_watch.py index dec47b4..0613d21 100644 --- a/tests/test_tunnel_watch.py +++ b/tests/test_tunnel_watch.py @@ -1,4 +1,9 @@ -from gpu_rent.tunnel import decide_watch, tunnel_forwards +import sys +import time + +import pytest + +from gpu_rent.tunnel import decide_watch, poll_ctrl_d, run_tunnel, tunnel_forwards def test_decide_ok_active(): @@ -71,3 +76,107 @@ def test_resolve_llm_uses_cfg_only(monkeypatch): llm_runtime = "ollama" assert resolve_llm_runtime(Cfg2()) == "ollama" + + +def test_poll_ctrl_d_skips_when_not_tty(monkeypatch): + monkeypatch.setattr("gpu_rent.tunnel.sys.stdin.isatty", lambda: False) + t0 = time.time() + assert poll_ctrl_d(0.02) is False + assert time.time() - t0 < 0.5 + + +def test_poll_ctrl_d_windows_eot(monkeypatch): + class Msvcrt: + def kbhit(self) -> bool: + return True + + def getch(self) -> bytes: + return b"\x04" + + monkeypatch.setattr("gpu_rent.tunnel.sys.stdin.isatty", lambda: True) + monkeypatch.setattr("gpu_rent.tunnel.os.name", "nt") + monkeypatch.setitem(sys.modules, "msvcrt", Msvcrt()) + assert poll_ctrl_d(0.2) is True + + +def test_poll_ctrl_d_windows_ctrl_c(monkeypatch): + class Msvcrt: + def kbhit(self) -> bool: + return True + + def getch(self) -> bytes: + return b"\x03" + + monkeypatch.setattr("gpu_rent.tunnel.sys.stdin.isatty", lambda: True) + monkeypatch.setattr("gpu_rent.tunnel.os.name", "nt") + monkeypatch.setitem(sys.modules, "msvcrt", Msvcrt()) + with pytest.raises(KeyboardInterrupt): + poll_ctrl_d(0.2) + + +class _Fwd: + is_active = True + + def stop(self) -> None: + pass + + +class _State: + notes: dict = {} + phase = "" + floating_ip = "1.2.3.4" + + +class _Cfg: + ssh_user = "u" + ssh_private_key_path = "k" + swarmui_local_port = 17801 + ollama_local_port = 17811 + llm_runtime = "none" + enable_swarmui = True + + +def _stub_tunnel(monkeypatch) -> None: + st = _State() + monkeypatch.setattr("gpu_rent.tunnel._ssh_tunnel_forwarder", lambda: object) + monkeypatch.setattr("gpu_rent.tunnel._start_forwarder", lambda *a, **k: _Fwd()) + monkeypatch.setattr("gpu_rent.ready.verify_stack_local", lambda *a, **k: []) + monkeypatch.setattr("gpu_rent.tunnel.load_state", lambda: st) + monkeypatch.setattr("gpu_rent.tunnel.save_state", lambda s: None) + monkeypatch.setattr("gpu_rent.access_card.print_access_card", lambda *a, **k: None) + monkeypatch.setattr("gpu_rent.local_watchdog.watchdog_installed", lambda: False) + + +def test_run_tunnel_ctrl_d_stops_gpu(monkeypatch): + _stub_tunnel(monkeypatch) + logs: list[str] = [] + stopped: list[bool] = [] + run_tunnel( + _Cfg(), + "1.2.3.4", + log=logs.append, + stop_gpu=lambda: stopped.append(True), + session_end_poll=lambda _t: True, + poll_seconds=999, + ) + assert stopped == [True] + assert any("GPU остановлен" in x for x in logs) + + +def test_run_tunnel_ctrl_c_keeps_gpu(monkeypatch): + _stub_tunnel(monkeypatch) + logs: list[str] = [] + stopped: list[bool] = [] + + def boom() -> None: + raise KeyboardInterrupt + + run_tunnel( + _Cfg(), + "1.2.3.4", + log=logs.append, + wait=boom, + stop_gpu=lambda: stopped.append(True), + ) + assert stopped == [] + assert any("GPU жив" in x for x in logs) diff --git a/tests/test_verify_stack.py b/tests/test_verify_stack.py index e54b69c..761c860 100644 --- a/tests/test_verify_stack.py +++ b/tests/test_verify_stack.py @@ -181,3 +181,11 @@ def test_verify_gpu_env_llm_only_skips_torch_requirement(monkeypatch): logs: list[str] = [] out = verify_gpu_env(C(), "1.2.3.4", logs.append, timeout=5.0) assert all(c.ok for c in out) + + +def test_stack_probe_requires_ollama_models(): + from gpu_rent.ready import _REMOTE_STACK_PROBE + + assert "/api/tags" in _REMOTE_STACK_PROBE + assert "0 models" in _REMOTE_STACK_PROBE + assert 'payload.get("models")' in _REMOTE_STACK_PROBE diff --git a/tests/test_wait_backend_idle.py b/tests/test_wait_backend_idle.py index eb227e2..61f2b68 100644 --- a/tests/test_wait_backend_idle.py +++ b/tests/test_wait_backend_idle.py @@ -138,9 +138,34 @@ def test_swarm_diag_script_covers_api_and_journal(): text = files("gpu_rent.remote").joinpath("swarm_diag.py").read_text(encoding="utf-8") assert "ListBackends" in text + assert "ListModels" in text assert "journalctl" in text assert ".gpu-rent-last-diag.txt" in text assert "nvidia-smi" in text + assert "findmnt /opt/swarmui/Models" in text + + +def test_ensure_binds_refuses_lazy_umount(): + from importlib.resources import files + + sh = files("gpu_rent.remote").joinpath("ensure_binds.sh").read_text(encoding="utf-8") + assert "umount -l" in sh # mentioned as forbidden + assert 'umount -l "$dst"' not in sh + assert "mount --bind" in sh + assert "weights data=" in sh + + comfy = files("gpu_rent.remote").joinpath("install_swarm_comfy.py").read_text( + encoding="utf-8" + ) + assert '"umount", "-l"' not in comfy + + from pathlib import Path + + session = (Path(__file__).resolve().parents[1] / "src" / "gpu_rent" / "session.py").read_text( + encoding="utf-8" + ) + assert "umount -l" not in session + assert "ensure_data_binds" in session def test_verify_gpu_env_fail_fast_empty_dlbackend(monkeypatch): diff --git a/tests/test_warm_up_skips.py b/tests/test_warm_up_skips.py index 57e9bf2..b82cfa0 100644 --- a/tests/test_warm_up_skips.py +++ b/tests/test_warm_up_skips.py @@ -109,6 +109,12 @@ def test_autocomplete_merge_sets_is_installed(): assert "GPU_RENT_COMFY_PRESENT" in _AUTOCOMPLETE_MERGE_PY assert "GPU_RENT_COMFY_PRESENT" in _ENSURE_INSTALLED_PY assert hasattr(provision, "ensure_settings_is_installed") + assert "set_autocomplete_source" in _AUTOCOMPLETE_MERGE_PY + assert "CHANGED inserted AutoComplete.Source" in _AUTOCOMPLETE_MERGE_PY + assert '"settings_applied": False' in Path(provision.__file__).read_text( + encoding="utf-8" + ) + assert "if not applied:" not in Path(provision.__file__).read_text(encoding="utf-8") def test_ensure_installed_clears_flag_without_comfy(tmp_path): @@ -178,3 +184,93 @@ def test_clear_settings_installed_flag(tmp_path, monkeypatch): assert "IsInstalled: false" in text assert "Theme: x" in text assert inst.clear_settings_installed_flag() is False + + +def _run_autocomplete_merge(tmp_path, settings_text: str, fname: str = "danbooru.csv"): + import os + import subprocess + import sys + + from gpu_rent.provision import _AUTOCOMPLETE_MERGE_PY + + settings = tmp_path / "Settings.fds" + settings.write_text(settings_text, encoding="utf-8") + script = tmp_path / "merge.py" + script.write_text(_AUTOCOMPLETE_MERGE_PY, encoding="utf-8") + env = os.environ.copy() + env["GPU_RENT_SETTINGS_FDS"] = str(settings) + env["GPU_RENT_AUTOCOMPLETE_FILE"] = fname + env["GPU_RENT_COMFY_PRESENT"] = "1" + out = subprocess.check_output([sys.executable, str(script)], env=env, text=True) + return out, settings.read_text(encoding="utf-8") + + +def test_autocomplete_merge_inserts_source_when_only_escapeparens(tmp_path): + out, text = _run_autocomplete_merge( + tmp_path, + "IsInstalled: true\nDefaultUser:\n AutoComplete:\n EscapeParens: true\n", + ) + assert "CHANGED inserted AutoComplete.Source=danbooru.csv" in out + assert "Source: danbooru.csv" in text + assert "EscapeParens: true" in text + + +def test_autocomplete_merge_fills_empty_fds_source(tmp_path): + out, text = _run_autocomplete_merge( + tmp_path, + "DefaultUser:\n AutoComplete:\n Source: \\x\n EscapeParens: true\n", + ) + assert "CHANGED patched AutoComplete.Source=danbooru.csv" in out + assert "Source: danbooru.csv" in text + + +def test_autocomplete_merge_keeps_user_source(tmp_path): + out, text = _run_autocomplete_merge( + tmp_path, + "DefaultUser:\n AutoComplete:\n Source: e621.csv\n", + ) + assert "SKIP_USER" in out + assert "Source: e621.csv" in text + assert "danbooru.csv" not in text + + +def test_seed_autocomplete_merges_when_csv_already_present(monkeypatch): + import json + + from gpu_rent import provision + + class Cfg: + autocomplete_enabled = True + autocomplete_filename = "danbooru.csv" + autocomplete_github_repo = "org/repo" + autocomplete_github_path = "tags/danbooru.csv" + autocomplete_github_ref = "main" + + monkeypatch.setattr( + provision, + "_github_blob", + lambda _cfg: {"sha": "abc", "download_url": "https://example.invalid/x"}, + ) + + def fake_exists(_cfg, _host, path): + return path.endswith("danbooru.csv") or path.endswith(".json") + + def fake_ssh(_cfg, _host, cmd, **_kw): + if "cat" in cmd: + return json.dumps({"github_blob_sha": "abc", "settings_applied": True}) + return "" + + merges: list[str] = [] + + def fake_merge(*_a, **_k): + merges.append("called") + return "changed" + + monkeypatch.setattr(provision, "remote_exists", fake_exists) + monkeypatch.setattr(provision, "run_ssh", fake_ssh) + monkeypatch.setattr(provision, "_merge_autocomplete_into_settings", fake_merge) + monkeypatch.setattr(provision, "put_text", lambda *_a, **_k: None) + + logs: list[str] = [] + assert provision.seed_autocomplete(Cfg(), "1.2.3.4", logs.append) is True + assert merges == ["called"]