From e941cc787ba858284065ffd18417266e9976a4de Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 4 Mar 2026 22:25:05 -0500 Subject: [PATCH 01/15] handle malformed repos better --- reports/tests/test_parsing.py | 59 +++++++++++++++++++++++++++++++++++ reports/utils.py | 40 +++++++++++++----------- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/reports/tests/test_parsing.py b/reports/tests/test_parsing.py index 9d50d2f3..f9c94098 100644 --- a/reports/tests/test_parsing.py +++ b/reports/tests/test_parsing.py @@ -19,6 +19,7 @@ from packages.models import Package from reports.utils import ( _get_package_type, _get_repo_type, parse_packages, parse_repos, + process_repo_text, ) from repos.models import Repository @@ -178,3 +179,61 @@ def test_get_repo_type_unknown(self): """Test unknown repo type returns None.""" self.assertIsNone(_get_repo_type('')) self.assertIsNone(_get_repo_type('invalid')) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessRepoTextTests(TestCase): + """Tests for process_repo_text() - handles malformed repo data gracefully.""" + + def setUp(self): + from arch.models import MachineArchitecture + self.arch, _ = MachineArchitecture.objects.get_or_create(name='x86_64') + + def test_rpm_normal(self): + """Test normal RPM repo parsing.""" + repo = ['rpm', 'Rocky BaseOS x86_64', 'baseos', '99', + 'https://dl.rockylinux.org/vault/rocky/9/BaseOS/x86_64/os/'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNotNone(result) + self.assertEqual(priority, -99) + + def test_rpm_url_as_priority(self): + """Test RPM repo where URL appears where priority should be (metalink merge bug).""" + repo = ['rpm', 'EPEL 9 x86_64', 'epel', + 'https://mirrors.fedoraproject.org/metalink?repo=epel-9&arch=x86_64'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) + + def test_rpm_missing_priority(self): + """Test RPM repo with missing priority field skips gracefully.""" + repo = ['rpm', 'EPEL x86_64', 'epel'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) + + def test_deb_normal(self): + """Test normal Debian repo parsing.""" + repo = ['deb', 'Ubuntu Main x86_64', '500', + 'http://archive.ubuntu.com/ubuntu'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNotNone(result) + self.assertEqual(priority, 500) + + def test_deb_url_as_priority(self): + """Test Debian repo where URL appears where priority should be.""" + repo = ['deb', 'Ubuntu Main x86_64', + 'http://archive.ubuntu.com/ubuntu'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) + + def test_unknown_type(self): + """Test unknown repo type returns None.""" + repo = ['unknown', 'test', '0', 'http://example.com'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) diff --git a/reports/utils.py b/reports/utils.py index f45cd19d..9b2a4196 100644 --- a/reports/utils.py +++ b/reports/utils.py @@ -34,7 +34,7 @@ from patchman.signals import pbar_start, pbar_update from repos.models import Mirror, MirrorPackage, Repository from repos.utils import get_or_create_repo -from util.logging import debug_message, info_message +from util.logging import debug_message, error_message, info_message def process_repos(report, host): @@ -281,23 +281,27 @@ def process_repo_text(repo, arch): """ r_id = None - if repo[0] == 'deb': - r_type = Repository.DEB - r_priority = int(repo[2]) - elif repo[0] == 'rpm': - r_type = Repository.RPM - r_id = repo.pop(2) - r_priority = int(repo[2]) * -1 - elif repo[0] == 'arch': - r_type = Repository.ARCH - r_id = repo[2] - r_priority = 0 - elif repo[0] == 'gentoo': - r_type = Repository.GENTOO - r_id = repo.pop(2) - r_priority = repo[2] - arch = 'any' - else: + try: + if repo[0] == 'deb': + r_type = Repository.DEB + r_priority = int(repo[2]) + elif repo[0] == 'rpm': + r_type = Repository.RPM + r_id = repo.pop(2) + r_priority = int(repo[2]) * -1 + elif repo[0] == 'arch': + r_type = Repository.ARCH + r_id = repo[2] + r_priority = 0 + elif repo[0] == 'gentoo': + r_type = Repository.GENTOO + r_id = repo.pop(2) + r_priority = int(repo[2]) + arch = 'any' + else: + return None, 0 + except (ValueError, IndexError) as e: + error_message(text=f'Skipping malformed repo line: {repo} ({e})') return None, 0 r_name = repo[1] if repo[1] else '' From 4eb279360bd860112150af6da5d6d61a901966d8 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 4 Mar 2026 22:23:02 -0500 Subject: [PATCH 02/15] fix metalink parsing error --- client/patchman-client | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/patchman-client b/client/patchman-client index de7189cd..b75f9eb5 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -505,7 +505,7 @@ get_repos() { fi # replace this with a dedicated awk or simple python script? yum_repolist=$(yum repolist enabled --verbose 2>/dev/null | sed -e "s/:\? *([0-9]\+ more)$//g" -e "s/ ([0-9]\+$//g" -e "s/:\? more)$//g" -e "s/'//g" -e "s/%/%%/g") - for i in $(echo "${yum_repolist}" | awk '{ if ($1=="Repo-id") {printf "'"'"'"; for (i=3; i0){print ""} n++; url=0; printf "'"'"'"; for (i=3; i0) print ""}' | sed -e "s/\/'/'/g" | sed -e "s/ ' /' /") ; do full_id=$(echo ${i} | cut -d \' -f 2) id=$(echo ${i} | cut -d \' -f 2 | cut -d \/ -f 1) name=$(echo ${i} | cut -d \' -f 4) From 4b91cd768fdb05a0bb1c154fd94d04e2039c2a8c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 12:27:34 -0500 Subject: [PATCH 03/15] add dry-run mode to client --- client/patchman-client | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/client/patchman-client b/client/patchman-client index b75f9eb5..dca4f18c 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -11,11 +11,12 @@ debug=false report=false local_updates=false repo_check=true +dry_run=false tags='' api_key='' usage() { - echo "${0} [-v] [-d] [-n] [-u] [-r] [-s SERVER] [-c FILE] [-t TAGS] [-h HOSTNAME] [-p PROTOCOL] [-k API_KEY]" + echo "${0} [-v] [-d] [-n] [-u] [-y] [-r] [-s SERVER] [-c FILE] [-t TAGS] [-H HOSTNAME] [-p PROTOCOL] [-k API_KEY]" echo "-v: verbose output (default is silent)" echo "-d: debug output" echo "-n: no repo check (required when used as an apt or yum plugin)" @@ -24,16 +25,17 @@ usage() { echo "-s SERVER: web server address, e.g. https://patchman.example.com" echo "-c FILE: config file location (default is /etc/patchman/patchman-client.conf)" echo "-t TAGS: comma-separated list of tags, e.g. -t www,dev" - echo "-h HOSTNAME: specify the hostname of the local host" + echo "-H HOSTNAME: specify the hostname of the local host" echo "-p PROTOCOL: protocol version (1 or 2, default is 1)" echo "-k API_KEY: API key for protocol 2 authentication" + echo "-y: dry run (collect data but do not submit)" echo echo "Command line options override config file options." exit 0 } parseopts() { - while getopts "vdnurs:c:t:h:p:k:" opt; do + while getopts "vdnuyrs:c:t:h:H:p:k:" opt; do case ${opt} in v) verbose=true @@ -48,6 +50,9 @@ parseopts() { u) local_updates=true ;; + y) + dry_run=true + ;; r) cli_report=true ;; @@ -60,7 +65,7 @@ parseopts() { t) cli_tags="${OPTARG}" ;; - h) + h|H) cli_hostname=${OPTARG} ;; p) @@ -191,7 +196,7 @@ check_conf() { if [ ! -z "${api_key}" ] ; then echo "API Key: ${api_key:0:12}..." fi - for var in report local_updates repo_check verbose debug ; do + for var in report local_updates repo_check dry_run verbose debug ; do eval val=\$${var} echo "${var}: ${val}" done @@ -199,7 +204,7 @@ check_conf() { } check_booleans() { - for var in report local_updates repo_check verbose debug ; do + for var in report local_updates repo_check dry_run verbose debug ; do eval val=\$${var} if [ -z ${val} ] || [ "${val}" == "0" ] || [ "${val,,}" == "false" ] ; then eval ${var}=false @@ -1085,6 +1090,25 @@ if ${repo_check} ; then fi reboot_required +if ${dry_run} ; then + echo + echo "=== Dry Run Summary ===" + echo "Hostname: ${hostname}" + echo "OS: ${os}" + echo "Arch: ${host_arch}" + echo "Kernel: ${host_kernel}" + echo "Packages: $(wc -l < ${tmpfile_pkg})" + echo "Repos: $(wc -l < ${tmpfile_rep})" + echo "Modules: $(wc -l < ${tmpfile_mod})" + echo "Security updates: $(wc -l < ${tmpfile_sec})" + echo "Bugfix updates: $(wc -l < ${tmpfile_bug})" + echo "Reboot required: ${reboot}" + if [ ! -z "${tags}" ] ; then + echo "Tags: ${tags}" + fi + exit 0 +fi + # Use protocol 2 (JSON) or protocol 1 (form data) based on config if [ "${protocol}" == "2" ] ; then post_json_data From b3b669d85d0ea712a6ec3997d3ea04936df8a82c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 12:28:11 -0500 Subject: [PATCH 04/15] improve client error output --- client/patchman-client | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/client/patchman-client b/client/patchman-client index dca4f18c..f8838716 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -126,12 +126,11 @@ check_conf() { fi if [ -z "${conf}" ] || [ ! -f "${conf}" ] ; then - if ${verbose} ; then - echo "Warning: config file '${conf}' not found." - fi - else - source "${conf}" + echo "patchman-client: config file not found: ${conf}" >&2 + echo " Create the config file and set server= to your patchman server." >&2 + exit 1 fi + source "${conf}" conf_dir=$(dirname "${conf}")/conf.d if [ -d "${conf_dir}" ] ; then @@ -141,9 +140,21 @@ check_conf() { fi fi - if [ -z "${server}" ] && [ -z "${cli_server}" ] ; then - echo 'Patchman server not set, exiting.' - exit 1 + # check server is configured and not the example placeholder + if ! ${dry_run} ; then + if [ -z "${server}" ] && [ -z "${cli_server}" ] ; then + echo "patchman-client: server not configured." >&2 + echo " Edit ${conf} and set server= to your patchman server URL." >&2 + exit 1 + fi + if [ ! -z "${cli_server}" ] ; then + server=${cli_server} + fi + if echo "${server}" | grep -qE 'patchman\.example\.com' ; then + echo "patchman-client: server not configured." >&2 + echo " Edit ${conf} and set server= to your patchman server URL." >&2 + exit 1 + fi else if [ ! -z "${cli_server}" ] ; then server=${cli_server} From 644919236258f5e7df48aea73fa628b15d17c162 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 12:28:39 -0500 Subject: [PATCH 05/15] option to get apt updates on debian --- client/patchman-client | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/client/patchman-client b/client/patchman-client index f8838716..8e225412 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -506,6 +506,30 @@ get_zypper_updates() { zypper -q -n -s11 lu -r ${1} | grep ^v | awk '{print $2"."$5,$4}' | sed -e "s/$/ ${1}/" >> "${tmpfile_bug}" } +get_apt_updates() { + if ! check_command_exists apt ; then + return + fi + if ${verbose} ; then + echo 'Finding apt updates...' + fi + apt list --upgradable 2>/dev/null | grep -v '^Listing' | while IFS= read -r line ; do + if [ -z "${line}" ] ; then + continue + fi + # Format: package/suite version arch [upgradable from: old-version] + pkg=$(echo "${line}" | cut -d '/' -f 1) + suite=$(echo "${line}" | cut -d '/' -f 2 | cut -d ' ' -f 1) + version=$(echo "${line}" | awk '{print $2}') + arch=$(echo "${line}" | awk '{print $3}') + if echo "${suite}" | grep -qi 'security' ; then + echo "${pkg}.${arch} ${version}" >> "${tmpfile_sec}" + else + echo "${pkg}.${arch} ${version}" >> "${tmpfile_bug}" + fi + done +} + get_repos() { IFS=${NL_IFS} @@ -1099,6 +1123,9 @@ get_modules if ${repo_check} ; then get_repos fi +if ${local_updates} ; then + get_apt_updates +fi reboot_required if ${dry_run} ; then From 5219ff1d483dfde1b23d3619e55062f83dae88f6 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 12:30:04 -0500 Subject: [PATCH 06/15] improve duplicate arch removal handling --- client/patchman-client | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/patchman-client b/client/patchman-client index 8e225412..0e7478bb 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -548,9 +548,9 @@ get_repos() { for i in $(echo "${yum_repolist}" | awk 'BEGIN{n=0} { if ($1=="Repo-id") {if(n>0){print ""} n++; url=0; printf "'"'"'"; for (i=3; i0) print ""}' | sed -e "s/\/'/'/g" | sed -e "s/ ' /' /") ; do full_id=$(echo ${i} | cut -d \' -f 2) id=$(echo ${i} | cut -d \' -f 2 | cut -d \/ -f 1) - name=$(echo ${i} | cut -d \' -f 4) + orig_name=$(echo ${i} | cut -d \' -f 4) # Strip " - arch arch" suffix pattern to avoid duplicates like "EPEL - x86_64 x86_64" - name=$(echo "${name}" | sed -e "s/ - ${host_arch} ${host_arch}$/ ${host_arch}/") + name=$(echo "${orig_name}" | sed -e "s/ - ${host_arch} ${host_arch}$/ ${host_arch}/") if [ "${priorities}" != "" ] ; then priority=$(echo "${priorities}" | grep "'${name}'" | sed -e "s/priority=\(.*\) '${name}'/\1/") fi @@ -568,7 +568,7 @@ get_repos() { if [ ! -z ${CPE_NAME} ] ; then id="${CPE_NAME}-${id}" fi - j=$(echo ${i} | sed -e "s#'${full_id}' '${name}'#'${name}' '${id}' '${priority}'#" | sed -e "s/'\[/'/g" -e "s/\]'/'/g") + j=$(echo ${i} | sed -e "s#'${full_id}' '${orig_name}'#'${name}' '${id}' '${priority}'#" | sed -e "s/'\[/'/g" -e "s/\]'/'/g") echo "'rpm' ${j}" >> "${tmpfile_rep}" unset priority done From 23a7728f54a896ae072796fb2af3c191bc60ef24 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 12:30:21 -0500 Subject: [PATCH 07/15] improve reboot detection --- client/patchman-client | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/client/patchman-client b/client/patchman-client index 0e7478bb..b350a606 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -713,13 +713,23 @@ get_repos() { } reboot_required() { - # On debian-based clients, the update-notifier-common - # package needs to be installed for this to work. + # Debian/Ubuntu: update-notifier-common sets this file if [ -e /var/run/reboot-required ] ; then reboot=True - else - reboot=ServerCheck + return + fi + + # Compare running vs installed kernel via /boot/vmlinuz symlink + if [ -e /proc/sys/kernel/osrelease ] && [ -L /boot/vmlinuz ] ; then + running_kernel=$(cat /proc/sys/kernel/osrelease) + installed_kernel=$(readlink /boot/vmlinuz | sed -e 's/^vmlinuz-//') + if [ "${running_kernel}" != "${installed_kernel}" ] ; then + reboot=True + return + fi fi + + reboot=ServerCheck } build_packages_json() { From 7986b55009fc54d7f013e7c05e7cfb90e746506e Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 12:30:53 -0500 Subject: [PATCH 08/15] add shellcheck disables for client --- client/patchman-client | 1 + 1 file changed, 1 insertion(+) diff --git a/client/patchman-client b/client/patchman-client index b350a606..b1a2a7f7 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -1,4 +1,5 @@ #!/bin/bash +# shellcheck disable=SC1001,SC1090,SC1091,SC2001,SC2002,SC2012,SC2013,SC2016,SC2034,SC2045,SC2046,SC2086,SC2143,SC2153,SC2154,SC2181,SC2206,SC2219,SC2236 export LC_ALL=C export FULL_IFS=$' \t\n' From d7c239c82dd88b2843a044409ffae95e05c9ea8b Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 4 Mar 2026 22:26:10 -0500 Subject: [PATCH 09/15] use pysqlite3 if available --- patchman/settings.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/patchman/settings.py b/patchman/settings.py index 5f6f4434..9fe4b84f 100644 --- a/patchman/settings.py +++ b/patchman/settings.py @@ -4,6 +4,13 @@ import site import sys +# use pysqlite3 if available +try: + import pysqlite3 # noqa + sys.modules['sqlite3'] = sys.modules.pop('pysqlite3') +except ImportError: + pass + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 3c8f42f1a542a1585a682e6ea69580d0af1abb89 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 20:45:23 -0500 Subject: [PATCH 10/15] update yum hook --- hooks/yum/patchman.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hooks/yum/patchman.py b/hooks/yum/patchman.py index 52f9cc8b..9ae55d90 100644 --- a/hooks/yum/patchman.py +++ b/hooks/yum/patchman.py @@ -1,4 +1,4 @@ -# Copyright 2013-2016 Marcus Furlong +# Copyright 2013-2026 Marcus Furlong # # This file is part of Patchman. # @@ -23,10 +23,12 @@ def posttrans_hook(conduit): - conduit.info(2, 'Sending report to patchman server...') servicecmd = conduit.confString('main', 'servicecmd', '/usr/sbin/patchman-client') + if not os.path.isfile(servicecmd) or not os.access(servicecmd, os.X_OK): + return + conduit.info(2, 'Sending report to patchman server...') args = '-n' - command = f'{servicecmd} {args}> /dev/null' + command = f'{servicecmd} {args} > /dev/null' os.system(command) From d1119d6e965cbfb2333a1b07da373d4a8b13b789 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 20:59:18 -0500 Subject: [PATCH 11/15] add helpful client debug and verbose output --- client/patchman-client | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/client/patchman-client b/client/patchman-client index b1a2a7f7..cbec7268 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -1155,6 +1155,54 @@ if ${dry_run} ; then if [ ! -z "${tags}" ] ; then echo "Tags: ${tags}" fi + if ${verbose} ; then + echo + echo "=== Packages ===" + cut -d \' -f 2 "${tmpfile_pkg}" | sort + echo + echo "=== Repos ===" + awk -F\' '{printf " [%s] %s\n", $2, $4}' "${tmpfile_rep}" + if [ -s "${tmpfile_sec}" ] ; then + echo + echo "=== Security Updates ===" + awk -F\' '{printf " %s %s\n", $2, $4}' "${tmpfile_sec}" + fi + if [ -s "${tmpfile_bug}" ] ; then + echo + echo "=== Bugfix Updates ===" + awk -F\' '{printf " %s %s\n", $2, $4}' "${tmpfile_bug}" + fi + if [ -s "${tmpfile_mod}" ] ; then + echo + echo "=== Modules ===" + awk -F\' '{printf " %s\n", $2}' "${tmpfile_mod}" + fi + fi + if ${debug} ; then + if [ "${protocol}" == "2" ] && check_command_exists jq ; then + tmpfile_packages_json=$(mktemp) + tmpfile_repos_json=$(mktemp) + tmpfile_modules_json=$(mktemp) + tmpfile_sec_json=$(mktemp) + tmpfile_bug_json=$(mktemp) + echo + echo "=== Full JSON Report ===" + build_json_report + else + echo + echo "=== Raw Data Files ===" + echo "--- ${tmpfile_pkg} ---" + cat "${tmpfile_pkg}" + echo "--- ${tmpfile_rep} ---" + cat "${tmpfile_rep}" + echo "--- ${tmpfile_sec} ---" + cat "${tmpfile_sec}" + echo "--- ${tmpfile_bug} ---" + cat "${tmpfile_bug}" + echo "--- ${tmpfile_mod} ---" + cat "${tmpfile_mod}" + fi + fi exit 0 fi From 1b6c5399015df5acfa26585faba1426b8eee1630 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 4 Mar 2026 22:23:41 -0500 Subject: [PATCH 12/15] fix repo/mirror bulk delete bug --- repos/tests/test_views.py | 114 ++++++++++++++++++++++++++++++++++++++ repos/views.py | 4 +- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 repos/tests/test_views.py diff --git a/repos/tests/test_views.py b/repos/tests/test_views.py new file mode 100644 index 00000000..9ad01689 --- /dev/null +++ b/repos/tests/test_views.py @@ -0,0 +1,114 @@ +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse + +from arch.models import MachineArchitecture +from repos.models import Mirror, Repository + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class RepoBulkActionTests(TestCase): + """Tests for repo_bulk_action view.""" + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', password='testpass' + ) + self.client.login(username='testuser', password='testpass') + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.repos = [] + for i in range(3): + self.repos.append(Repository.objects.create( + name=f'repo-{i}', + arch=self.arch, + repotype='D', + enabled=True, + )) + + def test_bulk_delete_selected(self): + """Test bulk delete with individually selected repos.""" + ids = [str(r.id) for r in self.repos[:2]] + resp = self.client.post(reverse('repos:repo_bulk_action'), { + 'action': 'delete', + 'selected_ids': ids, + }) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Repository.objects.count(), 1) + + def test_bulk_delete_select_all_filtered(self): + """Test bulk delete with select-all-filtered (uses .distinct()).""" + resp = self.client.post(reverse('repos:repo_bulk_action'), { + 'action': 'delete', + 'select_all_filtered': '1', + 'filter_params': '', + }) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Repository.objects.count(), 0) + + def test_bulk_enable_disable(self): + """Test bulk enable/disable actions.""" + ids = [str(r.id) for r in self.repos] + self.client.post(reverse('repos:repo_bulk_action'), { + 'action': 'disable', + 'selected_ids': ids, + }) + self.assertTrue(all( + not r.enabled for r in Repository.objects.all() + )) + self.client.post(reverse('repos:repo_bulk_action'), { + 'action': 'enable', + 'selected_ids': ids, + }) + self.assertTrue(all( + r.enabled for r in Repository.objects.all() + )) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class MirrorBulkActionTests(TestCase): + """Tests for mirror_bulk_action view.""" + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', password='testpass' + ) + self.client.login(username='testuser', password='testpass') + self.arch = MachineArchitecture.objects.create(name='x86_64') + self.repo = Repository.objects.create( + name='repo-0', + arch=self.arch, + repotype='D', + enabled=True, + ) + self.mirrors = [] + for i in range(3): + self.mirrors.append(Mirror.objects.create( + repo=self.repo, + url=f'http://mirror{i}.example.com/repo', + )) + + def test_bulk_delete_select_all_filtered(self): + """Test bulk delete mirrors with select-all-filtered (uses .distinct()).""" + resp = self.client.post(reverse('repos:mirror_bulk_action'), { + 'action': 'delete', + 'select_all_filtered': '1', + 'filter_params': '', + }) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Mirror.objects.count(), 0) + + def test_bulk_delete_selected(self): + """Test bulk delete with individually selected mirrors.""" + ids = [str(m.id) for m in self.mirrors[:2]] + resp = self.client.post(reverse('repos:mirror_bulk_action'), { + 'action': 'delete', + 'selected_ids': ids, + }) + self.assertEqual(resp.status_code, 302) + self.assertEqual(Mirror.objects.count(), 1) diff --git a/repos/views.py b/repos/views.py index e49c4972..1a796c11 100644 --- a/repos/views.py +++ b/repos/views.py @@ -491,7 +491,7 @@ def repo_bulk_action(request): refresh_repo.delay(repo.id) messages.success(request, f'Queued {count} {name} for refresh') elif action == 'delete': - repos.delete() + Repository.objects.filter(pk__in=repos.values_list('pk', flat=True)).delete() messages.success(request, f'Deleted {count} {name}') else: messages.warning(request, 'Invalid action') @@ -574,7 +574,7 @@ def mirror_bulk_action(request): mirrors.update(refresh=False) messages.success(request, f'Disabled refresh for {count} {name}') elif action == 'delete': - mirrors.delete() + Mirror.objects.filter(pk__in=mirrors.values_list('pk', flat=True)).delete() messages.success(request, f'Deleted {count} {name}') else: messages.warning(request, 'Invalid action') From 8567ac1e26a38654da71e669c4ab0ae104dfc245 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 5 Mar 2026 21:59:26 -0500 Subject: [PATCH 13/15] ensure host errata are tracked and updated Fixes: #788 --- hosts/models.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/hosts/models.py b/hosts/models.py index 10e7fb8e..38c33ff1 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -204,10 +204,12 @@ def find_updates(self): host_packages = self.packages.exclude(kernels_q).distinct() kernel_packages = self.packages.filter(kernels_q) + errata_ids = set() + if self.host_repos_only: - update_ids = self.find_host_repo_updates(host_packages, repo_packages) + 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) + update_ids = self.find_osrelease_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: @@ -217,7 +219,11 @@ def find_updates(self): if update.id not in update_ids: self.updates.remove(update) - def find_host_repo_updates(self, host_packages, repo_packages): + for erratum in self.errata.all(): + if erratum.id not in errata_ids: + self.errata.remove(erratum) + + def find_host_repo_updates(self, host_packages, repo_packages, errata_ids): update_ids = [] hostrepos_q = Q(repo__mirror__enabled=True, @@ -258,6 +264,7 @@ def find_host_repo_updates(self, host_packages, repo_packages): if errata: for erratum in errata: self.errata.add(erratum) + errata_ids.add(erratum.id) if highest_package.compare_version(pu) == -1: if priority is not None: # proceed only if the package is from a repo with a @@ -276,7 +283,7 @@ def find_host_repo_updates(self, host_packages, repo_packages): update_ids.append(uid) return update_ids - def find_osrelease_repo_updates(self, host_packages, repo_packages): + def find_osrelease_repo_updates(self, host_packages, repo_packages, errata_ids): update_ids = [] for package in host_packages: @@ -304,6 +311,7 @@ def find_osrelease_repo_updates(self, host_packages, repo_packages): 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 From f0f0245752b141a10bd67e82feed507510d323e1 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 6 Mar 2026 03:21:55 +0000 Subject: [PATCH 14/15] auto-commit to update version skip-checks: true --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 9d58a584..029696b1 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -4.0.17 +4.0.18 From 02e21db8c08f7ecac3636d9a23514805d3e87777 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 6 Mar 2026 03:21:58 +0000 Subject: [PATCH 15/15] auto-commit to update debian changelog skip-checks: true --- debian/changelog | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/debian/changelog b/debian/changelog index 4e13a9d5..45dd61ba 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,22 @@ +patchman (4.0.18-1) stable; urgency=medium + + * handle malformed repos better + * fix metalink parsing error + * add dry-run mode to client + * improve client error output + * option to get apt updates on debian + * improve duplicate arch removal handling + * improve reboot detection + * add shellcheck disables for client + * use pysqlite3 if available + * update yum hook + * add helpful client debug and verbose output + * fix repo/mirror bulk delete bug + * ensure host errata are tracked and updated + * auto-commit to update version skip-checks: true + + -- Marcus Furlong Fri, 06 Mar 2026 03:21:58 +0000 + patchman (4.0.17-1) stable; urgency=medium [ dependabot[bot] ]