From c089f8a328e51770e625bc0091411723ff199395 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:21:53 +0200 Subject: [PATCH] fix(backups): use configured databases when container env is missing (#12212) --- app/Jobs/DatabaseBackupJob.php | 46 ++++++++++++++++++++++-- tests/Feature/BackupRunSafetyTest.php | 52 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 99e691af86..54c6a11a81 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -217,7 +217,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if ($db) { $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); } else { - throw new \Exception('MYSQL_DATABASE not found'); + $databasesToBackup = $this->configuredDatabasesToBackup(); + if (blank($databasesToBackup)) { + $this->recordUndeterminedDatabases('MYSQL_DATABASE'); + + return; + } } } elseif (str($databaseType)->contains('mariadb')) { $commands[] = "docker exec {$escapedContainerName} env"; @@ -251,7 +256,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if ($db) { $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); } else { - throw new \Exception('MARIADB_DATABASE or MYSQL_DATABASE not found'); + $databasesToBackup = $this->configuredDatabasesToBackup(); + if (blank($databasesToBackup)) { + $this->recordUndeterminedDatabases('MARIADB_DATABASE or MYSQL_DATABASE'); + + return; + } } } } elseif (str($databaseType)->contains('mongo')) { @@ -1017,6 +1027,38 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue ]); } + /** + * Databases picked on the backup, for service databases whose container does not name one. + * + * @return string|array|null + */ + private function configuredDatabasesToBackup(): string|array|null + { + if ($this->backup->dump_all) { + return ['all']; + } + + return $this->backup->databases_to_backup; + } + + /** + * Show a run that cannot start because no database is known in the execution list, and notify the team. + */ + private function recordUndeterminedDatabases(string $envNames): void + { + $message = "No database to back up: the container does not set {$envNames}. Select \"All databases\" or enter the databases to back up in the backup settings."; + $this->recordSkippedBackup($message); + $this->logSkippedRun('no_databases_determined'); + try { + $this->team->notify(new BackupFailed($this->backup, $this->database, $message, 'unknown')); + } catch (Throwable $e) { + Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [ + 'backup_id' => $this->backup->uuid, + 'error' => $e->getMessage(), + ]); + } + } + private function markStaleExecutionsAsFailed(): void { try { diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index 0348fc1858..7f3475d846 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -277,6 +277,58 @@ it('stores service database backups in the folder of earlier releases', function ->toContain("/rallly-rallly_db-{$service->uuid}/pg-dump-rallly-"); }); +function backupRunSafetyServiceDatabase(Server $server, StandaloneDocker $destination, Environment $environment, string $image): ServiceDatabase +{ + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + + return ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'db', + 'image' => $image, + 'status' => 'running:healthy', + ]); +} + +it('backs up a service database without a database env variable from the backup settings', function (string $image, string $env, array $settings, string $databaseName) { + $database = backupRunSafetyServiceDatabase($this->server, $this->destination, $this->environment, $image); + $backup = backupRunSafetySchedule($database, $this->team, $settings); + backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->sole()) + ->status->toBe('success') + ->database_name->toBe($databaseName); +})->with([ + 'mariadb, all databases' => ['mariadb:11', 'MARIADB_ROOT_PASSWORD=secret', ['dump_all' => true], 'all'], + 'mariadb, selected database' => ['mariadb:11', 'MARIADB_ROOT_PASSWORD=secret', ['databases_to_backup' => 'shop'], 'shop'], + 'mysql, all databases' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret', ['dump_all' => true], 'all'], + 'mysql, selected database' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret', ['databases_to_backup' => 'shop'], 'shop'], +]); + +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); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]); + $commands = backupRunSafetyFakeRemoteCommands(["docker exec '{$database->name}-{$database->service->uuid}' env" => $env]); + + (new DatabaseBackupJob($backup))->handle(); + + expect(ScheduledDatabaseBackupExecution::query()->sole()) + ->status->toBe('failed') + ->message->toContain('No database to back up'); + expect($commands->contains(fn (string $command) => str_contains($command, 'dump')))->toBeFalse(); + Notification::assertSentTo($this->team, BackupFailed::class); +})->with([ + 'mariadb' => ['mariadb:11', 'MARIADB_ROOT_PASSWORD=secret'], + 'mysql' => ['mysql:8', 'MYSQL_ROOT_PASSWORD=secret'], +]); + // --- Worker timeout (M16) --- it('stops the backup command at the backup timeout, before the worker timeout', function () {