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:
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -1,6 +1,8 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Код не начинать, пока не закрыты **0.0** (квота) и **0** (spike). Без GPU в квоте остальное — театр.
|
**Код v1 (§1–6) готов.** Дальше блокирует только облако: **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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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."""
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 ["неизвестно"]
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 всё ещё жив."
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 мин")
|
||||||
+159
-19
@@ -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,
|
|
||||||
host: str,
|
|
||||||
*,
|
|
||||||
open_browser: bool = False,
|
|
||||||
log: Log = print,
|
|
||||||
wait: Callable[[], None] | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
from sshtunnel import SSHTunnelForwarder
|
|
||||||
except ImportError as exc:
|
|
||||||
raise CloudError("Нет sshtunnel. Переустанови пакет: pip install -e .") from exc
|
|
||||||
|
|
||||||
local_port = cfg.swarmui_local_port
|
@dataclass
|
||||||
log(f"туннель 127.0.0.1:{local_port} -> {host}:7801")
|
class WatchDecision:
|
||||||
log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop")
|
kind: str # ok | reconnect | unshelve | exit
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def decide_watch(status: str | None, tunnel_alive: bool) -> WatchDecision:
|
||||||
|
"""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
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user