Browse Source

Add scheduled database backups with rotation

Alexander Musikhin 1 week ago
parent
commit
a55bc00ef6

+ 5 - 1
.env.example

@@ -31,6 +31,10 @@ DB_PORT=3306
 DB_DATABASE=crm
 DB_USERNAME=dbuser
 DB_PASSWORD=password
+DB_BACKUP_ENABLED=false
+DB_BACKUP_TIME=02:00
+DB_BACKUP_KEEP=7
+DB_BACKUP_CONNECTION=mysql
 
 BROADCAST_DRIVER=redis
 CACHE_DRIVER=redis
@@ -67,4 +71,4 @@ FCM_CLIENT_EMAIL=
 FCM_PRIVATE_KEY=
 
 # id Artemenko Denis
-APP_DEFAULT_MAF_ORDER_USER_ID=1
+APP_DEFAULT_MAF_ORDER_USER_ID=1

+ 1 - 0
CLAUDE.md

@@ -35,6 +35,7 @@ npm run build             # Production build
 ```bash
 make queue-restart        # Перезапуск воркера
 make queue-log            # Логи очереди
+make scheduler-log        # Логи планировщика
 make application-log      # Логи приложения
 ```
 

+ 1 - 0
Dockerfile

@@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     wget \
     lynx \
     curl \
+    default-mysql-client \
     mc \
     vim \
     libmcrypt-dev \

+ 7 - 0
Makefile

@@ -37,6 +37,7 @@ message = @echo "\n========================================\n$(1)\n=============
 #
 application = docker exec -it $(call get_container_name, "app")
 queue = docker exec -it $(call get_container_name, "app-queue")
+scheduler = docker exec -it $(call get_container_name, "app-schedule")
 websocket = docker exec -it $(call get_container_name, "websocket")
 webserver = docker exec -it $(call get_container_name, "webserver")
 mysql = docker exec -it $(call get_container_name, "db")
@@ -146,6 +147,12 @@ queue-run-debug:
 queue-log: ## Лог приложения
 	$(compose) logs app-queue -f
 
+scheduler-shell: ## Консоль планировщика
+	$(scheduler) bash
+
+scheduler-log: ## Лог планировщика
+	$(compose) logs app-schedule -f
+
 test: ## Run tests
 	$(application) php artisan test
 

+ 115 - 0
app/Console/Commands/DbBackupRotate.php

