From 121272fdfe7ab4ae3695ac57f13e260198aa12e5 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 12 May 2026 13:36:47 +0300 Subject: [PATCH 1/2] infra: add PostgreSQL daily backup via pg_dump with rotation - Add db-backup service to compose.yaml (postgres:17-alpine + cron) - Add pgbackups volume for backup storage - Add scripts/restore.sh for manual restore from latest backup - Update .env.example with BACKUP_RETENTION_DAYS and BACKUP_VOLUME_NAME - Document backup/restore flow in README Bump version -> 1.15.0 Co-Authored-By: Claude Opus 4.7 --- .env.example | 7 ++ .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 40 ++++++++++- compose.yaml | 28 +++++++- scripts/restore.sh | 72 +++++++++++++++++++ src/GmRelay.AppHost/packages.lock.json | 6 ++ src/GmRelay.Bot/packages.lock.json | 6 ++ .../packages.lock.json | 6 ++ src/GmRelay.Shared/packages.lock.json | 9 ++- .../Components/Layout/NavMenu.razor | 2 +- src/GmRelay.Web/packages.lock.json | 6 ++ tests/GmRelay.Bot.Tests/packages.lock.json | 14 ++-- 13 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 scripts/restore.sh diff --git a/.env.example b/.env.example index 4e3262a..0df8b59 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ POSTGRES_PASSWORD=StrongPasswordForDatabase # Локальный порт веб-интерфейса GM-Relay GMRELAY_WEB_PORT=8080 + +# === Backup === +# Сколько дней хранить дампы PostgreSQL (default: 7) +BACKUP_RETENTION_DAYS=7 + +# Имя Docker volume для резервных копий БД +BACKUP_VOLUME_NAME=game_pgbackups diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 932e3fa..ba256c5 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.14.0 + VERSION: 1.15.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 7a5fd28..29c24cc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.14.0 + 1.15.0 net10.0 preview enable diff --git a/README.md b/README.md index f9ada98..40892c3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.14.0`. +**Текущая версия:** `v1.15.0`. --- @@ -105,6 +105,44 @@ docker compose up -d 2. Создайте группу через `/newgroup`. 3. Откройте Mini App или Web Dashboard для расширенного управления. +## 💾 Backup и восстановление + +Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose. + +### Как это работает +- **Каждый день в 03:00** выполняется `pg_dump` базы `gmrelay_db`. +- Дампы сжимаются (`gzip`) и сохраняются в volume `pgbackups` (`/backups`). +- Формат имени: `gmrelay_db_YYYYMMDD_HHMMSS.sql.gz`. +- Ротация: по умолчанию хранятся последние **7 дней** (настраивается через `BACKUP_RETENTION_DAYS`). + +### Проверка бэкапов +```bash +docker compose exec db-backup ls -la /backups +``` + +### Ручное создание дампа +```bash +docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz" +``` + +### Восстановление из бэкапа +```bash +# Использовать последний автоматический бэкап +./scripts/restore.sh + +# Или указать конкретный файл +./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz +``` + +> [!WARNING] +> Восстановление **перезаписывает текущую базу данных**. Убедитесь, что вы понимаете последствия, прежде чем запускать `restore.sh`. + +### Переменные окружения (опциональные) +```env +BACKUP_RETENTION_DAYS=7 +BACKUP_VOLUME_NAME=game_pgbackups +``` + --- ## 🗂 Структура репозитория diff --git a/compose.yaml b/compose.yaml index 3f17b13..bb12187 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,8 +16,30 @@ services: timeout: 3s retries: 10 + db-backup: + image: postgres:17-alpine + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + POSTGRES_USER: gmrelay + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: gmrelay_db + PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7} + volumes: + - pgbackups:/backups + networks: + - gmrelay + entrypoint: ["sh", "-c"] + command: + - | + echo "0 3 * * * pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_\$$(date +\%Y\%m\%d_\%H\%M\%S).sql.gz && find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +\$${BACKUP_RETENTION_DAYS} -delete" | crontab - + crond -f + bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.14.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.15.0 restart: always depends_on: db: @@ -30,7 +52,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.14.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.15.0 restart: always depends_on: db: @@ -52,6 +74,8 @@ volumes: name: ${POSTGRES_VOLUME_NAME:-game_pgdata} web_keys: name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys} + pgbackups: + name: ${BACKUP_VOLUME_NAME:-game_pgbackups} networks: gmrelay: diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100644 index 0000000..b771cb1 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# GM-Relay PostgreSQL Backup Restore Script +# Usage: ./scripts/restore.sh [backup_file] +# If no file is provided, uses the most recent backup. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Check required env +if [ -z "${POSTGRES_PASSWORD:-}" ]; then + if [ -f "${PROJECT_ROOT}/.env" ]; then + export $(grep -v '^#' "${PROJECT_ROOT}/.env" | xargs) 2>/dev/null || true + fi +fi + +if [ -z "${POSTGRES_PASSWORD:-}" ]; then + echo "ERROR: POSTGRES_PASSWORD is not set. Please set it in your environment or .env file." + exit 1 +fi + +BACKUP_DIR="${PROJECT_ROOT}/backups" + +# Determine backup file +if [ $# -ge 1 ]; then + BACKUP_FILE="$1" +else + BACKUP_FILE=$(find "${BACKUP_DIR}" -name 'gmrelay_db_*.sql.gz' -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n1 | cut -d' ' -f2-) + if [ -z "${BACKUP_FILE}" ]; then + echo "ERROR: No backup files found in ${BACKUP_DIR}." + exit 1 + fi +fi + +if [ ! -f "${BACKUP_FILE}" ]; then + echo "ERROR: Backup file not found: ${BACKUP_FILE}" + exit 1 +fi + +echo "==================================================" +echo " GM-Relay PostgreSQL Restore" +echo "==================================================" +echo "" +echo "Backup file: ${BACKUP_FILE}" +echo "Database: gmrelay_db" +echo "User: gmrelay" +echo "" +read -p "This will OVERWRITE the current database. Are you sure? [y/N] " CONFIRM + +if [[ ! "${CONFIRM}" =~ ^[Yy]$ ]]; then + echo "Restore cancelled." + exit 0 +fi + +echo "" +echo "Restoring database from ${BACKUP_FILE}..." + +# Restore using docker compose exec to leverage the running postgres container +docker compose -f "${PROJECT_ROOT}/compose.yaml" exec -T db psql \ + -U gmrelay \ + -d gmrelay_db \ + -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null || true + +gunzip -c "${BACKUP_FILE}" | docker compose -f "${PROJECT_ROOT}/compose.yaml" exec -T db psql \ + -U gmrelay \ + -d gmrelay_db + +echo "" +echo "==================================================" +echo " Restore completed successfully!" +echo "==================================================" diff --git a/src/GmRelay.AppHost/packages.lock.json b/src/GmRelay.AppHost/packages.lock.json index 8e0fb8f..3f74298 100644 --- a/src/GmRelay.AppHost/packages.lock.json +++ b/src/GmRelay.AppHost/packages.lock.json @@ -83,6 +83,12 @@ "System.IO.Hashing": "10.0.3" } }, + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + }, "Aspire.Hosting": { "type": "Transitive", "resolved": "13.2.1", diff --git a/src/GmRelay.Bot/packages.lock.json b/src/GmRelay.Bot/packages.lock.json index 3f68185..2b3b5c5 100644 --- a/src/GmRelay.Bot/packages.lock.json +++ b/src/GmRelay.Bot/packages.lock.json @@ -95,6 +95,12 @@ "Microsoft.Extensions.Logging.Abstractions": "10.0.0" } }, + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + }, "Telegram.Bot": { "type": "Direct", "requested": "[22.9.5.3, )", diff --git a/src/GmRelay.ServiceDefaults/packages.lock.json b/src/GmRelay.ServiceDefaults/packages.lock.json index 65d1c84..6a2c51d 100644 --- a/src/GmRelay.ServiceDefaults/packages.lock.json +++ b/src/GmRelay.ServiceDefaults/packages.lock.json @@ -66,6 +66,12 @@ "OpenTelemetry.Api": "[1.15.3, 2.0.0)" } }, + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + }, "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", "resolved": "10.2.0", diff --git a/src/GmRelay.Shared/packages.lock.json b/src/GmRelay.Shared/packages.lock.json index 4a91a8c..36d6a25 100644 --- a/src/GmRelay.Shared/packages.lock.json +++ b/src/GmRelay.Shared/packages.lock.json @@ -1,6 +1,13 @@ { "version": 1, "dependencies": { - "net10.0": {} + "net10.0": { + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + } + } } } \ No newline at end of file diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index b32e0d7..408fdb2 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/src/GmRelay.Web/packages.lock.json b/src/GmRelay.Web/packages.lock.json index d634357..8c9ff76 100644 --- a/src/GmRelay.Web/packages.lock.json +++ b/src/GmRelay.Web/packages.lock.json @@ -32,6 +32,12 @@ "resolved": "10.0.2", "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==" }, + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + }, "Telegram.Bot": { "type": "Direct", "requested": "[22.9.6.1, )", diff --git a/tests/GmRelay.Bot.Tests/packages.lock.json b/tests/GmRelay.Bot.Tests/packages.lock.json index 743677d..46df037 100644 --- a/tests/GmRelay.Bot.Tests/packages.lock.json +++ b/tests/GmRelay.Bot.Tests/packages.lock.json @@ -18,6 +18,12 @@ "Microsoft.TestPlatform.TestHost": "17.14.1" } }, + "SecurityCodeScan.VS2019": { + "type": "Direct", + "requested": "[5.6.7, )", + "resolved": "5.6.7", + "contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ==" + }, "xunit": { "type": "Direct", "requested": "[2.9.3, )", @@ -726,8 +732,8 @@ "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", "Dapper.AOT": "[1.0.48, )", - "GmRelay.ServiceDefaults": "[1.14.0, )", - "GmRelay.Shared": "[1.14.0, )", + "GmRelay.ServiceDefaults": "[1.15.0, )", + "GmRelay.Shared": "[1.15.0, )", "Microsoft.Extensions.Hosting": "[10.0.5, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.5.3, )", @@ -754,8 +760,8 @@ "dependencies": { "Aspire.Npgsql": "[13.2.2, )", "Dapper": "[2.1.72, )", - "GmRelay.ServiceDefaults": "[1.14.0, )", - "GmRelay.Shared": "[1.14.0, )", + "GmRelay.ServiceDefaults": "[1.15.0, )", + "GmRelay.Shared": "[1.15.0, )", "Npgsql": "[10.0.2, )", "Telegram.Bot": "[22.9.6.1, )" } From 5a18cacb2e3552d05756fb47470743079e07af3d Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 12 May 2026 14:04:53 +0300 Subject: [PATCH 2/2] fix: address review feedback for backup infrastructure - compose.yaml: rewrite db-backup to use heredoc script instead of inline cron command, fixing date escaping and adding temp-file pipeline for reliable error detection - compose.yaml: fix pipefail issue by writing pg_dump to tmp file before compression and rotation - restore.sh: pass PGPASSWORD explicitly via docker compose exec -e - restore.sh: use ". .env" with set -a/+a instead of fragile xargs export Co-Authored-By: Claude Opus 4.7 --- compose.yaml | 12 +++++++++++- scripts/restore.sh | 11 ++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index bb12187..c232406 100644 --- a/compose.yaml +++ b/compose.yaml @@ -35,7 +35,17 @@ services: entrypoint: ["sh", "-c"] command: - | - echo "0 3 * * * pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_\$$(date +\%Y\%m\%d_\%H\%M\%S).sql.gz && find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +\$${BACKUP_RETENTION_DAYS} -delete" | crontab - + cat > /usr/local/bin/backup.sh << 'EOF' + #!/bin/sh + set -e + TMPFILE="/tmp/backup_$$.sql" + pg_dump -h db -U gmrelay -d gmrelay_db > "$TMPFILE" + gzip "$TMPFILE" + mv "$TMPFILE.gz" "/backups/gmrelay_db_$(date +%Y%m%d_%H%M%S).sql.gz" + find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +${BACKUP_RETENTION_DAYS} -delete + EOF + chmod +x /usr/local/bin/backup.sh + echo "0 3 * * * /usr/local/bin/backup.sh" | crontab - crond -f bot: diff --git a/scripts/restore.sh b/scripts/restore.sh index b771cb1..a32dbd2 100644 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -11,7 +11,10 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # Check required env if [ -z "${POSTGRES_PASSWORD:-}" ]; then if [ -f "${PROJECT_ROOT}/.env" ]; then - export $(grep -v '^#' "${PROJECT_ROOT}/.env" | xargs) 2>/dev/null || true + # shellcheck source=/dev/null + set -a + . "${PROJECT_ROOT}/.env" + set +a fi fi @@ -57,12 +60,14 @@ echo "" echo "Restoring database from ${BACKUP_FILE}..." # Restore using docker compose exec to leverage the running postgres container -docker compose -f "${PROJECT_ROOT}/compose.yaml" exec -T db psql \ +COMPOSE_ARGS="-f ${PROJECT_ROOT}/compose.yaml" + +docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \ -U gmrelay \ -d gmrelay_db \ -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null || true -gunzip -c "${BACKUP_FILE}" | docker compose -f "${PROJECT_ROOT}/compose.yaml" exec -T db psql \ +gunzip -c "${BACKUP_FILE}" | docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \ -U gmrelay \ -d gmrelay_db