fix(patches): run apt without prompts, check pacman safely, show dnf versions

- apt: use apt-get with DEBIAN_FRONTEND=noninteractive and keep changed
  configuration files (--force-confdef/--force-confold). A single package
  uses install --only-upgrade, so it is never installed when missing.
- pacman: do not run `pacman -Sy` on the live database during the check.
  Use checkupdates (with fakeroot) or a temporary sync database, the same
  method checkupdates uses. pacman 7 downloads as the alpm user, so the
  temporary directory is 755. Arch allows only "Update all" (-Syu), because
  partial upgrades are unsupported.
  https://man.archlinux.org/man/checkupdates.8
  https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
- dnf: list upgrades with `repoquery --upgrades --latest-limit 1 --qf`
  (dnf5 needs the explicit \n, dnf4 prints empty lines) and read the
  current versions from `rpm -qa`. The package name has no .arch suffix.
  https://dnf5.readthedocs.io/en/latest/changes_from_dnf4.html

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-19 10:21:59 +02:00
co-authored by Claude Opus 5.5
parent 3d8cea9e3c
commit df17797d8f
5 changed files with 212 additions and 70 deletions
+59 -37
View File
@@ -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);
+37 -29
View File
@@ -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<int, string>|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,
};
}
}
+10
View File
@@ -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());
@@ -112,10 +112,12 @@
@endif
</div>
<div class="flex justify-end">
<x-forms.button type="button"
wire:click="$dispatch('updatePackage', { package: '{{ data_get($update, 'package') }}' })">
Update
</x-forms.button>
@unless (in_array($packageManager, \App\Actions\Server\CheckUpdates::FULL_UPGRADE_ONLY, true))
<x-forms.button type="button"
wire:click="$dispatch('updatePackage', { package: '{{ data_get($update, 'package') }}' })">
Update
</x-forms.button>
@endunless
</div>
</div>
@endforeach
@@ -0,0 +1,100 @@
<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\UpdatePackage;
use App\Models\Server;
function patchCommandsNonRootServer(): Server
{
$server = Mockery::mock(Server::class)->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'"]);
});