@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Artisan;
+
+class DbBackupRotate extends Command
+{
+    protected $signature = 'db:backup:rotate
+                            {--path= : Путь к каталогу с дампами (по умолчанию storage/db)}
+                            {--keep=7 : Сколько последних файлов дампа хранить}
+                            {--connection=mysql : Имя соединения из config/database.php}
+                            {--no-gzip : Не сжимать дамп gzip}';
+
+    protected $description = 'Создаёт SQL-дамп базы данных и удаляет старые бэкапы, оставляя только последние N файлов';
+
+    public function handle(): int
+    {
+        $keep = (int) $this->option('keep');
+
+        if ($keep < 1) {
+            $this->error('Опция --keep должна быть больше 0.');
+            return self::FAILURE;
+        }
+
+        $path = $this->resolveBackupDirectory();
+        $connection = (string) $this->option('connection');
+
+        $exportOptions = [
+            '--connection' => $connection,
+            '--path' => $path,
+        ];
+
+        if ($this->option('no-gzip')) {
+            $exportOptions['--no-gzip'] = true;
+        }
+
+        $this->info("Создаю ночной бэкап БД в {$path}...");
+
+        $exitCode = Artisan::call('db:export', $exportOptions, $this->output);
+
+        if ($exitCode !== self::SUCCESS) {
+            $this->error('Бэкап не создан, ротация старых файлов пропущена.');
+            return self::FAILURE;
+        }
+
+        $files = $this->findBackupFiles($path);
+
+        if (count($files) <= $keep) {
+            $this->info("Ротация не требуется. Файлов бэкапа: " . count($files));
+            return self::SUCCESS;
+        }
+
+        $deleted = 0;
+
+        foreach (array_slice($files, $keep) as $file) {
+            if (@unlink($file)) {
+                $deleted++;
+                $this->line("Удалён старый бэкап: {$file}");
+            } else {
+                $this->warn("Не удалось удалить старый бэкап: {$file}");
+            }
+        }
+
+        $this->info("Ротация завершена. Удалено файлов: {$deleted}. Оставлено: {$keep}.");
+
+        return self::SUCCESS;
+    }
+
+    private function resolveBackupDirectory(): string
+    {
+        $path = $this->option('path');
+
+        if (is_string($path) && $path !== '') {
+            return $path;
+        }
+
+        return storage_path('db');
+    }
+
+    /**
+     * @return list<string>
+     */
+    private function findBackupFiles(string $path): array
+    {
+        $files = glob(rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*');
+
+        if ($files === false) {
+            return [];
+        }
+
+        $backups = array_values(array_filter($files, function (string $file): bool {
+            if (!is_file($file)) {
+                return false;
+            }
+
+            return (bool) preg_match('/.+_\d{4}-\d{2}-\d{2}_\d{6}\.sql(?:\.gz)?$/', basename($file));
+        }));
+
+        usort($backups, static function (string $left, string $right): int {
+            $mtimeCompare = filemtime($right) <=> filemtime($left);
+
+            if ($mtimeCompare !== 0) {
+                return $mtimeCompare;
+            }
+
+            return strcmp($right, $left);
+        });
+
+        return $backups;
+    }
+}

+ 60 - 5
app/Console/Commands/DbExport.php

@@ -1,15 +1,17 @@
 <?php
 
+declare(strict_types=1);
+
 namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Storage;
+use Symfony\Component\Process\ExecutableFinder;
 use Symfony\Component\Process\Process;
 
 class DbExport extends Command
 {
     protected $signature = 'db:export
-                            {--path= : Путь для сохранения файла (по умолчанию storage/app/db-backups)}
+                            {--path= : Путь для сохранения файла (по умолчанию storage/db)}
                             {--no-gzip : Не сжимать дамп gzip}
                             {--connection=mysql : Имя соединения из config/database.php}';
 
@@ -17,6 +19,13 @@ class DbExport extends Command
 
     public function handle(): int
     {
+        $dumpBinary = $this->resolveExecutable(['mysqldump', 'mariadb-dump']);
+
+        if ($dumpBinary === null) {
+            $this->error('Не найден mysqldump/mariadb-dump. Установите mysql-client в окружение, где запускается команда.');
+            return self::FAILURE;
+        }
+
         $connection = $this->option('connection');
         $config = config("database.connections.{$connection}");
 
@@ -30,12 +39,13 @@ class DbExport extends Command
         $port = (string) ($config['port'] ?? '3306');
         $user = $config['username'] ?? 'root';
         $password = (string) ($config['password'] ?? '');
+        $sslOptions = $this->resolveCliSslOptions($config);
 
         $gzip = !$this->option('no-gzip');
         $timestamp = date('Y-m-d_His');
         $filename = "{$database}_{$timestamp}.sql" . ($gzip ? '.gz' : '');
 
-        $dir = $this->option('path') ?: storage_path('app/db-backups');
+        $dir = $this->resolveBackupDirectory();
         if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
             $this->error("Не удалось создать каталог: {$dir}");
             return self::FAILURE;
@@ -44,11 +54,13 @@ class DbExport extends Command
         $filepath = rtrim($dir, '/') . '/' . $filename;
 
         $cmd = sprintf(
-            'mysqldump --host=%s --port=%s --user=%s %s --single-transaction --quick --routines --triggers --events --hex-blob --default-character-set=utf8mb4 %s',
+            '%s --host=%s --port=%s --user=%s %s %s --single-transaction --quick --routines --triggers --events --hex-blob --default-character-set=utf8mb4 %s',
+            escapeshellarg($dumpBinary),
             escapeshellarg($host),
             escapeshellarg($port),
             escapeshellarg($user),
             $password !== '' ? '--password=' . escapeshellarg($password) : '',
+            $sslOptions,
             escapeshellarg($database),
         );
 
@@ -59,7 +71,7 @@ class DbExport extends Command
 
         $this->info("Создаю дамп БД {$database} → {$filepath}");
 
-        $process = Process::fromShellCommandline($cmd);
+        $process = new Process(['bash', '-o', 'pipefail', '-c', $cmd]);
         $process->setTimeout(null);
         $process->run(function ($type, $buffer) {
             if ($type === Process::ERR) {
@@ -79,4 +91,47 @@ class DbExport extends Command
 
         return self::SUCCESS;
     }
+
+    private function resolveBackupDirectory(): string
+    {
+        $path = $this->option('path');
+
+        if (is_string($path) && $path !== '') {
+            return $path;
+        }
+
+        return storage_path('db');
+    }
+
+    /**
+     * @param array<string, mixed> $config
+     */
+    private function resolveCliSslOptions(array $config): string
+    {
+        $sslCa = $config['options'][\PDO::MYSQL_ATTR_SSL_CA] ?? null;
+
+        if (is_string($sslCa) && $sslCa !== '') {
+            return '--ssl-ca=' . escapeshellarg($sslCa);
+        }
+
+        return '--skip-ssl';
+    }
+
+    /**
+     * @param list<string> $candidates
+     */
+    private function resolveExecutable(array $candidates): ?string
+    {
+        $finder = new ExecutableFinder();
+
+        foreach ($candidates as $candidate) {
+            $binary = $finder->find($candidate);
+
+            if ($binary !== null) {
+                return $binary;
+            }
+        }
+
+        return null;
+    }
 }

+ 63 - 5
app/Console/Commands/DbImport.php

@@ -1,14 +1,17 @@
 <?php
 
+declare(strict_types=1);
+
 namespace App\Console\Commands;
 
 use Illuminate\Console\Command;
+use Symfony\Component\Process\ExecutableFinder;
 use Symfony\Component\Process\Process;
 
 class DbImport extends Command
 {
     protected $signature = 'db:import
-                            {file : Путь к .sql или .sql.gz файлу}
+                            {file : Путь к .sql или .sql.gz файлу или имя файла из storage/db}
                             {--connection=mysql : Имя соединения из config/database.php}
                             {--force : Не запрашивать подтверждение}
                             {--drop : Очистить БД (DROP+CREATE) перед импортом}';
@@ -17,7 +20,15 @@ class DbImport extends Command
 
     public function handle(): int
     {
-        $file = $this->argument('file');
+        $mysqlBinary = $this->resolveExecutable(['mysql', 'mariadb']);
+
+        if ($mysqlBinary === null) {
+            $this->error('Не найден mysql/mariadb client. Установите mysql-client в окружение, где запускается команда.');
+            return self::FAILURE;
+        }
+
+        $file = $this->resolveImportFile((string) $this->argument('file'));
+
         if (!is_file($file)) {
             $this->error("Файл не найден: {$file}");
             return self::FAILURE;
@@ -36,6 +47,7 @@ class DbImport extends Command
         $port = (string) ($config['port'] ?? '3306');
         $user = $config['username'] ?? 'root';
         $password = (string) ($config['password'] ?? '');
+        $sslOptions = $this->resolveCliSslOptions($config);
 
         if (!$this->option('force') && !$this->confirm("Импортировать дамп в БД {$database}? Текущие данные будут перезаписаны.", false)) {
             $this->warn('Отменено.');
@@ -43,11 +55,12 @@ class DbImport extends Command
         }
 
         $authHost = sprintf(
-            '--host=%s --port=%s --user=%s %s',
+            '--host=%s --port=%s --user=%s %s %s',
             escapeshellarg($host),
             escapeshellarg($port),
             escapeshellarg($user),
             $password !== '' ? '--password=' . escapeshellarg($password) : '',
+            $sslOptions,
         );
 
         if ($this->option('drop')) {
@@ -69,11 +82,11 @@ class DbImport extends Command
 
         $isGz = str_ends_with($file, '.gz');
         $reader = $isGz ? 'gunzip -c ' : 'cat ';
-        $cmd = $reader . escapeshellarg($file) . " | mysql {$authHost} --default-character-set=utf8mb4 " . escapeshellarg($database);
+        $cmd = $reader . escapeshellarg($file) . ' | ' . escapeshellarg($mysqlBinary) . " {$authHost} --default-character-set=utf8mb4 " . escapeshellarg($database);
 
         $this->info("Импортирую {$file} → {$database}");
 
-        $process = Process::fromShellCommandline($cmd);
+        $process = new Process(['bash', '-o', 'pipefail', '-c', $cmd]);
         $process->setTimeout(null);
         $process->run(function ($type, $buffer) {
             if ($type === Process::ERR) {
@@ -89,4 +102,49 @@ class DbImport extends Command
         $this->info('Импорт завершён.');
         return self::SUCCESS;
     }
+
+    private function resolveImportFile(string $file): string
+    {
+        if ($file === '') {
+            return $file;
+        }
+
+        if (is_file($file)) {
+            return $file;
+        }
+
+        return storage_path('db/' . ltrim($file, '/'));
+    }
+
+    /**
+     * @param array<string, mixed> $config
+     */
+    private function resolveCliSslOptions(array $config): string
+    {
+        $sslCa = $config['options'][\PDO::MYSQL_ATTR_SSL_CA] ?? null;
+
+        if (is_string($sslCa) && $sslCa !== '') {
+            return '--ssl-ca=' . escapeshellarg($sslCa);
+        }
+
+        return '--skip-ssl';
+    }
+
+    /**
+     * @param list<string> $candidates
+     */
+    private function resolveExecutable(array $candidates): ?string
+    {
+        $finder = new ExecutableFinder();
+
+        foreach ($candidates as $candidate) {
+            $binary = $finder->find($candidate);
+
+            if ($binary !== null) {
+                return $binary;
+            }
+        }
+
+        return null;
+    }
 }

+ 6 - 7
docker-compose.yml

@@ -38,13 +38,12 @@ services:
         environment:
             - SERVICE_NAME=app-queue
 
-    # Laravel`s scheduler - disabled
-#    app-schedule:
-#        <<: *base-app
-#        command: php artisan schedule:work
-#        environment:
-#            SERVICE_NAME: ${COMPOSE_PROJECT_NAME}-schedule
-#        hostname: ${COMPOSE_PROJECT_NAME}-schedule
+    # Laravel`s scheduler
+    app-schedule:
+        <<: *base-app
+        command: php artisan schedule:work
+        environment:
+            - SERVICE_NAME=app-schedule
 
     # Nginx Service ----------------------------------------------------------------------------------------------------
     webserver:

+ 9 - 0
routes/console.php

@@ -2,7 +2,16 @@
 
 use Illuminate\Foundation\Inspiring;
 use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Schedule;
 
 Artisan::command('inspire', function () {
     $this->comment(Inspiring::quote());
 })->purpose('Display an inspiring quote')->hourly();
+
+Schedule::command('db:backup:rotate', [
+    '--keep' => (int) env('DB_BACKUP_KEEP', 7),
+    '--connection' => env('DB_BACKUP_CONNECTION', env('DB_CONNECTION', 'mysql')),
+])
+    ->dailyAt((string) env('DB_BACKUP_TIME', '02:00'))
+    ->withoutOverlapping()
+    ->when(static fn (): bool => filter_var(env('DB_BACKUP_ENABLED', false), FILTER_VALIDATE_BOOL));