Merge pull request #2823 from webmin/dev/dnf-hold

Add DNF versionlock support to Package Updates
This commit is contained in:
Jamie Cameron
2026-08-24 16:27:08 -07:00
committed by GitHub
11 changed files with 739 additions and 43 deletions

View File

@@ -3,6 +3,8 @@
* Add options to send Webmin and Usermin errors to the systemd journal [forum.virtualmin.com/t/136562](https://forum.virtualmin.com/t/miniserv-webserver-log-growing-too-big-should-be-rotated/136562)
* Add webserver logging controls to Usermin Configuration module
* Add option to rotate Webmin and Usermin webserver logs using `logrotate` instead of periodically clearing them [#2821](https://github.com/webmin/webmin/pull/2821)
* Add DNF 4 and 5 package hold management to the Software Package Updates module
* Fix DNF update confirmations by previewing packages and dependencies that will be installed or updated
* Fix PostgreSQL initialization on EL systems to use SCRAM-SHA-256 authentication by default
#### 2.660 (August 20, 2026)

View File

@@ -1,4 +1,5 @@
---- Changes since 2.660 ----
Added support for managing DNF 4 and 5 version locks when the versionlock command is installed.
Added a "New updates are found" choice to the "Send email when" option that sends a complete report only when new updates become available, so notify-only schedules no longer repeat the same message every run.
---- Changes since 2.641 ----
Added a Held updates view with controls to hold, unhold or explicitly update APT-held packages.

View File

@@ -107,7 +107,7 @@ update_newver=New version
update_confirm=Install Now
update_confirmheld=Update Held Packages
update_heldnote=These packages are held. This action explicitly updates them once, and leaves them held for future updates.
update_enotheld=Package $1 is not currently held by APT
update_enotheld=Package $1 is not currently held
update_enoheldops=No update operation was found for the selected held packages. Refresh the package list and try again.
update_none=None
update_ops=Building complete list of packages ..

View File

@@ -314,8 +314,12 @@ return defined(&software::update_system_updates);
# Returns true if the current update system can list and change package holds.
sub supports_package_holds
{
return defined(&software::list_update_system_holds) &&
defined(&software::update_system_hold);
return 0 if (!defined(&software::list_update_system_holds) ||
!defined(&software::update_system_hold) ||
!defined(&software::update_system_hold_flags));
return &software::supports_update_system_holds()
if (defined(&software::supports_update_system_holds));
return 1;
}
# list_package_holds()
@@ -389,8 +393,9 @@ my ($name, $system, $install, $flags) = @_;
$system ||= $software::update_system;
my @rv;
my $pkg;
my $include_held = $system eq 'apt' && defined($flags) &&
$flags eq '--allow-change-held-packages';
my $include_held = $system eq $software::update_system &&
&supports_package_holds() && defined($flags) &&
$flags eq &software::update_system_hold_flags();
# First get from list of updates
($pkg) = grep { $_->{'update'} eq $name &&
@@ -490,14 +495,13 @@ unlink($current_cache_file);
return @rv;
}
# list_package_operations(package|packages, system)
# Given a package (or space-separate package list), returns a list of all
# dependencies that will be installed
# list_package_operations(package|packages, system, [flags])
# Returns packages and dependencies that would be installed or updated.
sub list_package_operations
{
my ($name, $system) = @_;
my ($name, $system, $flags) = @_;
if (defined(&software::update_system_operations)) {
my @rv = &software::update_system_operations($name);
my @rv = &software::update_system_operations($name, $flags);
foreach my $p (@rv) {
$p->{'system'} = $system;
}

View File

@@ -73,18 +73,18 @@ else {
@pkgs || &error($text{'update_enone'});
$allow_held = 0;
if ($in{'mode'} eq 'held') {
# The held-updates page is the only UI that can explicitly
# override an APT hold for a single update transaction.
# Only Held updates can override a hold for one transaction.
&supports_package_holds() || &error($text{'hold_enotsupported'});
@held = &list_package_holds();
foreach $ps (@pkgs) {
($p, $s) = split(/\//, $ps, 2);
$s eq 'apt' && &package_is_held($p, \@held) ||
$s eq $software::update_system &&
&package_is_held($p, \@held) ||
&error(&text('update_enotheld', $p));
}
$allow_held = 1;
}
$install_flags = $allow_held ? '--allow-change-held-packages' :
$install_flags = $allow_held ? &software::update_system_hold_flags() :
$in{'flags'};
&ui_print_unbuffered_header(undef,
$in{'mode'} eq 'new' ? $text{'update_title2'} : $text{'update_title'}, "");
@@ -102,7 +102,8 @@ else {
($p, $s) = split(/\//, $ps);
push(@pkgnames, $p);
}
@ops = &list_package_operations(join(" ", @pkgnames), $s);
@ops = &list_package_operations(join(" ", @pkgnames), $s,
$install_flags);
&error($text{'update_enoheldops'}) if (!@ops && $allow_held);
}

View File

@@ -1,6 +1,7 @@
---- Changes since 2.641 ----
Fix Alpine Linux mysql/mariadb package installs names due missing server utils (means at least Alpine Linux package installation is supported since Alpine linux v 3.16 up to edge)
Added APT functions for listing, holding, unholding and explicitly updating held packages.
Added DNF 4 and 5 functions for listing, locking, unlocking and explicitly updating version-locked packages.
---- Changes since 1.130 ----
Packages can now be installed directly from yum, if installed.
The entire system can also be upgraded from yum.

View File

@@ -20,6 +20,13 @@ $name =~ s/:[A-Za-z0-9][A-Za-z0-9._-]*$//;
return $name;
}
# update_system_hold_flags()
# Returns the APT option for explicitly updating held packages.
sub update_system_hold_flags
{
return '--allow-change-held-packages';
}
# update_system_install([package], [&in], [no-force], [flags])
# Install some package with apt
sub update_system_install
@@ -105,15 +112,17 @@ $? = $status;
return @rv;
}
# update_system_operations(packages)
# Given a list of packages, returns a list containing packages that will
# actually get installed, each of which is a hash ref with name and version.
# update_system_operations(packages, [flags])
# Returns packages APT would install or update. The optional hold flag includes
# held packages in the simulation.
sub update_system_operations
{
my ($packages) = @_;
my ($packages, $flags) = @_;
$ENV{'UCF_FORCE_CONFFOLD'} = 'YES';
$ENV{'DEBIAN_FRONTEND'} = 'noninteractive';
my $cmd = "apt-get -s install ".
my $holdflag = defined($flags) &&
$flags eq &update_system_hold_flags() ? " $flags" : "";
my $cmd = "apt-get -s$holdflag install ".
join(" ", map { quotemeta($_) } split(/\s+/, $packages)).
" </dev/null 2>&1";
&clean_language();
@@ -261,8 +270,8 @@ close(DUMP);
return @rv;
}
# update_system_updates()
# Returns a list of available package updates
# update_system_updates([include-holds])
# Returns available package updates, optionally including held packages.
sub update_system_updates
{
my ($include_holds) = @_;

View File

@@ -266,6 +266,11 @@ yum_input=Package from YUM
yum_install=Installing package(s) with command $1 ..
yum_ok=.. install complete
yum_failed=.. install failed!
yum_unholdfailed=.. failed to temporarily remove the version lock on $1 : $2
yum_reholdfailed=.. failed to restore the version lock on $1 : $2
yum_versionlock_missing=The DNF versionlock command is not installed
yum_versionlock_none=No packages were specified
yum_versionlock_failed=dnf versionlock $1 failed
yum_find=Browse YUM ..
yum_package=Package
yum_version=Version

View File

@@ -19,17 +19,61 @@ sub list_update_system_commands
return ($yum_command);
}
# get_dnf_version()
# Returns the DNF major version, or zero when using YUM.
sub get_dnf_version
{
return 0 if ($yum_command !~ /(?:^|\/)dnf(?:-\d+)?$/);
if (!defined($dnf_version)) {
&clean_language();
my $out = &backquote_command(
"$yum_command --version 2>&1 </dev/null");
&reset_environment();
$dnf_version = $out =~ /^dnf5\s+version\s+(\d+)/m ? $1 : 4;
}
return $dnf_version;
}
# supports_update_system_holds()
# Reports whether DNF provides the versionlock command.
sub supports_update_system_holds
{
if (!defined($supports_dnf_versionlock)) {
$supports_dnf_versionlock = 0;
if (&get_dnf_version()) {
&clean_language();
my $out = &backquote_command(
"$yum_command --help 2>&1 </dev/null");
&reset_environment();
$supports_dnf_versionlock = 1
if ($out =~ /^\s*versionlock\s+/m);
}
}
return $supports_dnf_versionlock;
}
# update_system_hold_flags()
# Returns the DNF option that exposes version-locked updates.
sub update_system_hold_flags
{
return &get_dnf_version() >= 5 ? '--setopt=disable_excludes=*' :
'--disableplugin=versionlock';
}
# update_system_install([packages], [&in], [no-force], [flags])
# Install some package with yum
# Installs or updates packages with YUM or DNF.
sub update_system_install
{
local $update = $_[0] || $in{'update'};
local $in = $_[1];
local $force = !$_[2];
local $flags = $_[3];
local $versionlock_update = defined($flags) &&
$flags eq &update_system_hold_flags();
local $runflags = $versionlock_update ? undef : $flags;
local $qflags;
$qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $flags)))
if ($flags);
$qflags = &trim(join(" ", map { quotemeta($_) } split(/ /, $runflags)))
if ($runflags);
$update =~ s/\.\*/\*/g;
local $enable;
if ($in->{'enablerepo'}) {
@@ -45,7 +89,28 @@ if (@names == 1) {
}
$update = join(" ", map { quotemeta($_) } @names);
# Work out command to use - for DNF, upgrades need to use the update command
# Temporarily unlock selected packages, then restore their holds after the
# transaction.
local @relock;
if ($versionlock_update) {
my @packages = &unique(@updates);
my $unlock_error = &delete_update_system_holds(
\@packages, \@relock);
if ($unlock_error) {
my $relock_error = &restore_update_system_holds(\@relock);
if ($relock_error) {
print &text('yum_reholdfailed',
"<tt>".&html_escape(join(" ", @packages))."</tt>",
&html_escape($relock_error)),"<p>\n";
}
print &text('yum_unholdfailed',
"<tt>".&html_escape(join(" ", @packages))."</tt>",
&html_escape($unlock_error)),"<p>\n";
return ( );
}
}
# Use update for installed DNF packages and install for everything else.
local $cmd;
if ($yum_command =~ /dnf$/) {
local @pinfo = &package_info($updates[0]);
@@ -62,9 +127,9 @@ else {
# Work out the command to run, which may enable some repos
my $uicmd = "$yum_command $enable -y $cmd ".join(" ", @names);
$uicmd .= " $flags" if ($flags);
$uicmd .= " $runflags" if ($runflags);
my $fullcmd = "$yum_command $enable -y $cmd $update";
$fullcmd .= " $qflags" if ($flags);
$fullcmd .= " $qflags" if ($qflags);
foreach my $u (@updates) {
my $repo = &update_system_repo($u);
if ($repo) {
@@ -110,7 +175,7 @@ while(<CMD>) {
}
}
elsif (/^\s+(Updating|Installing|Upgrading)\s+:\s+(\S+)/) {
# Line like :
# Older DNF and YUM progress lines, for example:
# Updating : wbt-virtual-server-theme 1/2
# or
# Installing : 2:nmap-5.51-2.el6.i686 1/1
@@ -119,12 +184,11 @@ while(<CMD>) {
$pkg =~ s/\-\d.*$//; # Strip version number from end
push(@rv, $pkg);
}
elsif (/\]\s+(Upgrading|Installing)\s+(\S+)/) {
# Line like :
elsif (/\]\s+(Upgrading|Installing|Downgrading|Reinstalling)\s+(\S+)/) {
# DNF 5 progress line, for example:
# [3/8] Upgrading libcurl-0:8.11.1-5.fc42 100% ...
local $pkg = $2;
$pkg =~ s/:\d.*$//; # Strip version number from end
push(@rv, $pkg);
local $pkg = &update_system_nevra_name($2);
push(@rv, $pkg) if ($pkg);
}
if (!/ETA/ && !/\%\s+done\s+\d+\/\d+\s*$/) {
print &html_escape($_."\n");
@@ -134,8 +198,19 @@ while(<CMD>) {
}
}
close(CMD);
local $status = $?;
# Restore holds at the versions now installed.
if (@relock) {
local $relock_error = &restore_update_system_holds(\@relock);
if ($relock_error) {
print &text('yum_reholdfailed',
"<tt>".&html_escape(join(" ", &unique(@updates)))."</tt>",
&html_escape($relock_error)),"<p>\n";
}
}
print "</pre>\n";
if ($? || $nopackage) {
if ($status || $nopackage) {
print "$text{'yum_failed'}<p>\n";
return ( );
}
@@ -167,12 +242,26 @@ for(my $i=0; $i<$n; $i++) {
return @rv;
}
# update_system_operations(packages)
# Given a list of packages, returns a list containing packages that will
# actually get installed, each of which is a hash ref with name and version.
# update_system_nevra_name(nevra)
# Extracts the package name from a NEVRA string printed by DNF.
sub update_system_nevra_name
{
my ($nevra) = @_;
$nevra =~ s/^\d+://; # Older DNF may put the epoch before the name
return $1 if ($nevra =~ /^(.+)-\d+:/);
return $1 if ($nevra =~ /^(.+)-\d[^-]*-[^-]+(?:\.[^.]+)?$/);
return undef;
}
# update_system_operations(packages, [flags])
# Returns packages YUM or DNF would install or update. DNF previews the
# transaction directly; YUM uses shell mode.
sub update_system_operations
{
my ($packages) = @_;
my ($packages, $flags) = @_;
if ($yum_command =~ /(?:^|\/)dnf(?:-\d+)?$/) {
return &update_system_dnf_operations($packages, $flags);
}
my $temp = &transname();
&open_tempfile(SHELL, ">$temp", 0, 1);
&print_tempfile(SHELL, "install $packages\n");
@@ -198,6 +287,87 @@ close(SHELL);
return @rv;
}
# update_system_dnf_operations(packages, [flags])
# Returns packages DNF would install or update in a simulated transaction.
sub update_system_dnf_operations
{
my ($packages, $flags) = @_;
my @rv;
my $hold_override = defined($flags) &&
$flags eq &update_system_hold_flags();
my $runflags = defined($flags) && !$hold_override ?
&trim(join(" ", map { quotemeta($_) } split(/\s+/, $flags))) : "";
my @relock;
if ($hold_override) {
my @selected = &unique(split(/\s+/, $packages));
my $error = &delete_update_system_holds(\@selected, \@relock);
if ($error) {
&restore_update_system_holds(\@relock) if (@relock);
return ( );
}
}
# DNF 5 does not upgrade installed packages with install, so use upgrade for
# its previews.
my $action = &get_dnf_version() >= 5 ? 'upgrade' : 'install';
my $command = "$yum_command --assumeno $action ".
join(" ", map { quotemeta($_) } split(/\s+/, $packages));
$command .= " $runflags" if ($runflags);
$command .= " 2>/dev/null";
&clean_language();
&open_execute_command(DNF, $command, 1, 1);
my ($intable, $skip, $wrapped);
while(<DNF>) {
s/\r|\n//g;
if (/^\s*Package\s+Arch(itecture)?\s+Version\s+Repo/i) {
# Start reading the transaction table.
$intable = 1;
}
elsif (/^\s*Transaction\s+Summary/i) {
last;
}
elsif (!$intable || /^=+$/) {
next;
}
elsif (/^\S/) {
# Ignore sections that do not add package versions.
$skip = !/^(Installing|Upgrading|Reinstalling|Downgrading)/i;
$wrapped = undef;
}
elsif ($skip) {
next;
}
elsif (/^\s+(\S+)\s*$/) {
# Save a long package name wrapped onto its own line.
$wrapped = $1;
}
elsif (/^\s+replacing\s/i) {
# Ignore an old package shown below its replacement.
$wrapped = undef;
}
elsif (/^\s+\S/) {
# Parse a complete row, or the remainder of a wrapped row.
my @cols = split(/\s+/, &trim($_));
unshift(@cols, $wrapped) if ($wrapped);
$wrapped = undef;
next if (@cols < 4);
my $pkg = { 'name' => $cols[0],
'arch' => $cols[1],
'version' => $cols[2] };
if ($pkg->{'version'} =~ s/^(\S+)://) {
$pkg->{'epoch'} = $1;
}
push(@rv, $pkg);
}
}
close(DNF);
&reset_environment();
if (@relock) {
my $error = &restore_update_system_holds(\@relock);
return ( ) if ($error);
}
return @rv;
}
# show_update_system_opts()
# Returns HTML for enabling a repository, if any are disabled
sub show_update_system_opts
@@ -333,14 +503,19 @@ while(<PKG>) {
close(PKG);
}
# update_system_updates()
# Returns a list of package updates available from yum
# update_system_updates([include-holds])
# Returns available package updates, optionally including version-locked ones.
sub update_system_updates
{
my ($include_holds) = @_;
local @rv;
local %done;
if ($yum_command =~ /dnf/) {
&open_execute_command(PKG, "$yum_command check-update 2>/dev/null", 1, 1);
my $holdflag = $include_holds && &supports_update_system_holds() ?
" ".&update_system_hold_flags() : "";
$holdflag =~ s/\*/\\*/g;
&open_execute_command(PKG,
"$yum_command$holdflag check-update 2>/dev/null", 1, 1);
}
else {
&open_execute_command(PKG, "$yum_command check-update 2>/dev/null | tr '\n' '#' | sed -e 's/# / /g' | tr '#' '\n'", 1, 1);
@@ -361,10 +536,157 @@ while(<PKG>) {
last if (/Obsoleting\s+Packages/i);
}
close(PKG);
if (&supports_update_system_holds()) {
my %holds = map { $_, 1 } &list_update_system_holds();
foreach my $pkg (@rv) {
$pkg->{'held'} = 1 if ($holds{$pkg->{'name'}});
}
@rv = grep { !$_->{'held'} } @rv if (!$include_holds);
}
&set_yum_security_field(\%done);
return @rv;
}
# update_system_hold_spec_name(spec)
# Extracts the package name from an exact DNF 4 versionlock entry.
sub update_system_hold_spec_name
{
my ($spec) = @_;
return undef if ($spec =~ /^!/);
return $1 if ($spec =~ /^(.+)-\d+:[^-]+-[^-]+\.[^.]+$/);
return $1 if ($spec =~ /^\d+:(.+)-[^-]+-[^-]+\.[^.]+$/);
return undef;
}
# list_update_system_hold_specs()
# Returns exact locks that Webmin can safely manage by package name.
sub list_update_system_hold_specs
{
return ( ) if (!&supports_update_system_holds());
my @locks;
&clean_language();
&open_execute_command(VLOCK,
"$yum_command -q versionlock list 2>/dev/null", 1, 1);
if (&get_dnf_version() >= 5) {
# DNF 5 formats each lock as a multi-line package block.
my ($lock, @blocks);
while(<VLOCK>) {
s/\r|\n//g;
if (/^Package name:\s*(\S+)/) {
push(@blocks, $lock) if ($lock);
$lock = { 'name' => $1, 'spec' => $1 };
}
elsif ($lock && /^evr\s*=\s*(\S+)/) {
$lock->{'exact'} = 1;
}
elsif ($lock && /\S/ && !/^\s*#/) {
$lock->{'custom'} = 1;
}
elsif (!/\S/) {
push(@blocks, $lock) if ($lock);
$lock = undef;
}
}
push(@blocks, $lock) if ($lock);
# Ignore globs, duplicate entries and custom conditions because deleting
# them by name could remove rules that Webmin cannot restore.
my (%blocks, %custom);
foreach my $block (@blocks) {
$blocks{$block->{'name'}}++;
$custom{$block->{'name'}} = 1
if (!$block->{'exact'} || $block->{'custom'});
}
@locks = grep { !$custom{$_->{'name'}} &&
$blocks{$_->{'name'}} == 1 &&
$_->{'name'} !~ /[\*\?\[\]]/ } @blocks;
}
else {
# DNF 4 prints standard entries as name-epoch:version-release.arch.
while(<VLOCK>) {
s/\r|\n//g;
my $name = &update_system_hold_spec_name($_);
push(@locks, { 'name' => $name, 'spec' => $_ }) if ($name);
}
}
close(VLOCK);
&reset_environment();
return @locks;
}
# list_update_system_holds()
# Returns package names held by exact DNF version locks.
sub list_update_system_holds
{
my %holds = map { $_->{'name'}, 1 } &list_update_system_hold_specs();
return sort keys %holds;
}
# run_update_system_hold(action, package)
# Runs one versionlock operation. Returns undef on success, or error text.
sub run_update_system_hold
{
my ($action, $package) = @_;
my $cmd = "$yum_command -q versionlock $action ".quotemeta($package);
my $out;
&clean_language();
my $status = &execute_command_logged($cmd, undef, \$out, \$out);
&reset_environment();
if ($status) {
$out = &trim($out);
return $out || &text('yum_versionlock_failed', $action);
}
return undef;
}
# delete_update_system_holds(&packages, &removed)
# Unlocks selected packages and records each removed hold for restoration.
sub delete_update_system_holds
{
my ($packages, $removed) = @_;
my %held = map { $_, 1 } &list_update_system_holds();
my $error;
foreach my $package (&unique(@$packages)) {
next if (!$held{$package});
$error = &run_update_system_hold('delete', $package);
last if ($error);
push(@$removed, $package);
}
return $error;
}
# restore_update_system_holds(&packages)
# Re-locks packages at their currently installed versions.
sub restore_update_system_holds
{
my ($packages) = @_;
foreach my $package (&unique(@$packages)) {
my $error = &run_update_system_hold('add', $package);
return $error if ($error);
}
return undef;
}
# update_system_hold(&packages, hold)
# Holds or unholds DNF packages by name. Returns undef on success, or error text.
sub update_system_hold
{
my ($packages, $hold) = @_;
return $text{'yum_versionlock_missing'}
if (!&supports_update_system_holds());
my @packages = &unique(@$packages);
return $text{'yum_versionlock_none'} if (!@packages);
if ($hold) {
foreach my $package (@packages) {
my $error = &run_update_system_hold('add', $package);
return $error if ($error);
}
return undef;
}
my @removed;
return &delete_update_system_holds(\@packages, \@removed);
}
# get_yum_config()
# Returns entries from the YUM config file, as a list of hash references
sub get_yum_config
@@ -543,4 +865,3 @@ else {
}
1;

View File

@@ -24,7 +24,9 @@ is(strip_apt_package_arch('ncurses-base'), 'ncurses-base',
{
no warnings qw(once redefine);
my $command;
local *backquote_command = sub {
$command = $_[0];
return "Inst libtinfo6:amd64 [6.3-2ubuntu0.1] ".
"(6.3-2ubuntu0.2 Ubuntu:22.04/jammy-updates [amd64])\n";
};
@@ -34,6 +36,9 @@ local *reset_environment = sub { };
my @ops = update_system_operations('libtinfo6');
is($ops[0]->{'name'}, 'libtinfo6',
'normalizes package names from simulated APT operations');
update_system_operations('libtinfo6', update_system_hold_flags());
like($command, qr/^apt-get -s --allow-change-held-packages install /,
'overrides holds while resolving an explicit held-package update');
}
{

347
t/software-yum.t Normal file
View File

@@ -0,0 +1,347 @@
#!/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);
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');
}
{
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'; };
local *append_architectures = sub { return @_; };
local *package_info = sub { return ('bash'); };
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 @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 *open_execute_command = sub {
my ($fh, $command) = @_;
push(@commands, $command);
no strict 'refs';
open(ref($fh) ? $fh : \*{$fh}, '<', \$dnf_output)
or die "open simulated DNF updates: $!";
};
my @normal = update_system_updates(0);
is_deeply([ map { $_->{'name'} } @normal ], [ 'coreutils' ],
'DNF 4 regular updates exclude held packages');
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');
}
{
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');
}
done_testing();