fix(backups): let service databases choose the databases to back up

Closes #12432

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-02 13:59:53 +01:00
1 parent 33a6682791
commit dbd211486b
4 files changed
+95 -28

No files matched your search

+19 -26
View File
@@ -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<int, string>|null
*/
@@ -24,9 +24,14 @@
</x-slot:actions>
<div class="space-y-5">
@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)
<div class="grid w-full gap-4">
<x-forms.listbox id="dumpAll" label="Database selection" onChange="instantSave" :options="[
['value' => true, 'label' => 'All databases'],
@@ -67,7 +72,11 @@
placeholder="Type a database and press Enter" />
</div>
<p class="mt-1.5 text-xs text-neutral-500 dark:text-fg-dim">
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
</p>
</div>
@endif
@@ -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],
]);
+29
View File
@@ -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);