Added support for syncing Zabbix Inventory, this is also a fix for https://github.com/TheNetworkGuy/netbox-zabbix-sync/issues/44

This commit is contained in:
Raymond Kuiper 2024-03-27 15:37:50 +01:00
parent 583d845c40
commit 5b08d27a5e
3 changed files with 141 additions and 11 deletions

View File

@ -170,6 +170,24 @@ You can modify this behaviour by changing the following list variables in the sc
- `zabbix_device_removal` - `zabbix_device_removal`
- `zabbix_device_disable` - `zabbix_device_disable`
### Zabbix Inventory
This script allows you to enable the inventory on managed Zabbix hosts and sync NetBox device properties to the specified inventory fields.
To enable, set `inventory_sync` to `True`.
Set `inventory_automatic` to `False` to use manual inventory, or `True` for automatic.
See [Zabix Manual](https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory) for more information about the modes.
Use the `inventory_map` variable to map which NetBox properties are used in which Zabbix Inventory fields.
For nested properties, you can use the '/' seperator.
For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field:
```
inventory_sync = True
inventory_automatic = True
inventory_map = { "custom_fields/mycustomfield/name": "alias"}
```
See `config.py.example` for an extensive example map.
Any Zabix Inventory fields that are not included in the map will not be touched by the script,
so you can safely add manual values or use items to automatically add values to other fields.
### Template source ### Template source
You can either use a Netbox device type custom field or Netbox config context for the Zabbix template information. You can either use a Netbox device type custom field or Netbox config context for the Zabbix template information.

View File

@ -56,3 +56,32 @@ traverse_site_groups = False
# Default device filter, only get devices which have a name in Netbox: # Default device filter, only get devices which have a name in Netbox:
nb_device_filter = {"name__n": "null"} nb_device_filter = {"name__n": "null"}
## Inventory
# To allow syncing of NetBox device properties, set inventory_sync to True
inventory_sync = False
# Set inventory_automatic to False to use manual inventory, True for automatic
# See https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory
inventory_automatic = True
# inventory_map is used to map NetBox properties to Zabbix Inventory fields.
# For nested properties, you can use the '/' seperator.
# For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field:
#
# inventory_map = { "custom_fields/mycustomfield/name": "alias"}
#
# The following map should provide some nice defaults:
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" }

View File

