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.21
4.0.22
3 changes: 2 additions & 1 deletion client/patchman-client
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ get_repos() {
mirror_file=$(echo "${mirror_repo}" | sed -e "s/.* mirror+file://g" | cut -d " " -f 1)
if [ -f "${mirror_file}" ] ; then
for url in $(cat ${mirror_file}) ; do
url=${url%/}
dist_repo=$(echo "${mirror_repo}" | sed -e "s#mirror+file:${mirror_file}#${url}#g")
dist_repos="${dist_repos}"$'\n'"${dist_repo}"
done
Expand Down Expand Up @@ -948,7 +949,7 @@ build_json_report() {
# Convert tags to JSON array
local tags_json="[]"
if [ ! -z "${tags}" ] && [ "${tags}" != "Default" ] ; then
tags_json=$(echo "${tags}" | tr ',' '\n' | jq -R . | jq -s .)
tags_json=$(echo "${tags}" | jq -R 'if test(",") then split(",") else [splits("\\s+")] end | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))')
fi

# Convert reboot to boolean
Expand Down
2 changes: 1 addition & 1 deletion client/patchman-client.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ server=https://patchman.example.com
# Options to curl
curl_options="--insecure --connect-timeout 60 --max-time 300"

# Space delimited tags to send
# Comma or space delimited tags to send
tags="Server"

# Does the client output a report of the upload (e.g. for cronjob output)
Expand Down
32 changes: 32 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
patchman (4.0.22-1) stable; urgency=medium

[ dependabot[bot] ]
* Bump gitpython from 3.1.50 to 3.1.57
* Bump gitpython from 3.1.57 to 3.1.58

[ John ]
* Fix NVD CVE 2.0 API: cveId -> cveIds query parameter

[ Aman Maharjan ]
* skip deb kernel meta-packages in reboot check

[ Matthew Howle ]
* Add PACKAGE_TYPES choices

[ Marcus Furlong ]
* add missing report migration

[ Gedminas, Marius ]
* Fix UnboundLocalError while recovering from an error

[ Marcus Furlong ]
* fix legacy tagging migrations
* fix zstd repos without declared sizes
* fix tag handling to use commas or spaces
* make -a also update errata
* ensure update counts are refreshed
* strip trailing slash from apt mirror file urls
* auto-commit to update version skip-checks: true

-- Marcus Furlong <furlongm@gmail.com> Wed, 04 Nov 2026 04:38:08 +0000

patchman (4.0.21-1) stable; urgency=medium

[ dependabot[bot] ]
Expand Down
2 changes: 2 additions & 0 deletions errata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# along with Patchman. If not, see <http://www.gnu.org/licenses/>

from errata.models import Erratum
from hosts.utils import refresh_host_update_counts
from packages.models import PackageUpdate
from patchman.signals import pbar_start, pbar_update
from util import fetch_concurrently, run_concurrently, tz_aware_datetime
Expand Down Expand Up @@ -72,6 +73,7 @@ def mark_errata_security_updates(concurrent_processing=True, max_workers=25):
for i, e in enumerate(Erratum.objects.all()):
e.scan_for_security_updates()
pbar_update.send(sender=None, index=i + 1)
refresh_host_update_counts()


def scan_security_worker(pk):
Expand Down
34 changes: 34 additions & 0 deletions hosts/migrations/0014_drop_legacy_tagging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.db import migrations

LEGACY_TAGGING_TABLES = ['tagging_taggeditem', 'tagging_tag']


def drop_legacy_tagging(apps, schema_editor):
# 0004 never dropped the django-tagging column/tables on 3.x upgrades
conn = schema_editor.connection
q = schema_editor.quote_name
with conn.cursor() as cursor:
columns = [c.name for c in conn.introspection.get_table_description(cursor, 'hosts_host')]
tables = conn.introspection.table_names(cursor)

if 'tags' in columns:
if getattr(conn.features, 'can_alter_table_drop_column', True):
schema_editor.execute(f'ALTER TABLE {q("hosts_host")} DROP COLUMN {q("tags")}')
else:
# old sqlite: rebuilding the table from the model drops unknown columns
schema_editor._remake_table(apps.get_model('hosts', 'Host'))

for table in LEGACY_TAGGING_TABLES:
if table in tables:
schema_editor.execute(f'DROP TABLE {q(table)}')


class Migration(migrations.Migration):

dependencies = [
('hosts', '0013_alter_hostrepo_unique_together_and_more'),
]

operations = [
migrations.RunPython(drop_legacy_tagging, migrations.RunPython.noop),
]
5 changes: 5 additions & 0 deletions hosts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,11 @@ def find_deb_kernel_updates(self, kernel_packages, repo_packages, hostrepos):
if running_kernel_pkg:
for package in kernel_packages:
if package.name.name.startswith('linux-image-'):
# skip meta-packages (e.g. linux-image-generic): their version
# (5.15.0.198.171) always sorts above the kernel they pull in
# (5.15.0-198.208), so they would flag a reboot on every host
if self.get_deb_kernel_series(package.name.name) is None:
continue
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:
Expand Down
99 changes: 99 additions & 0 deletions hosts/tests/test_migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
#
# This file is part of Patchman.
#
# Patchman is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 only.
#
# Patchman is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Patchman. If not, see <http://www.gnu.org/licenses/>

import json
from importlib import import_module

from django.apps import apps
from django.db import connection
from django.test import TransactionTestCase, override_settings

from hosts.models import Host
from reports.models import Report

migration = import_module('hosts.migrations.0014_drop_legacy_tagging')


def host_columns():
with connection.cursor() as cursor:
return [c.name for c in connection.introspection.get_table_description(cursor, 'hosts_host')]


def table_names():
with connection.cursor() as cursor:
return connection.introspection.table_names(cursor)


@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
)
class DropLegacyTaggingTests(TransactionTestCase):
"""Tests for hosts migration 0014 (leftover django-tagging schema, #690)."""

def setUp(self):
self.addCleanup(self.run_migration)

def run_migration(self):
with connection.schema_editor() as schema_editor:
migration.drop_legacy_tagging(apps, schema_editor)

def add_legacy_schema(self):
with connection.cursor() as cursor:
cursor.execute('ALTER TABLE hosts_host ADD COLUMN tags varchar(255) CHECK (tags IS NOT NULL)')
cursor.execute('CREATE TABLE tagging_tag (id integer PRIMARY KEY, name varchar(50))')
cursor.execute(
'CREATE TABLE tagging_taggeditem '
'(id integer PRIMARY KEY, tag_id integer REFERENCES tagging_tag (id))'
)

def create_report(self):
return Report.objects.create(
host='legacy.example.com',
domain='example.com',
report_ip='192.168.1.10',
os='Ubuntu 22.04.3 LTS',
kernel='5.15.0-91-generic',
arch='x86_64',
protocol='2',
packages=json.dumps([]),
repos=json.dumps([]),
modules=json.dumps([]),
sec_updates=json.dumps([]),
bug_updates=json.dumps([]),
)

def test_legacy_column_does_not_crash_report_processing(self):
"""Host creation fails on the legacy column; process() must not raise."""
self.add_legacy_schema()
self.create_report().process(find_updates=False)
self.assertFalse(Host.objects.filter(hostname='legacy.example.com').exists())

def test_drops_legacy_column_and_tables(self):
"""Leftover tags column and tagging tables are dropped; hosts can be created again."""
self.add_legacy_schema()
self.run_migration()
self.assertNotIn('tags', host_columns())
self.assertNotIn('tagging_tag', table_names())
self.assertNotIn('tagging_taggeditem', table_names())
self.create_report().process(find_updates=False)
self.assertTrue(Host.objects.filter(hostname='legacy.example.com').exists())

def test_noop_on_clean_schema(self):
"""Fresh installs have nothing to drop."""
before = host_columns()
self.run_migration()
self.assertEqual(host_columns(), before)
39 changes: 39 additions & 0 deletions hosts/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,45 @@ def test_deb_latest_not_installed(self):
self.assertEqual(update.oldpackage, self.img_51)
self.assertEqual(update.newpackage, self.img_53)

def test_deb_reboot_required_newer_kernel_installed(self):
"""DEB: newer kernel installed but not running. Reboot required."""
host = self._create_host(
'6.8.0-51-generic',
[self.img_49, self.img_51, self.img_53],
)
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)

