From 9e36265f815ddce92ed2681f482f37e1da91584d Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 3 Aug 2026 00:24:06 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + app/Console/Commands/UploadDatabaseBackup.php | 70 +++++++ config/backup.php | 8 + ops/cron/moonwell-database-backup | 4 + ops/logrotate/moonwell-database-backup | 10 + scripts/backup-databases.sh | 174 ++++++++++++++++++ scripts/install-database-backup-cron.sh | 22 +++ .../UploadDatabaseBackupCommandTest.php | 43 +++++ 8 files changed, 335 insertions(+) create mode 100644 app/Console/Commands/UploadDatabaseBackup.php create mode 100644 config/backup.php create mode 100644 ops/cron/moonwell-database-backup create mode 100644 ops/logrotate/moonwell-database-backup create mode 100755 scripts/backup-databases.sh create mode 100755 scripts/install-database-backup-cron.sh create mode 100644 tests/Feature/UploadDatabaseBackupCommandTest.php diff --git a/.env.example b/.env.example index 94652f6..3a19d0a 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,10 @@ AWS_BUCKET=warcraft-client AWS_ENDPOINT=https://storage.yandexcloud.net AWS_USE_PATH_STYLE_ENDPOINT=true +DATABASE_BACKUP_DISK=s3 +DATABASE_BACKUP_PREFIX=backups/databases +DATABASE_BACKUP_ACORE_DATABASES="acore_auth acore_characters acore_world acore_playerbots store" + WWWUSER=1000 WWWGROUP=1000 SAIL_XDEBUG_MODE=off diff --git a/app/Console/Commands/UploadDatabaseBackup.php b/app/Console/Commands/UploadDatabaseBackup.php new file mode 100644 index 0000000..3324943 --- /dev/null +++ b/app/Console/Commands/UploadDatabaseBackup.php @@ -0,0 +1,70 @@ +argument('source'); + $destination = ltrim((string) $this->argument('destination'), '/'); + $diskName = (string) config('backup.database.disk', 's3'); + + if (! is_file($source) || ! is_readable($source)) { + $this->error("Backup file is not readable: {$source}"); + + return self::FAILURE; + } + + if ($destination === '' || str_contains($destination, '..')) { + $this->error('The destination object path is invalid.'); + + return self::FAILURE; + } + + $stream = fopen($source, 'rb'); + if ($stream === false) { + $this->error("Could not open backup file: {$source}"); + + return self::FAILURE; + } + + try { + $disk = Storage::disk($diskName); + $uploaded = $disk->writeStream($destination, $stream, [ + 'visibility' => 'private', + 'ContentType' => 'application/gzip', + ]); + + if (! $uploaded || ! $disk->exists($destination)) { + throw new RuntimeException('The storage driver did not confirm the uploaded object.'); + } + + $localSize = filesize($source); + $remoteSize = $disk->size($destination); + if ($localSize === false || $remoteSize !== $localSize) { + throw new RuntimeException("Uploaded object size mismatch: local={$localSize}, remote={$remoteSize}"); + } + + $this->info("Uploaded {$destination} ({$remoteSize} bytes) to {$diskName}."); + + return self::SUCCESS; + } catch (Throwable $exception) { + report($exception); + $this->error("Backup upload failed: {$exception->getMessage()}"); + + return self::FAILURE; + } finally { + fclose($stream); + } + } +} diff --git a/config/backup.php b/config/backup.php new file mode 100644 index 0000000..08a0ef3 --- /dev/null +++ b/config/backup.php @@ -0,0 +1,8 @@ + [ + 'disk' => env('DATABASE_BACKUP_DISK', 's3'), + 'prefix' => env('DATABASE_BACKUP_PREFIX', 'backups/databases'), + ], +]; diff --git a/ops/cron/moonwell-database-backup b/ops/cron/moonwell-database-backup new file mode 100644 index 0000000..e88bd04 --- /dev/null +++ b/ops/cron/moonwell-database-backup @@ -0,0 +1,4 @@ +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +15 0,8,16 * * * root /root/moonwell-web/scripts/backup-databases.sh >> /var/log/moonwell-database-backup.log 2>&1 diff --git a/ops/logrotate/moonwell-database-backup b/ops/logrotate/moonwell-database-backup new file mode 100644 index 0000000..bf0d7cf --- /dev/null +++ b/ops/logrotate/moonwell-database-backup @@ -0,0 +1,10 @@ +/var/log/moonwell-database-backup.log { + weekly + rotate 8 + compress + delaycompress + missingok + notifempty + copytruncate + create 0640 root adm +} diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh new file mode 100755 index 0000000..52bb77c --- /dev/null +++ b/scripts/backup-databases.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +PROD_COMPOSE="${ROOT_DIR}/compose.prod.yaml" +ENV_FILE="${MOONWELL_ENV_FILE:-${ROOT_DIR}/.env}" +BACKUP_WORK_DIR="${ROOT_DIR}/storage/app/private/database-backups" +LOCK_FILE="${BACKUP_WORK_DIR}/backup.lock" +RUN_DIR='' + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S %Z')" "$*" +} + +fail() { + log "ERROR: $*" >&2 + exit 1 +} + +env_value() { + local key="$1" + local value + + value="$(grep -E "^${key}=" "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2-)" + if [[ "$value" == \"*\" && "$value" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "$value" == \'*\' && "$value" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + + printf '%s' "$value" +} + +validate_database_name() { + [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]] || fail "Недопустимое имя базы: $1" +} + +prod() { + docker compose -f "$PROD_COMPOSE" "$@" +} + +cleanup() { + if [[ -n "$RUN_DIR" && -d "$RUN_DIR" && "$RUN_DIR" == "${BACKUP_WORK_DIR}/"* ]]; then + find "$RUN_DIR" -maxdepth 1 -type f -delete + rmdir "$RUN_DIR" 2>/dev/null || true + fi +} + +dump_web_database() { + local database="$1" + local output="$2" + + log "Создаю дамп локальной базы ${database}" + prod exec -T mysql sh -c ' + export MYSQL_PWD="$MYSQL_ROOT_PASSWORD" + exec mysqldump \ + --user=root \ + --single-transaction \ + --quick \ + --hex-blob \ + --triggers \ + --no-tablespaces \ + --set-gtid-purged=OFF \ + --databases "$1" + ' sh "$database" | gzip > "$output" +} + +dump_remote_database() { + local database="$1" + local output="$2" + local host="$3" + local port="$4" + local username="$5" + local password="$6" + + log "Создаю дамп AzerothCore ${database}" + docker run --rm --network host \ + --env MYSQL_PWD="$password" \ + mysql:8.4 \ + mysqldump \ + --host="$host" \ + --port="$port" \ + --user="$username" \ + --single-transaction \ + --quick \ + --hex-blob \ + --triggers \ + --no-tablespaces \ + --set-gtid-purged=OFF \ + --databases "$database" | gzip > "$output" +} + +upload_backup() { + local source="$1" + local destination="$2" + local app_uid app_gid + + app_uid="$(env_value WWWUSER)" + app_gid="$(env_value WWWGROUP)" + app_uid="${app_uid:-1000}" + app_gid="${app_gid:-1000}" + + chown "${app_uid}:${app_gid}" "$RUN_DIR" "$source" + chmod 0750 "$RUN_DIR" + chmod 0640 "$source" + prod exec -T --user "${app_uid}:${app_gid}" app \ + php artisan backup:upload "/var/www/html/storage/app/private/database-backups/${RUN_DIR##*/}/${source##*/}" "$destination" +} + +main() { + command -v docker >/dev/null 2>&1 || fail 'Не найден Docker' + command -v gzip >/dev/null 2>&1 || fail 'Не найден gzip' + command -v flock >/dev/null 2>&1 || fail 'Не найден flock' + docker compose version >/dev/null 2>&1 || fail 'Не найден Docker Compose Plugin' + [[ -f "$ENV_FILE" ]] || fail "Не найден ${ENV_FILE}" + [[ -f "$PROD_COMPOSE" ]] || fail "Не найден ${PROD_COMPOSE}" + + mkdir -p "$BACKUP_WORK_DIR" + exec 9>"$LOCK_FILE" + if ! flock -n 9; then + log 'Предыдущий запуск ещё выполняется, новый запуск пропущен' + return 0 + fi + + local timestamp object_prefix web_database + local acore_host acore_port acore_username acore_password acore_databases + local database filename + local -a databases=() files=() + + timestamp="$(date -u '+%Y-%m-%dT%H-%M-%SZ')" + object_prefix="$(env_value DATABASE_BACKUP_PREFIX)" + object_prefix="${object_prefix:-backups/databases}" + object_prefix="${object_prefix%/}/$(date -u '+%Y/%m/%d')/${timestamp}" + web_database="$(env_value DB_DATABASE)" + acore_host="$(env_value AZEROTHCORE_AUTH_DB_HOST)" + acore_port="$(env_value AZEROTHCORE_AUTH_DB_PORT)" + acore_username="$(env_value AZEROTHCORE_AUTH_DB_USERNAME)" + acore_password="$(env_value AZEROTHCORE_AUTH_DB_PASSWORD)" + acore_databases="$(env_value DATABASE_BACKUP_ACORE_DATABASES)" + acore_databases="${acore_databases:-acore_auth acore_characters acore_world acore_playerbots store}" + + [[ -n "$web_database" ]] || fail 'DB_DATABASE не задан' + [[ -n "$acore_host" && -n "$acore_port" && -n "$acore_username" ]] || fail 'Не настроено подключение к AzerothCore' + validate_database_name "$web_database" + + RUN_DIR="${BACKUP_WORK_DIR}/${timestamp}" + mkdir -m 0700 "$RUN_DIR" + trap cleanup EXIT + + filename="${web_database}.sql.gz" + dump_web_database "$web_database" "${RUN_DIR}/${filename}" + databases+=("$web_database") + files+=("$filename") + + for database in $acore_databases; do + validate_database_name "$database" + filename="${database}.sql.gz" + dump_remote_database "$database" "${RUN_DIR}/${filename}" "$acore_host" "$acore_port" "$acore_username" "$acore_password" + databases+=("$database") + files+=("$filename") + done + + log "Все дампы созданы, загружаю набор ${timestamp} в S3" + for filename in "${files[@]}"; do + [[ -s "${RUN_DIR}/${filename}" ]] || fail "Пустой файл дампа: ${filename}" + upload_backup "${RUN_DIR}/${filename}" "${object_prefix}/${filename}" + done + + log "Бэкап завершён: ${#databases[@]} баз, s3://${object_prefix}/" +} + +main "$@" diff --git a/scripts/install-database-backup-cron.sh b/scripts/install-database-backup-cron.sh new file mode 100755 index 0000000..5ff61fd --- /dev/null +++ b/scripts/install-database-backup-cron.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" + +if [[ "$(id -u)" -ne 0 ]]; then + printf 'Запусти установку cron от root.\n' >&2 + exit 1 +fi + +if [[ "$ROOT_DIR" != '/root/moonwell-web' ]]; then + printf 'Production-репозиторий должен находиться в /root/moonwell-web, сейчас: %s\n' "$ROOT_DIR" >&2 + exit 1 +fi + +install -m 0644 "${ROOT_DIR}/ops/cron/moonwell-database-backup" /etc/cron.d/moonwell-database-backup +install -m 0644 "${ROOT_DIR}/ops/logrotate/moonwell-database-backup" /etc/logrotate.d/moonwell-database-backup +chmod 0755 "${ROOT_DIR}/scripts/backup-databases.sh" + +printf 'Cron установлен: ежедневно в 00:15, 08:15 и 16:15 по времени сервера.\n' diff --git a/tests/Feature/UploadDatabaseBackupCommandTest.php b/tests/Feature/UploadDatabaseBackupCommandTest.php new file mode 100644 index 0000000..245932c --- /dev/null +++ b/tests/Feature/UploadDatabaseBackupCommandTest.php @@ -0,0 +1,43 @@ +set('backup.database.disk', 'backup-test'); + + $source = tempnam(sys_get_temp_dir(), 'moonwell-backup-'); + file_put_contents($source, 'compressed database dump'); + + try { + $this->artisan('backup:upload', [ + 'source' => $source, + 'destination' => 'backups/databases/test.sql.gz', + ])->assertSuccessful(); + + Storage::disk('backup-test')->assertExists('backups/databases/test.sql.gz'); + $this->assertSame( + file_get_contents($source), + Storage::disk('backup-test')->get('backups/databases/test.sql.gz'), + ); + } finally { + @unlink($source); + } + } + + public function test_it_rejects_a_missing_source_file(): void + { + config()->set('backup.database.disk', 'backup-test'); + + $this->artisan('backup:upload', [ + 'source' => '/missing/database.sql.gz', + 'destination' => 'backups/databases/test.sql.gz', + ])->assertFailed(); + } +}