From 04bcf016199d86330c6f5f050668dcf3cce46c12 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:35:58 +0100 Subject: [PATCH 01/13] feat: DB migration for Backup retention - rename number_of_backups_locally to database_backup_retention_amount_locally - add backup retention days to local stored backups - add s3 retention fields --- ...ds_to_scheduled_database_backups_table.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php diff --git a/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php b/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php new file mode 100644 index 000000000..f5371ee7a --- /dev/null +++ b/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php @@ -0,0 +1,34 @@ +renameColumn('number_of_backups_locally', 'database_backup_retention_amount_locally'); + $table->integer('database_backup_retention_amount_locally')->default(0)->nullable(false)->change(); + $table->integer('database_backup_retention_days_locally')->default(0)->nullable(false); + + $table->integer('database_backup_retention_amount_s3')->default(0)->nullable(false); + $table->integer('database_backup_retention_days_s3')->default(0)->nullable(false); + $table->integer('database_backup_retention_max_storage_s3')->default(0)->nullable(false); + }); + } + + public function down() + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->renameColumn('database_backup_retention_amount_locally', 'number_of_backups_locally')->nullable(true)->change(); + $table->dropColumn([ + 'database_backup_retention_days_locally', + 'database_backup_retention_amount_s3', + 'database_backup_retention_days_s3', + 'database_backup_retention_max_storage_s3', + ]); + }); + } +}; From e037ed738a344974fe92651b1d90d9e33436b8fa Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:36:41 +0100 Subject: [PATCH 02/13] feat: UI for backup retention settings --- .../project/database/backup-edit.blade.php | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 59628636d..1f3ee6913 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -72,7 +72,43 @@ - + + +

Backup Retention Settings

+
+

+ These settings control how long backups are kept. +

    +
  • Setting a value to 0 means unlimited retention.
  • +
  • The retention rules work independently and whichever limit is reached first will trigger a cleanup of the older backups.
  • +
+

+
+ +
+
+

Local Backup Retention

+
+ + +
+
+ + @if ($backup->save_s3) +
+

S3 Storage Retention

