From 343f741baa784040e77963060229d2bf4ace715d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 03:20:18 +0300 Subject: [PATCH] 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. --- .gitignore | 4 +- README.md | 2 +- docs/architecture.md | 4 +- docs/cli.md | 8 ++-- docs/decisions.md | 7 +-- docs/extensions.md | 6 +-- docs/models.md | 24 ++++------ docs/roadmap.md | 2 +- docs/setup.md | 17 +++---- env.example | 2 +- extensions.example.yaml | 2 +- gpu-rent.ps1 | 16 +++++-- gpu-rent.sh | 16 +++++-- models.example.yaml | 14 ++++-- src/gpu_rent/config.py | 25 ++++++---- src/gpu_rent/doctor.py | 4 +- src/gpu_rent/paths.py | 100 +++++++++++++++++++++++++++++++-------- src/gpu_rent/ssh_keys.py | 2 +- src/gpu_rent/state.py | 4 +- tests/conftest.py | 2 +- tests/test_config.py | 20 ++++++++ tests/test_launchers.py | 11 +++++ 22 files changed, 202 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 87e1df6..3c3a224 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .env .env.* !.env.example +models.yaml +extensions.yaml # SwarmUI checkpoints / LoRA dropped into the app tree Models/** @@ -27,7 +29,7 @@ __pycache__/ dist/ *.egg-info/ -# Local CLI state (if someone runs from the repo) +# Local CLI state / SSH key (inside the repo, not under %) .gpu-rent/ .pytest_cache/ .mypy_cache/ diff --git a/README.md b/README.md index 117f517..1cc4ae9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ chmod +x gpu-rent.sh # один раз на Unix ./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, диски оставляет. diff --git a/docs/architecture.md b/docs/architecture.md index ea5848b..f2b22bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ │ gpu-rent CLI │ │ up / stop / status / doctor / hold │ │ tunnel / open — SSH localhost:17801 → VM :7801 │ -│ state ~/.gpu-rent/state.json │ +│ state /.gpu-rent/state.json │ │ │ │ браузер / MCP / curl API → http://127.0.0.1:17801 │ │ локальный SwarmUI → http://127.0.0.1:7801 (не трогаем) @@ -169,5 +169,5 @@ Reconcile: сервер с тегом `gpu-rent` есть, локального | --- | --- | | Python 3.11+ | openstacksdk, paramiko, Typer, Rich, questionary, dotenv | | SSH | paramiko + sshtunnel (Windows без системного `ssh -L`) | -| Конфиг | `%USERPROFILE%\.gpu-rent\.env` | +| Конфиг | `/.env` + runtime в `/.gpu-rent/` | | Лицензия | MIT | diff --git a/docs/cli.md b/docs/cli.md index b5764d7..b330d96 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,7 +48,7 @@ 3. Квота GPU > 0; иначе текст «напишите в поддержку Selectel». 4. В сегменте есть хотя бы один flavor из `FLAVOR_PREFERENCE` (не `disabled`). 5. Volume type сегмента существует; data 100 GB влезает в квоту дисков. -6. SSH-ключ: есть `~/.gpu-rent/id_ed25519` или CLI сможет его создать. +6. SSH-ключ: есть `/.gpu-rent/id_ed25519` или CLI сможет его создать. 7. Если задан `CIVITAI_API_TOKEN` — HEAD/лёгкий запрос к `CIVITAI_API_HOST` (дефолт `.red`); манифест парсится. 8. `extensions.yaml` парсится, если файл есть. 9. Локальные папки: предупреждение, если `Models/` огромный относительно свободного места на будущем диске. @@ -98,7 +98,7 @@ Hold killer: gpu-rent hold ## Конфигурация -`%USERPROFILE%\.gpu-rent\.env` / `~/.gpu-rent/.env`: +`.env` в корне репозитория (рядом с `env.example`): ```env OS_AUTH_URL=https://cloud.api.selcloud.ru/identity/v3 @@ -154,7 +154,7 @@ Application credential для idle-killer CLI создаёт при bootstrap и ## State -`~/.gpu-rent/state.json` — ids, фаза (`idle` / `ready_cloud` / `ready_tunneled` / …), flavor, timestamps create/unshelve (для окна 24 ч). Без паролей. Lockfile, чтобы два `up` не создали два сервера. +`/.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 -Лаунчеры в корне: `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: `/.gpu-rent/id_ed25519`. `NOTIFY_READY`: toast через WinRT / `win10toast` (что заработает на 10/11 без админ-прав) + системный звук. Если toast недоступен — только звук и лог, не падать. diff --git a/docs/decisions.md b/docs/decisions.md index 88ad54b..c561a6f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -8,8 +8,8 @@ | Сессия | 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 | +| Манифест моделей | `/models.yaml`, типы: checkpoint / lora / vae / embedding / controlnet / upscaler. В git только `models.example.yaml` | +| Расширения | `/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` | @@ -20,7 +20,8 @@ | Льгота после 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 | +| SSH | CLI генерирует `/.gpu-rent/id_ed25519` без passphrase и сам регистрирует keypair | +| Локальные файлы | Всё в корне репозитория: `.env`, `models.yaml`, `extensions.yaml`; runtime (`state.json`, lock, SSH) в `/.gpu-rent/`. Не `%USERPROFILE%\.gpu-rent` | | Локальный порт | **17801** | | MCP-конфиг Cursor | Только сниппет в stdout, файл не трогаем | | `ready` для API | Ждать Idle backend, не только HTTP UI | diff --git a/docs/extensions.md b/docs/extensions.md index af8fb55..2424e6f 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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 swarmui: @@ -52,7 +52,7 @@ Idle-killer ещё не вооружён. Git нужен на VM (`apt` в boots Публичный 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, не обязательно сейчас. +- Либо отдельный deploy key `/.gpu-rent/git_deploy` — v1.1, не обязательно сейчас. Не использовать `git://`. Только `https://` и `git@` + ключ. @@ -63,6 +63,6 @@ Idle-killer ещё не вооружён. Git нужен на VM (`apt` в boots ## Конфиг ```env -EXTENSIONS_MANIFEST= # пусто = ~/.gpu-rent/extensions.yaml +EXTENSIONS_MANIFEST= # пусто = /extensions.yaml GIT_TOKEN= # опционально, для приватных https ``` diff --git a/docs/models.md b/docs/models.md index b5bbfd4..fa26c01 100644 --- a/docs/models.md +++ b/docs/models.md @@ -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**, не имя файла и не «последняя версия модели» (она плывёт). ```yaml -# ~/.gpu-rent/models.yaml +# models.yaml — пример: Krea 2 + LoRA (см. models.example.yaml) checkpoint: - - version_id: 2514310 + - url: https://civitai.red/models/2786499/realism-by-stable-yogi-krea2?modelVersionId=3231611 lora: - - version_id: 123456 -vae: - - version_id: 789012 -embedding: - - version_id: 111 -controlnet: - - version_id: 222 -upscaler: - - version_id: 333 + - url: https://civitai.red/models/2268008/realistic-snapshot-z-image-turbo-krea-2?modelVersionId=3084537 + - url: https://civitai.red/models/2187487/cutifyier?modelVersionId=3107521 + - url: https://civitai.red/models/1134895/2000s-analog-core?modelVersionId=3103914 ``` -Допустимо вместо `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`): @@ -103,7 +97,7 @@ upscaler: Тип в yaml задаёт **куда класть**, даже если Civitai назвал иначе. Если API-тип совсем другой — предупреждение, кладём по yaml. -В git не коммитить рабочий манифест с личными чекпоинтами. В примере — вымышленные id. +В git не коммитить рабочий манифест с личными чекпоинтами. В `models.example.yaml` — стартовый набор Krea 2 (чекпоинт + три LoRA); скопируй и дополни под себя. ## Civitai API (.com / .red) @@ -169,6 +163,6 @@ REST не раздвоился: те же `/api/v1/...` на обоих хост ```env CIVITAI_API_TOKEN= CIVITAI_API_HOST=civitai.red # полный каталог; civitai.com = только SFW -MODELS_MANIFEST= # пусто = ~/.gpu-rent/models.yaml +MODELS_MANIFEST= # пусто = /models.yaml LOCAL_MODELS_DIR= # пусто = <корень приложения>/Models ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index d742adc..3f8b56e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -53,7 +53,7 @@ - [ ] `status`: диск used/free, ₽/час, TTL preempt 24 ч, killer/hold - [x] Второй `up` не создаёт второй GPU - [ ] `resize-data` вверх -- [x] Генерация `~/.gpu-rent/id_ed25519` + keypair при первом `up` +- [x] Генерация `/.gpu-rent/id_ed25519` + keypair при первом `up` - [x] `seed-models` на живом диске (идемпотентно, SHA256) - [x] `push` / `push-models`: `Models/`, `Wildcards/`, `CustomWorkflows/` - [x] `pull-output` и `PULL_OUTPUT` на `stop` / повторный `up` diff --git a/docs/setup.md b/docs/setup.md index 69511a9..3ea9e41 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -26,7 +26,9 @@ chmod +x gpu-rent.sh ./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%`: только `/.env` и `/.gpu-rent/` (state, SSH-ключ). Оба в `.gitignore`. Если раньше лежало в `~\.gpu-rent\`, CLI один раз перенесёт в проект. Ручной венв по желанию (для разработки / pytest): @@ -129,9 +131,8 @@ python -m gpu_rent --help Создай каталог и файл **вне git**: ```powershell -mkdir $env:USERPROFILE\.gpu-rent -copy env.example $env:USERPROFILE\.gpu-rent\.env -notepad $env:USERPROFILE\.gpu-rent\.env +copy env.example .env +notepad .env ``` Вставь значения из RC + пароль + `GPU_RENT_AZ`. Никогда не коммить `.env`. @@ -163,7 +164,7 @@ openstack flavor list Манифест (не в git со своими id, если не хочешь светить вкусы): ```powershell -copy models.example.yaml $env:USERPROFILE\.gpu-rent\models.yaml +copy models.example.yaml models.yaml ``` `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**. 3. `GIT_TOKEN` в `.env`. -Скопируй шаблон: `extensions.example.yaml` → `~\.gpu-rent\extensions.yaml`. Пустой файл = стоковый SwarmUI. +Скопируй шаблон: `extensions.example.yaml` → `extensions.yaml` в корне репо. Пустой файл = стоковый SwarmUI. --- ## 6. SSH -Ключ **не надо** делать руками. CLI создаст `~\.gpu-rent\id_ed25519` без passphrase и зарегистрирует keypair в OpenStack при первом `up`. `doctor` только проверяет, что это получится. +Ключ **не надо** делать руками. CLI создаст `\.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) - [ ] Сервисный пользователь `member` на этот проект, пароль сохранён - [ ] RC скачан на **тот же пул**, где GPU -- [ ] `~\.gpu-rent\.env` заполнен (`OS_*` + `GPU_RENT_AZ`) +- [ ] `.env` в корне репо заполнен (`OS_*` + `GPU_RENT_AZ`) - [ ] Нет `X-Token` вместо пароля сервисного пользователя - [ ] (опционально) Civitai token + `models.yaml` - [ ] На балансе хватает на диск 100 GB **даже когда GPU выключен** diff --git a/env.example b/env.example index 43524fa..5299971 100644 --- a/env.example +++ b/env.example @@ -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 # X-Token from the panel does NOT work here. diff --git a/extensions.example.yaml b/extensions.example.yaml index e7443dc..6b6a2e4 100644 --- a/extensions.example.yaml +++ b/extensions.example.yaml @@ -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. # swarmui = C# repos cloned to src/Extensions # comfy = Python custom nodes cloned to ComfyUI DLNodes diff --git a/gpu-rent.ps1 b/gpu-rent.ps1 index 080cb6d..a3375d0 100644 --- a/gpu-rent.ps1 +++ b/gpu-rent.ps1 @@ -73,17 +73,25 @@ if ($needInstall) { Get-Date -Format o | Set-Content -LiteralPath $Stamp -Encoding ascii } -$GpuHome = Join-Path $env:USERPROFILE ".gpu-rent" -$EnvFile = Join-Path $GpuHome ".env" +$EnvFile = Join-Path $Root ".env" $Example = Join-Path $Root "env.example" if (-not (Test-Path -LiteralPath $EnvFile)) { - New-Item -ItemType Directory -Force -Path $GpuHome | Out-Null if (Test-Path -LiteralPath $Example) { 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" & $VenvPy -m gpu_rent @args exit $LASTEXITCODE diff --git a/gpu-rent.sh b/gpu-rent.sh index 5d899b0..cfe56b0 100644 --- a/gpu-rent.sh +++ b/gpu-rent.sh @@ -52,11 +52,17 @@ if [[ ! -f "$STAMP" || "$ROOT/pyproject.toml" -nt "$STAMP" ]]; then date -u +"%Y-%m-%dT%H:%M:%SZ" >"$STAMP" fi -GPU_HOME="${HOME}/.gpu-rent" -if [[ ! -f "$GPU_HOME/.env" && -f "$ROOT/env.example" ]]; then - mkdir -p "$GPU_HOME" - cp "$ROOT/env.example" "$GPU_HOME/.env" - echo "gpu-rent: created $GPU_HOME/.env - fill OS_* (see docs/setup.md)" +if [[ ! -f "$ROOT/.env" && -f "$ROOT/env.example" ]]; then + cp "$ROOT/env.example" "$ROOT/.env" + echo "gpu-rent: created .env - fill OS_* (see docs/setup.md)" +fi +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 exec "$VENV_PY" -m gpu_rent "$@" diff --git a/models.example.yaml b/models.example.yaml index 3814c0a..ea77ba6 100644 --- a/models.example.yaml +++ b/models.example.yaml @@ -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). # 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 + # Realism by Stable Yogi — Krea 2 + - url: https://civitai.red/models/2786499/realism-by-stable-yogi-krea2?modelVersionId=3231611 + 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: # - version_id: 0 diff --git a/src/gpu_rent/config.py b/src/gpu_rent/config.py index a8da22a..cb995d2 100644 --- a/src/gpu_rent/config.py +++ b/src/gpu_rent/config.py @@ -1,4 +1,4 @@ -"""Load ~/.gpu-rent/.env. No secrets in git.""" +"""Load /.env. No secrets in git.""" from __future__ import annotations @@ -10,10 +10,13 @@ from dotenv import load_dotenv from gpu_rent.errors import ConfigError from gpu_rent.paths import ( + app_root, default_ssh_key_path, - detect_app_root, 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: - 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() if env_file.is_file(): load_dotenv(env_file, override=False) - app_root = detect_app_root() missing: list[str] = [] required = ( "OS_AUTH_URL", @@ -119,7 +124,7 @@ def load_config(*, require_auth: bool = True) -> Config: if require_auth and missing: raise ConfigError( - "В ~/.gpu-rent/.env не хватает: " + f"В {env_file} не хватает: " + ", ".join(missing) + ". Как заполнить: 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() 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() extensions_manifest = Path( (os.environ.get("EXTENSIONS_MANIFEST") or "").strip() - or (home_dir() / "extensions.yaml") + or str(extensions_manifest_path()) ).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 Path(raw).expanduser() if raw else (root / folder) return Config( 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_workflows_dir=_dir("LOCAL_WORKFLOWS_DIR", "CustomWorkflows"), 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_github_repo=( os.environ.get("AUTOCOMPLETE_GITHUB_REPO") or "DominikDoom/a1111-sd-webui-tagcomplete" diff --git a/src/gpu_rent/doctor.py b/src/gpu_rent/doctor.py index 84668cf..ba14bcd 100644 --- a/src/gpu_rent/doctor.py +++ b/src/gpu_rent/doctor.py @@ -27,7 +27,7 @@ from gpu_rent.os_client import ( volume_quotas, ) 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.state import load_state @@ -42,7 +42,7 @@ class Check: def run_doctor() -> 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() if env_file.is_file(): diff --git a/src/gpu_rent/paths.py b/src/gpu_rent/paths.py index bcab7f4..0b46eb1 100644 --- a/src/gpu_rent/paths.py +++ b/src/gpu_rent/paths.py @@ -1,33 +1,91 @@ -"""Paths: ~/.gpu-rent, app tree, SSH key.""" +"""Paths: project root, local .gpu-rent runtime, SSH key.""" from __future__ import annotations +import shutil 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 + + +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 diff --git a/src/gpu_rent/ssh_keys.py b/src/gpu_rent/ssh_keys.py index 9f7bf27..c841174 100644 --- a/src/gpu_rent/ssh_keys.py +++ b/src/gpu_rent/ssh_keys.py @@ -1,4 +1,4 @@ -"""Ed25519 key at ~/.gpu-rent/id_ed25519 (no passphrase).""" +"""Ed25519 key at /.gpu-rent/id_ed25519 (no passphrase).""" from __future__ import annotations diff --git a/src/gpu_rent/state.py b/src/gpu_rent/state.py index 7e08ce1..120bf4e 100644 --- a/src/gpu_rent/state.py +++ b/src/gpu_rent/state.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from pathlib import Path 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 @@ -60,7 +60,7 @@ def load_state() -> SessionState: 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.write_text(json.dumps(state.to_dict(), indent=2) + "\n", encoding="utf-8") diff --git a/tests/conftest.py b/tests/conftest.py index 71b1283..1fc69c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index b471865..19513ad 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,5 @@ 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 @@ -19,6 +20,25 @@ def test_load_config_missing_auth(monkeypatch, tmp_path): assert not cfg.auth_ok assert "OS_USERNAME" in cfg.missing 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): diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 8d80902..253b0ae 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -17,3 +17,14 @@ def test_launchers_require_python_311(): sh = (ROOT / "gpu-rent.sh").read_text(encoding="utf-8") assert "3, 11" in ps1 or "3.11" in ps1 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