diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 82e35b73c..1838feb9e 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -279,33 +279,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } else { return; } - } else { - if (str($databaseType)->contains('postgres')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mongo')) { - // Format: db1:collection1,collection2|db2:collection3,collection4 - // Only explode if it's a string, not if it's already an array - if (is_string($databasesToBackup)) { - $databasesToBackup = explode('|', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } - } elseif (str($databaseType)->contains('mysql')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif (str($databaseType)->contains('mariadb')) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } elseif ($this->database instanceof StandaloneClickhouse) { - // Format: db1,db2,db3 - $databasesToBackup = explode(',', $databasesToBackup); - $databasesToBackup = array_map('trim', $databasesToBackup); - } else { - return; - } + } + $databasesToBackup = $this->databasesToBackup($databaseType, $databasesToBackup); + if ($databasesToBackup === []) { + return; } $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name; if ($this->database->name === 'coolify-db') { @@ -600,6 +577,30 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + /** @return array */ + private function databasesToBackup(string $databaseType, string|array $databases): array + { + $type = str($databaseType); + + if ($this->backup->dump_all && $type->contains(['postgres', 'mysql', 'mariadb'])) { + return ['all']; + } + + if (is_array($databases)) { + return $databases; + } + + if ($type->contains('mongo')) { + return array_map('trim', explode('|', $databases)); + } + + if ($type->contains(['postgres', 'mysql', 'mariadb', 'clickhouse'])) { + return array_map('trim', explode(',', $databases)); + } + + return []; + } + private function backup_standalone_postgresql(string $database): void { try { diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 41fb1681b..73877a945 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -98,26 +98,34 @@ class BackupExecutions extends Component return; } - $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class - ? $execution->scheduledDatabaseBackup->database->service->destination->server - : $execution->scheduledDatabaseBackup->database->destination->server; - try { - if ($execution->filename) { - deleteBackupsLocally($execution->filename, $server); + $deleteFromS3 = in_array('delete_backup_s3', $selectedActions, true); - if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { - deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + if ($execution->filename && ! $execution->local_storage_deleted) { + $server = $this->backup->server(); + if (! $server) { + throw new \RuntimeException('The backup server is unavailable.'); } + + deleteBackupsLocally($execution->filename, $server, throwError: true); + } + + if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + if (! $execution->scheduledDatabaseBackup->s3) { + throw new \RuntimeException('The S3 storage is unavailable.'); + } + + deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); } $execution->delete(); + $this->delete_backup_s3 = false; $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); } catch (\Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); - return true; + return false; } return true; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 4038c6288..e41c793c8 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -11,6 +11,7 @@ class ScheduledDatabaseBackup extends BaseModel protected function casts(): array { return [ + 'dump_all' => 'boolean', 'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_s3' => 'float', ]; diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php index 7e47126ab..23e1bbd8d 100644 --- a/tests/Feature/Jobs/DatabaseBackupJobTest.php +++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php @@ -44,6 +44,14 @@ test('scheduled database backup execution model casts storage deletion fields co expect($casts['s3_storage_deleted'])->toBe('boolean'); }); +test('scheduled database backup casts full dump selection to boolean', function () { + $model = new ScheduledDatabaseBackup; + + expect($model->getCasts())->toMatchArray(['dump_all' => 'boolean']) + ->and((new ScheduledDatabaseBackup(['dump_all' => '0']))->dump_all)->toBeFalse() + ->and((new ScheduledDatabaseBackup(['dump_all' => '1']))->dump_all)->toBeTrue(); +}); + test('upload_to_s3 throws exception and disables s3 when storage is null', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', @@ -362,3 +370,41 @@ test('all dump all database commands use shared helper compression', function () ->and(substr_count($source, '$this->buildCompressedDumpCommand($dumpCommand)'))->toBe(2) ->and($source)->not->toContain('| gzip >'); }); + +test('full database dumps create one logical all-databases archive regardless of saved database names', function (string $databaseType) { + $backup = new ScheduledDatabaseBackup([ + 'dump_all' => true, + 'databases_to_backup' => 'default,analytics', + ]); + $job = new DatabaseBackupJob($backup); + + $databases = (new ReflectionClass($job)) + ->getMethod('databasesToBackup') + ->invoke($job, $databaseType, $backup->databases_to_backup); + + expect($databases)->toBe(['all']); +})->with(['postgresql', 'mysql', 'mariadb']); + +test('specific database dumps keep every selected database', function (string $databaseType) { + $backup = new ScheduledDatabaseBackup([ + 'dump_all' => false, + 'databases_to_backup' => 'default, analytics', + ]); + $job = new DatabaseBackupJob($backup); + + $databases = (new ReflectionClass($job)) + ->getMethod('databasesToBackup') + ->invoke($job, $databaseType, $backup->databases_to_backup); + + expect($databases)->toBe(['default', 'analytics']); +})->with(['postgresql', 'mysql', 'mariadb']); + +test('individual database backup deletion surfaces local failures and honors selected S3 deletion', function () { + $source = file_get_contents(app_path('Livewire/Project/Database/BackupExecutions.php')); + + expect($source) + ->toContain("in_array('delete_backup_s3', \$selectedActions, true)") + ->toContain('deleteBackupsLocally($execution->filename, $server, throwError: true)') + ->toContain("throw new \\RuntimeException('The backup server is unavailable.')") + ->not->toContain('deleteBackupsLocally($execution->filename, $server);'); +});