mirror of
https://github.com/webmin/webmin.git
synced 2026-10-29 22:50:58 +00:00
This patch fixes package installation when a request contains both installed and missing packages. DNF 4 uses one `install` transaction, while DNF 5 updates installed packages first and installs missing packages only after the update succeeds. It preserves architecture package handling, always installs required repository packages, and prevents an empty package list from becoming a full system update. The complete transaction reads from `/dev/null`, and tests cover DNF 4, DNF 5, installed, missing, mixed, and empty package sets. Live AlmaLinux testing also confirmed both the single-command and chained-command paths.
453 lines
15 KiB
Perl
453 lines
15 KiB
Perl
#!/usr/local/bin/perl
|
|
|
|
use strict;
|
|
use warnings;
|
|
use Test::More;
|
|
use File::Basename qw(dirname);
|
|
use File::Spec;
|
|
use Cwd qw(abs_path);
|
|
|
|
our (%config, %packages, %text);
|
|
our ($yum_command, $supports_dnf_versionlock, $dnf_version,
|
|
$update_system_error);
|
|
|
|
sub has_command
|
|
{
|
|
return $_[0] eq 'dnf' ? '/usr/bin/dnf' : undef;
|
|
}
|
|
|
|
my $root = abs_path(File::Spec->catdir(dirname(__FILE__), '..'));
|
|
chdir($root) or die "chdir($root): $!";
|
|
|
|
do './software/yum-lib.pl' or die $@ || $!;
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *backquote_command = sub {
|
|
return $_[0] =~ /--version/ ? "4.14.0\n" :
|
|
" versionlock control package version locks\n";
|
|
};
|
|
local $dnf_version;
|
|
local $supports_dnf_versionlock;
|
|
is(get_dnf_version(), 4, 'detects DNF 4');
|
|
ok(supports_update_system_holds(),
|
|
'detects the DNF 4 versionlock command');
|
|
is(update_system_hold_flags(), '--disableplugin=versionlock',
|
|
'uses the DNF 4 held-update discovery option');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *backquote_command = sub {
|
|
return $_[0] =~ /--version/ ? "dnf5 version 5.4.2.1\n" :
|
|
" versionlock Manage versionlock configuration\n";
|
|
};
|
|
local $dnf_version;
|
|
local $supports_dnf_versionlock;
|
|
is(get_dnf_version(), 5, 'detects DNF 5');
|
|
ok(supports_update_system_holds(),
|
|
'detects the DNF 5 versionlock command');
|
|
is(update_system_hold_flags(), '--setopt=disable_excludes=*',
|
|
'uses the DNF 5 held-update discovery option');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *backquote_command = sub {
|
|
return $_[0] =~ /--version/ ? "4.14.0\n" :
|
|
"No such command: versionlock\n";
|
|
};
|
|
local $dnf_version;
|
|
local $supports_dnf_versionlock;
|
|
ok(!supports_update_system_holds(),
|
|
'hides holds when DNF does not expose versionlock');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
my $output =
|
|
"bash-0:5.1.8-8.el9.*\n".
|
|
"python3-*\n".
|
|
"0:python3-pip-21.2.3-8.el9.*\n".
|
|
"!blocked-0:2.0-1.el9.*\n";
|
|
local *supports_update_system_holds = sub { return 1; };
|
|
local *get_dnf_version = sub { return 4; };
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *open_execute_command = sub {
|
|
my ($fh) = @_;
|
|
no strict 'refs';
|
|
open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
|
|
or die "open simulated DNF 4 locks: $!";
|
|
};
|
|
|
|
is_deeply([ list_update_system_holds() ], [ qw(bash python3-pip) ],
|
|
'lists DNF 4 exact locks without raw patterns or excludes');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
my $output =
|
|
"# Added by 'dnf versionlock add nano'\n".
|
|
"Package name: nano\n".
|
|
"evr = 8.7.1-2.fc44\n\n".
|
|
"Package name: coreutils\n".
|
|
"evr != 9.10-4.fc44\n\n".
|
|
"Package name: bash\n".
|
|
"evr = 5.3.0-2.fc44\n".
|
|
"arch = aarch64\n\n".
|
|
"Package name: python3-*\n".
|
|
"evr = 3.14.0-1.fc44\n\n".
|
|
"Package name: nano\n".
|
|
"evr > 9\n";
|
|
local *supports_update_system_holds = sub { return 1; };
|
|
local *get_dnf_version = sub { return 5; };
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *open_execute_command = sub {
|
|
my ($fh) = @_;
|
|
no strict 'refs';
|
|
open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
|
|
or die "open simulated DNF 5 locks: $!";
|
|
};
|
|
|
|
is_deeply([ list_update_system_holds() ], [ ],
|
|
'skips DNF 5 names with duplicate, custom or glob lock rules');
|
|
|
|
$output = "Package name: nano\nevr = 8.7.1-2.fc44\n";
|
|
is_deeply([ list_update_system_holds() ], [ 'nano' ],
|
|
'lists a simple DNF 5 exact lock by package name');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
is(update_system_hold_spec_name('bash-0:5.1.8-8.el9.*'), 'bash',
|
|
'decodes DNF 4 name-epoch-version entries');
|
|
is(update_system_hold_spec_name('0:python3-pip-21.2.3-8.el9.*'),
|
|
'python3-pip', 'decodes legacy DNF 4 epoch-name entries');
|
|
is(update_system_hold_spec_name('python3-*'), undef,
|
|
'does not treat a raw pattern as an exact lock');
|
|
is(update_system_hold_spec_name('!bash-0:5.1.8-8.el9.*'), undef,
|
|
'does not treat an exclude as an exact lock');
|
|
is(update_system_nevra_name('nano-0:8.7.1-2.fc44'), 'nano',
|
|
'extracts a DNF 5 package name from transaction output');
|
|
is(update_system_nevra_name(
|
|
'webmin-virtualmin-support-2:4.3.202602061237-1.noarch'),
|
|
'webmin-virtualmin-support', 'extracts a hyphenated NEVRA name');
|
|
is(update_system_nevra_name('nano-8.7.1-2.fc44.aarch64'), 'nano',
|
|
'extracts a package name when no epoch is printed');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
my @commands;
|
|
local *supports_update_system_holds = sub { return 1; };
|
|
local *unique = sub {
|
|
my %seen;
|
|
return grep { !$seen{$_}++ } @_;
|
|
};
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *trim = sub {
|
|
my ($value) = @_;
|
|
$value =~ s/^\s+|\s+$//g;
|
|
return $value;
|
|
};
|
|
local *list_update_system_holds = sub { return ('bash'); };
|
|
local *execute_command_logged = sub {
|
|
my ($command, undef, $stdout) = @_;
|
|
push(@commands, $command);
|
|
$$stdout = '';
|
|
return 0;
|
|
};
|
|
|
|
is(update_system_hold([ 'bash', 'bash', 'coreutils' ], 1), undef,
|
|
'adds DNF version locks successfully');
|
|
is(update_system_hold([ 'bash', 'coreutils' ], 0), undef,
|
|
'removes only package names reported as exactly locked');
|
|
is_deeply(\@commands,
|
|
[ '/usr/bin/dnf -q versionlock add bash',
|
|
'/usr/bin/dnf -q versionlock add coreutils',
|
|
'/usr/bin/dnf -q versionlock delete bash' ],
|
|
'uses name-based add and delete commands on both DNF generations');
|
|
}
|
|
|
|
# Test a held update with its package marked as installed.
|
|
{
|
|
no warnings qw(once redefine);
|
|
my $dnf_output =
|
|
"[3/6] Upgrading bash-0:5.3.0-2.fc44 100% | 1.0 MiB/s | 1.0 MiB | 00m01s\n";
|
|
my $executed_command;
|
|
my @lock_actions;
|
|
local *update_system_hold_flags = sub { return 'held-update'; };
|
|
# append_architectures(name, [installed]) - Marks the package as installed.
|
|
local *append_architectures = sub {
|
|
my ($name, $installed) = @_;
|
|
$$installed = 1;
|
|
return $name;
|
|
};
|
|
local *update_system_repo = sub { return undef; };
|
|
local *additional_log = sub { };
|
|
local *html_escape = sub { return $_[0]; };
|
|
local *text = sub { return $_[0]; };
|
|
local *unique = sub {
|
|
my %seen;
|
|
return grep { !$seen{$_}++ } @_;
|
|
};
|
|
local *delete_update_system_holds = sub {
|
|
my ($packages, $removed) = @_;
|
|
push(@lock_actions, [ 'delete', [ @$packages ] ]);
|
|
push(@$removed, 'bash');
|
|
return undef;
|
|
};
|
|
local *restore_update_system_holds = sub {
|
|
my ($packages) = @_;
|
|
push(@lock_actions, [ 'restore', [ @$packages ] ]);
|
|
return undef;
|
|
};
|
|
local *open_execute_command = sub {
|
|
my ($fh, $command) = @_;
|
|
$executed_command = $command;
|
|
no strict 'refs';
|
|
open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
|
|
or die "open simulated DNF install: $!";
|
|
};
|
|
|
|
my $printed = '';
|
|
open(my $stdout, '>', \$printed) or die "open captured stdout: $!";
|
|
local *STDOUT = $stdout;
|
|
$? = 0;
|
|
my @installed = update_system_install('bash', { }, 1, 'held-update');
|
|
is_deeply(\@installed, [ 'bash' ],
|
|
'returns a DNF 5 package updated while held');
|
|
unlike($executed_command, qr/held-update|disableplugin|disable_excludes/,
|
|
'runs the transaction without a global versionlock bypass');
|
|
is_deeply(\@lock_actions,
|
|
[ [ 'delete', [ 'bash' ] ], [ 'restore', [ 'bash' ] ] ],
|
|
'temporarily unlocks and then re-locks only the selected package');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
my $dnf_output =
|
|
"bash.aarch64 5.1.8-10.el9 baseos\n".
|
|
"coreutils.aarch64 8.32-40.el9 baseos\n";
|
|
my $dnf_stderr = '';
|
|
my $dnf_status = 100;
|
|
my @commands;
|
|
local *supports_update_system_holds = sub { return 1; };
|
|
local *list_update_system_holds = sub { return ('bash'); };
|
|
local *set_yum_security_field = sub { };
|
|
local *get_dnf_version = sub { return 4; };
|
|
local *transname = sub { return 'simulated-stderr-file'; };
|
|
local *read_file_contents = sub { return $dnf_stderr; };
|
|
local *execute_command = sub {
|
|
my ($command, $stdin, $stdout) = @_;
|
|
push(@commands, $command);
|
|
$$stdout = $dnf_output;
|
|
return $dnf_status << 8;
|
|
};
|
|
|
|
my @normal = update_system_updates(0);
|
|
is_deeply([ map { $_->{'name'} } @normal ], [ 'coreutils' ],
|
|
'DNF 4 regular updates exclude held packages');
|
|
is($update_system_error, undef, 'a successful check reports no error');
|
|
my @with_holds = update_system_updates(1);
|
|
is_deeply([ map { $_->{'name'} } @with_holds ],
|
|
[ 'bash', 'coreutils' ], 'DNF 4 held-update query includes locks');
|
|
like($commands[1], qr/--disableplugin=versionlock check-update/,
|
|
'DNF 4 uses its plugin bypass for held-update discovery');
|
|
|
|
@commands = ( );
|
|
local *get_dnf_version = sub { return 5; };
|
|
@with_holds = update_system_updates(1);
|
|
like($commands[0], qr/--setopt=disable_excludes=\\\* check-update/,
|
|
'DNF 5 disables excludes for held-update discovery');
|
|
ok($with_holds[0]->{'held'}, 'marks a DNF 5 locked update as held');
|
|
|
|
$dnf_output = '';
|
|
$dnf_status = 0;
|
|
is_deeply([ update_system_updates(0) ], [ ],
|
|
'DNF exit status 0 means no updates');
|
|
is($update_system_error, undef, 'no updates is not reported as an error');
|
|
|
|
$dnf_output = "Some repo 0.0 B/s | 0 B 00:00\n";
|
|
$dnf_stderr =
|
|
"Errors during downloading metadata for repository 'x':\n".
|
|
"Error: Failed to download metadata for repo 'x'\n";
|
|
$dnf_status = 1;
|
|
is_deeply([ update_system_updates(0) ], [ ],
|
|
'a failed check returns no updates');
|
|
is($update_system_error,
|
|
"Errors during downloading metadata for repository 'x':\n".
|
|
"Error: Failed to download metadata for repo 'x'",
|
|
'a failed check reports the DNF error output');
|
|
|
|
$dnf_stderr = '';
|
|
update_system_updates(0);
|
|
is($update_system_error, 'Some repo 0.0 B/s | 0 B 00:00',
|
|
'a failed check without error output reports its normal output');
|
|
|
|
$dnf_output = '';
|
|
update_system_updates(0);
|
|
is($update_system_error, '/usr/bin/dnf check-update exited with status 1',
|
|
'a silent failure reports the exit status');
|
|
|
|
$dnf_status = 137;
|
|
update_system_updates(0);
|
|
is($update_system_error, '/usr/bin/dnf check-update exited with status 137',
|
|
'a killed check is reported as a failure');
|
|
}
|
|
|
|
{
|
|
no warnings qw(once redefine);
|
|
my $dnf_output =
|
|
"Last metadata expiration check: 0:10:00 ago.\n".
|
|
"Dependencies resolved.\n".
|
|
"================================================================\n".
|
|
" Package Architecture Version Repository Size\n".
|
|
"================================================================\n".
|
|
"Upgrading:\n".
|
|
" tzdata noarch 2026c-1.fc44 updates 497 k\n".
|
|
" vim-minimal aarch64 2:9.1.2000-2.fc44 updates 647 k\n".
|
|
"Installing dependencies:\n".
|
|
" wbt-virtual-server-theme\n".
|
|
" noarch 21.20-1 virtualmin 2.5 M\n".
|
|
"Removing dependent packages:\n".
|
|
" oldpkg noarch 1.0-1 system 1 k\n".
|
|
"\nTransaction Summary:\n".
|
|
"Upgrade 2 Packages\n";
|
|
my $command;
|
|
my @lock_actions;
|
|
my $dnf_major = 4;
|
|
local *update_system_hold_flags = sub {
|
|
return '--setopt=disable_excludes=*';
|
|
};
|
|
local *get_dnf_version = sub { return $dnf_major; };
|
|
local *open_execute_command = sub {
|
|
my ($fh, $cmd) = @_;
|
|
$command = $cmd;
|
|
no strict 'refs';
|
|
open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
|
|
or die "open simulated DNF transaction: $!";
|
|
};
|
|
local *clean_language = sub { };
|
|
local *reset_environment = sub { };
|
|
local *trim = sub {
|
|
my ($value) = @_;
|
|
$value =~ s/^\s+|\s+$//g;
|
|
return $value;
|
|
};
|
|
local *unique = sub {
|
|
my %seen;
|
|
return grep { !$seen{$_}++ } @_;
|
|
};
|
|
local *delete_update_system_holds = sub {
|
|
my ($packages, $removed) = @_;
|
|
push(@lock_actions, [ 'delete', [ @$packages ] ]);
|
|
push(@$removed, @$packages);
|
|
return undef;
|
|
};
|
|
local *restore_update_system_holds = sub {
|
|
my ($packages) = @_;
|
|
push(@lock_actions, [ 'restore', [ @$packages ] ]);
|
|
return undef;
|
|
};
|
|
|
|
my @ops = update_system_operations('tzdata vim-minimal');
|
|
like($command, qr{^/usr/bin/dnf --assumeno install },
|
|
'uses a simulated install for DNF 4 operations');
|
|
unlike($command, qr/disableplugin|disable_excludes/,
|
|
'keeps versionlock active for regular operations');
|
|
is_deeply([ map { $_->{'name'} } @ops ],
|
|
[ qw(tzdata vim-minimal wbt-virtual-server-theme) ],
|
|
'parses install and upgrade rows from a DNF transaction table');
|
|
|
|
$dnf_major = 5;
|
|
@ops = update_system_operations('tzdata vim-minimal',
|
|
'--setopt=disable_excludes=*');
|
|
like($command, qr{^/usr/bin/dnf --assumeno upgrade },
|
|
'uses a simulated upgrade for installed packages on DNF 5');
|
|
unlike($command, qr/disableplugin|disable_excludes/,
|
|
'does not pass the discovery-only bypass to a transaction preview');
|
|
is_deeply(\@lock_actions,
|
|
[ [ 'delete', [ qw(tzdata vim-minimal) ] ],
|
|
[ 'restore', [ qw(tzdata vim-minimal) ] ] ],
|
|
'temporarily unlocks selected packages for a held-update preview');
|
|
is_deeply([ map { $_->{'name'} } @ops ],
|
|
[ qw(tzdata vim-minimal wbt-virtual-server-theme) ],
|
|
'parses the held-package preview after restoring its locks');
|
|
is($ops[1]->{'epoch'}, '2', 'splits epochs from preview versions');
|
|
is($ops[2]->{'arch'}, 'noarch', 'handles wrapped package names');
|
|
}
|
|
|
|
# Test command selection for installed, missing, and mixed package sets.
|
|
{
|
|
no warnings qw(once redefine);
|
|
my @executed;
|
|
my $dnf_major = 4;
|
|
# get_dnf_version() - Returns the selected test version.
|
|
local *get_dnf_version = sub { return $dnf_major; };
|
|
# append_architectures(name, [installed]) - Marks only bash as installed.
|
|
local *append_architectures = sub {
|
|
my ($name, $installed) = @_;
|
|
$$installed = $name eq 'bash' ? 1 : 0;
|
|
return $name;
|
|
};
|
|
# update_system_repo(package) - Disables repository bootstrap commands.
|
|
local *update_system_repo = sub { return undef; };
|
|
# additional_log(action, object, details) - Suppresses command logging.
|
|
local *additional_log = sub { };
|
|
# html_escape(text) - Leaves commands unchanged for assertions.
|
|
local *html_escape = sub { return $_[0]; };
|
|
# text(key, [parameter]*) - Returns the untranslated key.
|
|
local *text = sub { return $_[0]; };
|
|
# unique(value*) - Removes duplicates while preserving order.
|
|
local *unique = sub {
|
|
my %seen;
|
|
return grep { !$seen{$_}++ } @_;
|
|
};
|
|
# open_execute_command(handle, command, [mode]) - Captures the command and
|
|
# supplies successful DNF output.
|
|
local *open_execute_command = sub {
|
|
my ($fh, $command) = @_;
|
|
push(@executed, $command);
|
|
my $output = "Complete!\n";
|
|
no strict 'refs';
|
|
open(ref($fh) ? $fh : \*{$fh}, '<', \$output)
|
|
or die "open simulated DNF install: $!";
|
|
};
|
|
my $printed = '';
|
|
open(my $stdout, '>', \$printed) or die "open captured stdout: $!";
|
|
local *STDOUT = $stdout;
|
|
$? = 0;
|
|
# Match package names escaped by quotemeta for the shell.
|
|
update_system_install('bash perl-DBD-SQLite', { }, 1);
|
|
like($executed[-1],
|
|
qr{^\(/usr/bin/dnf\s+-y install bash perl\\-DBD\\-SQLite\) </dev/null$},
|
|
'DNF 4 installs a mixed package set');
|
|
$dnf_major = 5;
|
|
update_system_install('bash perl-DBD-SQLite', { }, 1);
|
|
like($executed[-1],
|
|
qr{^\(/usr/bin/dnf\s+-y update bash && /usr/bin/dnf\s+-y install perl\\-DBD\\-SQLite\) </dev/null$},
|
|
'DNF 5 updates installed packages and installs missing packages');
|
|
update_system_install('bash', { }, 1);
|
|
like($executed[-1], qr{^\(/usr/bin/dnf\s+-y update bash\) </dev/null$},
|
|
'updates when every requested package is installed');
|
|
update_system_install('perl-DBD-SQLite', { }, 1);
|
|
like($executed[-1],
|
|
qr{^\(/usr/bin/dnf\s+-y install perl\\-DBD\\-SQLite\) </dev/null$},
|
|
'installs a single missing package');
|
|
update_system_install(' ', { }, 1);
|
|
like($executed[-1], qr{^\(/usr/bin/dnf\s+-y install\s+\) </dev/null$},
|
|
'does not turn an empty package list into a full system update');
|
|
}
|
|
|
|
done_testing();
|