├── tests ├── __init__.py ├── test_tools.py ├── test_list_hostgroup_formats.py ├── test_usermacros.py ├── test_configuration_parsing.py ├── test_device_deletion.py ├── test_interface.py ├── test_hostgroups.py └── test_physical_device.py ├── modules ├── __init__.py ├── exceptions.py ├── logging.py ├── virtual_machine.py ├── tags.py ├── config.py ├── interface.py ├── usermacros.py ├── tools.py ├── hostgroups.py └── device.py ├── requirements.txt ├── .gitignore ├── Dockerfile ├── .github └── workflows │ ├── quality.yml │ ├── run_tests.yml │ └── publish-image.yml ├── .devcontainer └── devcontainer.json ├── LICENSE ├── config.py.example ├── netbox_zabbix_sync.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pynetbox==7.4.1 2 | zabbix-utils==2.0.3 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .venv 3 | .env 4 | config.py 5 | Pipfile 6 | Pipfile.lock 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | .vscode 11 | .flake 12 | .coverage -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM python:3.12-alpine 3 | RUN mkdir -p /opt/netbox-zabbix && chown -R 1000:1000 /opt/netbox-zabbix 4 | 5 | RUN mkdir -p /opt/netbox-zabbix 6 | RUN addgroup -g 1000 -S netbox-zabbix && adduser -u 1000 -S netbox-zabbix -G netbox-zabbix 7 | RUN chown -R 1000:1000 /opt/netbox-zabbix 8 | 9 | WORKDIR /opt/netbox-zabbix 10 | 11 | COPY --chown=1000:1000 . /opt/netbox-zabbix 12 | 13 | USER 1000:1000 14 | 15 | RUN if ! [ -f ./config.py ]; then cp ./config.py.example ./config.py; fi 16 | USER root 17 | RUN pip install -r ./requirements.txt 18 | USER 1000:1000 19 | ENTRYPOINT ["python"] 20 | CMD ["/opt/netbox-zabbix/netbox_zabbix_sync.py", "-v"] 21 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pylint Quality control 3 | 4 | on: 5 | pull_request: 6 | workflow_call: 7 | 8 | jobs: 9 | python_quality_testing: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.12","3.13"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install pylint 24 | pip install -r requirements.txt 25 | - name: Analysing the code with pylint 26 | run: | 27 | pylint --module-naming-style=any modules/* netbox_zabbix_sync.py 28 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pytest code testing 3 | 4 | on: 5 | pull_request: 6 | workflow_call: 7 | 8 | jobs: 9 | test_code: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.12 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pytest pytest-mock coverage pytest-cov 21 | pip install -r requirements.txt 22 | - name: Testing the code with PyTest 23 | run: | 24 | cp config.py.example config.py 25 | pytest tests 26 | - name: Run tests with coverage 27 | run: | 28 | cp config.py.example config.py 29 | coverage run -m pytest tests 30 | - name: Check coverage percentage 31 | run: | 32 | coverage report --fail-under=70 33 | -------------------------------------------------------------------------------- /modules/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | All custom exceptions used for Exception generation 4 | """ 5 | 6 | 7 | class SyncError(Exception): 8 | """Class SyncError""" 9 | 10 | 11 | class JournalError(Exception): 12 | """Class SyncError""" 13 | 14 | 15 | class SyncExternalError(SyncError): 16 | """Class SyncExternalError""" 17 | 18 | 19 | class SyncInventoryError(SyncError): 20 | """Class SyncInventoryError""" 21 | 22 | 23 | class SyncDuplicateError(SyncError): 24 | """Class SyncDuplicateError""" 25 | 26 | 27 | class EnvironmentVarError(SyncError): 28 | """Class EnvironmentVarError""" 29 | 30 | 31 | class InterfaceConfigError(SyncError): 32 | """Class InterfaceConfigError""" 33 | 34 | 35 | class ProxyConfigError(SyncError): 36 | """Class ProxyConfigError""" 37 | 38 | 39 | class HostgroupError(SyncError): 40 | """Class HostgroupError""" 41 | 42 | 43 | class TemplateError(SyncError): 44 | """Class TemplateError""" 45 | 46 | 47 | class UsermacroError(SyncError): 48 | """Class UsermacroError""" 49 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Python 3", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | "postCreateCommand": "pip3 install --user -r requirements.txt && pip3 install --user pylint pytest coverage pytest-cov" 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Twan K. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /modules/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging module for Netbox-Zabbix-sync 3 | """ 4 | 5 | import logging 6 | from os import path 7 | 8 | logger = logging.getLogger("NetBox-Zabbix-sync") 9 | 10 | 11 | def get_logger(): 12 | """ 13 | Return the logger for Netbox Zabbix Sync 14 | """ 15 | return logger 16 | 17 | 18 | def setup_logger(): 19 | """ 20 | Prepare a logger with stream and file handlers 21 | """ 22 | # Set logging 23 | lgout = logging.StreamHandler() 24 | # Logfile in the project root 25 | project_root = path.dirname(path.dirname(path.realpath(__file__))) 26 | logfile_path = path.join(project_root, "sync.log") 27 | lgfile = logging.FileHandler(logfile_path) 28 | 29 | logging.basicConfig( 30 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 31 | level=logging.WARNING, 32 | handlers=[lgout, lgfile], 33 | ) 34 | 35 | 36 | def set_log_levels(root_level, own_level): 37 | """ 38 | Configure log levels for root and Netbox-Zabbix-sync logger 39 | """ 40 | logging.getLogger().setLevel(root_level) 41 | logger.setLevel(own_level) 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-image.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build and Push Docker Image 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | release: 9 | types: [published] 10 | pull_request: 11 | types: [opened, synchronize] 12 | 13 | jobs: 14 | test_quality: 15 | uses: ./.github/workflows/quality.yml 16 | test_code: 17 | uses: ./.github/workflows/run_tests.yml 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 26 | 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Extract metadata 35 | id: meta 36 | uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 37 | with: 38 | images: ghcr.io/${{ github.repository }} 39 | tags: | 40 | type=ref,event=branch 41 | type=ref,event=pr 42 | type=semver,pattern={{version}} 43 | type=semver,pattern={{major}}.{{minor}} 44 | 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 47 | with: 48 | context: . 49 | file: ./Dockerfile 50 | push: true 51 | platforms: linux/amd64,linux/arm64 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | annotations: | 55 | index:org.opencontainers.image.description=Python script to synchronise NetBox devices to Zabbix. 56 | -------------------------------------------------------------------------------- /modules/virtual_machine.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=duplicate-code 2 | """Module that hosts all functions for virtual machine processing""" 3 | from modules.device import PhysicalDevice 4 | from modules.exceptions import InterfaceConfigError, SyncInventoryError, TemplateError 5 | from modules.interface import ZabbixInterface 6 | from modules.config import load_config 7 | # Load config 8 | config = load_config() 9 | 10 | 11 | class VirtualMachine(PhysicalDevice): 12 | """Model for virtual machines""" 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | self.hostgroup = None 17 | self.zbx_template_names = None 18 | self.hostgroup_type = "vm" 19 | 20 | def _inventory_map(self): 21 | """use VM inventory maps""" 22 | return config["vm_inventory_map"] 23 | 24 | def _usermacro_map(self): 25 | """use VM usermacro maps""" 26 | return config["vm_usermacro_map"] 27 | 28 | def _tag_map(self): 29 | """use VM tag maps""" 30 | return config["vm_tag_map"] 31 | 32 | def set_vm_template(self): 33 | """Set Template for VMs. Overwrites default class 34 | to skip a lookup of custom fields.""" 35 | # Gather templates ONLY from the device specific context 36 | try: 37 | self.zbx_template_names = self.get_templates_context() 38 | except TemplateError as e: 39 | self.logger.warning(e) 40 | return True 41 | 42 | def setInterfaceDetails(self): # pylint: disable=invalid-name 43 | """ 44 | Overwrites device function to select an agent interface type by default 45 | Agent type interfaces are more likely to be used with VMs then SNMP 46 | """ 47 | try: 48 | # Initiate interface class 49 | interface = ZabbixInterface(self.nb.config_context, self.ip) 50 | # Check if NetBox has device context. 51 | # If not fall back to old config. 52 | if interface.get_context(): 53 | # If device is SNMP type, add aditional information. 54 | if interface.interface["type"] == 2: 55 | interface.set_snmp() 56 | else: 57 | interface.set_default_agent() 58 | return [interface.interface] 59 | except InterfaceConfigError as e: 60 | message = f"{self.name}: {e}" 61 | self.logger.warning(message) 62 | raise SyncInventoryError(message) from e 63 | -------------------------------------------------------------------------------- /tests/test_tools.py: -------------------------------------------------------------------------------- 1 | from modules.tools import sanatize_log_output 2 | 3 | def test_sanatize_log_output_secrets(): 4 | data = { 5 | "macros": [ 6 | {"macro": "{$SECRET}", "type": "1", "value": "supersecret"}, 7 | {"macro": "{$PLAIN}", "type": "0", "value": "notsecret"}, 8 | ] 9 | } 10 | sanitized = sanatize_log_output(data) 11 | assert sanitized["macros"][0]["value"] == "********" 12 | assert sanitized["macros"][1]["value"] == "notsecret" 13 | 14 | def test_sanatize_log_output_interface_secrets(): 15 | data = { 16 | "interfaceid": 123, 17 | "details": { 18 | "authpassphrase": "supersecret", 19 | "privpassphrase": "anothersecret", 20 | "securityname": "sensitiveuser", 21 | "community": "public", 22 | "other": "normalvalue" 23 | } 24 | } 25 | sanitized = sanatize_log_output(data) 26 | # Sensitive fields should be sanitized 27 | assert sanitized["details"]["authpassphrase"] == "********" 28 | assert sanitized["details"]["privpassphrase"] == "********" 29 | assert sanitized["details"]["securityname"] == "********" 30 | # Non-sensitive fields should remain 31 | assert sanitized["details"]["community"] == "********" 32 | assert sanitized["details"]["other"] == "normalvalue" 33 | # interfaceid should be removed 34 | assert "interfaceid" not in sanitized 35 | 36 | def test_sanatize_log_output_interface_macros(): 37 | data = { 38 | "interfaceid": 123, 39 | "details": { 40 | "authpassphrase": "{$SECRET_MACRO}", 41 | "privpassphrase": "{$SECRET_MACRO}", 42 | "securityname": "{$USER_MACRO}", 43 | "community": "{$SNNMP_COMMUNITY}", 44 | } 45 | } 46 | sanitized = sanatize_log_output(data) 47 | # Macro values should not be sanitized 48 | assert sanitized["details"]["authpassphrase"] == "{$SECRET_MACRO}" 49 | assert sanitized["details"]["privpassphrase"] == "{$SECRET_MACRO}" 50 | assert sanitized["details"]["securityname"] == "{$USER_MACRO}" 51 | assert sanitized["details"]["community"] == "{$SNNMP_COMMUNITY}" 52 | assert "interfaceid" not in sanitized 53 | 54 | def test_sanatize_log_output_plain_data(): 55 | data = {"foo": "bar", "baz": 123} 56 | sanitized = sanatize_log_output(data) 57 | assert sanitized == data 58 | 59 | def test_sanatize_log_output_non_dict(): 60 | data = [1, 2, 3] 61 | sanitized = sanatize_log_output(data) 62 | assert sanitized == data 63 | -------------------------------------------------------------------------------- /modules/tags.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments, logging-fstring-interpolation 3 | """ 4 | All of the Zabbix Usermacro related configuration 5 | """ 6 | 7 | from logging import getLogger 8 | 9 | from modules.tools import field_mapper, remove_duplicates 10 | 11 | 12 | class ZabbixTags: 13 | """Class that represents a Zabbix interface.""" 14 | 15 | def __init__( 16 | self, 17 | nb, 18 | tag_map, 19 | tag_sync=False, 20 | tag_lower=True, 21 | tag_name=None, 22 | tag_value=None, 23 | logger=None, 24 | host=None, 25 | ): 26 | self.nb = nb 27 | self.name = host if host else nb.name 28 | self.tag_map = tag_map 29 | self.logger = logger if logger else getLogger(__name__) 30 | self.tags = {} 31 | self.lower = tag_lower 32 | self.tag_name = tag_name 33 | self.tag_value = tag_value 34 | self.tag_sync = tag_sync 35 | self.sync = False 36 | self._set_config() 37 | 38 | def __repr__(self): 39 | return self.name 40 | 41 | def __str__(self): 42 | return self.__repr__() 43 | 44 | def _set_config(self): 45 | """ 46 | Setup class 47 | """ 48 | if self.tag_sync: 49 | self.sync = True 50 | 51 | return True 52 | 53 | def validate_tag(self, tag_name): 54 | """ 55 | Validates tag name 56 | """ 57 | if tag_name and isinstance(tag_name, str) and len(tag_name) <= 256: 58 | return True 59 | return False 60 | 61 | def validate_value(self, tag_value): 62 | """ 63 | Validates tag value 64 | """ 65 | if tag_value and isinstance(tag_value, str) and len(tag_value) <= 256: 66 | return True 67 | return False 68 | 69 | def render_tag(self, tag_name, tag_value): 70 | """ 71 | Renders a tag 72 | """ 73 | tag = {} 74 | if self.validate_tag(tag_name): 75 | if self.lower: 76 | tag["tag"] = tag_name.lower() 77 | else: 78 | tag["tag"] = tag_name 79 | else: 80 | self.logger.warning("Tag '%s' is not a valid tag name, skipping.", tag_name) 81 | return False 82 | 83 | if self.validate_value(tag_value): 84 | if self.lower: 85 | tag["value"] = tag_value.lower() 86 | else: 87 | tag["value"] = tag_value 88 | else: 89 | self.logger.info( 90 | "Tag '%s' has an invalid value: '%s', skipping.", tag_name, tag_value 91 | ) 92 | return False 93 | return tag 94 | 95 | def generate(self): 96 | """ 97 | Generate full set of Usermacros 98 | """ 99 | # pylint: disable=too-many-branches 100 | tags = [] 101 | # Parse the field mapper for tags 102 | if self.tag_map: 103 | self.logger.debug("Host %s: Starting tag mapper.", self.nb.name) 104 | field_tags = field_mapper(self.nb.name, self.tag_map, self.nb, self.logger) 105 | for tag, value in field_tags.items(): 106 | t = self.render_tag(tag, value) 107 | if t: 108 | tags.append(t) 109 | 110 | # Parse NetBox config context for tags 111 | if ( 112 | "zabbix" in self.nb.config_context 113 | and "tags" in self.nb.config_context["zabbix"] 114 | and isinstance(self.nb.config_context["zabbix"]["tags"], list) 115 | ): 116 | for tag in self.nb.config_context["zabbix"]["tags"]: 117 | if isinstance(tag, dict): 118 | for tagname, value in tag.items(): 119 | t = self.render_tag(tagname, value) 120 | if t: 121 | tags.append(t) 122 | 123 | # Pull in NetBox device tags if tag_name is set 124 | if self.tag_name and isinstance(self.tag_name, str): 125 | for tag in self.nb.tags: 126 | if self.tag_value.lower() in ["display", "name", "slug"]: 127 | value = tag[self.tag_value] 128 | else: 129 | value = tag["name"] 130 | t = self.render_tag(self.tag_name, value) 131 | if t: 132 | tags.append(t) 133 | 134 | tags = remove_duplicates(tags, sortkey="tag") 135 | self.logger.debug("Host %s: Resolved tags: %s", self.name, tags) 136 | return tags 137 | -------------------------------------------------------------------------------- /modules/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for parsing configuration from the top level config.py file 3 | """ 4 | 5 | from pathlib import Path 6 | from importlib import util 7 | from os import environ, path 8 | from logging import getLogger 9 | 10 | logger = getLogger(__name__) 11 | 12 | # PLEASE NOTE: This is a sample config file. Please do NOT make any edits in this file! 13 | # You should create your own config.py and it will overwrite the default config. 14 | 15 | DEFAULT_CONFIG = { 16 | "templates_config_context": False, 17 | "templates_config_context_overrule": False, 18 | "template_cf": "zabbix_template", 19 | "device_cf": "zabbix_hostid", 20 | "proxy_cf": False, 21 | "proxy_group_cf": False, 22 | "clustering": False, 23 | "create_hostgroups": True, 24 | "create_journal": False, 25 | "sync_vms": False, 26 | "vm_hostgroup_format": "cluster_type/cluster/role", 27 | "full_proxy_sync": False, 28 | "zabbix_device_removal": ["Decommissioning", "Inventory"], 29 | "zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"], 30 | "hostgroup_format": "site/manufacturer/role", 31 | "traverse_regions": False, 32 | "traverse_site_groups": False, 33 | "nb_device_filter": {"name__n": "null"}, 34 | "nb_vm_filter": {"name__n": "null"}, 35 | "inventory_mode": "disabled", 36 | "inventory_sync": False, 37 | "extended_site_properties": False, 38 | "device_inventory_map": { 39 | "asset_tag": "asset_tag", 40 | "virtual_chassis/name": "chassis", 41 | "status/label": "deployment_status", 42 | "location/name": "location", 43 | "latitude": "location_lat", 44 | "longitude": "location_lon", 45 | "comments": "notes", 46 | "name": "name", 47 | "rack/name": "site_rack", 48 | "serial": "serialno_a", 49 | "device_type/model": "type", 50 | "device_type/manufacturer/name": "vendor", 51 | "oob_ip/address": "oob_ip", 52 | }, 53 | "vm_inventory_map": { 54 | "status/label": "deployment_status", 55 | "comments": "notes", 56 | "name": "name", 57 | }, 58 | "usermacro_sync": False, 59 | "device_usermacro_map": { 60 | "serial": "{$HW_SERIAL}", 61 | "role/name": "{$DEV_ROLE}", 62 | "url": "{$NB_URL}", 63 | "id": "{$NB_ID}", 64 | }, 65 | "vm_usermacro_map": { 66 | "memory": "{$TOTAL_MEMORY}", 67 | "role/name": "{$DEV_ROLE}", 68 | "url": "{$NB_URL}", 69 | "id": "{$NB_ID}", 70 | }, 71 | "tag_sync": False, 72 | "tag_lower": True, 73 | "tag_name": "NetBox", 74 | "tag_value": "name", 75 | "device_tag_map": { 76 | "site/name": "site", 77 | "rack/name": "rack", 78 | "platform/name": "target", 79 | }, 80 | "vm_tag_map": { 81 | "site/name": "site", 82 | "cluster/name": "cluster", 83 | "platform/name": "target", 84 | }, 85 | } 86 | 87 | 88 | def load_config(): 89 | """Returns combined config from all sources""" 90 | # Overwrite default config with config.py 91 | conf = load_config_file(config_default=DEFAULT_CONFIG) 92 | # Overwrite default config and config.py with environment variables 93 | for key in conf: 94 | value_setting = load_env_variable(key) 95 | if value_setting is not None: 96 | conf[key] = value_setting 97 | return conf 98 | 99 | 100 | def load_env_variable(config_environvar): 101 | """Returns config from environment variable""" 102 | prefix = "NBZX_" 103 | config_environvar = prefix + config_environvar.upper() 104 | if config_environvar in environ: 105 | return environ[config_environvar] 106 | return None 107 | 108 | 109 | def load_config_file(config_default, config_file="config.py"): 110 | """Returns config from config.py file""" 111 | # Find the script path and config file next to it. 112 | script_dir = path.dirname(path.dirname(path.abspath(__file__))) 113 | config_path = Path(path.join(script_dir, config_file)) 114 | 115 | # If the script directory is not found, try the current working directory 116 | if not config_path.exists(): 117 | config_path = Path(config_file) 118 | 119 | # If both checks fail then fallback to the default config 120 | if not config_path.exists(): 121 | return config_default 122 | 123 | dconf = config_default.copy() 124 | # Dynamically import the config module 125 | spec = util.spec_from_file_location("config", config_path) 126 | config_module = util.module_from_spec(spec) 127 | spec.loader.exec_module(config_module) 128 | # Update DEFAULT_CONFIG with variables from the config module 129 | for key in dconf: 130 | if hasattr(config_module, key): 131 | dconf[key] = getattr(config_module, key) 132 | return dconf 133 | -------------------------------------------------------------------------------- /modules/interface.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | All of the Zabbix interface related configuration 4 | """ 5 | from modules.exceptions import InterfaceConfigError 6 | 7 | 8 | class ZabbixInterface: 9 | """Class that represents a Zabbix interface.""" 10 | 11 | def __init__(self, context, ip): 12 | self.context = context 13 | self.ip = ip 14 | self.skelet = {"main": "1", "useip": "1", "dns": "", "ip": self.ip} 15 | self.interface = self.skelet 16 | 17 | def _set_default_port(self): 18 | """Sets default TCP / UDP port for different interface types""" 19 | interface_mapping = {1: 10050, 2: 161, 3: 623, 4: 12345} 20 | # Check if interface type is listed in mapper. 21 | if self.interface["type"] not in interface_mapping: 22 | return False 23 | # Set default port to interface 24 | self.interface["port"] = str(interface_mapping[self.interface["type"]]) 25 | return True 26 | 27 | def get_context(self): 28 | """check if NetBox custom context has been defined.""" 29 | if "zabbix" in self.context: 30 | zabbix = self.context["zabbix"] 31 | if "interface_type" in zabbix: 32 | self.interface["type"] = zabbix["interface_type"] 33 | if not "interface_port" in zabbix: 34 | self._set_default_port() 35 | return True 36 | self.interface["port"] = zabbix["interface_port"] 37 | return True 38 | return False 39 | return False 40 | 41 | def set_snmp(self): 42 | """Check if interface is type SNMP""" 43 | # pylint: disable=too-many-branches 44 | if self.interface["type"] == 2: 45 | # Checks if SNMP settings are defined in NetBox 46 | if "snmp" in self.context["zabbix"]: 47 | snmp = self.context["zabbix"]["snmp"] 48 | self.interface["details"] = {} 49 | # Checks if bulk config has been defined 50 | if "bulk" in snmp: 51 | self.interface["details"]["bulk"] = str(snmp.pop("bulk")) 52 | else: 53 | # Fallback to bulk enabled if not specified 54 | self.interface["details"]["bulk"] = "1" 55 | # SNMP Version config is required in NetBox config context 56 | if snmp.get("version"): 57 | self.interface["details"]["version"] = str(snmp.pop("version")) 58 | else: 59 | e = "SNMP version option is not defined." 60 | raise InterfaceConfigError(e) 61 | # If version 1 or 2 is used, get community string 62 | if self.interface["details"]["version"] in ["1", "2"]: 63 | if "community" in snmp: 64 | # Set SNMP community to confix context value 65 | community = snmp["community"] 66 | else: 67 | # Set SNMP community to default 68 | community = "{$SNMP_COMMUNITY}" 69 | self.interface["details"]["community"] = str(community) 70 | # If version 3 has been used, get all 71 | # SNMPv3 NetBox related configs 72 | elif self.interface["details"]["version"] == "3": 73 | items = [ 74 | "securityname", 75 | "securitylevel", 76 | "authpassphrase", 77 | "privpassphrase", 78 | "authprotocol", 79 | "privprotocol", 80 | "contextname", 81 | ] 82 | for key, item in snmp.items(): 83 | if key in items: 84 | self.interface["details"][key] = str(item) 85 | else: 86 | e = "Unsupported SNMP version." 87 | raise InterfaceConfigError(e) 88 | else: 89 | e = "Interface type SNMP but no parameters provided." 90 | raise InterfaceConfigError(e) 91 | else: 92 | e = "Interface type is not SNMP, unable to set SNMP details" 93 | raise InterfaceConfigError(e) 94 | 95 | def set_default_snmp(self): 96 | """Set default config to SNMPv2, port 161 and community macro.""" 97 | self.interface = self.skelet 98 | self.interface["type"] = "2" 99 | self.interface["port"] = "161" 100 | self.interface["details"] = { 101 | "version": "2", 102 | "community": "{$SNMP_COMMUNITY}", 103 | "bulk": "1", 104 | } 105 | 106 | def set_default_agent(self): 107 | """Sets interface to Zabbix agent defaults""" 108 | self.interface["type"] = "1" 109 | self.interface["port"] = "10050" 110 | -------------------------------------------------------------------------------- /modules/usermacros.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments, logging-fstring-interpolation 3 | """ 4 | All of the Zabbix Usermacro related configuration 5 | """ 6 | 7 | from logging import getLogger 8 | from re import match 9 | 10 | from modules.tools import field_mapper, sanatize_log_output 11 | 12 | 13 | class ZabbixUsermacros: 14 | """Class that represents Zabbix usermacros.""" 15 | 16 | def __init__(self, nb, usermacro_map, usermacro_sync, logger=None, host=None): 17 | self.nb = nb 18 | self.name = host if host else nb.name 19 | self.usermacro_map = usermacro_map 20 | self.logger = logger if logger else getLogger(__name__) 21 | self.usermacros = {} 22 | self.usermacro_sync = usermacro_sync 23 | self.sync = False 24 | self.force_sync = False 25 | self._set_config() 26 | 27 | def __repr__(self): 28 | return self.name 29 | 30 | def __str__(self): 31 | return self.__repr__() 32 | 33 | def _set_config(self): 34 | """ 35 | Setup class 36 | """ 37 | if str(self.usermacro_sync).lower() == "full": 38 | self.sync = True 39 | self.force_sync = True 40 | elif self.usermacro_sync: 41 | self.sync = True 42 | return True 43 | 44 | def validate_macro(self, macro_name): 45 | """ 46 | Validates usermacro name 47 | """ 48 | pattern = r"\{\$[A-Z0-9\._]*(\:.*)?\}" 49 | return match(pattern, macro_name) 50 | 51 | def render_macro(self, macro_name, macro_properties): 52 | """ 53 | Renders a full usermacro from partial input 54 | """ 55 | macro = {} 56 | macrotypes = {"text": 0, "secret": 1, "vault": 2} 57 | if self.validate_macro(macro_name): 58 | macro["macro"] = str(macro_name) 59 | if isinstance(macro_properties, dict): 60 | if not "value" in macro_properties: 61 | self.logger.info( 62 | "Host %s: Usermacro %s has no value in Netbox, skipping.", 63 | self.name, 64 | macro_name, 65 | ) 66 | return False 67 | macro["value"] = macro_properties["value"] 68 | 69 | if ( 70 | "type" in macro_properties 71 | and macro_properties["type"].lower() in macrotypes 72 | ): 73 | macro["type"] = str(macrotypes[macro_properties["type"]]) 74 | else: 75 | macro["type"] = str(0) 76 | 77 | if "description" in macro_properties and isinstance( 78 | macro_properties["description"], str 79 | ): 80 | macro["description"] = macro_properties["description"] 81 | else: 82 | macro["description"] = "" 83 | 84 | elif isinstance(macro_properties, str) and macro_properties: 85 | macro["value"] = macro_properties 86 | macro["type"] = str(0) 87 | macro["description"] = "" 88 | 89 | else: 90 | self.logger.info( 91 | "Host %s: Usermacro %s has no value, skipping.", 92 | self.name, 93 | macro_name, 94 | ) 95 | return False 96 | else: 97 | self.logger.warning( 98 | "Host %s: Usermacro %s is not a valid usermacro name, skipping.", 99 | self.name, 100 | macro_name, 101 | ) 102 | return False 103 | return macro 104 | 105 | def generate(self): 106 | """ 107 | Generate full set of Usermacros 108 | """ 109 | macros = [] 110 | data = {} 111 | # Parse the field mapper for usermacros 112 | if self.usermacro_map: 113 | self.logger.debug("Host %s: Starting usermacro mapper.", self.nb.name) 114 | field_macros = field_mapper( 115 | self.nb.name, self.usermacro_map, self.nb, self.logger 116 | ) 117 | for macro, value in field_macros.items(): 118 | m = self.render_macro(macro, value) 119 | if m: 120 | macros.append(m) 121 | # Parse NetBox config context for usermacros 122 | if ( 123 | "zabbix" in self.nb.config_context 124 | and "usermacros" in self.nb.config_context["zabbix"] 125 | ): 126 | for macro, properties in self.nb.config_context["zabbix"][ 127 | "usermacros" 128 | ].items(): 129 | m = self.render_macro(macro, properties) 130 | if m: 131 | macros.append(m) 132 | data = {"macros": macros} 133 | self.logger.debug( 134 | "Host %s: Resolved macros: %s", self.name, sanatize_log_output(data) 135 | ) 136 | return macros 137 | -------------------------------------------------------------------------------- /tests/test_list_hostgroup_formats.py: -------------------------------------------------------------------------------- 1 | """Tests for list-based hostgroup formats in configuration.""" 2 | import unittest 3 | from unittest.mock import MagicMock, patch 4 | from modules.hostgroups import Hostgroup 5 | from modules.exceptions import HostgroupError 6 | from modules.tools import verify_hg_format 7 | 8 | 9 | class TestListHostgroupFormats(unittest.TestCase): 10 | """Test class for list-based hostgroup format functionality.""" 11 | 12 | def setUp(self): 13 | """Set up test fixtures.""" 14 | # Create mock logger 15 | self.mock_logger = MagicMock() 16 | 17 | # Create mock device 18 | self.mock_device = MagicMock() 19 | self.mock_device.name = "test-device" 20 | 21 | # Set up site information 22 | site = MagicMock() 23 | site.name = "TestSite" 24 | 25 | # Set up region information 26 | region = MagicMock() 27 | region.name = "TestRegion" 28 | region.__str__.return_value = "TestRegion" 29 | site.region = region 30 | 31 | # Set device site 32 | self.mock_device.site = site 33 | 34 | # Set up role information 35 | self.mock_device_role = MagicMock() 36 | self.mock_device_role.name = "TestRole" 37 | self.mock_device_role.__str__.return_value = "TestRole" 38 | self.mock_device.role = self.mock_device_role 39 | 40 | # Set up rack information 41 | rack = MagicMock() 42 | rack.name = "TestRack" 43 | self.mock_device.rack = rack 44 | 45 | # Set up platform information 46 | platform = MagicMock() 47 | platform.name = "TestPlatform" 48 | self.mock_device.platform = platform 49 | 50 | # Device-specific properties 51 | device_type = MagicMock() 52 | manufacturer = MagicMock() 53 | manufacturer.name = "TestManufacturer" 54 | device_type.manufacturer = manufacturer 55 | self.mock_device.device_type = device_type 56 | 57 | # Create mock VM 58 | self.mock_vm = MagicMock() 59 | self.mock_vm.name = "test-vm" 60 | 61 | # Reuse site from device 62 | self.mock_vm.site = site 63 | 64 | # Set up role for VM 65 | self.mock_vm.role = self.mock_device_role 66 | 67 | # Set up platform for VM 68 | self.mock_vm.platform = platform 69 | 70 | # VM-specific properties 71 | cluster = MagicMock() 72 | cluster.name = "TestCluster" 73 | cluster_type = MagicMock() 74 | cluster_type.name = "TestClusterType" 75 | cluster.type = cluster_type 76 | self.mock_vm.cluster = cluster 77 | 78 | def test_verify_list_based_hostgroup_format(self): 79 | """Test verification of list-based hostgroup formats.""" 80 | # List format with valid items 81 | valid_format = ["region", "site", "rack"] 82 | 83 | # List format with nested path 84 | valid_nested_format = ["region", "site/rack"] 85 | 86 | # List format with invalid item 87 | invalid_format = ["region", "invalid_item", "rack"] 88 | 89 | # Should not raise exception for valid formats 90 | verify_hg_format(valid_format, hg_type="dev", logger=self.mock_logger) 91 | verify_hg_format(valid_nested_format, hg_type="dev", logger=self.mock_logger) 92 | 93 | # Should raise exception for invalid format 94 | with self.assertRaises(HostgroupError): 95 | verify_hg_format(invalid_format, hg_type="dev", logger=self.mock_logger) 96 | 97 | def test_simulate_hostgroup_generation_from_config(self): 98 | """Simulate how the main script would generate hostgroups from list-based config.""" 99 | # Mock configuration with list-based hostgroup format 100 | config_format = ["region", "site", "rack"] 101 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 102 | 103 | # Simulate the main script's hostgroup generation process 104 | hostgroups = [] 105 | for fmt in config_format: 106 | result = hostgroup.generate(fmt) 107 | if result: 108 | hostgroups.append(result) 109 | 110 | # Check results 111 | self.assertEqual(len(hostgroups), 3) 112 | self.assertIn("TestRegion", hostgroups) 113 | self.assertIn("TestSite", hostgroups) 114 | self.assertIn("TestRack", hostgroups) 115 | 116 | def test_vm_hostgroup_format_from_config(self): 117 | """Test VM hostgroup generation with list-based format.""" 118 | # Mock VM configuration with mixed format 119 | config_format = ["platform", "role", "cluster_type/cluster"] 120 | hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger) 121 | 122 | # Simulate the main script's hostgroup generation process 123 | hostgroups = [] 124 | for fmt in config_format: 125 | result = hostgroup.generate(fmt) 126 | if result: 127 | hostgroups.append(result) 128 | 129 | # Check results 130 | self.assertEqual(len(hostgroups), 3) 131 | self.assertIn("TestPlatform", hostgroups) 132 | self.assertIn("TestRole", hostgroups) 133 | self.assertIn("TestClusterType/TestCluster", hostgroups) 134 | 135 | 136 | if __name__ == "__main__": 137 | unittest.main() 138 | -------------------------------------------------------------------------------- /tests/test_usermacros.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | from modules.device import PhysicalDevice 4 | from modules.usermacros import ZabbixUsermacros 5 | 6 | class DummyNB: 7 | def __init__(self, name="dummy", config_context=None, **kwargs): 8 | self.name = name 9 | self.config_context = config_context or {} 10 | for k, v in kwargs.items(): 11 | setattr(self, k, v) 12 | 13 | def __getitem__(self, key): 14 | # Allow dict-style access for test compatibility 15 | if hasattr(self, key): 16 | return getattr(self, key) 17 | if key in self.config_context: 18 | return self.config_context[key] 19 | raise KeyError(key) 20 | 21 | class TestUsermacroSync(unittest.TestCase): 22 | def setUp(self): 23 | self.nb = DummyNB(serial="1234") 24 | self.logger = MagicMock() 25 | self.usermacro_map = {"serial": "{$HW_SERIAL}"} 26 | 27 | @patch("modules.device.config", {"usermacro_sync": False}) 28 | def test_usermacro_sync_false(self): 29 | device = PhysicalDevice.__new__(PhysicalDevice) 30 | device.nb = self.nb 31 | device.logger = self.logger 32 | device.name = "dummy" 33 | device._usermacro_map = MagicMock(return_value=self.usermacro_map) 34 | # call set_usermacros 35 | result = device.set_usermacros() 36 | self.assertEqual(device.usermacros, []) 37 | self.assertTrue(result is True or result is None) 38 | 39 | @patch("modules.device.config", {"usermacro_sync": True}) 40 | def test_usermacro_sync_true(self): 41 | device = PhysicalDevice.__new__(PhysicalDevice) 42 | device.nb = self.nb 43 | device.logger = self.logger 44 | device.name = "dummy" 45 | device._usermacro_map = MagicMock(return_value=self.usermacro_map) 46 | result = device.set_usermacros() 47 | self.assertIsInstance(device.usermacros, list) 48 | self.assertGreater(len(device.usermacros), 0) 49 | 50 | @patch("modules.device.config", {"usermacro_sync": "full"}) 51 | def test_usermacro_sync_full(self): 52 | device = PhysicalDevice.__new__(PhysicalDevice) 53 | device.nb = self.nb 54 | device.logger = self.logger 55 | device.name = "dummy" 56 | device._usermacro_map = MagicMock(return_value=self.usermacro_map) 57 | result = device.set_usermacros() 58 | self.assertIsInstance(device.usermacros, list) 59 | self.assertGreater(len(device.usermacros), 0) 60 | 61 | class TestZabbixUsermacros(unittest.TestCase): 62 | def setUp(self): 63 | self.nb = DummyNB() 64 | self.logger = MagicMock() 65 | 66 | def test_validate_macro_valid(self): 67 | macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger) 68 | self.assertTrue(macros.validate_macro("{$TEST_MACRO}")) 69 | self.assertTrue(macros.validate_macro("{$A1_2.3}")) 70 | self.assertTrue(macros.validate_macro("{$FOO:bar}")) 71 | 72 | def test_validate_macro_invalid(self): 73 | macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger) 74 | self.assertFalse(macros.validate_macro("$TEST_MACRO")) 75 | self.assertFalse(macros.validate_macro("{TEST_MACRO}")) 76 | self.assertFalse(macros.validate_macro("{$test}")) # lower-case not allowed 77 | self.assertFalse(macros.validate_macro("")) 78 | 79 | def test_render_macro_dict(self): 80 | macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger) 81 | macro = macros.render_macro("{$FOO}", {"value": "bar", "type": "secret", "description": "desc"}) 82 | self.assertEqual(macro["macro"], "{$FOO}") 83 | self.assertEqual(macro["value"], "bar") 84 | self.assertEqual(macro["type"], "1") 85 | self.assertEqual(macro["description"], "desc") 86 | 87 | def test_render_macro_dict_missing_value(self): 88 | macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger) 89 | result = macros.render_macro("{$FOO}", {"type": "text"}) 90 | self.assertFalse(result) 91 | self.logger.info.assert_called() 92 | 93 | def test_render_macro_str(self): 94 | macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger) 95 | macro = macros.render_macro("{$FOO}", "bar") 96 | self.assertEqual(macro["macro"], "{$FOO}") 97 | self.assertEqual(macro["value"], "bar") 98 | self.assertEqual(macro["type"], "0") 99 | self.assertEqual(macro["description"], "") 100 | 101 | def test_render_macro_invalid_name(self): 102 | macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger) 103 | result = macros.render_macro("FOO", "bar") 104 | self.assertFalse(result) 105 | self.logger.warning.assert_called() 106 | 107 | def test_generate_from_map(self): 108 | nb = DummyNB(memory="bar", role="baz") 109 | usermacro_map = {"memory": "{$FOO}", "role": "{$BAR}"} 110 | macros = ZabbixUsermacros(nb, usermacro_map, True, logger=self.logger) 111 | result = macros.generate() 112 | self.assertEqual(len(result), 2) 113 | self.assertEqual(result[0]["macro"], "{$FOO}") 114 | self.assertEqual(result[1]["macro"], "{$BAR}") 115 | 116 | def test_generate_from_config_context(self): 117 | config_context = {"zabbix": {"usermacros": {"{$FOO}": {"value": "bar"}}}} 118 | nb = DummyNB(config_context=config_context) 119 | macros = ZabbixUsermacros(nb, {}, True, logger=self.logger) 120 | result = macros.generate() 121 | self.assertEqual(len(result), 1) 122 | self.assertEqual(result[0]["macro"], "{$FOO}") 123 | 124 | if __name__ == "__main__": 125 | unittest.main() 126 | -------------------------------------------------------------------------------- /tests/test_configuration_parsing.py: -------------------------------------------------------------------------------- 1 | """Tests for configuration parsing in the modules.config module.""" 2 | from unittest.mock import patch, MagicMock 3 | import os 4 | from modules.config import load_config, DEFAULT_CONFIG, load_config_file, load_env_variable 5 | 6 | 7 | def test_load_config_defaults(): 8 | """Test that load_config returns default values when no config file or env vars are present""" 9 | with patch('modules.config.load_config_file', return_value=DEFAULT_CONFIG.copy()), \ 10 | patch('modules.config.load_env_variable', return_value=None): 11 | config = load_config() 12 | assert config == DEFAULT_CONFIG 13 | assert config["templates_config_context"] is False 14 | assert config["create_hostgroups"] is True 15 | 16 | 17 | def test_load_config_file(): 18 | """Test that load_config properly loads values from config file""" 19 | mock_config = DEFAULT_CONFIG.copy() 20 | mock_config["templates_config_context"] = True 21 | mock_config["sync_vms"] = True 22 | 23 | with patch('modules.config.load_config_file', return_value=mock_config), \ 24 | patch('modules.config.load_env_variable', return_value=None): 25 | config = load_config() 26 | assert config["templates_config_context"] is True 27 | assert config["sync_vms"] is True 28 | # Unchanged values should remain as defaults 29 | assert config["create_journal"] is False 30 | 31 | 32 | def test_load_env_variables(): 33 | """Test that load_config properly loads values from environment variables""" 34 | # Mock env variable loading to return values for specific keys 35 | def mock_load_env(key): 36 | if key == "sync_vms": 37 | return True 38 | if key == "create_journal": 39 | return True 40 | return None 41 | 42 | with patch('modules.config.load_config_file', return_value=DEFAULT_CONFIG.copy()), \ 43 | patch('modules.config.load_env_variable', side_effect=mock_load_env): 44 | config = load_config() 45 | assert config["sync_vms"] is True 46 | assert config["create_journal"] is True 47 | # Unchanged values should remain as defaults 48 | assert config["templates_config_context"] is False 49 | 50 | 51 | def test_env_vars_override_config_file(): 52 | """Test that environment variables override values from config file""" 53 | mock_config = DEFAULT_CONFIG.copy() 54 | mock_config["templates_config_context"] = True 55 | mock_config["sync_vms"] = False 56 | 57 | # Mock env variable that will override the config file value 58 | def mock_load_env(key): 59 | if key == "sync_vms": 60 | return True 61 | return None 62 | 63 | with patch('modules.config.load_config_file', return_value=mock_config), \ 64 | patch('modules.config.load_env_variable', side_effect=mock_load_env): 65 | config = load_config() 66 | # This should be overridden by the env var 67 | assert config["sync_vms"] is True 68 | # This should remain from the config file 69 | assert config["templates_config_context"] is True 70 | 71 | 72 | def test_load_config_file_function(): 73 | """Test the load_config_file function directly""" 74 | # Test when the file exists 75 | with patch('pathlib.Path.exists', return_value=True), \ 76 | patch('importlib.util.spec_from_file_location') as mock_spec: 77 | # Setup the mock module with attributes 78 | mock_module = MagicMock() 79 | mock_module.templates_config_context = True 80 | mock_module.sync_vms = True 81 | 82 | # Setup the mock spec 83 | mock_spec_instance = MagicMock() 84 | mock_spec.return_value = mock_spec_instance 85 | mock_spec_instance.loader.exec_module = lambda x: None 86 | 87 | # Patch module_from_spec to return our mock module 88 | with patch('importlib.util.module_from_spec', return_value=mock_module): 89 | config = load_config_file(DEFAULT_CONFIG.copy()) 90 | assert config["templates_config_context"] is True 91 | assert config["sync_vms"] is True 92 | 93 | 94 | def test_load_config_file_not_found(): 95 | """Test load_config_file when the config file doesn't exist""" 96 | with patch('pathlib.Path.exists', return_value=False): 97 | result = load_config_file(DEFAULT_CONFIG.copy()) 98 | # Should return a dict equal to DEFAULT_CONFIG, not a new object 99 | assert result == DEFAULT_CONFIG 100 | 101 | 102 | def test_load_env_variable_function(): 103 | """Test the load_env_variable function directly""" 104 | # Create a real environment variable for testing with correct prefix and uppercase 105 | test_var = "NBZX_TEMPLATES_CONFIG_CONTEXT" 106 | original_env = os.environ.get(test_var, None) 107 | try: 108 | # Set the environment variable with the proper prefix and case 109 | os.environ[test_var] = "True" 110 | 111 | # Test that it's properly read (using lowercase in the function call) 112 | value = load_env_variable("templates_config_context") 113 | assert value == "True" 114 | 115 | # Test when the environment variable doesn't exist 116 | value = load_env_variable("nonexistent_variable") 117 | assert value is None 118 | finally: 119 | # Clean up - restore original environment 120 | if original_env is not None: 121 | os.environ[test_var] = original_env 122 | else: 123 | os.environ.pop(test_var, None) 124 | 125 | 126 | def test_load_config_file_exception_handling(): 127 | """Test that load_config_file handles exceptions gracefully""" 128 | # This test requires modifying the load_config_file function to handle exceptions 129 | # For now, we're just checking that an exception is raised 130 | with patch('pathlib.Path.exists', return_value=True), \ 131 | patch('importlib.util.spec_from_file_location', side_effect=Exception("Import error")): 132 | # Since the current implementation doesn't handle exceptions, we should 133 | # expect an exception to be raised 134 | try: 135 | load_config_file(DEFAULT_CONFIG.copy()) 136 | assert False, "An exception should have been raised" 137 | except Exception: # pylint: disable=broad-except 138 | # This is expected 139 | pass 140 | -------------------------------------------------------------------------------- /tests/test_device_deletion.py: -------------------------------------------------------------------------------- 1 | """Tests for device deletion functionality in the PhysicalDevice class.""" 2 | import unittest 3 | from unittest.mock import MagicMock, patch 4 | from zabbix_utils import APIRequestError 5 | from modules.device import PhysicalDevice 6 | from modules.exceptions import SyncExternalError 7 | 8 | 9 | class TestDeviceDeletion(unittest.TestCase): 10 | """Test class for device deletion functionality.""" 11 | 12 | def setUp(self): 13 | """Set up test fixtures.""" 14 | # Create mock NetBox device 15 | self.mock_nb_device = MagicMock() 16 | self.mock_nb_device.id = 123 17 | self.mock_nb_device.name = "test-device" 18 | self.mock_nb_device.status.label = "Decommissioning" 19 | self.mock_nb_device.custom_fields = {"zabbix_hostid": "456"} 20 | self.mock_nb_device.config_context = {} 21 | 22 | # Set up a primary IP 23 | primary_ip = MagicMock() 24 | primary_ip.address = "192.168.1.1/24" 25 | self.mock_nb_device.primary_ip = primary_ip 26 | 27 | # Create mock Zabbix API 28 | self.mock_zabbix = MagicMock() 29 | self.mock_zabbix.version = "6.0" 30 | 31 | # Set up mock host.get response 32 | self.mock_zabbix.host.get.return_value = [{"hostid": "456"}] 33 | 34 | # Mock NetBox journal class 35 | self.mock_nb_journal = MagicMock() 36 | 37 | # Create logger mock 38 | self.mock_logger = MagicMock() 39 | 40 | # Create PhysicalDevice instance with mocks 41 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 42 | self.device = PhysicalDevice( 43 | self.mock_nb_device, 44 | self.mock_zabbix, 45 | self.mock_nb_journal, 46 | "3.0", 47 | journal=True, 48 | logger=self.mock_logger 49 | ) 50 | 51 | def test_cleanup_successful_deletion(self): 52 | """Test successful device deletion from Zabbix.""" 53 | # Setup 54 | self.mock_zabbix.host.get.return_value = [{"hostid": "456"}] 55 | self.mock_zabbix.host.delete.return_value = {"hostids": ["456"]} 56 | 57 | # Execute 58 | self.device.cleanup() 59 | 60 | # Verify 61 | self.mock_zabbix.host.get.assert_called_once_with(filter={'hostid': '456'}, output=[]) 62 | self.mock_zabbix.host.delete.assert_called_once_with('456') 63 | self.mock_nb_device.save.assert_called_once() 64 | self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"]) 65 | self.mock_logger.info.assert_called_with(f"Host {self.device.name}: " 66 | "Deleted host from Zabbix.") 67 | 68 | def test_cleanup_device_already_deleted(self): 69 | """Test cleanup when device is already deleted from Zabbix.""" 70 | # Setup 71 | self.mock_zabbix.host.get.return_value = [] # Empty list means host not found 72 | 73 | # Execute 74 | self.device.cleanup() 75 | 76 | # Verify 77 | self.mock_zabbix.host.get.assert_called_once_with(filter={'hostid': '456'}, output=[]) 78 | self.mock_zabbix.host.delete.assert_not_called() 79 | self.mock_nb_device.save.assert_called_once() 80 | self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"]) 81 | self.mock_logger.info.assert_called_with( 82 | f"Host {self.device.name}: was already deleted from Zabbix. Removed link in NetBox.") 83 | 84 | def test_cleanup_api_error(self): 85 | """Test cleanup when Zabbix API returns an error.""" 86 | # Setup 87 | self.mock_zabbix.host.get.return_value = [{"hostid": "456"}] 88 | self.mock_zabbix.host.delete.side_effect = APIRequestError("API Error") 89 | 90 | # Execute and verify 91 | with self.assertRaises(SyncExternalError): 92 | self.device.cleanup() 93 | 94 | # Verify correct calls were made 95 | self.mock_zabbix.host.get.assert_called_once_with(filter={'hostid': '456'}, output=[]) 96 | self.mock_zabbix.host.delete.assert_called_once_with('456') 97 | self.mock_nb_device.save.assert_not_called() 98 | self.mock_logger.error.assert_called() 99 | 100 | def test_zeroize_cf(self): 101 | """Test _zeroize_cf method that clears the custom field.""" 102 | # Execute 103 | self.device._zeroize_cf() # pylint: disable=protected-access 104 | 105 | # Verify 106 | self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"]) 107 | self.mock_nb_device.save.assert_called_once() 108 | 109 | def test_create_journal_entry(self): 110 | """Test create_journal_entry method.""" 111 | # Setup 112 | test_message = "Test journal entry" 113 | 114 | # Execute 115 | result = self.device.create_journal_entry("info", test_message) 116 | 117 | # Verify 118 | self.assertTrue(result) 119 | self.mock_nb_journal.create.assert_called_once() 120 | journal_entry = self.mock_nb_journal.create.call_args[0][0] 121 | self.assertEqual(journal_entry["assigned_object_type"], "dcim.device") 122 | self.assertEqual(journal_entry["assigned_object_id"], 123) 123 | self.assertEqual(journal_entry["kind"], "info") 124 | self.assertEqual(journal_entry["comments"], test_message) 125 | 126 | def test_create_journal_entry_invalid_severity(self): 127 | """Test create_journal_entry with invalid severity.""" 128 | # Execute 129 | result = self.device.create_journal_entry("invalid", "Test message") 130 | 131 | # Verify 132 | self.assertFalse(result) 133 | self.mock_nb_journal.create.assert_not_called() 134 | self.mock_logger.warning.assert_called() 135 | 136 | def test_create_journal_entry_when_disabled(self): 137 | """Test create_journal_entry when journaling is disabled.""" 138 | # Setup - create device with journal=False 139 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 140 | device = PhysicalDevice( 141 | self.mock_nb_device, 142 | self.mock_zabbix, 143 | self.mock_nb_journal, 144 | "3.0", 145 | journal=False, # Disable journaling 146 | logger=self.mock_logger 147 | ) 148 | 149 | # Execute 150 | result = device.create_journal_entry("info", "Test message") 151 | 152 | # Verify 153 | self.assertFalse(result) 154 | self.mock_nb_journal.create.assert_not_called() 155 | 156 | def test_cleanup_updates_journal(self): 157 | """Test that cleanup method creates a journal entry.""" 158 | # Setup 159 | self.mock_zabbix.host.get.return_value = [{"hostid": "456"}] 160 | 161 | # Execute 162 | with patch.object(self.device, 'create_journal_entry') as mock_journal_entry: 163 | self.device.cleanup() 164 | 165 | # Verify 166 | mock_journal_entry.assert_called_once_with("warning", "Deleted host from Zabbix") 167 | -------------------------------------------------------------------------------- /config.py.example: -------------------------------------------------------------------------------- 1 | ## Template logic. 2 | # Set to true to enable the template source information 3 | # coming from config context instead of a custom field. 4 | templates_config_context = False 5 | 6 | # Set to true to give config context templates a 7 | # higher priority then custom field templates 8 | templates_config_context_overrule = False 9 | 10 | # Set template and device NetBox "custom field" names 11 | # Template_cf is not used when templates_config_context is enabled 12 | template_cf = "zabbix_template" 13 | device_cf = "zabbix_hostid" 14 | 15 | ## Enable clustering of devices with virtual chassis setup 16 | clustering = False 17 | 18 | ## Enable hostgroup generation. Requires permissions in Zabbix 19 | create_hostgroups = True 20 | 21 | ## Create journal entries 22 | create_journal = False 23 | 24 | ## Virtual machine sync 25 | # Set sync_vms to True in order to use this new feature 26 | # Use the hostgroup vm_hostgroup_format mapper for specific 27 | # hostgroup atributes of VM's such as cluster_type and cluster 28 | sync_vms = False 29 | # Check the README documentation for values to use in the VM hostgroup format. 30 | vm_hostgroup_format = "cluster_type/cluster/role" 31 | 32 | ## Proxy Sync 33 | # Set to true to enable removal of proxy's under hosts. Use with caution and make sure that you specified 34 | # all the required proxy's in the device config context before enabeling this option. 35 | # With this option disabled proxy's will only be added and modified for Zabbix hosts. 36 | full_proxy_sync = False 37 | 38 | ## NetBox to Zabbix device state convertion 39 | zabbix_device_removal = ["Decommissioning", "Inventory"] 40 | zabbix_device_disable = ["Offline", "Planned", "Staged", "Failed"] 41 | 42 | ## Hostgroup mapping 43 | # See the README documentation for available options 44 | # You can also use CF (custom field) names under the device. The CF content will be used for the hostgroup generation. 45 | # 46 | # When using region in the group name, the default behaviour is to use name of the directly assigned region. 47 | # By setting traverse_regions to True the full path of all parent regions will be used in the hostgroup, e.g.: 48 | # 49 | # 'Global/Europe/Netherlands/Amsterdam' instead of just 'Amsterdam'. 50 | # 51 | # traverse_site_groups controls the same behaviour for any assigned site_groups. 52 | hostgroup_format = "site/manufacturer/role" 53 | traverse_regions = False 54 | traverse_site_groups = False 55 | 56 | ## Extended site properties 57 | # By default, NetBox will only return basic site info for any device or VM. 58 | # By setting `extended_site_properties` to True, the script will query NetBox for additional site info. 59 | # Be aware that this will increase the number of API queries to NetBox. 60 | extended_site_properties = False 61 | 62 | ## Filtering 63 | # Custom device filter, variable must be present but can be left empty with no filtering. 64 | # A couple of examples: 65 | # nb_device_filter = {} #No filter 66 | # nb_device_filter = {"tag": "zabbix"} #Use a tag 67 | # nb_device_filter = {"site": "HQ-AMS"} #Use a site name 68 | # nb_device_filter = {"site": ["HQ-AMS", "HQ-FRA"]} #Device must be in either one of these sites 69 | # nb_device_filter = {"site": "HQ-AMS", "tag": "zabbix", "role__n": ["PDU", "console-server"]} #Device must be in site HQ-AMS, have the tag zabbix and must not be part of the PDU or console-server role 70 | 71 | # Default device filter, only get devices which have a name in NetBox: 72 | nb_device_filter = {"name__n": "null"} 73 | # Default filter for VMs 74 | nb_vm_filter = {"name__n": "null"} 75 | 76 | ## Inventory 77 | # See https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory 78 | # Choice between disabled, manual or automatic. 79 | # Make sure to select at least manual or automatic in use with the inventory_sync function. 80 | inventory_mode = "disabled" 81 | 82 | # To allow syncing of NetBox device properties, set inventory_sync to True 83 | inventory_sync = False 84 | 85 | # inventory_map is used to map NetBox properties to Zabbix Inventory fields. 86 | # For nested properties, you can use the '/' seperator. 87 | # For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field: 88 | # 89 | # device_inventory_map = { "custom_fields/mycustomfield/name": "alias"} 90 | # 91 | # The following maps should provide some nice defaults: 92 | device_inventory_map = { "asset_tag": "asset_tag", 93 | "virtual_chassis/name": "chassis", 94 | "status/label": "deployment_status", 95 | "location/name": "location", 96 | "latitude": "location_lat", 97 | "longitude": "location_lon", 98 | "comments": "notes", 99 | "name": "name", 100 | "rack/name": "site_rack", 101 | "serial": "serialno_a", 102 | "device_type/model": "type", 103 | "device_type/manufacturer/name": "vendor", 104 | "oob_ip/address": "oob_ip" } 105 | # Replace latitude and longitude with site/latitude and and site/longitude to use 106 | # site geo data. Enable extended_site_properties for this to work! 107 | 108 | # We also support inventory mapping on Virtual Machines. 109 | vm_inventory_map = { "status/label": "deployment_status", 110 | "comments": "notes", 111 | "name": "name" } 112 | 113 | # To allow syncing of usermacros from NetBox, set to True. 114 | # this will enable both field mapping and config context usermacros. 115 | # 116 | # If set to "full", it will force the update of secret usermacros every run. 117 | # Please see the README.md for more information. 118 | usermacro_sync = False 119 | 120 | # device usermacro_map to map NetBox fields to usermacros. 121 | device_usermacro_map = {"serial": "{$HW_SERIAL}", 122 | "role/name": "{$DEV_ROLE}", 123 | "display_url": "{$NB_URL}", 124 | "id": "{$NB_ID}"} 125 | 126 | # virtual machine usermacro_map to map NetBox fields to usermacros. 127 | vm_usermacro_map = {"memory": "{$TOTAL_MEMORY}", 128 | "role/name": "{$DEV_ROLE}", 129 | "display_url": "{$NB_URL}", 130 | "id": "{$NB_ID}"} 131 | 132 | # To sync host tags to Zabbix, set to True. 133 | tag_sync = False 134 | 135 | # Setting tag_lower to True will lower capital letters in tag names and values 136 | # This is more inline with the Zabbix way of working with tags. 137 | # 138 | # You can however set this to False to ensure capital letters are synced to Zabbix tags. 139 | tag_lower = True 140 | 141 | # We can sync NetBox device/VM tags to Zabbix, but as NetBox tags don't follow the key/value 142 | # pattern, we need to specify a tag name to register the NetBox tags in Zabbix. 143 | # 144 | # If tag_name is set to False, we won't sync NetBox device/VM tags to Zabbix. 145 | tag_name = 'NetBox' 146 | 147 | # We can choose to use 'name', 'slug' or 'display' NetBox tag properties as a value in Zabbix. 148 | # 'name'is used by default. 149 | tag_value = "name" 150 | 151 | # device tag_map to map NetBox fields to host tags. 152 | device_tag_map = {"site/name": "site", 153 | "rack/name": "rack", 154 | "platform/name": "target"} 155 | 156 | # Virtual machine tag_map to map NetBox fields to host tags. 157 | vm_tag_map = {"site/name": "site", 158 | "cluster/name": "cluster", 159 | "platform/name": "target"} 160 | -------------------------------------------------------------------------------- /modules/tools.py: -------------------------------------------------------------------------------- 1 | """A collection of tools used by several classes""" 2 | 3 | from modules.exceptions import HostgroupError 4 | 5 | 6 | def convert_recordset(recordset): 7 | """Converts netbox RedcordSet to list of dicts.""" 8 | recordlist = [] 9 | for record in recordset: 10 | recordlist.append(record.__dict__) 11 | return recordlist 12 | 13 | 14 | def build_path(endpoint, list_of_dicts): 15 | """ 16 | Builds a path list of related parent/child items. 17 | This can be used to generate a joinable list to 18 | be used in hostgroups. 19 | """ 20 | item_path = [] 21 | itemlist = [i for i in list_of_dicts if i["name"] == endpoint] 22 | item = itemlist[0] if len(itemlist) == 1 else None 23 | item_path.append(item["name"]) 24 | while item["_depth"] > 0: 25 | itemlist = [i for i in list_of_dicts if i["name"] == str(item["parent"])] 26 | item = itemlist[0] if len(itemlist) == 1 else None 27 | item_path.append(item["name"]) 28 | item_path.reverse() 29 | return item_path 30 | 31 | 32 | def proxy_prepper(proxy_list, proxy_group_list): 33 | """ 34 | Function that takes 2 lists and converts them using a 35 | standardized format for further processing. 36 | """ 37 | output = [] 38 | for proxy in proxy_list: 39 | proxy["type"] = "proxy" 40 | proxy["id"] = proxy["proxyid"] 41 | proxy["idtype"] = "proxyid" 42 | proxy["monitored_by"] = 1 43 | output.append(proxy) 44 | for group in proxy_group_list: 45 | group["type"] = "proxy_group" 46 | group["id"] = group["proxy_groupid"] 47 | group["idtype"] = "proxy_groupid" 48 | group["monitored_by"] = 2 49 | output.append(group) 50 | return output 51 | 52 | 53 | def cf_to_string(cf, key="name", logger=None): 54 | """ 55 | Converts a dict custom fields to string 56 | """ 57 | if isinstance(cf, dict): 58 | if key in cf: 59 | return cf[key] 60 | logger.error( 61 | "Conversion of custom field failed, '%s' not found in cf dict.", key 62 | ) 63 | return None 64 | return cf 65 | 66 | 67 | def field_mapper(host, mapper, nbdevice, logger): 68 | """ 69 | Maps NetBox field data to Zabbix properties. 70 | Used for Inventory, Usermacros and Tag mappings. 71 | """ 72 | data = {} 73 | # Let's build an dict for each property in the map 74 | for nb_field, zbx_field in mapper.items(): 75 | field_list = nb_field.split("/") # convert str to list based on delimiter 76 | # start at the base of the dict... 77 | value = nbdevice 78 | # ... and step through the dict till we find the needed value 79 | for item in field_list: 80 | value = value[item] if value else None 81 | # Check if the result is usable and expected 82 | # We want to apply any int or float 0 values, 83 | # even if python thinks those are empty. 84 | if (value and isinstance(value, int | float | str)) or ( 85 | isinstance(value, int | float) and int(value) == 0 86 | ): 87 | data[zbx_field] = str(value) 88 | elif not value: 89 | # empty value should just be an empty string for API compatibility 90 | logger.info( 91 | "Host %s: NetBox lookup for '%s' returned an empty value.", 92 | host, 93 | nb_field, 94 | ) 95 | data[zbx_field] = "" 96 | else: 97 | # Value is not a string or numeral, probably not what the user expected. 98 | logger.info( 99 | "Host %s: Lookup for '%s' returned an unexpected type: it will be skipped.", 100 | host, 101 | nb_field, 102 | ) 103 | logger.debug( 104 | "Host %s: Field mapping complete. Mapped %s field(s).", 105 | host, 106 | len(list(filter(None, data.values()))), 107 | ) 108 | return data 109 | 110 | 111 | def remove_duplicates(input_list, sortkey=None): 112 | """ 113 | Removes duplicate entries from a list and sorts the list 114 | """ 115 | output_list = [] 116 | if isinstance(input_list, list): 117 | output_list = [dict(t) for t in {tuple(d.items()) for d in input_list}] 118 | if sortkey and isinstance(sortkey, str): 119 | output_list.sort(key=lambda x: x[sortkey]) 120 | return output_list 121 | 122 | 123 | def verify_hg_format( 124 | hg_format, device_cfs=None, vm_cfs=None, hg_type="dev", logger=None 125 | ): 126 | """ 127 | Verifies hostgroup field format 128 | """ 129 | if not device_cfs: 130 | device_cfs = [] 131 | if not vm_cfs: 132 | vm_cfs = [] 133 | allowed_objects = { 134 | "dev": [ 135 | "location", 136 | "rack", 137 | "role", 138 | "manufacturer", 139 | "region", 140 | "site", 141 | "site_group", 142 | "tenant", 143 | "tenant_group", 144 | "platform", 145 | "cluster", 146 | ], 147 | "vm": [ 148 | "cluster_type", 149 | "role", 150 | "manufacturer", 151 | "region", 152 | "site", 153 | "site_group", 154 | "tenant", 155 | "tenant_group", 156 | "cluster", 157 | "device", 158 | "platform", 159 | ], 160 | "cfs": {"dev": [], "vm": []}, 161 | } 162 | for cf in device_cfs: 163 | allowed_objects["cfs"]["dev"].append(cf.name) 164 | for cf in vm_cfs: 165 | allowed_objects["cfs"]["vm"].append(cf.name) 166 | hg_objects = [] 167 | if isinstance(hg_format, list): 168 | for f in hg_format: 169 | hg_objects = hg_objects + f.split("/") 170 | else: 171 | hg_objects = hg_format.split("/") 172 | hg_objects = sorted(set(hg_objects)) 173 | for hg_object in hg_objects: 174 | if ( 175 | hg_object not in allowed_objects[hg_type] 176 | and hg_object not in allowed_objects["cfs"][hg_type] 177 | and not hg_object.startswith(('"', "'")) 178 | ): 179 | e = ( 180 | f"Hostgroup item {hg_object} is not valid. Make sure you" 181 | " use valid items and separate them with '/'." 182 | ) 183 | logger.warning(e) 184 | raise HostgroupError(e) 185 | 186 | 187 | def sanatize_log_output(data): 188 | """ 189 | Used for the update function to Zabbix which 190 | shows the data that its using to update the host. 191 | Removes any sensitive data from the input. 192 | """ 193 | if not isinstance(data, dict): 194 | return data 195 | sanitized_data = data.copy() 196 | # Check if there are any sensitive macros defined in the data 197 | if "macros" in data: 198 | for macro in sanitized_data["macros"]: 199 | # Check if macro is secret type 200 | if not (macro["type"] == str(1) or macro["type"] == 1): 201 | continue 202 | macro["value"] = "********" 203 | # Check for interface data 204 | if "interfaceid" in data: 205 | # Interface ID is a value which is most likely not helpful 206 | # in logging output or for troubleshooting. 207 | del sanitized_data["interfaceid"] 208 | # InterfaceID also hints that this is a interface update. 209 | # A check is required if there are no macro's used for SNMP security parameters. 210 | if not "details" in data: 211 | return sanitized_data 212 | for key, detail in sanitized_data["details"].items(): 213 | # If the detail is a secret, we don't want to log it. 214 | if key in ("authpassphrase", "privpassphrase", "securityname", "community"): 215 | # Check if a macro is used. 216 | # If so then logging the output is not a security issue. 217 | if detail.startswith("{$") and detail.endswith("}"): 218 | continue 219 | # A macro is not used, so we sanitize the value. 220 | sanitized_data["details"][key] = "********" 221 | return sanitized_data 222 | -------------------------------------------------------------------------------- /modules/hostgroups.py: -------------------------------------------------------------------------------- 1 | """Module for all hostgroup related code""" 2 | 3 | from logging import getLogger 4 | 5 | from modules.exceptions import HostgroupError 6 | from modules.tools import build_path, cf_to_string 7 | 8 | 9 | class Hostgroup: 10 | """Hostgroup class for devices and VM's 11 | Takes type (vm or dev) and NB object""" 12 | 13 | # pylint: disable=too-many-arguments, disable=too-many-positional-arguments 14 | # pylint: disable=logging-fstring-interpolation 15 | def __init__( 16 | self, 17 | obj_type, 18 | nb_obj, 19 | version, 20 | logger=None, 21 | nested_sitegroup_flag=False, 22 | nested_region_flag=False, 23 | nb_regions=None, 24 | nb_groups=None, 25 | ): 26 | self.logger = logger if logger else getLogger(__name__) 27 | if obj_type not in ("vm", "dev"): 28 | msg = f"Unable to create hostgroup with type {type}" 29 | self.logger.error() 30 | raise HostgroupError(msg) 31 | self.type = str(obj_type) 32 | self.nb = nb_obj 33 | self.name = self.nb.name 34 | self.nb_version = version 35 | # Used for nested data objects 36 | self.set_nesting( 37 | nested_sitegroup_flag, nested_region_flag, nb_groups, nb_regions 38 | ) 39 | self._set_format_options() 40 | 41 | def __str__(self): 42 | return f"Hostgroup for {self.type} {self.name}" 43 | 44 | def __repr__(self): 45 | return self.__str__() 46 | 47 | def _set_format_options(self): 48 | """ 49 | Set all available variables 50 | for hostgroup generation 51 | """ 52 | format_options = {} 53 | # Set variables for both type of devices 54 | if self.type in ("vm", "dev"): 55 | # Role fix for NetBox <=3 56 | role = None 57 | if self.nb_version.startswith(("2", "3")) and self.type == "dev": 58 | role = self.nb.device_role.name if self.nb.device_role else None 59 | else: 60 | role = self.nb.role.name if self.nb.role else None 61 | # Add default formatting options 62 | # Check if a site is configured. A site is optional for VMs 63 | format_options["region"] = None 64 | format_options["site_group"] = None 65 | if self.nb.site: 66 | if self.nb.site.region: 67 | format_options["region"] = self.generate_parents( 68 | "region", str(self.nb.site.region) 69 | ) 70 | if self.nb.site.group: 71 | format_options["site_group"] = self.generate_parents( 72 | "site_group", str(self.nb.site.group) 73 | ) 74 | format_options["role"] = role 75 | format_options["site"] = self.nb.site.name if self.nb.site else None 76 | format_options["tenant"] = str(self.nb.tenant) if self.nb.tenant else None 77 | format_options["tenant_group"] = ( 78 | str(self.nb.tenant.group) if self.nb.tenant else None 79 | ) 80 | format_options["platform"] = ( 81 | self.nb.platform.name if self.nb.platform else None 82 | ) 83 | # Variables only applicable for devices 84 | if self.type == "dev": 85 | format_options["manufacturer"] = self.nb.device_type.manufacturer.name 86 | format_options["location"] = ( 87 | str(self.nb.location) if self.nb.location else None 88 | ) 89 | format_options["rack"] = self.nb.rack.name if self.nb.rack else None 90 | # Variables only applicable for VM's 91 | if self.type == "vm": 92 | # Check if a cluster is configured. Could also be configured in a site. 93 | if self.nb.cluster: 94 | format_options["cluster"] = self.nb.cluster.name 95 | format_options["cluster_type"] = self.nb.cluster.type.name 96 | self.format_options = format_options 97 | self.logger.debug( 98 | "Host %s: Resolved properties for use in hostgroups: %s", 99 | self.name, 100 | self.format_options, 101 | ) 102 | 103 | def set_nesting( 104 | self, nested_sitegroup_flag, nested_region_flag, nb_groups, nb_regions 105 | ): 106 | """Set nesting options for this Hostgroup""" 107 | self.nested_objects = { 108 | "site_group": {"flag": nested_sitegroup_flag, "data": nb_groups}, 109 | "region": {"flag": nested_region_flag, "data": nb_regions}, 110 | } 111 | 112 | def generate(self, hg_format): 113 | """Generate hostgroup based on a provided format""" 114 | # Split all given names 115 | hg_output = [] 116 | hg_items = hg_format.split("/") 117 | for hg_item in hg_items: 118 | # Check if requested data is available as option for this host 119 | if hg_item not in self.format_options: 120 | if hg_item.startswith(("'", '"')) and hg_item.endswith(("'", '"')): 121 | hg_item = hg_item.strip("'") 122 | hg_item = hg_item.strip('"') 123 | hg_output.append(hg_item) 124 | else: 125 | # Check if a custom field exists with this name 126 | cf_data = self.custom_field_lookup(hg_item) 127 | # CF does not exist 128 | if not cf_data["result"]: 129 | msg = ( 130 | f"Unable to generate hostgroup for host {self.name}. " 131 | f"Item type {hg_item} not supported." 132 | ) 133 | self.logger.error(msg) 134 | raise HostgroupError(msg) 135 | # CF data is populated 136 | if cf_data["cf"]: 137 | hg_output.append(cf_to_string(cf_data["cf"])) 138 | continue 139 | # Check if there is a value associated to the variable. 140 | # For instance, if a device has no location, do not use it with hostgroup calculation 141 | hostgroup_value = self.format_options[hg_item] 142 | if hostgroup_value: 143 | hg_output.append(hostgroup_value) 144 | else: 145 | self.logger.info( 146 | "Host %s: Used field '%s' has no value.", self.name, hg_item 147 | ) 148 | # Check if the hostgroup is populated with at least one item. 149 | if bool(hg_output): 150 | return "/".join(hg_output) 151 | msg = ( 152 | f"Host {self.name}: Generating hostgroup name for '{hg_format}' failed. " 153 | f"This is most likely due to fields that have no value." 154 | ) 155 | self.logger.warning(msg) 156 | return None 157 | 158 | def list_formatoptions(self): 159 | """ 160 | Function to easily troubleshoot which values 161 | are generated for a specific device or VM. 162 | """ 163 | print(f"The following options are available for host {self.name}") 164 | for option_type, value in self.format_options.items(): 165 | if value is not None: 166 | print(f"{option_type} - {value}") 167 | print("The following options are not available") 168 | for option_type, value in self.format_options.items(): 169 | if value is None: 170 | print(f"{option_type}") 171 | 172 | def custom_field_lookup(self, hg_category): 173 | """ 174 | Checks if a valid custom field is present in NetBox. 175 | INPUT: Custom field name 176 | OUTPUT: dictionary with 'result' and 'cf' keys. 177 | """ 178 | # Check if the custom field exists 179 | if hg_category not in self.nb.custom_fields: 180 | return {"result": False, "cf": None} 181 | # Checks if the custom field has been populated 182 | if not bool(self.nb.custom_fields[hg_category]): 183 | return {"result": True, "cf": None} 184 | # Custom field exists and is populated 185 | return {"result": True, "cf": self.nb.custom_fields[hg_category]} 186 | 187 | def generate_parents(self, nest_type, child_object): 188 | """ 189 | Generates parent objects to implement nested regions / nested site groups 190 | INPUT: nest_type to set which type of nesting is going to be processed 191 | child_object: the name of the child object (for instance the last NB region) 192 | OUTPUT: STRING - Either the single child name or child and parents. 193 | """ 194 | # Check if this type of nesting is supported. 195 | if not nest_type in self.nested_objects: 196 | return child_object 197 | # If the nested flag is True, perform parent calculation 198 | if self.nested_objects[nest_type]["flag"]: 199 | final_nested_object = build_path( 200 | child_object, self.nested_objects[nest_type]["data"] 201 | ) 202 | return "/".join(final_nested_object) 203 | # Nesting is not allowed for this object. Return child_object 204 | return child_object 205 | -------------------------------------------------------------------------------- /tests/test_interface.py: -------------------------------------------------------------------------------- 1 | """Tests for the ZabbixInterface class in the interface module.""" 2 | import unittest 3 | from modules.interface import ZabbixInterface 4 | from modules.exceptions import InterfaceConfigError 5 | 6 | 7 | class TestZabbixInterface(unittest.TestCase): 8 | """Test class for ZabbixInterface functionality.""" 9 | 10 | def setUp(self): 11 | """Set up test fixtures.""" 12 | self.test_ip = "192.168.1.1" 13 | self.empty_context = {} 14 | self.default_interface = ZabbixInterface(self.empty_context, self.test_ip) 15 | 16 | # Create some test contexts for different scenarios 17 | self.snmpv2_context = { 18 | "zabbix": { 19 | "interface_type": 2, 20 | "interface_port": "161", 21 | "snmp": { 22 | "version": 2, 23 | "community": "public", 24 | "bulk": 1 25 | } 26 | } 27 | } 28 | 29 | self.snmpv3_context = { 30 | "zabbix": { 31 | "interface_type": 2, 32 | "snmp": { 33 | "version": 3, 34 | "securityname": "snmpuser", 35 | "securitylevel": "authPriv", 36 | "authprotocol": "SHA", 37 | "authpassphrase": "authpass123", 38 | "privprotocol": "AES", 39 | "privpassphrase": "privpass123", 40 | "contextname": "context1" 41 | } 42 | } 43 | } 44 | 45 | self.agent_context = { 46 | "zabbix": { 47 | "interface_type": 1, 48 | "interface_port": "10050" 49 | } 50 | } 51 | 52 | def test_init(self): 53 | """Test initialization of ZabbixInterface.""" 54 | interface = ZabbixInterface(self.empty_context, self.test_ip) 55 | 56 | # Check basic properties 57 | self.assertEqual(interface.ip, self.test_ip) 58 | self.assertEqual(interface.context, self.empty_context) 59 | self.assertEqual(interface.interface["ip"], self.test_ip) 60 | self.assertEqual(interface.interface["main"], "1") 61 | self.assertEqual(interface.interface["useip"], "1") 62 | self.assertEqual(interface.interface["dns"], "") 63 | 64 | def test_get_context_empty(self): 65 | """Test get_context with empty context.""" 66 | interface = ZabbixInterface(self.empty_context, self.test_ip) 67 | result = interface.get_context() 68 | self.assertFalse(result) 69 | 70 | def test_get_context_with_interface_type(self): 71 | """Test get_context with interface_type but no port.""" 72 | context = {"zabbix": {"interface_type": 2}} 73 | interface = ZabbixInterface(context, self.test_ip) 74 | 75 | # Should set type and default port 76 | result = interface.get_context() 77 | self.assertTrue(result) 78 | self.assertEqual(interface.interface["type"], 2) 79 | self.assertEqual(interface.interface["port"], "161") # Default port for SNMP 80 | 81 | def test_get_context_with_interface_type_and_port(self): 82 | """Test get_context with both interface_type and port.""" 83 | context = {"zabbix": {"interface_type": 1, "interface_port": "12345"}} 84 | interface = ZabbixInterface(context, self.test_ip) 85 | 86 | # Should set type and specified port 87 | result = interface.get_context() 88 | self.assertTrue(result) 89 | self.assertEqual(interface.interface["type"], 1) 90 | self.assertEqual(interface.interface["port"], "12345") 91 | 92 | def test_set_default_port(self): 93 | """Test _set_default_port for different interface types.""" 94 | interface = ZabbixInterface(self.empty_context, self.test_ip) 95 | 96 | # Test for agent type (1) 97 | interface.interface["type"] = 1 98 | interface._set_default_port() # pylint: disable=protected-access 99 | self.assertEqual(interface.interface["port"], "10050") 100 | 101 | # Test for SNMP type (2) 102 | interface.interface["type"] = 2 103 | interface._set_default_port() # pylint: disable=protected-access 104 | self.assertEqual(interface.interface["port"], "161") 105 | 106 | # Test for IPMI type (3) 107 | interface.interface["type"] = 3 108 | interface._set_default_port() # pylint: disable=protected-access 109 | self.assertEqual(interface.interface["port"], "623") 110 | 111 | # Test for JMX type (4) 112 | interface.interface["type"] = 4 113 | interface._set_default_port() # pylint: disable=protected-access 114 | self.assertEqual(interface.interface["port"], "12345") 115 | 116 | # Test for unsupported type 117 | interface.interface["type"] = 99 118 | result = interface._set_default_port() # pylint: disable=protected-access 119 | self.assertFalse(result) 120 | 121 | def test_set_snmp_v2(self): 122 | """Test set_snmp with SNMPv2 configuration.""" 123 | interface = ZabbixInterface(self.snmpv2_context, self.test_ip) 124 | interface.get_context() # Set the interface type 125 | 126 | # Call set_snmp 127 | interface.set_snmp() 128 | 129 | # Check SNMP details 130 | self.assertEqual(interface.interface["details"]["version"], "2") 131 | self.assertEqual(interface.interface["details"]["community"], "public") 132 | self.assertEqual(interface.interface["details"]["bulk"], "1") 133 | 134 | def test_set_snmp_v3(self): 135 | """Test set_snmp with SNMPv3 configuration.""" 136 | interface = ZabbixInterface(self.snmpv3_context, self.test_ip) 137 | interface.get_context() # Set the interface type 138 | 139 | # Call set_snmp 140 | interface.set_snmp() 141 | 142 | # Check SNMP details 143 | self.assertEqual(interface.interface["details"]["version"], "3") 144 | self.assertEqual(interface.interface["details"]["securityname"], "snmpuser") 145 | self.assertEqual(interface.interface["details"]["securitylevel"], "authPriv") 146 | self.assertEqual(interface.interface["details"]["authprotocol"], "SHA") 147 | self.assertEqual(interface.interface["details"]["authpassphrase"], "authpass123") 148 | self.assertEqual(interface.interface["details"]["privprotocol"], "AES") 149 | self.assertEqual(interface.interface["details"]["privpassphrase"], "privpass123") 150 | self.assertEqual(interface.interface["details"]["contextname"], "context1") 151 | 152 | def test_set_snmp_no_snmp_config(self): 153 | """Test set_snmp with missing SNMP configuration.""" 154 | # Create context with interface type but no SNMP config 155 | context = {"zabbix": {"interface_type": 2}} 156 | interface = ZabbixInterface(context, self.test_ip) 157 | interface.get_context() # Set the interface type 158 | 159 | # Call set_snmp - should raise exception 160 | with self.assertRaises(InterfaceConfigError): 161 | interface.set_snmp() 162 | 163 | def test_set_snmp_unsupported_version(self): 164 | """Test set_snmp with unsupported SNMP version.""" 165 | # Create context with invalid SNMP version 166 | context = { 167 | "zabbix": { 168 | "interface_type": 2, 169 | "snmp": { 170 | "version": 4 # Invalid version 171 | } 172 | } 173 | } 174 | interface = ZabbixInterface(context, self.test_ip) 175 | interface.get_context() # Set the interface type 176 | 177 | # Call set_snmp - should raise exception 178 | with self.assertRaises(InterfaceConfigError): 179 | interface.set_snmp() 180 | 181 | def test_set_snmp_no_version(self): 182 | """Test set_snmp with missing SNMP version.""" 183 | # Create context without SNMP version 184 | context = { 185 | "zabbix": { 186 | "interface_type": 2, 187 | "snmp": { 188 | "community": "public" # No version specified 189 | } 190 | } 191 | } 192 | interface = ZabbixInterface(context, self.test_ip) 193 | interface.get_context() # Set the interface type 194 | 195 | # Call set_snmp - should raise exception 196 | with self.assertRaises(InterfaceConfigError): 197 | interface.set_snmp() 198 | 199 | def test_set_snmp_non_snmp_interface(self): 200 | """Test set_snmp with non-SNMP interface type.""" 201 | interface = ZabbixInterface(self.agent_context, self.test_ip) 202 | interface.get_context() # Set the interface type 203 | 204 | # Call set_snmp - should raise exception 205 | with self.assertRaises(InterfaceConfigError): 206 | interface.set_snmp() 207 | 208 | def test_set_default_snmp(self): 209 | """Test set_default_snmp method.""" 210 | interface = ZabbixInterface(self.empty_context, self.test_ip) 211 | interface.set_default_snmp() 212 | 213 | # Check interface properties 214 | self.assertEqual(interface.interface["type"], "2") 215 | self.assertEqual(interface.interface["port"], "161") 216 | self.assertEqual(interface.interface["details"]["version"], "2") 217 | self.assertEqual(interface.interface["details"]["community"], "{$SNMP_COMMUNITY}") 218 | self.assertEqual(interface.interface["details"]["bulk"], "1") 219 | 220 | def test_set_default_agent(self): 221 | """Test set_default_agent method.""" 222 | interface = ZabbixInterface(self.empty_context, self.test_ip) 223 | interface.set_default_agent() 224 | 225 | # Check interface properties 226 | self.assertEqual(interface.interface["type"], "1") 227 | self.assertEqual(interface.interface["port"], "10050") 228 | 229 | def test_snmpv2_no_community(self): 230 | """Test SNMPv2 with no community string specified.""" 231 | # Create context with SNMPv2 but no community 232 | context = { 233 | "zabbix": { 234 | "interface_type": 2, 235 | "snmp": { 236 | "version": 2 237 | } 238 | } 239 | } 240 | interface = ZabbixInterface(context, self.test_ip) 241 | interface.get_context() # Set the interface type 242 | 243 | # Call set_snmp 244 | interface.set_snmp() 245 | 246 | # Should use default community string 247 | self.assertEqual(interface.interface["details"]["community"], "{$SNMP_COMMUNITY}") 248 | -------------------------------------------------------------------------------- /netbox_zabbix_sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation 3 | 4 | """NetBox to Zabbix sync script.""" 5 | 6 | import argparse 7 | import logging 8 | import ssl 9 | from os import environ, sys 10 | 11 | from pynetbox import api 12 | from pynetbox.core.query import RequestError as NBRequestError 13 | from requests.exceptions import ConnectionError as RequestsConnectionError 14 | from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI 15 | 16 | from modules.config import load_config 17 | from modules.device import PhysicalDevice 18 | from modules.exceptions import EnvironmentVarError, SyncError 19 | from modules.logging import get_logger, set_log_levels, setup_logger 20 | from modules.tools import convert_recordset, proxy_prepper, verify_hg_format 21 | from modules.virtual_machine import VirtualMachine 22 | 23 | config = load_config() 24 | 25 | 26 | setup_logger() 27 | logger = get_logger() 28 | 29 | 30 | def main(arguments): 31 | """Run the sync process.""" 32 | # pylint: disable=too-many-branches, too-many-statements 33 | # set environment variables 34 | if arguments.verbose: 35 | set_log_levels(logging.WARNING, logging.INFO) 36 | if arguments.debug: 37 | set_log_levels(logging.WARNING, logging.DEBUG) 38 | if arguments.debug_all: 39 | set_log_levels(logging.DEBUG, logging.DEBUG) 40 | if arguments.quiet: 41 | set_log_levels(logging.ERROR, logging.ERROR) 42 | 43 | env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"] 44 | if "ZABBIX_TOKEN" in environ: 45 | env_vars.append("ZABBIX_TOKEN") 46 | else: 47 | env_vars.append("ZABBIX_USER") 48 | env_vars.append("ZABBIX_PASS") 49 | for var in env_vars: 50 | if var not in environ: 51 | e = f"Environment variable {var} has not been defined." 52 | logger.error(e) 53 | raise EnvironmentVarError(e) 54 | # Get all virtual environment variables 55 | if "ZABBIX_TOKEN" in env_vars: 56 | zabbix_user = None 57 | zabbix_pass = None 58 | zabbix_token = environ.get("ZABBIX_TOKEN") 59 | else: 60 | zabbix_user = environ.get("ZABBIX_USER") 61 | zabbix_pass = environ.get("ZABBIX_PASS") 62 | zabbix_token = None 63 | zabbix_host = environ.get("ZABBIX_HOST") 64 | netbox_host = environ.get("NETBOX_HOST") 65 | netbox_token = environ.get("NETBOX_TOKEN") 66 | # Set NetBox API 67 | netbox = api(netbox_host, token=netbox_token, threading=True) 68 | # Create API call to get all custom fields which are on the device objects 69 | try: 70 | # Get NetBox version 71 | nb_version = netbox.version 72 | logger.debug("NetBox version is %s.", nb_version) 73 | except RequestsConnectionError: 74 | logger.error( 75 | "Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.", 76 | netbox_host, 77 | ) 78 | sys.exit(1) 79 | except NBRequestError as e: 80 | logger.error("NetBox error: %s", e) 81 | sys.exit(1) 82 | # Check if the provided Hostgroup layout is valid 83 | device_cfs = [] 84 | vm_cfs = [] 85 | device_cfs = list( 86 | netbox.extras.custom_fields.filter( 87 | type=["text", "object", "select"], content_types="dcim.device" 88 | ) 89 | ) 90 | verify_hg_format( 91 | config["hostgroup_format"], device_cfs=device_cfs, hg_type="dev", logger=logger 92 | ) 93 | if config["sync_vms"]: 94 | vm_cfs = list( 95 | netbox.extras.custom_fields.filter( 96 | type=["text", "object", "select"], 97 | content_types="virtualization.virtualmachine", 98 | ) 99 | ) 100 | verify_hg_format( 101 | config["vm_hostgroup_format"], vm_cfs=vm_cfs, hg_type="vm", logger=logger 102 | ) 103 | # Set Zabbix API 104 | try: 105 | ssl_ctx = ssl.create_default_context() 106 | 107 | # If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API 108 | if environ.get("REQUESTS_CA_BUNDLE", None): 109 | ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) 110 | 111 | if not zabbix_token: 112 | zabbix = ZabbixAPI( 113 | zabbix_host, user=zabbix_user, password=zabbix_pass, ssl_context=ssl_ctx 114 | ) 115 | else: 116 | zabbix = ZabbixAPI(zabbix_host, token=zabbix_token, ssl_context=ssl_ctx) 117 | zabbix.check_auth() 118 | except (APIRequestError, ProcessingError) as e: 119 | e = f"Zabbix returned the following error: {str(e)}" 120 | logger.error(e) 121 | sys.exit(1) 122 | # Set API parameter mapping based on API version 123 | if not str(zabbix.version).startswith("7"): 124 | proxy_name = "host" 125 | else: 126 | proxy_name = "name" 127 | # Get all Zabbix and NetBox data 128 | netbox_devices = list(netbox.dcim.devices.filter(**config["nb_device_filter"])) 129 | netbox_vms = [] 130 | if config["sync_vms"]: 131 | netbox_vms = list( 132 | netbox.virtualization.virtual_machines.filter(**config["nb_vm_filter"]) 133 | ) 134 | netbox_site_groups = convert_recordset((netbox.dcim.site_groups.all())) 135 | netbox_regions = convert_recordset(netbox.dcim.regions.all()) 136 | netbox_journals = netbox.extras.journal_entries 137 | zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"]) 138 | zabbix_templates = zabbix.template.get(output=["templateid", "name"]) 139 | zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name]) 140 | # Set empty list for proxy processing Zabbix <= 6 141 | zabbix_proxygroups = [] 142 | if str(zabbix.version).startswith("7"): 143 | zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"]) 144 | # Sanitize proxy data 145 | if proxy_name == "host": 146 | for proxy in zabbix_proxies: 147 | proxy["name"] = proxy.pop("host") 148 | # Prepare list of all proxy and proxy_groups 149 | zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups) 150 | 151 | # Go through all NetBox devices 152 | for nb_vm in netbox_vms: 153 | try: 154 | vm = VirtualMachine( 155 | nb_vm, 156 | zabbix, 157 | netbox_journals, 158 | nb_version, 159 | config["create_journal"], 160 | logger, 161 | ) 162 | logger.debug("Host %s: Started operations on VM.", vm.name) 163 | vm.set_vm_template() 164 | # Check if a valid template has been found for this VM. 165 | if not vm.zbx_template_names: 166 | continue 167 | vm.set_hostgroup( 168 | config["vm_hostgroup_format"], netbox_site_groups, netbox_regions 169 | ) 170 | # Check if a valid hostgroup has been found for this VM. 171 | if not vm.hostgroups: 172 | continue 173 | if config["extended_site_properties"] and nb_vm.site: 174 | logger.debug("VM %s: extending site information.", vm.name) 175 | vm.site = convert_recordset(netbox.dcim.sites.filter(id=nb_vm.site.id)) 176 | vm.set_inventory(nb_vm) 177 | vm.set_usermacros() 178 | vm.set_tags() 179 | # Checks if device is in cleanup state 180 | if vm.status in config["zabbix_device_removal"]: 181 | if vm.zabbix_id: 182 | # Delete device from Zabbix 183 | # and remove hostID from NetBox. 184 | vm.cleanup() 185 | logger.info("VM %s: cleanup complete", vm.name) 186 | continue 187 | # Device has been added to NetBox 188 | # but is not in Activate state 189 | logger.info( 190 | "VM %s: Skipping since this VM is not in the active state.", vm.name 191 | ) 192 | continue 193 | # Check if the VM is in the disabled state 194 | if vm.status in config["zabbix_device_disable"]: 195 | vm.zabbix_state = 1 196 | # Add hostgroup if config is set 197 | if config["create_hostgroups"]: 198 | # Create new hostgroup. Potentially multiple groups if nested 199 | hostgroups = vm.createZabbixHostgroup(zabbix_groups) 200 | # go through all newly created hostgroups 201 | for group in hostgroups: 202 | # Add new hostgroups to zabbix group list 203 | zabbix_groups.append(group) 204 | # Check if VM is already in Zabbix 205 | if vm.zabbix_id: 206 | vm.ConsistencyCheck( 207 | zabbix_groups, 208 | zabbix_templates, 209 | zabbix_proxy_list, 210 | config["full_proxy_sync"], 211 | config["create_hostgroups"], 212 | ) 213 | continue 214 | # Add VM to Zabbix 215 | vm.createInZabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) 216 | except SyncError: 217 | pass 218 | 219 | for nb_device in netbox_devices: 220 | try: 221 | # Set device instance set data such as hostgroup and template information. 222 | device = PhysicalDevice( 223 | nb_device, 224 | zabbix, 225 | netbox_journals, 226 | nb_version, 227 | config["create_journal"], 228 | logger, 229 | ) 230 | logger.debug("Host %s: Started operations on device.", device.name) 231 | device.set_template( 232 | config["templates_config_context"], 233 | config["templates_config_context_overrule"], 234 | ) 235 | # Check if a valid template has been found for this VM. 236 | if not device.zbx_template_names: 237 | continue 238 | device.set_hostgroup( 239 | config["hostgroup_format"], netbox_site_groups, netbox_regions 240 | ) 241 | # Check if a valid hostgroup has been found for this VM. 242 | if not device.hostgroups: 243 | logger.warning( 244 | "Host %s: Host has no valid hostgroups, Skipping this host...", 245 | device.name, 246 | ) 247 | continue 248 | if config["extended_site_properties"] and nb_device.site: 249 | logger.debug("Device %s: extending site information.", device.name) 250 | device.site = convert_recordset( 251 | netbox.dcim.sites.filter(id=nb_device.site.id) 252 | ) 253 | device.set_inventory(nb_device) 254 | device.set_usermacros() 255 | device.set_tags() 256 | # Checks if device is part of cluster. 257 | # Requires clustering variable 258 | if device.isCluster() and config["clustering"]: 259 | # Check if device is primary or secondary 260 | if device.promoteMasterDevice(): 261 | logger.info( 262 | "Device %s: is part of cluster and primary.", device.name 263 | ) 264 | else: 265 | # Device is secondary in cluster. 266 | # Don't continue with this device. 267 | logger.info( 268 | "Device %s: Is part of cluster but not primary. Skipping this host...", 269 | device.name, 270 | ) 271 | continue 272 | # Checks if device is in cleanup state 273 | if device.status in config["zabbix_device_removal"]: 274 | if device.zabbix_id: 275 | # Delete device from Zabbix 276 | # and remove hostID from NetBox. 277 | device.cleanup() 278 | logger.info("Device %s: cleanup complete", device.name) 279 | continue 280 | # Device has been added to NetBox 281 | # but is not in Activate state 282 | logger.info( 283 | "Device %s: Skipping since this device is not in the active state.", 284 | device.name, 285 | ) 286 | continue 287 | # Check if the device is in the disabled state 288 | if device.status in config["zabbix_device_disable"]: 289 | device.zabbix_state = 1 290 | # Add hostgroup is config is set 291 | if config["create_hostgroups"]: 292 | # Create new hostgroup. Potentially multiple groups if nested 293 | hostgroups = device.createZabbixHostgroup(zabbix_groups) 294 | # go through all newly created hostgroups 295 | for group in hostgroups: 296 | # Add new hostgroups to zabbix group list 297 | zabbix_groups.append(group) 298 | # Check if device is already in Zabbix 299 | if device.zabbix_id: 300 | device.ConsistencyCheck( 301 | zabbix_groups, 302 | zabbix_templates, 303 | zabbix_proxy_list, 304 | config["full_proxy_sync"], 305 | config["create_hostgroups"], 306 | ) 307 | continue 308 | # Add device to Zabbix 309 | device.createInZabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list) 310 | except SyncError: 311 | pass 312 | zabbix.logout() 313 | 314 | 315 | if __name__ == "__main__": 316 | parser = argparse.ArgumentParser( 317 | description="A script to sync Zabbix with NetBox device data." 318 | ) 319 | parser.add_argument( 320 | "-v", "--verbose", help="Turn on verbose logging.", action="store_true" 321 | ) 322 | parser.add_argument( 323 | "-vv", "--debug", help="Turn on debugging.", action="store_true" 324 | ) 325 | parser.add_argument( 326 | "-vvv", 327 | "--debug-all", 328 | help="Turn on debugging for all modules.", 329 | action="store_true", 330 | ) 331 | parser.add_argument("-q", "--quiet", help="Turn off warnings.", action="store_true") 332 | args = parser.parse_args() 333 | main(args) 334 | -------------------------------------------------------------------------------- /tests/test_hostgroups.py: -------------------------------------------------------------------------------- 1 | """Tests for the Hostgroup class in the hostgroups module.""" 2 | import unittest 3 | from unittest.mock import MagicMock, patch, call 4 | from modules.hostgroups import Hostgroup 5 | from modules.exceptions import HostgroupError 6 | 7 | 8 | class TestHostgroups(unittest.TestCase): 9 | """Test class for Hostgroup functionality.""" 10 | 11 | def setUp(self): 12 | """Set up test fixtures.""" 13 | # Create mock logger 14 | self.mock_logger = MagicMock() 15 | 16 | # *** Mock NetBox Device setup *** 17 | # Create mock device with all properties 18 | self.mock_device = MagicMock() 19 | self.mock_device.name = "test-device" 20 | 21 | # Set up site information 22 | site = MagicMock() 23 | site.name = "TestSite" 24 | 25 | # Set up region information 26 | region = MagicMock() 27 | region.name = "TestRegion" 28 | # Ensure region string representation returns the name 29 | region.__str__.return_value = "TestRegion" 30 | site.region = region 31 | 32 | # Set up site group information 33 | site_group = MagicMock() 34 | site_group.name = "TestSiteGroup" 35 | # Ensure site group string representation returns the name 36 | site_group.__str__.return_value = "TestSiteGroup" 37 | site.group = site_group 38 | 39 | self.mock_device.site = site 40 | 41 | # Set up role information (varies based on NetBox version) 42 | self.mock_device_role = MagicMock() 43 | self.mock_device_role.name = "TestRole" 44 | # Ensure string representation returns the name 45 | self.mock_device_role.__str__.return_value = "TestRole" 46 | self.mock_device.device_role = self.mock_device_role 47 | self.mock_device.role = self.mock_device_role 48 | 49 | # Set up tenant information 50 | tenant = MagicMock() 51 | tenant.name = "TestTenant" 52 | # Ensure tenant string representation returns the name 53 | tenant.__str__.return_value = "TestTenant" 54 | tenant_group = MagicMock() 55 | tenant_group.name = "TestTenantGroup" 56 | # Ensure tenant group string representation returns the name 57 | tenant_group.__str__.return_value = "TestTenantGroup" 58 | tenant.group = tenant_group 59 | self.mock_device.tenant = tenant 60 | 61 | # Set up platform information 62 | platform = MagicMock() 63 | platform.name = "TestPlatform" 64 | self.mock_device.platform = platform 65 | 66 | # Device-specific properties 67 | device_type = MagicMock() 68 | manufacturer = MagicMock() 69 | manufacturer.name = "TestManufacturer" 70 | device_type.manufacturer = manufacturer 71 | self.mock_device.device_type = device_type 72 | 73 | location = MagicMock() 74 | location.name = "TestLocation" 75 | # Ensure location string representation returns the name 76 | location.__str__.return_value = "TestLocation" 77 | self.mock_device.location = location 78 | 79 | # Custom fields 80 | self.mock_device.custom_fields = {"test_cf": "TestCF"} 81 | 82 | # *** Mock NetBox VM setup *** 83 | # Create mock VM with all properties 84 | self.mock_vm = MagicMock() 85 | self.mock_vm.name = "test-vm" 86 | 87 | # Reuse site from device 88 | self.mock_vm.site = site 89 | 90 | # Set up role for VM 91 | self.mock_vm.role = self.mock_device_role 92 | 93 | # Set up tenant for VM (same as device) 94 | self.mock_vm.tenant = tenant 95 | 96 | # Set up platform for VM (same as device) 97 | self.mock_vm.platform = platform 98 | 99 | # VM-specific properties 100 | cluster = MagicMock() 101 | cluster.name = "TestCluster" 102 | cluster_type = MagicMock() 103 | cluster_type.name = "TestClusterType" 104 | cluster.type = cluster_type 105 | self.mock_vm.cluster = cluster 106 | 107 | # Custom fields 108 | self.mock_vm.custom_fields = {"test_cf": "TestCF"} 109 | 110 | # Mock data for nesting tests 111 | self.mock_regions_data = [ 112 | {"name": "ParentRegion", "parent": None, "_depth": 0}, 113 | {"name": "TestRegion", "parent": "ParentRegion", "_depth": 1} 114 | ] 115 | 116 | self.mock_groups_data = [ 117 | {"name": "ParentSiteGroup", "parent": None, "_depth": 0}, 118 | {"name": "TestSiteGroup", "parent": "ParentSiteGroup", "_depth": 1} 119 | ] 120 | 121 | def test_device_hostgroup_creation(self): 122 | """Test basic device hostgroup creation.""" 123 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 124 | 125 | # Test the string representation 126 | self.assertEqual(str(hostgroup), "Hostgroup for dev test-device") 127 | 128 | # Check format options were set correctly 129 | self.assertEqual(hostgroup.format_options["site"], "TestSite") 130 | self.assertEqual(hostgroup.format_options["region"], "TestRegion") 131 | self.assertEqual(hostgroup.format_options["site_group"], "TestSiteGroup") 132 | self.assertEqual(hostgroup.format_options["role"], "TestRole") 133 | self.assertEqual(hostgroup.format_options["tenant"], "TestTenant") 134 | self.assertEqual(hostgroup.format_options["tenant_group"], "TestTenantGroup") 135 | self.assertEqual(hostgroup.format_options["platform"], "TestPlatform") 136 | self.assertEqual(hostgroup.format_options["manufacturer"], "TestManufacturer") 137 | self.assertEqual(hostgroup.format_options["location"], "TestLocation") 138 | 139 | def test_vm_hostgroup_creation(self): 140 | """Test basic VM hostgroup creation.""" 141 | hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger) 142 | 143 | # Test the string representation 144 | self.assertEqual(str(hostgroup), "Hostgroup for vm test-vm") 145 | 146 | # Check format options were set correctly 147 | self.assertEqual(hostgroup.format_options["site"], "TestSite") 148 | self.assertEqual(hostgroup.format_options["region"], "TestRegion") 149 | self.assertEqual(hostgroup.format_options["site_group"], "TestSiteGroup") 150 | self.assertEqual(hostgroup.format_options["role"], "TestRole") 151 | self.assertEqual(hostgroup.format_options["tenant"], "TestTenant") 152 | self.assertEqual(hostgroup.format_options["tenant_group"], "TestTenantGroup") 153 | self.assertEqual(hostgroup.format_options["platform"], "TestPlatform") 154 | self.assertEqual(hostgroup.format_options["cluster"], "TestCluster") 155 | self.assertEqual(hostgroup.format_options["cluster_type"], "TestClusterType") 156 | 157 | def test_invalid_object_type(self): 158 | """Test that an invalid object type raises an exception.""" 159 | with self.assertRaises(HostgroupError): 160 | Hostgroup("invalid", self.mock_device, "4.0", self.mock_logger) 161 | 162 | def test_device_hostgroup_formats(self): 163 | """Test different hostgroup formats for devices.""" 164 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 165 | 166 | # Custom format: site/region 167 | custom_result = hostgroup.generate("site/region") 168 | self.assertEqual(custom_result, "TestSite/TestRegion") 169 | 170 | # Custom format: site/tenant/platform/location 171 | complex_result = hostgroup.generate("site/tenant/platform/location") 172 | self.assertEqual(complex_result, "TestSite/TestTenant/TestPlatform/TestLocation") 173 | 174 | def test_vm_hostgroup_formats(self): 175 | """Test different hostgroup formats for VMs.""" 176 | hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger) 177 | 178 | # Default format: cluster/role 179 | default_result = hostgroup.generate("cluster/role") 180 | self.assertEqual(default_result, "TestCluster/TestRole") 181 | 182 | # Custom format: site/tenant 183 | custom_result = hostgroup.generate("site/tenant") 184 | self.assertEqual(custom_result, "TestSite/TestTenant") 185 | 186 | # Custom format: cluster/cluster_type/platform 187 | complex_result = hostgroup.generate("cluster/cluster_type/platform") 188 | self.assertEqual(complex_result, "TestCluster/TestClusterType/TestPlatform") 189 | 190 | def test_device_netbox_version_differences(self): 191 | """Test hostgroup generation with different NetBox versions.""" 192 | # NetBox v2.x 193 | hostgroup_v2 = Hostgroup("dev", self.mock_device, "2.11", self.mock_logger) 194 | self.assertEqual(hostgroup_v2.format_options["role"], "TestRole") 195 | 196 | # NetBox v3.x 197 | hostgroup_v3 = Hostgroup("dev", self.mock_device, "3.5", self.mock_logger) 198 | self.assertEqual(hostgroup_v3.format_options["role"], "TestRole") 199 | 200 | # NetBox v4.x (already tested in other methods) 201 | 202 | def test_custom_field_lookup(self): 203 | """Test custom field lookup functionality.""" 204 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 205 | 206 | # Test custom field exists and is populated 207 | cf_result = hostgroup.custom_field_lookup("test_cf") 208 | self.assertTrue(cf_result["result"]) 209 | self.assertEqual(cf_result["cf"], "TestCF") 210 | 211 | # Test custom field doesn't exist 212 | cf_result = hostgroup.custom_field_lookup("nonexistent_cf") 213 | self.assertFalse(cf_result["result"]) 214 | self.assertIsNone(cf_result["cf"]) 215 | 216 | def test_hostgroup_with_custom_field(self): 217 | """Test hostgroup generation including a custom field.""" 218 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 219 | 220 | # Generate with custom field included 221 | result = hostgroup.generate("site/test_cf/role") 222 | self.assertEqual(result, "TestSite/TestCF/TestRole") 223 | 224 | def test_missing_hostgroup_format_item(self): 225 | """Test handling of missing hostgroup format items.""" 226 | # Create a device with minimal attributes 227 | minimal_device = MagicMock() 228 | minimal_device.name = "minimal-device" 229 | minimal_device.site = None 230 | minimal_device.tenant = None 231 | minimal_device.platform = None 232 | minimal_device.custom_fields = {} 233 | 234 | # Create role 235 | role = MagicMock() 236 | role.name = "MinimalRole" 237 | minimal_device.role = role 238 | 239 | # Create device_type with manufacturer 240 | device_type = MagicMock() 241 | manufacturer = MagicMock() 242 | manufacturer.name = "MinimalManufacturer" 243 | device_type.manufacturer = manufacturer 244 | minimal_device.device_type = device_type 245 | 246 | # Create hostgroup 247 | hostgroup = Hostgroup("dev", minimal_device, "4.0", self.mock_logger) 248 | 249 | # Generate with default format 250 | result = hostgroup.generate("site/manufacturer/role") 251 | # Site is missing, so only manufacturer and role should be included 252 | self.assertEqual(result, "MinimalManufacturer/MinimalRole") 253 | 254 | # Test with invalid format 255 | with self.assertRaises(HostgroupError): 256 | hostgroup.generate("site/nonexistent/role") 257 | 258 | def test_nested_region_hostgroups(self): 259 | """Test hostgroup generation with nested regions.""" 260 | # Mock the build_path function to return a predictable result 261 | with patch('modules.hostgroups.build_path') as mock_build_path: 262 | # Configure the mock to return a list of regions in the path 263 | mock_build_path.return_value = ["ParentRegion", "TestRegion"] 264 | 265 | # Create hostgroup with nested regions enabled 266 | hostgroup = Hostgroup( 267 | "dev", 268 | self.mock_device, 269 | "4.0", 270 | self.mock_logger, 271 | nested_region_flag=True, 272 | nb_regions=self.mock_regions_data 273 | ) 274 | 275 | # Generate hostgroup with region 276 | result = hostgroup.generate("site/region/role") 277 | # Should include the parent region 278 | self.assertEqual(result, "TestSite/ParentRegion/TestRegion/TestRole") 279 | 280 | def test_nested_sitegroup_hostgroups(self): 281 | """Test hostgroup generation with nested site groups.""" 282 | # Mock the build_path function to return a predictable result 283 | with patch('modules.hostgroups.build_path') as mock_build_path: 284 | # Configure the mock to return a list of site groups in the path 285 | mock_build_path.return_value = ["ParentSiteGroup", "TestSiteGroup"] 286 | 287 | # Create hostgroup with nested site groups enabled 288 | hostgroup = Hostgroup( 289 | "dev", 290 | self.mock_device, 291 | "4.0", 292 | self.mock_logger, 293 | nested_sitegroup_flag=True, 294 | nb_groups=self.mock_groups_data 295 | ) 296 | 297 | # Generate hostgroup with site_group 298 | result = hostgroup.generate("site/site_group/role") 299 | # Should include the parent site group 300 | self.assertEqual(result, "TestSite/ParentSiteGroup/TestSiteGroup/TestRole") 301 | 302 | 303 | def test_list_formatoptions(self): 304 | """Test the list_formatoptions method for debugging.""" 305 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 306 | 307 | # Patch sys.stdout to capture print output 308 | with patch('sys.stdout') as mock_stdout: 309 | hostgroup.list_formatoptions() 310 | 311 | # Check that print was called with expected output 312 | calls = [call.write(f"The following options are available for host test-device"), 313 | call.write('\n')] 314 | mock_stdout.assert_has_calls(calls, any_order=True) 315 | 316 | def test_vm_list_based_hostgroup_format(self): 317 | """Test VM hostgroup generation with a list-based format.""" 318 | hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger) 319 | 320 | # Test with a list of format strings 321 | format_list = ["platform", "role", "cluster_type/cluster"] 322 | 323 | # Generate hostgroups for each format in the list 324 | hostgroups = [] 325 | for fmt in format_list: 326 | result = hostgroup.generate(fmt) 327 | if result: # Only add non-None results 328 | hostgroups.append(result) 329 | 330 | # Verify each expected hostgroup is generated 331 | self.assertEqual(len(hostgroups), 3) # Should have 3 hostgroups 332 | self.assertIn("TestPlatform", hostgroups) 333 | self.assertIn("TestRole", hostgroups) 334 | self.assertIn("TestClusterType/TestCluster", hostgroups) 335 | 336 | def test_nested_format_splitting(self): 337 | """Test that formats with slashes correctly split and resolve each component.""" 338 | hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger) 339 | 340 | # Test a format with slashes that should be split 341 | complex_format = "cluster_type/cluster" 342 | result = hostgroup.generate(complex_format) 343 | 344 | # Verify the format is correctly split and each component resolved 345 | self.assertEqual(result, "TestClusterType/TestCluster") 346 | 347 | def test_multiple_hostgroup_formats_device(self): 348 | """Test device hostgroup generation with multiple formats.""" 349 | hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger) 350 | 351 | # Test with various formats that would be in a list 352 | formats = [ 353 | "site", 354 | "manufacturer/role", 355 | "platform/location", 356 | "tenant_group/tenant" 357 | ] 358 | 359 | # Generate and check each format 360 | results = {} 361 | for fmt in formats: 362 | results[fmt] = hostgroup.generate(fmt) 363 | 364 | # Verify results 365 | self.assertEqual(results["site"], "TestSite") 366 | self.assertEqual(results["manufacturer/role"], "TestManufacturer/TestRole") 367 | self.assertEqual(results["platform/location"], "TestPlatform/TestLocation") 368 | self.assertEqual(results["tenant_group/tenant"], "TestTenantGroup/TestTenant") 369 | 370 | 371 | if __name__ == "__main__": 372 | unittest.main() 373 | -------------------------------------------------------------------------------- /tests/test_physical_device.py: -------------------------------------------------------------------------------- 1 | """Tests for the PhysicalDevice class in the device module.""" 2 | import unittest 3 | from unittest.mock import MagicMock, patch 4 | from modules.device import PhysicalDevice 5 | from modules.exceptions import TemplateError, SyncInventoryError 6 | 7 | 8 | class TestPhysicalDevice(unittest.TestCase): 9 | """Test class for PhysicalDevice functionality.""" 10 | 11 | def setUp(self): 12 | """Set up test fixtures.""" 13 | # Create mock NetBox device 14 | self.mock_nb_device = MagicMock() 15 | self.mock_nb_device.id = 123 16 | self.mock_nb_device.name = "test-device" 17 | self.mock_nb_device.status.label = "Active" 18 | self.mock_nb_device.custom_fields = {"zabbix_hostid": None} 19 | self.mock_nb_device.config_context = {} 20 | 21 | # Set up a primary IP 22 | primary_ip = MagicMock() 23 | primary_ip.address = "192.168.1.1/24" 24 | self.mock_nb_device.primary_ip = primary_ip 25 | 26 | # Create mock Zabbix API 27 | self.mock_zabbix = MagicMock() 28 | self.mock_zabbix.version = "6.0" 29 | 30 | # Mock NetBox journal class 31 | self.mock_nb_journal = MagicMock() 32 | 33 | # Create logger mock 34 | self.mock_logger = MagicMock() 35 | 36 | # Create PhysicalDevice instance with mocks 37 | with patch('modules.device.config', 38 | {"device_cf": "zabbix_hostid", 39 | "template_cf": "zabbix_template", 40 | "templates_config_context": False, 41 | "templates_config_context_overrule": False, 42 | "traverse_regions": False, 43 | "traverse_site_groups": False, 44 | "inventory_mode": "disabled", 45 | "inventory_sync": False, 46 | "device_inventory_map": {} 47 | }): 48 | self.device = PhysicalDevice( 49 | self.mock_nb_device, 50 | self.mock_zabbix, 51 | self.mock_nb_journal, 52 | "3.0", 53 | journal=True, 54 | logger=self.mock_logger 55 | ) 56 | 57 | def test_init(self): 58 | """Test the initialization of the PhysicalDevice class.""" 59 | # Check that basic properties are set correctly 60 | self.assertEqual(self.device.name, "test-device") 61 | self.assertEqual(self.device.id, 123) 62 | self.assertEqual(self.device.status, "Active") 63 | self.assertEqual(self.device.ip, "192.168.1.1") 64 | self.assertEqual(self.device.cidr, "192.168.1.1/24") 65 | 66 | def test_init_no_primary_ip(self): 67 | """Test initialization when device has no primary IP.""" 68 | # Set primary_ip to None 69 | self.mock_nb_device.primary_ip = None 70 | 71 | # Creating device should raise SyncInventoryError 72 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 73 | with self.assertRaises(SyncInventoryError): 74 | PhysicalDevice( 75 | self.mock_nb_device, 76 | self.mock_zabbix, 77 | self.mock_nb_journal, 78 | "3.0", 79 | logger=self.mock_logger 80 | ) 81 | 82 | def test_set_basics_with_special_characters(self): 83 | """Test _setBasics when device name contains special characters.""" 84 | # Set name with special characters that 85 | # will actually trigger the special character detection 86 | self.mock_nb_device.name = "test-devïce" 87 | 88 | # We need to patch the search function to simulate finding special characters 89 | with patch('modules.device.search') as mock_search, \ 90 | patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 91 | # Make the search function return True to simulate special characters 92 | mock_search.return_value = True 93 | 94 | device = PhysicalDevice( 95 | self.mock_nb_device, 96 | self.mock_zabbix, 97 | self.mock_nb_journal, 98 | "3.0", 99 | logger=self.mock_logger 100 | ) 101 | 102 | # With the mocked search function, the name should be changed to NETBOX_ID format 103 | self.assertEqual(device.name, f"NETBOX_ID{self.mock_nb_device.id}") 104 | # And visible_name should be set to the original name 105 | self.assertEqual(device.visible_name, "test-devïce") 106 | # use_visible_name flag should be set 107 | self.assertTrue(device.use_visible_name) 108 | 109 | def test_get_templates_context(self): 110 | """Test get_templates_context with valid config.""" 111 | # Set up config_context with valid template data 112 | self.mock_nb_device.config_context = { 113 | "zabbix": { 114 | "templates": ["Template1", "Template2"] 115 | } 116 | } 117 | 118 | # Create device with the updated mock 119 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 120 | device = PhysicalDevice( 121 | self.mock_nb_device, 122 | self.mock_zabbix, 123 | self.mock_nb_journal, 124 | "3.0", 125 | logger=self.mock_logger 126 | ) 127 | 128 | # Test that templates are returned correctly 129 | templates = device.get_templates_context() 130 | self.assertEqual(templates, ["Template1", "Template2"]) 131 | 132 | def test_get_templates_context_with_string(self): 133 | """Test get_templates_context with a string instead of list.""" 134 | # Set up config_context with a string template 135 | self.mock_nb_device.config_context = { 136 | "zabbix": { 137 | "templates": "Template1" 138 | } 139 | } 140 | 141 | # Create device with the updated mock 142 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 143 | device = PhysicalDevice( 144 | self.mock_nb_device, 145 | self.mock_zabbix, 146 | self.mock_nb_journal, 147 | "3.0", 148 | logger=self.mock_logger 149 | ) 150 | 151 | # Test that template is wrapped in a list 152 | templates = device.get_templates_context() 153 | self.assertEqual(templates, ["Template1"]) 154 | 155 | def test_get_templates_context_no_zabbix_key(self): 156 | """Test get_templates_context when zabbix key is missing.""" 157 | # Set up config_context without zabbix key 158 | self.mock_nb_device.config_context = {} 159 | 160 | # Create device with the updated mock 161 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 162 | device = PhysicalDevice( 163 | self.mock_nb_device, 164 | self.mock_zabbix, 165 | self.mock_nb_journal, 166 | "3.0", 167 | logger=self.mock_logger 168 | ) 169 | 170 | # Test that TemplateError is raised 171 | with self.assertRaises(TemplateError): 172 | device.get_templates_context() 173 | 174 | def test_get_templates_context_no_templates_key(self): 175 | """Test get_templates_context when templates key is missing.""" 176 | # Set up config_context without templates key 177 | self.mock_nb_device.config_context = {"zabbix": {}} 178 | 179 | # Create device with the updated mock 180 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 181 | device = PhysicalDevice( 182 | self.mock_nb_device, 183 | self.mock_zabbix, 184 | self.mock_nb_journal, 185 | "3.0", 186 | logger=self.mock_logger 187 | ) 188 | 189 | # Test that TemplateError is raised 190 | with self.assertRaises(TemplateError): 191 | device.get_templates_context() 192 | 193 | def test_set_template_with_config_context(self): 194 | """Test set_template with templates_config_context=True.""" 195 | # Set up config_context with templates 196 | self.mock_nb_device.config_context = { 197 | "zabbix": { 198 | "templates": ["Template1"] 199 | } 200 | } 201 | 202 | # Mock get_templates_context to return expected templates 203 | with patch.object(PhysicalDevice, 'get_templates_context', return_value=["Template1"]): 204 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 205 | device = PhysicalDevice( 206 | self.mock_nb_device, 207 | self.mock_zabbix, 208 | self.mock_nb_journal, 209 | "3.0", 210 | logger=self.mock_logger 211 | ) 212 | 213 | # Call set_template with prefer_config_context=True 214 | result = device.set_template(prefer_config_context=True, overrule_custom=False) 215 | 216 | # Check result and template names 217 | self.assertTrue(result) 218 | self.assertEqual(device.zbx_template_names, ["Template1"]) 219 | 220 | def test_set_inventory_disabled_mode(self): 221 | """Test set_inventory with inventory_mode=disabled.""" 222 | # Configure with disabled inventory mode 223 | config_patch = { 224 | "device_cf": "zabbix_hostid", 225 | "inventory_mode": "disabled", 226 | "inventory_sync": False 227 | } 228 | 229 | with patch('modules.device.config', config_patch): 230 | device = PhysicalDevice( 231 | self.mock_nb_device, 232 | self.mock_zabbix, 233 | self.mock_nb_journal, 234 | "3.0", 235 | logger=self.mock_logger 236 | ) 237 | 238 | # Call set_inventory with the config patch still active 239 | with patch('modules.device.config', config_patch): 240 | result = device.set_inventory({}) 241 | 242 | # Check result 243 | self.assertTrue(result) 244 | # Default value for disabled inventory 245 | self.assertEqual(device.inventory_mode, -1) 246 | 247 | def test_set_inventory_manual_mode(self): 248 | """Test set_inventory with inventory_mode=manual.""" 249 | # Configure with manual inventory mode 250 | config_patch = { 251 | "device_cf": "zabbix_hostid", 252 | "inventory_mode": "manual", 253 | "inventory_sync": False 254 | } 255 | 256 | with patch('modules.device.config', config_patch): 257 | device = PhysicalDevice( 258 | self.mock_nb_device, 259 | self.mock_zabbix, 260 | self.mock_nb_journal, 261 | "3.0", 262 | logger=self.mock_logger 263 | ) 264 | 265 | # Call set_inventory with the config patch still active 266 | with patch('modules.device.config', config_patch): 267 | result = device.set_inventory({}) 268 | 269 | # Check result 270 | self.assertTrue(result) 271 | self.assertEqual(device.inventory_mode, 0) # Manual mode 272 | 273 | def test_set_inventory_automatic_mode(self): 274 | """Test set_inventory with inventory_mode=automatic.""" 275 | # Configure with automatic inventory mode 276 | config_patch = { 277 | "device_cf": "zabbix_hostid", 278 | "inventory_mode": "automatic", 279 | "inventory_sync": False 280 | } 281 | 282 | with patch('modules.device.config', config_patch): 283 | device = PhysicalDevice( 284 | self.mock_nb_device, 285 | self.mock_zabbix, 286 | self.mock_nb_journal, 287 | "3.0", 288 | logger=self.mock_logger 289 | ) 290 | 291 | # Call set_inventory with the config patch still active 292 | with patch('modules.device.config', config_patch): 293 | result = device.set_inventory({}) 294 | 295 | # Check result 296 | self.assertTrue(result) 297 | self.assertEqual(device.inventory_mode, 1) # Automatic mode 298 | 299 | def test_set_inventory_with_inventory_sync(self): 300 | """Test set_inventory with inventory_sync=True.""" 301 | # Configure with inventory sync enabled 302 | config_patch = { 303 | "device_cf": "zabbix_hostid", 304 | "inventory_mode": "manual", 305 | "inventory_sync": True, 306 | "device_inventory_map": { 307 | "name": "name", 308 | "serial": "serialno_a" 309 | } 310 | } 311 | 312 | with patch('modules.device.config', config_patch): 313 | device = PhysicalDevice( 314 | self.mock_nb_device, 315 | self.mock_zabbix, 316 | self.mock_nb_journal, 317 | "3.0", 318 | logger=self.mock_logger 319 | ) 320 | 321 | # Create a mock device with the required attributes 322 | mock_device_data = { 323 | "name": "test-device", 324 | "serial": "ABC123" 325 | } 326 | 327 | # Call set_inventory with the config patch still active 328 | with patch('modules.device.config', config_patch): 329 | result = device.set_inventory(mock_device_data) 330 | 331 | # Check result 332 | self.assertTrue(result) 333 | self.assertEqual(device.inventory_mode, 0) # Manual mode 334 | self.assertEqual(device.inventory, { 335 | "name": "test-device", 336 | "serialno_a": "ABC123" 337 | }) 338 | 339 | def test_iscluster_true(self): 340 | """Test isCluster when device is part of a cluster.""" 341 | # Set up virtual_chassis 342 | self.mock_nb_device.virtual_chassis = MagicMock() 343 | 344 | # Create device with the updated mock 345 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 346 | device = PhysicalDevice( 347 | self.mock_nb_device, 348 | self.mock_zabbix, 349 | self.mock_nb_journal, 350 | "3.0", 351 | logger=self.mock_logger 352 | ) 353 | 354 | # Check isCluster result 355 | self.assertTrue(device.isCluster()) 356 | 357 | def test_is_cluster_false(self): 358 | """Test isCluster when device is not part of a cluster.""" 359 | # Set virtual_chassis to None 360 | self.mock_nb_device.virtual_chassis = None 361 | 362 | # Create device with the updated mock 363 | with patch('modules.device.config', {"device_cf": "zabbix_hostid"}): 364 | device = PhysicalDevice( 365 | self.mock_nb_device, 366 | self.mock_zabbix, 367 | self.mock_nb_journal, 368 | "3.0", 369 | logger=self.mock_logger 370 | ) 371 | 372 | # Check isCluster result 373 | self.assertFalse(device.isCluster()) 374 | 375 | 376 | def test_promote_master_device_primary(self): 377 | """Test promoteMasterDevice when device is primary in cluster.""" 378 | # Set up virtual chassis with master device 379 | mock_vc = MagicMock() 380 | mock_vc.name = "virtual-chassis-1" 381 | mock_master = MagicMock() 382 | mock_master.id = self.mock_nb_device.id # Set master ID to match the current device 383 | mock_vc.master = mock_master 384 | self.mock_nb_device.virtual_chassis = mock_vc 385 | 386 | # Create device with the updated mock 387 | device = PhysicalDevice( 388 | self.mock_nb_device, 389 | self.mock_zabbix, 390 | self.mock_nb_journal, 391 | "3.0", 392 | logger=self.mock_logger 393 | ) 394 | 395 | # Call promoteMasterDevice and check the result 396 | result = device.promoteMasterDevice() 397 | 398 | # Should return True for primary device 399 | self.assertTrue(result) 400 | # Device name should be updated to virtual chassis name 401 | self.assertEqual(device.name, "virtual-chassis-1") 402 | 403 | 404 | def test_promote_master_device_secondary(self): 405 | """Test promoteMasterDevice when device is secondary in cluster.""" 406 | # Set up virtual chassis with a different master device 407 | mock_vc = MagicMock() 408 | mock_vc.name = "virtual-chassis-1" 409 | mock_master = MagicMock() 410 | mock_master.id = self.mock_nb_device.id + 1 # Different ID than the current device 411 | mock_vc.master = mock_master 412 | self.mock_nb_device.virtual_chassis = mock_vc 413 | 414 | # Create device with the updated mock 415 | device = PhysicalDevice( 416 | self.mock_nb_device, 417 | self.mock_zabbix, 418 | self.mock_nb_journal, 419 | "3.0", 420 | logger=self.mock_logger 421 | ) 422 | 423 | # Call promoteMasterDevice and check the result 424 | result = device.promoteMasterDevice() 425 | 426 | # Should return False for secondary device 427 | self.assertFalse(result) 428 | # Device name should not be modified 429 | self.assertEqual(device.name, "test-device") 430 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox to Zabbix synchronization 2 | 3 | A script to create, update and delete Zabbix hosts using NetBox device objects. Tested and compatible with all [currently supported Zabbix releases](https://www.zabbix.com/life_cycle_and_release_policy). 4 | 5 | ## Installation via Docker 6 | 7 | To pull the latest stable version to your local cache, use the following docker 8 | pull command: 9 | 10 | ```bash 11 | docker pull ghcr.io/thenetworkguy/netbox-zabbix-sync:main 12 | ``` 13 | 14 | Make sure to specify the needed environment variables for the script to work 15 | (see [here](#set-environment-variables)) on the command line or use an 16 | [env file](https://docs.docker.com/reference/cli/docker/container/run/#env). 17 | 18 | ```bash 19 | docker run -d -t -i -e ZABBIX_HOST='https://zabbix.local' \ 20 | -e ZABBIX_TOKEN='othersecrettoken' \ 21 | -e NETBOX_HOST='https://netbox.local' \ 22 | -e NETBOX_TOKEN='secrettoken' \ 23 | --name netbox-zabbix-sync ghcr.io/thenetworkguy/netbox-zabbix-sync:main 24 | ``` 25 | 26 | This should run a one-time sync. You can check the sync with 27 | `docker logs netbox-zabbix-sync`. 28 | 29 | The image uses the default `config.py` for its configuration, you can use a 30 | volume mount in the docker run command to override with your own config file if 31 | needed (see [config file](#config-file)): 32 | 33 | ```bash 34 | docker run -d -t -i -v $(pwd)/config.py:/opt/netbox-zabbix/config.py ... 35 | ``` 36 | 37 | ## Installation from Source 38 | 39 | ### Cloning the repository 40 | 41 | ```bash 42 | git clone https://github.com/TheNetworkGuy/netbox-zabbix-sync.git 43 | ``` 44 | 45 | ### Packages 46 | 47 | Make sure that you have a python environment with the following packages 48 | installed. You can also use the `requirements.txt` file for installation with 49 | pip. 50 | 51 | ```sh 52 | # Packages: 53 | pynetbox 54 | zabbix-utils 55 | 56 | # Install them through requirements.txt from a venv: 57 | virtualenv .venv 58 | source .venv/bin/activate 59 | .venv/bin/pip --require-virtualenv install -r requirements.txt 60 | ``` 61 | 62 | ### Config file 63 | 64 | First time user? Copy the `config.py.example` file to `config.py`. This file is 65 | used for modifying filters and setting variables such as custom field names. 66 | 67 | ```sh 68 | cp config.py.example config.py 69 | ``` 70 | 71 | ### Set environment variables 72 | 73 | Set the following environment variables: 74 | 75 | ```bash 76 | ZABBIX_HOST="https://zabbix.local" 77 | ZABBIX_USER="username" 78 | ZABBIX_PASS="Password" 79 | NETBOX_HOST="https://netbox.local" 80 | NETBOX_TOKEN="secrettoken" 81 | ``` 82 | 83 | Or, you can use a Zabbix API token to login instead of using a username and 84 | password. In that case `ZABBIX_USER` and `ZABBIX_PASS` will be ignored. 85 | 86 | ```bash 87 | ZABBIX_TOKEN=othersecrettoken 88 | ``` 89 | 90 | If you are using custom SSL certificates for NetBox and/or Zabbix, you can set 91 | the following environment variable to the path of your CA bundle file: 92 | 93 | ```sh 94 | export REQUESTS_CA_BUNDLE=/path/to/your/ca-bundle.crt 95 | ``` 96 | 97 | ### NetBox custom fields 98 | 99 | Use the following custom fields in NetBox (if you are using config context for 100 | the template information then the zabbix_template field is not required): 101 | 102 | ``` 103 | * Type: Integer 104 | * Name: zabbix_hostid 105 | * Required: False 106 | * Default: null 107 | * Object: dcim > device 108 | ``` 109 | 110 | ``` 111 | * Type: Text 112 | * Name: zabbix_template 113 | * Required: False 114 | * Default: null 115 | * Object: dcim > device_type 116 | ``` 117 | 118 | You can make the `zabbix_hostid` field hidden or read-only to prevent human 119 | intervention. 120 | 121 | This is optional, but there may be cases where you want to leave it 122 | read-write in the UI. For example to manually change or clear the ID and re-run a sync. 123 | 124 | ## Virtual Machine (VM) Syncing 125 | 126 | In order to use VM syncing, make sure that the `zabbix_id` custom field is also 127 | present on Virtual machine objects in NetBox. 128 | 129 | Use the `config.py` file and set the `sync_vms` variable to `True`. 130 | 131 | You can set the `vm_hostgroup_format` variable to a customizable value for VM 132 | hostgroups. The default is `cluster_type/cluster/role`. 133 | 134 | To enable filtering for VM's, check the `nb_vm_filter` variable out. It works 135 | the same as with the device filter (see documentation under "Hostgroup layout"). 136 | Note that not all filtering capabilities and properties of devices are 137 | applicable to VM's and vice-versa. Check the NetBox API documentation to see 138 | which filtering options are available for each object type. 139 | 140 | ## Config file 141 | 142 | ### Hostgroup 143 | 144 | Setting the `create_hostgroups` variable to `False` requires manual hostgroup 145 | creation for devices in a new category. I would recommend setting this variable 146 | to `True` since leaving it on `False` results in a lot of manual work. 147 | 148 | The format can be set with the `hostgroup_format` variable for devices and 149 | `vm_hostgroup_format` for virtual machines. 150 | 151 | Any nested parent hostgroups will also be created automatically. For instance 152 | the region `Berlin` with parent region `Germany` will create the hostgroup 153 | `Germany/Berlin`. 154 | 155 | Make sure that the Zabbix user has proper permissions to create hosts. The 156 | hostgroups are in a nested format. This means that proper permissions only need 157 | to be applied to the site name hostgroup and cascaded to any child hostgroups. 158 | 159 | #### Layout 160 | 161 | The default hostgroup layout is "site/manufacturer/device_role". You can change 162 | this behaviour with the hostgroup_format variable. The following values can be 163 | used: 164 | 165 | **Both devices and virtual machines** 166 | 167 | | name | description | 168 | | ------------- | ------------------------------------------------------------------------------------ | 169 | | role | Role name of a device or VM | 170 | | region | The region name | 171 | | site | Site name | 172 | | site_group | Site group name | 173 | | tenant | Tenant name | 174 | | tenant_group | Tenant group name | 175 | | platform | Software platform of a device or VM | 176 | | custom fields | See the section "Layout -> Custom Fields" to use custom fields as hostgroup variable | 177 | 178 | **Only for devices** 179 | 180 | | name | description | 181 | | ------------ | ------------------------ | 182 | | location | The device location name | 183 | | manufacturer | Device manufacturer name | 184 | | rack | Rack | 185 | 186 | **Only for VMs** 187 | 188 | | name | description | 189 | | ------------ | --------------- | 190 | | cluster | VM cluster name | 191 | | cluster_type | VM cluster type | 192 | | device | parent device | 193 | 194 | You can specify the value separated by a "/" like so: 195 | 196 | ```python 197 | hostgroup_format = "tenant/site/location/role" 198 | ``` 199 | 200 | You can also provice a list of groups like so: 201 | 202 | ```python 203 | hostgroup_format = ["region/site_group/site", "role", "tenant_group/tenant"] 204 | ``` 205 | 206 | 207 | **Group traversal** 208 | 209 | The default behaviour for `region` is to only use the directly assigned region 210 | in the rendered hostgroup name. However, by setting `traverse_region` to `True` 211 | in `config.py` the script will render a full region path of all parent regions 212 | for the hostgroup name. `traverse_site_groups` controls the same behaviour for 213 | site_groups. 214 | 215 | **Hardcoded text** 216 | 217 | You can add hardcoded text in the hostgroup format by using quotes, this will 218 | insert the literal text: 219 | 220 | ```python 221 | hostgroup_format = "'MyDevices'/location/role" 222 | ``` 223 | 224 | In this case, the prefix MyDevices will be used for all generated groups. 225 | 226 | **Custom fields** 227 | 228 | You can use the value of custom fields for hostgroup generation. This allows 229 | more freedom and even allows a full static mapping instead of a dynamic rendered 230 | hostgroup name. 231 | 232 | For instance a custom field with the name `mycustomfieldname` and type string 233 | has the following values for 2 devices: 234 | 235 | ``` 236 | Device A has the value Train for custom field mycustomfieldname. 237 | Device B has the value Bus for custom field mycustomfieldname. 238 | Both devices are located in the site Paris. 239 | ``` 240 | 241 | With the hostgroup format `site/mycustomfieldname` the following hostgroups will 242 | be generated: 243 | 244 | ``` 245 | Device A: Paris/Train 246 | Device B: Paris/Bus 247 | ``` 248 | 249 | **Empty variables or hostgroups** 250 | 251 | Should the content of a variable be empty, then the hostgroup position is 252 | skipped. 253 | 254 | For example, consider the following scenario with 2 devices, both the same 255 | device type and site. One of them is linked to a tenant, the other one does not 256 | have a relationship with a tenant. 257 | 258 | - Device_role: PDU 259 | - Site: HQ-AMS 260 | 261 | ```python 262 | hostgroup_format = "site/tenant/role" 263 | ``` 264 | 265 | When running the script like above, the following hostgroup (HG) will be 266 | generated for both hosts: 267 | 268 | - Device A with no relationship with a tenant: HQ-AMS/PDU 269 | - Device B with a relationship to tenant "Fork Industries": HQ-AMS/Fork 270 | Industries/PDU 271 | 272 | The same logic applies to custom fields being used in the HG format: 273 | 274 | ```python 275 | hostgroup_format = "site/mycustomfieldname" 276 | ``` 277 | 278 | For device A with the value "ABC123" in the custom field "mycustomfieldname" -> 279 | HQ-AMS/ABC123 For a device which does not have a value in the custom field 280 | "mycustomfieldname" -> HQ-AMS 281 | 282 | Should there be a scenario where a custom field does not have a value under a 283 | device, and the HG format only uses this single variable, then this will result 284 | in an error: 285 | 286 | ``` 287 | hostgroup_format = "mycustomfieldname" 288 | 289 | NetBox-Zabbix-sync - ERROR - ESXI1 has no reliable hostgroup. This is most likely due to the use of custom fields that are empty. 290 | ``` 291 | 292 | ### Extended site properties 293 | 294 | By default, NetBox will only return the following properties under the 'site' key for a device: 295 | 296 | - site id 297 | - (api) url 298 | - display name 299 | - name 300 | - slug 301 | - description 302 | 303 | However, NetBox-Zabbix-Sync allows you to extend these site properties with the full site information 304 | so you can use this data in inventory fields, tags and usermacros. 305 | 306 | To enable this functionality, enable the following setting in your configuration file: 307 | 308 | `extended_site_properties = True` 309 | 310 | Keep in mind that enabling this option will increase the number of API calls to your NetBox instance, 311 | this might impact performance on large syncs. 312 | 313 | ### Device status 314 | 315 | By setting a status on a NetBox device you determine how the host is added (or 316 | updated) in Zabbix. There are, by default, 3 options: 317 | 318 | - Delete the host from Zabbix (triggered by NetBox status "Decommissioning" and 319 | "Inventory") 320 | - Create the host in Zabbix but with a disabled status (Trigger by "Offline", 321 | "Planned", "Staged" and "Failed") 322 | - Create the host in Zabbix with an enabled status (For now only enabled with 323 | the "Active" status) 324 | 325 | You can modify this behaviour by changing the following list variables in the 326 | script: 327 | 328 | - `zabbix_device_removal` 329 | - `zabbix_device_disable` 330 | 331 | ### Zabbix Inventory 332 | 333 | This script allows you to enable the inventory on managed Zabbix hosts and sync 334 | NetBox device properties to the specified inventory fields. To map NetBox 335 | information to NetBox inventory fields, set `inventory_sync` to `True`. 336 | 337 | You can set the inventory mode to "disabled", "manual" or "automatic" with the 338 | `inventory_mode` variable. See 339 | [Zabbix Manual](https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory) 340 | for more information about the modes. 341 | 342 | Use the `device_inventory_map` variable to map which NetBox properties are used in 343 | which Zabbix Inventory fields. For nested properties, you can use the '/' 344 | seperator. For example, the following map will assign the custom field 345 | 'mycustomfield' to the 'alias' Zabbix inventory field: 346 | 347 | For Virtual Machines, use `vm_inventory_map`. 348 | 349 | ```python 350 | inventory_sync = True 351 | inventory_mode = "manual" 352 | device_inventory_map = {"custom_fields/mycustomfield/name": "alias"} 353 | vm_inventory_map = {"custom_fields/mycustomfield/name": "alias"} 354 | ``` 355 | 356 | See `config.py.example` for an extensive example map. Any Zabbix Inventory fields 357 | that are not included in the map will not be touched by the script, so you can 358 | safely add manual values or use items to automatically add values to other 359 | fields. 360 | 361 | ### Template source 362 | 363 | You can either use a NetBox device type custom field or NetBox config context 364 | for the Zabbix template information. 365 | 366 | Using a custom field allows for only one template. You can assign multiple 367 | templates to one host using the config context source. Should you make use of an 368 | advanced templating structure with lots of nesting then i would recommend 369 | sticking to the custom field. 370 | 371 | You can change the behaviour in the config file. By default this setting is 372 | false but you can set it to true to use config context: 373 | 374 | ```python 375 | templates_config_context = True 376 | ``` 377 | 378 | After that make sure that for each host there is at least one template defined 379 | in the config context in this format: 380 | 381 | ```json 382 | { 383 | "zabbix": { 384 | "templates": [ 385 | "TemplateA", 386 | "TemplateB", 387 | "TemplateC", 388 | "Template123" 389 | ] 390 | } 391 | } 392 | ``` 393 | 394 | You can also opt for the default device type custom field behaviour but with the 395 | added benefit of overwriting the template should a device in NetBox have a 396 | device specific context defined. In this case the device specific context 397 | template(s) will take priority over the device type custom field template. 398 | 399 | ```python 400 | templates_config_context_overrule = True 401 | ``` 402 | 403 | ### Tags 404 | 405 | This script can sync host tags to your Zabbix hosts for use in filtering, 406 | SLA calculations and event correlation. 407 | 408 | Tags can be synced from the following sources: 409 | 410 | 1. NetBox device/vm tags 411 | 2. NetBox config context 412 | 3. NetBox fields 413 | 414 | Syncing tags will override any tags that were set manually on the host, 415 | making NetBox the single source-of-truth for managing tags. 416 | 417 | To enable syncing, turn on `tag_sync` in the config file. 418 | By default, this script will modify tag names and tag values to lowercase. 419 | You can change this behavior by setting `tag_lower` to `False`. 420 | 421 | ```python 422 | tag_sync = True 423 | tag_lower = True 424 | ``` 425 | 426 | #### Device tags 427 | 428 | As NetBox doesn't follow the tag/value pattern for tags, we will need a tag 429 | name set to register the netbox tags. 430 | 431 | By default the tag name is "NetBox", but you can change this to whatever you want. 432 | The value for the tag can be set to 'name', 'display', or 'slug', which refers to the 433 | property of the NetBox tag object that will be used as the value in Zabbix. 434 | 435 | ```python 436 | tag_name = 'NetBox' 437 | tag_value = 'name' 438 | ``` 439 | 440 | #### Config context 441 | 442 | You can supply custom tags via config context by adding the following: 443 | 444 | ```json 445 | { 446 | "zabbix": { 447 | "tags": [ 448 | { 449 | "MyTagName": "MyTagValue" 450 | }, 451 | { 452 | "environment": "production" 453 | } 454 | ], 455 | } 456 | } 457 | ``` 458 | 459 | This will allow you to assign tags based on the config context rules. 460 | 461 | #### NetBox Field 462 | 463 | NetBox field can also be used as input for tags, just like inventory and usermacros. 464 | To enable syncing from fields, make sure to configure a `device_tag_map` and/or a `vm_tag_map`. 465 | 466 | ```python 467 | device_tag_map = {"site/name": "site", 468 | "rack/name": "rack", 469 | "platform/name": "target"} 470 | 471 | vm_tag_map = {"site/name": "site", 472 | "cluster/name": "cluster", 473 | "platform/name": "target"} 474 | ``` 475 | 476 | To turn off field syncing, set the maps to empty dictionaries: 477 | 478 | ```python 479 | device_tag_map = {} 480 | vm_tag_map = {} 481 | ``` 482 | 483 | 484 | ### Usermacros 485 | 486 | You can choose to use NetBox as a source for Host usermacros by 487 | enabling the following option in the configuration file: 488 | 489 | ```python 490 | usermacro_sync = True 491 | ``` 492 | 493 | Please be advised that enabling this option will _clear_ any usermacros 494 | manually set on the managed hosts and override them with the usermacros 495 | from NetBox. 496 | 497 | There are two NetBox sources that can be used to populate usermacros: 498 | 499 | 1. NetBox config context 500 | 2. NetBox fields 501 | 502 | #### Config context 503 | 504 | By defining a dictionary `usermacros` within the `zabbix` key in 505 | config context, you can dynamically assign usermacro values based on 506 | anything that you can target based on 507 | [config contexts](https://netboxlabs.com/docs/netbox/en/stable/features/context-data/) 508 | within NetBox. 509 | 510 | Through this method, it is possible to define the following types of usermacros: 511 | 512 | 1. Text 513 | 2. Secret 514 | 3. Vault 515 | 516 | The default macro type is text, if no `type` and `value` have been set. 517 | It is also possible to create usermacros with 518 | [context](https://www.zabbix.com/documentation/7.0/en/manual/config/macros/user_macros_context). 519 | 520 | Examples: 521 | 522 | ```json 523 | { 524 | "zabbix": { 525 | "usermacros": { 526 | "{$USER_MACRO}": "test value", 527 | "{$CONTEXT_MACRO:\"test\"}": "test value", 528 | "{$CONTEXT_REGEX_MACRO:regex:\".*\"}": "test value", 529 | "{$SECRET_MACRO}": { 530 | "type": "secret", 531 | "value": "PaSsPhRaSe" 532 | }, 533 | "{$VAULT_MACRO}": { 534 | "type": "vault", 535 | "value": "secret/vmware:password" 536 | }, 537 | "{$USER_MACRO2}": { 538 | "type": "text", 539 | "value": "another test value" 540 | } 541 | } 542 | } 543 | } 544 | 545 | ``` 546 | 547 | Please be aware that secret usermacros are only synced _once_ by default. 548 | This is the default behavior because Zabbix API won't return the value of 549 | secrets so the script cannot compare the values with those set in NetBox. 550 | 551 | If you update a secret usermacro value, just remove the value from the host 552 | in Zabbix and the new value will be synced during the next run. 553 | 554 | Alternatively, you can set the following option in the config file: 555 | 556 | ```python 557 | usermacro_sync = "full" 558 | ``` 559 | 560 | This will force a full usermacro sync on every run on hosts that have secret usermacros set. 561 | That way, you will know for sure the secret values are always up to date. 562 | 563 | Keep in mind that NetBox will show your secrets in plain text. 564 | If true secrecy is required, consider switching to 565 | [vault](https://www.zabbix.com/documentation/current/en/manual/config/macros/secret_macros#vault-secret) 566 | usermacros. 567 | 568 | #### Netbox Fields 569 | 570 | To use NetBox fields as a source for usermacros, you will need to set up usermacro maps 571 | for devices and/or virtual machines in the configuration file. 572 | This method only supports `text` type usermacros. 573 | 574 | For example: 575 | 576 | ```python 577 | usermacro_sync = True 578 | device_usermacro_map = {"serial": "{$HW_SERIAL}", 579 | "role/name": "{$DEV_ROLE}", 580 | "url": "{$NB_URL}", 581 | "id": "{$NB_ID}"} 582 | vm_usermacro_map = {"memory": "{$TOTAL_MEMORY}", 583 | "role/name": "{$DEV_ROLE}", 584 | "url": "{$NB_URL}", 585 | "id": "{$NB_ID}"} 586 | ``` 587 | 588 | 589 | 590 | ## Permissions 591 | 592 | ### NetBox 593 | 594 | Make sure that the NetBox user has proper permissions for device read and modify 595 | (modify to set the Zabbix HostID custom field) operations. The user should also 596 | have read-only access to the device types. 597 | 598 | ### Zabbix 599 | 600 | Make sure that the Zabbix user has permissions to read hostgroups and proxy 601 | servers. The user should have full rights on creating, modifying and deleting 602 | hosts. 603 | 604 | If you want to automatically create hostgroups then the create permission on 605 | host-groups should also be applied. 606 | 607 | ### Custom links 608 | 609 | To make the user experience easier you could add a custom link that redirects 610 | users to the Zabbix latest data. 611 | 612 | ``` 613 | * Name: zabbix_latestData 614 | * Text: {% if object.cf["zabbix_hostid"] %}Show host in Zabbix{% endif %} 615 | * URL: http://myzabbixserver.local/zabbix.php?action=latest.view&hostids[]={{ object.cf["zabbix_hostid"] }} 616 | ``` 617 | 618 | ## Running the script 619 | 620 | ``` 621 | python3 netbox_zabbix_sync.py 622 | ``` 623 | 624 | ### Flags 625 | 626 | | Flag | Option | Description | 627 | | ---- | --------- | ------------------------------------- | 628 | | -v | verbose | Log with info on. | 629 | | -vv | debug | Log with debugging on. | 630 | | -vvv | debug-all | Log with debugging on for all modules | 631 | 632 | ## Config context 633 | 634 | ### Zabbix proxy 635 | 636 | #### Config Context 637 | You can set the proxy for a device using the `proxy` key in config context. 638 | 639 | ```json 640 | { 641 | "zabbix": { 642 | "proxy": "yourawesomeproxy.local" 643 | } 644 | } 645 | ``` 646 | 647 | It is now possible to specify proxy groups with the introduction of Proxy groups 648 | in Zabbix 7. Specifying a group in the config context on older Zabbix releases 649 | will have no impact and the script will ignore the statement. 650 | 651 | ```json 652 | { 653 | "zabbix": { 654 | "proxy_group": "yourawesomeproxygroup.local" 655 | } 656 | } 657 | ``` 658 | 659 | The script will prefer groups when specifying both a proxy and group. This is 660 | done with the assumption that groups are more resilient and HA ready, making it 661 | a more logical choice to use for proxy linkage. This also makes migrating from a 662 | proxy to proxy group easier since the group take priority over the individual 663 | proxy. 664 | 665 | ```json 666 | { 667 | "zabbix": { 668 | "proxy": "yourawesomeproxy.local", 669 | "proxy_group": "yourawesomeproxygroup.local" 670 | } 671 | } 672 | ``` 673 | 674 | In the example above the host will use the group on Zabbix 7. On Zabbix 6 and 675 | below the host will use the proxy. Zabbix 7 will use the proxy value when 676 | omitting the proxy_group value. 677 | 678 | #### Custom Field 679 | 680 | Alternatively, you can use a custom field for assigning a device or VM to 681 | a Zabbix proxy or proxy group. The custom fields can be assigned to both 682 | Devices and VMs. 683 | 684 | You can also assign these custom fields to a site to allow all devices/VMs 685 | in that site to be configured with the same proxy or proxy group. 686 | In order for this to work, `extended_site_properties` needs to be enabled in 687 | the configuration as well. 688 | 689 | To use the custom fields for proxy configuration, configure one or both 690 | of the following settings in the configuration file with the actual names of your 691 | custom fields: 692 | 693 | ```python 694 | proxy_cf = "zabbix_proxy" 695 | proxy_group_cf = "zabbix_proxy_group" 696 | ``` 697 | 698 | As with config context proxy configuration, proxy group will take precedence over 699 | standalone proxy when configured. 700 | Proxy settings configured on the device or VM will in their turn take precedence 701 | over any site configuration. 702 | 703 | If the custom fields have no value but the proxy or proxy group is configured in config context, 704 | that setting will be used. 705 | 706 | ### Set interface parameters within NetBox 707 | 708 | When adding a new device, you can set the interface type with custom context. By 709 | default, the following configuration is applied when no config context is 710 | provided: 711 | 712 | - SNMPv2 713 | - UDP 161 714 | - Bulk requests enabled 715 | - SNMP community: {$SNMP_COMMUNITY} 716 | 717 | Due to Zabbix limitations of changing interface type with a linked template, 718 | changing the interface type from within NetBox is not supported and the script 719 | will generate an error. 720 | 721 | For example, when changing a SNMP interface to an Agent interface: 722 | 723 | ``` 724 | NetBox-Zabbix-sync - WARNING - Device: Interface OUT of sync. 725 | NetBox-Zabbix-sync - ERROR - Device: changing interface type to 1 is not supported. 726 | ``` 727 | 728 | To configure the interface parameters you'll need to use custom context. Custom 729 | context was used to make this script as customizable as possible for each 730 | environment. For example, you could: 731 | 732 | - Set the custom context directly on a device 733 | - Set the custom context on a tag, which you would add to a device (for 734 | instance, SNMPv3) 735 | - Set the custom context on a device role 736 | - Set the custom context on a site or region 737 | 738 | ##### Agent interface configuration example 739 | 740 | ```json 741 | { 742 | "zabbix": { 743 | "interface_port": 1500, 744 | "interface_type": 1 745 | } 746 | } 747 | ``` 748 | 749 | ##### SNMPv2 interface configuration example 750 | 751 | ```json 752 | { 753 | "zabbix": { 754 | "interface_port": 161, 755 | "interface_type": 2, 756 | "snmp": { 757 | "bulk": 1, 758 | "community": "SecretCommunity", 759 | "version": 2 760 | } 761 | } 762 | } 763 | ``` 764 | 765 | ##### SNMPv3 interface configuration example 766 | 767 | ```json 768 | { 769 | "zabbix": { 770 | "interface_port": 1610, 771 | "interface_type": 2, 772 | "snmp": { 773 | "authpassphrase": "SecretAuth", 774 | "bulk": 1, 775 | "securitylevel": 1, 776 | "securityname": "MySecurityName", 777 | "version": 3 778 | } 779 | } 780 | } 781 | ``` 782 | 783 | I would recommend using usermacros for sensitive data such as community strings 784 | since the data in NetBox is plain-text. 785 | 786 | > **_NOTE:_** Not all SNMP data is required for a working configuration. 787 | > [The following parameters are allowed](https://www.zabbix.com/documentation/current/manual/api/reference/hostinterface/object#details_tag "The following parameters are allowed") but 788 | > are not all required, depending on your environment. 789 | 790 | 791 | 792 | 793 | -------------------------------------------------------------------------------- /modules/device.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation, too-many-lines, too-many-public-methods, duplicate-code 2 | """ 3 | Device specific handeling for NetBox to Zabbix 4 | """ 5 | 6 | from copy import deepcopy 7 | from logging import getLogger 8 | from operator import itemgetter 9 | from re import search 10 | from typing import Any 11 | 12 | from pynetbox import RequestError as NetboxRequestError 13 | from zabbix_utils import APIRequestError 14 | 15 | from modules.config import load_config 16 | from modules.exceptions import ( 17 | InterfaceConfigError, 18 | SyncExternalError, 19 | SyncInventoryError, 20 | TemplateError, 21 | ) 22 | from modules.hostgroups import Hostgroup 23 | from modules.interface import ZabbixInterface 24 | from modules.tags import ZabbixTags 25 | from modules.tools import ( 26 | cf_to_string, 27 | field_mapper, 28 | remove_duplicates, 29 | sanatize_log_output, 30 | ) 31 | from modules.usermacros import ZabbixUsermacros 32 | 33 | config = load_config() 34 | 35 | 36 | class PhysicalDevice: 37 | # pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments 38 | """ 39 | Represents Network device. 40 | INPUT: (NetBox device class, ZabbixAPI class, journal flag, NB journal class) 41 | """ 42 | 43 | def __init__( 44 | self, nb, zabbix, nb_journal_class, nb_version, journal=None, logger=None 45 | ): 46 | self.nb = nb 47 | self.id = nb.id 48 | self.name = nb.name 49 | self.visible_name = None 50 | self.status = nb.status.label 51 | self.zabbix = zabbix 52 | self.zabbix_id = None 53 | self.group_ids = [] 54 | self.nb_api_version = nb_version 55 | self.zbx_template_names = [] 56 | self.zbx_templates = [] 57 | self.hostgroups = [] 58 | self.hostgroup_type = "dev" 59 | self.tenant = nb.tenant 60 | self.config_context = nb.config_context 61 | self.zbxproxy = None 62 | self.zabbix_state = 0 63 | self.journal = journal 64 | self.nb_journals = nb_journal_class 65 | self.inventory_mode = -1 66 | self.inventory = {} 67 | self.usermacros = [] 68 | self.tags = {} 69 | self.logger = logger if logger else getLogger(__name__) 70 | self._setBasics() 71 | 72 | def __repr__(self): 73 | return self.name 74 | 75 | def __str__(self): 76 | return self.__repr__() 77 | 78 | def _inventory_map(self): 79 | """Use device inventory maps""" 80 | return config["device_inventory_map"] 81 | 82 | def _usermacro_map(self): 83 | """Use device inventory maps""" 84 | return config["device_usermacro_map"] 85 | 86 | def _tag_map(self): 87 | """Use device host tag maps""" 88 | return config["device_tag_map"] 89 | 90 | def _setBasics(self): 91 | """ 92 | Sets basic information like IP address. 93 | """ 94 | # Return error if device does not have primary IP. 95 | if self.nb.primary_ip: 96 | self.cidr = self.nb.primary_ip.address 97 | self.ip = self.cidr.split("/")[0] 98 | else: 99 | e = f"Host {self.name}: no primary IP." 100 | self.logger.warning(e) 101 | raise SyncInventoryError(e) 102 | 103 | # Check if device has custom field for ZBX ID 104 | if config["device_cf"] in self.nb.custom_fields: 105 | self.zabbix_id = self.nb.custom_fields[config["device_cf"]] 106 | else: 107 | e = f"Host {self.name}: Custom field {config['device_cf']} not present" 108 | self.logger.error(e) 109 | raise SyncInventoryError(e) 110 | 111 | # Validate hostname format. 112 | odd_character_list = ["ä", "ö", "ü", "Ä", "Ö", "Ü", "ß"] 113 | self.use_visible_name = False 114 | if any(letter in self.name for letter in odd_character_list) or bool( 115 | search("[\u0400-\u04ff]", self.name) 116 | ): 117 | self.name = f"NETBOX_ID{self.id}" 118 | self.visible_name = self.nb.name 119 | self.use_visible_name = True 120 | self.logger.info( 121 | "Host %s contains special characters." 122 | "Using %s as name for the NetBox object and using %s as visible name in Zabbix.", 123 | self.visible_name, 124 | self.name, 125 | self.visible_name, 126 | ) 127 | else: 128 | pass 129 | 130 | def set_hostgroup(self, hg_format, nb_site_groups, nb_regions): 131 | """Set the hostgroup for this device""" 132 | # Create new Hostgroup instance 133 | hg = Hostgroup( 134 | self.hostgroup_type, 135 | self.nb, 136 | self.nb_api_version, 137 | logger=self.logger, 138 | nested_sitegroup_flag=config["traverse_site_groups"], 139 | nested_region_flag=config["traverse_regions"], 140 | nb_groups=nb_site_groups, 141 | nb_regions=nb_regions, 142 | ) 143 | # Generate hostgroup based on hostgroup format 144 | if isinstance(hg_format, list): 145 | self.hostgroups = [hg.generate(f) for f in hg_format] 146 | else: 147 | self.hostgroups.append(hg.generate(hg_format)) 148 | # Remove duplicates and None values 149 | self.hostgroups = list(filter(None, list(set(self.hostgroups)))) 150 | if self.hostgroups: 151 | self.logger.debug( 152 | "Host %s: Should be member of groups: %s", self.name, self.hostgroups 153 | ) 154 | return True 155 | return False 156 | 157 | def set_template(self, prefer_config_context, overrule_custom): 158 | """Set Template""" 159 | self.zbx_template_names = None 160 | # Gather templates ONLY from the device specific context 161 | if prefer_config_context: 162 | try: 163 | self.zbx_template_names = self.get_templates_context() 164 | except TemplateError as e: 165 | self.logger.warning(e) 166 | return True 167 | # Gather templates from the custom field but overrule 168 | # them should there be any device specific templates 169 | if overrule_custom: 170 | try: 171 | self.zbx_template_names = self.get_templates_context() 172 | except TemplateError: 173 | pass 174 | if not self.zbx_template_names: 175 | self.zbx_template_names = self.get_templates_cf() 176 | return True 177 | # Gather templates ONLY from the custom field 178 | self.zbx_template_names = self.get_templates_cf() 179 | return True 180 | 181 | def get_templates_cf(self): 182 | """Get template from custom field""" 183 | # Get Zabbix templates from the device type 184 | device_type_cfs = self.nb.device_type.custom_fields 185 | # Check if the ZBX Template CF is present 186 | if config["template_cf"] in device_type_cfs: 187 | # Set value to template 188 | return [device_type_cfs[config["template_cf"]]] 189 | # Custom field not found, return error 190 | e = ( 191 | f"Custom field {config['template_cf']} not " 192 | f"found for {self.nb.device_type.manufacturer.name}" 193 | f" - {self.nb.device_type.display}." 194 | ) 195 | self.logger.warning(e) 196 | raise TemplateError(e) 197 | 198 | def get_templates_context(self): 199 | """Get Zabbix templates from the device context""" 200 | if "zabbix" not in self.config_context: 201 | e = ( 202 | f"Host {self.name}: Key 'zabbix' not found in config " 203 | "context for template lookup" 204 | ) 205 | raise TemplateError(e) 206 | if "templates" not in self.config_context["zabbix"]: 207 | e = ( 208 | f"Host {self.name}: Key 'templates' not found in config " 209 | "context 'zabbix' for template lookup" 210 | ) 211 | raise TemplateError(e) 212 | # Check if format is list or string. 213 | if isinstance(self.config_context["zabbix"]["templates"], str): 214 | return [self.config_context["zabbix"]["templates"]] 215 | return self.config_context["zabbix"]["templates"] 216 | 217 | def set_inventory(self, nbdevice): 218 | """Set host inventory""" 219 | # Set inventory mode. Default is disabled (see class init function). 220 | if config["inventory_mode"] == "disabled": 221 | if config["inventory_sync"]: 222 | self.logger.error( 223 | "Host %s: Unable to map NetBox inventory to Zabbix." 224 | "Inventory sync is enabled in config but inventory mode is disabled", 225 | self.name, 226 | ) 227 | return True 228 | if config["inventory_mode"] == "manual": 229 | self.inventory_mode = 0 230 | elif config["inventory_mode"] == "automatic": 231 | self.inventory_mode = 1 232 | else: 233 | self.logger.error( 234 | "Host %s: Specified value for inventory mode in config is not valid. Got value %s", 235 | self.name, 236 | config["inventory_mode"], 237 | ) 238 | return False 239 | self.inventory = {} 240 | if config["inventory_sync"] and self.inventory_mode in [0, 1]: 241 | self.logger.debug("Host %s: Starting inventory mapper.", self.name) 242 | self.inventory = field_mapper( 243 | self.name, self._inventory_map(), nbdevice, self.logger 244 | ) 245 | self.logger.debug( 246 | "Host %s: Resolved inventory: %s", self.name, self.inventory 247 | ) 248 | return True 249 | 250 | def isCluster(self): 251 | """ 252 | Checks if device is part of cluster. 253 | """ 254 | return bool(self.nb.virtual_chassis) 255 | 256 | def getClusterMaster(self): 257 | """ 258 | Returns chassis master ID. 259 | """ 260 | if not self.isCluster(): 261 | e = ( 262 | f"Unable to proces {self.name} for cluster calculation: " 263 | f"not part of a cluster." 264 | ) 265 | self.logger.info(e) 266 | raise SyncInventoryError(e) 267 | if not self.nb.virtual_chassis.master: 268 | e = ( 269 | f"{self.name} is part of a NetBox virtual chassis which does " 270 | "not have a master configured. Skipping for this reason." 271 | ) 272 | self.logger.warning(e) 273 | raise SyncInventoryError(e) 274 | return self.nb.virtual_chassis.master.id 275 | 276 | def promoteMasterDevice(self): 277 | """ 278 | If device is Primary in cluster, 279 | promote device name to the cluster name. 280 | Returns True if succesfull, returns False if device is secondary. 281 | """ 282 | masterid = self.getClusterMaster() 283 | if masterid == self.id: 284 | self.logger.info( 285 | "Host %s is primary cluster member. Modifying hostname from %s to %s.", 286 | self.name, 287 | self.name, 288 | self.nb.virtual_chassis.name, 289 | ) 290 | self.name = self.nb.virtual_chassis.name 291 | return True 292 | self.logger.info("Host %s is non-primary cluster member.", self.name) 293 | return False 294 | 295 | def zbxTemplatePrepper(self, templates): 296 | """ 297 | Returns Zabbix template IDs 298 | INPUT: list of templates from Zabbix 299 | OUTPUT: True 300 | """ 301 | # Check if there are templates defined 302 | if not self.zbx_template_names: 303 | e = f"Host {self.name}: No templates found" 304 | self.logger.warning(e) 305 | raise SyncInventoryError() 306 | # Set variable to empty list 307 | self.zbx_templates = [] 308 | # Go through all templates definded in NetBox 309 | for nb_template in self.zbx_template_names: 310 | template_match = False 311 | # Go through all templates found in Zabbix 312 | for zbx_template in templates: 313 | # If the template names match 314 | if zbx_template["name"] == nb_template: 315 | # Set match variable to true, add template details 316 | # to class variable and return debug log 317 | template_match = True 318 | self.zbx_templates.append( 319 | { 320 | "templateid": zbx_template["templateid"], 321 | "name": zbx_template["name"], 322 | } 323 | ) 324 | e = ( 325 | f"Host {self.name}: Found template '{zbx_template['name']}' " 326 | f"(ID:{zbx_template['templateid']})" 327 | ) 328 | self.logger.debug(e) 329 | # Return error should the template not be found in Zabbix 330 | if not template_match: 331 | e = ( 332 | f"Unable to find template {nb_template} " 333 | f"for host {self.name} in Zabbix. Skipping host..." 334 | ) 335 | self.logger.warning(e) 336 | raise SyncInventoryError(e) 337 | 338 | def setZabbixGroupID(self, groups): 339 | """ 340 | Sets Zabbix group ID as instance variable 341 | INPUT: list of hostgroups 342 | OUTPUT: True / False 343 | """ 344 | # Go through all groups 345 | for hg in self.hostgroups: 346 | for group in groups: 347 | if group["name"] == hg: 348 | self.group_ids.append({"groupid": group["groupid"]}) 349 | e = ( 350 | f"Host {self.name}: Matched group " 351 | f'"{group["name"]}" (ID:{group["groupid"]})' 352 | ) 353 | self.logger.debug(e) 354 | if len(self.group_ids) == len(self.hostgroups): 355 | return True 356 | return False 357 | 358 | def cleanup(self): 359 | """ 360 | Removes device from external resources. 361 | Resets custom fields in NetBox. 362 | """ 363 | if self.zabbix_id: 364 | try: 365 | # Check if the Zabbix host exists in Zabbix 366 | zbx_host = bool( 367 | self.zabbix.host.get(filter={"hostid": self.zabbix_id}, output=[]) 368 | ) 369 | e = ( 370 | f"Host {self.name}: was already deleted from Zabbix." 371 | " Removed link in NetBox." 372 | ) 373 | if zbx_host: 374 | # Delete host should it exists 375 | self.zabbix.host.delete(self.zabbix_id) 376 | e = f"Host {self.name}: Deleted host from Zabbix." 377 | self._zeroize_cf() 378 | self.logger.info(e) 379 | self.create_journal_entry("warning", "Deleted host from Zabbix") 380 | except APIRequestError as e: 381 | message = f"Zabbix returned the following error: {str(e)}." 382 | self.logger.error(message) 383 | raise SyncExternalError(message) from e 384 | 385 | def _zeroize_cf(self): 386 | """Sets the hostID custom field in NetBox to zero, 387 | effectively destroying the link""" 388 | self.nb.custom_fields[config["device_cf"]] = None 389 | self.nb.save() 390 | 391 | def _zabbixHostnameExists(self): 392 | """ 393 | Checks if hostname exists in Zabbix. 394 | """ 395 | # Validate the hostname or visible name field 396 | if not self.use_visible_name: 397 | zbx_filter = {"host": self.name} 398 | else: 399 | zbx_filter = {"name": self.visible_name} 400 | host = self.zabbix.host.get(filter=zbx_filter, output=[]) 401 | return bool(host) 402 | 403 | def setInterfaceDetails(self): 404 | """ 405 | Checks interface parameters from NetBox and 406 | creates a model for the interface to be used in Zabbix. 407 | """ 408 | try: 409 | # Initiate interface class 410 | interface = ZabbixInterface(self.nb.config_context, self.ip) 411 | # Check if NetBox has device context. 412 | # If not fall back to old config. 413 | if interface.get_context(): 414 | # If device is SNMP type, add aditional information. 415 | if interface.interface["type"] == 2: 416 | interface.set_snmp() 417 | else: 418 | interface.set_default_snmp() 419 | return [interface.interface] 420 | except InterfaceConfigError as e: 421 | message = f"{self.name}: {e}" 422 | self.logger.warning(message) 423 | raise SyncInventoryError(message) from e 424 | 425 | def set_usermacros(self): 426 | """ 427 | Generates Usermacros 428 | """ 429 | macros = ZabbixUsermacros( 430 | self.nb, 431 | self._usermacro_map(), 432 | config["usermacro_sync"], 433 | logger=self.logger, 434 | host=self.name, 435 | ) 436 | if macros.sync is False: 437 | self.usermacros = [] 438 | return True 439 | 440 | self.usermacros = macros.generate() 441 | return True 442 | 443 | def set_tags(self): 444 | """ 445 | Generates Host Tags 446 | """ 447 | tags = ZabbixTags( 448 | self.nb, 449 | self._tag_map(), 450 | tag_sync=config["tag_sync"], 451 | tag_lower=config["tag_lower"], 452 | tag_name=config["tag_name"], 453 | tag_value=config["tag_value"], 454 | logger=self.logger, 455 | host=self.name, 456 | ) 457 | if config["tag_sync"] is False: 458 | self.tags = [] 459 | return False 460 | self.tags = tags.generate() 461 | return True 462 | 463 | def _setProxy(self, proxy_list: list[dict[str, Any]]) -> bool: 464 | """ 465 | Sets proxy or proxy group if this 466 | value has been defined in config context 467 | or custom fields. 468 | 469 | input: List of all proxies and proxy groups in standardized format 470 | """ 471 | # Proxy group takes priority over a proxy due 472 | # to it being HA and therefore being more reliable 473 | # Includes proxy group fix since Zabbix <= 6 should ignore this 474 | proxy_types = ["proxy"] 475 | proxy_name = None 476 | 477 | if self.zabbix.version >= 7.0: 478 | # Only insert groups in front of list for Zabbix7 479 | proxy_types.insert(0, "proxy_group") 480 | 481 | # loop through supported proxy-types 482 | for proxy_type in proxy_types: 483 | # Check if we should use custom fields for proxy config 484 | field_config = "proxy_cf" if proxy_type == "proxy" else "proxy_group_cf" 485 | if config[field_config]: 486 | if ( 487 | config[field_config] in self.nb.custom_fields 488 | and self.nb.custom_fields[config[field_config]] 489 | ): 490 | proxy_name = cf_to_string( 491 | self.nb.custom_fields[config[field_config]] 492 | ) 493 | elif ( 494 | config[field_config] in self.nb.site.custom_fields 495 | and self.nb.site.custom_fields[config[field_config]] 496 | ): 497 | proxy_name = cf_to_string( 498 | self.nb.site.custom_fields[config[field_config]] 499 | ) 500 | 501 | # Otherwise check if the proxy is configured in NetBox CC 502 | if ( 503 | not proxy_name 504 | and "zabbix" in self.nb.config_context 505 | and proxy_type in self.nb.config_context["zabbix"] 506 | ): 507 | proxy_name = self.nb.config_context["zabbix"][proxy_type] 508 | 509 | # If a proxy name was found, loop through all proxies to find a match 510 | if proxy_name: 511 | for proxy in proxy_list: 512 | # If the proxy does not match the type, ignore and continue 513 | if not proxy["type"] == proxy_type: 514 | continue 515 | # If the proxy name matches 516 | if proxy["name"] == proxy_name: 517 | self.logger.debug( 518 | "Host %s: using {proxy['type']} '%s'", self.name, proxy_name 519 | ) 520 | self.zbxproxy = proxy 521 | return True 522 | 523 | self.logger.warning( 524 | "Host %s: unable to find proxy %s", self.name, proxy_name 525 | ) 526 | return False 527 | 528 | def createInZabbix( 529 | self, 530 | groups, 531 | templates, 532 | proxies, 533 | description="Host added by NetBox sync script.", 534 | ): 535 | """ 536 | Creates Zabbix host object with parameters from NetBox object. 537 | """ 538 | # Check if hostname is already present in Zabbix 539 | if not self._zabbixHostnameExists(): 540 | # Set group and template ID's for host 541 | if not self.setZabbixGroupID(groups): 542 | e = ( 543 | f"Unable to find group '{self.hostgroup}' " 544 | f"for host {self.name} in Zabbix." 545 | ) 546 | self.logger.warning(e) 547 | raise SyncInventoryError(e) 548 | self.zbxTemplatePrepper(templates) 549 | templateids = [] 550 | for template in self.zbx_templates: 551 | templateids.append({"templateid": template["templateid"]}) 552 | # Set interface, group and template configuration 553 | interfaces = self.setInterfaceDetails() 554 | # Set Zabbix proxy if defined 555 | self._setProxy(proxies) 556 | # Set basic data for host creation 557 | create_data = { 558 | "host": self.name, 559 | "name": self.visible_name, 560 | "status": self.zabbix_state, 561 | "interfaces": interfaces, 562 | "groups": self.group_ids, 563 | "templates": templateids, 564 | "description": description, 565 | "inventory_mode": self.inventory_mode, 566 | "inventory": self.inventory, 567 | "macros": self.usermacros, 568 | "tags": self.tags, 569 | } 570 | # If a Zabbix proxy or Zabbix Proxy group has been defined 571 | if self.zbxproxy: 572 | # If a lower version than 7 is used, we can assume that 573 | # the proxy is a normal proxy and not a proxy group 574 | if not str(self.zabbix.version).startswith("7"): 575 | create_data["proxy_hostid"] = self.zbxproxy["id"] 576 | else: 577 | # Configure either a proxy or proxy group 578 | create_data[self.zbxproxy["idtype"]] = self.zbxproxy["id"] 579 | create_data["monitored_by"] = self.zbxproxy["monitored_by"] 580 | # Add host to Zabbix 581 | try: 582 | host = self.zabbix.host.create(**create_data) 583 | self.zabbix_id = host["hostids"][0] 584 | except APIRequestError as e: 585 | msg = f"Host {self.name}: Couldn't create. Zabbix returned {str(e)}." 586 | self.logger.error(msg) 587 | raise SyncExternalError(msg) from e 588 | # Set NetBox custom field to hostID value. 589 | self.nb.custom_fields[config["device_cf"]] = int(self.zabbix_id) 590 | self.nb.save() 591 | msg = f"Host {self.name}: Created host in Zabbix. (ID:{self.zabbix_id})" 592 | self.logger.info(msg) 593 | self.create_journal_entry("success", msg) 594 | else: 595 | self.logger.error( 596 | "Host %s: Unable to add to Zabbix. Host already present.", self.name 597 | ) 598 | 599 | def createZabbixHostgroup(self, hostgroups): 600 | """ 601 | Creates Zabbix host group based on hostgroup format. 602 | Creates multiple when using a nested format. 603 | """ 604 | final_data = [] 605 | # Check if the hostgroup is in a nested format and check each parent 606 | for hostgroup in self.hostgroups: 607 | for pos in range(len(hostgroup.split("/"))): 608 | zabbix_hg = hostgroup.rsplit("/", pos)[0] 609 | if self.lookupZabbixHostgroup(hostgroups, zabbix_hg): 610 | # Hostgroup already exists 611 | continue 612 | # Create new group 613 | try: 614 | # API call to Zabbix 615 | groupid = self.zabbix.hostgroup.create(name=zabbix_hg) 616 | e = f"Hostgroup '{zabbix_hg}': created in Zabbix." 617 | self.logger.info(e) 618 | # Add group to final data 619 | final_data.append( 620 | {"groupid": groupid["groupids"][0], "name": zabbix_hg} 621 | ) 622 | except APIRequestError as e: 623 | msg = f"Hostgroup '{zabbix_hg}': unable to create. Zabbix returned {str(e)}." 624 | self.logger.error(msg) 625 | raise SyncExternalError(msg) from e 626 | return final_data 627 | 628 | def lookupZabbixHostgroup(self, group_list, lookup_group): 629 | """ 630 | Function to check if a hostgroup 631 | exists in a list of Zabbix hostgroups 632 | INPUT: Group list and group lookup 633 | OUTPUT: Boolean 634 | """ 635 | for group in group_list: 636 | if group["name"] == lookup_group: 637 | return True 638 | return False 639 | 640 | def updateZabbixHost(self, **kwargs): 641 | """ 642 | Updates Zabbix host with given parameters. 643 | INPUT: Key word arguments for Zabbix host object. 644 | """ 645 | try: 646 | self.zabbix.host.update(hostid=self.zabbix_id, **kwargs) 647 | except APIRequestError as e: 648 | e = ( 649 | f"Host {self.name}: Unable to update. " 650 | f"Zabbix returned the following error: {str(e)}." 651 | ) 652 | self.logger.error(e) 653 | raise SyncExternalError(e) from None 654 | self.logger.info( 655 | "Host %s: updated with data %s.", self.name, sanatize_log_output(kwargs) 656 | ) 657 | self.create_journal_entry("info", "Updated host in Zabbix with latest NB data.") 658 | 659 | def ConsistencyCheck( 660 | self, groups, templates, proxies, proxy_power, create_hostgroups 661 | ): 662 | # pylint: disable=too-many-branches, too-many-statements 663 | """ 664 | Checks if Zabbix object is still valid with NetBox parameters. 665 | """ 666 | # If group is found or if the hostgroup is nested 667 | if not self.setZabbixGroupID(groups): # or len(self.hostgroups.split("/")) > 1: 668 | if create_hostgroups: 669 | # Script is allowed to create a new hostgroup 670 | new_groups = self.createZabbixHostgroup(groups) 671 | for group in new_groups: 672 | # Add all new groups to the list of groups 673 | groups.append(group) 674 | # check if the initial group was not already found (and this is a nested folder check) 675 | if not self.group_ids: 676 | # Function returns true / false but also sets GroupID 677 | if not self.setZabbixGroupID(groups) and not create_hostgroups: 678 | e = ( 679 | f"Host {self.name}: different hostgroup is required but " 680 | "unable to create hostgroup without generation permission." 681 | ) 682 | self.logger.warning(e) 683 | raise SyncInventoryError(e) 684 | 685 | # Prepare templates and proxy config 686 | self.zbxTemplatePrepper(templates) 687 | self._setProxy(proxies) 688 | # Get host object from Zabbix 689 | host = self.zabbix.host.get( 690 | filter={"hostid": self.zabbix_id}, 691 | selectInterfaces=["type", "ip", "port", "details", "interfaceid"], 692 | selectGroups=["groupid"], 693 | selectHostGroups=["groupid"], 694 | selectParentTemplates=["templateid"], 695 | selectInventory=list(self._inventory_map().values()), 696 | selectMacros=["macro", "value", "type", "description"], 697 | selectTags=["tag", "value"], 698 | ) 699 | if len(host) > 1: 700 | e = ( 701 | f"Got {len(host)} results for Zabbix hosts " 702 | f"with ID {self.zabbix_id} - hostname {self.name}." 703 | ) 704 | self.logger.error(e) 705 | raise SyncInventoryError(e) 706 | if len(host) == 0: 707 | e = ( 708 | f"Host {self.name}: No Zabbix host found. " 709 | f"This is likely the result of a deleted Zabbix host " 710 | f"without zeroing the ID field in NetBox." 711 | ) 712 | self.logger.error(e) 713 | raise SyncInventoryError(e) 714 | host = host[0] 715 | if host["host"] == self.name: 716 | self.logger.debug("Host %s: Hostname in-sync.", self.name) 717 | else: 718 | self.logger.info( 719 | "Host %s: Hostname OUT of sync. Received value: %s", 720 | self.name, 721 | host["host"], 722 | ) 723 | self.updateZabbixHost(host=self.name) 724 | 725 | # Execute check depending on wether the name is special or not 726 | if self.use_visible_name: 727 | if host["name"] == self.visible_name: 728 | self.logger.debug("Host %s: Visible name in-sync.", self.name) 729 | else: 730 | self.logger.info( 731 | "Host %s: Visible name OUT of sync. Received value: %s", 732 | self.name, 733 | host["name"], 734 | ) 735 | self.updateZabbixHost(name=self.visible_name) 736 | 737 | # Check if the templates are in-sync 738 | if not self.zbx_template_comparer(host["parentTemplates"]): 739 | self.logger.info("Host %s: Template(s) OUT of sync.", self.name) 740 | # Prepare Templates for API parsing 741 | templateids = [] 742 | for template in self.zbx_templates: 743 | templateids.append({"templateid": template["templateid"]}) 744 | # Update Zabbix with NB templates and clear any old / lost templates 745 | self.updateZabbixHost( 746 | templates_clear=host["parentTemplates"], templates=templateids 747 | ) 748 | else: 749 | self.logger.debug("Host %s: Template(s) in-sync.", self.name) 750 | 751 | # Check if Zabbix version is 6 or higher. Issue #93 752 | group_dictname = "hostgroups" 753 | if str(self.zabbix.version).startswith(("6", "5")): 754 | group_dictname = "groups" 755 | # Check if hostgroups match 756 | if sorted(host[group_dictname], key=itemgetter("groupid")) == sorted( 757 | self.group_ids, key=itemgetter("groupid") 758 | ): 759 | self.logger.debug("Host %s: Hostgroups in-sync.", self.name) 760 | else: 761 | self.logger.info("Host %s: Hostgroups OUT of sync.", self.name) 762 | self.updateZabbixHost(groups=self.group_ids) 763 | 764 | if int(host["status"]) == self.zabbix_state: 765 | self.logger.debug("Host %s: Status in-sync.", self.name) 766 | else: 767 | self.logger.info("Host %s: Status OUT of sync.", self.name) 768 | self.updateZabbixHost(status=str(self.zabbix_state)) 769 | 770 | # Check if a proxy has been defined 771 | if self.zbxproxy: 772 | # Check if proxy or proxy group is defined 773 | if ( 774 | self.zbxproxy["idtype"] in host 775 | and host[self.zbxproxy["idtype"]] == self.zbxproxy["id"] 776 | ): 777 | self.logger.debug("Host %s: Proxy in-sync.", self.name) 778 | # Backwards compatibility for Zabbix <= 6 779 | elif "proxy_hostid" in host and host["proxy_hostid"] == self.zbxproxy["id"]: 780 | self.logger.debug("Host %s: Proxy in-sync.", self.name) 781 | # Proxy does not match, update Zabbix 782 | else: 783 | self.logger.info("Host %s: Proxy OUT of sync.", self.name) 784 | # Zabbix <= 6 patch 785 | if not str(self.zabbix.version).startswith("7"): 786 | self.updateZabbixHost(proxy_hostid=self.zbxproxy["id"]) 787 | # Zabbix 7+ 788 | else: 789 | # Prepare data structure for updating either proxy or group 790 | update_data = { 791 | self.zbxproxy["idtype"]: self.zbxproxy["id"], 792 | "monitored_by": self.zbxproxy["monitored_by"], 793 | } 794 | self.updateZabbixHost(**update_data) 795 | else: 796 | # No proxy is defined in NetBox 797 | proxy_set = False 798 | # Check if a proxy is defined. Uses the proxy_hostid key for backwards compatibility 799 | for key in ("proxy_hostid", "proxyid", "proxy_groupid"): 800 | if key in host: 801 | if bool(int(host[key])): 802 | proxy_set = True 803 | if proxy_power and proxy_set: 804 | # Zabbix <= 6 fix 805 | self.logger.warning( 806 | "Host %s: No proxy is configured in NetBox but is configured in Zabbix." 807 | "Removing proxy config in Zabbix", 808 | self.name, 809 | ) 810 | if "proxy_hostid" in host and bool(host["proxy_hostid"]): 811 | self.updateZabbixHost(proxy_hostid=0) 812 | # Zabbix 7 proxy 813 | elif "proxyid" in host and bool(host["proxyid"]): 814 | self.updateZabbixHost(proxyid=0, monitored_by=0) 815 | # Zabbix 7 proxy group 816 | elif "proxy_groupid" in host and bool(host["proxy_groupid"]): 817 | self.updateZabbixHost(proxy_groupid=0, monitored_by=0) 818 | # Checks if a proxy has been defined in Zabbix and if proxy_power config has been set 819 | if proxy_set and not proxy_power: 820 | # Display error message 821 | self.logger.warning( 822 | "Host %s: Is configured with proxy in Zabbix but not in NetBox." 823 | "full_proxy_sync is not set: no changes have been made.", 824 | self.name, 825 | ) 826 | if not proxy_set: 827 | self.logger.debug("Host %s: Proxy in-sync.", self.name) 828 | # Check host inventory mode 829 | if str(host["inventory_mode"]) == str(self.inventory_mode): 830 | self.logger.debug("Host %s: inventory_mode in-sync.", self.name) 831 | else: 832 | self.logger.info("Host %s: inventory_mode OUT of sync.", self.name) 833 | self.updateZabbixHost(inventory_mode=str(self.inventory_mode)) 834 | if config["inventory_sync"] and self.inventory_mode in [0, 1]: 835 | # Check host inventory mapping 836 | if host["inventory"] == self.inventory: 837 | self.logger.debug("Host %s: Inventory in-sync.", self.name) 838 | else: 839 | self.logger.info("Host %s: Inventory OUT of sync.", self.name) 840 | self.updateZabbixHost(inventory=self.inventory) 841 | 842 | # Check host usermacros 843 | if config["usermacro_sync"]: 844 | # Make a full copy synce we dont want to lose the original value 845 | # of secret type macros from Netbox 846 | netbox_macros = deepcopy(self.usermacros) 847 | # Set the sync bit 848 | full_sync_bit = bool(str(config["usermacro_sync"]).lower() == "full") 849 | for macro in netbox_macros: 850 | # If the Macro is a secret and full sync is NOT activated 851 | if macro["type"] == str(1) and not full_sync_bit: 852 | # Remove the value as the Zabbix api does not return the value key 853 | # This is required when you want to do a diff between both lists 854 | macro.pop("value") 855 | 856 | # Sort all lists 857 | def filter_with_macros(macro): 858 | return macro["macro"] 859 | 860 | host["macros"].sort(key=filter_with_macros) 861 | netbox_macros.sort(key=filter_with_macros) 862 | # Check if both lists are the same 863 | if host["macros"] == netbox_macros: 864 | self.logger.debug("Host %s: Usermacros in-sync.", self.name) 865 | else: 866 | self.logger.info("Host %s: Usermacros OUT of sync.", self.name) 867 | # Update Zabbix with NetBox usermacros 868 | self.updateZabbixHost(macros=self.usermacros) 869 | 870 | # Check host tags 871 | if config["tag_sync"]: 872 | if remove_duplicates(host["tags"], sortkey="tag") == self.tags: 873 | self.logger.debug("Host %s: Tags in-sync.", self.name) 874 | else: 875 | self.logger.info("Host %s: Tags OUT of sync.", self.name) 876 | self.updateZabbixHost(tags=self.tags) 877 | 878 | # If only 1 interface has been found 879 | # pylint: disable=too-many-nested-blocks 880 | if len(host["interfaces"]) == 1: 881 | updates = {} 882 | # Go through each key / item and check if it matches Zabbix 883 | for key, item in self.setInterfaceDetails()[0].items(): 884 | # Check if NetBox value is found in Zabbix 885 | if key in host["interfaces"][0]: 886 | # If SNMP is used, go through nested dict 887 | # to compare SNMP parameters 888 | if isinstance(item, dict) and key == "details": 889 | for k, i in item.items(): 890 | if k in host["interfaces"][0][key]: 891 | # Set update if values don't match 892 | if host["interfaces"][0][key][k] != str(i): 893 | # If dict has not been created, add it 894 | if key not in updates: 895 | updates[key] = {} 896 | updates[key][k] = str(i) 897 | # If SNMP version has been changed 898 | # break loop and force full SNMP update 899 | if k == "version": 900 | break 901 | # Force full SNMP config update 902 | # when version has changed. 903 | if key in updates: 904 | if "version" in updates[key]: 905 | for k, i in item.items(): 906 | updates[key][k] = str(i) 907 | continue 908 | # Set update if values don't match 909 | if host["interfaces"][0][key] != str(item): 910 | updates[key] = item 911 | if updates: 912 | # If interface updates have been found: push to Zabbix 913 | self.logger.info("Host %s: Interface OUT of sync.", self.name) 914 | if "type" in updates: 915 | # Changing interface type not supported. Raise exception. 916 | e = ( 917 | f"Host {self.name}: Changing interface type to " 918 | f"{str(updates['type'])} is not supported." 919 | ) 920 | self.logger.error(e) 921 | raise InterfaceConfigError(e) 922 | # Set interfaceID for Zabbix config 923 | updates["interfaceid"] = host["interfaces"][0]["interfaceid"] 924 | try: 925 | # API call to Zabbix 926 | self.zabbix.hostinterface.update(updates) 927 | err_msg = ( 928 | f"Host {self.name}: Updated interface " 929 | f"with data {sanatize_log_output(updates)}." 930 | ) 931 | self.logger.info(err_msg) 932 | self.create_journal_entry("info", err_msg) 933 | except APIRequestError as e: 934 | msg = f"Zabbix returned the following error: {str(e)}." 935 | self.logger.error(msg) 936 | raise SyncExternalError(msg) from e 937 | else: 938 | # If no updates are found, Zabbix interface is in-sync 939 | self.logger.debug("Host %s: Interface in-sync.", self.name) 940 | else: 941 | err_msg = ( 942 | f"Host {self.name}: Has unsupported interface configuration." 943 | f" Host has total of {len(host['interfaces'])} interfaces. " 944 | "Manual intervention required." 945 | ) 946 | self.logger.error(err_msg) 947 | raise SyncInventoryError(err_msg) 948 | 949 | def create_journal_entry(self, severity, message): 950 | """ 951 | Send a new Journal entry to NetBox. Usefull for viewing actions 952 | in NetBox without having to look in Zabbix or the script log output 953 | """ 954 | if self.journal: 955 | # Check if the severity is valid 956 | if severity not in ["info", "success", "warning", "danger"]: 957 | self.logger.warning( 958 | "Value %s not valid for NB journal entries.", severity 959 | ) 960 | return False 961 | journal = { 962 | "assigned_object_type": "dcim.device", 963 | "assigned_object_id": self.id, 964 | "kind": severity, 965 | "comments": message, 966 | } 967 | try: 968 | self.nb_journals.create(journal) 969 | self.logger.debug("Host %s: Created journal entry in NetBox", self.name) 970 | return True 971 | except NetboxRequestError as e: 972 | self.logger.warning( 973 | "Unable to create journal entry for %s: NB returned %s", 974 | self.name, 975 | e, 976 | ) 977 | return False 978 | return False 979 | 980 | def zbx_template_comparer(self, tmpls_from_zabbix): 981 | """ 982 | Compares the NetBox and Zabbix templates with each other. 983 | Should there be a mismatch then the function will return false 984 | 985 | INPUT: list of NB and ZBX templates 986 | OUTPUT: Boolean True/False 987 | """ 988 | succesfull_templates = [] 989 | # Go through each NetBox template 990 | for nb_tmpl in self.zbx_templates: 991 | # Go through each Zabbix template 992 | for pos, zbx_tmpl in enumerate(tmpls_from_zabbix): 993 | # Check if template IDs match 994 | if nb_tmpl["templateid"] == zbx_tmpl["templateid"]: 995 | # Templates match. Remove this template from the Zabbix templates 996 | # and add this NB template to the list of successfull templates 997 | tmpls_from_zabbix.pop(pos) 998 | succesfull_templates.append(nb_tmpl) 999 | self.logger.debug( 1000 | "Host %s: Template '%s' is present in Zabbix.", 1001 | self.name, 1002 | nb_tmpl["name"], 1003 | ) 1004 | break 1005 | if ( 1006 | len(succesfull_templates) == len(self.zbx_templates) 1007 | and len(tmpls_from_zabbix) == 0 1008 | ): 1009 | # All of the NetBox templates have been confirmed as successfull 1010 | # and the ZBX template list is empty. This means that 1011 | # all of the templates match. 1012 | return True 1013 | return False 1014 | --------------------------------------------------------------------------------