mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(patches): save check results and run checks in a queued job
- server_settings gets patch_check_result (JSON), patch_check_error, patch_checked_at, and patch_check_frequency. - The patches page shows the saved result and "Last checked" on load. "Check for updates" queues ServerPatchCheckJob (manual) instead of running SSH in the Livewire request. The job saves the result and broadcasts ServerPatchCheckFinished on the server team channel; the page reloads the saved result (with a 10 s poll as a fallback). - A finished package update (callEventOnFinish) queues a new check on the server side, so the page no longer depends on the browser. ServerPackageUpdated is removed. - The check frequency is a server setting (cron or preset, at most once per hour). Empty keeps the staggered weekly default. - The failure notification is sent only when the state changes from ok to error, not on every check. - Page access needs viewSecurity and every action needs update on the loaded server; a member gets 403. Updates accept only a package from the saved result. - Server transfer: the frequency moves, the last result stays on the source (runtime). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
df17797d8f
commit
6806ed31fa
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Traits\BroadcastsToTeam;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ServerPackageUpdated implements ShouldBroadcast
|
||||
{
|
||||
use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Dispatched by the remote process of a package update when it finishes (also when it fails).
|
||||
*/
|
||||
class ServerPackagesUpdateFinished
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* @param array{server_uuid: string, packages: array<int, string>} $data
|
||||
*/
|
||||
public function __construct(public readonly array $data) {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Traits\BroadcastsToTeam;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ServerPatchCheckFinished implements ShouldBroadcast
|
||||
{
|
||||
use BroadcastsToTeam, Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(?int $teamId, public ?string $serverUuid = null)
|
||||
{
|
||||
$this->teamId = $teamId;
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'ServerPatchCheckFinished';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{serverUuid: string|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return ['serverUuid' => $this->serverUuid];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\ServerPackagesUpdateFinished;
|
||||
use App\Jobs\ServerPatchCheckJob;
|
||||
use App\Models\Server;
|
||||
|
||||
class CheckPatchesAfterPackageUpdate
|
||||
{
|
||||
public function handle(ServerPackagesUpdateFinished $event): void
|
||||
{
|
||||
$server = Server::query()->where('uuid', $event->data['server_uuid'] ?? null)->first();
|
||||
if ($server === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ServerPatchCheckJob::dispatchManual($server);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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` |
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
</x-slot:content>
|
||||
</x-process-dialog>
|
||||
|
||||
@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
|
||||
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="security" activeSubMenu="patches" />
|
||||
@@ -28,122 +36,146 @@
|
||||
Send test email
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<x-forms.button type="button" wire:click="$dispatch('checkForUpdates')">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Check for updates
|
||||
</x-forms.button>
|
||||
@can('update', $server)
|
||||
<x-forms.button type="button" wire:click="checkForUpdates" :disabled="$checking">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Check for updates
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-neutral-600 dark:text-fg-dim">
|
||||
<span>
|
||||
Last checked:
|
||||
@if ($checkedAt)
|
||||
<span title="{{ $checkedAt->toDateTimeString() }}">{{ $checkedAt->diffForHumans() }}</span>
|
||||
@else
|
||||
never
|
||||
@endif
|
||||
</span>
|
||||
@if ($checkedAt && ! $error)
|
||||
@if ($totalUpdates === 0)
|
||||
<x-status-badge status="Up to date" type="success" />
|
||||
@else
|
||||
<x-status-badge :status="$totalUpdates . ' ' . Str::plural('update', $totalUpdates)" type="warning" />
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-callout type="info" title="Supported package managers">
|
||||
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
|
||||
<a class="font-medium underline" href="{{ route('notifications.email') }}"
|
||||
{{ wireNavigate() }}>notification settings</a>.
|
||||
</x-callout>
|
||||
</x-application.settings-section>
|
||||
|
||||
<div wire:loading wire:target="checkForUpdates">
|
||||
<x-application.settings-section title="Checking for updates"
|
||||
helper="Package discovery can take several minutes.">
|
||||
<div class="flex items-center gap-3 py-4 text-sm text-neutral-600 dark:text-fg-dim">
|
||||
<x-loading />
|
||||
Inspecting installed packages…
|
||||
</div>
|
||||
@if ($checking)
|
||||
<div wire:poll.10s="loadResult">
|
||||
<x-application.settings-section title="Checking for updates"
|
||||
helper="Package discovery runs in the background and can take several minutes.">
|
||||
<div class="flex items-center gap-3 py-4 text-sm text-neutral-600 dark:text-fg-dim">
|
||||
<x-loading />
|
||||
Inspecting installed packages…
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@elseif ($error)
|
||||
<x-application.settings-section title="Package updates"
|
||||
helper="Available operating system updates for this server.">
|
||||
<x-callout type="danger" title="Could not check for updates">
|
||||
{{ $error }}
|
||||
</x-callout>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove wire:target="checkForUpdates">
|
||||
@if ($error)
|
||||
<x-application.settings-section title="Package updates"
|
||||
helper="Available operating system updates for this server.">
|
||||
<x-callout type="danger" title="Could not check for updates">
|
||||
{{ $error }}
|
||||
</x-callout>
|
||||
</x-application.settings-section>
|
||||
@elseif ($totalUpdates === 0)
|
||||
<x-application.settings-section title="Package updates"
|
||||
helper="Available operating system updates for this server.">
|
||||
<x-empty size="sm" title="Server is up to date"
|
||||
description="No package updates are currently available."
|
||||
icon-name="check-circle" />
|
||||
</x-application.settings-section>
|
||||
@elseif (isset($updates) && count($updates) > 0)
|
||||
<x-application.settings-section id="server-package-updates-section" title="Package updates"
|
||||
helper="{{ $totalUpdates }} update{{ $totalUpdates === 1 ? '' : 's' }} available."
|
||||
flush>
|
||||
@elseif ($checkedAt && $totalUpdates === 0)
|
||||
<x-application.settings-section title="Package updates"
|
||||
helper="Available operating system updates for this server.">
|
||||
<x-empty size="sm" title="Server is up to date"
|
||||
description="No package updates are currently available." icon-name="check-circle" />
|
||||
</x-application.settings-section>
|
||||
@elseif ($totalUpdates > 0)
|
||||
<x-application.settings-section id="server-package-updates-section" title="Package updates"
|
||||
helper="{{ $totalUpdates }} {{ Str::plural('update', $totalUpdates) }} available."
|
||||
flush>
|
||||
@can('update', $server)
|
||||
<x-slot:actions>
|
||||
<x-modal-confirmation title="Confirm package update?"
|
||||
buttonTitle="Update all packages" isHighlightedButton
|
||||
submitAction="updateAllPackages" dispatchAction :actions="[
|
||||
<x-modal-confirmation title="Confirm package update?" buttonTitle="Update all packages"
|
||||
isHighlightedButton submitAction="updateAllPackages" :actions="[
|
||||
'All packages will be updated to their latest available versions.',
|
||||
'Docker or kernel updates may restart running containers.',
|
||||
]" confirmationText="Update All Packages"
|
||||
]"
|
||||
confirmationText="Update All Packages"
|
||||
confirmationLabel="Confirm by entering the text below"
|
||||
shortConfirmationLabel="Confirmation" :confirmWithPassword="false"
|
||||
step2ButtonText="Update All Packages" />
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
|
||||
<div class="data-table">
|
||||
<div class="data-table-header package-updates-table-grid">
|
||||
<span>Package</span>
|
||||
<span>New version</span>
|
||||
<span class="text-right">Action</span>
|
||||
</div>
|
||||
@foreach ($updates as $update)
|
||||
<div
|
||||
class="data-table-row package-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
@if (data_get_str($update, 'package')->contains('docker') || data_get_str($update, 'package')->contains('kernel'))
|
||||
<x-reicon name="alert-triangle"
|
||||
class="size-4 shrink-0 text-red-500" />
|
||||
@endif
|
||||
<span class="min-w-0 truncate font-mono text-[12px] text-neutral-950 dark:text-fg">
|
||||
{{ data_get($update, 'package') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-[12px] text-neutral-700 dark:text-fg-dim">
|
||||
{{ data_get($update, 'new_version') }}
|
||||
@if ($fullUpgradeOnly)
|
||||
<div class="px-4 pt-4">
|
||||
<x-callout type="info" title="Full system upgrade only">
|
||||
Arch Linux does not support partial upgrades, so packages cannot be updated one by one.
|
||||
</x-callout>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="data-table">
|
||||
<div class="data-table-header package-updates-table-grid">
|
||||
<span>Package</span>
|
||||
<span>New version</span>
|
||||
<span class="text-right">Action</span>
|
||||
</div>
|
||||
@foreach ($updates as $update)
|
||||
<div wire:key="package-update-{{ data_get($update, 'package') }}-{{ data_get($update, 'architecture') }}"
|
||||
class="data-table-row package-updates-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 truncate font-mono text-[12px] text-neutral-950 dark:text-fg">
|
||||
{{ data_get($update, 'package') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-[12px] text-neutral-700 dark:text-fg-dim">
|
||||
{{ data_get($update, 'new_version') }}
|
||||
</p>
|
||||
@if (data_get($update, 'current_version') && data_get($update, 'current_version') !== 'unknown')
|
||||
<p class="mt-0.5 truncate text-[10px] text-neutral-500 dark:text-fg-faint">
|
||||
Current: {{ data_get($update, 'current_version') }}
|
||||
</p>
|
||||
@if ($packageManager !== 'dnf' && data_get($update, 'current_version'))
|
||||
<p class="mt-0.5 truncate text-[10px] text-neutral-500 dark:text-fg-faint">
|
||||
Current: {{ data_get($update, 'current_version') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@unless (in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true))
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@if (! $fullUpgradeOnly)
|
||||
@can('update', $server)
|
||||
<x-forms.button type="button"
|
||||
wire:click="$dispatch('updatePackage', { package: '{{ data_get($update, 'package') }}' })">
|
||||
wire:click="updatePackage({{ Js::from(data_get($update, 'package')) }})">
|
||||
Update
|
||||
</x-forms.button>
|
||||
@endunless
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
<div
|
||||
class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
|
||||
{{ count($updates) }} {{ Str::plural('package update', count($updates)) }}
|
||||
</div>
|
||||
@endforeach
|
||||
<div
|
||||
class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
|
||||
{{ $totalUpdates }} {{ Str::plural('package update', $totalUpdates) }}
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
<form wire:submit="savePatchCheckFrequency">
|
||||
<x-application.settings-section id="server-patching-schedule-section" title="Schedule"
|
||||
helper="Control when Coolify checks this server for package updates.">
|
||||
<x-slot:actions>
|
||||
@can('update', $server)
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
<x-forms.input canGate="update" :canResource="$server" id="patchCheckFrequency"
|
||||
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." />
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
$wire.on('checkForUpdates', () => $wire.$call('checkForUpdatesDispatch'));
|
||||
$wire.on('updateAllPackages', () => {
|
||||
window.dispatchEvent(new CustomEvent('startupdate'));
|
||||
$wire.$call('updateAllPackages');
|
||||
});
|
||||
$wire.on('updatePackage', data => {
|
||||
window.dispatchEvent(new CustomEvent('startupdate'));
|
||||
$wire.$call('updatePackage', data.package);
|
||||
});
|
||||
$wire.on('checkForUpdatesDispatch', () => $wire.$call('checkForUpdates'));
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\CheckUpdates;
|
||||
use App\Events\ServerPackagesUpdateFinished;
|
||||
use App\Events\ServerPatchCheckFinished;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Jobs\ServerPatchCheckJob;
|
||||
use App\Livewire\Server\Security\Patches;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Server\ServerPatchCheck;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
Storage::fake('ssh-keys');
|
||||
$this->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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user