удаление test-контура (дополнение)

This commit is contained in:
2026-09-14 20:50:40 +03:00
parent dafbf5bad4
commit 101c457f7e
27 changed files with 195 additions and 375 deletions
+12 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
+44 -72
View File
@@ -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).
## Дополнения и фракции ## Дополнения и фракции
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"):
+1 -1
View File
@@ -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), удалять нельзя.
+2 -2
View File
@@ -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).
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1 -5
View File
@@ -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"
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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) НЕ кэшируем. Иначе браузер отдаёт старый
+4 -4
View File
@@ -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), это ожидаемо.
+1 -1
View File
@@ -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
View File
@@ -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)}
+4 -32
View File
@@ -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
} }
} }
+4 -14
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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,30 +69,10 @@ 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) {
if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)." }
@@ -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
View File
@@ -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