Files
coolify/app/Jobs/ServerPatchCheckJob.php
T
Andras BacsaiandClaude Opus 5.5 3da85e2c07 feat(patches): mark security updates, reboot state, and risky packages
- Security updates: apt marks a package from a "-security" suite (Debian
  12 now reports oldstable-security). dnf reads `dnf updateinfo list
  --security` (dnf5 keeps updateinfo as an alias of advisory). zypper
  uses `zypper -x patch --dry-run --category security`, because
  `zypper -x list-patches` ignores --category and has no package names.
  pacman and apk cannot tell, so they show no security count.
  https://dnf5.readthedocs.io/en/latest/commands/advisory.8.html
  https://github.com/openSUSE/zypper/blob/master/doc/zypper.8.txt
- Reboot required: /var/run/reboot-required, `needrestart -b` (KSTA 2
  or 3), `dnf needs-restarting -r` (matched by text, because a missing
  plugin also exits 1), and `zypper needs-rebooting` (exit 102).
  https://github.com/liske/needrestart/blob/master/README.batch.md
  https://dnf-plugins-core.readthedocs.io/en/latest/needs_restarting.html
- An explicit risk list per package manager (Docker engine, kernels,
  libc/glibc/musl, systemd, dbus, openssh) replaces the "name contains
  docker/kernel" guess on the page and in every notification channel.
- The page shows security and reboot badges, a security count, and the
  risk of each package. "Reboot server" (with confirmation, update
  policy on the loaded server) schedules the reboot with systemd-run and
  checks the server again five minutes later. Archived (transferred)
  servers are never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:39:51 +02:00

116 lines
3.9 KiB
PHP

<?php
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;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
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
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
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 handle(): void
{
try {
$team = data_get($this->server, 'team');
if (! $team || $this->server->isTransferredAway()) {
return;
}
if ($this->server->isFunctional() === false) {
if ($this->manual) {
$this->saveResult(['error' => 'Server is not reachable or not ready.']);
}
return;
}
$previousError = $this->server->settings->patch_check_error;
$patchData = CheckUpdates::run($this->server);
$this->saveResult($patchData);
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('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();
}
}