Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.0.17
4.0.18
165 changes: 143 additions & 22 deletions client/patchman-client
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,11 +12,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)"
Expand All @@ -24,16 +26,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
Expand All @@ -48,6 +51,9 @@ parseopts() {
u)
local_updates=true
;;
y)
dry_run=true
;;
r)
cli_report=true
;;
Expand All @@ -60,7 +66,7 @@ parseopts() {
t)
cli_tags="${OPTARG}"
;;
h)
h|H)
cli_hostname=${OPTARG}
;;
p)
Expand Down Expand Up @@ -121,12 +127,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
Expand All @@ -136,9 +141,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}
Expand Down Expand Up @@ -191,15 +208,15 @@ 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
fi
}

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
Expand Down Expand Up @@ -490,6 +507,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}

Expand All @@ -505,12 +546,12 @@ 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; 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
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
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
Expand All @@ -528,7 +569,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
Expand Down Expand Up @@ -673,13 +714,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() {
Expand Down Expand Up @@ -1083,8 +1134,78 @@ get_modules
if ${repo_check} ; then
get_repos
fi
if ${local_updates} ; then
get_apt_updates
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
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

# Use protocol 2 (JSON) or protocol 1 (form data) based on config
if [ "${protocol}" == "2" ] ; then
post_json_data
Expand Down
19 changes: 19 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <furlongm@gmail.com> Fri, 06 Mar 2026 03:21:58 +0000

patchman (4.0.17-1) stable; urgency=medium

[ dependabot[bot] ]
Expand Down
8 changes: 5 additions & 3 deletions hooks/yum/patchman.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2013-2016 Marcus Furlong <furlongm@gmail.com>
# Copyright 2013-2026 Marcus Furlong <furlongm@gmail.com>
#
# This file is part of Patchman.
#
Expand All @@ -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)
16 changes: 12 additions & 4 deletions hosts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions patchman/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)))

Expand Down
Loading
Loading