mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
3d8cea9e3c
commit
df17797d8f
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'"]);
|
||||
});
|
||||
Reference in New Issue
Block a user