+
+ + + +
+
+ @endif
From fb01aed6d5ee03663414501b69a3394ca3491373 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:37:36 +0100 Subject: [PATCH 03/13] feat: new global s3 and local backup deletion function --- app/Jobs/DatabaseBackupJob.php | 11 +- bootstrap/helpers/databases.php | 245 +++++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 11 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 6730dceb7..b9be4aaa6 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -459,14 +459,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue private function remove_old_backups(): void { - if ($this->backup->number_of_backups_locally === 0) { - $deletable = $this->backup->executions()->where('status', 'success'); - } else { - $deletable = $this->backup->executions()->where('status', 'success')->skip($this->backup->number_of_backups_locally - 1); - } - foreach ($deletable->get() as $execution) { - delete_backup_locally($execution->filename, $this->server); - $execution->delete(); + deleteOldBackupsLocally($this->backup); + if ($this->backup->save_s3) { + deleteOldBackupsFromS3($this->backup); } } diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index e12910f82..5c0c944ce 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -1,6 +1,7 @@ 's3', + 'key' => $s3->key, + 'secret' => $s3->secret, + 'region' => $s3->region, + 'bucket' => $s3->bucket, + 'endpoint' => $s3->endpoint, + 'use_path_style_endpoint' => true, + ]); + + // Delete files in bulk + $disk->delete($filenames); +} + +function deleteEmptyBackupFolder($folderPath, Server $server): void +{ + // Properly escape the folder path for shell commands + $escapedPath = escapeshellarg($folderPath); + $escapedParentPath = escapeshellarg(dirname($folderPath)); + + // Check if current folder is empty + $checkEmpty = instant_remote_process(["[ -d $escapedPath ] && [ -z \"$(ls -A $escapedPath)\" ] && echo 'empty' || echo 'not empty'"], $server, throwError: false); + + if (trim($checkEmpty) === 'empty') { + // Remove the empty folder + instant_remote_process(["rmdir $escapedPath"], $server, throwError: false); + + // Check if parent folder exists and is empty + $checkParentEmpty = instant_remote_process([ + "[ -d $escapedParentPath ] && [ -z \"$(ls -A $escapedParentPath)\" ] && echo 'empty' || echo 'not empty'", + ], $server, throwError: false); + + if (trim($checkParentEmpty) === 'empty') { + // Remove the empty parent folder + instant_remote_process(["rmdir $escapedParentPath"], $server, throwError: false); + } + } +} + +function deleteOldBackupsLocally($backup) +{ + if (! $backup || ! $backup->executions) { + return; + } + + $successfulBackups = $backup->executions() + ->where('status', 'success') + ->orderBy('created_at', 'desc') + ->get(); + + if ($successfulBackups->isEmpty()) { + return; + } + + // Get retention limits + $retentionAmount = $backup->database_backup_retention_amount_locally; + $retentionDays = $backup->database_backup_retention_days_locally; + + if ($retentionAmount === 0 && $retentionDays === 0) { + return; + } + + $backupsToDelete = collect(); + + // Process backups based on retention amount + if ($retentionAmount > 0) { + $backupsToDelete = $backupsToDelete->merge( + $successfulBackups->skip($retentionAmount) + ); + } + + // Process backups based on retention days + if ($retentionDays > 0) { + $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); + $oldBackups = $successfulBackups->filter(function ($execution) use ($oldestAllowedDate) { + return $execution->created_at->utc() < $oldestAllowedDate; + }); + $backupsToDelete = $backupsToDelete->merge($oldBackups); + } + + // Get unique backups to delete and chunk them for parallel processing + $backupsToDelete = $backupsToDelete->unique('id'); + + // Keep track of folders to check + $foldersToCheck = collect(); + + // Process deletions in parallel chunks + $backupsToDelete->chunk(10)->each(function ($chunk) use ($backup, &$foldersToCheck) { + $executionIds = []; + $filesToDelete = []; + + foreach ($chunk as $execution) { + if ($execution->filename) { + $filesToDelete[] = $execution->filename; + $executionIds[] = $execution->id; + // Add the folder path to check later + $foldersToCheck->push(dirname($execution->filename)); + } + } + + if (! empty($filesToDelete)) { + deleteBackupsLocally($filesToDelete, $backup->server); + + // Bulk delete executions from database + if (! empty($executionIds)) { + $backup->executions()->whereIn('id', $executionIds)->delete(); + } + } + }); + + // Check and clean up empty folders + $foldersToCheck->unique()->each(function ($folder) use ($backup) { + deleteEmptyBackupFolder($folder, $backup->server); + }); +} + +function deleteOldBackupsFromS3($backup) +{ + if (! $backup || ! $backup->executions || ! $backup->s3) { + return; + } + + $successfulBackups = $backup->executions() + ->where('status', 'success') + ->orderBy('created_at', 'desc') + ->get(); + + if ($successfulBackups->isEmpty()) { + return; + } + + // Get retention limits + $retentionAmount = $backup->database_backup_retention_amount_s3; + $retentionDays = $backup->database_backup_retention_days_s3; + $maxStorageGB = $backup->database_backup_retention_max_storage_s3; + + if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { + return; + } + + $backupsToDelete = collect(); + + // Process backups based on retention amount + if ($retentionAmount > 0) { + $backupsToDelete = $backupsToDelete->merge( + $successfulBackups->skip($retentionAmount) + ); + } + + // Process backups based on retention days + if ($retentionDays > 0) { + $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); + $oldBackups = $successfulBackups->filter(function ($execution) use ($oldestAllowedDate) { + return $execution->created_at->utc() < $oldestAllowedDate; + }); + $backupsToDelete = $backupsToDelete->merge($oldBackups); + } + + // Process backups based on total storage limit + if ($maxStorageGB > 0) { + $maxStorageBytes = $maxStorageGB * 1024 * 1024 * 1024; // Convert GB to bytes + $totalSize = 0; + $backupsOverLimit = collect(); + + foreach ($successfulBackups as $backup) { + $totalSize += (int) $backup->size; + + // If we're over the limit, add this and all older backups to delete list + if ($totalSize > $maxStorageBytes) { + $backupsOverLimit = $successfulBackups->filter(function ($b) use ($backup) { + return $b->created_at->utc() <= $backup->created_at->utc(); + }); + break; + } + } + + $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); + } + + // Get unique backups to delete and chunk them for parallel processing + $backupsToDelete = $backupsToDelete->unique('id'); + + // Keep track of folders to check + $foldersToCheck = collect(); + + // Process deletions in parallel chunks + $backupsToDelete->chunk(10)->each(function ($chunk) use ($backup, &$foldersToCheck) { + $executionIds = []; + $filesToDelete = []; + + foreach ($chunk as $execution) { + if ($execution->filename) { + $filesToDelete[] = $execution->filename; + $executionIds[] = $execution->id; + // Add the folder path to check later + $foldersToCheck->push(dirname($execution->filename)); + } + } + + if (! empty($filesToDelete)) { + deleteBackupsS3($filesToDelete, $backup->server, $backup->s3); + + // Update executions to mark S3 backup as deleted + if (! empty($executionIds)) { + $backup->executions() + ->whereIn('id', $executionIds) + ->update(['s3_backup_deleted_at' => now()]); + } + } + }); + + // Check and clean up empty folders + $foldersToCheck->unique()->each(function ($folder) use ($backup) { + deleteEmptyBackupFolder($folder, $backup->server); + }); } function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool From a8b77b389a9b69086acc2bf38110b0b6894d3620 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:38:16 +0100 Subject: [PATCH 04/13] feat: use new backup deletion functions --- app/Livewire/Project/Database/BackupEdit.php | 91 ++++++------------- .../Project/Database/BackupExecutions.php | 22 ++--- .../database/backup-executions.blade.php | 1 + 3 files changed, 40 insertions(+), 74 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 0dea0496c..77009002c 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -43,16 +43,28 @@ class BackupEdit extends Component #[Validate(['string'])] public string $timezone = ''; - #[Validate(['required', 'integer', 'min:1'])] - public int $numberOfBackupsLocally = 1; + #[Validate(['required', 'integer'])] + public int $databaseBackupRetentionAmountLocally = 0; + + #[Validate(['required', 'integer'])] + public ?int $databaseBackupRetentionDaysLocally = 0; + + #[Validate(['required', 'integer'])] + public ?int $databaseBackupRetentionAmountS3 = 0; + + #[Validate(['required', 'integer'])] + public ?int $databaseBackupRetentionDaysS3 = 0; + + #[Validate(['required', 'integer'])] + public ?int $databaseBackupRetentionMaxStorageS3 = 0; #[Validate(['required', 'boolean'])] public bool $saveS3 = false; - #[Validate(['nullable', 'integer'])] + #[Validate(['required', 'integer'])] public ?int $s3StorageId = 1; - #[Validate(['nullable', 'string'])] + #[Validate(['required', 'string'])] public ?string $databasesToBackup = null; #[Validate(['required', 'boolean'])] @@ -73,7 +85,11 @@ class BackupEdit extends Component if ($toModel) { $this->backup->enabled = $this->backupEnabled; $this->backup->frequency = $this->frequency; - $this->backup->number_of_backups_locally = $this->numberOfBackupsLocally; + $this->backup->database_backup_retention_amount_locally = $this->databaseBackupRetentionAmountLocally; + $this->backup->database_backup_retention_days_locally = $this->databaseBackupRetentionDaysLocally; + $this->backup->database_backup_retention_amount_s3 = $this->databaseBackupRetentionAmountS3; + $this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3; + $this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3; $this->backup->save_s3 = $this->saveS3; $this->backup->s3_storage_id = $this->s3StorageId; $this->backup->databases_to_backup = $this->databasesToBackup; @@ -84,7 +100,11 @@ class BackupEdit extends Component $this->backupEnabled = $this->backup->enabled; $this->frequency = $this->backup->frequency; $this->timezone = data_get($this->backup->server(), 'settings.server_timezone', 'Instance timezone'); - $this->numberOfBackupsLocally = $this->backup->number_of_backups_locally; + $this->databaseBackupRetentionAmountLocally = $this->backup->database_backup_retention_amount_locally; + $this->databaseBackupRetentionDaysLocally = $this->backup->database_backup_retention_days_locally; + $this->databaseBackupRetentionAmountS3 = $this->backup->database_backup_retention_amount_s3; + $this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3; + $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; $this->saveS3 = $this->backup->save_s3; $this->s3StorageId = $this->backup->s3_storage_id; $this->databasesToBackup = $this->backup->databases_to_backup; @@ -104,10 +124,10 @@ class BackupEdit extends Component try { if ($this->delete_associated_backups_locally) { - $this->deleteAssociatedBackupsLocally(); + deleteOldBackupsLocally($this->backup); } - if ($this->delete_associated_backups_s3) { - $this->deleteAssociatedBackupsS3(); + if ($this->delete_associated_backups_s3 && $this->backup->s3) { + deleteOldBackupsFromS3($this->backup); } $this->backup->delete(); @@ -160,63 +180,12 @@ class BackupEdit extends Component } } - private function deleteAssociatedBackupsLocally() - { - $executions = $this->backup->executions; - $backupFolder = null; - - foreach ($executions as $execution) { - if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { - $server = $this->backup->database->service->destination->server; - } else { - $server = $this->backup->database->destination->server; - } - - if (! $backupFolder) { - $backupFolder = dirname($execution->filename); - } - - delete_backup_locally($execution->filename, $server); - $execution->delete(); - } - - if (str($backupFolder)->isNotEmpty()) { - $this->deleteEmptyBackupFolder($backupFolder, $server); - } - } - - private function deleteAssociatedBackupsS3() - { - // Add function to delete backups from S3 - } - - private function deleteAssociatedBackupsSftp() - { - // Add function to delete backups from SFTP - } - - private function deleteEmptyBackupFolder($folderPath, $server) - { - $checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server); - - if (trim($checkEmpty) === 'empty') { - instant_remote_process(["rmdir '$folderPath'"], $server); - - $parentFolder = dirname($folderPath); - $checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server); - - if (trim($checkParentEmpty) === 'empty') { - instant_remote_process(["rmdir '$parentFolder'"], $server); - } - } - } - public function render() { return view('livewire.project.database.backup-edit', [ 'checkboxes' => [ ['id' => 'delete_associated_backups_locally', 'label' => __('database.delete_backups_locally')], - // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.'] + ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job for this database will be permanently deleted from the selected S3 Storage.'], // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] ], ]); diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index ce168a352..9ed608ed0 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -18,9 +18,9 @@ class BackupExecutions extends Component public $setDeletableBackup; - public $delete_backup_s3 = true; + public $delete_backup_s3 = false; - public $delete_backup_sftp = true; + public $delete_backup_sftp = false; public function getListeners() { @@ -57,18 +57,14 @@ class BackupExecutions extends Component return; } - if ($execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { - delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server); - } else { - delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server); - } + $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class + ? $execution->scheduledDatabaseBackup->database->service->destination->server + : $execution->scheduledDatabaseBackup->database->destination->server; - if ($this->delete_backup_s3) { - // Add logic to delete from S3 - } + deleteBackupsLocally($execution->filename, $server); - if ($this->delete_backup_sftp) { - // Add logic to delete from SFTP + if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { + deleteBackupsS3($execution->filename, $server, $execution->scheduledDatabaseBackup->s3); } $execution->delete(); @@ -143,7 +139,7 @@ class BackupExecutions extends Component return view('livewire.project.database.backup-executions', [ 'checkboxes' => [ ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], - ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], + // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], ], ]); } diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index ff2bc15b2..7f8350a3e 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -47,6 +47,7 @@ @endif From f0d15afbf29460fef783a2b4068b53b711259b76 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:43:23 +0100 Subject: [PATCH 05/13] chore: improve code --- bootstrap/helpers/databases.php | 133 +++++++------------------------- 1 file changed, 28 insertions(+), 105 deletions(-) diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5c0c944ce..d41ae5582 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,17 +17,12 @@ use Visus\Cuid2\Cuid2; function generate_database_name(string $type): string { - $cuid = new Cuid2; - - return $type.'-database-'.$cuid; + return $type.'-database-'.(new Cuid2); } function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { - $destination = StandaloneDocker::where('uuid', $destinationUuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail(); $database = new StandalonePostgresql; $database->name = generate_database_name('postgresql'); $database->image = $databaseImage; @@ -45,10 +40,7 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $ function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneRedis; $database->name = generate_database_name('redis'); $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -79,10 +71,7 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMongodb; $database->name = generate_database_name('mongodb'); $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -96,12 +85,10 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o return $database; } + function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMysql; $database->name = generate_database_name('mysql'); $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -116,12 +103,10 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth return $database; } + function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneMariadb; $database->name = generate_database_name('mariadb'); $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -129,7 +114,6 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); - if ($otherData) { $database->fill($otherData); } @@ -137,12 +121,10 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o return $database; } + function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneKeydb; $database->name = generate_database_name('keydb'); $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -159,10 +141,7 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneDragonfly; $database->name = generate_database_name('dragonfly'); $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -176,12 +155,10 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array return $database; } + function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse { - $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); - if (! $destination) { - throw new Exception('Destination not found'); - } + $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail(); $database = new StandaloneClickhouse; $database->name = generate_database_name('clickhouse'); $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); @@ -201,29 +178,22 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server): voi if (empty($filenames)) { return; } - if (is_string($filenames)) { $filenames = [$filenames]; } - - $quotedFiles = array_map(function ($file) { - return "\"$file\""; - }, $filenames); - + $quotedFiles = array_map(fn ($file) => "\"$file\"", $filenames); instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: false); } -function deleteBackupsS3(string|array|null $filenames, Server $server, S3Storage $s3): void +function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void { if (empty($filenames) || ! $s3) { return; } - if (is_string($filenames)) { $filenames = [$filenames]; } - // Initialize S3 client using Laravel's Storage facade $disk = Storage::build([ 'driver' => 's3', 'key' => $s3->key, @@ -232,38 +202,30 @@ function deleteBackupsS3(string|array|null $filenames, Server $server, S3Storage 'bucket' => $s3->bucket, 'endpoint' => $s3->endpoint, 'use_path_style_endpoint' => true, + 'bucket_endpoint' => $s3->isHetzner() || $s3->isDigitalOcean(), + 'aws_url' => $s3->awsUrl(), ]); - // Delete files in bulk $disk->delete($filenames); } function deleteEmptyBackupFolder($folderPath, Server $server): void { - // Properly escape the folder path for shell commands $escapedPath = escapeshellarg($folderPath); $escapedParentPath = escapeshellarg(dirname($folderPath)); - // Check if current folder is empty $checkEmpty = instant_remote_process(["[ -d $escapedPath ] && [ -z \"$(ls -A $escapedPath)\" ] && echo 'empty' || echo 'not empty'"], $server, throwError: false); if (trim($checkEmpty) === 'empty') { - // Remove the empty folder instant_remote_process(["rmdir $escapedPath"], $server, throwError: false); - - // Check if parent folder exists and is empty - $checkParentEmpty = instant_remote_process([ - "[ -d $escapedParentPath ] && [ -z \"$(ls -A $escapedParentPath)\" ] && echo 'empty' || echo 'not empty'", - ], $server, throwError: false); - + $checkParentEmpty = instant_remote_process(["[ -d $escapedParentPath ] && [ -z \"$(ls -A $escapedParentPath)\" ] && echo 'empty' || echo 'not empty'"], $server, throwError: false); if (trim($checkParentEmpty) === 'empty') { - // Remove the empty parent folder instant_remote_process(["rmdir $escapedParentPath"], $server, throwError: false); } } } -function deleteOldBackupsLocally($backup) +function deleteOldBackupsLocally($backup): void { if (! $backup || ! $backup->executions) { return; @@ -278,7 +240,6 @@ function deleteOldBackupsLocally($backup) return; } - // Get retention limits $retentionAmount = $backup->database_backup_retention_amount_locally; $retentionDays = $backup->database_backup_retention_days_locally; @@ -288,29 +249,19 @@ function deleteOldBackupsLocally($backup) $backupsToDelete = collect(); - // Process backups based on retention amount if ($retentionAmount > 0) { - $backupsToDelete = $backupsToDelete->merge( - $successfulBackups->skip($retentionAmount) - ); + $backupsToDelete = $backupsToDelete->merge($successfulBackups->skip($retentionAmount)); } - // Process backups based on retention days if ($retentionDays > 0) { $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); - $oldBackups = $successfulBackups->filter(function ($execution) use ($oldestAllowedDate) { - return $execution->created_at->utc() < $oldestAllowedDate; - }); + $oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate); $backupsToDelete = $backupsToDelete->merge($oldBackups); } - // Get unique backups to delete and chunk them for parallel processing $backupsToDelete = $backupsToDelete->unique('id'); - - // Keep track of folders to check $foldersToCheck = collect(); - // Process deletions in parallel chunks $backupsToDelete->chunk(10)->each(function ($chunk) use ($backup, &$foldersToCheck) { $executionIds = []; $filesToDelete = []; @@ -319,28 +270,22 @@ function deleteOldBackupsLocally($backup) if ($execution->filename) { $filesToDelete[] = $execution->filename; $executionIds[] = $execution->id; - // Add the folder path to check later $foldersToCheck->push(dirname($execution->filename)); } } if (! empty($filesToDelete)) { deleteBackupsLocally($filesToDelete, $backup->server); - - // Bulk delete executions from database if (! empty($executionIds)) { $backup->executions()->whereIn('id', $executionIds)->delete(); } } }); - // Check and clean up empty folders - $foldersToCheck->unique()->each(function ($folder) use ($backup) { - deleteEmptyBackupFolder($folder, $backup->server); - }); + $foldersToCheck->unique()->each(fn ($folder) => deleteEmptyBackupFolder($folder, $backup->server)); } -function deleteOldBackupsFromS3($backup) +function deleteOldBackupsFromS3($backup): void { if (! $backup || ! $backup->executions || ! $backup->s3) { return; @@ -355,7 +300,6 @@ function deleteOldBackupsFromS3($backup) return; } - // Get retention limits $retentionAmount = $backup->database_backup_retention_amount_s3; $retentionDays = $backup->database_backup_retention_days_s3; $maxStorageGB = $backup->database_backup_retention_max_storage_s3; @@ -366,36 +310,25 @@ function deleteOldBackupsFromS3($backup) $backupsToDelete = collect(); - // Process backups based on retention amount if ($retentionAmount > 0) { - $backupsToDelete = $backupsToDelete->merge( - $successfulBackups->skip($retentionAmount) - ); + $backupsToDelete = $backupsToDelete->merge($successfulBackups->skip($retentionAmount)); } - // Process backups based on retention days if ($retentionDays > 0) { $oldestAllowedDate = $successfulBackups->first()->created_at->clone()->utc()->subDays($retentionDays); - $oldBackups = $successfulBackups->filter(function ($execution) use ($oldestAllowedDate) { - return $execution->created_at->utc() < $oldestAllowedDate; - }); + $oldBackups = $successfulBackups->filter(fn ($execution) => $execution->created_at->utc() < $oldestAllowedDate); $backupsToDelete = $backupsToDelete->merge($oldBackups); } - // Process backups based on total storage limit if ($maxStorageGB > 0) { - $maxStorageBytes = $maxStorageGB * 1024 * 1024 * 1024; // Convert GB to bytes + $maxStorageBytes = $maxStorageGB * 1024 * 1024 * 1024; $totalSize = 0; $backupsOverLimit = collect(); foreach ($successfulBackups as $backup) { $totalSize += (int) $backup->size; - - // If we're over the limit, add this and all older backups to delete list if ($totalSize > $maxStorageBytes) { - $backupsOverLimit = $successfulBackups->filter(function ($b) use ($backup) { - return $b->created_at->utc() <= $backup->created_at->utc(); - }); + $backupsOverLimit = $successfulBackups->filter(fn ($b) => $b->created_at->utc() <= $backup->created_at->utc()); break; } } @@ -403,13 +336,9 @@ function deleteOldBackupsFromS3($backup) $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); } - // Get unique backups to delete and chunk them for parallel processing $backupsToDelete = $backupsToDelete->unique('id'); - - // Keep track of folders to check $foldersToCheck = collect(); - // Process deletions in parallel chunks $backupsToDelete->chunk(10)->each(function ($chunk) use ($backup, &$foldersToCheck) { $executionIds = []; $filesToDelete = []; @@ -418,15 +347,12 @@ function deleteOldBackupsFromS3($backup) if ($execution->filename) { $filesToDelete[] = $execution->filename; $executionIds[] = $execution->id; - // Add the folder path to check later $foldersToCheck->push(dirname($execution->filename)); } } if (! empty($filesToDelete)) { deleteBackupsS3($filesToDelete, $backup->server, $backup->s3); - - // Update executions to mark S3 backup as deleted if (! empty($executionIds)) { $backup->executions() ->whereIn('id', $executionIds) @@ -435,10 +361,7 @@ function deleteOldBackupsFromS3($backup) } }); - // Check and clean up empty folders - $foldersToCheck->unique()->each(function ($folder) use ($backup) { - deleteEmptyBackupFolder($folder, $backup->server); - }); + $foldersToCheck->unique()->each(fn ($folder) => deleteEmptyBackupFolder($folder, $backup->server)); } function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool From a5fadb053671f3c24ce366129899bbe2e901bab1 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:54:17 +0100 Subject: [PATCH 06/13] fix: function calls --- app/Livewire/Project/Database/BackupEdit.php | 6 ++++-- bootstrap/helpers/databases.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 77009002c..19b7987db 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -124,10 +124,12 @@ class BackupEdit extends Component try { if ($this->delete_associated_backups_locally) { - deleteOldBackupsLocally($this->backup); + $filenames = $this->backup->executions->pluck('filename')->filter()->all(); + deleteBackupsLocally($filenames, $this->backup->server); } if ($this->delete_associated_backups_s3 && $this->backup->s3) { - deleteOldBackupsFromS3($this->backup); + $filenames = $this->backup->executions->pluck('filename')->filter()->all(); + deleteBackupsS3($filenames, $this->backup->s3); } $this->backup->delete(); diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index d41ae5582..fbaae637a 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -352,7 +352,7 @@ function deleteOldBackupsFromS3($backup): void } if (! empty($filesToDelete)) { - deleteBackupsS3($filesToDelete, $backup->server, $backup->s3); + deleteBackupsS3($filesToDelete, $backup->s3); if (! empty($executionIds)) { $backup->executions() ->whereIn('id', $executionIds) From 71edab419258c157775f0f66baeceda6047faf25 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:54:22 +0100 Subject: [PATCH 07/13] fix: UI --- .../project/database/backup-edit.blade.php | 111 +++++++----------- 1 file changed, 45 insertions(+), 66 deletions(-) diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 1f3ee6913..4f3ebe8f8 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -5,17 +5,13 @@ Save @if (str($status)->startsWith('running')) - + @endif @if ($backup->database_id !== 0) - + ]" confirmationText="{{ $backup->database->name }}" confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below" shortConfirmationLabel="Database Name" /> @endif
@@ -23,91 +19,74 @@
@if ($backup->save_s3) -
- - - @foreach ($s3s as $s3) - - @endforeach - -
+
+ + + @foreach ($s3s as $s3) + + @endforeach + +
@endif