self.assertTrue(host.reboot_required)

def test_deb_meta_package_does_not_require_reboot(self):
"""DEB: running the latest kernel with linux-image-generic installed.

The meta-package version (6.8.0.53.54) sorts above the kernel it
depends on (6.8.0-53.54) and must not flag a reboot.
"""
meta_name = PackageName.objects.create(name='linux-image-generic')
meta = Package.objects.create(
name=meta_name, arch=self.pkg_arch, epoch='',
version='6.8.0.53.54', release='', packagetype='D'
)
host = self._create_host(
'6.8.0-53-generic',
[self.img_49, self.img_53, meta],
)
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)

self.assertFalse(host.reboot_required)

def test_deb_flavour_extraction(self):
"""Test get_deb_kernel_flavour helper."""
host = self._create_host('6.8.0-51-generic', [self.img_51])
Expand Down
91 changes: 91 additions & 0 deletions hosts/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
#
# This file is part of Patchman.
#
# Patchman is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 only.
#
# Patchman is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Patchman. If not, see <http://www.gnu.org/licenses/>

from unittest.mock import patch

from django.test import TestCase
from django.utils import timezone

from arch.models import MachineArchitecture, PackageArchitecture
from domains.models import Domain
from errata.models import Erratum
from errata.utils import mark_errata_security_updates
from hosts.models import Host
from hosts.utils import get_or_create_host
from operatingsystems.models import OSRelease, OSVariant
from packages.models import Package, PackageName, PackageUpdate
from reports.models import Report


