Merge branch 'develop' into unittesting

This commit is contained in:
Twan Kamans 2025-06-08 21:33:21 +02:00 committed by GitHub
commit 45e633b5ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1244 additions and 363 deletions

View File

@ -1,43 +1,57 @@
name: Publish Docker image to GHCR on a new version name: Build and Push Docker Image
on: on:
push: push:
branches: branches:
- main - main
permissions:
contents: read
packages: write
env: on:
REGISTRY: ghcr.io release:
IMAGE_NAME: ${{ github.repository }} types: [published]
pull_request:
types: [opened, synchronize]
jobs: jobs:
test_quality: test_quality:
uses: ./.github/workflows/quality.yml uses: ./.github/workflows/quality.yml
build_and_publish: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GHCR_PAT }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{ version }}
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: |
index:org.opencontainers.image.description=Python script to synchronise NetBox devices to Zabbix.

4
.gitignore vendored
View File

@ -1,6 +1,8 @@
*.log *.log
.venv .venv
/config.py config.py
Pipfile
Pipfile.lock
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -1,9 +1,13 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM python:3.12-alpine FROM python:3.12-alpine
RUN mkdir -p /opt/netbox-zabbix RUN mkdir -p /opt/netbox-zabbix && chown -R 1000:1000 /opt/netbox-zabbix
COPY . /opt/netbox-zabbix
USER 1000:1000
COPY --chown=1000:1000 . /opt/netbox-zabbix
WORKDIR /opt/netbox-zabbix WORKDIR /opt/netbox-zabbix
RUN if ! [ -f ./config.py ]; then cp ./config.py.example ./config.py; fi RUN if ! [ -f ./config.py ]; then cp ./config.py.example ./config.py; fi
USER root
RUN pip install -r ./requirements.txt RUN pip install -r ./requirements.txt
USER 1000:1000
ENTRYPOINT ["python"] ENTRYPOINT ["python"]
CMD ["/opt/netbox-zabbix/netbox_zabbix_sync.py", "-v"] CMD ["/opt/netbox-zabbix/netbox_zabbix_sync.py", "-v"]

247
README.md
View File

