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/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') 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/hosts/models.py b/hosts/models.py index 38c33ff1..b682d301 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 @@ -206,14 +208,11 @@ 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: - update_ids.append(ku_id) + update_ids.add(ku_id) for update in self.updates.all(): if update.id not in update_ids: @@ -223,9 +222,9 @@ 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 = [] + update_ids = set() hostrepos_q = Q(repo__mirror__enabled=True, repo__mirror__refresh=True, repo__mirror__repo__enabled=True, @@ -280,45 +279,7 @@ 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) - return update_ids - - def find_osrelease_repo_updates(self, host_packages, repo_packages, errata_ids): - - update_ids = [] - 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.append(uid) + update_ids.add(uid) return update_ids def check_if_reboot_required(self, host_highest): @@ -350,7 +311,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' @@ -359,7 +320,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' @@ -374,7 +335,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' @@ -391,7 +352,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-', @@ -404,12 +365,27 @@ 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 = [] + 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, @@ -423,16 +399,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): + 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 +474,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 ( @@ -511,9 +487,9 @@ 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 = [] + update_ids = set() for package in kernel_packages: pu_q = Q(name=package.name) @@ -540,7 +516,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: @@ -562,10 +538,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 = [] - running_flavour = self._get_running_kernel_flavour() + update_ids = set() + running_flavour = self.get_running_kernel_flavour() # find the linux-image package matching the running kernel running_kernel_pkg = None @@ -585,7 +561,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 @@ -594,7 +570,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 @@ -602,6 +578,15 @@ 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 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( name__name__startswith=prefix, @@ -611,8 +596,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: @@ -638,14 +628,14 @@ 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 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/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/tests/test_models.py b/hosts/tests/test_models.py index 86a846bf..72198ca6 100644 --- a/hosts/tests/test_models.py +++ b/hosts/tests/test_models.py @@ -544,35 +544,121 @@ 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()) + + 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( @@ -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.""" 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/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/packages/tables.py b/packages/tables.py index 633c79a2..6b41ac03 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 }}' @@ -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,20 +101,103 @@ 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 = ( + '' + '{{ 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_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/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/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 """ diff --git a/packages/views.py b/packages/views.py index 9f75b415..0f2aaecb 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 @@ -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,62 @@ 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 +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): diff --git a/patchman/celery.py b/patchman/celery.py index c47f994d..95c0c4fe 100644 --- a/patchman/celery.py +++ b/patchman/celery.py @@ -17,10 +17,24 @@ import os from celery import Celery +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') 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. + """ + db.close_old_connections() 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) 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') 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'] 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('/')) 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/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) 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') diff --git a/requirements.txt b/requirements.txt index 3f358230..62441847 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -Django==4.2.29 +Django==4.2.30 django-taggit==4.0.0 django-extensions==3.2.3 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 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] 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