From 857375f69cae1f5aa8cb016d116a1b56672c47c0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:00:46 +0200 Subject: [PATCH] feat(backups): add configurable CPU usage for volume compression Add server-level compression CPU presets for volume backups and close the private key modal after saving. --- app/Jobs/VolumeBackupJob.php | 17 ++++-- app/Livewire/Security/PrivateKey/Show.php | 3 ++ app/Livewire/Server/Advanced.php | 5 ++ app/Models/ServerSetting.php | 3 ++ ...pu_percentage_to_server_settings_table.php | 28 ++++++++++ .../views/livewire/server/advanced.blade.php | 12 +++++ .../Security/PrivateKeyDropdownTest.php | 16 ++++++ .../ServerBackupCompressionSettingTest.php | 52 +++++++++++++++++++ tests/Feature/VolumeBackupTest.php | 15 ++++-- 9 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 database/migrations/2026_08_14_074053_add_backup_compression_cpu_percentage_to_server_settings_table.php create mode 100644 tests/Feature/ServerBackupCompressionSettingTest.php diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 232f3aa0d..c1ddaedb8 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -77,13 +77,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $source = $this->backup->sourcePath(); $containerName = 'volume-backup-'.$this->execution->uuid; $image = coolifyHelperImage().':'.getHelperVersion(); - $this->logCompressorInDevelopment($image, $server); + $compressionCpuPercentage = $this->compressionCpuPercentage($server); + $this->logCompressorInDevelopment($image, $server, $compressionCpuPercentage); $verifySourceCommand = $target instanceof LocalPersistentVolume && blank($target->host_path) ? 'docker volume inspect '.escapeshellarg($source).' >/dev/null' : 'test -d '.escapeshellarg($source); $archiveScript = "compressor='gzip -3'; " - .'if command -v pigz >/dev/null 2>&1; then compressor="pigz -3 -p $(( ($(nproc) + 1) / 2 ))"; fi; ' + ."if command -v pigz >/dev/null 2>&1; then compressor=\"pigz -3 -p \$(( (\$(nproc) * {$compressionCpuPercentage} + 99) / 100 ))\"; fi; " .'tar -I "$compressor" -cf - -C /volume .'; $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) .' -v '.escapeshellarg($source.':/volume:ro') @@ -337,13 +338,13 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - private function logCompressorInDevelopment(string $image, Server $server): void + private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void { if (! isDev()) { return; } - $script = "if command -v pigz >/dev/null 2>&1; then printf 'pigz -3 -p %s' \"$(( ($(nproc) + 1) / 2 ))\"; else printf 'gzip -3'; fi"; + $script = "if command -v pigz >/dev/null 2>&1; then printf 'pigz -3 -p %s' \"\$(( (\$(nproc) * {$compressionCpuPercentage} + 99) / 100 ))\"; else printf 'gzip -3'; fi"; $compressor = instant_remote_process( ['docker run --rm '.escapeshellarg($image).' sh -c '.escapeshellarg($script)], $server, @@ -356,9 +357,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue 'execution_id' => $this->execution?->id, 'compressor' => $compressor, 'helper_image' => $image, + 'cpu_percentage' => $compressionCpuPercentage, ]); } + private function compressionCpuPercentage(Server $server): int + { + $percentage = (int) ($server->settings->backup_compression_cpu_percentage ?? 25); + + return in_array($percentage, [25, 50, 75, 100], true) ? $percentage : 25; + } + private function removeExpiredBackups(Server $server): void { if ($this->hasRetentionLimits( diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 181457047..7fa230003 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -151,6 +151,9 @@ class Show extends Component refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); $this->dispatch('securityResourceChanged'); + if ($this->modalMode) { + $this->dispatch('close-modal'); + } } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index b39da5e5a..a94881b12 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -27,6 +27,9 @@ class Advanced extends Component #[Validate(['required', 'integer', 'min:1'])] public int|string $deploymentQueueLimit = 25; + #[Validate(['required', 'integer', 'in:25,50,75,100'])] + public int|string $backupCompressionCpuPercentage = 25; + public function mount(string $server_uuid) { try { @@ -47,6 +50,7 @@ class Advanced extends Component $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; $this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit; + $this->server->settings->backup_compression_cpu_percentage = $this->backupCompressionCpuPercentage; $this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold; $this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency; $this->server->settings->save(); @@ -54,6 +58,7 @@ class Advanced extends Component $this->concurrentBuilds = $this->server->settings->concurrent_builds; $this->dynamicTimeout = $this->server->settings->dynamic_timeout; $this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit; + $this->backupCompressionCpuPercentage = $this->server->settings->backup_compression_cpu_percentage; $this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold; $this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 15ad3a95c..c3fa8721c 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -15,6 +15,7 @@ use OpenApi\Attributes as OA; 'id' => ['type' => 'integer'], 'concurrent_builds' => ['type' => 'integer'], 'deployment_queue_limit' => ['type' => 'integer'], + 'backup_compression_cpu_percentage' => ['type' => 'integer'], 'dynamic_timeout' => ['type' => 'integer'], 'force_disabled' => ['type' => 'boolean'], 'force_server_cleanup' => ['type' => 'boolean'], @@ -102,6 +103,7 @@ class ServerSetting extends Model 'server_disk_usage_check_frequency', 'is_terminal_enabled', 'deployment_queue_limit', + 'backup_compression_cpu_percentage', 'disable_application_image_retention', 'connection_timeout', 'docker_version', @@ -123,6 +125,7 @@ class ServerSetting extends Model 'connection_timeout' => 'integer', 'docker_version_checked_at' => 'datetime', 'compose_version_checked_at' => 'datetime', + 'backup_compression_cpu_percentage' => 'integer', ]; /** diff --git a/database/migrations/2026_08_14_074053_add_backup_compression_cpu_percentage_to_server_settings_table.php b/database/migrations/2026_08_14_074053_add_backup_compression_cpu_percentage_to_server_settings_table.php new file mode 100644 index 000000000..6df72807b --- /dev/null +++ b/database/migrations/2026_08_14_074053_add_backup_compression_cpu_percentage_to_server_settings_table.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('backup_compression_cpu_percentage')->default(25); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('backup_compression_cpu_percentage'); + }); + } +}; diff --git a/resources/views/livewire/server/advanced.blade.php b/resources/views/livewire/server/advanced.blade.php index 519029bbd..4c2845f93 100644 --- a/resources/views/livewire/server/advanced.blade.php +++ b/resources/views/livewire/server/advanced.blade.php @@ -25,6 +25,18 @@ + + + +
diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php index 9c19c7792..5724af568 100644 --- a/tests/Feature/Security/PrivateKeyDropdownTest.php +++ b/tests/Feature/Security/PrivateKeyDropdownTest.php @@ -60,6 +60,22 @@ test('generating a private key from the index stores it without redirecting to a ->assertNoRedirect(); }); +test('saving a private key from its modal editor closes the modal', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Livewire::test(Show::class, [ + 'private_key_uuid' => $privateKey->uuid, + 'modalMode' => true, + ])->set('name', 'Updated SSH key') + ->call('changePrivateKey') + ->assertDispatched('securityResourceChanged') + ->assertDispatched('close-modal'); + + expect($privateKey->fresh()->name)->toBe('Updated SSH key'); +}); + test('manual private key form does not expose key generation controls', function () { Livewire::test(Create::class) ->assertDontSee('Generate new ED25519 SSH Key') diff --git a/tests/Feature/ServerBackupCompressionSettingTest.php b/tests/Feature/ServerBackupCompressionSettingTest.php new file mode 100644 index 000000000..4db4e73ac --- /dev/null +++ b/tests/Feature/ServerBackupCompressionSettingTest.php @@ -0,0 +1,52 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user, ['role' => 'owner']); + $server = Server::factory()->create(['team_id' => $team->id]); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + expect($server->settings->backup_compression_cpu_percentage)->toBe(25); + + Livewire::test(Advanced::class, ['server_uuid' => $server->uuid]) + ->assertSet('backupCompressionCpuPercentage', 25) + ->assertSee('Backup compression CPU') + ->assertSee('Sets how many CPU threads can be used to compress volume backups') + ->assertDontSee('gzip fallback') + ->assertSee('Low (25%)') + ->set('backupCompressionCpuPercentage', 75) + ->call('instantSave') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect($server->settings->fresh()->backup_compression_cpu_percentage)->toBe(75); +}); + +it('rejects unsupported server backup compression CPU percentages', function () { + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user, ['role' => 'owner']); + $server = Server::factory()->create(['team_id' => $team->id]); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test(Advanced::class, ['server_uuid' => $server->uuid]) + ->set('backupCompressionCpuPercentage', 30) + ->call('instantSave') + ->assertHasErrors(['backupCompressionCpuPercentage']); + + expect($server->settings->fresh()->backup_compression_cpu_percentage)->toBe(25); +}); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index bcd0e1ff6..88e679a7c 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -1570,11 +1570,12 @@ it('marks a running execution failed even when the job instance lost its executi && str_contains($process->command, 'timed-out.tar.gz')); }); -it('archives a named volume on its server', function () { +it('archives a named volume using the server compression CPU percentage', function (int $compressionCpuPercentage) { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); $team = Team::factory()->create(); - [$application, $volume] = createVolumeBackupApplication($team); + [$application, $volume, $server] = createVolumeBackupApplication($team); + $server->settings->update(['backup_compression_cpu_percentage' => $compressionCpuPercentage]); $backup = $volume->scheduledBackups()->create([ 'team_id' => $team->id, 'frequency' => 'daily', @@ -1608,13 +1609,16 @@ it('archives a named volume on its server', function () { && str_contains($process->command, 'app-data:/volume:ro') && str_contains($process->command, 'command -v pigz') && str_contains($process->command, 'pigz -3 -p') - && str_contains($process->command, '$(nproc) + 1') + && str_contains($process->command, "\$(nproc) * {$compressionCpuPercentage} + 99") && str_contains($process->command, 'gzip -3') && str_contains($process->command, 'tar -I "$compressor" -cf -') && str_contains($process->command, '> ') && str_contains($process->command, '.tar.gz') && ! str_contains($process->command, ':/backup')); -}); +})->with([ + 'low' => 25, + 'high' => 75, +]); it('logs the selected volume backup compressor in development', function (string $detectedCompressor) { config(['app.env' => 'local', 'broadcasting.default' => 'null']); @@ -1644,7 +1648,8 @@ it('logs the selected volume backup compressor in development', function (string Log::shouldHaveReceived('info')->once()->with( 'Volume backup compressor selected', Mockery::on(fn (array $context): bool => $context['compressor'] === $detectedCompressor - && $context['backup_id'] === $backup->id), + && $context['backup_id'] === $backup->id + && $context['cpu_percentage'] === 25), ); })->with([ 'pigz' => 'pigz -3 -p 4',