From f01d65a26e3a09d1b8ee139d1c689589595f9b93 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sun, 23 Aug 2026 23:03:46 +0200 Subject: [PATCH 1/2] Add DNF versionlock support to Package Updates This PR adds DNF 4 and DNF 5 versionlock support to Package Updates, including listing, holding, unholding, and explicitly updating held packages. DNF 4 requires the versionlock plugin to be installed, while DNF 5 supports versionlock natively. Hold controls are shown only when the versionlock command is available. It also previews DNF transactions before confirmation so users can see which packages will be installed or updated in the table, similar to APT. --- package-updates/CHANGELOG | 1 + package-updates/lang/en | 2 +- package-updates/package-updates-lib.pl | 22 +- package-updates/update.cgi | 11 +- software/CHANGELOG | 1 + software/apt-lib.pl | 23 +- software/lang/en | 5 + software/yum-lib.pl | 363 +++++++++++++++++++++++-- t/software-apt.t | 5 + t/software-yum.t | 347 +++++++++++++++++++++++ 10 files changed, 737 insertions(+), 43 deletions(-) create mode 100644 t/software-yum.t diff --git a/package-updates/CHANGELOG b/package-updates/CHANGELOG index d67c3c1c7..9f6845d98 100644 --- a/package-updates/CHANGELOG +++ b/package-updates/CHANGELOG @@ -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. diff --git a/package-updates/lang/en b/package-updates/lang/en index a8009cd81..54a83fe30 100644 --- a/package-updates/lang/en +++ b/package-updates/lang/en @@ -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 .. diff --git a/package-updates/package-updates-lib.pl b/package-updates/package-updates-lib.pl index 4953bf16c..cb6356d8a 100644 --- a/package-updates/package-updates-lib.pl +++ b/package-updates/package-updates-lib.pl @@ -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; } diff --git a/package-updates/update.cgi b/package-updates/update.cgi index 4a2b88602..9d871700f 100755 --- a/package-updates/update.cgi +++ b/package-updates/update.cgi @@ -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); } diff --git a/software/CHANGELOG b/software/CHANGELOG index 82ef37064..23095748d 100644 --- a/software/CHANGELOG +++ b/software/CHANGELOG @@ -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. diff --git a/software/apt-lib.pl b/software/apt-lib.pl index ccf1ac286..37b4bba8a 100755 --- a/software/apt-lib.pl +++ b/software/apt-lib.pl @@ -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)). " &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) = @_; diff --git a/software/lang/en b/software/lang/en index aa84dbace..d69df21ce 100644 --- a/software/lang/en +++ b/software/lang/en @@ -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 diff --git a/software/yum-lib.pl b/software/yum-lib.pl index 42722eab3..68328cec7 100755 --- a/software/yum-lib.pl +++ b/software/yum-lib.pl @@ -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 &1 = 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', + "".&html_escape(join(" ", @packages))."", + &html_escape($relock_error)),"

\n"; + } + print &text('yum_unholdfailed', + "".&html_escape(join(" ", @packages))."", + &html_escape($unlock_error)),"

\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() { } } 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() { $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() { } } 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', + "".&html_escape(join(" ", &unique(@updates)))."", + &html_escape($relock_error)),"

\n"; + } + } print "\n"; -if ($? || $nopackage) { +if ($status || $nopackage) { print "$text{'yum_failed'}

\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() { + 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() { 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() { 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() { + 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() { + 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; - diff --git a/t/software-apt.t b/t/software-apt.t index 3e05ec256..5e2c1e317 100644 --- a/t/software-apt.t +++ b/t/software-apt.t @@ -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'); } { diff --git a/t/software-yum.t b/t/software-yum.t new file mode 100644 index 000000000..f64bbb2fa --- /dev/null +++ b/t/software-yum.t @@ -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(); From 86d3b95f3bfb249fbbef1acc6be947e8d3f999a7 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sun, 23 Aug 2026 23:13:35 +0200 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce36adc2c..acbdaff4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 #### 2.660 (August 20, 2026) * Add support for creating `vfsv1` Linux quota files for limits above 4 TiB, while preserving existing quota file formats