refactor: consolidate migration installer

This commit is contained in:
3252a8
2026-06-02 10:57:28 +03:00
parent 0473829f7a
commit 2ccead9b49
7 changed files with 162 additions and 814 deletions
+2 -2
View File
@@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина. - [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки. - [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы. - [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`. - [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека. - [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
## Совместимость ## Совместимость
+4 -4
View File
@@ -1,6 +1,6 @@
# Миграция из Remnashop # Миграция из Remnashop
Remnashop импортируется через общий legacy-importer `backend/scripts/import_legacy.py`. Remnashop импортируется через общий скрипт импорта `backend/scripts/import_legacy.py`.
Самый удобный путь - интерактивный install wizard: Самый удобный путь - интерактивный install wizard:
```bash ```bash
@@ -8,8 +8,8 @@ curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scri
sh install.sh sh install.sh
``` ```
В меню выберите `Install new stack and run legacy migration` для нового сервера В меню выберите `Install new stack and run migration` для нового сервера
или `Run legacy migration only`, если compose-папка и `.env` уже готовы. или `Run migration only`, если compose-папка и `.env` уже готовы.
## Что переносится ## Что переносится
@@ -21,7 +21,7 @@ sh install.sh
- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`; - служебные mappings, чтобы повторный запуск мог работать в режиме `merge`;
- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes. - настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes.
Данные, которые не имеют прямого аналога, сохраняются в legacy mappings или Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или
message logs как заметки, чтобы администратор мог проверить их после переноса. message logs как заметки, чтобы администратор мог проверить их после переноса.
## Flow wizard ## Flow wizard
+89 -348
View File
@@ -1,117 +1,60 @@
# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+) # Миграция с `remnawave-tg-shop` на `remnawave-minishop`
Эта страница - готовый сценарий для legacy-стека `remnawave-tg-shop`. Это единственная миграция с другого бота, которая сейчас описана в документации. Для других Telegram-ботов, самописных панелей и ручных таблиц готового сценария пока нет: их нельзя переносить по этой инструкции без отдельного анализа схемы БД, тарифов, платежей и связи с Remnawave Panel. Для переноса со старого родственного стека используйте общий install wizard:
Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта.
## Новый install wizard
Для нового сервера или переноса без клонирования репозитория используйте общий
`sh` wizard:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
sh install.sh sh install.sh
``` ```
В меню выберите `Install new stack and run legacy migration` или В меню выберите `Install new stack and run migration` для нового
`Run legacy migration only`, затем источник `Old remnawave-tg-shop`. сервера или `Run migration only`, если compose-папка уже готова. Затем
Wizard поддерживает два режима: выберите источник `Old remnawave-tg-shop`.
- `Copy old Docker volumes` - тот же безопасный сценарий, что и старый Wizard поддерживает два способа переноса:
`migrate_to_minishop.sh`: старый volume `remnawave-tg-shop-db-data`
копируется в `remnawave-minishop-db-data`, затем новый stack запускает
сервис `migrate` и накатывает все схемные миграции;
- `Dump from a source PostgreSQL DSN` - новый режим для случаев, когда старая
БД доступна как внешний PostgreSQL DSN. Wizard поднимает целевой `postgres`,
сбрасывает целевую БД, делает `pg_dump` из старой БД, восстанавливает дамп в
compose-БД и затем запускает `migrate`.
Старый helper `scripts/migrate_to_minishop.sh` ниже всё ещё полезен для - `Copy old Docker volumes` - для старого compose-стека на том же Docker host.
in-place обновления уже клонированного репозитория: он переключает git-ветку, Скрипт подготавливает новый stack, копирует
переносит volumes и стартует новый stack. Прямой source DSN он не поддерживал. `remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально
переносит Caddy volumes и запускает новый stack.
- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN.
Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает
`pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает
сервис `migrate`.
## Короткий путь без смены ветки и сборки В обоих режимах старые volumes и старая БД не удаляются автоматически.
Если вы используете только готовые Docker-образы и не собираете проект ## Как работает перенос
локально, git-команды из ручного способа не нужны. Достаточно обновить
compose-файл до одного из готовых примеров в `deploy/examples` и
перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси -
`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть
такие же самостоятельные папки.
Минимальная последовательность: `remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы.
После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие
миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`,
затем последовательные записи `schema_migrations`. Это one-shot сервис: он
должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`.
При volume-миграции wizard:
1. Останавливает известные контейнеры старого и переходного стеков, если вы
подтверждаете этот шаг.
2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые
volumes.
3. Копирует старый volume БД:
```bash ```bash
docker compose down
# Скопируйте старый .env в выбранную папку примера и обновите значения там.
cp .env deploy/examples/no-proxy/.env
nano deploy/examples/no-proxy/.env
# Подготовьте стек из готовых образов.
IMAGE_TAG=3.4.0 docker compose \
--env-file deploy/examples/no-proxy/.env \
-f deploy/examples/no-proxy/docker-compose.yml \
up --no-start
# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data.
# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите.
docker run --rm \ docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \ -v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \ -v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to" alpine sh -c "cd /from && cp -a . /to"
IMAGE_TAG=3.4.0 docker compose \
--env-file deploy/examples/no-proxy/.env \
-f deploy/examples/no-proxy/docker-compose.yml \
up -d
docker compose \
--env-file deploy/examples/no-proxy/.env \
-f deploy/examples/no-proxy/docker-compose.yml \
logs migrate
``` ```
Сервис `migrate` сам применит недостающие схемные миграции к перенесённому 4. Если старые Caddy volumes существуют, переносит
тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и `remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и
`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми. `remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`.
5. Запускает новый stack через Docker Compose.
Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop` Если целевой DB volume уже непустой, wizard не перетирает его молча: он
(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до останавливается и просит отдельное подтверждение на продолжение без копирования
текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли старой БД.
две независимые перетряски, и скрипт пытается отработать обе одной командой:
1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*`
стали `remnawave-minishop-*`. Простой `docker compose up -d` после
`git pull` создаёт пустую БД — без переноса тома данные теряются.
2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены
`backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`.
Появились новые volumes `redis-data` и `shop-data`, новые обязательные
переменные окружения, а схема БД обновляется автоматически one-shot
сервисом `migrate`.
После миграции `docker compose ps` должен показать как минимум: `backend`,
`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0).
Логи: `docker compose logs -f backend worker frontend`.
Доступные пути:
- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка
останавливает старый стек, накатывает свежий код, переносит том БД,
поднимает новые сервисы. Идемпотентный.
- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно
понимать каждое действие или выполнить выборочно.
В обоих случаях:
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай
отката;
- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`)
тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться
в rate limit;
- схема БД обновляется автоматически: при первом `docker compose up -d` сервис
`migrate` накатывает на перенесённый том все недостающие миграции (от
alembic-схемы v2.7.0 до текущей).
## Что меняется в архитектуре ## Что меняется в архитектуре
@@ -120,256 +63,55 @@ docker compose \
| Версия | Сервисы | | Версия | Сервисы |
| --- | --- | | --- | --- |
| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` | | `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` |
| `v3.1.xv3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` | | `v3.1.x-v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` |
| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` | | `v3.4+` | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` |
Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`,
`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно
для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже.
**Volumes**: **Volumes**:
| Volume | v2.7.0 | v3.4+ | Что внутри | | Volume | Что происходит |
| --- | --- | --- | --- | | --- | --- |
| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL | | `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN |
| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) | | `remnawave-minishop-redis-data` | создается пустым |
| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji | | `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную |
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте | | `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy |
`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего Доступные compose-профили: `docker-compose.yml`,
долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется `deploy/examples/caddy/docker-compose.yml`,
из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете `deploy/examples/nginx/docker-compose.yml`,
тарифы через админ-панель). `deploy/examples/newt/docker-compose.yml`,
`deploy/examples/no-proxy/docker-compose.yml`.
## Переменные окружения, которые могли исчезнуть или переехать ## Переменные окружения
Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно Перед запуском нового стека проверьте `.env`. Самые важные изменения:
менялось между v2.7.0 и v3.4+:
| Было (v2.7.0) | Стало (v3.4+) | Действие | | Было | Стало | Действие |
| --- | --- | --- | | --- | --- | --- |
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). | | `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. |
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. | | `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. |
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. | | `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. |
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. | | `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. |
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. | | `POSTGRES_HOST=remnawave-tg-shop-db` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. |
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env``remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. | | `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. |
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. | | - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. |
| | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. | | - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. |
| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. |
Полный референс — [docs/getting-started/configuration.md](../getting-started/configuration.md). Скрипт миграции Остальные продуктовые настройки удобнее проверить после первого входа в
эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому админку.
что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше
сравнить свой `.env` с `.env.example` глазами один раз, чем получить
несовместимый шаблон автоматом.
## Автоматический способ (через скрипт) ## Reverse Proxy
Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из В старом стеке часто был один upstream `remnawave-tg-shop:8000`. В текущем
корня старого репозитория: split-arch stack маршруты разделены:
```bash | Назначение | Service | Port |
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из
> PowerShell, удобнее сначала открыть Git Bash.
Если вы уже подтянули новую версию и файл есть локально, можно запускать так:
```bash
bash scripts/migrate_to_minishop.sh
```
По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку
`main`. Можно переопределить через переменные окружения:
| Переменная | Назначение | По умолчанию |
| ----------------- | ----------------------------------------------------------------------- | ---------------------- |
| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория |
| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` |
| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` |
| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` |
| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) |
| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` |
Примеры:
```bash
# Caddy-вариант из raw-файла.
# Перед запуском скопируйте старый .env в deploy/examples/caddy/.env
# и заполните WEBHOOK_HOST / MINIAPP_HOST.
COMPOSE_FILE=deploy/examples/caddy/docker-compose.yml \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# С переключением origin на форк 3252a8
NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
# Без интерактива
ASSUME_YES=1 \
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
```
Что делает скрипт:
1. **Останавливает текущий стек**: ищет известные контейнеры старой схемы
(`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода
(`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы
(`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`)
и останавливает их, если запущены. Безопасно при повторном запуске.
2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе
оставляет как есть.
3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`).
Прерывается, если в рабочем дереве есть незакоммиченные изменения.
4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в
compose это значение перебивает `environment:` блок).
5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам
создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже
существующий volume.
6. **Переносит том БД** `remnawave-tg-shop-db-data``remnawave-minishop-db-data`
(и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если
новый том уже непустой — копирование пропускается. Новые volumes
`redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом
стеке).
7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс
`--build` для локальной сборки). `migrate` отработает первым, накатит
на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до
текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`.
Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже
выполненные шаги.
После того как убедитесь, что бот работает и данные на месте, удалите старые
тома:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
## Ручной способ
1. **Остановите старый стек и обновите код:**
```bash
docker compose down
git fetch origin
git checkout main
git pull --ff-only origin main
```
2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё
жёстко прописан старый контейнер БД:
```bash
sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env
```
Под `docker compose up` это не нужно: compose сам выставляет
`POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение
не используется.
3. **Проверьте `.env`** на наличие переменных, которые исчезли или
переименовались — см. раздел
[Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать)
выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`),
обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано
в compose).
4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes
(`db-data`, `redis-data`, `shop-data`) и контейнеры:
```bash
# Локальная сборка
docker compose up --no-start --build
# Или готовый Caddy-вариант из GHCR-образов
cp .env deploy/examples/caddy/.env
nano deploy/examples/caddy/.env
docker compose \
--env-file deploy/examples/caddy/.env \
-f deploy/examples/caddy/docker-compose.yml \
up --no-start
# Другие готовые варианты:
# deploy/examples/nginx/docker-compose.yml
# deploy/examples/newt/docker-compose.yml
# deploy/examples/no-proxy/docker-compose.yml
```
5. **Перенесите том БД в новое имя:**
```bash
docker run --rm \
-v remnawave-tg-shop-db-data:/from:ro \
-v remnawave-minishop-db-data:/to \
alpine sh -c "cd /from && cp -a . /to"
```
`remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые,
переносить нечего. Они инициализируются на лету: Redis пуст, а `data/`
наполняется при первом обращении к настройкам Web App / каталогу тарифов.
6. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и
состоянием ACME:
```bash
for v in caddy-data caddy-config; do
docker run --rm \
-v "remnawave-tg-shop-$v":/from:ro \
-v "remnawave-minishop-$v":/to \
alpine sh -c "cd /from && cp -a . /to"
done
```
7. **Запустите новый стек:**
```bash
docker compose up -d
# или
docker compose \
--env-file deploy/examples/caddy/.env \
-f deploy/examples/caddy/docker-compose.yml \
up -d
```
Сервис `migrate` запустится первым, обнаружит перенесённый том,
применит недостающие схемные миграции (`Base.metadata.create_all` +
последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и
выйдет с кодом 0. Только после этого стартуют `backend` и `worker`.
8. **Проверьте состояние:**
```bash
docker compose ps
docker compose logs -f backend worker frontend
docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully"
```
9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек
стабилен:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
## Внешний reverse-proxy
В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность
разнесена по портам и сервисам:
| Назначение | DNS-имя сервиса | Порт |
| --- | --- | --- | | --- | --- | --- |
| Telegram / платежные / вебхуки панели | `backend` | `8080` | | Telegram, платежные и panel webhooks | `backend` | `8080` |
| Health-чек | `backend` | `8080` (`/healthz`) | | Health-check | `backend` | `8080` (`/healthz`) |
| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) | | Web App API и auth | `backend` | `8081` внутри Docker-сети |
| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) | | Статический Web App frontend | `frontend` | `80` |
Минимальная замена для внешнего Nginx, который раньше слал всё на один Минимальная схема для внешнего Nginx:
upstream:
```nginx ```nginx
upstream remnawave_backend_webhooks { server backend:8080; } upstream remnawave_backend_webhooks { server backend:8080; }
@@ -378,8 +120,6 @@ upstream remnawave_frontend { server frontend:80; }
server { server {
server_name app.domain.com; server_name app.domain.com;
listen 443 ssl; listen 443 ssl;
http2 on;
# ssl_certificate / ssl_certificate_key — без изменений
location /webhook/ { proxy_pass http://remnawave_backend_webhooks; } location /webhook/ { proxy_pass http://remnawave_backend_webhooks; }
location /healthz { proxy_pass http://remnawave_backend_webhooks; } location /healthz { proxy_pass http://remnawave_backend_webhooks; }
@@ -387,24 +127,25 @@ server {
} }
``` ```
Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную
[docs/getting-started/deployment.md](../getting-started/deployment.md) и [docs/features/web-app.md](../features/web-app.md). Если раньше прокси указывал на маршрутизацию.
`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на
`backend:8080` / `frontend:80`, либо использовать готовый Caddy/Nginx/Newt
пример, который уже знает правильную маршрутизацию.
## Если что-то пошло не так ## Проверка
`migrate` упал → читайте `docker compose logs migrate`. Том БД остался После переноса:
не тронут, можно откатиться, переключив compose-файл обратно на старый
коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data`
(пока вы его не удалили).
`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо ```bash
`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте docker compose ps
свежий секрет в `.env` и перезапустите — Telegram переустановит webhook docker compose logs migrate
автоматически. docker compose logs -f backend worker frontend
```
Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`), `migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`,
а внешний прокси шлёт на `frontend:80`, а не на старый `postgres` и `redis` должны быть running/healthy.
`remnawave-tg-shop:8000`.
Когда убедитесь, что новый stack работает, старые volumes можно удалить вручную:
```bash
docker volume rm remnawave-tg-shop-db-data
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
```
+4 -5
View File
@@ -939,7 +939,7 @@ run_remnashop_migration() {
if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then
(cd "$TARGET_DIR" && run_compose restart backend worker) || true (cd "$TARGET_DIR" && run_compose restart backend worker) || true
fi fi
ok "Legacy migration completed." ok "Migration completed."
} }
run_target_schema_migrations() { run_target_schema_migrations() {
@@ -980,11 +980,10 @@ run_tgshop_volume_migration() {
run_tgshop_dsn_migration() { run_tgshop_dsn_migration() {
section "Old remnawave-tg-shop DSN migration" section "Old remnawave-tg-shop DSN migration"
warn "The old standalone helper did not support direct DSN import."
warn "This wizard path dumps the old PostgreSQL database, restores it into target Compose PostgreSQL, then runs Minishop schema migrations." warn "This wizard path dumps the old PostgreSQL database, restores it into target Compose PostgreSQL, then runs Minishop schema migrations."
warn "The target database will be dropped and recreated before restore." warn "The target database will be dropped and recreated before restore."
if ! confirm "Replace target database with the legacy dump?" 0; then if ! confirm "Replace target database with the source dump?" 0; then
warn "Migration not applied." warn "Migration not applied."
return 0 return 0
fi fi
@@ -1141,8 +1140,8 @@ main_menu() {
banner banner
choose "Main menu" "1" "1|2|3|4|5|6" \ choose "Main menu" "1" "1|2|3|4|5|6" \
"1. Install new stack" \ "1. Install new stack" \
"2. Install new stack and run legacy migration" \ "2. Install new stack and run migration" \
"3. Run legacy migration only" \ "3. Run migration only" \
"4. Download/update deployment files only" \ "4. Download/update deployment files only" \
"5. Validate current stack" \ "5. Validate current stack" \
"6. Exit" "6. Exit"
-318
View File
@@ -1,318 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=""
OLD_PREFIX="remnawave-tg-shop"
NEW_PREFIX="remnawave-minishop"
OLD_DB_VOLUME="${OLD_PREFIX}-db-data"
NEW_DB_VOLUME="${NEW_PREFIX}-db-data"
OLD_CADDY_VOLUMES=("${OLD_PREFIX}-caddy-data" "${OLD_PREFIX}-caddy-config")
NEW_CADDY_VOLUMES=("${NEW_PREFIX}-caddy-data" "${NEW_PREFIX}-caddy-config")
# Container names span three eras: original ``remnawave-tg-shop*`` (≤ v2.7.0),
# the renamed but still single-container ``remnawave-minishop*`` (v3.1.0
# v3.3.x), and the split-arch stack introduced in v3.4.0 (backend / worker /
# frontend / migrate / postgres / redis, plus optional caddy). The list is
# used only to stop existing containers before migration, so it is safe —
# and idempotent — to include every known name from every era.
KNOWN_CONTAINERS=(
# v2.7.0 (upstream remnawave-tg-shop):
"${OLD_PREFIX}"
"${OLD_PREFIX}-db"
"${OLD_PREFIX}-caddy"
# v3.1.0 v3.2.x (renamed but still one container):
"${NEW_PREFIX}"
"${NEW_PREFIX}-db"
"${NEW_PREFIX}-caddy"
# v3.4.0+ (split architecture):
"${NEW_PREFIX}-backend"
"${NEW_PREFIX}-worker"
"${NEW_PREFIX}-frontend"
"${NEW_PREFIX}-migrate"
"${NEW_PREFIX}-postgres"
"${NEW_PREFIX}-redis"
)
log() {
printf '%s\n' "$*"
}
die() {
printf 'Ошибка: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Не найдено обязательное средство \`$1\` в PATH."
}
resolve_root() {
if [[ -n "${PROJECT_ROOT:-}" ]]; then
[[ -d "$PROJECT_ROOT" ]] || die "PROJECT_ROOT не существует: $PROJECT_ROOT"
(cd -- "$PROJECT_ROOT" >/dev/null && pwd -P)
return
fi
local git_root
if git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "$git_root"
return
fi
pwd -P
}
run() {
log "+ $*"
"$@"
}
container_exists() {
docker inspect "$1" >/dev/null 2>&1
}
container_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]]
}
stop_container() {
local name="$1"
if ! container_exists "$name"; then
return 1
fi
if container_running "$name"; then
docker stop "$name" >/dev/null
fi
docker rm "$name" >/dev/null
}
volume_exists() {
docker volume inspect "$1" >/dev/null 2>&1
}
volume_is_empty() {
docker run --rm -v "$1:/data" alpine sh -c 'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1
}
copy_volume() {
local source="$1"
local target="$2"
if ! volume_exists "$source"; then
log " - Пропускаю том \`$source\`: исходный том не найден."
return 1
fi
if volume_exists "$target" && ! volume_is_empty "$target"; then
log " - Пропускаю том \`$target\`: он уже не пустой."
return 1
fi
if ! volume_exists "$target"; then
die "Целевой том \`$target\` не создан Compose. Сначала нужно подготовить новый стек в режиме \`--no-start\`."
fi
docker run --rm -v "$source:/from:ro" -v "$target:/to" alpine sh -c 'cd /from && cp -a . /to/'
}
is_old_postgres_host() {
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${OLD_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
}
is_new_postgres_host() {
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${NEW_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
}
update_postgres_host() {
if is_old_postgres_host; then
sed -i.bak -E "s|^([[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*)${OLD_PREFIX}-db([[:space:]]*(#.*)?)$|\\1${NEW_PREFIX}-db\\2|" "$ROOT/.env"
log " - \`.env\` обновлён, резервная копия сохранена в \`.env.bak\`."
elif is_new_postgres_host; then
log " - \`POSTGRES_HOST\` уже указывает на новый контейнер, ничего менять не нужно."
else
log " - \`POSTGRES_HOST\` не похож на старую схему, пропускаю изменение."
fi
}
main() {
require_cmd git
require_cmd docker
docker info >/dev/null
ROOT="$(resolve_root)"
local compose_file="${COMPOSE_FILE:-docker-compose.yml}"
local target_branch="${TARGET_BRANCH:-main}"
local git_remote="${GIT_REMOTE:-origin}"
local new_origin_url="${NEW_ORIGIN_URL:-}"
local assume_yes="${ASSUME_YES:-0}"
local current_origin
local current_branch
local remote_ref
local head_commit
local compose_has_build=0
local compose_has_caddy=0
local -a compose_cmd
local -a running_containers=()
local -a summary=()
local -a up_args
local name
local source
local target
local answer
if [[ $compose_file != /* ]]; then
compose_file="$ROOT/$compose_file"
fi
[[ -f "$compose_file" ]] || die "Compose-файл не найден: $compose_file"
[[ -e "$ROOT/.git" ]] || die "Скрипт нужно запускать из корня git-репозитория."
[[ -f "$ROOT/.env" ]] || die "Не найден \`.env\` в корне репозитория."
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose)
else
die "Не найден ни \`docker compose\`, ни \`docker-compose\`."
fi
if [[ -n "$(git -C "$ROOT" status --porcelain=v1)" ]]; then
die "В рабочем дереве есть незакоммиченные изменения. Сначала сохраните их, чтобы миграция не затёрла чужие правки."
fi
if grep -Eq '^[[:space:]]*build:[[:space:]]*' "$compose_file"; then
compose_has_build=1
fi
if grep -Eq '^[[:space:]]*caddy:[[:space:]]*$' "$compose_file"; then
compose_has_caddy=1
fi
for name in "${KNOWN_CONTAINERS[@]}"; do
if container_exists "$name"; then
running_containers+=("$name")
fi
done
current_origin="$(git -C "$ROOT" remote get-url "$git_remote")"
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
summary+=( "обновить $git_remote с \`$current_origin\` на \`$new_origin_url\`" )
fi
summary+=( "скачать ветку \`$target_branch\` из \`$git_remote\`" )
if volume_exists "$OLD_DB_VOLUME"; then
summary+=( "проверить том БД \`$OLD_DB_VOLUME\` и перенести в \`$NEW_DB_VOLUME\` при необходимости" )
fi
if ((compose_has_caddy)); then
for i in 0 1; do
source="${OLD_CADDY_VOLUMES[$i]}"
target="${NEW_CADDY_VOLUMES[$i]}"
if volume_exists "$source"; then
summary+=( "проверить том \`$source\` и перенести в \`$target\` при необходимости" )
fi
done
fi
if is_old_postgres_host; then
summary+=( "обновить \`POSTGRES_HOST\` в \`.env\`" )
fi
summary+=( "подготовить новый стек через Compose в режиме \`--no-start\`" )
summary+=( "запустить compose-файл \`$(basename "$compose_file")\`" )
if [[ "$assume_yes" != "1" ]]; then
if [[ ! -t 0 ]]; then
die "Скрипт ожидает интерактивное подтверждение. Запустите с \`ASSUME_YES=1\` для неинтерактивного режима."
fi
log "План миграции:"
for name in "${summary[@]}"; do
log " - $name"
done
read -r -p "Продолжить? [y/N]: " answer
case "$answer" in
y|Y|yes|YES|Yes)
;;
*)
die "Миграция отменена пользователем."
;;
esac
fi
log "1. Останавливаю старый стек"
if ((${#running_containers[@]})); then
for name in "${running_containers[@]}"; do
stop_container "$name"
log " - контейнер \`$name\` остановлен/удалён"
done
else
log " - запущенных контейнеров старой схемы не найдено"
fi
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
log "2. Обновляю origin"
run git -C "$ROOT" remote set-url "$git_remote" "$new_origin_url"
else
log "2. Origin уже актуален, пропускаю"
fi
log "3. Обновляю git до ветки \`$target_branch\`"
run git -C "$ROOT" fetch "$git_remote" "$target_branch"
current_branch="$(git -C "$ROOT" branch --show-current || true)"
if [[ -z "$current_branch" ]]; then
current_branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
fi
if [[ "$current_branch" != "$target_branch" ]]; then
if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$target_branch"; then
run git -C "$ROOT" switch "$target_branch"
else
run git -C "$ROOT" switch -c "$target_branch" --track "$git_remote/$target_branch"
fi
else
log " - уже на ветке \`$target_branch\`"
fi
remote_ref="$(git -C "$ROOT" rev-parse "$git_remote/$target_branch")"
head_commit="$(git -C "$ROOT" rev-parse HEAD)"
if [[ "$head_commit" != "$remote_ref" ]]; then
run git -C "$ROOT" pull --ff-only "$git_remote" "$target_branch"
else
log " - локальная ветка уже совпадает с удалённой, \`git pull\` не нужен"
fi
log "4. Обновляю \`.env\`"
update_postgres_host
log "5. Подготавливаю новый стек через Compose"
if ((compose_has_build)); then
run "${compose_cmd[@]}" -f "$compose_file" up --no-start --build
else
run "${compose_cmd[@]}" -f "$compose_file" up --no-start
fi
log "6. Переношу тома"
if copy_volume "$OLD_DB_VOLUME" "$NEW_DB_VOLUME"; then
log " - БД перенесена в \`$NEW_DB_VOLUME\`"
fi
if ((compose_has_caddy)); then
for i in 0 1; do
source="${OLD_CADDY_VOLUMES[$i]}"
target="${NEW_CADDY_VOLUMES[$i]}"
if copy_volume "$source" "$target"; then
log " - \`$source\` перенесён в \`$target\`"
fi
done
fi
log "7. Запускаю новый стек"
if ((compose_has_build)); then
up_args=(up -d --build --remove-orphans)
else
up_args=(up -d --remove-orphans)
fi
run "${compose_cmd[@]}" -f "$compose_file" "${up_args[@]}"
run "${compose_cmd[@]}" -f "$compose_file" ps
log "Готово."
}
main "$@"
+2 -1
View File
@@ -37,7 +37,8 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container():
assert "git clone" not in script assert "git clone" not in script
assert "backend python backend/scripts/import_legacy.py" in script assert "backend python backend/scripts/import_legacy.py" in script
assert "--dry-run" in script assert "--dry-run" in script
assert "Install new stack and run legacy migration" in script assert "Install new stack and run migration" in script
assert "Run migration only" in script
def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths(): def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths():
+55 -130
View File
@@ -1,25 +1,15 @@
"""Pin facts that ``docs/migrations/remnawave-tg-shop.md`` and """Pin facts shared by the migration docs and the unified install wizard."""
``scripts/migrate_to_minishop.sh`` rely on.
Both documents are written for a user upgrading from ``remnawave-tg-shop``
(v2.7.0 era) to the current split-arch ``remnawave-minishop`` (v3.4+). They
make concrete claims about:
* the set of container names produced by today's compose files;
* the set of volume names produced by today's compose files;
* the eras the migration script is allowed to stop containers from.
If any of these drift apart from reality, the migration document silently
goes stale. These tests fail loudly instead.
"""
import re import re
import shutil
import subprocess
import unittest import unittest
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnawave-tg-shop.md" DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnawave-tg-shop.md"
SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh" INSTALL_SCRIPT_PATH = REPO_ROOT / "scripts" / "install.sh"
REMOVED_SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh"
COMPOSE_FILES = ( COMPOSE_FILES = (
REPO_ROOT / "docker-compose.yml", REPO_ROOT / "docker-compose.yml",
REPO_ROOT / "deploy" / "examples" / "caddy" / "docker-compose.yml", REPO_ROOT / "deploy" / "examples" / "caddy" / "docker-compose.yml",
@@ -28,7 +18,6 @@ COMPOSE_FILES = (
REPO_ROOT / "deploy" / "examples" / "no-proxy" / "docker-compose.yml", REPO_ROOT / "deploy" / "examples" / "no-proxy" / "docker-compose.yml",
) )
# Names that the current architecture must produce in at least one compose file.
EXPECTED_CONTAINER_NAMES = { EXPECTED_CONTAINER_NAMES = {
"remnawave-minishop-backend", "remnawave-minishop-backend",
"remnawave-minishop-worker", "remnawave-minishop-worker",
@@ -52,15 +41,27 @@ def _all_compose_text() -> str:
return "\n".join(_read(path) for path in COMPOSE_FILES if path.is_file()) return "\n".join(_read(path) for path in COMPOSE_FILES if path.is_file())
def _known_containers_from_install_script() -> set[str]:
text = _read(INSTALL_SCRIPT_PATH)
match = re.search(r'^KNOWN_LEGACY_CONTAINERS="([^"]+)"', text, flags=re.MULTILINE)
if not match:
return set()
return set(match.group(1).split())
class MigrationDocumentationFactsTests(unittest.TestCase): class MigrationDocumentationFactsTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.doc = _read(DOC_PATH) self.doc = _read(DOC_PATH)
self.script = _read(SCRIPT_PATH) self.script = _read(INSTALL_SCRIPT_PATH)
self.compose = _all_compose_text() self.compose = _all_compose_text()
def test_unified_install_script_is_the_only_migration_entrypoint(self):
self.assertTrue(INSTALL_SCRIPT_PATH.is_file())
self.assertFalse(REMOVED_SCRIPT_PATH.exists())
self.assertIn("scripts/install.sh", self.doc)
self.assertNotIn("migrate_to_minishop.sh", self.doc)
def test_doc_lists_every_running_container_in_current_compose(self): def test_doc_lists_every_running_container_in_current_compose(self):
"""The architecture table must reflect what ``docker compose up``
actually produces today."""
missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc) missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc)
self.assertFalse( self.assertFalse(
missing, missing,
@@ -77,152 +78,94 @@ class MigrationDocumentationFactsTests(unittest.TestCase):
) )
def test_doc_warns_about_renamed_telegram_webhook_secret(self): def test_doc_warns_about_renamed_telegram_webhook_secret(self):
# This is the single rename most likely to bite a v2.7.0 → HEAD user.
self.assertIn("TELEGRAM_WEBHOOK_SECRET", self.doc) self.assertIn("TELEGRAM_WEBHOOK_SECRET", self.doc)
self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc) self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc)
def test_doc_says_webhook_base_url_is_required(self): def test_doc_says_webhook_base_url_is_required(self):
# Polling mode was dropped — without WEBHOOK_BASE_URL the bot refuses
# to start. A user migrating from v2.7.0 (where it was optional) must
# be told this explicitly.
block = self.doc.lower() block = self.doc.lower()
self.assertIn("webhook_base_url", block) self.assertIn("webhook_base_url", block)
# "обязательна" is the marker text in the env-vars table.
self.assertIn("обязательн", block) self.assertIn("обязательн", block)
def test_doc_mentions_migrate_one_shot_service(self): def test_doc_mentions_migrate_one_shot_service(self):
# The migrate sidecar is what makes schema migrations transparent on
# the second-stage upgrade. Don't bury it.
self.assertIn("migrate", self.doc)
# "one-shot" or "разовый" / "однораз" text variants accepted.
normalized = self.doc.lower() normalized = self.doc.lower()
self.assertIn("migrate", normalized)
self.assertTrue( self.assertTrue(
"one-shot" in normalized or "однораз" in normalized, "one-shot" in normalized or "однораз" in normalized,
"migration doc must describe `migrate` as a one-shot service", "migration doc must describe `migrate` as a one-shot service",
) )
def test_doc_mentions_postgres_host_compose_override_caveat(self): def test_doc_mentions_postgres_host_compose_override_caveat(self):
# Otherwise users follow the sed-fix step blindly and then panic
# because their .env still has the "wrong" hostname under compose.
self.assertIn("POSTGRES_HOST", self.doc) self.assertIn("POSTGRES_HOST", self.doc)
# Russian: "переопределя…" / "перебивает" indicate the override is documented.
text = self.doc.lower() text = self.doc.lower()
self.assertTrue( self.assertTrue(
"переопредел" in text or "перебивает" in text, "переопредел" in text or "service name" in text,
"migration doc must explain that compose overrides POSTGRES_HOST", "migration doc must explain that compose overrides POSTGRES_HOST",
) )
def test_doc_describes_redis_data_and_shop_data_as_fresh(self): def test_doc_describes_redis_data_and_shop_data_as_fresh(self):
# We do not migrate redis-data or shop-data — make sure that's said
# so users don't try to copy them from the old stack.
text = self.doc.lower() text = self.doc.lower()
self.assertIn("redis-data", text) self.assertIn("redis-data", text)
self.assertIn("shop-data", text) self.assertIn("shop-data", text)
# Some phrasing variant must say it's empty / fresh / new on purpose. self.assertTrue(any(marker in text for marker in ("пустым", "создается пуст")))
self.assertTrue(
any(marker in text for marker in ("пустым", "создаётся пуст", "новые,", "пуст —"))
)
def test_doc_explains_reverse_proxy_no_longer_single_upstream(self): def test_doc_explains_reverse_proxy_no_longer_single_upstream(self):
# The note "rename remnawave-tg-shop → remnawave-minishop in your
# proxy config" used to be enough; after the split it's wrong.
# Make sure both backend:8080 and frontend:80 are documented.
self.assertIn("backend:8080", self.doc) self.assertIn("backend:8080", self.doc)
self.assertIn("frontend:80", self.doc) self.assertIn("frontend:80", self.doc)
def test_doc_mentions_both_supported_tgshop_migration_methods(self):
def _known_containers_from_script() -> set[str]: self.assertIn("Copy old Docker volumes", self.doc)
"""Parse the literal ``KNOWN_CONTAINERS`` array from the script source. self.assertIn("Dump from a source PostgreSQL DSN", self.doc)
self.assertIn("pg_dump", self.doc)
The array is defined with ``${OLD_PREFIX}`` / ``${NEW_PREFIX}`` placeholders
that we substitute here. Doing this statically (rather than sourcing the
script in bash) avoids running ``main`` and keeps the test independent of
a bash interpreter being available at runtime.
"""
text = _read(SCRIPT_PATH)
prefix_match = re.search(r'^OLD_PREFIX="([^"]+)"', text, flags=re.MULTILINE)
new_match = re.search(r'^NEW_PREFIX="([^"]+)"', text, flags=re.MULTILINE)
if not prefix_match or not new_match:
return set()
old_prefix = prefix_match.group(1)
new_prefix = new_match.group(1)
# ``\n)`` as a boundary: a non-greedy ``.*?\)`` would stop at the first
# close-paren in a comment like ``# (v2.7.0 upstream remnawave-tg-shop)``.
array_match = re.search(
r"KNOWN_CONTAINERS=\((.*?)\n\)",
text,
flags=re.DOTALL,
)
if not array_match:
return set()
body = array_match.group(1)
# Strip line comments and quotes, then expand the two placeholders.
names: set[str] = set()
for raw_line in body.splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line:
continue
for token in re.findall(r'"([^"]+)"', line):
expanded = token.replace("${OLD_PREFIX}", old_prefix).replace(
"${NEW_PREFIX}", new_prefix
)
names.add(expanded)
return names
class MigrationScriptCoverageTests(unittest.TestCase): class InstallWizardCoverageTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.script = _read(SCRIPT_PATH) self.script = _read(INSTALL_SCRIPT_PATH)
self.known = _known_containers_from_script() self.known = _known_containers_from_install_script()
def test_known_containers_covers_split_arch(self): def test_known_containers_covers_split_arch(self):
"""A re-run on a partially migrated stack must be able to stop the new
containers, otherwise ``docker compose up`` later fails with name
conflicts."""
missing = sorted(EXPECTED_CONTAINER_NAMES - self.known) missing = sorted(EXPECTED_CONTAINER_NAMES - self.known)
self.assertFalse( self.assertFalse(
missing, missing,
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\nactual: {sorted(self.known)}", f"KNOWN_LEGACY_CONTAINERS missing split-arch entries: {missing}",
) )
def test_known_containers_still_covers_legacy_eras(self): def test_known_containers_still_covers_old_eras(self):
# We must also keep stopping the original (v2.7.0) and intermediate
# (v3.1.x v3.2.x) container names.
for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"): for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"):
with self.subTest(container=legacy): with self.subTest(container=legacy):
self.assertIn(legacy, self.known) self.assertIn(legacy, self.known)
def test_script_is_syntactically_valid_bash(self): def test_installer_contains_tgshop_volume_and_dsn_paths(self):
# The script is curl|bash'ed from raw.githubusercontent in the docs, self.assertIn("run_tgshop_volume_migration", self.script)
# so a syntax break is a hard regression. self.assertIn("run_tgshop_dsn_migration", self.script)
import shutil self.assertIn("remnawave-tg-shop-db-data", self.script)
import subprocess self.assertIn("pg_dump --clean --if-exists", self.script)
self.assertIn("run_compose run --rm migrate", self.script)
bash = shutil.which("bash") def test_script_is_syntactically_valid_sh_and_bash(self):
if not bash: # pragma: no cover sh = shutil.which("sh")
self.skipTest("bash not available in PATH") if not sh: # pragma: no cover
self.skipTest("sh not available in PATH")
result = subprocess.run( result = subprocess.run(
[bash, "-n", str(SCRIPT_PATH)], [sh, "-n", str(INSTALL_SCRIPT_PATH)],
check=False, check=False,
capture_output=True, capture_output=True,
text=True, text=True,
) )
self.assertEqual( self.assertEqual(result.returncode, 0, result.stderr)
result.returncode,
0, bash = shutil.which("bash")
( if bash:
"bash -n flagged migrate_to_minishop.sh:\n" result = subprocess.run(
f"stdout={result.stdout}\nstderr={result.stderr}" [bash, "-n", str(INSTALL_SCRIPT_PATH)],
), check=False,
capture_output=True,
text=True,
) )
self.assertEqual(result.returncode, 0, result.stderr)
class DocComposeFileReferencesTests(unittest.TestCase): class DocComposeFileReferencesTests(unittest.TestCase):
"""The doc links the user to specific compose files — they must exist."""
def test_referenced_compose_files_exist(self): def test_referenced_compose_files_exist(self):
doc = _read(DOC_PATH) doc = _read(DOC_PATH)
for relpath in ( for relpath in (
@@ -234,40 +177,22 @@ class DocComposeFileReferencesTests(unittest.TestCase):
): ):
with self.subTest(path=relpath): with self.subTest(path=relpath):
self.assertIn(relpath, doc) self.assertIn(relpath, doc)
self.assertTrue( self.assertTrue((REPO_ROOT / relpath).is_file())
(REPO_ROOT / relpath).is_file(),
f"{relpath} is referenced in migrations/remnawave-tg-shop.md "
"but missing on disk",
)
def test_doc_references_migrator_module_path(self): def test_doc_references_migrator_module_path(self):
# The doc tells users to expect ``backend/db/migrator.py`` migrations
# to apply via the migrate service. If the file moves, the doc lies.
doc = _read(DOC_PATH) doc = _read(DOC_PATH)
self.assertIn("backend/db/migrator.py", doc) self.assertIn("backend/db/migrator.py", doc)
self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file()) self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file())
class MigrationFootprintRegexTests(unittest.TestCase): class MigrationFootprintRegexTests(unittest.TestCase):
"""Spot-check that what compose actually defines matches what we documented."""
def test_every_compose_volume_documented(self): def test_every_compose_volume_documented(self):
"""If a future compose file introduces a new ``remnawave-minishop-*``
named volume, the migration doc must call out whether it carries data
from the old stack or starts fresh."""
doc = _read(DOC_PATH) doc = _read(DOC_PATH)
compose_text = _all_compose_text() compose_text = _all_compose_text()
# Find every named volume of the form ``remnawave-minishop-<key>-data``.
defined = set(re.findall(r"remnawave-minishop-[\w-]+-data", compose_text)) defined = set(re.findall(r"remnawave-minishop-[\w-]+-data", compose_text))
# Caddy-only volumes only ship in the caddy compose file but are still
# documented; allow them either way.
for volume in defined: for volume in defined:
with self.subTest(volume=volume): with self.subTest(volume=volume):
self.assertIn( self.assertIn(volume, doc)
volume,
doc,
f"new volume {volume} is defined in compose but missing from migration doc",
)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover