diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 5cf5658f8f..2d4cbdef29 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -12,6 +12,29 @@ class CheckUpdates public string $jobQueue = 'high'; + /** + * Package managers that only support a full system upgrade. A partial upgrade on Arch Linux is not supported. + * + * @see https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported + */ + public const FULL_UPGRADE_ONLY = ['pacman']; + + /** + * Latest upgrade of each installed package. dnf5 does not add a newline after --qf, dnf4 does, so empty lines are skipped. + */ + public const DNF_UPGRADES_COMMAND = 'LANG=C dnf repoquery -q --refresh --upgrades --latest-limit 1 --qf "%{name} %{arch} %{evr} %{repoid}\n"'; + + public const RPM_INSTALLED_COMMAND = 'rpm -qa --qf "%{NAME} %{ARCH} %{VERSION}-%{RELEASE}\n"'; + + /** + * Lists pacman updates without `pacman -Sy` on the live database (that would make the next single install a partial upgrade). + * `checkupdates` needs fakeroot when it runs as root. Without it, the same sync runs into a temporary database. + * pacman 7 downloads as the `alpm` user, so the temporary directory must be readable (755). + * + * @see https://man.archlinux.org/man/checkupdates.8 + */ + public const PACMAN_CHECK_COMMAND = "sh -c 'if command -v checkupdates >/dev/null 2>&1 && command -v fakeroot >/dev/null 2>&1; then checkupdates; rc=\$?; if [ \$rc -eq 2 ]; then exit 0; fi; exit \$rc; fi; db=\$(mktemp -d) || exit 1; chmod 755 \"\$db\"; ln -s /var/lib/pacman/local \"\$db/local\"; if ! LANG=C pacman -Sy --dbpath \"\$db\" --logfile /dev/null >/dev/null; then rm -rf \"\$db\"; exit 1; fi; pacman -Qu --dbpath \"\$db\" 2>/dev/null; rm -rf \"\$db\"; exit 0'"; + public function handle(Server $server) { $osId = 'unknown'; @@ -83,8 +106,9 @@ class CheckUpdates return $out; case 'dnf': - $output = instant_remote_process(['LANG=C dnf list -q --updates --refresh'], $server); - $out = $this->parseDnfOutput($output); + $output = instant_remote_process([self::DNF_UPGRADES_COMMAND], $server); + $installed = instant_remote_process([self::RPM_INSTALLED_COMMAND], $server); + $out = $this->parseDnfOutput($output, $installed); $out['osId'] = $osId; $out['package_manager'] = $packageManager; @@ -99,10 +123,7 @@ class CheckUpdates return $out; case 'pacman': - // Sync database first, then check for updates - // Using -Sy to refresh package database before querying available updates - instant_remote_process(['pacman -Sy'], $server); - $output = instant_remote_process(['pacman -Qu 2>/dev/null'], $server); + $output = instant_remote_process([self::PACMAN_CHECK_COMMAND], $server); $out = $this->parsePacmanOutput($output); $out['osId'] = $osId; $out['package_manager'] = $packageManager; @@ -135,7 +156,7 @@ class CheckUpdates } } - private function parseZypperOutput(string $output): array + public function parseZypperOutput(string $output): array { $updates = []; @@ -177,44 +198,45 @@ class CheckUpdates } } - private function parseDnfOutput(string $output): array + /** + * @param string $output Output of DNF_UPGRADES_COMMAND: "name arch evr repoid" per line. + * @param string $installed Output of RPM_INSTALLED_COMMAND: "name arch version-release" per line. + */ + public function parseDnfOutput(string $output, string $installed = ''): array { - $updates = []; - $lines = explode("\n", $output); - - foreach ($lines as $line) { - if (empty($line)) { - continue; - } - - // Split by multiple spaces/tabs and filter out empty elements - $parts = array_values(array_filter(preg_split('/\s+/', $line))); - - if (count($parts) >= 3) { - $package = $parts[0]; - $new_version = $parts[1]; - $repository = $parts[2]; - - // Extract architecture from package name (e.g., "cloud-init.noarch" -> "noarch") - $architecture = str_contains($package, '.') ? explode('.', $package)[1] : 'noarch'; - - $updates[] = [ - 'package' => $package, - 'new_version' => $new_version, - 'repository' => $repository, - 'architecture' => $architecture, - 'current_version' => 'unknown', // DNF doesn't show current version in check-update output - ]; + $installedVersions = []; + foreach (explode("\n", $installed) as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) === 3) { + $installedVersions["{$parts[0]}.{$parts[1]}"] = $parts[2]; } } + $updates = []; + foreach (explode("\n", $output) as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) !== 4) { + continue; + } + [$package, $architecture, $newVersion, $repository] = $parts; + + $updates["{$package}.{$architecture}"] = [ + 'package' => $package, + 'new_version' => preg_replace('/^0:/', '', $newVersion), + 'repository' => $repository, + 'architecture' => $architecture, + 'current_version' => $installedVersions["{$package}.{$architecture}"] ?? 'unknown', + ]; + } + $updates = array_values($updates); + return [ 'total_updates' => count($updates), 'updates' => $updates, ]; } - private function parseAptOutput(string $output): array + public function parseAptOutput(string $output): array { $updates = []; $lines = explode("\n", $output); @@ -243,7 +265,7 @@ class CheckUpdates ]; } - private function parsePacmanOutput(string $output): array + public function parsePacmanOutput(string $output): array { $updates = []; $unparsedLines = []; @@ -284,7 +306,7 @@ class CheckUpdates return $result; } - private function parseApkOutput(string $output): array + public function parseApkOutput(string $output): array { $updates = []; $lines = explode("\n", $output); diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 2b06e06011..c87513502a 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -41,41 +41,49 @@ class UpdatePackage $sanitizedPackage = escapeshellarg($package); } - switch ($packageManager) { - case 'zypper': - $commandAll = 'zypper update -y'; - $commandInstall = 'zypper install -y '.$sanitizedPackage; - break; - case 'dnf': - $commandAll = 'dnf update -y'; - $commandInstall = 'dnf update -y '.$sanitizedPackage; - break; - case 'apt': - $commandAll = 'apt update && apt upgrade -y'; - $commandInstall = 'apt install -y '.$sanitizedPackage; - break; - case 'pacman': - $commandAll = 'pacman -Syu --noconfirm'; - $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; - break; - case 'apk': - $commandAll = 'apk update && apk upgrade'; - $commandInstall = 'apk upgrade '.$sanitizedPackage; - break; - default: - return [ - 'error' => 'OS not supported', - ]; - } - if ($all) { - return remote_process([$commandAll], $server); + if (! $all && in_array($packageManager, CheckUpdates::FULL_UPGRADE_ONLY, true)) { + return [ + 'error' => 'This operating system supports only a full system upgrade. Use "Update all packages".', + ]; } - return remote_process([$commandInstall], $server); + $commands = self::commands($packageManager, $all ? null : $sanitizedPackage); + if ($commands === null) { + return [ + 'error' => 'OS not supported', + ]; + } + + return remote_process($commands, $server); } catch (\Exception $e) { return [ 'error' => $e->getMessage(), ]; } } + + /** + * Update commands that never wait for input. apt keeps changed configuration files (confold) and uses the + * package default for unchanged ones (confdef). A null package updates all packages. + * + * @return array|null + */ + public static function commands(?string $packageManager, ?string $escapedPackage = null): ?array + { + $aptOptions = '-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold'; + + return match ($packageManager) { + 'apt' => [ + 'env DEBIAN_FRONTEND=noninteractive apt-get update -qq', + $escapedPackage === null + ? "env DEBIAN_FRONTEND=noninteractive apt-get {$aptOptions} upgrade" + : "env DEBIAN_FRONTEND=noninteractive apt-get {$aptOptions} install --only-upgrade {$escapedPackage}", + ], + 'dnf' => [$escapedPackage === null ? 'dnf upgrade -y' : "dnf upgrade -y {$escapedPackage}"], + 'zypper' => [$escapedPackage === null ? 'zypper --non-interactive update' : "zypper --non-interactive update {$escapedPackage}"], + 'pacman' => $escapedPackage === null ? ['pacman -Syu --noconfirm'] : null, + 'apk' => ['apk update', $escapedPackage === null ? 'apk upgrade' : "apk upgrade {$escapedPackage}"], + default => null, + }; + } } diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 4dd97519aa..2a2b82a3f1 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -87,6 +87,11 @@ class Patches extends Component 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); } catch (\Throwable $e) { return handleError($e, $this); @@ -98,6 +103,11 @@ class Patches extends Component 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.'); + + return; + } $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); } catch (\Exception $e) { $this->dispatch('error', message: $e->getMessage()); diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index bcb23f8c87..a094b8167a 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -112,10 +112,12 @@ @endif
- - Update - + @unless (in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true)) + + Update + + @endunless
@endforeach diff --git a/tests/Unit/Actions/Server/ServerPatchCommandsTest.php b/tests/Unit/Actions/Server/ServerPatchCommandsTest.php new file mode 100644 index 0000000000..9705b1eec0 --- /dev/null +++ b/tests/Unit/Actions/Server/ServerPatchCommandsTest.php @@ -0,0 +1,100 @@ +makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server->shouldReceive('setAttribute')->andReturnSelf(); + $server->user = 'ubuntu'; + + return $server; +} + +afterEach(function () { + Mockery::close(); +}); + +it('updates apt packages without prompts and keeps changed configuration files', function () { + expect(UpdatePackage::commands('apt'))->toBe([ + 'env DEBIAN_FRONTEND=noninteractive apt-get update -qq', + 'env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade', + ])->and(UpdatePackage::commands('apt', "'openssl'"))->toBe([ + 'env DEBIAN_FRONTEND=noninteractive apt-get update -qq', + "env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install --only-upgrade 'openssl'", + ]); +}); + +it('runs the apt update commands with sudo for a non-root user', function () { + $parsed = parseCommandsByLineForSudo(collect(UpdatePackage::commands('apt', "'openssl'")), patchCommandsNonRootServer()); + + expect($parsed)->toBe([ + 'sudo env DEBIAN_FRONTEND=noninteractive apt-get update -qq', + "sudo env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install --only-upgrade 'openssl'", + ]); +}); + +it('allows only a full system upgrade on Arch Linux', function () { + expect(UpdatePackage::commands('pacman'))->toBe(['pacman -Syu --noconfirm']) + ->and(UpdatePackage::commands('pacman', "'curl'"))->toBeNull(); + + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('serverStatus')->andReturn(true); + + expect(UpdatePackage::run($server, 'arch', 'curl', 'pacman')) + ->toHaveKey('error', 'This operating system supports only a full system upgrade. Use "Update all packages".'); +}); + +it('runs the pacman check as one sudo shell script for a non-root user', function () { + $parsed = parseCommandsByLineForSudo(collect([CheckUpdates::PACMAN_CHECK_COMMAND]), patchCommandsNonRootServer()); + + expect($parsed)->toBe(['sudo '.CheckUpdates::PACMAN_CHECK_COMMAND]) + ->and(CheckUpdates::PACMAN_CHECK_COMMAND)->not->toContain('pacman -Sy --noconfirm') + ->and(CheckUpdates::PACMAN_CHECK_COMMAND)->toContain('--dbpath'); +}); + +it('parses checkupdates output', function () { + $output = <<<'OUTPUT' +libarchive 3.8.9-1 -> 3.8.10-1 +zlib 1:1.3.2-3 -> 1:1.3.2-4 +OUTPUT; + + expect((new CheckUpdates)->parsePacmanOutput($output)['updates'])->toBe([ + ['package' => 'libarchive', 'current_version' => '3.8.9-1', 'new_version' => '3.8.10-1', 'architecture' => 'unknown', 'repository' => 'unknown'], + ['package' => 'zlib', 'current_version' => '1:1.3.2-3', 'new_version' => '1:1.3.2-4', 'architecture' => 'unknown', 'repository' => 'unknown'], + ]); +}); + +it('parses dnf4 repoquery output with the installed versions and without the arch suffix', function () { + // Rocky Linux 9 (dnf 4.14): dnf4 adds its own newline after --qf, so every second line is empty. + $upgrades = "acl x86_64 2.4.0-1.el9_8 baseos\n\nopenssl x86_64 1:3.5.8-2.el9_8 baseos\n\npython3.11 x86_64 3.11.13-1.el9 appstream\n\n"; + $installed = "acl x86_64 2.3.1-3.el9\nopenssl x86_64 3.0.1-41.el9_0\nbash x86_64 5.1.8-4.el9\n"; + + expect((new CheckUpdates)->parseDnfOutput($upgrades, $installed))->toBe([ + 'total_updates' => 3, + 'updates' => [ + ['package' => 'acl', 'new_version' => '2.4.0-1.el9_8', 'repository' => 'baseos', 'architecture' => 'x86_64', 'current_version' => '2.3.1-3.el9'], + ['package' => 'openssl', 'new_version' => '1:3.5.8-2.el9_8', 'repository' => 'baseos', 'architecture' => 'x86_64', 'current_version' => '3.0.1-41.el9_0'], + ['package' => 'python3.11', 'new_version' => '3.11.13-1.el9', 'repository' => 'appstream', 'architecture' => 'x86_64', 'current_version' => 'unknown'], + ], + ]); +}); + +it('parses dnf5 repoquery output', function () { + // Fedora 44 (dnf 5.4). + $upgrades = "ca-certificates noarch 2026.2.90_v9.0.317-1.fc44 updates\ncurl x86_64 8.18.0-14.fc44 updates\n"; + $installed = "curl x86_64 8.18.0-12.fc44\n"; + + $updates = (new CheckUpdates)->parseDnfOutput($upgrades, $installed)['updates']; + + expect($updates)->toHaveCount(2) + ->and($updates[0])->toMatchArray(['package' => 'ca-certificates', 'architecture' => 'noarch', 'current_version' => 'unknown']) + ->and($updates[1])->toMatchArray(['package' => 'curl', 'new_version' => '8.18.0-14.fc44', 'current_version' => '8.18.0-12.fc44']); +}); + +it('updates a dnf package by its name', function () { + expect(UpdatePackage::commands('dnf', "'python3.11'"))->toBe(["dnf upgrade -y 'python3.11'"]); +});