@patch('hosts.models.Host.check_rdns')
class GetOrCreateHostTagTests(TestCase):

def setUp(self):
self.arch = MachineArchitecture.objects.create(name='x86_64')
self.domain = Domain.objects.create(name='example.com')
self.osvariant = OSVariant.objects.create(
name='Debian 13', osrelease=OSRelease.objects.create(name='Debian 13'),
)

def host_tags(self, protocol, tags):
report = Report.objects.create(
host='tags.example.com', report_ip='192.168.1.10', os='Debian 13',
kernel='6.12.57+deb13-amd64', arch='x86_64', protocol=protocol, tags=tags,
)
host = get_or_create_host(report, self.arch, self.osvariant, self.domain)
return sorted(host.tags.names())

def test_protocol1_space_delimited(self, _):
self.assertEqual(self.host_tags('1', 'web prod'), ['prod', 'web'])

def test_protocol1_comma_delimited(self, _):
self.assertEqual(self.host_tags('1', 'web, prod'), ['prod', 'web'])

def test_protocol2_keeps_spaces_within_tags(self, _):
self.assertEqual(self.host_tags('2', 'my tag, web'), ['my tag', 'web'])

def test_protocol2_empty_tags(self, _):
self.assertEqual(self.host_tags('2', ''), [])


class RefreshHostUpdateCountsTests(TestCase):

def test_counts_refreshed_after_errata_security_marking(self):
os_release = OSRelease.objects.create(name='Debian 13')
host = Host.objects.create(
hostname='h.example.com', ipaddress='192.0.2.1',
osvariant=OSVariant.objects.create(name='Debian 13', osrelease=os_release),
kernel='6.12.0', arch=MachineArchitecture.objects.create(name='x86_64'),
domain=Domain.objects.create(name='example.com'),
lastreport=timezone.now(),
)
pkg_arch = PackageArchitecture.objects.create(name='amd64')
name = PackageName.objects.create(name='curl')
old = Package.objects.create(name=name, arch=pkg_arch, epoch='', version='1', release='1', packagetype='D')
new = Package.objects.create(name=name, arch=pkg_arch, epoch='', version='2', release='1', packagetype='D')
update = PackageUpdate.objects.create(oldpackage=old, newpackage=new, security=False)
host.updates.add(update)
host.refresh_from_db()
self.assertEqual((host.sec_updates_count, host.bug_updates_count), (0, 1))

erratum = Erratum.objects.create(
name='DSA-0000-1', e_type='security', issue_date=timezone.now(), synopsis='curl',
)
erratum.fixed_packages.add(new)
mark_errata_security_updates(concurrent_processing=False)

host.refresh_from_db()
self.assertEqual((host.sec_updates_count, host.bug_updates_count), (1, 0))
Loading
Loading