From 307ea4d8d86d27ccfa19c6b4bb2fe36b996fb462 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:31:28 +0100 Subject: [PATCH 01/11] feat(backup): mark existing backups as legacy --- .../ScheduledDatabaseBackupExecution.php | 1 + ...gacy_column_and_cleanup_backup_columns.php | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 database/migrations/2026_02_02_220629_add_legacy_column_and_cleanup_backup_columns.php diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index c0298ecc8..1cd4f1aba 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -14,6 +14,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel 's3_uploaded' => 'boolean', 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', + 'legacy' => 'boolean', ]; } diff --git a/database/migrations/2026_02_02_220629_add_legacy_column_and_cleanup_backup_columns.php b/database/migrations/2026_02_02_220629_add_legacy_column_and_cleanup_backup_columns.php new file mode 100644 index 000000000..3efe4778b --- /dev/null +++ b/database/migrations/2026_02_02_220629_add_legacy_column_and_cleanup_backup_columns.php @@ -0,0 +1,22 @@ +boolean('legacy')->default(false)->after('filename'); + }); + + // Mark all existing backup executions as legacy + DB::table('scheduled_database_backup_executions')->update(['legacy' => true]); + + // @todo: In a future update or v5 we no longer need "databases_to_backup", "dump_all" in "scheduled_database_backups" - keeping them for now if we need to rollback. + // @todo: In a future update or v5 we no longer need "database_name" from "scheduled_database_backup_executions" - keeping them for now if we need to rollback. + } +}; From b92aa0a3203ef771f9bdec0f3e6f868bb0503f30 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:32:08 +0100 Subject: [PATCH 02/11] feat(ui): add legacy badge to ui and remove database - we now backup all databases all the time so no need to specify the database anymore --- .../livewire/project/database/backup-executions.blade.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index b6d88a2fd..ba0e1eaf1 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -66,6 +66,11 @@ @endphp {{ $statusText }} + @if (data_get($execution, 'legacy', false)) + + Legacy + + @endif
@if (data_get($execution, 'status') === 'running') @@ -79,7 +84,6 @@ • {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->format('M j, H:i') }} @endif - • Database: {{ data_get($execution, 'database_name', 'N/A') }} @if(data_get($execution, 'size')) • Size: {{ formatBytes(data_get($execution, 'size')) }} @endif From 268dcc9d043b3414dd1dc4dc673a5b2b4b81a9c8 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:33:26 +0100 Subject: [PATCH 03/11] feat(backup): remove overcomplicated backup settings - remove databasesToBackup and dumpAll settings --- app/Livewire/Project/Database/BackupEdit.php | 29 ---------------- .../Database/CreateScheduledBackup.php | 8 ----- .../project/database/backup-edit.blade.php | 34 ------------------- 3 files changed, 71 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 35262d7b0..dfd32f2cf 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -69,12 +69,6 @@ class BackupEdit extends Component #[Validate(['nullable', 'integer'])] public ?int $s3StorageId = 1; - #[Validate(['nullable', 'string'])] - public ?string $databasesToBackup = null; - - #[Validate(['required', 'boolean'])] - public bool $dumpAll = false; - #[Validate(['required', 'int', 'min:60', 'max:36000'])] public int $timeout = 3600; @@ -103,27 +97,6 @@ class BackupEdit extends Component $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->disableLocalBackup; $this->backup->s3_storage_id = $this->s3StorageId; - - // Validate databases_to_backup to prevent command injection - if (filled($this->databasesToBackup)) { - $databases = str($this->databasesToBackup)->explode(','); - foreach ($databases as $index => $db) { - $dbName = trim($db); - try { - validateShellSafePath($dbName, 'database name'); - } catch (\Exception $e) { - // Provide specific error message indicating which database failed validation - $position = $index + 1; - throw new \Exception( - "Database #{$position} ('{$dbName}') validation failed: ". - $e->getMessage() - ); - } - } - } - - $this->backup->databases_to_backup = $this->databasesToBackup; - $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; $this->customValidate(); $this->backup->save(); @@ -140,8 +113,6 @@ class BackupEdit extends Component $this->saveS3 = $this->backup->save_s3; $this->disableLocalBackup = $this->backup->disable_local_backup ?? false; $this->s3StorageId = $this->backup->s3_storage_id; - $this->databasesToBackup = $this->backup->databases_to_backup; - $this->dumpAll = $this->backup->dump_all; $this->timeout = $this->backup->timeout; } } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7f807afe2..238e51ecd 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -65,14 +65,6 @@ class CreateScheduledBackup extends Component 'team_id' => currentTeam()->id, ]; - if ($this->database->type() === 'standalone-postgresql') { - $payload['databases_to_backup'] = $this->database->postgres_db; - } elseif ($this->database->type() === 'standalone-mysql') { - $payload['databases_to_backup'] = $this->database->mysql_database; - } elseif ($this->database->type() === 'standalone-mariadb') { - $payload['databases_to_backup'] = $this->database->mariadb_database; - } - $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { $this->dispatch('refreshScheduledBackups', $databaseBackup->id); diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index bb5dcfc4d..4afb116f2 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -46,40 +46,6 @@ @endif

Settings

