From 2ef0864e834baace9b96d264dbf8929cdb8b7858 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:53:01 +0000 Subject: [PATCH 01/20] Bump requests from 2.32.4 to 2.33.0 Bumps [requests](https://github.com/psf/requests) from 2.32.4 to 2.33.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.4...v2.33.0) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3f358230..126a51b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-bootstrap3==23.1 python-debian==1.0.1 defusedxml==0.7.1 PyYAML==6.0.2 -requests==2.32.4 +requests==2.33.0 colorama==0.4.6 djangorestframework==3.15.2 djangorestframework-api-key==3.0.0 From 0e392a0b783799e707b422d0a6b3d27d4dc96f8c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 22:35:42 -0500 Subject: [PATCH 02/20] use sets instead of lists for update tracking --- hosts/models.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/hosts/models.py b/hosts/models.py index 38c33ff1..167b680f 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -213,7 +213,7 @@ def find_updates(self): kernel_update_ids = self.find_kernel_updates(kernel_packages, repo_packages) for ku_id in kernel_update_ids: - update_ids.append(ku_id) + update_ids.add(ku_id) for update in self.updates.all(): if update.id not in update_ids: @@ -225,7 +225,7 @@ def find_updates(self): def find_host_repo_updates(self, host_packages, repo_packages, errata_ids): - update_ids = [] + update_ids = set() hostrepos_q = Q(repo__mirror__enabled=True, repo__mirror__refresh=True, repo__mirror__repo__enabled=True, @@ -280,12 +280,12 @@ def find_host_repo_updates(self, host_packages, repo_packages, errata_ids): if highest_package != package: uid = self.process_update(package, highest_package) if uid is not None: - update_ids.append(uid) + update_ids.add(uid) return update_ids def find_osrelease_repo_updates(self, host_packages, repo_packages, errata_ids): - update_ids = [] + update_ids = set() for package in host_packages: highest_package = package @@ -318,7 +318,7 @@ def find_osrelease_repo_updates(self, host_packages, repo_packages, errata_ids): if highest_package != package: uid = self.process_update(package, highest_package) if uid is not None: - update_ids.append(uid) + update_ids.add(uid) return update_ids def check_if_reboot_required(self, host_highest): @@ -406,7 +406,7 @@ def _get_running_kernel_flavour(self): def find_kernel_updates(self, kernel_packages, repo_packages): - update_ids = [] + update_ids = set() self.reboot_required = False # build hostrepos for priority filtering (same as find_host_repo_updates) @@ -423,16 +423,16 @@ def find_kernel_updates(self, kernel_packages, repo_packages): rpm_kernels = kernel_packages.filter(packagetype='R') arch_kernels = kernel_packages.filter(packagetype='A') - update_ids.extend(self._find_rpm_kernel_updates(rpm_kernels, repo_packages, hostrepos)) - update_ids.extend(self._find_deb_kernel_updates(deb_kernels, repo_packages, hostrepos)) - update_ids.extend(self._find_arch_kernel_updates(arch_kernels, repo_packages, hostrepos)) + update_ids.update(self._find_rpm_kernel_updates(rpm_kernels, repo_packages, hostrepos)) + update_ids.update(self._find_deb_kernel_updates(deb_kernels, repo_packages, hostrepos)) + update_ids.update(self._find_arch_kernel_updates(arch_kernels, repo_packages, hostrepos)) self.save(update_fields=['reboot_required']) return update_ids def _find_rpm_kernel_updates(self, kernel_packages, repo_packages, hostrepos): - update_ids = [] + update_ids = set() # parse running kernel version for comparison parts = self.kernel.split('-') @@ -498,7 +498,7 @@ def _find_rpm_kernel_updates(self, kernel_packages, repo_packages, hostrepos): if base_package and base_package.compare_version(repo_highest) == -1: uid = self.process_update(base_package, repo_highest) if uid is not None: - update_ids.append(uid) + update_ids.add(uid) # reboot check only on primary kernel packages if host_highest and package.name.name in ( @@ -513,7 +513,7 @@ def _find_rpm_kernel_updates(self, kernel_packages, repo_packages, hostrepos): def _find_arch_kernel_updates(self, kernel_packages, repo_packages, hostrepos): - update_ids = [] + update_ids = set() for package in kernel_packages: pu_q = Q(name=package.name) @@ -540,7 +540,7 @@ def _find_arch_kernel_updates(self, kernel_packages, repo_packages, hostrepos): if package.compare_version(repo_highest) == -1: uid = self.process_update(package, repo_highest) if uid is not None: - update_ids.append(uid) + update_ids.add(uid) # reboot check for main kernel packages (not -headers) # Arch uname -r format varies by flavour: @@ -564,7 +564,7 @@ def _find_arch_kernel_updates(self, kernel_packages, repo_packages, hostrepos): def _find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): - update_ids = [] + update_ids = set() running_flavour = self._get_running_kernel_flavour() # find the linux-image package matching the running kernel @@ -638,7 +638,7 @@ def _find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): if base_package.compare_version(repo_highest) == -1: uid = self.process_update(base_package, repo_highest) if uid is not None: - update_ids.append(uid) + update_ids.add(uid) # reboot check: see if a newer linux-image is installed but not running # use compare_version (DEB semantics) instead of labelCompare From eea7d6db28f99e2b8d5dbe6a1a270242e157df57 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 26 Mar 2026 23:26:20 -0400 Subject: [PATCH 03/20] merge duplicate update-finding methods into find_repo_updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove find_osrelease_repo_updates which duplicated find_host_repo_updates logic without host-repo filtering. the host_repos_only branch was the only caller differentiator — now handled in a single find_repo_updates. --- hosts/models.py | 47 +++-------------------------------------------- 1 file changed, 3 insertions(+), 44 deletions(-) diff --git a/hosts/models.py b/hosts/models.py index 167b680f..b70342f5 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -206,10 +206,7 @@ def find_updates(self): errata_ids = set() - if self.host_repos_only: - update_ids = self.find_host_repo_updates(host_packages, repo_packages, errata_ids) - else: - update_ids = self.find_osrelease_repo_updates(host_packages, repo_packages, errata_ids) + update_ids = self.find_repo_updates(host_packages, repo_packages, errata_ids) kernel_update_ids = self.find_kernel_updates(kernel_packages, repo_packages) for ku_id in kernel_update_ids: @@ -223,7 +220,7 @@ def find_updates(self): if erratum.id not in errata_ids: self.errata.remove(erratum) - def find_host_repo_updates(self, host_packages, repo_packages, errata_ids): + def find_repo_updates(self, host_packages, repo_packages, errata_ids): update_ids = set() hostrepos_q = Q(repo__mirror__enabled=True, @@ -283,44 +280,6 @@ def find_host_repo_updates(self, host_packages, repo_packages, errata_ids): update_ids.add(uid) return update_ids - def find_osrelease_repo_updates(self, host_packages, repo_packages, errata_ids): - - update_ids = set() - for package in host_packages: - highest_package = package - - # find the packages that are potential updates - pu_q = Q(name=package.name, - arch=package.arch, - packagetype=package.packagetype) - potential_updates = repo_packages.filter(pu_q) - for pu in potential_updates: - pu_is_module_package = False - pu_in_enabled_modules = False - if pu.module_set.exists(): - pu_is_module_package = True - for module in pu.module_set.all(): - if module in self.modules.all(): - pu_in_enabled_modules = True - if pu_is_module_package: - if not pu_in_enabled_modules: - continue - if package.compare_version(pu) == -1: - # package updates that are fixed by erratum (may already be superceded by another update) - errata = pu.provides_fix_in_erratum.all() - if errata: - for erratum in errata: - self.errata.add(erratum) - errata_ids.add(erratum.id) - if highest_package.compare_version(pu) == -1: - highest_package = pu - - if highest_package != package: - uid = self.process_update(package, highest_package) - if uid is not None: - update_ids.add(uid) - return update_ids - def check_if_reboot_required(self, host_highest): """Check if a reboot is required (running kernel < installed highest). @@ -409,7 +368,7 @@ def find_kernel_updates(self, kernel_packages, repo_packages): update_ids = set() self.reboot_required = False - # build hostrepos for priority filtering (same as find_host_repo_updates) + # build hostrepos for priority filtering (same as find_repo_updates) hostrepos = None if self.host_repos_only: hostrepos_q = Q(repo__mirror__enabled=True, From 84cc4293454dbc4be66dbbd36c0ae48497ab58ba Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 30 Mar 2026 23:15:08 -0400 Subject: [PATCH 04/20] remove underscore prefix from kernel helper methods rename to match codebase convention: get_deb_kernel_flavour, get_running_kernel_flavour, find_rpm_kernel_updates, find_deb_kernel_updates, find_arch_kernel_updates, deb_kernel_prefixes. --- hosts/models.py | 28 ++++++++++++++-------------- hosts/tests/test_models.py | 18 +++++++++--------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/hosts/models.py b/hosts/models.py index b70342f5..c5d2da83 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -309,7 +309,7 @@ def check_if_reboot_required(self, host_highest): else: self.reboot_required = False - def _get_deb_kernel_flavour(self, pkg_name): + def get_deb_kernel_flavour(self, pkg_name): """Extract the flavour suffix from a DEB kernel package name. e.g. 'linux-image-6.8.0-51-generic' → 'generic' @@ -318,7 +318,7 @@ def _get_deb_kernel_flavour(self, pkg_name): 'linux-modules-extra-6.8.0-51-generic' → 'generic' Returns None if the flavour cannot be determined. """ - for prefix in self._deb_kernel_prefixes: + for prefix in self.deb_kernel_prefixes: if pkg_name.startswith(prefix): # strip prefix, then split version from flavour # e.g. '6.8.0-51-generic' or '6.1.0-28-cloud-amd64' @@ -333,7 +333,7 @@ def _get_deb_kernel_flavour(self, pkg_name): return None return None - def _get_running_kernel_flavour(self): + def get_running_kernel_flavour(self): """Extract the flavour from the running kernel string. e.g. '6.8.0-51-generic' → 'generic' @@ -350,7 +350,7 @@ def _get_running_kernel_flavour(self): return None # longest prefixes first to avoid linux-modules- matching linux-modules-extra- - _deb_kernel_prefixes = [ + deb_kernel_prefixes = [ 'linux-image-unsigned-', 'linux-modules-extra-', 'linux-cloud-tools-', @@ -382,14 +382,14 @@ def find_kernel_updates(self, kernel_packages, repo_packages): rpm_kernels = kernel_packages.filter(packagetype='R') arch_kernels = kernel_packages.filter(packagetype='A') - update_ids.update(self._find_rpm_kernel_updates(rpm_kernels, repo_packages, hostrepos)) - update_ids.update(self._find_deb_kernel_updates(deb_kernels, repo_packages, hostrepos)) - update_ids.update(self._find_arch_kernel_updates(arch_kernels, repo_packages, hostrepos)) + update_ids.update(self.find_rpm_kernel_updates(rpm_kernels, repo_packages, hostrepos)) + update_ids.update(self.find_deb_kernel_updates(deb_kernels, repo_packages, hostrepos)) + update_ids.update(self.find_arch_kernel_updates(arch_kernels, repo_packages, hostrepos)) self.save(update_fields=['reboot_required']) return update_ids - def _find_rpm_kernel_updates(self, kernel_packages, repo_packages, hostrepos): + def find_rpm_kernel_updates(self, kernel_packages, repo_packages, hostrepos): update_ids = set() @@ -470,7 +470,7 @@ def _find_rpm_kernel_updates(self, kernel_packages, repo_packages, hostrepos): return update_ids - def _find_arch_kernel_updates(self, kernel_packages, repo_packages, hostrepos): + def find_arch_kernel_updates(self, kernel_packages, repo_packages, hostrepos): update_ids = set() @@ -521,10 +521,10 @@ def _find_arch_kernel_updates(self, kernel_packages, repo_packages, hostrepos): return update_ids - def _find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): + def find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): update_ids = set() - running_flavour = self._get_running_kernel_flavour() + running_flavour = self.get_running_kernel_flavour() # find the linux-image package matching the running kernel running_kernel_pkg = None @@ -544,7 +544,7 @@ def _find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): processed_prefixes = set() for package in kernel_packages: pkg_name = package.name.name - flavour = self._get_deb_kernel_flavour(pkg_name) + flavour = self.get_deb_kernel_flavour(pkg_name) # if we know the running flavour, only process matching packages # if we don't (unflavoured kernel), process all kernel packages @@ -553,7 +553,7 @@ def _find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): # determine the prefix (e.g. 'linux-image-') prefix = None - for p in self._deb_kernel_prefixes: + for p in self.deb_kernel_prefixes: if pkg_name.startswith(p): prefix = p break @@ -604,7 +604,7 @@ def _find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): if running_kernel_pkg: for package in kernel_packages: if package.name.name.startswith('linux-image-'): - flavour = self._get_deb_kernel_flavour(package.name.name) + flavour = self.get_deb_kernel_flavour(package.name.name) if running_flavour is None or flavour == running_flavour: if running_kernel_pkg.compare_version(package) == -1: self.reboot_required = True diff --git a/hosts/tests/test_models.py b/hosts/tests/test_models.py index 86a846bf..411492f0 100644 --- a/hosts/tests/test_models.py +++ b/hosts/tests/test_models.py @@ -544,35 +544,35 @@ def test_deb_latest_not_installed(self): self.assertEqual(update.newpackage, self.img_53) def test_deb_flavour_extraction(self): - """Test _get_deb_kernel_flavour helper.""" + """Test get_deb_kernel_flavour helper.""" host = self._create_host('6.8.0-51-generic', [self.img_51]) self.assertEqual( - host._get_deb_kernel_flavour('linux-image-6.8.0-51-generic'), + host.get_deb_kernel_flavour('linux-image-6.8.0-51-generic'), 'generic' ) self.assertEqual( - host._get_deb_kernel_flavour('linux-modules-extra-6.8.0-51-lowlatency'), + host.get_deb_kernel_flavour('linux-modules-extra-6.8.0-51-lowlatency'), 'lowlatency' ) self.assertEqual( - host._get_deb_kernel_flavour('linux-image-6.1.0-28-cloud-amd64'), + host.get_deb_kernel_flavour('linux-image-6.1.0-28-cloud-amd64'), 'cloud-amd64' ) self.assertEqual( - host._get_deb_kernel_flavour('linux-image-unsigned-6.8.0-51-generic'), + host.get_deb_kernel_flavour('linux-image-unsigned-6.8.0-51-generic'), 'generic' ) def test_deb_running_kernel_flavour(self): - """Test _get_running_kernel_flavour helper.""" + """Test get_running_kernel_flavour helper.""" host = self._create_host('6.8.0-51-generic', [self.img_51]) - self.assertEqual(host._get_running_kernel_flavour(), 'generic') + self.assertEqual(host.get_running_kernel_flavour(), 'generic') host.kernel = '6.1.0-28-cloud-amd64' - self.assertEqual(host._get_running_kernel_flavour(), 'cloud-amd64') + self.assertEqual(host.get_running_kernel_flavour(), 'cloud-amd64') host.kernel = '5.14.0-503.el9' - self.assertIsNone(host._get_running_kernel_flavour()) + self.assertIsNone(host.get_running_kernel_flavour()) @override_settings( From de53ae7012eabee37526589c38bee5d522745014 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 30 Mar 2026 23:46:24 -0400 Subject: [PATCH 05/20] don't rename repos from client reports repo names are set at creation via get_or_create_repo and should not be overwritten by subsequent client reports. the admin may have renamed the repo in the web ui, and mixed client versions can report different name formats for the same mirror url, causing unique constraint errors. --- reports/tests/test_utils.py | 29 +++++++++++++++++++++++++++++ reports/utils.py | 3 --- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/reports/tests/test_utils.py b/reports/tests/test_utils.py index b139669b..0f99c83a 100644 --- a/reports/tests/test_utils.py +++ b/reports/tests/test_utils.py @@ -227,6 +227,35 @@ def test_process_repo_json_rpm(self): # RPM priority is negated self.assertEqual(priority, -99) + def test_process_repo_no_rename_on_existing_mirror(self): + """Repo found by mirror URL should keep its original name. + + Repo names are set at creation and should not be overwritten by + client reports — the admin may have renamed the repo in the UI. + """ + repo, _ = process_repo( + r_type=Repository.RPM, + r_name='Zabbix Official Repository - x86_64 x86_64', + r_id='zabbix', + r_priority=-99, + urls=['https://repo.zabbix.com/zabbix/6.0/rhel/9/x86_64'], + arch='x86_64', + ) + self.assertEqual(repo.name, 'Zabbix Official Repository - x86_64 x86_64') + + # updated client reports same URL with corrected name + repo2, _ = process_repo( + r_type=Repository.RPM, + r_name='Zabbix Official Repository - x86_64', + r_id='zabbix', + r_priority=-99, + urls=['https://repo.zabbix.com/zabbix/6.0/rhel/9/x86_64'], + arch='x86_64', + ) + # should return the same repo, name unchanged + self.assertEqual(repo2.id, repo.id) + self.assertEqual(repo2.name, 'Zabbix Official Repository - x86_64 x86_64') + @override_settings( CELERY_TASK_ALWAYS_EAGER=True, diff --git a/reports/utils.py b/reports/utils.py index 9b2a4196..4fea67d3 100644 --- a/reports/utils.py +++ b/reports/utils.py @@ -258,9 +258,6 @@ def process_repo(r_type, r_name, r_id, r_priority, urls, arch): if r_id and repository.repo_id != r_id: repository.repo_id = r_id - if r_name and repository.name != r_name: - repository.name = r_name - for url in unknown: Mirror.objects.create(repo=repository, url=url.rstrip('/')) From b96e4f7eb2a1c73ff6f21afbcf12bbd33067a74e Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 30 Mar 2026 23:17:08 -0400 Subject: [PATCH 06/20] filter deb kernel updates by major.minor series prevent HWE kernels (e.g. 6.17) from being offered as updates to GA kernel hosts (e.g. 6.8) when both tracks ship in the same repository at the same priority. extract major.minor series from the deb kernel package name and only compare within the same series. --- hosts/models.py | 26 +++++++++ hosts/tests/test_models.py | 106 +++++++++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/hosts/models.py b/hosts/models.py index c5d2da83..37e67779 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see +import re + from django.db import models from django.db.models import Q from django.urls import reverse @@ -363,6 +365,21 @@ def get_running_kernel_flavour(self): 'linux-tools-', ] + def get_deb_kernel_series(self, pkg_name): + """Extract kernel major.minor series from a DEB kernel package name. + + e.g. 'linux-image-6.8.0-51-generic' → '6.8' + 'linux-image-6.17.0-19-generic' → '6.17' + 'linux-modules-extra-6.1.0-28-cloud-amd64' → '6.1' + Returns None if the series cannot be determined. + """ + for prefix in self.deb_kernel_prefixes: + if pkg_name.startswith(prefix): + remainder = pkg_name[len(prefix):] + m = re.match(r'(\d+\.\d+)', remainder) + return m.group(1) if m else None + return None + def find_kernel_updates(self, kernel_packages, repo_packages): update_ids = set() @@ -561,6 +578,10 @@ def find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): continue processed_prefixes.add(prefix) + # extract kernel series (e.g. '6.8') to avoid cross-track + # comparisons (GA 6.8 vs HWE 6.17 in the same repo) + installed_series = self.get_deb_kernel_series(pkg_name) + # build endswith filter for flavoured kernels name_filter = Q( name__name__startswith=prefix, @@ -570,8 +591,13 @@ def find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): name_filter &= Q(name__name__endswith=f'-{running_flavour}') # find repo highest for this prefix+flavour, respecting priority + # and kernel series (GA vs HWE) repo_highest = None for rp in repo_packages.filter(name_filter): + if installed_series is not None: + rp_series = self.get_deb_kernel_series(rp.name.name) + if rp_series != installed_series: + continue if priority is not None: rp_best_repo = find_best_repo(rp, hostrepos) if not rp_best_repo or rp_best_repo.priority < priority: diff --git a/hosts/tests/test_models.py b/hosts/tests/test_models.py index 411492f0..72198ca6 100644 --- a/hosts/tests/test_models.py +++ b/hosts/tests/test_models.py @@ -574,6 +574,92 @@ def test_deb_running_kernel_flavour(self): host.kernel = '5.14.0-503.el9' self.assertIsNone(host.get_running_kernel_flavour()) + def test_deb_kernel_series_extraction(self): + """Test get_deb_kernel_series helper.""" + host = self._create_host('6.8.0-51-generic', [self.img_51]) + self.assertEqual( + host.get_deb_kernel_series('linux-image-6.8.0-51-generic'), '6.8' + ) + self.assertEqual( + host.get_deb_kernel_series('linux-image-6.17.0-19-generic'), '6.17' + ) + self.assertEqual( + host.get_deb_kernel_series('linux-modules-extra-6.1.0-28-cloud-amd64'), '6.1' + ) + self.assertEqual( + host.get_deb_kernel_series('linux-image-unsigned-6.8.0-51-generic'), '6.8' + ) + self.assertIsNone(host.get_deb_kernel_series('not-a-kernel')) + + def test_deb_hwe_not_offered_to_ga_host(self): + """HWE kernels (6.17) should not be offered as updates to GA (6.8) hosts. + + Reproduces GitHub issue: both GA and HWE kernels in the same repo + (noble-updates), same priority. Without series filtering, 6.17 would + be incorrectly picked as an update for a 6.8 host. + """ + # add HWE kernel packages to the same repo/mirror + hwe_img_name = PackageName.objects.create( + name='linux-image-6.17.0-19-generic' + ) + hwe_img = Package.objects.create( + name=hwe_img_name, arch=self.pkg_arch, epoch='', + version='6.17.0-19.19~24.04.2', release='', packagetype='D' + ) + self.mirror.packages.add(hwe_img) + + host = self._create_host( + '6.8.0-51-generic', + [self.img_49, self.img_51], + ) + repo_packages = Package.objects.filter(mirror=self.mirror) + kernel_packages = host.packages.filter( + name__name__startswith='linux-image-' + ) + + host.find_kernel_updates(kernel_packages, repo_packages) + + # should get GA update (6.8.0-51 → 6.8.0-53), NOT HWE (6.17) + self.assertEqual(host.updates.count(), 1) + update = host.updates.first() + self.assertEqual(update.oldpackage, self.img_51) + self.assertEqual(update.newpackage, self.img_53) + + def test_deb_hwe_host_gets_hwe_updates(self): + """HWE host (6.17) should get HWE updates, not GA.""" + hwe_img_19_name = PackageName.objects.create( + name='linux-image-6.17.0-19-generic' + ) + hwe_img_19 = Package.objects.create( + name=hwe_img_19_name, arch=self.pkg_arch, epoch='', + version='6.17.0-19.19~24.04.2', release='', packagetype='D' + ) + hwe_img_21_name = PackageName.objects.create( + name='linux-image-6.17.0-21-generic' + ) + hwe_img_21 = Package.objects.create( + name=hwe_img_21_name, arch=self.pkg_arch, epoch='', + version='6.17.0-21.21~24.04.2', release='', packagetype='D' + ) + self.mirror.packages.add(hwe_img_19, hwe_img_21) + + host = self._create_host( + '6.17.0-19-generic', + [hwe_img_19], + ) + repo_packages = Package.objects.filter(mirror=self.mirror) + kernel_packages = host.packages.filter( + name__name__startswith='linux-image-' + ) + + host.find_kernel_updates(kernel_packages, repo_packages) + + # should get HWE update only + self.assertEqual(host.updates.count(), 1) + update = host.updates.first() + self.assertEqual(update.oldpackage, hwe_img_19) + self.assertEqual(update.newpackage, hwe_img_21) + @override_settings( CELERY_TASK_ALWAYS_EAGER=True, @@ -791,8 +877,10 @@ def test_deb_backports_lower_priority_no_update(self): self.assertEqual(host.updates.count(), 0) - def test_deb_backports_equal_priority_shows_update(self): - """DEB: backports kernel with equal priority SHOULD be flagged.""" + def test_deb_backports_equal_priority_no_update(self): + """DEB: backports kernel with equal priority but different series + should NOT be flagged — series filtering prevents cross-track updates. + """ host = self._create_host(main_priority=500, bp_priority=500) repo_packages = Package.objects.filter( mirror__in=[self.main_mirror, self.bp_mirror] @@ -803,10 +891,10 @@ def test_deb_backports_equal_priority_shows_update(self): host.find_kernel_updates(kernel_packages, repo_packages) - self.assertEqual(host.updates.count(), 1) + self.assertEqual(host.updates.count(), 0) - def test_deb_priority_zero_no_filtering(self): - """DEB: priority 0 (unset) means no filtering — backward compat.""" + def test_deb_priority_zero_no_cross_series_update(self): + """DEB: priority 0 (unset) still respects series filtering.""" host = self._create_host(main_priority=0, bp_priority=0) repo_packages = Package.objects.filter( mirror__in=[self.main_mirror, self.bp_mirror] @@ -817,10 +905,10 @@ def test_deb_priority_zero_no_filtering(self): host.find_kernel_updates(kernel_packages, repo_packages) - self.assertEqual(host.updates.count(), 1) + self.assertEqual(host.updates.count(), 0) - def test_deb_host_repos_only_false_no_filtering(self): - """DEB: host_repos_only=False skips priority filtering entirely.""" + def test_deb_host_repos_only_false_no_cross_series_update(self): + """DEB: host_repos_only=False still respects series filtering.""" host = self._create_host( main_priority=500, bp_priority=100, host_repos_only=False, ) @@ -833,7 +921,7 @@ def test_deb_host_repos_only_false_no_filtering(self): host.find_kernel_updates(kernel_packages, repo_packages) - self.assertEqual(host.updates.count(), 1) + self.assertEqual(host.updates.count(), 0) def test_rpm_backports_lower_priority_no_update(self): """RPM: kernel from lower-priority repo should NOT be flagged.""" From a0384a0ab49bf6da2f8ab875e9ccdd19c1418e72 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 4 Mar 2026 21:00:13 -0500 Subject: [PATCH 07/20] add package updates list view with table, filters, and nav entry - add PackageUpdateTable with installed/available package links and security badges - add package_update_list view with security type and search filters - add /packages/updates/ url route - add packages submenu in navbar (packages + updates) - add 6 view tests --- hosts/tables.py | 6 +- hosts/views.py | 3 + packages/tables.py | 73 ++++++++++++++++++- .../packages/package_update_list.html | 7 ++ packages/tests/test_views.py | 72 ++++++++++++++++++ packages/urls.py | 1 + packages/views.py | 46 +++++++++++- util/templates/navbar.html | 8 +- 8 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 packages/templates/packages/package_update_list.html create mode 100644 packages/tests/test_views.py diff --git a/hosts/tables.py b/hosts/tables.py index b34b7d61..296b8df3 100644 --- a/hosts/tables.py +++ b/hosts/tables.py @@ -22,12 +22,14 @@ HOSTNAME_TEMPLATE = '{{ record.hostname }}' SEC_UPDATES_TEMPLATE = ( '{% with count=record.get_num_security_updates %}' - '{% if count != 0 %}{{ count }}{% else %}{% endif %}' + '{% if count != 0 %}' + '{{ count }}{% endif %}' '{% endwith %}' ) BUG_UPDATES_TEMPLATE = ( '{% with count=record.get_num_bugfix_updates %}' - '{% if count != 0 %}{{ count }}{% else %}{% endif %}' + '{% if count != 0 %}' + '{{ count }}{% endif %}' '{% endwith %}' ) AFFECTED_ERRATA_TEMPLATE = ( diff --git a/hosts/views.py b/hosts/views.py index f940c3a4..b4e002dd 100644 --- a/hosts/views.py +++ b/hosts/views.py @@ -90,6 +90,9 @@ def host_list(request): if 'package' in request.GET: hosts = hosts.filter(packages__name__name=request.GET['package']) + if 'update_id' in request.GET: + hosts = hosts.filter(updates=request.GET['update_id']) + if 'repo_id' in request.GET: hosts = hosts.filter(repos=request.GET['repo_id']) diff --git a/packages/tables.py b/packages/tables.py index 633c79a2..0004c79a 100644 --- a/packages/tables.py +++ b/packages/tables.py @@ -14,7 +14,7 @@ import django_tables2 as tables -from packages.models import Package, PackageName +from packages.models import Package, PackageName, PackageUpdate from util.tables import BaseTable PACKAGE_NAME_TEMPLATE = '{{ record }}' @@ -118,3 +118,74 @@ class PackageNameTable(BaseTable): class Meta(BaseTable.Meta): model = PackageName fields = ('packagename_name', 'versions') + + +UPDATE_OLD_TEMPLATE = ( + '' + '{{ record.oldpackage }}' +) +UPDATE_NEW_TEMPLATE = ( + '' + '{{ record.newpackage }}' +) +UPDATE_HOSTS_TEMPLATE = ( + '' + '{{ record.host_count }}' +) +UPDATE_AFFECTED_TEMPLATE = ( + '' + '{{ record.affected_count }}' +) +UPDATE_FIXED_TEMPLATE = ( + '' + '{{ record.fixed_count }}' +) + + +UPDATE_TYPE_TEMPLATE = ( + '{% if record.security %}' + 'Security' + '{% else %}' + 'Bugfix' + '{% endif %}' +) + + +class PackageUpdateTable(BaseTable): + oldpackage = tables.TemplateColumn( + UPDATE_OLD_TEMPLATE, + verbose_name='Installed', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + newpackage = tables.TemplateColumn( + UPDATE_NEW_TEMPLATE, + verbose_name='Available', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + security = tables.TemplateColumn( + UPDATE_TYPE_TEMPLATE, + verbose_name='Type', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + hosts = tables.TemplateColumn( + UPDATE_HOSTS_TEMPLATE, + verbose_name='Hosts', + order_by='host_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + affected = tables.TemplateColumn( + UPDATE_AFFECTED_TEMPLATE, + verbose_name='Affected by Errata', + order_by='affected_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + fixed = tables.TemplateColumn( + UPDATE_FIXED_TEMPLATE, + verbose_name='Fixed in Errata', + order_by='fixed_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = PackageUpdate + fields = ('oldpackage', 'newpackage', 'security', 'hosts', 'affected', 'fixed') diff --git a/packages/templates/packages/package_update_list.html b/packages/templates/packages/package_update_list.html new file mode 100644 index 00000000..62c2557d --- /dev/null +++ b/packages/templates/packages/package_update_list.html @@ -0,0 +1,7 @@ +{% extends "objectlist.html" %} + +{% block page_title %}Package Updates{% endblock %} + +{% block breadcrumbs %} {{ block.super }}
  • Package Updates
  • {% endblock %} + +{% block content_title %} Package Updates {% endblock %} diff --git a/packages/tests/test_views.py b/packages/tests/test_views.py new file mode 100644 index 00000000..9fc86abd --- /dev/null +++ b/packages/tests/test_views.py @@ -0,0 +1,72 @@ +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse + +from arch.models import PackageArchitecture +from packages.models import Package, PackageName, PackageUpdate + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageUpdateViewTests(TestCase): + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', password='testpass' + ) + self.client.login(username='testuser', password='testpass') + self.arch = PackageArchitecture.objects.create(name='x86_64') + self.name = PackageName.objects.create(name='openssl') + self.old = Package.objects.create( + name=self.name, arch=self.arch, epoch='', + version='1.1.1', release='1', packagetype='R', + ) + self.new = Package.objects.create( + name=self.name, arch=self.arch, epoch='', + version='1.1.2', release='1', packagetype='R', + ) + self.sec_update = PackageUpdate.objects.create( + oldpackage=self.old, newpackage=self.new, security=True, + ) + self.bug_update = PackageUpdate.objects.create( + oldpackage=self.old, newpackage=self.new, security=False, + ) + + def test_update_list(self): + resp = self.client.get(reverse('packages:package_update_list')) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'openssl') + + def test_update_list_filter_security(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'security': 'true'} + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Security') + + def test_update_list_filter_bugfix(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'security': 'false'} + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Bugfix') + + def test_update_list_search(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'search': 'openssl'} + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'openssl') + + def test_update_list_search_no_results(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'search': 'nonexistent'} + ) + self.assertEqual(resp.status_code, 200) + + def test_update_list_requires_login(self): + self.client.logout() + resp = self.client.get(reverse('packages:package_update_list')) + self.assertEqual(resp.status_code, 302) diff --git a/packages/urls.py b/packages/urls.py index bc027807..75273450 100644 --- a/packages/urls.py +++ b/packages/urls.py @@ -27,4 +27,5 @@ path('name//', views.package_name_detail, name='package_name_detail'), path('id/', views.package_list, name='package_list'), path('id//', views.package_detail, name='package_detail'), + path('updates/', views.package_update_list, name='package_update_list'), ] diff --git a/packages/views.py b/packages/views.py index 9f75b415..6afa4f4d 100644 --- a/packages/views.py +++ b/packages/views.py @@ -16,7 +16,7 @@ # along with Patchman. If not, see from django.contrib.auth.decorators import login_required -from django.db.models import Q +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, render from django_tables2 import RequestConfig from rest_framework import viewsets @@ -26,7 +26,7 @@ from packages.serializers import ( PackageNameSerializer, PackageSerializer, PackageUpdateSerializer, ) -from packages.tables import PackageNameTable, PackageTable +from packages.tables import PackageNameTable, PackageTable, PackageUpdateTable from util.filterspecs import Filter, FilterBar @@ -172,6 +172,48 @@ def package_name_detail(request, packagename): 'allversions': allversions}) +@login_required +def package_update_list(request): + updates = PackageUpdate.objects.select_related( + 'oldpackage__name', 'oldpackage__arch', + 'newpackage__name', 'newpackage__arch', + ).annotate( + host_count=Count('host', distinct=True), + affected_count=Count('oldpackage__affected_by_erratum', distinct=True), + fixed_count=Count('newpackage__provides_fix_in_erratum', distinct=True), + ) + + if 'security' in request.GET: + security = request.GET['security'] == 'true' + updates = updates.filter(security=security) + if 'host_id' in request.GET: + updates = updates.filter(host=request.GET['host_id']) + if 'search' in request.GET: + terms = request.GET['search'].lower() + query = Q() + for term in terms.split(' '): + q = (Q(oldpackage__name__name__icontains=term) | + Q(newpackage__name__name__icontains=term)) + query = query & q + updates = updates.filter(query) + else: + terms = '' + + filter_list = [] + filter_list.append(Filter(request, 'Type', 'security', + {'true': 'Security', 'false': 'Bugfix'})) + filter_bar = FilterBar(request, filter_list) + + table = PackageUpdateTable(updates.distinct()) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + + return render(request, + 'packages/package_update_list.html', + {'table': table, + 'filter_bar': filter_bar, + 'terms': terms}) + + class PackageNameViewSet(viewsets.ModelViewSet): """ API endpoint that allows package names to be viewed or edited. diff --git a/util/templates/navbar.html b/util/templates/navbar.html index 263be6a4..d07c40d5 100644 --- a/util/templates/navbar.html +++ b/util/templates/navbar.html @@ -12,7 +12,13 @@
  • Mirrors
  • -
  • Packages
  • +
  • + Packages + +
  • Errata
  • Security From c13727677a13e3b22e31c0fbdb5d0f2a7ea6d7e0 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 31 Mar 2026 14:06:32 -0400 Subject: [PATCH 08/20] add sortable columns to package list and name detail views annotate package querysets with host_count, repo_count, affected_count, fixed_count to replace N+1 template calls. make repositories, hosts, affected, and fixed columns sortable on the package list view. convert package name detail from raw html table to django-tables2. add sortable hosts column to the package name list view. --- packages/tables.py | 34 +++++++++++++------ .../packages/package_name_detail.html | 29 ++-------------- packages/views.py | 22 ++++++++++-- 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/packages/tables.py b/packages/tables.py index 0004c79a..6b41ac03 100644 --- a/packages/tables.py +++ b/packages/tables.py @@ -24,15 +24,15 @@ ) PACKAGE_HOSTS_TEMPLATE = ( '' - 'Installed on {{ record.host_set.count }} Hosts' + 'Installed on {{ record.host_count }} Hosts' ) AFFECTED_TEMPLATE = ( '' - 'Affected by {{ record.affected_by_erratum.count }} Errata' + 'Affected by {{ record.affected_count }} Errata' ) FIXED_TEMPLATE = ( '' - 'Provides fix in {{ record.provides_fix_in_erratum.count }} Errata' + 'Provides fix in {{ record.fixed_count }} Errata' ) @@ -71,25 +71,25 @@ class PackageTable(BaseTable): package_repos = tables.TemplateColumn( PACKAGE_REPOS_TEMPLATE, verbose_name='Repositories', - orderable=False, + order_by='repo_count', attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, ) package_hosts = tables.TemplateColumn( PACKAGE_HOSTS_TEMPLATE, verbose_name='Hosts', - orderable=False, + order_by='host_count', attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, ) affected = tables.TemplateColumn( AFFECTED_TEMPLATE, verbose_name='Affected', - orderable=False, + order_by='affected_count', attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, ) fixed = tables.TemplateColumn( FIXED_TEMPLATE, verbose_name='Fixed', - orderable=False, + order_by='fixed_count', attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, ) @@ -101,23 +101,35 @@ class Meta(BaseTable.Meta): ) +PACKAGE_NAME_HOSTS_TEMPLATE = ( + '' + '{{ record.host_count }}' +) + + class PackageNameTable(BaseTable): packagename_name = tables.TemplateColumn( PACKAGE_NAME_TEMPLATE, order_by='name', verbose_name='Package', - attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6'}}, + attrs={'th': {'class': 'col-sm-5'}, 'td': {'class': 'col-sm-5'}}, ) versions = tables.TemplateColumn( '{{ record.package_set.count }}', orderable=False, - verbose_name='Versions available', - attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6'}}, + verbose_name='Versions', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + hosts = tables.TemplateColumn( + PACKAGE_NAME_HOSTS_TEMPLATE, + verbose_name='Hosts', + order_by='host_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, ) class Meta(BaseTable.Meta): model = PackageName - fields = ('packagename_name', 'versions') + fields = ('packagename_name', 'versions', 'hosts') UPDATE_OLD_TEMPLATE = ( diff --git a/packages/templates/packages/package_name_detail.html b/packages/templates/packages/package_name_detail.html index 5cef00ec..2dfc8d29 100644 --- a/packages/templates/packages/package_name_detail.html +++ b/packages/templates/packages/package_name_detail.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load django_tables2 %} {% block page_title %}Package - {{ package }} {% endblock %} @@ -9,32 +10,8 @@ {% block content %}
    - {% if allversions %} - - - - - - - - - - - - - {% for version in allversions %} - - - - - - - - - - - {% endfor %} -
    PackageEpochVersionReleaseArchTypeRepositoriesHostsErrata
    {{ version }} {{ version.epoch }} {{ version.version }} {{ version.release }} {{ version.arch }} {{ version.get_packagetype_display }} Available from {{ version.repo_count }} Repositories Installed on {{ version.host_set.count }} Hosts Affected by {{ version.affected_by_erratum.count }} Errata Provides fix in {{ version.provides_fix_in_erratum.count }} Errata
    + {% if table.rows %} + {% render_table table %} {% else %} No versions of this Package exist. {% endif %} diff --git a/packages/views.py b/packages/views.py index 6afa4f4d..0f2aaecb 100644 --- a/packages/views.py +++ b/packages/views.py @@ -109,6 +109,13 @@ def package_list(request): filter_list.append(Filter(request, 'Architecture', 'arch_id', PackageArchitecture.objects.all())) filter_bar = FilterBar(request, filter_list) + packages = packages.annotate( + host_count=Count('host', distinct=True), + repo_count=Count('mirror__repo', distinct=True), + affected_count=Count('affected_by_erratum', distinct=True), + fixed_count=Count('provides_fix_in_erratum', distinct=True), + ) + table = PackageTable(packages) RequestConfig(request, paginate={'per_page': 50}).configure(table) @@ -144,6 +151,8 @@ def package_name_list(request): filter_list.append(Filter(request, 'Architecture', 'arch_id', PackageArchitecture.objects.all())) filter_bar = FilterBar(request, filter_list) + packages = packages.annotate(host_count=Count('package__host', distinct=True)) + table = PackageNameTable(packages) RequestConfig(request, paginate={'per_page': 50}).configure(table) @@ -165,11 +174,20 @@ def package_detail(request, package_id): @login_required def package_name_detail(request, packagename): package = get_object_or_404(PackageName, name=packagename) - allversions = Package.objects.select_related('name', 'arch').filter(name=package.id) + allversions = Package.objects.select_related( + 'name', 'arch', + ).filter(name=package.id).annotate( + host_count=Count('host', distinct=True), + repo_count=Count('mirror__repo', distinct=True), + affected_count=Count('affected_by_erratum', distinct=True), + fixed_count=Count('provides_fix_in_erratum', distinct=True), + ) + table = PackageTable(allversions) + RequestConfig(request, paginate={'per_page': 50}).configure(table) return render(request, 'packages/package_name_detail.html', {'package': package, - 'allversions': allversions}) + 'table': table}) @login_required From 71c97d37a2fd15b4f7713cbf1b55107fb0c1f6b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:02:59 +0000 Subject: [PATCH 09/20] Bump django from 4.2.29 to 4.2.30 Bumps [django](https://github.com/django/django) from 4.2.29 to 4.2.30. - [Commits](https://github.com/django/django/compare/4.2.29...4.2.30) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.30 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 126a51b1..62441847 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.29 +Django==4.2.30 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From 57b88f495eb8f7567a8a0b49665c96093c96155c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Sat, 11 Apr 2026 15:57:31 -0400 Subject: [PATCH 10/20] add celery worker resilience for database connection timeouts - close stale db connections before each task via task_prerun signal (mirrors what django does for http requests) - add Restart=on-failure to worker and beat systemd services - add --loglevel info to worker service for diagnostics --- etc/systemd/system/patchman-celery-beat.service | 2 ++ etc/systemd/system/patchman-celery-worker@.service | 3 +++ patchman/celery.py | 14 ++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/etc/systemd/system/patchman-celery-beat.service b/etc/systemd/system/patchman-celery-beat.service index c9bce722..79a5bc5c 100644 --- a/etc/systemd/system/patchman-celery-beat.service +++ b/etc/systemd/system/patchman-celery-beat.service @@ -5,6 +5,8 @@ After=network-online.target [Service] Type=simple +Restart=on-failure +RestartSec=10 User=patchman Group=patchman Environment="REDIS_HOST=127.0.0.1" diff --git a/etc/systemd/system/patchman-celery-worker@.service b/etc/systemd/system/patchman-celery-worker@.service index b2d6f6b7..7e10bd85 100644 --- a/etc/systemd/system/patchman-celery-worker@.service +++ b/etc/systemd/system/patchman-celery-worker@.service @@ -5,6 +5,8 @@ After=network-online.target [Service] Type=simple +Restart=on-failure +RestartSec=10 User=patchman Group=patchman Environment="REDIS_HOST=127.0.0.1" @@ -19,6 +21,7 @@ ExecStart=/usr/bin/celery \ --task-events \ --pool ${CELERY_POOL_TYPE} \ --concurrency ${CELERY_CONCURRENCY} \ + --loglevel info \ --hostname patchman-celery-worker%i@%%h [Install] diff --git a/patchman/celery.py b/patchman/celery.py index c47f994d..a1f3188e 100644 --- a/patchman/celery.py +++ b/patchman/celery.py @@ -17,6 +17,7 @@ import os from celery import Celery +from celery.signals import task_prerun os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'patchman.settings') # noqa from django.conf import settings # noqa @@ -24,3 +25,16 @@ app = Celery('patchman') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks() + + +@task_prerun.connect +def close_stale_connections(**kwargs): + """Close stale DB connections before each task. + + Django does this automatically for HTTP requests but not for Celery + tasks. Without this, long-lived workers hit 'server has gone away' + (MySQL) or 'server closed the connection unexpectedly' (PostgreSQL) + when the DB server drops idle connections. + """ + from django import db + db.close_old_connections() From 9d04908a839a89c3e0fbef2965c5dabb7ec71f24 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Sat, 11 Apr 2026 15:57:35 -0400 Subject: [PATCH 11/20] auto-enable wal mode for sqlite backend - override get_new_connection to set journal_mode=wal automatically - eliminates need for manual pragma calls in post-install scripts --- patchman/sqlite3/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/patchman/sqlite3/base.py b/patchman/sqlite3/base.py index c7ba0c6f..8e1ea030 100644 --- a/patchman/sqlite3/base.py +++ b/patchman/sqlite3/base.py @@ -20,6 +20,11 @@ class DatabaseWrapper(base.DatabaseWrapper): + def get_new_connection(self, conn_params): + conn = super().get_new_connection(conn_params) + conn.execute('PRAGMA journal_mode=WAL') + return conn + def _start_transaction_under_autocommit(self): # Acquire a write lock immediately for transactions self.cursor().execute('BEGIN IMMEDIATE') From 5eb5e52419caa8c870d4be2a871b503640f74a14 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 7 Apr 2026 15:28:22 -0400 Subject: [PATCH 12/20] fix duplicate verbose_name_plural in report model meta first assignment was meant to be verbose_name (singular). --- reports/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reports/models.py b/reports/models.py index a0f2bbef..578e2c46 100644 --- a/reports/models.py +++ b/reports/models.py @@ -46,7 +46,7 @@ class Report(models.Model): reboot = models.TextField(null=True, blank=True) class Meta: - verbose_name_plural = 'Report' + verbose_name = 'Report' verbose_name_plural = 'Reports' ordering = ['-created'] From ad8d648cfc216addd252f3c5678c46d5c0a80a2c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 7 Apr 2026 15:28:28 -0400 Subject: [PATCH 13/20] sanitize filter_params in bulk action views five bulk action views passed raw POST filter_params into redirects without calling sanitize_filter_params(), unlike the rest of the codebase. --- operatingsystems/views.py | 4 ++-- reports/views.py | 2 +- repos/views.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/operatingsystems/views.py b/operatingsystems/views.py index 1a6b3fda..40824d0d 100644 --- a/operatingsystems/views.py +++ b/operatingsystems/views.py @@ -269,7 +269,7 @@ def osvariant_bulk_action(request): action = request.POST.get('action', '') select_all_filtered = request.POST.get('select_all_filtered') == '1' - filter_params = request.POST.get('filter_params', '') + filter_params = sanitize_filter_params(request.POST.get('filter_params', '')) if not action: messages.warning(request, 'Please select an action') @@ -310,7 +310,7 @@ def osrelease_bulk_action(request): action = request.POST.get('action', '') select_all_filtered = request.POST.get('select_all_filtered') == '1' - filter_params = request.POST.get('filter_params', '') + filter_params = sanitize_filter_params(request.POST.get('filter_params', '')) if not action: messages.warning(request, 'Please select an action') diff --git a/reports/views.py b/reports/views.py index e5d1d438..2aab3e54 100644 --- a/reports/views.py +++ b/reports/views.py @@ -220,7 +220,7 @@ def report_bulk_action(request): action = request.POST.get('action', '') select_all_filtered = request.POST.get('select_all_filtered') == '1' - filter_params = request.POST.get('filter_params', '') + filter_params = sanitize_filter_params(request.POST.get('filter_params', '')) if not action: messages.warning(request, 'Please select an action') diff --git a/repos/views.py b/repos/views.py index 1a796c11..7a804a19 100644 --- a/repos/views.py +++ b/repos/views.py @@ -452,7 +452,7 @@ def repo_bulk_action(request): action = request.POST.get('action', '') select_all_filtered = request.POST.get('select_all_filtered') == '1' - filter_params = request.POST.get('filter_params', '') + filter_params = sanitize_filter_params(request.POST.get('filter_params', '')) if not action: messages.warning(request, 'Please select an action') @@ -531,7 +531,7 @@ def mirror_bulk_action(request): action = request.POST.get('action', '') select_all_filtered = request.POST.get('select_all_filtered') == '1' - filter_params = request.POST.get('filter_params', '') + filter_params = sanitize_filter_params(request.POST.get('filter_params', '')) if not action: messages.warning(request, 'Please select an action') From e2fed70b3da5e703d766a4f888193557f3943dbe Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 7 Apr 2026 15:28:17 -0400 Subject: [PATCH 14/20] add null guard for missing references element in updateinfo xml find() returns None when the element doesn't exist, causing AttributeError on the subsequent findall() call. --- errata/sources/repos/yum.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/errata/sources/repos/yum.py b/errata/sources/repos/yum.py index 992830c4..72813436 100644 --- a/errata/sources/repos/yum.py +++ b/errata/sources/repos/yum.py @@ -136,6 +136,8 @@ def add_updateinfo_erratum_references(e, update, ref_type, urls): for url in urls: e.add_reference(ref_type, url) references = update.find('references') + if references is None: + return for reference in references.findall('reference'): if reference.attrib.get('type') == 'cve': cve_id = reference.attrib.get('id') From 0c30dcbb3fd005391901c494ddac4f374065021e Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 7 Apr 2026 15:28:13 -0400 Subject: [PATCH 15/20] return early on yaml parse error in extract_module_metadata missing return after except meant code fell through to iterate an unassigned variable, causing UnboundLocalError. --- repos/repo_types/yum.py | 1 + 1 file changed, 1 insertion(+) diff --git a/repos/repo_types/yum.py b/repos/repo_types/yum.py index 1e96db39..24584fe9 100644 --- a/repos/repo_types/yum.py +++ b/repos/repo_types/yum.py @@ -68,6 +68,7 @@ def extract_module_metadata(data, url, repo): modules_yaml = yaml.safe_load_all(extracted) except yaml.YAMLError as e: error_message(text=f'Error parsing modules.yaml: {e}') + return modules mlen = len(re.findall(r'---', yaml.dump(extracted.decode()))) pbar_start.send(sender=None, ptext=f'Extracting {mlen} Modules ', plen=mlen) From 578e4a39ecdbd1b392f1cd359e3a0bdb52d8e80f Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 1 Apr 2026 23:27:55 -0400 Subject: [PATCH 16/20] fix null url handling in osv.dev cve references - skip references with null urls in parse_osv_dev_cve_data - bail early from fixup_reference when urlparse has no hostname --- security/models.py | 4 +++- security/utils.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/security/models.py b/security/models.py index 4a19f9ca..aab22882 100644 --- a/security/models.py +++ b/security/models.py @@ -179,8 +179,10 @@ def parse_osv_dev_cve_data(self, cve_json): references = cve_json.get('references') if references: for reference in references: - ref_type = reference.get('type').capitalize() url = reference.get('url') + if not url: + continue + ref_type = reference.get('type').capitalize() get_or_create_reference(ref_type, url) scores = cve_json.get('severity') if scores: diff --git a/security/utils.py b/security/utils.py index 745cf1f8..4033fc68 100644 --- a/security/utils.py +++ b/security/utils.py @@ -95,15 +95,19 @@ def fixup_reference(ref): """ Fix up a Security Reference object to normalize the URL and type """ url = urlparse(ref.get('url')) + if not url.hostname: + return ref ref_type = ref.get('ref_type') - if 'lists' in url.hostname or 'lists' in url.path: + hostname = url.hostname + if 'lists' in hostname or 'lists' in url.path: ref_type = 'Mailing List' - if ref_type == 'bugzilla' or 'bug' in url.hostname or 'bugs' in url.path: + if ref_type == 'bugzilla' or 'bug' in hostname or 'bugs' in url.path: ref_type = 'Bug Tracker' url = fixup_ubuntu_usn_url(url) - if url.hostname == 'ubuntu.com' and url.path.startswith('/security/notices/USN'): + hostname = url.hostname + if hostname == 'ubuntu.com' and url.path.startswith('/security/notices/USN'): ref_type = 'USN' - if 'launchpad.net' in url.hostname: + if 'launchpad.net' in hostname: ref_type = 'Bug Tracker' netloc = url.netloc.replace('bugs.', '') bug = url.path.split('/')[-1] From bb53742cb9ad245bae2a939858a042c00bd05127 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 31 Mar 2026 23:27:34 -0400 Subject: [PATCH 17/20] send info messages to stdout instead of stderr tqdm.write(file=sys.stdout) replaces logger.info() for info messages so that patchman -lh, -lr etc. can be piped through grep and other standard unix tools. warnings and errors remain on stderr. --- patchman/receivers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/patchman/receivers.py b/patchman/receivers.py index 9393d891..99a544c9 100644 --- a/patchman/receivers.py +++ b/patchman/receivers.py @@ -15,9 +15,12 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see +import sys + from colorama import Fore, Style, init from django.conf import settings from django.dispatch import receiver +from tqdm import tqdm from tqdm.contrib.logging import logging_redirect_tqdm from patchman.signals import ( @@ -54,9 +57,7 @@ def print_info_message(**kwargs): """ text = str(kwargs.get('text')) if not get_quiet_mode(): - with logging_redirect_tqdm(loggers=[logger]): - for line in text.splitlines(): - logger.info(Style.RESET_ALL + Fore.RESET + line) + tqdm.write(text, file=sys.stdout) @receiver(warning_message_s) From 159ceca114cc6bc4f57a5c9e219ef46bb21d0757 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 13 Apr 2026 23:38:31 -0400 Subject: [PATCH 18/20] move function-level import to top-level --- patchman/celery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patchman/celery.py b/patchman/celery.py index a1f3188e..95c0c4fe 100644 --- a/patchman/celery.py +++ b/patchman/celery.py @@ -20,6 +20,7 @@ from celery.signals import task_prerun os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'patchman.settings') # noqa +from django import db # noqa from django.conf import settings # noqa app = Celery('patchman') @@ -36,5 +37,4 @@ def close_stale_connections(**kwargs): (MySQL) or 'server closed the connection unexpectedly' (PostgreSQL) when the DB server drops idle connections. """ - from django import db db.close_old_connections() From 847964af3d670906f6210fd56ac2e9f45c24e754 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 21 Apr 2026 22:23:06 -0400 Subject: [PATCH 19/20] bulk db optimizations for errata processing - optimize scan_for_security_updates with queryset update and __in filter - add _mark_updates_security helper with bulk update and IntegrityError fallback - optimize parse_osv_dev_data with Q batch filter for affected versions - bulk M2M add for add_fixed_packages/add_affected_packages - add get_matching_packages_q for batch version lookups - batch cve adds in parse_osv_dev_data --- errata/models.py | 89 ++++++++++++++++++++++++----------------------- packages/utils.py | 16 +++++++++ 2 files changed, 61 insertions(+), 44 deletions(-) diff --git a/errata/models.py b/errata/models.py index 4b09deeb..8f6a6f74 100644 --- a/errata/models.py +++ b/errata/models.py @@ -21,7 +21,7 @@ from errata.managers import ErratumManager from packages.models import Package, PackageUpdate -from packages.utils import find_evr, get_matching_packages +from packages.utils import find_evr, get_matching_packages_q from security.models import CVE, Reference from security.utils import get_or_create_cve, get_or_create_reference from util import get_url @@ -64,34 +64,32 @@ def get_absolute_url(self): def scan_for_security_updates(self): if self.e_type == 'security': - for package in self.fixed_packages.all(): - affected_updates = PackageUpdate.objects.filter( - newpackage=package, - security=False, + fixed_pks = list(self.fixed_packages.values_list('pk', flat=True)) + if fixed_pks: + self._mark_updates_security( + PackageUpdate.objects.filter(newpackage__in=fixed_pks, security=False) ) - for affected_update in affected_updates: - affected_update.security = True - try: - affected_update.save() - except IntegrityError as e: - error_message(text=e) - # a version of this update already exists that is - # marked as a security update, so delete this one - affected_update.delete() - for package in self.affected_packages.all(): - affected_updates = PackageUpdate.objects.filter( - oldpackage=package, - security=False, + affected_pks = list(self.affected_packages.values_list('pk', flat=True)) + if affected_pks: + self._mark_updates_security( + PackageUpdate.objects.filter(oldpackage__in=affected_pks, security=False) ) - for affected_update in affected_updates: - affected_update.security = True - try: - affected_update.save() - except IntegrityError as e: - error_message(text=e) - # a version of this update already exists that is - # marked as a security update, so delete this one - affected_update.delete() + + def _mark_updates_security(self, updates): + """ Mark a queryset of PackageUpdates as security updates. + Handles IntegrityError by deleting duplicates. + """ + try: + updates.update(security=True) + except IntegrityError: + # fall back to individual saves to handle duplicates + for update in updates: + update.security = True + try: + update.save() + except IntegrityError as e: + error_message(text=e) + update.delete() def fetch_osv_dev_data(self): osv_dev_url = f'https://api.osv.dev/v1/vulns/{self.name}' @@ -104,15 +102,19 @@ def fetch_osv_dev_data(self): self.parse_osv_dev_data(osv_dev_json) def parse_osv_dev_data(self, osv_dev_json): + from django.db.models import Q name = osv_dev_json.get('id') if name != self.name: error_message(text=f'Erratum name mismatch - {self.name} != {name}') return related = osv_dev_json.get('related') if related: + cves = [] for vuln in related: if vuln.startswith('CVE'): - self.add_cve(vuln) + cves.append(vuln) + for cve_id in cves: + self.add_cve(cve_id) affected = osv_dev_json.get('affected') if not affected: return @@ -129,30 +131,29 @@ def parse_osv_dev_data(self, osv_dev_json): for match in matching_packages: fixed_packages.add(match) affected_versions = package.get('versions') - if not affected_versions: + if not affected_versions or not fixed_packages: continue - for package in fixed_packages: + for fp in fixed_packages: + q = Q() for version in affected_versions: epoch, ver, rel = find_evr(version) - matching_packages = get_matching_packages( - name=package.name, - epoch=epoch, - version=ver, - release=rel, - arch=package.arch, - p_type=package.packagetype, - ) - for match in matching_packages: - affected_packages.add(match) + q |= Q(epoch=epoch, version=ver, release=rel) + matching_packages = get_matching_packages_q( + name=fp.name, + q=q, + arch=fp.arch, + p_type=fp.packagetype, + ) + affected_packages.update(matching_packages) self.add_affected_packages(affected_packages) def add_fixed_packages(self, packages): - for package in packages: - self.fixed_packages.add(package) + if packages: + self.fixed_packages.add(*packages) def add_affected_packages(self, packages): - for package in packages: - self.affected_packages.add(package) + if packages: + self.affected_packages.add(*packages) def add_cve(self, cve_id): """ Add a CVE to an Erratum object diff --git a/packages/utils.py b/packages/utils.py index c7f86b4c..54d56273 100644 --- a/packages/utils.py +++ b/packages/utils.py @@ -277,6 +277,22 @@ def get_matching_packages(name, epoch, version, release, p_type, arch=None): return packages +def get_matching_packages_q(name, q, p_type, arch=None): + """ Get packages matching a compound Q filter for batch version lookups. + Returns the matching packages or an empty queryset. + """ + try: + package_name = PackageName.objects.get(name=name) + except PackageName.DoesNotExist: + return Package.objects.none() + base_filter = {'name': package_name, 'packagetype': p_type} + if arch: + if not isinstance(arch, PackageArchitecture): + arch, _ = PackageArchitecture.objects.get_or_create(name=arch) + base_filter['arch'] = arch + return Package.objects.filter(q, **base_filter) + + def clean_packageupdates(): """ Removes PackageUpdate objects that are no longer linked to any hosts """ From fc693b82d48c42b67bcd666beeef2f4c553e9993 Mon Sep 17 00:00:00 2001 From: Aman Maharjan <38400817+mhrznamn068@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:58:01 +0200 Subject: [PATCH 20/20] fix: deb kernel meta-packages bypass series check causing false HWE updates --- hosts/models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hosts/models.py b/hosts/models.py index 37e67779..b682d301 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -579,8 +579,13 @@ def find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos): processed_prefixes.add(prefix) # extract kernel series (e.g. '6.8') to avoid cross-track - # comparisons (GA 6.8 vs HWE 6.17 in the same repo) + # comparisons (GA vs HWE); meta-packages like linux-image-generic + # yield None, so fall back to the running kernel's series installed_series = self.get_deb_kernel_series(pkg_name) + if installed_series is None and self.kernel: + m = re.match(r'(\d+\.\d+)', self.kernel) + if m: + installed_series = m.group(1) # build endswith filter for flavoured kernels name_filter = Q(