From 78bf92fe0d6ac19bc04183946329cfc72d119523 Mon Sep 17 00:00:00 2001 From: anandhx Date: Fri, 6 Nov 2026 17:40:28 +0530 Subject: [PATCH] fix: select base images and hotfixes during PAN-OS upgrades Upgrading 10.1.13 to 10.1.13-h1 crashed because a release with no hotfix was compared as None. The next minor hop also picked 10.2.0-h3 when 10.2.0 was available, and PAN-OS will not load that hotfix until the base image is downloaded. Co-authored-by: Cursor --- panos/updater.py | 39 +++++++++++++++++++++++++++++++++------ tests/test_updater.py | 26 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/panos/updater.py b/panos/updater.py index 01f34180..26724f58 100644 --- a/panos/updater.py +++ b/panos/updater.py @@ -29,6 +29,26 @@ logger = getlogger(__name__) +def _maintenance_num(version): + """Hotfix number for a version. A base image such as 10.1.13 ranks as 0.""" + if version.subrelease_num is None: + return 0 + return version.subrelease_num + + +def _base_image_preferred(candidate, incumbent): + """True when candidate should replace an equal-patch incumbent. + + PAN-OS will not load 10.2.0-h3 until the 10.2.0 base image is downloaded, + so the base image is the right next hop when both are published. + """ + candidate_is_base = candidate.subrelease_type != "h" + incumbent_is_base = incumbent.subrelease_type != "h" + if candidate_is_base != incumbent_is_base: + return candidate_is_base + return _maintenance_num(candidate) < _maintenance_num(incumbent) + + class Updater(object): """This class is instantiated by the PanDevice class as a software update subsystem""" @@ -418,7 +438,7 @@ def _next_minor_version(self, version, available_version_list): # Initial values before iterating over list of available versions next_minor_found = False - next_minor_patch_number = 999 + next_version = None # Create a map or PanOSVersion objects to represent all the # available versions the device has reported, and iterate @@ -432,10 +452,16 @@ def _next_minor_version(self, version, available_version_list): # Examples: 9.1 from 9.0, 10.2 from 10.1 next_minor_found = True - if available_version.patch < next_minor_patch_number: - # Looking for the lowest patch version of this newer - # minor version - next_minor_patch_number = available_version.patch + if ( + next_version is None + or available_version.patch < next_version.patch + or ( + available_version.patch == next_version.patch + and _base_image_preferred(available_version, next_version) + ) + ): + # Lowest patch of the next minor. On that patch, the base + # image (10.2.0) must be chosen ahead of a hotfix (10.2.0-h3). next_version = available_version if not next_minor_found: @@ -516,7 +542,8 @@ def _direct_upgrade_possible( current_version.patch < target_version.patch or ( current_version.patch == target_version.patch - and current_version.subrelease_num < target_version.subrelease_num + and _maintenance_num(current_version) + < _maintenance_num(target_version) ) ) ): diff --git a/tests/test_updater.py b/tests/test_updater.py index 669ff666..baee7e71 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -215,6 +215,32 @@ def test__direct_upgrade_possible_firewall_valid_expected_next_versions( ) +def test_direct_upgrade_from_base_release_to_hotfix(): + """Issue #565: 10.1.13 -> 10.1.13-h1 used to raise TypeError.""" + fw = _fw() + assert fw.software._direct_upgrade_possible( + "10.1.13", "10.1.13-h1", ["10.1.13", "10.1.13-h1"] + ) + assert fw.software._direct_upgrade_possible( + "10.1.13-h1", "10.1.13-h3", ["10.1.13-h1", "10.1.13-h3"] + ) + assert not fw.software._direct_upgrade_possible( + "10.1.13-h1", "10.1.13", ["10.1.13", "10.1.13-h1"] + ) + + +def test_next_minor_prefers_base_image_over_hotfix(): + """Issue #572: 10.2.0-h3 was selected even though 10.2.0 was published.""" + fw = _fw() + versions = ["10.2.0-h3", "10.2.4", "10.2.0", "10.1.16"] + nxt = fw.software._next_minor_version(PanOSVersion("10.1.16"), versions) + assert str(nxt) == "10.2.0" + + only_hotfix = ["10.2.0-h3", "10.1.16"] + nxt = fw.software._next_minor_version(PanOSVersion("10.1.16"), only_hotfix) + assert str(nxt) == "10.2.0-h3" + + # Test invalid direct upgrades return False @pytest.mark.parametrize( "input_version, target_version", firewall_invalid_direct_upgrade_paths