From d4f1a2a572cc0eb714aa9ab34cb998df8d79b3e9 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 25 Feb 2026 12:55:50 +0000 Subject: [PATCH] Added #112 --- config.py.example | 9 + netbox_zabbix_sync/modules/device.py | 17 +- .../modules/host_description.py | 125 ++++++++++++++ netbox_zabbix_sync/modules/settings.py | 2 + tests/test_host_description.py | 157 ++++++++++++++++++ 5 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 netbox_zabbix_sync/modules/host_description.py create mode 100644 tests/test_host_description.py diff --git a/config.py.example b/config.py.example index acc1629..5dbe28e 100644 --- a/config.py.example +++ b/config.py.example @@ -12,6 +12,15 @@ templates_config_context_overrule = False template_cf = "zabbix_template" device_cf = "zabbix_hostid" +# Zabbix host description +# The following options are available for the description of all created hosts in Zabbix +# static: Uses the default static string "Host added by NetBox sync script." +# dynamic: "Uses a predefined dynamic string which resolves the owner of an object and datetime. Recommended for users who use Netbox 4.5+ +# custom: Use a custom string such as "This host was created by Zabbix-sync on machine MGMT01.internal". It is also posible to resolve dynamic values in this string using {} markers. +description = "static" +# The timedate format which is used for generating the datetime macro when used in the dynamic description type or custom type. +description_dt_format = "%Y-%m-%d %H:%M:%S" + ## Enable clustering of devices with virtual chassis setup clustering = False diff --git a/netbox_zabbix_sync/modules/device.py b/netbox_zabbix_sync/modules/device.py index 3878756..3a80898 100644 --- a/netbox_zabbix_sync/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -17,6 +17,7 @@ from netbox_zabbix_sync.modules.exceptions import ( SyncInventoryError, TemplateError, ) +from netbox_zabbix_sync.modules.host_description import Description from netbox_zabbix_sync.modules.hostgroups import Hostgroup from netbox_zabbix_sync.modules.interface import ZabbixInterface from netbox_zabbix_sync.modules.settings import load_config @@ -534,13 +535,7 @@ class PhysicalDevice: ) return False - def create_in_zabbix( - self, - groups, - templates, - proxies, - description="Host added by NetBox sync script.", - ): + def create_in_zabbix(self, groups, templates, proxies): """ Creates Zabbix host object with parameters from NetBox object. """ @@ -562,6 +557,14 @@ class PhysicalDevice: interfaces = self.set_interface_details() # Set Zabbix proxy if defined self._set_proxy(proxies) + # Set description + description_handler = Description( + self.nb, + self.config, + logger=self.logger, + nb_version=self.nb_api_version, + ) + description = description_handler.generate() # Set basic data for host creation create_data = { "host": self.name, diff --git a/netbox_zabbix_sync/modules/host_description.py b/netbox_zabbix_sync/modules/host_description.py new file mode 100644 index 0000000..40d8a7b --- /dev/null +++ b/netbox_zabbix_sync/modules/host_description.py @@ -0,0 +1,125 @@ +""" +Modules that set description of a host in Zabbix +""" + +from datetime import datetime +from logging import getLogger +from re import findall as re_findall + + +class Description: + """ + Class that generates the description for a host in Zabbix based on the configuration provided. + + INPUT: + - netbox_object: The NetBox object that is being synced. + - configuration: configuration of the syncer. + Required keys in configuration: + description: Can be "static", "dynamic" or a custom description with macros. + - nb_version: The version of NetBox that is being used. + """ + + def __init__(self, netbox_object, configuration, nb_version, logger=None): + self.netbox_object = netbox_object + self.name = self.netbox_object.name + self.configuration = configuration + self.nb_version = nb_version + self.logger = logger or getLogger(__name__) + self._set_default_macro_values() + self._set_defaults() + + def _set_default_macro_values(self): + """ + Sets the default macro values for the description. + """ + # Get the datetime format from the configuration, + # or use the default format if not provided + dt_format = self.configuration.get("description_dt_format", "%Y-%m-%d %H:%M:%S") + # Set the datetime macro + try: + datetime_value = datetime.now().strftime(dt_format) + except (ValueError, TypeError) as e: + self.logger.warning( + "Host %s: invalid datetime format '%s': %s. Using default format.", + self.name, + dt_format, + e, + ) + datetime_value = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Set the owner macro + owner = self.netbox_object.owner if self.nb_version >= "4.5" else "" + # Set the macro list + self.macros = {"{datetime}": datetime_value, "{owner}": owner} + + def _resolve_macros(self, description): + """ + Takes a description and resolves the macro's in it. + Returns the description with the macro's resolved. + """ + # Find all macros in the description + provided_macros = re_findall(r"\{\w+\}", description) + # Go through all macros provided in the NB description + for macro in provided_macros: + # If the macro is in the list of default macro values + if macro in self.macros: + # Replace the macro in the description with the value of the macro + description = description.replace(macro, str(self.macros[macro])) + else: + # One of the macro's is invalid. + self.logger.warning( + "Host %s: macro %s is not valid. Failing back to default.", + self.name, + macro, + ) + return False + return description + + def _set_defaults(self): + """ + Sets the default descriptions for the host. + """ + self.defaults = { + "static": "Host added by NetBox sync script.", + "dynamic": ( + "Host by owner {owner} added by NetBox sync script on {datetime}." + ), + } + + def _custom_override(self): + """ + Checks if the description is mentioned in the config context. + """ + zabbix_config = self.netbox_object.config_context.get("zabbix") + if zabbix_config and "description" in zabbix_config: + return zabbix_config["description"] + return False + + def generate(self): + """ + Generates the description for the host. + """ + # First: check if an override is present. + config_context_description = self._custom_override() + if config_context_description is not False: + resolved = self._resolve_macros(config_context_description) + return resolved if resolved else self.defaults["static"] + # Override is not present: continue with config description + description = "" + if "description" not in self.configuration: + # If no description config is provided, use default static + return self.defaults["static"] + if not self.configuration["description"]: + # The configuration is set to False, meaning an empty description + return description + if self.configuration["description"] in self.defaults: + # The description is one of the default options + description = self.defaults[self.configuration["description"]] + else: + # The description is set to a custom description + description = self.configuration["description"] + # Resolve the macro's in the description + final_description = self._resolve_macros(description) + if final_description: + return final_description + return self.defaults["static"] diff --git a/netbox_zabbix_sync/modules/settings.py b/netbox_zabbix_sync/modules/settings.py index 1d07380..f44930a 100644 --- a/netbox_zabbix_sync/modules/settings.py +++ b/netbox_zabbix_sync/modules/settings.py @@ -82,6 +82,8 @@ DEFAULT_CONFIG = { "cluster/name": "cluster", "platform/name": "target", }, + "description_dt_format": "%Y-%m-%d %H:%M:%S", + "description": "static", } diff --git a/tests/test_host_description.py b/tests/test_host_description.py new file mode 100644 index 0000000..a358481 --- /dev/null +++ b/tests/test_host_description.py @@ -0,0 +1,157 @@ +"""Tests for the Description class in the host_description module.""" + +import unittest +from unittest.mock import MagicMock, patch + +from netbox_zabbix_sync.modules.host_description import Description + + +class TestDescription(unittest.TestCase): + """Test class for Description functionality.""" + + def setUp(self): + """Set up test fixtures.""" + # Create mock NetBox object + self.mock_nb_object = MagicMock() + self.mock_nb_object.name = "test-host" + self.mock_nb_object.owner = "admin" + self.mock_nb_object.config_context = {} + + # Create logger mock + self.mock_logger = MagicMock() + + # Base configuration + self.base_config = {} + + # Test 1: Config context description override + @patch("netbox_zabbix_sync.modules.host_description.datetime") + def test_1_config_context_override_value(self, mock_datetime): + """Test 1: User that provides a config context description value should get this override value back.""" + mock_now = MagicMock() + mock_now.strftime.return_value = "2026-02-25 10:30:00" + mock_datetime.now.return_value = mock_now + + # Set config context with description + self.mock_nb_object.config_context = { + "zabbix": {"description": "Custom override for {owner}"} + } + + config = {"description": "static"} + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + # Should use config context, not config + self.assertEqual(result, "Custom override for admin") + + # Test 2: Static description + def test_2_static_description( + self, + ): + """Test 2: User that provides static as description should get the default static value.""" + config = {"description": "static"} + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + self.assertEqual(result, "Host added by NetBox sync script.") + + # Test 3: Dynamic description + @patch("netbox_zabbix_sync.modules.host_description.datetime") + def test_3_dynamic_description(self, mock_datetime): + """Test 3: User that provides 'dynamic' should get the resolved description string back.""" + mock_now = MagicMock() + mock_now.strftime.return_value = "2026-02-25 10:30:00" + mock_datetime.now.return_value = mock_now + + config = {"description": "dynamic"} + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + expected = ( + "Host by owner admin added by NetBox sync script on 2026-02-25 10:30:00." + ) + self.assertEqual(result, expected) + + # Test 4: Invalid macro fallback + def test_4_invalid_macro_fallback_to_static(self): + """Test 4: Users who provide invalid macros should fallback to the static variant.""" + config = {"description": "Host {owner} with {invalid_macro}"} + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + # Should fall back to static default + self.assertEqual(result, "Host added by NetBox sync script.") + # Verify warning was logged + self.mock_logger.warning.assert_called_once() + + # Test 5: Custom time format + @patch("netbox_zabbix_sync.modules.host_description.datetime") + def test_5_custom_datetime_format(self, mock_datetime): + """Test 5: Users who change the time format.""" + mock_now = MagicMock() + # Will be called twice: once with custom format, once for string + mock_now.strftime.side_effect = ["25/02/2026", "25/02/2026"] + mock_datetime.now.return_value = mock_now + + config = { + "description": "Updated on {datetime}", + "description_dt_format": "%d/%m/%Y", + } + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + self.assertEqual(result, "Updated on 25/02/2026") + + # Test 6: Custom description format in config + @patch("netbox_zabbix_sync.modules.host_description.datetime") + def test_6_custom_description_format(self, mock_datetime): + """Test 6: Users who provide a custom description format in the config.""" + mock_now = MagicMock() + mock_now.strftime.return_value = "2026-02-25 10:30:00" + mock_datetime.now.return_value = mock_now + + config = {"description": "Server {owner} managed at {datetime}"} + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + self.assertEqual(result, "Server admin managed at 2026-02-25 10:30:00") + + # Test 7: Owner on lower NetBox version + @patch("netbox_zabbix_sync.modules.host_description.datetime") + def test_7_owner_on_lower_netbox_version(self, mock_datetime): + """Test 7: Users who try to resolve the owner property on a lower NetBox version (3.2).""" + mock_now = MagicMock() + mock_now.strftime.return_value = "2026-02-25 10:30:00" + mock_datetime.now.return_value = mock_now + + config = {"description": "Device owned by {owner}"} + desc = Description( + self.mock_nb_object, + config, + "3.2", # Lower NetBox version + logger=self.mock_logger, + ) + + result = desc.generate() + # Owner should be empty string on version < 4.5 + self.assertEqual(result, "Device owned by ") + + # Test 8: Missing or False description returns static + def test_8a_missing_description_returns_static(self): + """Test 8a: When description option is not found, script should return the static variant.""" + config = {} # No description key + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + self.assertEqual(result, "Host added by NetBox sync script.") + + def test_8b_false_description_returns_empty(self): + """Test 8b: When description is set to False, script should return empty string.""" + config = {"description": False} + desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger) + + result = desc.generate() + self.assertEqual(result, "") + + +if __name__ == "__main__": + unittest.main()