first commit

This commit is contained in:
Leonid Pershin
2026-08-21 02:42:48 +03:00
commit 167d07a733
46 changed files with 3334 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Secrets and local weights — never commit
.env
.env.*
!.env.example
# SwarmUI checkpoints / LoRA dropped into the app tree
Models/**
!Models/**/.gitkeep
!Models/README.md
Wildcards/**
!Wildcards/**/.gitkeep
!Wildcards/README.md
CustomWorkflows/**
!CustomWorkflows/**/.gitkeep
!CustomWorkflows/README.md
Output/**
!Output/**/.gitkeep
!Output/README.md
# Python
.venv/
__pycache__/
*.pyc
dist/
*.egg-info/
# Local CLI state (if someone runs from the repo)
.gpu-rent/
.pytest_cache/
.mypy_cache/
View File
+3
View File
@@ -0,0 +1,3 @@
# Comfy / SwarmUI workflows
Пусто — ничего не едет. Появился JSON — на `gpu-rent up` зальётся в CustomWorkflows на диске.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 gpu-rent contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# Локальные модели (не коммитить веса)
Положи сюда чекпоинт (и рядом `.json` / `.civitai.json` / `.swarm.json`, если есть).
- Папка пустая — на сервер ничего не едет.
- Появилась модель с метадатой — на `gpu-rent up` уедет эта пара (веса + sidecar).
- То, что уже на облачном диске с тем же хешем, повторно не грузим.
- С сервера файлы не удаляем.
Раскладка как у SwarmUI: `Stable-Diffusion/`, `Lora/`, `VAE/`, `Embeddings/`, `controlnet/`, `upscale_models/`, `clip/`.
Веса в git не попадают.
View File
+4
View File
@@ -0,0 +1,4 @@
# Картинки с облака (опциональный pull)
По умолчанию CLI их сам не скачивает. Включи `PULL_OUTPUT=true` или вызови `gpu-rent pull-output`.
Новые файлы с VM появятся здесь. С сервера ничего не удаляется.
+48
View File
@@ -0,0 +1,48 @@
# gpu-rent
Локальный CLI: прерываемый GPU в [Selectel](https://selectel.ru), SwarmUI через туннель на `localhost:17801`. Модели живут на сетевых дисках.
**Сначала ключи и квота — не `up`.** Пошагово: [`docs/setup.md`](docs/setup.md).
Спецификация: [`docs/`](docs/README.md). Решения: [`docs/decisions.md`](docs/decisions.md).
## Что уже можно запустить
```powershell
python -m venv .venv
.\.venv\Scripts\activate
python -m pip install -U pip
python -m pip install -e ".[dev]"
copy env.example $env:USERPROFILE\.gpu-rent\.env
# заполни OS_* по docs/setup.md
gpu-rent doctor
gpu-rent dry-run
gpu-rent status
```
`up` / `stop` / `tunnel` пока не создают сервер: сначала `doctor` должен быть зелёным и квота GPU > 0.
## Зачем какие ключи
| Что | Зачем | Как получить |
| --- | --- | --- |
| Сервисный пользователь 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` панели **не умеет** создавать облачные серверы.
## Продукт
- 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 не класть.
View File
+3
View File
@@ -0,0 +1,3 @@
# Wildcards SwarmUI
Пусто — на сервер ничего не едет. Положи `.txt` (один вариант на строку) — на `gpu-rent up` уедет в `Data/Wildcards`.
+37
View File
@@ -0,0 +1,37 @@
# Документация gpu-rent
CLI-оркестратор: поднимает прерываемый GPU-сервер в Selectel, монтирует постоянные сетевые диски и открывает SwarmUI на `localhost` через SSH-туннель. Платим за GPU только пока идёт сессия; модели и ComfyUI живут на диске.
Код в разработке: каркас CLI и `doctor` есть. GPU всё ещё не создаём, пока нет квоты и зелёного `doctor`. Что сделать руками до этого — [setup.md](setup.md).
## Как читать
| Документ | Зачем |
| --- | --- |
| [setup.md](setup.md) | **С чего начать:** квота GPU, сервисный пользователь, Civitai, установка CLI |
| [concept.md](concept.md) | Задача, границы, модель стоимости |
| [decisions.md](decisions.md) | Зафиксированные решения |
| [architecture.md](architecture.md) | Компоненты, диски, стейт-машина, idle-killer |
| [selectel.md](selectel.md) | Контракт с облаком: auth, API, GPU, preemptible, сеть |
| [models.md](models.md) | Первый seed с Civitai, манифест, пропуск дефолта SwarmUI |
| [extensions.md](extensions.md) | Git-репы SwarmUI-расширений и ComfyUI nodes |
| [autocomplete.md](autocomplete.md) | Word-list промптов, Settings.fds, проверка версии |
| [local-folders.md](local-folders.md) | `Models/` / `Wildcards/` / `CustomWorkflows/` push, optional `Output/` pull |
| [swarmui.md](swarmui.md) | Нативный install, порт 7801, API, MCP |
| [cli.md](cli.md) | Команды, конфиг, локальный state |
| [roadmap.md](roadmap.md) | Порядок реализации |
| [open-questions.md](open-questions.md) | Ещё не закрыто |
## Источники
Проверено 21 августа 2026:
- [Selectel: preemptible servers](https://docs.selectel.ru/en/cloud-servers/about/preemptible-servers/)
- [Selectel: GPU](https://docs.selectel.ru/en/cloud-servers/create/gpus/)
- [Selectel: GPU images / drivers](https://docs.selectel.ru/en/cloud-servers/manage/install-gpu-drivers/)
- [Selectel: IAM / API auth](https://docs.selectel.ru/en/api/authorization/)
- [Selectel: restore preemptible](https://docs.selectel.ru/en/cloud-servers/manage/restore-preemptible-server/)
- [SwarmUI](https://github.com/mcmonkeyprojects/SwarmUI) (нативный Linux, порт 7801)
- [Civitai Site API](https://developer.civitai.com/) (download + metadata; API есть и на `.red`)
- [Civitai: два входа .com / .red](https://civitai.com/articles/28369/two-front-doors-civitaicom-civitaired-and-whats-next)
- [SwarmUI Autocompletions](https://github.com/mcmonkeyprojects/SwarmUI/blob/master/docs/Features/Autocompletions.md)
+173
View File
@@ -0,0 +1,173 @@
# Архитектура
## Обзор
```
┌─ локальная машина (Windows / Linux) ─────────────────────────┐
│ gpu-rent CLI │
│ up / stop / status / doctor / hold │
│ tunnel / open — SSH localhost:17801 → VM :7801 │
│ state ~/.gpu-rent/state.json │
│ │
│ браузер / MCP / curl API → http://127.0.0.1:17801 │
│ локальный SwarmUI → http://127.0.0.1:7801 (не трогаем)
└───────────────────────────────┬──────────────────────────────┘
│ SSH :22 и OpenStack API
┌─ Selectel, сегмент пула (например ru-7a) ────────────────────┐
│ GPU VM (tag preemptible + gpu-rent) │
│ SwarmUI : 127.0.0.1:7801 │
│ idle-killer: очередь / hold / качалка в UI → delete self │
│ application credential (compute delete/shelve only) │
│ │
│ boot volume (network) ОС + NVIDIA + SwarmUI нативно + snapshot │
│ data volume (network) Models, Output, Data, workflows │
│ floating IP только SSH, удаляется на stop │
└──────────────────────────────────────────────────────────────┘
```
Два сетевых диска. После удаления VM boot-диск снова указывают как `--volume` при create.
Разделение **жизни GPU** и **локального туннеля** — следствие [decisions.md](decisions.md): ноут можно закрыть.
## Слои CLI
| Модуль | Ответственность |
| --- | --- |
| `cli` | Typer: `up`, `tunnel`, `open`, `status`, `stop`, `destroy`, `logs`, `ssh`, `doctor`, `hold`, `seed-*`, `push-models`, `pull-output`, `resize-data`, `dry-run` |
| `config` | `.env`, валидация, пути к ключам |
| `state` | JSON сессии: ids, фаза, timestamps |
| `os_client` | `openstacksdk`, refresh IAM-токена |
| `inventory` | Flavors/images в сегменте, квоты, **фоллбек flavor** |
| `bootstrap` | Идемпотентный first-boot; после успеха — snapshot boot volume |
| `doctor` | Preflight без mutating compute |
| `civitai_seed` | Манифест + Civitai API на `.red` |
| `models_push` | SFTP `./Models`, `./Wildcards`, `./CustomWorkflows` |
| `output_pull` | Опциональный SFTP с VM `Output/` |
| `git_seed` | Clone `extensions.yaml` |
| `autocomplete_seed` | Word-list + Settings.fds |
| `notify` | Toast/звук при backend Idle |
| `tunnel` | paramiko / sshtunnel, порт **17801** |
| `watchdog` | EXPIRED → unshelve, пока туннель жив |
| `idle_killer` | systemd на VM + hold-файл + «качалка занята» |
| `reconcile` | Сироты по state и тегу `gpu-rent` |
Каталог сервисов — из Keystone, не из выдуманного `api.selectel.ru/v3/`.
## Два диска
### Boot volume
Сетевой, тот же сегмент, что VM. Первый раз — GPU-optimized образ **без Docker**. `delete_on_termination=false`. Локальный boot запрещён (preemptible сотрёт ОС).
После bootstrap: NVIDIA из образа, `/opt/swarmui` + systemd, unit idle-killer, application credential в `/root/.gpu-rent/` (mode 600).
После **первого** успешного `waiting_ui` + backend Idle: один snapshot boot-диска `gpu-rent-boot-ok` (если ещё нет). Следующий create VM может идти из snapshot, не из сырого GPU-образа. Старый snapshot не плодить каждый `up`.
### Data volume
Второй BDM при create (не attach после ACTIVE). FS один раз, маркер `/mnt/swarm_data/.gpu-rent-ready`. Первый seed: [extensions.md](extensions.md), [autocomplete.md](autocomplete.md), [models.md](models.md), [local-folders.md](local-folders.md).
| На хосте (data volume) | В дереве SwarmUI (`/opt/swarmui`, bind) |
| --- | --- |
| `/mnt/swarm_data/Models` | `/opt/swarmui/Models` |
| `/mnt/swarm_data/Output` | `/opt/swarmui/Output` |
| `/mnt/swarm_data/Data` | `/opt/swarmui/Data` |
| `/mnt/swarm_data/dlbackend` | `/opt/swarmui/dlbackend` |
| `/mnt/swarm_data/Extensions` | `/opt/swarmui/src/Extensions` |
| `/mnt/swarm_data/DLNodes` | `/opt/swarmui/src/BuiltinExtensions/ComfyUIBackend/DLNodes` |
| `/mnt/swarm_data/CustomWorkflows` | `/opt/swarmui/src/BuiltinExtensions/ComfyUIBackend/CustomWorkflows` |
`mkfs` только если нет маркера **и** `blkid` подтвердил пустое устройство по serial/by-id. Не хардкодить `scsi-0Selectel_Volume_…`.
## Стейт-машина
```
idle
│ gpu-rent up
provisioning → bootstrapping → seeding_extensions → seeding_autocomplete → seeding_models → waiting_ui
ready_cloud ← compute жив, idle-killer вооружён
│ gpu-rent tunnel (опционально, пока ноут онлайн)
ready_tunneled ← localhost:17801
│ хостер: EXPIRED
restoring (unshelve) → ready_cloud / ready_tunneled
│ stop | idle-killer | destroy
stopping → idle
```
`ready_cloud` ≠ Nova `ACTIVE`. ACTIVE бывает раньше SSH и UI.
## Idle-killer (на VM)
Пока очередь SwarmUI пуста дольше **30 минут**, скрипт удаляет **этот** compute через OpenStack (диски не трогать). Открытый браузер без джобы жизнь **не** продлевает.
Killer молчит:
- clone расширений, Civitai-seed, push локальных папок;
- первые **45 минут** после ACTIVE или unshelve;
- пока backend/ComfyUI ещё не Idle;
- пока существует hold: файл `/mnt/swarm_data/.gpu-rent-hold-until` с unix ts (пишет `gpu-rent hold`);
- пока SwarmUI качает модель в UI (Model Downloader / активный download — точный JSON на spike). Нет сигнала — пользователь жмёт `hold`.
`gpu-rent hold` без аргументов = +`IDLE_MINUTES` от сейчас. `--minutes 90` — абсолютное продление. `--until` ISO опционально. `hold --clear` снимает.
Потом счётчик 30 минут пустой очереди.
Почему не `shutdown -h now`: у Selectel останов из гостя не обязан снять GPU с биллинга. Нужен API delete/shelve.
Почему не только локальный CLI: ноут спит — процесса нет — GPU продолжает тарифицироваться.
Учётные данные на VM: application credential (или роль без `compute:create`), только delete/shelve. Файл не попадает в Output/Models. Истечение кредов = killer слеп; тогда спасает `gpu-rent status` с ноутбука.
Дефолты: `IDLE_MINUTES=30`, `IDLE_GRACE_MINUTES=45`.
## Watchdog туннеля (на ноутбуке)
Работает только пока открыт туннель:
1. Refresh IAM-токена (TTL 24 ч, как у preemptible).
2. `EXPIRED` → unshelve → переоткрыть туннель.
3. `ERROR` / нет GPU → выход, не бесконечный recreate.
4. Туннель мёртв при ACTIVE → reconnect.
`Ctrl+C` здесь закрывает туннель, **не** вызывает `stop`.
## Teardown (`gpu-rent stop`)
Compute удаляется через OpenStack без SSH. Optional pull Output — только если VM ещё отвечает по SSH:
1. Если `PULL_OUTPUT` и SSH жив — забрать новые файлы в `./Output` (`--no-pull` пропускает).
2. Закрыть туннель этого процесса, если открыт.
3. Удалить compute. Volumes оставить.
4. Дождаться исчезновения сервера.
5. Удалить floating IP (`KEEP_FLOATING_IP=false`).
6. State → `idle`, сохранить volume ids.
`destroy` — то же + диски после `--i-understand-data-loss`.
Reconcile: сервер с тегом `gpu-rent` есть, локального процесса нет — это норма (`ready_cloud`). Сирота = нет тега в state и наоборот; `status` показывает «жив, туннеля нет, idle-killer: …».
## Сеть на VM
- Private net + subnet + router — один раз на пул, переиспользовать.
- SG: ingress TCP/22 с IP оператора. **Не** открывать 7801 наружу.
- SwarmUI: `--host 127.0.0.1 --port 7801` (systemd `swarmui`).
- Туннель: `127.0.0.1:17801``127.0.0.1:7801` на VM.
## Стек
| Слой | Выбор |
| --- | --- |
| Python 3.11+ | openstacksdk, paramiko, Typer, Rich, questionary, dotenv |
| SSH | paramiko + sshtunnel (Windows без системного `ssh -L`) |
| Конфиг | `%USERPROFILE%\.gpu-rent\.env` |
| Лицензия | MIT |
+94
View File
@@ -0,0 +1,94 @@
# Autocomplete промптов SwarmUI
Официальная инструкция: [docs/Features/Autocompletions.md](https://github.com/mcmonkeyprojects/SwarmUI/blob/master/docs/Features/Autocompletions.md).
Нужен word-list (`.csv` / `.txt`) в `Data/Autocompletions` и выбранный источник в настройках пользователя. Без файла в UI нечего выбирать.
## Поведение gpu-rent
| Момент | Действие |
| --- | --- |
| Первый диск / файла ещё нет | Скачать с GitHub на VM в `/mnt/swarm_data/Data/Autocompletions/<filename>`. Прописать `DefaultUser.AutoComplete.Source` в `Settings.fds` **до** первого старта контейнера |
| Каждый последующий `up` (диск уже есть) | Спросить GitHub blob `sha` того же пути. Если sha изменился — скачать заново, заменить файл, перезапустить контейнер SwarmUI если он уже крутится |
| `AUTOCOMPLETE_ENABLED=false` или URL пуст | Ничего не качать, стоковый SwarmUI (только синтаксис `<…>`, без booru-тегов) |
Это **не** одноразовый seed как модели: список тегов на GitHub обновляют. Проверка версии — часть каждого `up`, пока есть SSH.
Файл маленький (danbooru.csv ≈ 34 MB). Killer из‑за него не откладываем, но на первом bootstrap качаем **до** старта UI, вместе с extensions.
## Откуда файл
Дефолт — то, на что ссылается сама документация SwarmUI:
- репозиторий [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete/tree/main/tags)
- путь `tags/danbooru.csv`
- raw: `https://raw.githubusercontent.com/DominikDoom/a1111-sd-webui-tagcomplete/main/tags/danbooru.csv`
Альтернативы из той же папки (`e621.csv`, `danbooru_e621_merged.csv`, …) или релизы [BetaDoggo/danbooru-tag-list](https://github.com/BetaDoggo/danbooru-tag-list/releases) — через конфиг, не хардкод в коде кроме дефолта.
## Куда класть
На data volume (уже примонтирован как `/SwarmUI/Data`):
```text
/mnt/swarm_data/Data/Autocompletions/danbooru.csv
/mnt/swarm_data/Data/Autocompletions/danbooru.csv.gpu-rent-meta.json
```
В контейнере: `/SwarmUI/Data/Autocompletions/danbooru.csv`.
Sidecar meta (не отдавать в Output):
```json
{
"repo": "DominikDoom/a1111-sd-webui-tagcomplete",
"path": "tags/danbooru.csv",
"ref": "main",
"github_blob_sha": "7a3cba59…",
"filename": "danbooru.csv",
"fetched_at": "2026-08-21T00:00:00Z"
}
```
## Как узнать, что файл новый
Не сравнивать «дату файла на диске». GitHub Contents API:
```http
GET https://api.github.com/repos/{repo}/contents/{path}?ref={ref}
```
В ответе поле `sha` — blob git. Сравнить с `github_blob_sha` в sidecar. Совпало → ничего не качать. Другое → GET `download_url` (или raw), атомарно заменить файл, обновить sidecar.
Без токена GitHub лимит 60 запросов/час с IP VM — на один `up` хватает. Если 403 rate limit — пропустить обновление, не валить `up`.
Для URL не с GitHub (произвольный raw): `HEAD`/`GET` с `If-None-Match` / `Last-Modified`, хранить ETag в sidecar.
## Настройки SwarmUI до первого запуска
Положить/дописать в `/mnt/swarm_data/Data/Settings.fds` (Frenetic Data Syntax), пока контейнер ещё не стартовал:
```text
DefaultUser:
AutoComplete:
Source: danbooru.csv
EscapeParens: true
```
`Source` — имя файла **относительно** `Data/Autocompletions`, как в UI `AutoCompletionsSource`.
Если `Settings.fds` уже есть (повторный диск) и пользователь сменил список в UI — **не** перезаписывать `Source` на каждом `up`. Ставить Source только когда он пустой или мы его ещё ни разу не ставили (флаг в meta `settings_applied: true`).
После замены csv на уже живой VM: `systemctl restart swarmui` (хелпер `Reload()` при старте перечитает каталог).
## Конфиг
```env
AUTOCOMPLETE_ENABLED=true
AUTOCOMPLETE_GITHUB_REPO=DominikDoom/a1111-sd-webui-tagcomplete
AUTOCOMPLETE_GITHUB_PATH=tags/danbooru.csv
AUTOCOMPLETE_GITHUB_REF=main
AUTOCOMPLETE_FILENAME=danbooru.csv
```
Или один `AUTOCOMPLETE_URL=` для raw без GitHub sha (тогда версия по ETag).
+171
View File
@@ -0,0 +1,171 @@
# CLI и конфигурация
Имя команды: `gpu-rent`. Windows и Linux. Лицензия MIT, в репозитории нет секретов и имён личных чекпоинтов.
## Команды
| Команда | Поведение |
| --- | --- |
| `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) сервер |
| `gpu-rent up --flavor … --yes` | Без вопросов. `--flavor` бьёт список фоллбека. `--yes` без `--flavor` берёт первый доступный из `FLAVOR_PREFERENCE` |
| `gpu-rent tunnel` | SSH-проброс `localhost:17801` → VM `:7801`. Блокируется. Ctrl+C = закрыть туннель, GPU **оставить** |
| `gpu-rent tunnel --open` | Туннель + сразу открыть браузер на 17801 |
| `gpu-rent open` | Открыть браузер на `http://127.0.0.1:17801`. Туннель уже должен слушать порт |
| `gpu-rent status` | State + Nova + диск used/free + ₽/час + сколько до 24 ч preempt + killer/hold. Туннель не обязателен |
| `gpu-rent hold` | Отложить idle-killer на `IDLE_MINUTES` от сейчас |
| `gpu-rent hold --minutes 90` | Hold до now+90 мин (заменяет предыдущий, не складывает) |
| `gpu-rent hold --until <ISO>` | Hold до абсолютного времени |
| `gpu-rent hold --clear` | Снять hold |
| `gpu-rent stop` | Optional pull Output → удалить compute и FIP, диски оставить. С любой машины с `.env` |
| `gpu-rent stop --no-pull` | Не тянуть Output, даже если `PULL_OUTPUT=true` |
| `gpu-rent destroy` | `stop` + диски, только `--i-understand-data-loss` |
| `gpu-rent logs` | journalctl swarmui / cloud-init по SSH |
| `gpu-rent ssh` | Оболочка на VM |
| `gpu-rent seed-models` | Докачать новые строки манифеста Civitai на уже существующий диск |
| `gpu-rent push` | Инкремент `Models/` + `Wildcards/` + `CustomWorkflows/` (пустые skip) |
| `gpu-rent push-models` | Только `./Models` |
| `gpu-rent pull-output` | Забрать новые файлы с VM `Output/` в `./Output` (сервер не чистим) |
| `gpu-rent seed-extensions` | Доклонировать/обновить git-репы; если VM жива — `systemctl restart swarmui` |
| `gpu-rent resize-data --gb 400` | Увеличить data volume вверх (Selectel online resize). Вниз нельзя |
| `gpu-rent dry-run` | План без mutating-вызовов |
Второй `up` при живой VM: не создавать второй GPU; сделать autocomplete-check и **push** локальных папок; если `PULL_OUTPUT` — подтянуть Output; напомнить про `tunnel` / `stop`. `--adopt` если нашли тег без state.
Нет команды `generate`. Нет зеркала каталога локального SwarmUI — только папки приложения, см. [local-folders.md](local-folders.md).
## `doctor`
Не создаёт сервер, не тратит GPU. Нужен до первого `up` и когда «вчера работало».
Проверки по порядку, все печатаются (не падать на первой, собрать отчёт):
1. Читается `.env`, обязательные `OS_*`.
2. Keystone: токен выдаётся.
3. Квота GPU > 0; иначе текст «напишите в поддержку Selectel».
4. В сегменте есть хотя бы один flavor из `FLAVOR_PREFERENCE` (не `disabled`).
5. Volume type сегмента существует; data 100 GB влезает в квоту дисков.
6. SSH-ключ: есть `~/.gpu-rent/id_ed25519` или CLI сможет его создать.
7. Если задан `CIVITAI_API_TOKEN` — HEAD/лёгкий запрос к `CIVITAI_API_HOST` (дефолт `.red`); манифест парсится.
8. `extensions.yaml` парсится, если файл есть.
9. Локальные папки: предупреждение, если `Models/` огромный относительно свободного места на будущем диске.
Exit 0 — можно `up`. Exit 1 — нельзя, причина в отчёте.
## `status`
Без туннеля. Если SSH есть — ещё диск и killer; если нет — только OpenStack.
| Поле | Откуда |
| --- | --- |
| Фаза state / Nova status | `state.json` + compute |
| Flavor, ₽/час | extra specs / оценка с spike; нет цены в API — «см. панель», не выдумывать |
| Preempt window | create/unshelve timestamp + 24 ч; «осталось Hh Mm» или «не preemptible» |
| Data disk used/free | SSH `df` на `/mnt/swarm_data` |
| Idle-killer | armed / grace до … / hold до … / busy (очередь или качалка) / слеп (нет кредов) |
| Туннель | слушает ли локальный 17801 |
| Snapshot boot | есть ли `gpu-rent-boot-ok` |
## `hold`
Пишет на VM `/mnt/swarm_data/.gpu-rent-hold-until` (unix ts). Killer не удаляет compute, пока `now < ts`. Нужен SSH и живой сервер.
Без VM — ошибка, не «запомню на потом». После `unshelve` hold-файл на диске сохраняется.
## Что печатать при `up`
1. Preflight (тот же набор, что `doctor`, можно вызвать его внутри).
2. Если квота 0 — стоп с текстом «напишите в поддержку Selectel», не traceback.
3. Выбранный flavor (или фоллбек) + оценка: GPU ₽/час, диск ₽/мес, preemptible 24 ч, idle-killer через N мин.
4. Фазы: provisioning → bootstrap → clone extensions → autocomplete → seed Civitai → push локальных папок → waiting UI → backend Idle.
5. Если `NOTIFY_READY` — Windows toast + звук (и строка в лог).
6. Готово:
```text
SwarmUI на VM: 127.0.0.1:7801 (только через туннель)
Локально: gpu-rent tunnel
Браузер: gpu-rent open → http://127.0.0.1:17801
API: http://127.0.0.1:17801/API/
MCP: http://127.0.0.1:17801/mcp
Hold killer: gpu-rent hold
Стоп GPU: gpu-rent stop
```
Сниппет MCP для Cursor — в stdout, `mcp.json` не редактировать.
## Конфигурация
`%USERPROFILE%\.gpu-rent\.env` / `~/.gpu-rent/.env`:
```env
OS_AUTH_URL=https://cloud.api.selcloud.ru/identity/v3
OS_USER_DOMAIN_NAME=
OS_USERNAME=
OS_PASSWORD=
OS_PROJECT_ID=
OS_REGION_NAME=ru-7
GPU_RENT_AZ=ru-7a
SSH_PRIVATE_KEY_PATH=
SSH_USER=ubuntu
BOOT_VOLUME_ID=
DATA_VOLUME_ID=
DATA_VOLUME_SIZE_GB=100
BOOT_SNAPSHOT_NAME=gpu-rent-boot-ok
CIVITAI_API_TOKEN=
CIVITAI_API_HOST=civitai.red
MODELS_MANIFEST=
LOCAL_MODELS_DIR=
LOCAL_WILDCARDS_DIR=
LOCAL_WORKFLOWS_DIR=
LOCAL_OUTPUT_DIR=
EXTENSIONS_MANIFEST=
GIT_TOKEN=
AUTOCOMPLETE_ENABLED=true
AUTOCOMPLETE_GITHUB_REPO=DominikDoom/a1111-sd-webui-tagcomplete
AUTOCOMPLETE_GITHUB_PATH=tags/danbooru.csv
AUTOCOMPLETE_GITHUB_REF=main
AUTOCOMPLETE_FILENAME=danbooru.csv
SWARMUI_LOCAL_PORT=17801
DEFAULT_FLAVOR_ID=
FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
FLAVOR_FALLBACK=true
DEFAULT_SPOT=true
KEEP_FLOATING_IP=false
IDLE_MINUTES=30
IDLE_GRACE_MINUTES=45
PULL_OUTPUT=false
NOTIFY_READY=true
```
Flavor id в git не хардкодить. `FLAVOR_PREFERENCE` — ярлыки; реальные uuid резолвятся в сегменте. `FLAVOR_FALLBACK=false` — только `DEFAULT_FLAVOR_ID` / `--flavor`, без следующего в списке.
Application credential для idle-killer CLI создаёт при bootstrap и кладёт на VM; в `.env` ноутбука его можно не дублировать.
Пути локальных папок по умолчанию — каталоги в корне приложения: `Models/`, `Wildcards/`, `CustomWorkflows/`, `Output/`.
## State
`~/.gpu-rent/state.json` — ids, фаза (`idle` / `ready_cloud` / `ready_tunneled` / …), flavor, timestamps create/unshelve (для окна 24 ч). Без паролей. Lockfile, чтобы два `up` не создали два сервера.
## Ожидание готовности
Потолок ~20–40 мин на первый bootstrap, ~510 мин на unshelve:
1. Nova `ACTIVE`
2. TCP 22
3. SSH: cloud-init или `systemctl is-active swarmui`
4. HTTP `http://127.0.0.1:7801` **на VM** (через SSH), не путать с локальным 7801
5. Для MCP/API / toast — дождаться Idle backend.
## Windows
Пути через `Path.expanduser`. Венв: `.venv\Scripts\activate`. Ключ генерирует CLI: `~/.gpu-rent/id_ed25519`.
`NOTIFY_READY`: toast через WinRT / `win10toast` (что заработает на 10/11 без админ-прав) + системный звук. Если toast недоступен — только звук и лог, не падать.
+57
View File
@@ -0,0 +1,57 @@
# Концепция
## Проблема
GPU в облаке дорогой. Веса для генерации картинок — десятки гигабайт. Поднимать сервер и заново качать модели на каждый сеанс долго и бессмысленно. Держать GPU включённым «на всякий случай» ещё дороже.
## Решение
1. Арендовать **прерываемый** облачный сервер Selectel с GPU (~70% дешевле обычного, без SLA).
2. Хранить ОС, бинарники SwarmUI, модели, ComfyUI и результаты на **сетевых дисках**. Они переживают удаление и остановку VM.
3. Локальный CLI создаёт (или восстанавливает) сервер и отдельно, когда нужно, пробрасывает SwarmUI на `localhost:17801`.
4. GPU гасится командой `stop` **или** idle-killer на самой VM (пустая очередь). Диски остаются. Ноут можно закрыть — compute от этого не умирает.
Пользователь открывает браузер на `http://127.0.0.1:17801`, переключает Cursor MCP на этот URL или бьёт в HTTP API SwarmUI. Локальный SwarmUI на `7801` не трогаем.
## Что входит в продукт
- Одна GPU-сессия на проект gpu-rent (тег), не «по экземпляру CLI».
- Интерактивный запуск и запуск с флагами. Preemptible по умолчанию (`--no-spot` если нужна гарантия).
- Автовосстановление после прерывания хостером (`EXPIRED``unshelve`).
- Idle-killer на VM и `stop` с любой машины, где есть `.env` — даже без туннеля.
- Первый seed моделей с Civitai по манифесту (если есть API-токен); иначе дефолт SwarmUI.
- Push из `./Models`, `./Wildcards`, `./CustomWorkflows`, если папки не пустые; optional pull `./Output`.
- Первый clone git-реп расширений SwarmUI и ComfyUI nodes из `extensions.yaml`.
- Autocomplete: word-list в `Data/Autocompletions` до старта UI, на каждом `up` проверка новой версии.
- `doctor` до create; фоллбек flavor; `hold` для idle-killer; toast когда backend Idle; `open` на 17801.
- `status`: диск, ₽/час, окно preempt 24 ч, состояние killer.
- Snapshot boot-диска после первого удачного bootstrap.
## Что не входит (пока)
- Несколько одновременных GPU из этого CLI.
- Мультипользовательский доступ и публичный URL SwarmUI.
- Зеркало локального SwarmUI целиком, S3 как источник правды, команда `generate` в CLI.
- Автопатч `mcp.json` в Cursor (только печать сниппета).
- Облачный биллинг-дашборд Selectel: оценка ₽/час перед create (цифры с spike / панели, не отдельный биллинг API).
## Модель стоимости (как есть у Selectel)
| Ресурс | Пока VM работает | После прерывания / удаления compute |
| --- | --- | --- |
| vCPU, RAM, GPU, локальные диски | тарифицируются | с следующего часа — нет |
| Сетевые диски | тарифицируются | **тарифицируются всегда** |
| Публичный IP / публичная подсеть | тарифицируются | **тарифицируются, пока не удалены** |
Прерываемый сервер живёт **не больше 24 часов** с момента создания или restore и может быть остановлен в любой момент. Это не AWS-spot с «может повезти неделями»: суточный потолок — часть контракта.
Сетевой диск тарифицируется всегда: даже 100 GB — постоянный месячный расход при нуле сессий. Перед первым запуском CLI должен сказать это вслух.
Ориентир публичных цен (не договор): облачный сервер с 2×RTX 4090 у Selectel начинается примерно от 210 ₽/час за обычный тариф; preemptible в среднем на ~70% ниже. Точную цифру для выбранного flavor считать в калькуляторе/панели на этапе spike.
## Принципы
- **Не оставлять GPU без хозяина.** Падение CLI и сон ноутбука не гасят compute сами — это делает idle-killer на VM и команда `stop`. State на диске + reconcile, если killer не сработал.
- **Не ставить стек с нуля каждый раз.** Cloud-init «apt + dotnet + git clone SwarmUI» на каждый preempt съедает дешёвые часы. Первый bootstrap — на сетевой boot-диск, дальше `unshelve` или create from volume.
- **Не форматировать диск дважды.** Маркер файловой системы, никогда `mkfs` «если blkid не ответил».
- **SwarmUI не торчит в интернет.** На VM порт только на loopback, снаружи — SSH.
+43
View File
@@ -0,0 +1,43 @@
# Принятые решения
Зафиксировано 21 августа 2026. Менять только явно.
| Тема | Решение |
| --- | --- |
| Аудитория | Личный инструмент, репозиторий можно показать другим: MIT, без аккаунтных id и без имён локальных чекпоинтов |
| Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть |
| Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем |
| Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена |
| Манифест моделей | `~/.gpu-rent/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` |
| Расширения | `~/.gpu-rent/extensions.yaml`: git-репы `swarmui``src/Extensions`, `comfy` → DLNodes. Клон на первом bootstrap до старта UI. Пустой файл — стоковый SwarmUI |
| Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) |
| Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель |
| Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` |
| Selectel сейчас | Аккаунт есть, GPU-квоту не проверяли — скорее 0. Сначала тикет в поддержку |
| Образ ОС | **Ubuntu 24.04 LTS GPU Driver 580** (Selectel, **без Docker**). SwarmUI нативно: `launch-linux.sh` + systemd. Нет образа 580 — фоллбек 24.04 Driver 535 без Docker |
| Floating IP | `KEEP_FLOATING_IP=false`: выделить на `up`, удалить на `stop` |
| Idle-killer | 30 минут пустой очереди генерации. Открытый браузер не продлевает жизнь |
| Льгота после boot | Killer молчит во время clone/seed/push, hold, качалки в UI SwarmUI, пока backend не Idle, и 45 мин после ACTIVE/unshelve |
| `up` / `tunnel` | Разделены: `up` только облако, `tunnel` отдельно. Ctrl+C на туннеле не удаляет VM |
| Data-диск | Старт **100 GB**, рост через resize вверх (вниз Selectel не умеет) |
| SSH | CLI генерирует `~/.gpu-rent/id_ed25519` без passphrase и сам регистрирует keypair |
| Локальный порт | **17801** |
| MCP-конфиг Cursor | Только сниппет в stdout, файл не трогаем |
| `ready` для API | Ждать Idle backend, не только HTTP UI |
| Output | Pull в `./Output` **опционален** (`PULL_OUTPUT=false`). Новые файлы с VM, с сервера не удаляем |
| Wildcards / workflows | Как Models: пустые папки не трогаем; появился файл — push на `up` |
| Idle hold | `gpu-rent hold [--minutes N]` сдвигает дедлайн killer. Качалка моделей в UI SwarmUI тоже считается занятостью (сигнал уточнить на spike) |
| Doctor | `gpu-rent doctor` без create: Keystone, квота GPU, flavor, диск, Civitai token+`.red`, манифесты, SSH-ключ |
| Flavor fallback | Список предпочтений (4090 → A5000 → …). Нет первого — предложить следующий с ценой, `--yes` берёт первый доступный из списка |
| Готово | Windows toast + звук (и лог), когда backend Idle |
| Status | Диск used/free, ₽/час, сколько до 24 ч preempt, состояние killer / hold |
| Boot snapshot | После первого удачного bootstrap — один snapshot boot volume `gpu-rent-boot-ok`; следующие create могут идти с него |
| Open | `gpu-rent open` / флаг после `tunnel`: браузер на 17801 |
Следствия, которые из этого вытекают и тоже считаются принятыми:
1. **Туннель ≠ жизнь GPU.** `stop` и idle-killer не зависят от того, открыт ли SSH с ноутбука.
2. **Локальный SwarmUI на 7801 не трогаем.** Туннель по умолчанию на **17801** (на VM по-прежнему 7801 на loopback).
3. **`Ctrl+C` на туннеле не удаляет VM.** Иначе «закрыл ноут» невозможно. Чтобы убить GPU — `gpu-rent stop` или простой.
4. Idle-killer на VM **не может** быть `shutdown -h`: у Selectel остановленная изнутри VM часто продолжает тарифицировать ресурсы. Нужен вызов OpenStack: удалить **этот** compute, диски оставить.
5. Для этого на VM — OpenStack **application credential** с правом удалить/shelve сервер в проекте (не пароль владельца аккаунта). Компрометация SwarmUI в худшем случае сносит GPU-сессию, а не создаёт новые дорогие машины, если роль без `compute:create`.
+68
View File
@@ -0,0 +1,68 @@
# Расширения: git-репы на первый bootstrap
Срабатывает вместе с первым диском и первым инстансом, **до** первого старта SwarmUI (C# extensions компилируются при запуске). Повторный `up` на уже засеянный диск только догоняет новые строки манифеста (идемпотентный `git fetch`).
Два разных мира — не путать:
| Вид | Куда клонировать | Что это |
| --- | --- | --- |
| `swarmui` | `/mnt/swarm_data/Extensions/<dir>``/opt/swarmui/src/Extensions/<dir>` | C# extension SwarmUI (класс `: Extension`). После рестарта подхватывается сам |
| `comfy` | `/mnt/swarm_data/DLNodes/<dir>``…/ComfyUIBackend/DLNodes/<dir>` | Custom nodes ComfyUI (Python). `requirements.txt` ставит SwarmUI/Comfy при первом старте backend |
Пустой манифест или его нет — ничего не клонируем, SwarmUI как из коробки. Это не связано с Civitai-токеном.
## Манифест
Пример: [`extensions.example.yaml`](../extensions.example.yaml). Рабочая копия: `~/.gpu-rent/extensions.yaml` (или `EXTENSIONS_MANIFEST`).
```yaml
swarmui:
- url: https://github.com/example/SwarmUI-SomeExt.git
ref: main # ветка, тег или commit SHA; по умолчанию HEAD default branch
dir: SomeExt # имя папки; по умолчанию последний сегмент URL без .git
comfy:
- url: https://github.com/example/ComfyUI-Something.git
ref: v1.2.0
```
В git репозитория gpu-rent не коммитить рабочий список с приватными URL. В примере — вымышленные репы.
## Когда в жизненном цикле
Idle-killer ещё не вооружён. Git нужен на VM (`apt` в bootstrap, если нет в образе).
1. Маркер FS data volume.
2. **Clone extensions** (этот документ). Маркер `/mnt/swarm_data/.gpu-rent-extensions-seeded` после успешного прохода всех строк.
3. Civitai-seed моделей ([models.md](models.md)), если есть токен.
4. Первый `systemctl start swarmui`: видит `src/Extensions`, собирает их; ComfyUI ставит pip из DLNodes.
5. Backend Idle → idle-killer.
Порядок 2→3 важен: расширения до старта UI, модели тоже до старта (чтобы не качать дефолт SwarmUI).
Позже: `gpu-rent seed-extensions` — те же clone/fetch на живом диске, затем **рестарт** SwarmUI (иначе новый C# extension не подхватится). CLI предупреждает и делает `systemctl restart swarmui`, если VM жива.
## Clone на VM
- `git clone --recurse-submodules`.
- Если `ref` — ветка/тег: `--depth 1` + `checkout`.
- Если `ref` похож на SHA: fetch этого commit (shallow может не хватить).
- Каталог уже есть и `remote origin` совпадает с `url``fetch` + `checkout ref`, не `rm -rf`.
- Каталог есть, origin другой → fail этой строки, не молча перезаписывать.
Публичный HTTPS без секретов. Приватный репозиторий:
- `GIT_TOKEN` в `.env` (GitHub: `x-access-token`; GitLab: `oauth2` / PAT). CLI подставляет в URL **только на VM**, не пишет токен в yaml и не в cloud-init.
- Либо отдельный deploy key `~/.gpu-rent/git_deploy` — v1.1, не обязательно сейчас.
Не использовать `git://`. Только `https://` и `git@` + ключ.
## Сбой
Одна строка упала (404, auth, сеть) — остальные можно продолжить; `up` в конце ненулевой, маркер seeded не ставить, повтор доклонирует. Не оставлять полупустой `.git`.
## Конфиг
```env
EXTENSIONS_MANIFEST= # пусто = ~/.gpu-rent/extensions.yaml
GIT_TOKEN= # опционально, для приватных https
```
+37
View File
@@ -0,0 +1,37 @@
# Локальные папки приложения
Все живут в корне gpu-rent. Одно правило: **пусто → ничего не синхронизируем**. Появился файл → на `up` (и по отдельной команде) едет инкремент по SHA256. С сервера лишнее не удаляем.
| Локально | На VM | Направление | Обязательно |
| --- | --- | --- | --- |
| `Models/` | `/mnt/swarm_data/Models/` | push | да, если не пусто; см. [models.md](models.md) |
| `Wildcards/` | `/mnt/swarm_data/Data/Wildcards/` | push | да, если не пусто |
| `CustomWorkflows/` | `/mnt/swarm_data/CustomWorkflows/` → bind в ComfyUI CustomWorkflows | push | да, если не пусто |
| `Output/` | `/mnt/swarm_data/Output/` | **pull**, опционально | нет; флаг `PULL_OUTPUT` |
Веса, картинки, воркфлоу в git не коммитить (`.gitignore` + `.gitkeep`).
## Push (Models / Wildcards / CustomWorkflows)
Как у моделей: нет содержимого кроме README/`.gitkeep` — skip. Новый или изменённый файл — SFTP. Метадата рядом с весами едет парой. Команда на все три дерева: `gpu-rent push`.
Wildcards: `.txt` (одна строка — один вариант), как в доке SwarmUI `Data/Wildcards`.
CustomWorkflows: JSON воркфлоу Comfy/Swarm. После заливки на живой UI — refresh, если API это умеет; иначе рестарт контейнера только если без refresh файлы не видны (spike).
Пока идёт push крупных файлов — idle-killer на паузе.
## Pull Output (опционально)
`PULL_OUTPUT=false` по умолчанию. Включить: `PULL_OUTPUT=true` или `gpu-rent pull-output`.
Когда включено:
- на **`stop`** — забрать с VM файлы, которых нет локально или чей sha другой;
- на **`up`**, если VM уже жива и флаг включён — то же (подтянуть картинки с прошлой сессии, не дожидаясь stop).
Пустой локальный `Output/` — норма. С сервера ничего не стираем. Не тащить гигабайты видео без нужды: только новые/изменённые.
`gpu-rent stop --no-pull` — выключить pull на этот раз.
Отдельная команда всегда: `gpu-rent pull-output` (нужны живая VM и SSH).
+174
View File
@@ -0,0 +1,174 @@
# Модели
Два источника на data volume, они **складываются**, не заменяют друг друга:
1. **Civitai-seed** (ниже) — один раз на пустой диск, если есть API-токен и манифест.
2. **Папка `Models/` в корне gpu-rent** — на каждом `up` выгрузить на VM только то, что изменилось. `Wildcards/` и `CustomWorkflows/` — то же правило, см. [local-folders.md](local-folders.md).
Локальный SwarmUI на `7801` по-прежнему не зеркалируем. Только дерево `./Models` приложения.
## Локальная папка → сервер
Каталог: `<корень приложения>/Models/` (или `LOCAL_MODELS_DIR`). Раскладка как у SwarmUI: `Stable-Diffusion/`, `Lora/`, `VAE/`, … Описание: [`Models/README.md`](../Models/README.md). Веса в git не коммитятся.
Правило одно:
| Локально | На `up` |
| --- | --- |
| Папки нет, или только `.gitkeep` / README | **Ничего не выгружаем**, тихий лог |
| Появился файл весов (`.safetensors` / `.ckpt` / …) | Залить его **вместе с соседней метадатой** |
| Веса уже на VM с тем же SHA256 | Пропуск (нет «обновления») |
| Веса изменились | Залить веса заново и актуальную метадату |
Метадата — файлы рядом с тем же stem: `.json`, `.civitai.json`, `.swarm.json`, превью (`.preview.png` / `.png` / `.webp`), если лежат в той же папке. Веса без sidecar тоже можно залить (предупреждение в лог: «метадаты нет»). Метадату без весов **не** шлём отдельно.
С диска в облаке **ничего не удаляем**. Civitai-seed и то, чего нет в `./Models`, остаётся.
Технически: SHA256 локального файла vs тот же относительный путь под `/mnt/swarm_data/Models/`. SFTP/paramiko, resume `.partial`. Preflight свободного места. Если залили и SwarmUI уже жив — refresh списка моделей.
Повторный `up` при живой VM тоже смотрит папку (положил новую LoRA дома → следующий `up` её увезёт). Явно: `gpu-rent push-models` или `gpu-rent push` (все локальные деревья).
Пока идёт аплоад, idle-killer на паузе.
---
# Civitai: первый seed
Срабатывает **один раз**: пустой data-диск + первый инстанс. Повторный `up` к уже засеянному диску строки манифеста с совпавшим SHA256 пропускает.
## Две ветки
| Условие | Что происходит |
| --- | --- |
| В `.env` есть `CIVITAI_API_TOKEN` **и** в манифесте есть хотя бы одна запись | Скачать перечисленные модели с Civitai **на VM** (не через ноут). Рядом положить метаданные. **Не** качать дефолтный чекпоинт установщика SwarmUI |
| Токена нет, или манифест пуст/отсутствует | Обычный первый запуск SwarmUI: пусть ставит свою стандартную модель. В лог — почему Civitai-seed пропущен |
Токен без манифеста = предупреждение и ветка SwarmUI default, не «пустой диск без моделей».
ComfyUI в `dlbackend` качается всегда. Речь только о **модели весов** установщика SwarmUI, не о backend.
## Когда в жизненном цикле
Пока idle-killer ещё **не вооружён**:
1. Создать/примонтировать data volume, маркер FS.
2. Clone расширений из `extensions.yaml` ([extensions.md](extensions.md)).
3. Autocomplete word-list ([autocomplete.md](autocomplete.md)): скачать, если нет; иначе проверить GitHub sha.
4. Если ветка Civitai: залить на VM манифест (scp, не cloud-init — токен не должен светиться в Nova user_data).
5. Скачать файлы в `/mnt/swarm_data/Models/…`, проверить SHA256, записать sidecar.
6. Маркер `/mnt/swarm_data/.gpu-rent-models-seeded`.
7. **Push** из корневой `Models/` приложения (новые/изменённые, см. выше).
8. Первый старт SwarmUI (папки чекпоинтов уже не пустые → установщик не предлагает свой SD; Extensions и Autocompletions уже на месте).
9. Ждать backend Idle, затем вооружить idle-killer.
Качание нескольких GB легко длиннее льготы 45 минут. Поэтому killer не живёт, пока не закончился seed.
Позже тот же код: `gpu-rent seed-models` — докачать новые строки манифеста на уже живой диск (идемпотентно).
## Манифест
Пример в репозитории: [`models.example.yaml`](../models.example.yaml). Рабочая копия: `~/.gpu-rent/models.yaml` (или путь `MODELS_MANIFEST`).
Идентификатор — **Civitai model version id**, не имя файла и не «последняя версия модели» (она плывёт).
```yaml
# ~/.gpu-rent/models.yaml
checkpoint:
- version_id: 2514310
lora:
- version_id: 123456
vae:
- version_id: 789012
embedding:
- version_id: 111
controlnet:
- version_id: 222
upscaler:
- version_id: 333
```
Допустимо вместо `version_id` поле `url` страницы модели/версии — CLI вынимает id. Хосты `civitai.com`, `civitai.red`, `civitai.green` равнозначны (зелёный редиректит на `.com`). Несколько файлов у версии: берём `files[]` с `"primary": true`, иначе SafeTensor / fp16.
Типы манифеста → каталоги SwarmUI (`ModelRoot` = `Models`):
| Ключ yaml | Civitai `model.type` (ориентир) | Каталог на диске |
| --- | --- | --- |
| `checkpoint` | Checkpoint | `Models/Stable-Diffusion` |
| `lora` | LORA, LoCon, DoRA | `Models/Lora` |
| `vae` | VAE | `Models/VAE` |
| `embedding` | TextualInversion | `Models/Embeddings` |
| `controlnet` | Controlnet | `Models/controlnet` |
| `upscaler` | Upscaler | `Models/upscale_models` |
| `clip` | — | `Models/clip` |
Тип в yaml задаёт **куда класть**, даже если Civitai назвал иначе. Если API-тип совсем другой — предупреждение, кладём по yaml.
В git не коммитить рабочий манифест с личными чекпоинтами. В примере — вымышленные id.
## Civitai API (.com / .red)
С апреля 2026 у Civitai два входа в **одну** базу ([анонс](https://civitai.com/articles/28369/two-front-doors-civitaicom-civitaired-and-whats-next)):
| Домен | Что видно |
| --- | --- |
| `civitai.com` | SFW / PG (бывший green). NSFW-карточки с этого хоста часто **не находятся** (404) или без `files[]` |
| `civitai.red` | Полный каталог, включая NSFW |
| `civitai.green` | 301 на `.com` |
REST не раздвоился: те же `/api/v1/...` на обоих хостах, один аккаунт, один API-токен. Официальная дока по-прежнему пишет base `https://civitai.com/api/v1/`, но там же: ответы с «зелёного» домена и из части регионов **молча режутся до SFW**. Нам нужны **любые** `version_id` из манифеста → **дефолтный API-хост `civitai.red`**.
Клиент:
1. Доверять только `civitai.com`, `civitai.red` и их поддоменам. Токен на чужой хост (CDN после редиректа) **не** отправлять.
2. URL из yaml нормализовать: вытащить `modelVersionId` / `/models/{id}?modelVersionId=` с любого из трёх доменов.
3. Метаданные: `GET https://{CIVITAI_API_HOST}/api/v1/model-versions/{id}` с `Authorization: Bearer`.
4. Если 404, пустой `files[]` или нет `downloadUrl`**один** повтор на второй хост (`.red``.com`). Не крутить бесконечно.
5. Качать: `GET https://{хост_успешного_GET}/api/download/models/{id}` с Bearer, следовать редиректам на presigned S3. Имя — `Content-Disposition`. `downloadUrl` в JSON часто указывает на `.com` — для NSFW его переписать на хост, который отдал файлы.
6. Поле `nsfwLevel` / `model.nsfw` в sidecar сохранить как есть, фильтром не пользоваться.
Без токена скачивание часто 401 даже на публичные веса. `requireAuth: true` в mini-endpoint — токен обязателен.
После ответа version:
1. Primary file, `hashes.SHA256`, `sizeKB`.
2. Preflight места: сумма `sizeKB` × 1.2 < свободно на data volume.
3. Качать на VM. Прогресс в логах.
4. Сверить SHA256. Несовпадение — удалить файл, fail строки.
Рядом с весами:
| Файл | Содержимое |
| --- | --- |
| `{name}.safetensors` | веса |
| `{name}.civitai.json` | сырой JSON version (+ при желании `GET /models/{modelId}` для автора/тегов) |
| `{name}.swarm.json` | маппинг под SwarmUI: `name`, `title`, `description`, `trigger_phrase` из `trainedWords`, `author`, `license`, `tags` |
Превью-картинки в base64 не обязательны в v1 (раздувают json). SwarmUI подтянет превью сам, если умеет по civitai id.
Не класть API-токен в sidecar и не в Output.
## Пропуск дефолта SwarmUI
Когда seed успешен (есть хотя бы один checkpoint **или** пользователь явно согласен на «только LoRA»):
- первый старт SwarmUI — **после** появления файлов в `Models/`;
- на spike проверить: установщик не качает Z-Image / SDXL Base, если `Stable-Diffusion` не пуст;
- если всё же качает — выключить автозагрузку настройкой/LaunchArg (имя ключа зафиксировать на spike, не выдумывать сейчас).
Если в манифесте нет ни одного `checkpoint` — предупреждение «без базовой модели генерация может не стартовать», не жёсткий fail.
## Сбой
- 401 → «токен неверный или скачивание требует логина».
- 404 на обоих хостах → версии нет / снята / не published.
- Early access / 403 → строка fail, остальные можно продолжить; маркер seeded не ставить.
- Обрыв сети — resume по `.partial` + повтор SHA256.
## Конфиг
```env
CIVITAI_API_TOKEN=
CIVITAI_API_HOST=civitai.red # полный каталог; civitai.com = только SFW
MODELS_MANIFEST= # пусто = ~/.gpu-rent/models.yaml
LOCAL_MODELS_DIR= # пусто = <корень приложения>/Models
```
+13
View File
@@ -0,0 +1,13 @@
# Открытые вопросы
Пока пусто по продуктовым развилкам — см. [decisions.md](decisions.md).
Осталось подтвердить на **spike**, не в споре:
- Точный URL/JSON «очередь пуста», «backend Idle» и «идёт скачивание модели в UI» у твоей версии SwarmUI.
- Реальная цена 100 GB сетевого диска в выбранном сегменте и цена 1×4090 preemptible ₽/час.
- Имя GPU-образа (без Docker) и flavor id в твоём пуле (в git не класть); какие из списка фоллбека реально есть.
- Хватает ли роли application credential «только delete этого сервера», или Selectel отдаёт более широкую роль.
- Snapshot attached boot volume после bootstrap: время и можно ли сразу create from snapshot.
Если spike покажет, что 45 минут льготы мало на первую установку ComfyUI — поднять `IDLE_GRACE_MINUTES`, не отключать killer.
+94
View File
@@ -0,0 +1,94 @@
# Roadmap
Код не начинать, пока не закрыты **0.0** (квота) и **0** (spike). Без GPU в квоте остальное — театр.
## 0.0 Квота GPU
Аккаунт есть, квота скорее 0.
- [ ] В панели: Облачные серверы → квоты / создание сервера с GPU
- [ ] Тикет в поддержку Selectel: 1× GPU (целевой: RTX 4090 24 GB или что дадут в пуле), проект gpu-rent
- [ ] Сервисный пользователь с правом только на этот проект
Пока квота 0, можно писать каркас CLI с dry-run и моками, но не считать этап 0 закрытым.
## 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`)
Заметки — `docs/spike-notes.md`, без секретов и личных имён файлов моделей.
## 1. Каркас
- [x] `pyproject.toml`, пакет `gpu_rent`, MIT
- [x] config / state / Typer по [cli.md](cli.md): `doctor` / `dry-run` / `status` / `open` живые; остальные команды есть и честно говорят, что mutating ещё нет
- [x] Windows: `python -m gpu_rent`
- [x] [setup.md](setup.md) — квота, сервисный пользователь, RC, Civitai
## 2. OpenStack-клиент
- [x] Token через openstacksdk (`authorize`), inventory flavors/квота/volume type
- [ ] Сеть find-or-create, SG, FIP, volumes, server, unshelve
- [ ] Preemptible tag 2.72, BDM boot+data, `delete_on_termination=false`
- [x] Flavor fallback по `FLAVOR_PREFERENCE` (ранжирование; create ещё нет)
- [x] `doctor`: Keystone, квота, flavor, диск, Civitai `.red`, манифесты
- [x] Тесты с моками / без облака
## 3. Сессия без туннеля
- [ ] `up` / `stop` / `status` / reconcile
- [ ] `status`: диск used/free, ₽/час, TTL preempt 24 ч, killer/hold
- [ ] Второй `up` не создаёт второй GPU
- [ ] `resize-data` вверх
- [ ] Генерация `~/.gpu-rent/id_ed25519` + keypair при первом `up`
- [ ] `seed-models` на живом диске (идемпотентно, SHA256)
- [ ] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/`
- [ ] `pull-output` и `PULL_OUTPUT` на `stop` / повторный `up`
- [ ] `seed-extensions` + restart контейнера
- [ ] `destroy` только с флагом
## 4. Bootstrap SwarmUI + idle-killer
- [ ] Идемпотентный first-boot, маркер FS
- [ ] Bind-mounts data volume → `/opt/swarmui`, systemd `swarmui`, auth
- [ ] Clone `extensions.yaml` в Extensions / DLNodes до старта UI
- [ ] Autocomplete: danbooru.csv + Settings.fds; на каждом up — GitHub sha
- [ ] 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
## 5. Туннель
- [ ] `gpu-rent tunnel` → 17801, Ctrl+C не делает `stop`
- [ ] `gpu-rent open` / `tunnel --open`
- [ ] EXPIRED → unshelve + reconnect, пока туннель жив
- [ ] Сниппет MCP в stdout
## 6. UX
- [ ] Живой список flavors, `--no-spot`, печать фоллбека и цены
- [ ] Оценка ₽, предупреждение про диск 24/7 и про idle-killer
- [ ] `NOTIFY_READY`: Windows toast + звук при backend Idle
- [ ] Пользовательский README поверх `docs/`
## Вне скоупа v1
- `generate` в CLI, S3 как источник моделей
- Автоправка Cursor `mcp.json`
- Несколько GPU, публичный URL, Terraform как основной путь
- Tailscale (туннеля достаточно; к сети с телефона не подключались)
+154
View File
@@ -0,0 +1,154 @@
# Контракт с Selectel
Ниже — то, на что опирается код. Не выдуманные URL и не «примерно OpenStack».
## Иерархия размещения
| Термин | Пример | Смысл |
| --- | --- | --- |
| Аккаунт | число в углу панели | Domain в Keystone (`OS_USER_DOMAIN_NAME`) |
| Проект | uuid / имя | Scope токена |
| Пул (region) | `ru-7`, `ru-9` | Куда ходит compute API: `https://<pool>.cloud.api.selcloud.ru/compute/v2.1/` |
| Сегмент пула (AZ) | `ru-7a`, `ru-9a` | Куда ставятся VM и **диски**. Должны совпадать |
`SELECTEL_REGION=ru-1` из старого черновика недостаточно. Flavor, image, volume type (`fast.ru-7a`) и availability zone берутся из **одного сегмента**. Диск из `ru-7a` нельзя приаттачить к VM в `ru-9a`.
GPU есть не во всех сегментах. Перед create — [матрица доступности GPU](https://docs.selectel.ru/en/cloud-servers/create/gpus/) и живой `flavor list` в выбранном AZ.
## Авторизация
Статический API-ключ панели (`X-Token`) **не управляет** объектами OpenStack (серверы, диски, сети).
Нужен **сервисный пользователь** с правом на проект.
1. CLI хранит: account id, username, password, project id/name, pool, pool segment.
2. Запрос токена: `POST https://cloud.api.selcloud.ru/identity/v3/auth/tokens` (password + project scope).
3. Заголовок ответа `X-Subject-Token` — IAM-токен. TTL **24 часа**.
4. Дальнейшие вызовы: `X-Auth-Token` на endpoints из service catalog (compute, volume, network, image).
Токен нельзя один раз прописать в `.env` и забыть. Клиент обновляет его до истечения. Совпадение «24 часа токена» и «24 часа preemptible» легко пропустить: длинная сессия умрёт с 401, VM при этом останется.
Учётные данные сервисного пользователя — секрет. Роль — только нужный проект (compute + block storage + networking), не владелец аккаунта.
Практичный клиент: `openstacksdk.Connection` с теми же полями, что в RC-файле панели (`OS_AUTH_URL`, `OS_PROJECT_ID`, `OS_USERNAME`, `OS_PASSWORD`, `OS_USER_DOMAIN_NAME`, `OS_REGION_NAME`).
## GPU
Актуальный список в панели и в документации Selectel. Для генерации картинок из черновика плана:
| В старом плане | В облаке Selectel |
| --- | --- |
| RTX 3090 | **нет** |
| RTX 4090 | 24 GB и 48 GB |
| A100 40 GB | есть |
Ближайшие бытовые аналоги 3090: **A5000** (как 3080, 24 GB) или **RTX 4090 24 GB**.
Фиксированный flavor связывает GPU + vCPU + RAM. Три независимых вопроса «какая карта / сколько RAM / сколько CPU» допустимы только если CLI создаёт **custom GPU flavor** через Compute API (`gpu: "4090:1"` и т.п.). Для MVP — живой список фиксированных flavors в сегменте.
Custom GPU flavor создаётся только через API, не через `openstack flavor create` в CLI, и живёт в конкретном пуле/проекте.
### Фоллбек, если предпочтительный GPU занят
Квота «есть GPU» ≠ flavor свободен в сегменте. `up` идёт по списку предпочтений (`FLAVOR_PREFERENCE`), не падает на первом `disabled` / нет capacity.
Дефолт (подстроки в имени или extra spec, живые id — из `flavor list` на spike):
1. RTX 4090 24 GB
2. RTX 4090 48 GB
3. A5000
4. A100 40 GB
Поведение:
- Предпочтительный доступен — берём его, печатаем ₽/час.
- Нет — показать следующий доступный из списка с ценой и отличием (VRAM / ₽). Интерактив: подтвердить. `--yes` — взять первый доступный из списка без вопроса, цену всё равно напечатать.
- Ни один из списка не доступен — стоп с текстом «нет GPU в сегменте», не молчаливый CPU-flavor.
- Явный `--flavor <id>` бьёт список: нет этого id → ошибка, без автофоллбека.
## Образы
Не ставить драйверы NVIDIA руками на каждом boot и не брать Windows.
Канонический boot-образ:
**`Ubuntu 24.04 LTS 64-bit GPU Driver 580`**
Без слова **Docker** в имени. SwarmUI и Comfy крутятся на хосте (`launch-linux.sh`, systemd), не в контейнере.
| Вариант | Почему нет |
| --- | --- |
| Тот же образ **с Docker** | Лишний слой, nvidia-ctk, `docker build` на первом bootstrap. Для одной VM не нужен |
| Windows Server | Лицензия, хуже стек под SwarmUI/Comfy |
| Ubuntu без «GPU optimized» | Сами ставите драйвер + kernel pin — съедает preempt-часы |
| Ubuntu 22.04 GPU | Работает, но старше ядро |
| Driver **535** | Для 4090/свежего PyTorch лучше host driver **580** |
| Data Science / Analytics VM | Jupyter/conda, 22.04, ядро не так же pinned |
Фоллбек, если в Glance пула нет 580: `Ubuntu 24.04 LTS 64-bit GPU Driver 535` (тоже без Docker). Образ «… Docker» — только если не-Docker GPU-образа в пуле нет; `doctor` предупредит.
Имена уточнять через Glance (`image list`) — id плавают, в git не класть. Ядро уже pinned: `apt upgrade` ядра ломает `nvidia-smi`. Не трогать `linux-image-*` / `nvidia-*` в unattended-upgrades.
Образ без GPU optimization = сами ставите драйверы, это не наш путь.
## Прерываемые серверы
Документация: [preemptible servers](https://docs.selectel.ru/en/cloud-servers/about/preemptible-servers/), [create](https://docs.selectel.ru/en/cloud-servers/create/create-preemptible-server/), [restore](https://docs.selectel.ru/en/cloud-servers/manage/restore-preemptible-server/).
- Создаются тегом `preemptible` и микроверсией compute **2.72** (`openstack server create --tag preemptible --os-compute-api-version 2.72`).
- Могут быть остановлены в любой момент **в пределах 24 часов** после create или restore.
- При прерывании сервер **не удаляется**, статус **`EXPIRED`**.
- Restore: `openstack server unshelve <server>`. После restore снова preemptible, снова 24 часа.
- Сетевой boot-диск: VM поднимается в том состоянии, в каком остановилась.
- Локальный диск: данные ОС/локали **теряются**, создаётся новая VM из исходного образа.
- SLA облака на preemptible не действует.
- После остановки GPU/CPU/RAM не тарифицируются со следующего часа; сетевые диски и публичные IP — тарифицируются.
Watchdog, который ждёт `SHUTOFF_BY_HOST` и делает delete+create, к Selectel не применим.
## Сеть
Минимальный набор, которого не было в черновике:
1. Приватная сеть + subnet.
2. Router с gateway на внешнюю сеть.
3. Port VM в приватной сети.
4. Security group (если на сети включён port security: без группы трафик **запрещён**).
5. Floating IP (или порт в public subnet) + association.
6. Keypair (публичный ключ пользователя), заведённый на **того же** сервисного пользователя / проекта.
Сеть и router создаются один раз и переиспользуются. Floating IP либо переиспользовать (платим между сессиями), либо выделять на `up` и удалять на `stop`.
Ingress SG: TCP/22 с адреса оператора. 7801 снаружи не открывать.
## Диски
- Сетевой диск создаётся в **том же сегменте**, тип вида `fast.ru-9a` (точное имя — `volume type list`).
- Boot: из GPU-образа, `delete_on_termination=false`.
- Data: пустой, подключается вторым блочным устройством **в том же `server create`**, не отдельным attach после ACTIVE (гонка с cloud-init).
- Удалить VM с приаттаченным диском в OpenStack часто нельзя: detach → wait → delete, либо delete server с корректным BDM.
- Online resize вверх у Selectel есть; вниз — нет. Заложить команду увеличения data-диска, не «создать новый на 100 GB и надеяться».
- После **первого** удачного bootstrap (backend Idle) — один snapshot boot volume `gpu-rent-boot-ok`, если его ещё нет. Следующий create может идти из snapshot, не из сырого GPU-образа. Каждый `up` новый snapshot не плодит. Attached snapshot в OpenStack допустим для v1 (без fsfreeze).
Путь устройства в Linux: чаще virtio (`/dev/vdb`), не `scsi-0Selectel_Volume_<uuid>`. Определять по serial/by-id после проверки, не хардкодить SCSI-имя из другого провайдера.
## Квоты и лимиты
На новых аккаунтах квота GPU часто **0**. Preflight до create:
- квота compute/GPU;
- наличие flavor в сегменте (`OS-FLV-DISABLED:disabled`, extra specs);
- баланс / возможность создать ресурс (ошибка 402/403 — понятное сообщение, не traceback).
Письмо в поддержку — ручной шаг один раз, не часть CLI.
## Теги и имена ресурсов
Чтобы reconcile нашёл сирот без локального state:
- сервер: имя `gpu-rent` (или `gpu-rent-<short>`), теги `gpu-rent`, `preemptible`;
- диски: `gpu-rent-boot`, `gpu-rent-data`;
- snapshot boot: `gpu-rent-boot-ok` (один, не на каждый `up`);
- SG / сеть: `gpu-rent`, если создаём сами.
Не плодить вторую сеть на каждый `up`.
+201
View File
@@ -0,0 +1,201 @@
# Что сделать до первого `gpu-rent up`
Код **не создаёт GPU**, пока не пройден `gpu-rent doctor`. Сначала доступ в облако, квота GPU и ключи. Статический ключ панели **`X-Token` для этого CLI не подходит** — им нельзя управлять серверами и дисками.
Панель: [my.selectel.ru](https://my.selectel.ru).
---
## 1. Python
Нужен **Python 3.11+**. На Windows при установке включи «Add python.exe to PATH».
В корне репозитория:
```powershell
python -m venv .venv
.\.venv\Scripts\activate
python -m pip install -U pip
python -m pip install -e .
```
Проверка:
```powershell
python -m gpu_rent --help
gpu-rent --help
```
---
## 2. Selectel: проект и квота GPU
На новых аккаунтах лимит GPU почти всегда **0**. Без тикета в поддержку `up` создать карту не сможет — это норма, не баг CLI.
### 2.1. Проект
1. Панель → сверху **IAM****Projects** (Проекты).
2. Отдельный проект, например `gpu-rent`. Не клади GPU в общий «мусорный» проект.
3. Скопируй **ID проекта** (uuid). Он же `OS_PROJECT_ID`.
4. **Номер аккаунта** — в правом верхнем углу панели. Он же `OS_USER_DOMAIN_NAME` (domain в Keystone).
### 2.2. Есть ли GPU в квоте
1. **IAM****Projects** → твой проект → вкладка **Quotas and limits** / **Квоты и лимиты****Cloud platform**.
2. Выбери пул и сегмент, где есть GPU (матрица: [GPU availability](https://docs.selectel.ru/en/cloud-servers/create/gpus/)). Для Москвы часто смотрят `ru-7` / `ru-7a`**проверь матрицу**, GPU есть не везде.
3. Строка **GPU** (и при необходимости vCPU/RAM/network volumes). Если лимит 0 — дальше тикет.
Квоту внутри уже выданного лимита можно крутить в панели. **Сам лимит GPU поднимает только поддержка.**
### 2.3. Тикет в поддержку
**Тикеты** в панели (не email вслепую). Лимит увеличивают **на один конкретный проект**.
Текст можно почти копировать:
```text
Прошу увеличить лимит GPU в облачной платформе.
Проект: <имя> (ID: <uuid проекта>)
Пул / сегмент: ru-7 / ru-7a ← подставь свой из матрицы GPU
Нужно: 1× NVIDIA RTX 4090 24 GB (если нет — ближайший аналог в этом сегменте: 4090 48 GB или A5000).
Цель: один прерываемый (preemptible) облачный сервер для персональных сессий генерации, диски сетевые.
Сейчас квота/лимит GPU = 0, создать сервер с GPU нельзя.
```
Пока тикет не закрыт, ставишь CLI и гоняешь `doctor` — он как раз покажет «квота 0, напишите в поддержку».
---
## 3. Ключи OpenStack (это и есть «API key» для gpu-rent)
Нужен **сервисный пользователь** + пароль. CLI сам получает IAM-токен на 24 часа (`X-Auth-Token`). В `.env` токен хранить не надо.
**Не используй:** Профиль → Access → API Keys → `X-Token`. Это статический ключ панели, OpenStack (серверы/диски/сети) он **не** двигает.
### 3.1. Сервисный пользователь
Только владелец аккаунта или роль `iam.admin`. На балансе для роли `member` должно быть хотя бы **100 ₽**.
1. Сверху **IAM****Service users** / **Сервисные пользователи**.
2. **Add service user**.
3. Имя, например `gpu-rent-api`.
4. Пароль: **минимум 20 символов**, сохрани в менеджер паролей. После создания пароль **больше не показывают** — только сброс.
5. Права:
- **Scope: Projects** (не весь аккаунт);
- проект `gpu-rent`;
- роль **`member`** (создание серверов/дисков/сетей). Роль `reader` для `up` не хватит.
6. **Add user**.
Официально: [Add user](https://docs.selectel.ru/en/access-control/manage/add-user/), [авторизация API](https://docs.selectel.ru/en/api/authorization/).
### 3.2. Скачать RC-файл (готовые `OS_*`)
1. **IAM****Service users** → твой пользователь → вкладка **Access**.
2. Блок **RC files**:
- проект `gpu-rent`;
- локация = **пул**, например `ru-7` (это `OS_REGION_NAME`, не сегмент `ru-7a`);
- **Download**.
3. Открой файл (`rc.sh`). Из него в `.env` переносятся:
| Переменная | Откуда |
| --- | --- |
| `OS_AUTH_URL` | обычно `https://cloud.api.selcloud.ru/identity/v3` |
| `OS_USER_DOMAIN_NAME` | номер аккаунта |
| `OS_PROJECT_DOMAIN_NAME` | тот же номер (можно не дублировать в нашем `.env`) |
| `OS_PROJECT_ID` | uuid проекта |
| `OS_USERNAME` | имя сервисного пользователя |
| `OS_PASSWORD` | пароль, который ты сохранил (в RC его часто нет — дописываешь сам) |
| `OS_REGION_NAME` | пул, `ru-7` |
| `GPU_RENT_AZ` | **сегмент** пула, `ru-7a` — в RC его может не быть, смотри матрицу GPU |
Официально: [Configure OpenStack CLI](https://docs.selectel.ru/en/cloud-servers/tools/openstack-cli/configure-openstack-cli/).
### 3.3. Куда класть
Создай каталог и файл **вне git**:
```powershell
mkdir $env:USERPROFILE\.gpu-rent
copy env.example $env:USERPROFILE\.gpu-rent\.env
notepad $env:USERPROFILE\.gpu-rent\.env
```
Вставь значения из RC + пароль + `GPU_RENT_AZ`. Никогда не коммить `.env`.
Проверка без нашего CLI (необязательно):
```powershell
# после pip install python-openstackclient, если хочешь
openstack token issue
openstack flavor list
```
Наш способ: `gpu-rent doctor`.
---
## 4. Civitai API token (модели)
Нужен, если хочешь seed с Civitai по `models.yaml`. Без токена SwarmUI поставит свою дефолтную модель — это допустимо.
1. Войди на [civitai.com](https://civitai.com) (тот же аккаунт, что и для `.red`).
2. [Account settings](https://civitai.com/user/account) → блок **API Keys****Add API key**.
3. Имя, например `gpu-rent`. Токен показывают **один раз**.
4. В `.env`: `CIVITAI_API_TOKEN=...`
5. Хост API по умолчанию **`civitai.red`** (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Один токен на оба домена.
Не клади токен в query-string в скриптах «на память» — в логах светится. CLI шлёт `Authorization: Bearer …` только на `civitai.com` / `civitai.red` / `civitai.green`, не на CDN.
Манифест (не в git со своими id, если не хочешь светить вкусы):
```powershell
copy models.example.yaml $env:USERPROFILE\.gpu-rent\models.yaml
```
`version_id: 0` — заглушка, doctor её игнорирует. Нужен **modelVersionId** из URL, не id карточки модели.
---
## 5. Git-токен (только приватные репы расширений)
Публичные GitHub-репы в `extensions.yaml` клонируются без токена.
Если репа приватная:
1. GitHub → Settings → Developer settings → Personal access tokens.
2. Fine-grained: доступ только к нужным репам, **Contents: Read**.
3. `GIT_TOKEN` в `.env`.
Скопируй шаблон: `extensions.example.yaml``~\.gpu-rent\extensions.yaml`. Пустой файл = стоковый SwarmUI.
---
## 6. SSH
Ключ **не надо** делать руками. CLI создаст `~\.gpu-rent\id_ed25519` без passphrase и зарегистрирует keypair в OpenStack при первом `up`. `doctor` только проверяет, что это получится.
---
## 7. Чеклист перед `doctor`
- [ ] Python 3.11+, `pip install -e .`
- [ ] Проект Selectel, скопирован uuid
- [ ] Тикет на лимит **1× GPU** в нужном сегменте (или квота уже > 0)
- [ ] Сервисный пользователь `member` на этот проект, пароль сохранён
- [ ] RC скачан на **тот же пул**, где GPU
- [ ] `~\.gpu-rent\.env` заполнен (`OS_*` + `GPU_RENT_AZ`)
- [ ] Нет `X-Token` вместо пароля сервисного пользователя
- [ ] (опционально) Civitai token + `models.yaml`
- [ ] На балансе хватает на диск 100 GB **даже когда GPU выключен**
Дальше:
```powershell
gpu-rent doctor
```
Exit 0 — облако отвечает, можно идти к spike / `up`, когда команда появится. Exit 1 — в отчёте причина (часто квота GPU = 0).
`gpu-rent dry-run` печатает план без создания сервера.
+55
View File
@@ -0,0 +1,55 @@
# SwarmUI на GPU-сервере
Репозиторий: [mcmonkeyprojects/SwarmUI](https://github.com/mcmonkeyprojects/SwarmUI).
**Без Docker.** На VM процесс слушает **7801** на loopback (`launch-linux.sh`). На ноутбуке туннель — **17801**.
## Чего нет
- Docker, nvidia-container-toolkit, образа на Docker Hub.
- Порта 7860 (это Automatic1111).
- Зеркала **локального** SwarmUI. С ноутбука едут только деревья приложения: [local-folders.md](local-folders.md). Первый Civitai-seed — [models.md](models.md).
## Bootstrap (один раз на boot volume)
1. Образ Selectel **Ubuntu 24.04 LTS GPU Driver 580** (без Docker в имени, см. [selectel.md](selectel.md)).
2. Git clone SwarmUI в `/opt/swarmui`. .NET — `launchtools/linux-dotnet-install.sh` (SDK 8 и 10, как в доке SwarmUI).
3. Bind-mounts с data volume в дерево `/opt/swarmui` — таблица в [architecture.md](architecture.md).
4. Clone git-расширений из манифеста ([extensions.md](extensions.md)), если файл не пустой.
5. Word-list autocomplete в `Data/Autocompletions` и `DefaultUser.AutoComplete.Source` ([autocomplete.md](autocomplete.md)).
6. Если есть Civitai-токен и манифест моделей: seed весов **до** первого старта SwarmUI; иначе — дефолтная модель установщика.
7. Push непустых `Models/` / `Wildcards/` / `CustomWorkflows/`.
8. systemd unit `swarmui`: `./launch-linux.sh --launch_mode none --host 127.0.0.1 --port 7801`.
9. Авторизация SwarmUI включена, токен в `Data` на диске.
10. systemd unit idle-killer **после** seed и backend Idle.
11. Один snapshot boot volume `gpu-rent-boot-ok`, если ещё нет.
Рестарт UI: `systemctl restart swarmui`, не `docker restart`.
## ComfyUI
Первый запуск качает backend в `/opt/swarmui/dlbackend` (это bind на data volume). Иначе каждый recreate потеряет часы.
Пока CUDA/ComfyUI поднимаются, UI уже может отвечать. Для MCP и `/API/` — рано: `ready` после Idle backend (подтвердить на spike).
## Доступ с ноутбука
Пока запущен `gpu-rent tunnel`:
| Что | URL |
| --- | --- |
| UI | `http://127.0.0.1:17801` |
| HTTP API | `http://127.0.0.1:17801/API/` |
| MCP | `http://127.0.0.1:17801/mcp` |
Локальный инстанс на 7801 продолжает жить. В Cursor MCP на время сессии переключают на 17801 (вручную по сниппету CLI).
Без туннеля API с ноутбука недоступен: 7801 на VM не опубликован в интернет.
## Чего не делать
- `chmod -R 777` на Models.
- Слушать 7801 на `0.0.0.0`.
- Ставить Docker «на всякий случай».
- Считать Nova `ACTIVE` = можно генерировать.
- `apt upgrade` ядра (ломает nvidia-smi).
+47
View File
@@ -0,0 +1,47 @@
# Copy to %USERPROFILE%\.gpu-rent\.env (never commit the copy)
# How to fill: docs/setup.md
# X-Token from the panel does NOT work here.
OS_AUTH_URL=https://cloud.api.selcloud.ru/identity/v3
OS_USER_DOMAIN_NAME=
OS_USERNAME=
OS_PASSWORD=
OS_PROJECT_ID=
OS_REGION_NAME=ru-7
GPU_RENT_AZ=ru-7a
SSH_PRIVATE_KEY_PATH=
SSH_USER=ubuntu
BOOT_VOLUME_ID=
DATA_VOLUME_ID=
DATA_VOLUME_SIZE_GB=100
BOOT_SNAPSHOT_NAME=gpu-rent-boot-ok
CIVITAI_API_TOKEN=
CIVITAI_API_HOST=civitai.red
MODELS_MANIFEST=
LOCAL_MODELS_DIR=
LOCAL_WILDCARDS_DIR=
LOCAL_WORKFLOWS_DIR=
LOCAL_OUTPUT_DIR=
EXTENSIONS_MANIFEST=
GIT_TOKEN=
AUTOCOMPLETE_ENABLED=true
AUTOCOMPLETE_GITHUB_REPO=DominikDoom/a1111-sd-webui-tagcomplete
AUTOCOMPLETE_GITHUB_PATH=tags/danbooru.csv
AUTOCOMPLETE_GITHUB_REF=main
AUTOCOMPLETE_FILENAME=danbooru.csv
SWARMUI_LOCAL_PORT=17801
DEFAULT_FLAVOR_ID=
FLAVOR_PREFERENCE=4090-24,4090-48,a5000,a100-40
FLAVOR_FALLBACK=true
DEFAULT_SPOT=true
KEEP_FLOATING_IP=false
IDLE_MINUTES=30
IDLE_GRACE_MINUTES=45
PULL_OUTPUT=false
NOTIFY_READY=true
+13
View File
@@ -0,0 +1,13 @@
# Copy to ~/.gpu-rent/extensions.yaml and put real repo URLs.
# Empty/missing file → no extra extensions, stock SwarmUI.
# swarmui = C# repos cloned to src/Extensions
# comfy = Python custom nodes cloned to ComfyUI DLNodes
swarmui: []
# - url: https://github.com/org/SwarmUI-MyExt.git
# ref: main
# dir: MyExt
comfy: []
# - url: https://github.com/org/ComfyUI-CustomNodes.git
# ref: v1.0.0
+19
View File
@@ -0,0 +1,19 @@
# Copy to ~/.gpu-rent/models.yaml
# version_id = modelVersionId from the URL (not the model id).
# url may be civitai.com or civitai.red — both work.
# Requires CIVITAI_API_TOKEN. Empty file → SwarmUI default model.
checkpoint:
- version_id: 0
# - url: https://civitai.red/models/123?modelVersionId=456
lora:
- version_id: 0
# vae:
# - version_id: 0
# embedding:
# - version_id: 0
# controlnet:
# - version_id: 0
# upscaler:
# - version_id: 0
+49
View File
@@ -0,0 +1,49 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "gpu-rent"
version = "0.1.0"
description = "Rent a preemptible Selectel GPU, keep SwarmUI on persistent disks, tunnel to localhost:17801."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
authors = [{ name = "gpu-rent contributors" }]
keywords = ["selectel", "openstack", "gpu", "swarmui"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Environment :: Console",
]
dependencies = [
"typer>=0.16",
"rich>=13.7",
"python-dotenv>=1.0",
"openstacksdk>=3.3",
"paramiko>=3.4",
"sshtunnel>=0.4",
"httpx>=0.27",
"pyyaml>=6.0",
"cryptography>=42.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[project.scripts]
gpu-rent = "gpu_rent.cli:app"
[project.urls]
Documentation = "https://github.com/mrleo1nid/gpu-rent/blob/main/docs/README.md"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+3
View File
@@ -0,0 +1,3 @@
"""gpu-rent: Selectel GPU session CLI."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from gpu_rent.cli import app
if __name__ == "__main__":
app()
+58
View File
@@ -0,0 +1,58 @@
"""Civitai site API. Bearer only on civitai.com / .red / .green."""
from __future__ import annotations
from dataclasses import dataclass
import httpx
ALLOWED_HOSTS = ("civitai.com", "civitai.red", "civitai.green")
@dataclass
class CivitaiProbe:
host: str
ok: bool
status: int | None
detail: str
def _normalize_host(host: str) -> str:
h = host.strip().lower().removeprefix("https://").removeprefix("http://").split("/")[0]
if h.startswith("www."):
h = h[4:]
return h
def other_host(host: str) -> str:
h = _normalize_host(host)
if h.endswith(".red") or h == "civitai.red":
return "civitai.com"
return "civitai.red"
def probe_me(token: str, host: str, timeout: float = 15.0) -> CivitaiProbe:
host = _normalize_host(host)
if host not in ALLOWED_HOSTS:
return CivitaiProbe(host=host, ok=False, status=None, detail="хост не из allow-list")
url = f"https://{host}/api/v1/me"
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
response = client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
return CivitaiProbe(host=host, ok=False, status=None, detail=str(exc))
if response.status_code == 200:
return CivitaiProbe(host=host, ok=True, status=200, detail="токен принят")
if response.status_code in {401, 403}:
return CivitaiProbe(
host=host,
ok=False,
status=response.status_code,
detail="токен отвергнут — перевыпусти ключ на civitai.com/user/account",
)
return CivitaiProbe(
host=host,
ok=False,
status=response.status_code,
detail=response.text[:200] or response.reason_phrase,
)
+334
View File
@@ -0,0 +1,334 @@
"""Typer entry: gpu-rent."""
from __future__ import annotations
import socket
import sys
import traceback
import webbrowser
from datetime import datetime, timezone
from typing import Optional
import typer
from rich.console import Console
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.os_client import connect, find_snapshot_by_name, find_tagged_servers
from gpu_rent.state import load_state, preempt_window_end
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, OSError):
pass
app = typer.Typer(
no_args_is_help=True,
pretty_exceptions_enable=False,
add_completion=False,
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent doctor. Ключи: docs/setup.md",
)
console = Console(highlight=False, legacy_windows=False)
_DEBUG = False
@app.callback()
def _root(
debug: bool = typer.Option(False, "--debug", help="Показать traceback"),
) -> None:
global _DEBUG
_DEBUG = debug
def _die(exc: BaseException) -> None:
if _DEBUG:
traceback.print_exc()
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1)
def _nyi(name: str) -> None:
raise NotReadyError(
f"`{name}` ещё не создаёт/не гасит GPU. Сейчас работают: doctor, dry-run, status, open.\n"
"1) Заполни ~/.gpu-rent/.env по docs/setup.md\n"
"2) gpu-rent doctor\n"
"3) Когда doctor зелёный и квота GPU > 0 — можно писать up."
)
def _print_checks(checks) -> int:
table = Table(title="gpu-rent doctor", show_lines=False)
table.add_column("ok")
table.add_column("проверка")
table.add_column("блок?")
table.add_column("деталь")
for check in checks:
mark = "[green]yes[/green]" if check.ok else "[red]NO[/red]"
block = "да" if check.blocking else "нет"
table.add_row(mark, check.name, block, check.detail)
console.print(table)
failed = blocking_failed(checks)
if failed:
console.print("\n[red]Сессию начинать нельзя.[/red] См. docs/setup.md")
return 1
console.print("\n[green]Можно идти дальше.[/green] mutating up пока не подключён.")
return 0
@app.command()
def version() -> None:
"""Версия пакета."""
console.print(__version__)
@app.command()
def doctor() -> None:
"""Preflight без create: Keystone, квота, flavor, диск, Civitai, манифесты."""
try:
checks = run_doctor()
except GpuRentError as exc:
_die(exc)
code = _print_checks(checks)
raise typer.Exit(code)
@app.command("dry-run")
def dry_run() -> None:
"""План без mutating-вызовов."""
try:
checks = run_doctor()
_print_checks(checks)
console.print("\n[bold]План[/bold]")
for line in dry_run_plan(checks):
console.print(f"{line}")
if blocking_failed(checks):
raise typer.Exit(1)
except GpuRentError as exc:
_die(exc)
@app.command()
def status() -> None:
"""Локальный state + OpenStack, если .env есть. Туннель не нужен."""
state = load_state()
table = Table(title="status")
table.add_column("поле")
table.add_column("значение")
table.add_row("фаза", state.phase)
table.add_row("server", state.server_id or "")
table.add_row("flavor", state.flavor_name or state.flavor_id or "")
table.add_row("boot volume", state.boot_volume_id or "")
table.add_row("data volume", state.data_volume_id or "")
table.add_row("FIP", state.floating_ip or "")
end = preempt_window_end(state)
if end:
left = end - datetime.now(timezone.utc)
hours = max(int(left.total_seconds() // 3600), 0)
mins = max(int((left.total_seconds() % 3600) // 60), 0)
table.add_row("preempt 24ч", f"до {end.isoformat()} (осталось {hours}h {mins}m)")
else:
table.add_row("preempt 24ч", "нет create/unshelve timestamp")
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")
if cfg.auth_ok:
try:
conn = connect(cfg)
servers = find_tagged_servers(conn)
if servers:
table.add_row(
"Nova",
", ".join(f"{s.name} {s.status}" for s in servers),
)
else:
table.add_row("Nova", "нет сервера gpu-rent")
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
table.add_row("snapshot", cfg.boot_snapshot_name if snap else "нет")
except GpuRentError as exc:
table.add_row("Nova", f"не достучались: {exc}")
else:
table.add_row("Nova", "нет .env — только локальный state")
console.print(table)
@app.command()
def open() -> None:
"""Открыть браузер на http://127.0.0.1:17801. Туннель уже должен слушать порт."""
cfg = load_config(require_auth=False)
port = cfg.swarmui_local_port
if not _port_open(port):
console.print(
f"[red]localhost:{port} молчит.[/red] Сначала `gpu-rent tunnel`, потом open."
)
raise typer.Exit(1)
url = f"http://127.0.0.1:{port}"
webbrowser.open(url)
console.print(url)
@app.command()
def up(
no_spot: bool = typer.Option(False, "--no-spot", help="Обычный сервер, не preemptible"),
flavor: Optional[str] = typer.Option(None, "--flavor", help="Flavor id, без фоллбека"),
yes: bool = typer.Option(False, "--yes", help="Без вопросов"),
adopt: bool = typer.Option(False, "--adopt", help="Подхватить тег gpu-rent без state"),
) -> None:
"""Create/unshelve GPU. Пока: doctor, затем стоп — mutating ещё не подключён."""
del no_spot, flavor, yes, adopt
try:
checks = run_doctor()
code = _print_checks(checks)
if code != 0:
raise typer.Exit(1)
_nyi("up")
except GpuRentError as exc:
_die(exc)
@app.command()
def tunnel(
open_browser: bool = typer.Option(False, "--open", help="Открыть браузер на 17801"),
) -> None:
"""SSH localhost:17801 -> VM :7801. Ctrl+C закрывает туннель, GPU оставляет."""
del open_browser
try:
_nyi("tunnel")
except GpuRentError as exc:
_die(exc)
@app.command()
def hold(
minutes: Optional[int] = typer.Option(None, "--minutes"),
until: Optional[str] = typer.Option(None, "--until"),
clear: bool = typer.Option(False, "--clear"),
) -> None:
"""Отложить idle-killer на VM."""
del minutes, until, clear
try:
_nyi("hold")
except GpuRentError as exc:
_die(exc)
@app.command()
def stop(
no_pull: bool = typer.Option(False, "--no-pull"),
) -> None:
"""Удалить compute и FIP, диски оставить."""
del no_pull
try:
_nyi("stop")
except GpuRentError as exc:
_die(exc)
@app.command()
def destroy(
i_understand_data_loss: bool = typer.Option(False, "--i-understand-data-loss"),
) -> None:
"""stop + диски."""
if not i_understand_data_loss:
console.print("Нужен флаг --i-understand-data-loss")
raise typer.Exit(1)
try:
_nyi("destroy")
except GpuRentError as exc:
_die(exc)
@app.command()
def logs() -> None:
try:
_nyi("logs")
except GpuRentError as exc:
_die(exc)
@app.command()
def ssh() -> None:
try:
_nyi("ssh")
except GpuRentError as exc:
_die(exc)
@app.command("seed-models")
def seed_models() -> None:
try:
_nyi("seed-models")
except GpuRentError as exc:
_die(exc)
@app.command("push")
def push_all() -> None:
try:
_nyi("push")
except GpuRentError as exc:
_die(exc)
@app.command("push-models")
def push_models() -> None:
try:
_nyi("push-models")
except GpuRentError as exc:
_die(exc)
@app.command("pull-output")
def pull_output() -> None:
try:
_nyi("pull-output")
except GpuRentError as exc:
_die(exc)
@app.command("seed-extensions")
def seed_extensions() -> None:
try:
_nyi("seed-extensions")
except GpuRentError as exc:
_die(exc)
@app.command("resize-data")
def resize_data(gb: int = typer.Option(..., "--gb")) -> None:
del gb
try:
_nyi("resize-data")
except GpuRentError as exc:
_die(exc)
def _port_open(port: int) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.4)
try:
return sock.connect_ex(("127.0.0.1", port)) == 0
finally:
sock.close()
def main() -> None:
try:
app()
except GpuRentError as exc:
_die(exc)
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
"""Load ~/.gpu-rent/.env. No secrets in git."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
from gpu_rent.errors import ConfigError
from gpu_rent.paths import (
default_ssh_key_path,
detect_app_root,
env_path,
home_dir,
)
def _as_bool(value: str | None, default: bool) -> bool:
if value is None or value.strip() == "":
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _as_int(value: str | None, default: int) -> int:
if value is None or value.strip() == "":
return default
return int(value)
def _csv(value: str | None, default: tuple[str, ...]) -> tuple[str, ...]:
if value is None or value.strip() == "":
return default
return tuple(part.strip() for part in value.split(",") if part.strip())
@dataclass
class Config:
os_auth_url: str
os_user_domain_name: str
os_username: str
os_password: str
os_project_id: str
os_region_name: str
gpu_rent_az: str
ssh_private_key_path: Path
ssh_user: str
boot_volume_id: str
data_volume_id: str
data_volume_size_gb: int
boot_snapshot_name: str
civitai_api_token: str
civitai_api_host: str
models_manifest: Path
extensions_manifest: Path
git_token: str
local_models_dir: Path
local_wildcards_dir: Path
local_workflows_dir: Path
local_output_dir: Path
app_root: Path
autocomplete_enabled: bool
autocomplete_github_repo: str
autocomplete_github_path: str
autocomplete_github_ref: str
autocomplete_filename: str
swarmui_local_port: int
swarmui_image: str
default_flavor_id: str
flavor_preference: tuple[str, ...]
flavor_fallback: bool
default_spot: bool
keep_floating_ip: bool
idle_minutes: int
idle_grace_minutes: int
pull_output: bool
notify_ready: bool
missing: list[str] = field(default_factory=list)
@property
def auth_ok(self) -> bool:
return not self.missing
def _required(name: str) -> str:
return (os.environ.get(name) or "").strip()
def load_config(*, require_auth: bool = True) -> Config:
home_dir().mkdir(parents=True, exist_ok=True)
env_file = env_path()
if env_file.is_file():
load_dotenv(env_file, override=False)
app_root = detect_app_root()
missing: list[str] = []
required = (
"OS_AUTH_URL",
"OS_USER_DOMAIN_NAME",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"GPU_RENT_AZ",
)
values = {name: _required(name) for name in required}
for name, value in values.items():
if not value:
missing.append(name)
if require_auth and missing:
raise ConfigError(
"В ~/.gpu-rent/.env не хватает: "
+ ", ".join(missing)
+ ". Как заполнить: docs/setup.md (сервисный пользователь, не X-Token)."
)
key_override = (os.environ.get("SSH_PRIVATE_KEY_PATH") or "").strip()
ssh_key = Path(key_override).expanduser() if key_override else default_ssh_key_path()
models_manifest = Path(
(os.environ.get("MODELS_MANIFEST") or "").strip() or (home_dir() / "models.yaml")
).expanduser()
extensions_manifest = Path(
(os.environ.get("EXTENSIONS_MANIFEST") or "").strip()
or (home_dir() / "extensions.yaml")
).expanduser()
def _dir(env_name: str, folder: str) -> Path:
raw = (os.environ.get(env_name) or "").strip()
return Path(raw).expanduser() if raw else (app_root / folder)
return Config(
os_auth_url=values["OS_AUTH_URL"] or "https://cloud.api.selcloud.ru/identity/v3",
os_user_domain_name=values["OS_USER_DOMAIN_NAME"],
os_username=values["OS_USERNAME"],
os_password=values["OS_PASSWORD"],
os_project_id=values["OS_PROJECT_ID"],
os_region_name=values["OS_REGION_NAME"],
gpu_rent_az=values["GPU_RENT_AZ"],
ssh_private_key_path=ssh_key,
ssh_user=(os.environ.get("SSH_USER") or "ubuntu").strip(),
boot_volume_id=(os.environ.get("BOOT_VOLUME_ID") or "").strip(),
data_volume_id=(os.environ.get("DATA_VOLUME_ID") or "").strip(),
data_volume_size_gb=_as_int(os.environ.get("DATA_VOLUME_SIZE_GB"), 100),
boot_snapshot_name=(os.environ.get("BOOT_SNAPSHOT_NAME") or "gpu-rent-boot-ok").strip(),
civitai_api_token=(os.environ.get("CIVITAI_API_TOKEN") or "").strip(),
civitai_api_host=(os.environ.get("CIVITAI_API_HOST") or "civitai.red").strip().lower(),
models_manifest=models_manifest,
extensions_manifest=extensions_manifest,
git_token=(os.environ.get("GIT_TOKEN") or "").strip(),
local_models_dir=_dir("LOCAL_MODELS_DIR", "Models"),
local_wildcards_dir=_dir("LOCAL_WILDCARDS_DIR", "Wildcards"),
local_workflows_dir=_dir("LOCAL_WORKFLOWS_DIR", "CustomWorkflows"),
local_output_dir=_dir("LOCAL_OUTPUT_DIR", "Output"),
app_root=app_root,
autocomplete_enabled=_as_bool(os.environ.get("AUTOCOMPLETE_ENABLED"), True),
autocomplete_github_repo=(
os.environ.get("AUTOCOMPLETE_GITHUB_REPO") or "DominikDoom/a1111-sd-webui-tagcomplete"
).strip(),
autocomplete_github_path=(
os.environ.get("AUTOCOMPLETE_GITHUB_PATH") or "tags/danbooru.csv"
).strip(),
autocomplete_github_ref=(os.environ.get("AUTOCOMPLETE_GITHUB_REF") or "main").strip(),
autocomplete_filename=(os.environ.get("AUTOCOMPLETE_FILENAME") or "danbooru.csv").strip(),
swarmui_local_port=_as_int(os.environ.get("SWARMUI_LOCAL_PORT"), 17801),
swarmui_image=(os.environ.get("SWARMUI_IMAGE") or "").strip(),
default_flavor_id=(os.environ.get("DEFAULT_FLAVOR_ID") or "").strip(),
flavor_preference=_csv(
os.environ.get("FLAVOR_PREFERENCE"),
("4090-24", "4090-48", "a5000", "a100-40"),
),
flavor_fallback=_as_bool(os.environ.get("FLAVOR_FALLBACK"), True),
default_spot=_as_bool(os.environ.get("DEFAULT_SPOT"), True),
keep_floating_ip=_as_bool(os.environ.get("KEEP_FLOATING_IP"), False),
idle_minutes=_as_int(os.environ.get("IDLE_MINUTES"), 30),
idle_grace_minutes=_as_int(os.environ.get("IDLE_GRACE_MINUTES"), 45),
pull_output=_as_bool(os.environ.get("PULL_OUTPUT"), False),
notify_ready=_as_bool(os.environ.get("NOTIFY_READY"), True),
missing=missing,
)
+391
View File
@@ -0,0 +1,391 @@
"""Preflight without creating a GPU. All checks print; exit 1 if session cannot start."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from gpu_rent.civitai import other_host, probe_me
from gpu_rent.config import Config, load_config
from gpu_rent.errors import CloudError, ConfigError
from gpu_rent.inventory import (
gpu_quota_from_compute,
looks_like_gpu,
pick_boot_image,
pick_volume_type,
rank_flavors,
)
from gpu_rent.manifests import parse_extensions, parse_models
from gpu_rent.os_client import (
compute_quotas,
connect,
find_snapshot_by_name,
find_tagged_servers,
iter_flavors,
iter_images,
iter_volume_types,
volume_quotas,
)
from gpu_rent.paths import env_path, home_dir
from gpu_rent.ssh_keys import key_ready
from gpu_rent.state import load_state
@dataclass
class Check:
name: str
ok: bool
blocking: bool
detail: str
SKIP = {".gitkeep", "README.md", ".gitignore"}
def _folder_bytes(root: Path) -> int:
if not root.is_dir():
return 0
total = 0
for path in root.rglob("*"):
if path.is_file() and path.name not in SKIP:
total += path.stat().st_size
return total
def _has_payload(root: Path) -> bool:
if not root.is_dir():
return False
for path in root.rglob("*"):
if path.is_file() and path.name not in SKIP:
return True
return False
def run_doctor() -> list[Check]:
checks: list[Check] = []
home_dir().mkdir(parents=True, exist_ok=True)
env_file = env_path()
if env_file.is_file():
checks.append(Check("env file", True, True, str(env_file)))
else:
checks.append(
Check(
"env file",
False,
True,
f"нет {env_file}. Скопируй env.example и заполни по docs/setup.md",
)
)
return checks
try:
cfg = load_config(require_auth=True)
except ConfigError as exc:
checks.append(Check("OS_*", False, True, str(exc)))
return checks
checks.append(
Check(
"OS_*",
True,
True,
f"project={cfg.os_project_id} region={cfg.os_region_name} az={cfg.gpu_rent_az}",
)
)
if key_ready(cfg.ssh_private_key_path):
checks.append(Check("SSH key", True, True, f"есть {cfg.ssh_private_key_path}"))
else:
checks.append(
Check(
"SSH key",
True,
True,
f"ключа нет — CLI создаст {cfg.ssh_private_key_path} на первом up",
)
)
conn = None
try:
conn = connect(cfg)
checks.append(Check("Keystone", True, True, "IAM-токен выдан (TTL ~24 ч, sdk обновит)"))
except CloudError as exc:
checks.append(Check("Keystone", False, True, str(exc)))
_local_manifests(cfg, checks)
_local_folders(cfg, checks)
return checks
try:
quota = compute_quotas(conn)
gpu_limit = gpu_quota_from_compute(quota)
if gpu_limit is None:
checks.append(
Check(
"квота GPU",
True,
False,
"в compute quota нет поля gpu — смотри панель IAM -> проект -> квоты. "
"Если там 0, напишите в поддержку Selectel (docs/setup.md).",
)
)
elif gpu_limit <= 0:
checks.append(
Check(
"квота GPU",
False,
True,
"квота GPU = 0. Напиши в поддержку Selectel: 1× RTX 4090 24 GB "
f"в {cfg.os_region_name}/{cfg.gpu_rent_az}, проект {cfg.os_project_id}. "
"Текст тикета — docs/setup.md. CLI сервер не создаст, пока лимит 0.",
)
)
else:
checks.append(Check("квота GPU", True, True, f"limit={gpu_limit}"))
except CloudError as exc:
checks.append(Check("квота GPU", False, True, str(exc)))
flavors = list(iter_flavors(conn))
gpu_flavors = [f for f in flavors if looks_like_gpu(f)]
ranked = rank_flavors(gpu_flavors or flavors, cfg.flavor_preference)
if cfg.default_flavor_id:
hit = next((f for f in flavors if getattr(f, "id", None) == cfg.default_flavor_id), None)
if hit:
checks.append(Check("flavor", True, True, f"DEFAULT_FLAVOR_ID={cfg.default_flavor_id}"))
else:
checks.append(
Check(
"flavor",
False,
True,
f"DEFAULT_FLAVOR_ID={cfg.default_flavor_id} в регионе нет",
)
)
elif not ranked:
names = ", ".join(getattr(f, "name", "?") for f in gpu_flavors[:8]) or "нет GPU-flavors"
checks.append(
Check(
"flavor",
False,
True,
"ни один flavor из FLAVOR_PREFERENCE не найден в этом пуле. "
f"Видно: {names}. Смени GPU_RENT_AZ / OS_REGION_NAME по матрице GPU.",
)
)
else:
first = ranked[0]
rest = ", ".join(f"{x.label}:{x.name}" for x in ranked[1:3])
extra = f"; дальше {rest}" if rest else ""
checks.append(
Check(
"flavor",
True,
True,
f"первый доступный {first.label} -> {first.name} ({first.id}){extra}",
)
)
types = list(iter_volume_types(conn))
vtype = pick_volume_type(types, cfg.gpu_rent_az)
gigabytes = volume_quotas(conn).get("gigabytes")
if vtype:
disk_note = f"type={vtype}, data {cfg.data_volume_size_gb} GB"
if isinstance(gigabytes, int) and gigabytes >= 0:
disk_note += f", quota gigabytes={gigabytes}"
if gigabytes < cfg.data_volume_size_gb:
checks.append(
Check(
"диск",
False,
True,
f"{disk_note} — квота меньше {cfg.data_volume_size_gb} GB",
)
)
else:
checks.append(Check("диск", True, True, disk_note))
else:
checks.append(Check("диск", True, False, disk_note + " (квоту дисков API не отдал)"))
else:
checks.append(
Check(
"диск",
False,
True,
f"нет volume type для AZ {cfg.gpu_rent_az}. volume type list пуст?",
)
)
chosen = pick_boot_image(list(iter_images(conn)))
if chosen:
name = getattr(chosen, "name", str(chosen))
dockerish = "docker" in name.lower()
checks.append(
Check(
"образ GPU",
True,
False,
name
+ (
" (это Docker-образ: в пуле нет варианта без Docker)"
if dockerish
else ""
),
)
)
else:
checks.append(
Check(
"образ GPU",
False,
True,
"не нашёл GPU-образ в Glance (ожидаем Ubuntu 24.04 Driver 580 без Docker)",
)
)
servers = find_tagged_servers(conn)
if servers:
names = ", ".join(f"{s.name}:{s.status}" for s in servers)
checks.append(Check("живой gpu-rent", True, False, names))
else:
checks.append(Check("живой gpu-rent", True, False, "серверов с тегом нет (это норма)"))
snap = find_snapshot_by_name(conn, cfg.boot_snapshot_name)
if snap:
checks.append(Check("boot snapshot", True, False, cfg.boot_snapshot_name))
else:
checks.append(
Check("boot snapshot", True, False, f"{cfg.boot_snapshot_name} ещё нет — будет после первого bootstrap")
)
_civitai(cfg, checks)
_local_manifests(cfg, checks)
_local_folders(cfg, checks)
return checks
def _civitai(cfg: Config, checks: list[Check]) -> None:
if not cfg.civitai_api_token:
checks.append(
Check(
"Civitai",
True,
False,
"токена нет — на первом диске будет дефолт SwarmUI. Ключ: docs/setup.md §4",
)
)
return
probe = probe_me(cfg.civitai_api_token, cfg.civitai_api_host)
if probe.ok:
checks.append(Check("Civitai", True, True, f"{probe.host}: {probe.detail}"))
return
alt = probe_me(cfg.civitai_api_token, other_host(cfg.civitai_api_host))
if alt.ok:
checks.append(
Check(
"Civitai",
True,
False,
f"{probe.host} не ответил ({probe.detail}); {alt.host} принял токен. "
"Для NSFW оставь CIVITAI_API_HOST=civitai.red",
)
)
return
checks.append(
Check(
"Civitai",
False,
True,
f"{probe.host}: {probe.detail}; fallback {alt.host}: {alt.detail}",
)
)
def _local_manifests(cfg: Config, checks: list[Check]) -> None:
try:
models = parse_models(cfg.models_manifest)
if cfg.models_manifest.is_file():
checks.append(
Check(
"models.yaml",
True,
True,
f"{cfg.models_manifest} — записей (без заглушек 0): {len(models)}",
)
)
else:
checks.append(
Check(
"models.yaml",
True,
False,
f"нет {cfg.models_manifest} — seed Civitai пропустится",
)
)
except ConfigError as exc:
checks.append(Check("models.yaml", False, True, str(exc)))
try:
repos = parse_extensions(cfg.extensions_manifest)
if cfg.extensions_manifest.is_file():
checks.append(
Check(
"extensions.yaml",
True,
True,
f"{len(repos)} git-реп",
)
)
else:
checks.append(Check("extensions.yaml", True, False, "файла нет — стоковый SwarmUI"))
except ConfigError as exc:
checks.append(Check("extensions.yaml", False, True, str(exc)))
def _local_folders(cfg: Config, checks: list[Check]) -> None:
size = _folder_bytes(cfg.local_models_dir)
gi = size / (1024**3)
payload = _has_payload(cfg.local_models_dir)
if payload and gi > cfg.data_volume_size_gb * 0.8:
checks.append(
Check(
"Models/",
False,
True,
f"локально ~{gi:.1f} GB, диск {cfg.data_volume_size_gb} GB — не влезет. "
"Урежь папку или gpu-rent resize-data после первого диска.",
)
)
elif payload:
checks.append(Check("Models/", True, False, f"есть файлы, ~{gi:.2f} GB — уедут на up"))
else:
checks.append(Check("Models/", True, False, "пусто — на up ничего не грузим"))
for label, folder in (
("Wildcards/", cfg.local_wildcards_dir),
("CustomWorkflows/", cfg.local_workflows_dir),
):
if _has_payload(folder):
checks.append(Check(label, True, False, "не пусто — push на up"))
else:
checks.append(Check(label, True, False, "пусто — skip"))
def blocking_failed(checks: list[Check]) -> list[Check]:
return [c for c in checks if c.blocking and not c.ok]
def dry_run_plan(checks: list[Check]) -> list[str]:
cfg = load_config(require_auth=False)
state = load_state()
lines = [
f"фаза state: {state.phase}",
f"пул {cfg.os_region_name} / AZ {cfg.gpu_rent_az}",
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} мин",
f"туннель: localhost:{cfg.swarmui_local_port} -> VM :7801",
"сейчас mutating up/stop ещё не подключены — только doctor / dry-run / status / open",
]
flavor = next((c.detail for c in checks if c.name == "flavor" and c.ok), None)
if flavor:
lines.insert(2, f"flavor: {flavor}")
return lines
+19
View File
@@ -0,0 +1,19 @@
"""User-facing errors without tracebacks."""
from __future__ import annotations
class GpuRentError(Exception):
"""Printed as a short message; process exits 1."""
class ConfigError(GpuRentError):
pass
class CloudError(GpuRentError):
pass
class NotReadyError(GpuRentError):
"""Command exists in the spec but is not implemented yet."""
+169
View File
@@ -0,0 +1,169 @@
"""Flavor preference matching and volume-type pick for the AZ."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class FlavorInfo:
id: str
name: str
vcpus: int | None
ram_mb: int | None
disabled: bool
extra: dict[str, Any]
label: str | None = None
def _name(obj: Any) -> str:
return (getattr(obj, "name", None) or "").strip()
def _id(obj: Any) -> str:
return str(getattr(obj, "id", "") or "")
def is_disabled(flavor: Any) -> bool:
if getattr(flavor, "is_disabled", False):
return True
extra = extra_specs(flavor)
flag = extra.get("OS-FLV-DISABLED:disabled") or extra.get("disabled")
if flag in (True, "True", "true", "1"):
return True
return False
def extra_specs(flavor: Any) -> dict[str, Any]:
extra = getattr(flavor, "extra_specs", None)
if isinstance(extra, dict):
return extra
blob = getattr(flavor, "get", None)
if callable(blob):
got = flavor.get("extra_specs")
if isinstance(got, dict):
return got
return {}
def looks_like_gpu(flavor: Any) -> bool:
name = _name(flavor).lower()
extra = extra_specs(flavor)
blob = " ".join(f"{k}={v}" for k, v in extra.items()).lower()
hay = f"{name} {blob}"
needles = ("gpu", "4090", "a5000", "a100", "a6000", "l40", "h100")
return any(n in hay for n in needles)
def match_label(label: str, flavor: Any) -> bool:
"""Match FLAVOR_PREFERENCE tokens to a live flavor name/extra specs."""
name = _name(flavor).lower()
extra = extra_specs(flavor)
hay = name + " " + " ".join(str(v).lower() for v in extra.values())
token = label.strip().lower()
if token in {"4090-24", "4090_24", "rtx4090-24"}:
return "4090" in hay and "48" not in hay
if token in {"4090-48", "4090_48", "rtx4090-48"}:
return "4090" in hay and "48" in hay
if token in {"a5000", "rtx-a5000"}:
return "a5000" in hay or "rtx a5000" in hay
if token in {"a100-40", "a100_40"}:
return "a100" in hay and "80" not in hay
if token in {"a100-80", "a100_80"}:
return "a100" in hay and "80" in hay
return token.replace("_", "-") in hay or token.replace("-", " ") in hay
def flavor_info(flavor: Any, label: str | None = None) -> FlavorInfo:
ram = getattr(flavor, "ram", None)
vcpus = getattr(flavor, "vcpus", None)
return FlavorInfo(
id=_id(flavor),
name=_name(flavor) or _id(flavor),
vcpus=int(vcpus) if vcpus is not None else None,
ram_mb=int(ram) if ram is not None else None,
disabled=is_disabled(flavor),
extra=extra_specs(flavor),
label=label,
)
def rank_flavors(flavors: list[Any], preference: tuple[str, ...]) -> list[FlavorInfo]:
ranked: list[FlavorInfo] = []
seen: set[str] = set()
for label in preference:
for flavor in flavors:
fid = _id(flavor)
if fid in seen or is_disabled(flavor):
continue
if match_label(label, flavor):
ranked.append(flavor_info(flavor, label))
seen.add(fid)
break
return ranked
def pick_volume_type(types: list[Any], az: str) -> str | None:
az_l = az.lower()
names = [_name(t) for t in types if _name(t)]
for name in names:
if az_l in name.lower() and "fast" in name.lower():
return name
for name in names:
if az_l in name.lower():
return name
return names[0] if names else None
def gpu_quota_from_compute(quota: dict[str, Any]) -> int | None:
"""Return GPU limit if the quota dict exposes it; else None."""
keys = []
for key in quota:
if "gpu" in str(key).lower():
keys.append(key)
if not keys:
return None
values = []
for key in keys:
raw = quota[key]
if isinstance(raw, dict):
raw = raw.get("limit", raw.get("in_use"))
try:
values.append(int(raw))
except (TypeError, ValueError):
continue
if not values:
return None
return max(values)
def gpu_boot_image_score(name: str) -> int:
"""Higher is better. Canonical: Ubuntu 24.04 + driver 580, no Docker."""
n = name.lower()
if "gpu" not in n:
return 0
if "data science" in n or "analytics" in n:
return 1
score = 10
if "docker" in n:
score -= 30
if "24.04" in n:
score += 20
elif "22.04" in n:
score += 5
if "580" in n:
score += 15
elif "535" in n:
score += 4
return score
def pick_boot_image(images: list[Any]) -> Any | None:
ranked = [(gpu_boot_image_score(_name(img)), img) for img in images]
ranked = [item for item in ranked if item[0] > 0]
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1]
+102
View File
@@ -0,0 +1,102 @@
"""Parse models.yaml / extensions.yaml. version_id 0 is a placeholder."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from gpu_rent.errors import ConfigError
MODEL_TYPES = (
"checkpoint",
"lora",
"vae",
"embedding",
"controlnet",
"upscaler",
)
@dataclass
class ModelEntry:
kind: str
version_id: int | None
url: str | None
@dataclass
class GitRepo:
kind: str
url: str
ref: str
directory: str | None
def _load_yaml(path: Path) -> Any:
if not path.is_file():
return None
text = path.read_text(encoding="utf-8")
if not text.strip():
return {}
try:
return yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ConfigError(f"Не разобрать YAML {path}: {exc}") from exc
def parse_models(path: Path) -> list[ModelEntry]:
data = _load_yaml(path)
if data is None:
return []
if data == {} or data is None:
return []
if not isinstance(data, dict):
raise ConfigError(f"{path}: корень должен быть mapping типов моделей")
entries: list[ModelEntry] = []
for kind, items in data.items():
if kind not in MODEL_TYPES:
continue
if not items:
continue
if not isinstance(items, list):
raise ConfigError(f"{path}: {kind} должен быть списком")
for item in items:
if not isinstance(item, dict):
raise ConfigError(f"{path}: элемент {kind} — объект с version_id или url")
vid = item.get("version_id")
url = item.get("url")
if vid in (0, "0", None) and not url:
continue
version_id = int(vid) if vid not in (None, "", 0, "0") else None
entries.append(ModelEntry(kind=kind, version_id=version_id, url=str(url) if url else None))
return entries
def parse_extensions(path: Path) -> list[GitRepo]:
data = _load_yaml(path)
if not data:
return []
if not isinstance(data, dict):
raise ConfigError(f"{path}: корень swarmui: / comfy:")
repos: list[GitRepo] = []
for kind in ("swarmui", "comfy"):
items = data.get(kind) or []
if not items:
continue
if not isinstance(items, list):
raise ConfigError(f"{path}: {kind} должен быть списком")
for item in items:
if not isinstance(item, dict) or not item.get("url"):
raise ConfigError(f"{path}: у {kind} нужен url")
repos.append(
GitRepo(
kind=kind,
url=str(item["url"]),
ref=str(item.get("ref") or "main"),
directory=str(item["dir"]) if item.get("dir") else None,
)
)
return repos
+118
View File
@@ -0,0 +1,118 @@
"""openstacksdk connection. IAM token is 24h; sdk refreshes on authorize()."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from gpu_rent.config import Config
from gpu_rent.errors import CloudError
RESOURCE_TAG = "gpu-rent"
COMPUTE_MICROVERSION = "2.72"
def connect(cfg: Config):
try:
import openstack
except ImportError as exc:
raise CloudError("Нет openstacksdk. Переустанови пакет: pip install -e .") from exc
try:
conn = openstack.connect(
auth_url=cfg.os_auth_url,
project_id=cfg.os_project_id,
username=cfg.os_username,
password=cfg.os_password,
user_domain_name=cfg.os_user_domain_name,
project_domain_name=cfg.os_user_domain_name,
region_name=cfg.os_region_name,
identity_api_version="3",
interface="public",
compute_api_version=COMPUTE_MICROVERSION,
app_name="gpu-rent",
app_version="0.1.0",
)
conn.authorize()
except Exception as exc:
raise CloudError(
"Keystone не выдал токен. Проверь OS_USERNAME / OS_PASSWORD / "
"OS_PROJECT_ID / OS_USER_DOMAIN_NAME (номер аккаунта). "
"Не используй X-Token панели. Подробности: docs/setup.md. "
f"Ошибка SDK: {exc}"
) from exc
return conn
def _obj_dict(obj: Any) -> dict[str, Any]:
if obj is None:
return {}
if hasattr(obj, "to_dict"):
try:
return obj.to_dict(computed=False)
except TypeError:
return obj.to_dict()
if isinstance(obj, dict):
return obj
return {"repr": repr(obj)}
def compute_quotas(conn) -> dict[str, Any]:
project = conn.current_project_id
try:
quota = conn.compute.get_quota_set(project)
return _obj_dict(quota)
except Exception:
try:
return dict(conn.get_compute_quotas(project) or {})
except Exception as exc:
raise CloudError(f"Не прочитать compute quota: {exc}") from exc
def volume_quotas(conn) -> dict[str, Any]:
project = conn.current_project_id
try:
quota = conn.block_storage.get_quota_set(project)
return _obj_dict(quota)
except Exception:
try:
return dict(conn.get_volume_quotas(project) or {})
except Exception as exc:
return {"error": str(exc)}
def iter_flavors(conn) -> Iterator[Any]:
yield from conn.compute.flavors(details=True)
def iter_volume_types(conn) -> Iterator[Any]:
yield from conn.block_storage.types()
def iter_images(conn) -> Iterator[Any]:
yield from conn.image.images()
def find_tagged_servers(conn) -> list[Any]:
servers = []
for server in conn.compute.servers(details=True):
tags = set(getattr(server, "tags", None) or [])
name = (getattr(server, "name", "") or "").lower()
if RESOURCE_TAG in tags or name.startswith("gpu-rent"):
servers.append(server)
return servers
def find_volumes_by_name(conn, name: str) -> list[Any]:
found = []
for volume in conn.block_storage.volumes():
if getattr(volume, "name", None) == name:
found.append(volume)
return found
def find_snapshot_by_name(conn, name: str) -> Any | None:
for snap in conn.block_storage.snapshots():
if getattr(snap, "name", None) == name:
return snap
return None
+33
View File
@@ -0,0 +1,33 @@
"""Paths: ~/.gpu-rent, app tree, SSH key."""
from __future__ import annotations
from pathlib import Path
def home_dir() -> Path:
return Path.home() / ".gpu-rent"
def env_path() -> Path:
return home_dir() / ".env"
def state_path() -> Path:
return home_dir() / "state.json"
def lock_path() -> Path:
return home_dir() / "gpu-rent.lock"
def default_ssh_key_path() -> Path:
return home_dir() / "id_ed25519"
def detect_app_root() -> Path:
cwd = Path.cwd().resolve()
for candidate in (cwd, *cwd.parents):
if (candidate / "Models").is_dir() and (candidate / "docs").is_dir():
return candidate
return cwd
+46
View File
@@ -0,0 +1,46 @@
"""Ed25519 key at ~/.gpu-rent/id_ed25519 (no passphrase)."""
from __future__ import annotations
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from gpu_rent.paths import default_ssh_key_path
def public_path(private: Path) -> Path:
return private.with_suffix(private.suffix + ".pub") if private.suffix else Path(str(private) + ".pub")
def ensure_ed25519(path: Path | None = None) -> tuple[Path, Path]:
private = path or default_ssh_key_path()
pub = public_path(private)
if private.is_file() and pub.is_file():
return private, pub
private.parent.mkdir(parents=True, exist_ok=True)
key = Ed25519PrivateKey.generate()
private_bytes = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption(),
)
public_bytes = key.public_key().public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
) + b" gpu-rent\n"
private.write_bytes(private_bytes)
try:
private.chmod(0o600)
except OSError:
pass
pub.write_bytes(public_bytes)
return private, pub
def key_ready(path: Path | None = None) -> bool:
private = path or default_ssh_key_path()
return private.is_file() and public_path(private).is_file()
+73
View File
@@ -0,0 +1,73 @@
"""Local session state. No passwords."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from gpu_rent.paths import home_dir, state_path
CURRENT_VERSION = 1
@dataclass
class SessionState:
version: int = CURRENT_VERSION
phase: str = "idle"
server_id: str | None = None
server_name: str | None = None
flavor_id: str | None = None
flavor_name: str | None = None
boot_volume_id: str | None = None
data_volume_id: str | None = None
floating_ip: str | None = None
keypair_name: str | None = None
created_at: str | None = None
unshelved_at: str | None = None
notes: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> SessionState:
known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
return cls(**known)
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def load_state() -> SessionState:
path = state_path()
if not path.is_file():
return SessionState()
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return SessionState()
return SessionState.from_dict(raw)
def save_state(state: SessionState) -> None:
home_dir().mkdir(parents=True, exist_ok=True)
path = state_path()
path.write_text(json.dumps(state.to_dict(), indent=2) + "\n", encoding="utf-8")
def preempt_window_end(state: SessionState) -> datetime | None:
stamp = state.unshelved_at or state.created_at
if not stamp:
return None
try:
start = datetime.fromisoformat(stamp)
except ValueError:
return None
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
from datetime import timedelta
return start + timedelta(hours=24)
+25
View File
@@ -0,0 +1,25 @@
"""Keep tests from picking up a real ~/.gpu-rent/.env or OS_* from the shell."""
import pytest
OS_KEYS = (
"OS_AUTH_URL",
"OS_USER_DOMAIN_NAME",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"GPU_RENT_AZ",
"CIVITAI_API_TOKEN",
)
@pytest.fixture(autouse=True)
def isolate_home(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.chdir(tmp_path)
for key in OS_KEYS:
monkeypatch.delenv(key, raising=False)
return tmp_path
+24
View File
@@ -0,0 +1,24 @@
from typer.testing import CliRunner
from gpu_rent.cli import app
runner = CliRunner()
def test_help():
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "doctor" in result.stdout
def test_version():
result = runner.invoke(app, ["version"])
assert result.exit_code == 0
assert "0.1.0" in result.stdout
def test_up_nyi_after_missing_env(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
result = runner.invoke(app, ["up"])
assert result.exit_code != 0
+34
View File
@@ -0,0 +1,34 @@
from gpu_rent.config import load_config
from gpu_rent.ssh_keys import ensure_ed25519, public_path
def test_load_config_missing_auth(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(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)
assert not cfg.auth_ok
assert "OS_USERNAME" in cfg.missing
assert cfg.data_volume_size_gb == 100
def test_ssh_key_generate(tmp_path):
private = tmp_path / "id_ed25519"
got, pub = ensure_ed25519(private)
assert got == private
assert pub == public_path(private)
assert private.is_file()
assert pub.is_file()
text = pub.read_text(encoding="utf-8")
assert text.startswith("ssh-ed25519")
ensure_ed25519(private)
assert private.read_bytes()
+57
View File
@@ -0,0 +1,57 @@
from gpu_rent.inventory import (
gpu_boot_image_score,
gpu_quota_from_compute,
match_label,
pick_boot_image,
rank_flavors,
)
class FakeFlavor:
def __init__(self, flavor_id: str, name: str, extra=None, disabled: bool = False):
self.id = flavor_id
self.name = name
self.extra_specs = extra or {}
self.is_disabled = disabled
self.vcpus = 8
self.ram = 32768
def test_match_4090_24_not_48():
a = FakeFlavor("1", "GPU 1x RTX 4090 24GB")
b = FakeFlavor("2", "GPU 1x RTX 4090 48GB")
assert match_label("4090-24", a)
assert not match_label("4090-24", b)
assert match_label("4090-48", b)
def test_rank_skips_disabled():
flavors = [
FakeFlavor("d", "RTX 4090 24GB", disabled=True),
FakeFlavor("ok", "RTX A5000 24GB"),
]
ranked = rank_flavors(flavors, ("4090-24", "a5000"))
assert [x.id for x in ranked] == ["ok"]
assert ranked[0].label == "a5000"
def test_gpu_quota_from_compute():
assert gpu_quota_from_compute({"cores": 10}) is None
assert gpu_quota_from_compute({"gpu": 0}) == 0
assert gpu_quota_from_compute({"GPU_limit": 2}) == 2
def test_pick_boot_image_prefers_24_580_without_docker():
class Img:
def __init__(self, name):
self.name = name
images = [
Img("Ubuntu 24.04 LTS 64-bit GPU Driver 580 Docker"),
Img("Ubuntu 24.04 LTS 64-bit GPU Driver 535"),
Img("Ubuntu 24.04 LTS 64-bit GPU Driver 580"),
Img("Data Science VM (Ubuntu 22.04 LTS 64-bit)"),
]
picked = pick_boot_image(images)
assert picked.name == "Ubuntu 24.04 LTS 64-bit GPU Driver 580"
assert gpu_boot_image_score(picked.name) > gpu_boot_image_score(images[0].name)
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
from gpu_rent.manifests import parse_extensions, parse_models
def test_models_skips_version_zero(tmp_path: Path):
path = tmp_path / "models.yaml"
path.write_text(
"checkpoint:\n - version_id: 0\n - version_id: 123\n - url: https://civitai.red/models/1?modelVersionId=9\n",
encoding="utf-8",
)
entries = parse_models(path)
assert len(entries) == 2
assert entries[0].version_id == 123
assert entries[1].url.endswith("9")
def test_extensions_empty_file(tmp_path: Path):
path = tmp_path / "extensions.yaml"
path.write_text("swarmui: []\ncomfy: []\n", encoding="utf-8")
assert parse_extensions(path) == []
def test_extensions_repo(tmp_path: Path):
path = tmp_path / "extensions.yaml"
path.write_text(
"swarmui:\n - url: https://github.com/org/Ext.git\n ref: main\n dir: Ext\n",
encoding="utf-8",
)
repos = parse_extensions(path)
assert repos[0].kind == "swarmui"
assert repos[0].directory == "Ext"