From dbd211486bfbe44a85fcaeecca85fe602b96e55c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:53 +0100 Subject: [PATCH] fix(backups): let service databases choose the databases to back up Closes #12432 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/DatabaseBackupJob.php | 45 ++++++++----------- .../database/backup-edit/general.blade.php | 13 +++++- tests/Feature/BackupEditValidationTest.php | 36 +++++++++++++++ tests/Feature/BackupRunSafetyTest.php | 29 ++++++++++++ 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 2fe350e37d..02094a9dc2 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -169,6 +169,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // The container name is validated above, so it is a safe folder name. Keep the folder of earlier releases. $this->directory_name = $serviceName.'-'.$this->container_name; $escapedContainerName = escapeshellarg($this->container_name); + $configuredDatabases = $this->configuredDatabasesToBackup(); if (str($databaseType)->contains('postgres')) { $commands[] = "docker exec {$escapedContainerName} env | grep -E '^(POSTGRES|PGUSER=)'"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); @@ -180,7 +181,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return str($env)->startsWith('POSTGRES_DB='); })->first(); - if ($db) { + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { $databasesToBackup = str($db)->after('POSTGRES_DB=')->value(); } else { $databasesToBackup = $this->database->postgres_user; @@ -207,15 +210,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return str($env)->startsWith('MYSQL_DATABASE='); })->first(); - if ($db) { + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); } else { - $databasesToBackup = $this->configuredDatabasesToBackup(); - if (blank($databasesToBackup)) { - $this->recordUndeterminedDatabases('MYSQL_DATABASE'); + $this->recordUndeterminedDatabases('MYSQL_DATABASE'); - return; - } + return; } } elseif (str($databaseType)->contains('mariadb')) { $commands[] = "docker exec {$escapedContainerName} env"; @@ -235,27 +237,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MARIADB_DATABASE='); - })->first(); + $db = $envs->first(fn ($env) => str($env)->startsWith('MARIADB_DATABASE=')) + ?? $envs->first(fn ($env) => str($env)->startsWith('MYSQL_DATABASE=')); - if ($db) { - $databasesToBackup = str($db)->after('MARIADB_DATABASE=')->value(); + if (filled($configuredDatabases)) { + $databasesToBackup = $configuredDatabases; + } elseif ($db) { + $databasesToBackup = str($db)->after('=')->value(); } else { - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MYSQL_DATABASE='); - })->first(); + $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); - if ($db) { - $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); - } else { - $databasesToBackup = $this->configuredDatabasesToBackup(); - if (blank($databasesToBackup)) { - $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); - - return; - } - } + return; } } elseif (str($databaseType)->contains('mongo')) { $databasesToBackup = ['*']; @@ -1104,7 +1096,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } /** - * Databases picked on the backup, for service databases whose container does not name one. + * Databases picked in the backup settings of a service database. They take priority over the + * database that the container env names, as for standalone databases. * * @return string|array|null */ diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index 56cf82e507..f5a0c1d738 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -24,9 +24,14 @@
+ @php + $isServiceSqlDatabase = $backup->database instanceof \App\Models\ServiceDatabase + && str($backup->database->databaseType())->contains(['postgres', 'mysql', 'mariadb']); + @endphp @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0 || $backup->database_type === 'App\Models\StandaloneMysql' - || $backup->database_type === 'App\Models\StandaloneMariadb') + || $backup->database_type === 'App\Models\StandaloneMariadb' + || $isServiceSqlDatabase)

- Add one or more database names. Leave empty to include the default database. + @if ($isServiceSqlDatabase) + Add one or more database names. Leave empty to back up the database that the container env names. + @else + Add one or more database names. Leave empty to include the default database. + @endif

@endif diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 93d38c78e5..a052562811 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -678,3 +678,39 @@ it('toggles the schedule from the form and notifies the page header', function ( expect((bool) $backup->fresh()->enabled)->toBeFalse(); }); + +it('lets a service database backup choose the databases to back up', function (string $image, bool $showsSelection) { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database = ServiceDatabase::create(['service_id' => $service->id, 'name' => 'db', 'image' => $image]); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $this->team->id, + ]); + + $component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect()]); + + $showsSelection + ? $component->assertSee('Database selection')->assertSee('Databases to back up') + : $component->assertDontSee('Database selection'); + + if ($showsSelection) { + $component->set('databasesToBackup', 'shop')->call('submit')->assertHasNoErrors(); + expect($backup->fresh()->databases_to_backup)->toBe('shop'); + } +})->with([ + 'mariadb' => ['mariadb:11', true], + 'mysql' => ['mysql:8', true], + 'postgres' => ['postgres:16-alpine', true], + 'mongodb' => ['mongo:7', false], +]); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index b058cbadab..a349afb277 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -361,6 +361,35 @@ it('backs up a service database without a database env variable from the backup 'mysql, selected database' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret', ['databases_to_backup' => 'shop'], 'shop'], ]); +it('backs up the databases from the backup settings instead of the container env database', function (string $image, string $env) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team, ['databases_to_backup' => 'shop,blog']); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->orderBy('id')->pluck('database_name')->all())->toBe(['shop', 'blog']); +})->with([ + 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"], + 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"], +]); + +it('backs up the container env database when the backup settings name none', function (string $image, string $env) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->sole()->database_name)->toBe('app'); +})->with([ + 'mariadb' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMARIADB_DATABASE=app"], + 'mariadb with mysql env' => ['mariadb:11', "MARIADB_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'mysql' => ['mysql:8', "MYSQL_ROOT_PASSWORD=secret\nMYSQL_DATABASE=app"], + 'postgres' => ['postgres:16', "POSTGRES_PASSWORD=secret\nPOSTGRES_DB=app"], +]); + it('records a failed execution and notifies when no service database to back up is known', function (string $image, string $env) { $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); $backup = backupRunSafetySchedule($database, $this->team);