diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 2d4cbdef29..a50af94ddb 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -146,12 +146,12 @@ class CheckUpdates ]; } } catch (\Throwable $e) { + Log::warning('Server patch check failed', ['server_uuid' => $server->uuid, 'error' => $e->getMessage()]); return [ 'osId' => $osId, 'package_manager' => $packageManager, 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), ]; } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index c87513502a..7b6b6116d3 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -54,7 +54,10 @@ class UpdatePackage ]; } - return remote_process($commands, $server); + return remote_process($commands, $server, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [ + 'server_uuid' => $server->uuid, + 'packages' => $all ? [] : [$package], + ]); } catch (\Exception $e) { return [ 'error' => $e->getMessage(), diff --git a/app/Events/ServerPackageUpdated.php b/app/Events/ServerPackageUpdated.php deleted file mode 100644 index 88c8c52bb7..0000000000 --- a/app/Events/ServerPackageUpdated.php +++ /dev/null @@ -1,14 +0,0 @@ -} $data + */ + public function __construct(public readonly array $data) {} +} diff --git a/app/Events/ServerPatchCheckFinished.php b/app/Events/ServerPatchCheckFinished.php new file mode 100644 index 0000000000..c9d8cb269e --- /dev/null +++ b/app/Events/ServerPatchCheckFinished.php @@ -0,0 +1,32 @@ +teamId = $teamId; + } + + public function broadcastAs(): string + { + return 'ServerPatchCheckFinished'; + } + + /** + * @return array{serverUuid: string|null} + */ + public function broadcastWith(): array + { + return ['serverUuid' => $this->serverUuid]; + } +} diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index ac1c551982..e1ab5f94ee 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -205,7 +205,7 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue } } - // Dispatch ServerPatchCheckJob if due (weekly, staggered per server on Sunday). + // Dispatch ServerPatchCheckJob if due (by default weekly, staggered per server on Sunday). // The "-v2" keys start fresh: v4.3.23 stored its old run times under the unversioned keys, which // would make every server run these checks at once on the first run after the upgrade. $shouldRunPatchCheck = shouldRunCronNow(self::patchCheckCron($server), $serverTimezone, "server-patch-check-v2:{$server->id}", $this->executionTime); @@ -229,12 +229,16 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue } /** - * Weekly patch check at a stable per-server time on Sunday between 04:00 and 23:59. - * - * The early hours are skipped because most daylight saving transitions happen there. + * The patch check frequency of the server. The default is weekly, at a stable per-server time on Sunday + * between 04:00 and 23:59. The early hours are skipped because most daylight saving transitions happen there. */ public static function patchCheckCron(Server $server): string { + $frequency = data_get($server->settings, 'patch_check_frequency'); + if (filled($frequency)) { + return VALID_CRON_STRINGS[$frequency] ?? $frequency; + } + $slot = $server->id % (20 * 60); return sprintf('%d %d * * 0', $slot % 60, 4 + intdiv($slot, 60)); diff --git a/app/Jobs/ServerPatchCheckJob.php b/app/Jobs/ServerPatchCheckJob.php index a383a962a3..f35881efa9 100644 --- a/app/Jobs/ServerPatchCheckJob.php +++ b/app/Jobs/ServerPatchCheckJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Actions\Server\CheckUpdates; +use App\Events\ServerPatchCheckFinished; use App\Models\Server; use App\Notifications\Server\ServerPatchCheck; use Illuminate\Bus\Queueable; @@ -12,6 +13,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue @@ -20,51 +23,93 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 600; + public $tries = 1; + + /** + * @param bool $manual A check that a user started from the patches page. It saves the result also for a + * server that is not functional, and it sends no "updates available" notification. + */ + public function __construct(public Server $server, public bool $manual = false) + { + $this->onQueue(maintenance_queue()); + } + + public static function runningCacheKey(Server $server): string + { + return "server-patch-check-running:{$server->uuid}"; + } + + /** + * Queues a manual check and marks it as running, so the patches page shows the progress after a reload. + */ + public static function dispatchManual(Server $server): void + { + Cache::put(self::runningCacheKey($server), true, now()->addMinutes(15)); + self::dispatch($server, true); + } + public function middleware(): array { return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->expireAfter(600)->dontRelease()]; } - public function __construct(public Server $server) - { - $this->onQueue(maintenance_queue()); - } - public function handle(): void { try { - if ($this->server->isFunctional() === false) { - return; - } - $team = data_get($this->server, 'team'); if (! $team) { return; } - // Check for updates - $patchData = CheckUpdates::run($this->server); + if ($this->server->isFunctional() === false) { + if ($this->manual) { + $this->saveResult(['error' => 'Server is not reachable or not ready.']); + } - if (isset($patchData['error'])) { - $team->notify(new ServerPatchCheck($this->server, $patchData)); - - return; // Skip if there's an error checking for updates + return; } - $totalUpdates = $patchData['total_updates'] ?? 0; + $previousError = $this->server->settings->patch_check_error; + $patchData = CheckUpdates::run($this->server); + $this->saveResult($patchData); - // Only send notification if there are updates available - if ($totalUpdates > 0) { + if (isset($patchData['error'])) { + // Notify only when the state changes from ok to error, not on every check of a broken server. + if (blank($previousError)) { + $team->notify(new ServerPatchCheck($this->server, $patchData)); + } + + return; + } + + if (! $this->manual && ($patchData['total_updates'] ?? 0) > 0) { $team->notify(new ServerPatchCheck($this->server, $patchData)); } } catch (\Throwable $e) { - // Log error but don't fail the job Log::error('ServerPatchCheckJob failed: '.$e->getMessage(), [ 'server_id' => $this->server->id, 'server_name' => $this->server->name, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); + $this->saveResult(['error' => $e->getMessage()]); + } finally { + Cache::forget(self::runningCacheKey($this->server)); + ServerPatchCheckFinished::dispatch($this->server->team_id, $this->server->uuid); } } + + /** + * @param array $patchData + */ + private function saveResult(array $patchData): void + { + $error = $patchData['error'] ?? null; + + $this->server->settings->forceFill([ + 'patch_check_result' => $error === null ? $patchData : Arr::only($patchData, ['osId', 'package_manager']), + 'patch_check_error' => $error === null ? null : (string) $error, + 'patch_checked_at' => now(), + ])->saveQuietly(); + } } diff --git a/app/Listeners/CheckPatchesAfterPackageUpdate.php b/app/Listeners/CheckPatchesAfterPackageUpdate.php new file mode 100644 index 0000000000..492f9f5463 --- /dev/null +++ b/app/Listeners/CheckPatchesAfterPackageUpdate.php @@ -0,0 +1,20 @@ +where('uuid', $event->data['server_uuid'] ?? null)->first(); + if ($server === null) { + return; + } + + ServerPatchCheckJob::dispatchManual($server); + } +} diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 2a2b82a3f1..dd5a37070e 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -2,13 +2,15 @@ namespace App\Livewire\Server\Security; -use App\Actions\Server\CheckUpdates; use App\Actions\Server\UpdatePackage; -use App\Events\ServerPackageUpdated; +use App\Jobs\ServerPatchCheckJob; use App\Models\Server; use App\Notifications\Server\ServerPatchCheck; use App\Traits\ListensToTeamChannel; +use Cron\CronExpression; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Cache; +use Livewire\Attributes\Locked; use Livewire\Component; class Patches extends Component @@ -16,58 +18,62 @@ class Patches extends Component use AuthorizesRequests; use ListensToTeamChannel; - public array $parameters; - public Server $server; - public ?int $totalUpdates = null; - - public ?array $updates = null; + /** + * The saved result of the last check (see ServerPatchCheckJob). + */ + #[Locked] + public ?array $result = null; + #[Locked] public ?string $error = null; - public ?string $osId = null; + #[Locked] + public bool $checking = false; - public ?string $packageManager = null; + public string $patchCheckFrequency = ''; public function getListeners() { return $this->teamChannelListeners([ - 'ServerPackageUpdated' => 'checkForUpdatesDispatch', + 'ServerPatchCheckFinished' => 'patchCheckFinished', ]); } - public function mount() + public function mount(string $server_uuid) { - $this->parameters = get_route_parameters(); - $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); - try { - $this->authorize('viewSecurity', $this->server); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('viewSecurity', $this->server); + $this->loadResult(); + $this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency; } - public function checkForUpdatesDispatch() + public function loadResult(): void { - $this->totalUpdates = null; - $this->updates = null; - $this->error = null; - $this->osId = null; - $this->packageManager = null; - $this->dispatch('checkForUpdatesDispatch'); + $this->server->settings->refresh(); + $this->result = $this->server->settings->patch_check_result; + $this->error = $this->server->settings->patch_check_error; + $this->checking = Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)); + } + + public function patchCheckFinished(array $event = []): void + { + $serverUuid = data_get($event, 'serverUuid'); + if ($serverUuid !== null && $serverUuid !== $this->server->uuid) { + return; + } + $this->loadResult(); } public function checkForUpdates() { - $job = CheckUpdates::run($this->server); - if (isset($job['error'])) { - $this->error = data_get($job, 'error', 'Something went wrong.'); - } else { - $this->totalUpdates = data_get($job, 'total_updates', 0); - $this->updates = data_get($job, 'updates', []); - $this->osId = data_get($job, 'osId', null); - $this->packageManager = data_get($job, 'package_manager', null); + try { + $this->authorize('update', $this->server); + ServerPatchCheckJob::dispatchManual($this->server); + $this->checking = true; + } catch (\Throwable $e) { + return handleError($e, $this); } } @@ -75,45 +81,85 @@ class Patches extends Component { try { $this->authorize('update', $this->server); - if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run "Check for updates" first.'); - - return; - } - - $activity = UpdatePackage::run( - server: $this->server, - packageManager: $this->packageManager, - osId: $this->osId, - all: true - ); - if (is_array($activity)) { - $this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.'); - - return; - } - $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); + $this->runUpdate(all: true); } catch (\Throwable $e) { return handleError($e, $this); } } - public function updatePackage($package) + public function updatePackage(string $package) { try { $this->authorize('update', $this->server); - $activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, package: $package); - if (is_array($activity)) { - $this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.'); + $savedPackages = collect(data_get($this->server->settings->patch_check_result, 'updates', []))->pluck('package'); + if (! $savedPackages->contains($package)) { + $this->dispatch('error', message: 'This package has no update in the last check.'); return; } - $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); - } catch (\Exception $e) { - $this->dispatch('error', message: $e->getMessage()); + $this->runUpdate(package: $package); + } catch (\Throwable $e) { + return handleError($e, $this); } } + public function savePatchCheckFrequency() + { + try { + $this->authorize('update', $this->server); + $frequency = trim($this->patchCheckFrequency); + if ($frequency !== '' && ! self::isValidPatchCheckFrequency($frequency)) { + $this->patchCheckFrequency = (string) $this->server->settings->patch_check_frequency; + throw new \Exception('Invalid check frequency. Use a cron expression or a preset (daily, weekly, monthly) that runs at most once per hour.'); + } + $this->server->settings->patch_check_frequency = $frequency === '' ? null : $frequency; + $this->server->settings->save(); + $this->patchCheckFrequency = $frequency; + $this->dispatch('success', 'Check frequency saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * A valid cron expression or preset that runs at most once per hour, because every check runs the package manager. + */ + public static function isValidPatchCheckFrequency(string $frequency): bool + { + if (! validate_cron_expression($frequency)) { + return false; + } + $runs = (new CronExpression(VALID_CRON_STRINGS[$frequency] ?? $frequency))->getMultipleRunDates(5); + for ($i = 1; $i < count($runs); $i++) { + if ($runs[$i]->getTimestamp() - $runs[$i - 1]->getTimestamp() < 3600) { + return false; + } + } + + return true; + } + + private function runUpdate(bool $all = false, ?string $package = null): void + { + $savedResult = $this->server->settings->patch_check_result; + $packageManager = data_get($savedResult, 'package_manager'); + $osId = data_get($savedResult, 'osId'); + if (! $packageManager || ! $osId) { + $this->dispatch('error', message: 'Run "Check for updates" first.'); + + return; + } + + $activity = UpdatePackage::run(server: $this->server, osId: $osId, package: $package, packageManager: $packageManager, all: $all); + if (is_array($activity)) { + $this->dispatch('error', message: $activity['error'] ?? 'Something went wrong.'); + + return; + } + $this->dispatch('startupdate'); + $this->dispatch('activityMonitor', $activity->id); + } + public function sendTestEmail() { if (! isDev()) { @@ -123,11 +169,7 @@ class Patches extends Component } try { - // Get current patch data or create test data if none exists - $testPatchData = $this->createTestPatchData(); - - // Send test notification - $this->server->team->notify(new ServerPatchCheck($this->server, $testPatchData)); + $this->server->team->notify(new ServerPatchCheck($this->server, $this->createTestPatchData())); $this->dispatch('success', 'Test email sent successfully! Check your email inbox.'); } catch (\Exception $e) { @@ -137,63 +179,19 @@ class Patches extends Component private function createTestPatchData(): array { - // If we have real patch data, use it - if (isset($this->updates) && is_array($this->updates) && count($this->updates) > 0) { - return [ - 'total_updates' => $this->totalUpdates, - 'updates' => $this->updates, - 'osId' => $this->osId, - 'package_manager' => $this->packageManager, - ]; + if (count(data_get($this->result, 'updates', [])) > 0) { + return $this->result; } - // Otherwise create realistic test data return [ - 'total_updates' => 8, + 'total_updates' => 3, 'updates' => [ - [ - 'package' => 'docker-ce', - 'current_version' => '24.0.7-1', - 'new_version' => '25.0.1-1', - ], - [ - 'package' => 'nginx', - 'current_version' => '1.20.2-1', - 'new_version' => '1.22.1-1', - ], - [ - 'package' => 'kernel-generic', - 'current_version' => '5.15.0-89', - 'new_version' => '5.15.0-91', - ], - [ - 'package' => 'openssh-server', - 'current_version' => '8.9p1-3', - 'new_version' => '9.0p1-1', - ], - [ - 'package' => 'curl', - 'current_version' => '7.81.0-1', - 'new_version' => '7.85.0-1', - ], - [ - 'package' => 'git', - 'current_version' => '2.34.1-1', - 'new_version' => '2.39.1-1', - ], - [ - 'package' => 'python3', - 'current_version' => '3.10.6-1', - 'new_version' => '3.11.0-1', - ], - [ - 'package' => 'htop', - 'current_version' => '3.2.1-1', - 'new_version' => '3.2.2-1', - ], + ['package' => 'docker-ce', 'current_version' => '24.0.7-1', 'new_version' => '25.0.1-1', 'architecture' => 'amd64', 'repository' => 'stable'], + ['package' => 'openssh-server', 'current_version' => '8.9p1-3', 'new_version' => '9.0p1-1', 'architecture' => 'amd64', 'repository' => 'noble-security'], + ['package' => 'curl', 'current_version' => '7.81.0-1', 'new_version' => '7.85.0-1', 'architecture' => 'amd64', 'repository' => 'noble-updates'], ], - 'osId' => $this->osId ?? 'ubuntu', - 'package_manager' => $this->packageManager ?? 'apt', + 'osId' => 'ubuntu', + 'package_manager' => 'apt', ]; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f279f978ef..5519e1effa 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -153,6 +153,10 @@ class ServerSetting extends Model 'docker_version_checked_at', 'compose_version', 'compose_version_checked_at', + 'patch_check_frequency', + 'patch_check_result', + 'patch_check_error', + 'patch_checked_at', ]; /** @@ -190,6 +194,8 @@ class ServerSetting extends Model 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', 'server_disk_usage_notification_interval_hours' => 'integer', + 'patch_check_result' => 'array', + 'patch_checked_at' => 'datetime', ]; /** diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 168ffae7c0..94200d4169 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -191,6 +191,7 @@ final class ServerTransferCoverage 'sentinel_token' => 'The target makes a new Sentinel token when it claims the server.', 'sentinel_custom_url' => 'The target sets its own URL when it claims the server.', 'cloudflare_tunnel_integration_token_id' => 'Exported as cloudflare_tunnel_integration_token_uuid. '.self::LINKED, + 'patch_check_result' => self::RUNTIME, 'patch_check_error' => self::RUNTIME, 'patch_checked_at' => self::RUNTIME, ], PrivateKey::class => ['team_id' => self::TEAM], StandaloneDocker::class => ['server_id' => self::LINKED], diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 66895c4209..c80309cb9d 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -63,6 +63,10 @@ class ServerTransferExporter 'is_sentinel_debug_enabled', // Exported as cloudflare_tunnel_integration_token_uuid. 'cloudflare_tunnel_integration_token_id', + // The last patch check of the source; the target runs its own. + 'patch_check_result', + 'patch_check_error', + 'patch_checked_at', ]; /** diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 053d043d0d..d183f4d76b 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -698,6 +698,7 @@ class ServerTransferImporter // A foreign key of the source instance; older bundles still contain it. 'cloudflare_tunnel_integration_token_id', 'cloudflare_tunnel_integration_token_uuid', + 'patch_check_result', 'patch_check_error', 'patch_checked_at', ]) ->all(); $server->settings->fill($safe); diff --git a/database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php b/database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php new file mode 100644 index 0000000000..4b6f0bd79a --- /dev/null +++ b/database/migrations/2026_10_19_082209_add_patch_check_to_server_settings_table.php @@ -0,0 +1,25 @@ +string('patch_check_frequency')->nullable(); + $table->json('patch_check_result')->nullable(); + $table->text('patch_check_error')->nullable(); + $table->timestamp('patch_checked_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn(['patch_check_frequency', 'patch_check_result', 'patch_check_error', 'patch_checked_at']); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index af6636b8f0..daabfaaec5 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -32,6 +32,7 @@ When you add a model or a setting to the transfer, add it to this file too. | ➕ | Server timezone | `Europe/Budapest` | | ➕ | Docker cleanup | force cleanup on, frequency `0 4 * * *`, threshold 70, delete unused volumes on, delete unused networks on | | ➕ | Disk usage notification | threshold 70, check frequency `*/30 * * * *`, interval 12 h | +| ➕ | Security > Patches | check frequency `daily` (the last check result is not moved; the target shows "never" until its first check) | | ➕ | Terminal | terminal access off | | ➕ | Image retention | "disable application image retention" on | | ➕ | Labels / Traefik | generate exact labels on, Traefik certificate resolver `custom` | diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index a094b8167a..3669a3a981 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -14,6 +14,14 @@ + @php + $updates = data_get($result, 'updates', []); + $totalUpdates = count($updates); + $packageManager = data_get($result, 'package_manager'); + $checkedAt = $server->settings->patch_checked_at; + $fullUpgradeOnly = in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true); + @endphp +
@@ -28,122 +36,146 @@ Send test email @endif - - - Check for updates - + @can('update', $server) + + + Check for updates + + @endcan +
+ + Last checked: + @if ($checkedAt) + {{ $checkedAt->diffForHumans() }} + @else + never + @endif + + @if ($checkedAt && ! $error) + @if ($totalUpdates === 0) + + @else + + @endif + @endif +
+ - Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status + Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Status notifications can be managed from notification settings. -
- -
- - Inspecting installed packages… -
+ @if ($checking) +
+ +
+ + Inspecting installed packages… +
+
+
+ @elseif ($error) + + + {{ $error }} + -
- -
- @if ($error) - - - {{ $error }} - - - @elseif ($totalUpdates === 0) - - - - @elseif (isset($updates) && count($updates) > 0) - + @elseif ($checkedAt && $totalUpdates === 0) + + + + @elseif ($totalUpdates > 0) + + @can('update', $server) - + @endcan -
-
- Package - New version - Action -
- @foreach ($updates as $update) -
-
- @if (data_get_str($update, 'package')->contains('docker') || data_get_str($update, 'package')->contains('kernel')) - - @endif - - {{ data_get($update, 'package') }} - -
-
-

