diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 3c60c34379..ddfc6578e4 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -2,7 +2,9 @@ namespace App\Actions\Server; +use App\Jobs\DatabaseBackupJob; use App\Models\Server; +use App\Models\StandalonePostgresql; use App\Services\CoolifyUpgradeStatus; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -22,6 +24,7 @@ class UpdateCoolify public function handle($manual_update = false) { if (isDev()) { + $this->backupDatabase(); Sleep::for(10)->seconds(); return; @@ -110,11 +113,28 @@ class UpdateCoolify ); } + $this->backupDatabase(); $this->update(); $settings->new_version_available = false; $settings->save(); } + private function backupDatabase(): void + { + $backup = StandalonePostgresql::find(0)?->scheduledBackups()->first(); + if (! $backup || ! instanceSettings()->is_backup_before_update_enabled) { + return; + } + + // Runs inline so the update waits for the result. dispatchSync() would go through the job's + // WithoutOverlapping middleware, which silently drops the run while the scheduled backup is running. + $job = new DatabaseBackupJob($backup); + $job->handle(); + if ($job->backup_log?->status !== 'success') { + throw new \Exception('The database backup failed, so the update was not started.'); + } + } + private function update() { $this->ensureNoUpgradeIsRunning(); diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index a7e8b89488..0f92b2ad34 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -46,6 +46,8 @@ class SettingsBackup extends Component #[Validate(['required'])] public string $postgres_password; + public bool $is_backup_before_update_enabled; + public function mount() { if (! isInstanceAdmin()) { @@ -75,6 +77,7 @@ class SettingsBackup extends Component $this->executions = $this->backup?->executions ?? []; } $this->settings = $settings; + $this->is_backup_before_update_enabled = $settings->is_backup_before_update_enabled; $this->s3s = $s3s; } @@ -136,6 +139,27 @@ class SettingsBackup extends Component } } + public function instantSave() + { + try { + $this->authorize('update', $this->settings); + $this->settings->is_backup_before_update_enabled = $this->is_backup_before_update_enabled; + $changedFields = auditChangedFields($this->settings); + $this->settings->save(); + if ($changedFields !== []) { + auditLog('ui.instance.settings.updated', [ + 'team_id' => null, + 'resource' => 'instance', + 'section' => 'backup', + 'changed_fields' => $changedFields, + ]); + } + $this->dispatch('success', 'Backup updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function submit() { try { diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 16a1d7ea30..87a7356920 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -21,6 +21,7 @@ class InstanceSettings extends Model 'public_port_max', 'do_not_track', 'is_auto_update_enabled', + 'is_backup_before_update_enabled', 'is_registration_enabled', 'disable_registration_when_oauth_enabled', 'next_channel', @@ -90,6 +91,7 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_backup_before_update_enabled' => 'boolean', 'is_registration_enabled' => 'boolean', 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', diff --git a/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php b/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..ff6cc2509e --- /dev/null +++ b/database/migrations/2026_10_12_002632_add_is_backup_before_update_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_backup_before_update_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_backup_before_update_enabled'); + }); + } +}; diff --git a/resources/views/livewire/settings-backup.blade.php b/resources/views/livewire/settings-backup.blade.php index c89bf148cf..05673e9816 100644 --- a/resources/views/livewire/settings-backup.blade.php +++ b/resources/views/livewire/settings-backup.blade.php @@ -8,7 +8,8 @@ @if ($server->isFunctional()) @if (isset($database) && isset($backup))
- + {{-- Exclude is_backup_before_update_enabled (instantSave) so the bar does not flash. --}} +
@@ -26,6 +27,18 @@ + +
+ +
+
+ @else diff --git a/tests/Feature/SettingsBackupBeforeUpdateTest.php b/tests/Feature/SettingsBackupBeforeUpdateTest.php new file mode 100644 index 0000000000..211e37ffd5 --- /dev/null +++ b/tests/Feature/SettingsBackupBeforeUpdateTest.php @@ -0,0 +1,47 @@ +create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => 0, 'ip' => '127.0.0.1']); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->user = User::factory()->create(); + $rootTeam->members()->attach($this->user->id, ['role' => 'admin']); + $this->actingAs($this->user); + session(['currentTeam' => ['id' => 0]]); +}); + +test('instance admin can turn the backup before update off', function () { + Livewire::test(SettingsBackup::class) + ->assertSet('is_backup_before_update_enabled', true) + ->set('is_backup_before_update_enabled', false) + ->call('instantSave') + ->assertDispatched('success'); + + expect(InstanceSettings::find(0)->is_backup_before_update_enabled)->toBeFalse(); +}); + +test('team member cannot change the backup before update', function () { + $component = Livewire::test(SettingsBackup::class); + Team::find(0)->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + $this->user->refresh(); + + $component->set('is_backup_before_update_enabled', false) + ->call('instantSave') + ->assertDispatched('error', 'This action is unauthorized.'); + + expect(InstanceSettings::find(0)->is_backup_before_update_enabled)->toBeTrue(); +}); diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php index ddc8c2546b..087fc03514 100644 --- a/tests/Unit/UpdateCoolifyTest.php +++ b/tests/Unit/UpdateCoolifyTest.php @@ -3,7 +3,11 @@ use App\Actions\Server\UpdateCoolify; use App\Livewire\Settings\Updates; use App\Models\InstanceSettings; +use App\Models\PrivateKey; +use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use App\Support\RemoteProcessCommand; @@ -11,8 +15,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Once; use Livewire\Livewire; use Spatie\Activitylog\Models\Activity; @@ -53,6 +59,40 @@ function updateCoolifyTestActionWithStatus(string $status): UpdateCoolify }; } +function updateCoolifyTestCreateInstanceBackup(): void +{ + Storage::fake('ssh-keys'); + Notification::fake(); + Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]); + Server::flushIdentityMap(); + StandalonePostgresql::forceCreate([ + 'id' => 0, + 'name' => 'coolify-db', + 'postgres_password' => 'password', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => 0, + ]); + ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'database_id' => 0, + 'database_type' => StandalonePostgresql::class, + 'team_id' => 0, + ]); +} + +function updateCoolifyTestPrepareUpdate(array $settings = []): void +{ + Queue::fake(); + config([ + 'app.env' => 'testing', + 'constants.coolify.version' => '4.0.9', + 'constants.ssh.mux_enabled' => false, + ]); + Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.0.10']]], 200)]); + updateCoolifyTestCreateRootServerAndSettings($settings); + updateCoolifyTestCreateInstanceBackup(); +} + afterEach(function () { Mockery::close(); }); @@ -363,3 +403,33 @@ it('starts an upgrade when the previous upgrade status is older than the lock ex expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first())) ->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'"); }); + +it('backs up the instance database before the update', function () { + updateCoolifyTestPrepareUpdate(); + Process::fake(['*du -b*' => '128', '*' => '']); + + (new UpdateCoolify)->handle(manual_update: true); + + Process::assertRan(fn ($process) => str_contains($process->command, 'pg_dump')); + expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first())) + ->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'"); +}); + +it('skips the backup before the update when it is disabled', function () { + updateCoolifyTestPrepareUpdate(['is_backup_before_update_enabled' => false]); + Process::fake(); + + (new UpdateCoolify)->handle(manual_update: true); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'pg_dump')); + expect(Activity::query()->count())->toBe(1); +}); + +it('does not start the update when the instance backup fails', function () { + updateCoolifyTestPrepareUpdate(); + Process::fake(['*pg_dump*' => Process::result(exitCode: 1), '*' => '']); + + expect(fn () => (new UpdateCoolify)->handle(manual_update: true)) + ->toThrow(Exception::class, 'The database backup failed, so the update was not started.') + ->and(Activity::query()->count())->toBe(0); +});