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
117 changes: 95 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,30 @@ 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
exit 0
fi

# Use protocol 2 (JSON) or protocol 1 (form data) based on config
if [ "${protocol}" == "2" ] ; then
post_json_data
Expand Down
59 changes: 59 additions & 0 deletions reports/tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
40 changes: 22 additions & 18 deletions reports/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 ''
Expand Down