From 167d07a733abdcee6d9b7fb6baa9f55862bcf752 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 02:42:48 +0300 Subject: [PATCH] first commit --- .gitignore | 33 ++++ CustomWorkflows/.gitkeep | 0 CustomWorkflows/README.md | 3 + LICENSE | 21 ++ Models/README.md | 11 ++ Output/.gitkeep | 0 Output/README.md | 4 + README.md | 48 +++++ Wildcards/.gitkeep | 0 Wildcards/README.md | 3 + docs/README.md | 37 ++++ docs/architecture.md | 173 +++++++++++++++++ docs/autocomplete.md | 94 +++++++++ docs/cli.md | 171 +++++++++++++++++ docs/concept.md | 57 ++++++ docs/decisions.md | 43 +++++ docs/extensions.md | 68 +++++++ docs/local-folders.md | 37 ++++ docs/models.md | 174 +++++++++++++++++ docs/open-questions.md | 13 ++ docs/roadmap.md | 94 +++++++++ docs/selectel.md | 154 +++++++++++++++ docs/setup.md | 201 ++++++++++++++++++++ docs/swarmui.md | 55 ++++++ env.example | 47 +++++ extensions.example.yaml | 13 ++ models.example.yaml | 19 ++ pyproject.toml | 49 +++++ src/gpu_rent/__init__.py | 3 + src/gpu_rent/__main__.py | 4 + src/gpu_rent/civitai.py | 58 ++++++ src/gpu_rent/cli.py | 334 ++++++++++++++++++++++++++++++++ src/gpu_rent/config.py | 190 ++++++++++++++++++ src/gpu_rent/doctor.py | 391 ++++++++++++++++++++++++++++++++++++++ src/gpu_rent/errors.py | 19 ++ src/gpu_rent/inventory.py | 169 ++++++++++++++++ src/gpu_rent/manifests.py | 102 ++++++++++ src/gpu_rent/os_client.py | 118 ++++++++++++ src/gpu_rent/paths.py | 33 ++++ src/gpu_rent/ssh_keys.py | 46 +++++ src/gpu_rent/state.py | 73 +++++++ tests/conftest.py | 25 +++ tests/test_cli.py | 24 +++ tests/test_config.py | 34 ++++ tests/test_inventory.py | 57 ++++++ tests/test_manifests.py | 32 ++++ 46 files changed, 3334 insertions(+) create mode 100644 .gitignore create mode 100644 CustomWorkflows/.gitkeep create mode 100644 CustomWorkflows/README.md create mode 100644 LICENSE create mode 100644 Models/README.md create mode 100644 Output/.gitkeep create mode 100644 Output/README.md create mode 100644 README.md create mode 100644 Wildcards/.gitkeep create mode 100644 Wildcards/README.md create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/autocomplete.md create mode 100644 docs/cli.md create mode 100644 docs/concept.md create mode 100644 docs/decisions.md create mode 100644 docs/extensions.md create mode 100644 docs/local-folders.md create mode 100644 docs/models.md create mode 100644 docs/open-questions.md create mode 100644 docs/roadmap.md create mode 100644 docs/selectel.md create mode 100644 docs/setup.md create mode 100644 docs/swarmui.md create mode 100644 env.example create mode 100644 extensions.example.yaml create mode 100644 models.example.yaml create mode 100644 pyproject.toml create mode 100644 src/gpu_rent/__init__.py create mode 100644 src/gpu_rent/__main__.py create mode 100644 src/gpu_rent/civitai.py create mode 100644 src/gpu_rent/cli.py create mode 100644 src/gpu_rent/config.py create mode 100644 src/gpu_rent/doctor.py create mode 100644 src/gpu_rent/errors.py create mode 100644 src/gpu_rent/inventory.py create mode 100644 src/gpu_rent/manifests.py create mode 100644 src/gpu_rent/os_client.py create mode 100644 src/gpu_rent/paths.py create mode 100644 src/gpu_rent/ssh_keys.py create mode 100644 src/gpu_rent/state.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_inventory.py create mode 100644 tests/test_manifests.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e1df6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CustomWorkflows/.gitkeep b/CustomWorkflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/CustomWorkflows/README.md b/CustomWorkflows/README.md new file mode 100644 index 0000000..8fe385f --- /dev/null +++ b/CustomWorkflows/README.md @@ -0,0 +1,3 @@ +# Comfy / SwarmUI workflows + +Пусто — ничего не едет. Появился JSON — на `gpu-rent up` зальётся в CustomWorkflows на диске. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..44e6bb8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Models/README.md b/Models/README.md new file mode 100644 index 0000000..ead0214 --- /dev/null +++ b/Models/README.md @@ -0,0 +1,11 @@ +# Локальные модели (не коммитить веса) + +Положи сюда чекпоинт (и рядом `.json` / `.civitai.json` / `.swarm.json`, если есть). + +- Папка пустая — на сервер ничего не едет. +- Появилась модель с метадатой — на `gpu-rent up` уедет эта пара (веса + sidecar). +- То, что уже на облачном диске с тем же хешем, повторно не грузим. +- С сервера файлы не удаляем. + +Раскладка как у SwarmUI: `Stable-Diffusion/`, `Lora/`, `VAE/`, `Embeddings/`, `controlnet/`, `upscale_models/`, `clip/`. +Веса в git не попадают. diff --git a/Output/.gitkeep b/Output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Output/README.md b/Output/README.md new file mode 100644 index 0000000..c7b5ab3 --- /dev/null +++ b/Output/README.md @@ -0,0 +1,4 @@ +# Картинки с облака (опциональный pull) + +По умолчанию CLI их сам не скачивает. Включи `PULL_OUTPUT=true` или вызови `gpu-rent pull-output`. +Новые файлы с VM появятся здесь. С сервера ничего не удаляется. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1316f6 --- /dev/null +++ b/README.md @@ -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 не класть. diff --git a/Wildcards/.gitkeep b/Wildcards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Wildcards/README.md b/Wildcards/README.md new file mode 100644 index 0000000..6069a81 --- /dev/null +++ b/Wildcards/README.md @@ -0,0 +1,3 @@ +# Wildcards SwarmUI + +Пусто — на сервер ничего не едет. Положи `.txt` (один вариант на строку) — на `gpu-rent up` уедет в `Data/Wildcards`. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8c91dd3 --- /dev/null +++ b/docs/README.md @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ea5848b --- /dev/null +++ b/docs/architecture.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 | diff --git a/docs/autocomplete.md b/docs/autocomplete.md new file mode 100644 index 0000000..10c5d14 --- /dev/null +++ b/docs/autocomplete.md @@ -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/`. Прописать `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). diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..023b40e --- /dev/null +++ b/docs/cli.md @@ -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 ` | 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 недоступен — только звук и лог, не падать. diff --git a/docs/concept.md b/docs/concept.md new file mode 100644 index 0000000..db8fc64 --- /dev/null +++ b/docs/concept.md @@ -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. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..88ad54b --- /dev/null +++ b/docs/decisions.md @@ -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`. diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 0000000..af8fb55 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,68 @@ +# Расширения: git-репы на первый bootstrap + +Срабатывает вместе с первым диском и первым инстансом, **до** первого старта SwarmUI (C# extensions компилируются при запуске). Повторный `up` на уже засеянный диск только догоняет новые строки манифеста (идемпотентный `git fetch`). + +Два разных мира — не путать: + +| Вид | Куда клонировать | Что это | +| --- | --- | --- | +| `swarmui` | `/mnt/swarm_data/Extensions/` → `/opt/swarmui/src/Extensions/` | C# extension SwarmUI (класс `: Extension`). После рестарта подхватывается сам | +| `comfy` | `/mnt/swarm_data/DLNodes/` → `…/ComfyUIBackend/DLNodes/` | 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 +``` diff --git a/docs/local-folders.md b/docs/local-folders.md new file mode 100644 index 0000000..cd9ed32 --- /dev/null +++ b/docs/local-folders.md @@ -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). diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..b5bbfd4 --- /dev/null +++ b/docs/models.md @@ -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 +``` diff --git a/docs/open-questions.md b/docs/open-questions.md new file mode 100644 index 0000000..7c748a4 --- /dev/null +++ b/docs/open-questions.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..3f6e09f --- /dev/null +++ b/docs/roadmap.md @@ -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 (туннеля достаточно; к сети с телефона не подключались) diff --git a/docs/selectel.md b/docs/selectel.md new file mode 100644 index 0000000..68a203c --- /dev/null +++ b/docs/selectel.md @@ -0,0 +1,154 @@ +# Контракт с Selectel + +Ниже — то, на что опирается код. Не выдуманные URL и не «примерно OpenStack». + +## Иерархия размещения + +| Термин | Пример | Смысл | +| --- | --- | --- | +| Аккаунт | число в углу панели | Domain в Keystone (`OS_USER_DOMAIN_NAME`) | +| Проект | uuid / имя | Scope токена | +| Пул (region) | `ru-7`, `ru-9` | Куда ходит compute API: `https://.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 → ошибка, без автофоллбека. + +## Образы + +Не ставить драйверы 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 `. После 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_`. Определять по 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-`), теги `gpu-rent`, `preemptible`; +- диски: `gpu-rent-boot`, `gpu-rent-data`; +- snapshot boot: `gpu-rent-boot-ok` (один, не на каждый `up`); +- SG / сеть: `gpu-rent`, если создаём сами. + +Не плодить вторую сеть на каждый `up`. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..37ae055 --- /dev/null +++ b/docs/setup.md @@ -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: ) +Пул / сегмент: 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` печатает план без создания сервера. diff --git a/docs/swarmui.md b/docs/swarmui.md new file mode 100644 index 0000000..4731845 --- /dev/null +++ b/docs/swarmui.md @@ -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). diff --git a/env.example b/env.example new file mode 100644 index 0000000..43524fa --- /dev/null +++ b/env.example @@ -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 diff --git a/extensions.example.yaml b/extensions.example.yaml new file mode 100644 index 0000000..e7443dc --- /dev/null +++ b/extensions.example.yaml @@ -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 diff --git a/models.example.yaml b/models.example.yaml new file mode 100644 index 0000000..3814c0a --- /dev/null +++ b/models.example.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e114b4c --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/gpu_rent/__init__.py b/src/gpu_rent/__init__.py new file mode 100644 index 0000000..cd44681 --- /dev/null +++ b/src/gpu_rent/__init__.py @@ -0,0 +1,3 @@ +"""gpu-rent: Selectel GPU session CLI.""" + +__version__ = "0.1.0" diff --git a/src/gpu_rent/__main__.py b/src/gpu_rent/__main__.py new file mode 100644 index 0000000..74eea75 --- /dev/null +++ b/src/gpu_rent/__main__.py @@ -0,0 +1,4 @@ +from gpu_rent.cli import app + +if __name__ == "__main__": + app() diff --git a/src/gpu_rent/civitai.py b/src/gpu_rent/civitai.py new file mode 100644 index 0000000..8a52419 --- /dev/null +++ b/src/gpu_rent/civitai.py @@ -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, + ) diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py new file mode 100644 index 0000000..28b9de9 --- /dev/null +++ b/src/gpu_rent/cli.py @@ -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()) diff --git a/src/gpu_rent/config.py b/src/gpu_rent/config.py new file mode 100644 index 0000000..a8da22a --- /dev/null +++ b/src/gpu_rent/config.py @@ -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, + ) diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py new file mode 100644 index 0000000..7d1ec0b --- /dev/null +++ b/src/gpu_rent/doctor.py @@ -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 diff --git a/src/gpu_rent/errors.py b/src/gpu_rent/errors.py new file mode 100644 index 0000000..2985fea --- /dev/null +++ b/src/gpu_rent/errors.py @@ -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.""" diff --git a/src/gpu_rent/inventory.py b/src/gpu_rent/inventory.py new file mode 100644 index 0000000..62aa0c7 --- /dev/null +++ b/src/gpu_rent/inventory.py @@ -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] diff --git a/src/gpu_rent/manifests.py b/src/gpu_rent/manifests.py new file mode 100644 index 0000000..420669d --- /dev/null +++ b/src/gpu_rent/manifests.py @@ -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 diff --git a/src/gpu_rent/os_client.py b/src/gpu_rent/os_client.py new file mode 100644 index 0000000..7374fb9 --- /dev/null +++ b/src/gpu_rent/os_client.py @@ -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 diff --git a/src/gpu_rent/paths.py b/src/gpu_rent/paths.py new file mode 100644 index 0000000..bcab7f4 --- /dev/null +++ b/src/gpu_rent/paths.py @@ -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 diff --git a/src/gpu_rent/ssh_keys.py b/src/gpu_rent/ssh_keys.py new file mode 100644 index 0000000..9f7bf27 --- /dev/null +++ b/src/gpu_rent/ssh_keys.py @@ -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() diff --git a/src/gpu_rent/state.py b/src/gpu_rent/state.py new file mode 100644 index 0000000..b1fa431 --- /dev/null +++ b/src/gpu_rent/state.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..71b1283 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b893a4d --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b471865 --- /dev/null +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_inventory.py b/tests/test_inventory.py new file mode 100644 index 0000000..092bc28 --- /dev/null +++ b/tests/test_inventory.py @@ -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) diff --git a/tests/test_manifests.py b/tests/test_manifests.py new file mode 100644 index 0000000..d2dfc95 --- /dev/null +++ b/tests/test_manifests.py @@ -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"