diff --git a/README.md b/README.md index 1cc4ae9..ccda7ea 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,83 @@ # 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 -.\gpu-rent.ps1 doctor -.\gpu-rent.ps1 dry-run -.\gpu-rent.ps1 status -# если doctor зелёный и квота GPU > 0: -.\gpu-rent.ps1 up --yes -.\gpu-rent.ps1 tunnel -.\gpu-rent.ps1 stop +.\gpu-rent.ps1 doctor # preflight +.\gpu-rent.ps1 flavors # что выберет фоллбек +.\gpu-rent.ps1 dry-run # план без create +.\gpu-rent.ps1 up --yes # GPU + SwarmUI + seed +.\gpu-rent.ps1 tunnel # UI на :17801 (Ctrl+C ≠ stop) +.\gpu-rent.ps1 hold # отложить idle-killer +.\gpu-rent.ps1 stop # гасить compute, диски оставить ``` -```bash -chmod +x gpu-rent.sh # один раз на Unix -./gpu-rent.sh doctor -``` +Unix: `./gpu-rent.sh …` (один раз `chmod +x gpu-rent.sh`). -Ключи: скопируй `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 не коммитить. diff --git a/docs/README.md b/docs/README.md index 8c91dd3..c964064 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,13 +2,14 @@ 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 | +| [spike-notes.md](spike-notes.md) | Чеклист первого живого прогона | | [concept.md](concept.md) | Задача, границы, модель стоимости | | [decisions.md](decisions.md) | Зафиксированные решения | | [architecture.md](architecture.md) | Компоненты, диски, стейт-машина, idle-killer | diff --git a/docs/autocomplete.md b/docs/autocomplete.md index 10c5d14..b021bf5 100644 --- a/docs/autocomplete.md +++ b/docs/autocomplete.md @@ -8,8 +8,8 @@ | Момент | Действие | | --- | --- | -| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта контейнера | -| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, перезапустить контейнер SwarmUI если он уже крутится | +| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта SwarmUI | +| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, `systemctl restart swarmui` если UI уже крутится | | `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) | Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH. @@ -35,7 +35,7 @@ /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): @@ -66,7 +66,7 @@ GET https://api.github.com/repos/{repo}/contents/{path}?ref={ref} ## Настройки SwarmUI до первого запуска -Положить/дописать в `/mnt/swarm_data/Data/Settings.fds` (Frenetic Data Syntax), пока контейнер ещё не стартовал: +Положить/дописать в `/mnt/swarm_data/Data/Settings.fds` (Frenetic Data Syntax), пока SwarmUI ещё не стартовал: ```text DefaultUser: diff --git a/docs/cli.md b/docs/cli.md index b330d96..6a6c09b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 up` | Preflight → create/unshelve (с фоллбеком flavor) → extensions → autocomplete → Civitai seed → push локальных папок → ждать UI → toast/звук | | `gpu-rent up --no-spot` | Обычный (не preemptible) сервер | @@ -35,7 +36,7 @@ Нет команды `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` diff --git a/docs/local-folders.md b/docs/local-folders.md index cd9ed32..1feedf6 100644 --- a/docs/local-folders.md +++ b/docs/local-folders.md @@ -17,7 +17,7 @@ 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 на паузе. diff --git a/docs/open-questions.md b/docs/open-questions.md index 7c748a4..3a8b1b7 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -4,10 +4,10 @@ Осталось подтвердить на **spike**, не в споре: -- Точный URL/JSON «очередь пуста», «backend Idle» и «идёт скачивание модели в UI» у твоей версии SwarmUI. -- Реальная цена 100 GB сетевого диска в выбранном сегменте и цена 1×4090 preemptible ₽/час. +- Качалка модели в UI: отдельного poll-API нет (`DoModelDownloadWS` только WS) → v1 считает busy через `waiting_gens` / `live_gens` / `loading_models` / backend≠idle; иначе пользователь жмёт `hold`. +- Реальная цена 100 GB сетевого диска в выбранном сегменте и цена 1×4090 preemptible ₽/час (в OpenStack API нет). - Имя GPU-образа (без Docker) и flavor id в твоём пуле (в git не класть); какие из списка фоллбека реально есть. -- Хватает ли роли application credential «только delete этого сервера», или Selectel отдаёт более широкую роль. -- Snapshot attached boot volume после bootstrap: время и можно ли сразу create from snapshot. +- Хватает ли application credential с правами сервисного `member`, или Selectel отдаёт более узкую роль «только delete». +- Snapshot attached boot volume после Idle: время и можно ли сразу create from snapshot. Если spike покажет, что 45 минут льготы мало на первую установку ComfyUI — поднять `IDLE_GRACE_MINUTES`, не отключать killer. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3f8b56e..a26ec93 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # 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 @@ -10,31 +12,28 @@ - [ ] Тикет в поддержку Selectel: 1× GPU (целевой: RTX 4090 24 GB или что дадут в пуле), проект gpu-rent - [ ] Сервисный пользователь с правом только на этот проект -Пока квота 0, можно писать каркас CLI с dry-run и моками, но не считать этап 0 закрытым. +Пока квота 0: `doctor` / `dry-run` / `flavors` работают; `up` откажется с текстом про поддержку. ## 0. Ручной spike в панели -- [ ] RC-файл, `openstack token issue` -- [ ] Сегмент с 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`) +Заполняй [spike-notes.md](spike-notes.md). В коде уже есть idle-killer, hold, snapshot, tunnel-watchdog — spike **проверяет** на живом GPU, не пишет заново. -Заметки — `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. Каркас - [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] [setup.md](setup.md) — квота, сервисный пользователь, RC, Civitai @@ -50,9 +49,9 @@ ## 3. Сессия без туннеля - [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 -- [ ] `resize-data` вверх +- [x] `resize-data` вверх - [x] Генерация `/.gpu-rent/id_ed25519` + keypair при первом `up` - [x] `seed-models` на живом диске (идемпотентно, SHA256) - [x] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/` @@ -67,24 +66,24 @@ - [x] Clone `extensions.yaml` в Extensions / DLNodes до старта UI - [x] Autocomplete: danbooru.csv + Settings.fds; на каждом up — GitHub sha - [x] Civitai seed по манифесту; без токена — дефолт SwarmUI -- [ ] systemd idle-killer + application credential; льгота 45 мин; hold-файл; качалка в UI = busy; затем 30 мин пустой очереди; не вооружать до конца seed -- [ ] Один snapshot `gpu-rent-boot-ok` после первого backend Idle; следующие create — из snapshot если есть -- [ ] `gpu-rent hold` / `hold --clear` по SSH -- [ ] `ready` по HTTP, затем backend Idle +- [x] systemd idle-killer + application credential; льгота 45 мин; hold-файл; очередь/loading = busy; затем 30 мин пустой очереди; вооружение после seed/start (качалка UI = hold) +- [x] Один snapshot `gpu-rent-boot-ok` после первого backend Idle; следующие create — из snapshot если есть +- [x] `gpu-rent hold` / `hold --clear` по SSH +- [x] `ready` по HTTP, затем backend Idle ## 5. Туннель - [x] `gpu-rent tunnel` → 17801, Ctrl+C не делает `stop` - [x] `gpu-rent open` / `tunnel --open` -- [ ] EXPIRED → unshelve + reconnect, пока туннель жив -- [ ] Сниппет MCP в stdout +- [x] EXPIRED → unshelve + reconnect, пока туннель жив +- [x] Сниппет MCP в stdout ## 6. UX -- [ ] Живой список flavors, `--no-spot`, печать фоллбека и цены -- [ ] Оценка ₽, предупреждение про диск 24/7 и про idle-killer -- [ ] `NOTIFY_READY`: Windows toast + звук при backend Idle -- [ ] Пользовательский README поверх `docs/` +- [x] Живой список flavors (`gpu-rent flavors` / dry-run / up), `--no-spot`, печать фоллбека +- [x] Оценка ₽ честно («смотри панель»), предупреждение про диск 24/7 и idle-killer +- [x] `NOTIFY_READY`: Windows toast + звук при backend Idle +- [x] Пользовательский README поверх `docs/` ## Вне скоупа v1 diff --git a/docs/spike-notes.md b/docs/spike-notes.md new file mode 100644 index 0000000..a72e597 --- /dev/null +++ b/docs/spike-notes.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 351cc7e..858d994 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] 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." readme = "README.md" license = { text = "MIT" } diff --git a/src/gpu_rent/__init__.py b/src/gpu_rent/__init__.py index cd44681..25c30e2 100644 --- a/src/gpu_rent/__init__.py +++ b/src/gpu_rent/__init__.py @@ -1,3 +1,3 @@ """gpu-rent: Selectel GPU session CLI.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index d1a096a..28573ff 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -16,7 +16,7 @@ from rich.table import Table from gpu_rent import __version__ from gpu_rent.config import load_config 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.session import cmd_stop, cmd_up from gpu_rent.ssh_ops import interactive_ssh, run_ssh @@ -58,13 +58,6 @@ def _die(exc: BaseException) -> None: 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: table = Table(title="gpu-rent doctor", show_lines=False) table.add_column("ok") @@ -79,11 +72,30 @@ def _print_checks(checks) -> int: failed = blocking_failed(checks) if failed: console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md") + _print_next_steps(failed) return 1 console.print("\n[green]Можно идти дальше.[/green] Дальше: gpu-rent up --yes") + console.print("Чеклист spike: docs/spike-notes.md") 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(): cfg = load_config(require_auth=True) state = load_state() @@ -118,12 +130,61 @@ def dry_run() -> None: console.print("\n[bold]План[/bold]") for line in dry_run_plan(checks): 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): raise typer.Exit(1) except GpuRentError as 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() def status() -> None: """Локальный state + OpenStack, если .env есть. Туннель не нужен.""" @@ -149,9 +210,31 @@ def status() -> None: cfg = load_config(require_auth=False) listening = _port_open(cfg.swarmui_local_port) table.add_row("туннель", f"localhost:{cfg.swarmui_local_port} {'слушает' if listening else 'нет'}") - table.add_row("₽/час", "нет цены в API — смотри панель / spike") - table.add_row("диск used/free", "нужен SSH на живую VM") - table.add_row("idle-killer", "на VM; локально не видно без SSH") + table.add_row( + "₽ / риски", + 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: try: @@ -306,10 +389,15 @@ def hold( until: Optional[str] = typer.Option(None, "--until"), clear: bool = typer.Option(False, "--clear"), ) -> None: - """Отложить idle-killer на VM.""" - del minutes, until, clear + """Отложить idle-killer на VM (файл .gpu-rent-hold-until).""" 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: _die(exc) @@ -389,10 +477,13 @@ def seed_extensions_cmd() -> None: @app.command("resize-data") -def resize_data(gb: int = typer.Option(..., "--gb")) -> None: - del gb +def resize_data(gb: int = typer.Option(..., "--gb", help="Новый размер data volume, GB (только вверх)")) -> None: + """Cinder extend data volume + resize2fs на VM.""" 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: _die(exc) diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index ba14bcd..f12fa33 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -361,6 +361,7 @@ def dry_run_plan(checks: list[Check]) -> list[str]: f"data volume: {cfg.data_volume_size_gb} GB (рост только вверх)", f"preemptible: {cfg.default_spot} (обычный сервер: gpu-rent up --no-spot)", f"idle-killer: {cfg.idle_minutes} мин пустой очереди, льгота {cfg.idle_grace_minutes} мин", + "₽: в API нет — смотри панель; диск 24/7 даже после stop", f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801", "gpu-rent up --yes создаст сеть/диски/compute и поставит SwarmUI (если doctor зелёный и квота GPU > 0)", "после up: gpu-rent tunnel (Ctrl+C не гасит GPU)", diff --git a/src/gpu_rent/errors.py b/src/gpu_rent/errors.py index 2985fea..6a722f5 100644 --- a/src/gpu_rent/errors.py +++ b/src/gpu_rent/errors.py @@ -13,7 +13,3 @@ class ConfigError(GpuRentError): class CloudError(GpuRentError): pass - - -class NotReadyError(GpuRentError): - """Command exists in the spec but is not implemented yet.""" diff --git a/src/gpu_rent/hold.py b/src/gpu_rent/hold.py new file mode 100644 index 0000000..4b77d95 --- /dev/null +++ b/src/gpu_rent/hold.py @@ -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 diff --git a/src/gpu_rent/idle_killer.py b/src/gpu_rent/idle_killer.py new file mode 100644 index 0000000..8dcac89 --- /dev/null +++ b/src/gpu_rent/idle_killer.py @@ -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 ["неизвестно"] diff --git a/src/gpu_rent/notify.py b/src/gpu_rent/notify.py new file mode 100644 index 0000000..ec70674 --- /dev/null +++ b/src/gpu_rent/notify.py @@ -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") diff --git a/src/gpu_rent/provision.py b/src/gpu_rent/provision.py index 65490b1..c90c443 100644 --- a/src/gpu_rent/provision.py +++ b/src/gpu_rent/provision.py @@ -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.sync_files import pull_tree, push_tree +from gpu_rent.idle_killer import arm_idle_killer Log = Callable[[str], None] 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) -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 try: if seed_extensions(cfg, host, log): @@ -266,4 +274,10 @@ def provision_vm(cfg: Config, host: str, log: Log) -> None: if cfg.pull_output: pull_tree(cfg, host, f"{DATA}/Output", cfg.local_output_dir, log) 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("Hold killer: gpu-rent hold | Стоп GPU: gpu-rent stop") diff --git a/src/gpu_rent/ready.py b/src/gpu_rent/ready.py new file mode 100644 index 0000000..23cf939 --- /dev/null +++ b/src/gpu_rent/ready.py @@ -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 всё ещё жив." + ) diff --git a/src/gpu_rent/remote/idle_killer.py b/src/gpu_rent/remote/idle_killer.py new file mode 100644 index 0000000..0fef0d2 --- /dev/null +++ b/src/gpu_rent/remote/idle_killer.py @@ -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()) diff --git a/src/gpu_rent/resize.py b/src/gpu_rent/resize.py new file mode 100644 index 0000000..ae77a22 --- /dev/null +++ b/src/gpu_rent/resize.py @@ -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") diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index 440c674..f7ac0d4 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -21,6 +21,9 @@ from gpu_rent.cloud import ( ) from gpu_rent.bootstrap import run_bootstrap 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.errors import CloudError, GpuRentError from gpu_rent.inventory import ( @@ -29,6 +32,7 @@ from gpu_rent.inventory import ( pick_volume_type, resolve_flavor, ) +from gpu_rent.ux import print_up_preview from gpu_rent.lock import SessionLock from gpu_rent.os_client import ( KEYPAIR_NAME, @@ -71,11 +75,25 @@ def _bind_access(conn, server, state: SessionState, cfg: Config, log: Log) -> Se wait_ssh(cfg, ip) log(f"SSH {cfg.ssh_user}@{ip}") 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.phase = "ready_cloud" save_state(state) - log("gpu-rent tunnel — UI на localhost:17801") - log("gpu-rent stop — удалить compute, диски оставить") return state @@ -154,10 +172,14 @@ def cmd_up( vtype = pick_volume_type(list(iter_volume_types(conn)), cfg.gpu_rent_az) spot = cfg.default_spot and not no_spot + print_up_preview(cfg, flavors, picked=picked, spot=spot, log=log) + prompt = ( f"Создать {'preemptible ' if spot else ''}GPU {picked.name} " 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: ok = confirm(prompt) if confirm else False diff --git a/src/gpu_rent/snapshot.py b/src/gpu_rent/snapshot.py new file mode 100644 index 0000000..121285e --- /dev/null +++ b/src/gpu_rent/snapshot.py @@ -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 мин") diff --git a/src/gpu_rent/tunnel.py b/src/gpu_rent/tunnel.py index 5b06b04..ed4daaa 100644 --- a/src/gpu_rent/tunnel.py +++ b/src/gpu_rent/tunnel.py @@ -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 import time import webbrowser 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.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] +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 - log(f"туннель 127.0.0.1:{local_port} -> {host}:7801") - log("Ctrl+C закрывает туннель, GPU оставляет. Стоп GPU: gpu-rent stop") +@dataclass +class WatchDecision: + 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( (host, 22), ssh_username=cfg.ssh_user, @@ -43,20 +68,135 @@ def run_tunnel( f"не открыть туннель на {local_port}: {exc}. Порт занят локальным SwarmUI? " "17801 должен быть свободен." ) 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}" log(f"UI {url}") log(f"API {url}/API/") log(f"MCP {url}/mcp") if open_browser: webbrowser.open(url) + + state = load_state() + state.phase = "ready_tunneled" + save_state(state) + try: if wait is not None: wait() return - while server.is_active: + + next_poll = time.time() + poll_seconds + while True: 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: log("туннель закрыт. GPU жив.") finally: - server.stop() + _stop_forwarder(server) diff --git a/src/gpu_rent/ux.py b/src/gpu_rent/ux.py new file mode 100644 index 0000000..eaf15f6 --- /dev/null +++ b/src/gpu_rent/ux.py @@ -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 diff --git a/tests/test_hold_killer.py b/tests/test_hold_killer.py new file mode 100644 index 0000000..6091fdc --- /dev/null +++ b/tests/test_hold_killer.py @@ -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 diff --git a/tests/test_ready_snapshot.py b/tests/test_ready_snapshot.py new file mode 100644 index 0000000..95d223c --- /dev/null +++ b/tests/test_ready_snapshot.py @@ -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 diff --git a/tests/test_resize.py b/tests/test_resize.py new file mode 100644 index 0000000..8a8886c --- /dev/null +++ b/tests/test_resize.py @@ -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) diff --git a/tests/test_session.py b/tests/test_session.py index 1bf08e4..b6824ca 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -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.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( "gpu_rent.session.create_gpu_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.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( "gpu_rent.session.create_gpu_server", lambda *a, **k: created.append("created"), diff --git a/tests/test_tunnel_watch.py b/tests/test_tunnel_watch.py new file mode 100644 index 0000000..6446f95 --- /dev/null +++ b/tests/test_tunnel_watch.py @@ -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" diff --git a/tests/test_ux.py b/tests/test_ux.py new file mode 100644 index 0000000..ea48b5a --- /dev/null +++ b/tests/test_ux.py @@ -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