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:
@@ -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` |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
| Момент | Действие |
|
||||
| --- | --- |
|
||||
| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта SwarmUI |
|
||||
| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, `systemctl restart swarmui` если UI уже крутится |
|
||||
| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. После `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()` при старте перечитает каталог).
|
||||
|
||||
## Конфиг
|
||||
|
||||
|
||||
+3
-2
@@ -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 сам
|
||||
|
||||
|
||||
+3
-3
@@ -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-сессию, а не произвольные машины проекта.
|
||||
|
||||
+1
-1
@@ -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 (диски остаются).
|
||||
|
||||
Полезные флаги:
|
||||
|
||||
|
||||
+3
-1
@@ -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 | |
|
||||
|
||||
@@ -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 потеряет часы.
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+126
-59
@@ -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,11 +244,12 @@ 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
|
||||
changed = False
|
||||
else:
|
||||
log(f"качаю {cfg.autocomplete_filename}")
|
||||
run_ssh(
|
||||
cfg,
|
||||
@@ -250,19 +269,16 @@ def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool:
|
||||
"github_blob_sha": sha,
|
||||
"filename": cfg.autocomplete_filename,
|
||||
"fetched_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
"settings_applied": True,
|
||||
"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)
|
||||
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(
|
||||
@@ -273,7 +289,7 @@ def seed_autocomplete(cfg: Config, host: str, log: Log) -> bool:
|
||||
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
|
||||
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:
|
||||
|
||||
+21
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
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", "-l", dst],
|
||||
["sudo", "-n", "umount", dst],
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
subprocess.run(
|
||||
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
|
||||
|
||||
@@ -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():
|
||||
|
||||
+12
-17
@@ -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:
|
||||
|
||||
+76
-5
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
+110
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user