first commit
This commit is contained in:
+33
@@ -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/
|
||||
@@ -0,0 +1,3 @@
|
||||
# Comfy / SwarmUI workflows
|
||||
|
||||
Пусто — ничего не едет. Появился JSON — на `gpu-rent up` зальётся в CustomWorkflows на диске.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Локальные модели (не коммитить веса)
|
||||
|
||||
Положи сюда чекпоинт (и рядом `.json` / `.civitai.json` / `.swarm.json`, если есть).
|
||||
|
||||
- Папка пустая — на сервер ничего не едет.
|
||||
- Появилась модель с метадатой — на `gpu-rent up` уедет эта пара (веса + sidecar).
|
||||
- То, что уже на облачном диске с тем же хешем, повторно не грузим.
|
||||
- С сервера файлы не удаляем.
|
||||
|
||||
Раскладка как у SwarmUI: `Stable-Diffusion/`, `Lora/`, `VAE/`, `Embeddings/`, `controlnet/`, `upscale_models/`, `clip/`.
|
||||
Веса в git не попадают.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Картинки с облака (опциональный pull)
|
||||
|
||||
По умолчанию CLI их сам не скачивает. Включи `PULL_OUTPUT=true` или вызови `gpu-rent pull-output`.
|
||||
Новые файлы с VM появятся здесь. С сервера ничего не удаляется.
|
||||
@@ -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 не класть.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Wildcards SwarmUI
|
||||
|
||||
Пусто — на сервер ничего не едет. Положи `.txt` (один вариант на строку) — на `gpu-rent up` уедет в `Data/Wildcards`.
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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 ≈ 3–4 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
@@ -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, ~5–10 мин на 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 недоступен — только звук и лог, не падать.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 (туннеля достаточно; к сети с телефона не подключались)
|
||||
@@ -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
@@ -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` печатает план без создания сервера.
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""gpu-rent: Selectel GPU session CLI."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from gpu_rent.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user