From e695fbd0c756b54ddeebd41b1c1f5119c8f7a16d Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Fri, 14 Aug 2026 03:43:50 +0200 Subject: [PATCH] Add APT package hold management to Package Updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an APT-only “Held updates” view while keeping held packages excluded from normal and scheduled updates. - Allow administrators to hold or unhold packages from package details. - Allow explicitly selected held packages to be updated once, then restore all existing package holds. - Preserve configured `aptitude` behavior for regular installs while using `apt-get` for the held-package override. Fixes #2689 --- package-updates/CHANGELOG | 2 + package-updates/index.cgi | 35 ++++--- package-updates/lang/en | 22 +++++ package-updates/log_parser.pl | 3 + package-updates/package-updates-lib.pl | 84 ++++++++++++++--- package-updates/save_view.cgi | 10 +- package-updates/update.cgi | 60 +++++++++++- package-updates/view.cgi | 17 +++- software/CHANGELOG | 1 + software/apt-lib.pl | 124 ++++++++++++++++++------- software/lang/en | 1 + t/software-apt.t | 112 +++++++++++++++++++++- 12 files changed, 404 insertions(+), 67 deletions(-) diff --git a/package-updates/CHANGELOG b/package-updates/CHANGELOG index c6dd08cd9..806fb24bd 100644 --- a/package-updates/CHANGELOG +++ b/package-updates/CHANGELOG @@ -1,3 +1,5 @@ +---- Changes since 2.641 ---- +Added a Held updates view with controls to hold, unhold or explicitly update APT-held packages. ---- Changes since 1.490 ---- First version of this module. ---- Changes since 1.500 ---- diff --git a/package-updates/index.cgi b/package-updates/index.cgi index 8df9e0eb5..e0f820d28 100755 --- a/package-updates/index.cgi +++ b/package-updates/index.cgi @@ -9,6 +9,7 @@ if ($in{'clear'}) { $in{'search'} = ''; } $has_repos = defined(&software::list_package_repos); +$has_holds = &supports_package_holds(); # Start of mode tabs print &ui_tabs_start([ [ 'pkgs', $text{'index_tabpkgs'} ], @@ -23,7 +24,10 @@ $in{'mode'} ||= 'updates'; # Show mode selector (all, updates only, updates and new) @grid = ( ); -foreach $m ('current', 'updates', 'security', 'new') { +@modes = ('current', 'updates'); +push(@modes, 'held') if ($has_holds); +push(@modes, 'security', 'new'); +foreach $m (@modes) { $mmsg = $text{'index_mode_'.$m}; if ($in{'mode'} eq $m) { push(@mlinks, "$mmsg"); @@ -68,15 +72,21 @@ foreach $p (sort { $a->{'name'} cmp $b->{'name'} } (@current, @avail)) { $c = $current{$p->{'name'}."/".$p->{'system'}}; $a = $avail{$p->{'name'}."/".$p->{'system'}}; - if ($a && $c && (&compare_versions($a, $c) > 0 || $upmode)) { + if ($a && $c && (&compare_versions($a, $c) > 0 || $upmode || + $in{'mode'} eq 'held')) { # An update is available - $msg = "". - &text('index_new', $a->{'version'}).""; - $need = 1; + $msg = $a->{'held'} ? + "". + &text('index_held', $c->{'version'}, $a->{'version'}). + "" : + "". + &text('index_new', $a->{'version'}).""; + $need = $a->{'held'} ? 0 : 1; next if ($in{'mode'} eq 'security' && !$a->{'security'}); next if ($in{'mode'} ne 'updates' && $in{'mode'} ne 'current' && - $in{'mode'} ne 'security'); + $in{'mode'} ne 'security' && + $in{'mode'} ne 'held'); } elsif ($a && !$c) { # Could be installed, but isn't currently @@ -143,18 +153,20 @@ if ($in{'mode'} eq 'new' && !$in{'search'}) { } else { # Show the packages, if any + $update_label = $in{'mode'} eq 'new' ? $text{'index_install'} : + $in{'mode'} eq 'held' ? $text{'index_updateheld'} : + $text{'index_update'}; if (@rows) { print &text('index_count', scalar(@rows)),"
\n"; print &ui_form_start("update.cgi", "post"); - print &ui_submit($in{'mode'} eq 'new' ? $text{'index_install'} - : $text{'index_update'}, "ok_top" ); + print &ui_submit($update_label, "ok_top" ); print &ui_submit($text{'index_refresh'}, "refresh_top"), "
"; } + @buttons = ( [ "ok", $update_label ] ); + push(@buttons, [ "refresh", $text{'index_refresh'} ]); print &ui_form_columns_table( "", - [ [ "ok", $in{'mode'} eq 'new' ? $text{'index_install'} - : $text{'index_update'} ], - [ "refresh", $text{'index_refresh'} ] ], + \@buttons, 1, undef, [ [ "mode", $in{'mode'} ], @@ -296,4 +308,3 @@ if ($has_repos) { print &ui_tabs_end(1); &ui_print_footer("/", $text{'index'}); - diff --git a/package-updates/lang/en b/package-updates/lang/en index df86114cf..c865d450a 100644 --- a/package-updates/lang/en +++ b/package-updates/lang/en @@ -6,8 +6,10 @@ index_source=Source index_bad2=Update to version $1 not yet available : $1 index_bad=Update to version $1 advised : $2 index_new=New version $1 +index_held=Held at version $1; version $2 is available index_ok=Running latest $1 index_update=Update Selected Packages +index_updateheld=Update Selected Held Packages index_install=Install Selected Packages index_return=package list index_header=Scheduled checking options @@ -39,6 +41,7 @@ index_webmintheme=Webmin theme $1 index_mode=States to display: index_mode_current=Installed index_mode_updates=Only updates +index_mode_held=Held updates index_mode_new=Only new index_mode_security=Only security updates index_allsel=Packages to show: @@ -46,6 +49,7 @@ index_all_0=Only Virtualmin related index_all_1=All packages index_none_all=No packages managed by an update system were found on your system! index_none_updates=No packages available to be updated were found. +index_none_held=No held packages with available updates were found. index_none_both=No new packages or packages available to be updated were found. index_none_new=No new packages for installation were found. index_none_security=No packages available for security updates were found. @@ -100,6 +104,10 @@ update_rusure=Are you sure you wish to install the $1 packages listed below? Thi update_oldver=Current version 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_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 .. update_rebootdesc=One of the installed packages requires a reboot to be fully applied. @@ -114,6 +122,8 @@ log_schedup=Background installed $1 updated packages log_sched=Enabled scheduled updates log_unsched=Disabled scheduled updates log_refresh=Refreshed available packages +log_hold=Held updates for $1 packages +log_unhold=Unheld updates for $1 packages log_enable_repos=Enabled $1 package repositories log_disable_repos=Disabled $1 package repositories log_delete_repos=Deleted $1 package repositories @@ -140,6 +150,10 @@ view_source=Installation source view_changelog=Changelog for available version view_software=Manage Package view_update=Update Package +view_updateheld=Update Held Package +view_hold=Hold Package +view_unhold=Unhold Package +view_held=Held at version $1 view_install=Install Package system_yum=YUM @@ -159,4 +173,12 @@ repos_title=Delete Repositories repos_rusure=Are you sure you want to delete the $1 selected package repositories? Packages installed from them will still be available, but may not be updatable. repos_ok=Delete Now +hold_enotsupported=The active package update system does not support package holds +hold_enone=No packages were selected +hold_enotinstalled=Package $1 is not installed +hold_enotheld=Package $1 is not currently held +hold_esystem=Package $1 is not managed by the active package update system +hold_efailed=Failed to hold packages: $1 +unhold_efailed=Failed to unhold packages: $1 + __norefs=1 diff --git a/package-updates/log_parser.pl b/package-updates/log_parser.pl index fed1dbbd4..c5f7618ff 100644 --- a/package-updates/log_parser.pl +++ b/package-updates/log_parser.pl @@ -21,6 +21,9 @@ elsif ($action eq 'update') { elsif ($action eq 'schedup') { return &text('log_schedup', $object); } +elsif ($action eq 'hold' || $action eq 'unhold') { + return &text('log_'.$action, $object); + } elsif ($action eq 'sched') { return $text{$object ? 'log_sched' : 'log_unsched'}; } diff --git a/package-updates/package-updates-lib.pl b/package-updates/package-updates-lib.pl index 54eed7eb2..9612d7397 100644 --- a/package-updates/package-updates-lib.pl +++ b/package-updates/package-updates-lib.pl @@ -21,6 +21,7 @@ eval "use WebminCore;"; $available_cache_file = &cache_file_path("available.cache"); $current_cache_file = &cache_file_path("current.cache"); $updates_cache_file = &cache_file_path("updates.cache"); +$held_updates_cache_file = &cache_file_path("held-updates.cache"); $cron_cmd = "$module_config_directory/update.pl"; $yum_cache_file = &cache_file_path("yumcache"); @@ -308,30 +309,74 @@ sub supports_updates_available return defined(&software::update_system_updates); } -# updates_available(no-cache) +# supports_package_holds() +# 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); +} + +# list_package_holds() +# Returns the package names currently held by the update system. +sub list_package_holds +{ +return ( ) if (!&supports_package_holds()); +return &software::list_update_system_holds(); +} + +# package_is_held(package, [holds]) +# Returns true if a package is in a supplied or freshly-read list of holds. +sub package_is_held +{ +my ($name, $holds) = @_; +my @holds = $holds ? @$holds : &list_package_holds(); +return 1 if (grep { $_ eq $name } @holds); +if ($software::update_system eq 'apt' && + defined(&software::strip_apt_package_arch)) { + my $base = &software::strip_apt_package_arch($name); + return 1 if (grep { + &software::strip_apt_package_arch($_) eq $base + } @holds); + } +return 0; +} + +# update_package_holds(&packages, hold) +# Holds or unholds packages. Returns undef on success, or an error message. +sub update_package_holds +{ +my ($packages, $hold) = @_; +return $text{'hold_enotsupported'} if (!&supports_package_holds()); +return &software::update_system_hold($packages, $hold); +} + +# updates_available(no-cache, [include-held]) # Returns an array of hash refs of package updates available, according to # the update system, with caching. sub updates_available { -my ($nocache) = @_; -if (!scalar(@updates_available_cache)) { - if ($nocache || &cache_expired($updates_cache_file)) { +my ($nocache, $include_held) = @_; +my $cache_file = $include_held ? $held_updates_cache_file : + $updates_cache_file; +my $cache = $include_held ? \@held_updates_available_cache : + \@updates_available_cache; +if (!scalar(@$cache)) { + if ($nocache || &cache_expired($cache_file)) { # Get from original source - @updates_available_cache = &software::update_system_updates(); - foreach my $a (@updates_available_cache) { + @$cache = &software::update_system_updates($include_held); + foreach my $a (@$cache) { $a->{'update'} = $a->{'name'}; $a->{'system'} = $software::update_system; } - &write_cache_file($updates_cache_file, - \@updates_available_cache); + &write_cache_file($cache_file, $cache); } else { # Use on-disk cache - @updates_available_cache = - &read_cache_file($updates_cache_file); + @$cache = &read_cache_file($cache_file); } } -return @updates_available_cache; +return @$cache; } # package_install(package-name, [system], [new-install], [flags]) @@ -343,12 +388,14 @@ 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'; # First get from list of updates ($pkg) = grep { $_->{'update'} eq $name && ($_->{'system'} eq $system || !$system) } sort { &compare_versions($b, $a) } - &list_possible_updates(0); + &list_possible_updates(0, 0, $include_held); if (!$pkg) { # Then try list of all available packages ($pkg) = grep { $_->{'update'} eq $name && @@ -458,14 +505,14 @@ if (defined(&software::update_system_operations)) { return ( ); } -# list_possible_updates([nocache], [nocache-no-data]) +# list_possible_updates([nocache], [nocache-no-data], [include-held]) # Returns a list of updates that are available. Each element in the array # is a hash ref containing a name, version, description and severity flag. # Intended for calling from themes. Nocache 0=cache everything, 1=flush all # caches, 2=flush only current. Nocache-no-data prohibits collecting data sub list_possible_updates { -my ($nocache, $nocache_no_data) = @_; +my ($nocache, $nocache_no_data, $include_held) = @_; my @rv; return @rv if ($nocache_no_data); my @current = &list_current($nocache); @@ -476,9 +523,10 @@ if (&supports_updates_available()) { foreach my $c (@current) { $currentmap{$c->{'name'},$c->{'system'}} ||= $c; } - foreach my $a (&updates_available($nocache == 1)) { + foreach my $a (&updates_available($nocache == 1, $include_held)) { my $c = $currentmap{$a->{'name'},$a->{'system'}}; next if (!$c); + next if ($a->{'held'} && !$include_held); next if ($a->{'version'} eq $c->{'version'} && $a->{'epoch'} eq $c->{'epoch'}); push(@rv, { 'name' => $a->{'name'}, @@ -489,6 +537,7 @@ if (&supports_updates_available()) { 'epoch' => $a->{'epoch'}, 'oldepoch' => $c->{'epoch'}, 'security' => $a->{'security'}, + 'held' => $a->{'held'}, 'source' => $a->{'source'}, 'desc' => $c->{'desc'} || $a->{'desc'} }); } @@ -700,10 +749,13 @@ sub flush_package_caches { unlink($current_cache_file); unlink($updates_cache_file); +unlink($held_updates_cache_file); unlink($available_cache_file); unlink($available_cache_file.'0'); unlink($available_cache_file.'1'); @packages_available_cache = ( ); +@updates_available_cache = ( ); +@held_updates_available_cache = ( ); %read_cache_file_cache = ( ); } @@ -713,6 +765,8 @@ unlink($available_cache_file.'1'); sub list_for_mode { my ($mode, $nocache) = @_; +return grep { $_->{'held'} } + &list_possible_updates($nocache, 0, 1) if ($mode eq 'held'); return $mode eq 'updates' || $mode eq 'security' ? &list_possible_updates($nocache) : &list_available($nocache); } diff --git a/package-updates/save_view.cgi b/package-updates/save_view.cgi index 2fd5f2866..fbff27020 100755 --- a/package-updates/save_view.cgi +++ b/package-updates/save_view.cgi @@ -8,8 +8,14 @@ if ($in{'software'}) { &redirect("../software/edit_pack.cgi?package=".&urlize($in{'name'}). "&version=".&urlize($in{'version'})); } -else { +elsif ($in{'hold'} || $in{'unhold'}) { + $action = $in{'hold'} ? "hold" : "unhold"; &redirect("update.cgi?u=".&urlize($in{'name'}."/".$in{'system'}). - "&all=$in{'all'}&mode=$in{'mode'}"); + "&$action=1&mode=".&urlize($in{'mode'})); + } +else { + $mode = $in{'held'} ? "held" : $in{'mode'}; + &redirect("update.cgi?u=".&urlize($in{'name'}."/".$in{'system'}). + "&all=$in{'all'}&mode=".&urlize($mode)); } diff --git a/package-updates/update.cgi b/package-updates/update.cgi index af66b8d30..4a2b88602 100755 --- a/package-updates/update.cgi +++ b/package-updates/update.cgi @@ -19,7 +19,38 @@ else { $redir =~ /\?/ ? "$redir&tab=pkgs" : "$redir?tab=pkgs"; } -if ($in{'refresh'} || $in{'refresh_top'}) { +$hold_action = $in{'hold'} ? 1 : $in{'unhold'} ? 0 : undef; +if (defined($hold_action)) { + # Hold or unhold selected packages + &supports_package_holds() || &error($text{'hold_enotsupported'}); + @holdpkgs = split(/\0/, $in{'u'}); + @holdpkgs || &error($text{'hold_enone'}); + @current = &list_current(1); + %current = map { $_->{'name'}."/".$_->{'system'}, 1 } @current; + @held = &list_package_holds(); + @holdnames = ( ); + foreach $ps (@holdpkgs) { + ($p, $s) = split(/\//, $ps, 2); + $current{$p."/".$s} || &error(&text('hold_enotinstalled', $p)); + $s eq $software::update_system || + &error(&text('hold_esystem', $p)); + if (!$hold_action && !&package_is_held($p, \@held)) { + &error(&text('hold_enotheld', $p)); + } + push(@holdnames, $p); + } + @holdnames = &unique(@holdnames); + $err = &update_package_holds(\@holdnames, $hold_action); + &error(&text($hold_action ? 'hold_efailed' : 'unhold_efailed', $err)) + if ($err); + &flush_package_caches(); + $logaction = $hold_action ? 'hold' : 'unhold'; + &webmin_log($logaction, "packages", scalar(@holdnames), + { 'packages' => \@holdnames }); + &redirect("index.cgi?mode=".&urlize($in{'mode'}). + "&search=".&urlize($in{'search'})); + } +elsif ($in{'refresh'} || $in{'refresh_top'}) { &ui_print_unbuffered_header(undef, $text{'refresh_title'}, ""); # Clear all caches @@ -40,6 +71,21 @@ else { # Upgrade some packages my @pkgs = split(/\0/, $in{'u'}); @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. + &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) || + &error(&text('update_enotheld', $p)); + } + $allow_held = 1; + } + $install_flags = $allow_held ? '--allow-change-held-packages' : + $in{'flags'}; &ui_print_unbuffered_header(undef, $in{'mode'} eq 'new' ? $text{'update_title2'} : $text{'update_title'}, ""); @@ -57,6 +103,7 @@ else { push(@pkgnames, $p); } @ops = &list_package_operations(join(" ", @pkgnames), $s); + &error($text{'update_enoheldops'}) if (!@ops && $allow_held); } if (@ops) { @@ -74,9 +121,14 @@ else { foreach $ps (@pkgs) { $confform .= &ui_hidden("u", $ps); } + $confform .= &ui_alert_box($text{'update_heldnote'}, + 'warn', undef, undef, '') + if ($allow_held && !$bottom); $confform .= &text('update_rusure', scalar(@ops)),"

\n" if (!$bottom); - $confform .= &ui_form_end([ [ "confirm", $text{'update_confirm'} ] ]); + $confform .= &ui_form_end([ [ "confirm", + $allow_held ? $text{'update_confirmheld'} : + $text{'update_confirm'} ] ]); }; print &$getconfform(); @@ -127,7 +179,7 @@ else { "
\n"; print "


\n"; } else { @@ -138,7 +190,7 @@ else { print &text($msg, "@{[&html_escape($p)]}"),"
\n"; print "