@ -3,7 +3,6 @@
"""Netbox to Zabbix sync script.""" """Netbox to Zabbix sync script."""
import logging import logging
import argparse import argparse
from os import environ, path, sys from os import environ, path, sys
@ -20,6 +19,11 @@ try:
zabbix_device_removal, zabbix_device_removal,
zabbix_device_disable, zabbix_device_disable,
hostgroup_format, hostgroup_format,
traverse_site_groups,
traverse_regions,
inventory_sync,
inventory_automatic,
inventory_map,
nb_device_filter nb_device_filter
) )
except ModuleNotFoundError: except ModuleNotFoundError:
@ -45,6 +49,30 @@ logger.addHandler(lgfile)
logger.setLevel(logging.WARNING) logger.setLevel(logging.WARNING)
def convert_recordset(recordset):
""" Converts netbox RedcordSet to list of dicts. """
recordlist = []
for record in recordset:
recordlist.append(record.__dict__)
return recordlist
def build_path(endpoint, list_of_dicts):
"""
Builds a path list of related parent/child items.
This can be used to generate a joinable list to
be used in hostgroups.
"""
path = []
itemlist = [i for i in list_of_dicts if i['name'] == endpoint]
item = itemlist[0] if len(itemlist) == 1 else None
path.append(item['name'])
while item['_depth'] > 0:
itemlist = [i for i in list_of_dicts if i['name'] == str(item['parent'])]
item = itemlist[0] if len(itemlist) == 1 else None
path.append(item['name'])
path.reverse()
return(path)
def main(arguments): def main(arguments):
"""Run the sync process.""" """Run the sync process."""
# pylint: disable=too-many-branches, too-many-statements # pylint: disable=too-many-branches, too-many-statements
@ -110,6 +138,8 @@ def main(arguments):
proxy_name = "name" proxy_name = "name"
# Get all Zabbix and Netbox data # Get all Zabbix and Netbox data
netbox_devices = netbox.dcim.devices.filter(**nb_device_filter) netbox_devices = netbox.dcim.devices.filter(**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 netbox_journals = netbox.extras.journal_entries
zabbix_groups = zabbix.hostgroup.get(output=['groupid', 'name']) zabbix_groups = zabbix.hostgroup.get(output=['groupid', 'name'])
zabbix_templates = zabbix.template.get(output=['templateid', 'name']) zabbix_templates = zabbix.template.get(output=['templateid', 'name'])
@ -125,8 +155,9 @@ def main(arguments):
try: try:
device = NetworkDevice(nb_device, zabbix, netbox_journals, device = NetworkDevice(nb_device, zabbix, netbox_journals,
create_journal) create_journal)
device.set_hostgroup(hostgroup_format) device.set_hostgroup(hostgroup_format,netbox_site_groups,netbox_regions)
device.set_template(templates_config_context, templates_config_context_overrule) device.set_template(templates_config_context, templates_config_context_overrule)
device.set_inventory(nb_device)
# Checks if device is part of cluster. # Checks if device is part of cluster.
# Requires clustering variable # Requires clustering variable
if device.isCluster() and clustering: if device.isCluster() and clustering:
@ -236,6 +267,8 @@ class NetworkDevice():
self.zabbix_state = 0 self.zabbix_state = 0
self.journal = journal self.journal = journal
self.nb_journals = nb_journal_class self.nb_journals = nb_journal_class
self.inventory_mode = -1
self.inventory = {}
self._setBasics() self._setBasics()
def _setBasics(self): def _setBasics(self):
@ -248,7 +281,7 @@ class NetworkDevice():
self.ip = self.cidr.split("/")[0] self.ip = self.cidr.split("/")[0]
else: else:
e = f"Device {self.name}: no primary IP." e = f"Device {self.name}: no primary IP."
logger.warning(e) logger.info(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
# Check if device has custom field for ZBX ID # Check if device has custom field for ZBX ID
@ -259,7 +292,7 @@ class NetworkDevice():
logger.warning(e) logger.warning(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
def set_hostgroup(self, hg_format): def set_hostgroup(self, hg_format, nb_site_groups, nb_regions):
"""Set the hostgroup for this device""" """Set the hostgroup for this device"""
# Get all variables from the NB data # Get all variables from the NB data
dev_location = str(self.nb.location) if self.nb.location else None dev_location = str(self.nb.location) if self.nb.location else None
@ -274,7 +307,7 @@ class NetworkDevice():
hostgroup_vars = {"dev_location": dev_location, "dev_role": dev_role, hostgroup_vars = {"dev_location": dev_location, "dev_role": dev_role,
"manufacturer": manufacturer, "region": region, "manufacturer": manufacturer, "region": region,
"site": site, "site_group": site_group, "site": site, "site_group": site_group,
"tenant": tenant, "tenant_group": tenant_group} "tenant": tenant, "tenant_group": tenant_group}
# Generate list based off string input format # Generate list based off string input format
hg_items = hg_format.split("/") hg_items = hg_format.split("/")
hostgroup = "" hostgroup = ""
@ -293,7 +326,14 @@ class NetworkDevice():
# the variable is invalid. Skip regardless. # the variable is invalid. Skip regardless.
continue continue
# Add value of predefined variable to hostgroup format # Add value of predefined variable to hostgroup format
hostgroup += hostgroup_vars[item] + "/" if item == "site_group" and nb_site_groups and traverse_site_groups:
path = build_path(site_group, nb_site_groups)
hostgroup += "/".join(path) + "/"
elif item == "region" and nb_regions and traverse_regions:
path = build_path(region, nb_regions)
hostgroup += "/".join(path) + "/"
else:
hostgroup += hostgroup_vars[item] + "/"
# If the final hostgroup variable is empty # If the final hostgroup variable is empty
if not hostgroup: if not hostgroup:
e = (f"{self.name} has no reliable hostgroup. This is" e = (f"{self.name} has no reliable hostgroup. This is"
@ -353,6 +393,32 @@ class NetworkDevice():
raise TemplateError(e) raise TemplateError(e)
return self.config_context["zabbix"]["templates"] return self.config_context["zabbix"]["templates"]
def set_inventory(self, nbdevice):
""" Set host inventory """
self.inventory_mode = -1
self.inventory = {}
if inventory_sync:
self.inventory_mode = 1 if inventory_automatic else 0
for nb_inv_field, zbx_inv_field in inventory_map.items():
field_list = nb_inv_field.split("/")
fieldstr = "nbdevice"
for field in field_list:
fieldstr += "['" + field + "']"
try:
nb_value = eval(fieldstr)
except:
nb_value = None
if nb_value and isinstance(nb_value, int | float | str ):
self.inventory[zbx_inv_field] = str(nb_value)
elif not nb_value:
logger.debug('Inventory lookup for "%s" returned an empty value' % nb_inv_field)
self.inventory[zbx_inv_field] = ""
else:
# Value is not a string or numeral, probably not what the user expected.
logger.error('Inventory lookup for "%s" returned an unexpected type,'
' it will be skipped.' % nb_inv_field)
return True
def isCluster(self): def isCluster(self):
""" """
Checks if device is part of cluster. Checks if device is part of cluster.
@ -415,7 +481,7 @@ class NetworkDevice():
# to class variable and return debug log # to class variable and return debug log
template_match = True template_match = True
self.zbx_templates.append({"templateid": zbx_template['templateid'], self.zbx_templates.append({"templateid": zbx_template['templateid'],
"name": zbx_template['name']}) "name": zbx_template['name']})
e = (f"Found template {zbx_template['name']}" e = (f"Found template {zbx_template['name']}"
f" for host {self.name}.") f" for host {self.name}.")
logger.debug(e) logger.debug(e)
@ -537,7 +603,9 @@ class NetworkDevice():
groups=groups, groups=groups,
templates=templateids, templates=templateids,
proxy_hostid=self.zbxproxy, proxy_hostid=self.zbxproxy,
description=description) description=description,
inventory_mode=self.inventory_mode,
inventory=self.inventory)
else: else:
host = self.zabbix.host.create(host=self.name, host = self.zabbix.host.create(host=self.name,
status=self.zabbix_state, status=self.zabbix_state,
@ -545,7 +613,9 @@ class NetworkDevice():
groups=groups, groups=groups,
templates=templateids, templates=templateids,
proxyid=self.zbxproxy, proxyid=self.zbxproxy,
description=description) description=description,
inventory_mode=self.inventory_mode,
inventory=self.inventory)
self.zabbix_id = host["hostids"][0] self.zabbix_id = host["hostids"][0]
except ZabbixAPIException as e: except ZabbixAPIException as e:
e = f"Couldn't add {self.name}, Zabbix returned {str(e)}." e = f"Couldn't add {self.name}, Zabbix returned {str(e)}."
@ -603,7 +673,8 @@ class NetworkDevice():
'port', 'details', 'port', 'details',
'interfaceid'], 'interfaceid'],
selectGroups=["groupid"], selectGroups=["groupid"],
selectParentTemplates=["templateid"]) selectParentTemplates=["templateid"],
selectInventory=list(inventory_map.values()))
if len(host) > 1: if len(host) > 1:
e = (f"Got {len(host)} results for Zabbix hosts " e = (f"Got {len(host)} results for Zabbix hosts "
f"with ID {self.zabbix_id} - hostname {self.name}.") f"with ID {self.zabbix_id} - hostname {self.name}.")
@ -616,7 +687,6 @@ class NetworkDevice():
logger.error(e) logger.error(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
host = host[0] host = host[0]
if host["host"] == self.name: if host["host"] == self.name:
logger.debug(f"Device {self.name}: hostname in-sync.") logger.debug(f"Device {self.name}: hostname in-sync.")
else: else:
@ -678,6 +748,19 @@ class NetworkDevice():
f"with proxy in Zabbix but not in Netbox. The" f"with proxy in Zabbix but not in Netbox. The"
" -p flag was ommited: no " " -p flag was ommited: no "
"changes have been made.") "changes have been made.")
# Check host inventory
if inventory_sync:
if str(host['inventory_mode']) == str(self.inventory_mode):
logger.debug(f"Device {self.name}: inventory_mode in-sync.")
else:
logger.warning(f"Device {self.name}: inventory_mode OUT of sync.")
self.updateZabbixHost(inventory_mode=str(self.inventory_mode))
if host['inventory'] == self.inventory:
logger.debug(f"Device {self.name}: inventory in-sync.")
else:
logger.warning(f"Device {self.name}: inventory OUT of sync.")
self.updateZabbixHost(inventory=self.inventory)
# If only 1 interface has been found # If only 1 interface has been found
# pylint: disable=too-many-nested-blocks # pylint: disable=too-many-nested-blocks
if len(host['interfaces']) == 1: if len(host['interfaces']) == 1: