From 623994c55f302590abb6ebf4ed5c9c462ba85012 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 09:24:32 +0100 Subject: [PATCH 01/57] =?UTF-8?q?=F0=9F=94=A7=20Specifically=20ignore=20as?= =?UTF-8?q?sertion=20in=20tests=20instead=20of=20entire=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5d64a22..77282b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,6 @@ ignore = [ "PLR0915", # Ignore too many branches "PLR0912", - # Ignore use of assert - "S101", ] select = [ @@ -65,10 +63,12 @@ select = [ "RUF", ] -[dependency-groups] -dev = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", - "ruff>=0.14.14", - "ty>=0.0.14", +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + # Ignore use of assert + "S101", ] + + +[dependency-groups] +dev = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "ruff>=0.14.14", "ty>=0.0.14"] From df00114e3a765a5e623829ab952a5380c88af6e2 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 09:57:15 +0100 Subject: [PATCH 02/57] =?UTF-8?q?=F0=9F=94=A7=20Removed=20pip=20installati?= =?UTF-8?q?on=20is=20favor=20of=20installing=20uv=20and=20synchronizing=20?= =?UTF-8?q?virtual=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/devcontainer.json | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 402443d..5e80be9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,19 +4,14 @@ "name": "Python 3", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/python:3.14", - // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "pip3 install --user -r requirements.txt && pip3 install --user uv pylint pytest coverage pytest-cov && uv sync --dev" - + "postCreateCommand": "pip install --user uv && uv sync --dev" // Configure tool-specific properties. // "customizations": {}, - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" -} +} \ No newline at end of file From a1517710023448c6f4f7ccb291428866b8d71c1b Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 10:02:45 +0100 Subject: [PATCH 03/57] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Switched=20to=20i?= =?UTF-8?q?nstallation=20of=20locked=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5e80be9..8070853 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "pip install --user uv && uv sync --dev" + "postCreateCommand": "pip install --user uv && uv sync --frozen --dev" // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. From 6d715e6835d9b22600b48da1eb6e73b2c9a1b642 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 10:25:35 +0100 Subject: [PATCH 04/57] =?UTF-8?q?=F0=9F=92=A1=20Removed=20old=20pylint=20a?= =?UTF-8?q?nnotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/device.py | 4 ---- modules/hostgroups.py | 2 -- modules/interface.py | 1 - modules/tags.py | 2 -- modules/usermacros.py | 1 - modules/virtual_machine.py | 1 - netbox_zabbix_sync.py | 2 -- tests/test_device_deletion.py | 2 +- tests/test_interface.py | 10 +++++----- 9 files changed, 6 insertions(+), 19 deletions(-) diff --git a/modules/device.py b/modules/device.py index 71cb6dd..7d60142 100644 --- a/modules/device.py +++ b/modules/device.py @@ -1,4 +1,3 @@ -# pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation, too-many-lines, too-many-public-methods, duplicate-code """ Device specific handeling for NetBox to Zabbix """ @@ -34,7 +33,6 @@ config = load_config() class PhysicalDevice: - # pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments """ Represents Network device. INPUT: (NetBox device class, ZabbixAPI class, journal flag, NB journal class) @@ -657,7 +655,6 @@ class PhysicalDevice: def consistency_check( self, groups, templates, proxies, proxy_power, create_hostgroups ): - # pylint: disable=too-many-branches, too-many-statements """ Checks if Zabbix object is still valid with NetBox parameters. """ @@ -878,7 +875,6 @@ class PhysicalDevice: self.update_zabbix_host(tags=self.tags) # If only 1 interface has been found - # pylint: disable=too-many-nested-blocks if len(host["interfaces"]) == 1: updates = {} # Go through each key / item and check if it matches Zabbix diff --git a/modules/hostgroups.py b/modules/hostgroups.py index d5544be..c78c00c 100644 --- a/modules/hostgroups.py +++ b/modules/hostgroups.py @@ -10,8 +10,6 @@ class Hostgroup: """Hostgroup class for devices and VM's Takes type (vm or dev) and NB object""" - # pylint: disable=too-many-arguments, disable=too-many-positional-arguments - # pylint: disable=logging-fstring-interpolation def __init__( self, obj_type, diff --git a/modules/interface.py b/modules/interface.py index 4b79134..1bb5502 100644 --- a/modules/interface.py +++ b/modules/interface.py @@ -40,7 +40,6 @@ class ZabbixInterface: def set_snmp(self): """Check if interface is type SNMP""" - # pylint: disable=too-many-branches snmp_interface_type = 2 if self.interface["type"] == snmp_interface_type: # Checks if SNMP settings are defined in NetBox diff --git a/modules/tags.py b/modules/tags.py index 21497f6..6ed17be 100644 --- a/modules/tags.py +++ b/modules/tags.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments, logging-fstring-interpolation """ All of the Zabbix Usermacro related configuration """ @@ -101,7 +100,6 @@ class ZabbixTags: """ Generate full set of Usermacros """ - # pylint: disable=too-many-branches tags = [] # Parse the field mapper for tags if self.tag_map: diff --git a/modules/usermacros.py b/modules/usermacros.py index 1a0780c..3258dde 100644 --- a/modules/usermacros.py +++ b/modules/usermacros.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments, logging-fstring-interpolation """ All of the Zabbix Usermacro related configuration """ diff --git a/modules/virtual_machine.py b/modules/virtual_machine.py index ff4ed0c..2ee70ce 100644 --- a/modules/virtual_machine.py +++ b/modules/virtual_machine.py @@ -1,4 +1,3 @@ -# pylint: disable=duplicate-code """Module that hosts all functions for virtual machine processing""" from modules.config import load_config diff --git a/netbox_zabbix_sync.py b/netbox_zabbix_sync.py index db4edc9..4a5c880 100755 --- a/netbox_zabbix_sync.py +++ b/netbox_zabbix_sync.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation """NetBox to Zabbix sync script.""" @@ -30,7 +29,6 @@ logger = get_logger() def main(arguments): """Run the sync process.""" - # pylint: disable=too-many-branches, too-many-statements # set environment variables if arguments.verbose: set_log_levels(logging.WARNING, logging.INFO) diff --git a/tests/test_device_deletion.py b/tests/test_device_deletion.py index b26ca9b..918ab33 100644 --- a/tests/test_device_deletion.py +++ b/tests/test_device_deletion.py @@ -111,7 +111,7 @@ class TestDeviceDeletion(unittest.TestCase): def test_zeroize_cf(self): """Test _zeroize_cf method that clears the custom field.""" # Execute - self.device._zeroize_cf() # pylint: disable=protected-access + self.device._zeroize_cf() # Verify self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"]) diff --git a/tests/test_interface.py b/tests/test_interface.py index 3c37413..ee6b4a7 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -91,27 +91,27 @@ class TestZabbixInterface(unittest.TestCase): # Test for agent type (1) interface.interface["type"] = 1 - interface._set_default_port() # pylint: disable=protected-access + interface._set_default_port() self.assertEqual(interface.interface["port"], "10050") # Test for SNMP type (2) interface.interface["type"] = 2 - interface._set_default_port() # pylint: disable=protected-access + interface._set_default_port() self.assertEqual(interface.interface["port"], "161") # Test for IPMI type (3) interface.interface["type"] = 3 - interface._set_default_port() # pylint: disable=protected-access + interface._set_default_port() self.assertEqual(interface.interface["port"], "623") # Test for JMX type (4) interface.interface["type"] = 4 - interface._set_default_port() # pylint: disable=protected-access + interface._set_default_port() self.assertEqual(interface.interface["port"], "12345") # Test for unsupported type interface.interface["type"] = 99 - result = interface._set_default_port() # pylint: disable=protected-access + result = interface._set_default_port() self.assertFalse(result) def test_set_snmp_v2(self): From 6697311f8d282303797de305f3dcbc8eb0e38fcb Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 12 Feb 2026 10:33:17 +0000 Subject: [PATCH 05/57] Splitted core code from calling the script directly --- modules/core.py | 276 ++++++++++++++++++++++++++++++++++++++++++ netbox_zabbix_sync.py | 273 ++--------------------------------------- 2 files changed, 289 insertions(+), 260 deletions(-) create mode 100644 modules/core.py diff --git a/modules/core.py b/modules/core.py new file mode 100644 index 0000000..ed99b28 --- /dev/null +++ b/modules/core.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 + +"""Core component of the sync process""" + +import ssl +import sys +from os import environ + +from pynetbox import api +from pynetbox.core.query import RequestError as NBRequestError +from requests.exceptions import ConnectionError as RequestsConnectionError +from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI + +from modules.config import load_config +from modules.device import PhysicalDevice +from modules.exceptions import EnvironmentVarError, SyncError +from modules.logging import get_logger, setup_logger +from modules.tools import convert_recordset, proxy_prepper, verify_hg_format +from modules.virtual_machine import VirtualMachine + +# Import configuration settings +config = load_config() + + +setup_logger() +logger = get_logger() + + +def run_sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): + """ + Run the NetBox to Zabbix sync process. + """ + # Set NetBox API + netbox = api(nb_host, token=nb_token, threading=True) + # Create API call to get all custom fields which are on the device objects + try: + # Get NetBox version + nb_version = netbox.version + logger.debug("NetBox version is %s.", nb_version) + except RequestsConnectionError: + logger.error( + "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", + nb_host, + ) + sys.exit(1) + except NBRequestError as e: + logger.error("NetBox error: %s", e) + sys.exit(1) + # Check if the provided Hostgroup layout is valid + device_cfs = [] + vm_cfs = [] + device_cfs = list( + netbox.extras.custom_fields.filter( + type=["text", "object", "select"], content_types="dcim.device" + ) + ) + verify_hg_format( + config["hostgroup_format"], device_cfs=device_cfs, hg_type="dev", logger=logger + ) + if config["sync_vms"]: + vm_cfs = list( + netbox.extras.custom_fields.filter( + type=["text", "object", "select"], + content_types="virtualization.virtualmachine", + ) + ) + verify_hg_format( + config["vm_hostgroup_format"], vm_cfs=vm_cfs, hg_type="vm", logger=logger + ) + # Set Zabbix API + try: + ssl_ctx = ssl.create_default_context() + + # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API + if environ.get("REQUESTS_CA_BUNDLE", None): + ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) + + if not zbx_token: + zabbix = ZabbixAPI( + zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx + ) + else: + zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) + zabbix.check_auth() + except (APIRequestError, ProcessingError) as zbx_error: + e = f"Zabbix returned the following error: {zbx_error}." + logger.error(e) + sys.exit(1) + # Set API parameter mapping based on API version + proxy_name = "host" if not str(zabbix.version).startswith("7") else "name" + # Get all Zabbix and NetBox data + netbox_devices = list(netbox.dcim.devices.filter(**config["nb_device_filter"])) + netbox_vms = [] + if config["sync_vms"]: + netbox_vms = list( + netbox.virtualization.virtual_machines.filter(**config["nb_vm_filter"]) + ) + netbox_site_groups = convert_recordset(netbox.dcim.site_groups.all()) + netbox_regions = convert_recordset(netbox.dcim.regions.all()) + netbox_journals = netbox.extras.journal_entries + zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] + zabbix_templates = zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] + zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] + # Set empty list for proxy processing Zabbix <= 6 + zabbix_proxygroups = [] + if str(zabbix.version).startswith("7"): + zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"]) # type: ignore[attr-defined] + # Sanitize proxy data + if proxy_name == "host": + for proxy in zabbix_proxies: + proxy["name"] = proxy.pop("host") + # Prepare list of all proxy and proxy_groups + zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups) + + # Go through all NetBox devices + for nb_vm in netbox_vms: + try: + vm = VirtualMachine( + nb_vm, + zabbix, + netbox_journals, + nb_version, + config["create_journal"], + logger, + ) + logger.debug("Host %s: Started operations on VM.", vm.name) + vm.set_vm_template() + # Check if a valid template has been found for this VM. + if not vm.zbx_template_names: + continue + vm.set_hostgroup( + config["vm_hostgroup_format"], netbox_site_groups, netbox_regions + ) + # Check if a valid hostgroup has been found for this VM. + if not vm.hostgroups: + continue + if config["extended_site_properties"] and nb_vm.site: + logger.debug("VM %s: extending site information.", vm.name) + vm.site = convert_recordset(netbox.dcim.sites.filter(id=nb_vm.site.id)) # type: ignore[attr-defined] + vm.set_inventory(nb_vm) + vm.set_usermacros() + vm.set_tags() + # Checks if device is in cleanup state + if vm.status in config["zabbix_device_removal"]: + if vm.zabbix_id: + # Delete device from Zabbix + # and remove hostID from NetBox. + vm.cleanup() + logger.info("VM %s: cleanup complete", vm.name) + continue + # Device has been added to NetBox + # but is not in Activate state + logger.info( + "VM %s: Skipping since this VM is not in the active state.", vm.name + ) + continue + # Check if the VM is in the disabled state + if vm.status in config["zabbix_device_disable"]: + vm.zabbix_state = 1 + # Add hostgroup if config is set + if config["create_hostgroups"]: + # Create new hostgroup. Potentially multiple groups if nested + hostgroups = vm.create_zbx_hostgroup(zabbix_groups) + # go through all newly created hostgroups + for group in hostgroups: + # Add new hostgroups to zabbix group list + zabbix_groups.append(group) + # Check if VM is already in Zabbix + if vm.zabbix_id: + vm.consistency_check( + zabbix_groups, + zabbix_templates, + zabbix_proxy_list, + config["full_proxy_sync"], + config["create_hostgroups"], + ) + continue + # Add VM to Zabbix + vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) + except SyncError: + pass + + for nb_device in netbox_devices: + try: + # Set device instance set data such as hostgroup and template information. + device = PhysicalDevice( + nb_device, + zabbix, + netbox_journals, + nb_version, + config["create_journal"], + logger, + ) + logger.debug("Host %s: Started operations on device.", device.name) + device.set_template( + config["templates_config_context"], + config["templates_config_context_overrule"], + ) + # Check if a valid template has been found for this VM. + if not device.zbx_template_names: + continue + device.set_hostgroup( + config["hostgroup_format"], netbox_site_groups, netbox_regions + ) + # Check if a valid hostgroup has been found for this VM. + if not device.hostgroups: + logger.warning( + "Host %s: Host has no valid hostgroups, Skipping this host...", + device.name, + ) + continue + if config["extended_site_properties"] and nb_device.site: + logger.debug("Device %s: extending site information.", device.name) + device.site = convert_recordset( # type: ignore[attr-defined] + netbox.dcim.sites.filter(id=nb_device.site.id) + ) + device.set_inventory(nb_device) + device.set_usermacros() + device.set_tags() + # Checks if device is part of cluster. + # Requires clustering variable + if device.is_cluster() and config["clustering"]: + # Check if device is primary or secondary + if device.promote_primary_device(): + logger.info( + "Device %s: is part of cluster and primary.", device.name + ) + else: + # Device is secondary in cluster. + # Don't continue with this device. + logger.info( + "Device %s: Is part of cluster but not primary. Skipping this host...", + device.name, + ) + continue + # Checks if device is in cleanup state + if device.status in config["zabbix_device_removal"]: + if device.zabbix_id: + # Delete device from Zabbix + # and remove hostID from NetBox. + device.cleanup() + logger.info("Device %s: cleanup complete", device.name) + continue + # Device has been added to NetBox + # but is not in Activate state + logger.info( + "Device %s: Skipping since this device is not in the active state.", + device.name, + ) + continue + # Check if the device is in the disabled state + if device.status in config["zabbix_device_disable"]: + device.zabbix_state = 1 + # Add hostgroup is config is set + if config["create_hostgroups"]: + # Create new hostgroup. Potentially multiple groups if nested + hostgroups = device.create_zbx_hostgroup(zabbix_groups) + # go through all newly created hostgroups + for group in hostgroups: + # Add new hostgroups to zabbix group list + zabbix_groups.append(group) + # Check if device is already in Zabbix + if device.zabbix_id: + device.consistency_check( + zabbix_groups, + zabbix_templates, + zabbix_proxy_list, + config["full_proxy_sync"], + config["create_hostgroups"], + ) + continue + # Add device to Zabbix + device.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) + except SyncError: + pass + zabbix.logout() diff --git a/netbox_zabbix_sync.py b/netbox_zabbix_sync.py index 4a5c880..353a436 100755 --- a/netbox_zabbix_sync.py +++ b/netbox_zabbix_sync.py @@ -4,26 +4,12 @@ import argparse import logging -import ssl -import sys from os import environ -from pynetbox import api -from pynetbox.core.query import RequestError as NBRequestError -from requests.exceptions import ConnectionError as RequestsConnectionError -from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI +from modules.core import run_sync +from modules.exceptions import EnvironmentVarError +from modules.logging import get_logger, set_log_levels -from modules.config import load_config -from modules.device import PhysicalDevice -from modules.exceptions import EnvironmentVarError, SyncError -from modules.logging import get_logger, set_log_levels, setup_logger -from modules.tools import convert_recordset, proxy_prepper, verify_hg_format -from modules.virtual_machine import VirtualMachine - -config = load_config() - - -setup_logger() logger = get_logger() @@ -39,6 +25,7 @@ def main(arguments): if arguments.quiet: set_log_levels(logging.ERROR, logging.ERROR) + # Gather environment variables for Zabbix and Netbox communication env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"] if "ZABBIX_TOKEN" in environ: env_vars.append("ZABBIX_TOKEN") @@ -62,250 +49,16 @@ def main(arguments): zabbix_host = environ.get("ZABBIX_HOST") netbox_host = environ.get("NETBOX_HOST") netbox_token = environ.get("NETBOX_TOKEN") - # Set NetBox API - netbox = api(netbox_host, token=netbox_token, threading=True) - # Create API call to get all custom fields which are on the device objects - try: - # Get NetBox version - nb_version = netbox.version - logger.debug("NetBox version is %s.", nb_version) - except RequestsConnectionError: - logger.error( - "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", - netbox_host, - ) - sys.exit(1) - except NBRequestError as e: - logger.error("NetBox error: %s", e) - sys.exit(1) - # Check if the provided Hostgroup layout is valid - device_cfs = [] - vm_cfs = [] - device_cfs = list( - netbox.extras.custom_fields.filter( - type=["text", "object", "select"], content_types="dcim.device" - ) + + # Run main sync process + run_sync( + nb_host=netbox_host, + nb_token=netbox_token, + zbx_host=zabbix_host, + zbx_user=zabbix_user, + zbx_pass=zabbix_pass, + zbx_token=zabbix_token, ) - verify_hg_format( - config["hostgroup_format"], device_cfs=device_cfs, hg_type="dev", logger=logger - ) - if config["sync_vms"]: - vm_cfs = list( - netbox.extras.custom_fields.filter( - type=["text", "object", "select"], - content_types="virtualization.virtualmachine", - ) - ) - verify_hg_format( - config["vm_hostgroup_format"], vm_cfs=vm_cfs, hg_type="vm", logger=logger - ) - # Set Zabbix API - try: - ssl_ctx = ssl.create_default_context() - - # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API - if environ.get("REQUESTS_CA_BUNDLE", None): - ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) - - if not zabbix_token: - zabbix = ZabbixAPI( - zabbix_host, user=zabbix_user, password=zabbix_pass, ssl_context=ssl_ctx - ) - else: - zabbix = ZabbixAPI(zabbix_host, token=zabbix_token, ssl_context=ssl_ctx) - zabbix.check_auth() - except (APIRequestError, ProcessingError) as zbx_error: - e = f"Zabbix returned the following error: {zbx_error}." - logger.error(e) - sys.exit(1) - # Set API parameter mapping based on API version - proxy_name = "host" if not str(zabbix.version).startswith("7") else "name" - # Get all Zabbix and NetBox data - netbox_devices = list(netbox.dcim.devices.filter(**config["nb_device_filter"])) - netbox_vms = [] - if config["sync_vms"]: - netbox_vms = list( - netbox.virtualization.virtual_machines.filter(**config["nb_vm_filter"]) - ) - netbox_site_groups = convert_recordset(netbox.dcim.site_groups.all()) - netbox_regions = convert_recordset(netbox.dcim.regions.all()) - netbox_journals = netbox.extras.journal_entries - zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] - zabbix_templates = zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] - zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] - # Set empty list for proxy processing Zabbix <= 6 - zabbix_proxygroups = [] - if str(zabbix.version).startswith("7"): - zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"]) # type: ignore[attr-defined] - # Sanitize proxy data - if proxy_name == "host": - for proxy in zabbix_proxies: - proxy["name"] = proxy.pop("host") - # Prepare list of all proxy and proxy_groups - zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups) - - # Go through all NetBox devices - for nb_vm in netbox_vms: - try: - vm = VirtualMachine( - nb_vm, - zabbix, - netbox_journals, - nb_version, - config["create_journal"], - logger, - ) - logger.debug("Host %s: Started operations on VM.", vm.name) - vm.set_vm_template() - # Check if a valid template has been found for this VM. - if not vm.zbx_template_names: - continue - vm.set_hostgroup( - config["vm_hostgroup_format"], netbox_site_groups, netbox_regions - ) - # Check if a valid hostgroup has been found for this VM. - if not vm.hostgroups: - continue - if config["extended_site_properties"] and nb_vm.site: - logger.debug("VM %s: extending site information.", vm.name) - vm.site = convert_recordset(netbox.dcim.sites.filter(id=nb_vm.site.id)) # type: ignore[attr-defined] - vm.set_inventory(nb_vm) - vm.set_usermacros() - vm.set_tags() - # Checks if device is in cleanup state - if vm.status in config["zabbix_device_removal"]: - if vm.zabbix_id: - # Delete device from Zabbix - # and remove hostID from NetBox. - vm.cleanup() - logger.info("VM %s: cleanup complete", vm.name) - continue - # Device has been added to NetBox - # but is not in Activate state - logger.info( - "VM %s: Skipping since this VM is not in the active state.", vm.name - ) - continue - # Check if the VM is in the disabled state - if vm.status in config["zabbix_device_disable"]: - vm.zabbix_state = 1 - # Add hostgroup if config is set - if config["create_hostgroups"]: - # Create new hostgroup. Potentially multiple groups if nested - hostgroups = vm.create_zbx_hostgroup(zabbix_groups) - # go through all newly created hostgroups - for group in hostgroups: - # Add new hostgroups to zabbix group list - zabbix_groups.append(group) - # Check if VM is already in Zabbix - if vm.zabbix_id: - vm.consistency_check( - zabbix_groups, - zabbix_templates, - zabbix_proxy_list, - config["full_proxy_sync"], - config["create_hostgroups"], - ) - continue - # Add VM to Zabbix - vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) - except SyncError: - pass - - for nb_device in netbox_devices: - try: - # Set device instance set data such as hostgroup and template information. - device = PhysicalDevice( - nb_device, - zabbix, - netbox_journals, - nb_version, - config["create_journal"], - logger, - ) - logger.debug("Host %s: Started operations on device.", device.name) - device.set_template( - config["templates_config_context"], - config["templates_config_context_overrule"], - ) - # Check if a valid template has been found for this VM. - if not device.zbx_template_names: - continue - device.set_hostgroup( - config["hostgroup_format"], netbox_site_groups, netbox_regions - ) - # Check if a valid hostgroup has been found for this VM. - if not device.hostgroups: - logger.warning( - "Host %s: Host has no valid hostgroups, Skipping this host...", - device.name, - ) - continue - if config["extended_site_properties"] and nb_device.site: - logger.debug("Device %s: extending site information.", device.name) - device.site = convert_recordset( # type: ignore[attr-defined] - netbox.dcim.sites.filter(id=nb_device.site.id) - ) - device.set_inventory(nb_device) - device.set_usermacros() - device.set_tags() - # Checks if device is part of cluster. - # Requires clustering variable - if device.is_cluster() and config["clustering"]: - # Check if device is primary or secondary - if device.promote_primary_device(): - logger.info( - "Device %s: is part of cluster and primary.", device.name - ) - else: - # Device is secondary in cluster. - # Don't continue with this device. - logger.info( - "Device %s: Is part of cluster but not primary. Skipping this host...", - device.name, - ) - continue - # Checks if device is in cleanup state - if device.status in config["zabbix_device_removal"]: - if device.zabbix_id: - # Delete device from Zabbix - # and remove hostID from NetBox. - device.cleanup() - logger.info("Device %s: cleanup complete", device.name) - continue - # Device has been added to NetBox - # but is not in Activate state - logger.info( - "Device %s: Skipping since this device is not in the active state.", - device.name, - ) - continue - # Check if the device is in the disabled state - if device.status in config["zabbix_device_disable"]: - device.zabbix_state = 1 - # Add hostgroup is config is set - if config["create_hostgroups"]: - # Create new hostgroup. Potentially multiple groups if nested - hostgroups = device.create_zbx_hostgroup(zabbix_groups) - # go through all newly created hostgroups - for group in hostgroups: - # Add new hostgroups to zabbix group list - zabbix_groups.append(group) - # Check if device is already in Zabbix - if device.zabbix_id: - device.consistency_check( - zabbix_groups, - zabbix_templates, - zabbix_proxy_list, - config["full_proxy_sync"], - config["create_hostgroups"], - ) - continue - # Add device to Zabbix - device.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) - except SyncError: - pass - zabbix.logout() if __name__ == "__main__": From a8146b1e0535976bab8200156b226f80a9d25b03 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:14:05 +0100 Subject: [PATCH 06/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Moved=20sourcecode?= =?UTF-8?q?=20into=20netbox=5Fzabbix=5Fsync=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/config.py | 134 ------------------ {modules => netbox_zabbix_sync}/__init__.py | 0 netbox_zabbix_sync/modules/__init__.py | 0 netbox_zabbix_sync/modules/cli.py | 78 ++++++++++ .../modules}/core.py | 18 +-- .../modules}/device.py | 14 +- .../modules}/exceptions.py | 0 .../modules}/hostgroups.py | 4 +- .../modules}/interface.py | 2 +- .../modules}/logging.py | 0 .../modules}/tags.py | 2 +- .../modules}/tools.py | 2 +- .../modules}/usermacros.py | 2 +- .../modules}/virtual_machine.py | 12 +- tests/test_configuration_parsing.py | 2 +- tests/test_device_deletion.py | 4 +- tests/test_hostgroups.py | 4 +- tests/test_interface.py | 4 +- tests/test_list_hostgroup_formats.py | 6 +- tests/test_physical_device.py | 4 +- tests/test_tools.py | 2 +- tests/test_usermacros.py | 4 +- 22 files changed, 124 insertions(+), 174 deletions(-) delete mode 100644 modules/config.py rename {modules => netbox_zabbix_sync}/__init__.py (100%) create mode 100644 netbox_zabbix_sync/modules/__init__.py create mode 100644 netbox_zabbix_sync/modules/cli.py rename {modules => netbox_zabbix_sync/modules}/core.py (96%) rename {modules => netbox_zabbix_sync/modules}/device.py (99%) rename {modules => netbox_zabbix_sync/modules}/exceptions.py (100%) rename {modules => netbox_zabbix_sync/modules}/hostgroups.py (98%) rename {modules => netbox_zabbix_sync/modules}/interface.py (98%) rename {modules => netbox_zabbix_sync/modules}/logging.py (100%) rename {modules => netbox_zabbix_sync/modules}/tags.py (98%) rename {modules => netbox_zabbix_sync/modules}/tools.py (99%) rename {modules => netbox_zabbix_sync/modules}/usermacros.py (98%) rename {modules => netbox_zabbix_sync/modules}/virtual_machine.py (86%) diff --git a/modules/config.py b/modules/config.py deleted file mode 100644 index e5509c6..0000000 --- a/modules/config.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Module for parsing configuration from the top level config.py file -""" - -from importlib import util -from logging import getLogger -from os import environ, path -from pathlib import Path - -logger = getLogger(__name__) - -# PLEASE NOTE: This is a sample config file. Please do NOT make any edits in this file! -# You should create your own config.py and it will overwrite the default config. - -DEFAULT_CONFIG = { - "templates_config_context": False, - "templates_config_context_overrule": False, - "template_cf": "zabbix_template", - "device_cf": "zabbix_hostid", - "proxy_cf": False, - "proxy_group_cf": False, - "clustering": False, - "create_hostgroups": True, - "create_journal": False, - "sync_vms": False, - "vm_hostgroup_format": "cluster_type/cluster/role", - "full_proxy_sync": False, - "zabbix_device_removal": ["Decommissioning", "Inventory"], - "zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"], - "hostgroup_format": "site/manufacturer/role", - "traverse_regions": False, - "traverse_site_groups": False, - "nb_device_filter": {"name__n": "null"}, - "nb_vm_filter": {"name__n": "null"}, - "inventory_mode": "disabled", - "inventory_sync": False, - "extended_site_properties": False, - "device_inventory_map": { - "asset_tag": "asset_tag", - "virtual_chassis/name": "chassis", - "status/label": "deployment_status", - "location/name": "location", - "latitude": "location_lat", - "longitude": "location_lon", - "comments": "notes", - "name": "name", - "rack/name": "site_rack", - "serial": "serialno_a", - "device_type/model": "type", - "device_type/manufacturer/name": "vendor", - "oob_ip/address": "oob_ip", - }, - "vm_inventory_map": { - "status/label": "deployment_status", - "comments": "notes", - "name": "name", - }, - "usermacro_sync": False, - "device_usermacro_map": { - "serial": "{$HW_SERIAL}", - "role/name": "{$DEV_ROLE}", - "url": "{$NB_URL}", - "id": "{$NB_ID}", - }, - "vm_usermacro_map": { - "memory": "{$TOTAL_MEMORY}", - "role/name": "{$DEV_ROLE}", - "url": "{$NB_URL}", - "id": "{$NB_ID}", - }, - "tag_sync": False, - "tag_lower": True, - "tag_name": "NetBox", - "tag_value": "name", - "device_tag_map": { - "site/name": "site", - "rack/name": "rack", - "platform/name": "target", - }, - "vm_tag_map": { - "site/name": "site", - "cluster/name": "cluster", - "platform/name": "target", - }, -} - - -def load_config(): - """Returns combined config from all sources""" - # Overwrite default config with config.py - conf = load_config_file(config_default=DEFAULT_CONFIG) - # Overwrite default config and config.py with environment variables - for key in conf: - value_setting = load_env_variable(key) - if value_setting is not None: - conf[key] = value_setting - return conf - - -def load_env_variable(config_environvar): - """Returns config from environment variable""" - prefix = "NBZX_" - config_environvar = prefix + config_environvar.upper() - if config_environvar in environ: - return environ[config_environvar] - return None - - -def load_config_file(config_default, config_file="config.py"): - """Returns config from config.py file""" - # Find the script path and config file next to it. - script_dir = path.dirname(path.dirname(path.abspath(__file__))) - config_path = Path(path.join(script_dir, config_file)) - - # If the script directory is not found, try the current working directory - if not config_path.exists(): - config_path = Path(config_file) - - # If both checks fail then fallback to the default config - if not config_path.exists(): - return config_default - - dconf = config_default.copy() - # Dynamically import the config module - spec = util.spec_from_file_location("config", config_path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load config from {config_path}") - config_module = util.module_from_spec(spec) - spec.loader.exec_module(config_module) - # Update DEFAULT_CONFIG with variables from the config module - for key in dconf: - if hasattr(config_module, key): - dconf[key] = getattr(config_module, key) - return dconf diff --git a/modules/__init__.py b/netbox_zabbix_sync/__init__.py similarity index 100% rename from modules/__init__.py rename to netbox_zabbix_sync/__init__.py diff --git a/netbox_zabbix_sync/modules/__init__.py b/netbox_zabbix_sync/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_zabbix_sync/modules/cli.py b/netbox_zabbix_sync/modules/cli.py new file mode 100644 index 0000000..939f250 --- /dev/null +++ b/netbox_zabbix_sync/modules/cli.py @@ -0,0 +1,78 @@ +import argparse +import logging +from os import environ + +from netbox_zabbix_sync.modules.core import run_sync +from netbox_zabbix_sync.modules.exceptions import EnvironmentVarError +from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels + +logger = get_logger() + + +def main(arguments): + """Run the sync process.""" + # set environment variables + if arguments.verbose: + set_log_levels(logging.WARNING, logging.INFO) + if arguments.debug: + set_log_levels(logging.WARNING, logging.DEBUG) + if arguments.debug_all: + set_log_levels(logging.DEBUG, logging.DEBUG) + if arguments.quiet: + set_log_levels(logging.ERROR, logging.ERROR) + + # Gather environment variables for Zabbix and Netbox communication + env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"] + if "ZABBIX_TOKEN" in environ: + env_vars.append("ZABBIX_TOKEN") + else: + env_vars.append("ZABBIX_USER") + env_vars.append("ZABBIX_PASS") + for var in env_vars: + if var not in environ: + e = f"Environment variable {var} has not been defined." + logger.error(e) + raise EnvironmentVarError(e) + # Get all virtual environment variables + if "ZABBIX_TOKEN" in env_vars: + zabbix_user = None + zabbix_pass = None + zabbix_token = environ.get("ZABBIX_TOKEN") + else: + zabbix_user = environ.get("ZABBIX_USER") + zabbix_pass = environ.get("ZABBIX_PASS") + zabbix_token = None + zabbix_host = environ.get("ZABBIX_HOST") + netbox_host = environ.get("NETBOX_HOST") + netbox_token = environ.get("NETBOX_TOKEN") + + # Run main sync process + run_sync( + nb_host=netbox_host, + nb_token=netbox_token, + zbx_host=zabbix_host, + zbx_user=zabbix_user, + zbx_pass=zabbix_pass, + zbx_token=zabbix_token, + ) + + +def parse_cli(): + parser = argparse.ArgumentParser( + description="A script to sync Zabbix with NetBox device data." + ) + parser.add_argument( + "-v", "--verbose", help="Turn on verbose logging.", action="store_true" + ) + parser.add_argument( + "-vv", "--debug", help="Turn on debugging.", action="store_true" + ) + parser.add_argument( + "-vvv", + "--debug-all", + help="Turn on debugging for all modules.", + action="store_true", + ) + parser.add_argument("-q", "--quiet", help="Turn off warnings.", action="store_true") + args = parser.parse_args() + main(args) diff --git a/modules/core.py b/netbox_zabbix_sync/modules/core.py similarity index 96% rename from modules/core.py rename to netbox_zabbix_sync/modules/core.py index ed99b28..d8c036c 100644 --- a/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - """Core component of the sync process""" import ssl @@ -11,12 +9,16 @@ from pynetbox.core.query import RequestError as NBRequestError from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI -from modules.config import load_config -from modules.device import PhysicalDevice -from modules.exceptions import EnvironmentVarError, SyncError -from modules.logging import get_logger, setup_logger -from modules.tools import convert_recordset, proxy_prepper, verify_hg_format -from modules.virtual_machine import VirtualMachine +from netbox_zabbix_sync.modules.config import load_config +from netbox_zabbix_sync.modules.device import PhysicalDevice +from netbox_zabbix_sync.modules.exceptions import SyncError +from netbox_zabbix_sync.modules.logging import get_logger, setup_logger +from netbox_zabbix_sync.modules.tools import ( + convert_recordset, + proxy_prepper, + verify_hg_format, +) +from netbox_zabbix_sync.modules.virtual_machine import VirtualMachine # Import configuration settings config = load_config() diff --git a/modules/device.py b/netbox_zabbix_sync/modules/device.py similarity index 99% rename from modules/device.py rename to netbox_zabbix_sync/modules/device.py index 7d60142..b8405b9 100644 --- a/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -11,23 +11,23 @@ from typing import Any from pynetbox import RequestError as NetboxRequestError from zabbix_utils import APIRequestError -from modules.config import load_config -from modules.exceptions import ( +from netbox_zabbix_sync.modules.config import load_config +from netbox_zabbix_sync.modules.exceptions import ( InterfaceConfigError, SyncExternalError, SyncInventoryError, TemplateError, ) -from modules.hostgroups import Hostgroup -from modules.interface import ZabbixInterface -from modules.tags import ZabbixTags -from modules.tools import ( +from netbox_zabbix_sync.modules.hostgroups import Hostgroup +from netbox_zabbix_sync.modules.interface import ZabbixInterface +from netbox_zabbix_sync.modules.tags import ZabbixTags +from netbox_zabbix_sync.modules.tools import ( cf_to_string, field_mapper, remove_duplicates, sanatize_log_output, ) -from modules.usermacros import ZabbixUsermacros +from netbox_zabbix_sync.modules.usermacros import ZabbixUsermacros config = load_config() diff --git a/modules/exceptions.py b/netbox_zabbix_sync/modules/exceptions.py similarity index 100% rename from modules/exceptions.py rename to netbox_zabbix_sync/modules/exceptions.py diff --git a/modules/hostgroups.py b/netbox_zabbix_sync/modules/hostgroups.py similarity index 98% rename from modules/hostgroups.py rename to netbox_zabbix_sync/modules/hostgroups.py index c78c00c..6a647cd 100644 --- a/modules/hostgroups.py +++ b/netbox_zabbix_sync/modules/hostgroups.py @@ -2,8 +2,8 @@ from logging import getLogger -from modules.exceptions import HostgroupError -from modules.tools import build_path, cf_to_string +from netbox_zabbix_sync.modules.exceptions import HostgroupError +from netbox_zabbix_sync.modules.tools import build_path, cf_to_string class Hostgroup: diff --git a/modules/interface.py b/netbox_zabbix_sync/modules/interface.py similarity index 98% rename from modules/interface.py rename to netbox_zabbix_sync/modules/interface.py index 1bb5502..cea0eda 100644 --- a/modules/interface.py +++ b/netbox_zabbix_sync/modules/interface.py @@ -2,7 +2,7 @@ All of the Zabbix interface related configuration """ -from modules.exceptions import InterfaceConfigError +from netbox_zabbix_sync.modules.exceptions import InterfaceConfigError class ZabbixInterface: diff --git a/modules/logging.py b/netbox_zabbix_sync/modules/logging.py similarity index 100% rename from modules/logging.py rename to netbox_zabbix_sync/modules/logging.py diff --git a/modules/tags.py b/netbox_zabbix_sync/modules/tags.py similarity index 98% rename from modules/tags.py rename to netbox_zabbix_sync/modules/tags.py index 6ed17be..b05e599 100644 --- a/modules/tags.py +++ b/netbox_zabbix_sync/modules/tags.py @@ -4,7 +4,7 @@ All of the Zabbix Usermacro related configuration from logging import getLogger -from modules.tools import field_mapper, remove_duplicates +from netbox_zabbix_sync.modules.tools import field_mapper, remove_duplicates class ZabbixTags: diff --git a/modules/tools.py b/netbox_zabbix_sync/modules/tools.py similarity index 99% rename from modules/tools.py rename to netbox_zabbix_sync/modules/tools.py index a6b0a22..50a9781 100644 --- a/modules/tools.py +++ b/netbox_zabbix_sync/modules/tools.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import Any, cast, overload -from modules.exceptions import HostgroupError +from netbox_zabbix_sync.modules.exceptions import HostgroupError def convert_recordset(recordset): diff --git a/modules/usermacros.py b/netbox_zabbix_sync/modules/usermacros.py similarity index 98% rename from modules/usermacros.py rename to netbox_zabbix_sync/modules/usermacros.py index 3258dde..05a8778 100644 --- a/modules/usermacros.py +++ b/netbox_zabbix_sync/modules/usermacros.py @@ -5,7 +5,7 @@ All of the Zabbix Usermacro related configuration from logging import getLogger from re import match -from modules.tools import field_mapper, sanatize_log_output +from netbox_zabbix_sync.modules.tools import field_mapper, sanatize_log_output class ZabbixUsermacros: diff --git a/modules/virtual_machine.py b/netbox_zabbix_sync/modules/virtual_machine.py similarity index 86% rename from modules/virtual_machine.py rename to netbox_zabbix_sync/modules/virtual_machine.py index 2ee70ce..e34beb8 100644 --- a/modules/virtual_machine.py +++ b/netbox_zabbix_sync/modules/virtual_machine.py @@ -1,9 +1,13 @@ """Module that hosts all functions for virtual machine processing""" -from modules.config import load_config -from modules.device import PhysicalDevice -from modules.exceptions import InterfaceConfigError, SyncInventoryError, TemplateError -from modules.interface import ZabbixInterface +from netbox_zabbix_sync.modules.config import load_config +from netbox_zabbix_sync.modules.device import PhysicalDevice +from netbox_zabbix_sync.modules.exceptions import ( + InterfaceConfigError, + SyncInventoryError, + TemplateError, +) +from netbox_zabbix_sync.modules.interface import ZabbixInterface # Load config config = load_config() diff --git a/tests/test_configuration_parsing.py b/tests/test_configuration_parsing.py index d6186e9..3da7e3a 100644 --- a/tests/test_configuration_parsing.py +++ b/tests/test_configuration_parsing.py @@ -3,7 +3,7 @@ import os from unittest.mock import MagicMock, patch -from modules.config import ( +from netbox_zabbix_sync.modules.config import ( DEFAULT_CONFIG, load_config, load_config_file, diff --git a/tests/test_device_deletion.py b/tests/test_device_deletion.py index 918ab33..6bba9d8 100644 --- a/tests/test_device_deletion.py +++ b/tests/test_device_deletion.py @@ -5,8 +5,8 @@ from unittest.mock import MagicMock, patch from zabbix_utils import APIRequestError -from modules.device import PhysicalDevice -from modules.exceptions import SyncExternalError +from netbox_zabbix_sync.modules.device import PhysicalDevice +from netbox_zabbix_sync.modules.exceptions import SyncExternalError class TestDeviceDeletion(unittest.TestCase): diff --git a/tests/test_hostgroups.py b/tests/test_hostgroups.py index d9c0a69..1eab424 100644 --- a/tests/test_hostgroups.py +++ b/tests/test_hostgroups.py @@ -3,8 +3,8 @@ import unittest from unittest.mock import MagicMock, patch -from modules.exceptions import HostgroupError -from modules.hostgroups import Hostgroup +from netbox_zabbix_sync.modules.exceptions import HostgroupError +from netbox_zabbix_sync.modules.hostgroups import Hostgroup class TestHostgroups(unittest.TestCase): diff --git a/tests/test_interface.py b/tests/test_interface.py index ee6b4a7..97131ec 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -3,8 +3,8 @@ import unittest from typing import cast -from modules.exceptions import InterfaceConfigError -from modules.interface import ZabbixInterface +from netbox_zabbix_sync.modules.exceptions import InterfaceConfigError +from netbox_zabbix_sync.modules.interface import ZabbixInterface class TestZabbixInterface(unittest.TestCase): diff --git a/tests/test_list_hostgroup_formats.py b/tests/test_list_hostgroup_formats.py index aeaa181..47f3fc5 100644 --- a/tests/test_list_hostgroup_formats.py +++ b/tests/test_list_hostgroup_formats.py @@ -3,9 +3,9 @@ import unittest from unittest.mock import MagicMock -from modules.exceptions import HostgroupError -from modules.hostgroups import Hostgroup -from modules.tools import verify_hg_format +from netbox_zabbix_sync.modules.exceptions import HostgroupError +from netbox_zabbix_sync.modules.hostgroups import Hostgroup +from netbox_zabbix_sync.modules.tools import verify_hg_format class TestListHostgroupFormats(unittest.TestCase): diff --git a/tests/test_physical_device.py b/tests/test_physical_device.py index 2c9843f..5ef1ce6 100644 --- a/tests/test_physical_device.py +++ b/tests/test_physical_device.py @@ -3,8 +3,8 @@ import unittest from unittest.mock import MagicMock, patch -from modules.device import PhysicalDevice -from modules.exceptions import TemplateError +from netbox_zabbix_sync.modules.device import PhysicalDevice +from netbox_zabbix_sync.modules.exceptions import TemplateError class TestPhysicalDevice(unittest.TestCase): diff --git a/tests/test_tools.py b/tests/test_tools.py index 5361743..1199cf5 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,4 +1,4 @@ -from modules.tools import sanatize_log_output +from netbox_zabbix_sync.modules.tools import sanatize_log_output def test_sanatize_log_output_secrets(): diff --git a/tests/test_usermacros.py b/tests/test_usermacros.py index dbcfaed..ca4d6a1 100644 --- a/tests/test_usermacros.py +++ b/tests/test_usermacros.py @@ -1,8 +1,8 @@ import unittest from unittest.mock import MagicMock, patch -from modules.device import PhysicalDevice -from modules.usermacros import ZabbixUsermacros +from netbox_zabbix_sync.modules.device import PhysicalDevice +from netbox_zabbix_sync.modules.usermacros import ZabbixUsermacros class DummyNB: From 414f272d75eceda2f9c06eee397581918df32428 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:14:15 +0100 Subject: [PATCH 07/57] =?UTF-8?q?=F0=9F=99=88=20Ignored=20build=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0cf4953..324505e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ __pycache__/ *.py[cod] .vscode .flake -.coverage \ No newline at end of file +.coverage + +*.egg-info +dist +build \ No newline at end of file From f302cef05ca664e049186927166eee34342bfcc2 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:14:35 +0100 Subject: [PATCH 08/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Importing=20cli=20pa?= =?UTF-8?q?rser=20from=20netbox=5Fzabbix=5Fsync=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync.py | 80 ++----------------------------------------- 1 file changed, 2 insertions(+), 78 deletions(-) diff --git a/netbox_zabbix_sync.py b/netbox_zabbix_sync.py index 353a436..51b6751 100755 --- a/netbox_zabbix_sync.py +++ b/netbox_zabbix_sync.py @@ -1,82 +1,6 @@ #!/usr/bin/env python3 -"""NetBox to Zabbix sync script.""" - -import argparse -import logging -from os import environ - -from modules.core import run_sync -from modules.exceptions import EnvironmentVarError -from modules.logging import get_logger, set_log_levels - -logger = get_logger() - - -def main(arguments): - """Run the sync process.""" - # set environment variables - if arguments.verbose: - set_log_levels(logging.WARNING, logging.INFO) - if arguments.debug: - set_log_levels(logging.WARNING, logging.DEBUG) - if arguments.debug_all: - set_log_levels(logging.DEBUG, logging.DEBUG) - if arguments.quiet: - set_log_levels(logging.ERROR, logging.ERROR) - - # Gather environment variables for Zabbix and Netbox communication - env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"] - if "ZABBIX_TOKEN" in environ: - env_vars.append("ZABBIX_TOKEN") - else: - env_vars.append("ZABBIX_USER") - env_vars.append("ZABBIX_PASS") - for var in env_vars: - if var not in environ: - e = f"Environment variable {var} has not been defined." - logger.error(e) - raise EnvironmentVarError(e) - # Get all virtual environment variables - if "ZABBIX_TOKEN" in env_vars: - zabbix_user = None - zabbix_pass = None - zabbix_token = environ.get("ZABBIX_TOKEN") - else: - zabbix_user = environ.get("ZABBIX_USER") - zabbix_pass = environ.get("ZABBIX_PASS") - zabbix_token = None - zabbix_host = environ.get("ZABBIX_HOST") - netbox_host = environ.get("NETBOX_HOST") - netbox_token = environ.get("NETBOX_TOKEN") - - # Run main sync process - run_sync( - nb_host=netbox_host, - nb_token=netbox_token, - zbx_host=zabbix_host, - zbx_user=zabbix_user, - zbx_pass=zabbix_pass, - zbx_token=zabbix_token, - ) - +from netbox_zabbix_sync.modules.cli import parse_cli if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="A script to sync Zabbix with NetBox device data." - ) - parser.add_argument( - "-v", "--verbose", help="Turn on verbose logging.", action="store_true" - ) - parser.add_argument( - "-vv", "--debug", help="Turn on debugging.", action="store_true" - ) - parser.add_argument( - "-vvv", - "--debug-all", - help="Turn on debugging for all modules.", - action="store_true", - ) - parser.add_argument("-q", "--quiet", help="Turn off warnings.", action="store_true") - args = parser.parse_args() - main(args) + args = parse_cli() From b2d021e8495e94bdc8a1af617c2c70265f0cdeed Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:14:54 +0100 Subject: [PATCH 09/57] =?UTF-8?q?=F0=9F=91=B7=20Added=20python=20packaging?= =?UTF-8?q?=20build=20step=20in=20GitHub=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-package.yml | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/build-package.yml diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml new file mode 100644 index 0000000..994e3e2 --- /dev/null +++ b/.github/workflows/build-package.yml @@ -0,0 +1,56 @@ +--- +name: Build Package + +on: + pull_request: + push: + branches: + - main + - develop + workflow_dispatch: + +permissions: + contents: read + +jobs: + quality: + name: Code Quality + uses: ./.github/workflows/quality.yml + + test: + name: Tests + uses: ./.github/workflows/run_tests.yml + + build: + name: Build Distribution + runs-on: ubuntu-latest + needs: [quality, test] + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --dev + + - name: Build package + run: uv build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-python-${{ matrix.python-version }} + path: dist/ + retention-days: 30 + if-no-files-found: error From 22ebeaec1b3f9fd6fe3734da8723c6e865e41441 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:20:47 +0100 Subject: [PATCH 10/57] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20exclusion=20of=20a?= =?UTF-8?q?ll=20config.py=20files=20instead=20of=20only=20root=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- netbox_zabbix_sync/modules/config.py | 134 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 netbox_zabbix_sync/modules/config.py diff --git a/.gitignore b/.gitignore index 324505e..d24d08f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ *.log .venv .env -config.py +/config.py Pipfile Pipfile.lock # Byte-compiled / optimized / DLL files diff --git a/netbox_zabbix_sync/modules/config.py b/netbox_zabbix_sync/modules/config.py new file mode 100644 index 0000000..e5509c6 --- /dev/null +++ b/netbox_zabbix_sync/modules/config.py @@ -0,0 +1,134 @@ +""" +Module for parsing configuration from the top level config.py file +""" + +from importlib import util +from logging import getLogger +from os import environ, path +from pathlib import Path + +logger = getLogger(__name__) + +# PLEASE NOTE: This is a sample config file. Please do NOT make any edits in this file! +# You should create your own config.py and it will overwrite the default config. + +DEFAULT_CONFIG = { + "templates_config_context": False, + "templates_config_context_overrule": False, + "template_cf": "zabbix_template", + "device_cf": "zabbix_hostid", + "proxy_cf": False, + "proxy_group_cf": False, + "clustering": False, + "create_hostgroups": True, + "create_journal": False, + "sync_vms": False, + "vm_hostgroup_format": "cluster_type/cluster/role", + "full_proxy_sync": False, + "zabbix_device_removal": ["Decommissioning", "Inventory"], + "zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"], + "hostgroup_format": "site/manufacturer/role", + "traverse_regions": False, + "traverse_site_groups": False, + "nb_device_filter": {"name__n": "null"}, + "nb_vm_filter": {"name__n": "null"}, + "inventory_mode": "disabled", + "inventory_sync": False, + "extended_site_properties": False, + "device_inventory_map": { + "asset_tag": "asset_tag", + "virtual_chassis/name": "chassis", + "status/label": "deployment_status", + "location/name": "location", + "latitude": "location_lat", + "longitude": "location_lon", + "comments": "notes", + "name": "name", + "rack/name": "site_rack", + "serial": "serialno_a", + "device_type/model": "type", + "device_type/manufacturer/name": "vendor", + "oob_ip/address": "oob_ip", + }, + "vm_inventory_map": { + "status/label": "deployment_status", + "comments": "notes", + "name": "name", + }, + "usermacro_sync": False, + "device_usermacro_map": { + "serial": "{$HW_SERIAL}", + "role/name": "{$DEV_ROLE}", + "url": "{$NB_URL}", + "id": "{$NB_ID}", + }, + "vm_usermacro_map": { + "memory": "{$TOTAL_MEMORY}", + "role/name": "{$DEV_ROLE}", + "url": "{$NB_URL}", + "id": "{$NB_ID}", + }, + "tag_sync": False, + "tag_lower": True, + "tag_name": "NetBox", + "tag_value": "name", + "device_tag_map": { + "site/name": "site", + "rack/name": "rack", + "platform/name": "target", + }, + "vm_tag_map": { + "site/name": "site", + "cluster/name": "cluster", + "platform/name": "target", + }, +} + + +def load_config(): + """Returns combined config from all sources""" + # Overwrite default config with config.py + conf = load_config_file(config_default=DEFAULT_CONFIG) + # Overwrite default config and config.py with environment variables + for key in conf: + value_setting = load_env_variable(key) + if value_setting is not None: + conf[key] = value_setting + return conf + + +def load_env_variable(config_environvar): + """Returns config from environment variable""" + prefix = "NBZX_" + config_environvar = prefix + config_environvar.upper() + if config_environvar in environ: + return environ[config_environvar] + return None + + +def load_config_file(config_default, config_file="config.py"): + """Returns config from config.py file""" + # Find the script path and config file next to it. + script_dir = path.dirname(path.dirname(path.abspath(__file__))) + config_path = Path(path.join(script_dir, config_file)) + + # If the script directory is not found, try the current working directory + if not config_path.exists(): + config_path = Path(config_file) + + # If both checks fail then fallback to the default config + if not config_path.exists(): + return config_default + + dconf = config_default.copy() + # Dynamically import the config module + spec = util.spec_from_file_location("config", config_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load config from {config_path}") + config_module = util.module_from_spec(spec) + spec.loader.exec_module(config_module) + # Update DEFAULT_CONFIG with variables from the config module + for key in dconf: + if hasattr(config_module, key): + dconf[key] = getattr(config_module, key) + return dconf From 14c0b9a479c3a0b6483ef8a8ac2109fbbe3c5bf8 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:22:39 +0100 Subject: [PATCH 11/57] =?UTF-8?q?=E2=9C=85=20Updated=20patch=20targets=20f?= =?UTF-8?q?or=20new=20module=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_configuration_parsing.py | 34 +++++++++++++----- tests/test_device_deletion.py | 8 +++-- tests/test_hostgroups.py | 8 +++-- tests/test_physical_device.py | 54 +++++++++++++++++++---------- tests/test_usermacros.py | 10 +++--- 5 files changed, 79 insertions(+), 35 deletions(-) diff --git a/tests/test_configuration_parsing.py b/tests/test_configuration_parsing.py index 3da7e3a..e2b1293 100644 --- a/tests/test_configuration_parsing.py +++ b/tests/test_configuration_parsing.py @@ -14,8 +14,11 @@ from netbox_zabbix_sync.modules.config import ( def test_load_config_defaults(): """Test that load_config returns default values when no config file or env vars are present""" with ( - patch("modules.config.load_config_file", return_value=DEFAULT_CONFIG.copy()), - patch("modules.config.load_env_variable", return_value=None), + patch( + "netbox_zabbix_sync.modules.config.load_config_file", + return_value=DEFAULT_CONFIG.copy(), + ), + patch("netbox_zabbix_sync.modules.config.load_env_variable", return_value=None), ): config = load_config() assert config == DEFAULT_CONFIG @@ -30,8 +33,11 @@ def test_load_config_file(): mock_config["sync_vms"] = True with ( - patch("modules.config.load_config_file", return_value=mock_config), - patch("modules.config.load_env_variable", return_value=None), + patch( + "netbox_zabbix_sync.modules.config.load_config_file", + return_value=mock_config, + ), + patch("netbox_zabbix_sync.modules.config.load_env_variable", return_value=None), ): config = load_config() assert config["templates_config_context"] is True @@ -52,8 +58,14 @@ def test_load_env_variables(): return None with ( - patch("modules.config.load_config_file", return_value=DEFAULT_CONFIG.copy()), - patch("modules.config.load_env_variable", side_effect=mock_load_env), + patch( + "netbox_zabbix_sync.modules.config.load_config_file", + return_value=DEFAULT_CONFIG.copy(), + ), + patch( + "netbox_zabbix_sync.modules.config.load_env_variable", + side_effect=mock_load_env, + ), ): config = load_config() assert config["sync_vms"] is True @@ -75,8 +87,14 @@ def test_env_vars_override_config_file(): return None with ( - patch("modules.config.load_config_file", return_value=mock_config), - patch("modules.config.load_env_variable", side_effect=mock_load_env), + patch( + "netbox_zabbix_sync.modules.config.load_config_file", + return_value=mock_config, + ), + patch( + "netbox_zabbix_sync.modules.config.load_env_variable", + side_effect=mock_load_env, + ), ): config = load_config() # This should be overridden by the env var diff --git a/tests/test_device_deletion.py b/tests/test_device_deletion.py index 6bba9d8..0d78f36 100644 --- a/tests/test_device_deletion.py +++ b/tests/test_device_deletion.py @@ -41,7 +41,9 @@ class TestDeviceDeletion(unittest.TestCase): self.mock_logger = MagicMock() # Create PhysicalDevice instance with mocks - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): self.device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -147,7 +149,9 @@ class TestDeviceDeletion(unittest.TestCase): def test_create_journal_entry_when_disabled(self): """Test create_journal_entry when journaling is disabled.""" # Setup - create device with journal=False - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, diff --git a/tests/test_hostgroups.py b/tests/test_hostgroups.py index 1eab424..4bab4aa 100644 --- a/tests/test_hostgroups.py +++ b/tests/test_hostgroups.py @@ -262,7 +262,9 @@ class TestHostgroups(unittest.TestCase): def test_nested_region_hostgroups(self): """Test hostgroup generation with nested regions.""" # Mock the build_path function to return a predictable result - with patch("modules.hostgroups.build_path") as mock_build_path: + with patch( + "netbox_zabbix_sync.modules.hostgroups.build_path" + ) as mock_build_path: # Configure the mock to return a list of regions in the path mock_build_path.return_value = ["ParentRegion", "TestRegion"] @@ -284,7 +286,9 @@ class TestHostgroups(unittest.TestCase): def test_nested_sitegroup_hostgroups(self): """Test hostgroup generation with nested site groups.""" # Mock the build_path function to return a predictable result - with patch("modules.hostgroups.build_path") as mock_build_path: + with patch( + "netbox_zabbix_sync.modules.hostgroups.build_path" + ) as mock_build_path: # Configure the mock to return a list of site groups in the path mock_build_path.return_value = ["ParentSiteGroup", "TestSiteGroup"] diff --git a/tests/test_physical_device.py b/tests/test_physical_device.py index 5ef1ce6..0ee6ffc 100644 --- a/tests/test_physical_device.py +++ b/tests/test_physical_device.py @@ -37,7 +37,7 @@ class TestPhysicalDevice(unittest.TestCase): # Create PhysicalDevice instance with mocks with patch( - "modules.device.config", + "netbox_zabbix_sync.modules.device.config", { "device_cf": "zabbix_hostid", "template_cf": "zabbix_template", @@ -76,8 +76,11 @@ class TestPhysicalDevice(unittest.TestCase): # We need to patch the search function to simulate finding special characters with ( - patch("modules.device.search") as mock_search, - patch("modules.device.config", {"device_cf": "zabbix_hostid"}), + patch("netbox_zabbix_sync.modules.device.search") as mock_search, + patch( + "netbox_zabbix_sync.modules.device.config", + {"device_cf": "zabbix_hostid"}, + ), ): # Make the search function return True to simulate special characters mock_search.return_value = True @@ -105,7 +108,9 @@ class TestPhysicalDevice(unittest.TestCase): } # Create device with the updated mock - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -124,7 +129,9 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.config_context = {"zabbix": {"templates": "Template1"}} # Create device with the updated mock - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -143,7 +150,9 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.config_context = {} # Create device with the updated mock - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -162,7 +171,9 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.config_context = {"zabbix": {}} # Create device with the updated mock - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -184,7 +195,10 @@ class TestPhysicalDevice(unittest.TestCase): with patch.object( PhysicalDevice, "get_templates_context", return_value=["Template1"] ): - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", + {"device_cf": "zabbix_hostid"}, + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -211,7 +225,7 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, } - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -221,7 +235,7 @@ class TestPhysicalDevice(unittest.TestCase): ) # Call set_inventory with the config patch still active - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): result = device.set_inventory({}) # Check result @@ -238,7 +252,7 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, } - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -248,7 +262,7 @@ class TestPhysicalDevice(unittest.TestCase): ) # Call set_inventory with the config patch still active - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): result = device.set_inventory({}) # Check result @@ -264,7 +278,7 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, } - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -274,7 +288,7 @@ class TestPhysicalDevice(unittest.TestCase): ) # Call set_inventory with the config patch still active - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): result = device.set_inventory({}) # Check result @@ -291,7 +305,7 @@ class TestPhysicalDevice(unittest.TestCase): "device_inventory_map": {"name": "name", "serial": "serialno_a"}, } - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -304,7 +318,7 @@ class TestPhysicalDevice(unittest.TestCase): mock_device_data = {"name": "test-device", "serial": "ABC123"} # Call set_inventory with the config patch still active - with patch("modules.device.config", config_patch): + with patch("netbox_zabbix_sync.modules.device.config", config_patch): result = device.set_inventory(mock_device_data) # Check result @@ -320,7 +334,9 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.virtual_chassis = MagicMock() # Create device with the updated mock - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, @@ -338,7 +354,9 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.virtual_chassis = None # Create device with the updated mock - with patch("modules.device.config", {"device_cf": "zabbix_hostid"}): + with patch( + "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} + ): device = PhysicalDevice( self.mock_nb_device, self.mock_zabbix, diff --git a/tests/test_usermacros.py b/tests/test_usermacros.py index ca4d6a1..45576fc 100644 --- a/tests/test_usermacros.py +++ b/tests/test_usermacros.py @@ -51,7 +51,7 @@ class TestUsermacroSync(unittest.TestCase): return device @patch( - "modules.device.config", + "netbox_zabbix_sync.modules.device.config", {"usermacro_sync": False, "device_cf": "zabbix_hostid", "tag_sync": False}, ) @patch.object(PhysicalDevice, "_usermacro_map") @@ -66,10 +66,10 @@ class TestUsermacroSync(unittest.TestCase): self.assertTrue(result is True or result is None) @patch( - "modules.device.config", + "netbox_zabbix_sync.modules.device.config", {"usermacro_sync": True, "device_cf": "zabbix_hostid", "tag_sync": False}, ) - @patch("modules.device.ZabbixUsermacros") + @patch("netbox_zabbix_sync.modules.device.ZabbixUsermacros") @patch.object(PhysicalDevice, "_usermacro_map") def test_usermacro_sync_true(self, mock_usermacro_map, mock_usermacros_class): mock_usermacro_map.return_value = self.usermacro_map @@ -90,10 +90,10 @@ class TestUsermacroSync(unittest.TestCase): self.assertGreater(len(device.usermacros), 0) @patch( - "modules.device.config", + "netbox_zabbix_sync.modules.device.config", {"usermacro_sync": "full", "device_cf": "zabbix_hostid", "tag_sync": False}, ) - @patch("modules.device.ZabbixUsermacros") + @patch("netbox_zabbix_sync.modules.device.ZabbixUsermacros") @patch.object(PhysicalDevice, "_usermacro_map") def test_usermacro_sync_full(self, mock_usermacro_map, mock_usermacros_class): mock_usermacro_map.return_value = self.usermacro_map From 3209e7077c68618ced60cf77a7f4211262f590d9 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:25:35 +0100 Subject: [PATCH 12/57] =?UTF-8?q?=F0=9F=94=A5=20Removed=20saving=20of=20No?= =?UTF-8?q?ne=20return=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_zabbix_sync.py b/netbox_zabbix_sync.py index 51b6751..89d2677 100755 --- a/netbox_zabbix_sync.py +++ b/netbox_zabbix_sync.py @@ -3,4 +3,4 @@ from netbox_zabbix_sync.modules.cli import parse_cli if __name__ == "__main__": - args = parse_cli() + parse_cli() From e2b5c853a4e860476ecefba91019e69899f8f8b6 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:41:19 +0100 Subject: [PATCH 13/57] =?UTF-8?q?=F0=9F=99=88=20Ignored=20=5Fversion.py=20?= =?UTF-8?q?file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d24d08f..40b2d6a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ __pycache__/ *.egg-info dist -build \ No newline at end of file +build + +netbox_zabbix_sync/_version.py \ No newline at end of file From ce7ad878a29e92d7884f248be1dc183990f96736 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:41:59 +0100 Subject: [PATCH 14/57] =?UTF-8?q?=F0=9F=94=A7=20Added=20cli=20command=20on?= =?UTF-8?q?=20package=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 77282b9..1f36932 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,10 @@ version = "3.3.0" "Homepage" = "https://github.com/TheNetworkGuy/netbox-zabbix-sync" "Issues" = "https://github.com/TheNetworkGuy/netbox-zabbix-sync/issues" +[project.scripts] +netbox-zabbix-sync = "netbox_zabbix_sync.modules.cli:parse_cli" + + [tool.ruff.lint] ignore = [ # Ignore line-length From 6d0b0310166023ce98f2e49523f1f92631ed64e3 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 15:42:13 +0100 Subject: [PATCH 15/57] =?UTF-8?q?=F0=9F=94=A7=20Adjusted=20build=20to=20us?= =?UTF-8?q?e=20dynamic=20git=20tag=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1f36932..640fdf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "Python script to synchronize Netbox devices to Zabbix." readme = "README.md" requires-python = ">=3.12" dependencies = ["igraph>=1.0.0", "pynetbox>=7.6.1", "zabbix-utils>=2.0.4"] -version = "3.3.0" +dynamic = ["version"] [project.urls] "Homepage" = "https://github.com/TheNetworkGuy/netbox-zabbix-sync" @@ -13,6 +13,15 @@ version = "3.3.0" [project.scripts] netbox-zabbix-sync = "netbox_zabbix_sync.modules.cli:parse_cli" +[build-system] +requires = ["setuptools>=64", "setuptools_scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["netbox_zabbix_sync*"] + +[tool.setuptools_scm] +version_file = "netbox_zabbix_sync/_version.py" [tool.ruff.lint] ignore = [ From 0aa019e104f779df6c73dfc01d8d893ddcf061ac Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 16:25:11 +0100 Subject: [PATCH 16/57] =?UTF-8?q?=F0=9F=94=A7=20Added=20pypi=20publishing?= =?UTF-8?q?=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-package.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 994e3e2..1bb3d20 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -54,3 +54,6 @@ jobs: path: dist/ retention-days: 30 if-no-files-found: error + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1.13 From 37257074bc2e21c60ab5dbdeb4f62f90a7ebae52 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 16:25:20 +0100 Subject: [PATCH 17/57] =?UTF-8?q?=F0=9F=94=A7=20Updated=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uv.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index d6885eb..746fcfa 100644 --- a/uv.lock +++ b/uv.lock @@ -191,8 +191,7 @@ wheels = [ [[package]] name = "netbox-zabbix-sync" -version = "3.3.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "igraph" }, { name = "pynetbox" }, From 2b251b8f68f6ff786abfd721b42e732de7cd468e Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Thu, 12 Feb 2026 16:30:54 +0100 Subject: [PATCH 18/57] =?UTF-8?q?=F0=9F=91=B7=20Changed=20build=20to=20onl?= =?UTF-8?q?y=20run=20once=20for=20Python=203.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-package.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 1bb3d20..f1e1961 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -25,9 +25,7 @@ jobs: name: Build Distribution runs-on: ubuntu-latest needs: [quality, test] - strategy: - matrix: - python-version: ["3.12", "3.13"] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -38,8 +36,8 @@ jobs: enable-cache: true cache-dependency-glob: "pyproject.toml" - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} + - name: Set up Python 3.12 + run: uv python install 3.12 - name: Install dependencies run: uv sync --dev @@ -50,7 +48,7 @@ jobs: - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: dist-python-${{ matrix.python-version }} + name: dist-python-3.12 path: dist/ retention-days: 30 if-no-files-found: error From b3f02dc02819efd375f0c934b3cb7b7b1c3bef2f Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 12 Feb 2026 17:22:41 +0100 Subject: [PATCH 19/57] Renamed run_sync function to sync and imported it at package level for easier imports. --- netbox_zabbix_sync/__init__.py | 4 ++++ netbox_zabbix_sync/modules/cli.py | 4 ++-- netbox_zabbix_sync/modules/core.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox_zabbix_sync/__init__.py b/netbox_zabbix_sync/__init__.py index e69de29..5a377d7 100644 --- a/netbox_zabbix_sync/__init__.py +++ b/netbox_zabbix_sync/__init__.py @@ -0,0 +1,4 @@ +""" +Makes core module sync function available at package level for easier imports. +""" +from netbox_zabbix_sync.modules.core import sync diff --git a/netbox_zabbix_sync/modules/cli.py b/netbox_zabbix_sync/modules/cli.py index 939f250..48a50e9 100644 --- a/netbox_zabbix_sync/modules/cli.py +++ b/netbox_zabbix_sync/modules/cli.py @@ -2,7 +2,7 @@ import argparse import logging from os import environ -from netbox_zabbix_sync.modules.core import run_sync +from netbox_zabbix_sync.modules.core import sync from netbox_zabbix_sync.modules.exceptions import EnvironmentVarError from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels @@ -47,7 +47,7 @@ def main(arguments): netbox_token = environ.get("NETBOX_TOKEN") # Run main sync process - run_sync( + sync( nb_host=netbox_host, nb_token=netbox_token, zbx_host=zabbix_host, diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index d8c036c..7439502 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -28,7 +28,7 @@ setup_logger() logger = get_logger() -def run_sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): +def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): """ Run the NetBox to Zabbix sync process. """ From de02d257f7a1a1ece4a89ca03d34d767732061c4 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 12 Feb 2026 16:27:18 +0000 Subject: [PATCH 20/57] Fixed file for linting issues. --- netbox_zabbix_sync/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/__init__.py b/netbox_zabbix_sync/__init__.py index 5a377d7..08f1ffd 100644 --- a/netbox_zabbix_sync/__init__.py +++ b/netbox_zabbix_sync/__init__.py @@ -1,4 +1,5 @@ """ Makes core module sync function available at package level for easier imports. """ -from netbox_zabbix_sync.modules.core import sync + +from netbox_zabbix_sync.modules.core import sync as sync From ebbebfa17f7d3263332cd39a386f75b77b399b10 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 12 Feb 2026 22:24:06 +0000 Subject: [PATCH 21/57] Adds tests for new core module --- tests/test_core.py | 604 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 tests/test_core.py diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..f54a20d --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,604 @@ +"""Tests for the core sync module.""" + +import unittest +from unittest.mock import MagicMock, patch + +from pynetbox.core.query import RequestError as NBRequestError +from requests.exceptions import ConnectionError as RequestsConnectionError +from zabbix_utils import APIRequestError, ProcessingError + +from netbox_zabbix_sync.modules.core import sync + +# Minimal config for testing - includes all keys used by sync() +TEST_CONFIG = { + "hostgroup_format": "site", + "vm_hostgroup_format": "site", + "sync_vms": False, + "nb_device_filter": {}, + "nb_vm_filter": {}, + "create_journal": False, + "templates_config_context": False, + "templates_config_context_overrule": False, + "create_hostgroups": False, + "clustering": False, + "zabbix_device_removal": ["Decommissioning", "Inventory"], + "zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"], + "full_proxy_sync": False, + "extended_site_properties": False, +} + + +class MockNetboxDevice: + """Mock NetBox device object.""" + + def __init__( + self, + device_id=1, + name="test-device", + status_label="Active", + zabbix_hostid=None, + config_context=None, + site=None, + primary_ip=None, + ): + self.id = device_id + self.name = name + self.status = MagicMock() + self.status.label = status_label + self.custom_fields = {"zabbix_hostid": zabbix_hostid} + self.config_context = config_context or {} + self.site = site + self.primary_ip = primary_ip + + +class MockNetboxVM: + """Mock NetBox virtual machine object.""" + + def __init__( + self, + vm_id=1, + name="test-vm", + status_label="Active", + zabbix_hostid=None, + config_context=None, + site=None, + primary_ip=None, + ): + self.id = vm_id + self.name = name + self.status = MagicMock() + self.status.label = status_label + self.custom_fields = {"zabbix_hostid": zabbix_hostid} + self.config_context = config_context or {} + self.site = site + self.primary_ip = primary_ip + + +class TestSyncNetboxConnection(unittest.TestCase): + """Test NetBox connection handling in sync function.""" + + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_exits_on_netbox_connection_error(self, mock_api): + """Test that sync exits when NetBox connection fails.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + # Simulate connection error when accessing version + type(mock_netbox).version = property( + lambda self: (_ for _ in ()).throw(RequestsConnectionError()) + ) + + with self.assertRaises(SystemExit) as context: + sync( + "http://netbox.local", + "token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + self.assertEqual(context.exception.code, 1) + + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_exits_on_netbox_request_error(self, mock_api): + """Test that sync exits when NetBox returns a request error.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + # Simulate NetBox request error + type(mock_netbox).version = property( + lambda self: (_ for _ in ()).throw(NBRequestError(MagicMock())) + ) + + with self.assertRaises(SystemExit) as context: + sync( + "http://netbox.local", + "token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + self.assertEqual(context.exception.code, 1) + + +class TestSyncZabbixConnection(unittest.TestCase): + """Test Zabbix connection handling in sync function.""" + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_exits_on_zabbix_api_error(self, mock_api, mock_zabbix_api): + """Test that sync exits when Zabbix API authentication fails.""" + self._setup_netbox_mock(mock_api) + + # Simulate Zabbix API error + mock_zabbix_api.return_value.check_auth.side_effect = APIRequestError( + "Invalid credentials" + ) + + with self.assertRaises(SystemExit) as context: + sync( + "http://netbox.local", + "token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + self.assertEqual(context.exception.code, 1) + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_exits_on_zabbix_processing_error(self, mock_api, mock_zabbix_api): + """Test that sync exits when Zabbix has processing error.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix_api.return_value.check_auth.side_effect = ProcessingError( + "Processing failed" + ) + + with self.assertRaises(SystemExit) as context: + sync( + "http://netbox.local", + "token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + self.assertEqual(context.exception.code, 1) + + +class TestSyncZabbixAuthentication(unittest.TestCase): + """Test Zabbix authentication methods.""" + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + def _setup_zabbix_mock(self, mock_zabbix_api, version="6.0"): + """Helper to setup a working Zabbix mock.""" + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = version + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + return mock_zabbix + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_uses_user_password_when_no_token(self, mock_api, mock_zabbix_api): + """Test that sync uses user/password auth when no token is provided.""" + self._setup_netbox_mock(mock_api) + self._setup_zabbix_mock(mock_zabbix_api) + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "zbx_user", + "zbx_pass", + None, # No token + ) + + # Verify ZabbixAPI was called with user/password + mock_zabbix_api.assert_called_once() + call_kwargs = mock_zabbix_api.call_args.kwargs + self.assertEqual(call_kwargs["user"], "zbx_user") + self.assertEqual(call_kwargs["password"], "zbx_pass") + self.assertNotIn("token", call_kwargs) + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_uses_token_when_provided(self, mock_api, mock_zabbix_api): + """Test that sync uses token auth when token is provided.""" + self._setup_netbox_mock(mock_api) + self._setup_zabbix_mock(mock_zabbix_api) + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "zbx_user", + "zbx_pass", + "zbx_token", # Token provided + ) + + # Verify ZabbixAPI was called with token + mock_zabbix_api.assert_called_once() + call_kwargs = mock_zabbix_api.call_args.kwargs + self.assertEqual(call_kwargs["token"], "zbx_token") + self.assertNotIn("user", call_kwargs) + self.assertNotIn("password", call_kwargs) + + +class TestSyncDeviceProcessing(unittest.TestCase): + """Test device processing in sync function.""" + + def _setup_netbox_mock(self, mock_api, devices=None, vms=None): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = devices or [] + mock_netbox.virtualization.virtual_machines.filter.return_value = vms or [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + mock_netbox.extras.journal_entries = MagicMock() + return mock_netbox + + def _setup_zabbix_mock(self, mock_zabbix_api, version="6.0"): + """Helper to setup a working Zabbix mock.""" + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = version + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"} + ] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + return mock_zabbix + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.PhysicalDevice") + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_processes_devices_from_netbox( + self, mock_api, mock_zabbix_api, mock_physical_device + ): + """Test that sync creates PhysicalDevice instances for NetBox devices.""" + device1 = MockNetboxDevice(device_id=1, name="device1") + device2 = MockNetboxDevice(device_id=2, name="device2") + + self._setup_netbox_mock(mock_api, devices=[device1, device2]) + self._setup_zabbix_mock(mock_zabbix_api) + + # Mock PhysicalDevice to have no template (skip further processing) + mock_device_instance = MagicMock() + mock_device_instance.zbx_template_names = [] + mock_physical_device.return_value = mock_device_instance + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify PhysicalDevice was instantiated for each device + self.assertEqual(mock_physical_device.call_count, 2) + + @patch("netbox_zabbix_sync.modules.core.config", {**TEST_CONFIG, "sync_vms": True}) + @patch("netbox_zabbix_sync.modules.core.VirtualMachine") + @patch("netbox_zabbix_sync.modules.core.PhysicalDevice") + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_processes_vms_when_enabled( + self, mock_api, mock_zabbix_api, mock_physical_device, mock_virtual_machine + ): + """Test that sync processes VMs when sync_vms is enabled.""" + vm1 = MockNetboxVM(vm_id=1, name="vm1") + vm2 = MockNetboxVM(vm_id=2, name="vm2") + + self._setup_netbox_mock(mock_api, vms=[vm1, vm2]) + self._setup_zabbix_mock(mock_zabbix_api) + + # Mock VM to have no template (skip further processing) + mock_vm_instance = MagicMock() + mock_vm_instance.zbx_template_names = [] + mock_virtual_machine.return_value = mock_vm_instance + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify VirtualMachine was instantiated for each VM + self.assertEqual(mock_virtual_machine.call_count, 2) + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.VirtualMachine") + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_skips_vms_when_disabled( + self, mock_api, mock_zabbix_api, mock_virtual_machine + ): + """Test that sync does NOT process VMs when sync_vms is disabled.""" + vm1 = MockNetboxVM(vm_id=1, name="vm1") + + self._setup_netbox_mock(mock_api, vms=[vm1]) + self._setup_zabbix_mock(mock_zabbix_api) + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify VirtualMachine was never called + mock_virtual_machine.assert_not_called() + + +class TestSyncZabbixVersionHandling(unittest.TestCase): + """Test Zabbix version-specific handling.""" + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_uses_host_proxy_name_for_zabbix_6(self, mock_api, mock_zabbix_api): + """Test that sync uses 'host' as proxy name field for Zabbix 6.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = "6.0" + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "host": "proxy1"}] + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify proxy.get was called with 'host' field + mock_zabbix.proxy.get.assert_called_with(output=["proxyid", "host"]) + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_uses_name_proxy_field_for_zabbix_7(self, mock_api, mock_zabbix_api): + """Test that sync uses 'name' as proxy name field for Zabbix 7.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = "7.0" + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "name": "proxy1"}] + mock_zabbix.proxygroup.get.return_value = [] + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify proxy.get was called with 'name' field + mock_zabbix.proxy.get.assert_called_with(output=["proxyid", "name"]) + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_fetches_proxygroups_for_zabbix_7(self, mock_api, mock_zabbix_api): + """Test that sync fetches proxy groups for Zabbix 7.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = "7.0" + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify proxygroup.get was called for Zabbix 7 + mock_zabbix.proxygroup.get.assert_called_once() + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_skips_proxygroups_for_zabbix_6(self, mock_api, mock_zabbix_api): + """Test that sync does NOT fetch proxy groups for Zabbix 6.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = "6.0" + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + mock_zabbix.proxy.get.return_value = [] + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify proxygroup.get was NOT called for Zabbix 6 + mock_zabbix.proxygroup.get.assert_not_called() + + +class TestSyncLogout(unittest.TestCase): + """Test that sync properly logs out from Zabbix.""" + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_logs_out_from_zabbix(self, mock_api, mock_zabbix_api): + """Test that sync calls logout on Zabbix API after completion.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = "6.0" + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + mock_zabbix.proxy.get.return_value = [] + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify logout was called + mock_zabbix.logout.assert_called_once() + + +class TestSyncProxyNameSanitization(unittest.TestCase): + """Test proxy name field sanitization for Zabbix 6.""" + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + @patch("netbox_zabbix_sync.modules.core.proxy_prepper") + @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.api") + def test_sync_renames_host_to_name_for_zabbix_6_proxies( + self, mock_api, mock_zabbix_api, mock_proxy_prepper + ): + """Test that for Zabbix 6, proxy 'host' field is renamed to 'name'.""" + self._setup_netbox_mock(mock_api) + + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = "6.0" + mock_zabbix.hostgroup.get.return_value = [] + mock_zabbix.template.get.return_value = [] + # Zabbix 6 returns 'host' field + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "host": "proxy1"}, + {"proxyid": "2", "host": "proxy2"}, + ] + mock_proxy_prepper.return_value = [] + + sync( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + + # Verify proxy_prepper was called with sanitized proxy list + call_args = mock_proxy_prepper.call_args[0] + proxies = call_args[0] + # Check that 'host' was renamed to 'name' + for proxy in proxies: + self.assertIn("name", proxy) + self.assertNotIn("host", proxy) + + +if __name__ == "__main__": + unittest.main() From 2028b7b8aa0e33270ac6b86df5f6a2bf36bbdb15 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 12 Feb 2026 22:27:22 +0000 Subject: [PATCH 22/57] Reformatted file for ruff check --- tests/test_core.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index f54a20d..77dca09 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -281,8 +281,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [ - {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -406,8 +405,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "6.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [ - {"proxyid": "1", "host": "proxy1"}] + mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}] sync( "http://netbox.local", @@ -433,8 +431,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "7.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [ - {"proxyid": "1", "name": "proxy1"}] + mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] sync( From 79396242fefab20161be0415bcfab2e40dfaa7d1 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Fri, 13 Feb 2026 15:42:45 +0100 Subject: [PATCH 23/57] =?UTF-8?q?=F0=9F=91=B7=20Fixed=20CI=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-package.yml | 57 --------------------------- .github/workflows/publish-package.yml | 33 ++++++++++++++++ 2 files changed, 33 insertions(+), 57 deletions(-) delete mode 100644 .github/workflows/build-package.yml create mode 100644 .github/workflows/publish-package.yml diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml deleted file mode 100644 index f1e1961..0000000 --- a/.github/workflows/build-package.yml +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: Build Package - -on: - pull_request: - push: - branches: - - main - - develop - workflow_dispatch: - -permissions: - contents: read - -jobs: - quality: - name: Code Quality - uses: ./.github/workflows/quality.yml - - test: - name: Tests - uses: ./.github/workflows/run_tests.yml - - build: - name: Build Distribution - runs-on: ubuntu-latest - needs: [quality, test] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: "pyproject.toml" - - - name: Set up Python 3.12 - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync --dev - - - name: Build package - run: uv build - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist-python-3.12 - path: dist/ - retention-days: 30 - if-no-files-found: error - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1.13 diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml new file mode 100644 index 0000000..008a9a0 --- /dev/null +++ b/.github/workflows/publish-package.yml @@ -0,0 +1,33 @@ +name: Upload Python Package to PyPI when a Release is Created + +permissions: + contents: read + +on: + release: + types: [published] + +jobs: + pypi-publish: + name: Publish release to PyPI + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/netbox-zabbix-sync + permissions: + id-token: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel build + - name: Build package + run: | + python -m build + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc From 39f3c57cca23bf145d7a5be3e99c12abc70fae99 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 16 Feb 2026 13:28:18 +0000 Subject: [PATCH 24/57] Renamed module/config.py file to settings.py to avoid confusion with the main config.py file --- netbox_zabbix_sync/modules/core.py | 32 ++++++++++++------- netbox_zabbix_sync/modules/device.py | 23 ++++++++----- .../modules/{config.py => settings.py} | 0 netbox_zabbix_sync/modules/virtual_machine.py | 2 +- tests/test_configuration_parsing.py | 22 +++++++------ 5 files changed, 50 insertions(+), 29 deletions(-) rename netbox_zabbix_sync/modules/{config.py => settings.py} (100%) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 7439502..a71b9c4 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -9,10 +9,10 @@ from pynetbox.core.query import RequestError as NBRequestError from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI -from netbox_zabbix_sync.modules.config import load_config from netbox_zabbix_sync.modules.device import PhysicalDevice from netbox_zabbix_sync.modules.exceptions import SyncError from netbox_zabbix_sync.modules.logging import get_logger, setup_logger +from netbox_zabbix_sync.modules.settings import load_config from netbox_zabbix_sync.modules.tools import ( convert_recordset, proxy_prepper, @@ -91,22 +91,28 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): # Set API parameter mapping based on API version proxy_name = "host" if not str(zabbix.version).startswith("7") else "name" # Get all Zabbix and NetBox data - netbox_devices = list(netbox.dcim.devices.filter(**config["nb_device_filter"])) + netbox_devices = list(netbox.dcim.devices.filter( + **config["nb_device_filter"])) netbox_vms = [] if config["sync_vms"]: netbox_vms = list( - netbox.virtualization.virtual_machines.filter(**config["nb_vm_filter"]) + netbox.virtualization.virtual_machines.filter( + **config["nb_vm_filter"]) ) netbox_site_groups = convert_recordset(netbox.dcim.site_groups.all()) netbox_regions = convert_recordset(netbox.dcim.regions.all()) netbox_journals = netbox.extras.journal_entries - zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] - zabbix_templates = zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] - zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] + zabbix_groups = zabbix.hostgroup.get( + output=["groupid", "name"]) # type: ignore[attr-defined] + zabbix_templates = zabbix.template.get( + output=["templateid", "name"]) # type: ignore[attr-defined] + zabbix_proxies = zabbix.proxy.get( + output=["proxyid", proxy_name]) # type: ignore[attr-defined] # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] if str(zabbix.version).startswith("7"): - zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"]) # type: ignore[attr-defined] + zabbix_proxygroups = zabbix.proxygroup.get( + output=["proxy_groupid", "name"]) # type: ignore[attr-defined] # Sanitize proxy data if proxy_name == "host": for proxy in zabbix_proxies: @@ -138,7 +144,8 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): continue if config["extended_site_properties"] and nb_vm.site: logger.debug("VM %s: extending site information.", vm.name) - vm.site = convert_recordset(netbox.dcim.sites.filter(id=nb_vm.site.id)) # type: ignore[attr-defined] + vm.site = convert_recordset(netbox.dcim.sites.filter( + id=nb_vm.site.id)) # type: ignore[attr-defined] vm.set_inventory(nb_vm) vm.set_usermacros() vm.set_tags() @@ -178,7 +185,8 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): ) continue # Add VM to Zabbix - vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) + vm.create_in_zabbix( + zabbix_groups, zabbix_templates, zabbix_proxy_list) except SyncError: pass @@ -212,7 +220,8 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): ) continue if config["extended_site_properties"] and nb_device.site: - logger.debug("Device %s: extending site information.", device.name) + logger.debug( + "Device %s: extending site information.", device.name) device.site = convert_recordset( # type: ignore[attr-defined] netbox.dcim.sites.filter(id=nb_device.site.id) ) @@ -272,7 +281,8 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): ) continue # Add device to Zabbix - device.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) + device.create_in_zabbix( + zabbix_groups, zabbix_templates, zabbix_proxy_list) except SyncError: pass zabbix.logout() diff --git a/netbox_zabbix_sync/modules/device.py b/netbox_zabbix_sync/modules/device.py index b8405b9..17ca6e2 100644 --- a/netbox_zabbix_sync/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -11,7 +11,6 @@ from typing import Any from pynetbox import RequestError as NetboxRequestError from zabbix_utils import APIRequestError -from netbox_zabbix_sync.modules.config import load_config from netbox_zabbix_sync.modules.exceptions import ( InterfaceConfigError, SyncExternalError, @@ -20,6 +19,7 @@ from netbox_zabbix_sync.modules.exceptions import ( ) 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 from netbox_zabbix_sync.modules.tags import ZabbixTags from netbox_zabbix_sync.modules.tools import ( cf_to_string, @@ -360,7 +360,8 @@ class PhysicalDevice: try: # Check if the Zabbix host exists in Zabbix zbx_host = bool( - self.zabbix.host.get(filter={"hostid": self.zabbix_id}, output=[]) + self.zabbix.host.get( + filter={"hostid": self.zabbix_id}, output=[]) ) e = ( f"Host {self.name}: was already deleted from Zabbix." @@ -372,7 +373,8 @@ class PhysicalDevice: e = f"Host {self.name}: Deleted host from Zabbix." self._zeroize_cf() self.logger.info(e) - self.create_journal_entry("warning", "Deleted host from Zabbix") + self.create_journal_entry( + "warning", "Deleted host from Zabbix") except APIRequestError as e: message = f"Zabbix returned the following error: {e}." self.logger.error(message) @@ -648,9 +650,11 @@ class PhysicalDevice: self.logger.error(e) raise SyncExternalError(e) from None self.logger.info( - "Host %s: updated with data %s.", self.name, sanatize_log_output(kwargs) + "Host %s: updated with data %s.", self.name, sanatize_log_output( + kwargs) ) - self.create_journal_entry("info", "Updated host in Zabbix with latest NB data.") + self.create_journal_entry( + "info", "Updated host in Zabbix with latest NB data.") def consistency_check( self, groups, templates, proxies, proxy_power, create_hostgroups @@ -659,7 +663,8 @@ class PhysicalDevice: Checks if Zabbix object is still valid with NetBox parameters. """ # If group is found or if the hostgroup is nested - if not self.set_zbx_groupid(groups): # or len(self.hostgroups.split("/")) > 1: + # or len(self.hostgroups.split("/")) > 1: + if not self.set_zbx_groupid(groups): if create_hostgroups: # Script is allowed to create a new hostgroup new_groups = self.create_zbx_hostgroup(groups) @@ -840,7 +845,8 @@ class PhysicalDevice: # of secret type macros from Netbox netbox_macros = deepcopy(self.usermacros) # Set the sync bit - full_sync_bit = bool(str(config["usermacro_sync"]).lower() == "full") + full_sync_bit = bool( + str(config["usermacro_sync"]).lower() == "full") for macro in netbox_macros: # If the Macro is a secret and full sync is NOT activated if macro["type"] == str(1) and not full_sync_bit: @@ -964,7 +970,8 @@ class PhysicalDevice: } try: self.nb_journals.create(journal) - self.logger.debug("Host %s: Created journal entry in NetBox", self.name) + self.logger.debug( + "Host %s: Created journal entry in NetBox", self.name) return True except NetboxRequestError as e: self.logger.warning( diff --git a/netbox_zabbix_sync/modules/config.py b/netbox_zabbix_sync/modules/settings.py similarity index 100% rename from netbox_zabbix_sync/modules/config.py rename to netbox_zabbix_sync/modules/settings.py diff --git a/netbox_zabbix_sync/modules/virtual_machine.py b/netbox_zabbix_sync/modules/virtual_machine.py index e34beb8..a88865c 100644 --- a/netbox_zabbix_sync/modules/virtual_machine.py +++ b/netbox_zabbix_sync/modules/virtual_machine.py @@ -1,6 +1,5 @@ """Module that hosts all functions for virtual machine processing""" -from netbox_zabbix_sync.modules.config import load_config from netbox_zabbix_sync.modules.device import PhysicalDevice from netbox_zabbix_sync.modules.exceptions import ( InterfaceConfigError, @@ -8,6 +7,7 @@ from netbox_zabbix_sync.modules.exceptions import ( TemplateError, ) from netbox_zabbix_sync.modules.interface import ZabbixInterface +from netbox_zabbix_sync.modules.settings import load_config # Load config config = load_config() diff --git a/tests/test_configuration_parsing.py b/tests/test_configuration_parsing.py index e2b1293..845ef52 100644 --- a/tests/test_configuration_parsing.py +++ b/tests/test_configuration_parsing.py @@ -3,7 +3,7 @@ import os from unittest.mock import MagicMock, patch -from netbox_zabbix_sync.modules.config import ( +from netbox_zabbix_sync.modules.settings import ( DEFAULT_CONFIG, load_config, load_config_file, @@ -15,10 +15,12 @@ def test_load_config_defaults(): """Test that load_config returns default values when no config file or env vars are present""" with ( patch( - "netbox_zabbix_sync.modules.config.load_config_file", + "netbox_zabbix_sync.modules.settings.load_config_file", return_value=DEFAULT_CONFIG.copy(), ), - patch("netbox_zabbix_sync.modules.config.load_env_variable", return_value=None), + patch( + "netbox_zabbix_sync.modules.settings.load_env_variable", return_value=None + ), ): config = load_config() assert config == DEFAULT_CONFIG @@ -34,10 +36,12 @@ def test_load_config_file(): with ( patch( - "netbox_zabbix_sync.modules.config.load_config_file", + "netbox_zabbix_sync.modules.settings.load_config_file", return_value=mock_config, ), - patch("netbox_zabbix_sync.modules.config.load_env_variable", return_value=None), + patch( + "netbox_zabbix_sync.modules.settings.load_env_variable", return_value=None + ), ): config = load_config() assert config["templates_config_context"] is True @@ -59,11 +63,11 @@ def test_load_env_variables(): with ( patch( - "netbox_zabbix_sync.modules.config.load_config_file", + "netbox_zabbix_sync.modules.settings.load_config_file", return_value=DEFAULT_CONFIG.copy(), ), patch( - "netbox_zabbix_sync.modules.config.load_env_variable", + "netbox_zabbix_sync.modules.settings.load_env_variable", side_effect=mock_load_env, ), ): @@ -88,11 +92,11 @@ def test_env_vars_override_config_file(): with ( patch( - "netbox_zabbix_sync.modules.config.load_config_file", + "netbox_zabbix_sync.modules.settings.load_config_file", return_value=mock_config, ), patch( - "netbox_zabbix_sync.modules.config.load_env_variable", + "netbox_zabbix_sync.modules.settings.load_env_variable", side_effect=mock_load_env, ), ): From d55fc0a4e726a56344080e0bc143c0a33c1285ec Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 16 Feb 2026 13:29:48 +0000 Subject: [PATCH 25/57] Fixed ruff formatting --- netbox_zabbix_sync/modules/core.py | 30 ++++++++++------------------ netbox_zabbix_sync/modules/device.py | 18 ++++++----------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index a71b9c4..111dad7 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -91,28 +91,22 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): # Set API parameter mapping based on API version proxy_name = "host" if not str(zabbix.version).startswith("7") else "name" # Get all Zabbix and NetBox data - netbox_devices = list(netbox.dcim.devices.filter( - **config["nb_device_filter"])) + netbox_devices = list(netbox.dcim.devices.filter(**config["nb_device_filter"])) netbox_vms = [] if config["sync_vms"]: netbox_vms = list( - netbox.virtualization.virtual_machines.filter( - **config["nb_vm_filter"]) + netbox.virtualization.virtual_machines.filter(**config["nb_vm_filter"]) ) netbox_site_groups = convert_recordset(netbox.dcim.site_groups.all()) netbox_regions = convert_recordset(netbox.dcim.regions.all()) netbox_journals = netbox.extras.journal_entries - zabbix_groups = zabbix.hostgroup.get( - output=["groupid", "name"]) # type: ignore[attr-defined] - zabbix_templates = zabbix.template.get( - output=["templateid", "name"]) # type: ignore[attr-defined] - zabbix_proxies = zabbix.proxy.get( - output=["proxyid", proxy_name]) # type: ignore[attr-defined] + zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] + zabbix_templates = zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] + zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] if str(zabbix.version).startswith("7"): - zabbix_proxygroups = zabbix.proxygroup.get( - output=["proxy_groupid", "name"]) # type: ignore[attr-defined] + zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"]) # type: ignore[attr-defined] # Sanitize proxy data if proxy_name == "host": for proxy in zabbix_proxies: @@ -144,8 +138,7 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): continue if config["extended_site_properties"] and nb_vm.site: logger.debug("VM %s: extending site information.", vm.name) - vm.site = convert_recordset(netbox.dcim.sites.filter( - id=nb_vm.site.id)) # type: ignore[attr-defined] + vm.site = convert_recordset(netbox.dcim.sites.filter(id=nb_vm.site.id)) # type: ignore[attr-defined] vm.set_inventory(nb_vm) vm.set_usermacros() vm.set_tags() @@ -185,8 +178,7 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): ) continue # Add VM to Zabbix - vm.create_in_zabbix( - zabbix_groups, zabbix_templates, zabbix_proxy_list) + vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) except SyncError: pass @@ -220,8 +212,7 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): ) continue if config["extended_site_properties"] and nb_device.site: - logger.debug( - "Device %s: extending site information.", device.name) + logger.debug("Device %s: extending site information.", device.name) device.site = convert_recordset( # type: ignore[attr-defined] netbox.dcim.sites.filter(id=nb_device.site.id) ) @@ -281,8 +272,7 @@ def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): ) continue # Add device to Zabbix - device.create_in_zabbix( - zabbix_groups, zabbix_templates, zabbix_proxy_list) + device.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) except SyncError: pass zabbix.logout() diff --git a/netbox_zabbix_sync/modules/device.py b/netbox_zabbix_sync/modules/device.py index 17ca6e2..77b1113 100644 --- a/netbox_zabbix_sync/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -360,8 +360,7 @@ class PhysicalDevice: try: # Check if the Zabbix host exists in Zabbix zbx_host = bool( - self.zabbix.host.get( - filter={"hostid": self.zabbix_id}, output=[]) + self.zabbix.host.get(filter={"hostid": self.zabbix_id}, output=[]) ) e = ( f"Host {self.name}: was already deleted from Zabbix." @@ -373,8 +372,7 @@ class PhysicalDevice: e = f"Host {self.name}: Deleted host from Zabbix." self._zeroize_cf() self.logger.info(e) - self.create_journal_entry( - "warning", "Deleted host from Zabbix") + self.create_journal_entry("warning", "Deleted host from Zabbix") except APIRequestError as e: message = f"Zabbix returned the following error: {e}." self.logger.error(message) @@ -650,11 +648,9 @@ class PhysicalDevice: self.logger.error(e) raise SyncExternalError(e) from None self.logger.info( - "Host %s: updated with data %s.", self.name, sanatize_log_output( - kwargs) + "Host %s: updated with data %s.", self.name, sanatize_log_output(kwargs) ) - self.create_journal_entry( - "info", "Updated host in Zabbix with latest NB data.") + self.create_journal_entry("info", "Updated host in Zabbix with latest NB data.") def consistency_check( self, groups, templates, proxies, proxy_power, create_hostgroups @@ -845,8 +841,7 @@ class PhysicalDevice: # of secret type macros from Netbox netbox_macros = deepcopy(self.usermacros) # Set the sync bit - full_sync_bit = bool( - str(config["usermacro_sync"]).lower() == "full") + full_sync_bit = bool(str(config["usermacro_sync"]).lower() == "full") for macro in netbox_macros: # If the Macro is a secret and full sync is NOT activated if macro["type"] == str(1) and not full_sync_bit: @@ -970,8 +965,7 @@ class PhysicalDevice: } try: self.nb_journals.create(journal) - self.logger.debug( - "Host %s: Created journal entry in NetBox", self.name) + self.logger.debug("Host %s: Created journal entry in NetBox", self.name) return True except NetboxRequestError as e: self.logger.warning( From 223a27f47c601c69b5a7e9b9d930e756c5c8aa00 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Tue, 17 Feb 2026 15:45:43 +0000 Subject: [PATCH 26/57] Changed sync function to class --- netbox_zabbix_sync/__init__.py | 2 +- netbox_zabbix_sync/modules/cli.py | 10 +- netbox_zabbix_sync/modules/core.py | 552 ++++++++++++++++------------- pyproject.toml | 2 + tests/test_core.py | 55 +-- 5 files changed, 354 insertions(+), 267 deletions(-) diff --git a/netbox_zabbix_sync/__init__.py b/netbox_zabbix_sync/__init__.py index 08f1ffd..7517e89 100644 --- a/netbox_zabbix_sync/__init__.py +++ b/netbox_zabbix_sync/__init__.py @@ -2,4 +2,4 @@ Makes core module sync function available at package level for easier imports. """ -from netbox_zabbix_sync.modules.core import sync as sync +from netbox_zabbix_sync.modules.core import Sync as Sync diff --git a/netbox_zabbix_sync/modules/cli.py b/netbox_zabbix_sync/modules/cli.py index 48a50e9..016e28a 100644 --- a/netbox_zabbix_sync/modules/cli.py +++ b/netbox_zabbix_sync/modules/cli.py @@ -2,10 +2,12 @@ import argparse import logging from os import environ -from netbox_zabbix_sync.modules.core import sync +from netbox_zabbix_sync.modules.core import Sync from netbox_zabbix_sync.modules.exceptions import EnvironmentVarError -from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels +from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels, setup_logger +# Set logging +setup_logger() logger = get_logger() @@ -47,7 +49,8 @@ def main(arguments): netbox_token = environ.get("NETBOX_TOKEN") # Run main sync process - sync( + syncer = Sync() + syncer.connect( nb_host=netbox_host, nb_token=netbox_token, zbx_host=zabbix_host, @@ -55,6 +58,7 @@ def main(arguments): zbx_pass=zabbix_pass, zbx_token=zabbix_token, ) + syncer.start() def parse_cli(): diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 111dad7..d53c305 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -5,14 +5,13 @@ import sys from os import environ from pynetbox import api -from pynetbox.core.query import RequestError as NBRequestError from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI from netbox_zabbix_sync.modules.device import PhysicalDevice from netbox_zabbix_sync.modules.exceptions import SyncError -from netbox_zabbix_sync.modules.logging import get_logger, setup_logger -from netbox_zabbix_sync.modules.settings import load_config +from netbox_zabbix_sync.modules.logging import get_logger +from netbox_zabbix_sync.modules.settings import DEFAULT_CONFIG, load_config from netbox_zabbix_sync.modules.tools import ( convert_recordset, proxy_prepper, @@ -20,259 +19,336 @@ from netbox_zabbix_sync.modules.tools import ( ) from netbox_zabbix_sync.modules.virtual_machine import VirtualMachine -# Import configuration settings -config = load_config() - - -setup_logger() logger = get_logger() -def sync(nb_host, nb_token, zbx_host, zbx_user, zbx_pass, zbx_token): +class Sync: """ - Run the NetBox to Zabbix sync process. + Class that hosts the main sync process. + This class is used to connect to NetBox and Zabbix and run the sync process. """ - # Set NetBox API - netbox = api(nb_host, token=nb_token, threading=True) - # Create API call to get all custom fields which are on the device objects - try: - # Get NetBox version - nb_version = netbox.version - logger.debug("NetBox version is %s.", nb_version) - except RequestsConnectionError: - logger.error( - "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", - nb_host, - ) - sys.exit(1) - except NBRequestError as e: - logger.error("NetBox error: %s", e) - sys.exit(1) - # Check if the provided Hostgroup layout is valid - device_cfs = [] - vm_cfs = [] - device_cfs = list( - netbox.extras.custom_fields.filter( - type=["text", "object", "select"], content_types="dcim.device" - ) - ) - verify_hg_format( - config["hostgroup_format"], device_cfs=device_cfs, hg_type="dev", logger=logger - ) - if config["sync_vms"]: - vm_cfs = list( - netbox.extras.custom_fields.filter( - type=["text", "object", "select"], - content_types="virtualization.virtualmachine", + + def __init__(self, config=None): + """ + Docstring for __init__ + + :param self: Description + :param config: Description + """ + self.netbox = None + self.zabbix = None + self.config = config + self.nb_version = None + self._config() + + def _config(self): + """ + Load config and check if provided config is valid. + """ + if not self.config: + self.config = load_config() + return True + # Check if provided config is valid + if not isinstance(self.config, dict): + e = "Provided config is not a dictionary." + logger.error(e) + raise SyncError(e) + # Combine default options and provided config + default_config = DEFAULT_CONFIG.copy() + for key in self.config: + # Check if the user provided an invalid option parameter + if key not in default_config: + e = f"Provided config contains invalid key: {key}." + logger.error(e) + raise SyncError(e) + # Remove keys from default config to keep track of missing keys + default_config.pop(key) + # Add missing options with default values + for key in default_config: + self.config[key] = default_config[key] + return True + + def connect( + self, nb_host, nb_token, zbx_host, zbx_user=None, zbx_pass=None, zbx_token=None + ): + """ + Docstring for connect + + :param self: Description + :param nb_host: Description + :param nb_token: Description + :param zbx_host: Description + :param zbx_user: Description + :param zbx_pass: Description + :param zbx_token: Description + """ + # Initialize Netbox API connection + netbox = api(nb_host, token=nb_token, threading=True) + try: + # Get NetBox version + nb_version = netbox.version + logger.debug("NetBox version is %s.", nb_version) + self.netbox = netbox + self.nb_version = nb_version + except RequestsConnectionError: + logger.error( + "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", + nb_host, + ) + # Set Zabbix API + try: + ssl_ctx = ssl.create_default_context() + + # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API + if environ.get("REQUESTS_CA_BUNDLE", None): + ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) + + if not zbx_token: + self.zabbix = ZabbixAPI( + zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx + ) + else: + self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) + self.zabbix.check_auth() + except (APIRequestError, ProcessingError) as zbx_error: + e = f"Zabbix returned the following error: {zbx_error}." + logger.error(e) + sys.exit(1) + + def start(self): + """ + Run the NetBox to Zabbix sync process. + """ + if not self.netbox or not self.zabbix: + e = "Not connected to NetBox or Zabbix. Please run the connect function first." + logger.error(e) + raise SyncError(e) + device_cfs = [] + vm_cfs = [] + # Create API call to get all custom fields which are on the device objects + device_cfs = list( + self.netbox.extras.custom_fields.filter( + type=["text", "object", "select"], content_types="dcim.device" ) ) + # Check if the provided Hostgroup layout is valid verify_hg_format( - config["vm_hostgroup_format"], vm_cfs=vm_cfs, hg_type="vm", logger=logger + self.config["hostgroup_format"], + device_cfs=device_cfs, + hg_type="dev", + logger=logger, ) - # Set Zabbix API - try: - ssl_ctx = ssl.create_default_context() - - # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API - if environ.get("REQUESTS_CA_BUNDLE", None): - ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) - - if not zbx_token: - zabbix = ZabbixAPI( - zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx + if self.config["sync_vms"]: + vm_cfs = list( + self.netbox.extras.custom_fields.filter( + type=["text", "object", "select"], + content_types="virtualization.virtualmachine", + ) ) - else: - zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) - zabbix.check_auth() - except (APIRequestError, ProcessingError) as zbx_error: - e = f"Zabbix returned the following error: {zbx_error}." - logger.error(e) - sys.exit(1) - # Set API parameter mapping based on API version - proxy_name = "host" if not str(zabbix.version).startswith("7") else "name" - # Get all Zabbix and NetBox data - netbox_devices = list(netbox.dcim.devices.filter(**config["nb_device_filter"])) - netbox_vms = [] - if config["sync_vms"]: - netbox_vms = list( - netbox.virtualization.virtual_machines.filter(**config["nb_vm_filter"]) + verify_hg_format( + self.config["vm_hostgroup_format"], + vm_cfs=vm_cfs, + hg_type="vm", + logger=logger, + ) + # Set API parameter mapping based on API version + proxy_name = "host" if not str(self.zabbix.version).startswith("7") else "name" + # Get all Zabbix and NetBox data + netbox_devices = list( + self.netbox.dcim.devices.filter(**self.config["nb_device_filter"]) ) - netbox_site_groups = convert_recordset(netbox.dcim.site_groups.all()) - netbox_regions = convert_recordset(netbox.dcim.regions.all()) - netbox_journals = netbox.extras.journal_entries - zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] - zabbix_templates = zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] - zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] - # Set empty list for proxy processing Zabbix <= 6 - zabbix_proxygroups = [] - if str(zabbix.version).startswith("7"): - zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"]) # type: ignore[attr-defined] - # Sanitize proxy data - if proxy_name == "host": - for proxy in zabbix_proxies: - proxy["name"] = proxy.pop("host") - # Prepare list of all proxy and proxy_groups - zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups) + netbox_vms = [] + if self.config["sync_vms"]: + netbox_vms = list( + self.netbox.virtualization.virtual_machines.filter( + **self.config["nb_vm_filter"] + ) + ) + netbox_site_groups = convert_recordset(self.netbox.dcim.site_groups.all()) + netbox_regions = convert_recordset(self.netbox.dcim.regions.all()) + netbox_journals = self.netbox.extras.journal_entries + zabbix_groups = self.zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] + zabbix_templates = self.zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] + zabbix_proxies = self.zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] + # Set empty list for proxy processing Zabbix <= 6 + zabbix_proxygroups = [] + if str(self.zabbix.version).startswith("7"): + zabbix_proxygroups = self.zabbix.proxygroup.get( + output=["proxy_groupid", "name"] + ) # type: ignore[attr-defined] + # Sanitize proxy data + if proxy_name == "host": + for proxy in zabbix_proxies: + proxy["name"] = proxy.pop("host") + # Prepare list of all proxy and proxy_groups + zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups) - # Go through all NetBox devices - for nb_vm in netbox_vms: - try: - vm = VirtualMachine( - nb_vm, - zabbix, - netbox_journals, - nb_version, - config["create_journal"], - logger, - ) - logger.debug("Host %s: Started operations on VM.", vm.name) - vm.set_vm_template() - # Check if a valid template has been found for this VM. - if not vm.zbx_template_names: - continue - vm.set_hostgroup( - config["vm_hostgroup_format"], netbox_site_groups, netbox_regions - ) - # Check if a valid hostgroup has been found for this VM. - if not vm.hostgroups: - continue - if config["extended_site_properties"] and nb_vm.site: - logger.debug("VM %s: extending site information.", vm.name) - vm.site = convert_recordset(netbox.dcim.sites.filter(id=nb_vm.site.id)) # type: ignore[attr-defined] - vm.set_inventory(nb_vm) - vm.set_usermacros() - vm.set_tags() - # Checks if device is in cleanup state - if vm.status in config["zabbix_device_removal"]: - if vm.zabbix_id: - # Delete device from Zabbix - # and remove hostID from NetBox. - vm.cleanup() - logger.info("VM %s: cleanup complete", vm.name) + # Go through all NetBox devices + for nb_vm in netbox_vms: + try: + vm = VirtualMachine( + nb_vm, + self.zabbix, + netbox_journals, + self.nb_version, + self.config["create_journal"], + logger, + ) + logger.debug("Host %s: Started operations on VM.", vm.name) + vm.set_vm_template() + # Check if a valid template has been found for this VM. + if not vm.zbx_template_names: continue - # Device has been added to NetBox - # but is not in Activate state - logger.info( - "VM %s: Skipping since this VM is not in the active state.", vm.name + vm.set_hostgroup( + self.config["vm_hostgroup_format"], + netbox_site_groups, + netbox_regions, ) - continue - # Check if the VM is in the disabled state - if vm.status in config["zabbix_device_disable"]: - vm.zabbix_state = 1 - # Add hostgroup if config is set - if config["create_hostgroups"]: - # Create new hostgroup. Potentially multiple groups if nested - hostgroups = vm.create_zbx_hostgroup(zabbix_groups) - # go through all newly created hostgroups - for group in hostgroups: - # Add new hostgroups to zabbix group list - zabbix_groups.append(group) - # Check if VM is already in Zabbix - if vm.zabbix_id: - vm.consistency_check( - zabbix_groups, - zabbix_templates, - zabbix_proxy_list, - config["full_proxy_sync"], - config["create_hostgroups"], - ) - continue - # Add VM to Zabbix - vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) - except SyncError: - pass - - for nb_device in netbox_devices: - try: - # Set device instance set data such as hostgroup and template information. - device = PhysicalDevice( - nb_device, - zabbix, - netbox_journals, - nb_version, - config["create_journal"], - logger, - ) - logger.debug("Host %s: Started operations on device.", device.name) - device.set_template( - config["templates_config_context"], - config["templates_config_context_overrule"], - ) - # Check if a valid template has been found for this VM. - if not device.zbx_template_names: - continue - device.set_hostgroup( - config["hostgroup_format"], netbox_site_groups, netbox_regions - ) - # Check if a valid hostgroup has been found for this VM. - if not device.hostgroups: - logger.warning( - "Host %s: Host has no valid hostgroups, Skipping this host...", - device.name, - ) - continue - if config["extended_site_properties"] and nb_device.site: - logger.debug("Device %s: extending site information.", device.name) - device.site = convert_recordset( # type: ignore[attr-defined] - netbox.dcim.sites.filter(id=nb_device.site.id) - ) - device.set_inventory(nb_device) - device.set_usermacros() - device.set_tags() - # Checks if device is part of cluster. - # Requires clustering variable - if device.is_cluster() and config["clustering"]: - # Check if device is primary or secondary - if device.promote_primary_device(): + # Check if a valid hostgroup has been found for this VM. + if not vm.hostgroups: + continue + if self.config["extended_site_properties"] and nb_vm.site: + logger.debug("VM %s: extending site information.", vm.name) + vm.site = convert_recordset( + self.netbox.dcim.sites.filter(id=nb_vm.site.id) + ) # type: ignore[attr-defined] + vm.set_inventory(nb_vm) + vm.set_usermacros() + vm.set_tags() + # Checks if device is in cleanup state + if vm.status in self.config["zabbix_device_removal"]: + if vm.zabbix_id: + # Delete device from Zabbix + # and remove hostID from self.netbox. + vm.cleanup() + logger.info("VM %s: cleanup complete", vm.name) + continue + # Device has been added to NetBox + # but is not in Activate state logger.info( - "Device %s: is part of cluster and primary.", device.name + "VM %s: Skipping since this VM is not in the active state.", + vm.name, ) - else: - # Device is secondary in cluster. - # Don't continue with this device. - logger.info( - "Device %s: Is part of cluster but not primary. Skipping this host...", + continue + # Check if the VM is in the disabled state + if vm.status in self.config["zabbix_device_disable"]: + vm.zabbix_state = 1 + # Add hostgroup if config is set + if self.config["create_hostgroups"]: + # Create new hostgroup. Potentially multiple groups if nested + hostgroups = vm.create_zbx_hostgroup(zabbix_groups) + # go through all newly created hostgroups + for group in hostgroups: + # Add new hostgroups to zabbix group list + zabbix_groups.append(group) + # Check if VM is already in Zabbix + if vm.zabbix_id: + vm.consistency_check( + zabbix_groups, + zabbix_templates, + zabbix_proxy_list, + self.config["full_proxy_sync"], + self.config["create_hostgroups"], + ) + continue + # Add VM to Zabbix + vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) + except SyncError: + pass + + for nb_device in netbox_devices: + try: + # Set device instance set data such as hostgroup and template information. + device = PhysicalDevice( + nb_device, + self.zabbix, + netbox_journals, + self.nb_version, + self.config["create_journal"], + logger, + ) + logger.debug("Host %s: Started operations on device.", device.name) + device.set_template( + self.config["templates_config_context"], + self.config["templates_config_context_overrule"], + ) + # Check if a valid template has been found for this VM. + if not device.zbx_template_names: + continue + device.set_hostgroup( + self.config["hostgroup_format"], netbox_site_groups, netbox_regions + ) + # Check if a valid hostgroup has been found for this VM. + if not device.hostgroups: + logger.warning( + "Host %s: Host has no valid hostgroups, Skipping this host...", device.name, ) continue - # Checks if device is in cleanup state - if device.status in config["zabbix_device_removal"]: - if device.zabbix_id: - # Delete device from Zabbix - # and remove hostID from NetBox. - device.cleanup() - logger.info("Device %s: cleanup complete", device.name) + if self.config["extended_site_properties"] and nb_device.site: + logger.debug("Device %s: extending site information.", device.name) + device.site = convert_recordset( # type: ignore[attr-defined] + self.netbox.dcim.sites.filter(id=nb_device.site.id) + ) + device.set_inventory(nb_device) + device.set_usermacros() + device.set_tags() + # Checks if device is part of cluster. + # Requires clustering variable + if device.is_cluster() and self.config["clustering"]: + # Check if device is primary or secondary + if device.promote_primary_device(): + logger.info( + "Device %s: is part of cluster and primary.", device.name + ) + else: + # Device is secondary in cluster. + # Don't continue with this device. + logger.info( + "Device %s: Is part of cluster but not primary. Skipping this host...", + device.name, + ) + continue + # Checks if device is in cleanup state + if device.status in self.config["zabbix_device_removal"]: + if device.zabbix_id: + # Delete device from Zabbix + # and remove hostID from NetBox. + device.cleanup() + logger.info("Device %s: cleanup complete", device.name) + continue + # Device has been added to NetBox + # but is not in Activate state + logger.info( + "Device %s: Skipping since this device is not in the active state.", + device.name, + ) continue - # Device has been added to NetBox - # but is not in Activate state - logger.info( - "Device %s: Skipping since this device is not in the active state.", - device.name, + # Check if the device is in the disabled state + if device.status in self.config["zabbix_device_disable"]: + device.zabbix_state = 1 + # Add hostgroup is config is set + if self.config["create_hostgroups"]: + # Create new hostgroup. Potentially multiple groups if nested + hostgroups = device.create_zbx_hostgroup(zabbix_groups) + # go through all newly created hostgroups + for group in hostgroups: + # Add new hostgroups to zabbix group list + zabbix_groups.append(group) + # Check if device is already in Zabbix + if device.zabbix_id: + device.consistency_check( + zabbix_groups, + zabbix_templates, + zabbix_proxy_list, + self.config["full_proxy_sync"], + self.config["create_hostgroups"], + ) + continue + # Add device to Zabbix + device.create_in_zabbix( + zabbix_groups, zabbix_templates, zabbix_proxy_list ) - continue - # Check if the device is in the disabled state - if device.status in config["zabbix_device_disable"]: - device.zabbix_state = 1 - # Add hostgroup is config is set - if config["create_hostgroups"]: - # Create new hostgroup. Potentially multiple groups if nested - hostgroups = device.create_zbx_hostgroup(zabbix_groups) - # go through all newly created hostgroups - for group in hostgroups: - # Add new hostgroups to zabbix group list - zabbix_groups.append(group) - # Check if device is already in Zabbix - if device.zabbix_id: - device.consistency_check( - zabbix_groups, - zabbix_templates, - zabbix_proxy_list, - config["full_proxy_sync"], - config["create_hostgroups"], - ) - continue - # Add device to Zabbix - device.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) - except SyncError: - pass - zabbix.logout() + except SyncError: + pass + self.zabbix.logout() diff --git a/pyproject.toml b/pyproject.toml index 640fdf8..df55916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,8 @@ select = [ "tests/*" = [ # Ignore use of assert "S101", + # Ignore hardcoded passwords / tokens + "S106", ] diff --git a/tests/test_core.py b/tests/test_core.py index 77dca09..e45c2dd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,7 +7,7 @@ from pynetbox.core.query import RequestError as NBRequestError from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError -from netbox_zabbix_sync.modules.core import sync +from netbox_zabbix_sync.modules.core import Sync # Minimal config for testing - includes all keys used by sync() TEST_CONFIG = { @@ -88,14 +88,16 @@ class TestSyncNetboxConnection(unittest.TestCase): ) with self.assertRaises(SystemExit) as context: - sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, + syncer = Sync() + syncer.connect( + nb_host="http://netbox.local", + nb_token="token", + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token=None, ) + syncer.start() self.assertEqual(context.exception.code, 1) @@ -110,7 +112,7 @@ class TestSyncNetboxConnection(unittest.TestCase): ) with self.assertRaises(SystemExit) as context: - sync( + Sync( "http://netbox.local", "token", "http://zabbix.local", @@ -150,7 +152,7 @@ class TestSyncZabbixConnection(unittest.TestCase): ) with self.assertRaises(SystemExit) as context: - sync( + Sync( "http://netbox.local", "token", "http://zabbix.local", @@ -173,7 +175,7 @@ class TestSyncZabbixConnection(unittest.TestCase): ) with self.assertRaises(SystemExit) as context: - sync( + Sync( "http://netbox.local", "token", "http://zabbix.local", @@ -219,7 +221,7 @@ class TestSyncZabbixAuthentication(unittest.TestCase): self._setup_netbox_mock(mock_api) self._setup_zabbix_mock(mock_zabbix_api) - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -243,7 +245,7 @@ class TestSyncZabbixAuthentication(unittest.TestCase): self._setup_netbox_mock(mock_api) self._setup_zabbix_mock(mock_zabbix_api) - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -281,7 +283,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -308,7 +311,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_device_instance.zbx_template_names = [] mock_physical_device.return_value = mock_device_instance - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -340,7 +343,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_vm_instance.zbx_template_names = [] mock_virtual_machine.return_value = mock_vm_instance - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -365,7 +368,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): self._setup_netbox_mock(mock_api, vms=[vm1]) self._setup_zabbix_mock(mock_zabbix_api) - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -405,9 +408,10 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "6.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}] + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "host": "proxy1"}] - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -431,10 +435,11 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "7.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}] + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -461,7 +466,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.proxy.get.return_value = [] mock_zabbix.proxygroup.get.return_value = [] - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -487,7 +492,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.template.get.return_value = [] mock_zabbix.proxy.get.return_value = [] - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -529,7 +534,7 @@ class TestSyncLogout(unittest.TestCase): mock_zabbix.template.get.return_value = [] mock_zabbix.proxy.get.return_value = [] - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -579,7 +584,7 @@ class TestSyncProxyNameSanitization(unittest.TestCase): ] mock_proxy_prepper.return_value = [] - sync( + Sync( "http://netbox.local", "nb_token", "http://zabbix.local", From dfba6f4714d914eb688ad09ca852afe5e5864302 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 18 Feb 2026 13:57:37 +0000 Subject: [PATCH 27/57] Renamed NB API import, removed unused sys import, added error when ZBX token and password are both used, revamped the core testing file and added useful tests such as device clustering and a base for future device testing. --- netbox_zabbix_sync/modules/core.py | 19 +- tests/test_core.py | 417 ++++++++++++++++++----------- 2 files changed, 280 insertions(+), 156 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index d53c305..83538ed 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -1,10 +1,9 @@ """Core component of the sync process""" import ssl -import sys from os import environ -from pynetbox import api +from pynetbox import api as nbapi from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI @@ -83,7 +82,7 @@ class Sync: :param zbx_token: Description """ # Initialize Netbox API connection - netbox = api(nb_host, token=nb_token, threading=True) + netbox = nbapi(nb_host, token=nb_token, threading=True) try: # Get NetBox version nb_version = netbox.version @@ -95,25 +94,35 @@ class Sync: "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", nb_host, ) + return False # Set Zabbix API + if (zbx_pass or zbx_user) and zbx_token: + e = ( + "Both ZABBIX_PASS, ZABBIX_USER and ZABBIX_TOKEN environment variables are set. " + "Please choose between token or password based authentication." + ) + logger.error(e) + return False try: ssl_ctx = ssl.create_default_context() # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API if environ.get("REQUESTS_CA_BUNDLE", None): ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) - if not zbx_token: + logger.debug("Using user/password authentication for Zabbix API.") self.zabbix = ZabbixAPI( zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx ) else: + logger.debug("Using token authentication for Zabbix API.") self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) self.zabbix.check_auth() except (APIRequestError, ProcessingError) as zbx_error: e = f"Zabbix returned the following error: {zbx_error}." logger.error(e) - sys.exit(1) + return False + return True def start(self): """ diff --git a/tests/test_core.py b/tests/test_core.py index e45c2dd..f5dde70 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,30 +3,11 @@ import unittest from unittest.mock import MagicMock, patch -from pynetbox.core.query import RequestError as NBRequestError from requests.exceptions import ConnectionError as RequestsConnectionError -from zabbix_utils import APIRequestError, ProcessingError +from zabbix_utils import APIRequestError from netbox_zabbix_sync.modules.core import Sync -# Minimal config for testing - includes all keys used by sync() -TEST_CONFIG = { - "hostgroup_format": "site", - "vm_hostgroup_format": "site", - "sync_vms": False, - "nb_device_filter": {}, - "nb_vm_filter": {}, - "create_journal": False, - "templates_config_context": False, - "templates_config_context_overrule": False, - "create_hostgroups": False, - "clustering": False, - "zabbix_device_removal": ["Decommissioning", "Inventory"], - "zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"], - "full_proxy_sync": False, - "extended_site_properties": False, -} - class MockNetboxDevice: """Mock NetBox device object.""" @@ -40,15 +21,93 @@ class MockNetboxDevice: config_context=None, site=None, primary_ip=None, + virtual_chassis=None, + device_type=None, + tenant=None, + device_role=None, + role=None, + platform=None, + serial="", + tags=None, ): self.id = device_id self.name = name self.status = MagicMock() self.status.label = status_label - self.custom_fields = {"zabbix_hostid": zabbix_hostid} + self.status.value = status_label.lower() + self.custom_fields = { + "zabbix_hostid": zabbix_hostid, + "zabbix_template": "TestTemplate", + } self.config_context = config_context or {} - self.site = site - self.primary_ip = primary_ip + self.tenant = tenant + self.platform = platform + self.serial = serial + self.asset_tag = None + self.location = None + self.rack = None + self.position = None + self.face = None + self.latitude = None + self.longitude = None + self.parent_device = None + self.airflow = None + self.cluster = None + self.vc_position = None + self.vc_priority = None + self.description = "" + self.comments = "" + self.tags = tags or [] + self.oob_ip = None + + # Setup site with proper structure + if site is None: + self.site = MagicMock() + self.site.name = "TestSite" + self.site.slug = "testsite" + else: + self.site = site + + # Setup primary IP with proper structure + if primary_ip is None: + self.primary_ip = MagicMock() + self.primary_ip.address = "192.168.1.1/24" + else: + self.primary_ip = primary_ip + + self.primary_ip4 = self.primary_ip + self.primary_ip6 = None + + # Setup device type with proper structure + if device_type is None: + self.device_type = MagicMock() + self.device_type.custom_fields = { + "zabbix_template": "TestTemplate"} + self.device_type.manufacturer = MagicMock() + self.device_type.manufacturer.name = "TestManufacturer" + self.device_type.display = "Test Device Type" + self.device_type.model = "Test Model" + self.device_type.slug = "test-model" + else: + self.device_type = device_type + + # Setup device role (NetBox 2/3 compatibility) and role (NetBox 4+) + if device_role is None and role is None: + # Create default role + mock_role = MagicMock() + mock_role.name = "Switch" + mock_role.slug = "switch" + self.device_role = mock_role # NetBox 2/3 + self.role = mock_role # NetBox 4+ + else: + self.device_role = device_role or role + self.role = role or device_role + + self.virtual_chassis = virtual_chassis + + def save(self): + """Mock save method for NetBox device.""" + pass class MockNetboxVM: @@ -77,9 +136,9 @@ class MockNetboxVM: class TestSyncNetboxConnection(unittest.TestCase): """Test NetBox connection handling in sync function.""" - @patch("netbox_zabbix_sync.modules.core.api") - def test_sync_exits_on_netbox_connection_error(self, mock_api): - """Test that sync exits when NetBox connection fails.""" + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_error_on_netbox_connection_error(self, mock_api): + """Test that sync returns False when NetBox connection fails.""" mock_netbox = MagicMock() mock_api.return_value = mock_netbox # Simulate connection error when accessing version @@ -87,41 +146,40 @@ class TestSyncNetboxConnection(unittest.TestCase): lambda self: (_ for _ in ()).throw(RequestsConnectionError()) ) - with self.assertRaises(SystemExit) as context: - syncer = Sync() - syncer.connect( - nb_host="http://netbox.local", - nb_token="token", - zbx_host="http://zabbix.local", - zbx_user="user", - zbx_pass="pass", - zbx_token=None, - ) - syncer.start() - - self.assertEqual(context.exception.code, 1) - - @patch("netbox_zabbix_sync.modules.core.api") - def test_sync_exits_on_netbox_request_error(self, mock_api): - """Test that sync exits when NetBox returns a request error.""" - mock_netbox = MagicMock() - mock_api.return_value = mock_netbox - # Simulate NetBox request error - type(mock_netbox).version = property( - lambda self: (_ for _ in ()).throw(NBRequestError(MagicMock())) + syncer = Sync() + result = syncer.connect( + nb_host="http://netbox.local", + nb_token="token", + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token=None, ) - with self.assertRaises(SystemExit) as context: - Sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, - ) + self.assertFalse(result) - self.assertEqual(context.exception.code, 1) + +class TestZabbixUserTokenConflict(unittest.TestCase): + """Test that sync returns False when both ZABBIX_USER/PASS and ZABBIX_TOKEN are set.""" + + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_error_on_user_token_conflict(self, mock_api): + """Test that sync returns False when both user/pass and token are provided.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + + syncer = Sync() + result = syncer.connect( + nb_host="http://netbox.local", + nb_token="token", + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token="token", # Both token and user/pass provided + ) + + self.assertFalse(result) class TestSyncZabbixConnection(unittest.TestCase): @@ -132,59 +190,30 @@ class TestSyncZabbixConnection(unittest.TestCase): mock_netbox = MagicMock() mock_api.return_value = mock_netbox mock_netbox.version = "3.5" - mock_netbox.extras.custom_fields.filter.return_value = [] - mock_netbox.dcim.devices.filter.return_value = [] - mock_netbox.virtualization.virtual_machines.filter.return_value = [] - mock_netbox.dcim.site_groups.all.return_value = [] - mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_exits_on_zabbix_api_error(self, mock_api, mock_zabbix_api): """Test that sync exits when Zabbix API authentication fails.""" + # Simulate Netbox API self._setup_netbox_mock(mock_api) - # Simulate Zabbix API error mock_zabbix_api.return_value.check_auth.side_effect = APIRequestError( "Invalid credentials" ) - - with self.assertRaises(SystemExit) as context: - Sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, - ) - - self.assertEqual(context.exception.code, 1) - - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) - @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") - def test_sync_exits_on_zabbix_processing_error(self, mock_api, mock_zabbix_api): - """Test that sync exits when Zabbix has processing error.""" - self._setup_netbox_mock(mock_api) - - mock_zabbix_api.return_value.check_auth.side_effect = ProcessingError( - "Processing failed" + # Start syncer and set connection details + syncer = Sync() + result = syncer.connect( + "http://netbox.local", + "token", + "http://zabbix.local", + "user", + "pass", + None, ) - - with self.assertRaises(SystemExit) as context: - Sync( - "http://netbox.local", - "token", - "http://zabbix.local", - "user", - "pass", - None, - ) - - self.assertEqual(context.exception.code, 1) + # Validate that result is False due to Zabbix API error + self.assertFalse(result) class TestSyncZabbixAuthentication(unittest.TestCase): @@ -202,7 +231,7 @@ class TestSyncZabbixAuthentication(unittest.TestCase): mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - def _setup_zabbix_mock(self, mock_zabbix_api, version="6.0"): + def _setup_zabbix_mock(self, mock_zabbix_api, version="7.0"): """Helper to setup a working Zabbix mock.""" mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix @@ -213,48 +242,44 @@ class TestSyncZabbixAuthentication(unittest.TestCase): mock_zabbix.proxygroup.get.return_value = [] return mock_zabbix - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_user_password_when_no_token(self, mock_api, mock_zabbix_api): """Test that sync uses user/password auth when no token is provided.""" self._setup_netbox_mock(mock_api) - self._setup_zabbix_mock(mock_zabbix_api) - Sync( - "http://netbox.local", - "nb_token", - "http://zabbix.local", - "zbx_user", - "zbx_pass", - None, # No token + syncer = Sync() + syncer.connect( + nb_host="http://netbox.local", + nb_token="nb_token", + zbx_host="http://zabbix.local", + zbx_user="zbx_user", + zbx_pass="zbx_pass", ) - # Verify ZabbixAPI was called with user/password + # Verify ZabbixAPI was called with user/password and without token mock_zabbix_api.assert_called_once() call_kwargs = mock_zabbix_api.call_args.kwargs self.assertEqual(call_kwargs["user"], "zbx_user") self.assertEqual(call_kwargs["password"], "zbx_pass") self.assertNotIn("token", call_kwargs) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_token_when_provided(self, mock_api, mock_zabbix_api): """Test that sync uses token auth when token is provided.""" self._setup_netbox_mock(mock_api) self._setup_zabbix_mock(mock_zabbix_api) - Sync( - "http://netbox.local", - "nb_token", - "http://zabbix.local", - "zbx_user", - "zbx_pass", - "zbx_token", # Token provided + syncer = Sync() + syncer.connect( + nb_host="http://netbox.local", + nb_token="nb_token", + zbx_host="http://zabbix.local", + zbx_token="zbx_token", ) - # Verify ZabbixAPI was called with token + # Verify ZabbixAPI was called with token and without user/password mock_zabbix_api.assert_called_once() call_kwargs = mock_zabbix_api.call_args.kwargs self.assertEqual(call_kwargs["token"], "zbx_token") @@ -292,10 +317,9 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix.proxygroup.get.return_value = [] return mock_zabbix - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.PhysicalDevice") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_processes_devices_from_netbox( self, mock_api, mock_zabbix_api, mock_physical_device ): @@ -311,7 +335,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_device_instance.zbx_template_names = [] mock_physical_device.return_value = mock_device_instance - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -319,15 +344,15 @@ class TestSyncDeviceProcessing(unittest.TestCase): "pass", None, ) + syncer.start() # Verify PhysicalDevice was instantiated for each device self.assertEqual(mock_physical_device.call_count, 2) - @patch("netbox_zabbix_sync.modules.core.config", {**TEST_CONFIG, "sync_vms": True}) @patch("netbox_zabbix_sync.modules.core.VirtualMachine") @patch("netbox_zabbix_sync.modules.core.PhysicalDevice") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_processes_vms_when_enabled( self, mock_api, mock_zabbix_api, mock_physical_device, mock_virtual_machine ): @@ -343,7 +368,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_vm_instance.zbx_template_names = [] mock_virtual_machine.return_value = mock_vm_instance - Sync( + syncer = Sync({"sync_vms": True}) + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -351,14 +377,14 @@ class TestSyncDeviceProcessing(unittest.TestCase): "pass", None, ) + syncer.start() # Verify VirtualMachine was instantiated for each VM self.assertEqual(mock_virtual_machine.call_count, 2) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.VirtualMachine") @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_skips_vms_when_disabled( self, mock_api, mock_zabbix_api, mock_virtual_machine ): @@ -368,7 +394,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): self._setup_netbox_mock(mock_api, vms=[vm1]) self._setup_zabbix_mock(mock_zabbix_api) - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -376,6 +403,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): "pass", None, ) + syncer.start() # Verify VirtualMachine was never called mock_virtual_machine.assert_not_called() @@ -396,9 +424,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_host_proxy_name_for_zabbix_6(self, mock_api, mock_zabbix_api): """Test that sync uses 'host' as proxy name field for Zabbix 6.""" self._setup_netbox_mock(mock_api) @@ -411,7 +438,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.proxy.get.return_value = [ {"proxyid": "1", "host": "proxy1"}] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -419,13 +447,13 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxy.get was called with 'host' field mock_zabbix.proxy.get.assert_called_with(output=["proxyid", "host"]) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_uses_name_proxy_field_for_zabbix_7(self, mock_api, mock_zabbix_api): """Test that sync uses 'name' as proxy name field for Zabbix 7.""" self._setup_netbox_mock(mock_api) @@ -439,7 +467,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): {"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -447,13 +476,13 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxy.get was called with 'name' field mock_zabbix.proxy.get.assert_called_with(output=["proxyid", "name"]) - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_fetches_proxygroups_for_zabbix_7(self, mock_api, mock_zabbix_api): """Test that sync fetches proxy groups for Zabbix 7.""" self._setup_netbox_mock(mock_api) @@ -466,7 +495,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.proxy.get.return_value = [] mock_zabbix.proxygroup.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -474,13 +504,13 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxygroup.get was called for Zabbix 7 mock_zabbix.proxygroup.get.assert_called_once() - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_skips_proxygroups_for_zabbix_6(self, mock_api, mock_zabbix_api): """Test that sync does NOT fetch proxy groups for Zabbix 6.""" self._setup_netbox_mock(mock_api) @@ -492,7 +522,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.template.get.return_value = [] mock_zabbix.proxy.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -500,6 +531,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxygroup.get was NOT called for Zabbix 6 mock_zabbix.proxygroup.get.assert_not_called() @@ -520,9 +552,8 @@ class TestSyncLogout(unittest.TestCase): mock_netbox.dcim.regions.all.return_value = [] return mock_netbox - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_logs_out_from_zabbix(self, mock_api, mock_zabbix_api): """Test that sync calls logout on Zabbix API after completion.""" self._setup_netbox_mock(mock_api) @@ -534,7 +565,8 @@ class TestSyncLogout(unittest.TestCase): mock_zabbix.template.get.return_value = [] mock_zabbix.proxy.get.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -542,6 +574,7 @@ class TestSyncLogout(unittest.TestCase): "pass", None, ) + syncer.start() # Verify logout was called mock_zabbix.logout.assert_called_once() @@ -563,9 +596,8 @@ class TestSyncProxyNameSanitization(unittest.TestCase): return mock_netbox @patch("netbox_zabbix_sync.modules.core.proxy_prepper") - @patch("netbox_zabbix_sync.modules.core.config", TEST_CONFIG) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.api") + @patch("netbox_zabbix_sync.modules.core.nbapi") def test_sync_renames_host_to_name_for_zabbix_6_proxies( self, mock_api, mock_zabbix_api, mock_proxy_prepper ): @@ -584,7 +616,8 @@ class TestSyncProxyNameSanitization(unittest.TestCase): ] mock_proxy_prepper.return_value = [] - Sync( + syncer = Sync() + syncer.connect( "http://netbox.local", "nb_token", "http://zabbix.local", @@ -592,6 +625,7 @@ class TestSyncProxyNameSanitization(unittest.TestCase): "pass", None, ) + syncer.start() # Verify proxy_prepper was called with sanitized proxy list call_args = mock_proxy_prepper.call_args[0] @@ -602,5 +636,86 @@ class TestSyncProxyNameSanitization(unittest.TestCase): self.assertNotIn("host", proxy) -if __name__ == "__main__": - unittest.main() +class TestDeviceHandeling(unittest.TestCase): + """ + Tests several devices which can be synced to Zabbix. + This class contains a lot of data in order to validate proper handling of different device types and configurations. + """ + + def _setup_netbox_mock(self, mock_api): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + return mock_netbox + + def _setup_zabbix_mock(self, mock_zabbix_api, version=7.0): + """Helper to setup a working Zabbix mock.""" + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = version + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"} + ] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + mock_zabbix.logout = MagicMock() + # Mock host.get to return empty (host doesn't exist yet) + mock_zabbix.host.get.return_value = [] + # Mock host.create to return success + mock_zabbix.host.create.return_value = {"hostids": ["1"]} + return mock_zabbix + + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_cluster_where_device_is_primary( + self, mock_api, mock_zabbix_api + ): + """Test that sync properly handles a device that is the primary in a virtual chassis.""" + # Create a device that is part of a virtual chassis and is the primary + device = MockNetboxDevice( + device_id=1, + name="SW01N0", + virtual_chassis=MagicMock(), + ) + device.virtual_chassis.master = MagicMock() + device.virtual_chassis.master.id = 1 # Same as device ID - device is primary + device.virtual_chassis.name = "SW01" + + # Setup NetBox mock with a site for hostgroup + mock_netbox = self._setup_netbox_mock(mock_api) + mock_netbox.dcim.devices.filter.return_value = [device] + + # Create a mock site for hostgroup generation + mock_site = MagicMock() + mock_site.name = "TestSite" + device.site = mock_site + + # Setup Zabbix mock + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + # Run the sync with clustering enabled + syncer = Sync({"clustering": True}) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + # Verify that host.create was called with the cluster name "SW01", not "SW01N0" + mock_zabbix.host.create.assert_called_once() + create_call_kwargs = mock_zabbix.host.create.call_args.kwargs + + # The host should be created with the virtual chassis name, not the device name + self.assertEqual(create_call_kwargs["host"], "SW01") From c00ec4de3134428de652d232fe4ec40e3a50c883 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 18 Feb 2026 14:10:59 +0000 Subject: [PATCH 28/57] Fixed several ruff and ty checks. --- netbox_zabbix_sync/modules/core.py | 13 ++++++----- netbox_zabbix_sync/modules/device.py | 1 + tests/test_core.py | 33 +++++++++++++--------------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 83538ed..05ff624 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -2,6 +2,7 @@ import ssl from os import environ +from typing import Any from pynetbox import api as nbapi from requests.exceptions import ConnectionError as RequestsConnectionError @@ -27,7 +28,7 @@ class Sync: This class is used to connect to NetBox and Zabbix and run the sync process. """ - def __init__(self, config=None): + def __init__(self, config: dict[str, Any] | None = None): """ Docstring for __init__ @@ -36,7 +37,7 @@ class Sync: """ self.netbox = None self.zabbix = None - self.config = config + self.config: dict[str, Any] = config if config else {} self.nb_version = None self._config() @@ -182,9 +183,9 @@ class Sync: # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] if str(self.zabbix.version).startswith("7"): - zabbix_proxygroups = self.zabbix.proxygroup.get( + zabbix_proxygroups = self.zabbix.proxygroup.get( # type: ignore[attr-defined] output=["proxy_groupid", "name"] - ) # type: ignore[attr-defined] + ) # Sanitize proxy data if proxy_name == "host": for proxy in zabbix_proxies: @@ -220,7 +221,7 @@ class Sync: logger.debug("VM %s: extending site information.", vm.name) vm.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_vm.site.id) - ) # type: ignore[attr-defined] + ) vm.set_inventory(nb_vm) vm.set_usermacros() vm.set_tags() @@ -296,7 +297,7 @@ class Sync: continue if self.config["extended_site_properties"] and nb_device.site: logger.debug("Device %s: extending site information.", device.name) - device.site = convert_recordset( # type: ignore[attr-defined] + device.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_device.site.id) ) device.set_inventory(nb_device) diff --git a/netbox_zabbix_sync/modules/device.py b/netbox_zabbix_sync/modules/device.py index 77b1113..c3e7dd8 100644 --- a/netbox_zabbix_sync/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -55,6 +55,7 @@ class PhysicalDevice: self.hostgroups = [] self.hostgroup_type = "dev" self.tenant = nb.tenant + self.site = nb.site self.config_context = nb.config_context self.zbxproxy = None self.zabbix_state = 0 diff --git a/tests/test_core.py b/tests/test_core.py index f5dde70..c6b3dd5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -81,8 +81,7 @@ class MockNetboxDevice: # Setup device type with proper structure if device_type is None: self.device_type = MagicMock() - self.device_type.custom_fields = { - "zabbix_template": "TestTemplate"} + self.device_type.custom_fields = {"zabbix_template": "TestTemplate"} self.device_type.manufacturer = MagicMock() self.device_type.manufacturer.name = "TestManufacturer" self.device_type.display = "Test Device Type" @@ -107,7 +106,6 @@ class MockNetboxDevice: def save(self): """Mock save method for NetBox device.""" - pass class MockNetboxVM: @@ -308,8 +306,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [ - {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -435,8 +432,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "6.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [ - {"proxyid": "1", "host": "proxy1"}] + mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}] syncer = Sync() syncer.connect( @@ -463,8 +459,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "7.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [ - {"proxyid": "1", "name": "proxy1"}] + mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] syncer = Sync() @@ -659,8 +654,7 @@ class TestDeviceHandeling(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [ - {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -675,19 +669,22 @@ class TestDeviceHandeling(unittest.TestCase): @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") @patch("netbox_zabbix_sync.modules.core.nbapi") - def test_sync_cluster_where_device_is_primary( - self, mock_api, mock_zabbix_api - ): + def test_sync_cluster_where_device_is_primary(self, mock_api, mock_zabbix_api): """Test that sync properly handles a device that is the primary in a virtual chassis.""" # Create a device that is part of a virtual chassis and is the primary + # Setup virtual chassis mock + vc_master = MagicMock() + vc_master.id = 1 # Same as device ID - device is primary + + virtual_chassis = MagicMock() + virtual_chassis.master = vc_master + virtual_chassis.name = "SW01" + device = MockNetboxDevice( device_id=1, name="SW01N0", - virtual_chassis=MagicMock(), + virtual_chassis=virtual_chassis, ) - device.virtual_chassis.master = MagicMock() - device.virtual_chassis.master.id = 1 # Same as device ID - device is primary - device.virtual_chassis.name = "SW01" # Setup NetBox mock with a site for hostgroup mock_netbox = self._setup_netbox_mock(mock_api) From 434f0c9e68f709f73aa99dc86167c3996028d0ee Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 19 Feb 2026 11:47:50 +0000 Subject: [PATCH 29/57] Added new core tests dedicated towards status conflicts / changes and Template sourcing --- tests/test_core.py | 408 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 407 insertions(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index c6b3dd5..9460564 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -90,7 +90,6 @@ class MockNetboxDevice: else: self.device_type = device_type - # Setup device role (NetBox 2/3 compatibility) and role (NetBox 4+) if device_role is None and role is None: # Create default role mock_role = MagicMock() @@ -716,3 +715,410 @@ class TestDeviceHandeling(unittest.TestCase): # The host should be created with the virtual chassis name, not the device name self.assertEqual(create_call_kwargs["host"], "SW01") + + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_templates_from_config_context(self, mock_api, mock_zabbix_api): + """Test that templates_config_context=True uses the config context template.""" + device = MockNetboxDevice( + device_id=1, + name="Router01", + config_context={ + "zabbix": { + "templates": ["ContextTemplate"], + } + }, + ) + + mock_netbox = self._setup_netbox_mock(mock_api) + mock_netbox.dcim.devices.filter.return_value = [device] + + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + # Both templates exist in Zabbix + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"}, + {"templateid": "2", "name": "ContextTemplate"}, + ] + + syncer = Sync({"templates_config_context": True}) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + # Verify host was created with the config context template, not the custom field one + mock_zabbix.host.create.assert_called_once() + create_call_kwargs = mock_zabbix.host.create.call_args.kwargs + self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) + + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_templates_config_context_overrule(self, mock_api, mock_zabbix_api): + """Test that templates_config_context_overrule=True prefers config context over custom field. + + The device has: + - Custom field template (device type): "TestTemplate" + - Config context template (device): "ContextTemplate" + + With overrule enabled the config context should win and the host should + be created with "ContextTemplate" only. + """ + device = MockNetboxDevice( + device_id=1, + name="Router01", + config_context={ + "zabbix": { + "templates": ["ContextTemplate"], + } + }, + ) + + mock_netbox = self._setup_netbox_mock(mock_api) + mock_netbox.dcim.devices.filter.return_value = [device] + + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + # Both templates exist in Zabbix + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"}, + {"templateid": "2", "name": "ContextTemplate"}, + ] + + syncer = Sync({"templates_config_context_overrule": True}) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + # Config context overrides the custom field - only "ContextTemplate" should be used + mock_zabbix.host.create.assert_called_once() + create_call_kwargs = mock_zabbix.host.create.call_args.kwargs + self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) + # Verify the custom field template was NOT used + self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"]) + + +class TestDeviceStatusHandling(unittest.TestCase): + """ + Tests device status handling during NetBox to Zabbix synchronization. + + Validates the correct sync behavior for various combinations of NetBox device + status, Zabbix host state, and the 'zabbix_device_removal' / 'zabbix_device_disable' + configuration settings. + + Scenarios: + 1. Active, not in Zabbix → created enabled + 2. Active, already in Zabbix → consistency check passes, no update + 3. Staged, not in Zabbix → created disabled + 4. Staged, already in Zabbix → consistency check passes, no update + 5. Decommissioning, not in Zabbix → skipped entirely + 6. Decommissioning, in Zabbix → host deleted from Zabbix (cleanup) + 7. Active, in Zabbix but disabled → host re-enabled via consistency check + 8. Failed, in Zabbix but enabled → host disabled via consistency check + """ + + # Hostgroup produced by the default "site/manufacturer/role" format + # for the default MockNetboxDevice attributes. + EXPECTED_HOSTGROUP = "TestSite/TestManufacturer/Switch" + + def _setup_netbox_mock(self, mock_api, devices=None): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = devices or [] + mock_netbox.virtualization.virtual_machines.filter.return_value = [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + mock_netbox.extras.journal_entries = MagicMock() + return mock_netbox + + def _setup_zabbix_mock(self, mock_zabbix_api, version=7.0): + """Helper to setup a working Zabbix mock.""" + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = version + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": self.EXPECTED_HOSTGROUP} + ] + mock_zabbix.hostgroup.create.return_value = {"groupids": ["2"]} + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"} + ] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + mock_zabbix.logout = MagicMock() + mock_zabbix.host.get.return_value = [] + mock_zabbix.host.create.return_value = {"hostids": ["1"]} + mock_zabbix.host.update.return_value = {"hostids": ["42"]} + mock_zabbix.host.delete.return_value = [42] + return mock_zabbix + + def _make_zabbix_host(self, hostname="test-device", status="0"): + """Build a minimal but complete Zabbix host response for consistency_check.""" + return [ + { + "hostid": "42", + "host": hostname, + "name": hostname, + "parentTemplates": [{"templateid": "1"}], + "hostgroups": [{"groupid": "1"}], + "groups": [{"groupid": "1"}], + "status": status, + # Single empty-dict interface: len==1 avoids SyncInventoryError, + # empty keys prevent any spurious interface-update calls. + "interfaces": [{}], + "inventory_mode": "-1", + "inventory": {}, + "macros": [], + "tags": [], + "proxy_hostid": "0", + "proxyid": "0", + "proxy_groupid": "0", + } + ] + + # ------------------------------------------------------------------ + # Scenario 1: Active device, not yet in Zabbix → created enabled (status=0) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_active_device_not_in_zabbix_is_created(self, mock_api, mock_zabbix_api): + """Active device not yet synced to Zabbix should be created with status enabled (0).""" + device = MockNetboxDevice( + name="test-device", status_label="Active", zabbix_hostid=None + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_called_once() + create_kwargs = mock_zabbix.host.create.call_args.kwargs + self.assertEqual(create_kwargs["host"], "test-device") + self.assertEqual(create_kwargs["status"], 0) + + # ------------------------------------------------------------------ + # Scenario 2: Active device, already in Zabbix → consistency check, + # Zabbix status matches → no updates + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_active_device_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api): + """Active device already in Zabbix with matching status should require no updates.""" + device = MockNetboxDevice( + name="test-device", status_label="Active", zabbix_hostid=42 + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0") + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_not_called() + mock_zabbix.host.update.assert_not_called() + + # ------------------------------------------------------------------ + # Scenario 3: Staged device, not yet in Zabbix → created disabled (status=1) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_staged_device_not_in_zabbix_is_created_disabled( + self, mock_api, mock_zabbix_api + ): + """Staged device not yet in Zabbix should be created with status disabled (1).""" + device = MockNetboxDevice( + name="test-device", status_label="Staged", zabbix_hostid=None + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_called_once() + create_kwargs = mock_zabbix.host.create.call_args.kwargs + self.assertEqual(create_kwargs["status"], 1) + + # ------------------------------------------------------------------ + # Scenario 4: Staged device, already in Zabbix as disabled → no update needed + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_staged_device_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api): + """Staged device already in Zabbix as disabled should pass consistency check with no updates.""" + device = MockNetboxDevice( + name="test-device", status_label="Staged", zabbix_hostid=42 + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1") + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_not_called() + mock_zabbix.host.update.assert_not_called() + + # ------------------------------------------------------------------ + # Scenario 5: Decommissioning device, not in Zabbix → skipped (no create, no delete) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_decommissioning_device_not_in_zabbix_is_skipped( + self, mock_api, mock_zabbix_api + ): + """Decommissioning device with no Zabbix ID should be skipped entirely.""" + device = MockNetboxDevice( + name="test-device", status_label="Decommissioning", zabbix_hostid=None + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_not_called() + mock_zabbix.host.delete.assert_not_called() + + # ------------------------------------------------------------------ + # Scenario 6: Decommissioning device, already in Zabbix → cleanup (host deleted) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_decommissioning_device_in_zabbix_is_deleted( + self, mock_api, mock_zabbix_api + ): + """Decommissioning device with a Zabbix ID should be deleted from Zabbix.""" + device = MockNetboxDevice( + name="test-device", status_label="Decommissioning", zabbix_hostid=42 + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + # Zabbix still has the host → it should be deleted + mock_zabbix.host.get.return_value = [{"hostid": "42"}] + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.delete.assert_called_once_with(42) + + # ------------------------------------------------------------------ + # Scenario 7: Active device, Zabbix host is disabled → re-enable via consistency check + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_active_device_disabled_in_zabbix_is_enabled( + self, mock_api, mock_zabbix_api + ): + """Active device whose Zabbix host is disabled should be re-enabled by consistency check.""" + device = MockNetboxDevice( + name="test-device", status_label="Active", zabbix_hostid=42 + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + # Zabbix host currently disabled; device is Active → status out-of-sync + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1") + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.update.assert_called_once_with(hostid=42, status="0") + + # ------------------------------------------------------------------ + # Scenario 8: Failed device, Zabbix host is enabled → disable via consistency check + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_failed_device_enabled_in_zabbix_is_disabled( + self, mock_api, mock_zabbix_api + ): + """Failed device whose Zabbix host is enabled should be disabled by consistency check.""" + device = MockNetboxDevice( + name="test-device", status_label="Failed", zabbix_hostid=42 + ) + self._setup_netbox_mock(mock_api, devices=[device]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + # Zabbix host currently enabled; device is Failed → status out-of-sync + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0") + + syncer = Sync() + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1") From 02a5617bc8f75c339e421f74f00c7bd71d0589bc Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 19 Feb 2026 12:08:01 +0000 Subject: [PATCH 30/57] Fixed some hostgroup tests and added 4 new tests --- tests/test_hostgroups.py | 117 +++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/tests/test_hostgroups.py b/tests/test_hostgroups.py index 4bab4aa..05e1ad1 100644 --- a/tests/test_hostgroups.py +++ b/tests/test_hostgroups.py @@ -78,8 +78,12 @@ class TestHostgroups(unittest.TestCase): location.__str__.return_value = "TestLocation" self.mock_device.location = location - # Custom fields - self.mock_device.custom_fields = {"test_cf": "TestCF"} + rack = MagicMock() + rack.name = "TestRack" + self.mock_device.rack = rack + + # Custom fields — empty_cf is intentionally None to test the empty CF path + self.mock_device.custom_fields = {"test_cf": "TestCF", "empty_cf": None} # *** Mock NetBox VM setup *** # Create mock VM with all properties @@ -137,6 +141,7 @@ class TestHostgroups(unittest.TestCase): self.assertEqual(hostgroup.format_options["platform"], "TestPlatform") self.assertEqual(hostgroup.format_options["manufacturer"], "TestManufacturer") self.assertEqual(hostgroup.format_options["location"], "TestLocation") + self.assertEqual(hostgroup.format_options["rack"], "TestRack") def test_vm_hostgroup_creation(self): """Test basic VM hostgroup creation.""" @@ -192,16 +197,40 @@ class TestHostgroups(unittest.TestCase): self.assertEqual(complex_result, "TestCluster/TestClusterType/TestPlatform") def test_device_netbox_version_differences(self): - """Test hostgroup generation with different NetBox versions.""" - # NetBox v2.x - hostgroup_v2 = Hostgroup("dev", self.mock_device, "2.11", self.mock_logger) - self.assertEqual(hostgroup_v2.format_options["role"], "TestRole") + """Test hostgroup generation with different NetBox versions. - # NetBox v3.x - hostgroup_v3 = Hostgroup("dev", self.mock_device, "3.5", self.mock_logger) - self.assertEqual(hostgroup_v3.format_options["role"], "TestRole") + device_role (v2/v3) and role (v4+) are set to different values so the + test can verify that the correct attribute is read for each version. + """ + # Build a device with deliberately different names on each role attribute + versioned_device = MagicMock() + versioned_device.name = "versioned-device" + versioned_device.site = self.mock_device.site + versioned_device.tenant = self.mock_device.tenant + versioned_device.platform = self.mock_device.platform + versioned_device.location = self.mock_device.location + versioned_device.rack = self.mock_device.rack + versioned_device.device_type = self.mock_device.device_type + versioned_device.custom_fields = self.mock_device.custom_fields - # NetBox v4.x (already tested in other methods) + old_role = MagicMock() + old_role.name = "OldRole" + new_role = MagicMock() + new_role.name = "NewRole" + versioned_device.device_role = old_role # read by NetBox v2 / v3 code path + versioned_device.role = new_role # read by NetBox v4+ code path + + # v2 must use device_role + hostgroup_v2 = Hostgroup("dev", versioned_device, "2.11", self.mock_logger) + self.assertEqual(hostgroup_v2.format_options["role"], "OldRole") + + # v3 must also use device_role + hostgroup_v3 = Hostgroup("dev", versioned_device, "3.5", self.mock_logger) + self.assertEqual(hostgroup_v3.format_options["role"], "OldRole") + + # v4+ must use role + hostgroup_v4 = Hostgroup("dev", versioned_device, "4.0", self.mock_logger) + self.assertEqual(hostgroup_v4.format_options["role"], "NewRole") def test_custom_field_lookup(self): """Test custom field lookup functionality.""" @@ -217,6 +246,11 @@ class TestHostgroups(unittest.TestCase): self.assertFalse(cf_result["result"]) self.assertIsNone(cf_result["cf"]) + # Test custom field exists but has no value (None) + cf_result = hostgroup.custom_field_lookup("empty_cf") + self.assertTrue(cf_result["result"]) # key is present + self.assertIsNone(cf_result["cf"]) # value is empty + def test_hostgroup_with_custom_field(self): """Test hostgroup generation including a custom field.""" hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) @@ -361,6 +395,69 @@ class TestHostgroups(unittest.TestCase): self.assertEqual(results["platform/location"], "TestPlatform/TestLocation") self.assertEqual(results["tenant_group/tenant"], "TestTenantGroup/TestTenant") + def test_literal_string_in_format(self): + """Test that quoted literal strings in a format are used verbatim.""" + hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) + + # Single-quoted literal + result = hostgroup.generate("'MyDevices'/role") + self.assertEqual(result, "MyDevices/TestRole") + + # Double-quoted literal + result = hostgroup.generate('"MyDevices"/role') + self.assertEqual(result, "MyDevices/TestRole") + + def test_generate_returns_none_when_all_fields_empty(self): + """Test that generate() returns None when every format field resolves to no value.""" + empty_device = MagicMock() + empty_device.name = "empty-device" + empty_device.site = None + empty_device.tenant = None + empty_device.platform = None + empty_device.role = None + empty_device.location = None + empty_device.rack = None + empty_device.custom_fields = {} + device_type = MagicMock() + manufacturer = MagicMock() + manufacturer.name = "SomeManufacturer" + device_type.manufacturer = manufacturer + empty_device.device_type = device_type + + hostgroup = Hostgroup("dev", empty_device, "4.0", self.mock_logger) + # site, tenant and platform all have no value → hg_output stays empty → None + result = hostgroup.generate("site/tenant/platform") + self.assertIsNone(result) + + def test_vm_without_cluster(self): + """Test that cluster/cluster_type are absent from format_options when VM has no cluster.""" + clusterless_vm = MagicMock() + clusterless_vm.name = "clusterless-vm" + clusterless_vm.site = self.mock_vm.site + clusterless_vm.tenant = self.mock_vm.tenant + clusterless_vm.platform = self.mock_vm.platform + clusterless_vm.role = self.mock_device_role + clusterless_vm.cluster = None + clusterless_vm.custom_fields = {} + + hostgroup = Hostgroup("vm", clusterless_vm, "4.0", self.mock_logger) + + # cluster and cluster_type must not appear in format_options + self.assertNotIn("cluster", hostgroup.format_options) + self.assertNotIn("cluster_type", hostgroup.format_options) + + # Requesting cluster in a format must raise HostgroupError + with self.assertRaises(HostgroupError): + hostgroup.generate("cluster/role") + + def test_empty_custom_field_skipped_in_format(self): + """Test that an empty (None) custom field is silently omitted from the hostgroup name.""" + hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) + + # empty_cf has no value → it is skipped; only site and role appear + result = hostgroup.generate("site/empty_cf/role") + self.assertEqual(result, "TestSite/TestRole") + if __name__ == "__main__": unittest.main() From a4d5fda5e3824d0551a10bafa6b814f0e8006dc9 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 19 Feb 2026 12:38:29 +0000 Subject: [PATCH 31/57] Added VM tests and tag tests --- tests/test_core.py | 389 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_tags.py | 284 +++++++++++++++++++++++++++++++++ 2 files changed, 662 insertions(+), 11 deletions(-) create mode 100644 tests/test_tags.py diff --git a/tests/test_core.py b/tests/test_core.py index 9460564..68c7506 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,7 @@ """Tests for the core sync module.""" import unittest +from typing import ClassVar from unittest.mock import MagicMock, patch from requests.exceptions import ConnectionError as RequestsConnectionError @@ -81,7 +82,8 @@ class MockNetboxDevice: # Setup device type with proper structure if device_type is None: self.device_type = MagicMock() - self.device_type.custom_fields = {"zabbix_template": "TestTemplate"} + self.device_type.custom_fields = { + "zabbix_template": "TestTemplate"} self.device_type.manufacturer = MagicMock() self.device_type.manufacturer.name = "TestManufacturer" self.device_type.display = "Test Device Type" @@ -108,7 +110,11 @@ class MockNetboxDevice: class MockNetboxVM: - """Mock NetBox virtual machine object.""" + """Mock NetBox virtual machine object. + + Mirrors the real NetBox API response structure so the full VirtualMachine + pipeline runs without mocking the class itself. + """ def __init__( self, @@ -119,15 +125,77 @@ class MockNetboxVM: config_context=None, site=None, primary_ip=None, + role=None, + cluster=None, + tenant=None, + platform=None, + tags=None, ): self.id = vm_id self.name = name self.status = MagicMock() self.status.label = status_label + self.status.value = status_label.lower() self.custom_fields = {"zabbix_hostid": zabbix_hostid} - self.config_context = config_context or {} - self.site = site - self.primary_ip = primary_ip + # Default config_context includes a template so the VM is not skipped + self.config_context = ( + config_context + if config_context is not None + else {"zabbix": {"templates": ["TestTemplate"]}} + ) + self.tenant = tenant + self.platform = platform + self.serial = "" + self.description = "" + self.comments = "" + self.vcpus = None + self.memory = None + self.disk = None + self.virtual_chassis = None + self.tags = tags or [] + self.oob_ip = None + + # Setup site + if site is None: + self.site = MagicMock() + self.site.name = "TestSite" + self.site.slug = "testsite" + self.site.region = None + self.site.group = None + else: + self.site = site + + # Setup primary IP + if primary_ip is None: + self.primary_ip = MagicMock() + self.primary_ip.address = "192.168.1.1/24" + else: + self.primary_ip = primary_ip + self.primary_ip4 = self.primary_ip + self.primary_ip6 = None + + # Setup role + if role is None: + mock_role = MagicMock() + mock_role.name = "Switch" + mock_role.slug = "switch" + self.role = mock_role + else: + self.role = role + + # Setup cluster + if cluster is None: + mock_cluster = MagicMock() + mock_cluster.name = "TestCluster" + mock_cluster_type = MagicMock() + mock_cluster_type.name = "TestClusterType" + mock_cluster.type = mock_cluster_type + self.cluster = mock_cluster + else: + self.cluster = cluster + + def save(self): + """Mock save method.""" class TestSyncNetboxConnection(unittest.TestCase): @@ -305,7 +373,8 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -431,7 +500,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "6.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}] + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "host": "proxy1"}] syncer = Sync() syncer.connect( @@ -458,7 +528,8 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "7.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}] + mock_zabbix.proxy.get.return_value = [ + {"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] syncer = Sync() @@ -653,7 +724,8 @@ class TestDeviceHandeling(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -754,7 +826,8 @@ class TestDeviceHandeling(unittest.TestCase): # Verify host was created with the config context template, not the custom field one mock_zabbix.host.create.assert_called_once() create_call_kwargs = mock_zabbix.host.create.call_args.kwargs - self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) + self.assertEqual(create_call_kwargs["templates"], [ + {"templateid": "2"}]) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") @patch("netbox_zabbix_sync.modules.core.nbapi") @@ -802,7 +875,8 @@ class TestDeviceHandeling(unittest.TestCase): # Config context overrides the custom field - only "ContextTemplate" should be used mock_zabbix.host.create.assert_called_once() create_call_kwargs = mock_zabbix.host.create.call_args.kwargs - self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) + self.assertEqual(create_call_kwargs["templates"], [ + {"templateid": "2"}]) # Verify the custom field template was NOT used self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"]) @@ -1122,3 +1196,296 @@ class TestDeviceStatusHandling(unittest.TestCase): syncer.start() mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1") + + +class TestVMStatusHandling(unittest.TestCase): + """ + Mirrors TestDeviceStatusHandling for VirtualMachine objects. + + Validates the VM sync loop in core.py using real VirtualMachine instances + (not mocked) for the same 8 status scenarios. + """ + + # Hostgroup produced by vm_hostgroup_format "site/role" with default MockNetboxVM values. + EXPECTED_HOSTGROUP = "TestSite/Switch" + + def _setup_netbox_mock(self, mock_api, vms=None): + """Helper to setup a working NetBox mock.""" + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "3.5" + mock_netbox.extras.custom_fields.filter.return_value = [] + mock_netbox.dcim.devices.filter.return_value = [] + mock_netbox.virtualization.virtual_machines.filter.return_value = vms or [] + mock_netbox.dcim.site_groups.all.return_value = [] + mock_netbox.dcim.regions.all.return_value = [] + mock_netbox.extras.journal_entries = MagicMock() + return mock_netbox + + def _setup_zabbix_mock(self, mock_zabbix_api, version=7.0): + """Helper to setup a working Zabbix mock.""" + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.version = version + mock_zabbix.hostgroup.get.return_value = [ + {"groupid": "1", "name": self.EXPECTED_HOSTGROUP} + ] + mock_zabbix.hostgroup.create.return_value = {"groupids": ["2"]} + mock_zabbix.template.get.return_value = [ + {"templateid": "1", "name": "TestTemplate"} + ] + mock_zabbix.proxy.get.return_value = [] + mock_zabbix.proxygroup.get.return_value = [] + mock_zabbix.logout = MagicMock() + mock_zabbix.host.get.return_value = [] + mock_zabbix.host.create.return_value = {"hostids": ["1"]} + mock_zabbix.host.update.return_value = {"hostids": ["42"]} + mock_zabbix.host.delete.return_value = [42] + return mock_zabbix + + def _make_zabbix_host(self, hostname="test-vm", status="0"): + """Build a minimal Zabbix host response for consistency_check.""" + return [ + { + "hostid": "42", + "host": hostname, + "name": hostname, + "parentTemplates": [{"templateid": "1"}], + "hostgroups": [{"groupid": "1"}], + "groups": [{"groupid": "1"}], + "status": status, + # Single empty-dict interface: len==1 avoids SyncInventoryError, + # empty keys mean no spurious interface-update calls. + "interfaces": [{}], + "inventory_mode": "-1", + "inventory": {}, + "macros": [], + "tags": [], + "proxy_hostid": "0", + "proxyid": "0", + "proxy_groupid": "0", + } + ] + + # Simple Sync config that enables VM sync with a flat hostgroup format + _SYNC_CFG: ClassVar[dict] = {"sync_vms": True, + "vm_hostgroup_format": "site/role"} + + # ------------------------------------------------------------------ + # Scenario 1: Active VM, not yet in Zabbix → created enabled (status=0) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_active_vm_not_in_zabbix_is_created(self, mock_api, mock_zabbix_api): + """Active VM not yet synced to Zabbix should be created with status enabled (0).""" + vm = MockNetboxVM( + name="test-vm", status_label="Active", zabbix_hostid=None) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_called_once() + create_kwargs = mock_zabbix.host.create.call_args.kwargs + self.assertEqual(create_kwargs["host"], "test-vm") + self.assertEqual(create_kwargs["status"], 0) + + # ------------------------------------------------------------------ + # Scenario 2: Active VM, already in Zabbix → consistency check, no update + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_active_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api): + """Active VM already in Zabbix with matching status should require no updates.""" + vm = MockNetboxVM( + name="test-vm", status_label="Active", zabbix_hostid=42) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0") + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_not_called() + mock_zabbix.host.update.assert_not_called() + + # ------------------------------------------------------------------ + # Scenario 3: Staged VM, not yet in Zabbix → created disabled (status=1) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_staged_vm_not_in_zabbix_is_created_disabled( + self, mock_api, mock_zabbix_api + ): + """Staged VM not yet in Zabbix should be created with status disabled (1).""" + vm = MockNetboxVM( + name="test-vm", status_label="Staged", zabbix_hostid=None) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_called_once() + create_kwargs = mock_zabbix.host.create.call_args.kwargs + self.assertEqual(create_kwargs["status"], 1) + + # ------------------------------------------------------------------ + # Scenario 4: Staged VM, already in Zabbix as disabled → no update needed + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_staged_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api): + """Staged VM already in Zabbix as disabled should pass consistency check with no updates.""" + vm = MockNetboxVM( + name="test-vm", status_label="Staged", zabbix_hostid=42) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1") + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_not_called() + mock_zabbix.host.update.assert_not_called() + + # ------------------------------------------------------------------ + # Scenario 5: Decommissioning VM, not in Zabbix → skipped (no create, no delete) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_decommissioning_vm_not_in_zabbix_is_skipped( + self, mock_api, mock_zabbix_api + ): + """Decommissioning VM with no Zabbix ID should be skipped entirely.""" + vm = MockNetboxVM( + name="test-vm", status_label="Decommissioning", zabbix_hostid=None + ) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.create.assert_not_called() + mock_zabbix.host.delete.assert_not_called() + + # ------------------------------------------------------------------ + # Scenario 6: Decommissioning VM, already in Zabbix → cleanup (host deleted) + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_decommissioning_vm_in_zabbix_is_deleted(self, mock_api, mock_zabbix_api): + """Decommissioning VM with a Zabbix ID should be deleted from Zabbix.""" + vm = MockNetboxVM( + name="test-vm", status_label="Decommissioning", zabbix_hostid=42 + ) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = [{"hostid": "42"}] + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.delete.assert_called_once_with(42) + + # ------------------------------------------------------------------ + # Scenario 7: Active VM, Zabbix host is disabled → re-enable via consistency check + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_active_vm_disabled_in_zabbix_is_enabled(self, mock_api, mock_zabbix_api): + """Active VM whose Zabbix host is disabled should be re-enabled by consistency check.""" + vm = MockNetboxVM( + name="test-vm", status_label="Active", zabbix_hostid=42) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1") + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.update.assert_called_once_with(hostid=42, status="0") + + # ------------------------------------------------------------------ + # Scenario 8: Failed VM, Zabbix host is enabled → disable via consistency check + # ------------------------------------------------------------------ + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_failed_vm_enabled_in_zabbix_is_disabled(self, mock_api, mock_zabbix_api): + """Failed VM whose Zabbix host is enabled should be disabled by consistency check.""" + vm = MockNetboxVM( + name="test-vm", status_label="Failed", zabbix_hostid=42) + self._setup_netbox_mock(mock_api, vms=[vm]) + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0") + + syncer = Sync(self._SYNC_CFG) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + mock_zabbix.host.update.assert_called_once_with(hostid=42, status="1") diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..3e6c416 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,284 @@ +"""Tests for the ZabbixTags class in the tags module.""" + +import unittest +from unittest.mock import MagicMock + +from netbox_zabbix_sync.modules.tags import ZabbixTags + + +class DummyNBForTags: + """Minimal NetBox object that supports field_mapper's dict-style access.""" + + def __init__(self, name="test-host", config_context=None, tags=None, site=None): + self.name = name + self.config_context = config_context or {} + self.tags = tags or [] + # Stored as a plain dict so field_mapper can traverse "site/name" + self.site = site if site is not None else {"name": "TestSite"} + + def __getitem__(self, key): + return getattr(self, key) + + +class TestZabbixTagsInit(unittest.TestCase): + """Tests for ZabbixTags initialisation.""" + + def test_sync_true_when_tag_sync_enabled(self): + """sync flag should be True when tag_sync=True.""" + nb = DummyNBForTags() + tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=MagicMock()) + self.assertTrue(tags.sync) + + def test_sync_false_when_tag_sync_disabled(self): + """sync flag should be False when tag_sync=False (default).""" + nb = DummyNBForTags() + tags = ZabbixTags(nb, tag_map={}, logger=MagicMock()) + self.assertFalse(tags.sync) + + def test_repr_and_str_return_host_name(self): + nb = DummyNBForTags(name="my-host") + tags = ZabbixTags(nb, tag_map={}, host="my-host", logger=MagicMock()) + self.assertEqual(repr(tags), "my-host") + self.assertEqual(str(tags), "my-host") + + +class TestRenderTag(unittest.TestCase): + """Tests for ZabbixTags.render_tag().""" + + def setUp(self): + nb = DummyNBForTags() + self.logger = MagicMock() + self.tags = ZabbixTags( + nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger + ) + + def test_valid_tag_lowercased(self): + """Valid name+value with tag_lower=True should produce lowercase keys.""" + result = self.tags.render_tag("Site", "Production") + self.assertEqual(result, {"tag": "site", "value": "production"}) + + def test_valid_tag_not_lowercased(self): + """tag_lower=False should preserve original case.""" + nb = DummyNBForTags() + tags = ZabbixTags( + nb, tag_map={}, tag_sync=True, tag_lower=False, logger=self.logger + ) + result = tags.render_tag("Site", "Production") + self.assertEqual(result, {"tag": "Site", "value": "Production"}) + + def test_invalid_name_none_returns_false(self): + """None as tag name should return False.""" + result = self.tags.render_tag(None, "somevalue") + self.assertFalse(result) + + def test_invalid_name_too_long_returns_false(self): + """Name exceeding 256 characters should return False.""" + long_name = "x" * 257 + result = self.tags.render_tag(long_name, "somevalue") + self.assertFalse(result) + + def test_invalid_value_none_returns_false(self): + """None as tag value should return False.""" + result = self.tags.render_tag("site", None) + self.assertFalse(result) + + def test_invalid_value_empty_string_returns_false(self): + """Empty string as tag value should return False.""" + result = self.tags.render_tag("site", "") + self.assertFalse(result) + + def test_invalid_value_too_long_returns_false(self): + """Value exceeding 256 characters should return False.""" + long_value = "x" * 257 + result = self.tags.render_tag("site", long_value) + self.assertFalse(result) + + +class TestGenerateFromTagMap(unittest.TestCase): + """Tests for the field_mapper-driven tag generation path.""" + + def setUp(self): + self.logger = MagicMock() + + def test_generate_tag_from_field_map(self): + """Tags derived from tag_map fields are lowercased and returned correctly.""" + nb = DummyNBForTags(name="router01") + # "site/name" → nb["site"]["name"] → "TestSite", mapped to tag name "site" + tag_map = {"site/name": "site"} + tags = ZabbixTags( + nb, + tag_map=tag_map, + tag_sync=True, + tag_lower=True, + logger=self.logger, + ) + result = tags.generate() + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["tag"], "site") + self.assertEqual(result[0]["value"], "testsite") + + def test_generate_empty_field_map_produces_no_tags(self): + """An empty tag_map with no context or NB tags should return an empty list.""" + nb = DummyNBForTags() + tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger) + result = tags.generate() + self.assertEqual(result, []) + + def test_generate_deduplicates_tags(self): + """Duplicate tags produced by the map should be deduplicated.""" + # Two map entries that resolve to the same tag/value pair + nb = DummyNBForTags(name="router01") + tag_map = {"site/name": "site", "site/name": "site"} # noqa: F601 + tags = ZabbixTags( + nb, + tag_map=tag_map, + tag_sync=True, + tag_lower=True, + logger=self.logger, + ) + result = tags.generate() + self.assertEqual(len(result), 1) + + +class TestGenerateFromConfigContext(unittest.TestCase): + """Tests for the config_context-driven tag generation path.""" + + def setUp(self): + self.logger = MagicMock() + + def test_generates_tags_from_config_context(self): + """Tags listed in config_context['zabbix']['tags'] are added correctly.""" + nb = DummyNBForTags( + config_context={ + "zabbix": { + "tags": [ + {"environment": "production"}, + {"location": "DC1"}, + ] + } + } + ) + tags = ZabbixTags( + nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger + ) + result = tags.generate() + self.assertEqual(len(result), 2) + tag_names = [t["tag"] for t in result] + self.assertIn("environment", tag_names) + self.assertIn("location", tag_names) + + def test_skips_config_context_tags_with_invalid_values(self): + """Config context tags with None value should be silently dropped.""" + nb = DummyNBForTags( + config_context={ + "zabbix": { + "tags": [ + {"environment": None}, # invalid value + {"location": "DC1"}, + ] + } + } + ) + tags = ZabbixTags( + nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger + ) + result = tags.generate() + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["tag"], "location") + + def test_ignores_zabbix_tags_key_missing(self): + """Missing 'tags' key inside config_context['zabbix'] produces no tags.""" + nb = DummyNBForTags(config_context={"zabbix": {"templates": ["T1"]}}) + tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger) + result = tags.generate() + self.assertEqual(result, []) + + def test_ignores_config_context_tags_not_a_list(self): + """Non-list value for config_context['zabbix']['tags'] produces no tags.""" + nb = DummyNBForTags(config_context={"zabbix": {"tags": "not-a-list"}}) + tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger) + result = tags.generate() + self.assertEqual(result, []) + + +class TestGenerateFromNetboxTags(unittest.TestCase): + """Tests for the NetBox device tags forwarding path.""" + + def setUp(self): + self.logger = MagicMock() + # Simulate a list of NetBox tag objects (as dicts, matching real API shape) + self.nb_tags = [ + {"name": "ping", "slug": "ping", "display": "ping"}, + {"name": "snmp", "slug": "snmp", "display": "snmp"}, + ] + + def test_generates_tags_from_netbox_tags_using_name(self): + """NetBox device tags are forwarded using tag_name label and tag_value='name'.""" + nb = DummyNBForTags(tags=self.nb_tags) + tags = ZabbixTags( + nb, + tag_map={}, + tag_sync=True, + tag_lower=True, + tag_name="NetBox", + tag_value="name", + logger=self.logger, + ) + result = tags.generate() + self.assertEqual(len(result), 2) + for t in result: + self.assertEqual(t["tag"], "netbox") + values = {t["value"] for t in result} + self.assertIn("ping", values) + self.assertIn("snmp", values) + + def test_generates_tags_from_netbox_tags_using_slug(self): + """tag_value='slug' should use the slug field from each NetBox tag.""" + nb = DummyNBForTags(tags=self.nb_tags) + tags = ZabbixTags( + nb, + tag_map={}, + tag_sync=True, + tag_lower=False, + tag_name="NetBox", + tag_value="slug", + logger=self.logger, + ) + result = tags.generate() + values = {t["value"] for t in result} + self.assertIn("ping", values) + self.assertIn("snmp", values) + + def test_generates_tags_from_netbox_tags_default_value_field(self): + """When tag_value is not a recognised field name, falls back to 'name'.""" + nb = DummyNBForTags(tags=self.nb_tags) + tags = ZabbixTags( + nb, + tag_map={}, + tag_sync=True, + tag_lower=True, + tag_name="NetBox", + tag_value="invalid_field", # not display/name/slug → fall back to "name" + logger=self.logger, + ) + result = tags.generate() + values = {t["value"] for t in result} + self.assertIn("ping", values) + + def test_skips_netbox_tags_when_tag_name_not_set(self): + """NetBox tag forwarding is skipped when tag_name is not configured.""" + nb = DummyNBForTags(tags=self.nb_tags) + tags = ZabbixTags( + nb, + tag_map={}, + tag_sync=True, + tag_lower=True, + tag_name=None, + logger=self.logger, + ) + result = tags.generate() + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main() From 3be3cdc8ef8f9db7fb98cb350ea901fdbbd67f37 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 19 Feb 2026 12:40:50 +0000 Subject: [PATCH 32/57] Fixed Ruff linting --- tests/test_core.py | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 68c7506..a495c58 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -82,8 +82,7 @@ class MockNetboxDevice: # Setup device type with proper structure if device_type is None: self.device_type = MagicMock() - self.device_type.custom_fields = { - "zabbix_template": "TestTemplate"} + self.device_type.custom_fields = {"zabbix_template": "TestTemplate"} self.device_type.manufacturer = MagicMock() self.device_type.manufacturer.name = "TestManufacturer" self.device_type.display = "Test Device Type" @@ -373,8 +372,7 @@ class TestSyncDeviceProcessing(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [ - {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -500,8 +498,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "6.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [ - {"proxyid": "1", "host": "proxy1"}] + mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "host": "proxy1"}] syncer = Sync() syncer.connect( @@ -528,8 +525,7 @@ class TestSyncZabbixVersionHandling(unittest.TestCase): mock_zabbix.version = "7.0" mock_zabbix.hostgroup.get.return_value = [] mock_zabbix.template.get.return_value = [] - mock_zabbix.proxy.get.return_value = [ - {"proxyid": "1", "name": "proxy1"}] + mock_zabbix.proxy.get.return_value = [{"proxyid": "1", "name": "proxy1"}] mock_zabbix.proxygroup.get.return_value = [] syncer = Sync() @@ -724,8 +720,7 @@ class TestDeviceHandeling(unittest.TestCase): mock_zabbix = MagicMock() mock_zabbix_api.return_value = mock_zabbix mock_zabbix.version = version - mock_zabbix.hostgroup.get.return_value = [ - {"groupid": "1", "name": "TestGroup"}] + mock_zabbix.hostgroup.get.return_value = [{"groupid": "1", "name": "TestGroup"}] mock_zabbix.template.get.return_value = [ {"templateid": "1", "name": "TestTemplate"} ] @@ -826,8 +821,7 @@ class TestDeviceHandeling(unittest.TestCase): # Verify host was created with the config context template, not the custom field one mock_zabbix.host.create.assert_called_once() create_call_kwargs = mock_zabbix.host.create.call_args.kwargs - self.assertEqual(create_call_kwargs["templates"], [ - {"templateid": "2"}]) + self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") @patch("netbox_zabbix_sync.modules.core.nbapi") @@ -875,8 +869,7 @@ class TestDeviceHandeling(unittest.TestCase): # Config context overrides the custom field - only "ContextTemplate" should be used mock_zabbix.host.create.assert_called_once() create_call_kwargs = mock_zabbix.host.create.call_args.kwargs - self.assertEqual(create_call_kwargs["templates"], [ - {"templateid": "2"}]) + self.assertEqual(create_call_kwargs["templates"], [{"templateid": "2"}]) # Verify the custom field template was NOT used self.assertNotIn({"templateid": "1"}, create_call_kwargs["templates"]) @@ -1268,8 +1261,7 @@ class TestVMStatusHandling(unittest.TestCase): ] # Simple Sync config that enables VM sync with a flat hostgroup format - _SYNC_CFG: ClassVar[dict] = {"sync_vms": True, - "vm_hostgroup_format": "site/role"} + _SYNC_CFG: ClassVar[dict] = {"sync_vms": True, "vm_hostgroup_format": "site/role"} # ------------------------------------------------------------------ # Scenario 1: Active VM, not yet in Zabbix → created enabled (status=0) @@ -1278,8 +1270,7 @@ class TestVMStatusHandling(unittest.TestCase): @patch("netbox_zabbix_sync.modules.core.nbapi") def test_active_vm_not_in_zabbix_is_created(self, mock_api, mock_zabbix_api): """Active VM not yet synced to Zabbix should be created with status enabled (0).""" - vm = MockNetboxVM( - name="test-vm", status_label="Active", zabbix_hostid=None) + vm = MockNetboxVM(name="test-vm", status_label="Active", zabbix_hostid=None) self._setup_netbox_mock(mock_api, vms=[vm]) mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) @@ -1306,8 +1297,7 @@ class TestVMStatusHandling(unittest.TestCase): @patch("netbox_zabbix_sync.modules.core.nbapi") def test_active_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api): """Active VM already in Zabbix with matching status should require no updates.""" - vm = MockNetboxVM( - name="test-vm", status_label="Active", zabbix_hostid=42) + vm = MockNetboxVM(name="test-vm", status_label="Active", zabbix_hostid=42) self._setup_netbox_mock(mock_api, vms=[vm]) mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0") @@ -1335,8 +1325,7 @@ class TestVMStatusHandling(unittest.TestCase): self, mock_api, mock_zabbix_api ): """Staged VM not yet in Zabbix should be created with status disabled (1).""" - vm = MockNetboxVM( - name="test-vm", status_label="Staged", zabbix_hostid=None) + vm = MockNetboxVM(name="test-vm", status_label="Staged", zabbix_hostid=None) self._setup_netbox_mock(mock_api, vms=[vm]) mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) @@ -1362,8 +1351,7 @@ class TestVMStatusHandling(unittest.TestCase): @patch("netbox_zabbix_sync.modules.core.nbapi") def test_staged_vm_in_zabbix_is_consistent(self, mock_api, mock_zabbix_api): """Staged VM already in Zabbix as disabled should pass consistency check with no updates.""" - vm = MockNetboxVM( - name="test-vm", status_label="Staged", zabbix_hostid=42) + vm = MockNetboxVM(name="test-vm", status_label="Staged", zabbix_hostid=42) self._setup_netbox_mock(mock_api, vms=[vm]) mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1") @@ -1445,8 +1433,7 @@ class TestVMStatusHandling(unittest.TestCase): @patch("netbox_zabbix_sync.modules.core.nbapi") def test_active_vm_disabled_in_zabbix_is_enabled(self, mock_api, mock_zabbix_api): """Active VM whose Zabbix host is disabled should be re-enabled by consistency check.""" - vm = MockNetboxVM( - name="test-vm", status_label="Active", zabbix_hostid=42) + vm = MockNetboxVM(name="test-vm", status_label="Active", zabbix_hostid=42) self._setup_netbox_mock(mock_api, vms=[vm]) mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) mock_zabbix.host.get.return_value = self._make_zabbix_host(status="1") @@ -1471,8 +1458,7 @@ class TestVMStatusHandling(unittest.TestCase): @patch("netbox_zabbix_sync.modules.core.nbapi") def test_failed_vm_enabled_in_zabbix_is_disabled(self, mock_api, mock_zabbix_api): """Failed VM whose Zabbix host is enabled should be disabled by consistency check.""" - vm = MockNetboxVM( - name="test-vm", status_label="Failed", zabbix_hostid=42) + vm = MockNetboxVM(name="test-vm", status_label="Failed", zabbix_hostid=42) self._setup_netbox_mock(mock_api, vms=[vm]) mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) mock_zabbix.host.get.return_value = self._make_zabbix_host(status="0") From f7d09893203c6cd7a83f3a197c390b2e86d0ccf8 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 19 Feb 2026 13:04:32 +0000 Subject: [PATCH 33/57] Added check for when a non-primary cluster member is synced --- tests/test_core.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index a495c58..0618ee6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -783,6 +783,47 @@ class TestDeviceHandeling(unittest.TestCase): # The host should be created with the virtual chassis name, not the device name self.assertEqual(create_call_kwargs["host"], "SW01") + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_cluster_where_device_is_not_primary(self, mock_api, mock_zabbix_api): + """Test that a non-primary cluster member is skipped and not created in Zabbix.""" + # vc_master.id (2) differs from device.id (1) → device is secondary + vc_master = MagicMock() + vc_master.id = 2 # Different from device ID → device is NOT primary + + virtual_chassis = MagicMock() + virtual_chassis.master = vc_master + virtual_chassis.name = "SW01" + + device = MockNetboxDevice( + device_id=1, + name="SW01N1", + virtual_chassis=virtual_chassis, + ) + + mock_netbox = self._setup_netbox_mock(mock_api) + mock_netbox.dcim.devices.filter.return_value = [device] + + mock_site = MagicMock() + mock_site.name = "TestSite" + device.site = mock_site + + mock_zabbix = self._setup_zabbix_mock(mock_zabbix_api) + + syncer = Sync({"clustering": True}) + syncer.connect( + "http://netbox.local", + "nb_token", + "http://zabbix.local", + "user", + "pass", + None, + ) + syncer.start() + + # Secondary cluster member must be skipped — no host should be created + mock_zabbix.host.create.assert_not_called() + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") @patch("netbox_zabbix_sync.modules.core.nbapi") def test_templates_from_config_context(self, mock_api, mock_zabbix_api): From d5e3199e92b73470e4c97be3893443808d72bbab Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Thu, 19 Feb 2026 16:17:58 +0000 Subject: [PATCH 34/57] Implemented central configuration and config path that is configurable as path. Updated tests to use self.config instead on re-initializing config. --- netbox_zabbix_sync/modules/cli.py | 124 +++++++- netbox_zabbix_sync/modules/core.py | 2 + netbox_zabbix_sync/modules/device.py | 84 +++--- netbox_zabbix_sync/modules/settings.py | 10 +- netbox_zabbix_sync/modules/virtual_machine.py | 10 +- tests/test_device_deletion.py | 40 ++- tests/test_physical_device.py | 277 ++++++++---------- tests/test_usermacros.py | 41 ++- 8 files changed, 342 insertions(+), 246 deletions(-) diff --git a/netbox_zabbix_sync/modules/cli.py b/netbox_zabbix_sync/modules/cli.py index 016e28a..4b8110d 100644 --- a/netbox_zabbix_sync/modules/cli.py +++ b/netbox_zabbix_sync/modules/cli.py @@ -5,15 +5,86 @@ from os import environ from netbox_zabbix_sync.modules.core import Sync from netbox_zabbix_sync.modules.exceptions import EnvironmentVarError from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels, setup_logger +from netbox_zabbix_sync.modules.settings import load_config # Set logging setup_logger() logger = get_logger() +# Boolean settings that can be toggled via --flag / --no-flag +_BOOL_ARGS = [ + ("clustering", "Enable clustering of devices with virtual chassis setup."), + ("create_hostgroups", "Enable hostgroup generation (requires Zabbix permissions)."), + ("create_journal", "Create NetBox journal entries on changes."), + ("sync_vms", "Enable virtual machine sync."), + ( + "full_proxy_sync", + "Enable full proxy sync (removes proxies not in config context).", + ), + ( + "templates_config_context", + "Use config context as the template source instead of a custom field.", + ), + ( + "templates_config_context_overrule", + "Give config context templates higher priority than custom field templates.", + ), + ("traverse_regions", "Use the full parent-region path in hostgroup names."), + ("traverse_site_groups", "Use the full parent-site-group path in hostgroup names."), + ( + "extended_site_properties", + "Fetch additional site info from NetBox (increases API queries).", + ), + ("inventory_sync", "Sync NetBox device properties to Zabbix inventory."), + ("usermacro_sync", "Sync usermacros from NetBox to Zabbix."), + ("tag_sync", "Sync host tags to Zabbix."), + ("tag_lower", "Lowercase tag names and values before syncing."), +] + +# String settings that can be set via --option VALUE +_STR_ARGS = [ + ("template_cf", "NetBox custom field name for the Zabbix template.", "FIELD"), + ("device_cf", "NetBox custom field name for the Zabbix host ID.", "FIELD"), + ( + "hostgroup_format", + "Hostgroup path pattern for physical devices (e.g. site/manufacturer/role).", + "PATTERN", + ), + ( + "vm_hostgroup_format", + "Hostgroup path pattern for virtual machines (e.g. cluster_type/cluster/role).", + "PATTERN", + ), + ( + "inventory_mode", + "Zabbix inventory mode: disabled, manual, or automatic.", + "MODE", + ), + ("tag_name", "Zabbix tag name used when syncing NetBox tags.", "NAME"), + ( + "tag_value", + "NetBox tag property to use as the Zabbix tag value (name, slug, or display).", + "PROPERTY", + ), +] + + +def _apply_cli_overrides(config: dict, arguments: argparse.Namespace) -> dict: + """Override loaded config with any values explicitly provided on the CLI.""" + for key, _help in _BOOL_ARGS: + cli_val = getattr(arguments, key, None) + if cli_val is not None: + config[key] = cli_val + for key, _help, _meta in _STR_ARGS: + cli_val = getattr(arguments, key, None) + if cli_val is not None: + config[key] = cli_val + return config + def main(arguments): """Run the sync process.""" - # set environment variables + # Set log levels based on verbosity flags if arguments.verbose: set_log_levels(logging.WARNING, logging.INFO) if arguments.debug: @@ -48,8 +119,12 @@ def main(arguments): netbox_host = environ.get("NETBOX_HOST") netbox_token = environ.get("NETBOX_TOKEN") + # Load config (defaults → config.py → env vars), then apply CLI overrides + config = load_config(config_file=arguments.config) + config = _apply_cli_overrides(config, arguments) + # Run main sync process - syncer = Sync() + syncer = Sync(config=config) syncer.connect( nb_host=netbox_host, nb_token=netbox_token, @@ -63,8 +138,10 @@ def main(arguments): def parse_cli(): parser = argparse.ArgumentParser( - description="A script to sync Zabbix with NetBox device data." + description="Synchronise NetBox device data to Zabbix." ) + + # ── Verbosity ────────────────────────────────────────────────────────────── parser.add_argument( "-v", "--verbose", help="Turn on verbose logging.", action="store_true" ) @@ -78,5 +155,46 @@ def parse_cli(): action="store_true", ) parser.add_argument("-q", "--quiet", help="Turn off warnings.", action="store_true") + parser.add_argument( + "-c", + "--config", + help="Path to the config file (default: config.py next to the script or in the current directory).", + metavar="FILE", + default=None, + ) + + # ── Boolean config overrides ─────────────────────────────────────────────── + bool_group = parser.add_argument_group( + "config overrides (boolean)", + "Override boolean settings from config.py. " + "Use --flag to enable or --no-flag to disable. " + "When omitted, the value from config.py (or the built-in default) is used.", + ) + for key, help_text in _BOOL_ARGS: + flag = key.replace("_", "-") + bool_group.add_argument( + f"--{flag}", + dest=key, + help=help_text, + action=argparse.BooleanOptionalAction, + default=None, + ) + + # ── String config overrides ──────────────────────────────────────────────── + str_group = parser.add_argument_group( + "config overrides (string)", + "Override string settings from config.py. " + "When omitted, the value from config.py (or the built-in default) is used.", + ) + for key, help_text, metavar in _STR_ARGS: + flag = key.replace("_", "-") + str_group.add_argument( + f"--{flag}", + dest=key, + help=help_text, + metavar=metavar, + default=None, + ) + args = parser.parse_args() main(args) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 05ff624..bd95f99 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -203,6 +203,7 @@ class Sync: self.nb_version, self.config["create_journal"], logger, + config=self.config, ) logger.debug("Host %s: Started operations on VM.", vm.name) vm.set_vm_template() @@ -276,6 +277,7 @@ class Sync: self.nb_version, self.config["create_journal"], logger, + config=self.config, ) logger.debug("Host %s: Started operations on device.", device.name) device.set_template( diff --git a/netbox_zabbix_sync/modules/device.py b/netbox_zabbix_sync/modules/device.py index c3e7dd8..7d95172 100644 --- a/netbox_zabbix_sync/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -29,8 +29,6 @@ from netbox_zabbix_sync.modules.tools import ( ) from netbox_zabbix_sync.modules.usermacros import ZabbixUsermacros -config = load_config() - class PhysicalDevice: """ @@ -39,8 +37,16 @@ class PhysicalDevice: """ def __init__( - self, nb, zabbix, nb_journal_class, nb_version, journal=None, logger=None + self, + nb, + zabbix, + nb_journal_class, + nb_version, + journal=None, + logger=None, + config=None, ): + self.config = config if config is not None else load_config() self.nb = nb self.id = nb.id self.name = nb.name @@ -76,15 +82,15 @@ class PhysicalDevice: def _inventory_map(self): """Use device inventory maps""" - return config["device_inventory_map"] + return self.config["device_inventory_map"] def _usermacro_map(self): """Use device inventory maps""" - return config["device_usermacro_map"] + return self.config["device_usermacro_map"] def _tag_map(self): """Use device host tag maps""" - return config["device_tag_map"] + return self.config["device_tag_map"] def _set_basics(self): """ @@ -100,10 +106,10 @@ class PhysicalDevice: raise SyncInventoryError(e) # Check if device has custom field for ZBX ID - if config["device_cf"] in self.nb.custom_fields: - self.zabbix_id = self.nb.custom_fields[config["device_cf"]] + if self.config["device_cf"] in self.nb.custom_fields: + self.zabbix_id = self.nb.custom_fields[self.config["device_cf"]] else: - e = f"Host {self.name}: Custom field {config['device_cf']} not present" + e = f"Host {self.name}: Custom field {self.config['device_cf']} not present" self.logger.error(e) raise SyncInventoryError(e) @@ -134,8 +140,8 @@ class PhysicalDevice: self.nb, self.nb_api_version, logger=self.logger, - nested_sitegroup_flag=config["traverse_site_groups"], - nested_region_flag=config["traverse_regions"], + nested_sitegroup_flag=self.config["traverse_site_groups"], + nested_region_flag=self.config["traverse_regions"], nb_groups=nb_site_groups, nb_regions=nb_regions, ) @@ -182,12 +188,12 @@ class PhysicalDevice: # Get Zabbix templates from the device type device_type_cfs = self.nb.device_type.custom_fields # Check if the ZBX Template CF is present - if config["template_cf"] in device_type_cfs: + if self.config["template_cf"] in device_type_cfs: # Set value to template - return [device_type_cfs[config["template_cf"]]] + return [device_type_cfs[self.config["template_cf"]]] # Custom field not found, return error e = ( - f"Custom field {config['template_cf']} not " + f"Custom field {self.config['template_cf']} not " f"found for {self.nb.device_type.manufacturer.name}" f" - {self.nb.device_type.display}." ) @@ -216,27 +222,27 @@ class PhysicalDevice: def set_inventory(self, nbdevice): """Set host inventory""" # Set inventory mode. Default is disabled (see class init function). - if config["inventory_mode"] == "disabled": - if config["inventory_sync"]: + if self.config["inventory_mode"] == "disabled": + if self.config["inventory_sync"]: self.logger.error( "Host %s: Unable to map NetBox inventory to Zabbix." "Inventory sync is enabled in config but inventory mode is disabled", self.name, ) return True - if config["inventory_mode"] == "manual": + if self.config["inventory_mode"] == "manual": self.inventory_mode = 0 - elif config["inventory_mode"] == "automatic": + elif self.config["inventory_mode"] == "automatic": self.inventory_mode = 1 else: self.logger.error( "Host %s: Specified value for inventory mode in config is not valid. Got value %s", self.name, - config["inventory_mode"], + self.config["inventory_mode"], ) return False self.inventory = {} - if config["inventory_sync"] and self.inventory_mode in [0, 1]: + if self.config["inventory_sync"] and self.inventory_mode in [0, 1]: self.logger.debug("Host %s: Starting inventory mapper.", self.name) self.inventory = field_mapper( self.name, self._inventory_map(), nbdevice, self.logger @@ -382,7 +388,7 @@ class PhysicalDevice: def _zeroize_cf(self): """Sets the hostID custom field in NetBox to zero, effectively destroying the link""" - self.nb.custom_fields[config["device_cf"]] = None + self.nb.custom_fields[self.config["device_cf"]] = None self.nb.save() def _zabbix_hostname_exists(self): @@ -427,7 +433,7 @@ class PhysicalDevice: macros = ZabbixUsermacros( self.nb, self._usermacro_map(), - config["usermacro_sync"], + self.config["usermacro_sync"], logger=self.logger, host=self.name, ) @@ -445,14 +451,14 @@ class PhysicalDevice: tags = ZabbixTags( self.nb, self._tag_map(), - tag_sync=config["tag_sync"], - tag_lower=config["tag_lower"], - tag_name=config["tag_name"], - tag_value=config["tag_value"], + tag_sync=self.config["tag_sync"], + tag_lower=self.config["tag_lower"], + tag_name=self.config["tag_name"], + tag_value=self.config["tag_value"], logger=self.logger, host=self.name, ) - if config["tag_sync"] is False: + if self.config["tag_sync"] is False: self.tags = [] return False self.tags = tags.generate() @@ -482,20 +488,20 @@ class PhysicalDevice: for proxy_type in proxy_types: # Check if we should use custom fields for proxy config field_config = "proxy_cf" if proxy_type == "proxy" else "proxy_group_cf" - if config[field_config]: + if self.config[field_config]: if ( - config[field_config] in self.nb.custom_fields - and self.nb.custom_fields[config[field_config]] + self.config[field_config] in self.nb.custom_fields + and self.nb.custom_fields[self.config[field_config]] ): proxy_name = cf_to_string( - self.nb.custom_fields[config[field_config]] + self.nb.custom_fields[self.config[field_config]] ) elif ( - config[field_config] in self.nb.site.custom_fields - and self.nb.site.custom_fields[config[field_config]] + self.config[field_config] in self.nb.site.custom_fields + and self.nb.site.custom_fields[self.config[field_config]] ): proxy_name = cf_to_string( - self.nb.site.custom_fields[config[field_config]] + self.nb.site.custom_fields[self.config[field_config]] ) # Otherwise check if the proxy is configured in NetBox CC @@ -586,7 +592,7 @@ class PhysicalDevice: self.logger.error(msg) raise SyncExternalError(msg) from e # Set NetBox custom field to hostID value. - self.nb.custom_fields[config["device_cf"]] = int(self.zabbix_id) + self.nb.custom_fields[self.config["device_cf"]] = int(self.zabbix_id) self.nb.save() msg = f"Host {self.name}: Created host in Zabbix. (ID:{self.zabbix_id})" self.logger.info(msg) @@ -828,7 +834,7 @@ class PhysicalDevice: else: self.logger.info("Host %s: inventory_mode OUT of sync.", self.name) self.update_zabbix_host(inventory_mode=str(self.inventory_mode)) - if config["inventory_sync"] and self.inventory_mode in [0, 1]: + if self.config["inventory_sync"] and self.inventory_mode in [0, 1]: # Check host inventory mapping if host["inventory"] == self.inventory: self.logger.debug("Host %s: Inventory in-sync.", self.name) @@ -837,12 +843,12 @@ class PhysicalDevice: self.update_zabbix_host(inventory=self.inventory) # Check host usermacros - if config["usermacro_sync"]: + if self.config["usermacro_sync"]: # Make a full copy synce we dont want to lose the original value # of secret type macros from Netbox netbox_macros = deepcopy(self.usermacros) # Set the sync bit - full_sync_bit = bool(str(config["usermacro_sync"]).lower() == "full") + full_sync_bit = bool(str(self.config["usermacro_sync"]).lower() == "full") for macro in netbox_macros: # If the Macro is a secret and full sync is NOT activated if macro["type"] == str(1) and not full_sync_bit: @@ -865,7 +871,7 @@ class PhysicalDevice: self.update_zabbix_host(macros=self.usermacros) # Check host tags - if config["tag_sync"]: + if self.config["tag_sync"]: if remove_duplicates( host["tags"], lambda tag: f"{tag['tag']}{tag['value']}" ) == remove_duplicates( diff --git a/netbox_zabbix_sync/modules/settings.py b/netbox_zabbix_sync/modules/settings.py index e5509c6..1d07380 100644 --- a/netbox_zabbix_sync/modules/settings.py +++ b/netbox_zabbix_sync/modules/settings.py @@ -85,10 +85,14 @@ DEFAULT_CONFIG = { } -def load_config(): +def load_config(config_file=None): """Returns combined config from all sources""" - # Overwrite default config with config.py - conf = load_config_file(config_default=DEFAULT_CONFIG) + # Overwrite default config with config file. + # Default config file is config.py but can be overridden by providing a different file path. + conf = load_config_file( + config_default=DEFAULT_CONFIG, + config_file=config_file if config_file else "config.py", + ) # Overwrite default config and config.py with environment variables for key in conf: value_setting = load_env_variable(key) diff --git a/netbox_zabbix_sync/modules/virtual_machine.py b/netbox_zabbix_sync/modules/virtual_machine.py index a88865c..a9f57bf 100644 --- a/netbox_zabbix_sync/modules/virtual_machine.py +++ b/netbox_zabbix_sync/modules/virtual_machine.py @@ -7,10 +7,6 @@ from netbox_zabbix_sync.modules.exceptions import ( TemplateError, ) from netbox_zabbix_sync.modules.interface import ZabbixInterface -from netbox_zabbix_sync.modules.settings import load_config - -# Load config -config = load_config() class VirtualMachine(PhysicalDevice): @@ -24,15 +20,15 @@ class VirtualMachine(PhysicalDevice): def _inventory_map(self): """use VM inventory maps""" - return config["vm_inventory_map"] + return self.config["vm_inventory_map"] def _usermacro_map(self): """use VM usermacro maps""" - return config["vm_usermacro_map"] + return self.config["vm_usermacro_map"] def _tag_map(self): """use VM tag maps""" - return config["vm_tag_map"] + return self.config["vm_tag_map"] def set_vm_template(self): """Set Template for VMs. Overwrites default class diff --git a/tests/test_device_deletion.py b/tests/test_device_deletion.py index 0d78f36..2ee154f 100644 --- a/tests/test_device_deletion.py +++ b/tests/test_device_deletion.py @@ -41,17 +41,15 @@ class TestDeviceDeletion(unittest.TestCase): self.mock_logger = MagicMock() # Create PhysicalDevice instance with mocks - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - self.device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - journal=True, - logger=self.mock_logger, - ) + self.device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + journal=True, + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) def test_cleanup_successful_deletion(self): """Test successful device deletion from Zabbix.""" @@ -149,17 +147,15 @@ class TestDeviceDeletion(unittest.TestCase): def test_create_journal_entry_when_disabled(self): """Test create_journal_entry when journaling is disabled.""" # Setup - create device with journal=False - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - journal=False, # Disable journaling - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + journal=False, # Disable journaling + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Execute result = device.create_journal_entry("info", "Test message") diff --git a/tests/test_physical_device.py b/tests/test_physical_device.py index 0ee6ffc..133de1d 100644 --- a/tests/test_physical_device.py +++ b/tests/test_physical_device.py @@ -36,9 +36,14 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_logger = MagicMock() # Create PhysicalDevice instance with mocks - with patch( - "netbox_zabbix_sync.modules.device.config", - { + self.device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + journal=True, + logger=self.mock_logger, + config={ "device_cf": "zabbix_hostid", "template_cf": "zabbix_template", "templates_config_context": False, @@ -49,15 +54,7 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, "device_inventory_map": {}, }, - ): - self.device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - journal=True, - logger=self.mock_logger, - ) + ) def test_init(self): """Test the initialization of the PhysicalDevice class.""" @@ -75,13 +72,7 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.name = "test-devïce" # We need to patch the search function to simulate finding special characters - with ( - patch("netbox_zabbix_sync.modules.device.search") as mock_search, - patch( - "netbox_zabbix_sync.modules.device.config", - {"device_cf": "zabbix_hostid"}, - ), - ): + with patch("netbox_zabbix_sync.modules.device.search") as mock_search: # Make the search function return True to simulate special characters mock_search.return_value = True @@ -91,6 +82,7 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_journal, "3.0", logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, ) # With the mocked search function, the name should be changed to NETBOX_ID format @@ -108,16 +100,14 @@ class TestPhysicalDevice(unittest.TestCase): } # Create device with the updated mock - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Test that templates are returned correctly templates = device.get_templates_context() @@ -129,16 +119,14 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.config_context = {"zabbix": {"templates": "Template1"}} # Create device with the updated mock - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Test that template is wrapped in a list templates = device.get_templates_context() @@ -150,16 +138,14 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.config_context = {} # Create device with the updated mock - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Test that TemplateError is raised with self.assertRaises(TemplateError): @@ -171,16 +157,14 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.config_context = {"zabbix": {}} # Create device with the updated mock - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Test that TemplateError is raised with self.assertRaises(TemplateError): @@ -195,17 +179,14 @@ class TestPhysicalDevice(unittest.TestCase): with patch.object( PhysicalDevice, "get_templates_context", return_value=["Template1"] ): - with patch( - "netbox_zabbix_sync.modules.device.config", - {"device_cf": "zabbix_hostid"}, - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Call set_template with prefer_config_context=True result = device.set_template( @@ -225,23 +206,20 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, } - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config=config_patch, + ) + result = device.set_inventory({}) - # Call set_inventory with the config patch still active - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - result = device.set_inventory({}) - - # Check result - self.assertTrue(result) - # Default value for disabled inventory - self.assertEqual(device.inventory_mode, -1) + # Check result + self.assertTrue(result) + # Default value for disabled inventory + self.assertEqual(device.inventory_mode, -1) def test_set_inventory_manual_mode(self): """Test set_inventory with inventory_mode=manual.""" @@ -252,22 +230,19 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, } - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config=config_patch, + ) + result = device.set_inventory({}) - # Call set_inventory with the config patch still active - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - result = device.set_inventory({}) - - # Check result - self.assertTrue(result) - self.assertEqual(device.inventory_mode, 0) # Manual mode + # Check result + self.assertTrue(result) + self.assertEqual(device.inventory_mode, 0) # Manual mode def test_set_inventory_automatic_mode(self): """Test set_inventory with inventory_mode=automatic.""" @@ -278,22 +253,19 @@ class TestPhysicalDevice(unittest.TestCase): "inventory_sync": False, } - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config=config_patch, + ) + result = device.set_inventory({}) - # Call set_inventory with the config patch still active - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - result = device.set_inventory({}) - - # Check result - self.assertTrue(result) - self.assertEqual(device.inventory_mode, 1) # Automatic mode + # Check result + self.assertTrue(result) + self.assertEqual(device.inventory_mode, 1) # Automatic mode def test_set_inventory_with_inventory_sync(self): """Test set_inventory with inventory_sync=True.""" @@ -305,28 +277,25 @@ class TestPhysicalDevice(unittest.TestCase): "device_inventory_map": {"name": "name", "serial": "serialno_a"}, } - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config=config_patch, + ) - # Create a mock device with the required attributes - mock_device_data = {"name": "test-device", "serial": "ABC123"} + # Create a mock device with the required attributes + mock_device_data = {"name": "test-device", "serial": "ABC123"} + result = device.set_inventory(mock_device_data) - # Call set_inventory with the config patch still active - with patch("netbox_zabbix_sync.modules.device.config", config_patch): - result = device.set_inventory(mock_device_data) - - # Check result - self.assertTrue(result) - self.assertEqual(device.inventory_mode, 0) # Manual mode - self.assertEqual( - device.inventory, {"name": "test-device", "serialno_a": "ABC123"} - ) + # Check result + self.assertTrue(result) + self.assertEqual(device.inventory_mode, 0) # Manual mode + self.assertEqual( + device.inventory, {"name": "test-device", "serialno_a": "ABC123"} + ) def test_iscluster_true(self): """Test isCluster when device is part of a cluster.""" @@ -334,16 +303,14 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.virtual_chassis = MagicMock() # Create device with the updated mock - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Check isCluster result self.assertTrue(device.is_cluster()) @@ -354,16 +321,14 @@ class TestPhysicalDevice(unittest.TestCase): self.mock_nb_device.virtual_chassis = None # Create device with the updated mock - with patch( - "netbox_zabbix_sync.modules.device.config", {"device_cf": "zabbix_hostid"} - ): - device = PhysicalDevice( - self.mock_nb_device, - self.mock_zabbix, - self.mock_nb_journal, - "3.0", - logger=self.mock_logger, - ) + device = PhysicalDevice( + self.mock_nb_device, + self.mock_zabbix, + self.mock_nb_journal, + "3.0", + logger=self.mock_logger, + config={"device_cf": "zabbix_hostid"}, + ) # Check isCluster result self.assertFalse(device.is_cluster()) diff --git a/tests/test_usermacros.py b/tests/test_usermacros.py index 45576fc..1b13080 100644 --- a/tests/test_usermacros.py +++ b/tests/test_usermacros.py @@ -27,7 +27,7 @@ class TestUsermacroSync(unittest.TestCase): self.logger = MagicMock() self.usermacro_map = {"serial": "{$HW_SERIAL}"} - def create_mock_device(self): + def create_mock_device(self, config=None): """Helper method to create a properly mocked PhysicalDevice""" # Mock the NetBox device with all required attributes mock_nb = MagicMock() @@ -39,6 +39,8 @@ class TestUsermacroSync(unittest.TestCase): mock_nb.primary_ip.address = "192.168.1.1/24" mock_nb.custom_fields = {"zabbix_hostid": None} + device_config = config if config is not None else {"device_cf": "zabbix_hostid"} + # Create device with proper initialization device = PhysicalDevice( nb=mock_nb, @@ -46,18 +48,21 @@ class TestUsermacroSync(unittest.TestCase): nb_journal_class=MagicMock(), nb_version="3.0", logger=self.logger, + config=device_config, ) return device - @patch( - "netbox_zabbix_sync.modules.device.config", - {"usermacro_sync": False, "device_cf": "zabbix_hostid", "tag_sync": False}, - ) @patch.object(PhysicalDevice, "_usermacro_map") def test_usermacro_sync_false(self, mock_usermacro_map): mock_usermacro_map.return_value = self.usermacro_map - device = self.create_mock_device() + device = self.create_mock_device( + config={ + "usermacro_sync": False, + "device_cf": "zabbix_hostid", + "tag_sync": False, + } + ) # Call set_usermacros result = device.set_usermacros() @@ -65,10 +70,6 @@ class TestUsermacroSync(unittest.TestCase): self.assertEqual(device.usermacros, []) self.assertTrue(result is True or result is None) - @patch( - "netbox_zabbix_sync.modules.device.config", - {"usermacro_sync": True, "device_cf": "zabbix_hostid", "tag_sync": False}, - ) @patch("netbox_zabbix_sync.modules.device.ZabbixUsermacros") @patch.object(PhysicalDevice, "_usermacro_map") def test_usermacro_sync_true(self, mock_usermacro_map, mock_usermacros_class): @@ -81,7 +82,13 @@ class TestUsermacroSync(unittest.TestCase): ] mock_usermacros_class.return_value = mock_macros_instance - device = self.create_mock_device() + device = self.create_mock_device( + config={ + "usermacro_sync": True, + "device_cf": "zabbix_hostid", + "tag_sync": False, + } + ) # Call set_usermacros device.set_usermacros() @@ -89,10 +96,6 @@ class TestUsermacroSync(unittest.TestCase): self.assertIsInstance(device.usermacros, list) self.assertGreater(len(device.usermacros), 0) - @patch( - "netbox_zabbix_sync.modules.device.config", - {"usermacro_sync": "full", "device_cf": "zabbix_hostid", "tag_sync": False}, - ) @patch("netbox_zabbix_sync.modules.device.ZabbixUsermacros") @patch.object(PhysicalDevice, "_usermacro_map") def test_usermacro_sync_full(self, mock_usermacro_map, mock_usermacros_class): @@ -105,7 +108,13 @@ class TestUsermacroSync(unittest.TestCase): ] mock_usermacros_class.return_value = mock_macros_instance - device = self.create_mock_device() + device = self.create_mock_device( + config={ + "usermacro_sync": "full", + "device_cf": "zabbix_hostid", + "tag_sync": False, + } + ) # Call set_usermacros device.set_usermacros() From 7cfed2ec76203f7fbf45bd6286ba75360e4deb20 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 10:35:41 +0000 Subject: [PATCH 35/57] Added warning message for Token v2 with Netbox 4.5 or higher. This is according to Netbox recommendations and to warn our users. --- netbox_zabbix_sync/modules/core.py | 7 ++++ tests/test_core.py | 67 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index bd95f99..66f225b 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -96,6 +96,13 @@ class Sync: nb_host, ) return False + # Warning message for Netbox token v1 with Netbox v4.5 and higher + if not str(nb_token).startswith("nbt_") and self.nb_version >= "4.5": + logger.warning( + "Using Netbox v1 token format. " + "Consider updating to a v2 token. For more info, see " + "https://netboxlabs.com/docs/netbox/integrations/rest-api/#v1-and-v2-tokens" + ) # Set Zabbix API if (zbx_pass or zbx_user) and zbx_token: e = ( diff --git a/tests/test_core.py b/tests/test_core.py index 0618ee6..2391a84 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -197,6 +197,63 @@ class MockNetboxVM: """Mock save method.""" +class TestNetboxv2TokenHandling(unittest.TestCase): + """Test that sync properly handles NetBox v2 token authentication.""" + + @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") + @patch("netbox_zabbix_sync.modules.core.nbapi") + def test_sync_uses_token_for_netbox_v2(self, mock_api, mock_zabbix_api): + """Test that sync uses token authentication for NetBox v2.""" + # Setup NetBox mock + mock_netbox = MagicMock() + mock_api.return_value = mock_netbox + mock_netbox.version = "4.5" + + # Setup Zabbix mock + mock_zabbix = MagicMock() + mock_zabbix_api.return_value = mock_zabbix + mock_zabbix.check_auth.return_value = True + + # Test v1 token (should log warning) + with self.assertLogs("NetBox-Zabbix-sync", level="WARNING") as log_context: + syncer_with_v1_token = Sync() + result_v1_token = syncer_with_v1_token.connect( + nb_host="http://netbox.local", + nb_token="token123", # v1 token (doesn't start with "nbt_") + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token=None, + ) + + # Verify v1 token connection succeeded and warning was logged + self.assertTrue(result_v1_token) + self.assertTrue( + any("v1 token format" in record.message for record in log_context.records) + ) + + # Test v2 token (should not log warning) + # Reset the log capture context + syncer_with_v2_token = Sync() + with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context_v2: + result_v2_token = syncer_with_v2_token.connect( + nb_host="http://netbox.local", + nb_token="nbt_token123", # v2 token (starts with "nbt_") + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token=None, + ) + + # Verify v2 token connection succeeded and NO warning was logged + self.assertTrue(result_v2_token) + self.assertFalse( + any( + "v1 token format" in record.message for record in log_context_v2.records + ) + ) + + class TestSyncNetboxConnection(unittest.TestCase): """Test NetBox connection handling in sync function.""" @@ -268,6 +325,16 @@ class TestSyncZabbixConnection(unittest.TestCase): ) # Start syncer and set connection details syncer = Sync() + result = syncer.connect( + nb_host="http://netbox.local", + nb_token="token", + zbx_host="http://zabbix.local", + zbx_user="user", + zbx_pass="pass", + zbx_token=None, + ) + # Should return False due to Zabbix API error + self.assertFalse(result) result = syncer.connect( "http://netbox.local", "token", From 08519c74335ac3e2f0770523e1bec4f97555f8b3 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 11:33:15 +0000 Subject: [PATCH 36/57] Added Netbox token check to support #159 --- netbox_zabbix_sync/modules/core.py | 57 ++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 66f225b..642f16d 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -5,6 +5,7 @@ from os import environ from typing import Any from pynetbox import api as nbapi +from pynetbox.core.query import RequestError as NetBoxRequestError from requests.exceptions import ConnectionError as RequestsConnectionError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI @@ -88,6 +89,9 @@ class Sync: # Get NetBox version nb_version = netbox.version logger.debug("NetBox version is %s.", nb_version) + # Test API access by attempting to access a basic endpoint + # This will catch authorization errors early + netbox.dcim.sites.all() self.netbox = netbox self.nb_version = nb_version except RequestsConnectionError: @@ -96,13 +100,13 @@ class Sync: nb_host, ) return False - # Warning message for Netbox token v1 with Netbox v4.5 and higher - if not str(nb_token).startswith("nbt_") and self.nb_version >= "4.5": - logger.warning( - "Using Netbox v1 token format. " - "Consider updating to a v2 token. For more info, see " - "https://netboxlabs.com/docs/netbox/integrations/rest-api/#v1-and-v2-tokens" - ) + except NetBoxRequestError as nb_error: + e = f"NetBox returned the following error: {nb_error}." + logger.error(e) + return False + # Check Netbox API token format based on NetBox version + if not self._validate_netbox_token(nb_token, self.nb_version): + return False # Set Zabbix API if (zbx_pass or zbx_user) and zbx_token: e = ( @@ -132,6 +136,35 @@ class Sync: return False return True + def _validate_netbox_token(self, token: str, nb_version: str) -> bool: + """Validate the format of the NetBox token based on the NetBox version. + :param token: The NetBox token to validate. + :param nb_version: The version of NetBox being used. + :return: True if the token format is valid for the given NetBox version, False otherwise. + """ + support_token_url = ( + "https://netboxlabs.com/docs/netbox/integrations/rest-api/#v1-and-v2-tokens" # noqa: S105 + ) + # Warning message for Netbox token v1 with Netbox v4.5 and higher + if not str(token).startswith("nbt_") and nb_version >= "4.5": + logger.warning( + "Using Netbox v1 token format. " + "Consider updating to a v2 token. For more info, see %s", + support_token_url, + ) + elif nb_version < "4.5" and str(token).startswith("nbt_"): + logger.error( + "Using Netbox v2 token format with Netbox version lower than 4.5. " + "Revert to v1 token or upgrade Netbox to 4.5 or higher. For more info, see %s", + support_token_url, + ) + return False + elif nb_version >= "4.5" and str(token).startswith("nbt_"): + logger.debug("Using NetBox v2 token format.") + else: + logger.debug("Using NetBox v1 token format.") + return True + def start(self): """ Run the NetBox to Zabbix sync process. @@ -184,13 +217,15 @@ class Sync: netbox_site_groups = convert_recordset(self.netbox.dcim.site_groups.all()) netbox_regions = convert_recordset(self.netbox.dcim.regions.all()) netbox_journals = self.netbox.extras.journal_entries - zabbix_groups = self.zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore[attr-defined] - zabbix_templates = self.zabbix.template.get(output=["templateid", "name"]) # type: ignore[attr-defined] - zabbix_proxies = self.zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore[attr-defined] + zabbix_groups = self.zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore + zabbix_templates = self.zabbix.template.get( # type: ignore + output=["templateid", "name"] + ) + zabbix_proxies = self.zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] if str(self.zabbix.version).startswith("7"): - zabbix_proxygroups = self.zabbix.proxygroup.get( # type: ignore[attr-defined] + zabbix_proxygroups = self.zabbix.proxygroup.get( # type: ignore output=["proxy_groupid", "name"] ) # Sanitize proxy data From a29f51f3147b009b93c1e178fc9fa356d3b49311 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 11:39:25 +0000 Subject: [PATCH 37/57] Updated and simplified Netbox token testing --- tests/test_core.py | 84 ++++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 2391a84..d21a7a2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -197,62 +197,60 @@ class MockNetboxVM: """Mock save method.""" -class TestNetboxv2TokenHandling(unittest.TestCase): - """Test that sync properly handles NetBox v2 token authentication.""" +class TestNetboxTokenHandling(unittest.TestCase): + """Test that sync properly handles NetBox token authentication.""" - @patch("netbox_zabbix_sync.modules.core.ZabbixAPI") - @patch("netbox_zabbix_sync.modules.core.nbapi") - def test_sync_uses_token_for_netbox_v2(self, mock_api, mock_zabbix_api): - """Test that sync uses token authentication for NetBox v2.""" - # Setup NetBox mock - mock_netbox = MagicMock() - mock_api.return_value = mock_netbox - mock_netbox.version = "4.5" + def test_v1_token_with_netbox_45(self): + """Test that v1 token with NetBox 4.5+ logs warning but returns True.""" + syncer = Sync() - # Setup Zabbix mock - mock_zabbix = MagicMock() - mock_zabbix_api.return_value = mock_zabbix - mock_zabbix.check_auth.return_value = True - - # Test v1 token (should log warning) with self.assertLogs("NetBox-Zabbix-sync", level="WARNING") as log_context: - syncer_with_v1_token = Sync() - result_v1_token = syncer_with_v1_token.connect( - nb_host="http://netbox.local", - nb_token="token123", # v1 token (doesn't start with "nbt_") - zbx_host="http://zabbix.local", - zbx_user="user", - zbx_pass="pass", - zbx_token=None, - ) + result = syncer._validate_netbox_token("token123", "4.5") - # Verify v1 token connection succeeded and warning was logged - self.assertTrue(result_v1_token) + self.assertTrue(result) self.assertTrue( any("v1 token format" in record.message for record in log_context.records) ) - # Test v2 token (should not log warning) - # Reset the log capture context - syncer_with_v2_token = Sync() - with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context_v2: - result_v2_token = syncer_with_v2_token.connect( - nb_host="http://netbox.local", - nb_token="nbt_token123", # v2 token (starts with "nbt_") - zbx_host="http://zabbix.local", - zbx_user="user", - zbx_pass="pass", - zbx_token=None, - ) + def test_v2_token_with_netbox_35(self): + """Test that v2 token with NetBox < 4.5 logs error and returns False.""" + syncer = Sync() - # Verify v2 token connection succeeded and NO warning was logged - self.assertTrue(result_v2_token) - self.assertFalse( + with self.assertLogs("NetBox-Zabbix-sync", level="ERROR") as log_context: + result = syncer._validate_netbox_token("nbt_token123", "3.5") + + self.assertFalse(result) + self.assertTrue( any( - "v1 token format" in record.message for record in log_context_v2.records + "v2 token format with Netbox version lower than 4.5" in record.message + for record in log_context.records ) ) + def test_v2_token_with_netbox_45(self): + """Test that v2 token with NetBox 4.5+ logs debug and returns True.""" + syncer = Sync() + + with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context: + result = syncer._validate_netbox_token("nbt_token123", "4.5") + + self.assertTrue(result) + self.assertTrue( + any("v2 token format" in record.message for record in log_context.records) + ) + + def test_v1_token_with_netbox_35(self): + """Test that v1 token with NetBox < 4.5 logs debug and returns True.""" + syncer = Sync() + + with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context: + result = syncer._validate_netbox_token("token123", "3.5") + + self.assertTrue(result) + self.assertTrue( + any("v1 token format" in record.message for record in log_context.records) + ) + class TestSyncNetboxConnection(unittest.TestCase): """Test NetBox connection handling in sync function.""" From 4185aaba240985c930b4522fff42601d803d81d1 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 11:59:13 +0000 Subject: [PATCH 38/57] Made the token checks more consistent with function variables and updated the tests to better reflect the use of a proper V2 token. --- netbox_zabbix_sync/modules/core.py | 25 ++++++++++++++++++++----- tests/test_core.py | 4 ++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 642f16d..b48c5dc 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -145,21 +145,32 @@ class Sync: support_token_url = ( "https://netboxlabs.com/docs/netbox/integrations/rest-api/#v1-and-v2-tokens" # noqa: S105 ) + token_prefix = "nbt_" # noqa: S105 + nb_v2_support_version = "4.5" + v2_token = bool(token.startswith(token_prefix) and "." in token) + v2_error_token = bool(token.startswith(token_prefix) and "." not in token) + # Check if the token is passed without a proper key.token format + if v2_error_token: + logger.error( + "It looks like an invalid v2 token was passed. For more info, see %s", + support_token_url, + ) + return False # Warning message for Netbox token v1 with Netbox v4.5 and higher - if not str(token).startswith("nbt_") and nb_version >= "4.5": + if not v2_token and nb_version >= nb_v2_support_version: logger.warning( "Using Netbox v1 token format. " "Consider updating to a v2 token. For more info, see %s", support_token_url, ) - elif nb_version < "4.5" and str(token).startswith("nbt_"): + elif v2_token and nb_version < nb_v2_support_version: logger.error( "Using Netbox v2 token format with Netbox version lower than 4.5. " "Revert to v1 token or upgrade Netbox to 4.5 or higher. For more info, see %s", support_token_url, ) return False - elif nb_version >= "4.5" and str(token).startswith("nbt_"): + elif v2_token and nb_version >= nb_v2_support_version: logger.debug("Using NetBox v2 token format.") else: logger.debug("Using NetBox v1 token format.") @@ -217,11 +228,15 @@ class Sync: netbox_site_groups = convert_recordset(self.netbox.dcim.site_groups.all()) netbox_regions = convert_recordset(self.netbox.dcim.regions.all()) netbox_journals = self.netbox.extras.journal_entries - zabbix_groups = self.zabbix.hostgroup.get(output=["groupid", "name"]) # type: ignore + zabbix_groups = self.zabbix.hostgroup.get( # type: ignore + output=["groupid", "name"] + ) zabbix_templates = self.zabbix.template.get( # type: ignore output=["templateid", "name"] ) - zabbix_proxies = self.zabbix.proxy.get(output=["proxyid", proxy_name]) # type: ignore + zabbix_proxies = self.zabbix.proxy.get( # type: ignore + output=["proxyid", proxy_name] + ) # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] if str(self.zabbix.version).startswith("7"): diff --git a/tests/test_core.py b/tests/test_core.py index d21a7a2..d8dc573 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -217,7 +217,7 @@ class TestNetboxTokenHandling(unittest.TestCase): syncer = Sync() with self.assertLogs("NetBox-Zabbix-sync", level="ERROR") as log_context: - result = syncer._validate_netbox_token("nbt_token123", "3.5") + result = syncer._validate_netbox_token("nbt_key123.token123", "3.5") self.assertFalse(result) self.assertTrue( @@ -232,7 +232,7 @@ class TestNetboxTokenHandling(unittest.TestCase): syncer = Sync() with self.assertLogs("NetBox-Zabbix-sync", level="DEBUG") as log_context: - result = syncer._validate_netbox_token("nbt_token123", "4.5") + result = syncer._validate_netbox_token("nbt_key123.token123", "4.5") self.assertTrue(result) self.assertTrue( From 489a70b7039552212dcd65cd406ddd0d432882ba Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:15:15 +0100 Subject: [PATCH 39/57] Modified API call to sites.all for testing the API connection to devices.count. --- netbox_zabbix_sync/modules/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index b48c5dc..22ece3f 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -91,7 +91,7 @@ class Sync: logger.debug("NetBox version is %s.", nb_version) # Test API access by attempting to access a basic endpoint # This will catch authorization errors early - netbox.dcim.sites.all() + netbox.dcim.devices.count() self.netbox = netbox self.nb_version = nb_version except RequestsConnectionError: From 449704156cc9d08def258e5b24bd4edef228dbf3 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:23:27 +0100 Subject: [PATCH 40/57] Added debug line for showing Zabbix version and modified check for Zabbix versions above 6.x.x for proxy configuration --- netbox_zabbix_sync/modules/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 22ece3f..92be308 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -130,6 +130,7 @@ class Sync: logger.debug("Using token authentication for Zabbix API.") self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) self.zabbix.check_auth() + logger.debug("Zabbix version is %s.", self.zabbix.version) except (APIRequestError, ProcessingError) as zbx_error: e = f"Zabbix returned the following error: {zbx_error}." logger.error(e) @@ -239,7 +240,7 @@ class Sync: ) # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] - if str(self.zabbix.version).startswith("7"): + if self.zabbix.version >= "7": zabbix_proxygroups = self.zabbix.proxygroup.get( # type: ignore output=["proxy_groupid", "name"] ) From e3487378c1cc341c7377751fbf6c657bf84c3546 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:28:43 +0100 Subject: [PATCH 41/57] Fixed check for future Zabbix versions. --- netbox_zabbix_sync/modules/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 92be308..eaad57f 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -214,7 +214,7 @@ class Sync: logger=logger, ) # Set API parameter mapping based on API version - proxy_name = "host" if not str(self.zabbix.version).startswith("7") else "name" + proxy_name = "host" if str(self.zabbix.version) < "7" else "name" # Get all Zabbix and NetBox data netbox_devices = list( self.netbox.dcim.devices.filter(**self.config["nb_device_filter"]) @@ -240,7 +240,7 @@ class Sync: ) # Set empty list for proxy processing Zabbix <= 6 zabbix_proxygroups = [] - if self.zabbix.version >= "7": + if str(self.zabbix.version) >= "7": zabbix_proxygroups = self.zabbix.proxygroup.get( # type: ignore output=["proxy_groupid", "name"] ) From dc0a1f91220f2a6d723fe6b4ca412d96768421f6 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:46:53 +0100 Subject: [PATCH 42/57] Modified logging messages which contain device / VM and renamed this for consistent logging to "Host". Moved logging function from global to main() for CLI --- netbox_zabbix_sync/modules/cli.py | 10 ++++++---- netbox_zabbix_sync/modules/core.py | 18 +++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/netbox_zabbix_sync/modules/cli.py b/netbox_zabbix_sync/modules/cli.py index 4b8110d..b7f410a 100644 --- a/netbox_zabbix_sync/modules/cli.py +++ b/netbox_zabbix_sync/modules/cli.py @@ -7,10 +7,6 @@ from netbox_zabbix_sync.modules.exceptions import EnvironmentVarError from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels, setup_logger from netbox_zabbix_sync.modules.settings import load_config -# Set logging -setup_logger() -logger = get_logger() - # Boolean settings that can be toggled via --flag / --no-flag _BOOL_ARGS = [ ("clustering", "Enable clustering of devices with virtual chassis setup."), @@ -84,6 +80,9 @@ def _apply_cli_overrides(config: dict, arguments: argparse.Namespace) -> dict: def main(arguments): """Run the sync process.""" + # Set logging + setup_logger() + logger = get_logger() # Set log levels based on verbosity flags if arguments.verbose: set_log_levels(logging.WARNING, logging.INFO) @@ -137,6 +136,9 @@ def main(arguments): def parse_cli(): + """ + Parse command-line arguments and run the main function. + """ parser = argparse.ArgumentParser( description="Synchronise NetBox device data to Zabbix." ) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index eaad57f..62fda70 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -277,7 +277,7 @@ class Sync: if not vm.hostgroups: continue if self.config["extended_site_properties"] and nb_vm.site: - logger.debug("VM %s: extending site information.", vm.name) + logger.debug("Host %s: extending site information.", vm.name) vm.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_vm.site.id) ) @@ -290,12 +290,12 @@ class Sync: # Delete device from Zabbix # and remove hostID from self.netbox. vm.cleanup() - logger.info("VM %s: cleanup complete", vm.name) + logger.info("Host %s: cleanup complete", vm.name) continue # Device has been added to NetBox # but is not in Activate state logger.info( - "VM %s: Skipping since this VM is not in the active state.", + "Host %s: Skipping since this host is not in the active state.", vm.name, ) continue @@ -351,12 +351,12 @@ class Sync: # Check if a valid hostgroup has been found for this VM. if not device.hostgroups: logger.warning( - "Host %s: Host has no valid hostgroups, Skipping this host...", + "Host %s: has no valid hostgroups, Skipping this host...", device.name, ) continue if self.config["extended_site_properties"] and nb_device.site: - logger.debug("Device %s: extending site information.", device.name) + logger.debug("Host %s: extending site information.", device.name) device.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_device.site.id) ) @@ -369,13 +369,13 @@ class Sync: # Check if device is primary or secondary if device.promote_primary_device(): logger.info( - "Device %s: is part of cluster and primary.", device.name + "Host %s: is part of cluster and primary.", device.name ) else: # Device is secondary in cluster. # Don't continue with this device. logger.info( - "Device %s: Is part of cluster but not primary. Skipping this host...", + "Host %s: Is part of cluster but not primary. Skipping this host...", device.name, ) continue @@ -385,12 +385,12 @@ class Sync: # Delete device from Zabbix # and remove hostID from NetBox. device.cleanup() - logger.info("Device %s: cleanup complete", device.name) + logger.info("Host %s: cleanup complete", device.name) continue # Device has been added to NetBox # but is not in Activate state logger.info( - "Device %s: Skipping since this device is not in the active state.", + "Host %s: Skipping since this host is not in the active state.", device.name, ) continue From 0b925860570edb153a44def490f253bc50f5037e Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 14:00:13 +0100 Subject: [PATCH 43/57] Removed unused code for testing UserMacroSync logic that contained a raise exception. --- tests/test_usermacros.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_usermacros.py b/tests/test_usermacros.py index 1b13080..c464015 100644 --- a/tests/test_usermacros.py +++ b/tests/test_usermacros.py @@ -12,14 +12,6 @@ class DummyNB: for k, v in kwargs.items(): setattr(self, k, v) - def __getitem__(self, key): - # Allow dict-style access for test compatibility - if hasattr(self, key): - return getattr(self, key) - if key in self.config_context: - return self.config_context[key] - raise KeyError(key) - class TestUsermacroSync(unittest.TestCase): def setUp(self): From b7b399444c6d0bc679bf1090827b498f5554f816 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 14:04:56 +0100 Subject: [PATCH 44/57] Replaced raise with return False statement and added return True at the end of the function. --- netbox_zabbix_sync/modules/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 62fda70..0e8dd68 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -184,7 +184,7 @@ class Sync: if not self.netbox or not self.zabbix: e = "Not connected to NetBox or Zabbix. Please run the connect function first." logger.error(e) - raise SyncError(e) + return False device_cfs = [] vm_cfs = [] # Create API call to get all custom fields which are on the device objects @@ -422,3 +422,4 @@ class Sync: except SyncError: pass self.zabbix.logout() + return True From ed63c3e33bfeceabda5db8aafdcf9c7237e84de7 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:17:32 +0000 Subject: [PATCH 45/57] Added missing function to testing suite which was deleted in a previous commit. --- tests/test_usermacros.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_usermacros.py b/tests/test_usermacros.py index c464015..82dbf75 100644 --- a/tests/test_usermacros.py +++ b/tests/test_usermacros.py @@ -12,6 +12,9 @@ class DummyNB: for k, v in kwargs.items(): setattr(self, k, v) + def __getitem__(self, key): + return getattr(self, key) + class TestUsermacroSync(unittest.TestCase): def setUp(self): From 7b83d768d0fb4746e06726a93d51455309668bc6 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:46:35 +0000 Subject: [PATCH 46/57] Modified error message for sync function that does not have a valid netbox or zabbix connection. --- netbox_zabbix_sync/modules/core.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 0e8dd68..feaa3f2 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -122,13 +122,15 @@ class Sync: if environ.get("REQUESTS_CA_BUNDLE", None): ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) if not zbx_token: - logger.debug("Using user/password authentication for Zabbix API.") + logger.debug( + "Using user/password authentication for Zabbix API.") self.zabbix = ZabbixAPI( zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx ) else: logger.debug("Using token authentication for Zabbix API.") - self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) + self.zabbix = ZabbixAPI( + zbx_host, token=zbx_token, ssl_context=ssl_ctx) self.zabbix.check_auth() logger.debug("Zabbix version is %s.", self.zabbix.version) except (APIRequestError, ProcessingError) as zbx_error: @@ -149,7 +151,8 @@ class Sync: token_prefix = "nbt_" # noqa: S105 nb_v2_support_version = "4.5" v2_token = bool(token.startswith(token_prefix) and "." in token) - v2_error_token = bool(token.startswith(token_prefix) and "." not in token) + v2_error_token = bool(token.startswith( + token_prefix) and "." not in token) # Check if the token is passed without a proper key.token format if v2_error_token: logger.error( @@ -182,7 +185,7 @@ class Sync: Run the NetBox to Zabbix sync process. """ if not self.netbox or not self.zabbix: - e = "Not connected to NetBox or Zabbix. Please run the connect function first." + e = "Not able to start sync: No connection to NetBox or Zabbix API." logger.error(e) return False device_cfs = [] @@ -226,7 +229,8 @@ class Sync: **self.config["nb_vm_filter"] ) ) - netbox_site_groups = convert_recordset(self.netbox.dcim.site_groups.all()) + netbox_site_groups = convert_recordset( + self.netbox.dcim.site_groups.all()) netbox_regions = convert_recordset(self.netbox.dcim.regions.all()) netbox_journals = self.netbox.extras.journal_entries zabbix_groups = self.zabbix.hostgroup.get( # type: ignore @@ -277,7 +281,8 @@ class Sync: if not vm.hostgroups: continue if self.config["extended_site_properties"] and nb_vm.site: - logger.debug("Host %s: extending site information.", vm.name) + logger.debug( + "Host %s: extending site information.", vm.name) vm.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_vm.site.id) ) @@ -321,7 +326,8 @@ class Sync: ) continue # Add VM to Zabbix - vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) + vm.create_in_zabbix( + zabbix_groups, zabbix_templates, zabbix_proxy_list) except SyncError: pass @@ -337,7 +343,8 @@ class Sync: logger, config=self.config, ) - logger.debug("Host %s: Started operations on device.", device.name) + logger.debug( + "Host %s: Started operations on device.", device.name) device.set_template( self.config["templates_config_context"], self.config["templates_config_context_overrule"], @@ -356,7 +363,8 @@ class Sync: ) continue if self.config["extended_site_properties"] and nb_device.site: - logger.debug("Host %s: extending site information.", device.name) + logger.debug( + "Host %s: extending site information.", device.name) device.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_device.site.id) ) From 9ec8bb3c2cacdb8366c0f979cb703f0d12eaacaf Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Mon, 23 Feb 2026 13:49:11 +0000 Subject: [PATCH 47/57] Fixed some Ruff linting and modified the error message for start function without a proper netbox / zabbix connection. --- netbox_zabbix_sync/modules/core.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index feaa3f2..10cad22 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -122,15 +122,13 @@ class Sync: if environ.get("REQUESTS_CA_BUNDLE", None): ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) if not zbx_token: - logger.debug( - "Using user/password authentication for Zabbix API.") + logger.debug("Using user/password authentication for Zabbix API.") self.zabbix = ZabbixAPI( zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx ) else: logger.debug("Using token authentication for Zabbix API.") - self.zabbix = ZabbixAPI( - zbx_host, token=zbx_token, ssl_context=ssl_ctx) + self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx) self.zabbix.check_auth() logger.debug("Zabbix version is %s.", self.zabbix.version) except (APIRequestError, ProcessingError) as zbx_error: @@ -151,8 +149,7 @@ class Sync: token_prefix = "nbt_" # noqa: S105 nb_v2_support_version = "4.5" v2_token = bool(token.startswith(token_prefix) and "." in token) - v2_error_token = bool(token.startswith( - token_prefix) and "." not in token) + v2_error_token = bool(token.startswith(token_prefix) and "." not in token) # Check if the token is passed without a proper key.token format if v2_error_token: logger.error( @@ -229,8 +226,7 @@ class Sync: **self.config["nb_vm_filter"] ) ) - netbox_site_groups = convert_recordset( - self.netbox.dcim.site_groups.all()) + netbox_site_groups = convert_recordset(self.netbox.dcim.site_groups.all()) netbox_regions = convert_recordset(self.netbox.dcim.regions.all()) netbox_journals = self.netbox.extras.journal_entries zabbix_groups = self.zabbix.hostgroup.get( # type: ignore @@ -281,8 +277,7 @@ class Sync: if not vm.hostgroups: continue if self.config["extended_site_properties"] and nb_vm.site: - logger.debug( - "Host %s: extending site information.", vm.name) + logger.debug("Host %s: extending site information.", vm.name) vm.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_vm.site.id) ) @@ -326,8 +321,7 @@ class Sync: ) continue # Add VM to Zabbix - vm.create_in_zabbix( - zabbix_groups, zabbix_templates, zabbix_proxy_list) + vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) except SyncError: pass @@ -343,8 +337,7 @@ class Sync: logger, config=self.config, ) - logger.debug( - "Host %s: Started operations on device.", device.name) + logger.debug("Host %s: Started operations on device.", device.name) device.set_template( self.config["templates_config_context"], self.config["templates_config_context_overrule"], @@ -363,8 +356,7 @@ class Sync: ) continue if self.config["extended_site_properties"] and nb_device.site: - logger.debug( - "Host %s: extending site information.", device.name) + logger.debug("Host %s: extending site information.", device.name) device.site = convert_recordset( self.netbox.dcim.sites.filter(id=nb_device.site.id) ) From 0a37ff491c0656611f90e757e319317890ad1748 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 25 Feb 2026 09:05:21 +0100 Subject: [PATCH 48/57] Changed order of printing Netbox version. It makes sense to print it when a succesfull session with authentication is present. This fixes a bug where the version is printed even though the token might be invalid which might cause confusion. --- netbox_zabbix_sync/modules/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 10cad22..4dabf0c 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -88,10 +88,10 @@ class Sync: try: # Get NetBox version nb_version = netbox.version - logger.debug("NetBox version is %s.", nb_version) # Test API access by attempting to access a basic endpoint # This will catch authorization errors early netbox.dcim.devices.count() + logger.debug("NetBox version is %s.", nb_version) self.netbox = netbox self.nb_version = nb_version except RequestsConnectionError: From e7de68c7c30cc3faea9ca0bf698dc97ddcb09ce7 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 25 Feb 2026 09:08:08 +0000 Subject: [PATCH 49/57] Fixes #152 --- netbox_zabbix_sync/modules/device.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/modules/device.py b/netbox_zabbix_sync/modules/device.py index 7d95172..3878756 100644 --- a/netbox_zabbix_sync/modules/device.py +++ b/netbox_zabbix_sync/modules/device.py @@ -521,7 +521,10 @@ class PhysicalDevice: # If the proxy name matches if proxy["name"] == proxy_name: self.logger.debug( - "Host %s: using {proxy['type']} '%s'", self.name, proxy_name + "Host %s: using %s '%s'", + self.name, + proxy["type"], + proxy_name, ) self.zbxproxy = proxy return True From f6b23b4bcd7d1a9065c4b60f87d9cd24ccc9c615 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 25 Feb 2026 09:13:18 +0000 Subject: [PATCH 50/57] Adds #151 --- netbox_zabbix_sync/modules/cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox_zabbix_sync/modules/cli.py b/netbox_zabbix_sync/modules/cli.py index b7f410a..afc3495 100644 --- a/netbox_zabbix_sync/modules/cli.py +++ b/netbox_zabbix_sync/modules/cli.py @@ -164,6 +164,9 @@ def parse_cli(): metavar="FILE", default=None, ) + parser.add_argument( + "--version", action="version", version="NetBox-Zabbix Sync 3.4.0" + ) # ── Boolean config overrides ─────────────────────────────────────────────── bool_group = parser.add_argument_group( From d4f1a2a572cc0eb714aa9ab34cb998df8d79b3e9 Mon Sep 17 00:00:00 2001 From: TheNetworkGuy Date: Wed, 25 Feb 2026 12:55:50 +0000 Subject: [PATCH 51/57] 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() From 4d0c2a42e2a2ffebd8dfe780af46a82bacc6b51c Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Wed, 25 Feb 2026 14:20:25 +0100 Subject: [PATCH 52/57] =?UTF-8?q?=F0=9F=94=A5=20Removed=20=5Fconfig=20meth?= =?UTF-8?q?od?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync/modules/core.py | 34 ++++++------------------------ 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 4dabf0c..06cfba2 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -38,36 +38,16 @@ class Sync: """ self.netbox = None self.zabbix = None - self.config: dict[str, Any] = config if config else {} self.nb_version = None - self._config() - def _config(self): - """ - Load config and check if provided config is valid. - """ - if not self.config: - self.config = load_config() - return True - # Check if provided config is valid - if not isinstance(self.config, dict): - e = "Provided config is not a dictionary." - logger.error(e) - raise SyncError(e) - # Combine default options and provided config default_config = DEFAULT_CONFIG.copy() - for key in self.config: - # Check if the user provided an invalid option parameter - if key not in default_config: - e = f"Provided config contains invalid key: {key}." - logger.error(e) - raise SyncError(e) - # Remove keys from default config to keep track of missing keys - default_config.pop(key) - # Add missing options with default values - for key in default_config: - self.config[key] = default_config[key] - return True + + combined_config = { + **default_config, + **(config if config else {}), + } + + self.config: dict[str, Any] = combined_config def connect( self, nb_host, nb_token, zbx_host, zbx_user=None, zbx_pass=None, zbx_token=None From 0874bc927594909ecb91b74f3190101c001befa8 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Wed, 25 Feb 2026 14:21:32 +0100 Subject: [PATCH 53/57] =?UTF-8?q?=F0=9F=94=A5=20Removed=20load=5Fconfig=20?= =?UTF-8?q?method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync/modules/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_zabbix_sync/modules/core.py b/netbox_zabbix_sync/modules/core.py index 06cfba2..d5470e5 100644 --- a/netbox_zabbix_sync/modules/core.py +++ b/netbox_zabbix_sync/modules/core.py @@ -12,7 +12,7 @@ from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI from netbox_zabbix_sync.modules.device import PhysicalDevice from netbox_zabbix_sync.modules.exceptions import SyncError from netbox_zabbix_sync.modules.logging import get_logger -from netbox_zabbix_sync.modules.settings import DEFAULT_CONFIG, load_config +from netbox_zabbix_sync.modules.settings import DEFAULT_CONFIG from netbox_zabbix_sync.modules.tools import ( convert_recordset, proxy_prepper, From 14e68c34eacbe60d792c33bd6e3435c3ab769925 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Fri, 27 Feb 2026 13:42:31 +0100 Subject: [PATCH 54/57] =?UTF-8?q?=F0=9F=90=9B=20Changed=20end=20of=20line?= =?UTF-8?q?=20to=20LF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox_zabbix_sync.py b/netbox_zabbix_sync.py index 89d2677..9d4ea6e 100755 --- a/netbox_zabbix_sync.py +++ b/netbox_zabbix_sync.py @@ -1,6 +1,6 @@ -#!/usr/bin/env python3 - -from netbox_zabbix_sync.modules.cli import parse_cli - -if __name__ == "__main__": - parse_cli() +#!/usr/bin/env python3 + +from netbox_zabbix_sync.modules.cli import parse_cli + +if __name__ == "__main__": + parse_cli() From 473dd1dcc189cb6d9c312bf0840c4f99a5d089a5 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Fri, 27 Feb 2026 13:43:18 +0100 Subject: [PATCH 55/57] =?UTF-8?q?=F0=9F=90=9B=20Updated=20script=5Fdir=20p?= =?UTF-8?q?ath=20for=20new=20netbox=5Fzabbix=5Fsync=20parent=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync/modules/settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox_zabbix_sync/modules/settings.py b/netbox_zabbix_sync/modules/settings.py index f44930a..3d4f86f 100644 --- a/netbox_zabbix_sync/modules/settings.py +++ b/netbox_zabbix_sync/modules/settings.py @@ -114,9 +114,10 @@ def load_env_variable(config_environvar): def load_config_file(config_default, config_file="config.py"): """Returns config from config.py file""" - # Find the script path and config file next to it. - script_dir = path.dirname(path.dirname(path.abspath(__file__))) - config_path = Path(path.join(script_dir, config_file)) + if not config_path or not config_path.exists(): + # Find the script path and config file next to it. + script_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) + config_path = Path(path.join(script_dir, config_file)) # If the script directory is not found, try the current working directory if not config_path.exists(): From 9da113ac60dd9b636489a0197c82ac0e27dc87b5 Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Fri, 27 Feb 2026 13:43:56 +0100 Subject: [PATCH 56/57] =?UTF-8?q?=F0=9F=9A=A7=20Added=20check=20for=20read?= =?UTF-8?q?ing=20config=20file=20from=20netbox-zabbix-sync.py=20as=20root?= =?UTF-8?q?=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync/modules/settings.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/netbox_zabbix_sync/modules/settings.py b/netbox_zabbix_sync/modules/settings.py index 3d4f86f..c0ef5fd 100644 --- a/netbox_zabbix_sync/modules/settings.py +++ b/netbox_zabbix_sync/modules/settings.py @@ -2,6 +2,8 @@ Module for parsing configuration from the top level config.py file """ +from sys import argv + from importlib import util from logging import getLogger from os import environ, path @@ -114,6 +116,17 @@ def load_env_variable(config_environvar): def load_config_file(config_default, config_file="config.py"): """Returns config from config.py file""" + + first_cli_arg = argv[0] if len(argv) > 1 else None + config_path = None + + # If the script was run by the old .py file extension, use path as root of the config file. + if first_cli_arg and first_cli_arg.endswith(".py"): + logger.debug( + "Using legacy script path detection for config file location. Please switch to --config to specify the config file location." + ) + config_path = Path(first_cli_arg).parent / config_file + if not config_path or not config_path.exists(): # Find the script path and config file next to it. script_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) From 8073cae46abea72e832db1f502b0de2a92e6990d Mon Sep 17 00:00:00 2001 From: Wouter de Bruijn Date: Fri, 27 Feb 2026 15:36:39 +0100 Subject: [PATCH 57/57] =?UTF-8?q?=F0=9F=94=A5=20Removed=20special=20case?= =?UTF-8?q?=20option=20because=20of=20unlikely=20scenario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox_zabbix_sync/modules/settings.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/netbox_zabbix_sync/modules/settings.py b/netbox_zabbix_sync/modules/settings.py index c0ef5fd..78cbecd 100644 --- a/netbox_zabbix_sync/modules/settings.py +++ b/netbox_zabbix_sync/modules/settings.py @@ -2,8 +2,6 @@ Module for parsing configuration from the top level config.py file """ -from sys import argv - from importlib import util from logging import getLogger from os import environ, path @@ -117,20 +115,9 @@ def load_env_variable(config_environvar): def load_config_file(config_default, config_file="config.py"): """Returns config from config.py file""" - first_cli_arg = argv[0] if len(argv) > 1 else None - config_path = None - - # If the script was run by the old .py file extension, use path as root of the config file. - if first_cli_arg and first_cli_arg.endswith(".py"): - logger.debug( - "Using legacy script path detection for config file location. Please switch to --config to specify the config file location." - ) - config_path = Path(first_cli_arg).parent / config_file - - if not config_path or not config_path.exists(): - # Find the script path and config file next to it. - script_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) - config_path = Path(path.join(script_dir, config_file)) + # Find the script path and config file next to it. + script_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) + config_path = Path(path.join(script_dir, config_file)) # If the script directory is not found, try the current working directory if not config_path.exists():