mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Phased Ubuntu updates, apt-mark holds, and apk version pins showed as available updates that no update could install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
577 lines
25 KiB
PHP
577 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Actions\Server;
|
|
|
|
use App\Models\Server;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Lorisleiva\Actions\Concerns\AsAction;
|
|
|
|
class CheckUpdates
|
|
{
|
|
use AsAction;
|
|
|
|
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"';
|
|
|
|
/**
|
|
* Simulations of `apt-get upgrade` without and with the phased updates. apt holds back an update in a phased rollout
|
|
* until this machine is in the rollout, but `apt list --upgradable` still shows it. The difference of the two runs
|
|
* gives the phased updates. Text matching is not possible: apt 2.4 (Ubuntu 22.04) prints them as "kept back".
|
|
*
|
|
* @see https://documentation.ubuntu.com/server/explanation/software/about-apt-upgrade-and-phased-updates/
|
|
*/
|
|
public const APT_SIMULATE_UPGRADE_COMMAND = 'LANG=C apt-get -s upgrade 2>/dev/null';
|
|
|
|
public const APT_SIMULATE_PHASED_UPGRADE_COMMAND = 'LANG=C apt-get -s -o APT::Get::Always-Include-Phased-Updates=true upgrade 2>/dev/null';
|
|
|
|
/**
|
|
* Packages held with `apt-mark hold`. `apt list --upgradable` shows their updates, but apt does not install them.
|
|
*/
|
|
public const APT_HELD_COMMAND = 'apt-mark showhold 2>/dev/null';
|
|
|
|
/**
|
|
* Simulation of `apk upgrade`. A version pin in /etc/apk/world (`name=1.2-r0`, `name<2`, `name~1.2`) blocks an
|
|
* update that `apk list --upgradable` still shows, and also the updates of packages that depend on that version.
|
|
*/
|
|
public const APK_SIMULATE_UPGRADE_COMMAND = 'LANG=C apk upgrade -s 2>/dev/null';
|
|
|
|
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'";
|
|
|
|
/**
|
|
* Security advisories of the available updates. dnf5 still accepts `updateinfo` as an alias of `advisory`.
|
|
*
|
|
* @see https://dnf5.readthedocs.io/en/latest/commands/advisory.8.html
|
|
*/
|
|
public const DNF_SECURITY_COMMAND = "sh -c 'LANG=C dnf updateinfo list --security -q 2>/dev/null; exit 0'";
|
|
|
|
/**
|
|
* Packages that the security patches would upgrade. `zypper -x list-patches` ignores --category, and it does
|
|
* not contain package names, so a dry run of `zypper patch` is used.
|
|
*
|
|
* @see https://github.com/openSUSE/zypper/blob/master/doc/zypper.8.txt
|
|
*/
|
|
public const ZYPPER_SECURITY_COMMAND = "sh -c 'LANG=C zypper -n -x patch --dry-run --category security 2>/dev/null; exit 0'";
|
|
|
|
/**
|
|
* Prints REBOOT=yes|no|unknown. Debian/Ubuntu write /var/run/reboot-required; needrestart batch mode reports the
|
|
* kernel status (KSTA 2 and 3 mean a newer kernel is installed); dnf needs-restarting and zypper needs-rebooting
|
|
* (exit code 102) cover the RHEL and SUSE families. Text is matched because a missing dnf plugin also exits 1.
|
|
*
|
|
* @see https://github.com/liske/needrestart/blob/master/README.batch.md
|
|
* @see https://dnf-plugins-core.readthedocs.io/en/latest/needs_restarting.html
|
|
*/
|
|
public const REBOOT_CHECK_COMMAND = <<<'SH'
|
|
sh -c 'if [ -f /var/run/reboot-required ]; then echo REBOOT=yes; exit 0; fi; if command -v needrestart >/dev/null 2>&1; then k=$(needrestart -b 2>/dev/null | sed -n "s/^NEEDRESTART-KSTA: *//p"); case "$k" in 2|3) echo REBOOT=yes; exit 0;; 1) echo REBOOT=no; exit 0;; esac; fi; if command -v dnf >/dev/null 2>&1; then o=$(LANG=C dnf needs-restarting -r 2>/dev/null); case "$o" in *"Reboot is required"*) echo REBOOT=yes; exit 0;; *"should not be necessary"*) echo REBOOT=no; exit 0;; esac; fi; if command -v zypper >/dev/null 2>&1; then zypper -q needs-rebooting >/dev/null 2>&1; rc=$?; if [ $rc -eq 102 ]; then echo REBOOT=yes; exit 0; fi; if [ $rc -eq 0 ]; then echo REBOOT=no; exit 0; fi; fi; if [ -d /var/lib/dpkg ]; then echo REBOOT=no; exit 0; fi; echo REBOOT=unknown'
|
|
SH;
|
|
|
|
/**
|
|
* Packages whose update can interrupt the server, per package manager. `*` matches like a shell glob.
|
|
* docker: restarts the Docker daemon and with it the running containers. kernel: needs a reboot.
|
|
* system: core libraries and services that are restarted or need a reboot to apply fully.
|
|
*
|
|
* @var array<string, array<string, list<string>>>
|
|
*/
|
|
public const RISKY_PACKAGES = [
|
|
'apt' => [
|
|
'docker' => ['docker-ce', 'docker-ce-cli', 'docker-ce-rootless-extras', 'docker-buildx-plugin', 'docker-compose-plugin', 'containerd.io', 'containerd', 'runc', 'docker.io'],
|
|
'kernel' => ['linux-image-*', 'linux-modules-*', 'linux-generic*', 'linux-virtual*', 'linux-aws*', 'linux-azure*', 'linux-gcp*', 'linux-oracle*', 'linux-kvm*', 'linux-raspi*'],
|
|
'system' => ['libc6', 'libc-bin', 'systemd', 'systemd-sysv', 'libsystemd0', 'udev', 'dbus', 'openssh-server', 'openssh-sftp-server'],
|
|
],
|
|
'dnf' => [
|
|
'docker' => ['docker-ce', 'docker-ce-cli', 'docker-ce-rootless-extras', 'docker-buildx-plugin', 'docker-compose-plugin', 'containerd.io', 'containerd', 'runc', 'moby-engine', 'docker'],
|
|
'kernel' => ['kernel', 'kernel-core', 'kernel-modules', 'kernel-modules-core', 'kernel-modules-extra', 'kernel-uek', 'kernel-uek-core', 'kernel-uek-modules'],
|
|
'system' => ['glibc', 'glibc-common', 'glibc-langpack-*', 'systemd', 'systemd-libs', 'systemd-udev', 'dbus', 'dbus-broker', 'openssh-server'],
|
|
],
|
|
'zypper' => [
|
|
'docker' => ['docker', 'docker-ce', 'containerd', 'runc', 'docker-compose', 'docker-buildx'],
|
|
'kernel' => ['kernel-default', 'kernel-default-base', 'kernel-azure', 'kernel-kvmsmall'],
|
|
'system' => ['glibc', 'systemd', 'libsystemd0', 'udev', 'dbus-1', 'openssh', 'openssh-server'],
|
|
],
|
|
'pacman' => [
|
|
'docker' => ['docker', 'containerd', 'runc', 'docker-compose', 'docker-buildx'],
|
|
'kernel' => ['linux', 'linux-lts', 'linux-zen', 'linux-hardened'],
|
|
'system' => ['glibc', 'systemd', 'systemd-libs', 'dbus', 'openssh'],
|
|
],
|
|
'apk' => [
|
|
'docker' => ['docker', 'docker-engine', 'docker-cli', 'docker-openrc', 'docker-cli-compose', 'docker-cli-buildx', 'containerd', 'runc'],
|
|
'kernel' => ['linux-lts', 'linux-virt', 'linux-edge'],
|
|
'system' => ['musl', 'openrc', 'openssh', 'openssh-server'],
|
|
],
|
|
];
|
|
|
|
public const RISK_LABELS = [
|
|
'docker' => 'Restarts Docker and the running containers',
|
|
'kernel' => 'Needs a reboot to apply',
|
|
'system' => 'Restarts core system services',
|
|
];
|
|
|
|
public const HELD_REASON_LABELS = [
|
|
'phased' => 'Phased rollout',
|
|
'held' => 'Held',
|
|
];
|
|
|
|
public function handle(Server $server)
|
|
{
|
|
$osId = 'unknown';
|
|
$packageManager = null;
|
|
|
|
try {
|
|
if ($server->serverStatus() === false) {
|
|
return [
|
|
'error' => 'Server is not reachable or not ready.',
|
|
];
|
|
}
|
|
|
|
// Try first method - using instant_remote_process
|
|
$output = instant_remote_process(['cat /etc/os-release'], $server);
|
|
|
|
// Parse os-release into an associative array
|
|
$osInfo = [];
|
|
foreach (explode("\n", $output) as $line) {
|
|
if (empty($line)) {
|
|
continue;
|
|
}
|
|
if (strpos($line, '=') === false) {
|
|
continue;
|
|
}
|
|
[$key, $value] = explode('=', $line, 2);
|
|
$osInfo[$key] = trim($value, '"');
|
|
}
|
|
|
|
// Get the main OS identifier
|
|
$osId = $osInfo['ID'] ?? '';
|
|
// $osIdLike = $osInfo['ID_LIKE'] ?? '';
|
|
// $versionId = $osInfo['VERSION_ID'] ?? '';
|
|
|
|
// Normalize OS types based on install.sh logic
|
|
switch ($osId) {
|
|
case 'manjaro':
|
|
case 'manjaro-arm':
|
|
case 'endeavouros':
|
|
$osType = 'arch';
|
|
break;
|
|
case 'pop':
|
|
case 'linuxmint':
|
|
case 'zorin':
|
|
$osType = 'ubuntu';
|
|
break;
|
|
case 'fedora-asahi-remix':
|
|
$osType = 'fedora';
|
|
break;
|
|
default:
|
|
$osType = $osId;
|
|
}
|
|
|
|
// Determine package manager based on OS type
|
|
$packageManager = match ($osType) {
|
|
'arch' => 'pacman',
|
|
'alpine' => 'apk',
|
|
'ubuntu', 'debian', 'raspbian' => 'apt',
|
|
'centos', 'fedora', 'rhel', 'ol', 'rocky', 'almalinux', 'amzn' => 'dnf',
|
|
'sles', 'opensuse-leap', 'opensuse-tumbleweed' => 'zypper',
|
|
default => null
|
|
};
|
|
|
|
$securityPackages = null;
|
|
switch ($packageManager) {
|
|
case 'zypper':
|
|
$out = $this->parseZypperOutput(instant_remote_process(['LANG=C zypper -tx list-updates'], $server));
|
|
$securityPackages = $this->parseZypperSecurityPackages((string) instant_remote_process([self::ZYPPER_SECURITY_COMMAND], $server, false));
|
|
break;
|
|
case 'dnf':
|
|
$output = instant_remote_process([self::DNF_UPGRADES_COMMAND], $server);
|
|
$installed = instant_remote_process([self::RPM_INSTALLED_COMMAND], $server);
|
|
$out = $this->parseDnfOutput($output, $installed);
|
|
$securityPackages = $this->parseDnfSecurityPackages((string) instant_remote_process([self::DNF_SECURITY_COMMAND], $server, false));
|
|
break;
|
|
case 'apt':
|
|
instant_remote_process(['apt-get update -qq'], $server);
|
|
$out = $this->separateHeldUpdates(
|
|
$this->parseAptOutput(instant_remote_process(['LANG=C apt list --upgradable 2>/dev/null'], $server)),
|
|
$this->parseAptHeldPackages(
|
|
(string) instant_remote_process([self::APT_HELD_COMMAND], $server, false),
|
|
(string) instant_remote_process([self::APT_SIMULATE_UPGRADE_COMMAND], $server, false),
|
|
(string) instant_remote_process([self::APT_SIMULATE_PHASED_UPGRADE_COMMAND], $server, false),
|
|
),
|
|
);
|
|
break;
|
|
case 'pacman':
|
|
$out = $this->parsePacmanOutput(instant_remote_process([self::PACMAN_CHECK_COMMAND], $server));
|
|
break;
|
|
case 'apk':
|
|
instant_remote_process(['apk update -q'], $server);
|
|
$out = $this->parseApkOutput(instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server));
|
|
$out = $this->separateHeldUpdates($out, $this->parseApkHeldPackages($out, (string) instant_remote_process([self::APK_SIMULATE_UPGRADE_COMMAND], $server, false)));
|
|
break;
|
|
default:
|
|
return [
|
|
'osId' => $osId,
|
|
'error' => 'Unsupported package manager',
|
|
'package_manager' => $packageManager,
|
|
];
|
|
}
|
|
|
|
$out = $this->addUpdateDetails($out, $packageManager, $securityPackages);
|
|
$out['reboot_required'] = $this->parseRebootStatus((string) instant_remote_process([self::REBOOT_CHECK_COMMAND], $server, false));
|
|
$out['osId'] = $osId;
|
|
$out['package_manager'] = $packageManager;
|
|
|
|
return $out;
|
|
} 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(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds the security flag and the risk to each update, and the number of security updates.
|
|
* The security count is null when the package manager cannot tell (pacman, apk) or the advisory query failed.
|
|
*
|
|
* @param list<string>|null $securityPackages Package names with a security advisory (dnf, zypper).
|
|
*/
|
|
public function addUpdateDetails(array $out, string $packageManager, ?array $securityPackages = null): array
|
|
{
|
|
$knowsSecurity = $packageManager === 'apt' || $securityPackages !== null;
|
|
|
|
$out['updates'] = array_map(function (array $update) use ($packageManager, $securityPackages) {
|
|
$update['security'] = match ($packageManager) {
|
|
'apt' => collect(explode(',', (string) ($update['repository'] ?? '')))->contains(fn (string $suite) => str_ends_with($suite, '-security')),
|
|
default => in_array($update['package'], $securityPackages ?? [], true),
|
|
};
|
|
$update['risk'] = self::riskFor($packageManager, $update['package']);
|
|
|
|
return $update;
|
|
}, $out['updates'] ?? []);
|
|
$out['security_updates'] = $knowsSecurity ? count(array_filter($out['updates'], fn (array $update) => $update['security'])) : null;
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @return 'docker'|'kernel'|'system'|null
|
|
*/
|
|
public static function riskFor(?string $packageManager, string $package): ?string
|
|
{
|
|
foreach (self::RISKY_PACKAGES[$packageManager] ?? [] as $risk => $patterns) {
|
|
foreach ($patterns as $pattern) {
|
|
if (fnmatch($pattern, $package)) {
|
|
return $risk;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Package names from `dnf updateinfo list --security` (dnf4: "RLSA-2024:1234 Important/Sec. openssl-1:3.0.7-27.el9.x86_64",
|
|
* dnf5: "FEDORA-2024-1 security Critical curl-8.6.0-1.fc40.x86_64 2024-01-01 00:00:00").
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public function parseDnfSecurityPackages(string $output): array
|
|
{
|
|
$packages = [];
|
|
foreach (preg_split('/\s+/', $output) as $token) {
|
|
// name-[epoch:]version-release.arch: the name is everything before the last two dashes.
|
|
if (preg_match('/^(.+)-[^-\/]+-[^-\/]+\.[A-Za-z0-9_]+$/', $token, $matches)) {
|
|
$packages[] = $matches[1];
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($packages));
|
|
}
|
|
|
|
/**
|
|
* Package names that `zypper -x patch --dry-run --category security` would upgrade.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public function parseZypperSecurityPackages(string $output): array
|
|
{
|
|
if (! preg_match_all('#<to-upgrade>(.*?)</to-upgrade>#s', $output, $blocks)) {
|
|
return [];
|
|
}
|
|
|
|
$packages = [];
|
|
foreach ($blocks[1] as $block) {
|
|
preg_match_all('#<solvable\b[^>]*>#', $block, $solvables);
|
|
foreach ($solvables[0] as $solvable) {
|
|
if (preg_match('#\bkind="package"#', $solvable) && preg_match('#\bname="([^"]+)"#', $solvable, $name)) {
|
|
$packages[] = $name[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($packages));
|
|
}
|
|
|
|
public function parseRebootStatus(string $output): ?bool
|
|
{
|
|
return match (true) {
|
|
str_contains($output, 'REBOOT=yes') => true,
|
|
str_contains($output, 'REBOOT=no') => false,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
public function parseZypperOutput(string $output): array
|
|
{
|
|
$updates = [];
|
|
|
|
try {
|
|
$xml = simplexml_load_string($output);
|
|
if ($xml === false) {
|
|
return [
|
|
'total_updates' => 0,
|
|
'updates' => [],
|
|
'error' => 'Failed to parse XML output',
|
|
];
|
|
}
|
|
|
|
// Navigate to the update-list node
|
|
$updateList = $xml->xpath('//update-list/update');
|
|
|
|
foreach ($updateList as $update) {
|
|
$updates[] = [
|
|
'package' => (string) $update['name'],
|
|
'new_version' => (string) $update['edition'],
|
|
'current_version' => (string) $update['edition-old'],
|
|
'architecture' => (string) $update['arch'],
|
|
'repository' => (string) $update->source['alias'],
|
|
'summary' => (string) $update->summary,
|
|
'description' => (string) $update->description,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'total_updates' => count($updates),
|
|
'updates' => $updates,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return [
|
|
'total_updates' => 0,
|
|
'updates' => [],
|
|
'error' => 'Error parsing zypper output: '.$e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
$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,
|
|
];
|
|
}
|
|
|
|
public function parseAptOutput(string $output): array
|
|
{
|
|
$updates = [];
|
|
$lines = explode("\n", $output);
|
|
|
|
foreach ($lines as $line) {
|
|
// Skip the "Listing... Done" line and empty lines
|
|
if (empty($line) || str_contains($line, 'Listing...')) {
|
|
continue;
|
|
}
|
|
|
|
// Example line: package/stable 2.0-1 amd64 [upgradable from: 1.0-1]
|
|
if (preg_match('/^(.+?)\/(\S+)\s+(\S+)\s+(\S+)\s+\[upgradable from: (.+?)\]/', $line, $matches)) {
|
|
$updates[] = [
|
|
'package' => $matches[1],
|
|
'repository' => $matches[2],
|
|
'new_version' => $matches[3],
|
|
'architecture' => $matches[4],
|
|
'current_version' => $matches[5],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total_updates' => count($updates),
|
|
'updates' => $updates,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Moves the updates that the package manager does not install to `held_updates`, with the reason in `held_reason`.
|
|
*
|
|
* @param array<string, 'phased'|'held'> $heldPackages Package name => reason.
|
|
*/
|
|
public function separateHeldUpdates(array $out, array $heldPackages): array
|
|
{
|
|
$updates = collect($out['updates'] ?? [])->partition(fn (array $update) => isset($heldPackages[$update['package']]));
|
|
$out['updates'] = $updates[1]->values()->all();
|
|
$out['held_updates'] = $updates[0]->map(fn (array $update) => [...$update, 'held_reason' => $heldPackages[$update['package']]])->values()->all();
|
|
$out['total_updates'] = count($out['updates']);
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @param string $held Output of APT_HELD_COMMAND.
|
|
* @param string $upgrade Output of APT_SIMULATE_UPGRADE_COMMAND.
|
|
* @param string $upgradeWithPhased Output of APT_SIMULATE_PHASED_UPGRADE_COMMAND.
|
|
* @return array<string, 'phased'|'held'>
|
|
*/
|
|
public function parseAptHeldPackages(string $held, string $upgrade, string $upgradeWithPhased): array
|
|
{
|
|
$installs = function (string $output): ?array {
|
|
// Without the summary line, the simulation failed and tells nothing.
|
|
if (! preg_match('/^\d+ upgraded,/m', $output)) {
|
|
return null;
|
|
}
|
|
preg_match_all('/^Inst ([^\s:]+)/m', $output, $matches);
|
|
|
|
return $matches[1];
|
|
};
|
|
$installed = $installs($upgrade);
|
|
$installedWithPhased = $installs($upgradeWithPhased);
|
|
$phased = $installed === null || $installedWithPhased === null ? [] : array_diff($installedWithPhased, $installed);
|
|
preg_match_all('/^([^\s:]+)/m', $held, $heldMatches);
|
|
|
|
return [...array_fill_keys($phased, 'phased'), ...array_fill_keys($heldMatches[1], 'held')];
|
|
}
|
|
|
|
/**
|
|
* @param string $simulation Output of APK_SIMULATE_UPGRADE_COMMAND.
|
|
* @return array<string, 'held'>
|
|
*/
|
|
public function parseApkHeldPackages(array $out, string $simulation): array
|
|
{
|
|
// Without the summary line, the simulation failed and tells nothing.
|
|
if (! preg_match('/^OK: /m', $simulation)) {
|
|
return [];
|
|
}
|
|
preg_match_all('/^\(\s*\d+\/\d+\) Upgrading (\S+) \(/m', $simulation, $matches);
|
|
$held = array_diff(array_column($out['updates'] ?? [], 'package'), $matches[1]);
|
|
|
|
return array_fill_keys($held, 'held');
|
|
}
|
|
|
|
public function parsePacmanOutput(string $output): array
|
|
{
|
|
$updates = [];
|
|
$unparsedLines = [];
|
|
$lines = explode("\n", $output);
|
|
|
|
foreach ($lines as $line) {
|
|
if (empty($line)) {
|
|
continue;
|
|
}
|
|
// Format: package current_version -> new_version
|
|
if (preg_match('/^(\S+)\s+(\S+)\s+->\s+(\S+)$/', $line, $matches)) {
|
|
$updates[] = [
|
|
'package' => $matches[1],
|
|
'current_version' => $matches[2],
|
|
'new_version' => $matches[3],
|
|
'architecture' => 'unknown',
|
|
'repository' => 'unknown',
|
|
];
|
|
} else {
|
|
// Log unmatched lines for debugging purposes
|
|
$unparsedLines[] = $line;
|
|
}
|
|
}
|
|
|
|
$result = [
|
|
'total_updates' => count($updates),
|
|
'updates' => $updates,
|
|
];
|
|
|
|
// Include unparsed lines in the result for debugging if any exist
|
|
if (! empty($unparsedLines)) {
|
|
$result['unparsed_lines'] = $unparsedLines;
|
|
Log::debug('Pacman output contained unparsed lines', [
|
|
'unparsed_lines' => $unparsedLines,
|
|
]);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function parseApkOutput(string $output): array
|
|
{
|
|
$updates = [];
|
|
$lines = explode("\n", $output);
|
|
|
|
foreach ($lines as $line) {
|
|
// Skip empty lines
|
|
if (empty($line)) {
|
|
continue;
|
|
}
|
|
|
|
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
|
|
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
|
|
$updates[] = [
|
|
'package' => $matches[1],
|
|
'new_version' => $matches[2],
|
|
'architecture' => $matches[3],
|
|
'current_version' => $matches[4],
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total_updates' => count($updates),
|
|
'updates' => $updates,
|
|
];
|
|
}
|
|
}
|