бэкапы
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class UploadDatabaseBackup extends Command
|
||||
{
|
||||
protected $signature = 'backup:upload {source : Absolute path to the backup file} {destination : Object path on the configured disk}';
|
||||
|
||||
protected $description = 'Upload a database backup file to private object storage';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$source = (string) $this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'database' => [
|
||||
'disk' => env('DATABASE_BACKUP_DISK', 's3'),
|
||||
'prefix' => env('DATABASE_BACKUP_PREFIX', 'backups/databases'),
|
||||
],
|
||||
];
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
/var/log/moonwell-database-backup.log {
|
||||
weekly
|
||||
rotate 8
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
create 0640 root adm
|
||||
}
|
||||
Executable
+174
@@ -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 "$@"
|
||||
Executable
+22
@@ -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'
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UploadDatabaseBackupCommandTest extends TestCase
|
||||
{
|
||||
public function test_it_uploads_and_verifies_a_private_backup(): void
|
||||
{
|
||||
Storage::fake('backup-test');
|
||||
config()->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user