Refactor environment and configuration management

- Updated the project structure to store configuration files (.env, models.yaml, extensions.yaml) in the project root instead of the user's home directory.
- Enhanced the setup process to automatically copy example files to the project root on first run.
- Implemented a migration function to transfer legacy configuration files from the user's home directory to the new project structure.
- Revised documentation to reflect changes in file locations and setup instructions.
- Improved code readability and maintainability by refactoring path management functions.
This commit is contained in:
Leonid Pershin
2026-08-21 03:20:18 +03:00
parent c2ca39a4a7
commit 343f741baa
22 changed files with 202 additions and 90 deletions
+3 -1
View File
@@ -2,6 +2,8 @@
.env .env
.env.* .env.*
!.env.example !.env.example
models.yaml
extensions.yaml
# SwarmUI checkpoints / LoRA dropped into the app tree # SwarmUI checkpoints / LoRA dropped into the app tree
Models/** Models/**
@@ -27,7 +29,7 @@ __pycache__/
dist/ dist/
*.egg-info/ *.egg-info/
# Local CLI state (if someone runs from the repo) # Local CLI state / SSH key (inside the repo, not under %)
.gpu-rent/ .gpu-rent/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
+1 -1
View File
@@ -29,7 +29,7 @@ chmod +x gpu-rent.sh # один раз на Unix
./gpu-rent.sh doctor ./gpu-rent.sh doctor
``` ```
Ключи: скопируй `env.example` в `%USERPROFILE%\.gpu-rent\.env` (скрипт сделает это сам при первом запуске) и заполни `OS_*` по [`docs/setup.md`](docs/setup.md). Ключи: скопируй `env.example` в `.env` в корне репо (скрипт сделает это сам при первом запуске) и заполни `OS_*` по [`docs/setup.md`](docs/setup.md).
`up` поднимает compute и ставит SwarmUI нативно (`launch-linux.sh` + systemd). `tunnel` пробрасывает `localhost:17801`. `stop` гасит GPU, диски оставляет. `up` поднимает compute и ставит SwarmUI нативно (`launch-linux.sh` + systemd). `tunnel` пробрасывает `localhost:17801`. `stop` гасит GPU, диски оставляет.
+2 -2
View File
@@ -7,7 +7,7 @@
│ gpu-rent CLI │ │ gpu-rent CLI │
│ up / stop / status / doctor / hold │ │ up / stop / status / doctor / hold │
│ tunnel / open — SSH localhost:17801 → VM :7801 │ │ tunnel / open — SSH localhost:17801 → VM :7801 │
│ state ~/.gpu-rent/state.json │ state <repo>/.gpu-rent/state.json │
│ │ │ │
│ браузер / MCP / curl API → http://127.0.0.1:17801 │ │ браузер / MCP / curl API → http://127.0.0.1:17801 │
│ локальный SwarmUI → http://127.0.0.1:7801 (не трогаем) │ локальный SwarmUI → http://127.0.0.1:7801 (не трогаем)
@@ -169,5 +169,5 @@ Reconcile: сервер с тегом `gpu-rent` есть, локального
| --- | --- | | --- | --- |
| Python 3.11+ | openstacksdk, paramiko, Typer, Rich, questionary, dotenv | | Python 3.11+ | openstacksdk, paramiko, Typer, Rich, questionary, dotenv |
| SSH | paramiko + sshtunnel (Windows без системного `ssh -L`) | | SSH | paramiko + sshtunnel (Windows без системного `ssh -L`) |
| Конфиг | `%USERPROFILE%\.gpu-rent\.env` | | Конфиг | `<repo>/.env` + runtime в `<repo>/.gpu-rent/` |
| Лицензия | MIT | | Лицензия | MIT |
+4 -4
View File
@@ -48,7 +48,7 @@
3. Квота GPU > 0; иначе текст «напишите в поддержку Selectel». 3. Квота GPU > 0; иначе текст «напишите в поддержку Selectel».
4. В сегменте есть хотя бы один flavor из `FLAVOR_PREFERENCE` (не `disabled`). 4. В сегменте есть хотя бы один flavor из `FLAVOR_PREFERENCE` (не `disabled`).
5. Volume type сегмента существует; data 100 GB влезает в квоту дисков. 5. Volume type сегмента существует; data 100 GB влезает в квоту дисков.
6. SSH-ключ: есть `~/.gpu-rent/id_ed25519` или CLI сможет его создать. 6. SSH-ключ: есть `<repo>/.gpu-rent/id_ed25519` или CLI сможет его создать.
7. Если задан `CIVITAI_API_TOKEN` — HEAD/лёгкий запрос к `CIVITAI_API_HOST` (дефолт `.red`); манифест парсится. 7. Если задан `CIVITAI_API_TOKEN` — HEAD/лёгкий запрос к `CIVITAI_API_HOST` (дефолт `.red`); манифест парсится.
8. `extensions.yaml` парсится, если файл есть. 8. `extensions.yaml` парсится, если файл есть.
9. Локальные папки: предупреждение, если `Models/` огромный относительно свободного места на будущем диске. 9. Локальные папки: предупреждение, если `Models/` огромный относительно свободного места на будущем диске.
@@ -98,7 +98,7 @@ Hold killer: gpu-rent hold
## Конфигурация ## Конфигурация
`%USERPROFILE%\.gpu-rent\.env` / `~/.gpu-rent/.env`: `.env` в корне репозитория (рядом с `env.example`):
```env ```env
OS_AUTH_URL=https://cloud.api.selcloud.ru/identity/v3 OS_AUTH_URL=https://cloud.api.selcloud.ru/identity/v3
@@ -154,7 +154,7 @@ Application credential для idle-killer CLI создаёт при bootstrap и
## State ## State
`~/.gpu-rent/state.json` — ids, фаза (`idle` / `ready_cloud` / `ready_tunneled` / …), flavor, timestamps create/unshelve (для окна 24 ч). Без паролей. Lockfile, чтобы два `up` не создали два сервера. `<repo>/.gpu-rent/state.json` — ids, фаза (`idle` / `ready_cloud` / `ready_tunneled` / …), flavor, timestamps create/unshelve (для окна 24 ч). Без паролей. Lockfile там же, чтобы два `up` не создали два сервера.
## Ожидание готовности ## Ожидание готовности
@@ -168,6 +168,6 @@ Application credential для idle-killer CLI создаёт при bootstrap и
## Windows ## Windows
Лаунчеры в корне: `gpu-rent.bat` (cmd / двойной клик), `.\gpu-rent.ps1` (PowerShell). На Unix — `./gpu-rent.sh`. Они создают `.venv` в репозитории и вызывают `python -m gpu_rent`. Пути в приложении через `Path.expanduser`. Ключ генерирует CLI: `~/.gpu-rent/id_ed25519`. Лаунчеры в корне: `gpu-rent.bat` (cmd / двойной клик), `.\gpu-rent.ps1` (PowerShell). На Unix — `./gpu-rent.sh`. Они создают `.venv` в репозитории и вызывают `python -m gpu_rent`. Ключ генерирует CLI: `<repo>/.gpu-rent/id_ed25519`.
`NOTIFY_READY`: toast через WinRT / `win10toast` (что заработает на 10/11 без админ-прав) + системный звук. Если toast недоступен — только звук и лог, не падать. `NOTIFY_READY`: toast через WinRT / `win10toast` (что заработает на 10/11 без админ-прав) + системный звук. Если toast недоступен — только звук и лог, не падать.
+4 -3
View File
@@ -8,8 +8,8 @@
| Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть | | Сессия | GPU живёт до `stop` **или** простоя (idle-killer **на VM**). Ноут можно закрыть — compute не обязан умереть |
| Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем | | Модели | Пустой `./Models` → не грузим ничего. Появились веса + метадата → на `up` выгрузить эту пару. Civitai-seed отдельно. С сервера не удаляем |
| Civitai хост | Дефолт API `civitai.red` (полный каталог). `.com` — SFW-витрина, NSFW с неё часто 404. Ссылки `.com`/`.red`/`.green` в манифесте принимаем. 404 → один retry на второй хост. Один токен на оба домена | | 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` | | Манифест моделей | `<repo>/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 | | Расширения | `<repo>/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) | | Autocomplete | До первого старта: скачать word-list в `Data/Autocompletions`, прописать `DefaultUser.AutoComplete.Source`. На каждом `up` сверить GitHub blob sha и обновить файл, если изменился. Дефолт: `tags/danbooru.csv` из a1111-sd-webui-tagcomplete (как в доке SwarmUI) |
| Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель | | Доступ | Браузер на туннеле; MCP переключается на облако, пока оно живо; HTTP API SwarmUI через тот же туннель |
| Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` | | Preemptible | По умолчанию всегда. Обычный сервер — только `--no-spot` |
@@ -20,7 +20,8 @@
| Льгота после boot | Killer молчит во время clone/seed/push, hold, качалки в UI SwarmUI, пока backend не Idle, и 45 мин после ACTIVE/unshelve | | Льгота после boot | Killer молчит во время clone/seed/push, hold, качалки в UI SwarmUI, пока backend не Idle, и 45 мин после ACTIVE/unshelve |
| `up` / `tunnel` | Разделены: `up` только облако, `tunnel` отдельно. Ctrl+C на туннеле не удаляет VM | | `up` / `tunnel` | Разделены: `up` только облако, `tunnel` отдельно. Ctrl+C на туннеле не удаляет VM |
| Data-диск | Старт **100 GB**, рост через resize вверх (вниз Selectel не умеет) | | Data-диск | Старт **100 GB**, рост через resize вверх (вниз Selectel не умеет) |
| SSH | CLI генерирует `~/.gpu-rent/id_ed25519` без passphrase и сам регистрирует keypair | | SSH | CLI генерирует `<repo>/.gpu-rent/id_ed25519` без passphrase и сам регистрирует keypair |
| Локальные файлы | Всё в корне репозитория: `.env`, `models.yaml`, `extensions.yaml`; runtime (`state.json`, lock, SSH) в `<repo>/.gpu-rent/`. Не `%USERPROFILE%\.gpu-rent` |
| Локальный порт | **17801** | | Локальный порт | **17801** |
| MCP-конфиг Cursor | Только сниппет в stdout, файл не трогаем | | MCP-конфиг Cursor | Только сниппет в stdout, файл не трогаем |
| `ready` для API | Ждать Idle backend, не только HTTP UI | | `ready` для API | Ждать Idle backend, не только HTTP UI |
+3 -3
View File
@@ -13,7 +13,7 @@
## Манифест ## Манифест
Пример: [`extensions.example.yaml`](../extensions.example.yaml). Рабочая копия: `~/.gpu-rent/extensions.yaml` (или `EXTENSIONS_MANIFEST`). Пример: [`extensions.example.yaml`](../extensions.example.yaml). Рабочая копия: `extensions.yaml` в корне репо (или `EXTENSIONS_MANIFEST`).
```yaml ```yaml
swarmui: swarmui:
@@ -52,7 +52,7 @@ Idle-killer ещё не вооружён. Git нужен на VM (`apt` в boots
Публичный HTTPS без секретов. Приватный репозиторий: Публичный HTTPS без секретов. Приватный репозиторий:
- `GIT_TOKEN` в `.env` (GitHub: `x-access-token`; GitLab: `oauth2` / PAT). CLI подставляет в URL **только на VM**, не пишет токен в yaml и не в cloud-init. - `GIT_TOKEN` в `.env` (GitHub: `x-access-token`; GitLab: `oauth2` / PAT). CLI подставляет в URL **только на VM**, не пишет токен в yaml и не в cloud-init.
- Либо отдельный deploy key `~/.gpu-rent/git_deploy` — v1.1, не обязательно сейчас. - Либо отдельный deploy key `<repo>/.gpu-rent/git_deploy` — v1.1, не обязательно сейчас.
Не использовать `git://`. Только `https://` и `git@` + ключ. Не использовать `git://`. Только `https://` и `git@` + ключ.
@@ -63,6 +63,6 @@ Idle-killer ещё не вооружён. Git нужен на VM (`apt` в boots
## Конфиг ## Конфиг
```env ```env
EXTENSIONS_MANIFEST= # пусто = ~/.gpu-rent/extensions.yaml EXTENSIONS_MANIFEST= # пусто = <repo>/extensions.yaml
GIT_TOKEN= # опционально, для приватных https GIT_TOKEN= # опционально, для приватных https
``` ```
+9 -15
View File
@@ -67,27 +67,21 @@ ComfyUI в `dlbackend` качается всегда. Речь только о *
## Манифест ## Манифест
Пример в репозитории: [`models.example.yaml`](../models.example.yaml). Рабочая копия: `~/.gpu-rent/models.yaml` (или путь `MODELS_MANIFEST`). Пример в репозитории: [`models.example.yaml`](../models.example.yaml). Рабочая копия: `models.yaml` в корне репо (или путь `MODELS_MANIFEST`).
Идентификатор — **Civitai model version id**, не имя файла и не «последняя версия модели» (она плывёт). Идентификатор — **Civitai model version id**, не имя файла и не «последняя версия модели» (она плывёт).
```yaml ```yaml
# ~/.gpu-rent/models.yaml # models.yaml — пример: Krea 2 + LoRA (см. models.example.yaml)
checkpoint: checkpoint:
- version_id: 2514310 - url: https://civitai.red/models/2786499/realism-by-stable-yogi-krea2?modelVersionId=3231611
lora: lora:
- version_id: 123456 - url: https://civitai.red/models/2268008/realistic-snapshot-z-image-turbo-krea-2?modelVersionId=3084537
vae: - url: https://civitai.red/models/2187487/cutifyier?modelVersionId=3107521
- version_id: 789012 - url: https://civitai.red/models/1134895/2000s-analog-core?modelVersionId=3103914
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. Допустимо вместо `url` поле `version_id` (число из `modelVersionId=`). Хосты `civitai.com`, `civitai.red`, `civitai.green` равнозначны (зелёный редиректит на `.com`). Несколько файлов у версии: берём `files[]` с `"primary": true`, иначе SafeTensor / fp16.
Типы манифеста → каталоги SwarmUI (`ModelRoot` = `Models`): Типы манифеста → каталоги SwarmUI (`ModelRoot` = `Models`):
@@ -103,7 +97,7 @@ upscaler:
Тип в yaml задаёт **куда класть**, даже если Civitai назвал иначе. Если API-тип совсем другой — предупреждение, кладём по yaml. Тип в yaml задаёт **куда класть**, даже если Civitai назвал иначе. Если API-тип совсем другой — предупреждение, кладём по yaml.
В git не коммитить рабочий манифест с личными чекпоинтами. В примере — вымышленные id. В git не коммитить рабочий манифест с личными чекпоинтами. В `models.example.yaml` — стартовый набор Krea 2 (чекпоинт + три LoRA); скопируй и дополни под себя.
## Civitai API (.com / .red) ## Civitai API (.com / .red)
@@ -169,6 +163,6 @@ REST не раздвоился: те же `/api/v1/...` на обоих хост
```env ```env
CIVITAI_API_TOKEN= CIVITAI_API_TOKEN=
CIVITAI_API_HOST=civitai.red # полный каталог; civitai.com = только SFW CIVITAI_API_HOST=civitai.red # полный каталог; civitai.com = только SFW
MODELS_MANIFEST= # пусто = ~/.gpu-rent/models.yaml MODELS_MANIFEST= # пусто = <repo>/models.yaml
LOCAL_MODELS_DIR= # пусто = <корень приложения>/Models LOCAL_MODELS_DIR= # пусто = <корень приложения>/Models
``` ```
+1 -1
View File
@@ -53,7 +53,7 @@
- [ ] `status`: диск used/free, ₽/час, TTL preempt 24 ч, killer/hold - [ ] `status`: диск used/free, ₽/час, TTL preempt 24 ч, killer/hold
- [x] Второй `up` не создаёт второй GPU - [x] Второй `up` не создаёт второй GPU
- [ ] `resize-data` вверх - [ ] `resize-data` вверх
- [x] Генерация `~/.gpu-rent/id_ed25519` + keypair при первом `up` - [x] Генерация `<repo>/.gpu-rent/id_ed25519` + keypair при первом `up`
- [x] `seed-models` на живом диске (идемпотентно, SHA256) - [x] `seed-models` на живом диске (идемпотентно, SHA256)
- [x] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/` - [x] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/`
- [x] `pull-output` и `PULL_OUTPUT` на `stop` / повторный `up` - [x] `pull-output` и `PULL_OUTPUT` на `stop` / повторный `up`
+9 -8
View File
@@ -26,7 +26,9 @@ chmod +x gpu-rent.sh
./gpu-rent.sh --help ./gpu-rent.sh --help
``` ```
Первый запуск копирует `env.example` в `%USERPROFILE%\.gpu-rent\.env` / `~/.gpu-rent/.env`, если файла ещё нет. Заполни `OS_*` (шаги ниже). После `git pull`, если изменился `pyproject.toml`, лаунчер переустановит пакет. Первый запуск копирует `env.example` `.env`, `models.example.yaml` `models.yaml`, `extensions.example.yaml``extensions.yaml` в **корне репозитория**, если файлов ещё нет. Заполни `OS_*` в `.env` (шаги ниже). После `git pull`, если изменился `pyproject.toml`, лаунчер переустановит пакет.
Секреты и runtime не уезжают в `%USERPROFILE%`: только `<repo>/.env` и `<repo>/.gpu-rent/` (state, SSH-ключ). Оба в `.gitignore`. Если раньше лежало в `~\.gpu-rent\`, CLI один раз перенесёт в проект.
Ручной венв по желанию (для разработки / pytest): Ручной венв по желанию (для разработки / pytest):
@@ -129,9 +131,8 @@ python -m gpu_rent --help
Создай каталог и файл **вне git**: Создай каталог и файл **вне git**:
```powershell ```powershell
mkdir $env:USERPROFILE\.gpu-rent copy env.example .env
copy env.example $env:USERPROFILE\.gpu-rent\.env notepad .env
notepad $env:USERPROFILE\.gpu-rent\.env
``` ```
Вставь значения из RC + пароль + `GPU_RENT_AZ`. Никогда не коммить `.env`. Вставь значения из RC + пароль + `GPU_RENT_AZ`. Никогда не коммить `.env`.
@@ -163,7 +164,7 @@ openstack flavor list
Манифест (не в git со своими id, если не хочешь светить вкусы): Манифест (не в git со своими id, если не хочешь светить вкусы):
```powershell ```powershell
copy models.example.yaml $env:USERPROFILE\.gpu-rent\models.yaml copy models.example.yaml models.yaml
``` ```
`version_id: 0` — заглушка, doctor её игнорирует. Нужен **modelVersionId** из URL, не id карточки модели. `version_id: 0` — заглушка, doctor её игнорирует. Нужен **modelVersionId** из URL, не id карточки модели.
@@ -180,13 +181,13 @@ copy models.example.yaml $env:USERPROFILE\.gpu-rent\models.yaml
2. Fine-grained: доступ только к нужным репам, **Contents: Read**. 2. Fine-grained: доступ только к нужным репам, **Contents: Read**.
3. `GIT_TOKEN` в `.env`. 3. `GIT_TOKEN` в `.env`.
Скопируй шаблон: `extensions.example.yaml``~\.gpu-rent\extensions.yaml`. Пустой файл = стоковый SwarmUI. Скопируй шаблон: `extensions.example.yaml``extensions.yaml` в корне репо. Пустой файл = стоковый SwarmUI.
--- ---
## 6. SSH ## 6. SSH
Ключ **не надо** делать руками. CLI создаст `~\.gpu-rent\id_ed25519` без passphrase и зарегистрирует keypair в OpenStack при первом `up`. `doctor` только проверяет, что это получится. Ключ **не надо** делать руками. CLI создаст `<repo>\.gpu-rent\id_ed25519` без passphrase и зарегистрирует keypair в OpenStack при первом `up`. `doctor` только проверяет, что это получится.
--- ---
@@ -197,7 +198,7 @@ copy models.example.yaml $env:USERPROFILE\.gpu-rent\models.yaml
- [ ] Тикет на лимит **1× GPU** в нужном сегменте (или квота уже > 0) - [ ] Тикет на лимит **1× GPU** в нужном сегменте (или квота уже > 0)
- [ ] Сервисный пользователь `member` на этот проект, пароль сохранён - [ ] Сервисный пользователь `member` на этот проект, пароль сохранён
- [ ] RC скачан на **тот же пул**, где GPU - [ ] RC скачан на **тот же пул**, где GPU
- [ ] `~\.gpu-rent\.env` заполнен (`OS_*` + `GPU_RENT_AZ`) - [ ] `.env` в корне репо заполнен (`OS_*` + `GPU_RENT_AZ`)
- [ ] Нет `X-Token` вместо пароля сервисного пользователя - [ ] Нет `X-Token` вместо пароля сервисного пользователя
- [ ] (опционально) Civitai token + `models.yaml` - [ ] (опционально) Civitai token + `models.yaml`
- [ ] На балансе хватает на диск 100 GB **даже когда GPU выключен** - [ ] На балансе хватает на диск 100 GB **даже когда GPU выключен**
+1 -1
View File
@@ -1,4 +1,4 @@
# Copy to %USERPROFILE%\.gpu-rent\.env (never commit the copy) # Copy to .env in this repo (never commit the copy)
# How to fill: docs/setup.md # How to fill: docs/setup.md
# X-Token from the panel does NOT work here. # X-Token from the panel does NOT work here.
+1 -1
View File
@@ -1,4 +1,4 @@
# Copy to ~/.gpu-rent/extensions.yaml and put real repo URLs. # Copy to extensions.yaml in this repo and put real repo URLs.
# Empty/missing file → no extra extensions, stock SwarmUI. # Empty/missing file → no extra extensions, stock SwarmUI.
# swarmui = C# repos cloned to src/Extensions # swarmui = C# repos cloned to src/Extensions
# comfy = Python custom nodes cloned to ComfyUI DLNodes # comfy = Python custom nodes cloned to ComfyUI DLNodes
+12 -4
View File
@@ -73,17 +73,25 @@ if ($needInstall) {
Get-Date -Format o | Set-Content -LiteralPath $Stamp -Encoding ascii Get-Date -Format o | Set-Content -LiteralPath $Stamp -Encoding ascii
} }
$GpuHome = Join-Path $env:USERPROFILE ".gpu-rent" $EnvFile = Join-Path $Root ".env"
$EnvFile = Join-Path $GpuHome ".env"
$Example = Join-Path $Root "env.example" $Example = Join-Path $Root "env.example"
if (-not (Test-Path -LiteralPath $EnvFile)) { if (-not (Test-Path -LiteralPath $EnvFile)) {
New-Item -ItemType Directory -Force -Path $GpuHome | Out-Null
if (Test-Path -LiteralPath $Example) { if (Test-Path -LiteralPath $Example) {
Copy-Item -LiteralPath $Example -Destination $EnvFile Copy-Item -LiteralPath $Example -Destination $EnvFile
Write-Host "gpu-rent: created $EnvFile - fill OS_* (docs/setup.md)" Write-Host "gpu-rent: created .env - fill OS_* (docs/setup.md)"
} }
} }
function Copy-IfMissing {
param([string]$Src, [string]$Dst, [string]$Label)
if ((Test-Path -LiteralPath $Src) -and -not (Test-Path -LiteralPath $Dst)) {
Copy-Item -LiteralPath $Src -Destination $Dst
Write-Host "gpu-rent: created $Label"
}
}
Copy-IfMissing (Join-Path $Root "models.example.yaml") (Join-Path $Root "models.yaml") "models.yaml"
Copy-IfMissing (Join-Path $Root "extensions.example.yaml") (Join-Path $Root "extensions.yaml") "extensions.yaml"
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
& $VenvPy -m gpu_rent @args & $VenvPy -m gpu_rent @args
exit $LASTEXITCODE exit $LASTEXITCODE
+11 -5
View File
@@ -52,11 +52,17 @@ if [[ ! -f "$STAMP" || "$ROOT/pyproject.toml" -nt "$STAMP" ]]; then
date -u +"%Y-%m-%dT%H:%M:%SZ" >"$STAMP" date -u +"%Y-%m-%dT%H:%M:%SZ" >"$STAMP"
fi fi
GPU_HOME="${HOME}/.gpu-rent" if [[ ! -f "$ROOT/.env" && -f "$ROOT/env.example" ]]; then
if [[ ! -f "$GPU_HOME/.env" && -f "$ROOT/env.example" ]]; then cp "$ROOT/env.example" "$ROOT/.env"
mkdir -p "$GPU_HOME" echo "gpu-rent: created .env - fill OS_* (see docs/setup.md)"
cp "$ROOT/env.example" "$GPU_HOME/.env" fi
echo "gpu-rent: created $GPU_HOME/.env - fill OS_* (see docs/setup.md)" if [[ ! -f "$ROOT/models.yaml" && -f "$ROOT/models.example.yaml" ]]; then
cp "$ROOT/models.example.yaml" "$ROOT/models.yaml"
echo "gpu-rent: created models.yaml"
fi
if [[ ! -f "$ROOT/extensions.yaml" && -f "$ROOT/extensions.example.yaml" ]]; then
cp "$ROOT/extensions.example.yaml" "$ROOT/extensions.yaml"
echo "gpu-rent: created extensions.yaml"
fi fi
exec "$VENV_PY" -m gpu_rent "$@" exec "$VENV_PY" -m gpu_rent "$@"
+10 -4
View File
@@ -1,13 +1,19 @@
# Copy to ~/.gpu-rent/models.yaml # Copy to models.yaml in this repo (never commit the working copy).
# version_id = modelVersionId from the URL (not the model id). # version_id = modelVersionId from the URL (not the model id).
# url may be civitai.com or civitai.red — both work. # url may be civitai.com or civitai.red — both work.
# Requires CIVITAI_API_TOKEN. Empty file → SwarmUI default model. # Requires CIVITAI_API_TOKEN. Empty file → SwarmUI default model.
checkpoint: checkpoint:
- version_id: 0 # Realism by Stable Yogi — Krea 2
# - url: https://civitai.red/models/123?modelVersionId=456 - url: https://civitai.red/models/2786499/realism-by-stable-yogi-krea2?modelVersionId=3231611
lora: lora:
- version_id: 0 # Realistic Snapshot — Z-Image Turbo / Krea 2
- url: https://civitai.red/models/2268008/realistic-snapshot-z-image-turbo-krea-2?modelVersionId=3084537
# Cutifyier
- url: https://civitai.red/models/2187487/cutifyier?modelVersionId=3107521
# 2000s Analog Core
- url: https://civitai.red/models/1134895/2000s-analog-core?modelVersionId=3103914
# vae: # vae:
# - version_id: 0 # - version_id: 0
+15 -10
View File
@@ -1,4 +1,4 @@
"""Load ~/.gpu-rent/.env. No secrets in git.""" """Load <project>/.env. No secrets in git."""
from __future__ import annotations from __future__ import annotations
@@ -10,10 +10,13 @@ from dotenv import load_dotenv
from gpu_rent.errors import ConfigError from gpu_rent.errors import ConfigError
from gpu_rent.paths import ( from gpu_rent.paths import (
app_root,
default_ssh_key_path, default_ssh_key_path,
detect_app_root,
env_path, env_path,
home_dir, extensions_manifest_path,
migrate_legacy_if_needed,
models_manifest_path,
runtime_dir,
) )
@@ -96,12 +99,14 @@ def _required(name: str) -> str:
def load_config(*, require_auth: bool = True) -> Config: def load_config(*, require_auth: bool = True) -> Config:
home_dir().mkdir(parents=True, exist_ok=True) root = app_root()
runtime_dir().mkdir(parents=True, exist_ok=True)
migrate_legacy_if_needed()
env_file = env_path() env_file = env_path()
if env_file.is_file(): if env_file.is_file():
load_dotenv(env_file, override=False) load_dotenv(env_file, override=False)
app_root = detect_app_root()
missing: list[str] = [] missing: list[str] = []
required = ( required = (
"OS_AUTH_URL", "OS_AUTH_URL",
@@ -119,7 +124,7 @@ def load_config(*, require_auth: bool = True) -> Config:
if require_auth and missing: if require_auth and missing:
raise ConfigError( raise ConfigError(
"В ~/.gpu-rent/.env не хватает: " f"В {env_file} не хватает: "
+ ", ".join(missing) + ", ".join(missing)
+ ". Как заполнить: docs/setup.md (сервисный пользователь, не X-Token)." + ". Как заполнить: docs/setup.md (сервисный пользователь, не X-Token)."
) )
@@ -128,16 +133,16 @@ def load_config(*, require_auth: bool = True) -> Config:
ssh_key = Path(key_override).expanduser() if key_override else default_ssh_key_path() ssh_key = Path(key_override).expanduser() if key_override else default_ssh_key_path()
models_manifest = Path( models_manifest = Path(
(os.environ.get("MODELS_MANIFEST") or "").strip() or (home_dir() / "models.yaml") (os.environ.get("MODELS_MANIFEST") or "").strip() or str(models_manifest_path())
).expanduser() ).expanduser()
extensions_manifest = Path( extensions_manifest = Path(
(os.environ.get("EXTENSIONS_MANIFEST") or "").strip() (os.environ.get("EXTENSIONS_MANIFEST") or "").strip()
or (home_dir() / "extensions.yaml") or str(extensions_manifest_path())
).expanduser() ).expanduser()
def _dir(env_name: str, folder: str) -> Path: def _dir(env_name: str, folder: str) -> Path:
raw = (os.environ.get(env_name) or "").strip() raw = (os.environ.get(env_name) or "").strip()
return Path(raw).expanduser() if raw else (app_root / folder) return Path(raw).expanduser() if raw else (root / folder)
return Config( return Config(
os_auth_url=values["OS_AUTH_URL"] or "https://cloud.api.selcloud.ru/identity/v3", os_auth_url=values["OS_AUTH_URL"] or "https://cloud.api.selcloud.ru/identity/v3",
@@ -162,7 +167,7 @@ def load_config(*, require_auth: bool = True) -> Config:
local_wildcards_dir=_dir("LOCAL_WILDCARDS_DIR", "Wildcards"), local_wildcards_dir=_dir("LOCAL_WILDCARDS_DIR", "Wildcards"),
local_workflows_dir=_dir("LOCAL_WORKFLOWS_DIR", "CustomWorkflows"), local_workflows_dir=_dir("LOCAL_WORKFLOWS_DIR", "CustomWorkflows"),
local_output_dir=_dir("LOCAL_OUTPUT_DIR", "Output"), local_output_dir=_dir("LOCAL_OUTPUT_DIR", "Output"),
app_root=app_root, app_root=root,
autocomplete_enabled=_as_bool(os.environ.get("AUTOCOMPLETE_ENABLED"), True), autocomplete_enabled=_as_bool(os.environ.get("AUTOCOMPLETE_ENABLED"), True),
autocomplete_github_repo=( autocomplete_github_repo=(
os.environ.get("AUTOCOMPLETE_GITHUB_REPO") or "DominikDoom/a1111-sd-webui-tagcomplete" os.environ.get("AUTOCOMPLETE_GITHUB_REPO") or "DominikDoom/a1111-sd-webui-tagcomplete"
+2 -2
View File
@@ -27,7 +27,7 @@ from gpu_rent.os_client import (
volume_quotas, volume_quotas,
) )
from gpu_rent.payload import folder_bytes as _folder_bytes, has_payload as _has_payload from gpu_rent.payload import folder_bytes as _folder_bytes, has_payload as _has_payload
from gpu_rent.paths import env_path, home_dir from gpu_rent.paths import env_path, runtime_dir
from gpu_rent.ssh_keys import key_ready from gpu_rent.ssh_keys import key_ready
from gpu_rent.state import load_state from gpu_rent.state import load_state
@@ -42,7 +42,7 @@ class Check:
def run_doctor() -> list[Check]: def run_doctor() -> list[Check]:
checks: list[Check] = [] checks: list[Check] = []
home_dir().mkdir(parents=True, exist_ok=True) runtime_dir().mkdir(parents=True, exist_ok=True)
env_file = env_path() env_file = env_path()
if env_file.is_file(): if env_file.is_file():
+79 -21
View File
@@ -1,33 +1,91 @@
"""Paths: ~/.gpu-rent, app tree, SSH key.""" """Paths: project root, local .gpu-rent runtime, SSH key."""
from __future__ import annotations from __future__ import annotations
import shutil
from pathlib import Path 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: def detect_app_root() -> Path:
cwd = Path.cwd().resolve() cwd = Path.cwd().resolve()
for candidate in (cwd, *cwd.parents): for candidate in (cwd, *cwd.parents):
if (candidate / "Models").is_dir() and (candidate / "docs").is_dir(): if (candidate / "Models").is_dir() and (candidate / "docs").is_dir():
return candidate return candidate
return cwd return cwd
def app_root() -> Path:
return detect_app_root()
def runtime_dir() -> Path:
"""State, lock, SSH key — always inside the project (gitignored)."""
return app_root() / ".gpu-rent"
# Back-compat alias used across the package.
def home_dir() -> Path:
return runtime_dir()
def env_path() -> Path:
return app_root() / ".env"
def models_manifest_path() -> Path:
return app_root() / "models.yaml"
def extensions_manifest_path() -> Path:
return app_root() / "extensions.yaml"
def state_path() -> Path:
return runtime_dir() / "state.json"
def lock_path() -> Path:
return runtime_dir() / "gpu-rent.lock"
def default_ssh_key_path() -> Path:
return runtime_dir() / "id_ed25519"
def legacy_user_dir() -> Path:
"""Old location (~/.gpu-rent) — only for one-shot migrate."""
return Path.home() / ".gpu-rent"
def migrate_legacy_if_needed() -> list[str]:
"""Copy ~/.gpu-rent leftovers into the project once. Returns log lines."""
legacy = legacy_user_dir()
if not legacy.is_dir():
return []
root = app_root()
runtime = runtime_dir()
notes: list[str] = []
pairs = (
(legacy / ".env", env_path()),
(legacy / "models.yaml", models_manifest_path()),
(legacy / "extensions.yaml", extensions_manifest_path()),
(legacy / "state.json", state_path()),
(legacy / "id_ed25519", default_ssh_key_path()),
(legacy / "id_ed25519.pub", default_ssh_key_path().with_suffix(".pub")),
)
for src, dst in pairs:
if not src.is_file() or dst.is_file():
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
notes.append(f"перенёс {src}{dst}")
if notes:
runtime.mkdir(parents=True, exist_ok=True)
marker = runtime / ".migrated-from-user-home"
if not marker.is_file():
marker.write_text(
f"from {legacy}\n" + "\n".join(notes) + "\n",
encoding="utf-8",
)
return notes
+1 -1
View File
@@ -1,4 +1,4 @@
"""Ed25519 key at ~/.gpu-rent/id_ed25519 (no passphrase).""" """Ed25519 key at <project>/.gpu-rent/id_ed25519 (no passphrase)."""
from __future__ import annotations from __future__ import annotations
+2 -2
View File
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from gpu_rent.paths import home_dir, state_path from gpu_rent.paths import runtime_dir, state_path
CURRENT_VERSION = 1 CURRENT_VERSION = 1
@@ -60,7 +60,7 @@ def load_state() -> SessionState:
def save_state(state: SessionState) -> None: def save_state(state: SessionState) -> None:
home_dir().mkdir(parents=True, exist_ok=True) runtime_dir().mkdir(parents=True, exist_ok=True)
path = state_path() path = state_path()
path.write_text(json.dumps(state.to_dict(), indent=2) + "\n", encoding="utf-8") path.write_text(json.dumps(state.to_dict(), indent=2) + "\n", encoding="utf-8")
+1 -1
View File
@@ -1,4 +1,4 @@
"""Keep tests from picking up a real ~/.gpu-rent/.env or OS_* from the shell.""" """Keep tests from picking up a real project .env or OS_* from the shell."""
import pytest import pytest
+20
View File
@@ -1,4 +1,5 @@
from gpu_rent.config import load_config from gpu_rent.config import load_config
from gpu_rent.paths import env_path, migrate_legacy_if_needed, models_manifest_path, runtime_dir
from gpu_rent.ssh_keys import ensure_ed25519, public_path from gpu_rent.ssh_keys import ensure_ed25519, public_path
@@ -19,6 +20,25 @@ def test_load_config_missing_auth(monkeypatch, tmp_path):
assert not cfg.auth_ok assert not cfg.auth_ok
assert "OS_USERNAME" in cfg.missing assert "OS_USERNAME" in cfg.missing
assert cfg.data_volume_size_gb == 100 assert cfg.data_volume_size_gb == 100
assert cfg.models_manifest == models_manifest_path()
assert runtime_dir() == tmp_path / ".gpu-rent"
assert env_path() == tmp_path / ".env"
def test_migrate_legacy_user_home(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path / "user"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "user"))
proj = tmp_path / "proj"
proj.mkdir()
monkeypatch.chdir(proj)
legacy = tmp_path / "user" / ".gpu-rent"
legacy.mkdir(parents=True)
(legacy / "models.yaml").write_text("checkpoint:\n - version_id: 1\n", encoding="utf-8")
(legacy / ".env").write_text("OS_USERNAME=x\n", encoding="utf-8")
notes = migrate_legacy_if_needed()
assert notes
assert (proj / "models.yaml").is_file()
assert (proj / ".env").is_file()
def test_ssh_key_generate(tmp_path): def test_ssh_key_generate(tmp_path):
+11
View File
@@ -17,3 +17,14 @@ def test_launchers_require_python_311():
sh = (ROOT / "gpu-rent.sh").read_text(encoding="utf-8") sh = (ROOT / "gpu-rent.sh").read_text(encoding="utf-8")
assert "3, 11" in ps1 or "3.11" in ps1 assert "3, 11" in ps1 or "3.11" in ps1
assert "3, 11" in sh assert "3, 11" in sh
def test_launchers_seed_project_local_files():
ps1 = (ROOT / "gpu-rent.ps1").read_text(encoding="utf-8")
sh = (ROOT / "gpu-rent.sh").read_text(encoding="utf-8")
assert 'Join-Path $env:USERPROFILE ".gpu-rent"' not in ps1
assert "${HOME}/.gpu-rent" not in sh
assert "models.yaml" in ps1 and "models.yaml" in sh
assert "extensions.yaml" in ps1 and "extensions.yaml" in sh
assert 'Join-Path $Root ".env"' in ps1
assert "$ROOT/.env" in sh