Settings

@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0) -
- -
- @if (!$backup->dump_all) - - @endif +
+ +
+ @if (!$backup->dump_all) + + @endif @elseif($backup->database_type === 'App\Models\StandaloneMongodb') - + @elseif($backup->database_type === 'App\Models\StandaloneMysql') -
- -
- @if (!$backup->dump_all) - - @endif +
+ +
+ @if (!$backup->dump_all) + + @endif @elseif($backup->database_type === 'App\Models\StandaloneMariadb') -
- -
- @if (!$backup->dump_all) - - @endif +
+ +
+ @if (!$backup->dump_all) + + @endif @endif
- +

Backup Retention Settings

-

- These settings control how long backups are kept. -

    -
  • Setting a value to 0 means unlimited retention.
  • -
  • The retention rules work independently and whichever limit is reached first will trigger a cleanup of the older backups.
  • -
-

+
    +
  • Setting a value to 0 means unlimited retention
  • +
  • The retention rules work independently - whichever limit is reached first will trigger cleanup
  • +
- +

Local Backup Retention

- - + +
@if ($backup->save_s3) -
-

S3 Storage Retention

-
- - - -
+
+

S3 Storage Retention

+
+ + +
+
@endif
From c03b629e85a409951a7a8d7b4a4653b6d2e27497 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:21:03 +0100 Subject: [PATCH 08/13] fix: deletion of single backup --- .../Project/Database/BackupExecutions.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 9ed608ed0..526239eea 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -61,15 +61,19 @@ class BackupExecutions extends Component ? $execution->scheduledDatabaseBackup->database->service->destination->server : $execution->scheduledDatabaseBackup->database->destination->server; - deleteBackupsLocally($execution->filename, $server); + try { + deleteBackupsLocally($execution->filename, $server); - if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { - deleteBackupsS3($execution->filename, $server, $execution->scheduledDatabaseBackup->s3); + if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { + deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + } + + $execution->delete(); + $this->dispatch('success', 'Backup deleted.'); + $this->refreshBackupExecutions(); + } catch (\Exception $e) { + $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); } - - $execution->delete(); - $this->dispatch('success', 'Backup deleted.'); - $this->refreshBackupExecutions(); } public function download_file($exeuctionId) From 3dfca4e4bd799f56c09f70bb9d63a8fb24b8e1c9 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:31:55 +0100 Subject: [PATCH 09/13] fix: backup job deletion - delete all backups from s3 and local --- app/Livewire/Project/Database/BackupEdit.php | 32 +++++++++++++++---- .../Project/Database/BackupExecutions.php | 8 +++-- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 19b7987db..853b918b8 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -123,13 +123,29 @@ class BackupEdit extends Component } try { - if ($this->delete_associated_backups_locally) { - $filenames = $this->backup->executions->pluck('filename')->filter()->all(); - deleteBackupsLocally($filenames, $this->backup->server); + $server = null; + if ($this->backup->database instanceof \App\Models\ServiceDatabase) { + $server = $this->backup->database->service->destination->server; + } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { + $server = $this->backup->database->destination->server; } - if ($this->delete_associated_backups_s3 && $this->backup->s3) { - $filenames = $this->backup->executions->pluck('filename')->filter()->all(); - deleteBackupsS3($filenames, $this->backup->s3); + + $filenames = $this->backup->executions() + ->whereNotNull('filename') + ->where('filename', '!=', '') + ->where('scheduled_database_backup_id', $this->backup->id) + ->pluck('filename') + ->filter() + ->all(); + + if (! empty($filenames)) { + if ($this->delete_associated_backups_locally && $server) { + deleteBackupsLocally($filenames, $server); + } + + if ($this->delete_associated_backups_s3 && $this->backup->s3) { + deleteBackupsS3($filenames, $this->backup->s3); + } } $this->backup->delete(); @@ -145,7 +161,9 @@ class BackupEdit extends Component } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Throwable $e) { + } catch (\Exception $e) { + $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); + return handleError($e, $this); } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 526239eea..7eef1a539 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -62,10 +62,12 @@ class BackupExecutions extends Component : $execution->scheduledDatabaseBackup->database->destination->server; try { - deleteBackupsLocally($execution->filename, $server); + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $server); - if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { - deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { + deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); + } } $execution->delete(); From 9eebeb924134ebe06f4d766f57f9fead18d7c14e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:39:22 +0100 Subject: [PATCH 10/13] fix: use new removeOldBackups function - use the new removeOldBackups function - only call removeOldBackups function when the backup is completed and also only if the backup is successful --- app/Jobs/DatabaseBackupJob.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index b9be4aaa6..577c1f11a 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -299,7 +299,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue throw new \Exception('Unsupported database type'); } $size = $this->calculate_size(); - $this->remove_old_backups(); if ($this->backup->save_s3) { $this->upload_to_s3(); } @@ -323,6 +322,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database)); } } + if ($this->backup_log && $this->backup_log->status === 'success') { + removeOldBackups($this->backup); + } } catch (\Throwable $e) { throw $e; } finally { @@ -457,14 +459,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); } - private function remove_old_backups(): void - { - deleteOldBackupsLocally($this->backup); - if ($this->backup->save_s3) { - deleteOldBackupsFromS3($this->backup); - } - } - private function upload_to_s3(): void { try { From 3347eb3a1a911903267f68c8feaae4c4340b4718 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:46:27 +0100 Subject: [PATCH 11/13] fix: retention functions and folder deletion for local backups - fix: Delete folder and parent folder if folders are empty when deleting local backups. - fix: Do not remove executions from DB until both S3 and local backups have been deleted and successfully processed otherwise backups will never be deleted from s3. - fix: Server ID could be null --- bootstrap/helpers/databases.php | 114 ++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index fbaae637a..90c18dc6d 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -12,6 +12,7 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Visus\Cuid2\Cuid2; @@ -183,6 +184,9 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server): voi } $quotedFiles = array_map(fn ($file) => "\"$file\"", $filenames); instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: false); + + $foldersToCheck = collect($filenames)->map(fn ($file) => dirname($file))->unique(); + $foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server)); } function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void @@ -225,10 +229,27 @@ function deleteEmptyBackupFolder($folderPath, Server $server): void } } -function deleteOldBackupsLocally($backup): void +function removeOldBackups($backup): void +{ + try { + $processedBackups = deleteOldBackupsLocally($backup); + + if ($backup->save_s3) { + $processedBackups = $processedBackups->merge(deleteOldBackupsFromS3($backup)); + } + + if ($processedBackups->isNotEmpty()) { + $backup->executions()->whereIn('id', $processedBackups->pluck('id'))->delete(); + } + } catch (\Exception $e) { + throw $e; + } +} + +function deleteOldBackupsLocally($backup): Collection { if (! $backup || ! $backup->executions) { - return; + return collect(); } $successfulBackups = $backup->executions() @@ -237,20 +258,21 @@ function deleteOldBackupsLocally($backup): void ->get(); if ($successfulBackups->isEmpty()) { - return; + return collect(); } $retentionAmount = $backup->database_backup_retention_amount_locally; $retentionDays = $backup->database_backup_retention_days_locally; if ($retentionAmount === 0 && $retentionDays === 0) { - return; + return collect(); } $backupsToDelete = collect(); if ($retentionAmount > 0) { - $backupsToDelete = $backupsToDelete->merge($successfulBackups->skip($retentionAmount)); + $byAmount = $successfulBackups->skip($retentionAmount); + $backupsToDelete = $backupsToDelete->merge($byAmount); } if ($retentionDays > 0) { @@ -260,35 +282,36 @@ function deleteOldBackupsLocally($backup): void } $backupsToDelete = $backupsToDelete->unique('id'); - $foldersToCheck = collect(); + $processedBackups = collect(); - $backupsToDelete->chunk(10)->each(function ($chunk) use ($backup, &$foldersToCheck) { - $executionIds = []; - $filesToDelete = []; + $server = null; + if ($backup->database_type === \App\Models\ServiceDatabase::class) { + $server = $backup->database->service->server; + } else { + $server = $backup->database->destination->server; + } - foreach ($chunk as $execution) { - if ($execution->filename) { - $filesToDelete[] = $execution->filename; - $executionIds[] = $execution->id; - $foldersToCheck->push(dirname($execution->filename)); - } - } + if (! $server) { + return collect(); + } - if (! empty($filesToDelete)) { - deleteBackupsLocally($filesToDelete, $backup->server); - if (! empty($executionIds)) { - $backup->executions()->whereIn('id', $executionIds)->delete(); - } - } - }); + $filesToDelete = $backupsToDelete + ->filter(fn ($execution) => ! empty($execution->filename)) + ->pluck('filename') + ->all(); - $foldersToCheck->unique()->each(fn ($folder) => deleteEmptyBackupFolder($folder, $backup->server)); + if (! empty($filesToDelete)) { + deleteBackupsLocally($filesToDelete, $server); + $processedBackups = $backupsToDelete; + } + + return $processedBackups; } -function deleteOldBackupsFromS3($backup): void +function deleteOldBackupsFromS3($backup): Collection { if (! $backup || ! $backup->executions || ! $backup->s3) { - return; + return collect(); } $successfulBackups = $backup->executions() @@ -297,7 +320,7 @@ function deleteOldBackupsFromS3($backup): void ->get(); if ($successfulBackups->isEmpty()) { - return; + return collect(); } $retentionAmount = $backup->database_backup_retention_amount_s3; @@ -305,13 +328,14 @@ function deleteOldBackupsFromS3($backup): void $maxStorageGB = $backup->database_backup_retention_max_storage_s3; if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { - return; + return collect(); } $backupsToDelete = collect(); if ($retentionAmount > 0) { - $backupsToDelete = $backupsToDelete->merge($successfulBackups->skip($retentionAmount)); + $byAmount = $successfulBackups->skip($retentionAmount); + $backupsToDelete = $backupsToDelete->merge($byAmount); } if ($retentionDays > 0) { @@ -337,31 +361,19 @@ function deleteOldBackupsFromS3($backup): void } $backupsToDelete = $backupsToDelete->unique('id'); - $foldersToCheck = collect(); + $processedBackups = collect(); - $backupsToDelete->chunk(10)->each(function ($chunk) use ($backup, &$foldersToCheck) { - $executionIds = []; - $filesToDelete = []; + $filesToDelete = $backupsToDelete + ->filter(fn ($execution) => ! empty($execution->filename)) + ->pluck('filename') + ->all(); - foreach ($chunk as $execution) { - if ($execution->filename) { - $filesToDelete[] = $execution->filename; - $executionIds[] = $execution->id; - $foldersToCheck->push(dirname($execution->filename)); - } - } + if (! empty($filesToDelete)) { + deleteBackupsS3($filesToDelete, $backup->s3); + $processedBackups = $backupsToDelete; + } - if (! empty($filesToDelete)) { - deleteBackupsS3($filesToDelete, $backup->s3); - if (! empty($executionIds)) { - $backup->executions() - ->whereIn('id', $executionIds) - ->update(['s3_backup_deleted_at' => now()]); - } - } - }); - - $foldersToCheck->unique()->each(fn ($folder) => deleteEmptyBackupFolder($folder, $backup->server)); + return $processedBackups; } function isPublicPortAlreadyUsed(Server $server, int $port, ?string $id = null): bool From e9f691bf4527d650ea3f500fa63fd8c2ae309516 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 19:49:12 +0100 Subject: [PATCH 12/13] fix: storage retention setting - feat: add storage retention to local storage as well - fix: UI input for max storage now allows exact decimals so MB input is now also possible - fix: Database column is now decimal instead of integer - fix: variable naming of storage check no longer overwrites $backup - renamed it to $backupExecution --- app/Livewire/Project/Database/BackupEdit.php | 9 +++-- bootstrap/helpers/databases.php | 35 ++++++++++++++++--- ...ds_to_scheduled_database_backups_table.php | 4 ++- .../project/database/backup-edit.blade.php | 15 +++++++- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 853b918b8..6ebbf951e 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -49,14 +49,17 @@ class BackupEdit extends Component #[Validate(['required', 'integer'])] public ?int $databaseBackupRetentionDaysLocally = 0; + #[Validate(['required', 'numeric', 'min:0'])] + public ?float $databaseBackupRetentionMaxStorageLocally = 0; + #[Validate(['required', 'integer'])] public ?int $databaseBackupRetentionAmountS3 = 0; #[Validate(['required', 'integer'])] public ?int $databaseBackupRetentionDaysS3 = 0; - #[Validate(['required', 'integer'])] - public ?int $databaseBackupRetentionMaxStorageS3 = 0; + #[Validate(['required', 'numeric', 'min:0'])] + public ?float $databaseBackupRetentionMaxStorageS3 = 0; #[Validate(['required', 'boolean'])] public bool $saveS3 = false; @@ -87,6 +90,7 @@ class BackupEdit extends Component $this->backup->frequency = $this->frequency; $this->backup->database_backup_retention_amount_locally = $this->databaseBackupRetentionAmountLocally; $this->backup->database_backup_retention_days_locally = $this->databaseBackupRetentionDaysLocally; + $this->backup->database_backup_retention_max_storage_locally = $this->databaseBackupRetentionMaxStorageLocally; $this->backup->database_backup_retention_amount_s3 = $this->databaseBackupRetentionAmountS3; $this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3; $this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3; @@ -102,6 +106,7 @@ class BackupEdit extends Component $this->timezone = data_get($this->backup->server(), 'settings.server_timezone', 'Instance timezone'); $this->databaseBackupRetentionAmountLocally = $this->backup->database_backup_retention_amount_locally; $this->databaseBackupRetentionDaysLocally = $this->backup->database_backup_retention_days_locally; + $this->databaseBackupRetentionMaxStorageLocally = $this->backup->database_backup_retention_max_storage_locally; $this->databaseBackupRetentionAmountS3 = $this->backup->database_backup_retention_amount_s3; $this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3; $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 90c18dc6d..6a834ee6f 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -263,8 +263,9 @@ function deleteOldBackupsLocally($backup): Collection $retentionAmount = $backup->database_backup_retention_amount_locally; $retentionDays = $backup->database_backup_retention_days_locally; + $maxStorageGB = $backup->database_backup_retention_max_storage_locally; - if ($retentionAmount === 0 && $retentionDays === 0) { + if ($retentionAmount === 0 && $retentionDays === 0 && $maxStorageGB === 0) { return collect(); } @@ -281,6 +282,26 @@ function deleteOldBackupsLocally($backup): Collection $backupsToDelete = $backupsToDelete->merge($oldBackups); } + if ($maxStorageGB > 0) { + $maxStorageBytes = $maxStorageGB * pow(1024, 3); + $totalSize = 0; + $backupsOverLimit = collect(); + + $backupsToCheck = $successfulBackups->skip(1); + + foreach ($backupsToCheck as $backupExecution) { + $totalSize += (int) $backupExecution->size; + if ($totalSize > $maxStorageBytes) { + $backupsOverLimit = $successfulBackups->filter( + fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc() + )->skip(1); + break; + } + } + + $backupsToDelete = $backupsToDelete->merge($backupsOverLimit); + } + $backupsToDelete = $backupsToDelete->unique('id'); $processedBackups = collect(); @@ -345,14 +366,18 @@ function deleteOldBackupsFromS3($backup): Collection } if ($maxStorageGB > 0) { - $maxStorageBytes = $maxStorageGB * 1024 * 1024 * 1024; + $maxStorageBytes = $maxStorageGB * pow(1024, 3); $totalSize = 0; $backupsOverLimit = collect(); - foreach ($successfulBackups as $backup) { - $totalSize += (int) $backup->size; + $backupsToCheck = $successfulBackups->skip(1); + + foreach ($backupsToCheck as $backupExecution) { + $totalSize += (int) $backupExecution->size; if ($totalSize > $maxStorageBytes) { - $backupsOverLimit = $successfulBackups->filter(fn ($b) => $b->created_at->utc() <= $backup->created_at->utc()); + $backupsOverLimit = $successfulBackups->filter( + fn ($b) => $b->created_at->utc() <= $backupExecution->created_at->utc() + )->skip(1); break; } } diff --git a/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php b/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php index f5371ee7a..f06bc367e 100644 --- a/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php +++ b/database/migrations/2025_01_13_130238_add_backup_retention_fields_to_scheduled_database_backups_table.php @@ -12,10 +12,11 @@ return new class extends Migration $table->renameColumn('number_of_backups_locally', 'database_backup_retention_amount_locally'); $table->integer('database_backup_retention_amount_locally')->default(0)->nullable(false)->change(); $table->integer('database_backup_retention_days_locally')->default(0)->nullable(false); + $table->decimal('database_backup_retention_max_storage_locally', 17, 7)->default(0)->nullable(false); $table->integer('database_backup_retention_amount_s3')->default(0)->nullable(false); $table->integer('database_backup_retention_days_s3')->default(0)->nullable(false); - $table->integer('database_backup_retention_max_storage_s3')->default(0)->nullable(false); + $table->decimal('database_backup_retention_max_storage_s3', 17, 7)->default(0)->nullable(false); }); } @@ -25,6 +26,7 @@ return new class extends Migration $table->renameColumn('database_backup_retention_amount_locally', 'number_of_backups_locally')->nullable(true)->change(); $table->dropColumn([ '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', diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 4f3ebe8f8..f1f604a7d 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -75,6 +75,13 @@
+
@@ -84,7 +91,13 @@
- +
@endif From 8709a7749bb932a2e6a8acf0d06b5c247f38763d Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:26:20 +0100 Subject: [PATCH 13/13] fix: db without s3 should still backup --- app/Livewire/Project/Database/BackupEdit.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 6ebbf951e..012c6bb24 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -64,10 +64,10 @@ class BackupEdit extends Component #[Validate(['required', 'boolean'])] public bool $saveS3 = false; - #[Validate(['required', 'integer'])] + #[Validate(['nullable', 'integer'])] public ?int $s3StorageId = 1; - #[Validate(['required', 'string'])] + #[Validate(['nullable', 'string'])] public ?string $databasesToBackup = null; #[Validate(['required', 'boolean'])]