Bump version to 0.2.0 and enhance documentation

- Updated version number in pyproject.toml and __init__.py to 0.2.0.
- Revised README.md to reflect the current state of the project, including usage instructions and setup steps.
- Improved CLI documentation in cli.md, adding details about new commands and their functionalities.
- Enhanced the quick start section in README.md for better clarity on initial setup.
- Updated local folder documentation to clarify file handling and commands.
- Added a new command for listing GPU flavors and improved error handling in the CLI.
- Implemented a watchdog feature in the tunnel to manage server states effectively.
This commit is contained in:
Leonid Pershin
2026-08-21 03:38:01 +03:00
parent 343f741baa
commit a563ae06c4
30 changed files with 1750 additions and 132 deletions
+66 -42
View File
@@ -1,59 +1,83 @@
# gpu-rent # gpu-rent
Локальный CLI: прерываемый GPU в [Selectel](https://selectel.ru), SwarmUI через туннель на `localhost:17801`. Модели живут на сетевых дисках. Прерываемый GPU в [Selectel](https://selectel.ru) + SwarmUI на `http://127.0.0.1:17801`.
Модели и ComfyUI живут на сетевом диске — платишь за GPU только пока он поднят.
**Сначала ключи и квота не `up`.** Пошагово: [`docs/setup.md`](docs/setup.md). **Сначала ключи и квота GPU, не `up`.** [`docs/setup.md`](docs/setup.md)
Спецификация: [`docs/`](docs/README.md). Решения: [`docs/decisions.md`](docs/decisions.md). ---
## Что уже можно запустить ## Быстрый старт
Windows (cmd или двойной клик): `gpu-rent.bat`
PowerShell: `.\gpu-rent.ps1`
Linux / macOS / Git Bash: `./gpu-rent.sh`
Скрипты сами создают `.venv`, ставят пакет и прокидывают аргументы. Активировать окружение не нужно.
```powershell ```powershell
.\gpu-rent.ps1 doctor .\gpu-rent.ps1 doctor # preflight
.\gpu-rent.ps1 dry-run .\gpu-rent.ps1 flavors # что выберет фоллбек
.\gpu-rent.ps1 status .\gpu-rent.ps1 dry-run # план без create
# если doctor зелёный и квота GPU > 0: .\gpu-rent.ps1 up --yes # GPU + SwarmUI + seed
.\gpu-rent.ps1 up --yes .\gpu-rent.ps1 tunnel # UI на :17801 (Ctrl+C ≠ stop)
.\gpu-rent.ps1 tunnel .\gpu-rent.ps1 hold # отложить idle-killer
.\gpu-rent.ps1 stop .\gpu-rent.ps1 stop # гасить compute, диски оставить
``` ```
```bash Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`).
chmod +x gpu-rent.sh # один раз на Unix
./gpu-rent.sh doctor
```
Ключи: скопируй `env.example` в `.env` в корне репо (скрипт сделает это сам при первом запуске) и заполни `OS_*` по [`docs/setup.md`](docs/setup.md). Лаунчер сам создаёт `.venv` и ставит пакет. Конфиг — **в корне репо**:
`up` поднимает compute и ставит SwarmUI нативно (`launch-linux.sh` + systemd). `tunnel` пробрасывает `localhost:17801`. `stop` гасит GPU, диски оставляет. | Файл | Что |
| --- | --- |
| `.env` | из `env.example``OS_*`, Civitai |
| `models.yaml` | из `models.example.yaml` — seed с Civitai |
| `extensions.yaml` | из `extensions.example.yaml` |
| `.gpu-rent/` | state, SSH-ключ (gitignore) |
## Зачем какие ключи ---
| Что | Зачем | Как получить | ## Команды
| --- | --- | --- |
| Сервисный пользователь Selectel + пароль | OpenStack: VM, диски, сеть. IAM-токен на 24 ч CLI берёт сам | IAM → Service users. **Не** X-Token из профиля |
| `OS_PROJECT_ID`, номер аккаунта, пул, AZ | Scope токена и куда ставить GPU | RC-файл с вкладки Access + матрица GPU |
| Тикет в поддержку | Лимит GPU часто 0 | Текст в docs/setup.md |
| Civitai API token | Seed моделей | [civitai.com/user/account](https://civitai.com/user/account) → API Keys |
| GitHub PAT | Только приватные репы в `extensions.yaml` | Fine-grained, Contents: Read |
Статический `X-Token` панели **не умеет** создавать облачные серверы. | Команда | Смысл |
| --- | --- |
| `doctor` | Keystone, квота GPU, flavor, Civitai, манифесты |
| `flavors` | Живой список по `FLAVOR_PREFERENCE` |
| `up` / `up --yes` / `up --no-spot` / `up --flavor ID` | Поднять GPU |
| `tunnel` / `tunnel --open` | SSH-проброс; watchdog на EXPIRED |
| `open` | Браузер на 17801 |
| `hold` / `hold --minutes 90` / `hold --clear` | Пауза idle-killer |
| `status` | Nova + диск + killer |
| `seed-models` / `seed-extensions` | Докачать на живой диск |
| `push` / `push-models` / `pull-output` | Локальные папки ↔ VM |
| `resize-data --gb 400` | Диск только вверх |
| `stop` / `destroy --i-understand-data-loss` | Стоп GPU / + диски |
## Продукт ---
- GPU по умолчанию preemptible; гасится `stop` или idle-killer на VM (`hold` откладывает killer). ## Деньги и риски
- Первый диск: модели с Civitai по `models.yaml`, если задан API-токен; иначе дефолт SwarmUI.
- Локальные папки: непустые `Models/`, `Wildcards/`, `CustomWorkflows/` едут на `up`; `Output/` можно забрать (`PULL_OUTPUT`).
- Расширения: git-репы из `extensions.yaml` на первый bootstrap.
- Autocomplete: `Data/Autocompletions` + проверка новой версии csv на каждом `up`.
- `doctor` до create; фоллбек GPU flavor; toast когда UI готов; `open` на 17801.
- Локальный SwarmUI на порту 7801 не занимает.
- Прерывание хостером: `EXPIRED``unshelve`.
Лицензия MIT. Секреты и веса в git не класть. - **GPU** — пока жив compute (`up``stop` или idle-killer).
- **Диск data** — **24/7**, даже после `stop`.
- Цены в OpenStack API **нет** — смотри панель Selectel.
- Preemptible по умолчанию (~24 ч) → `EXPIRED`; `tunnel` сам unshelve, иначе `up`.
- Idle-killer: льгота после boot, потом N мин пустой очереди → delete. `hold` откладывает.
- `Ctrl+C` на tunnel **не** гасит GPU.
---
## Модели
1. Civitai-seed по `models.yaml` (нужен `CIVITAI_API_TOKEN`, хост `civitai.red`).
2. Локальные `Models/`, `Wildcards/`, `CustomWorkflows/` — непустые едут на каждый `up`.
Пример в репо уже с Krea 2 + LoRA — скопируй в `models.yaml`.
---
## Документация
| Док | Зачем |
| --- | --- |
| [docs/setup.md](docs/setup.md) | Ключи, квота, RC |
| [docs/spike-notes.md](docs/spike-notes.md) | Чеклист живого spike |
| [docs/cli.md](docs/cli.md) | Все команды и `.env` |
| [docs/decisions.md](docs/decisions.md) | Продуктовые решения |
| [docs/roadmap.md](docs/roadmap.md) | Что готово / что нет |
MIT. Секреты и веса в git не коммитить.
+2 -1
View File
@@ -2,13 +2,14 @@
CLI-оркестратор: поднимает прерываемый GPU-сервер в Selectel, монтирует постоянные сетевые диски и открывает SwarmUI на `localhost` через SSH-туннель. Платим за GPU только пока идёт сессия; модели и ComfyUI живут на диске. CLI-оркестратор: поднимает прерываемый GPU-сервер в Selectel, монтирует постоянные сетевые диски и открывает SwarmUI на `localhost` через SSH-туннель. Платим за GPU только пока идёт сессия; модели и ComfyUI живут на диске.
Код в разработке: каркас CLI и `doctor` есть. GPU всё ещё не создаём, пока нет квоты и зелёного `doctor`. Что сделать руками до этого [setup.md](setup.md). **Код v1 готов.** Живой `up` ждёт квоту GPU и зелёный `doctor`. Старт руками: [setup.md](setup.md), прогон: [spike-notes.md](spike-notes.md).
## Как читать ## Как читать
| Документ | Зачем | | Документ | Зачем |
| --- | --- | | --- | --- |
| [setup.md](setup.md) | **С чего начать:** квота GPU, сервисный пользователь, Civitai, установка CLI | | [setup.md](setup.md) | **С чего начать:** квота GPU, сервисный пользователь, Civitai, установка CLI |
| [spike-notes.md](spike-notes.md) | Чеклист первого живого прогона |
| [concept.md](concept.md) | Задача, границы, модель стоимости | | [concept.md](concept.md) | Задача, границы, модель стоимости |
| [decisions.md](decisions.md) | Зафиксированные решения | | [decisions.md](decisions.md) | Зафиксированные решения |
| [architecture.md](architecture.md) | Компоненты, диски, стейт-машина, idle-killer | | [architecture.md](architecture.md) | Компоненты, диски, стейт-машина, idle-killer |
+4 -4
View File
@@ -8,8 +8,8 @@
| Момент | Действие | | Момент | Действие |
| --- | --- | | --- | --- |
| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта контейнера | | Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта SwarmUI |
| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, перезапустить контейнер SwarmUI если он уже крутится | | Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, `systemctl restart swarmui` если UI уже крутится |
| `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) | | `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) |
Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH. Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH.
@@ -35,7 +35,7 @@
/mnt/swarm_data/Data/Autocompletions/danbooru.csv.gpu-rent-meta.json /mnt/swarm_data/Data/Autocompletions/danbooru.csv.gpu-rent-meta.json
``` ```
В контейнере: `/SwarmUI/Data/Autocompletions/danbooru.csv`. На data volume: `/mnt/swarm_data/Data/Autocompletions/danbooru.csv` (bind → `/opt/swarmui/Data/…`).
Sidecar meta (не отдавать в Output): Sidecar meta (не отдавать в Output):
@@ -66,7 +66,7 @@ GET https://api.github.com/repos/{repo}/contents/{path}?ref={ref}
## Настройки SwarmUI до первого запуска ## Настройки SwarmUI до первого запуска
Положить/дописать в `/mnt/swarm_data/Data/Settings.fds` (Frenetic Data Syntax), пока контейнер ещё не стартовал: Положить/дописать в `/mnt/swarm_data/Data/Settings.fds` (Frenetic Data Syntax), пока SwarmUI ещё не стартовал:
```text ```text
DefaultUser: DefaultUser:
+2 -1
View File
@@ -6,6 +6,7 @@
| Команда | Поведение | | Команда | Поведение |
| --- | --- | | --- | --- |
| `gpu-rent flavors` | Живой список GPU flavors по `FLAVOR_PREFERENCE` + что выберет фоллбек |
| `gpu-rent doctor` | Preflight **без** create: Keystone, квота GPU, flavor в AZ, диски, Civitai token+`.red`, манифесты, SSH-ключ. Код выхода ≠ 0, если сессию нельзя начать | | `gpu-rent doctor` | Preflight **без** create: Keystone, квота GPU, flavor в AZ, диски, Civitai token+`.red`, манифесты, SSH-ключ. Код выхода ≠ 0, если сессию нельзя начать |
| `gpu-rent up` | Preflight → create/unshelve (с фоллбеком flavor) → extensions → autocomplete → Civitai seed → push локальных папок → ждать UI → toast/звук | | `gpu-rent up` | Preflight → create/unshelve (с фоллбеком flavor) → extensions → autocomplete → Civitai seed → push локальных папок → ждать UI → toast/звук |
| `gpu-rent up --no-spot` | Обычный (не preemptible) сервер | | `gpu-rent up --no-spot` | Обычный (не preemptible) сервер |
@@ -35,7 +36,7 @@
Нет команды `generate`. Нет зеркала каталога локального SwarmUI — только папки приложения, см. [local-folders.md](local-folders.md). Нет команды `generate`. Нет зеркала каталога локального SwarmUI — только папки приложения, см. [local-folders.md](local-folders.md).
Сейчас в коде: `up` создаёт compute, идемпотентный bootstrap (без Docker), **затем** git-расширения, autocomplete, Civitai-seed и push локальных папок, и только потом `systemctl start swarmui`. `tunnel` на 17801. Ещё нет: idle-killer, snapshot, hold, EXPIRED-reconnect туннеля. Сейчас в коде: после seed/`start swarmui` — idle-killer, Idle backend, boot snapshot, toast/MCP-сниппет, `resize-data`. `tunnel` с watchdog: EXPIRED → unshelve + reconnect FIP/SSH. UX-полировка flavors/цен ещё впереди.
## `doctor` ## `doctor`
+1 -1
View File
@@ -17,7 +17,7 @@
Wildcards: `.txt` (одна строка — один вариант), как в доке SwarmUI `Data/Wildcards`. Wildcards: `.txt` (одна строка — один вариант), как в доке SwarmUI `Data/Wildcards`.
CustomWorkflows: JSON воркфлоу Comfy/Swarm. После заливки на живой UI — refresh, если API это умеет; иначе рестарт контейнера только если без refresh файлы не видны (spike). CustomWorkflows: JSON воркфлоу Comfy/Swarm. После заливки на живой UI — refresh, если API это умеет; иначе `systemctl restart swarmui` только если без refresh файлы не видны (spike).
Пока идёт push крупных файлов — idle-killer на паузе. Пока идёт push крупных файлов — idle-killer на паузе.
+4 -4
View File
@@ -4,10 +4,10 @@
Осталось подтвердить на **spike**, не в споре: Осталось подтвердить на **spike**, не в споре:
- Точный URL/JSON «очередь пуста», «backend Idle» и «идёт скачивание модели в UI» у твоей версии SwarmUI. - Качалка модели в UI: отдельного poll-API нет (`DoModelDownloadWS` только WS) → v1 считает busy через `waiting_gens` / `live_gens` / `loading_models` / backend≠idle; иначе пользователь жмёт `hold`.
- Реальная цена 100 GB сетевого диска в выбранном сегменте и цена 1×4090 preemptible ₽/час. - Реальная цена 100 GB сетевого диска в выбранном сегменте и цена 1×4090 preemptible ₽/час (в OpenStack API нет).
- Имя GPU-образа (без Docker) и flavor id в твоём пуле (в git не класть); какие из списка фоллбека реально есть. - Имя GPU-образа (без Docker) и flavor id в твоём пуле (в git не класть); какие из списка фоллбека реально есть.
- Хватает ли роли application credential «только delete этого сервера», или Selectel отдаёт более широкую роль. - Хватает ли application credential с правами сервисного `member`, или Selectel отдаёт более узкую роль «только delete».
- Snapshot attached boot volume после bootstrap: время и можно ли сразу create from snapshot. - Snapshot attached boot volume после Idle: время и можно ли сразу create from snapshot.
Если spike покажет, что 45 минут льготы мало на первую установку ComfyUI — поднять `IDLE_GRACE_MINUTES`, не отключать killer. Если spike покажет, что 45 минут льготы мало на первую установку ComfyUI — поднять `IDLE_GRACE_MINUTES`, не отключать killer.
+29 -30
View File
@@ -1,6 +1,8 @@
# Roadmap # Roadmap
Код не начинать, пока не закрыты **0.0** (квота) и **0** (spike). Без GPU в квоте остальное — театр. **Код v1 (§16) готов.** Дальше блокирует только облако: **0.0 квота GPU** и **0 spike** в панели. Без GPU в квоте `up` не создать — это ожидаемо.
Чеклист прогона: [spike-notes.md](spike-notes.md). Ключи: [setup.md](setup.md).
## 0.0 Квота GPU ## 0.0 Квота GPU
@@ -10,31 +12,28 @@
- [ ] Тикет в поддержку Selectel: 1× GPU (целевой: RTX 4090 24 GB или что дадут в пуле), проект gpu-rent - [ ] Тикет в поддержку Selectel: 1× GPU (целевой: RTX 4090 24 GB или что дадут в пуле), проект gpu-rent
- [ ] Сервисный пользователь с правом только на этот проект - [ ] Сервисный пользователь с правом только на этот проект
Пока квота 0, можно писать каркас CLI с dry-run и моками, но не считать этап 0 закрытым. Пока квота 0: `doctor` / `dry-run` / `flavors` работают; `up` откажется с текстом про поддержку.
## 0. Ручной spike в панели ## 0. Ручной spike в панели
- [ ] RC-файл, `openstack token issue` Заполняй [spike-notes.md](spike-notes.md). В коде уже есть idle-killer, hold, snapshot, tunnel-watchdog — spike **проверяет** на живом GPU, не пишет заново.
- [ ] Сегмент с GPU, `flavor list` / `image list` (GPU Driver **580**, без Docker)
- [ ] Сеть + SG :22 + FIP + keypair
- [ ] Boot-from-volume, тег `preemptible`, API 2.72, второй диск в том же AZ
- [ ] `nvidia-smi` на хосте; SwarmUI нативно `launch-linux.sh`
- [ ] SwarmUI на 127.0.0.1:7801, туннель на **17801**, UI + один вызов API
- [ ] `flavor list`: какие 4090 / A5000 / A100 есть; disabled vs capacity
- [ ] Скачать тестовую маленькую модель **через Civitai API** на VM (крошечный embedding) + sidecar json; убедиться, что установщик SwarmUI не качает свой SD
- [ ] Время: ACTIVE / SSH / UI / backend Idle / seed; ₽/час
- [ ] EXPIRED → `unshelve` → те же модели
- [ ] Delete VM, диски живы → create from boot volume
- [ ] Snapshot boot после первого Idle → create from snapshot
- [ ] Прототип idle-killer: скрипт на VM с application credential удаляет **этот** сервер
- [ ] JSON «качалка модели в UI» vs пустая очередь (или нет сигнала → только `hold`)
Заметки — `docs/spike-notes.md`, без секретов и личных имён файлов моделей. - [ ] RC-файл, `doctor` зелёный, квота > 0
- [ ] `flavors` / образ Driver **580** без Docker в пуле
- [ ] `up --yes` → Idle backend + seed; время ACTIVE/SSH/Idle
- [ ] `tunnel` → UI :17801 + API; `nvidia-smi`
- [ ] idle-killer timer + `hold`; качалка UI → только hold
- [ ] EXPIRED → tunnel unshelve или `up`
- [ ] `stop` → диски живы → второй `up`
- [ ] Boot snapshot `gpu-rent-boot-ok` → следующий boot из него
- [ ] ₽/час и ₽/мес диска из панели (в API нет)
Заметки — только в spike-notes.md, без секретов.
## 1. Каркас ## 1. Каркас
- [x] `pyproject.toml`, пакет `gpu_rent`, MIT - [x] `pyproject.toml`, пакет `gpu_rent`, MIT
- [x] config / state / Typer: `doctor` / `dry-run` / `status` / `open` / `up` / `stop` / `destroy` / `ssh` / `logs`; `tunnel` / `hold` / seed ещё `NotReadyError` - [x] config / state / Typer: doctor, dry-run, status, open, up, stop, destroy, ssh, logs, tunnel, hold, seed-*, push, pull-output, resize-data, flavors
- [x] Windows: `gpu-rent.bat` / `gpu-rent.ps1`; Unix: `gpu-rent.sh`; также `python -m gpu_rent` - [x] Windows: `gpu-rent.bat` / `gpu-rent.ps1`; Unix: `gpu-rent.sh`; также `python -m gpu_rent`
- [x] [setup.md](setup.md) — квота, сервисный пользователь, RC, Civitai - [x] [setup.md](setup.md) — квота, сервисный пользователь, RC, Civitai
@@ -50,9 +49,9 @@
## 3. Сессия без туннеля ## 3. Сессия без туннеля
- [x] `up` / `stop` / `status` / reconcile (compute+диски+SSH+bootstrap SwarmUI) - [x] `up` / `stop` / `status` / reconcile (compute+диски+SSH+bootstrap SwarmUI)
- [ ] `status`: диск used/free, ₽/час, TTL preempt 24 ч, killer/hold - [x] `status`: диск used/free (SSH), TTL preempt 24 ч, killer/hold; ₽/час — «смотри панель»
- [x] Второй `up` не создаёт второй GPU - [x] Второй `up` не создаёт второй GPU
- [ ] `resize-data` вверх - [x] `resize-data` вверх
- [x] Генерация `<repo>/.gpu-rent/id_ed25519` + keypair при первом `up` - [x] Генерация `<repo>/.gpu-rent/id_ed25519` + keypair при первом `up`
- [x] `seed-models` на живом диске (идемпотентно, SHA256) - [x] `seed-models` на живом диске (идемпотентно, SHA256)
- [x] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/` - [x] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/`
@@ -67,24 +66,24 @@
- [x] Clone `extensions.yaml` в Extensions / DLNodes до старта UI - [x] Clone `extensions.yaml` в Extensions / DLNodes до старта UI
- [x] Autocomplete: danbooru.csv + Settings.fds; на каждом up — GitHub sha - [x] Autocomplete: danbooru.csv + Settings.fds; на каждом up — GitHub sha
- [x] Civitai seed по манифесту; без токена — дефолт SwarmUI - [x] Civitai seed по манифесту; без токена — дефолт SwarmUI
- [ ] systemd idle-killer + application credential; льгота 45 мин; hold-файл; качалка в UI = busy; затем 30 мин пустой очереди; не вооружать до конца seed - [x] systemd idle-killer + application credential; льгота 45 мин; hold-файл; очередь/loading = busy; затем 30 мин пустой очереди; вооружение после seed/start (качалка UI = hold)
- [ ] Один snapshot `gpu-rent-boot-ok` после первого backend Idle; следующие create — из snapshot если есть - [x] Один snapshot `gpu-rent-boot-ok` после первого backend Idle; следующие create — из snapshot если есть
- [ ] `gpu-rent hold` / `hold --clear` по SSH - [x] `gpu-rent hold` / `hold --clear` по SSH
- [ ] `ready` по HTTP, затем backend Idle - [x] `ready` по HTTP, затем backend Idle
## 5. Туннель ## 5. Туннель
- [x] `gpu-rent tunnel` → 17801, Ctrl+C не делает `stop` - [x] `gpu-rent tunnel` → 17801, Ctrl+C не делает `stop`
- [x] `gpu-rent open` / `tunnel --open` - [x] `gpu-rent open` / `tunnel --open`
- [ ] EXPIRED → unshelve + reconnect, пока туннель жив - [x] EXPIRED → unshelve + reconnect, пока туннель жив
- [ ] Сниппет MCP в stdout - [x] Сниппет MCP в stdout
## 6. UX ## 6. UX
- [ ] Живой список flavors, `--no-spot`, печать фоллбека и цены - [x] Живой список flavors (`gpu-rent flavors` / dry-run / up), `--no-spot`, печать фоллбека
- [ ] Оценка ₽, предупреждение про диск 24/7 и про idle-killer - [x] Оценка ₽ честно («смотри панель»), предупреждение про диск 24/7 и idle-killer
- [ ] `NOTIFY_READY`: Windows toast + звук при backend Idle - [x] `NOTIFY_READY`: Windows toast + звук при backend Idle
- [ ] Пользовательский README поверх `docs/` - [x] Пользовательский README поверх `docs/`
## Вне скоупа v1 ## Вне скоупа v1
+89
View File
@@ -0,0 +1,89 @@
# Spike notes (заполнять руками)
Без секретов, без личных имён файлов моделей. Цель — один раз пройти панель + CLI на живом GPU и зафиксировать факты для v1.
## 0.0 Квота
| Шаг | Дата | Результат |
| --- | --- | --- |
| Квота GPU в панели (лимит) | | 0 / N |
| Тикет в поддержку (номер) | | |
| Лимит поднят | | |
| Сервисный пользователь `member` на проект | | |
Текст тикета: [setup.md §2.3](setup.md).
## Preflight CLI
```text
.\gpu-rent.ps1 doctor
.\gpu-rent.ps1 flavors
.\gpu-rent.ps1 dry-run
```
| Проверка | OK? | Заметка |
| --- | --- | --- |
| Keystone token | | |
| GPU quota > 0 | | |
| Flavor из FLAVOR_PREFERENCE | | id / имя |
| Image Driver 580 (без Docker) | | имя образа |
| Volume type в AZ | | |
| Civitai `.red` + token | | |
## Первый `up`
```text
.\gpu-rent.ps1 up --yes
```
| Метрика | Значение |
| --- | --- |
| Flavor фактически | |
| Spot / preemptible | |
| ACTIVE через (мин) | |
| SSH через (мин) | |
| Backend Idle через (мин) | |
| Seed Civitai (мин / GB) | |
| Boot snapshot создался | да / нет / ошибка |
| ₽/час GPU (панель) | |
| ₽/мес data disk | |
## Туннель и API
```text
.\gpu-rent.ps1 tunnel --open
```
| Шаг | OK? |
| --- | --- |
| UI http://127.0.0.1:17801 | |
| `/API/GetNewSession` | |
| MCP `/mcp` (если нужно) | |
| `nvidia-smi` по `gpu-rent ssh` | |
## Idle-killer / hold
| Шаг | OK? | Заметка |
| --- | --- | --- |
| `systemctl status gpu-rent-idle-killer.timer` | | |
| `gpu-rent hold` пишет hold-until | | |
| Качалка модели в UI → нужен hold? | | нет сигнала API — только hold |
## Preempt / диски
| Шаг | OK? |
| --- | --- |
| EXPIRED → tunnel unshelve или `up` | |
| `stop` — диски живы | |
| Второй `up` — те же модели на data | |
| Boot из snapshot `gpu-rent-boot-ok` | |
## Выводы для кода
- Имя GPU-образа в пуле:
- Реальные flavor id (не в git):
- Хватает ли `IDLE_GRACE_MINUTES=45`:
- Application credential: ок / нужны другие права:
- Прочее:
Когда spike закрыт — отметь пункты в [roadmap.md](roadmap.md) §0 / §0.0.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gpu-rent" name = "gpu-rent"
version = "0.1.0" version = "0.2.0"
description = "Rent a preemptible Selectel GPU, keep SwarmUI on persistent disks, tunnel to localhost:17801." description = "Rent a preemptible Selectel GPU, keep SwarmUI on persistent disks, tunnel to localhost:17801."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
+1 -1
View File
@@ -1,3 +1,3 @@
"""gpu-rent: Selectel GPU session CLI.""" """gpu-rent: Selectel GPU session CLI."""
__version__ = "0.1.0" __version__ = "0.2.0"
+108 -17
View File
@@ -16,7 +16,7 @@ from rich.table import Table
from gpu_rent import __version__ from gpu_rent import __version__
from gpu_rent.config import load_config from gpu_rent.config import load_config
from gpu_rent.doctor import blocking_failed, dry_run_plan, run_doctor from gpu_rent.doctor import blocking_failed, dry_run_plan, run_doctor
from gpu_rent.errors import GpuRentError, NotReadyError from gpu_rent.errors import GpuRentError
from gpu_rent.os_client import connect, find_snapshot_by_name, find_tagged_servers from gpu_rent.os_client import connect, find_snapshot_by_name, find_tagged_servers
from gpu_rent.session import cmd_stop, cmd_up from gpu_rent.session import cmd_stop, cmd_up
from gpu_rent.ssh_ops import interactive_ssh, run_ssh from gpu_rent.ssh_ops import interactive_ssh, run_ssh
@@ -58,13 +58,6 @@ def _die(exc: BaseException) -> None:
raise typer.Exit(1) raise typer.Exit(1)
def _nyi(name: str) -> None:
raise NotReadyError(
f"`{name}` ещё не готов. Уже работают: doctor, dry-run, status, open, up, stop, destroy, ssh, logs, tunnel, seed-*, push, pull-output.\n"
"Ключи: docs/setup.md"
)
def _print_checks(checks) -> int: def _print_checks(checks) -> int:
table = Table(title="gpu-rent doctor", show_lines=False) table = Table(title="gpu-rent doctor", show_lines=False)
table.add_column("ok") table.add_column("ok")
@@ -79,11 +72,30 @@ def _print_checks(checks) -> int:
failed = blocking_failed(checks) failed = blocking_failed(checks)
if failed: if failed:
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md") console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
_print_next_steps(failed)
return 1 return 1
console.print("\n[green]Можно идти дальше.[/green] Дальше: gpu-rent up --yes") console.print("\n[green]Можно идти дальше.[/green] Дальше: gpu-rent up --yes")
console.print("Чеклист spike: docs/spike-notes.md")
return 0 return 0
def _print_next_steps(failed) -> None:
names = {c.name for c in failed}
console.print("\n[bold]Что сделать дальше[/bold]")
if "env file" in names or any("OS_" in (c.detail or "") for c in failed):
console.print(" 1. copy env.example .env → заполни OS_* (docs/setup.md §3)")
if any(
"квота" in (c.detail or "").lower()
or "quota" in c.name.lower()
or c.name == "GPU quota"
for c in failed
):
console.print(" 2. Тикет в поддержку Selectel — лимит GPU (docs/setup.md §2.3)")
if any(c.name == "flavor" for c in failed):
console.print(" 3. gpu-rent flavors — проверь пул / FLAVOR_PREFERENCE")
console.print(" • Чеклист живого прогона: docs/spike-notes.md")
def _live(): def _live():
cfg = load_config(require_auth=True) cfg = load_config(require_auth=True)
state = load_state() state = load_state()
@@ -118,12 +130,61 @@ def dry_run() -> None:
console.print("\n[bold]План[/bold]") console.print("\n[bold]План[/bold]")
for line in dry_run_plan(checks): for line in dry_run_plan(checks):
console.print(f"{line}") console.print(f"{line}")
cfg = load_config(require_auth=False)
if cfg.auth_ok:
try:
from gpu_rent.inventory import looks_like_gpu, rank_flavors
from gpu_rent.os_client import iter_flavors
from gpu_rent.ux import format_flavor_lines
conn = connect(cfg)
flavors = list(iter_flavors(conn))
gpu = [f for f in flavors if looks_like_gpu(f)]
ranked = rank_flavors(gpu or flavors, cfg.flavor_preference)
console.print("\n[bold]Flavors[/bold]")
for line in format_flavor_lines(ranked):
console.print(f" {line}")
except GpuRentError as exc:
console.print(f" flavors: {exc}")
if blocking_failed(checks): if blocking_failed(checks):
raise typer.Exit(1) raise typer.Exit(1)
except GpuRentError as exc: except GpuRentError as exc:
_die(exc) _die(exc)
@app.command()
def flavors() -> None:
"""Живой список GPU flavors по FLAVOR_PREFERENCE."""
try:
from gpu_rent.inventory import looks_like_gpu, rank_flavors, resolve_flavor
from gpu_rent.os_client import iter_flavors
from gpu_rent.ux import format_flavor_lines
cfg = load_config(require_auth=True)
conn = connect(cfg)
all_f = list(iter_flavors(conn))
gpu = [f for f in all_f if looks_like_gpu(f)]
ranked = rank_flavors(gpu or all_f, cfg.flavor_preference)
try:
picked = resolve_flavor(
all_f,
cfg.flavor_preference,
default_id=cfg.default_flavor_id or None,
fallback=cfg.flavor_fallback,
)
except ValueError:
picked = None
for line in format_flavor_lines(ranked, picked):
console.print(line)
console.print(
f"\nspot по умолчанию: {cfg.default_spot} "
f"(обычный: gpu-rent up --no-spot)"
)
console.print("₽ в API нет — смотри панель Selectel.")
except GpuRentError as exc:
_die(exc)
@app.command() @app.command()
def status() -> None: def status() -> None:
"""Локальный state + OpenStack, если .env есть. Туннель не нужен.""" """Локальный state + OpenStack, если .env есть. Туннель не нужен."""
@@ -149,9 +210,31 @@ def status() -> None:
cfg = load_config(require_auth=False) cfg = load_config(require_auth=False)
listening = _port_open(cfg.swarmui_local_port) listening = _port_open(cfg.swarmui_local_port)
table.add_row("туннель", f"localhost:{cfg.swarmui_local_port} {'слушает' if listening else 'нет'}") table.add_row("туннель", f"localhost:{cfg.swarmui_local_port} {'слушает' if listening else 'нет'}")
table.add_row("₽/час", "нет цены в API — смотри панель / spike") table.add_row(
table.add_row("диск used/free", "нужен SSH на живую VM") "₽ / риски",
table.add_row("idle-killer", "на VM; локально не видно без SSH") f"панель Selectel; диск {cfg.data_volume_size_gb}GB 24/7; "
f"killer {cfg.idle_minutes}м (+{cfg.idle_grace_minutes}м льгота)",
)
if state.floating_ip and cfg.ssh_private_key_path.is_file():
try:
df = run_ssh(
cfg,
state.floating_ip,
"df -h /mnt/swarm_data 2>/dev/null | tail -1",
check=False,
timeout=15,
).strip()
table.add_row("диск used/free", df or "нет df")
from gpu_rent.idle_killer import killer_status_lines
table.add_row("idle-killer", "; ".join(killer_status_lines(cfg, state.floating_ip)))
except GpuRentError as exc:
table.add_row("диск used/free", f"SSH: {exc}")
table.add_row("idle-killer", "нет SSH")
else:
table.add_row("диск used/free", "нужен живой FIP + SSH-ключ")
table.add_row("idle-killer", "нужен SSH на живую VM")
if cfg.auth_ok: if cfg.auth_ok:
try: try:
@@ -306,10 +389,15 @@ def hold(
until: Optional[str] = typer.Option(None, "--until"), until: Optional[str] = typer.Option(None, "--until"),
clear: bool = typer.Option(False, "--clear"), clear: bool = typer.Option(False, "--clear"),
) -> None: ) -> None:
"""Отложить idle-killer на VM.""" """Отложить idle-killer на VM (файл .gpu-rent-hold-until)."""
del minutes, until, clear
try: try:
_nyi("hold") from gpu_rent.hold import clear_hold, set_hold
cfg, host = _live()
if clear:
clear_hold(cfg, host, log=lambda m: console.print(m))
return
set_hold(cfg, host, minutes=minutes, until=until, log=lambda m: console.print(m))
except GpuRentError as exc: except GpuRentError as exc:
_die(exc) _die(exc)
@@ -389,10 +477,13 @@ def seed_extensions_cmd() -> None:
@app.command("resize-data") @app.command("resize-data")
def resize_data(gb: int = typer.Option(..., "--gb")) -> None: def resize_data(gb: int = typer.Option(..., "--gb", help="Новый размер data volume, GB (только вверх)")) -> None:
del gb """Cinder extend data volume + resize2fs на VM."""
try: try:
_nyi("resize-data") from gpu_rent.resize import resize_data_volume
cfg = load_config(require_auth=True)
resize_data_volume(cfg, gb, log=lambda m: console.print(m))
except GpuRentError as exc: except GpuRentError as exc:
_die(exc) _die(exc)
+1
View File
@@ -361,6 +361,7 @@ def dry_run_plan(checks: list[Check]) -> list[str]:
f"data volume: {cfg.data_volume_size_gb} GB (рост только вверх)", f"data volume: {cfg.data_volume_size_gb} GB (рост только вверх)",
f"preemptible: {cfg.default_spot} (обычный сервер: gpu-rent up --no-spot)", f"preemptible: {cfg.default_spot} (обычный сервер: gpu-rent up --no-spot)",
f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин", f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин",
"₽: в API нет — смотри панель; диск 24/7 даже после stop",
f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801", f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801",
"gpu-rent up --yes создаст сеть/диски/compute и поставит SwarmUI (если doctor зелёный и квота GPU > 0)", "gpu-rent up --yes создаст сеть/диски/compute и поставит SwarmUI (если doctor зелёный и квота GPU > 0)",
"после up: gpu-rent tunnel (Ctrl+C не гасит GPU)", "после up: gpu-rent tunnel (Ctrl+C не гасит GPU)",
-4
View File
@@ -13,7 +13,3 @@ class ConfigError(GpuRentError):
class CloudError(GpuRentError): class CloudError(GpuRentError):
pass pass
class NotReadyError(GpuRentError):
"""Command exists in the spec but is not implemented yet."""
+72
View File
@@ -0,0 +1,72 @@
"""gpu-rent hold: pause idle-killer via hold-until file on the data volume."""
from __future__ import annotations
from datetime import datetime, timezone
from collections.abc import Callable
from gpu_rent.config import Config
from gpu_rent.errors import GpuRentError
from gpu_rent.ssh_ops import put_text, remote_exists, run_ssh
Log = Callable[[str], None]
HOLD_PATH = "/mnt/swarm_data/.gpu-rent-hold-until"
def _parse_until(value: str) -> int:
text = value.strip()
if text.isdigit():
return int(text)
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError as exc:
raise GpuRentError(f"не разобрать --until {value!r}: нужен ISO или unix ts") from exc
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
def set_hold(
cfg: Config,
host: str,
*,
minutes: int | None = None,
until: str | None = None,
log: Log | None = None,
) -> int:
"""Write hold-until unix ts. Default = now + IDLE_MINUTES."""
if minutes is not None and until is not None:
raise GpuRentError("укажи либо --minutes, либо --until, не оба")
now = int(datetime.now(timezone.utc).timestamp())
if until:
ts = _parse_until(until)
else:
mins = minutes if minutes is not None else cfg.idle_minutes
if mins <= 0:
raise GpuRentError("--minutes должен быть > 0")
ts = now + mins * 60
if ts <= now:
raise GpuRentError("hold уже в прошлом — увеличь время или используй --clear")
put_text(cfg, host, HOLD_PATH, f"{ts}\n")
# readable by ubuntu + root
run_ssh(cfg, host, f"sudo -n chmod 644 {HOLD_PATH}", check=False)
iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
if log:
log(f"hold до {iso} (unix {ts})")
return ts
def clear_hold(cfg: Config, host: str, log: Log | None = None) -> None:
run_ssh(cfg, host, f"sudo -n rm -f {HOLD_PATH}", check=False)
if log:
log("hold снят")
def read_hold(cfg: Config, host: str) -> int | None:
if not remote_exists(cfg, host, HOLD_PATH):
return None
raw = run_ssh(cfg, host, f"cat {HOLD_PATH}", check=False).strip()
try:
return int(float(raw.split()[0]))
except (ValueError, IndexError):
return None
+237
View File
@@ -0,0 +1,237 @@
"""Create OpenStack application credential and arm idle-killer on the VM."""
from __future__ import annotations
import json
import secrets
import time
from collections.abc import Callable
from importlib.resources import files
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
from gpu_rent.ssh_ops import put_text, run_ssh
Log = Callable[[str], None]
DATA = "/mnt/swarm_data"
CREDS_REMOTE = "/root/.gpu-rent/idle-killer.json"
SCRIPT_REMOTE = "/usr/local/lib/gpu-rent/idle_killer.py"
UNIT = "gpu-rent-idle-killer"
CRED_NAME_PREFIX = "gpu-rent-idle-killer"
def _pkg_text(name: str) -> str:
return files("gpu_rent.remote").joinpath(name).read_text(encoding="utf-8")
def revoke_old_credentials(conn, log: Log) -> None:
user_id = getattr(conn, "current_user_id", None)
if not user_id:
return
try:
for ac in conn.identity.application_credentials(user=user_id):
name = getattr(ac, "name", "") or ""
if name.startswith(CRED_NAME_PREFIX):
try:
conn.identity.delete_application_credential(user_id, ac.id)
log(f"отозван старый app cred {name}")
except Exception as exc:
log(f"не отозвать {name}: {exc}")
except Exception as exc:
log(f"list application_credentials: {exc}")
def create_application_credential(conn, cfg: Config, server_id: str, log: Log) -> dict:
user_id = getattr(conn, "current_user_id", None)
if not user_id:
raise CloudError("нет current_user_id — idle-killer без app cred")
revoke_old_credentials(conn, log)
secret = secrets.token_urlsafe(32)
name = f"{CRED_NAME_PREFIX}-{server_id[:8]}"
try:
ac = conn.identity.create_application_credential(
user=user_id,
name=name,
secret=secret,
description="gpu-rent idle-killer: delete this compute",
)
except Exception as exc:
raise CloudError(
f"не создать application credential: {exc}. "
"Нужны права identity:application_credential_create на сервисного пользователя."
) from exc
ac_id = getattr(ac, "id", None) or (ac.get("id") if isinstance(ac, dict) else None)
ac_secret = getattr(ac, "secret", None) or secret
if not ac_id:
raise CloudError("application credential создан без id")
log(f"application credential {name}")
return {
"auth_url": cfg.os_auth_url,
"project_id": cfg.os_project_id,
"region_name": cfg.os_region_name,
"application_credential_id": ac_id,
"application_credential_secret": ac_secret,
"server_id": server_id,
"idle_minutes": cfg.idle_minutes,
"grace_minutes": cfg.idle_grace_minutes,
"grace_from": int(time.time()),
"swarm_url": "http://127.0.0.1:7801",
}
def install_units(cfg: Config, host: str, log: Log) -> None:
script = _pkg_text("idle_killer.py")
run_ssh(cfg, host, "sudo -n mkdir -p /usr/local/lib/gpu-rent /root/.gpu-rent", check=False)
put_text(cfg, host, "/tmp/gpu-rent-idle_killer.py", script)
run_ssh(
cfg,
host,
f"sudo -n mv /tmp/gpu-rent-idle_killer.py {SCRIPT_REMOTE} && "
f"sudo -n chmod 755 {SCRIPT_REMOTE}",
)
service = f"""[Unit]
Description=gpu-rent idle-killer (one shot)
After=network-online.target swarmui.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 {SCRIPT_REMOTE}
Nice=10
"""
timer = f"""[Unit]
Description=gpu-rent idle-killer every minute
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
AccuracySec=15s
Unit={UNIT}.service
[Install]
WantedBy=timers.target
"""
put_text(cfg, host, f"/tmp/{UNIT}.service", service)
put_text(cfg, host, f"/tmp/{UNIT}.timer", timer)
run_ssh(
cfg,
host,
f"sudo -n mv /tmp/{UNIT}.service /etc/systemd/system/{UNIT}.service && "
f"sudo -n mv /tmp/{UNIT}.timer /etc/systemd/system/{UNIT}.timer && "
"sudo -n systemctl daemon-reload && "
f"sudo -n systemctl enable --now {UNIT}.timer",
)
log(f"systemd timer {UNIT}.timer")
def arm_idle_killer(
cfg: Config,
host: str,
conn,
server_id: str,
log: Log,
) -> None:
if not server_id:
log("idle-killer: нет server_id — пропуск")
return
try:
creds = create_application_credential(conn, cfg, server_id, log)
except CloudError as exc:
log(f"idle-killer слеп: {exc}")
return
put_text(cfg, host, "/tmp/gpu-rent-idle-killer.json", json.dumps(creds, indent=2) + "\n")
run_ssh(
cfg,
host,
"sudo -n mkdir -p /root/.gpu-rent && "
f"sudo -n mv /tmp/gpu-rent-idle-killer.json {CREDS_REMOTE} && "
f"sudo -n chmod 600 {CREDS_REMOTE}",
)
put_text(cfg, host, f"{DATA}/.gpu-rent-server-id", server_id + "\n")
put_text(
cfg,
host,
f"{DATA}/.gpu-rent-killer-armed",
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + "\n",
)
put_text(cfg, host, f"{DATA}/.gpu-rent-killer-creds-ok", "1\n")
run_ssh(
cfg,
host,
f"sudo -n rm -f {DATA}/.gpu-rent-idle-since && "
f"sudo -n chmod 644 {DATA}/.gpu-rent-server-id "
f"{DATA}/.gpu-rent-killer-armed {DATA}/.gpu-rent-killer-creds-ok",
check=False,
)
install_units(cfg, host, log)
log(
f"idle-killer вооружён: льгота {cfg.idle_grace_minutes} мин, "
f"потом {cfg.idle_minutes} мин пустой очереди → delete compute"
)
def killer_status_lines(cfg: Config, host: str) -> list[str]:
"""Best-effort SSH snapshot for `gpu-rent status`."""
lines: list[str] = []
script = f"""
python3 - <<'PY'
from pathlib import Path
import time
data = Path("{DATA}")
now = time.time()
armed = (data / ".gpu-rent-killer-armed").is_file()
creds = (data / ".gpu-rent-killer-creds-ok").is_file()
print("armed", "yes" if armed else "no")
print("creds", "yes" if creds else "no")
hold = data / ".gpu-rent-hold-until"
if hold.is_file():
try:
ts = float(hold.read_text().strip().split()[0])
left = int(ts - now)
print("hold", left if left > 0 else 0)
except Exception:
print("hold", "bad")
else:
print("hold", "none")
idle = data / ".gpu-rent-idle-since"
if idle.is_file():
try:
ts = float(idle.read_text().strip().split()[0])
print("idle_for", int(now - ts))
except Exception:
print("idle_for", "bad")
else:
print("idle_for", "none")
PY
"""
try:
from gpu_rent.ssh_ops import run_ssh as _ssh
out = _ssh(cfg, host, script.strip(), check=False, timeout=20)
except Exception as exc:
return [f"ssh fail: {exc}"]
parsed = {}
for line in out.splitlines():
parts = line.strip().split(None, 1)
if len(parts) == 2:
parsed[parts[0]] = parts[1]
if parsed.get("armed") != "yes":
lines.append("не вооружён")
elif parsed.get("creds") != "yes":
lines.append("слеп (нет кредов)")
else:
lines.append("armed")
hold = parsed.get("hold")
if hold and hold not in {"none", "bad", "0"}:
try:
sec = int(hold)
lines.append(f"hold ещё {sec // 60}m")
except ValueError:
lines.append(f"hold={hold}")
idle_for = parsed.get("idle_for")
if idle_for and idle_for not in {"none", "bad"}:
try:
sec = int(idle_for)
lines.append(f"пустая очередь {sec // 60}m {sec % 60}s / {cfg.idle_minutes}m")
except ValueError:
pass
return lines or ["неизвестно"]
+82
View File
@@ -0,0 +1,82 @@
"""Ready notification: toast + sound. Never raises."""
from __future__ import annotations
import sys
from collections.abc import Callable
from gpu_rent.config import Config
Log = Callable[[str], None]
def notify_ready(cfg: Config, log: Log) -> None:
if not cfg.notify_ready:
return
log("NOTIFY_READY: SwarmUI Idle")
_sound()
if sys.platform == "win32":
_windows_toast(log)
def _sound() -> None:
try:
if sys.platform == "win32":
import winsound
winsound.MessageBeep(winsound.MB_ICONASTERISK)
return
except Exception:
pass
try:
sys.stdout.write("\a")
sys.stdout.flush()
except Exception:
pass
def _windows_toast(log: Log) -> None:
# PowerShell WinRT toast — no admin; fail soft.
script = (
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, "
"ContentType = WindowsRuntime] > $null; "
"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent("
"[Windows.UI.Notifications.ToastTemplateType]::ToastText02); "
"$text = $template.GetElementsByTagName('text'); "
"$text.Item(0).AppendChild($template.CreateTextNode('gpu-rent')) | Out-Null; "
"$text.Item(1).AppendChild($template.CreateTextNode('SwarmUI backend Idle — gpu-rent tunnel')) | Out-Null; "
"$toast = [Windows.UI.Notifications.ToastNotification]::new($template); "
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('gpu-rent').Show($toast)"
)
try:
import subprocess
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
check=False,
capture_output=True,
timeout=15,
)
except Exception as exc:
log(f"toast недоступен ({exc}) — только звук/лог")
def print_mcp_snippet(cfg: Config, log: Log) -> None:
port = cfg.swarmui_local_port
log("")
log("— Cursor MCP (вставь в mcp.json, файл сам не трогаем) —")
log("{")
log(' "mcpServers": {')
log(' "swarmui": {')
log(f' "url": "http://127.0.0.1:{port}/mcp"')
log(" }")
log(" }")
log("}")
log("")
log(f"SwarmUI на VM: 127.0.0.1:7801 (только через туннель)")
log(f"Локально: gpu-rent tunnel")
log(f"Браузер: gpu-rent open → http://127.0.0.1:{port}")
log(f"API: http://127.0.0.1:{port}/API/")
log(f"MCP: http://127.0.0.1:{port}/mcp")
log("Hold killer: gpu-rent hold")
log("Стоп GPU: gpu-rent stop")
+15 -1
View File
@@ -23,6 +23,7 @@ from gpu_rent.manifests import (
) )
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_ssh
from gpu_rent.sync_files import pull_tree, push_tree from gpu_rent.sync_files import pull_tree, push_tree
from gpu_rent.idle_killer import arm_idle_killer
Log = Callable[[str], None] Log = Callable[[str], None]
DATA = "/mnt/swarm_data" DATA = "/mnt/swarm_data"
@@ -246,7 +247,14 @@ def ensure_swarmui_running(cfg: Config, host: str, log: Log, restart: bool) -> N
run_ssh(cfg, host, "sudo -n systemctl enable swarmui", check=False) run_ssh(cfg, host, "sudo -n systemctl enable swarmui", check=False)
def provision_vm(cfg: Config, host: str, log: Log) -> None: def provision_vm(
cfg: Config,
host: str,
log: Log,
*,
conn=None,
server_id: str | None = None,
) -> None:
restart = False restart = False
try: try:
if seed_extensions(cfg, host, log): if seed_extensions(cfg, host, log):
@@ -266,4 +274,10 @@ def provision_vm(cfg: Config, host: str, log: Log) -> None:
if cfg.pull_output: if cfg.pull_output:
pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log) pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log)
ensure_swarmui_running(cfg, host, log, restart=restart) ensure_swarmui_running(cfg, host, log, restart=restart)
if conn is not None and server_id:
try:
arm_idle_killer(cfg, host, conn, server_id, log)
except GpuRentError as exc:
log(f"idle-killer: {exc}")
log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel") log("SwarmUI слушает 127.0.0.1:7801 — gpu-rent tunnel")
log("Hold killer: gpu-rent hold | Стоп GPU: gpu-rent stop")
+95
View File
@@ -0,0 +1,95 @@
"""Wait until SwarmUI HTTP is up and backend is Idle (on the VM)."""
from __future__ import annotations
import time
from collections.abc import Callable
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
from gpu_rent.ssh_ops import run_ssh
Log = Callable[[str], None]
_REMOTE_POLL = r"""
import json, time, urllib.request
url = "http://127.0.0.1:7801"
deadline = time.time() + 25
while time.time() < deadline:
try:
req = urllib.request.Request(
url + "/API/GetNewSession",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
session = json.loads(resp.read().decode())
sid = session.get("session_id")
if not sid:
time.sleep(2)
continue
body = json.dumps({"session_id": sid}).encode()
req2 = urllib.request.Request(
url + "/API/GetCurrentStatus",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req2, timeout=5) as resp:
data = json.loads(resp.read().decode())
st = data.get("status") or {}
be = data.get("backend_status") or {}
waiting = int(st.get("waiting_gens") or 0)
live = int(st.get("live_gens") or 0)
loading = int(st.get("loading_models") or 0)
bstat = str(be.get("status") or "unknown").lower()
if waiting or live or loading:
print(f"BUSY queue w={waiting} live={live} load={loading}")
elif bstat not in ("idle", "disabled", "all_disabled", "empty"):
print(f"BUSY backend={bstat}")
else:
print(f"READY backend={bstat}")
raise SystemExit(0)
except Exception as exc:
print(f"WAIT {exc}")
time.sleep(3)
print("WAIT timeout-slice")
"""
def wait_backend_idle(
cfg: Config,
host: str,
log: Log,
*,
timeout: float = 2400.0,
poll_every: float = 15.0,
) -> None:
"""Block until SwarmUI on the VM reports Idle backend (or timeout)."""
deadline = time.time() + timeout
log("жду HTTP :7801 и Idle backend на VM…")
last = ""
while time.time() < deadline:
try:
out = run_ssh(
cfg,
host,
"python3 - <<'PY'\n" + _REMOTE_POLL + "\nPY",
check=False,
timeout=40,
).strip()
except Exception as exc:
out = f"WAIT ssh: {exc}"
line = out.splitlines()[-1] if out else "WAIT empty"
if line != last:
log(line)
last = line
if line.startswith("READY"):
log("backend Idle")
return
time.sleep(poll_every)
raise CloudError(
f"backend не стал Idle за {int(timeout)} с. "
"Проверь journalctl -u swarmui на VM; GPU всё ещё жив."
)
+227
View File
@@ -0,0 +1,227 @@
"""Remote idle-killer: stdlib only. Runs on the VM via systemd timer."""
from __future__ import annotations
import json
import ssl
import time
import urllib.error
import urllib.request
from pathlib import Path
DATA = Path("/mnt/swarm_data")
CREDS = Path("/root/.gpu-rent/idle-killer.json")
HOLD = DATA / ".gpu-rent-hold-until"
IDLE_SINCE = DATA / ".gpu-rent-idle-since"
ARMED = DATA / ".gpu-rent-killer-armed"
LOG = DATA / ".gpu-rent-killer.log"
SERVER_ID_FILE = DATA / ".gpu-rent-server-id"
def log(msg: str) -> None:
line = f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} {msg}\n"
try:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as fh:
fh.write(line)
except OSError:
pass
print(line.rstrip())
def read_ts(path: Path) -> float | None:
if not path.is_file():
return None
try:
return float(path.read_text(encoding="utf-8").strip().split()[0])
except (OSError, ValueError, IndexError):
return None
def write_ts(path: Path, value: float | None) -> None:
if value is None:
try:
path.unlink(missing_ok=True)
except OSError:
pass
return
path.write_text(f"{int(value)}\n", encoding="utf-8")
def swarm_busy(swarm_url: str, timeout: float = 8.0) -> tuple[bool, str]:
"""Return (busy, detail). Treat unreachable UI as busy (don't kill mid-boot)."""
ctx = ssl.create_default_context()
try:
req = urllib.request.Request(
f"{swarm_url.rstrip('/')}/API/GetNewSession",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
session = json.loads(resp.read().decode("utf-8"))
sid = session.get("session_id")
if not sid:
return True, "no session_id"
body = json.dumps({"session_id": sid}).encode("utf-8")
req2 = urllib.request.Request(
f"{swarm_url.rstrip('/')}/API/GetCurrentStatus",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req2, timeout=timeout, context=ctx) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError, OSError) as exc:
return True, f"swarm unreachable: {exc}"
status = data.get("status") or {}
backend = data.get("backend_status") or {}
waiting = int(status.get("waiting_gens") or 0)
live = int(status.get("live_gens") or 0)
loading = int(status.get("loading_models") or 0)
bstat = str(backend.get("status") or "unknown").lower()
if waiting or live or loading:
return True, f"queue waiting={waiting} live={live} loading={loading}"
if bstat not in {"idle", "disabled", "all_disabled", "empty"}:
return True, f"backend={bstat}"
return False, f"idle backend={bstat}"
def keystone_token(creds: dict) -> tuple[str, str]:
"""Return (token, compute_url)."""
auth = {
"auth": {
"identity": {
"methods": ["application_credential"],
"application_credential": {
"id": creds["application_credential_id"],
"secret": creds["application_credential_secret"],
},
}
}
}
url = creds["auth_url"].rstrip("/") + "/auth/tokens"
body = json.dumps(auth).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
token = resp.headers.get("X-Subject-Token")
payload = json.loads(resp.read().decode("utf-8"))
if not token:
raise RuntimeError("нет X-Subject-Token")
catalog = ((payload.get("token") or {}).get("catalog")) or []
region = (creds.get("region_name") or "").lower()
compute = ""
for svc in catalog:
if svc.get("type") != "compute":
continue
for ep in svc.get("endpoints") or []:
if ep.get("interface") != "public":
continue
if region and str(ep.get("region") or "").lower() != region:
continue
compute = str(ep.get("url") or "").rstrip("/")
break
if compute:
break
if not compute:
raise RuntimeError("compute endpoint не найден в catalog")
# Prefer v2.1
if "/v2/" in compute and "/v2.1" not in compute:
compute = compute.replace("/v2/", "/v2.1/")
return token, compute
def delete_server(creds: dict, server_id: str) -> None:
token, compute = keystone_token(creds)
url = f"{compute}/servers/{server_id}"
req = urllib.request.Request(
url,
headers={"X-Auth-Token": token},
method="DELETE",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
code = getattr(resp, "status", 204)
log(f"DELETE {server_id} -> {code}")
except urllib.error.HTTPError as exc:
if exc.code in (404, 410):
log(f"server already gone ({exc.code})")
return
raise
def main() -> int:
if not ARMED.is_file():
log("not armed")
return 0
if not CREDS.is_file():
log("blind: no creds")
return 0
try:
creds = json.loads(CREDS.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log(f"blind: bad creds ({exc})")
return 0
now = time.time()
grace_from = float(creds.get("grace_from") or 0)
grace_minutes = float(creds.get("grace_minutes") or 45)
if now < grace_from + grace_minutes * 60:
left = int(grace_from + grace_minutes * 60 - now)
log(f"grace {left}s left")
return 0
hold_until = read_ts(HOLD)
if hold_until and now < hold_until:
log(f"hold until {int(hold_until)}")
return 0
swarm_url = str(creds.get("swarm_url") or "http://127.0.0.1:7801")
busy, detail = swarm_busy(swarm_url)
if busy:
write_ts(IDLE_SINCE, None)
log(f"busy: {detail}")
return 0
idle_minutes = float(creds.get("idle_minutes") or 30)
since = read_ts(IDLE_SINCE)
if since is None:
write_ts(IDLE_SINCE, now)
log(f"idle clock start ({detail})")
return 0
elapsed = now - since
need = idle_minutes * 60
if elapsed < need:
log(f"idle {int(elapsed)}s / {int(need)}s ({detail})")
return 0
server_id = str(creds.get("server_id") or "").strip()
if not server_id and SERVER_ID_FILE.is_file():
server_id = SERVER_ID_FILE.read_text(encoding="utf-8").strip()
if not server_id:
log("no server_id")
return 1
log(f"idle {int(elapsed)}s >= {int(need)}s — delete {server_id}")
try:
delete_server(creds, server_id)
except Exception as exc:
log(f"delete failed: {exc}")
return 1
write_ts(IDLE_SINCE, None)
try:
ARMED.unlink(missing_ok=True)
except OSError:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
+91
View File
@@ -0,0 +1,91 @@
"""Grow data volume upward (Cinder extend + resize2fs on VM)."""
from __future__ import annotations
import shlex
import time
from collections.abc import Callable
from gpu_rent.config import Config
from gpu_rent.errors import CloudError, GpuRentError
from gpu_rent.os_client import DATA_VOLUME_NAME, connect, find_volumes_by_name
from gpu_rent.ssh_ops import run_ssh
from gpu_rent.state import load_state, save_state
Log = Callable[[str], None]
_GROW_FS = r"""
set -euo pipefail
mnt=/mnt/swarm_data
src=$(findmnt -n -o SOURCE "$mnt" || true)
if [[ -z "$src" ]]; then
echo "data volume not mounted at $mnt"
exit 1
fi
if command -v resize2fs >/dev/null 2>&1; then
resize2fs "$src"
elif command -v xfs_growfs >/dev/null 2>&1; then
xfs_growfs "$mnt"
else
echo "нет resize2fs/xfs_growfs"
exit 1
fi
df -h "$mnt"
"""
def resize_data_volume(cfg: Config, new_gb: int, log: Log) -> None:
if new_gb < 1:
raise GpuRentError("--gb должен быть положительным")
state = load_state()
vol_id = state.data_volume_id
conn = connect(cfg)
if not vol_id:
found = find_volumes_by_name(conn, DATA_VOLUME_NAME)
if not found:
raise CloudError("нет data volume — сначала gpu-rent up")
vol_id = found[0].id
state.data_volume_id = vol_id
save_state(state)
vol = conn.block_storage.get_volume(vol_id)
current = int(getattr(vol, "size", 0) or 0)
if new_gb < current:
raise GpuRentError(
f"вниз нельзя: сейчас {current} GB, запрошено {new_gb} GB. "
"Selectel online resize только вверх."
)
if new_gb == current:
log(f"data volume уже {current} GB")
return
log(f"Cinder extend {vol_id}: {current}{new_gb} GB")
try:
conn.block_storage.extend_volume(vol, new_gb)
except Exception:
try:
conn.block_storage.extend_volume(vol_id, new_gb)
except Exception as exc:
raise CloudError(f"extend volume: {exc}") from exc
deadline = time.time() + 600
while time.time() < deadline:
cur = conn.block_storage.get_volume(vol_id)
size = int(getattr(cur, "size", 0) or 0)
status = (getattr(cur, "status", "") or "").lower()
if size >= new_gb and status in {"available", "in-use"}:
log(f"volume size={size} status={status}")
break
time.sleep(5)
else:
raise CloudError("volume не вырос за 10 мин")
host = state.floating_ip
if not host:
log("нет FIP — FS на VM не расширяю; подними GPU и повтори resize2fs вручную")
return
log("resize2fs на /mnt/swarm_data…")
quoted = shlex.quote(_GROW_FS)
out = run_ssh(cfg, host, f"sudo -n bash -c {quoted}", timeout=180)
log(out.strip() or "FS grown")
+26 -4
View File
@@ -21,6 +21,9 @@ from gpu_rent.cloud import (
) )
from gpu_rent.bootstrap import run_bootstrap from gpu_rent.bootstrap import run_bootstrap
from gpu_rent.provision import provision_vm from gpu_rent.provision import provision_vm
from gpu_rent.ready import wait_backend_idle
from gpu_rent.snapshot import ensure_boot_snapshot
from gpu_rent.notify import notify_ready, print_mcp_snippet
from gpu_rent.config import Config from gpu_rent.config import Config
from gpu_rent.errors import CloudError, GpuRentError from gpu_rent.errors import CloudError, GpuRentError
from gpu_rent.inventory import ( from gpu_rent.inventory import (
@@ -29,6 +32,7 @@ from gpu_rent.inventory import (
pick_volume_type, pick_volume_type,
resolve_flavor, resolve_flavor,
) )
from gpu_rent.ux import print_up_preview
from gpu_rent.lock import SessionLock from gpu_rent.lock import SessionLock
from gpu_rent.os_client import ( from gpu_rent.os_client import (
KEYPAIR_NAME, KEYPAIR_NAME,
@@ -71,11 +75,25 @@ def _bind_access(conn, server, state: SessionState, cfg: Config, log: Log) -> Se
wait_ssh(cfg, ip) wait_ssh(cfg, ip)
log(f"SSH {cfg.ssh_user}@{ip}") log(f"SSH {cfg.ssh_user}@{ip}")
run_bootstrap(cfg, ip, log) run_bootstrap(cfg, ip, log)
provision_vm(cfg, ip, log) provision_vm(cfg, ip, log, conn=conn, server_id=getattr(server, "id", None) or state.server_id)
try:
wait_backend_idle(cfg, ip, log)
except CloudError as exc:
log(f"ready: {exc}")
try:
ensure_boot_snapshot(
conn,
boot_volume_id=state.boot_volume_id,
cfg=cfg,
log=log,
)
except CloudError as exc:
log(f"snapshot: {exc}")
notify_ready(cfg, log)
print_mcp_snippet(cfg, log)
state.bootstrapped = True state.bootstrapped = True
state.phase = "ready_cloud"
save_state(state) save_state(state)
log("gpu-rent tunnel — UI на localhost:17801")
log("gpu-rent stop — удалить compute, диски оставить")
return state return state
@@ -154,10 +172,14 @@ def cmd_up(
vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az) vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az)
spot = cfg.default_spot and not no_spot spot = cfg.default_spot and not no_spot
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
prompt = ( prompt = (
f"Создать {'preemptible ' if spot else ''}GPU {picked.name} " f"Создать {'preemptible ' if spot else ''}GPU {picked.name} "
f"в {cfg.gpu_rent_az}, образ {getattr(image, 'name', image.id)}, " f"в {cfg.gpu_rent_az}, образ {getattr(image, 'name', image.id)}, "
f"data {cfg.data_volume_size_gb} GB. Диск тарифицируется всегда. Продолжить?" f"data {cfg.data_volume_size_gb} GB.\n"
f"Диск тарифицируется всегда; idle-killer через {cfg.idle_minutes} мин "
f"простоя (льгота {cfg.idle_grace_minutes} мин). Продолжить?"
) )
if not yes: if not yes:
ok = confirm(prompt) if confirm else False ok = confirm(prompt) if confirm else False
+63
View File
@@ -0,0 +1,63 @@
"""Boot volume snapshot after first Idle (once)."""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
from gpu_rent.os_client import find_snapshot_by_name
Log = Callable[[str], None]
def ensure_boot_snapshot(
conn,
*,
boot_volume_id: str | None,
cfg: Config,
log: Log,
) -> Any | None:
"""Create named boot snapshot if missing. Safe to call every up."""
name = cfg.boot_snapshot_name
existing = find_snapshot_by_name(conn, name)
if existing:
log(f"boot snapshot уже есть: {name}")
return existing
if not boot_volume_id:
log("boot snapshot пропущен: нет boot_volume_id")
return None
log(f"создаю snapshot {name} с boot volume {boot_volume_id} (force)…")
try:
snap = conn.block_storage.create_snapshot(
volume_id=boot_volume_id,
name=name,
description="gpu-rent first Idle; reuse for next boot",
force=True,
)
except TypeError:
try:
snap = conn.block_storage.create_snapshot(
volume_id=boot_volume_id,
name=name,
force=True,
)
except Exception as exc:
raise CloudError(f"snapshot boot: {exc}") from exc
except Exception as exc:
raise CloudError(f"snapshot boot: {exc}") from exc
snap_id = getattr(snap, "id", None)
deadline = time.time() + 1800
while time.time() < deadline:
cur = conn.block_storage.get_snapshot(snap_id)
status = (getattr(cur, "status", None) or "").lower()
if status == "available":
log(f"snapshot {name} ready ({snap_id})")
return cur
if status in {"error", "error_deleting"}:
raise CloudError(f"snapshot {name} в статусе {status}")
time.sleep(8)
raise CloudError(f"snapshot {name} не стал available за 30 мин")
+158 -18
View File
@@ -1,33 +1,58 @@
"""SSH local forward. Closing the tunnel does not stop the GPU.""" """SSH local forward with Nova watchdog. Ctrl+C closes tunnel only."""
from __future__ import annotations from __future__ import annotations
import time import time
import webbrowser import webbrowser
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from gpu_rent.cloud import (
ensure_floating_ip,
pick_existing_server,
server_status,
unshelve,
)
from gpu_rent.config import Config from gpu_rent.config import Config
from gpu_rent.errors import CloudError from gpu_rent.errors import CloudError, GpuRentError
from gpu_rent.os_client import connect
from gpu_rent.ssh_ops import wait_ssh
from gpu_rent.state import load_state, save_state, utc_now
Log = Callable[[str], None] Log = Callable[[str], None]
EXIT_STATUSES = frozenset(
{"ERROR", "DELETED", "SOFT_DELETED", "UNKNOWN", "BUILD_FAILED"}
)
SHELVED_STATUSES = frozenset({"EXPIRED", "SHELVED", "SHELVED_OFFLOADED"})
def run_tunnel(
cfg: Config, @dataclass
host: str, class WatchDecision:
*, kind: str # ok | reconnect | unshelve | exit
open_browser: bool = False, detail: str = ""
log: Log = print,
wait: Callable[[], None] | None = None,
) -> None: def decide_watch(status: str | None, tunnel_alive: bool) -> WatchDecision:
try: """Pure policy for tunnel watchdog (unit-tested)."""
if not status:
return WatchDecision("exit", "нет сервера gpu-rent")
st = status.upper()
if st in EXIT_STATUSES:
return WatchDecision("exit", f"Nova {st}")
if st in SHELVED_STATUSES:
return WatchDecision("unshelve", f"Nova {st}")
if st == "ACTIVE" and not tunnel_alive:
return WatchDecision("reconnect", "туннель мёртв, сервер ACTIVE")
if st == "ACTIVE":
return WatchDecision("ok", "ACTIVE")
# transitional: BUILD, REBOOT, …
return WatchDecision("ok", f"ждём {st}")
def _start_forwarder(cfg: Config, host: str, local_port: int):
from sshtunnel import SSHTunnelForwarder from sshtunnel import SSHTunnelForwarder
except ImportError as exc:
raise CloudError("Нет sshtunnel. Переустанови пакет: pip install -e .") from exc
local_port = cfg.swarmui_local_port
log(f"туннель 127.0.0.1:{local_port} -> {host}:7801")
log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop")
server = SSHTunnelForwarder( server = SSHTunnelForwarder(
(host, 22), (host, 22),
ssh_username=cfg.ssh_user, ssh_username=cfg.ssh_user,
@@ -43,20 +68,135 @@ def run_tunnel(
f"не открыть туннель на {local_port}: {exc}. Порт занят локальным SwarmUI? " f"не открыть туннель на {local_port}: {exc}. Порт занят локальным SwarmUI? "
"17801 должен быть свободен." "17801 должен быть свободен."
) from exc ) from exc
return server
def _stop_forwarder(server) -> None:
if server is None:
return
try:
server.stop()
except Exception:
pass
def _recover_unshelve(cfg: Config, log: Log) -> str:
"""Unshelve EXPIRED VM, rebind FIP, wait SSH. Returns new host."""
conn = connect(cfg)
server = pick_existing_server(conn)
if not server:
raise CloudError("сервер gpu-rent исчез во время EXPIRED")
status = server_status(server)
if status in SHELVED_STATUSES:
server = unshelve(conn, server, log)
elif status != "ACTIVE":
raise CloudError(f"после preempt статус {status} — не unshelve")
state = load_state()
ip, fip_id = ensure_floating_ip(
conn, server, state.floating_ip_id, state.floating_ip, log
)
state.floating_ip = ip
if fip_id:
state.floating_ip_id = fip_id
state.server_id = server.id
state.unshelved_at = utc_now()
state.phase = "ready_tunneled"
save_state(state)
log(f"жду SSH на {ip}")
wait_ssh(cfg, ip, timeout=420)
return ip
def _poll_nova(cfg: Config, log: Log) -> tuple[str | None, str]:
"""Return (status, detail). Refreshes IAM token via connect()."""
try:
conn = connect(cfg)
server = pick_existing_server(conn)
if not server:
return None, "нет сервера"
return server_status(server), server.id
except GpuRentError as exc:
log(f"watch: OpenStack временно недоступен ({exc})")
return "ACTIVE", "auth-soft-fail" # don't tear down on transient auth blip
def run_tunnel(
cfg: Config,
host: str,
*,
open_browser: bool = False,
log: Log = print,
wait: Callable[[], None] | None = None,
poll_seconds: float = 30.0,
) -> None:
try:
from sshtunnel import SSHTunnelForwarder # noqa: F401
except ImportError as exc:
raise CloudError("Нет sshtunnel. Переустанови пакет: pip install -e .") from exc
local_port = cfg.swarmui_local_port
current_host = host
log(f"туннель 127.0.0.1:{local_port} -> {current_host}:7801")
log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop")
log("watchdog: EXPIRED → unshelve + reconnect")
server = _start_forwarder(cfg, current_host, local_port)
url = f"http://127.0.0.1:{local_port}" url = f"http://127.0.0.1:{local_port}"
log(f"UI {url}") log(f"UI {url}")
log(f"API {url}/API/") log(f"API {url}/API/")
log(f"MCP {url}/mcp") log(f"MCP {url}/mcp")
if open_browser: if open_browser:
webbrowser.open(url) webbrowser.open(url)
state = load_state()
state.phase = "ready_tunneled"
save_state(state)
try: try:
if wait is not None: if wait is not None:
wait() wait()
return return
while server.is_active:
next_poll = time.time() + poll_seconds
while True:
time.sleep(1) time.sleep(1)
if not server.is_active:
# fall through to poll immediately
next_poll = 0
if time.time() < next_poll:
continue
next_poll = time.time() + poll_seconds
tunnel_alive = bool(server.is_active)
status, detail = _poll_nova(cfg, log)
decision = decide_watch(status, tunnel_alive)
if decision.kind == "ok":
continue
if decision.kind == "exit":
log(f"watchdog стоп: {decision.detail} ({detail})")
return
if decision.kind == "reconnect":
log(f"reconnect: {decision.detail}")
_stop_forwarder(server)
try:
server = _start_forwarder(cfg, current_host, local_port)
log(f"туннель снова на {current_host}")
except CloudError as exc:
log(f"reconnect не вышел: {exc}")
time.sleep(10)
continue
if decision.kind == "unshelve":
log(f"watchdog: {decision.detail} — unshelve…")
_stop_forwarder(server)
try:
current_host = _recover_unshelve(cfg, log)
server = _start_forwarder(cfg, current_host, local_port)
log(f"туннель после unshelve → {current_host}:7801")
except (CloudError, GpuRentError) as exc:
log(f"unshelve/reconnect fail: {exc}")
return
except KeyboardInterrupt: except KeyboardInterrupt:
log("туннель закрыт. GPU жив.") log("туннель закрыт. GPU жив.")
finally: finally:
server.stop() _stop_forwarder(server)
+78
View File
@@ -0,0 +1,78 @@
"""User-facing prints: flavor list, cost warnings (no invented ₽)."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from gpu_rent.config import Config
from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
Log = Callable[[str], None]
def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]:
gpu = [f for f in flavors if looks_like_gpu(f)]
return rank_flavors(gpu or flavors, cfg.flavor_preference)
def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = None) -> list[str]:
if not ranked:
return ["flavors: пусто (проверь регион / FLAVOR_PREFERENCE)"]
lines = ["flavors (по FLAVOR_PREFERENCE):"]
for i, info in enumerate(ranked, 1):
mark = " ← выберем" if picked and info.id == picked.id else ""
ram = f"{info.ram_mb // 1024}GB" if info.ram_mb else "?"
vcpu = str(info.vcpus) if info.vcpus is not None else "?"
label = info.label or ""
lines.append(f" {i}. [{label}] {info.name} vCPU={vcpu} RAM={ram} id={info.id}{mark}")
return lines
def cost_and_risk_lines(cfg: Config, *, spot: bool, flavor_name: str) -> list[str]:
"""Honest billing notes — OpenStack has no ₽ prices."""
return [
f"план: {'preemptible ' if spot else ''}{flavor_name}, data {cfg.data_volume_size_gb} GB",
"₽: цены в OpenStack API нет — смотри панель Selectel (GPU ₽/час + диск ₽/мес).",
"диск data тарифицируется 24/7, даже когда GPU выключен (stop).",
f"idle-killer: {cfg.idle_grace_minutes} мин льготы после boot, потом "
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",
]
def print_up_preview(
cfg: Config,
flavors: list[Any],
*,
picked: FlavorInfo,
spot: bool,
log: Log,
) -> None:
ranked = list_ranked_flavors(flavors, cfg)
for line in format_flavor_lines(ranked, picked):
log(line)
log("")
for line in cost_and_risk_lines(cfg, spot=spot, flavor_name=picked.name):
log(f"! {line}")
def resolve_and_preview(
cfg: Config,
flavors: list[Any],
*,
explicit: str | None,
spot: bool,
log: Log,
) -> FlavorInfo:
picked = resolve_flavor(
flavors,
cfg.flavor_preference,
explicit=explicit,
default_id=cfg.default_flavor_id or None,
fallback=cfg.flavor_fallback,
)
print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log)
return picked
+111
View File
@@ -0,0 +1,111 @@
"""Tests for hold parsing and idle-killer busy classification."""
from __future__ import annotations
import importlib.util
from datetime import datetime, timezone
from pathlib import Path
import pytest
from gpu_rent.errors import GpuRentError
from gpu_rent.hold import _parse_until
ROOT = Path(__file__).resolve().parents[1]
REMOTE_KILLER = ROOT / "src" / "gpu_rent" / "remote" / "idle_killer.py"
def _load_remote():
spec = importlib.util.spec_from_file_location("idle_killer_remote", REMOTE_KILLER)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_parse_until_iso():
ts = _parse_until("2030-01-01T00:00:00+00:00")
assert ts == int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
def test_parse_until_unix():
assert _parse_until("1700000000") == 1700000000
def test_parse_until_bad():
with pytest.raises(GpuRentError):
_parse_until("not-a-date")
def test_classify_busy_from_status(monkeypatch):
mod = _load_remote()
class FakeResp:
def __init__(self, payload):
self._payload = payload
def read(self):
import json
return json.dumps(self._payload).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
calls = {"n": 0}
def fake_urlopen(req, timeout=0, context=None):
calls["n"] += 1
url = getattr(req, "full_url", None) or req.get_full_url()
if "GetNewSession" in url:
return FakeResp({"session_id": "abc"})
return FakeResp(
{
"status": {"waiting_gens": 0, "live_gens": 0, "loading_models": 0},
"backend_status": {"status": "idle"},
}
)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
busy, detail = mod.swarm_busy("http://127.0.0.1:7801")
assert busy is False
assert "idle" in detail
def test_classify_busy_queue(monkeypatch):
mod = _load_remote()
class FakeResp:
def __init__(self, payload):
self._payload = payload
def read(self):
import json
return json.dumps(self._payload).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(req, timeout=0, context=None):
url = getattr(req, "full_url", None) or req.get_full_url()
if "GetNewSession" in url:
return FakeResp({"session_id": "abc"})
return FakeResp(
{
"status": {"waiting_gens": 2, "live_gens": 0, "loading_models": 0},
"backend_status": {"status": "idle"},
}
)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
busy, detail = mod.swarm_busy("http://127.0.0.1:7801")
assert busy is True
assert "waiting=2" in detail
+75
View File
@@ -0,0 +1,75 @@
from gpu_rent.notify import print_mcp_snippet
from gpu_rent.snapshot import ensure_boot_snapshot
class _Cfg:
boot_snapshot_name = "gpu-rent-boot-ok"
swarmui_local_port = 17801
notify_ready = True
def test_ensure_boot_snapshot_skips_existing():
class Snap:
id = "snap1"
name = "gpu-rent-boot-ok"
class Conn:
class block_storage:
@staticmethod
def create_snapshot(**kwargs):
raise AssertionError("should not create")
logs = []
import gpu_rent.snapshot as snap_mod
# monkey via attribute
orig = snap_mod.find_snapshot_by_name
snap_mod.find_snapshot_by_name = lambda conn, name: Snap()
try:
got = ensure_boot_snapshot(Conn(), boot_volume_id="v1", cfg=_Cfg(), log=logs.append)
assert got is not None
assert any("уже есть" in m for m in logs)
finally:
snap_mod.find_snapshot_by_name = orig
def test_ensure_boot_snapshot_creates(monkeypatch):
created = {}
class Snap:
def __init__(self):
self.id = "new"
self.status = "creating"
class Conn:
class block_storage:
@staticmethod
def create_snapshot(**kwargs):
created.update(kwargs)
return Snap()
@staticmethod
def get_snapshot(sid):
s = Snap()
s.status = "available"
return s
import gpu_rent.snapshot as snap_mod
monkeypatch.setattr(snap_mod, "find_snapshot_by_name", lambda conn, name: None)
monkeypatch.setattr(snap_mod.time, "sleep", lambda s: None)
logs = []
got = ensure_boot_snapshot(Conn(), boot_volume_id="vol-1", cfg=_Cfg(), log=logs.append)
assert created["volume_id"] == "vol-1"
assert created["force"] is True
assert created["name"] == "gpu-rent-boot-ok"
assert got.status == "available"
def test_mcp_snippet(capsys):
logs = []
print_mcp_snippet(_Cfg(), logs.append)
text = "\n".join(logs)
assert "17801/mcp" in text
assert "gpu-rent tunnel" in text
assert "mcp.json" in text
+32
View File
@@ -0,0 +1,32 @@
import pytest
from gpu_rent.errors import GpuRentError
from gpu_rent.resize import resize_data_volume
from gpu_rent.state import SessionState, save_state
class _Cfg:
pass
def test_resize_rejects_shrink(monkeypatch):
save_state(SessionState(data_volume_id="d1", floating_ip=None))
class Vol:
id = "d1"
size = 200
status = "in-use"
class Conn:
class block_storage:
@staticmethod
def get_volume(vid):
return Vol()
@staticmethod
def extend_volume(*a, **k):
raise AssertionError("no extend")
monkeypatch.setattr("gpu_rent.resize.connect", lambda cfg: Conn())
with pytest.raises(GpuRentError, match="вниз нельзя"):
resize_data_volume(_Cfg(), 100, log=lambda m: None)
+16 -2
View File
@@ -48,7 +48,14 @@ def test_cmd_up_does_not_create_second_gpu(monkeypatch):
) )
monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None) monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None)
monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None) monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None)
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log: None) monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log, **kw: None)
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
monkeypatch.setattr(
"gpu_rent.session.ensure_boot_snapshot",
lambda conn, boot_volume_id, cfg, log: None,
)
monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None)
monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None)
monkeypatch.setattr( monkeypatch.setattr(
"gpu_rent.session.create_gpu_server", "gpu_rent.session.create_gpu_server",
lambda *a, **k: created.append("created") or Server(), lambda *a, **k: created.append("created") or Server(),
@@ -79,7 +86,14 @@ def test_cmd_up_unshelves_expired(monkeypatch):
) )
monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None) monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None)
monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None) monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None)
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log: None) monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log, **kw: None)
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
monkeypatch.setattr(
"gpu_rent.session.ensure_boot_snapshot",
lambda conn, boot_volume_id, cfg, log: None,
)
monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None)
monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None)
monkeypatch.setattr( monkeypatch.setattr(
"gpu_rent.session.create_gpu_server", "gpu_rent.session.create_gpu_server",
lambda *a, **k: created.append("created"), lambda *a, **k: created.append("created"),
+27
View File
@@ -0,0 +1,27 @@
from gpu_rent.tunnel import decide_watch
def test_decide_ok_active():
d = decide_watch("ACTIVE", tunnel_alive=True)
assert d.kind == "ok"
def test_decide_reconnect_when_tunnel_dead():
d = decide_watch("ACTIVE", tunnel_alive=False)
assert d.kind == "reconnect"
def test_decide_unshelve_expired():
for st in ("EXPIRED", "SHELVED", "SHELVED_OFFLOADED"):
d = decide_watch(st, tunnel_alive=True)
assert d.kind == "unshelve", st
def test_decide_exit_error():
d = decide_watch("ERROR", tunnel_alive=True)
assert d.kind == "exit"
def test_decide_exit_missing():
d = decide_watch(None, tunnel_alive=False)
assert d.kind == "exit"
+36
View File
@@ -0,0 +1,36 @@
from gpu_rent.config import load_config
from gpu_rent.inventory import FlavorInfo
from gpu_rent.ux import cost_and_risk_lines, format_flavor_lines
def test_format_flavor_lines_marks_pick():
ranked = [
FlavorInfo("a", "RTX 4090 24GB", 8, 32768, False, {}, "4090-24"),
FlavorInfo("b", "A5000", 8, 32768, False, {}, "a5000"),
]
picked = ranked[0]
text = "\n".join(format_flavor_lines(ranked, picked))
assert "выберем" in text
assert "4090-24" in text
def test_cost_lines_mention_disk_and_killer(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.chdir(tmp_path)
for key in (
"OS_AUTH_URL",
"OS_USER_DOMAIN_NAME",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"GPU_RENT_AZ",
):
monkeypatch.delenv(key, raising=False)
cfg = load_config(require_auth=False)
lines = cost_and_risk_lines(cfg, spot=True, flavor_name="GPU 4090")
blob = "\n".join(lines)
assert "24/7" in blob
assert "idle-killer" in blob
assert "панель" in blob.lower() or "Selectel" in blob