diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 45a69ed76c..c7228760bf 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -36,6 +36,7 @@ class Patches extends Component public string $patchCheckFrequency = ''; + #[Locked] public bool $autoSecurityUpdates = false; public string $autoSecurityUpdatesTime = '03:00'; @@ -150,33 +151,15 @@ class Patches extends Component throw new \Exception('Invalid time. Use the 24-hour format HH:MM, for example 03:00.'); } - $wasEnabled = (bool) $settings->is_auto_security_updates_enabled; - $autoUpdatesChanged = $this->autoSecurityUpdates !== $wasEnabled - || ($this->autoSecurityUpdates && $time !== $settings->auto_security_updates_time); - if ($autoUpdatesChanged) { - $this->ensureManagedHere(); - $packageManager = data_get($settings->patch_check_result, 'package_manager'); - if (! ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) { - throw new \Exception('Automatic security updates are supported only on Debian, Ubuntu, and RHEL-family servers. Run "Check for updates" first, so Coolify knows the package manager.'); - } - $auditContext = ['enabled' => $this->autoSecurityUpdates, 'time' => $time, 'package_manager' => $packageManager]; - try { - ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time); - } catch (\Throwable $e) { - $this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'failed', 'error' => $e->getMessage()], 'error'); - - throw $e; - } + if ($settings->is_auto_security_updates_enabled && $time !== $settings->auto_security_updates_time) { + $this->applyAutomaticSecurityUpdates(true, $time); } $settings->patch_check_frequency = $frequency === '' ? null : $frequency; - $settings->is_auto_security_updates_enabled = $this->autoSecurityUpdates; $settings->auto_security_updates_time = $time; $changedFields = auditChangedFields($settings); $settings->save(); - if ($autoUpdatesChanged) { - $this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'success']); - } elseif ($changedFields !== []) { + if ($changedFields !== []) { $this->auditPatchAction('patch_settings_updated', ['changed_fields' => $changedFields]); } $this->syncSettings(); @@ -189,6 +172,49 @@ class Patches extends Component } } + /** + * Turns automatic security updates on or off after the user confirmed it. + */ + public function toggleAutomaticSecurityUpdates() + { + try { + $this->authorize('update', $this->server); + $settings = $this->server->settings; + $enabled = ! $settings->is_auto_security_updates_enabled; + $time = $settings->auto_security_updates_time ?: '03:00'; + + $this->applyAutomaticSecurityUpdates($enabled, $time); + $settings->is_auto_security_updates_enabled = $enabled; + $settings->save(); + $this->syncSettings(); + $this->dispatch('success', $enabled ? 'Automatic security updates turned on.' : 'Automatic security updates turned off.'); + } catch (\Throwable $e) { + $this->server->settings->refresh(); + $this->syncSettings(); + + return handleError($e, $this); + } + } + + private function applyAutomaticSecurityUpdates(bool $enabled, string $time): void + { + $this->ensureManagedHere(); + $packageManager = data_get($this->server->settings->patch_check_result, 'package_manager'); + if (! ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) { + throw new \Exception('Automatic security updates are supported only on Debian, Ubuntu, and RHEL-family servers. Run "Check for updates" first, so Coolify knows the package manager.'); + } + + $auditContext = ['enabled' => $enabled, 'time' => $time, 'package_manager' => $packageManager]; + try { + ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $enabled, $time); + } catch (\Throwable $e) { + $this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'failed', 'error' => $e->getMessage()], 'error'); + + throw $e; + } + $this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'success']); + } + /** * A valid cron expression or preset that runs at most once per hour, because every check runs the package manager. */ diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 4fadec9a8a..afdd91c541 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -99,13 +99,44 @@ label="Check frequency" placeholder="weekly (default, on Sunday)" helper="Cron expression or preset such as daily, weekly, or monthly. Leave empty for the default: weekly, at a fixed time on Sunday. Coolify notifies your team when updates are available and when a check starts to fail." /> @if ($autoUpdatesSupported) - - + + Automatic security updates + + + + + @can('update', $server) + + @if ($autoSecurityUpdates) + + @else + + @endif + + @endcan + + + @endif diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php index db1b6d18c2..e8e69287ec 100644 --- a/tests/Feature/Server/ServerPatchesTest.php +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -347,36 +347,55 @@ describe('automatic security updates', function () { $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(0), 'patch_checked_at' => now()])->saveQuietly(); }); - it('configures the server and saves the setting', function () { + it('turns automatic security updates on and off with the saved time', function () { + $this->server->settings->forceFill(['auto_security_updates_time' => '04:30'])->saveQuietly(); ConfigureAutomaticSecurityUpdates::shouldRun()->once() ->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', true, '04:30'); + ConfigureAutomaticSecurityUpdates::shouldRun()->once() + ->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', false, '04:30'); - Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) - ->set('autoSecurityUpdates', true) - ->set('autoSecurityUpdatesTime', '04:30') - ->call('submit') - ->assertDispatched('success'); + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Turn on automatic security updates?') + ->assertSee('all containers on this server stop and start again') + ->call('toggleAutomaticSecurityUpdates') + ->assertDispatched('success') + ->assertSet('autoSecurityUpdates', true); + expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeTrue(); - expect($this->server->settings->fresh()) - ->is_auto_security_updates_enabled->toBeTrue() - ->auto_security_updates_time->toBe('04:30'); + $component->call('toggleAutomaticSecurityUpdates')->assertSet('autoSecurityUpdates', false); + expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeFalse(); }); - it('does not run the server commands when only the frequency changes', function () { + it('does not let the client turn on automatic updates without the confirmed action', function () { ConfigureAutomaticSecurityUpdates::shouldRun()->never(); + expect(fn () => Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->set('autoSecurityUpdates', true)) + ->toThrow(\Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException::class); + }); + + it('applies a new maintenance window only when automatic updates are on', function () { + ConfigureAutomaticSecurityUpdates::shouldRun()->once() + ->with(Mockery::on(fn ($server) => $server->is($this->server)), 'apt', true, '05:00'); + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) - ->set('patchCheckFrequency', 'daily') + ->set('autoSecurityUpdatesTime', '04:00') ->call('submit') ->assertDispatched('success'); + expect($this->server->settings->fresh()->auto_security_updates_time)->toBe('04:00'); + + $this->server->settings->forceFill(['is_auto_security_updates_enabled' => true])->saveQuietly(); + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('autoSecurityUpdatesTime', '05:00') + ->call('submit') + ->assertDispatched('success'); + expect($this->server->settings->fresh()->auto_security_updates_time)->toBe('05:00'); }); it('keeps the setting off when the server commands fail', function () { ConfigureAutomaticSecurityUpdates::shouldRun()->once()->andThrow(new RuntimeException('apt-get failed')); Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) - ->set('autoSecurityUpdates', true) - ->call('submit') + ->call('toggleAutomaticSecurityUpdates') ->assertDispatched('error') ->assertSet('autoSecurityUpdates', false); @@ -389,8 +408,8 @@ describe('automatic security updates', function () { Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) ->assertSee('this server uses pacman') - ->set('autoSecurityUpdates', true) - ->call('submit') + ->assertDontSee('Turn on automatic security updates?') + ->call('toggleAutomaticSecurityUpdates') ->assertDispatched('error'); }); @@ -398,11 +417,23 @@ describe('automatic security updates', function () { ConfigureAutomaticSecurityUpdates::shouldRun()->never(); Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) - ->set('autoSecurityUpdates', true) ->set('autoSecurityUpdatesTime', '25:00') ->call('submit') ->assertDispatched('error'); }); + + it('does not let a member turn on automatic updates', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'admin']); + $this->actingAs($member); + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]); + $member->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $member->unsetRelation('teams'); + ConfigureAutomaticSecurityUpdates::shouldRun()->never(); + + $component->call('toggleAutomaticSecurityUpdates')->assertDispatched('error'); + expect($this->server->settings->fresh()->is_auto_security_updates_enabled)->toBeFalse(); + }); }); describe('audit trail', function () { @@ -464,8 +495,7 @@ describe('audit trail', function () { ConfigureAutomaticSecurityUpdates::shouldRun()->once(); Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) - ->set('autoSecurityUpdates', true) - ->call('submit'); + ->call('toggleAutomaticSecurityUpdates'); $event = AuditEvent::query()->where('event', 'ui.server.auto_security_updates_updated')->sole(); expect($event->actor_email)->toBe($this->admin->email)