@ -8,7 +8,7 @@ Currently compatible with Zabbix 7.0. Zabbix 7.2 is unfortunately not supported
To pull the latest stable version to your local cache, use the following docker To pull the latest stable version to your local cache, use the following docker
pull command: pull command:
```sh ```bash
docker pull ghcr.io/thenetworkguy/netbox-zabbix-sync:main docker pull ghcr.io/thenetworkguy/netbox-zabbix-sync:main
``` ```
@ -16,7 +16,7 @@ Make sure to specify the needed environment variables for the script to work
(see [here](#set-environment-variables)) on the command line or use an (see [here](#set-environment-variables)) on the command line or use an
[env file](https://docs.docker.com/reference/cli/docker/container/run/#env). [env file](https://docs.docker.com/reference/cli/docker/container/run/#env).
```sh ```bash
docker run -d -t -i -e ZABBIX_HOST='https://zabbix.local' \ docker run -d -t -i -e ZABBIX_HOST='https://zabbix.local' \
-e ZABBIX_TOKEN='othersecrettoken' \ -e ZABBIX_TOKEN='othersecrettoken' \
-e NETBOX_HOST='https://netbox.local' \ -e NETBOX_HOST='https://netbox.local' \
@ -31,7 +31,7 @@ The image uses the default `config.py` for it's configuration, you can use a
volume mount in the docker run command to override with your own config file if volume mount in the docker run command to override with your own config file if
needed (see [config file](#config-file)): needed (see [config file](#config-file)):
```sh ```bash
docker run -d -t -i -v $(pwd)/config.py:/opt/netbox-zabbix/config.py ... docker run -d -t -i -v $(pwd)/config.py:/opt/netbox-zabbix/config.py ...
``` ```
@ -39,7 +39,7 @@ docker run -d -t -i -v $(pwd)/config.py:/opt/netbox-zabbix/config.py ...
### Cloning the repository ### Cloning the repository
```sh ```bash
git clone https://github.com/TheNetworkGuy/netbox-zabbix-sync.git git clone https://github.com/TheNetworkGuy/netbox-zabbix-sync.git
``` ```
@ -73,19 +73,19 @@ cp config.py.example config.py
Set the following environment variables: Set the following environment variables:
```sh ```bash
export ZABBIX_HOST="https://zabbix.local" ZABBIX_HOST="https://zabbix.local"
export ZABBIX_USER="username" ZABBIX_USER="username"
export ZABBIX_PASS="Password" ZABBIX_PASS="Password"
export NETBOX_HOST="https://netbox.local" NETBOX_HOST="https://netbox.local"
export NETBOX_TOKEN="secrettoken" NETBOX_TOKEN="secrettoken"
``` ```
Or, you can use a Zabbix API token to login instead of using a username and Or, you can use a Zabbix API token to login instead of using a username and
password. In that case `ZABBIX_USER` and `ZABBIX_PASS` will be ignored. password. In that case `ZABBIX_USER` and `ZABBIX_PASS` will be ignored.
```sh ```bash
export ZABBIX_TOKEN=othersecrettoken ZABBIX_TOKEN=othersecrettoken
``` ```
If you are using custom SSL certificates for NetBox and/or Zabbix, you can set If you are using custom SSL certificates for NetBox and/or Zabbix, you can set
@ -190,9 +190,9 @@ used:
| cluster | VM cluster name | | cluster | VM cluster name |
| cluster_type | VM cluster type | | cluster_type | VM cluster type |
You can specify the value sperated by a "/" like so: You can specify the value seperated by a "/" like so:
``` ```python
hostgroup_format = "tenant/site/dev_location/role" hostgroup_format = "tenant/site/dev_location/role"
``` ```
@ -239,7 +239,7 @@ have a relationship with a tenant.
- Device_role: PDU - Device_role: PDU
- Site: HQ-AMS - Site: HQ-AMS
``` ```python
hostgroup_format = "site/tenant/device_role" hostgroup_format = "site/tenant/device_role"
``` ```
@ -252,7 +252,7 @@ generated for both hosts:
The same logic applies to custom fields being used in the HG format: The same logic applies to custom fields being used in the HG format:
``` ```python
hostgroup_format = "site/mycustomfieldname" hostgroup_format = "site/mycustomfieldname"
``` ```
@ -299,15 +299,18 @@ You can set the inventory mode to "disabled", "manual" or "automatic" with the
[Zabbix Manual](https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory) [Zabbix Manual](https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory)
for more information about the modes. for more information about the modes.
Use the `inventory_map` variable to map which NetBox properties are used in Use the `device_inventory_map` variable to map which NetBox properties are used in
which Zabbix Inventory fields. For nested properties, you can use the '/' which Zabbix Inventory fields. For nested properties, you can use the '/'
seperator. For example, the following map will assign the custom field seperator. For example, the following map will assign the custom field
'mycustomfield' to the 'alias' Zabbix inventory field: 'mycustomfield' to the 'alias' Zabbix inventory field:
``` For Virtual Machines, use `vm_inventory_map`.
```python
inventory_sync = True inventory_sync = True
inventory_mode = "manual" inventory_mode = "manual"
inventory_map = { "custom_fields/mycustomfield/name": "alias"} device_inventory_map = {"custom_fields/mycustomfield/name": "alias"}
vm_inventory_map = {"custom_fields/mycustomfield/name": "alias"}
``` ```
See `config.py.example` for an extensive example map. Any Zabix Inventory fields See `config.py.example` for an extensive example map. Any Zabix Inventory fields
@ -328,14 +331,14 @@ sticking to the custom field.
You can change the behaviour in the config file. By default this setting is You can change the behaviour in the config file. By default this setting is
false but you can set it to true to use config context: false but you can set it to true to use config context:
``` ```python
templates_config_context = True templates_config_context = True
``` ```
After that make sure that for each host there is at least one template defined After that make sure that for each host there is at least one template defined
in the config context in this format: in the config context in this format:
``` ```json
{ {
"zabbix": { "zabbix": {
"templates": [ "templates": [
@ -353,10 +356,196 @@ added benefit of overwriting the template should a device in NetBox have a
device specific context defined. In this case the device specific context device specific context defined. In this case the device specific context
template(s) will take priority over the device type custom field template. template(s) will take priority over the device type custom field template.
``` ```python
templates_config_context_overrule = True templates_config_context_overrule = True
``` ```
### Tags
This script can sync host tags to your Zabbix hosts for use in filtering,
SLA calculations and event correlation.
Tags can be synced from the following sources:
1. NetBox device/vm tags
2. NetBox config ontext
3. NetBox fields
Syncing tags will override any tags that were set manually on the host,
making NetBox the single source-of-truth for managing tags.
To enable syncing, turn on tag_sync in the config file.
By default, this script will modify tag names and tag values to lowercase.
You can change this behaviour by setting tag_lower to False.
```python
tag_sync = True
tag_lower = True
```
#### Device tags
As NetBox doesn't follow the tag/value pattern for tags, we will need a tag
name set to register the netwbox tags.
By default the tag name is "NetBox", but you can change this to whatever you want.
The value for the tag can be choosen from 'name', 'display' or 'slug'.
```python
tag_name = 'NetBox'
tag_value = 'name'
```
#### Config context
You can supply custom tags via config context by adding the following:
```json
{
"zabbix": {
"tags": [
{
"MyTagName": "MyTagValue"
},
{
"environment": "production"
}
],
}
}
```
This will allow you to assign tags based on the config context rules.
#### NetBox Field
NetBox field can also be used as input for tags, just like inventory and usermacros.
To enable syncing from fields, make sure to configure a `device_tag_map` and/or a `vm_tag_map`.
```python
device_tag_map = {"site/name": "site",
"rack/name": "rack",
"platform/name": "target"}
vm_tag_map = {"site/name": "site",
"cluster/name": "cluster",
"platform/name": "target"}
```
To turn off field syncing, set the maps to empty dictionaries:
```python
device_tag_map = {}
vm_tag_map = {}
```
### Usermacros
You can choose to use NetBox as a source for Host usermacros by
enabling the following option in the configuration file:
```python
usermacro_sync = True
```
Please be advised that enabling this option will _clear_ any usermacros
manually set on the managed hosts and override them with the usermacros
from NetBox.
There are two NetBox sources that can be used to populate usermacros:
1. NetBox config context
2. NetBox fields
#### Config context
By defining a dictionary `usermacros` within the `zabbix` key in
config context, you can dynamically assign usermacro values based on
anything that you can target based on
[config contexts](https://netboxlabs.com/docs/netbox/en/stable/features/context-data/)
within NetBox.
Through this method, it is possible to define the following types of usermacros:
1. Text
2. Secret
3. Vault
The default macro type is text if no `type` and `value` have been set.
It is also possible to create usermacros with
[context](https://www.zabbix.com/documentation/7.0/en/manual/config/macros/user_macros_context).
Examples:
```json
{
"zabbix": {
"usermacros": {
"{$USER_MACRO}": "test value",
"{$CONTEXT_MACRO:\"test\"}": "test value",
"{$CONTEXT_REGEX_MACRO:regex:\".*\"}": "test value",
"{$SECRET_MACRO}": {
"type": "secret",
"value": "PaSsPhRaSe"
},
"{$VAULT_MACRO}": {
"type": "vault",
"value": "secret/vmware:password"
},
"{$USER_MACRO2}": {
"type": "text",
"value": "another test value"
}
}
}
}
```
Please be aware that secret usermacros are only synced _once_ by default.
This is the default behaviour because Zabbix API won't return the value of
secrets so the script cannot compare the values with the ones set in NetBox.
If you update a secret usermacro value, just remove the value from the host
in Zabbix and the new value will be synced during the next run.
Alternatively, you can set the following option in the config file:
```python
usermacro_sync = "full"
```
This will force a full usermacro sync on every run on hosts that have secret usermacros set.
That way, you will know for sure the secret values are always up to date.
Keep in mind that NetBox (and the log output of this script) will show your secrets
in plain text. If true secrecy is required, consider switching to
[vault](https://www.zabbix.com/documentation/current/en/manual/config/macros/secret_macros#vault-secret)
usermacros.
#### Netbox Fields
To use NetBox fields as a source for usermacros, you will need to set up usermacro maps
for devices and/or virtual machines in the configuration file.
This method only supports `text` type usermacros.
For example:
```python
usermacro_sync = True
device_usermacro_map = {"serial": "{$HW_SERIAL}",
"role/name": "{$DEV_ROLE}",
"url": "{$NB_URL}",
"id": "{$NB_ID}"}
vm_usermacro_map = {"memory": "{$TOTAL_MEMORY}",
"role/name": "{$DEV_ROLE}",
"url": "{$NB_URL}",
"id": "{$NB_ID}"}
```
## Permissions ## Permissions
### NetBox ### NetBox
@ -393,9 +582,11 @@ python3 netbox_zabbix_sync.py
### Flags ### Flags
| Flag | Option | Description | | Flag | Option | Description |
| ---- | ------- | ---------------------- | | ---- | --------- | ------------------------------------- |
| -v | verbose | Log with debugging on. | | -v | verbose | Log with info on. |
| -vv | debug | Log with debugging on. |
| -vvv | debug-all | Log with debugging on for all modules |
## Config context ## Config context
@ -525,9 +716,13 @@ environment. For example, you could:
} }
``` ```
I would recommend using macros for sensitive data such as community strings I would recommend using usermacros for sensitive data such as community strings
since the data in NetBox is plain-text. since the data in NetBox is plain-text.
> **_NOTE:_** Not all SNMP data is required for a working configuration. > **_NOTE:_** Not all SNMP data is required for a working configuration.
> [The following parameters are allowed](https://www.zabbix.com/documentation/current/manual/api/reference/hostinterface/object#details_tag "The following parameters are allowed")but > [The following parameters are allowed](https://www.zabbix.com/documentation/current/manual/api/reference/hostinterface/object#details_tag "The following parameters are allowed")but
> are not all required, depending on your environment. > are not all required, depending on your environment.

View File

@ -80,19 +80,74 @@ inventory_sync = False
# For nested properties, you can use the '/' seperator. # For nested properties, you can use the '/' seperator.
# For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field: # For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field:
# #
# inventory_map = { "custom_fields/mycustomfield/name": "alias"} # device_inventory_map = { "custom_fields/mycustomfield/name": "alias"}
# #
# The following map should provide some nice defaults: # The following maps should provide some nice defaults:
inventory_map = { "asset_tag": "asset_tag", device_inventory_map = { "asset_tag": "asset_tag",
"virtual_chassis/name": "chassis", "virtual_chassis/name": "chassis",
"status/label": "deployment_status", "status/label": "deployment_status",
"location/name": "location", "location/name": "location",
"latitude": "location_lat", "latitude": "location_lat",
"longitude": "location_lon", "longitude": "location_lon",
"comments": "notes", "comments": "notes",
"name": "name", "name": "name",
"rack/name": "site_rack", "rack/name": "site_rack",
"serial": "serialno_a", "serial": "serialno_a",
"device_type/model": "type", "device_type/model": "type",
"device_type/manufacturer/name": "vendor", "device_type/manufacturer/name": "vendor",
"oob_ip/address": "oob_ip" } "oob_ip/address": "oob_ip" }
# We also support inventory mapping on Virtual Machines.
vm_inventory_map = { "status/label": "deployment_status",
"comments": "notes",
"name": "name" }
# To allow syncing of usermacros from NetBox, set to True.
# this will enable both field mapping and config context usermacros.
#
# If set to "full", it will force the update of secret usermacros every run.
# Please see the README.md for more information.
usermacro_sync = False
# device usermacro_map to map NetBox fields to usermacros.
device_usermacro_map = {"serial": "{$HW_SERIAL}",
"role/name": "{$DEV_ROLE}",
"url": "{$NB_URL}",
"id": "{$NB_ID}"}
# virtual machine usermacro_map to map NetBox fields to usermacros.
vm_usermacro_map = {"memory": "{$TOTAL_MEMORY}",
"role/name": "{$DEV_ROLE}",
"url": "{$NB_URL}",
"id": "{$NB_ID}"}
# To sync host tags to Zabbix, set to True.
tag_sync = False
# Setting tag_lower to True will lower capital letters ain tag names and values
# This is more inline with the Zabbix way of working with tags.
#
# You can however set this to False to ensure capital letters are synced to Zabbix tags.
tag_lower = True
# We can sync NetBox device/VM tags to Zabbix, but as NetBox tags don't follow the key/value
# pattern, we need to specify a tag name to register the NetBox tags in Zabbix.
#
#
#
# If tag_name is set to False, we won't sync NetBox device/VM tags to Zabbix.
tag_name = 'NetBox'
# We can choose to use 'name', 'slug' or 'display' NetBox tag properties as a value in Zabbix.
# 'name'is used by default.
tag_value = "name"
# device tag_map to map NetBox fields to host tags.
device_tag_map = {"site/name": "site",
"rack/name": "rack",
"platform/name": "target"}
# Virtual machine tag_map to map NetBox fields to host tags.
vm_tag_map = {"site/name": "site",
"cluster/name": "cluster",
"platform/name": "target"}

View File

@ -1,28 +1,40 @@
#!/usr/bin/env python3 # pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation, too-many-lines, too-many-public-methods, duplicate-code
# pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation, too-many-lines
""" """
Device specific handeling for NetBox to Zabbix Device specific handeling for NetBox to Zabbix
""" """
from re import search
from copy import deepcopy
from logging import getLogger from logging import getLogger
from re import search
from zabbix_utils import APIRequestError from zabbix_utils import APIRequestError
from modules.exceptions import (SyncInventoryError, TemplateError, SyncExternalError,
InterfaceConfigError, JournalError) from modules.exceptions import (
from modules.interface import ZabbixInterface InterfaceConfigError,
JournalError,
SyncExternalError,
SyncInventoryError,
TemplateError,
)
from modules.hostgroups import Hostgroup from modules.hostgroups import Hostgroup
from modules.interface import ZabbixInterface
from modules.tags import ZabbixTags
from modules.tools import field_mapper, remove_duplicates
from modules.usermacros import ZabbixUsermacros
from modules.config import load_config from modules.config import load_config
config = load_config() config = load_config()
class PhysicalDevice:
class PhysicalDevice():
# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments # pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments
""" """
Represents Network device. Represents Network device.
INPUT: (NetBox device class, ZabbixAPI class, journal flag, NB journal class) INPUT: (NetBox device class, ZabbixAPI class, journal flag, NB journal class)
""" """
def __init__(self, nb, zabbix, nb_journal_class, nb_version, journal=None, logger=None): def __init__(
self, nb, zabbix, nb_journal_class, nb_version, journal=None, logger=None
):
self.nb = nb self.nb = nb
self.id = nb.id self.id = nb.id
self.name = nb.name self.name = nb.name
@ -43,6 +55,8 @@ class PhysicalDevice():
self.nb_journals = nb_journal_class self.nb_journals = nb_journal_class
self.inventory_mode = -1 self.inventory_mode = -1
self.inventory = {} self.inventory = {}
self.usermacros = {}
self.tags = {}
self.logger = logger if logger else getLogger(__name__) self.logger = logger if logger else getLogger(__name__)
self._setBasics() self._setBasics()
@ -52,6 +66,18 @@ class PhysicalDevice():
def __str__(self): def __str__(self):
return self.__repr__() return self.__repr__()
def _inventory_map(self):
"""Use device inventory maps"""
return config["device_inventory_map"]
def _usermacro_map(self):
"""Use device inventory maps"""
return config["device_usermacro_map"]
def _tag_map(self):
"""Use device host tag maps"""
return config["device_tag_map"]
def _setBasics(self): def _setBasics(self):
""" """
Sets basic information like IP address. Sets basic information like IP address.
@ -62,7 +88,7 @@ class PhysicalDevice():
self.ip = self.cidr.split("/")[0] self.ip = self.cidr.split("/")[0]
else: else:
e = f"Host {self.name}: no primary IP." e = f"Host {self.name}: no primary IP."
self.logger.info(e) self.logger.warning(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
# Check if device has custom field for ZBX ID # Check if device has custom field for ZBX ID
@ -76,30 +102,38 @@ class PhysicalDevice():
# Validate hostname format. # Validate hostname format.
odd_character_list = ["ä", "ö", "ü", "Ä", "Ö", "Ü", "ß"] odd_character_list = ["ä", "ö", "ü", "Ä", "Ö", "Ü", "ß"]
self.use_visible_name = False self.use_visible_name = False
if (any(letter in self.name for letter in odd_character_list) or if any(letter in self.name for letter in odd_character_list) or bool(
bool(search('[\u0400-\u04FF]', self.name))): search("[\u0400-\u04ff]", self.name)
):
self.name = f"NETBOX_ID{self.id}" self.name = f"NETBOX_ID{self.id}"
self.visible_name = self.nb.name self.visible_name = self.nb.name
self.use_visible_name = True self.use_visible_name = True
self.logger.info(f"Host {self.visible_name} contains special characters. " self.logger.info(
f"Using {self.name} as name for the NetBox object " f"Host {self.visible_name} contains special characters. "
f"and using {self.visible_name} as visible name in Zabbix.") f"Using {self.name} as name for the NetBox object "
f"and using {self.visible_name} as visible name in Zabbix."
)
else: else:
pass pass
def set_hostgroup(self, hg_format, nb_site_groups, nb_regions): def set_hostgroup(self, hg_format, nb_site_groups, nb_regions):
"""Set the hostgroup for this device""" """Set the hostgroup for this device"""
# Create new Hostgroup instance # Create new Hostgroup instance
hg = Hostgroup("dev", self.nb, self.nb_api_version, logger=self.logger, hg = Hostgroup(
nested_sitegroup_flag=config["traverse_site_groups"], "dev",
nested_region_flag=config["traverse_regions"], self.nb,
nb_groups=nb_site_groups, self.nb_api_version,
nb_regions=nb_regions) logger=self.logger,
nested_sitegroup_flag=traverse_site_groups,
nested_region_flag=traverse_regions,
nb_groups=nb_site_groups,
nb_regions=nb_regions,
)
# Generate hostgroup based on hostgroup format # Generate hostgroup based on hostgroup format
self.hostgroup = hg.generate(hg_format) self.hostgroup = hg.generate(hg_format)
def set_template(self, prefer_config_context, overrule_custom): def set_template(self, prefer_config_context, overrule_custom):
""" Set Template """ """Set Template"""
self.zbx_template_names = None self.zbx_template_names = None
# Gather templates ONLY from the device specific context # Gather templates ONLY from the device specific context
if prefer_config_context: if prefer_config_context:
@ -123,7 +157,7 @@ class PhysicalDevice():
return True return True
def get_templates_cf(self): def get_templates_cf(self):
""" Get template from custom field """ """Get template from custom field"""
# Get Zabbix templates from the device type # Get Zabbix templates from the device type
device_type_cfs = self.nb.device_type.custom_fields device_type_cfs = self.nb.device_type.custom_fields
# Check if the ZBX Template CF is present # Check if the ZBX Template CF is present
@ -131,22 +165,29 @@ class PhysicalDevice():
# Set value to template # Set value to template
return [device_type_cfs[config["template_cf"]]] return [device_type_cfs[config["template_cf"]]]
# Custom field not found, return error # Custom field not found, return error
e = (f'Custom field {config["template_cf"]} not ' e = (
f"found for {self.nb.device_type.manufacturer.name}" f"Custom field {template_cf} not "
f" - {self.nb.device_type.display}.") f"found for {self.nb.device_type.manufacturer.name}"
f" - {self.nb.device_type.display}."
)
self.logger.warning(e)
raise TemplateError(e) raise TemplateError(e)
def get_templates_context(self): def get_templates_context(self):
""" Get Zabbix templates from the device context """ """Get Zabbix templates from the device context"""
if "zabbix" not in self.config_context: if "zabbix" not in self.config_context:
e = (f"Host {self.name}: Key 'zabbix' not found in config " e = (
"context for template lookup") f"Host {self.name}: Key 'zabbix' not found in config "
"context for template lookup"
)
raise TemplateError(e) raise TemplateError(e)
if "templates" not in self.config_context["zabbix"]: if "templates" not in self.config_context["zabbix"]:
e = (f"Host {self.name}: Key 'templates' not found in config " e = (
"context 'zabbix' for template lookup") f"Host {self.name}: Key 'templates' not found in config "
"context 'zabbix' for template lookup"
)
raise TemplateError(e) raise TemplateError(e)
# Check if format is list or string. # Check if format is list or string.
if isinstance(self.config_context["zabbix"]["templates"], str): if isinstance(self.config_context["zabbix"]["templates"], str):
@ -154,7 +195,7 @@ class PhysicalDevice():
return self.config_context["zabbix"]["templates"] return self.config_context["zabbix"]["templates"]
def set_inventory(self, nbdevice): def set_inventory(self, nbdevice):
""" Set host inventory """ """Set host inventory"""
# Set inventory mode. Default is disabled (see class init function). # Set inventory mode. Default is disabled (see class init function).
if config["inventory_mode"] == "disabled": if config["inventory_mode"] == "disabled":
if config["inventory_sync"]: if config["inventory_sync"]:
@ -167,37 +208,17 @@ class PhysicalDevice():
elif config["inventory_mode"] == "automatic": elif config["inventory_mode"] == "automatic":
self.inventory_mode = 1 self.inventory_mode = 1
else: else:
self.logger.error(f"Host {self.name}: Specified value for inventory mode in " self.logger.error(
f'config is not valid. Got value {config["inventory_mode"]}') f"Host {self.name}: Specified value for inventory mode in"
f" config is not valid. Got value {config['inventory_mode']}"
)
return False return False
self.inventory = {} self.inventory = {}
if config["inventory_sync"] and self.inventory_mode in [0, 1]: if config["inventory_sync"] and self.inventory_mode in [0, 1]:
self.logger.debug(f"Host {self.name}: Starting inventory mapper") self.logger.debug(f"Host {self.name}: Starting inventory mapper")
# Let's build an inventory dict for each property in the inventory_map self.inventory = field_mapper(
for nb_inv_field, zbx_inv_field in config["inventory_map"].items(): self.name, self._inventory_map(), nbdevice, self.logger
field_list = nb_inv_field.split("/") # convert str to list based on delimiter )
# start at the base of the dict...
value = nbdevice
# ... and step through the dict till we find the needed value
for item in field_list:
value = value[item] if value else None
# Check if the result is usable and expected
# We want to apply any int or float 0 values,
# even if python thinks those are empty.
if ((value and isinstance(value, int | float | str)) or
(isinstance(value, int | float) and int(value) == 0)):
self.inventory[zbx_inv_field] = str(value)
elif not value:
# empty value should just be an empty string for API compatibility
self.logger.debug(f"Host {self.name}: NetBox inventory lookup for "
f"'{nb_inv_field}' returned an empty value")
self.inventory[zbx_inv_field] = ""
else:
# Value is not a string or numeral, probably not what the user expected.
self.logger.error(f"Host {self.name}: Inventory lookup for '{nb_inv_field}'"
" returned an unexpected type: it will be skipped.")
self.logger.debug(f"Host {self.name}: Inventory mapping complete. "
f"Mapped {len(list(filter(None, self.inventory.values())))} field(s)")
return True return True
def isCluster(self): def isCluster(self):
@ -211,13 +232,17 @@ class PhysicalDevice():
Returns chassis master ID. Returns chassis master ID.
""" """
if not self.isCluster(): if not self.isCluster():
e = (f"Unable to proces {self.name} for cluster calculation: " e = (
f"not part of a cluster.") f"Unable to proces {self.name} for cluster calculation: "
f"not part of a cluster."
)
self.logger.warning(e) self.logger.warning(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
if not self.nb.virtual_chassis.master: if not self.nb.virtual_chassis.master:
e = (f"{self.name} is part of a NetBox virtual chassis which does " e = (
"not have a master configured. Skipping for this reason.") f"{self.name} is part of a NetBox virtual chassis which does "
"not have a master configured. Skipping for this reason."
)
self.logger.error(e) self.logger.error(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
return self.nb.virtual_chassis.master.id return self.nb.virtual_chassis.master.id
@ -230,9 +255,11 @@ class PhysicalDevice():
""" """
masterid = self.getClusterMaster() masterid = self.getClusterMaster()
if masterid == self.id: if masterid == self.id:
self.logger.debug(f"Host {self.name} is primary cluster member. " self.logger.debug(
f"Modifying hostname from {self.name} to " + f"Host {self.name} is primary cluster member. "
f"{self.nb.virtual_chassis.name}.") f"Modifying hostname from {self.name} to "
+ f"{self.nb.virtual_chassis.name}."
)
self.name = self.nb.virtual_chassis.name self.name = self.nb.virtual_chassis.name
return True return True
self.logger.debug(f"Host {self.name} is non-primary cluster member.") self.logger.debug(f"Host {self.name} is non-primary cluster member.")
@ -257,18 +284,24 @@ class PhysicalDevice():
# Go through all templates found in Zabbix # Go through all templates found in Zabbix
for zbx_template in templates: for zbx_template in templates:
# If the template names match # If the template names match
if zbx_template['name'] == nb_template: if zbx_template["name"] == nb_template:
# Set match variable to true, add template details # Set match variable to true, add template details
# to class variable and return debug log # to class variable and return debug log
template_match = True template_match = True
self.zbx_templates.append({"templateid": zbx_template['templateid'], self.zbx_templates.append(
"name": zbx_template['name']}) {
"templateid": zbx_template["templateid"],
"name": zbx_template["name"],
}
)
e = f"Host {self.name}: found template {zbx_template['name']}" e = f"Host {self.name}: found template {zbx_template['name']}"
self.logger.debug(e) self.logger.debug(e)
# Return error should the template not be found in Zabbix # Return error should the template not be found in Zabbix
if not template_match: if not template_match:
e = (f"Unable to find template {nb_template} " e = (
f"for host {self.name} in Zabbix. Skipping host...") f"Unable to find template {nb_template} "
f"for host {self.name} in Zabbix. Skipping host..."
)
self.logger.warning(e) self.logger.warning(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
@ -280,8 +313,8 @@ class PhysicalDevice():
""" """
# Go through all groups # Go through all groups
for group in groups: for group in groups:
if group['name'] == self.hostgroup: if group["name"] == self.hostgroup:
self.group_id = group['groupid'] self.group_id = group["groupid"]
e = f"Host {self.name}: matched group {group['name']}" e = f"Host {self.name}: matched group {group['name']}"
self.logger.debug(e) self.logger.debug(e)
return True return True
@ -295,10 +328,13 @@ class PhysicalDevice():
if self.zabbix_id: if self.zabbix_id:
try: try:
# Check if the Zabbix host exists in Zabbix # Check if the Zabbix host exists in Zabbix
zbx_host = bool(self.zabbix.host.get(filter={'hostid': self.zabbix_id}, zbx_host = bool(
output=[])) self.zabbix.host.get(filter={"hostid": self.zabbix_id}, output=[])
e = (f"Host {self.name}: was already deleted from Zabbix." )
" Removed link in NetBox.") e = (
f"Host {self.name}: was already deleted from Zabbix."
" Removed link in NetBox."
)
if zbx_host: if zbx_host:
# Delete host should it exists # Delete host should it exists
self.zabbix.host.delete(self.zabbix_id) self.zabbix.host.delete(self.zabbix_id)
@ -323,9 +359,9 @@ class PhysicalDevice():
""" """
# Validate the hostname or visible name field # Validate the hostname or visible name field
if not self.use_visible_name: if not self.use_visible_name:
zbx_filter = {'host': self.name} zbx_filter = {"host": self.name}
else: else:
zbx_filter = {'name': self.visible_name} zbx_filter = {"name": self.visible_name}
host = self.zabbix.host.get(filter=zbx_filter, output=[]) host = self.zabbix.host.get(filter=zbx_filter, output=[])
return bool(host) return bool(host)
@ -351,6 +387,43 @@ class PhysicalDevice():
self.logger.warning(message) self.logger.warning(message)
raise SyncInventoryError(message) from e raise SyncInventoryError(message) from e
def set_usermacros(self):
"""
Generates Usermacros
"""
macros = ZabbixUsermacros(
self.nb,
self._usermacro_map(),
usermacro_sync,
logger=self.logger,
host=self.name,
)
if macros.sync is False:
self.usermacros = []
self.usermacros = macros.generate()
return True
def set_tags(self):
"""
Generates Host Tags
"""
tags = ZabbixTags(
self.nb,
self._tag_map(),
tag_sync,
tag_lower,
tag_name=tag_name,
tag_value=tag_value,
logger=self.logger,
host=self.name,
)
if tags.sync is False:
self.tags = []
self.tags = tags.generate()
return True
def setProxy(self, proxy_list): def setProxy(self, proxy_list):
""" """
Sets proxy or proxy group if this Sets proxy or proxy group if this
@ -361,14 +434,16 @@ class PhysicalDevice():
# check if the key Zabbix is defined in the config context # check if the key Zabbix is defined in the config context
if "zabbix" not in self.nb.config_context: if "zabbix" not in self.nb.config_context:
return False return False
if ("proxy" in self.nb.config_context["zabbix"] and if (
not self.nb.config_context["zabbix"]["proxy"]): "proxy" in self.nb.config_context["zabbix"]
and not self.nb.config_context["zabbix"]["proxy"]
):
return False return False
# Proxy group takes priority over a proxy due # Proxy group takes priority over a proxy due
# to it being HA and therefore being more reliable # to it being HA and therefore being more reliable
# Includes proxy group fix since Zabbix <= 6 should ignore this # Includes proxy group fix since Zabbix <= 6 should ignore this
proxy_types = ["proxy"] proxy_types = ["proxy"]
if str(self.zabbix.version).startswith('7'): if str(self.zabbix.version).startswith("7"):
# Only insert groups in front of list for Zabbix7 # Only insert groups in front of list for Zabbix7
proxy_types.insert(0, "proxy_group") proxy_types.insert(0, "proxy_group")
for proxy_type in proxy_types: for proxy_type in proxy_types:
@ -382,15 +457,23 @@ class PhysicalDevice():
continue continue
# If the proxy name matches # If the proxy name matches
if proxy["name"] == proxy_name: if proxy["name"] == proxy_name:
self.logger.debug(f"Host {self.name}: using {proxy['type']}" self.logger.debug(
f" {proxy_name}") f"Host {self.name}: using {proxy['type']}" f" {proxy_name}"
)
self.zbxproxy = proxy self.zbxproxy = proxy
return True return True
self.logger.warning(f"Host {self.name}: unable to find proxy {proxy_name}") self.logger.warning(
f"Host {self.name}: unable to find proxy {proxy_name}"
)
return False return False
def createInZabbix(self, groups, templates, proxies, def createInZabbix(
description="Host added by NetBox sync script."): self,
groups,
templates,
proxies,
description="Host added by NetBox sync script.",
):
""" """
Creates Zabbix host object with parameters from NetBox object. Creates Zabbix host object with parameters from NetBox object.
""" """
@ -398,14 +481,16 @@ class PhysicalDevice():
if not self._zabbixHostnameExists(): if not self._zabbixHostnameExists():
# Set group and template ID's for host # Set group and template ID's for host
if not self.setZabbixGroupID(groups): if not self.setZabbixGroupID(groups):
e = (f"Unable to find group '{self.hostgroup}' " e = (
f"for host {self.name} in Zabbix.") f"Unable to find group '{self.hostgroup}' "
f"for host {self.name} in Zabbix."
)
self.logger.warning(e) self.logger.warning(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
self.zbxTemplatePrepper(templates) self.zbxTemplatePrepper(templates)
templateids = [] templateids = []
for template in self.zbx_templates: for template in self.zbx_templates:
templateids.append({'templateid': template['templateid']}) templateids.append({"templateid": template["templateid"]})
# Set interface, group and template configuration # Set interface, group and template configuration
interfaces = self.setInterfaceDetails() interfaces = self.setInterfaceDetails()
groups = [{"groupid": self.group_id}] groups = [{"groupid": self.group_id}]
@ -421,13 +506,15 @@ class PhysicalDevice():
"templates": templateids, "templates": templateids,
"description": description, "description": description,
"inventory_mode": self.inventory_mode, "inventory_mode": self.inventory_mode,
"inventory": self.inventory "inventory": self.inventory,
"macros": self.usermacros,
"tags": self.tags,
} }
# If a Zabbix proxy or Zabbix Proxy group has been defined # If a Zabbix proxy or Zabbix Proxy group has been defined
if self.zbxproxy: if self.zbxproxy:
# If a lower version than 7 is used, we can assume that # If a lower version than 7 is used, we can assume that
# the proxy is a normal proxy and not a proxy group # the proxy is a normal proxy and not a proxy group
if not str(self.zabbix.version).startswith('7'): if not str(self.zabbix.version).startswith("7"):
create_data["proxy_hostid"] = self.zbxproxy["id"] create_data["proxy_hostid"] = self.zbxproxy["id"]
else: else:
# Configure either a proxy or proxy group # Configure either a proxy or proxy group
@ -438,9 +525,9 @@ class PhysicalDevice():
host = self.zabbix.host.create(**create_data) host = self.zabbix.host.create(**create_data)
self.zabbix_id = host["hostids"][0] self.zabbix_id = host["hostids"][0]
except APIRequestError as e: except APIRequestError as e:
e = f"Host {self.name}: Couldn't create. Zabbix returned {str(e)}." msg = f"Host {self.name}: Couldn't create. Zabbix returned {str(e)}."
self.logger.error(e) self.logger.error(msg)
raise SyncExternalError(e) from None raise SyncExternalError(msg) from e
# Set NetBox custom field to hostID value. # Set NetBox custom field to hostID value.
self.nb.custom_fields[config["device_cf"]] = int(self.zabbix_id) self.nb.custom_fields[config["device_cf"]] = int(self.zabbix_id)
self.nb.save() self.nb.save()
@ -448,8 +535,9 @@ class PhysicalDevice():
self.logger.info(msg) self.logger.info(msg)
self.create_journal_entry("success", msg) self.create_journal_entry("success", msg)
else: else:
e = f"Host {self.name}: Unable to add to Zabbix. Host already present." self.logger.error(
self.logger.warning(e) f"Host {self.name}: Unable to add to Zabbix. Host already present."
)
def createZabbixHostgroup(self, hostgroups): def createZabbixHostgroup(self, hostgroups):
""" """
@ -458,8 +546,8 @@ class PhysicalDevice():
""" """
final_data = [] final_data = []
# Check if the hostgroup is in a nested format and check each parent # Check if the hostgroup is in a nested format and check each parent
for pos in range(len(self.hostgroup.split('/'))): for pos in range(len(self.hostgroup.split("/"))):
zabbix_hg = self.hostgroup.rsplit('/', pos)[0] zabbix_hg = self.hostgroup.rsplit("/", pos)[0]
if self.lookupZabbixHostgroup(hostgroups, zabbix_hg): if self.lookupZabbixHostgroup(hostgroups, zabbix_hg):
# Hostgroup already exists # Hostgroup already exists
continue continue
@ -470,7 +558,9 @@ class PhysicalDevice():
e = f"Hostgroup '{zabbix_hg}': created in Zabbix." e = f"Hostgroup '{zabbix_hg}': created in Zabbix."
self.logger.info(e) self.logger.info(e)
# Add group to final data # Add group to final data
final_data.append({'groupid': groupid["groupids"][0], 'name': zabbix_hg}) final_data.append(
{"groupid": groupid["groupids"][0], "name": zabbix_hg}
)
except APIRequestError as e: except APIRequestError as e:
msg = f"Hostgroup '{zabbix_hg}': unable to create. Zabbix returned {str(e)}." msg = f"Hostgroup '{zabbix_hg}': unable to create. Zabbix returned {str(e)}."
self.logger.error(msg) self.logger.error(msg)
@ -497,20 +587,24 @@ class PhysicalDevice():
try: try:
self.zabbix.host.update(hostid=self.zabbix_id, **kwargs) self.zabbix.host.update(hostid=self.zabbix_id, **kwargs)
except APIRequestError as e: except APIRequestError as e:
e = (f"Host {self.name}: Unable to update. " e = (
f"Zabbix returned the following error: {str(e)}.") f"Host {self.name}: Unable to update. "
f"Zabbix returned the following error: {str(e)}."
)
self.logger.error(e) self.logger.error(e)
raise SyncExternalError(e) from None raise SyncExternalError(e) from None
self.logger.info(f"Updated host {self.name} with data {kwargs}.") self.logger.info(f"Updated host {self.name} with data {kwargs}.")
self.create_journal_entry("info", "Updated host in Zabbix with latest NB data.") self.create_journal_entry("info", "Updated host in Zabbix with latest NB data.")
def ConsistencyCheck(self, groups, templates, proxies, proxy_power, create_hostgroups): def ConsistencyCheck(
self, groups, templates, proxies, proxy_power, create_hostgroups
):
# pylint: disable=too-many-branches, too-many-statements # pylint: disable=too-many-branches, too-many-statements
""" """
Checks if Zabbix object is still valid with NetBox parameters. Checks if Zabbix object is still valid with NetBox parameters.
""" """
# If group is found or if the hostgroup is nested # If group is found or if the hostgroup is nested
if not self.setZabbixGroupID(groups) or len(self.hostgroup.split('/')) > 1: if not self.setZabbixGroupID(groups) or len(self.hostgroup.split("/")) > 1:
if create_hostgroups: if create_hostgroups:
# Script is allowed to create a new hostgroup # Script is allowed to create a new hostgroup
new_groups = self.createZabbixHostgroup(groups) new_groups = self.createZabbixHostgroup(groups)
@ -521,47 +615,59 @@ class PhysicalDevice():
if not self.group_id: if not self.group_id:
# Function returns true / false but also sets GroupID # Function returns true / false but also sets GroupID
if not self.setZabbixGroupID(groups) and not create_hostgroups: if not self.setZabbixGroupID(groups) and not create_hostgroups:
e = (f"Host {self.name}: different hostgroup is required but " e = (
"unable to create hostgroup without generation permission.") f"Host {self.name}: different hostgroup is required but "
"unable to create hostgroup without generation permission."
)
self.logger.warning(e) self.logger.warning(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
# Prepare templates and proxy config # Prepare templates and proxy config
self.zbxTemplatePrepper(templates) self.zbxTemplatePrepper(templates)
self.setProxy(proxies) self.setProxy(proxies)
# Get host object from Zabbix # Get host object from Zabbix
host = self.zabbix.host.get(filter={'hostid': self.zabbix_id}, host = self.zabbix.host.get(
selectInterfaces=['type', 'ip', filter={"hostid": self.zabbix_id},
'port', 'details', selectInterfaces=["type", "ip", "port", "details", "interfaceid"],
'interfaceid'], selectGroups=["groupid"],
selectGroups=["groupid"], selectHostGroups=["groupid"],
selectHostGroups=["groupid"], selectParentTemplates=["templateid"],
selectParentTemplates=["templateid"], selectInventory=list(self._inventory_map().values()),
selectInventory=list(config["inventory_map"].values())) selectMacros=["macro", "value", "type", "description"],
selectTags=["tag", "value"],
)
if len(host) > 1: if len(host) > 1:
e = (f"Got {len(host)} results for Zabbix hosts " e = (
f"with ID {self.zabbix_id} - hostname {self.name}.") f"Got {len(host)} results for Zabbix hosts "
f"with ID {self.zabbix_id} - hostname {self.name}."
)
self.logger.error(e) self.logger.error(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
if len(host) == 0: if len(host) == 0:
e = (f"Host {self.name}: No Zabbix host found. " e = (
f"This is likely the result of a deleted Zabbix host " f"Host {self.name}: No Zabbix host found. "
f"without zeroing the ID field in NetBox.") f"This is likely the result of a deleted Zabbix host "
f"without zeroing the ID field in NetBox."
)
self.logger.error(e) self.logger.error(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
host = host[0] host = host[0]
if host["host"] == self.name: if host["host"] == self.name:
self.logger.debug(f"Host {self.name}: hostname in-sync.") self.logger.debug(f"Host {self.name}: hostname in-sync.")
else: else:
self.logger.warning(f"Host {self.name}: hostname OUT of sync. " self.logger.warning(
f"Received value: {host['host']}") f"Host {self.name}: hostname OUT of sync. "
f"Received value: {host['host']}"
)
self.updateZabbixHost(host=self.name) self.updateZabbixHost(host=self.name)
# Execute check depending on wether the name is special or not # Execute check depending on wether the name is special or not
if self.use_visible_name: if self.use_visible_name:
if host["name"] == self.visible_name: if host["name"] == self.visible_name:
self.logger.debug(f"Host {self.name}: visible name in-sync.") self.logger.debug(f"Host {self.name}: visible name in-sync.")
else: else:
self.logger.warning(f"Host {self.name}: visible name OUT of sync." self.logger.warning(
f" Received value: {host['name']}") f"Host {self.name}: visible name OUT of sync."
f" Received value: {host['name']}"
)
self.updateZabbixHost(name=self.visible_name) self.updateZabbixHost(name=self.visible_name)
# Check if the templates are in-sync # Check if the templates are in-sync
@ -570,24 +676,24 @@ class PhysicalDevice():
# Prepare Templates for API parsing # Prepare Templates for API parsing
templateids = [] templateids = []
for template in self.zbx_templates: for template in self.zbx_templates:
templateids.append({'templateid': template['templateid']}) templateids.append({"templateid": template["templateid"]})
# Update Zabbix with NB templates and clear any old / lost templates # Update Zabbix with NB templates and clear any old / lost templates
self.updateZabbixHost(templates_clear=host["parentTemplates"], self.updateZabbixHost(
templates=templateids) templates_clear=host["parentTemplates"], templates=templateids
)
else: else:
self.logger.debug(f"Host {self.name}: template(s) in-sync.") self.logger.debug(f"Host {self.name}: template(s) in-sync.")
# Check if Zabbix version is 6 or higher. Issue #93 # Check if Zabbix version is 6 or higher. Issue #93
group_dictname = "hostgroups" group_dictname = "hostgroups"
if str(self.zabbix.version).startswith(('6', '5')): if str(self.zabbix.version).startswith(("6", "5")):
group_dictname = "groups" group_dictname = "groups"
for group in host[group_dictname]: for group in host[group_dictname]:
if group["groupid"] == self.group_id: if group["groupid"] == self.group_id:
self.logger.debug(f"Host {self.name}: hostgroup in-sync.") self.logger.debug(f"Host {self.name}: hostgroup in-sync.")
break break
else:
self.logger.warning(f"Host {self.name}: hostgroup OUT of sync.") self.logger.warning(f"Host {self.name}: hostgroup OUT of sync.")
self.updateZabbixHost(groups={'groupid': self.group_id}) self.updateZabbixHost(groups={"groupid": self.group_id})
if int(host["status"]) == self.zabbix_state: if int(host["status"]) == self.zabbix_state:
self.logger.debug(f"Host {self.name}: status in-sync.") self.logger.debug(f"Host {self.name}: status in-sync.")
@ -607,13 +713,15 @@ class PhysicalDevice():
else: else:
self.logger.warning(f"Host {self.name}: proxy OUT of sync.") self.logger.warning(f"Host {self.name}: proxy OUT of sync.")
# Zabbix <= 6 patch # Zabbix <= 6 patch
if not str(self.zabbix.version).startswith('7'): if not str(self.zabbix.version).startswith("7"):
self.updateZabbixHost(proxy_hostid=self.zbxproxy['id']) self.updateZabbixHost(proxy_hostid=self.zbxproxy["id"])
# Zabbix 7+ # Zabbix 7+
else: else:
# Prepare data structure for updating either proxy or group # Prepare data structure for updating either proxy or group
update_data = {self.zbxproxy["idtype"]: self.zbxproxy["id"], update_data = {
"monitored_by": self.zbxproxy['monitored_by']} self.zbxproxy["idtype"]: self.zbxproxy["id"],
"monitored_by": self.zbxproxy["monitored_by"],
}
self.updateZabbixHost(**update_data) self.updateZabbixHost(**update_data)
else: else:
# No proxy is defined in NetBox # No proxy is defined in NetBox
@ -625,8 +733,10 @@ class PhysicalDevice():
proxy_set = True proxy_set = True
if proxy_power and proxy_set: if proxy_power and proxy_set:
# Zabbix <= 6 fix # Zabbix <= 6 fix
self.logger.warning(f"Host {self.name}: no proxy is configured in NetBox " self.logger.warning(
"but is configured in Zabbix. Removing proxy config in Zabbix") f"Host {self.name}: no proxy is configured in NetBox "
"but is configured in Zabbix. Removing proxy config in Zabbix"
)
if "proxy_hostid" in host and bool(host["proxy_hostid"]): if "proxy_hostid" in host and bool(host["proxy_hostid"]):
self.updateZabbixHost(proxy_hostid=0) self.updateZabbixHost(proxy_hostid=0)
# Zabbix 7 proxy # Zabbix 7 proxy
@ -638,29 +748,60 @@ class PhysicalDevice():
# Checks if a proxy has been defined in Zabbix and if proxy_power config has been set # Checks if a proxy has been defined in Zabbix and if proxy_power config has been set
if proxy_set and not proxy_power: if proxy_set and not proxy_power:
# Display error message # Display error message
self.logger.error(f"Host {self.name} is configured " self.logger.error(
f"with proxy in Zabbix but not in NetBox. The" f"Host {self.name} is configured "
" -p flag was ommited: no " f"with proxy in Zabbix but not in NetBox. The"
"changes have been made.") " -p flag was ommited: no "
"changes have been made."
)
if not proxy_set: if not proxy_set:
self.logger.debug(f"Host {self.name}: proxy in-sync.") self.logger.debug(f"Host {self.name}: proxy in-sync.")
# Check host inventory mode # Check host inventory mode
if str(host['inventory_mode']) == str(self.inventory_mode): if str(host["inventory_mode"]) == str(self.inventory_mode):
self.logger.debug(f"Host {self.name}: inventory_mode in-sync.") self.logger.debug(f"Host {self.name}: inventory_mode in-sync.")
else: else:
self.logger.warning(f"Host {self.name}: inventory_mode OUT of sync.") self.logger.warning(f"Host {self.name}: inventory_mode OUT of sync.")
self.updateZabbixHost(inventory_mode=str(self.inventory_mode)) self.updateZabbixHost(inventory_mode=str(self.inventory_mode))
if config["inventory_sync"] and self.inventory_mode in [0, 1]: if config["inventory_sync"] and self.inventory_mode in [0, 1]:
# Check host inventory mapping # Check host inventory mapping
if host['inventory'] == self.inventory: if host["inventory"] == self.inventory:
self.logger.debug(f"Host {self.name}: inventory in-sync.") self.logger.debug(f"Host {self.name}: inventory in-sync.")
else: else:
self.logger.warning(f"Host {self.name}: inventory OUT of sync.") self.logger.warning(f"Host {self.name}: inventory OUT of sync.")
self.updateZabbixHost(inventory=self.inventory) self.updateZabbixHost(inventory=self.inventory)
# Check host usermacros
if usermacro_sync:
macros_filtered = []
# Do not re-sync secret usermacros unless sync is set to 'full'
if str(usermacro_sync).lower() != "full":
for m in
(self.usermacros):
if m["type"] == str(1):
# Remove the value as the api doesn't return it
# this will allow us to only update usermacros that don't exist
m.pop("value")
macros_filtered.append(m)
if host["macros"] == self.usermacros or host["macros"] == macros_filtered:
self.logger.debug(f"Host {self.name}: usermacros in-sync.")
else:
self.logger.warning(f"Host {self.name}: usermacros OUT of sync.")
self.updateZabbixHost(macros=self.usermacros)
# Check host usermacros
if tag_sync:
if remove_duplicates(host["tags"], sortkey="tag") == self.tags:
self.logger.debug(f"Host {self.name}: tags in-sync.")
else:
self.logger.warning(f"Host {self.name}: tags OUT of sync.")
self.updateZabbixHost(tags=self.tags)
# If only 1 interface has been found # If only 1 interface has been found
# pylint: disable=too-many-nested-blocks # pylint: disable=too-many-nested-blocks
if len(host['interfaces']) == 1: if len(host["interfaces"]) == 1:
updates = {} updates = {}
# Go through each key / item and check if it matches Zabbix # Go through each key / item and check if it matches Zabbix
for key, item in self.setInterfaceDetails()[0].items(): for key, item in self.setInterfaceDetails()[0].items():
@ -696,12 +837,14 @@ class PhysicalDevice():
self.logger.warning(f"Host {self.name}: Interface OUT of sync.") self.logger.warning(f"Host {self.name}: Interface OUT of sync.")
if "type" in updates: if "type" in updates:
# Changing interface type not supported. Raise exception. # Changing interface type not supported. Raise exception.
e = (f"Host {self.name}: changing interface type to " e = (
f"{str(updates['type'])} is not supported.") f"Host {self.name}: changing interface type to "
f"{str(updates['type'])} is not supported."
)
self.logger.error(e) self.logger.error(e)
raise InterfaceConfigError(e) raise InterfaceConfigError(e)
# Set interfaceID for Zabbix config # Set interfaceID for Zabbix config
updates["interfaceid"] = host["interfaces"][0]['interfaceid'] updates["interfaceid"] = host["interfaces"][0]["interfaceid"]
try: try:
# API call to Zabbix # API call to Zabbix
self.zabbix.hostinterface.update(updates) self.zabbix.hostinterface.update(updates)
@ -717,9 +860,11 @@ class PhysicalDevice():
e = f"Host {self.name}: interface in-sync." e = f"Host {self.name}: interface in-sync."
self.logger.debug(e) self.logger.debug(e)
else: else:
e = (f"Host {self.name} has unsupported interface configuration." e = (
f" Host has total of {len(host['interfaces'])} interfaces. " f"Host {self.name} has unsupported interface configuration."
"Manual interfention required.") f" Host has total of {len(host['interfaces'])} interfaces. "
"Manual interfention required."
)
self.logger.error(e) self.logger.error(e)
raise SyncInventoryError(e) raise SyncInventoryError(e)
@ -731,20 +876,25 @@ class PhysicalDevice():
if self.journal: if self.journal:
# Check if the severity is valid # Check if the severity is valid
if severity not in ["info", "success", "warning", "danger"]: if severity not in ["info", "success", "warning", "danger"]:
self.logger.warning(f"Value {severity} not valid for NB journal entries.") self.logger.warning(
f"Value {severity} not valid for NB journal entries."
)
return False return False
journal = {"assigned_object_type": "dcim.device", journal = {
"assigned_object_id": self.id, "assigned_object_type": "dcim.device",
"kind": severity, "assigned_object_id": self.id,
"comments": message "kind": severity,
} "comments": message,
}
try: try:
self.nb_journals.create(journal) self.nb_journals.create(journal)
self.logger.debug(f"Host {self.name}: Created journal entry in NetBox") self.logger.debug(f"Host {self.name}: Created journal entry in NetBox")
return True return True
except JournalError(e) as e: except JournalError(e) as e:
self.logger.warning("Unable to create journal entry for " self.logger.warning(
f"{self.name}: NB returned {e}") "Unable to create journal entry for "
f"{self.name}: NB returned {e}"
)
return False return False
return False return False
@ -767,10 +917,15 @@ class PhysicalDevice():
# and add this NB template to the list of successfull templates # and add this NB template to the list of successfull templates
tmpls_from_zabbix.pop(pos) tmpls_from_zabbix.pop(pos)
succesfull_templates.append(nb_tmpl) succesfull_templates.append(nb_tmpl)
self.logger.debug(f"Host {self.name}: template " self.logger.debug(
f"{nb_tmpl['name']} is present in Zabbix.") f"Host {self.name}: template "
f"{nb_tmpl['name']} is present in Zabbix."
)
break break
if len(succesfull_templates) == len(self.zbx_templates) and len(tmpls_from_zabbix) == 0: if (
len(succesfull_templates) == len(self.zbx_templates)
and len(tmpls_from_zabbix) == 0
):
# All of the NetBox templates have been confirmed as successfull # All of the NetBox templates have been confirmed as successfull
# and the ZBX template list is empty. This means that # and the ZBX template list is empty. This means that
# all of the templates match. # all of the templates match.

View File

@ -2,32 +2,47 @@
""" """
All custom exceptions used for Exception generation All custom exceptions used for Exception generation
""" """
class SyncError(Exception): class SyncError(Exception):
""" Class SyncError """ """Class SyncError"""
class JournalError(Exception): class JournalError(Exception):
""" Class SyncError """ """Class SyncError"""
class SyncExternalError(SyncError): class SyncExternalError(SyncError):
""" Class SyncExternalError """ """Class SyncExternalError"""
class SyncInventoryError(SyncError): class SyncInventoryError(SyncError):
""" Class SyncInventoryError """ """Class SyncInventoryError"""
class SyncDuplicateError(SyncError): class SyncDuplicateError(SyncError):
""" Class SyncDuplicateError """ """Class SyncDuplicateError"""
class EnvironmentVarError(SyncError): class EnvironmentVarError(SyncError):
""" Class EnvironmentVarError """ """Class EnvironmentVarError"""
class InterfaceConfigError(SyncError): class InterfaceConfigError(SyncError):
""" Class InterfaceConfigError """ """Class InterfaceConfigError"""
class ProxyConfigError(SyncError): class ProxyConfigError(SyncError):
""" Class ProxyConfigError """ """Class ProxyConfigError"""
class HostgroupError(SyncError): class HostgroupError(SyncError):
""" Class HostgroupError """ """Class HostgroupError"""
class TemplateError(SyncError): class TemplateError(SyncError):
""" Class TemplateError """ """Class TemplateError"""
class UsermacroError(SyncError):
"""Class UsermacroError"""

View File

@ -1,14 +1,27 @@
"""Module for all hostgroup related code""" """Module for all hostgroup related code"""
from logging import getLogger from logging import getLogger
from modules.exceptions import HostgroupError from modules.exceptions import HostgroupError
from modules.tools import build_path from modules.tools import build_path
class Hostgroup():
class Hostgroup:
"""Hostgroup class for devices and VM's """Hostgroup class for devices and VM's
Takes type (vm or dev) and NB object""" Takes type (vm or dev) and NB object"""
def __init__(self, obj_type, nb_obj, version, logger=None, #pylint: disable=too-many-arguments, too-many-positional-arguments
nested_sitegroup_flag=False, nested_region_flag=False, # pylint: disable=too-many-arguments, disable=too-many-positional-arguments
nb_regions=None, nb_groups=None): def __init__(
self,
obj_type,
nb_obj,
version,
logger=None,
nested_sitegroup_flag=False,
nested_region_flag=False,
nb_regions=None,
nb_groups=None,
):
self.logger = logger if logger else getLogger(__name__) self.logger = logger if logger else getLogger(__name__)
if obj_type not in ("vm", "dev"): if obj_type not in ("vm", "dev"):
msg = f"Unable to create hostgroup with type {type}" msg = f"Unable to create hostgroup with type {type}"
@ -19,8 +32,9 @@ class Hostgroup():
self.name = self.nb.name self.name = self.nb.name
self.nb_version = version self.nb_version = version
# Used for nested data objects # Used for nested data objects
self.set_nesting(nested_sitegroup_flag, nested_region_flag, self.set_nesting(
nb_groups, nb_regions) nested_sitegroup_flag, nested_region_flag, nb_groups, nb_regions
)
self._set_format_options() self._set_format_options()
def __str__(self): def __str__(self):
@ -49,20 +63,28 @@ class Hostgroup():
format_options["site_group"] = None format_options["site_group"] = None
if self.nb.site: if self.nb.site:
if self.nb.site.region: if self.nb.site.region:
format_options["region"] = self.generate_parents("region", format_options["region"] = self.generate_parents(
str(self.nb.site.region)) "region", str(self.nb.site.region)
)
if self.nb.site.group: if self.nb.site.group:
format_options["site_group"] = self.generate_parents("site_group", format_options["site_group"] = self.generate_parents(
str(self.nb.site.group)) "site_group", str(self.nb.site.group)
)
format_options["role"] = role format_options["role"] = role
format_options["site"] = self.nb.site.name if self.nb.site else None format_options["site"] = self.nb.site.name if self.nb.site else None
format_options["tenant"] = str(self.nb.tenant) if self.nb.tenant else None format_options["tenant"] = str(self.nb.tenant) if self.nb.tenant else None
format_options["tenant_group"] = str(self.nb.tenant.group) if self.nb.tenant else None format_options["tenant_group"] = (
format_options["platform"] = self.nb.platform.name if self.nb.platform else None str(self.nb.tenant.group) if self.nb.tenant else None
)
format_options["platform"] = (
self.nb.platform.name if self.nb.platform else None
)
# Variables only applicable for devices # Variables only applicable for devices
if self.type == "dev": if self.type == "dev":
format_options["manufacturer"] = self.nb.device_type.manufacturer.name format_options["manufacturer"] = self.nb.device_type.manufacturer.name
format_options["location"] = str(self.nb.location) if self.nb.location else None format_options["location"] = (
str(self.nb.location) if self.nb.location else None
)
# Variables only applicable for VM's # Variables only applicable for VM's
if self.type == "vm": if self.type == "vm":
# Check if a cluster is configured. Could also be configured in a site. # Check if a cluster is configured. Could also be configured in a site.
@ -72,17 +94,22 @@ class Hostgroup():
self.format_options = format_options self.format_options = format_options
def set_nesting(self, nested_sitegroup_flag, nested_region_flag, def set_nesting(
nb_groups, nb_regions): self, nested_sitegroup_flag, nested_region_flag, nb_groups, nb_regions
):
"""Set nesting options for this Hostgroup""" """Set nesting options for this Hostgroup"""
self.nested_objects = {"site_group": {"flag": nested_sitegroup_flag, "data": nb_groups}, self.nested_objects = {
"region": {"flag": nested_region_flag, "data": nb_regions}} "site_group": {"flag": nested_sitegroup_flag, "data": nb_groups},
"region": {"flag": nested_region_flag, "data": nb_regions},
}
def generate(self, hg_format=None): def generate(self, hg_format=None):
"""Generate hostgroup based on a provided format""" """Generate hostgroup based on a provided format"""
# Set format to default in case its not specified # Set format to default in case its not specified
if not hg_format: if not hg_format:
hg_format = "site/manufacturer/role" if self.type == "dev" else "cluster/role" hg_format = (
"site/manufacturer/role" if self.type == "dev" else "cluster/role"
)
# Split all given names # Split all given names
hg_output = [] hg_output = []
hg_items = hg_format.split("/") hg_items = hg_format.split("/")
@ -93,8 +120,10 @@ class Hostgroup():
cf_data = self.custom_field_lookup(hg_item) cf_data = self.custom_field_lookup(hg_item)
# CF does not exist # CF does not exist
if not cf_data["result"]: if not cf_data["result"]:
msg = (f"Unable to generate hostgroup for host {self.name}. " msg = (
f"Item type {hg_item} not supported.") f"Unable to generate hostgroup for host {self.name}. "
f"Item type {hg_item} not supported."
)
self.logger.error(msg) self.logger.error(msg)
raise HostgroupError(msg) raise HostgroupError(msg)
# CF data is populated # CF data is populated
@ -109,10 +138,12 @@ class Hostgroup():
# Check if the hostgroup is populated with at least one item. # Check if the hostgroup is populated with at least one item.
if bool(hg_output): if bool(hg_output):
return "/".join(hg_output) return "/".join(hg_output)
msg = (f"Unable to generate hostgroup for host {self.name}." msg = (
" Not enough valid items. This is most likely" f"Unable to generate hostgroup for host {self.name}."
" due to the use of custom fields that are empty" " Not enough valid items. This is most likely"
" or an invalid hostgroup format.") " due to the use of custom fields that are empty"
" or an invalid hostgroup format."
)
self.logger.error(msg) self.logger.error(msg)
raise HostgroupError(msg) raise HostgroupError(msg)
@ -157,7 +188,9 @@ class Hostgroup():
return child_object return child_object
# If the nested flag is True, perform parent calculation # If the nested flag is True, perform parent calculation
if self.nested_objects[nest_type]["flag"]: if self.nested_objects[nest_type]["flag"]:
final_nested_object = build_path(child_object, self.nested_objects[nest_type]["data"]) final_nested_object = build_path(
child_object, self.nested_objects[nest_type]["data"]
)
return "/".join(final_nested_object) return "/".join(final_nested_object)
# Nesting is not allowed for this object. Return child_object # Nesting is not allowed for this object. Return child_object
return child_object return child_object

View File

@ -4,7 +4,8 @@ All of the Zabbix interface related configuration
""" """
from modules.exceptions import InterfaceConfigError from modules.exceptions import InterfaceConfigError
class ZabbixInterface():
class ZabbixInterface:
"""Class that represents a Zabbix interface.""" """Class that represents a Zabbix interface."""
def __init__(self, context, ip): def __init__(self, context, ip):
@ -15,21 +16,16 @@ class ZabbixInterface():
def _set_default_port(self): def _set_default_port(self):
"""Sets default TCP / UDP port for different interface types""" """Sets default TCP / UDP port for different interface types"""
interface_mapping = { interface_mapping = {1: 10050, 2: 161, 3: 623, 4: 12345}
1: 10050,
2: 161,
3: 623,
4: 12345
}
# Check if interface type is listed in mapper. # Check if interface type is listed in mapper.
if self.interface['type'] not in interface_mapping: if self.interface["type"] not in interface_mapping:
return False return False
# Set default port to interface # Set default port to interface
self.interface["port"] = str(interface_mapping[self.interface['type']]) self.interface["port"] = str(interface_mapping[self.interface["type"]])
return True return True
def get_context(self): def get_context(self):
""" check if NetBox custom context has been defined. """ """check if NetBox custom context has been defined."""
if "zabbix" in self.context: if "zabbix" in self.context:
zabbix = self.context["zabbix"] zabbix = self.context["zabbix"]
if "interface_type" in zabbix: if "interface_type" in zabbix:
@ -43,7 +39,7 @@ class ZabbixInterface():
return False return False
def set_snmp(self): def set_snmp(self):
""" Check if interface is type SNMP """ """Check if interface is type SNMP"""
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
if self.interface["type"] == 2: if self.interface["type"] == 2:
# Checks if SNMP settings are defined in NetBox # Checks if SNMP settings are defined in NetBox
@ -63,7 +59,7 @@ class ZabbixInterface():
e = "SNMP version option is not defined." e = "SNMP version option is not defined."
raise InterfaceConfigError(e) raise InterfaceConfigError(e)
# If version 1 or 2 is used, get community string # If version 1 or 2 is used, get community string
if self.interface["details"]["version"] in ['1','2']: if self.interface["details"]["version"] in ["1", "2"]:
if "community" in snmp: if "community" in snmp:
# Set SNMP community to confix context value # Set SNMP community to confix context value
community = snmp["community"] community = snmp["community"]
@ -73,10 +69,16 @@ class ZabbixInterface():
self.interface["details"]["community"] = str(community) self.interface["details"]["community"] = str(community)
# If version 3 has been used, get all # If version 3 has been used, get all
# SNMPv3 NetBox related configs # SNMPv3 NetBox related configs
elif self.interface["details"]["version"] == '3': elif self.interface["details"]["version"] == "3":
items = ["securityname", "securitylevel", "authpassphrase", items = [
"privpassphrase", "authprotocol", "privprotocol", "securityname",
"contextname"] "securitylevel",
"authpassphrase",
"privpassphrase",
"authprotocol",
"privprotocol",
"contextname",
]
for key, item in snmp.items(): for key, item in snmp.items():
if key in items: if key in items:
self.interface["details"][key] = str(item) self.interface["details"][key] = str(item)
@ -91,13 +93,15 @@ class ZabbixInterface():
raise InterfaceConfigError(e) raise InterfaceConfigError(e)
def set_default_snmp(self): def set_default_snmp(self):
""" Set default config to SNMPv2, port 161 and community macro. """ """Set default config to SNMPv2, port 161 and community macro."""
self.interface = self.skelet self.interface = self.skelet
self.interface["type"] = "2" self.interface["type"] = "2"
self.interface["port"] = "161" self.interface["port"] = "161"
self.interface["details"] = {"version": "2", self.interface["details"] = {
"community": "{$SNMP_COMMUNITY}", "version": "2",
"bulk": "1"} "community": "{$SNMP_COMMUNITY}",
"bulk": "1",
}
def set_default_agent(self): def set_default_agent(self):
"""Sets interface to Zabbix agent defaults""" """Sets interface to Zabbix agent defaults"""

40
modules/logging.py Normal file
View File

@ -0,0 +1,40 @@
"""
Logging module for Netbox-Zabbix-sync
"""
import logging
from os import path
logger = logging.getLogger("NetBox-Zabbix-sync")
def get_logger():
"""
Return the logger for Netbox Zabbix Sync
"""
return logger
def setup_logger():
"""
Prepare a logger with stream and file handlers
"""
# Set logging
lgout = logging.StreamHandler()
lgfile = logging.FileHandler(
path.join(path.dirname(path.realpath(__file__)), "sync.log")
)
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.WARNING,
handlers=[lgout, lgfile],
)
def set_log_levels(root_level, own_level):
"""
Configure log levels for root and Netbox-Zabbix-sync logger
"""
logging.getLogger().setLevel(root_level)
logger.setLevel(own_level)

133
modules/tags.py Normal file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments, logging-fstring-interpolation
"""
All of the Zabbix Usermacro related configuration
"""
from logging import getLogger
from modules.tools import field_mapper, remove_duplicates
class ZabbixTags:
"""Class that represents a Zabbix interface."""
def __init__(
self,
nb,
tag_map,
tag_sync,
tag_lower=True,
tag_name=None,
tag_value=None,
logger=None,
host=None,
):
self.nb = nb
self.name = host if host else nb.name
self.tag_map = tag_map
self.logger = logger if logger else getLogger(__name__)
self.tags = {}
self.lower = tag_lower
self.tag_name = tag_name
self.tag_value = tag_value
self.tag_sync = tag_sync
self.sync = False
self._set_config()
def __repr__(self):
return self.name
def __str__(self):
return self.__repr__()
def _set_config(self):
"""
Setup class
"""
if self.tag_sync:
self.sync = True
return True
def validate_tag(self, tag_name):
"""
Validates tag name
"""
if tag_name and isinstance(tag_name, str) and len(tag_name) <= 256:
return True
return False
def validate_value(self, tag_value):
"""
Validates tag value
"""
if tag_value and isinstance(tag_value, str) and len(tag_value) <= 256:
return True
return False
def render_tag(self, tag_name, tag_value):
"""
Renders a tag
"""
tag = {}
if self.validate_tag(tag_name):
if self.lower:
tag["tag"] = tag_name.lower()
else:
tag["tag"] = tag_name
else:
self.logger.warning(f"Tag {tag_name} is not a valid tag name, skipping.")
return False
if self.validate_value(tag_value):
if self.lower:
tag["value"] = tag_value.lower()
else:
tag["value"] = tag_value
else:
self.logger.warning(
f"Tag {tag_name} has an invalid value: '{tag_value}', skipping."
)
return False
return tag
def generate(self):
"""
Generate full set of Usermacros
"""
# pylint: disable=too-many-branches
tags = []
# Parse the field mapper for tags
if self.tag_map:
self.logger.debug(f"Host {self.nb.name}: Starting tag mapper")
field_tags = field_mapper(self.nb.name, self.tag_map, self.nb, self.logger)
for tag, value in field_tags.items():
t = self.render_tag(tag, value)
if t:
tags.append(t)
# Parse NetBox config context for tags
if (
"zabbix" in self.nb.config_context
and "tags" in self.nb.config_context["zabbix"]
and isinstance(self.nb.config_context["zabbix"]["tags"], list)
):
for tag in self.nb.config_context["zabbix"]["tags"]:
if isinstance(tag, dict):
for tagname, value in tag.items():
t = self.render_tag(tagname, value)
if t:
tags.append(t)
# Pull in NetBox device tags if tag_name is set
if self.tag_name and isinstance(self.tag_name, str):
for tag in self.nb.tags:
if self.tag_value.lower() in ["display", "name", "slug"]:
value = tag[self.tag_value]
else:
value = tag["name"]
t = self.render_tag(self.tag_name, value)
if t:
tags.append(t)
return remove_duplicates(tags, sortkey="tag")

View File

@ -1,11 +1,14 @@
"""A collection of tools used by several classes""" """A collection of tools used by several classes"""
def convert_recordset(recordset): def convert_recordset(recordset):
""" Converts netbox RedcordSet to list of dicts. """ """Converts netbox RedcordSet to list of dicts."""
recordlist = [] recordlist = []
for record in recordset: for record in recordset:
recordlist.append(record.__dict__) recordlist.append(record.__dict__)
return recordlist return recordlist
def build_path(endpoint, list_of_dicts): def build_path(endpoint, list_of_dicts):
""" """
Builds a path list of related parent/child items. Builds a path list of related parent/child items.
@ -13,16 +16,17 @@ def build_path(endpoint, list_of_dicts):
be used in hostgroups. be used in hostgroups.
""" """
item_path = [] item_path = []
itemlist = [i for i in list_of_dicts if i['name'] == endpoint] itemlist = [i for i in list_of_dicts if i["name"] == endpoint]
item = itemlist[0] if len(itemlist) == 1 else None item = itemlist[0] if len(itemlist) == 1 else None
item_path.append(item['name']) item_path.append(item["name"])
while item['_depth'] > 0: while item["_depth"] > 0:
itemlist = [i for i in list_of_dicts if i['name'] == str(item['parent'])] itemlist = [i for i in list_of_dicts if i["name"] == str(item["parent"])]
item = itemlist[0] if len(itemlist) == 1 else None item = itemlist[0] if len(itemlist) == 1 else None
item_path.append(item['name']) item_path.append(item["name"])
item_path.reverse() item_path.reverse()
return item_path return item_path
def proxy_prepper(proxy_list, proxy_group_list): def proxy_prepper(proxy_list, proxy_group_list):
""" """
Function that takes 2 lists and converts them using a Function that takes 2 lists and converts them using a
@ -42,3 +46,56 @@ def proxy_prepper(proxy_list, proxy_group_list):
group["monitored_by"] = 2 group["monitored_by"] = 2
output.append(group) output.append(group)
return output return output
def field_mapper(host, mapper, nbdevice, logger):
"""
Maps NetBox field data to Zabbix properties.
Used for Inventory, Usermacros and Tag mappings.
"""
data = {}
# Let's build an dict for each property in the map
for nb_field, zbx_field in mapper.items():
field_list = nb_field.split("/") # convert str to list based on delimiter
# start at the base of the dict...
value = nbdevice
# ... and step through the dict till we find the needed value
for item in field_list:
value = value[item] if value else None
# Check if the result is usable and expected
# We want to apply any int or float 0 values,
# even if python thinks those are empty.
if (value and isinstance(value, int | float | str)) or (
isinstance(value, int | float) and int(value) == 0
):
data[zbx_field] = str(value)
elif not value:
# empty value should just be an empty string for API compatibility
logger.debug(
f"Host {host}: NetBox lookup for "
f"'{nb_field}' returned an empty value"
)
data[zbx_field] = ""
else:
# Value is not a string or numeral, probably not what the user expected.
logger.error(
f"Host {host}: Lookup for '{nb_field}'"
" returned an unexpected type: it will be skipped."
)
logger.debug(
f"Host {host}: Field mapping complete. "
f"Mapped {len(list(filter(None, data.values())))} field(s)"
)
return data
def remove_duplicates(input_list, sortkey=None):
"""
Removes duplicate entries from a list and sorts the list
"""
output_list = []
if isinstance(input_list, list):
output_list = [dict(t) for t in {tuple(d.items()) for d in input_list}]
if sortkey and isinstance(sortkey, str):
output_list.sort(key=lambda x: x[sortkey])
return output_list

120
modules/usermacros.py Normal file
View File

@ -0,0 +1,120 @@
#!/usr/bin/env python3
# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments, logging-fstring-interpolation
"""
All of the Zabbix Usermacro related configuration
"""
from logging import getLogger
from re import match
from modules.tools import field_mapper
class ZabbixUsermacros:
"""Class that represents a Zabbix interface."""
def __init__(self, nb, usermacro_map, usermacro_sync, logger=None, host=None):
self.nb = nb
self.name = host if host else nb.name
self.usermacro_map = usermacro_map
self.logger = logger if logger else getLogger(__name__)
self.usermacros = {}
self.usermacro_sync = usermacro_sync
self.sync = False
self.force_sync = False
self._set_config()
def __repr__(self):
return self.name
def __str__(self):
return self.__repr__()
def _set_config(self):
"""
Setup class
"""
if str(self.usermacro_sync).lower() == "full":
self.sync = True
self.force_sync = True
elif self.usermacro_sync:
self.sync = True
return True
def validate_macro(self, macro_name):
"""
Validates usermacro name
"""
pattern = r"\{\$[A-Z0-9\._]*(\:.*)?\}"
return match(pattern, macro_name)
def render_macro(self, macro_name, macro_properties):
"""
Renders a full usermacro from partial input
"""
macro = {}
macrotypes = {"text": 0, "secret": 1, "vault": 2}
if self.validate_macro(macro_name):
macro["macro"] = str(macro_name)
if isinstance(macro_properties, dict):
if not "value" in macro_properties:
self.logger.warning(f"Usermacro {macro_name} has no value, skipping.")
return False
macro["value"] = macro_properties["value"]
if (
"type" in macro_properties
and macro_properties["type"].lower() in macrotypes
):
macro["type"] = str(macrotypes[macro_properties["type"]])
else:
macro["type"] = str(0)
if "description" in macro_properties and isinstance(
macro_properties["description"], str
):
macro["description"] = macro_properties["description"]
else:
macro["description"] = ""
elif isinstance(macro_properties, str) and macro_properties:
macro["value"] = macro_properties
macro["type"] = str(0)
macro["description"] = ""
else:
self.logger.warning(f"Usermacro {macro_name} has no value, skipping.")
return False
else:
self.logger.error(
f"Usermacro {macro_name} is not a valid usermacro name, skipping."
)
return False
return macro
def generate(self):
"""
Generate full set of Usermacros
"""
macros = []
# Parse the field mapper for usermacros
if self.usermacro_map:
self.logger.debug(f"Host {self.nb.name}: Starting usermacro mapper")
field_macros = field_mapper(
self.nb.name, self.usermacro_map, self.nb, self.logger
)
for macro, value in field_macros.items():
m = self.render_macro(macro, value)
if m:
macros.append(m)
# Parse NetBox config context for usermacros
if (
"zabbix" in self.nb.config_context
and "usermacros" in self.nb.config_context["zabbix"]
):
for macro, properties in self.nb.config_context["zabbix"][
"usermacros"
].items():
m = self.render_macro(macro, properties)
if m:
macros.append(m)
return macros

View File

@ -1,9 +1,10 @@
#!/usr/bin/env python3
# pylint: disable=duplicate-code # pylint: disable=duplicate-code
"""Module that hosts all functions for virtual machine processing""" """Module that hosts all functions for virtual machine processing"""
from modules.device import PhysicalDevice from modules.device import PhysicalDevice
from modules.exceptions import InterfaceConfigError, SyncInventoryError, TemplateError
from modules.hostgroups import Hostgroup from modules.hostgroups import Hostgroup
from modules.interface import ZabbixInterface from modules.interface import ZabbixInterface
from modules.exceptions import (TemplateError, InterfaceConfigError, from modules.exceptions import (TemplateError, InterfaceConfigError,
SyncInventoryError) SyncInventoryError)
from modules.config import load_config from modules.config import load_config
@ -13,24 +14,42 @@ config = load_config()
class VirtualMachine(PhysicalDevice): class VirtualMachine(PhysicalDevice):
"""Model for virtual machines""" """Model for virtual machines"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.hostgroup = None self.hostgroup = None
self.zbx_template_names = None self.zbx_template_names = None
def _inventory_map(self):
"""use VM inventory maps"""
return vm_inventory_map
def _usermacro_map(self):
"""use VM usermacro maps"""
return vm_usermacro_map
def _tag_map(self):
"""use VM tag maps"""
return vm_tag_map
def set_hostgroup(self, hg_format, nb_site_groups, nb_regions): def set_hostgroup(self, hg_format, nb_site_groups, nb_regions):
"""Set the hostgroup for this device""" """Set the hostgroup for this device"""
# Create new Hostgroup instance # Create new Hostgroup instance
hg = Hostgroup("vm", self.nb, self.nb_api_version, logger=self.logger, hg = Hostgroup(
nested_sitegroup_flag=config["traverse_site_groups"], "vm",
nested_region_flag=config["traverse_regions"], self.nb,
nb_groups=nb_site_groups, self.nb_api_version,
nb_regions=nb_regions) logger=self.logger,
nested_sitegroup_flag=traverse_site_groups,
nested_region_flag=traverse_regions,
nb_groups=nb_site_groups,
nb_regions=nb_regions,
)
# Generate hostgroup based on hostgroup format # Generate hostgroup based on hostgroup format
self.hostgroup = hg.generate(hg_format) self.hostgroup = hg.generate(hg_format)
def set_vm_template(self): def set_vm_template(self):
""" Set Template for VMs. Overwrites default class """Set Template for VMs. Overwrites default class
to skip a lookup of custom fields.""" to skip a lookup of custom fields."""
# Gather templates ONLY from the device specific context # Gather templates ONLY from the device specific context
try: try:

View File

@ -2,38 +2,27 @@
# pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation # pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation
"""NetBox to Zabbix sync script.""" """NetBox to Zabbix sync script."""
import logging
import argparse import argparse
import logging
import ssl import ssl
from os import environ, path, sys from os import environ, sys
from pynetbox import api from pynetbox import api
from pynetbox.core.query import RequestError as NBRequestError from pynetbox.core.query import RequestError as NBRequestError
from requests.exceptions import ConnectionError as RequestsConnectionError from requests.exceptions import ConnectionError as RequestsConnectionError
from zabbix_utils import ZabbixAPI, APIRequestError, ProcessingError from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI
from modules.config import load_config from modules.config import load_config
from modules.device import PhysicalDevice from modules.device import PhysicalDevice
from modules.virtual_machine import VirtualMachine
from modules.tools import convert_recordset, proxy_prepper
from modules.exceptions import EnvironmentVarError, HostgroupError, SyncError from modules.exceptions import EnvironmentVarError, HostgroupError, SyncError
from modules.logging import get_logger, set_log_levels, setup_logger
from modules.tools import convert_recordset, proxy_prepper
from modules.virtual_machine import VirtualMachine
config = load_config() config = load_config()
# Set logging
log_format = logging.Formatter('%(asctime)s - %(name)s - '
'%(levelname)s - %(message)s')
lgout = logging.StreamHandler()
lgout.setFormatter(log_format)
lgout.setLevel(logging.DEBUG)
lgfile = logging.FileHandler(path.join(path.dirname( setup_logger()
path.realpath(__file__)), "sync.log")) logger = get_logger()
lgfile.setFormatter(log_format)
lgfile.setLevel(logging.DEBUG)
logger = logging.getLogger("NetBox-Zabbix-sync")
logger.addHandler(lgout)
logger.addHandler(lgfile)
logger.setLevel(logging.WARNING)
def main(arguments): def main(arguments):
@ -41,7 +30,14 @@ def main(arguments):
# pylint: disable=too-many-branches, too-many-statements # pylint: disable=too-many-branches, too-many-statements
# set environment variables # set environment variables
if arguments.verbose: if arguments.verbose:
logger.setLevel(logging.DEBUG) set_log_levels(logging.WARNING, logging.INFO)
if arguments.debug:
set_log_levels(logging.WARNING, logging.DEBUG)
if arguments.debug_all:
set_log_levels(logging.DEBUG, logging.DEBUG)
if arguments.quiet:
set_log_levels(logging.ERROR, logging.ERROR)
env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"] env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"]
if "ZABBIX_TOKEN" in environ: if "ZABBIX_TOKEN" in environ:
env_vars.append("ZABBIX_TOKEN") env_vars.append("ZABBIX_TOKEN")
@ -69,15 +65,26 @@ def main(arguments):
netbox = api(netbox_host, token=netbox_token, threading=True) netbox = api(netbox_host, token=netbox_token, threading=True)
# Check if the provided Hostgroup layout is valid # Check if the provided Hostgroup layout is valid
hg_objects = config["hostgroup_format"].split("/") hg_objects = config["hostgroup_format"].split("/")
allowed_objects = ["location", "role", "manufacturer", "region", allowed_objects = [
"site", "site_group", "tenant", "tenant_group"] "location",
"role",
"manufacturer",
"region",
"site",
"site_group",
"tenant",
"tenant_group",
]
# Create API call to get all custom fields which are on the device objects # Create API call to get all custom fields which are on the device objects
try: try:
device_cfs = list(netbox.extras.custom_fields.filter( device_cfs = list(
type="text", content_type_id=23)) netbox.extras.custom_fields.filter(type="text", content_type_id=23)
)
except RequestsConnectionError: except RequestsConnectionError:
logger.error(f"Unable to connect to NetBox with URL {netbox_host}." logger.error(
" Please check the URL and status of NetBox.") f"Unable to connect to NetBox with URL {netbox_host}."
" Please check the URL and status of NetBox."
)
sys.exit(1) sys.exit(1)
except NBRequestError as e: except NBRequestError as e:
logger.error(f"NetBox error: {e}") logger.error(f"NetBox error: {e}")
@ -86,8 +93,10 @@ def main(arguments):
allowed_objects.append(cf.name) allowed_objects.append(cf.name)
for hg_object in hg_objects: for hg_object in hg_objects:
if hg_object not in allowed_objects: if hg_object not in allowed_objects:
e = (f"Hostgroup item {hg_object} is not valid. Make sure you" e = (
" use valid items and seperate them with '/'.") f"Hostgroup item {hg_object} is not valid. Make sure you"
" use valid items and seperate them with '/'."
)
logger.error(e) logger.error(e)
raise HostgroupError(e) raise HostgroupError(e)
# Set Zabbix API # Set Zabbix API
@ -99,18 +108,18 @@ def main(arguments):
ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"]) ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"])
if not zabbix_token: if not zabbix_token:
zabbix = ZabbixAPI(zabbix_host, user=zabbix_user,
password=zabbix_pass, ssl_context=ssl_ctx)
else:
zabbix = ZabbixAPI( zabbix = ZabbixAPI(
zabbix_host, token=zabbix_token, ssl_context=ssl_ctx) zabbix_host, user=zabbix_user, password=zabbix_pass, ssl_context=ssl_ctx
)
else:
zabbix = ZabbixAPI(zabbix_host, token=zabbix_token, ssl_context=ssl_ctx)
zabbix.check_auth() zabbix.check_auth()
except (APIRequestError, ProcessingError) as e: except (APIRequestError, ProcessingError) as e:
e = f"Zabbix returned the following error: {str(e)}" e = f"Zabbix returned the following error: {str(e)}"
logger.error(e) logger.error(e)
sys.exit(1) sys.exit(1)
# Set API parameter mapping based on API version # Set API parameter mapping based on API version
if not str(zabbix.version).startswith('7'): if not str(zabbix.version).startswith("7"):
proxy_name = "host" proxy_name = "host"
else: else:
proxy_name = "name" proxy_name = "name"
@ -123,18 +132,17 @@ def main(arguments):
netbox_site_groups = convert_recordset((netbox.dcim.site_groups.all())) netbox_site_groups = convert_recordset((netbox.dcim.site_groups.all()))
netbox_regions = convert_recordset(netbox.dcim.regions.all()) netbox_regions = convert_recordset(netbox.dcim.regions.all())
netbox_journals = netbox.extras.journal_entries netbox_journals = netbox.extras.journal_entries
zabbix_groups = zabbix.hostgroup.get(output=['groupid', 'name']) zabbix_groups = zabbix.hostgroup.get(output=["groupid", "name"])
zabbix_templates = zabbix.template.get(output=['templateid', 'name']) zabbix_templates = zabbix.template.get(output=["templateid", "name"])
zabbix_proxies = zabbix.proxy.get(output=['proxyid', proxy_name]) zabbix_proxies = zabbix.proxy.get(output=["proxyid", proxy_name])
# Set empty list for proxy processing Zabbix <= 6 # Set empty list for proxy processing Zabbix <= 6
zabbix_proxygroups = [] zabbix_proxygroups = []
if str(zabbix.version).startswith('7'): if str(zabbix.version).startswith("7"):
zabbix_proxygroups = zabbix.proxygroup.get( zabbix_proxygroups = zabbix.proxygroup.get(output=["proxy_groupid", "name"])
output=["proxy_groupid", "name"])
# Sanitize proxy data # Sanitize proxy data
if proxy_name == "host": if proxy_name == "host":
for proxy in zabbix_proxies: for proxy in zabbix_proxies:
proxy['name'] = proxy.pop('host') proxy["name"] = proxy.pop("host")
# Prepare list of all proxy and proxy_groups # Prepare list of all proxy and proxy_groups
zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups) zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups)
@ -156,27 +164,36 @@ def main(arguments):
# Check if a valid hostgroup has been found for this VM. # Check if a valid hostgroup has been found for this VM.
if not vm.hostgroup: if not vm.hostgroup:
continue continue
vm.set_inventory(nb_vm)
vm.set_usermacros()
vm.set_tags()
# Checks if device is in cleanup state # Checks if device is in cleanup state
if vm.status in config["zabbix_device_removal"]: if vm.status in config["zabbix_device_removal"]:
if vm.zabbix_id: if vm.zabbix_id:
# Delete device from Zabbix # Delete device from Zabbix
# and remove hostID from NetBox. # and remove hostID from NetBox.
vm.cleanup() vm.cleanup()
logger.info(f"VM {vm.name}: cleanup complete") logger.debug(f"VM {vm.name}: cleanup complete")
continue continue
# Device has been added to NetBox # Device has been added to NetBox
# but is not in Activate state # but is not in Activate state
logger.info(f"VM {vm.name}: skipping since this VM is " logger.info(
f"not in the active state.") f"VM {vm.name}: skipping since this VM is "
f"not in the active state."
)
continue continue
# Check if the VM is in the disabled state # Check if the VM is in the disabled state
if vm.status in config["zabbix_device_disable"]: if vm.status in config["zabbix_device_disable"]:
vm.zabbix_state = 1 vm.zabbix_state = 1
# Check if VM is already in Zabbix # Check if VM is already in Zabbix
if vm.zabbix_id: if vm.zabbix_id:
vm.ConsistencyCheck(zabbix_groups, zabbix_templates, vm.ConsistencyCheck(
zabbix_proxy_list, config["full_proxy_sync"], zabbix_groups,
config["create_hostgroups"]) zabbix_templates,
zabbix_proxy_list,
config["full_proxy_sync"],
config["create_hostgroups"],
)
continue continue
# Add hostgroup is config is set # Add hostgroup is config is set
if config["create_hostgroups"]: if config["create_hostgroups"]:
@ -187,8 +204,7 @@ def main(arguments):
# Add new hostgroups to zabbix group list # Add new hostgroups to zabbix group list
zabbix_groups.append(group) zabbix_groups.append(group)
# Add VM to Zabbix # Add VM to Zabbix
vm.createInZabbix(zabbix_groups, zabbix_templates, vm.createInZabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list)
zabbix_proxy_list)
except SyncError: except SyncError:
pass pass
@ -209,19 +225,22 @@ def main(arguments):
if not device.hostgroup: if not device.hostgroup:
continue continue
device.set_inventory(nb_device) device.set_inventory(nb_device)
device.set_usermacros()
device.set_tags()
# Checks if device is part of cluster. # Checks if device is part of cluster.
# Requires clustering variable # Requires clustering variable
if device.isCluster() and config["clustering"]: if device.isCluster() and config["clustering"]:
# Check if device is primary or secondary # Check if device is primary or secondary
if device.promoteMasterDevice(): if device.promoteMasterDevice():
e = (f"Device {device.name}: is " e = f"Device {device.name}: is " f"part of cluster and primary."
f"part of cluster and primary.")
logger.info(e) logger.info(e)
else: else:
# Device is secondary in cluster. # Device is secondary in cluster.
# Don't continue with this device. # Don't continue with this device.
e = (f"Device {device.name}: is part of cluster " e = (
f"but not primary. Skipping this host...") f"Device {device.name}: is part of cluster "
f"but not primary. Skipping this host..."
)
logger.info(e) logger.info(e)
continue continue
# Checks if device is in cleanup state # Checks if device is in cleanup state
@ -234,17 +253,23 @@ def main(arguments):
continue continue
# Device has been added to NetBox # Device has been added to NetBox
# but is not in Activate state # but is not in Activate state
logger.info(f"Device {device.name}: skipping since this device is " logger.info(
f"not in the active state.") f"Device {device.name}: skipping since this device is "
f"not in the active state."
)
continue continue
# Check if the device is in the disabled state # Check if the device is in the disabled state
if device.status in config["zabbix_device_disable"]: if device.status in config["zabbix_device_disable"]:
device.zabbix_state = 1 device.zabbix_state = 1
# Check if device is already in Zabbix # Check if device is already in Zabbix
if device.zabbix_id: if device.zabbix_id:
device.ConsistencyCheck(zabbix_groups, zabbix_templates, device.ConsistencyCheck(
zabbix_proxy_list, config["full_proxy_sync"], zabbix_groups,
config["create_hostgroups"]) zabbix_templates,
zabbix_proxy_list,
config["full_proxy_sync"],
config["create_hostgroups"],
)
continue continue
# Add hostgroup is config is set # Add hostgroup is config is set
if config["create_hostgroups"]: if config["create_hostgroups"]:
@ -255,17 +280,27 @@ def main(arguments):
# Add new hostgroups to zabbix group list # Add new hostgroups to zabbix group list
zabbix_groups.append(group) zabbix_groups.append(group)
# Add device to Zabbix # Add device to Zabbix
device.createInZabbix(zabbix_groups, zabbix_templates, device.createInZabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list)
zabbix_proxy_list)
except SyncError: except SyncError:
pass pass
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='A script to sync Zabbix with NetBox device data.' description="A script to sync Zabbix with NetBox device data."
) )
parser.add_argument("-v", "--verbose", help="Turn on debugging.", parser.add_argument(
action="store_true") "-v", "--verbose", help="Turn on debugging.", action="store_true"
)
parser.add_argument(
"-vv", "--debug", help="Turn on debugging.", action="store_true"
)
parser.add_argument(
"-vvv",
"--debug-all",
help="Turn on debugging for all modules.",
action="store_true",
)
parser.add_argument("-q", "--quiet", help="Turn off warnings.", action="store_true")
args = parser.parse_args() args = parser.parse_args()
main(args) main(args)