удаление test-контура (дополнение)
This commit is contained in:
+12
-13
@@ -1,5 +1,5 @@
|
|||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Forbidden Stars — единый .env (dev / test / prod)
|
# Forbidden Stars — единый .env (dev / prod)
|
||||||
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
|
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
|
||||||
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
|
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
||||||
# test — прод-клон в Docker локально (порт 8080), вход только TG
|
|
||||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
@@ -15,21 +14,21 @@ APP_ENV=development
|
|||||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||||
# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им
|
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают.
|
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||||
LOCAL_PUBLIC=local
|
LOCAL_PUBLIC=local
|
||||||
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod.
|
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода.
|
||||||
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
|
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
|
||||||
VPS_TUNNEL_HOST=186.246.51.17
|
VPS_TUNNEL_HOST=186.246.51.17
|
||||||
VPS_TUNNEL_USER=tunnel
|
VPS_TUNNEL_USER=tunnel
|
||||||
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
|
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
|
||||||
# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001.
|
# dev (run.ps1) → 9001, прод-контейнер → 9000.
|
||||||
# Раскомментируй и переопредели, только если нужен нестандартный слот.
|
# Раскомментируй и переопредели, только если нужен нестандартный слот.
|
||||||
#VPS_TUNNEL_PORT=9001
|
#VPS_TUNNEL_PORT=9001
|
||||||
|
|
||||||
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
|
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
|
||||||
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test
|
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод
|
||||||
# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
|
# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
|
||||||
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
|
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
|
||||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
|
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel")))
|
||||||
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
|
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
|
||||||
@@ -45,10 +44,10 @@ ADMIN_BOOTSTRAP_ENABLED=true
|
|||||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
||||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||||
# и forbidden-stars.ru (dev/test).
|
# и forbidden-stars.ru (dev).
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_BOT_USERNAME=
|
TELEGRAM_BOT_USERNAME=
|
||||||
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru.
|
# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru.
|
||||||
PUBLIC_BASE_URL=
|
PUBLIC_BASE_URL=
|
||||||
|
|
||||||
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
|
||||||
@@ -60,12 +59,12 @@ JWT_ALGORITHM=HS256
|
|||||||
JWT_USER_TTL_MINUTES=10080
|
JWT_USER_TTL_MINUTES=10080
|
||||||
JWT_ADMIN_TTL_MINUTES=480
|
JWT_ADMIN_TTL_MINUTES=480
|
||||||
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
|
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
|
||||||
# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно.
|
# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно.
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
|
|
||||||
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
|
||||||
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL
|
# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL
|
||||||
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml).
|
# (том /data контейнера, см. docker-compose*.yml).
|
||||||
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
|
||||||
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,9 +4,9 @@
|
|||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
backend/entrypoint.sh text eol=lf
|
backend/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
|
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ─────────
|
||||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
||||||
# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет —
|
# но в архив деплоя (scripts/export-prod.sh) не идут. На Docker-сборку НЕ влияет —
|
||||||
# там чистоту образа обеспечивает .dockerignore.
|
# там чистоту образа обеспечивает .dockerignore.
|
||||||
backend/tests/ export-ignore
|
backend/tests/ export-ignore
|
||||||
backend/app/auth/dev_stub.py export-ignore
|
backend/app/auth/dev_stub.py export-ignore
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ venv/
|
|||||||
data/
|
data/
|
||||||
backend/dev.db*
|
backend/dev.db*
|
||||||
|
|
||||||
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod)
|
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod)
|
||||||
.env
|
.env
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,22 @@
|
|||||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
||||||
frontend/ React + Vite SPA
|
frontend/ React + Vite SPA
|
||||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
||||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-*.sh
|
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh
|
||||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
docker-compose.yml прод на Pi: app + tunnel + backup
|
||||||
docker-compose.test.yml тест-клон прода на ПК: app + tunnel + backup
|
|
||||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
||||||
run.ps1 / run.sh единый лаунчер dev/test
|
run.ps1 / run.sh единый лаунчер dev
|
||||||
.env.example шаблон единого .env
|
.env.example шаблон единого .env
|
||||||
```
|
```
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
|
|
||||||
|
Всё проверяется в `development` — отдельного тестового контейнера нет.
|
||||||
|
|
||||||
### Единый лаунчер (`run.ps1` / `run.sh`)
|
### Единый лаунчер (`run.ps1` / `run.sh`)
|
||||||
|
|
||||||
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно,
|
После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает
|
||||||
решает `APP_ENV` в корневом `.env`:
|
`APP_ENV` в корневом `.env`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
|
||||||
@@ -39,8 +40,8 @@ run.ps1 / run.sh единый лаунчер dev/test
|
|||||||
| `APP_ENV` в `.env` | что делает лаунчер |
|
| `APP_ENV` в `.env` | что делает лаунчер |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
|
| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
|
||||||
| `test` | `docker compose -f docker-compose.test.yml up --build -d` — прод-клон (app + tunnel + backup); портов на хост нет, открывается на `https://forbidden-stars.ru` |
|
|
||||||
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
|
| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
|
||||||
|
| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) |
|
||||||
|
|
||||||
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
|
||||||
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
|
||||||
@@ -128,46 +129,19 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
|
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
|
||||||
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||||
|
|
||||||
## Test — прод-клон в контейнере на ПК
|
|
||||||
|
|
||||||
Тот же `Dockerfile` и поведение, что у прода (FastAPI отдаёт SPA, БД на томе, вход игроков
|
|
||||||
по логину/паролю или через Telegram), но образ собирается локально — для проверки прод-сборки
|
|
||||||
до выката на Pi. Портов на хост **нет**: тест-клон виден только на `https://forbidden-stars.ru`
|
|
||||||
через свой туннель-контейнер (ключ — файл `deploy/tunnel/id_tunnel`).
|
|
||||||
|
|
||||||
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
|
||||||
Вручную (тот же эффект):
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.test.yml up -d --build
|
|
||||||
# открыть https://forbidden-stars.ru (Swagger: /api/docs)
|
|
||||||
docker compose -f docker-compose.test.yml logs -f app
|
|
||||||
docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные
|
|
||||||
```
|
|
||||||
|
|
||||||
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
|
||||||
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). Cookie — Secure
|
|
||||||
(снаружи HTTPS).
|
|
||||||
- От прода test отличается тем, что OpenAPI/Swagger открыт и **нет** fail-fast по дефолтным
|
|
||||||
секретам, хотя контур публичный (задача #69). Не держите в `.env` дефолтные `SECRET_KEY` /
|
|
||||||
`ADMIN_PASSWORD`, когда поднимаете test, — особенно после `restore-test` с прод-данными.
|
|
||||||
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` / `achievements-data-test`
|
|
||||||
(и `backup-data-test` у контейнера бэкапов, он работает без расписания и без VPS);
|
|
||||||
с dev и Pi не пересекаются.
|
|
||||||
- Слот VPS 9001 общий с dev-туннелем (`LOCAL_PUBLIC=vps`) — поднимайте что-то одно.
|
|
||||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — по
|
|
||||||
логину/паролю сразу, через Telegram — при настроенном боте (`/setdomain` → `forbidden-stars.ru`).
|
|
||||||
- Учебное восстановление прод-бэкапа в тест-клон — `.\scripts\fs-backup.ps1 restore-test`
|
|
||||||
([`deploy/backup/README.md`, шаг 7](deploy/backup/README.md#шаг-7-учебное-восстановление-на-тест-клоне)).
|
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||||
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
|
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`).
|
||||||
|
- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` —
|
||||||
|
поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`,
|
||||||
|
тот же слот VPS 9000, что у Pi (одновременно не запускать).
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|
||||||
Методы входа зависят от окружения (`APP_ENV`):
|
Методы входа зависят от окружения (`APP_ENV`):
|
||||||
|
|
||||||
| | dev | test / prod |
|
| | dev | prod |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
||||||
| Telegram Login Widget | ✓ | ✓ |
|
| Telegram Login Widget | ✓ | ✓ |
|
||||||
@@ -189,7 +163,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
`backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из
|
`backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из
|
||||||
Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development`
|
Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development`
|
||||||
(`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`).
|
(`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`).
|
||||||
В test/prod аккаунт можно только отключить.
|
В проде аккаунт можно только отключить.
|
||||||
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть
|
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть
|
||||||
данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник
|
данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник
|
||||||
занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные
|
занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные
|
||||||
@@ -197,43 +171,42 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
|
|
||||||
**Настройка Telegram (когда будете подключать реальный вход):**
|
**Настройка Telegram (когда будете подключать реальный вход):**
|
||||||
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
|
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**.
|
||||||
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test).
|
2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev).
|
||||||
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
|
||||||
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
|
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
|
||||||
|
|
||||||
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
|
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
|
||||||
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
|
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
|
||||||
|
|
||||||
## Окружения (dev / test / prod)
|
## Окружения (dev / prod)
|
||||||
|
|
||||||
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер):
|
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только
|
||||||
|
`development` и `production` — с любым другим значением бэкенд не стартует:
|
||||||
|
|
||||||
| | dev | test (прод-клон локально) | prod (Pi) |
|
| | dev | prod (Pi) |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` |
|
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` |
|
||||||
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) |
|
| `APP_ENV` | `development` | `production` (форсится в compose) |
|
||||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
| Env-файл | единый `.env` | единый `.env` (на Pi) |
|
||||||
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbidden-stars.ru` | FastAPI, только через `https://forbiddenstars.ru` |
|
| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` |
|
||||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
| База данных | `backend/data/dev/…` | том `db-data` (`/data`) |
|
||||||
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram |
|
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram |
|
||||||
| Swagger (`/api/docs`) | ✓ | ✓ | ✗ |
|
| Swagger (`/api/docs`) | ✓ | ✗ |
|
||||||
| Fail-fast по дефолтным секретам | ✗ | ✗ | ✓ |
|
| Fail-fast по дефолтным секретам | ✗ | ✓ |
|
||||||
|
|
||||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение
|
||||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
`APP_ENV` из него **игнорирует** и всегда `production`.
|
||||||
`production`. Отдельного `.env.test` больше нет.
|
|
||||||
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
|
||||||
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это
|
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
||||||
РАЗНЫЕ тома). Так же раздельно лежат загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
||||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
||||||
исключены из образа (`.dockerignore`); `test` собирается из того же `Dockerfile`, что и прод,
|
исключены из образа (`.dockerignore`).
|
||||||
просто локально и с `APP_ENV=test`.
|
|
||||||
- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило
|
- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило
|
||||||
`data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71).
|
`data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71).
|
||||||
|
|
||||||
## Git и деплой
|
## Git и деплой
|
||||||
|
|
||||||
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь.
|
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь.
|
||||||
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
|
||||||
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
|
||||||
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
|
||||||
@@ -241,10 +214,9 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||||
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
|
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
|
||||||
`scripts/export-prod.sh <dir> [ref]` / `scripts/export-test.sh <dir> [ref]` — через
|
`scripts/export-prod.sh <dir> [ref]` — через `git archive` + `export-ignore` из
|
||||||
`git archive` + `export-ignore` из `.gitattributes` (без тестов, stub-входа, `pyproject.toml`,
|
`.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера).
|
||||||
README-файлов; у прода ещё без лаунчера и тест-compose, у теста — без прод-compose). `dev_admin.py` в
|
`dev_admin.py` в `export-ignore` пока не внесён (задача #70).
|
||||||
`export-ignore` пока не внесён (задача #70).
|
|
||||||
|
|
||||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||||
|
|
||||||
@@ -252,21 +224,21 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
|
|
||||||
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
|
||||||
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
|
||||||
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост
|
У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
|
||||||
они не публикуют (доступны только через домен):
|
не публикует (доступен только через домен):
|
||||||
|
|
||||||
| | домен | как выставляется | слот VPS |
|
| | домен | как выставляется | слот VPS |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 |
|
||||||
| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 |
|
|
||||||
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
|
||||||
|
|
||||||
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают
|
- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
|
||||||
одновременно. Dev и test делят слот 9001 → по очереди. Временный прод на ПК
|
прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
|
||||||
(`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
|
|
||||||
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
|
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
|
||||||
|
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
|
||||||
|
время проверки (задача #69).
|
||||||
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
|
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
|
||||||
- Ключ туннеля: test и временный прод берут файл `deploy/tunnel/id_tunnel`, прод на Pi —
|
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
|
||||||
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
|
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
|
||||||
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
|
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
|
||||||
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
|
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`),
|
||||||
@@ -276,7 +248,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
|
|
||||||
Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок
|
Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок
|
||||||
БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки
|
БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки
|
||||||
скачиваются и проверяются учебным восстановлением в тест-клон (`scripts/fs-backup.ps1`).
|
скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`).
|
||||||
Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md).
|
Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||||
|
|
||||||
## Дополнения и фракции
|
## Дополнения и фракции
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
|
|
||||||
# Администратор уже существует.
|
# Администратор уже существует.
|
||||||
if not settings.is_development:
|
if not settings.is_development:
|
||||||
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
||||||
return
|
return
|
||||||
|
|
||||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||||
|
|||||||
+19
-10
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import model_validator
|
from pydantic import field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||||
@@ -18,6 +18,9 @@ _DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
|||||||
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
_MIN_SECRET_KEY_LENGTH = 32
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development.
|
||||||
|
_APP_ENVS = ("development", "production")
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -27,14 +30,13 @@ class Settings(BaseSettings):
|
|||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Главный переключатель окружения: development | test | production ───────
|
# ── Главный переключатель окружения: development | production ─────────────
|
||||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
||||||
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
|
|
||||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
|
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||||
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
||||||
local_public: str = "local"
|
local_public: str = "local"
|
||||||
@@ -91,17 +93,13 @@ class Settings(BaseSettings):
|
|||||||
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
||||||
return self.app_env.lower() == "development"
|
return self.app_env.lower() == "development"
|
||||||
|
|
||||||
@property
|
|
||||||
def is_test(self) -> bool:
|
|
||||||
return self.app_env.lower() == "test"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_production(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
return self.app_env.lower() == "production"
|
return self.app_env.lower() == "production"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
|
"""БД: dev — отдельный файл дева; prod — том контейнера (/data)."""
|
||||||
return self.dev_database_url if self.is_development else self.prod_database_url
|
return self.dev_database_url if self.is_development else self.prod_database_url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -122,13 +120,24 @@ class Settings(BaseSettings):
|
|||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
|
|
||||||
|
@field_validator("app_env")
|
||||||
|
@classmethod
|
||||||
|
def _known_app_env(cls, value: str) -> str:
|
||||||
|
"""Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое
|
||||||
|
значение, кроме development, выбирает прод-пути к данным и выключает dev-вход."""
|
||||||
|
if value.lower() not in _APP_ENVS:
|
||||||
|
raise ValueError(
|
||||||
|
f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
||||||
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
||||||
|
|
||||||
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
||||||
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
||||||
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
|
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
|
||||||
if self.app_env.lower() != "production":
|
if self.app_env.lower() != "production":
|
||||||
return self
|
return self
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
|
|||||||
+4
-4
@@ -132,7 +132,7 @@ async def _lifespan(_app: FastAPI):
|
|||||||
hub.bind_loop(asyncio.get_running_loop())
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
|
||||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||||
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||||
try:
|
try:
|
||||||
from app.bootstrap import bootstrap
|
from app.bootstrap import bootstrap
|
||||||
@@ -151,7 +151,7 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
|
||||||
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
||||||
# незачем облегчать разведку поверхности API анонимам (#61).
|
# незачем облегчать разведку поверхности API анонимам (#61).
|
||||||
docs_enabled = not settings.is_production
|
docs_enabled = not settings.is_production
|
||||||
@@ -165,7 +165,7 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin).
|
||||||
# В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
# В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем.
|
||||||
if settings.is_development and settings.cors_origins_list:
|
if settings.is_development and settings.cors_origins_list:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -204,7 +204,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(r, prefix="/api")
|
app.include_router(r, prefix="/api")
|
||||||
|
|
||||||
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
|
||||||
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены
|
# и только если код физически есть (в прод-образе dev_*-файлы исключены
|
||||||
# .dockerignore, импорт просто не выполнится).
|
# .dockerignore, импорт просто не выполнится).
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
for mod_name in ("dev_auth", "dev_admin"):
|
for mod_name in ("dev_auth", "dev_admin"):
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ def set_user_password(
|
|||||||
|
|
||||||
|
|
||||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||||
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только
|
# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
|
||||||
# отключается (PATCH is_active), удалять нельзя.
|
# отключается (PATCH is_active), удалять нельзя.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
|
||||||
|
|
||||||
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер
|
Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
|
||||||
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
|
||||||
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
|
||||||
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только
|
возможность удаления не попадает в прод — там аккаунт можно только
|
||||||
отключить (PATCH is_active).
|
отключить (PATCH is_active).
|
||||||
|
|
||||||
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def set_player_password(session: Session, user_id: int, new_password: str) -> Us
|
|||||||
|
|
||||||
|
|
||||||
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы ──────────────────────────────────────────────────────────────────
|
# ─── Группы ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6)."""
|
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|||||||
@@ -23,14 +23,12 @@ def test_enabled_methods_by_env(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
monkeypatch.setattr(settings, "app_env", "development")
|
||||||
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
||||||
monkeypatch.setattr(settings, "app_env", "test")
|
|
||||||
assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub
|
|
||||||
monkeypatch.setattr(settings, "app_env", "production")
|
monkeypatch.setattr(settings, "app_env", "production")
|
||||||
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
||||||
|
|
||||||
|
|
||||||
def test_env_flags_and_db_path(monkeypatch):
|
def test_env_flags_and_db_path(monkeypatch):
|
||||||
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
|
"""dev → файл дева; prod → том /data."""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
|
||||||
@@ -38,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
monkeypatch.setattr(settings, "app_env", "development")
|
||||||
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
|
||||||
monkeypatch.setattr(settings, "app_env", "test")
|
|
||||||
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
|
|
||||||
monkeypatch.setattr(settings, "app_env", "production")
|
monkeypatch.setattr(settings, "app_env", "production")
|
||||||
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
|
||||||
|
|
||||||
|
|||||||
@@ -65,3 +65,15 @@ def test_development_allows_defaults():
|
|||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
)
|
)
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
||||||
|
def test_unknown_app_env_rejected(app_env):
|
||||||
|
# Отдельного test-контура больше нет: такое значение не должно молча включать прод-пути.
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(app_env=app_env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_env_case_insensitive():
|
||||||
|
s = config.Settings(app_env="Development")
|
||||||
|
assert s.is_development
|
||||||
|
|||||||
+9
-17
@@ -1,6 +1,6 @@
|
|||||||
# Публикация: домены, VPS, туннели
|
# Публикация: домены, VPS, туннели
|
||||||
|
|
||||||
Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет
|
Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет
|
||||||
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
|
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
|
||||||
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
|
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
|
||||||
приложение само открывает к VPS.
|
приложение само открывает к VPS.
|
||||||
@@ -11,10 +11,8 @@ HTTPS твоими сертификатами и проксирует трафи
|
|||||||
│ ▲ туннель-КОНТЕЙНЕР │
|
│ ▲ туннель-КОНТЕЙНЕР │
|
||||||
│ └── Pi : app:8000 PROD │
|
│ └── Pi : app:8000 PROD │
|
||||||
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
|
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
|
||||||
│ ▲ контейнер (test) ИЛИ │
|
│ ▲ ssh с ПК (по требованию) │
|
||||||
│ ▲ ssh с ПК (dev) │
|
│ └── ПК dev : vite:5173 DEV │
|
||||||
│ ├── ПК test : app:8000 │
|
|
||||||
│ └── ПК dev : vite:5173 │
|
|
||||||
└───────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,32 +21,26 @@ HTTPS твоими сертификатами и проксирует трафи
|
|||||||
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
|
хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
|
||||||
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
|
Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
|
||||||
(`restart: unless-stopped`). См. [`pi/`](pi/README.md).
|
(`restart: unless-stopped`). См. [`pi/`](pi/README.md).
|
||||||
- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` собирает образы
|
|
||||||
локально и поднимает `app` + `tunnel` (`ssh -R 9001:app:8000`) + `backup` (без расписания и
|
|
||||||
без VPS). Портов на хост нет — тест виден только на `forbidden-stars.ru`. Обычно
|
|
||||||
запускается лаунчером при `APP_ENV=test`.
|
|
||||||
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
|
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при
|
||||||
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК
|
`LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК
|
||||||
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`.
|
(`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**).
|
||||||
- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**.
|
- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev.
|
||||||
PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо.
|
|
||||||
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
|
Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
|
||||||
|
|
||||||
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
|
Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
|
||||||
`tunnel` на VPS):
|
`tunnel` на VPS):
|
||||||
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
|
- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
|
||||||
- **ПК, test и временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер.
|
- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер.
|
||||||
- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по
|
- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по
|
||||||
умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`).
|
умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`).
|
||||||
|
|
||||||
Настройка по шагам:
|
Настройка по шагам:
|
||||||
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
|
1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`.
|
||||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
|
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
|
||||||
3. **ПК (dev/test)** — ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или ключ по
|
3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод,
|
||||||
умолчанию в `~/.ssh` (для dev-туннеля); pubkey — в `authorized_keys` у `tunnel@VPS`.
|
файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||||
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
|
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
|
||||||
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют
|
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
|
||||||
восстановление на тест-клоне.
|
|
||||||
|
|
||||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
|
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
|
||||||
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
|
VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
|
||||||
|
|||||||
+31
-33
@@ -16,7 +16,7 @@
|
|||||||
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
|
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
|
||||||
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
|
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
|
||||||
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
|
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
|
||||||
7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК
|
7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК
|
||||||
8. [Восстановление прода](#8-восстановление-прода) — Pi
|
8. [Восстановление прода](#8-восстановление-прода) — Pi
|
||||||
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
|
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
|
||||||
10. [Повседневные действия](#10-повседневные-действия)
|
10. [Повседневные действия](#10-повседневные-действия)
|
||||||
@@ -562,43 +562,42 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Шаг 7. Учебное восстановление на тест-клоне
|
## Шаг 7. Проверка скачанного архива
|
||||||
|
|
||||||
**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно
|
**Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что
|
||||||
восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается.
|
ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается.
|
||||||
|
|
||||||
> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона
|
> Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм
|
||||||
> сохраняются в его собственный снимок `pre-restore`.
|
> `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь
|
||||||
|
> проверяется содержимое архива.
|
||||||
|
|
||||||
1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла):
|
1. Распакуйте последний скачанный архив во временную папку:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260914_0400_3f2a9c1d.tar
|
$f = (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName
|
||||||
|
$d = Join-Path $env:TEMP "fs-check"; New-Item -ItemType Directory -Force $d | Out-Null
|
||||||
|
tar -xf $f -C $d
|
||||||
```
|
```
|
||||||
|
|
||||||
В первый раз Docker соберёт образы тест-клона — это несколько минут.
|
2. Откройте `%TEMP%\fs-check\forbidden_stars.db` в [DB Browser for SQLite](https://sqlitebrowser.org)
|
||||||
|
(вкладка «Выполнить SQL») и выполните:
|
||||||
|
|
||||||
2. Проверьте, что приложение тест-клона поднялось:
|
```sql
|
||||||
|
PRAGMA integrity_check;
|
||||||
|
SELECT (SELECT count(*) FROM users WHERE role = 'player') AS players,
|
||||||
|
(SELECT count(*) FROM matches) AS matches;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Закройте DB Browser и удалите временную папку — данные в ней не зашифрованы:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f docker-compose.test.yml ps
|
Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check")
|
||||||
docker compose -f docker-compose.test.yml logs --tail 20 app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите
|
|
||||||
`.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ,
|
|
||||||
профили, историю партий. Потом верните `APP_ENV=development`.
|
|
||||||
|
|
||||||
**Что должно получиться:**
|
**Что должно получиться:**
|
||||||
- в выводе `restore-test`:
|
- `PRAGMA integrity_check` → `ok`;
|
||||||
- `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi;
|
- `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi;
|
||||||
- `Данные восстановлены.`;
|
- в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`.
|
||||||
- `Done. The test clone now runs on the restored data.`;
|
|
||||||
- `docker compose ... ps` показывает `app` в состоянии `Up … (healthy)`;
|
|
||||||
- на сайте тест-клона — данные прода на момент снимка.
|
|
||||||
|
|
||||||
> Этим же способом можно восстановить в тест-клон старые архивы `fs_*.tar.gz` прежнего
|
|
||||||
> `scripts/backup.sh`: `.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260710_140914.tar.gz`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -651,8 +650,9 @@
|
|||||||
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
|
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
|
||||||
`docker compose exec backup fs-backup list vps`.
|
`docker compose exec backup fs-backup list vps`.
|
||||||
|
|
||||||
2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК:
|
2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК:
|
||||||
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)).
|
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем [шаг 7](#шаг-7-проверка-скачанного-архива).
|
||||||
|
Заодно у вас останется копия этого снимка вне Pi.
|
||||||
|
|
||||||
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
|
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
|
||||||
|
|
||||||
@@ -806,7 +806,7 @@
|
|||||||
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
|
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
|
||||||
- **Раз в месяц:**
|
- **Раз в месяц:**
|
||||||
- скачать снимок на ПК (`pull`);
|
- скачать снимок на ПК (`pull`);
|
||||||
- раз в пару месяцев сделать учебное восстановление (`restore-test`);
|
- проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива));
|
||||||
- удалить с ПК старые архивы — они не зашифрованы.
|
- удалить с ПК старые архивы — они не зашифрованы.
|
||||||
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
|
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
|
||||||
бэкапы перестали проходить (причину покажет `fs-backup status`).
|
бэкапы перестали проходить (причину покажет `fs-backup status`).
|
||||||
@@ -899,7 +899,7 @@ docker volume rm <имя тома>
|
|||||||
| `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` |
|
| `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` |
|
||||||
| `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) |
|
| `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) |
|
||||||
| `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` |
|
| `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` |
|
||||||
| `restore-test`: `Import failed … The test clone data was not changed` | архив повреждён или неполный | скачать заново (`pull`); текст ошибки выше в выводе |
|
| шаг 7: `integrity_check` не `ok` или счётчики не совпадают с `list` | архив повреждён или скачан не тот снимок | удалить файл из `backups\` и скачать заново (`pull -Snapshot <ID>`); если повторяется — `fs-backup verify` на Pi |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -935,11 +935,9 @@ docker volume rm <имя тома>
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
|
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
|
||||||
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
|
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
|
||||||
| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон |
|
|
||||||
| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне |
|
|
||||||
|
|
||||||
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
|
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
|
||||||
`pull <ID> --repo vps`, `restore-test <архив>`, `--test` первым аргументом.
|
`pull <ID> --repo vps`.
|
||||||
|
|
||||||
### Переменные `.env`
|
### Переменные `.env`
|
||||||
|
|
||||||
@@ -987,7 +985,7 @@ docker volume rm <имя тома>
|
|||||||
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
|
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
|
||||||
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
|
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
|
||||||
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
|
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
|
||||||
- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7)
|
- [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7)
|
||||||
|
|
||||||
**Через сутки**
|
**Через сутки**
|
||||||
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00
|
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}"
|
|||||||
UPSTREAM="${UPSTREAM:-app:8000}"
|
UPSTREAM="${UPSTREAM:-app:8000}"
|
||||||
|
|
||||||
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
|
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
|
||||||
# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте).
|
# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте).
|
||||||
mkdir -p /root/.ssh
|
mkdir -p /root/.ssh
|
||||||
KEY=/root/.ssh/id_tunnel
|
KEY=/root/.ssh/id_tunnel
|
||||||
if [ -n "${TUNNEL_KEY_B64:-}" ]; then
|
if [ -n "${TUNNEL_KEY_B64:-}" ]; then
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||||
#
|
#
|
||||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
-Server
|
-Server
|
||||||
}
|
}
|
||||||
|
|
||||||
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
|
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко
|
||||||
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
|
# ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe
|
||||||
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
|
# oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки,
|
||||||
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
|
# что вход и реал-тайм работают (#61).
|
||||||
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||||
|
|
||||||
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
|
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
|
||||||
|
|
||||||
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
|
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
|
||||||
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test).
|
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev).
|
||||||
|
|
||||||
```
|
```
|
||||||
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD
|
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD
|
||||||
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (контейнер test или ssh dev, по требованию) DEV/TEST
|
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV
|
||||||
```
|
```
|
||||||
|
|
||||||
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
|
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит
|
||||||
@@ -106,8 +106,8 @@ systemctl reload caddy
|
|||||||
> файл читается на каждый запрос).
|
> файл читается на каждый запрос).
|
||||||
|
|
||||||
## 7. Проверка
|
## 7. Проверка
|
||||||
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК
|
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК
|
||||||
(лаунчер `run.ps1`: dev — при `LOCAL_PUBLIC=vps`, test — при `APP_ENV=test`).
|
(лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`).
|
||||||
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
|
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
|
||||||
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
|
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
|
||||||
(HTTP 503), это ожидаемо.
|
(HTTP 503), это ожидаемо.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# Отличия от docker-compose.yml (прод на Pi):
|
# Отличия от docker-compose.yml (прод на Pi):
|
||||||
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
|
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
|
||||||
# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК.
|
# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК.
|
||||||
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
|
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
|
||||||
#
|
#
|
||||||
# Запуск: docker compose -f docker-compose.temp.yml up -d --build
|
# Запуск: docker compose -f docker-compose.temp.yml up -d --build
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены
|
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены
|
||||||
BACKUP_HOSTNAME: fs-prod # имя хоста в снимках (у тест-клона — fs-test)
|
BACKUP_HOSTNAME: fs-prod # имя хоста в снимках
|
||||||
BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *}
|
BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *}
|
||||||
BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0}
|
BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0}
|
||||||
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
|
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Spinner } from "../../components/Spinner";
|
|||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||||
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||||
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
// поэтому в прод-сборке вырезается тришейкингом (как и dev-вход).
|
||||||
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||||
|
|
||||||
export function AdminAccountsPage() {
|
export function AdminAccountsPage() {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { useToast } from "../../context/ToastContext";
|
|||||||
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
|
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
|
||||||
*
|
*
|
||||||
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
|
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда
|
||||||
* (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль
|
* (backend/app/routers/dev_admin.py, исключён из прод-образа). Этот модуль
|
||||||
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
|
* рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
|
||||||
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно.
|
* он не используется и вырезается тришейкингом — в проде удаление недоступно.
|
||||||
*/
|
*/
|
||||||
export function DevDeleteAccountButton({
|
export function DevDeleteAccountButton({
|
||||||
userId,
|
userId,
|
||||||
@@ -39,7 +39,7 @@ export function DevDeleteAccountButton({
|
|||||||
<button
|
<button
|
||||||
className="btn small"
|
className="btn small"
|
||||||
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
||||||
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
|
// чтобы в прод-стили не попало ничего, связанного с удалением.
|
||||||
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
||||||
disabled={del.isPending}
|
disabled={del.isPending}
|
||||||
onClick={() => setConfirmOpen(true)}
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
#!/usr/bin/env pwsh
|
#!/usr/bin/env pwsh
|
||||||
# Unified launcher for dev / test. APP_ENV in the root .env decides what to run.
|
# Unified launcher for dev. APP_ENV in the root .env decides what to run.
|
||||||
# development -> uvicorn --reload (backend) + vite (frontend), native, two windows
|
# development -> uvicorn --reload (backend) + vite (frontend), native, two windows
|
||||||
# test -> docker compose prod-clone (port 8080)
|
|
||||||
# production -> NOT started by launcher (prod is separate: docker compose up -d on Pi)
|
# production -> NOT started by launcher (prod is separate: docker compose up -d on Pi)
|
||||||
#
|
#
|
||||||
# LOCAL_PUBLIC=vps (dev only) opens an SSH tunnel to the VPS, exposing dev at
|
# LOCAL_PUBLIC=vps opens an SSH tunnel to the VPS, exposing dev at
|
||||||
# https://forbidden-stars.ru. test/prod expose themselves via an in-container tunnel.
|
# https://forbidden-stars.ru. Prod exposes itself via an in-container tunnel.
|
||||||
#
|
#
|
||||||
# Run: .\run.ps1
|
# Run: .\run.ps1
|
||||||
# If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
# If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||||
@@ -131,40 +130,13 @@ switch ($appEnv) {
|
|||||||
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"test" {
|
|
||||||
$compose = Join-Path $root "docker-compose.test.yml"
|
|
||||||
docker info --format '{{.ServerVersion}}' *> $null
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Host "Docker daemon is not reachable. Start Docker Desktop, wait until it is running, then re-run .\run.ps1" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$keyFile = Join-Path $root "deploy\tunnel\id_tunnel"
|
|
||||||
if (-not (Test-Path $keyFile -PathType Leaf)) {
|
|
||||||
if (Test-Path $keyFile -PathType Container) {
|
|
||||||
Write-Host "deploy\tunnel\id_tunnel is a DIRECTORY - Docker auto-created it because the key file was missing." -ForegroundColor Red
|
|
||||||
Write-Host "Remove it first: Remove-Item -Recurse -Force deploy\tunnel\id_tunnel" -ForegroundColor Yellow
|
|
||||||
} else {
|
|
||||||
Write-Host "No SSH key at deploy\tunnel\id_tunnel (the tunnel container needs it)." -ForegroundColor Red
|
|
||||||
}
|
|
||||||
Write-Host "Add your VPS-authorized key: Copy-Item `$env:USERPROFILE\.ssh\id_ed25519 deploy\tunnel\id_tunnel" -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "Building and starting prod-clone in Docker (app + in-container tunnel)..." -ForegroundColor Green
|
|
||||||
docker compose -f $compose up --build -d
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " Public: https://forbidden-stars.ru (Swagger: /api/docs)" -ForegroundColor Green
|
|
||||||
Write-Host " (no host port - reachable only via the domain; tunnel runs inside compose)"
|
|
||||||
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f"
|
|
||||||
Write-Host " Stop: docker compose -f docker-compose.test.yml down -v"
|
|
||||||
}
|
|
||||||
"production" {
|
"production" {
|
||||||
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
||||||
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build"
|
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | test | production." -ForegroundColor Red
|
Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | production." -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
||||||
# ║ Единый лаунчер dev / test. Что запускать — решает APP_ENV из корневого .env ║
|
# ║ Единый лаунчер dev. Что запускать — решает APP_ENV из корневого .env ║
|
||||||
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
||||||
# development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба)
|
# development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба)
|
||||||
# test → docker compose прод-клон (порт 8080)
|
|
||||||
# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
|
# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
|
||||||
#
|
#
|
||||||
# LOCAL_PUBLIC=vps (только dev) поднимает SSH-туннель на VPS → https://forbidden-stars.ru
|
# LOCAL_PUBLIC=vps поднимает SSH-туннель на VPS → https://forbidden-stars.ru
|
||||||
# test/prod выставляют себя сами через туннель-контейнер (см. docker-compose*.yml).
|
# Прод выставляет себя сам через туннель-контейнер (см. docker-compose.yml).
|
||||||
#
|
#
|
||||||
# Запуск: ./run.sh (при необходимости: chmod +x run.sh)
|
# Запуск: ./run.sh (при необходимости: chmod +x run.sh)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -79,22 +78,13 @@ case "$app_env" in
|
|||||||
[ "$local_public" = "vps" ] && start_tunnel 5173
|
[ "$local_public" = "vps" ] && start_tunnel 5173
|
||||||
( cd "$root/frontend" && npm run dev )
|
( cd "$root/frontend" && npm run dev )
|
||||||
;;
|
;;
|
||||||
test)
|
|
||||||
docker info >/dev/null 2>&1 || { echo "Docker-демон недоступен — запусти Docker и повтори ./run.sh"; exit 1; }
|
|
||||||
echo "Сборка и запуск прод-клона в Docker (app + туннель в контейнере)…"
|
|
||||||
docker compose -f "$root/docker-compose.test.yml" up --build -d
|
|
||||||
echo " Публично: https://forbidden-stars.ru (Swagger: /api/docs)"
|
|
||||||
echo " (портов на хост нет — только через домен; туннель живёт внутри compose)"
|
|
||||||
echo " Логи: docker compose -f docker-compose.test.yml logs -f"
|
|
||||||
echo " Стоп: docker compose -f docker-compose.test.yml down -v"
|
|
||||||
;;
|
|
||||||
production)
|
production)
|
||||||
echo "production лаунчером не запускается — прод обособлен."
|
echo "production лаунчером не запускается — прод обособлен."
|
||||||
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
|
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | test | production."
|
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | production."
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
|
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
|
||||||
# прод-контейнера (без тестов, dev-входа и тест-специфики).
|
# прод-контейнера (без тестов, dev-входа и dev-лаунчера).
|
||||||
#
|
#
|
||||||
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
|
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
|
||||||
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
|
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
|
||||||
@@ -13,8 +13,8 @@ mkdir -p "$DEST"
|
|||||||
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
|
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
|
||||||
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
||||||
|
|
||||||
# тест/dev-специфика в проде не нужна (тест-compose и лаунчер)
|
# dev-специфика в проде не нужна (лаунчер)
|
||||||
rm -f "$DEST/docker-compose.test.yml" "$DEST/run.ps1" "$DEST/run.sh"
|
rm -f "$DEST/run.ps1" "$DEST/run.sh"
|
||||||
|
|
||||||
echo "[export-prod] Прод выгружен в: $DEST"
|
echo "[export-prod] Прод выгружен в: $DEST"
|
||||||
echo " дальше: cp .env.example .env && docker compose up -d --build"
|
echo " дальше: cp .env.example .env && docker compose up -d --build"
|
||||||
|
|||||||
+5
-76
@@ -1,6 +1,5 @@
|
|||||||
# Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi
|
# Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi
|
||||||
# over SSH, or (with -Target test) to the local test clone (docker-compose.test.yml).
|
# over SSH. Step-by-step guide: deploy/backup/README.md
|
||||||
# Step-by-step guide: deploy/backup/README.md
|
|
||||||
#
|
#
|
||||||
# .\scripts\fs-backup.ps1 status backup state on the Pi
|
# .\scripts\fs-backup.ps1 status backup state on the Pi
|
||||||
# .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history
|
# .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history
|
||||||
@@ -8,9 +7,6 @@
|
|||||||
# .\scripts\fs-backup.ps1 verify check data integrity in the repositories
|
# .\scripts\fs-backup.ps1 verify check data integrity in the repositories
|
||||||
# .\scripts\fs-backup.ps1 pull [-Snapshot <id>] [-Repo vps]
|
# .\scripts\fs-backup.ps1 pull [-Snapshot <id>] [-Repo vps]
|
||||||
# download a snapshot to backups\ (sha256 checked)
|
# download a snapshot to backups\ (sha256 checked)
|
||||||
# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar
|
|
||||||
# practice restore into the local test clone
|
|
||||||
# add -Target test to run status/list/now/verify/pull against the local test clone
|
|
||||||
#
|
#
|
||||||
# Settings come from the root .env (an environment variable with the same name wins):
|
# Settings come from the root .env (an environment variable with the same name wins):
|
||||||
# BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias)
|
# BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias)
|
||||||
@@ -19,15 +15,12 @@
|
|||||||
# Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM.
|
# Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM.
|
||||||
param(
|
param(
|
||||||
[Parameter(Position = 0)]
|
[Parameter(Position = 0)]
|
||||||
[ValidateSet("status", "list", "now", "verify", "pull", "restore-test", "help")]
|
[ValidateSet("status", "list", "now", "verify", "pull", "help")]
|
||||||
[string]$Command = "help",
|
[string]$Command = "help",
|
||||||
[string]$Snapshot = "latest",
|
[string]$Snapshot = "latest",
|
||||||
[ValidateSet("local", "vps")]
|
[ValidateSet("local", "vps")]
|
||||||
[string]$Repo = "local",
|
[string]$Repo = "local",
|
||||||
[string]$Tag = "",
|
[string]$Tag = ""
|
||||||
[string]$File = "",
|
|
||||||
[ValidateSet("pi", "test")]
|
|
||||||
[string]$Target = "pi"
|
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
# Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a
|
# Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a
|
||||||
@@ -35,7 +28,6 @@ $ErrorActionPreference = "Stop"
|
|||||||
# run under "Continue" and success is judged by $LASTEXITCODE only.
|
# run under "Continue" and success is judged by $LASTEXITCODE only.
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
$envFile = Join-Path $root ".env"
|
$envFile = Join-Path $root ".env"
|
||||||
$testCompose = Join-Path $root "docker-compose.test.yml"
|
|
||||||
|
|
||||||
# Read a key: environment variable first, then the root .env (last assignment wins).
|
# Read a key: environment variable first, then the root .env (last assignment wins).
|
||||||
function Get-Setting([string]$name, [string]$default) {
|
function Get-Setting([string]$name, [string]$default) {
|
||||||
@@ -56,7 +48,7 @@ function Fail([string]$msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Show-Help {
|
function Show-Help {
|
||||||
Get-Content $PSCommandPath -TotalCount 19 | ForEach-Object { $_ -replace '^# ?', '' }
|
Get-Content $PSCommandPath -TotalCount 15 | ForEach-Object { $_ -replace '^# ?', '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
$piSsh = Get-Setting "BACKUP_PI_SSH" ""
|
$piSsh = Get-Setting "BACKUP_PI_SSH" ""
|
||||||
@@ -77,29 +69,9 @@ function Invoke-Scp([string]$from, [string]$to) {
|
|||||||
& scp -o ConnectTimeout=15 $from $to
|
& scp -o ConnectTimeout=15 $from $to
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-TestCompose([string[]]$composeArgs) {
|
# Run fs-backup with arguments on the Pi; output goes to the console.
|
||||||
$ErrorActionPreference = "Continue"
|
|
||||||
& docker compose -f $testCompose @composeArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make sure the backup container of the test clone is running (build it if needed).
|
|
||||||
function Start-TestBackup {
|
|
||||||
$id = (Invoke-TestCompose @("ps", "-q", "backup")) | Select-Object -First 1
|
|
||||||
if (-not $id) {
|
|
||||||
Write-Host "Starting the backup container of the test clone..." -ForegroundColor Cyan
|
|
||||||
Invoke-TestCompose @("up", "-d", "--build", "backup") | Out-Host
|
|
||||||
if ($LASTEXITCODE -ne 0) { Fail "Could not start the test clone backup container." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run fs-backup with arguments on the chosen target; output goes to the console.
|
|
||||||
function Invoke-FsBackup([string[]]$fsArgs) {
|
function Invoke-FsBackup([string[]]$fsArgs) {
|
||||||
if ($Target -eq "test") {
|
|
||||||
Start-TestBackup
|
|
||||||
Invoke-TestCompose (@("exec", "-T", "backup", "fs-backup") + $fsArgs)
|
|
||||||
} else {
|
|
||||||
Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " "))
|
Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " "))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Assert-LastExit([string]$what) {
|
function Assert-LastExit([string]$what) {
|
||||||
@@ -126,19 +98,6 @@ function Invoke-Pull {
|
|||||||
Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan
|
Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan
|
||||||
|
|
||||||
$partial = "$local.part"
|
$partial = "$local.part"
|
||||||
if ($Target -eq "test") {
|
|
||||||
Start-TestBackup
|
|
||||||
$tmp = "/tmp/fs-backup/export-$id.tar"
|
|
||||||
$hashLine = Invoke-TestCompose @("exec", "-T", "backup", "sh", "-c",
|
|
||||||
"fs-backup export $id --repo $Repo > $tmp && sha256sum $tmp") | Select-Object -Last 1
|
|
||||||
Assert-LastExit "Export"
|
|
||||||
try {
|
|
||||||
Invoke-TestCompose @("cp", "backup:$tmp", $partial)
|
|
||||||
Assert-LastExit "Copy from the container"
|
|
||||||
} finally {
|
|
||||||
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $tmp) | Out-Null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
# Export into a file in the Pi user's home (binary data never passes through
|
# Export into a file in the Pi user's home (binary data never passes through
|
||||||
# PowerShell pipes - they would corrupt it), then scp it and compare sha256.
|
# PowerShell pipes - they would corrupt it), then scp it and compare sha256.
|
||||||
$remote = "fs-export-$id.tar"
|
$remote = "fs-export-$id.tar"
|
||||||
@@ -151,7 +110,6 @@ function Invoke-Pull {
|
|||||||
} finally {
|
} finally {
|
||||||
Invoke-Pi "rm -f ~/$remote"
|
Invoke-Pi "rm -f ~/$remote"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$expected = ("$hashLine".Trim() -split "\s+")[0].ToLower()
|
$expected = ("$hashLine".Trim() -split "\s+")[0].ToLower()
|
||||||
$actual = (Get-FileHash -Algorithm SHA256 $partial).Hash.ToLower()
|
$actual = (Get-FileHash -Algorithm SHA256 $partial).Hash.ToLower()
|
||||||
@@ -172,34 +130,6 @@ function Invoke-Pull {
|
|||||||
Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green
|
Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------------------------------------------------------- restore-test
|
|
||||||
function Invoke-RestoreTest {
|
|
||||||
if (-not $File) { Fail "Specify the archive: -File backups\fs_....tar" }
|
|
||||||
if (-not (Test-Path $File -PathType Leaf)) { Fail "File not found: $File" }
|
|
||||||
$full = (Resolve-Path $File).Path
|
|
||||||
$inContainer = "/import/restore-test.archive" # tar or tar.gz: fs-backup detects the format itself
|
|
||||||
|
|
||||||
Write-Host "Practice restore of $full into the LOCAL TEST CLONE (prod is not touched)." -ForegroundColor Cyan
|
|
||||||
Start-TestBackup
|
|
||||||
Invoke-TestCompose @("stop", "app")
|
|
||||||
Assert-LastExit "Stopping the test app"
|
|
||||||
Invoke-TestCompose @("cp", $full, "backup:$inContainer")
|
|
||||||
Assert-LastExit "Copy into the container"
|
|
||||||
try {
|
|
||||||
Invoke-TestCompose @("exec", "-T", "backup", "fs-backup", "import", $inContainer, "--yes")
|
|
||||||
$importExit = $LASTEXITCODE
|
|
||||||
} finally {
|
|
||||||
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $inContainer) | Out-Null
|
|
||||||
}
|
|
||||||
if ($importExit -ne 0) { Fail "Import failed (exit code $importExit). The test clone data was not changed." }
|
|
||||||
|
|
||||||
Invoke-TestCompose @("up", "-d", "app")
|
|
||||||
Assert-LastExit "Starting the test app"
|
|
||||||
Write-Host "Done. The test clone now runs on the restored data." -ForegroundColor Green
|
|
||||||
Write-Host " See it on https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (or .\run.ps1 with APP_ENV=test)"
|
|
||||||
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f app"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------- main
|
# ---------------------------------------------------------------------------- main
|
||||||
$prevEncoding = $null
|
$prevEncoding = $null
|
||||||
try {
|
try {
|
||||||
@@ -218,7 +148,6 @@ try {
|
|||||||
}
|
}
|
||||||
"verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" }
|
"verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" }
|
||||||
"pull" { Invoke-Pull }
|
"pull" { Invoke-Pull }
|
||||||
"restore-test" { Invoke-RestoreTest }
|
|
||||||
default { Show-Help }
|
default { Show-Help }
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+4
-53
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup
|
# Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup
|
||||||
# прода на Pi по SSH или (с --test) в локальный тест-клон (docker-compose.test.yml).
|
# прода на Pi по SSH. На Windows удобнее scripts/fs-backup.ps1 — поведение то же.
|
||||||
# На Windows удобнее scripts/fs-backup.ps1 — поведение то же. Инструкция: deploy/backup/README.md
|
# Инструкция: deploy/backup/README.md
|
||||||
#
|
#
|
||||||
# scripts/fs-backup.sh status состояние бэкапов на Pi
|
# scripts/fs-backup.sh status состояние бэкапов на Pi
|
||||||
# scripts/fs-backup.sh list [vps] хронология снимков
|
# scripts/fs-backup.sh list [vps] хронология снимков
|
||||||
@@ -9,9 +9,6 @@
|
|||||||
# scripts/fs-backup.sh verify проверить целостность данных
|
# scripts/fs-backup.sh verify проверить целостность данных
|
||||||
# scripts/fs-backup.sh pull [<id>|latest] [--repo vps]
|
# scripts/fs-backup.sh pull [<id>|latest] [--repo vps]
|
||||||
# скачать снимок в backups/ (сверка sha256)
|
# скачать снимок в backups/ (сверка sha256)
|
||||||
# scripts/fs-backup.sh restore-test <файл.tar|.tar.gz>
|
|
||||||
# учебное восстановление в локальный тест-клон
|
|
||||||
# --test первым аргументом — status/list/now/verify/pull для локального тест-клона
|
|
||||||
#
|
#
|
||||||
# Настройки — из корневого .env (переменная окружения с тем же именем важнее):
|
# Настройки — из корневого .env (переменная окружения с тем же именем важнее):
|
||||||
# BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config)
|
# BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config)
|
||||||
@@ -19,8 +16,6 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
TEST_COMPOSE="$PROJECT_DIR/docker-compose.test.yml"
|
|
||||||
export MSYS_NO_PATHCONV=1 # Git Bash: не переписывать /import/... в аргументах docker
|
|
||||||
|
|
||||||
die() { echo "ОШИБКА: $*" >&2; exit 1; }
|
die() { echo "ОШИБКА: $*" >&2; exit 1; }
|
||||||
|
|
||||||
@@ -35,10 +30,7 @@ setting() { # setting <ключ> <по умолчанию>: окружение,
|
|||||||
PI_SSH="$(setting BACKUP_PI_SSH "")"
|
PI_SSH="$(setting BACKUP_PI_SSH "")"
|
||||||
# shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi
|
# shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi
|
||||||
PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")"
|
PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")"
|
||||||
TARGET="pi"
|
|
||||||
if [ "${1:-}" = "--test" ]; then TARGET="test"; shift; fi
|
|
||||||
|
|
||||||
native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; }
|
|
||||||
sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; }
|
sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; }
|
||||||
|
|
||||||
pi() { # pi <shell-команда>: выполнить на Pi в папке прода
|
pi() { # pi <shell-команда>: выполнить на Pi в папке прода
|
||||||
@@ -46,22 +38,8 @@ pi() { # pi <shell-команда>: выполнить на Pi в папке п
|
|||||||
ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1"
|
ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
tc() { docker compose -f "$(native_path "$TEST_COMPOSE")" "$@"; }
|
fs() { # fs <аргументы fs-backup…>: выполнить fs-backup в контейнере backup на Pi
|
||||||
|
|
||||||
start_test_backup() {
|
|
||||||
if [ -z "$(tc ps -q backup 2>/dev/null)" ]; then
|
|
||||||
echo "Запускаю контейнер backup тест-клона…"
|
|
||||||
tc up -d --build backup
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fs() { # fs <аргументы fs-backup…>
|
|
||||||
if [ "$TARGET" = test ]; then
|
|
||||||
start_test_backup
|
|
||||||
tc exec -T backup fs-backup "$@"
|
|
||||||
else
|
|
||||||
pi "docker compose exec -T backup fs-backup $*"
|
pi "docker compose exec -T backup fs-backup $*"
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_pull() {
|
cmd_pull() {
|
||||||
@@ -84,18 +62,10 @@ cmd_pull() {
|
|||||||
partial="$local_file.part"
|
partial="$local_file.part"
|
||||||
echo "Снимок $id ($repo) -> $local_file"
|
echo "Снимок $id ($repo) -> $local_file"
|
||||||
|
|
||||||
if [ "$TARGET" = test ]; then
|
|
||||||
start_test_backup
|
|
||||||
local tmp="/tmp/fs-backup/export-$id.tar"
|
|
||||||
expected="$(tc exec -T backup sh -c "fs-backup export $id --repo $repo > $tmp && sha256sum $tmp" | tail -n 1 | cut -d' ' -f1)"
|
|
||||||
tc cp "backup:$tmp" "$(native_path "$partial")" || { tc exec -T backup rm -f "$tmp"; die "копирование из контейнера не удалось"; }
|
|
||||||
tc exec -T backup rm -f "$tmp"
|
|
||||||
else
|
|
||||||
local remote="fs-export-$id.tar"
|
local remote="fs-export-$id.tar"
|
||||||
expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)"
|
expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)"
|
||||||
scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; }
|
scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; }
|
||||||
pi "rm -f ~/$remote"
|
pi "rm -f ~/$remote"
|
||||||
fi
|
|
||||||
|
|
||||||
actual="$(sha256 "$partial")"
|
actual="$(sha256 "$partial")"
|
||||||
if [ "$expected" != "$actual" ]; then
|
if [ "$expected" != "$actual" ]; then
|
||||||
@@ -107,24 +77,6 @@ cmd_pull() {
|
|||||||
echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)"
|
echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_restore_test() {
|
|
||||||
local file="${1:-}"
|
|
||||||
[ -n "$file" ] || die "укажите архив: scripts/fs-backup.sh restore-test backups/fs_....tar"
|
|
||||||
[ -f "$file" ] || die "файл не найден: $file"
|
|
||||||
local in_container=/import/restore-test.archive # tar или tar.gz — формат fs-backup определит сам
|
|
||||||
echo "Учебное восстановление $file в ЛОКАЛЬНЫЙ ТЕСТ-КЛОН (прод не затрагивается)."
|
|
||||||
start_test_backup
|
|
||||||
tc stop app
|
|
||||||
tc cp "$(native_path "$file")" "backup:$in_container"
|
|
||||||
local rc=0
|
|
||||||
tc exec -T backup fs-backup import "$in_container" --yes || rc=$?
|
|
||||||
tc exec -T backup rm -f "$in_container"
|
|
||||||
[ "$rc" -eq 0 ] || die "импорт не удался (код $rc) — данные тест-клона не изменены."
|
|
||||||
tc up -d app
|
|
||||||
echo "Готово: тест-клон работает на восстановленных данных."
|
|
||||||
echo " На https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (или ./run.sh при APP_ENV=test)"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd="${1:-help}"
|
cmd="${1:-help}"
|
||||||
[ $# -eq 0 ] || shift
|
[ $# -eq 0 ] || shift
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
@@ -133,6 +85,5 @@ case "$cmd" in
|
|||||||
now) fs run "$@" ;;
|
now) fs run "$@" ;;
|
||||||
verify) fs verify ;;
|
verify) fs verify ;;
|
||||||
pull) cmd_pull "$@" ;;
|
pull) cmd_pull "$@" ;;
|
||||||
restore-test) cmd_restore_test "$@" ;;
|
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||||
*) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
||||||
esac
|
esac
|
||||||
|
|||||||
Reference in New Issue
Block a user