Skip to content

Commit 7053f64

Browse files
Merge pull request #40 from RicardoJeronimo/patchman-email
Update to 4.0.18
2 parents ea24d53 + fbf06b8 commit 7053f64

File tree

10 files changed

+384
-50
lines changed

10 files changed

+384
-50
lines changed

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4.0.17
1+
4.0.18

client/patchman-client

Lines changed: 143 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/bin/bash
2+
# shellcheck disable=SC1001,SC1090,SC1091,SC2001,SC2002,SC2012,SC2013,SC2016,SC2034,SC2045,SC2046,SC2086,SC2143,SC2153,SC2154,SC2181,SC2206,SC2219,SC2236
23

34
export LC_ALL=C
45
export FULL_IFS=$' \t\n'
@@ -11,11 +12,12 @@ debug=false
1112
report=false
1213
local_updates=false
1314
repo_check=true
15+
dry_run=false
1416
tags=''
1517
api_key=''
1618

1719
usage() {
18-
echo "${0} [-v] [-d] [-n] [-u] [-r] [-s SERVER] [-c FILE] [-t TAGS] [-h HOSTNAME] [-p PROTOCOL] [-k API_KEY]"
20+
echo "${0} [-v] [-d] [-n] [-u] [-y] [-r] [-s SERVER] [-c FILE] [-t TAGS] [-H HOSTNAME] [-p PROTOCOL] [-k API_KEY]"
1921
echo "-v: verbose output (default is silent)"
2022
echo "-d: debug output"
2123
echo "-n: no repo check (required when used as an apt or yum plugin)"
@@ -24,16 +26,17 @@ usage() {
2426
echo "-s SERVER: web server address, e.g. https://patchman.example.com"
2527
echo "-c FILE: config file location (default is /etc/patchman/patchman-client.conf)"
2628
echo "-t TAGS: comma-separated list of tags, e.g. -t www,dev"
27-
echo "-h HOSTNAME: specify the hostname of the local host"
29+
echo "-H HOSTNAME: specify the hostname of the local host"
2830
echo "-p PROTOCOL: protocol version (1 or 2, default is 1)"
2931
echo "-k API_KEY: API key for protocol 2 authentication"
32+
echo "-y: dry run (collect data but do not submit)"
3033
echo
3134
echo "Command line options override config file options."
3235
exit 0
3336
}
3437

3538
parseopts() {
36-
while getopts "vdnurs:c:t:h:p:k:" opt; do
39+
while getopts "vdnuyrs:c:t:h:H:p:k:" opt; do
3740
case ${opt} in
3841
v)
3942
verbose=true
@@ -48,6 +51,9 @@ parseopts() {
4851
u)
4952
local_updates=true
5053
;;
54+
y)
55+
dry_run=true
56+
;;
5157
r)
5258
cli_report=true
5359
;;
@@ -60,7 +66,7 @@ parseopts() {
6066
t)
6167
cli_tags="${OPTARG}"
6268
;;
63-
h)
69+
h|H)
6470
cli_hostname=${OPTARG}
6571
;;
6672
p)
@@ -121,12 +127,11 @@ check_conf() {
121127
fi
122128

123129
if [ -z "${conf}" ] || [ ! -f "${conf}" ] ; then
124-
if ${verbose} ; then
125-
echo "Warning: config file '${conf}' not found."
126-
fi
127-
else
128-
source "${conf}"
130+
echo "patchman-client: config file not found: ${conf}" >&2
131+
echo " Create the config file and set server= to your patchman server." >&2
132+
exit 1
129133
fi
134+
source "${conf}"
130135

131136
conf_dir=$(dirname "${conf}")/conf.d
132137
if [ -d "${conf_dir}" ] ; then
@@ -136,9 +141,21 @@ check_conf() {
136141
fi
137142
fi
138143

139-
if [ -z "${server}" ] && [ -z "${cli_server}" ] ; then
140-
echo 'Patchman server not set, exiting.'
141-
exit 1
144+
# check server is configured and not the example placeholder
145+
if ! ${dry_run} ; then
146+
if [ -z "${server}" ] && [ -z "${cli_server}" ] ; then
147+
echo "patchman-client: server not configured." >&2
148+
echo " Edit ${conf} and set server= to your patchman server URL." >&2
149+
exit 1
150+
fi
151+
if [ ! -z "${cli_server}" ] ; then
152+
server=${cli_server}
153+
fi
154+
if echo "${server}" | grep -qE 'patchman\.example\.com' ; then
155+
echo "patchman-client: server not configured." >&2
156+
echo " Edit ${conf} and set server= to your patchman server URL." >&2
157+
exit 1
158+
fi
142159
else
143160
if [ ! -z "${cli_server}" ] ; then
144161
server=${cli_server}
@@ -191,15 +208,15 @@ check_conf() {
191208
if [ ! -z "${api_key}" ] ; then
192209
echo "API Key: ${api_key:0:12}..."
193210
fi
194-
for var in report local_updates repo_check verbose debug ; do
211+
for var in report local_updates repo_check dry_run verbose debug ; do
195212
eval val=\$${var}
196213
echo "${var}: ${val}"
197214
done
198215
fi
199216
}
200217

201218
check_booleans() {
202-
for var in report local_updates repo_check verbose debug ; do
219+
for var in report local_updates repo_check dry_run verbose debug ; do
203220
eval val=\$${var}
204221
if [ -z ${val} ] || [ "${val}" == "0" ] || [ "${val,,}" == "false" ] ; then
205222
eval ${var}=false
@@ -490,6 +507,30 @@ get_zypper_updates() {
490507
zypper -q -n -s11 lu -r ${1} | grep ^v | awk '{print $2"."$5,$4}' | sed -e "s/$/ ${1}/" >> "${tmpfile_bug}"
491508
}
492509

510+
get_apt_updates() {
511+
if ! check_command_exists apt ; then
512+
return
513+
fi
514+
if ${verbose} ; then
515+
echo 'Finding apt updates...'
516+
fi
517+
apt list --upgradable 2>/dev/null | grep -v '^Listing' | while IFS= read -r line ; do
518+
if [ -z "${line}" ] ; then
519+
continue
520+
fi
521+
# Format: package/suite version arch [upgradable from: old-version]
522+
pkg=$(echo "${line}" | cut -d '/' -f 1)
523+
suite=$(echo "${line}" | cut -d '/' -f 2 | cut -d ' ' -f 1)
524+
version=$(echo "${line}" | awk '{print $2}')
525+
arch=$(echo "${line}" | awk '{print $3}')
526+
if echo "${suite}" | grep -qi 'security' ; then
527+
echo "${pkg}.${arch} ${version}" >> "${tmpfile_sec}"
528+
else
529+
echo "${pkg}.${arch} ${version}" >> "${tmpfile_bug}"
530+
fi
531+
done
532+
}
533+
493534
get_repos() {
494535
IFS=${NL_IFS}
495536

@@ -505,12 +546,12 @@ get_repos() {
505546
fi
506547
# replace this with a dedicated awk or simple python script?
507548
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")
508-
for i in $(echo "${yum_repolist}" | awk '{ if ($1=="Repo-id") {printf "'"'"'"; for (i=3; i<NF; i++) printf $i " "; printf $NF"'"'"' "} if ($1=="Repo-name") {printf "'"'"'"; for (i=3; i<NF; i++) printf $i " "; printf $NF"'" ${host_arch}'"' "} if ($1=="Repo-mirrors" || $1=="Repo-metalink") {printf "'"'"'"; for (i=3; i<NF; i++) printf $i " "; printf $NF"'"'"' "} if ($1=="Repo-baseurl" || $1=="Repo-baseurl:") { url=1; comma=match($NF,","); if (comma) out=substr($NF,1,comma-1); else out=$NF; printf "'"'"'"out"'"'"' "; } else { if (url==1) { if ($1==":") { comma=match($NF,","); if (comma) out=substr($NF,1,comma-1); else out=$NF; printf "'"'"'"out"'"'"' "; } else {url=0; print "";} } } }' | sed -e "s/\/'/'/g" | sed -e "s/ ' /' /") ; do
549+
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; i<NF; i++) printf $i " "; printf $NF"'"'"' "} if ($1=="Repo-name") {printf "'"'"'"; for (i=3; i<NF; i++) printf $i " "; printf $NF"'" ${host_arch}'"' "} if ($1=="Repo-mirrors" || $1=="Repo-metalink") {printf "'"'"'"; for (i=3; i<NF; i++) printf $i " "; printf $NF"'"'"' "} if ($1=="Repo-baseurl" || $1=="Repo-baseurl:") { url=1; comma=match($NF,","); if (comma) out=substr($NF,1,comma-1); else out=$NF; printf "'"'"'"out"'"'"' "; } else { if (url==1) { if ($1==":") { comma=match($NF,","); if (comma) out=substr($NF,1,comma-1); else out=$NF; printf "'"'"'"out"'"'"' "; } else {url=0} } } } END{if(n>0) print ""}' | sed -e "s/\/'/'/g" | sed -e "s/ ' /' /") ; do
509550
full_id=$(echo ${i} | cut -d \' -f 2)
510551
id=$(echo ${i} | cut -d \' -f 2 | cut -d \/ -f 1)
511-
name=$(echo ${i} | cut -d \' -f 4)
552+
orig_name=$(echo ${i} | cut -d \' -f 4)
512553
# Strip " - arch arch" suffix pattern to avoid duplicates like "EPEL - x86_64 x86_64"
513-
name=$(echo "${name}" | sed -e "s/ - ${host_arch} ${host_arch}$/ ${host_arch}/")
554+
name=$(echo "${orig_name}" | sed -e "s/ - ${host_arch} ${host_arch}$/ ${host_arch}/")
514555
if [ "${priorities}" != "" ] ; then
515556
priority=$(echo "${priorities}" | grep "'${name}'" | sed -e "s/priority=\(.*\) '${name}'/\1/")
516557
fi
@@ -528,7 +569,7 @@ get_repos() {
528569
if [ ! -z ${CPE_NAME} ] ; then
529570
id="${CPE_NAME}-${id}"
530571
fi
531-
j=$(echo ${i} | sed -e "s#'${full_id}' '${name}'#'${name}' '${id}' '${priority}'#" | sed -e "s/'\[/'/g" -e "s/\]'/'/g")
572+
j=$(echo ${i} | sed -e "s#'${full_id}' '${orig_name}'#'${name}' '${id}' '${priority}'#" | sed -e "s/'\[/'/g" -e "s/\]'/'/g")
532573
echo "'rpm' ${j}" >> "${tmpfile_rep}"
533574
unset priority
534575
done
@@ -673,13 +714,23 @@ get_repos() {
673714
}
674715

675716
reboot_required() {
676-
# On debian-based clients, the update-notifier-common
677-
# package needs to be installed for this to work.
717+
# Debian/Ubuntu: update-notifier-common sets this file
678718
if [ -e /var/run/reboot-required ] ; then
679719
reboot=True
680-
else
681-
reboot=ServerCheck
720+
return
721+
fi
722+
723+
# Compare running vs installed kernel via /boot/vmlinuz symlink
724+
if [ -e /proc/sys/kernel/osrelease ] && [ -L /boot/vmlinuz ] ; then
725+
running_kernel=$(cat /proc/sys/kernel/osrelease)
726+
installed_kernel=$(readlink /boot/vmlinuz | sed -e 's/^vmlinuz-//')
727+
if [ "${running_kernel}" != "${installed_kernel}" ] ; then
728+
reboot=True
729+
return
730+
fi
682731
fi
732+
733+
reboot=ServerCheck
683734
}
684735

685736
build_packages_json() {
@@ -1083,8 +1134,78 @@ get_modules
10831134
if ${repo_check} ; then
10841135
get_repos
10851136
fi
1137+
if ${local_updates} ; then
1138+
get_apt_updates
1139+
fi
10861140
reboot_required
10871141

1142+
if ${dry_run} ; then
1143+
echo
1144+
echo "=== Dry Run Summary ==="
1145+
echo "Hostname: ${hostname}"
1146+
echo "OS: ${os}"
1147+
echo "Arch: ${host_arch}"
1148+
echo "Kernel: ${host_kernel}"
1149+
echo "Packages: $(wc -l < ${tmpfile_pkg})"
1150+
echo "Repos: $(wc -l < ${tmpfile_rep})"
1151+
echo "Modules: $(wc -l < ${tmpfile_mod})"
1152+
echo "Security updates: $(wc -l < ${tmpfile_sec})"
1153+
echo "Bugfix updates: $(wc -l < ${tmpfile_bug})"
1154+
echo "Reboot required: ${reboot}"
1155+
if [ ! -z "${tags}" ] ; then
1156+
echo "Tags: ${tags}"
1157+
fi
1158+
if ${verbose} ; then
1159+
echo
1160+
echo "=== Packages ==="
1161+
cut -d \' -f 2 "${tmpfile_pkg}" | sort
1162+
echo
1163+
echo "=== Repos ==="
1164+
awk -F\' '{printf " [%s] %s\n", $2, $4}' "${tmpfile_rep}"
1165+
if [ -s "${tmpfile_sec}" ] ; then
1166+
echo
1167+
echo "=== Security Updates ==="
1168+
awk -F\' '{printf " %s %s\n", $2, $4}' "${tmpfile_sec}"
1169+
fi
1170+
if [ -s "${tmpfile_bug}" ] ; then
1171+
echo
1172+
echo "=== Bugfix Updates ==="
1173+
awk -F\' '{printf " %s %s\n", $2, $4}' "${tmpfile_bug}"
1174+
fi
1175+
if [ -s "${tmpfile_mod}" ] ; then
1176+
echo
1177+
echo "=== Modules ==="
1178+
awk -F\' '{printf " %s\n", $2}' "${tmpfile_mod}"
1179+
fi
1180+
fi
1181+
if ${debug} ; then
1182+
if [ "${protocol}" == "2" ] && check_command_exists jq ; then
1183+
tmpfile_packages_json=$(mktemp)
1184+
tmpfile_repos_json=$(mktemp)
1185+
tmpfile_modules_json=$(mktemp)
1186+
tmpfile_sec_json=$(mktemp)
1187+
tmpfile_bug_json=$(mktemp)
1188+
echo
1189+
echo "=== Full JSON Report ==="
1190+
build_json_report
1191+
else
1192+
echo
1193+
echo "=== Raw Data Files ==="
1194+
echo "--- ${tmpfile_pkg} ---"
1195+
cat "${tmpfile_pkg}"
1196+
echo "--- ${tmpfile_rep} ---"
1197+
cat "${tmpfile_rep}"
1198+
echo "--- ${tmpfile_sec} ---"
1199+
cat "${tmpfile_sec}"
1200+
echo "--- ${tmpfile_bug} ---"
1201+
cat "${tmpfile_bug}"
1202+
echo "--- ${tmpfile_mod} ---"
1203+
cat "${tmpfile_mod}"
1204+
fi
1205+
fi
1206+
exit 0
1207+
fi
1208+
10881209
# Use protocol 2 (JSON) or protocol 1 (form data) based on config
10891210
if [ "${protocol}" == "2" ] ; then
10901211
post_json_data

debian/changelog

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
patchman (4.0.18-1) stable; urgency=medium
2+
3+
* handle malformed repos better
4+
* fix metalink parsing error
5+
* add dry-run mode to client
6+
* improve client error output
7+
* option to get apt updates on debian
8+
* improve duplicate arch removal handling
9+
* improve reboot detection
10+
* add shellcheck disables for client
11+
* use pysqlite3 if available
12+
* update yum hook
13+
* add helpful client debug and verbose output
14+
* fix repo/mirror bulk delete bug
15+
* ensure host errata are tracked and updated
16+
* auto-commit to update version skip-checks: true
17+
18+
-- Marcus Furlong <furlongm@gmail.com> Fri, 06 Mar 2026 03:21:58 +0000
19+
120
patchman (4.0.17-1) stable; urgency=medium
221

322
[ dependabot[bot] ]

hooks/yum/patchman.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2013-2016 Marcus Furlong <furlongm@gmail.com>
1+
# Copyright 2013-2026 Marcus Furlong <furlongm@gmail.com>
22
#
33
# This file is part of Patchman.
44
#
@@ -23,10 +23,12 @@
2323

2424

2525
def posttrans_hook(conduit):
26-
conduit.info(2, 'Sending report to patchman server...')
2726
servicecmd = conduit.confString('main',
2827
'servicecmd',
2928
'/usr/sbin/patchman-client')
29+
if not os.path.isfile(servicecmd) or not os.access(servicecmd, os.X_OK):
30+
return
31+
conduit.info(2, 'Sending report to patchman server...')
3032
args = '-n'
31-
command = f'{servicecmd} {args}> /dev/null'
33+
command = f'{servicecmd} {args} > /dev/null'
3234
os.system(command)

hosts/models.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,12 @@ def find_updates(self):
204204
host_packages = self.packages.exclude(kernels_q).distinct()
205205
kernel_packages = self.packages.filter(kernels_q)
206206

207+
errata_ids = set()
208+
207209
if self.host_repos_only:
208-
update_ids = self.find_host_repo_updates(host_packages, repo_packages)
210+
update_ids = self.find_host_repo_updates(host_packages, repo_packages, errata_ids)
209211
else:
210-
update_ids = self.find_osrelease_repo_updates(host_packages, repo_packages)
212+
update_ids = self.find_osrelease_repo_updates(host_packages, repo_packages, errata_ids)
211213

212214
kernel_update_ids = self.find_kernel_updates(kernel_packages, repo_packages)
213215
for ku_id in kernel_update_ids:
@@ -217,7 +219,11 @@ def find_updates(self):
217219
if update.id not in update_ids:
218220
self.updates.remove(update)
219221

220-
def find_host_repo_updates(self, host_packages, repo_packages):
222+
for erratum in self.errata.all():
223+
if erratum.id not in errata_ids:
224+
self.errata.remove(erratum)
225+
226+
def find_host_repo_updates(self, host_packages, repo_packages, errata_ids):
221227

222228
update_ids = []
223229
hostrepos_q = Q(repo__mirror__enabled=True,
@@ -258,6 +264,7 @@ def find_host_repo_updates(self, host_packages, repo_packages):
258264
if errata:
259265
for erratum in errata:
260266
self.errata.add(erratum)
267+
errata_ids.add(erratum.id)
261268
if highest_package.compare_version(pu) == -1:
262269
if priority is not None:
263270
# 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):
276283
update_ids.append(uid)
277284
return update_ids
278285

279-
def find_osrelease_repo_updates(self, host_packages, repo_packages):
286+
def find_osrelease_repo_updates(self, host_packages, repo_packages, errata_ids):
280287

281288
update_ids = []
282289
for package in host_packages:
@@ -304,6 +311,7 @@ def find_osrelease_repo_updates(self, host_packages, repo_packages):
304311
if errata:
305312
for erratum in errata:
306313
self.errata.add(erratum)
314+
errata_ids.add(erratum.id)
307315
if highest_package.compare_version(pu) == -1:
308316
highest_package = pu
309317

patchman/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
import site
55
import sys
66

7+
# use pysqlite3 if available
8+
try:
9+
import pysqlite3 # noqa
10+
sys.modules['sqlite3'] = sys.modules.pop('pysqlite3')
11+
except ImportError:
12+
pass
13+
714
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
815
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
916

0 commit comments

Comments
 (0)