From e6ef26ddc3a95f14a4f810dba7e91eff960c5c75 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:12:55 +0800 Subject: [PATCH 1/2] feat(backups): settings-driven database backups that fail loudly Replace the db:backup command, which piped mysqldump through gzip without pipefail, swallowed upload errors and returned success on a failed dump. That is how production uploaded 20-byte empty dumps on 2026-09-24/25 (no mysqldump in the image) and then nothing at all while reporting DONE. - DatabaseBackupService streams the dump client's stdout into gzip in PHP (no shell pipeline), passes the password via MYSQL_PWD, and fails a run on a non-zero exit, a missing '-- Dump completed' marker, a dump under min_size_bytes, or an uploaded object whose size differs from the file. - Uploads go to any filesystem disk (bucket override for s3, key prefix); retention by age and/or count runs only after a fully successful run and always keeps each database's newest backup. - Every attempt is recorded in database_backups (status, size, duration, error, trigger); failures can email configured addresses. - Settings live in system.database-backups (env defaults in config/database-backups.php) and drive the schedule; disabled by default. - Admin endpoints under int/v1/database-backups: settings get/save/reset, recent runs, and a queued 'run now'. - db:backup exits non-zero on any failure; --force runs while disabled. --- config/database-backups.php | 81 ++ config/laravel-mysql-s3-backup.php | 63 -- ...6_000000_create_database_backups_table.php | 44 + src/Console/Commands/BackupDatabase.php | 65 ++ .../Commands/BackupDatabase/MysqlS3Backup.php | 186 ---- .../BackupDatabase/S3BackupTrimmer.php | 93 -- .../Internal/v1/DatabaseBackupController.php | 98 ++ src/Jobs/RunDatabaseBackup.php | 46 + src/Models/DatabaseBackup.php | 97 ++ src/Notifications/DatabaseBackupFailed.php | 69 ++ src/Providers/CoreServiceProvider.php | 7 +- .../DatabaseBackupException.php | 10 + .../DatabaseBackup/DatabaseBackupService.php | 364 +++++++ src/Support/DatabaseBackupSettings.php | 188 ++++ src/routes.php | 7 + .../Console/BackupDatabaseCommandsTest.php | 386 ------- tests/Unit/DatabaseBackupsTest.php | 938 ++++++++++++++++++ tests/Unit/RoutesContractTest.php | 21 + 18 files changed, 2033 insertions(+), 730 deletions(-) create mode 100644 config/database-backups.php delete mode 100644 config/laravel-mysql-s3-backup.php create mode 100644 migrations/2026_10_06_000000_create_database_backups_table.php create mode 100644 src/Console/Commands/BackupDatabase.php delete mode 100644 src/Console/Commands/BackupDatabase/MysqlS3Backup.php delete mode 100644 src/Console/Commands/BackupDatabase/S3BackupTrimmer.php create mode 100644 src/Http/Controllers/Internal/v1/DatabaseBackupController.php create mode 100644 src/Jobs/RunDatabaseBackup.php create mode 100644 src/Models/DatabaseBackup.php create mode 100644 src/Notifications/DatabaseBackupFailed.php create mode 100644 src/Services/DatabaseBackup/DatabaseBackupException.php create mode 100644 src/Services/DatabaseBackup/DatabaseBackupService.php create mode 100644 src/Support/DatabaseBackupSettings.php delete mode 100644 tests/Unit/Console/BackupDatabaseCommandsTest.php create mode 100644 tests/Unit/DatabaseBackupsTest.php diff --git a/config/database-backups.php b/config/database-backups.php new file mode 100644 index 00000000..6d4fd674 --- /dev/null +++ b/config/database-backups.php @@ -0,0 +1,81 @@ + env('DB_BACKUP_ENABLED', false), + + /* + * hourly, every_six_hours, every_twelve_hours, daily or weekly. Times are UTC. + */ + 'frequency' => env('DB_BACKUP_FREQUENCY', 'daily'), + 'time' => env('DB_BACKUP_TIME', '00:00'), + 'day_of_week' => (int) env('DB_BACKUP_DAY_OF_WEEK', 0), + + /* + * Where dumps go: a disk from config/filesystems.php, an optional bucket override for + * s3 disks, and a key prefix. + */ + 'disk' => env('DB_BACKUP_DISK', 's3'), + 'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'), + 'path' => env('DB_BACKUP_PATH', ''), + + /* + * The database connections to dump, by name. + */ + 'connections' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_CONNECTIONS', 'mysql,sandbox'))))), + + /* + * Retention, applied after each fully successful run. Null disables that limit. + */ + 'retention_days' => env('DB_BACKUP_RETENTION_DAYS', 30), + 'retention_count' => env('DB_BACKUP_RETENTION_COUNT'), + + /* + * A compressed dump smaller than this many bytes fails the run. A gzip of nothing is + * 20 bytes; even an empty schema dump compresses to several hundred. + */ + 'min_size_bytes' => (int) env('DB_BACKUP_MIN_SIZE_BYTES', 1024), + + /* + * Who hears about a failed run. + */ + 'notify_on_failure' => env('DB_BACKUP_NOTIFY_ON_FAILURE', false), + 'notify_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_NOTIFY_EMAILS', ''))))), + + /* + * The dump client and the arguments it always gets. --single-transaction gives a + * consistent InnoDB snapshot without locking; --no-tablespaces avoids needing the + * PROCESS privilege, which managed databases such as RDS do not grant. + */ + 'dump_binary' => env('DB_BACKUP_DUMP_BINARY', 'mysqldump'), + 'dump_args' => [ + '--single-transaction', + '--quick', + '--routines', + '--triggers', + '--hex-blob', + '--no-tablespaces', + '--default-character-set=utf8mb4', + ], + 'extra_dump_args' => array_values(array_filter(explode(' ', (string) env('DB_BACKUP_EXTRA_DUMP_ARGS', '')))), + + /* + * Seconds a single database's dump may take. + */ + 'timeout' => (int) env('DB_BACKUP_TIMEOUT', 7200), + + /* + * Where dumps are written before upload. + */ + 'tmp_dir' => env('DB_BACKUP_TMP_DIR', sys_get_temp_dir()), +]; diff --git a/config/laravel-mysql-s3-backup.php b/config/laravel-mysql-s3-backup.php deleted file mode 100644 index 0170d661..00000000 --- a/config/laravel-mysql-s3-backup.php +++ /dev/null @@ -1,63 +0,0 @@ - 'latest', - 'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'), - 'region' => env('AWS_DEFAULT_REGION', 'ap-southeast-1'), - 'endpoint' => env('AWS_ENDPOINT') -]; - -if (env('APP_ENV') === 'local' || env('APP_ENV') === 'development') { - $s3Config['key'] = env('AWS_ACCESS_KEY_ID'); - $s3Config['secret'] = env('AWS_SECRET_ACCESS_KEY'); -} - -return [ - /* - * Configure with your Amazon S3 credentials - * You should use an IAM user who only has PutObject access - * to a specified bucket - */ - 's3' => $s3Config, - - /* - * Want to add some custom mysqldump args? - */ - 'custom_mysqldump_args' => '--default-character-set=utf8mb4', - - /* - * Whether or not to gzip the .sql file - */ - 'gzip' => true, - - /* - * Time allowed to run backup - */ - 'sql_timout' => 7200, // 2 hours - - /* - * Backup filename - */ - 'filename' => str_replace([' ', '-'], '_', env('APP_ENV', 'local')) . '_%s_backup-%s.sql', - - /* - * Where to store the backup file locally - */ - 'backup_dir' => '/tmp', - - /* - * Do you want to keep a copy of it or delete it - * after it's been uploaded? - */ - 'keep_local_copy' => false, - - /* - * Do you want to keep a rolling number of - * backups on S3? How many days worth? - */ - 'rolling_backup_days' => 30, -]; diff --git a/migrations/2026_10_06_000000_create_database_backups_table.php b/migrations/2026_10_06_000000_create_database_backups_table.php new file mode 100644 index 00000000..a2e0f400 --- /dev/null +++ b/migrations/2026_10_06_000000_create_database_backups_table.php @@ -0,0 +1,44 @@ +uuid('uuid')->primary(); + $table->string('connection_name', 64); + $table->string('database', 128); + $table->string('status', 16)->index(); + $table->string('trigger', 16); + $table->string('disk', 64); + $table->string('path', 512)->nullable(); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->unsignedBigInteger('duration_ms')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('started_at')->index(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('pruned_at')->nullable(); + $table->timestamps(); + + $table->index(['disk', 'path']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('database_backups'); + } +}; diff --git a/src/Console/Commands/BackupDatabase.php b/src/Console/Commands/BackupDatabase.php new file mode 100644 index 00000000..b0cacf32 --- /dev/null +++ b/src/Console/Commands/BackupDatabase.php @@ -0,0 +1,65 @@ +option('force')) { + $this->info('Database backups are disabled. Enable them under Admin → Database Backups, or pass --force.'); + + return self::SUCCESS; + } + + $trigger = in_array($this->option('trigger'), [DatabaseBackup::TRIGGER_SCHEDULED, DatabaseBackup::TRIGGER_MANUAL, DatabaseBackup::TRIGGER_CONSOLE], true) + ? $this->option('trigger') + : DatabaseBackup::TRIGGER_CONSOLE; + + try { + $records = $service->run($trigger, $this->option('connection') ?: null, $settings); + } catch (DatabaseBackupException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + if (!$records) { + $this->error('No database connections are configured for backup.'); + + return self::FAILURE; + } + + $failed = 0; + foreach ($records as $record) { + if ($record->status === DatabaseBackup::STATUS_COMPLETED) { + $this->info(sprintf('Backed up %s to %s:%s (%d bytes, %d ms)', $record->database, $record->disk, $record->path, $record->size_bytes, $record->duration_ms)); + } else { + $failed++; + $this->error(sprintf('Backup of %s failed: %s', $record->database, $record->error)); + } + } + + return $failed ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Console/Commands/BackupDatabase/MysqlS3Backup.php b/src/Console/Commands/BackupDatabase/MysqlS3Backup.php deleted file mode 100644 index ae926121..00000000 --- a/src/Console/Commands/BackupDatabase/MysqlS3Backup.php +++ /dev/null @@ -1,186 +0,0 @@ - %s', escapeshellarg($fileName)); - } else { - $cmd .= sprintf(' > %s', escapeshellarg($fileName)); - } - - if ($this->output->isVerbose()) { - $this->output->writeln('Running backup for database `' . $databaseName . '`'); - $this->output->writeln('Saving to ' . $fileName); - } - - if ($this->output->isDebug()) { - $this->output->writeln("Running command: {$cmd}"); - } - - $process = $this->makeProcess($cmd); - $process->setTimeout(config('laravel-mysql-s3-backup.sql_timout')); - $process->run(); - - if (!$process->isSuccessful()) { - $this->error($process->getErrorOutput()); - - if ($this->output->isVerbose()) { - $this->output->writeln( - sprintf( - 'Unable to dump database for %s with a file name of %s. Error: %s', - now()->toDateString(), - $fileName, - $process->getErrorOutput() - ) - ); - } - - return; - } - - if ($this->output->isVerbose()) { - $this->output->writeln("Backup saved to {$fileName}"); - } - - // Upload to S3 - $s3config = config('laravel-mysql-s3-backup.s3'); - $s3 = $this->makeS3Client($s3config); - - $bucket = config('laravel-mysql-s3-backup.s3.bucket'); - $key = basename($fileName); - - if ($folder = config('laravel-mysql-s3-backup.s3.folder')) { - $key = $folder . '/' . $key; - } - - if ($this->output->isVerbose()) { - $this->output->writeln(sprintf('Uploading %s to S3/%s', $key, $bucket)); - } - - $uploader = $this->makeMultipartUploader( - $s3, - $fileName, - [ - 'bucket' => $bucket, - 'key' => $key, - ] - ); - - try { - $uploader->upload(); - } catch (MultipartUploadException $e) { - if ($this->output->isVerbose()) { - $this->output->writeln( - sprintf( - 'Unable to upload "%s" backup to s3. Error: %s', - $fileName, - $e->getMessage() - ) - ); - } - } - - // Delete the local tmp file - if (!config('laravel-mysql-s3-backup.keep_local_copy')) { - if ($this->output->isVerbose()) { - $this->output->writeln("Deleting local backup file {$fileName}"); - } - - $this->deleteLocalFile($fileName); - } - - if ($this->output->isVerbose()) { - $this->output->writeln("Backup {$fileName} successfully uploaded to s3"); - } - - if (config('laravel-mysql-s3-backup.rolling_backup_days')) { - if ($this->output->isVerbose()) { - $this->output->writeln("Trimming {$bucket} have have only " . config('laravel-mysql-s3-backup.rolling_backup_days') . ' days of backups'); - } - - $this->makeBackupTrimmer(config('laravel-mysql-s3-backup.rolling_backup_days'), $bucket)->run(); - } - } - } - - protected function makeProcess(string $command) - { - return Process::fromShellCommandline($command); - } - - protected function makeS3Client(array $config) - { - return new S3Client($config); - } - - protected function makeMultipartUploader($s3, string $fileName, array $options) - { - return new MultipartUploader($s3, $fileName, $options); - } - - protected function makeBackupTrimmer($days, $bucket): S3BackupTrimmer - { - return S3BackupTrimmer::make($days, $bucket); - } - - protected function deleteLocalFile(string $fileName): void - { - unlink($fileName); - } -} diff --git a/src/Console/Commands/BackupDatabase/S3BackupTrimmer.php b/src/Console/Commands/BackupDatabase/S3BackupTrimmer.php deleted file mode 100644 index b929fa17..00000000 --- a/src/Console/Commands/BackupDatabase/S3BackupTrimmer.php +++ /dev/null @@ -1,93 +0,0 @@ -days = $days; - $this->bucket = $bucket; - $this->when = now()->subDays($this->days)->startOfDay(); - } - - public static function make($days, $bucket) - { - return new static($days, $bucket); - } - - public function run() - { - $s3config = config('laravel-mysql-s3-backup.s3'); - $s3 = $this->makeS3Client($s3config); - - with($s3->listObjects( - [ - 'Bucket' => $this->bucket, - ] - ), function ($response) { - return collect($response['Contents'] ?? []) - ->when( - !empty(config('laravel-mysql-s3-backup.s3.folder')), - function ($contents) { - return collect($contents)->reject( - function ($item) { - return !Str::startsWith($item['Key'], config('laravel-mysql-s3-backup.s3.folder') . '/'); - } - )->values(); - } - ) - ->transform( - function ($item) { - return $item['Key']; - } - ); - })->filter( - function ($filename) { - if (!empty(config('laravel-mysql-s3-backup.s3.folder'))) { - $filename = str_replace(config('laravel-mysql-s3-backup.s3.folder') . '/', '', $filename); - } - - // date is second to last part of filename - $parts = explode('-', $filename); - $index = count($parts) - 2; - $date = $parts[$index]; - - return Carbon::createFromFormat('Ymd', $date)->lt($this->when); - } - )->tap( - function ($filenames) use ($s3) { - if ($filenames->isNotEmpty()) { - $s3->deleteObjects( - [ - 'Bucket' => $this->bucket, - 'Delete' => [ - 'Objects' => $filenames->map( - function ($filename) { - return ['Key' => $filename]; - } - )->all(), - ], - ] - ); - } - } - ); - } - - protected function makeS3Client(array $config) - { - // Real AWS client construction is covered at the command seam with injected fakes. - // @codeCoverageIgnoreStart - return new S3Client($config); - // @codeCoverageIgnoreEnd - } -} diff --git a/src/Http/Controllers/Internal/v1/DatabaseBackupController.php b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php new file mode 100644 index 00000000..2784d49a --- /dev/null +++ b/src/Http/Controllers/Internal/v1/DatabaseBackupController.php @@ -0,0 +1,98 @@ +json($this->settingsPayload(DatabaseBackupSettings::settings())); + } + + /** + * Save the administrator's settings; the scheduler picks them up from its next minute. + */ + public function saveSettings(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'frequency' => ['required', Rule::in(DatabaseBackupSettings::FREQUENCIES)], + 'time' => ['required', 'regex:/^([01]\d|2[0-3]):[0-5]\d$/'], + 'day_of_week' => ['sometimes', 'integer', 'min:0', 'max:6'], + 'disk' => ['required', 'string', Rule::in(array_column(DatabaseBackupSettings::disks(), 'name'))], + 'bucket' => ['nullable', 'string', 'max:255'], + 'path' => ['nullable', 'string', 'max:255'], + 'connections' => ['required', 'array', 'min:1'], + 'connections.*' => ['string', Rule::in(DatabaseBackupSettings::connections())], + 'retention_days' => ['nullable', 'integer', 'min:1', 'max:3650'], + 'retention_count' => ['nullable', 'integer', 'min:1', 'max:10000'], + 'min_size_bytes' => ['sometimes', 'integer', 'min:0'], + 'notify_on_failure' => ['sometimes', 'boolean'], + 'notify_emails' => ['sometimes', 'array'], + 'notify_emails.*' => ['email'], + ]); + + return response()->json($this->settingsPayload(DatabaseBackupSettings::store($validated))); + } + + /** + * Discard the administrator's settings and fall back to the environment. + */ + public function resetSettings(AdminRequest $request): JsonResponse + { + return response()->json($this->settingsPayload(DatabaseBackupSettings::reset())); + } + + /** + * Recent runs, newest first. + */ + public function runs(AdminRequest $request): JsonResponse + { + $request->validate(['limit' => ['sometimes', 'integer', 'min:1', 'max:200']]); + + $runs = DatabaseBackup::orderByDesc('started_at') + ->limit((int) $request->input('limit', 25)) + ->get() + ->map(fn (DatabaseBackup $backup) => $backup->toAdminArray()) + ->values(); + + return response()->json(['runs' => $runs]); + } + + /** + * Queue a backup of the configured databases now, whether or not scheduling is enabled. + */ + public function run(AdminRequest $request): JsonResponse + { + RunDatabaseBackup::dispatch(DatabaseBackup::TRIGGER_MANUAL); + + return response()->json(['status' => 'queued'], 202); + } + + protected function settingsPayload(array $settings): array + { + $lastRun = DatabaseBackup::orderByDesc('started_at')->first(); + $lastSuccess = DatabaseBackup::where('status', DatabaseBackup::STATUS_COMPLETED)->orderByDesc('started_at')->first(); + + return [ + 'settings' => $settings, + 'defaults' => DatabaseBackupSettings::defaults(), + 'disks' => DatabaseBackupSettings::disks(), + 'connections' => DatabaseBackupSettings::connections(), + 'last_run' => $lastRun?->toAdminArray(), + 'last_success' => $lastSuccess?->toAdminArray(), + ]; + } +} diff --git a/src/Jobs/RunDatabaseBackup.php b/src/Jobs/RunDatabaseBackup.php new file mode 100644 index 00000000..da7ff503 --- /dev/null +++ b/src/Jobs/RunDatabaseBackup.php @@ -0,0 +1,46 @@ +run($this->trigger); + } catch (DatabaseBackupException $e) { + Log::warning('Requested database backup did not run', ['error' => $e->getMessage()]); + } + } +} diff --git a/src/Models/DatabaseBackup.php b/src/Models/DatabaseBackup.php new file mode 100644 index 00000000..5b3928b8 --- /dev/null +++ b/src/Models/DatabaseBackup.php @@ -0,0 +1,97 @@ + 'integer', + 'duration_ms' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'pruned_at' => 'datetime', + ]; + + public function getConnectionName() + { + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); + } + + protected static function booted(): void + { + static::creating(function (DatabaseBackup $backup) { + $backup->uuid ??= (string) Str::uuid(); + }); + } + + /** + * Run records are kept for a year, long after their files have been trimmed. + */ + public function prunable() + { + return static::where('started_at', '<', now()->subYear()); + } + + /** + * The shape the admin console reads. + */ + public function toAdminArray(): array + { + return [ + 'id' => $this->uuid, + 'connection' => $this->connection_name, + 'database' => $this->database, + 'status' => $this->status, + 'trigger' => $this->trigger, + 'disk' => $this->disk, + 'path' => $this->path, + 'size_bytes' => $this->size_bytes, + 'duration_ms' => $this->duration_ms, + 'error' => $this->error, + 'started_at' => $this->started_at?->toIso8601String(), + 'completed_at' => $this->completed_at?->toIso8601String(), + 'pruned_at' => $this->pruned_at?->toIso8601String(), + ]; + } +} diff --git a/src/Notifications/DatabaseBackupFailed.php b/src/Notifications/DatabaseBackupFailed.php new file mode 100644 index 00000000..d320dca5 --- /dev/null +++ b/src/Notifications/DatabaseBackupFailed.php @@ -0,0 +1,69 @@ + + */ + public array $failures; + + /** + * @param array $failed + */ + public function __construct(array $failed) + { + $this->failures = array_map(fn (DatabaseBackup $backup) => [ + 'connection' => (string) $backup->connection_name, + 'database' => (string) $backup->database, + 'error' => $backup->error, + ], $failed); + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $message = (new MailMessage()) + ->error() + ->subject($app . ' database backup failed') + ->line('The database backup that just ran did not complete, so no new backup was stored for:'); + + foreach ($this->failures as $failure) { + $message->line($failure['database'] . ' (' . $failure['connection'] . '): ' . ($failure['error'] ?: 'unknown error')); + } + + return $message + ->line('Older backups were left in place.') + ->action('Review database backups', Utils::consoleUrl('admin/database-backups')); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return ['failures' => $this->failures]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 37732a92..25a165aa 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -101,7 +101,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\PurgeScheduledTaskLogs::class, \Fleetbase\Console\Commands\PurgeOrphanedModelRecords::class, \Fleetbase\Console\Commands\DeleteUser::class, - \Fleetbase\Console\Commands\BackupDatabase\MysqlS3Backup::class, + \Fleetbase\Console\Commands\BackupDatabase::class, \Fleetbase\Console\Commands\TelemetryPing::class, ]; @@ -144,7 +144,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/schedule-monitor.php', 'schedule-monitor'); $this->mergeConfigFrom(__DIR__ . '/../../config/excel.php', 'excel'); $this->mergeConfigFrom(__DIR__ . '/../../config/sentry.php', 'sentry'); - $this->mergeConfigFrom(__DIR__ . '/../../config/laravel-mysql-s3-backup.php', 'laravel-mysql-s3-backup'); + $this->mergeConfigFrom(__DIR__ . '/../../config/database-backups.php', 'database-backups'); $this->mergeConfigFrom(__DIR__ . '/../../config/responsecache.php', 'responsecache'); $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); @@ -216,6 +216,9 @@ public function boot() // available in sandbox within an hour of being created in // production, but infrequent enough to avoid unnecessary DB load. $schedule->command('sandbox:sync')->hourly()->name('sandbox-sync')->withoutOverlapping(); + // Database backups run on the schedule an administrator sets under Admin → Database + // Backups (or the DB_BACKUP_* environment defaults), and not at all while disabled. + \Fleetbase\Support\DatabaseBackupSettings::schedule($schedule); }); $this->registerObservers(); $this->registerExpansionsFrom(); diff --git a/src/Services/DatabaseBackup/DatabaseBackupException.php b/src/Services/DatabaseBackup/DatabaseBackupException.php new file mode 100644 index 00000000..2904d296 --- /dev/null +++ b/src/Services/DatabaseBackup/DatabaseBackupException.php @@ -0,0 +1,10 @@ + one record per connection + * + * @throws DatabaseBackupException when another backup is already running + */ + public function run(string $trigger = DatabaseBackup::TRIGGER_CONSOLE, ?array $connections = null, ?array $settings = null): array + { + $settings ??= DatabaseBackupSettings::settings(); + $connections = $connections ?: $settings['connections']; + + $lock = $this->acquireLock(); + if ($lock === false) { + throw new DatabaseBackupException('Another database backup is already running.'); + } + + try { + $records = []; + foreach ($connections as $connection) { + $records[] = $this->backupConnection((string) $connection, $trigger, $settings); + } + + $failed = array_values(array_filter($records, fn (DatabaseBackup $record) => $record->status === DatabaseBackup::STATUS_FAILED)); + + if ($failed) { + $this->notifyFailure($failed, $settings); + } elseif ($records) { + $this->pruneQuietly($settings); + } + + return $records; + } finally { + if (is_object($lock)) { + $lock->release(); + } + } + } + + /** + * Dump, verify and upload one connection's database, recording the outcome. + */ + public function backupConnection(string $connection, string $trigger, array $settings): DatabaseBackup + { + $database = (string) config("database.connections.{$connection}.database", $connection); + $started = hrtime(true); + $record = DatabaseBackup::create([ + 'connection_name' => $connection, + 'database' => $database, + 'status' => DatabaseBackup::STATUS_RUNNING, + 'trigger' => $trigger, + 'disk' => $settings['disk'], + 'started_at' => now(), + ]); + + $file = null; + try { + $file = $this->dump($connection, $this->fileName($database)); + $size = (int) filesize($file); + + if ($size < $settings['min_size_bytes']) { + throw new DatabaseBackupException(sprintf('The compressed dump is %d bytes, below the %d byte minimum.', $size, $settings['min_size_bytes'])); + } + + $path = $this->objectPath($settings['path'], basename($file)); + $this->upload($this->disk($settings), $file, $path, $size); + + $record->fill([ + 'status' => DatabaseBackup::STATUS_COMPLETED, + 'path' => $path, + 'size_bytes' => $size, + ]); + } catch (\Throwable $e) { + $record->fill([ + 'status' => DatabaseBackup::STATUS_FAILED, + 'error' => mb_substr($e->getMessage(), 0, 4000), + ]); + + Log::error('Database backup failed', ['connection' => $connection, 'database' => $database, 'error' => $e->getMessage()]); + } finally { + if ($file && is_file($file)) { + @unlink($file); + } + } + + $record->fill([ + 'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000), + 'completed_at' => now(), + ])->save(); + + return $record; + } + + /** + * Stream the dump client's output into a gzip file and verify it finished. + * + * @return string the path of the compressed dump + */ + public function dump(string $connection, string $fileName): string + { + $config = config("database.connections.{$connection}"); + if (!is_array($config) || !in_array($config['driver'] ?? null, ['mysql', 'mariadb'], true)) { + throw new DatabaseBackupException("Connection [{$connection}] is not a MySQL connection."); + } + + $directory = rtrim((string) config('database-backups.tmp_dir', sys_get_temp_dir()), '/'); + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new DatabaseBackupException("Cannot create the backup directory {$directory}."); + } + + $file = $directory . '/' . $fileName; + $gz = @gzopen($file, 'wb6'); + if ($gz === false) { + throw new DatabaseBackupException("Cannot write {$file}."); + } + + $tail = ''; + $stderr = ''; + + try { + $process = $this->makeProcess($this->dumpCommand($config), ['MYSQL_PWD' => (string) ($config['password'] ?? '')]); + $process->setTimeout((int) config('database-backups.timeout', 7200)); + $process->start(); + + foreach ($process as $type => $data) { + if ($type === Process::OUT) { + gzwrite($gz, $data); + $tail = substr($tail . $data, -512); + } else { + $stderr .= $data; + } + } + + $exitCode = $process->wait(); + } catch (\Throwable $e) { + gzclose($gz); + @unlink($file); + + throw new DatabaseBackupException('The dump could not run: ' . $e->getMessage(), 0, $e); + } + + gzclose($gz); + + if ($exitCode !== 0) { + @unlink($file); + + throw new DatabaseBackupException(sprintf('The dump exited with code %d: %s', $exitCode, trim($stderr) ?: 'no error output')); + } + + if (!str_contains($tail, static::COMPLETION_MARKER)) { + @unlink($file); + + throw new DatabaseBackupException('The dump ended without its completion marker, so it is incomplete.' . (trim($stderr) ? ' ' . trim($stderr) : '')); + } + + return $file; + } + + /** + * The dump client's argument list. The password travels in MYSQL_PWD, never on the + * command line, where any process listing would show it. + */ + public function dumpCommand(array $config): array + { + $command = [(string) config('database-backups.dump_binary', 'mysqldump')]; + + if (!empty($config['unix_socket'])) { + $command[] = '--socket=' . $config['unix_socket']; + } else { + $command[] = '--host=' . ($config['host'] ?? '127.0.0.1'); + $command[] = '--port=' . ($config['port'] ?? 3306); + } + + $command[] = '--user=' . ($config['username'] ?? 'root'); + + return array_merge( + $command, + (array) config('database-backups.dump_args', []), + (array) config('database-backups.extra_dump_args', []), + [(string) $config['database']] + ); + } + + /** + * Upload the file and confirm the stored object is the size we wrote. + */ + public function upload(Filesystem $disk, string $file, string $path, int $size): void + { + $stream = fopen($file, 'rb'); + + try { + $disk->writeStream($path, $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + $stored = $disk->size($path); + if ((int) $stored !== $size) { + throw new DatabaseBackupException(sprintf('The uploaded backup is %d bytes but the local dump is %d bytes.', $stored, $size)); + } + } + + /** + * Delete backups beyond the retention limits, always keeping each database's newest. + * + * Only files following this command's naming scheme are considered, so nothing else + * stored under the same prefix is ever touched. + * + * @return array the deleted paths + */ + public function prune(array $settings): array + { + if (!$settings['retention_days'] && !$settings['retention_count']) { + return []; + } + + $disk = $this->disk($settings); + $cutoff = $settings['retention_days'] ? now()->subDays($settings['retention_days'])->format('Ymd-His') : null; + $groups = []; + + foreach ($disk->files($settings['path']) as $path) { + if (preg_match('/^(.+)_backup-(\d{8}-\d{6})\.sql(?:\.gz)?$/', basename($path), $matches)) { + $groups[$matches[1]][$path] = $matches[2]; + } + } + + $delete = []; + foreach ($groups as $files) { + arsort($files); + $index = 0; + foreach ($files as $path => $timestamp) { + $tooMany = $settings['retention_count'] && $index >= $settings['retention_count']; + $tooOld = $cutoff && $timestamp < $cutoff; + + if ($index > 0 && ($tooMany || $tooOld)) { + $delete[] = $path; + } + $index++; + } + } + + if ($delete) { + $disk->delete($delete); + DatabaseBackup::where('disk', $settings['disk'])->whereIn('path', $delete)->update(['pruned_at' => now()]); + } + + return $delete; + } + + /** + * The disk backups are written to, with the bucket override applied and errors thrown + * rather than reported as `false`. + */ + public function disk(array $settings): Filesystem + { + $config = config("filesystems.disks.{$settings['disk']}"); + if (!is_array($config)) { + throw new DatabaseBackupException("Filesystem disk [{$settings['disk']}] is not configured."); + } + + if ($settings['bucket'] && ($config['driver'] ?? null) === 's3') { + $config['bucket'] = $settings['bucket']; + } + + $config['throw'] = true; + + return $this->buildDisk($config); + } + + public function fileName(string $database): string + { + $environment = str_replace([' ', '-'], '_', (string) config('app.env', 'local')); + + return sprintf('%s_%s_backup-%s.sql.gz', $environment, $database, now()->format('Ymd-His')); + } + + public function objectPath(string $prefix, string $fileName): string + { + return ltrim(trim($prefix, '/') . '/' . $fileName, '/'); + } + + protected function pruneQuietly(array $settings): void + { + try { + $this->prune($settings); + } catch (\Throwable $e) { + Log::warning('Database backup retention failed', ['error' => $e->getMessage()]); + } + } + + /** + * @param array $failed + */ + protected function notifyFailure(array $failed, array $settings): void + { + if (!$settings['notify_on_failure'] || !$settings['notify_emails']) { + return; + } + + try { + Notification::route('mail', $settings['notify_emails'])->notify(new DatabaseBackupFailed($failed)); + } catch (\Throwable $e) { + Log::error('Could not send the database backup failure notification', ['error' => $e->getMessage()]); + } + } + + /** + * @return object|bool a lock to release, true when the cache cannot lock, or false when held elsewhere + */ + protected function acquireLock() + { + try { + $lock = Cache::lock(static::LOCK_KEY, 4 * 3600); + } catch (\Throwable $e) { + return true; + } + + return $lock->get() ? $lock : false; + } + + protected function makeProcess(array $command, array $env): Process + { + return new Process($command, null, $env); + } + + protected function buildDisk(array $config): Filesystem + { + return Storage::build($config); + } +} diff --git a/src/Support/DatabaseBackupSettings.php b/src/Support/DatabaseBackupSettings.php new file mode 100644 index 00000000..807e9b68 --- /dev/null +++ b/src/Support/DatabaseBackupSettings.php @@ -0,0 +1,188 @@ + config('database-backups.enabled', false), + 'frequency' => config('database-backups.frequency', 'daily'), + 'time' => config('database-backups.time', '00:00'), + 'day_of_week' => config('database-backups.day_of_week', 0), + 'disk' => config('database-backups.disk', 's3'), + 'bucket' => config('database-backups.bucket'), + 'path' => config('database-backups.path', ''), + 'connections' => config('database-backups.connections', ['mysql', 'sandbox']), + 'retention_days' => config('database-backups.retention_days', 30), + 'retention_count' => config('database-backups.retention_count'), + 'min_size_bytes' => config('database-backups.min_size_bytes', 1024), + 'notify_on_failure' => config('database-backups.notify_on_failure', false), + 'notify_emails' => config('database-backups.notify_emails', []), + ]); + } + + /** + * The effective settings: the environment defaults with the stored override applied. + * + * A missing database (a fresh install, unit tests) falls back to the defaults rather than + * failing whatever asked — most importantly the scheduler. + */ + public static function settings(): array + { + try { + $stored = Setting::where('key', 'system.' . static::SETTING_KEY)->value('value'); + } catch (\Throwable $e) { + $stored = null; + } + + return static::normalize(array_merge(static::defaults(), is_array($stored) ? $stored : [])); + } + + /** + * Persist administrator settings. + */ + public static function store(array $settings): array + { + $settings = static::normalize(array_merge(static::defaults(), $settings)); + + Setting::configureSystem(static::SETTING_KEY, $settings); + + return $settings; + } + + /** + * Drop the stored override so the environment defaults apply again. + */ + public static function reset(): array + { + Setting::where('key', 'system.' . static::SETTING_KEY)->delete(); + + return static::settings(); + } + + /** + * Coerce settings into their canonical shape. + */ + public static function normalize(array $settings): array + { + $frequency = in_array($settings['frequency'] ?? null, static::FREQUENCIES, true) ? $settings['frequency'] : 'daily'; + $time = is_string($settings['time'] ?? null) && preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $settings['time']) ? $settings['time'] : '00:00'; + $bucket = trim((string) ($settings['bucket'] ?? '')); + + return [ + 'enabled' => filter_var($settings['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'frequency' => $frequency, + 'time' => $time, + 'day_of_week' => min(6, max(0, (int) ($settings['day_of_week'] ?? 0))), + 'disk' => (string) ($settings['disk'] ?? 's3') ?: 's3', + 'bucket' => $bucket === '' ? null : $bucket, + 'path' => trim((string) ($settings['path'] ?? ''), "/ \t\n\r"), + 'connections' => static::stringList($settings['connections'] ?? []), + 'retention_days' => static::positiveIntOrNull($settings['retention_days'] ?? null), + 'retention_count' => static::positiveIntOrNull($settings['retention_count'] ?? null), + 'min_size_bytes' => max(0, (int) ($settings['min_size_bytes'] ?? 1024)), + 'notify_on_failure' => filter_var($settings['notify_on_failure'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'notify_emails' => static::stringList($settings['notify_emails'] ?? []), + ]; + } + + /** + * The cron expression for the configured frequency, in UTC. + */ + public static function cronExpression(array $settings): string + { + [$hour, $minute] = array_map('intval', explode(':', $settings['time'])); + + return match ($settings['frequency']) { + 'hourly' => "{$minute} * * * *", + 'every_six_hours' => $minute . ' ' . implode(',', [$hour % 6, $hour % 6 + 6, $hour % 6 + 12, $hour % 6 + 18]) . ' * * *', + 'every_twelve_hours' => $minute . ' ' . implode(',', [$hour % 12, $hour % 12 + 12]) . ' * * *', + 'weekly' => "{$minute} {$hour} * * {$settings['day_of_week']}", + default => "{$minute} {$hour} * * *", + }; + } + + /** + * Register the backup on the scheduler when backups are enabled. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + */ + public static function schedule($schedule, ?array $settings = null): void + { + $settings ??= static::settings(); + + if (!$settings['enabled']) { + return; + } + + $schedule->command('db:backup --no-interaction --trigger=scheduled') + ->cron(static::cronExpression($settings)) + ->timezone('UTC') + ->name('database-backup') + ->withoutOverlapping(240); + } + + /** + * The filesystem disks a backup can be written to. + */ + public static function disks(): array + { + $disks = []; + foreach ((array) config('filesystems.disks', []) as $name => $disk) { + $disks[] = ['name' => (string) $name, 'driver' => (string) data_get($disk, 'driver', '')]; + } + + return $disks; + } + + /** + * The database connections that can be dumped: those using a MySQL-compatible driver. + */ + public static function connections(): array + { + $connections = []; + foreach ((array) config('database.connections', []) as $name => $connection) { + if (in_array(data_get($connection, 'driver'), ['mysql', 'mariadb'], true)) { + $connections[] = (string) $name; + } + } + + return $connections; + } + + protected static function stringList($value): array + { + if (is_string($value)) { + $value = explode(',', $value); + } + + return array_values(array_unique(array_filter(array_map(fn ($item) => trim((string) $item), (array) $value), fn ($item) => $item !== ''))); + } + + protected static function positiveIntOrNull($value): ?int + { + if ($value === null || $value === '' || !is_numeric($value) || (int) $value < 1) { + return null; + } + + return (int) $value; + } +} diff --git a/src/routes.php b/src/routes.php index fe76c767..a8bf720e 100644 --- a/src/routes.php +++ b/src/routes.php @@ -254,6 +254,13 @@ function ($router, $controller) { $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); } ); + $router->group(['prefix' => 'database-backups'], function ($router) { + $router->get('settings', 'DatabaseBackupController@getSettings'); + $router->post('settings', 'DatabaseBackupController@saveSettings'); + $router->delete('settings', 'DatabaseBackupController@resetSettings'); + $router->get('runs', 'DatabaseBackupController@runs'); + $router->post('run', 'DatabaseBackupController@run'); + }); $router->group(['prefix' => 'rate-limits'], function ($router) { $router->get('settings', 'RateLimitController@getSettings'); $router->post('settings', 'RateLimitController@saveSettings'); diff --git a/tests/Unit/Console/BackupDatabaseCommandsTest.php b/tests/Unit/Console/BackupDatabaseCommandsTest.php deleted file mode 100644 index 98021f3b..00000000 --- a/tests/Unit/Console/BackupDatabaseCommandsTest.php +++ /dev/null @@ -1,386 +0,0 @@ -timeout = $timeout; - } - - public function run(): void - { - $this->ran = true; - } - - public function isSuccessful(): bool - { - return $this->successful; - } - - public function getErrorOutput(): string - { - return $this->errorOutput; - } -} - -class BackupDatabaseUploaderFake -{ - public bool $uploaded = false; - - public function __construct( - public object $s3, - public string $fileName, - public array $options, - private ?MultipartUploadException $exception = null, - ) { - } - - public function upload(): void - { - if ($this->exception) { - throw $this->exception; - } - - $this->uploaded = true; - } -} - -class BackupDatabaseTrimmerFake extends S3BackupTrimmer -{ - public bool $ran = false; - - public function __construct(int $days, string $bucket) - { - $this->days = $days; - $this->bucket = $bucket; - $this->when = now()->subDays($this->days)->startOfDay(); - } - - public function run(): void - { - $this->ran = true; - } -} - -class BackupDatabaseTestCommand extends MysqlS3Backup -{ - public array $processes = []; - public array $s3Configs = []; - public array $uploaders = []; - public array $deletedFiles = []; - public array $trimmers = []; - public bool $processSuccessful = true; - public string $processErrorOutput = ''; - public ?MultipartUploadException $uploadException = null; - - protected function makeProcess(string $command) - { - return $this->processes[] = new BackupDatabaseProcessFake($command, $this->processSuccessful, $this->processErrorOutput); - } - - protected function makeS3Client(array $config) - { - $this->s3Configs[] = $config; - - return (object) ['config' => $config]; - } - - protected function makeMultipartUploader($s3, string $fileName, array $options) - { - return $this->uploaders[] = new BackupDatabaseUploaderFake($s3, $fileName, $options, $this->uploadException); - } - - protected function deleteLocalFile(string $fileName): void - { - $this->deletedFiles[] = $fileName; - } - - protected function makeBackupTrimmer($days, $bucket): S3BackupTrimmer - { - $trimmer = new BackupDatabaseTrimmerFake((int) $days, $bucket); - $this->trimmers[] = $trimmer; - - return $trimmer; - } -} - -class BackupDatabaseS3Fake -{ - public array $deletedPayloads = []; - - public function __construct(public array $contents) - { - } - - public function listObjects(array $payload): array - { - return ['Contents' => $this->contents]; - } - - public function deleteObjects(array $payload): void - { - $this->deletedPayloads[] = $payload; - } -} - -class BackupDatabaseTestTrimmer extends S3BackupTrimmer -{ - public function __construct(int $days, string $bucket, public BackupDatabaseS3Fake $s3) - { - parent::__construct($days, $bucket); - } - - protected function makeS3Client(array $config) - { - return $this->s3; - } -} - -function backup_database_container(array $overrides = []): void -{ - Container::setInstance(new BackupDatabaseCommandContainer()); - - bind_test_container(array_replace_recursive([ - 'database.connections.mysql.host' => 'db.example.test', - 'database.connections.mysql.port' => 3307, - 'database.connections.mysql.username' => 'fleetbase', - 'database.connections.mysql.password' => 'secret value', - 'database.connections.mysql.database' => 'fleetbase', - 'database.connections.sandbox.database' => 'fleetbase_sandbox', - 'laravel-mysql-s3-backup.backup_dir' => '/tmp/fleetbase-backups', - 'laravel-mysql-s3-backup.filename' => '%s-%s.sql', - 'laravel-mysql-s3-backup.gzip' => true, - 'laravel-mysql-s3-backup.custom_mysqldump_args' => '--no-tablespaces', - 'laravel-mysql-s3-backup.sql_timout' => 120, - 'laravel-mysql-s3-backup.keep_local_copy' => false, - 'laravel-mysql-s3-backup.rolling_backup_days' => 7, - 'laravel-mysql-s3-backup.s3' => [ - 'bucket' => 'fleetbase-backups', - 'folder' => 'daily', - 'region' => 'ap-southeast-1', - 'version' => 'latest', - ], - ], $overrides)); - - Facade::clearResolvedInstances(); -} - -function backup_database_call(object $target, string $method, mixed ...$arguments): mixed -{ - $reflection = new ReflectionMethod($target, $method); - $reflection->setAccessible(true); - - return $reflection->invoke($target, ...$arguments); -} - -afterEach(function () { - Carbon::setTestNow(); - Facade::clearResolvedInstances(); -}); - -it('creates s3 backup trimmers through the static factory', function () { - Carbon::setTestNow(Carbon::parse('2026-07-18 09:30:45')); - - $trimmer = S3BackupTrimmer::make(14, 'fleetbase-backups'); - - expect($trimmer)->toBeInstanceOf(S3BackupTrimmer::class) - ->and($trimmer->days)->toBe(14) - ->and($trimmer->bucket)->toBe('fleetbase-backups') - ->and($trimmer->when->toDateTimeString())->toBe('2026-07-04 00:00:00'); -}); - -it('builds real backup helper collaborators without invoking external services', function () { - backup_database_container(); - $command = new MysqlS3Backup(); - - $process = backup_database_call($command, 'makeProcess', 'echo fleetbase'); - $s3 = backup_database_call($command, 'makeS3Client', [ - 'region' => 'ap-southeast-1', - 'version' => 'latest', - 'credentials' => [ - 'key' => 'test-key', - 'secret' => 'test-secret', - ], - ]); - $fileName = tempnam(sys_get_temp_dir(), 'fleetbase-backup-helper-'); - file_put_contents($fileName, 'sql dump'); - - $uploader = backup_database_call($command, 'makeMultipartUploader', $s3, $fileName, [ - 'bucket' => 'fleetbase-backups', - 'key' => 'daily/' . basename($fileName), - ]); - $trimmer = backup_database_call($command, 'makeBackupTrimmer', 3, 'fleetbase-backups'); - - expect($process)->toBeInstanceOf(Process::class) - ->and($process->getCommandLine())->toBe('echo fleetbase') - ->and($s3)->toBeInstanceOf(S3Client::class) - ->and($uploader)->toBeInstanceOf(MultipartUploader::class) - ->and($trimmer)->toBeInstanceOf(S3BackupTrimmer::class) - ->and(file_exists($fileName))->toBeTrue(); - - backup_database_call($command, 'deleteLocalFile', $fileName); - - expect(file_exists($fileName))->toBeFalse(); -}); - -it('builds mysql and sandbox dump commands uploads to configured s3 folder and trims rolling backups', function () { - backup_database_container(); - Carbon::setTestNow(Carbon::parse('2026-07-18 09:30:45')); - - $command = new BackupDatabaseTestCommand(); - $command->setLaravel(app()); - $tester = new CommandTester($command); - - expect($tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_DEBUG]))->toBe(0) - ->and($command->processes)->toHaveCount(2) - ->and($command->processes[0]->command)->toMatch("/^mysqldump --host='db\\.example\\.test' --port='3307' --user='fleetbase' --password='secret value' --single-transaction --routines --triggers --no-tablespaces 'fleetbase' \\| gzip > '\\/tmp\\/fleetbase-backups\\/fleetbase-\\d{8}-\\d{6}\\.sql\\.gz'$/") - ->and($command->processes[1]->command)->toMatch("/^mysqldump --host='db\\.example\\.test' --port='3307' --user='fleetbase' --password='secret value' --single-transaction --routines --triggers --no-tablespaces 'fleetbase_sandbox' \\| gzip > '\\/tmp\\/fleetbase-backups\\/fleetbase_sandbox-\\d{8}-\\d{6}\\.sql\\.gz'$/") - ->and($command->processes[0]->timeout)->toBe(120) - ->and($command->processes[0]->ran)->toBeTrue() - ->and($command->s3Configs)->toHaveCount(2) - ->and($command->uploaders)->toHaveCount(2) - ->and($command->uploaders[0]->uploaded)->toBeTrue(); - - $firstBackup = $command->uploaders[0]->fileName; - $secondBackup = $command->uploaders[1]->fileName; - - expect($command->uploaders[0]->options)->toBe([ - 'bucket' => 'fleetbase-backups', - 'key' => 'daily/' . basename($firstBackup), - ]) - ->and($command->deletedFiles)->toBe([ - $firstBackup, - $secondBackup, - ]) - ->and($command->trimmers)->toHaveCount(2) - ->and($command->trimmers[0]->ran)->toBeTrue() - ->and($command->trimmers[0]->days)->toBe(7) - ->and($command->trimmers[0]->bucket)->toBe('fleetbase-backups') - ->and($tester->getDisplay())->toContain('Running backup for database `fleetbase`') - ->and($tester->getDisplay())->toContain('Running command: mysqldump'); -}); - -it('stops backup processing when a database dump fails before upload or cleanup', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.gzip' => false, - 'laravel-mysql-s3-backup.custom_mysqldump_args' => null, - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 10:00:00')); - - $command = new BackupDatabaseTestCommand(); - $command->processSuccessful = false; - $command->processErrorOutput = 'mysqldump failed'; - $command->setLaravel(app()); - $tester = new CommandTester($command); - - expect($tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]))->toBe(0) - ->and($command->processes)->toHaveCount(1) - ->and($command->processes[0]->command)->toMatch("/^mysqldump --host='db\\.example\\.test' --port='3307' --user='fleetbase' --password='secret value' --single-transaction --routines --triggers 'fleetbase' > '\\/tmp\\/fleetbase-backups\\/fleetbase-\\d{8}-\\d{6}\\.sql'$/") - ->and($command->uploaders)->toBeEmpty() - ->and($command->deletedFiles)->toBeEmpty() - ->and($command->trimmers)->toBeEmpty() - ->and($tester->getDisplay())->toContain('mysqldump failed'); -}); - -it('reports multipart upload failures while still cleaning up local backups', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.keep_local_copy' => false, - 'laravel-mysql-s3-backup.rolling_backup_days' => null, - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 11:00:00')); - - $command = new BackupDatabaseTestCommand(); - $command->uploadException = new MultipartUploadException(new UploadState([ - 'Bucket' => 'fleetbase-backups', - 'Key' => 'daily/fleetbase.sql.gz', - ])); - $command->setLaravel(app()); - $tester = new CommandTester($command); - - expect($tester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]))->toBe(0) - ->and($command->processes)->toHaveCount(2) - ->and($command->uploaders)->toHaveCount(2) - ->and($command->uploaders[0]->uploaded)->toBeFalse() - ->and($command->uploaders[1]->uploaded)->toBeFalse() - ->and($command->deletedFiles)->toBe([ - $command->uploaders[0]->fileName, - $command->uploaders[1]->fileName, - ]) - ->and($command->trimmers)->toBeEmpty() - ->and($tester->getDisplay())->toContain('Unable to upload "' . $command->uploaders[0]->fileName . '" backup to s3. Error: An exception occurred while performing a multipart upload') - ->and($tester->getDisplay())->toContain('Deleting local backup file ' . $command->uploaders[0]->fileName); -}); - -it('trims only old backup objects inside the configured s3 folder', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.s3.folder' => 'daily', - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 12:00:00')); - - $s3 = new BackupDatabaseS3Fake([ - ['Key' => 'daily/fleetbase-20260701-000000.sql.gz'], - ['Key' => 'daily/fleetbase-20260717-000000.sql.gz'], - ['Key' => 'weekly/fleetbase-20260701-000000.sql.gz'], - ]); - - $trimmer = new BackupDatabaseTestTrimmer(7, 'fleetbase-backups', $s3); - $trimmer->run(); - - expect($trimmer->days)->toBe(7) - ->and($trimmer->bucket)->toBe('fleetbase-backups') - ->and($trimmer->when->toDateTimeString())->toBe('2026-07-11 00:00:00') - ->and($s3->deletedPayloads)->toBe([[ - 'Bucket' => 'fleetbase-backups', - 'Delete' => [ - 'Objects' => [ - ['Key' => 'daily/fleetbase-20260701-000000.sql.gz'], - ], - ], - ]]); -}); - -it('does not call s3 delete when no backups are old enough to trim', function () { - backup_database_container([ - 'laravel-mysql-s3-backup.s3.folder' => null, - ]); - Carbon::setTestNow(Carbon::parse('2026-07-18 12:00:00')); - - $s3 = new BackupDatabaseS3Fake([ - ['Key' => 'fleetbase-20260717-000000.sql.gz'], - ]); - - (new BackupDatabaseTestTrimmer(7, 'fleetbase-backups', $s3))->run(); - - expect($s3->deletedPayloads)->toBeEmpty(); -}); diff --git a/tests/Unit/DatabaseBackupsTest.php b/tests/Unit/DatabaseBackupsTest.php new file mode 100644 index 00000000..22a123f2 --- /dev/null +++ b/tests/Unit/DatabaseBackupsTest.php @@ -0,0 +1,938 @@ +commands[] = $command; + $this->envs[] = $env; + + return parent::makeProcess([PHP_BINARY, '-r', $this->script], $env); + } + + protected function buildDisk(array $config): Filesystem + { + $this->diskConfigs[] = $config; + + return $this->diskOverride ?? parent::buildDisk($config); + } +} + +/** + * A real local disk whose reported object size or listing can be made to misbehave. + */ +class DatabaseBackupsDiskFake extends FilesystemAdapter +{ + public ?int $reportedSize = null; + public ?Throwable $listingError = null; + + public static function at(string $root): self + { + $adapter = new LocalFilesystemAdapter($root); + + return new self(new Flysystem($adapter), $adapter, ['root' => $root, 'throw' => true]); + } + + public function size($path) + { + return $this->reportedSize ?? parent::size($path); + } + + public function files($directory = null, $recursive = false) + { + if ($this->listingError) { + throw $this->listingError; + } + + return parent::files($directory, $recursive); + } +} + +class DatabaseBackupsNotificationDispatcherFake implements NotificationDispatcher +{ + public array $sent = []; + public bool $fail = false; + + public function send($notifiables, $notification) + { + if ($this->fail) { + throw new RuntimeException('mail is down'); + } + + $this->sent[] = [$notifiables, $notification]; + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + $this->send($notifiables, $notification); + } +} + +class DatabaseBackupsBusFake implements BusDispatcher +{ + public array $dispatched = []; + + public function dispatch($command) + { + $this->dispatched[] = $command; + } + + public function dispatchSync($command, $handler = null) + { + $this->dispatch($command); + } + + public function dispatchNow($command, $handler = null) + { + $this->dispatch($command); + } + + public function hasCommandHandler($command) + { + return false; + } + + public function getCommandHandler($command) + { + return false; + } + + public function pipeThrough(array $pipes) + { + return $this; + } + + public function map(array $map) + { + return $this; + } +} + +class DatabaseBackupsServiceStub extends DatabaseBackupService +{ + public array $calls = []; + + public function __construct(public array|Throwable $result = []) + { + } + + public function run(string $trigger = DatabaseBackup::TRIGGER_CONSOLE, ?array $connections = null, ?array $settings = null): array + { + $this->calls[] = [$trigger, $connections]; + + if ($this->result instanceof Throwable) { + throw $this->result; + } + + return $this->result; + } +} + +class DatabaseBackupsScheduleFake +{ + public array $events = []; + + public function command(string $command): DatabaseBackupsScheduledEventFake + { + return $this->events[] = new DatabaseBackupsScheduledEventFake($command); + } +} + +class DatabaseBackupsScheduledEventFake +{ + public array $calls = []; + + public function __construct(public string $command) + { + } + + public function __call($method, $arguments) + { + $this->calls[$method] = $arguments; + + return $this; + } +} + +const DATABASE_BACKUPS_DUMP_OK = 'echo "-- MySQL dump 10.19\n", bin2hex(random_bytes(4000)), "\n-- Dump completed on 2026-10-06 0:00:01\n";'; +const DATABASE_BACKUPS_DUMP_DENIED = 'fwrite(STDERR, "mysqldump: Got error: 1045: Access denied"); exit(2);'; +const DATABASE_BACKUPS_DUMP_TRUNCATED = 'echo bin2hex(random_bytes(4000));'; +const DATABASE_BACKUPS_DUMP_TINY = 'echo "-- Dump completed\n";'; +const DATABASE_BACKUPS_DUMP_SILENT_ERR = 'echo bin2hex(random_bytes(10)); fwrite(STDERR, "warning: lost connection");'; + +function database_backups_root(): string +{ + return sys_get_temp_dir() . '/fleetbase-database-backups-test'; +} + +function database_backups_rmdir(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($directory); +} + +function database_backups_fixture(array $config = []): array +{ + EloquentModel::clearBootedModels(); + Container::setInstance(new DatabaseBackupsTestContainer()); + + $root = database_backups_root(); + database_backups_rmdir($root); + mkdir($root . '/disk', 0777, true); + mkdir($root . '/tmp', 0777, true); + + $sqlite = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + + $container = bind_test_container(array_merge([ + 'app.name' => 'Fleetbase', + 'app.env' => 'production', + 'fleetbase.console.host' => 'https://console.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $sqlite, + 'database.connections.primary' => ['driver' => 'mysql', 'host' => 'db.example.test', 'port' => 3307, 'username' => 'fleetbase', 'password' => 'secret value', 'database' => 'fleetbase'], + 'database.connections.sandbox' => ['driver' => 'mysql', 'unix_socket' => '/run/mysqld.sock', 'username' => 'fleetbase', 'password' => 'sandbox secret', 'database' => 'fleetbase_sandbox'], + 'fleetbase.connection.db' => 'mysql', + 'filesystems.disks' => [ + 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], + 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], + ], + 'database-backups.enabled' => true, + 'database-backups.frequency' => 'daily', + 'database-backups.time' => '02:30', + 'database-backups.day_of_week' => 0, + 'database-backups.disk' => 'backups', + 'database-backups.bucket' => null, + 'database-backups.path' => 'nightly', + 'database-backups.connections' => ['primary', 'sandbox'], + 'database-backups.retention_days' => 30, + 'database-backups.retention_count' => null, + 'database-backups.min_size_bytes' => 1024, + 'database-backups.notify_on_failure' => true, + 'database-backups.notify_emails' => ['ops@example.test'], + 'database-backups.dump_binary' => 'mysqldump', + 'database-backups.dump_args' => ['--single-transaction', '--no-tablespaces'], + 'database-backups.extra_dump_args' => ['--column-statistics=0'], + 'database-backups.timeout' => 60, + 'database-backups.tmp_dir' => $root . '/tmp', + ], $config)); + + $container->instance('cache', new CacheRepository(new ArrayStore())); + $container->instance('filesystem', new FilesystemManager($container)); + $notifications = new DatabaseBackupsNotificationDispatcherFake(); + $container->instance(NotificationDispatcher::class, $notifications); + $bus = new DatabaseBackupsBusFake(); + $container->instance(BusDispatcher::class, $bus); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($sqlite, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('mysql')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + (require __DIR__ . '/../../migrations/2026_10_06_000000_create_database_backups_table.php')->up(); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return compact('container', 'capsule', 'notifications', 'bus', 'root'); +} + +function database_backups_record(array $attributes): DatabaseBackup +{ + return DatabaseBackup::create(array_merge([ + 'connection_name' => 'primary', + 'database' => 'fleetbase', + 'status' => DatabaseBackup::STATUS_COMPLETED, + 'trigger' => DatabaseBackup::TRIGGER_SCHEDULED, + 'disk' => 'backups', + 'started_at' => now(), + ], $attributes)); +} + +function database_backups_command(DatabaseBackupService $service, array $input = []): array +{ + app()->instance(DatabaseBackupService::class, $service); + + $command = new BackupDatabase(); + $command->setLaravel(app()); + $tester = new CommandTester($command); + $code = $tester->execute($input); + + return [$code, $tester->getDisplay()]; +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + database_backups_rmdir(database_backups_root()); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +// --------------------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------------------- + +test('database backup settings come from the environment until an administrator overrides them', function () { + database_backups_fixture(); + + $defaults = DatabaseBackupSettings::defaults(); + + expect($defaults)->toBe([ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '02:30', + 'day_of_week' => 0, + 'disk' => 'backups', + 'bucket' => null, + 'path' => 'nightly', + 'connections' => ['primary', 'sandbox'], + 'retention_days' => 30, + 'retention_count' => null, + 'min_size_bytes' => 1024, + 'notify_on_failure' => true, + 'notify_emails' => ['ops@example.test'], + ])->and(DatabaseBackupSettings::settings())->toBe($defaults); + + $stored = DatabaseBackupSettings::store(['frequency' => 'weekly', 'day_of_week' => 3, 'retention_count' => '5']); + + expect($stored['frequency'])->toBe('weekly') + ->and($stored['day_of_week'])->toBe(3) + ->and($stored['retention_count'])->toBe(5) + ->and(DatabaseBackupSettings::settings())->toBe($stored) + ->and(DatabaseBackupSettings::reset())->toBe($defaults); +}); + +test('database backup settings fall back to the defaults when the settings table is unreachable', function () { + $fixture = database_backups_fixture(); + $fixture['capsule']->getConnection('mysql')->getSchemaBuilder()->drop('settings'); + + expect(DatabaseBackupSettings::settings())->toBe(DatabaseBackupSettings::defaults()); +}); + +test('database backup settings normalize loose and invalid input', function () { + database_backups_fixture(); + + expect(DatabaseBackupSettings::normalize([ + 'enabled' => 'true', + 'frequency' => 'fortnightly', + 'time' => '25:00', + 'day_of_week' => 9, + 'disk' => '', + 'bucket' => ' ', + 'path' => '/backups/db/', + 'connections' => 'primary, sandbox ,,primary', + 'retention_days' => '0', + 'retention_count' => 'many', + 'min_size_bytes' => -5, + 'notify_on_failure' => '1', + 'notify_emails' => [' ops@example.test ', ''], + ]))->toBe([ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '00:00', + 'day_of_week' => 6, + 'disk' => 's3', + 'bucket' => null, + 'path' => 'backups/db', + 'connections' => ['primary', 'sandbox'], + 'retention_days' => null, + 'retention_count' => null, + 'min_size_bytes' => 0, + 'notify_on_failure' => true, + 'notify_emails' => ['ops@example.test'], + ])->and(DatabaseBackupSettings::normalize([])['min_size_bytes'])->toBe(1024); +}); + +test('database backup frequencies translate to utc cron expressions', function () { + database_backups_fixture(); + $settings = fn (string $frequency, string $time = '14:05', int $day = 2) => ['frequency' => $frequency, 'time' => $time, 'day_of_week' => $day]; + + expect(DatabaseBackupSettings::cronExpression($settings('hourly')))->toBe('5 * * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('every_six_hours')))->toBe('5 2,8,14,20 * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('every_twelve_hours')))->toBe('5 2,14 * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('daily')))->toBe('5 14 * * *') + ->and(DatabaseBackupSettings::cronExpression($settings('weekly')))->toBe('5 14 * * 2'); +}); + +test('database backups are scheduled only while enabled', function () { + database_backups_fixture(); + + $schedule = new DatabaseBackupsScheduleFake(); + DatabaseBackupSettings::schedule($schedule); + + expect($schedule->events)->toHaveCount(1) + ->and($schedule->events[0]->command)->toBe('db:backup --no-interaction --trigger=scheduled') + ->and($schedule->events[0]->calls)->toBe([ + 'cron' => ['30 2 * * *'], + 'timezone' => ['UTC'], + 'name' => ['database-backup'], + 'withoutOverlapping' => [240], + ]); + + $disabled = new DatabaseBackupsScheduleFake(); + DatabaseBackupSettings::schedule($disabled, array_merge(DatabaseBackupSettings::settings(), ['enabled' => false])); + + expect($disabled->events)->toBe([]); +}); + +test('database backup settings list the disks and the mysql connections', function () { + database_backups_fixture(); + + expect(DatabaseBackupSettings::disks())->toBe([ + ['name' => 'backups', 'driver' => 'local'], + ['name' => 's3', 'driver' => 's3'], + ])->and(DatabaseBackupSettings::connections())->toBe(['primary', 'sandbox']); +}); + +// --------------------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------------------- + +test('database backup service dumps uploads verifies and records every connection', function () { + $fixture = database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:05')); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $records = $service->run(DatabaseBackup::TRIGGER_SCHEDULED); + + expect($records)->toHaveCount(2) + ->and($records[0]->status)->toBe(DatabaseBackup::STATUS_COMPLETED) + ->and($records[0]->path)->toBe('nightly/production_fleetbase_backup-20261006-000005.sql.gz') + ->and($records[1]->path)->toBe('nightly/production_fleetbase_sandbox_backup-20261006-000005.sql.gz') + ->and($records[0]->size_bytes)->toBeGreaterThan(1024) + ->and($records[0]->trigger)->toBe('scheduled') + ->and($records[0]->error)->toBeNull() + ->and($records[0]->duration_ms)->toBeInt() + ->and(DatabaseBackup::count())->toBe(2) + ->and(file_exists($fixture['root'] . '/disk/' . $records[0]->path))->toBeTrue() + ->and(glob($fixture['root'] . '/tmp/*'))->toBe([]) + ->and($fixture['notifications']->sent)->toBe([]); + + $sql = gzdecode(file_get_contents($fixture['root'] . '/disk/' . $records[0]->path)); + expect($sql)->toStartWith('-- MySQL dump')->toContain('-- Dump completed'); + + expect($service->commands[0])->toBe(['mysqldump', '--host=db.example.test', '--port=3307', '--user=fleetbase', '--single-transaction', '--no-tablespaces', '--column-statistics=0', 'fleetbase']) + ->and($service->commands[1])->toBe(['mysqldump', '--socket=/run/mysqld.sock', '--user=fleetbase', '--single-transaction', '--no-tablespaces', '--column-statistics=0', 'fleetbase_sandbox']) + ->and($service->envs)->toBe([['MYSQL_PWD' => 'secret value'], ['MYSQL_PWD' => 'sandbox secret']]) + ->and($service->diskConfigs[0]['throw'])->toBeTrue(); +}); + +test('database backup service fails loudly when the dump client fails and keeps old backups', function () { + $fixture = database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:05')); + file_put_contents($fixture['root'] . '/disk/old.txt', 'x'); + mkdir($fixture['root'] . '/disk/nightly'); + file_put_contents($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz', 'old'); + file_put_contents($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260102-000000.sql.gz', 'old'); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_DENIED; + $records = $service->run(DatabaseBackup::TRIGGER_MANUAL, ['primary']); + + expect($records)->toHaveCount(1) + ->and($records[0]->status)->toBe(DatabaseBackup::STATUS_FAILED) + ->and($records[0]->error)->toBe('The dump exited with code 2: mysqldump: Got error: 1045: Access denied') + ->and($records[0]->path)->toBeNull() + ->and($records[0]->completed_at)->not->toBeNull() + ->and(glob($fixture['root'] . '/tmp/*'))->toBe([]) + ->and(file_exists($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz'))->toBeTrue() + ->and($fixture['notifications']->sent)->toHaveCount(1); + + [$notifiable, $notification] = $fixture['notifications']->sent[0]; + expect($notifiable)->toBeInstanceOf(AnonymousNotifiable::class) + ->and($notifiable->routes['mail'])->toBe(['ops@example.test']) + ->and($notification)->toBeInstanceOf(DatabaseBackupFailed::class) + ->and($notification->failures)->toBe([[ + 'connection' => 'primary', + 'database' => 'fleetbase', + 'error' => 'The dump exited with code 2: mysqldump: Got error: 1045: Access denied', + ]]) + ->and(collect(app('log')->entries)->pluck(1))->toContain('Database backup failed'); +}); + +test('database backup service rejects incomplete, empty and undersized dumps', function () { + $fixture = database_backups_fixture(['database-backups.notify_on_failure' => false]); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_TRUNCATED; + expect($service->run('manual', ['primary'])[0]->error)->toBe('The dump ended without its completion marker, so it is incomplete.'); + + $service->script = DATABASE_BACKUPS_DUMP_SILENT_ERR; + expect($service->run('manual', ['primary'])[0]->error)->toBe('The dump ended without its completion marker, so it is incomplete. warning: lost connection'); + + $service->script = DATABASE_BACKUPS_DUMP_TINY; + expect($service->run('manual', ['primary'])[0]->error)->toMatch('/^The compressed dump is \d+ bytes, below the 1024 byte minimum\.$/'); + + $service->script = 'exit(3);'; + expect($service->run('manual', ['primary'])[0]->error)->toBe('The dump exited with code 3: no error output') + ->and(glob($fixture['root'] . '/tmp/*'))->toBe([]) + ->and(glob($fixture['root'] . '/disk/nightly/*') ?: [])->toBe([]) + ->and($fixture['notifications']->sent)->toBe([]); +}); + +test('database backup service refuses connections that are not mysql or are missing', function () { + database_backups_fixture(['database-backups.notify_emails' => []]); + + $service = new DatabaseBackupsServiceFake(); + $records = $service->run('console', ['mysql', 'nowhere']); + + expect($records[0]->error)->toBe('Connection [mysql] is not a MySQL connection.') + ->and($records[1]->database)->toBe('nowhere') + ->and($records[1]->error)->toBe('Connection [nowhere] is not a MySQL connection.') + ->and($service->commands)->toBe([]); +}); + +test('database backup service reports an unwritable temporary directory', function () { + $fixture = database_backups_fixture(); + file_put_contents($fixture['root'] . '/blocker', 'x'); + config(['database-backups.tmp_dir' => $fixture['root'] . '/blocker/tmp']); + + $service = new DatabaseBackupsServiceFake(); + + expect(fn () => $service->dump('primary', 'x.sql.gz')) + ->toThrow(DatabaseBackupException::class, 'Cannot create the backup directory ' . $fixture['root'] . '/blocker/tmp.'); + + config(['database-backups.tmp_dir' => $fixture['root'] . '/tmp']); + mkdir($fixture['root'] . '/tmp/x.sql.gz'); + + expect(fn () => $service->dump('primary', 'x.sql.gz')) + ->toThrow(DatabaseBackupException::class, 'Cannot write ' . $fixture['root'] . '/tmp/x.sql.gz.'); +}); + +test('database backup service wraps a dump client that cannot start or times out', function () { + $fixture = database_backups_fixture(['database-backups.timeout' => 1]); + + $service = new DatabaseBackupsServiceFake(); + $service->script = 'sleep(3);'; + + expect(fn () => $service->dump('primary', 'slow.sql.gz')) + ->toThrow(DatabaseBackupException::class, 'The dump could not run: ') + ->and(file_exists($fixture['root'] . '/tmp/slow.sql.gz'))->toBeFalse(); +}); + +test('database backup service fails a run whose upload does not match the local dump', function () { + database_backups_fixture(['database-backups.notify_on_failure' => false]); + + $disk = DatabaseBackupsDiskFake::at(database_backups_root() . '/disk'); + $disk->reportedSize = 20; + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $service->diskOverride = $disk; + $record = $service->run('manual', ['primary'])[0]; + + expect($record->status)->toBe('failed') + ->and($record->error)->toMatch('/^The uploaded backup is 20 bytes but the local dump is \d+ bytes\.$/'); +}); + +test('database backup service fails a run whose disk is not configured', function () { + database_backups_fixture(['database-backups.disk' => 'missing', 'database-backups.notify_on_failure' => false]); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + + expect($service->run('manual', ['primary'])[0]->error)->toBe('Filesystem disk [missing] is not configured.'); +}); + +test('database backup service applies the bucket override only to s3 disks', function () { + database_backups_fixture(); + + $service = new DatabaseBackupsServiceFake(); + $service->diskOverride = DatabaseBackupsDiskFake::at(database_backups_root() . '/disk'); + $settings = DatabaseBackupSettings::settings(); + + $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => 'fleetbase-db-backups'])); + $service->disk(array_merge($settings, ['disk' => 's3', 'bucket' => null])); + $service->disk(array_merge($settings, ['bucket' => 'ignored'])); + + expect($service->diskConfigs[0]['bucket'])->toBe('fleetbase-db-backups') + ->and($service->diskConfigs[1]['bucket'])->toBe('fleetbase-media') + ->and($service->diskConfigs[2])->not->toHaveKey('bucket') + ->and($service->objectPath('', 'a.sql.gz'))->toBe('a.sql.gz') + ->and($service->objectPath('/x/y/', 'a.sql.gz'))->toBe('x/y/a.sql.gz'); +}); + +test('database backup retention trims by age and count but always keeps each database newest', function () { + $fixture = database_backups_fixture(['database-backups.retention_days' => 10, 'database-backups.retention_count' => 2]); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:00')); + $dir = $fixture['root'] . '/disk/nightly'; + mkdir($dir); + + $files = [ + 'production_fleetbase_backup-20261005-000000.sql.gz', + 'production_fleetbase_backup-20261004-000000.sql.gz', + 'production_fleetbase_backup-20261003-000000.sql.gz', // beyond the count of 2 + 'production_fleetbase_sandbox_backup-20260801-000000.sql.gz', // old, but the newest of its database + 'production_fleetbase_sandbox_backup-20260701-000000.sql', // old + 'notes.txt', + ]; + foreach ($files as $file) { + file_put_contents($dir . '/' . $file, 'x'); + } + database_backups_record(['path' => 'nightly/production_fleetbase_backup-20261003-000000.sql.gz']); + + $service = new DatabaseBackupsServiceFake(); + $deleted = $service->prune(DatabaseBackupSettings::settings()); + sort($deleted); + + expect($deleted)->toBe([ + 'nightly/production_fleetbase_backup-20261003-000000.sql.gz', + 'nightly/production_fleetbase_sandbox_backup-20260701-000000.sql', + ]) + ->and(array_map('basename', glob($dir . '/*')))->toBe([ + 'notes.txt', + 'production_fleetbase_backup-20261004-000000.sql.gz', + 'production_fleetbase_backup-20261005-000000.sql.gz', + 'production_fleetbase_sandbox_backup-20260801-000000.sql.gz', + ]) + ->and(DatabaseBackup::first()->pruned_at)->not->toBeNull() + ->and($service->prune(array_merge(DatabaseBackupSettings::settings(), ['retention_days' => null, 'retention_count' => null])))->toBe([]) + ->and($service->prune(array_merge(DatabaseBackupSettings::settings(), ['retention_count' => null])))->toBe([]); +}); + +test('database backup runs trim after a full success and log a failed trim without failing', function () { + $fixture = database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 00:00:05')); + mkdir($fixture['root'] . '/disk/nightly'); + file_put_contents($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz', 'old'); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $service->run('scheduled', ['primary']); + + expect(file_exists($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz'))->toBeFalse(); + + $disk = DatabaseBackupsDiskFake::at($fixture['root'] . '/disk'); + $disk->listingError = new RuntimeException('listing denied'); + $service->diskOverride = $disk; + + expect($service->run('scheduled', ['primary'])[0]->status)->toBe('completed') + ->and(collect(app('log')->entries)->last())->toBe(['warning', 'Database backup retention failed', ['error' => 'listing denied']]); +}); + +test('database backup service logs a failure notification it could not send', function () { + $fixture = database_backups_fixture(); + $fixture['notifications']->fail = true; + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_DENIED; + $service->run('manual', ['primary']); + + expect(collect(app('log')->entries)->last())->toBe(['error', 'Could not send the database backup failure notification', ['error' => 'mail is down']]); +}); + +test('database backup service never runs two backups at once', function () { + database_backups_fixture(); + + $service = new DatabaseBackupsServiceFake(); + $held = app('cache')->lock(DatabaseBackupService::LOCK_KEY, 60); + $held->get(); + + expect(fn () => $service->run('manual'))->toThrow(DatabaseBackupException::class, 'Another database backup is already running.'); + + $held->release(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + $service->run('manual', ['primary']); + + expect(app('cache')->lock(DatabaseBackupService::LOCK_KEY, 60)->get())->toBeTrue(); +}); + +test('database backup service runs unlocked when the cache cannot lock', function () { + database_backups_fixture(); + app()->instance('cache', new class { + public function lock() + { + throw new BadMethodCallException('no locks'); + } + }); + Facade::clearResolvedInstance('cache'); + + $service = new DatabaseBackupsServiceFake(); + $service->script = DATABASE_BACKUPS_DUMP_OK; + + expect($service->run('manual', ['primary'])[0]->status)->toBe('completed') + ->and($service->run('manual', []))->toHaveCount(2); +}); + +// --------------------------------------------------------------------------------------- +// Command, job, notification, model +// --------------------------------------------------------------------------------------- + +test('db backup command reports each database and exits non zero on any failure', function () { + database_backups_fixture(); + + $ok = database_backups_record(['path' => 'nightly/a.sql.gz', 'size_bytes' => 2048, 'duration_ms' => 15]); + $failed = database_backups_record(['database' => 'fleetbase_sandbox', 'status' => 'failed', 'error' => 'Access denied']); + + [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok]), ['--trigger' => 'scheduled', '--connection' => ['primary']]); + expect($code)->toBe(0) + ->and($display)->toContain('Backed up fleetbase to backups:nightly/a.sql.gz (2048 bytes, 15 ms)') + ->and($service->calls)->toBe([['scheduled', ['primary']]]); + + [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok, $failed]), ['--trigger' => 'bogus']); + expect($code)->toBe(1) + ->and($display)->toContain('Backup of fleetbase_sandbox failed: Access denied') + ->and($service->calls)->toBe([['console', null]]); + + [$code, $display] = database_backups_command(new DatabaseBackupsServiceStub([])); + expect($code)->toBe(1)->and($display)->toContain('No database connections are configured for backup.'); + + [$code, $display] = database_backups_command(new DatabaseBackupsServiceStub(new DatabaseBackupException('Another database backup is already running.'))); + expect($code)->toBe(1)->and($display)->toContain('Another database backup is already running.'); +}); + +test('db backup command does nothing while disabled unless forced', function () { + database_backups_fixture(['database-backups.enabled' => false]); + + [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([])); + expect($code)->toBe(0) + ->and($display)->toContain('Database backups are disabled.') + ->and($service->calls)->toBe([]); + + [$code] = database_backups_command($service = new DatabaseBackupsServiceStub([database_backups_record([])]), ['--force' => true]); + expect($code)->toBe(0)->and($service->calls)->toHaveCount(1); +}); + +test('run database backup job backs up as a manual run and logs a run that could not start', function () { + database_backups_fixture(); + + $job = new RunDatabaseBackup(); + $job->handle($service = new DatabaseBackupsServiceStub([])); + + expect($service->calls)->toBe([['manual', null]]) + ->and($job->tries)->toBe(1); + + $job->handle(new DatabaseBackupsServiceStub(new DatabaseBackupException('Another database backup is already running.'))); + + expect(collect(app('log')->entries)->last())->toBe(['warning', 'Requested database backup did not run', ['error' => 'Another database backup is already running.']]); +}); + +test('database backup failed notification mails each failure with a link to the console', function () { + database_backups_fixture(); + + $notification = new DatabaseBackupFailed([ + database_backups_record(['status' => 'failed', 'error' => 'Access denied']), + database_backups_record(['connection_name' => 'sandbox', 'database' => 'fleetbase_sandbox', 'status' => 'failed', 'error' => null]), + ]); + $mail = $notification->toMail(new AnonymousNotifiable()); + + expect($notification->via(null))->toBe(['mail']) + ->and($mail->subject)->toBe('Fleetbase database backup failed') + ->and($mail->level)->toBe('error') + ->and($mail->introLines)->toContain('fleetbase (primary): Access denied') + ->and($mail->introLines)->toContain('fleetbase_sandbox (sandbox): unknown error') + ->and($mail->actionUrl)->toContain('admin/database-backups') + ->and($notification->toArray(null)['failures'])->toHaveCount(2); +}); + +test('database backup records expose the admin shape and prune after a year', function () { + database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 12:00:00')); + + $record = database_backups_record(['path' => 'a.sql.gz', 'size_bytes' => 10, 'duration_ms' => 5, 'completed_at' => now(), 'pruned_at' => null]); + database_backups_record(['started_at' => now()->subYears(2)]); + + expect($record->uuid)->toBeString()->toHaveLength(36) + ->and($record->getConnectionName())->toBe('mysql') + ->and($record->toAdminArray())->toBe([ + 'id' => $record->uuid, + 'connection' => 'primary', + 'database' => 'fleetbase', + 'status' => 'completed', + 'trigger' => 'scheduled', + 'disk' => 'backups', + 'path' => 'a.sql.gz', + 'size_bytes' => 10, + 'duration_ms' => 5, + 'error' => null, + 'started_at' => '2026-10-06T12:00:00+00:00', + 'completed_at' => '2026-10-06T12:00:00+00:00', + 'pruned_at' => null, + ]) + ->and($record->prunable()->count())->toBe(1); +}); + +// --------------------------------------------------------------------------------------- +// Admin controller +// --------------------------------------------------------------------------------------- + +test('database backup controller returns settings with defaults, choices and the latest runs', function () { + database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 12:00:00')); + + $controller = new DatabaseBackupController(); + $empty = $controller->getSettings(AdminRequest::create('/int/v1/database-backups/settings'))->getData(true); + + expect($empty['last_run'])->toBeNull() + ->and($empty['last_success'])->toBeNull() + ->and($empty['disks'])->toBe(DatabaseBackupSettings::disks()) + ->and($empty['connections'])->toBe(['primary', 'sandbox']) + ->and($empty['settings'])->toBe(DatabaseBackupSettings::defaults()) + ->and($empty['defaults'])->toBe(DatabaseBackupSettings::defaults()); + + $success = database_backups_record(['started_at' => now()->subDay()]); + $failure = database_backups_record(['status' => 'failed', 'started_at' => now()]); + $payload = $controller->getSettings(AdminRequest::create('/int/v1/database-backups/settings'))->getData(true); + + expect($payload['last_run']['id'])->toBe($failure->uuid) + ->and($payload['last_success']['id'])->toBe($success->uuid); +}); + +test('database backup controller saves validated settings and resets them', function () { + database_backups_fixture(); + $controller = new DatabaseBackupController(); + $input = [ + 'enabled' => true, + 'frequency' => 'every_six_hours', + 'time' => '01:15', + 'day_of_week' => 1, + 'disk' => 's3', + 'bucket' => 'fleetbase-db-backups', + 'path' => 'mysql', + 'connections' => ['primary'], + 'retention_days' => 14, + 'retention_count' => null, + 'min_size_bytes' => 2048, + 'notify_on_failure' => true, + 'notify_emails' => ['ops@example.test', 'cto@example.test'], + ]; + + $saved = $controller->saveSettings(AdminRequest::create('/int/v1/database-backups/settings', 'POST', $input))->getData(true); + + expect($saved['settings'])->toBe(array_merge($input, ['day_of_week' => 1])) + ->and(DatabaseBackupSettings::settings()['frequency'])->toBe('every_six_hours'); + + $reset = $controller->resetSettings(AdminRequest::create('/int/v1/database-backups/settings', 'DELETE'))->getData(true); + expect($reset['settings'])->toBe(DatabaseBackupSettings::defaults()); +}); + +test('database backup controller rejects invalid settings', function (array $override) { + database_backups_fixture(); + $input = array_merge([ + 'enabled' => true, + 'frequency' => 'daily', + 'time' => '00:00', + 'disk' => 'backups', + 'connections' => ['primary'], + ], $override); + + expect(fn () => (new DatabaseBackupController())->saveSettings(AdminRequest::create('/int/v1/database-backups/settings', 'POST', $input))) + ->toThrow(ValidationException::class); +})->with([ + 'unknown frequency' => [['frequency' => 'monthly']], + 'bad time' => [['time' => '7pm']], + 'unknown disk' => [['disk' => 'nowhere']], + 'non mysql database' => [['connections' => ['mysql']]], + 'no databases' => [['connections' => []]], + 'bad email' => [['notify_emails' => ['not-an-email']]], + 'zero retention' => [['retention_days' => 0]], +]); + +test('database backup controller lists recent runs newest first', function () { + database_backups_fixture(); + Carbon::setTestNow(Carbon::parse('2026-10-06 12:00:00')); + + $older = database_backups_record(['started_at' => now()->subHours(2)]); + $newer = database_backups_record(['started_at' => now()->subHour()]); + database_backups_record(['started_at' => now()->subHours(3)]); + + $controller = new DatabaseBackupController(); + $all = $controller->runs(AdminRequest::create('/int/v1/database-backups/runs'))->getData(true); + $limited = $controller->runs(AdminRequest::create('/int/v1/database-backups/runs', 'GET', ['limit' => 2]))->getData(true); + + expect(array_column($all['runs'], 'id'))->toHaveCount(3) + ->and(array_column($limited['runs'], 'id'))->toBe([$newer->uuid, $older->uuid]); + + expect(fn () => $controller->runs(AdminRequest::create('/int/v1/database-backups/runs', 'GET', ['limit' => 0]))) + ->toThrow(ValidationException::class); +}); + +test('database backup controller queues a manual run', function () { + $fixture = database_backups_fixture(); + + $response = (new DatabaseBackupController())->run(AdminRequest::create('/int/v1/database-backups/run', 'POST')); + + expect($response->getStatusCode())->toBe(202) + ->and($response->getData(true))->toBe(['status' => 'queued']) + ->and($fixture['bus']->dispatched)->toHaveCount(1) + ->and($fixture['bus']->dispatched[0])->toBeInstanceOf(RunDatabaseBackup::class) + ->and($fixture['bus']->dispatched[0]->trigger)->toBe('manual'); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index f7e8fe2e..9c6eefb6 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -385,4 +385,25 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($route['middleware'])->toContain('fleetbase.protected'); } }); + + test('route file exposes database backup administration as protected routes', function () { + $routes = routes_contract_rows(routes_contract_router()); + $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\DatabaseBackupController'; + + $expected = [ + ['GET', 'int/v1/database-backups/settings', 'getSettings'], + ['POST', 'int/v1/database-backups/settings', 'saveSettings'], + ['DELETE', 'int/v1/database-backups/settings', 'resetSettings'], + ['GET', 'int/v1/database-backups/runs', 'runs'], + ['POST', 'int/v1/database-backups/run', 'run'], + ]; + + foreach ($expected as [$method, $uri, $action]) { + $route = routes_contract_find($routes, $method, $uri); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($controller . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + }); } From 710839295080c1404335f00c106341e951512f92 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:20:57 +0800 Subject: [PATCH 2/2] test(backups): align operators for php-cs-fixer --- tests/Unit/DatabaseBackupsTest.php | 82 ++++++++++++++++-------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/tests/Unit/DatabaseBackupsTest.php b/tests/Unit/DatabaseBackupsTest.php index 22a123f2..820ac9b2 100644 --- a/tests/Unit/DatabaseBackupsTest.php +++ b/tests/Unit/DatabaseBackupsTest.php @@ -240,38 +240,39 @@ function database_backups_fixture(array $config = []): array mkdir($root . '/tmp', 0777, true); $sqlite = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $disks = [ + 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], + 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], + ]; $container = bind_test_container(array_merge([ - 'app.name' => 'Fleetbase', - 'app.env' => 'production', - 'fleetbase.console.host' => 'https://console.fleetbase.test', - 'database.default' => 'mysql', - 'database.connections.mysql' => $sqlite, - 'database.connections.primary' => ['driver' => 'mysql', 'host' => 'db.example.test', 'port' => 3307, 'username' => 'fleetbase', 'password' => 'secret value', 'database' => 'fleetbase'], - 'database.connections.sandbox' => ['driver' => 'mysql', 'unix_socket' => '/run/mysqld.sock', 'username' => 'fleetbase', 'password' => 'sandbox secret', 'database' => 'fleetbase_sandbox'], - 'fleetbase.connection.db' => 'mysql', - 'filesystems.disks' => [ - 'backups' => ['driver' => 'local', 'root' => $root . '/disk'], - 's3' => ['driver' => 's3', 'bucket' => 'fleetbase-media', 'region' => 'ap-southeast-1'], - ], - 'database-backups.enabled' => true, - 'database-backups.frequency' => 'daily', - 'database-backups.time' => '02:30', - 'database-backups.day_of_week' => 0, - 'database-backups.disk' => 'backups', - 'database-backups.bucket' => null, - 'database-backups.path' => 'nightly', - 'database-backups.connections' => ['primary', 'sandbox'], - 'database-backups.retention_days' => 30, - 'database-backups.retention_count' => null, - 'database-backups.min_size_bytes' => 1024, - 'database-backups.notify_on_failure' => true, - 'database-backups.notify_emails' => ['ops@example.test'], - 'database-backups.dump_binary' => 'mysqldump', - 'database-backups.dump_args' => ['--single-transaction', '--no-tablespaces'], - 'database-backups.extra_dump_args' => ['--column-statistics=0'], - 'database-backups.timeout' => 60, - 'database-backups.tmp_dir' => $root . '/tmp', + 'app.name' => 'Fleetbase', + 'app.env' => 'production', + 'fleetbase.console.host' => 'https://console.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $sqlite, + 'database.connections.primary' => ['driver' => 'mysql', 'host' => 'db.example.test', 'port' => 3307, 'username' => 'fleetbase', 'password' => 'secret value', 'database' => 'fleetbase'], + 'database.connections.sandbox' => ['driver' => 'mysql', 'unix_socket' => '/run/mysqld.sock', 'username' => 'fleetbase', 'password' => 'sandbox secret', 'database' => 'fleetbase_sandbox'], + 'fleetbase.connection.db' => 'mysql', + 'filesystems.disks' => $disks, + 'database-backups.enabled' => true, + 'database-backups.frequency' => 'daily', + 'database-backups.time' => '02:30', + 'database-backups.day_of_week' => 0, + 'database-backups.disk' => 'backups', + 'database-backups.bucket' => null, + 'database-backups.path' => 'nightly', + 'database-backups.connections' => ['primary', 'sandbox'], + 'database-backups.retention_days' => 30, + 'database-backups.retention_count' => null, + 'database-backups.min_size_bytes' => 1024, + 'database-backups.notify_on_failure' => true, + 'database-backups.notify_emails' => ['ops@example.test'], + 'database-backups.dump_binary' => 'mysqldump', + 'database-backups.dump_args' => ['--single-transaction', '--no-tablespaces'], + 'database-backups.extra_dump_args' => ['--column-statistics=0'], + 'database-backups.timeout' => 60, + 'database-backups.tmp_dir' => $root . '/tmp', ], $config)); $container->instance('cache', new CacheRepository(new ArrayStore())); @@ -680,8 +681,8 @@ function database_backups_command(DatabaseBackupService $service, array $input = expect(file_exists($fixture['root'] . '/disk/nightly/production_fleetbase_backup-20260101-000000.sql.gz'))->toBeFalse(); - $disk = DatabaseBackupsDiskFake::at($fixture['root'] . '/disk'); - $disk->listingError = new RuntimeException('listing denied'); + $disk = DatabaseBackupsDiskFake::at($fixture['root'] . '/disk'); + $disk->listingError = new RuntimeException('listing denied'); $service->diskOverride = $disk; expect($service->run('scheduled', ['primary'])[0]->status)->toBe('completed') @@ -742,12 +743,14 @@ public function lock() $ok = database_backups_record(['path' => 'nightly/a.sql.gz', 'size_bytes' => 2048, 'duration_ms' => 15]); $failed = database_backups_record(['database' => 'fleetbase_sandbox', 'status' => 'failed', 'error' => 'Access denied']); - [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok]), ['--trigger' => 'scheduled', '--connection' => ['primary']]); + $service = new DatabaseBackupsServiceStub([$ok]); + [$code, $display] = database_backups_command($service, ['--trigger' => 'scheduled', '--connection' => ['primary']]); expect($code)->toBe(0) ->and($display)->toContain('Backed up fleetbase to backups:nightly/a.sql.gz (2048 bytes, 15 ms)') ->and($service->calls)->toBe([['scheduled', ['primary']]]); - [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([$ok, $failed]), ['--trigger' => 'bogus']); + $service = new DatabaseBackupsServiceStub([$ok, $failed]); + [$code, $display] = database_backups_command($service, ['--trigger' => 'bogus']); expect($code)->toBe(1) ->and($display)->toContain('Backup of fleetbase_sandbox failed: Access denied') ->and($service->calls)->toBe([['console', null]]); @@ -762,20 +765,23 @@ public function lock() test('db backup command does nothing while disabled unless forced', function () { database_backups_fixture(['database-backups.enabled' => false]); - [$code, $display] = database_backups_command($service = new DatabaseBackupsServiceStub([])); + $service = new DatabaseBackupsServiceStub([]); + [$code, $display] = database_backups_command($service); expect($code)->toBe(0) ->and($display)->toContain('Database backups are disabled.') ->and($service->calls)->toBe([]); - [$code] = database_backups_command($service = new DatabaseBackupsServiceStub([database_backups_record([])]), ['--force' => true]); + $service = new DatabaseBackupsServiceStub([database_backups_record([])]); + [$code] = database_backups_command($service, ['--force' => true]); expect($code)->toBe(0)->and($service->calls)->toHaveCount(1); }); test('run database backup job backs up as a manual run and logs a run that could not start', function () { database_backups_fixture(); - $job = new RunDatabaseBackup(); - $job->handle($service = new DatabaseBackupsServiceStub([])); + $job = new RunDatabaseBackup(); + $service = new DatabaseBackupsServiceStub([]); + $job->handle($service); expect($service->calls)->toBe([['manual', null]]) ->and($job->tries)->toBe(1);