-
- @if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0) -
- -
- @if (!$backup->dump_all) - - @endif - @elseif($backup->database_type === 'App\Models\StandaloneMongodb') - - @elseif($backup->database_type === 'App\Models\StandaloneMysql') -
- -
- @if (!$backup->dump_all) - - @endif - @elseif($backup->database_type === 'App\Models\StandaloneMariadb') -
- -
- @if (!$backup->dump_all) - - @endif - @endif -
Date: Mon, 2 Feb 2026 22:34:27 +0100 Subject: [PATCH 04/11] feat(api): remove dump_all and databases_to_backup --- .../Controllers/Api/DatabasesController.php | 23 ++----------------- openapi.json | 17 -------------- openapi.yaml | 13 ----------- 3 files changed, 2 insertions(+), 51 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 15d182db2..4c24af2a9 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -627,8 +627,6 @@ class DatabasesController extends Controller 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true], 'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false], 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'], - 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], - 'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], @@ -672,7 +670,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'frequency', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -689,10 +687,8 @@ class DatabasesController extends Controller 'frequency' => 'required|string', 'enabled' => 'boolean', 'save_s3' => 'boolean', - 'dump_all' => 'boolean', 'backup_now' => 'boolean|nullable', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', - 'databases_to_backup' => 'string|nullable', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', 'database_backup_retention_max_storage_locally' => 'integer|min:0', @@ -777,17 +773,6 @@ class DatabasesController extends Controller unset($backupData['s3_storage_uuid']); } - // Set default databases_to_backup based on database type if not provided - if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) { - if ($database->type() === 'standalone-postgresql') { - $backupData['databases_to_backup'] = $database->postgres_db; - } elseif ($database->type() === 'standalone-mysql') { - $backupData['databases_to_backup'] = $database->mysql_database; - } elseif ($database->type() === 'standalone-mariadb') { - $backupData['databases_to_backup'] = $database->mariadb_database; - } - } - // Add required fields $backupData['database_id'] = $database->id; $backupData['database_type'] = $database->getMorphClass(); @@ -852,8 +837,6 @@ class DatabasesController extends Controller 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], - 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], - 'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'], 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], @@ -890,7 +873,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'frequency', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -905,9 +888,7 @@ class DatabasesController extends Controller 'save_s3' => 'boolean', 'backup_now' => 'boolean|nullable', 'enabled' => 'boolean', - 'dump_all' => 'boolean', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', - 'databases_to_backup' => 'string|nullable', 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', diff --git a/openapi.json b/openapi.json index bd502865a..a08db6d3c 100644 --- a/openapi.json +++ b/openapi.json @@ -3946,15 +3946,6 @@ "type": "string", "description": "S3 storage UUID (required if save_s3 is true)" }, - "databases_to_backup": { - "type": "string", - "description": "Comma separated list of databases to backup" - }, - "dump_all": { - "type": "boolean", - "description": "Whether to dump all databases", - "default": false - }, "backup_now": { "type": "boolean", "description": "Whether to trigger backup immediately after creation" @@ -4508,14 +4499,6 @@ "type": "boolean", "description": "Whether the backup is enabled or not" }, - "databases_to_backup": { - "type": "string", - "description": "Comma separated list of databases to backup" - }, - "dump_all": { - "type": "boolean", - "description": "Whether all databases are dumped or not" - }, "frequency": { "type": "string", "description": "Frequency of the backup" diff --git a/openapi.yaml b/openapi.yaml index 11148f43b..c4ec408cd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2473,13 +2473,6 @@ paths: s3_storage_uuid: type: string description: 'S3 storage UUID (required if save_s3 is true)' - databases_to_backup: - type: string - description: 'Comma separated list of databases to backup' - dump_all: - type: boolean - description: 'Whether to dump all databases' - default: false backup_now: type: boolean description: 'Whether to trigger backup immediately after creation' @@ -2862,12 +2855,6 @@ paths: enabled: type: boolean description: 'Whether the backup is enabled or not' - databases_to_backup: - type: string - description: 'Comma separated list of databases to backup' - dump_all: - type: boolean - description: 'Whether all databases are dumped or not' frequency: type: string description: 'Frequency of the backup' From 4d50511b1db151e41988bc37e2aa79471354a535 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:38:50 +0100 Subject: [PATCH 05/11] chore(test): remove restore test for now --- .../Unit/Livewire/Database/S3RestoreTest.php | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 tests/Unit/Livewire/Database/S3RestoreTest.php diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php deleted file mode 100644 index 18837b466..000000000 --- a/tests/Unit/Livewire/Database/S3RestoreTest.php +++ /dev/null @@ -1,79 +0,0 @@ -dumpAll = false; - $component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB'; - - $database = Mockery::mock('App\Models\StandalonePostgresql'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); - $component->resource = $database; - - $result = $component->buildRestoreCommand('/tmp/test.dump'); - - expect($result)->toContain('pg_restore'); - expect($result)->toContain('/tmp/test.dump'); -}); - -test('buildRestoreCommand handles PostgreSQL with dumpAll', function () { - $component = new Import; - $component->dumpAll = true; - // This is the full dump-all command prefix that would be set in the updatedDumpAll method - $component->postgresqlRestoreCommand = 'psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && psql -U $POSTGRES_USER -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U $POSTGRES_USER --if-exists {} && createdb -U $POSTGRES_USER postgres'; - - $database = Mockery::mock('App\Models\StandalonePostgresql'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); - $component->resource = $database; - - $result = $component->buildRestoreCommand('/tmp/test.dump'); - - expect($result)->toContain('gunzip -cf /tmp/test.dump'); - expect($result)->toContain('psql -U $POSTGRES_USER postgres'); -}); - -test('buildRestoreCommand handles MySQL without dumpAll', function () { - $component = new Import; - $component->dumpAll = false; - $component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - - $database = Mockery::mock('App\Models\StandaloneMysql'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql'); - $component->resource = $database; - - $result = $component->buildRestoreCommand('/tmp/test.dump'); - - expect($result)->toContain('mysql -u $MYSQL_USER'); - expect($result)->toContain('< /tmp/test.dump'); -}); - -test('buildRestoreCommand handles MariaDB without dumpAll', function () { - $component = new Import; - $component->dumpAll = false; - $component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - - $database = Mockery::mock('App\Models\StandaloneMariadb'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb'); - $component->resource = $database; - - $result = $component->buildRestoreCommand('/tmp/test.dump'); - - expect($result)->toContain('mariadb -u $MARIADB_USER'); - expect($result)->toContain('< /tmp/test.dump'); -}); - -test('buildRestoreCommand handles MongoDB', function () { - $component = new Import; - $component->dumpAll = false; - $component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; - - $database = Mockery::mock('App\Models\StandaloneMongodb'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb'); - $component->resource = $database; - - $result = $component->buildRestoreCommand('/tmp/test.dump'); - - expect($result)->toContain('mongorestore'); - expect($result)->toContain('/tmp/test.dump'); -}); From 2cfc9d6afc90ed65b2fbc8cc21917188ba490ea0 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:44:26 +0100 Subject: [PATCH 06/11] test: remove database_name from StartupExecutionCleanupTest --- tests/Feature/StartupExecutionCleanupTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Feature/StartupExecutionCleanupTest.php b/tests/Feature/StartupExecutionCleanupTest.php index 3a6b00208..0b03ca647 100644 --- a/tests/Feature/StartupExecutionCleanupTest.php +++ b/tests/Feature/StartupExecutionCleanupTest.php @@ -101,20 +101,17 @@ test('app:init marks stuck database backup executions as failed', function () { $runningBackup1 = ScheduledDatabaseBackupExecution::create([ 'scheduled_database_backup_id' => $scheduledBackup->id, 'status' => 'running', - 'database_name' => 'test_db', ]); $runningBackup2 = ScheduledDatabaseBackupExecution::create([ 'scheduled_database_backup_id' => $scheduledBackup->id, 'status' => 'running', - 'database_name' => 'test_db_2', ]); // Create a successful backup (should not be affected) $successfulBackup = ScheduledDatabaseBackupExecution::create([ 'scheduled_database_backup_id' => $scheduledBackup->id, 'status' => 'success', - 'database_name' => 'test_db_3', 'finished_at' => Carbon::now()->subMinutes(20), ]); From dc04b786317346e2b9adc1d7881c6c5a596bfcc4 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:23:44 +0100 Subject: [PATCH 07/11] feat(backup): always backup all database with the backup job --- app/Jobs/DatabaseBackupJob.php | 410 +++++++++++---------------------- 1 file changed, 130 insertions(+), 280 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a585baa69..dae8e96ef 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -83,8 +83,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { try { - $databasesToBackup = null; - $this->team = Team::find($this->backup->team_id); if (! $this->team) { $this->backup->delete(); @@ -133,15 +131,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->database->postgres_user = 'postgres'; } - $db = $envs->filter(function ($env) { - return str($env)->startsWith('POSTGRES_DB='); - })->first(); - - if ($db) { - $databasesToBackup = str($db)->after('POSTGRES_DB=')->value(); - } else { - $databasesToBackup = $this->database->postgres_user; - } $this->postgres_password = $envs->filter(function ($env) { return str($env)->startsWith('POSTGRES_PASSWORD='); })->first(); @@ -161,16 +150,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if ($rootPassword) { $this->database->mysql_root_password = str($rootPassword)->after('MYSQL_ROOT_PASSWORD=')->value(); } - - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MYSQL_DATABASE='); - })->first(); - - if ($db) { - $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); - } else { - throw new \Exception('MYSQL_DATABASE not found'); - } } elseif (str($databaseType)->contains('mariadb')) { $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; @@ -190,26 +169,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->database->mariadb_root_password = str($rootPassword)->after('MYSQL_ROOT_PASSWORD=')->value(); } } - - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MARIADB_DATABASE='); - })->first(); - - if ($db) { - $databasesToBackup = str($db)->after('MARIADB_DATABASE=')->value(); - } else { - $db = $envs->filter(function ($env) { - return str($env)->startsWith('MYSQL_DATABASE='); - })->first(); - - if ($db) { - $databasesToBackup = str($db)->after('MYSQL_DATABASE=')->value(); - } else { - throw new \Exception('MARIADB_DATABASE or MYSQL_DATABASE not found'); - } - } } elseif (str($databaseType)->contains('mongo')) { - $databasesToBackup = ['*']; $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; @@ -244,44 +204,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->container_name = $this->database->uuid; $this->directory_name = $databaseName.'-'.$this->container_name; $databaseType = $this->database->type(); - $databasesToBackup = data_get($this->backup, 'databases_to_backup'); - } - if (blank($databasesToBackup)) { - if (str($databaseType)->contains('postgres')) { - $databasesToBackup = [$this->database->postgres_db]; - } elseif (str($databaseType)->contains('mongo')) { - $databasesToBackup = ['*']; - } elseif (str($databaseType)->contains('mysql')) { - $databasesToBackup = [$this->database->mysql_database]; - } elseif (str($databaseType)->contains('mariadb')) { - $databasesToBackup = [$this->database->mariadb_database]; - } 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); - } else { - return; - } } + $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name; if ($this->database->name === 'coolify-db') { $databasesToBackup = ['coolify']; @@ -289,156 +213,131 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $ip = Str::slug($this->server->ip); $this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip"; } - foreach ($databasesToBackup as $database) { - // Generate unique UUID for each database backup execution - $attempts = 0; - do { - $this->backup_log_uuid = (string) new Cuid2; - $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); - $attempts++; - if ($attempts >= 3 && $exists) { - throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts'); - } - } while ($exists); - $size = 0; - $localBackupSucceeded = false; - $s3UploadError = null; + // Generate unique UUID for backup execution + $attempts = 0; + do { + $this->backup_log_uuid = (string) new Cuid2; + $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); + $attempts++; + if ($attempts >= 3 && $exists) { + throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts'); + } + } while ($exists); - // Step 1: Create local backup + $size = 0; + $localBackupSucceeded = false; + $s3UploadError = null; + + // Step 1: Create local backup (always dump all databases) + try { + if (str($databaseType)->contains('postgres')) { + $this->backup_file = '/pg-dump-all-'.Carbon::now()->timestamp.'.sql.gz'; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_postgresql(); + } elseif (str($databaseType)->contains('mongo')) { + $this->backup_file = '/mongo-dump-all-'.Carbon::now()->timestamp.'.tar.gz'; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_mongodb(); + } elseif (str($databaseType)->contains('mysql')) { + $this->backup_file = '/mysql-dump-all-'.Carbon::now()->timestamp.'.sql.gz'; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_mysql(); + } elseif (str($databaseType)->contains('mariadb')) { + $this->backup_file = '/mariadb-dump-all-'.Carbon::now()->timestamp.'.sql.gz'; + $this->backup_location = $this->backup_dir.$this->backup_file; + $this->backup_log = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $this->backup_log_uuid, + 'filename' => $this->backup_location, + 'scheduled_database_backup_id' => $this->backup->id, + 'local_storage_deleted' => false, + ]); + $this->backup_standalone_mariadb(); + } else { + throw new \Exception('Unsupported database type'); + } + + $size = $this->calculate_size(); + + // Verify local backup succeeded + if ($size > 0) { + $localBackupSucceeded = true; + } else { + throw new \Exception('Local backup file is empty or was not created'); + } + } catch (\Throwable $e) { + // Local backup failed + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'failed', + 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), + 'size' => $size, + 'filename' => null, + 's3_uploaded' => null, + ]); + } + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage())); + throw $e; + } + + // Step 2: Upload to S3 if enabled (independent of local backup) + $localStorageDeleted = false; + if ($this->backup->save_s3 && $localBackupSucceeded) { try { - if (str($databaseType)->contains('postgres')) { - $this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp'; - if ($this->backup->dump_all) { - $this->backup_file = '/pg-dump-all-'.Carbon::now()->timestamp.'.gz'; - } - $this->backup_location = $this->backup_dir.$this->backup_file; - $this->backup_log = ScheduledDatabaseBackupExecution::create([ - 'uuid' => $this->backup_log_uuid, - 'database_name' => $database, - 'filename' => $this->backup_location, - 'scheduled_database_backup_id' => $this->backup->id, - 'local_storage_deleted' => false, - ]); - $this->backup_standalone_postgresql($database); - } elseif (str($databaseType)->contains('mongo')) { - if ($database === '*') { - $database = 'all'; - $databaseName = 'all'; - } else { - if (str($database)->contains(':')) { - $databaseName = str($database)->before(':'); - } else { - $databaseName = $database; - } - } - $this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz'; - $this->backup_location = $this->backup_dir.$this->backup_file; - $this->backup_log = ScheduledDatabaseBackupExecution::create([ - 'uuid' => $this->backup_log_uuid, - 'database_name' => $databaseName, - 'filename' => $this->backup_location, - 'scheduled_database_backup_id' => $this->backup->id, - 'local_storage_deleted' => false, - ]); - $this->backup_standalone_mongodb($database); - } elseif (str($databaseType)->contains('mysql')) { - $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; - if ($this->backup->dump_all) { - $this->backup_file = '/mysql-dump-all-'.Carbon::now()->timestamp.'.gz'; - } - $this->backup_location = $this->backup_dir.$this->backup_file; - $this->backup_log = ScheduledDatabaseBackupExecution::create([ - 'uuid' => $this->backup_log_uuid, - 'database_name' => $database, - 'filename' => $this->backup_location, - 'scheduled_database_backup_id' => $this->backup->id, - 'local_storage_deleted' => false, - ]); - $this->backup_standalone_mysql($database); - } elseif (str($databaseType)->contains('mariadb')) { - $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; - if ($this->backup->dump_all) { - $this->backup_file = '/mariadb-dump-all-'.Carbon::now()->timestamp.'.gz'; - } - $this->backup_location = $this->backup_dir.$this->backup_file; - $this->backup_log = ScheduledDatabaseBackupExecution::create([ - 'uuid' => $this->backup_log_uuid, - 'database_name' => $database, - 'filename' => $this->backup_location, - 'scheduled_database_backup_id' => $this->backup->id, - 'local_storage_deleted' => false, - ]); - $this->backup_standalone_mariadb($database); - } else { - throw new \Exception('Unsupported database type'); - } + $this->upload_to_s3(); - $size = $this->calculate_size(); - - // Verify local backup succeeded - if ($size > 0) { - $localBackupSucceeded = true; - } else { - throw new \Exception('Local backup file is empty or was not created'); + // If local backup is disabled, delete the local file immediately after S3 upload + if ($this->backup->disable_local_backup) { + deleteBackupsLocally($this->backup_location, $this->server); + $localStorageDeleted = true; } } catch (\Throwable $e) { - // Local backup failed - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'failed', - 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), - 'size' => $size, - 'filename' => null, - 's3_uploaded' => null, - ]); - } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + // S3 upload failed but local backup succeeded + $s3UploadError = $e->getMessage(); + } + } - continue; + // Step 3: Update status and send notifications based on results + if ($localBackupSucceeded) { + $message = $this->backup_output; + + if ($s3UploadError) { + $message = $message + ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError + : 'Warning: S3 upload failed: '.$s3UploadError; } - // Step 2: Upload to S3 if enabled (independent of local backup) - $localStorageDeleted = false; - if ($this->backup->save_s3 && $localBackupSucceeded) { - try { - $this->upload_to_s3(); + $this->backup_log->update([ + 'status' => 'success', + 'message' => $message, + 'size' => $size, + 's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null, + 'local_storage_deleted' => $localStorageDeleted, + ]); - // If local backup is disabled, delete the local file immediately after S3 upload - if ($this->backup->disable_local_backup) { - deleteBackupsLocally($this->backup_location, $this->server); - $localStorageDeleted = true; - } - } catch (\Throwable $e) { - // S3 upload failed but local backup succeeded - $s3UploadError = $e->getMessage(); - } - } - - // Step 3: Update status and send notifications based on results - if ($localBackupSucceeded) { - $message = $this->backup_output; - - if ($s3UploadError) { - $message = $message - ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError - : 'Warning: S3 upload failed: '.$s3UploadError; - } - - $this->backup_log->update([ - 'status' => 'success', - 'message' => $message, - 'size' => $size, - 's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null, - 'local_storage_deleted' => $localStorageDeleted, - ]); - - // Send appropriate notification - if ($s3UploadError) { - $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError)); - } else { - $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); - } + // Send appropriate notification + if ($s3UploadError) { + $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $s3UploadError)); + } else { + $this->team->notify(new BackupSuccess($this->backup, $this->database)); } } if ($this->backup_log && $this->backup_log->status === 'success') { @@ -458,7 +357,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function backup_standalone_mongodb(string $databaseWithCollections): void + private function backup_standalone_mongodb(): void { try { $url = $this->database->internal_db_url; @@ -473,41 +372,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } \Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]); - if ($databaseWithCollections === 'all') { - $commands[] = 'mkdir -p '.$this->backup_dir; - if (str($this->database->image)->startsWith('mongo:4')) { - $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; - } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location"; - } + + $commands[] = 'mkdir -p '.$this->backup_dir; + if (str($this->database->image)->startsWith('mongo:4')) { + $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; } else { - if (str($databaseWithCollections)->contains(':')) { - $databaseName = str($databaseWithCollections)->before(':'); - $collectionsToExclude = str($databaseWithCollections)->after(':')->explode(','); - } else { - $databaseName = $databaseWithCollections; - $collectionsToExclude = collect(); - } - $commands[] = 'mkdir -p '.$this->backup_dir; - - // Validate and escape database name to prevent command injection - validateShellSafePath($databaseName, 'database name'); - $escapedDatabaseName = escapeshellarg($databaseName); - - if ($collectionsToExclude->count() === 0) { - if (str($this->database->image)->startsWith('mongo:4')) { - $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location"; - } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location"; - } - } else { - if (str($this->database->image)->startsWith('mongo:4')) { - $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; - } else { - $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location"; - } - } + $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location"; } + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { @@ -519,7 +391,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function backup_standalone_postgresql(string $database): void + private function backup_standalone_postgresql(): void { try { $commands[] = 'mkdir -p '.$this->backup_dir; @@ -527,14 +399,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue if ($this->postgres_password) { $backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\""; } - if ($this->backup->dump_all) { - $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location"; - } else { - // Validate and escape database name to prevent command injection - validateShellSafePath($database, 'database name'); - $escapedDatabase = escapeshellarg($database); - $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location"; - } + $backupCommand .= " $this->container_name pg_dumpall --clean --if-exists --username {$this->database->postgres_user} | gzip > $this->backup_location"; $commands[] = $backupCommand; $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); @@ -548,18 +413,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function backup_standalone_mysql(string $database): void + private function backup_standalone_mysql(): void { try { $commands[] = 'mkdir -p '.$this->backup_dir; - if ($this->backup->dump_all) { - $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location"; - } else { - // Validate and escape database name to prevent command injection - validateShellSafePath($database, 'database name'); - $escapedDatabase = escapeshellarg($database); - $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; - } + $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false | gzip > $this->backup_location"; $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { @@ -571,18 +429,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function backup_standalone_mariadb(string $database): void + private function backup_standalone_mariadb(): void { try { $commands[] = 'mkdir -p '.$this->backup_dir; - if ($this->backup->dump_all) { - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location"; - } else { - // Validate and escape database name to prevent command injection - validateShellSafePath($database, 'database name'); - $escapedDatabase = escapeshellarg($database); - $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; - } + $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false | gzip > $this->backup_location"; $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { @@ -709,9 +560,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue // Notify team about permanent failure if ($this->team) { - $databaseName = $log?->database_name ?? 'unknown'; $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error'; - $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName)); + $this->team->notify(new BackupFailed($this->backup, $this->database, $output)); } } } From f9958d83dc37afdc07b188abf9f85ffb9661d467 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:27:08 +0100 Subject: [PATCH 08/11] feat(notifcations): use just the coolify database name in notifications - no longer use the cluster database_name in backup notifications as we always backup all databases --- app/Console/Commands/Emails.php | 3 +- app/Notifications/Database/BackupFailed.php | 12 ++-- app/Notifications/Database/BackupSuccess.php | 12 ++-- .../Database/BackupSuccessWithS3Warning.php | 12 ++-- .../views/emails/backup-failed.blade.php | 2 +- .../backup-success-with-s3-warning.blade.php | 2 +- .../views/emails/backup-success.blade.php | 2 +- .../project/database/import.blade.php | 66 +++++++++++-------- 8 files changed, 57 insertions(+), 54 deletions(-) diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php index 43ba06804..60626ced8 100644 --- a/app/Console/Commands/Emails.php +++ b/app/Console/Commands/Emails.php @@ -54,7 +54,6 @@ class Emails extends Command options: [ 'updates' => 'Send Update Email to all users', 'emails-test' => 'Test', - 'database-backup-statuses-daily' => 'Database - Backup Statuses (Daily)', 'application-deployment-success-daily' => 'Application - Deployment Success (Daily)', 'application-deployment-success' => 'Application - Deployment Success', 'application-deployment-failed' => 'Application - Deployment Failed', @@ -167,7 +166,7 @@ class Emails extends Command ]); } $output = 'Because of an error, the backup of the database '.$db->name.' failed.'; - $this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail(); + $this->mail = (new BackupFailed($backup, $db, $output))->toMail(); $this->sendEmail(); break; case 'backup-success': diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index c2b21b1d5..0040d7ace 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -15,7 +15,7 @@ class BackupFailed extends CustomEmailNotification public string $frequency; - public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) + public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output) { $this->onQueue('high'); $this->name = $database->name; @@ -33,7 +33,6 @@ class BackupFailed extends CustomEmailNotification $mail->subject("Coolify: [ACTION REQUIRED] Database Backup FAILED for {$this->database->name}"); $mail->view('emails.backup-failed', [ 'name' => $this->name, - 'database_name' => $this->database_name, 'frequency' => $this->frequency, 'output' => $this->output, ]); @@ -45,7 +44,7 @@ class BackupFailed extends CustomEmailNotification { $message = new DiscordMessage( title: ':cross_mark: Database backup failed', - description: "Database backup for {$this->name} (db:{$this->database_name}) has FAILED.", + description: "Database backup for {$this->name} has FAILED.", color: DiscordMessage::errorColor(), isCritical: true, ); @@ -58,7 +57,7 @@ class BackupFailed extends CustomEmailNotification public function toTelegram(): array { - $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} was FAILED.\n\nReason:\n{$this->output}"; + $message = "Coolify: Database backup for {$this->name} with frequency of {$this->frequency} was FAILED.\n\nReason:\n{$this->output}"; return [ 'message' => $message, @@ -70,14 +69,14 @@ class BackupFailed extends CustomEmailNotification return new PushoverMessage( title: 'Database backup failed', level: 'error', - message: "Database backup for {$this->name} (db:{$this->database_name}) was FAILED

Frequency: {$this->frequency} .
Reason: {$this->output}", + message: "Database backup for {$this->name} was FAILED

Frequency: {$this->frequency} .
Reason: {$this->output}", ); } public function toSlack(): SlackMessage { $title = 'Database backup failed'; - $description = "Database backup for {$this->name} (db:{$this->database_name}) has FAILED."; + $description = "Database backup for {$this->name} has FAILED."; $description .= "\n\n*Frequency:* {$this->frequency}"; $description .= "\n\n*Error Output:* {$this->output}"; @@ -99,7 +98,6 @@ class BackupFailed extends CustomEmailNotification 'event' => 'backup_failed', 'database_name' => $this->name, 'database_uuid' => $this->database->uuid, - 'database_type' => $this->database_name, 'frequency' => $this->frequency, 'error_output' => $this->output, 'url' => $url, diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 3d2d8ece3..4624fcef8 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -15,7 +15,7 @@ class BackupSuccess extends CustomEmailNotification public string $frequency; - public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) + public function __construct(ScheduledDatabaseBackup $backup, public $database) { $this->onQueue('high'); @@ -34,7 +34,6 @@ class BackupSuccess extends CustomEmailNotification $mail->subject("Coolify: Backup successfully done for {$this->database->name}"); $mail->view('emails.backup-success', [ 'name' => $this->name, - 'database_name' => $this->database_name, 'frequency' => $this->frequency, ]); @@ -45,7 +44,7 @@ class BackupSuccess extends CustomEmailNotification { $message = new DiscordMessage( title: ':white_check_mark: Database backup successful', - description: "Database backup for {$this->name} (db:{$this->database_name}) was successful.", + description: "Database backup for {$this->name} was successful.", color: DiscordMessage::successColor(), ); @@ -56,7 +55,7 @@ class BackupSuccess extends CustomEmailNotification public function toTelegram(): array { - $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} was successful."; + $message = "Coolify: Database backup for {$this->name} with frequency of {$this->frequency} was successful."; return [ 'message' => $message, @@ -68,14 +67,14 @@ class BackupSuccess extends CustomEmailNotification return new PushoverMessage( title: 'Database backup successful', level: 'success', - message: "Database backup for {$this->name} (db:{$this->database_name}) was successful.

Frequency: {$this->frequency}.", + message: "Database backup for {$this->name} was successful.

Frequency: {$this->frequency}.", ); } public function toSlack(): SlackMessage { $title = 'Database backup successful'; - $description = "Database backup for {$this->name} (db:{$this->database_name}) was successful."; + $description = "Database backup for {$this->name} was successful."; $description .= "\n\n*Frequency:* {$this->frequency}"; @@ -96,7 +95,6 @@ class BackupSuccess extends CustomEmailNotification 'event' => 'backup_success', 'database_name' => $this->name, 'database_uuid' => $this->database->uuid, - 'database_type' => $this->database_name, 'frequency' => $this->frequency, 'url' => $url, ]; diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index ee24ef17d..d8ec01731 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -17,7 +17,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public ?string $s3_storage_url = null; - public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error) + public function __construct(ScheduledDatabaseBackup $backup, public $database, public $s3_error) { $this->onQueue('high'); @@ -40,7 +40,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification $mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}"); $mail->view('emails.backup-success-with-s3-warning', [ 'name' => $this->name, - 'database_name' => $this->database_name, 'frequency' => $this->frequency, 's3_error' => $this->s3_error, 's3_storage_url' => $this->s3_storage_url, @@ -53,7 +52,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification { $message = new DiscordMessage( title: ':warning: Database backup succeeded locally, S3 upload failed', - description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.", + description: "Database backup for {$this->name} was created successfully on local storage, but failed to upload to S3.", color: DiscordMessage::warningColor(), ); @@ -69,7 +68,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function toTelegram(): array { - $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}"; + $message = "Coolify: Database backup for {$this->name} with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}"; if ($this->s3_storage_url) { $message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}"; @@ -82,7 +81,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function toPushover(): PushoverMessage { - $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.

Frequency: {$this->frequency}.
S3 Error: {$this->s3_error}"; + $message = "Database backup for {$this->name} was created successfully on local storage, but failed to upload to S3.

Frequency: {$this->frequency}.
S3 Error: {$this->s3_error}"; if ($this->s3_storage_url) { $message .= "

s3_storage_url}\">Check S3 Configuration"; @@ -98,7 +97,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function toSlack(): SlackMessage { $title = 'Database backup succeeded locally, S3 upload failed'; - $description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3."; + $description = "Database backup for {$this->name} was created successfully on local storage, but failed to upload to S3."; $description .= "\n\n*Frequency:* {$this->frequency}"; $description .= "\n\n*S3 Error:* {$this->s3_error}"; @@ -124,7 +123,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification 'event' => 'backup_success_with_s3_warning', 'database_name' => $this->name, 'database_uuid' => $this->database->uuid, - 'database_type' => $this->database_name, 'frequency' => $this->frequency, 's3_error' => $this->s3_error, 'url' => $url, diff --git a/resources/views/emails/backup-failed.blade.php b/resources/views/emails/backup-failed.blade.php index 013c8bc98..de6a7c981 100644 --- a/resources/views/emails/backup-failed.blade.php +++ b/resources/views/emails/backup-failed.blade.php @@ -1,5 +1,5 @@ -Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} was FAILED. +Database backup for {{ $name }} with frequency of {{ $frequency }} was FAILED. ### Reason diff --git a/resources/views/emails/backup-success-with-s3-warning.blade.php b/resources/views/emails/backup-success-with-s3-warning.blade.php index 5d2f25851..1cc2e4549 100644 --- a/resources/views/emails/backup-success-with-s3-warning.blade.php +++ b/resources/views/emails/backup-success-with-s3-warning.blade.php @@ -1,5 +1,5 @@ -Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} succeeded locally but failed to upload to S3. +Database backup for {{ $name }} with frequency of {{ $frequency }} succeeded locally but failed to upload to S3. S3 Error: {{ $s3_error }} diff --git a/resources/views/emails/backup-success.blade.php b/resources/views/emails/backup-success.blade.php index d06bca6ce..0d54a254c 100644 --- a/resources/views/emails/backup-success.blade.php +++ b/resources/views/emails/backup-success.blade.php @@ -1,3 +1,3 @@ -Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} was successful. +Database backup for {{ $name }} with frequency of {{ $frequency }} was successful. diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index 666abb3b3..fa360e35b 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -61,39 +61,49 @@ @if (str($resourceStatus)->startsWith('running')) {{-- Restore Command Configuration --}} @if ($resourceDbType === 'standalone-postgresql') - @if ($dumpAll) - - @else - -
- You can add "--clean" to drop objects before creating them, avoiding - conflicts. - You can add "--verbose" to log more things. +
+

Import Options

+
+ +
- @endif -
- + + @if ($restoreCommandText) +
{{ $restoreCommandText }}
+ @endif
@elseif ($resourceDbType === 'standalone-mysql') - @if ($dumpAll) - - @else - - @endif -
- +
+

Import Options

+
+ + +
+ + @if ($restoreCommandText) +
{{ $restoreCommandText }}
+ @endif
@elseif ($resourceDbType === 'standalone-mariadb') - @if ($dumpAll) - - @else - - @endif -
- +
+

Import Options

+
+ + +
+ + @if ($restoreCommandText) +
{{ $restoreCommandText }}
+ @endif
@endif From fc5d7f31aeb17a68f7a4312aa014b7a0c91af2fc Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:28:45 +0100 Subject: [PATCH 09/11] chore(notifications): remove unused email view --- resources/views/emails/daily-backup.blade.php | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 resources/views/emails/daily-backup.blade.php diff --git a/resources/views/emails/daily-backup.blade.php b/resources/views/emails/daily-backup.blade.php deleted file mode 100644 index 25d887387..000000000 --- a/resources/views/emails/daily-backup.blade.php +++ /dev/null @@ -1,19 +0,0 @@ - -@foreach ($databases as $database_name => $databases) - -@if(data_get($databases,'failed_count') > 0) - -
- -"{{ $database_name }}" backups: There were some failed backups. Please login and check the logs for more details. - -
- -@else - -"{{ $database_name }}" backups: All backups were successful. - -@endif - -@endforeach -
From 9f8b95777e639d849c4b9d2ec20e4df921bfc8ad Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:46:32 +0100 Subject: [PATCH 10/11] feat(ui): add legacy import command options to the ui --- .../project/database/import.blade.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index fa360e35b..91efcd75b 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -64,10 +64,10 @@

Import Options

- - + +
@@ -79,10 +79,10 @@

Import Options

- - + +
@@ -94,10 +94,10 @@

Import Options

- - + +
From 1095f248bfe6ddc30e48664a591706bda768e8cb Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:49:25 +0100 Subject: [PATCH 11/11] feat(backup): add new restore functionality --- app/Livewire/Project/Database/Import.php | 80 +++++++++++++----------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 7d37bd473..23c077a34 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -139,7 +139,9 @@ class Import extends Component public array $importCommands = []; - public bool $dumpAll = false; + public bool $legacyDumpAll = false; + + public bool $legacySingleDb = false; public string $restoreCommandText = ''; @@ -147,11 +149,11 @@ class Import extends Component public ?int $activityId = null; - public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + public string $postgresqlRestoreCommand = 'gunzip -c $tmpPath | psql -X -U ${POSTGRES_USER} -d postgres'; - public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + public string $mysqlRestoreCommand = 'gunzip -c $tmpPath | mysql -u root -p$MYSQL_ROOT_PASSWORD'; - public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + public string $mariadbRestoreCommand = 'gunzip -c $tmpPath | mariadb -u root -p$MARIADB_ROOT_PASSWORD'; public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; @@ -206,7 +208,23 @@ class Import extends Component $this->loadAvailableS3Storages(); } - public function updatedDumpAll($value) + public function updatedLegacyDumpAll($value) + { + if ($value) { + $this->legacySingleDb = false; + } + $this->updateRestoreCommands(); + } + + public function updatedLegacySingleDb($value) + { + if ($value) { + $this->legacyDumpAll = false; + } + $this->updateRestoreCommands(); + } + + private function updateRestoreCommands() { $morphClass = $this->resource->getMorphClass(); @@ -225,7 +243,7 @@ class Import extends Component switch ($morphClass) { case \App\Models\StandaloneMariadb::class: case 'mariadb': - if ($value === true) { + if ($this->legacyDumpAll) { $this->mariadbRestoreCommand = <<<'EOD' for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true @@ -234,14 +252,15 @@ mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \ (gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default} EOD; - $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}'; + } elseif ($this->legacySingleDb) { + $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE < $tmpPath'; } else { - $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; + $this->mariadbRestoreCommand = 'gunzip -c $tmpPath | mariadb -u root -p$MARIADB_ROOT_PASSWORD'; } break; case \App\Models\StandaloneMysql::class: case 'mysql': - if ($value === true) { + if ($this->legacyDumpAll) { $this->mysqlRestoreCommand = <<<'EOD' for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true @@ -250,26 +269,29 @@ mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXIS mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \ (gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default} EOD; - $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}'; + } elseif ($this->legacySingleDb) { + $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < $tmpPath'; } else { - $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; + $this->mysqlRestoreCommand = 'gunzip -c $tmpPath | mysql -u root -p$MYSQL_ROOT_PASSWORD'; } break; case \App\Models\StandalonePostgresql::class: case 'postgresql': - if ($value === true) { + if ($this->legacyDumpAll) { $this->postgresqlRestoreCommand = <<<'EOD' psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \ -createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} +createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} && \ +(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} EOD; - $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + } elseif ($this->legacySingleDb) { + $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}} $tmpPath'; } else { - $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'; + $this->postgresqlRestoreCommand = 'gunzip -c $tmpPath | psql -X -U ${POSTGRES_USER} -d postgres'; + $this->restoreCommandText = 'Default: Restore from pg_dumpall file (gzipped SQL)'; } break; } - } public function getContainers() @@ -737,37 +759,19 @@ EOD; switch ($morphClass) { case \App\Models\StandaloneMariadb::class: case 'mariadb': - $restoreCommand = $this->mariadbRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } + $restoreCommand = str_replace('$tmpPath', $tmpPath, $this->mariadbRestoreCommand); break; case \App\Models\StandaloneMysql::class: case 'mysql': - $restoreCommand = $this->mysqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}"; - } else { - $restoreCommand .= " < {$tmpPath}"; - } + $restoreCommand = str_replace('$tmpPath', $tmpPath, $this->mysqlRestoreCommand); break; case \App\Models\StandalonePostgresql::class: case 'postgresql': - $restoreCommand = $this->postgresqlRestoreCommand; - if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; - } else { - $restoreCommand .= " {$tmpPath}"; - } + $restoreCommand = str_replace('$tmpPath', $tmpPath, $this->postgresqlRestoreCommand); break; case \App\Models\StandaloneMongodb::class: case 'mongodb': - $restoreCommand = $this->mongodbRestoreCommand; - if ($this->dumpAll === false) { - $restoreCommand .= "{$tmpPath}"; - } + $restoreCommand = $this->mongodbRestoreCommand.$tmpPath; break; default: $restoreCommand = '';