diff --git a/client/patchman-client b/client/patchman-client index cbec7268..9e8615bb 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -948,7 +948,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 diff --git a/client/patchman-client.conf b/client/patchman-client.conf index 666dd54c..483d9a3d 100644 --- a/client/patchman-client.conf +++ b/client/patchman-client.conf @@ -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) diff --git a/hosts/tests/test_utils.py b/hosts/tests/test_utils.py new file mode 100644 index 00000000..c41c2890 --- /dev/null +++ b/hosts/tests/test_utils.py @@ -0,0 +1,56 @@ +# Copyright 2026 Marcus Furlong +# +# 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 + +from unittest.mock import patch + +from django.test import TestCase + +from arch.models import MachineArchitecture +from domains.models import Domain +from hosts.utils import get_or_create_host +from operatingsystems.models import OSRelease, OSVariant +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', ''), []) diff --git a/hosts/utils.py b/hosts/utils.py index bf6a1215..9e618c4e 100644 --- a/hosts/utils.py +++ b/hosts/utils.py @@ -19,6 +19,7 @@ from django.db import IntegrityError, transaction from taggit.models import Tag +from taggit.utils import parse_tags from util import get_datetime_now from util.logging import error_message, info_message @@ -36,6 +37,15 @@ def update_rdns(host): host.save() +def get_report_tags(report): + """ Protocol 2 sends a list (stored comma-joined), protocol 1 sends the + raw config string, which may be comma or space delimited + """ + if report.protocol == '2': + return [t.strip() for t in (report.tags or '').split(',') if t.strip()] + return parse_tags(report.tags or '') + + def get_or_create_host(report, arch, osvariant, domain): """ Get or create a host from from a report """ @@ -65,7 +75,7 @@ def get_or_create_host(report, arch, osvariant, domain): host.osvariant = osvariant host.domain = domain host.lastreport = report.created - host.tags.set(report.tags.split(','), clear=True) + host.tags.set(get_report_tags(report), clear=True) if report.reboot == 'True': host.reboot_required = True else: