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.
This commit is contained in:
Leonid Pershin
2026-08-21 13:25:55 +03:00
parent 1d18ece17b
commit f437cd0373
26 changed files with 712 additions and 142 deletions
+4 -2
View File
@@ -32,8 +32,9 @@ Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`).
```powershell ```powershell
.\gpu-rent.ps1 up --yes # GPU + SwarmUI + туннель :17801 .\gpu-rent.ps1 up --yes # GPU + SwarmUI + туннель :17801
# работаешь в браузере… # работаешь в браузере…
# Ctrl+C в терминале = только туннель off, GPU жив # Ctrl+C = туннель off, GPU жив
.\gpu-rent.ps1 tunnel --open # снова UI # Ctrl+D = stop GPU (диски остаются)
.\gpu-rent.ps1 tunnel --open # снова UI, если закрыл Ctrl+C
.\gpu-rent.ps1 hold # отложить idle-killer .\gpu-rent.ps1 hold # отложить idle-killer
.\gpu-rent.ps1 stop # погасить GPU, диски оставить .\gpu-rent.ps1 stop # погасить GPU, диски оставить
``` ```
@@ -41,6 +42,7 @@ Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`).
| Важно | | | Важно | |
| --- | --- | | --- | --- |
| `Ctrl+C` на туннеле | **не** гасит GPU | | `Ctrl+C` на туннеле | **не** гасит GPU |
| `Ctrl+D` на туннеле | **stop** GPU, диски остаются |
| Простой ~30 мин | idle-killer сам удалит compute | | Простой ~30 мин | idle-killer сам удалит compute |
| Preemptible | ~24 ч → `EXPIRED`; `tunnel` / `up` поднимут снова | | Preemptible | ~24 ч → `EXPIRED`; `tunnel` / `up` поднимут снова |
| Двойной клик лаунчера | задай `GPU_RENT_DEFAULT_ARGS=up --yes` в `gpu-rent.vars` | | Двойной клик лаунчера | задай `GPU_RENT_DEFAULT_ARGS=up --yes` в `gpu-rent.vars` |
+3 -1
View File
@@ -82,6 +82,8 @@
`mkfs` только если нет маркера **и** `blkid` подтвердил пустое устройство по serial/by-id. Не хардкодить `scsi-0Selectel_Volume_…`. `mkfs` только если нет маркера **и** `blkid` подтвердил пустое устройство по serial/by-id. Не хардкодить `scsi-0Selectel_Volume_…`.
Remount bind’ов — только после `systemctl stop swarmui` и обычного `umount`. `umount -l` на живом Comfy позже опустошает вкладку Models (веса на data volume остаются).
## Стейт-машина ## Стейт-машина
Фазы в `state.json` (код пишет только эти): Фазы в `state.json` (код пишет только эти):
@@ -144,7 +146,7 @@ Killer молчит:
3. `ERROR` / нет GPU → выход, не бесконечный recreate. 3. `ERROR` / нет GPU → выход, не бесконечный recreate.
4. Туннель мёртв при ACTIVE → reconnect. 4. Туннель мёртв при ACTIVE → reconnect.
`Ctrl+C` здесь закрывает туннель, **не** вызывает `stop`. `Ctrl+C` здесь закрывает туннель, **не** вызывает `stop`. **Ctrl+D**`stop` (диски остаются).
## Teardown (`gpu-rent stop`) ## Teardown (`gpu-rent stop`)
+4 -4
View File
@@ -10,8 +10,8 @@
| Момент | Действие | | Момент | Действие |
| --- | --- | | --- | --- |
| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта SwarmUI | | Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. После `InstallConfirmWS` прописать `DefaultUser.AutoComplete.Source` (установщик затирает Settings) и при необходимости перезапустить SwarmUI |
| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, `systemctl restart swarmui` если UI уже крутится | | Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл. Если `Source` в Settings пустой/`\\x` — снова прописать filename (не трогать, если пользователь выбрал другой файл). `systemctl restart swarmui` если csv или Source изменились |
| `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) | | `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) |
Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH. Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH.
@@ -79,9 +79,9 @@ DefaultUser:
`Source` — имя файла **относительно** `Data/Autocompletions`, как в UI `AutoCompletionsSource`. `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()` при старте перечитает каталог).
## Конфиг ## Конфиг
+3 -2
View File
@@ -24,6 +24,7 @@ gpu-rent stop
```text ```text
gpu-rent up --yes gpu-rent up --yes
# Ctrl+C → туннель off, GPU жив # Ctrl+C → туннель off, GPU жив
# Ctrl+D → stop GPU
gpu-rent tunnel --open gpu-rent tunnel --open
gpu-rent hold gpu-rent hold
gpu-rent stop gpu-rent stop
@@ -65,7 +66,7 @@ gpu-rent up --yes --ollama
| `gpu-rent up --no-tunnel` | Только облако | | `gpu-rent up --no-tunnel` | Только облако |
| `gpu-rent up --no-spot` | Не preemptible | | `gpu-rent up --no-spot` | Не preemptible |
| `gpu-rent up --flavor ID --yes` | Явный flavor; без `--flavor` + `--yes` — первый доступный из `FLAVOR_PREFERENCE` | | `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 open` / `open --llm` | Браузер на SwarmUI / LLM-порт (туннель уже должен слушать) |
| `gpu-rent status` | State, Nova, диск, killer/hold, LLM, local-watchdog | | `gpu-rent status` | State, Nova, диск, killer/hold, LLM, local-watchdog |
| `gpu-rent hold` / `--minutes N` / `--until ISO` / `--clear` | Пауза idle-killer (нужны живая VM + SSH) | | `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` (раз на машине, из корня репо) 1. `gpu-rent watchdog install` (раз на машине, из корня репо)
2. Пока крутится `up`/`tunnel`, пишется heartbeat `.gpu-rent/local-lease.json` 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` 4. Процесс умер / ребут → через `LOCAL_WATCHDOG_GRACE_MINUTES` (дефолт 10) → `stop`
5. `gpu-rent stop` чистит lease сам 5. `gpu-rent stop` чистит lease сам
+3 -3
View File
@@ -5,7 +5,7 @@
| Тема | Решение | | Тема | Решение |
| --- | --- | | --- | --- |
| Аудитория | Личный инструмент, репозиторий можно показать другим: MIT, без аккаунтных id и без имён локальных чекпоинтов | | Аудитория | Личный инструмент, репозиторий можно показать другим: 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 отдельно. С сервера не удаляем | | Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем |
| Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | 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` | | Пул 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` | | Floating IP | `KEEP_FLOATING_IP=false`: выделить на `up`, удалить на `stop` |
| Idle-killer | 30 минут пустой очереди генерации. Открытый браузер не продлевает жизнь | | Idle-killer | 30 минут пустой очереди генерации. Открытый браузер не продлевает жизнь |
| Льгота после boot | Killer молчит во время clone/seed/push, hold, качалки в UI SwarmUI, пока backend не Idle, и 45 мин после ACTIVE/unshelve | | Льгота после 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` | | 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` — не тянуть | | 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 | | LLM (opt-in) | `none` по умолчанию. Флаги / `LLM_RUNTIME` / меню. Манифест: `ollama-models.yaml`. Порт 17811. Prompt-help, не замена SwarmUI |
@@ -49,6 +49,6 @@
1. **Туннель ≠ жизнь GPU.** `stop` и idle-killer не зависят от того, открыт ли SSH с ноутбука. 1. **Туннель ≠ жизнь GPU.** `stop` и idle-killer не зависят от того, открыт ли SSH с ноутбука.
2. **Локальный SwarmUI на 7801 не трогаем.** Туннель по умолчанию на **17801** (на VM по-прежнему 7801 на loopback). 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, диски оставить. 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-сессию, а не произвольные машины проекта. 5. Для этого на VM — OpenStack **application credential** с правом DELETE/GET **только этого** compute (не `/servers/*`, не unrestricted fallback). На `stop`/`destroy` cred отзывается. Компрометация SwarmUI в худшем случае сносит текущую GPU-сессию, а не произвольные машины проекта.
+1 -1
View File
@@ -235,7 +235,7 @@ copy models.example.yaml models.yaml
2. Create/unshelve GPU + диски (после confirm). 2. Create/unshelve GPU + диски (после confirm).
3. Bootstrap SwarmUI, extensions, autocomplete, Civitai-seed, push локальных папок; optional LLM. 3. Bootstrap SwarmUI, extensions, autocomplete, Civitai-seed, push локальных папок; optional LLM.
4. Туннель на `localhost:17801` (или LLM-порт), **проверка** что сервисы отвечают, access-card. 4. Туннель на `localhost:17801` (или LLM-порт), **проверка** что сервисы отвечают, access-card.
5. Процесс ждёт: **Ctrl+C** закрывает только туннель, GPU остаётся. 5. Процесс ждёт: **Ctrl+C** закрывает только туннель (GPU остаётся); **Ctrl+D**`stop` GPU (диски остаются).
Полезные флаги: Полезные флаги:
+3 -1
View File
@@ -63,12 +63,14 @@
## 3. Туннель и API ## 3. Туннель и API
Если закрыл туннель (Ctrl+C): Если закрыл туннель (**Ctrl+C**, GPU жив):
```powershell ```powershell
.\gpu-rent.ps1 tunnel --open .\gpu-rent.ps1 tunnel --open
``` ```
**Ctrl+D** на живом туннеле = `stop` GPU.
| Шаг | OK? | | Шаг | OK? |
| --- | --- | | --- | --- |
| UI http://127.0.0.1:17801 | | | UI http://127.0.0.1:17801 | |
+6
View File
@@ -28,6 +28,12 @@
Рестарт UI: `systemctl restart swarmui`, не `docker restart`. Рестарт 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 ## ComfyUI
Первый запуск качает backend в `/opt/swarmui/dlbackend` (это bind на data volume). Иначе каждый recreate потеряет часы. Первый запуск качает backend в `/opt/swarmui/dlbackend` (это bind на data volume). Иначе каждый recreate потеряет часы.
+4
View File
@@ -124,6 +124,9 @@ def render_access_panel(
cmds.add_row("открыть LLM", "gpu-rent open --llm") cmds.add_row("открыть LLM", "gpu-rent open --llm")
cmds.add_row("hold killer", "gpu-rent hold") cmds.add_row("hold killer", "gpu-rent hold")
cmds.add_row("стоп GPU", "gpu-rent stop") 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: if not tunneled:
cmds.add_row("туннель", "gpu-rent tunnel --open") cmds.add_row("туннель", "gpu-rent tunnel --open")
@@ -217,6 +220,7 @@ def print_access_card(
for line in mcp_snippet_lines(cfg): for line in mcp_snippet_lines(cfg):
log(line) log(line)
log("hold: gpu-rent hold | stop: gpu-rent stop") log("hold: gpu-rent hold | stop: gpu-rent stop")
log("Ctrl+C — туннель off, GPU жив | Ctrl+D — stop GPU")
log("") log("")
return return
from gpu_rent.term import console as default_console from gpu_rent.term import console as default_console
+3 -3
View File
@@ -49,7 +49,7 @@ def _die(exc: BaseException) -> None:
err(str(exc)) err(str(exc))
hint = ( hint = (
"Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop " "Дальше: gpu-rent status · gpu-rent logs · gpu-rent stop "
"(Ctrl+C на туннеле GPU не гасит)" "(Ctrl+C на туннеле GPU не гасит; Ctrl+D гасит)"
) )
msg = str(exc) msg = str(exc)
if "gpu-rent status" not in msg and "Дальше:" not in msg: if "gpu-rent status" not in msg and "Дальше:" not in msg:
@@ -809,7 +809,7 @@ def logs(
def tunnel( def tunnel(
open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"), open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"),
) -> None: ) -> None:
"""SSH localhost:17801 -> VM :7801. Ctrl+C закрывает туннель, GPU оставляет.""" """SSH localhost:17801 -> VM :7801. Ctrl+C — туннель off; Ctrl+D — stop GPU."""
try: try:
cfg = load_config(require_auth=True) cfg = load_config(require_auth=True)
state = load_state() state = load_state()
@@ -1052,7 +1052,7 @@ def resize_data(gb: int = typer.Option(..., "--gb", help="Новый разме
watchdog_app = typer.Typer( watchdog_app = typer.Typer(
help=( help=(
"Локальный сервис: если туннель умер без Ctrl+C / stop — " "Локальный сервис: если туннель умер без Ctrl+C / Ctrl+D / stop — "
"через grace удалить compute. Не путать с idle-killer на VM." "через grace удалить compute. Не путать с idle-killer на VM."
), ),
no_args_is_help=True, no_args_is_help=True,
+2 -1
View File
@@ -422,7 +422,8 @@ def dry_run_plan(checks: list[Check]) -> list[str]:
f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин", f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин",
"₽: в API нет — смотри панель; диск 24/7 даже после stop", "₽: в API нет — смотри панель; диск 24/7 даже после stop",
f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801", 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", "только облако без туннеля: gpu-rent up --yes --no-tunnel",
] ]
flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None) flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None)
+1 -1
View File
@@ -271,7 +271,7 @@ def install_watchdog(
log( log(
f"local-watchdog установлен ({platform}): тик каждые {interval} мин. " f"local-watchdog установлен ({platform}): тик каждые {interval} мин. "
f"Grace {grace_seconds() // 60} мин после смерти процесса туннеля → stop. " 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)." "При SELECTEL_API_TOKEN — toast каждые BALANCE_NOTIFY_STEP_RUB ₽ (дефолт 200)."
) )
return marker return marker
+157 -90
View File
@@ -28,7 +28,7 @@ from gpu_rent.manifests import (
repo_dirname, repo_dirname,
repo_matches_runtime, 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 from gpu_rent.sync_files import pull_tree, push_tree
Log = Callable[[str], None] 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 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: def ensure_swarm_comfy_installed(cfg: Config, host: str, log: Log) -> None:
"""Headless Comfy install / recover errored backends before ready wait.""" """Headless Comfy install / recover errored backends before ready wait."""
# Diag script next to install so recover-fail can subprocess it on the VM. # 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}" dest = f"{dest_dir}/{cfg.autocomplete_filename}"
meta_path = f"{dest}.gpu-rent-meta.json" meta_path = f"{dest}.gpu-rent-meta.json"
blob = _github_blob(cfg) blob = _github_blob(cfg)
if blob is None: if blob and blob.get("error"):
return False
if blob.get("error"):
log(str(blob["error"])) log(str(blob["error"]))
return False github_ok = isinstance(blob, dict) and not blob.get("error")
sha = str(blob.get("sha") or "") sha = str(blob.get("sha") or "") if github_ok else ""
download_url = str(blob.get("download_url") or "") download_url = str(blob.get("download_url") or "") if github_ok else ""
old_sha = "" old_sha = ""
if remote_exists(cfg, host, meta_path): if remote_exists(cfg, host, meta_path):
raw = run_ssh(cfg, host, f"cat {meta_path}", check=False) 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 "") old_sha = str(json.loads(raw).get("github_blob_sha") or "")
except json.JSONDecodeError: except json.JSONDecodeError:
old_sha = "" 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 changed:
if not download_url: if not download_url:
log("GitHub не дал download_url") log("GitHub не дал download_url")
return False changed = False
log(f"качаю {cfg.autocomplete_filename}") else:
run_ssh( log(f"качаю {cfg.autocomplete_filename}")
cfg, run_ssh(
host, cfg,
"mkdir -p {dir} && curl -fsSL -o {part} {url} && mv {part} {dest}".format( host,
dir=shlex.quote(dest_dir), "mkdir -p {dir} && curl -fsSL -o {part} {url} && mv {part} {dest}".format(
part=shlex.quote(dest + ".partial"), dir=shlex.quote(dest_dir),
url=shlex.quote(download_url), part=shlex.quote(dest + ".partial"),
dest=shlex.quote(dest), url=shlex.quote(download_url),
), dest=shlex.quote(dest),
timeout=180, ),
) timeout=180,
meta = { )
"repo": cfg.autocomplete_github_repo, meta = {
"path": cfg.autocomplete_github_path, "repo": cfg.autocomplete_github_repo,
"ref": cfg.autocomplete_github_ref, "path": cfg.autocomplete_github_path,
"github_blob_sha": sha, "ref": cfg.autocomplete_github_ref,
"filename": cfg.autocomplete_filename, "github_blob_sha": sha,
"fetched_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), "filename": cfg.autocomplete_filename,
"settings_applied": True, "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") }
put_text(cfg, host, meta_path, json.dumps(meta, indent=2) + "\n")
settings = f"{DATA}/Data/Settings.fds" settings = f"{DATA}/Data/Settings.fds"
applied = False settings_status = "noop"
if remote_exists(cfg, host, meta_path): if remote_exists(cfg, host, dest):
raw = run_ssh(cfg, host, f"cat {meta_path}", check=False) settings_status = _merge_autocomplete_into_settings(
try: cfg, host, settings, cfg.autocomplete_filename, log
applied = bool(json.loads(raw).get("settings_applied")) )
except json.JSONDecodeError: if settings_status in ("changed", "already", "skip_user"):
applied = False if remote_exists(cfg, host, meta_path):
if not applied: try:
_merge_autocomplete_into_settings(cfg, host, settings, cfg.autocomplete_filename, log) meta_obj = json.loads(
if remote_exists(cfg, host, meta_path): run_ssh(cfg, host, f"cat {meta_path}", check=False)
try: )
meta_obj = json.loads( except json.JSONDecodeError:
run_ssh(cfg, host, f"cat {meta_path}", check=False) meta_obj = {}
) if isinstance(meta_obj, dict):
except json.JSONDecodeError: meta_obj["settings_applied"] = True
meta_obj = {} put_text(cfg, host, meta_path, json.dumps(meta_obj, indent=2) + "\n")
if isinstance(meta_obj, dict): return changed or settings_status == "changed"
meta_obj["settings_applied"] = True
put_text(cfg, host, meta_path, json.dumps(meta_obj, indent=2) + "\n")
return changed
_AUTOCOMPLETE_MERGE_PY = r''' _AUTOCOMPLETE_MERGE_PY = r'''
@@ -335,6 +351,77 @@ def sync_is_installed(text: str) -> tuple[str, str | None]:
return text, 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") 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() fname = (os.environ.get("GPU_RENT_AUTOCOMPLETE_FILE") or "").strip()
if not fname: if not fname:
@@ -358,7 +445,7 @@ if not p.is_file():
prefix = installed_prefix if comfy_on_disk() else "" prefix = installed_prefix if comfy_on_disk() else ""
p.write_text(prefix + ac_block, encoding="utf-8") p.write_text(prefix + ac_block, encoding="utf-8")
extra = "+IsInstalled" if prefix else "без IsInstalled (первый Comfy install)" 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) raise SystemExit(0)
text = p.read_text(encoding="utf-8", errors="replace") text = p.read_text(encoding="utf-8", errors="replace")
@@ -368,40 +455,10 @@ if note:
print(note) print(note)
text = p.read_text(encoding="utf-8", errors="replace") text = p.read_text(encoding="utf-8", errors="replace")
if re.search(rf"^\s*Source:\s*{re.escape(fname)}\s*$", text, re.M): text, src_note = set_autocomplete_source(text, fname)
print(f"AutoComplete.Source already {fname}") print(src_note)
raise SystemExit(0) if src_note.startswith("CHANGED"):
p.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
# 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}")
''' '''
@@ -548,8 +605,8 @@ print("CHANGED" if changed else "NOOP")
def _merge_autocomplete_into_settings( def _merge_autocomplete_into_settings(
cfg: Config, host: str, settings_path: str, filename: str, log: Log cfg: Config, host: str, settings_path: str, filename: str, log: Log
) -> None: ) -> str:
"""Patch AutoComplete.Source in Settings.fds without wiping the rest.""" """Patch AutoComplete.Source. Returns changed|already|skip_user|noop."""
out = run_python( out = run_python(
cfg, cfg,
host, host,
@@ -562,9 +619,19 @@ def _merge_autocomplete_into_settings(
"GPU_RENT_AUTOCOMPLETE_FILE": filename, "GPU_RENT_AUTOCOMPLETE_FILE": filename,
}, },
) )
status = "noop"
for line in (out or "").splitlines(): for line in (out or "").splitlines():
if line.strip(): line = line.strip()
log(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: def ensure_settings_is_installed(cfg: Config, host: str, log: Log) -> bool:
+21 -1
View File
@@ -137,7 +137,27 @@ if want_swarm:
}) })
if want_ollama: 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({ checks.append({
"name": "ollama", "name": "ollama",
"ok": ok, "ok": ok,
+6 -1
View File
@@ -76,7 +76,12 @@ ensure_bind() {
return 0 return 0
fi fi
log "bind remount ${dst} (было: ${src_mnt:-?})" 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 fi
mount --bind "$src" "$dst" || log "WARN: mount --bind ${src}${dst} failed" mount --bind "$src" "$dst" || log "WARN: mount --bind ${src}${dst} failed"
if ! grep -Fq " ${dst} " /etc/fstab; then if ! grep -Fq " ${dst} " /etc/fstab; then
+93
View File
@@ -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
+27 -7
View File
@@ -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) print(f"{name} bind missing — mount --bind {src}{dst}", flush=True)
subprocess.run(["sudo", "-n", "mkdir", "-p", src, dst], check=False, timeout=15) subprocess.run(["sudo", "-n", "mkdir", "-p", src, dst], check=False, timeout=15)
if mounted.returncode == 0: if mounted.returncode == 0:
# Lazy umount if busy (open files from old SwarmUI process). umounted = False
subprocess.run( for _ in range(20):
["sudo", "-n", "umount", "-l", dst], still = subprocess.run(
check=False, ["findmnt", "-n", "--target", dst],
timeout=15, check=False,
) capture_output=True,
subprocess.run( 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], ["sudo", "-n", "mount", "--bind", src, dst],
check=False, check=False,
timeout=30, timeout=30,
) )
if rc.returncode != 0:
print(f"WARN {name} bind failed rc={rc.returncode}", flush=True)
except (OSError, subprocess.TimeoutExpired) as exc: except (OSError, subprocess.TimeoutExpired) as exc:
print(f"WARN {name} bind: {exc}", flush=True) print(f"WARN {name} bind: {exc}", flush=True)
return stopped return stopped
+35
View File
@@ -97,6 +97,23 @@ def swarm_api_bits() -> str:
"current_model": val.get("current_model"), "current_model": val.get("current_model"),
} }
chunks.append("ListBackends=" + json.dumps(summary, ensure_ascii=False, indent=2)) 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) # Full settings for first backend (StartScript path matters)
for key, val in (backends or {}).items(): for key, val in (backends or {}).items():
if isinstance(val, dict) and val.get("settings"): if isinstance(val, dict) and val.get("settings"):
@@ -125,6 +142,24 @@ def paths_bits() -> str:
f"Backends.fds exists={BACKENDS_FDS.is_file()}", 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/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(): if SETTINGS.is_file():
try: try:
for line in SETTINGS.read_text(encoding="utf-8", errors="replace").splitlines(): for line in SETTINGS.read_text(encoding="utf-8", errors="replace").splitlines():
+12 -17
View File
@@ -22,8 +22,10 @@ from gpu_rent.cloud import (
) )
from gpu_rent.bootstrap import run_bootstrap from gpu_rent.bootstrap import run_bootstrap
from gpu_rent.provision import ( from gpu_rent.provision import (
ensure_data_binds,
ensure_swarm_comfy_installed, ensure_swarm_comfy_installed,
provision_vm, provision_vm,
seed_autocomplete,
seed_swarmui_api_keys, seed_swarmui_api_keys,
tune_swarm_perf, tune_swarm_perf,
) )
@@ -169,6 +171,12 @@ def _bind_access(
log(f"diag: {diag_exc}") log(f"diag: {diag_exc}")
raise raise
clock.mark("comfy-install", log) 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: try:
wait_backend_idle(cfg, ip, log) wait_backend_idle(cfg, ip, log)
except CloudError as exc: except CloudError as exc:
@@ -186,23 +194,10 @@ def _bind_access(
check=False, check=False,
timeout=120, timeout=120,
) )
run_ssh( try:
cfg, ensure_data_binds(cfg, ip, log, stop_swarm=False)
ip, except Exception as exc:
"sudo -n bash -c '" log(f"binds remount: {exc}")
"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,
)
log("systemctl restart swarmui (perf ExtraArgs)") log("systemctl restart swarmui (perf ExtraArgs)")
run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120) run_ssh(cfg, ip, "sudo -n systemctl restart swarmui", timeout=120)
try: try:
+76 -5
View File
@@ -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 from __future__ import annotations
import os
import sys
import time import time
import webbrowser import webbrowser
from collections.abc import Callable from collections.abc import Callable
@@ -138,6 +143,57 @@ def _stop_forwarder(server) -> None:
pass 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: def _recover_unshelve(cfg: Config, log: Log) -> str:
"""Unshelve EXPIRED VM, rebind FIP, wait SSH. Returns new host.""" """Unshelve EXPIRED VM, rebind FIP, wait SSH. Returns new host."""
conn = connect(cfg) conn = connect(cfg)
@@ -174,6 +230,8 @@ def run_tunnel(
log: Log = print, log: Log = print,
wait: Callable[[], None] | None = None, wait: Callable[[], None] | None = None,
poll_seconds: float = 30.0, poll_seconds: float = 30.0,
stop_gpu: Callable[[], None] | None = None,
session_end_poll: Callable[[float], bool] | None = None,
) -> None: ) -> None:
try: try:
_ssh_tunnel_forwarder() _ssh_tunnel_forwarder()
@@ -184,7 +242,7 @@ def run_tunnel(
current_host = host current_host = host
for loc, rem in forwards: for loc, rem in forwards:
log(f"туннель 127.0.0.1:{loc} -> {current_host}:{rem}") 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") log("watchdog: EXPIRED → unshelve + reconnect")
server = _start_forwarder(cfg, current_host, forwards) server = _start_forwarder(cfg, current_host, forwards)
@@ -233,7 +291,7 @@ def run_tunnel(
start_heartbeat_thread() start_heartbeat_thread()
log( log(
"local-watchdog: heartbeat активен — аварийное закрытие " "local-watchdog: heartbeat активен — аварийное закрытие "
"(не Ctrl+C) → stop после grace" "(не Ctrl+C / не Ctrl+D) → stop после grace"
) )
try: try:
@@ -241,9 +299,22 @@ def run_tunnel(
wait() wait()
return return
end_poll = session_end_poll or poll_ctrl_d
next_poll = time.time() + poll_seconds next_poll = time.time() + poll_seconds
while True: 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: if not server.is_active:
next_poll = 0 next_poll = 0
if time.time() < next_poll: if time.time() < next_poll:
@@ -280,7 +351,7 @@ def run_tunnel(
return return
except KeyboardInterrupt: except KeyboardInterrupt:
detach_lease_keep_gpu() detach_lease_keep_gpu()
log("туннель закрыт. GPU жив.") log("Ctrl+C — туннель закрыт. GPU жив.")
finally: finally:
stop_heartbeat_thread() stop_heartbeat_thread()
_stop_forwarder(server) _stop_forwarder(server)
+1 -1
View File
@@ -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", f"{cfg.idle_minutes} мин пустой очереди → delete compute. Отложить: gpu-rent hold",
"preemptible: хостер может усыпить (~24 ч окно) → EXPIRED; tunnel сам unshelve, " "preemptible: хостер может усыпить (~24 ч окно) → EXPIRED; tunnel сам unshelve, "
"или gpu-rent up", "или 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 — аварийное закрытие окна/ребут " "Опционально: gpu-rent watchdog install — аварийное закрытие окна/ребут "
"после grace тоже stop (Ctrl+C по-прежнему detach)", "после grace тоже stop (Ctrl+C по-прежнему detach)",
] ]
+8
View File
@@ -51,6 +51,10 @@ def _mock_bind(monkeypatch):
"gpu_rent.session.ensure_swarm_comfy_installed", "gpu_rent.session.ensure_swarm_comfy_installed",
lambda cfg, host, log: None, 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.wait_backend_idle", lambda cfg, host, log, **kw: None)
monkeypatch.setattr( monkeypatch.setattr(
"gpu_rent.session.verify_stack_on_vm", "gpu_rent.session.verify_stack_on_vm",
@@ -64,6 +68,10 @@ def _mock_bind(monkeypatch):
"gpu_rent.session.tune_swarm_perf", "gpu_rent.session.tune_swarm_perf",
lambda cfg, host, log: False, lambda cfg, host, log: False,
) )
monkeypatch.setattr(
"gpu_rent.session.ensure_data_binds",
lambda cfg, host, log, **kw: None,
)
monkeypatch.setattr( monkeypatch.setattr(
"gpu_rent.session.ensure_boot_snapshot", "gpu_rent.session.ensure_boot_snapshot",
lambda conn, boot_volume_id, cfg, log: None, lambda conn, boot_volume_id, cfg, log: None,
+110 -1
View File
@@ -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(): def test_decide_ok_active():
@@ -71,3 +76,107 @@ def test_resolve_llm_uses_cfg_only(monkeypatch):
llm_runtime = "ollama" llm_runtime = "ollama"
assert resolve_llm_runtime(Cfg2()) == "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)
+8
View File
@@ -181,3 +181,11 @@ def test_verify_gpu_env_llm_only_skips_torch_requirement(monkeypatch):
logs: list[str] = [] logs: list[str] = []
out = verify_gpu_env(C(), "1.2.3.4", logs.append, timeout=5.0) out = verify_gpu_env(C(), "1.2.3.4", logs.append, timeout=5.0)
assert all(c.ok for c in out) 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
+25
View File
@@ -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") text = files("gpu_rent.remote").joinpath("swarm_diag.py").read_text(encoding="utf-8")
assert "ListBackends" in text assert "ListBackends" in text
assert "ListModels" in text
assert "journalctl" in text assert "journalctl" in text
assert ".gpu-rent-last-diag.txt" in text assert ".gpu-rent-last-diag.txt" in text
assert "nvidia-smi" 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): def test_verify_gpu_env_fail_fast_empty_dlbackend(monkeypatch):
+96
View File
@@ -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 _AUTOCOMPLETE_MERGE_PY
assert "GPU_RENT_COMFY_PRESENT" in _ENSURE_INSTALLED_PY assert "GPU_RENT_COMFY_PRESENT" in _ENSURE_INSTALLED_PY
assert hasattr(provision, "ensure_settings_is_installed") 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): 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 "IsInstalled: false" in text
assert "Theme: x" in text assert "Theme: x" in text
assert inst.clear_settings_installed_flag() is False 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"]