diff --git a/elevate-cpanel b/elevate-cpanel index 72c1859e..79a70cab 100755 --- a/elevate-cpanel +++ b/elevate-cpanel @@ -43,6 +43,7 @@ BEGIN { # Suppress load of all of these at earliest point. $INC{'Elevate/Components/NixStats.pm'} = 'script/elevate-cpanel.PL.static'; $INC{'Elevate/Components/OVH.pm'} = 'script/elevate-cpanel.PL.static'; $INC{'Elevate/Components/PackageRestore.pm'} = 'script/elevate-cpanel.PL.static'; + $INC{'Elevate/Components/PackageDupes.pm'} = 'script/elevate-cpanel.PL.static'; $INC{'Elevate/Components/Panopta.pm'} = 'script/elevate-cpanel.PL.static'; $INC{'Elevate/Components/PECL.pm'} = 'script/elevate-cpanel.PL.static'; $INC{'Elevate/Components/PerlXS.pm'} = 'script/elevate-cpanel.PL.static'; @@ -102,6 +103,9 @@ BEGIN { # Suppress load of all of these at earliest point. use constant ELEVATE_BACKUP_DIR => "/root/.elevate.backup"; + use constant RPMDB_DIR => q[/var/lib/rpm]; + use constant RPMDB_BACKUP_DIR => q[/var/lib/rpm-elevate-backup]; + use constant IMUNIFY_AGENT => '/usr/bin/imunify360-agent'; use constant CHKSRVD_SUSPEND_FILE => q[/var/run/chkservd.suspend]; @@ -332,6 +336,7 @@ BEGIN { # Suppress load of all of these at earliest point. use Elevate::Components::NixStats (); use Elevate::Components::OVH (); use Elevate::Components::PackageRestore (); + use Elevate::Components::PackageDupes (); use Elevate::Components::Panopta (); use Elevate::Components::PECL (); use Elevate::Components::PerlXS (); @@ -394,6 +399,7 @@ BEGIN { # Suppress load of all of these at earliest point. NixStats PECL PackageRestore + PackageDupes Panopta PerlXS PostgreSQL @@ -4351,6 +4357,151 @@ EOS } # --- END lib/Elevate/Components/PackageRestore.pm +{ # --- BEGIN lib/Elevate/Components/PackageDupes.pm + + package Elevate::Components::PackageDupes; + + use cPstrict; + + use Digest::SHA (); + + use Elevate::Constants (); + + use Cpanel::SafeRun::Simple (); + use Cpanel::Version::Compare::Package (); + + # use Log::Log4perl qw(:easy); + INIT { Log::Log4perl->import(qw{:easy}); } + + # use Elevate::Components::Base(); + our @ISA; + BEGIN { push @ISA, qw(Elevate::Components::Base); } + + use constant { ALPHA => 0, DIGIT => 1 }; + + sub pre_distro_upgrade ($self) { + + INFO('Looking for duplicate system packages...'); + my %dupes = $self->_find_dupes(); + if ( scalar %dupes > 0 ) { + + INFO('Duplicates found.'); + if ( !-d Elevate::Constants::RPMDB_BACKUP_DIR ) { + INFO('Backing up system package database. If there are problems upgrading packages, consider restoring this backup and resolving the duplicate packages manually.'); + if ( $self->_backup_rpmdb() ) { + INFO( 'Active RPM database: ' . Elevate::Constants::RPMDB_DIR ); + INFO( 'Backup RPM database: ' . Elevate::Constants::RPMDB_BACKUP_DIR ); + } + else { + ERROR('The backup process did not produce a correct backup! ELevate will proceed with the next step in the upgrade process without attempting to correct the issue. If there are problems upgrading packages, resolve the duplicate packages manually.'); + return; + } + } + + my @packages_to_remove = $self->_select_packages_for_removal(%dupes); + + DEBUG( "The following packages are being removed from the system package database:\n" . join( "\n", @packages_to_remove ) ); + $self->_remove_packages(@packages_to_remove); + } + else { + INFO('No duplicate packages found.'); + } + + return; + } + + sub _find_dupes ($self) { + my %dupes; + my $output = Cpanel::SafeRun::Simple::saferunnoerror(qw( /usr/bin/package-cleanup --dupes )); + + foreach my $line ( split /\n/, $output ) { + my ( $name, $version, $release, $arch ) = _parse_package($line); + push $dupes{$name}->@*, { version => $version, release => $release, arch => $arch } if $name; + } + + return %dupes; + } + + sub _parse_package ($pkg) { + return ( $pkg =~ m/^(.+)-(.+)-(.+)\.(.+)$/ ); + } + + sub _backup_rpmdb ($self) { + my ( $orig_dir, $backup_dir ) = ( Elevate::Constants::RPMDB_DIR, Elevate::Constants::RPMDB_BACKUP_DIR ); + rename $orig_dir, $backup_dir or LOGDIE("Failed to move $orig_dir to $backup_dir (reason: $!)"); + + File::Copy::Recursive::dircopy( $backup_dir, $orig_dir ); + if ( !_rpmdb_backup_is_good( $orig_dir, $backup_dir ) ) { + restore_rpmdb_from_backup(); + return 0; + } + + return 1; + } + + sub _rpmdb_backup_is_good ( $orig_dir, $backup_dir ) { + + opendir( my $orig_dh, $orig_dir ) or return 0; + opendir( my $backup_dh, $backup_dir ) or return 0; + + my @orig_files = sort grep { !/^\./ } readdir($orig_dh); + my @backup_files = sort grep { !/^\./ } readdir($backup_dh); + + return 0 if scalar @orig_files != scalar @backup_files; + + while ( scalar @orig_files && scalar @backup_files ) { + my ( $orig_file, $backup_file ) = ( shift(@orig_files), shift(@backup_files) ); + return 0 if $orig_file ne $backup_file; + + my ( $orig_digest, $backup_digest ) = map { Digest::SHA->new(256)->addfile($_)->hexdigest } ( "$orig_dir/$orig_file", "$backup_dir/$backup_file" ); + return 0 if !$orig_digest || !$backup_digest || $orig_digest ne $backup_digest; + } + + return 1; + } + + sub restore_rpmdb_from_backup () { + + local $SIG{'HUP'} = 'IGNORE'; + local $SIG{'TERM'} = 'IGNORE'; + local $SIG{'INT'} = 'IGNORE'; + local $SIG{'QUIT'} = 'IGNORE'; + local $SIG{'USR1'} = 'IGNORE'; + local $SIG{'USR2'} = 'IGNORE'; + + my ( $orig_dir, $backup_dir ) = ( Elevate::Constants::RPMDB_DIR, Elevate::Constants::RPMDB_BACKUP_DIR ); + + File::Path::rmtree($orig_dir); + rename $backup_dir, $orig_dir or LOGDIE("Failed to restore original RPM database to $orig_dir (reason: $!)! It is currently stored at $backup_dir."); + + return; + } + + sub _select_packages_for_removal ( $self, %dupes ) { + my @pkgs_for_removal; + + for my $pkg ( keys %dupes ) { + my @sorted_versions = sort { Cpanel::Version::Compare::Package::version_cmp( $a->{version}, $b->{version} ) || Cpanel::Version::Compare::Package::version_cmp( $a->{release}, $b->{release} ) } $dupes{$pkg}->@*; + + splice @sorted_versions, -2, 1; + + push @pkgs_for_removal, sprintf( '%s-%s-%s.%s', $pkg, $_->@{qw( version release arch )} ) foreach @sorted_versions; + } + + return @pkgs_for_removal; + } + + sub _remove_packages ( $self, @packages ) { + foreach my $pkg (@packages) { + $self->rpm->remove_no_dependencies_or_scripts_and_justdb($pkg); + } + return; + } + + 1; + +} # --- END lib/Elevate/Components/PackageDupes.pm + { # --- BEGIN lib/Elevate/Components/Panopta.pm package Elevate::Components::Panopta; @@ -7639,6 +7790,11 @@ EOS return; } + sub remove_no_dependencies_or_scripts_and_justdb ( $self, $pkg ) { + $self->cpev->ssystem( $rpm, '-e', '--nodeps', '--noscripts', '--justdb', $pkg ); + return; + } + sub get_installed_rpms ( $self, $format = undef ) { my @args = qw{-qa}; @@ -8658,6 +8814,7 @@ use Elevate::Components::NICs (); use Elevate::Components::NixStats (); use Elevate::Components::OVH (); use Elevate::Components::PackageRestore (); +use Elevate::Components::PackageDupes (); use Elevate::Components::Panopta (); use Elevate::Components::PECL (); use Elevate::Components::PerlXS (); @@ -9332,6 +9489,9 @@ sub run_stage_2 ($self) { $self->run_component_once( 'Grub2' => 'verify_cmdline' ); + # Best to do this before clearing the yum cache: + $self->run_component_once( 'PackageDupes' => 'pre_distro_upgrade' ); + $self->ssystem(qw{/usr/bin/yum clean all}); $self->ssystem_and_die(qw{/scripts/update-packages}); $self->ssystem_and_die(qw{/usr/bin/yum -y update}); diff --git a/lib/Elevate/Components.pm b/lib/Elevate/Components.pm index 3b552cb3..87da14cb 100644 --- a/lib/Elevate/Components.pm +++ b/lib/Elevate/Components.pm @@ -47,6 +47,7 @@ use Elevate::Components::NICs (); use Elevate::Components::NixStats (); use Elevate::Components::OVH (); use Elevate::Components::PackageRestore (); +use Elevate::Components::PackageDupes (); use Elevate::Components::Panopta (); use Elevate::Components::PECL (); use Elevate::Components::PerlXS (); @@ -112,6 +113,7 @@ our @NOOP_CHECKS = qw{ NixStats PECL PackageRestore + PackageDupes Panopta PerlXS PostgreSQL diff --git a/lib/Elevate/Components/PackageDupes.pm b/lib/Elevate/Components/PackageDupes.pm new file mode 100644 index 00000000..70d45494 --- /dev/null +++ b/lib/Elevate/Components/PackageDupes.pm @@ -0,0 +1,179 @@ +package Elevate::Components::PackageDupes; + +=encoding utf-8 + +=head1 NAME + +Elevate::Components::PackageDupes + +=cut + +use cPstrict; + +use Digest::SHA (); + +use Elevate::Constants (); + +use Cpanel::SafeRun::Simple (); +use Cpanel::Version::Compare::Package (); + +use Log::Log4perl qw(:easy); + +use parent qw{Elevate::Components::Base}; + +use constant { ALPHA => 0, DIGIT => 1 }; + +=head1 METHODS + +=head2 pre_distro_upgrade + +Detect if there are any duplicate packages using C. +If not, great. Otherwise, we need to remove the duplicates ourselves. + +Since the tool explicitly does not guarantee that the duplicates will be listed +in any particular order, we need to sort the packages ourselves. + +On the assumption that the newer package did not install correctly and is the +problem, we will remove it. Use L to figure +this out. + +Note that there is a further assumption that there will only be two packages in +a duplicated set. What happens if that is not the case? We have to remove all +but one, but which is the right choice to keep? Oldest one? Second-newest one? +Or is the first assumption about the problem being during upgrade then wrong? +The working assumption will be to remove all but the second-newest. + +Package removal is not only without dependencies and just with the DB, but we +also ignore scripts. + +TODO: Is this phenomenon of duplicate packages exclusive to RPM systems? If so, +bail out early on Ubuntu when that support is added. If not, refactor needed +to make it more distro-agnostic. + +=cut + +sub pre_distro_upgrade ($self) { + + INFO('Looking for duplicate system packages...'); + my %dupes = $self->_find_dupes(); + if ( scalar %dupes > 0 ) { + + INFO('Duplicates found.'); + if ( !-d Elevate::Constants::RPMDB_BACKUP_DIR ) { + INFO('Backing up system package database. If there are problems upgrading packages, consider restoring this backup and resolving the duplicate packages manually.'); + if ( $self->_backup_rpmdb() ) { + INFO( 'Active RPM database: ' . Elevate::Constants::RPMDB_DIR ); + INFO( 'Backup RPM database: ' . Elevate::Constants::RPMDB_BACKUP_DIR ); + } + else { + ERROR('The backup process did not produce a correct backup! ELevate will proceed with the next step in the upgrade process without attempting to correct the issue. If there are problems upgrading packages, resolve the duplicate packages manually.'); + return; + } + } + + my @packages_to_remove = $self->_select_packages_for_removal(%dupes); + + DEBUG( "The following packages are being removed from the system package database:\n" . join( "\n", @packages_to_remove ) ); + $self->_remove_packages(@packages_to_remove); + } + else { + INFO('No duplicate packages found.'); + } + + return; +} + +sub _find_dupes ($self) { + my %dupes; + my $output = Cpanel::SafeRun::Simple::saferunnoerror(qw( /usr/bin/package-cleanup --dupes )); + + foreach my $line ( split /\n/, $output ) { + my ( $name, $version, $release, $arch ) = _parse_package($line); + push $dupes{$name}->@*, { version => $version, release => $release, arch => $arch } if $name; + } + + return %dupes; +} + +sub _parse_package ($pkg) { + return ( $pkg =~ m/^(.+)-(.+)-(.+)\.(.+)$/ ); +} + +# To ensure that the backup is absolutely correct, use the original as the backup, and then copy it back into place. +sub _backup_rpmdb ($self) { + my ( $orig_dir, $backup_dir ) = ( Elevate::Constants::RPMDB_DIR, Elevate::Constants::RPMDB_BACKUP_DIR ); + rename $orig_dir, $backup_dir or LOGDIE("Failed to move $orig_dir to $backup_dir (reason: $!)"); + + # Even if dircopy returns a truthy value, we can't trust it. + File::Copy::Recursive::dircopy( $backup_dir, $orig_dir ); + if ( !_rpmdb_backup_is_good( $orig_dir, $backup_dir ) ) { + restore_rpmdb_from_backup(); + return 0; + } + + return 1; +} + +sub _rpmdb_backup_is_good ( $orig_dir, $backup_dir ) { + + opendir( my $orig_dh, $orig_dir ) or return 0; + opendir( my $backup_dh, $backup_dir ) or return 0; + + my @orig_files = sort grep { !/^\./ } readdir($orig_dh); + my @backup_files = sort grep { !/^\./ } readdir($backup_dh); + + return 0 if scalar @orig_files != scalar @backup_files; + + while ( scalar @orig_files && scalar @backup_files ) { + my ( $orig_file, $backup_file ) = ( shift(@orig_files), shift(@backup_files) ); + return 0 if $orig_file ne $backup_file; + + my ( $orig_digest, $backup_digest ) = map { Digest::SHA->new(256)->addfile($_)->hexdigest } ( "$orig_dir/$orig_file", "$backup_dir/$backup_file" ); + return 0 if !$orig_digest || !$backup_digest || $orig_digest ne $backup_digest; + } + + return 1; +} + +sub restore_rpmdb_from_backup () { + + # Absolutely DO NOT let anything interrupt us here, if we can help it: + local $SIG{'HUP'} = 'IGNORE'; + local $SIG{'TERM'} = 'IGNORE'; + local $SIG{'INT'} = 'IGNORE'; + local $SIG{'QUIT'} = 'IGNORE'; + local $SIG{'USR1'} = 'IGNORE'; + local $SIG{'USR2'} = 'IGNORE'; + + my ( $orig_dir, $backup_dir ) = ( Elevate::Constants::RPMDB_DIR, Elevate::Constants::RPMDB_BACKUP_DIR ); + + File::Path::rmtree($orig_dir); + rename $backup_dir, $orig_dir or LOGDIE("Failed to restore original RPM database to $orig_dir (reason: $!)! It is currently stored at $backup_dir."); + + return; +} + +sub _select_packages_for_removal ( $self, %dupes ) { + my @pkgs_for_removal; + + for my $pkg ( keys %dupes ) { + my @sorted_versions = sort { Cpanel::Version::Compare::Package::version_cmp( $a->{version}, $b->{version} ) || Cpanel::Version::Compare::Package::version_cmp( $a->{release}, $b->{release} ) } $dupes{$pkg}->@*; + + # Keep second-newest package: + splice @sorted_versions, -2, 1; + + # Reconstruct package strings and push to the list: + push @pkgs_for_removal, sprintf( '%s-%s-%s.%s', $pkg, $_->@{qw( version release arch )} ) foreach @sorted_versions; + } + + return @pkgs_for_removal; +} + +sub _remove_packages ( $self, @packages ) { + foreach my $pkg (@packages) { + $self->rpm->remove_no_dependencies_or_scripts_and_justdb($pkg); + } + return; +} + +1; diff --git a/lib/Elevate/Constants.pm b/lib/Elevate/Constants.pm index 1b562854..631cf10a 100644 --- a/lib/Elevate/Constants.pm +++ b/lib/Elevate/Constants.pm @@ -31,6 +31,9 @@ use constant YUM_REPOS_D => q[/etc/yum.repos.d]; use constant ELEVATE_BACKUP_DIR => "/root/.elevate.backup"; +use constant RPMDB_DIR => q[/var/lib/rpm]; +use constant RPMDB_BACKUP_DIR => q[/var/lib/rpm-elevate-backup]; + use constant IMUNIFY_AGENT => '/usr/bin/imunify360-agent'; use constant CHKSRVD_SUSPEND_FILE => q[/var/run/chkservd.suspend]; diff --git a/lib/Elevate/RPM.pm b/lib/Elevate/RPM.pm index 84d20790..a8b5e279 100644 --- a/lib/Elevate/RPM.pm +++ b/lib/Elevate/RPM.pm @@ -96,6 +96,11 @@ sub remove_no_dependencies_and_justdb ( $self, $pkg ) { return; } +sub remove_no_dependencies_or_scripts_and_justdb ( $self, $pkg ) { + $self->cpev->ssystem( $rpm, '-e', '--nodeps', '--noscripts', '--justdb', $pkg ); + return; +} + sub get_installed_rpms ( $self, $format = undef ) { my @args = qw{-qa}; diff --git a/script/elevate-cpanel.PL b/script/elevate-cpanel.PL index 43262513..35cd0cda 100755 --- a/script/elevate-cpanel.PL +++ b/script/elevate-cpanel.PL @@ -267,6 +267,7 @@ use Elevate::Components::NICs (); use Elevate::Components::NixStats (); use Elevate::Components::OVH (); use Elevate::Components::PackageRestore (); +use Elevate::Components::PackageDupes (); use Elevate::Components::Panopta (); use Elevate::Components::PECL (); use Elevate::Components::PerlXS (); @@ -941,6 +942,9 @@ sub run_stage_2 ($self) { $self->run_component_once( 'Grub2' => 'verify_cmdline' ); + # Best to do this before clearing the yum cache: + $self->run_component_once( 'PackageDupes' => 'pre_distro_upgrade' ); + $self->ssystem(qw{/usr/bin/yum clean all}); $self->ssystem_and_die(qw{/scripts/update-packages}); $self->ssystem_and_die(qw{/usr/bin/yum -y update}); diff --git a/t/components-PackageDupes.t b/t/components-PackageDupes.t new file mode 100644 index 00000000..1d5e54ed --- /dev/null +++ b/t/components-PackageDupes.t @@ -0,0 +1,135 @@ +#!/usr/local/cpanel/3rdparty/bin/perl + +# Copyright 2024 WebPros International, LLC +# All rights reserved. +# copyright@cpanel.net http://cpanel.net +# This code is subject to the cPanel license. Unauthorized copying is prohibited. + +package test::cpev::components; + +use FindBin; + +use Test2::V0; +use Test2::Tools::Explain; +use Test2::Plugin::NoWarnings; +use Test2::Tools::Exception; + +use Test::MockFile 0.032; +use Test::MockModule qw/strict/; + +use lib $FindBin::Bin . "/lib"; +use Test::Elevate; + +use cPstrict; + +my $cpev = cpev->new; +my $components = Elevate::Components->new( cpev => $cpev ); + +my $dupe_comp = $cpev->get_component('PackageDupes'); + +my $saferun_output; +my $mock_saferun = Test::MockModule->new('Cpanel::SafeRun::Simple'); +$mock_saferun->redefine( saferunnoerror => sub { return $saferun_output } ); + +{ + note "Checking _find_dupes"; + + my @test_data = ( + { + input => [qw( tar-1.30-9.el8.x86_64 tar-1.30-8.el8.x86_64 )], + output => { + tar => bag { + item { version => '1.30', release => '8.el8', arch => 'x86_64' }; + item { version => '1.30', release => '9.el8', arch => 'x86_64' }; + }, + }, + }, + { + input => [qw( tar-1.30-9.el8.x86_64 tar-1.30-8.el8.x86_64 tar-1.30-10.el8.x86_64 )], + output => { + tar => bag { + item { version => '1.30', release => '8.el8', arch => 'x86_64' }; + item { version => '1.30', release => '9.el8', arch => 'x86_64' }; + item { version => '1.30', release => '10.el8', arch => 'x86_64' }; + }, + }, + }, + { + input => [qw( tar-1.30-9.el8.x86_64 tar-1.30-8.el8.x86_64 flarble-69-11.el9.noarch flarble-42.0-1.noarch )], + output => { + tar => bag { + item { version => '1.30', release => '8.el8', arch => 'x86_64' }; + item { version => '1.30', release => '9.el8', arch => 'x86_64' }; + }, + flarble => bag { + item { version => '42.0', release => '1', arch => 'noarch' }; + item { version => '69', release => '11.el9', arch => 'noarch' }; + }, + }, + }, + { + input => [qw( i-can-parse-dashes-correctly-9.0.0-1.x86_64 i-can-parse-dashes-correctly-9.0.0-2.x86_64 )], + output => { + 'i-can-parse-dashes-correctly' => bag { + item { version => '9.0.0', release => '1', arch => 'x86_64' }; + item { version => '9.0.0', release => '2', arch => 'x86_64' }; + }, + }, + }, + ); + + foreach my $case (@test_data) { + $saferun_output = join "\n", $case->{input}->@*; + my $pkglist = join ' ', $case->{input}->@*; + is( { $dupe_comp->_find_dupes() }, $case->{output}, "Test case “$pkglist” yields expected results" ); + } +} + +{ + note "Checking _select_packages_for_removal"; + + my @test_data = ( + { + input => { + tar => [ + { version => '1.30', release => '8.el8', arch => 'x86_64' }, + { version => '1.30', release => '9.el8', arch => 'x86_64' }, + ], + }, + output => bag { item $_ foreach qw(tar-1.30-9.el8.x86_64) }, + message => "one pair", + }, + { + input => { + tar => [ + { version => '1.30', release => '8.el8', arch => 'x86_64' }, + { version => '1.30', release => '9.el8', arch => 'x86_64' }, + { version => '1.30', release => '10.el8', arch => 'x86_64' }, + ], + }, + output => bag { item $_ foreach qw(tar-1.30-10.el8.x86_64 tar-1.30-8.el8.x86_64) }, + message => "one triple", + }, + { + input => { + tar => [ + { version => '1.30', release => '8.el8', arch => 'x86_64' }, + { version => '1.30', release => '9.el8', arch => 'x86_64' }, + ], + flarble => [ + { version => '42.0', release => '1', arch => 'noarch' }, + { version => '69', release => '11.el9', arch => 'noarch' }, + ], + }, + output => bag { item $_ foreach qw(tar-1.30-9.el8.x86_64 flarble-69-11.el9.noarch) }, + message => "two pairs", + }, + ); + + foreach my $case (@test_data) { + is( [ $dupe_comp->_select_packages_for_removal( $case->{input}->%* ) ], $case->{output}, "Test case with $case->{message} yields expected results" ); + } + +} + +done_testing;