- {{ data_get($update, 'new_version') }} + @if ($fullUpgradeOnly) +

+ + Arch Linux does not support partial upgrades, so packages cannot be updated one by one. + +
+ @endif + +
+
+ Package + New version + Action +
+ @foreach ($updates as $update) +
+
+ + {{ data_get($update, 'package') }} + +
+
+

+ {{ data_get($update, 'new_version') }} +

+ @if (data_get($update, 'current_version') && data_get($update, 'current_version') !== 'unknown') +

+ Current: {{ data_get($update, 'current_version') }}

- @if ($packageManager !== 'dnf' && data_get($update, 'current_version')) -

- Current: {{ data_get($update, 'current_version') }} -

- @endif -
-
- @unless (in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true)) + @endif +
+
+ @if (! $fullUpgradeOnly) + @can('update', $server) + wire:click="updatePackage({{ Js::from(data_get($update, 'package')) }})"> Update - @endunless -
+ @endcan + @endif
- @endforeach -
- {{ count($updates) }} {{ Str::plural('package update', count($updates)) }}
+ @endforeach +
+ {{ $totalUpdates }} {{ Str::plural('package update', $totalUpdates) }}
- - @endif -
+
+ + @endif + +
+ + + @can('update', $server) + Save + @endcan + + + +
- - @script - - @endscript
diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php new file mode 100644 index 0000000000..21a8cf77ba --- /dev/null +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -0,0 +1,265 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin, ['role' => 'admin']); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->saveQuietly(); +}); + +function patchesTestResult(int $updates = 1): array +{ + return [ + 'osId' => 'ubuntu', + 'package_manager' => 'apt', + 'total_updates' => $updates, + 'updates' => array_slice([ + ['package' => 'openssl', 'current_version' => '3.0.13-0ubuntu3.1', 'new_version' => '3.0.13-0ubuntu3.4', 'architecture' => 'amd64', 'repository' => 'noble-updates,noble-security'], + ['package' => 'curl', 'current_version' => '8.5.0-2ubuntu10.1', 'new_version' => '8.5.0-2ubuntu10.6', 'architecture' => 'amd64', 'repository' => 'noble-updates'], + ], 0, $updates), + ]; +} + +function runPatchesTestJob(Server $server, bool $manual = false): void +{ + (new ServerPatchCheckJob($server->fresh(), $manual))->handle(); +} + +describe('patch check job', function () { + beforeEach(function () { + Notification::fake(); + Event::fake([ServerPatchCheckFinished::class]); + $this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true, 'server_patch_email_notifications' => true]); + }); + + it('saves the result, clears the running flag, and broadcasts to the server team', function () { + CheckUpdates::shouldRun()->andReturn(patchesTestResult(2)); + Cache::put(ServerPatchCheckJob::runningCacheKey($this->server), true); + + runPatchesTestJob($this->server, manual: true); + + $settings = $this->server->settings->fresh(); + expect($settings->patch_check_result['total_updates'])->toBe(2) + ->and($settings->patch_check_result['updates'][0]['package'])->toBe('openssl') + ->and($settings->patch_check_error)->toBeNull() + ->and($settings->patch_checked_at)->not->toBeNull() + ->and(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeFalse(); + Event::assertDispatched(ServerPatchCheckFinished::class, fn ($event) => $event->teamId === $this->team->id && $event->serverUuid === $this->server->uuid); + Notification::assertNothingSent(); + }); + + it('notifies about available updates on a scheduled check', function () { + CheckUpdates::shouldRun()->andReturn(patchesTestResult()); + + runPatchesTestJob($this->server); + + Notification::assertSentTo($this->team, ServerPatchCheck::class); + }); + + it('sends the failure notification only when the check changes from ok to error', function () { + CheckUpdates::shouldRun()->andReturn( + ['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked'], + ['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked'], + patchesTestResult(0), + ['osId' => 'ubuntu', 'package_manager' => 'apt', 'error' => 'apt is locked again'], + ); + + runPatchesTestJob($this->server); + runPatchesTestJob($this->server); + expect($this->server->settings->fresh()->patch_check_error)->toBe('apt is locked'); + Notification::assertSentToTimes($this->team, ServerPatchCheck::class, 1); + + runPatchesTestJob($this->server); + expect($this->server->settings->fresh()->patch_check_error)->toBeNull(); + + runPatchesTestJob($this->server); + Notification::assertSentToTimes($this->team, ServerPatchCheck::class, 2); + }); + + it('saves an error for a manual check of a server that is not functional', function () { + $this->server->settings->forceFill(['is_reachable' => false])->saveQuietly(); + CheckUpdates::shouldRun()->never(); + + runPatchesTestJob($this->server, manual: true); + + expect($this->server->settings->fresh()->patch_check_error)->toBe('Server is not reachable or not ready.'); + Event::assertDispatched(ServerPatchCheckFinished::class); + }); +}); + +describe('check frequency', function () { + it('uses the server frequency instead of the weekly default', function () { + $default = ServerManagerJob::patchCheckCron($this->server); + expect($default)->toEndWith('* * 0'); + + $this->server->settings->forceFill(['patch_check_frequency' => 'daily'])->saveQuietly(); + expect(ServerManagerJob::patchCheckCron($this->server->fresh()))->toBe('0 0 * * *'); + + $this->server->settings->forceFill(['patch_check_frequency' => '30 3 * * 1'])->saveQuietly(); + expect(ServerManagerJob::patchCheckCron($this->server->fresh()))->toBe('30 3 * * 1'); + }); + + it('accepts presets and cron expressions that run at most once per hour', function (string $frequency, bool $valid) { + expect(Patches::isValidPatchCheckFrequency($frequency))->toBe($valid); + })->with([ + ['daily', true], + ['hourly', true], + ['0 3 * * *', true], + ['every_minute', false], + ['*/30 * * * *', false], + ['not a cron', false], + ]); + + it('saves a valid frequency and rejects an invalid one', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('patchCheckFrequency', 'daily') + ->call('savePatchCheckFrequency') + ->assertDispatched('success') + ->set('patchCheckFrequency', '* * * * *') + ->call('savePatchCheckFrequency') + ->assertDispatched('error'); + + expect($this->server->settings->fresh()->patch_check_frequency)->toBe('daily'); + }); +}); + +describe('patches page', function () { + beforeEach(function () { + Queue::fake(); + }); + + it('shows the saved result of the last check', function () { + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(2), 'patch_checked_at' => now()->subHour()])->saveQuietly(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('1 hour ago') + ->assertSee('openssl') + ->assertSee('8.5.0-2ubuntu10.6'); + }); + + it('queues the check for an admin and shows the progress', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->call('checkForUpdates') + ->assertSet('checking', true) + ->assertSee('Inspecting installed packages'); + + Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $job->manual && $job->server->is($this->server)); + }); + + it('refreshes from the saved result when the check finishes', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('checkForUpdates'); + + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly(); + Cache::forget(ServerPatchCheckJob::runningCacheKey($this->server)); + + $component->call('patchCheckFinished', ['serverUuid' => $this->server->uuid]) + ->assertSet('checking', false) + ->assertSee('openssl'); + }); + + it('updates only a package from the saved result', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->call('updatePackage', 'curl; reboot') + ->assertDispatched('error', message: 'This package has no update in the last check.'); + }); +}); + +describe('patches authorization', function () { + beforeEach(function () { + Queue::fake(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->member, ['role' => 'member']); + }); + + it('does not let a member open the page', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->assertForbidden(); + }); + + it('does not show a server of another team', function () { + $otherTeam = Team::factory()->create(); + $this->admin->teams()->attach($otherTeam, ['role' => 'owner']); + $this->actingAs($this->admin); + session(['currentTeam' => $otherTeam]); + + expect(fn () => Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]))->toThrow(ModelNotFoundException::class); + }); + + it('uses the role in the server team after the session switches to a team the user owns', function () { + $otherTeam = Team::factory()->create(); + $this->member->teams()->attach($otherTeam, ['role' => 'owner']); + $this->member->teams()->updateExistingPivot($this->team->id, ['role' => 'admin']); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]); + + // The user is now only a member of the server team, but owns the session team. + $this->member->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->member->unsetRelation('teams'); + session(['currentTeam' => $otherTeam]); + + $component->call('checkForUpdates')->assertDispatched('error'); + $component->set('patchCheckFrequency', 'daily')->call('savePatchCheckFrequency')->assertDispatched('error'); + + Queue::assertNothingPushed(); + expect($this->server->settings->fresh()->patch_check_frequency)->toBeNull(); + }); +}); + +it('runs a new check when a package update finishes', function () { + Queue::fake(); + + ServerPackagesUpdateFinished::dispatch(['server_uuid' => $this->server->uuid, 'packages' => ['curl']]); + + Queue::assertPushed(ServerPatchCheckJob::class, fn ($job) => $job->manual && $job->server->is($this->server)); + expect(Cache::has(ServerPatchCheckJob::runningCacheKey($this->server)))->toBeTrue(); +}); diff --git a/tests/Feature/TeamBroadcastEventsTest.php b/tests/Feature/TeamBroadcastEventsTest.php index c41abb7c99..f07a521501 100644 --- a/tests/Feature/TeamBroadcastEventsTest.php +++ b/tests/Feature/TeamBroadcastEventsTest.php @@ -11,7 +11,6 @@ use App\Events\ProxyStatusChangedUI; use App\Events\ScheduledTaskDone; use App\Events\SentinelRestarted; use App\Events\SentinelSynchronized; -use App\Events\ServerPackageUpdated; use App\Events\ServerValidated; use App\Events\ServiceChecked; use App\Events\ServiceStatusChanged; @@ -54,7 +53,6 @@ dataset('team broadcast events', [ FileStorageChanged::class, ProxyStatusChangedUI::class, ScheduledTaskDone::class, - ServerPackageUpdated::class, ServerValidated::class, ServiceChecked::class, ServiceStatusChanged::class, diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 26eb2f9aad..7970238a1b 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -390,6 +390,31 @@ test('the Cloudflare tunnel token moves with the server by uuid and the raw toke ->and($imported->token)->toBe('cf-tunnel-token'); }); +test('the patch check frequency moves with the server and the last patch check result stays on the source', function () { + $this->server->settings->forceFill([ + 'patch_check_frequency' => 'daily', + 'patch_check_result' => ['osId' => 'ubuntu', 'package_manager' => 'apt', 'total_updates' => 0, 'updates' => []], + 'patch_check_error' => 'old error', + 'patch_checked_at' => now(), + ])->saveQuietly(); + + $bundle = $this->exporter->export($this->server); + expect(data_get($bundle, 'server.settings'))->not->toHaveKeys(['patch_check_result', 'patch_check_error', 'patch_checked_at']); + + $bundle['server']['settings']['patch_check_result'] = ['osId' => 'tampered']; + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + + $result = $this->importer->import($bundle, teamId: $this->team->id); + + $settings = Server::where('uuid', $result['server_uuid'])->first()->settings; + expect($settings->patch_check_frequency)->toBe('daily') + ->and($settings->patch_check_result)->toBeNull() + ->and($settings->patch_checked_at)->toBeNull(); +}); + test('a transfer back replaces the archive of the same team in the import transaction', function () { $bundle = $this->exporter->export($this->server); $bundle['server']['name'] = 'changed-on-the-other-instance';