mirror of
https://github.com/TheNetworkGuy/netbox-zabbix-sync.git
synced 2026-03-22 04:28:37 -06:00
Compare commits
345 Commits
1.1
...
documentation-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aae04476b | ||
|
|
da17b7be7d | ||
|
|
4b54d93c6f | ||
|
|
8073cae46a | ||
|
|
9da113ac60 | ||
|
|
473dd1dcc1 | ||
|
|
14e68c34ea | ||
|
|
4d8cd6a81d | ||
|
|
0874bc9275 | ||
|
|
4d0c2a42e2 | ||
|
|
d4f1a2a572 | ||
|
|
f6b23b4bcd | ||
|
|
e7de68c7c3 | ||
|
|
0a37ff491c | ||
|
|
9ec8bb3c2c | ||
|
|
7b83d768d0 | ||
|
|
ed63c3e33b | ||
|
|
b7b399444c | ||
|
|
0b92586057 | ||
|
|
dc0a1f9122 | ||
|
|
e3487378c1 | ||
|
|
449704156c | ||
|
|
489a70b703 | ||
|
|
4185aaba24 | ||
|
|
a29f51f314 | ||
|
|
08519c7433 | ||
|
|
7cfed2ec76 | ||
|
|
d5e3199e92 | ||
|
|
f7d0989320 | ||
|
|
3be3cdc8ef | ||
|
|
a4d5fda5e3 | ||
|
|
02a5617bc8 | ||
|
|
434f0c9e68 | ||
|
|
c00ec4de31 | ||
|
|
dfba6f4714 | ||
|
|
223a27f47c | ||
|
|
d55fc0a4e7 | ||
|
|
39f3c57cca | ||
|
|
79396242fe | ||
|
|
2028b7b8aa | ||
|
|
ebbebfa17f | ||
|
|
de02d257f7 | ||
|
|
b3f02dc028 | ||
|
|
2b251b8f68 | ||
|
|
37257074bc | ||
|
|
0aa019e104 | ||
|
|
6d0b031016 | ||
|
|
ce7ad878a2 | ||
|
|
e2b5c853a4 | ||
|
|
3209e7077c | ||
|
|
14c0b9a479 | ||
|
|
22ebeaec1b | ||
|
|
b2d021e849 | ||
|
|
f302cef05c | ||
|
|
414f272d75 | ||
|
|
a8146b1e05 | ||
|
|
6697311f8d | ||
|
|
811e1eaa69 | ||
|
|
e15919cfdd | ||
|
|
6d715e6835 | ||
|
|
ab761f6b07 | ||
|
|
a151771002 | ||
|
|
df00114e3a | ||
|
|
623994c55f | ||
|
|
5c04757f4b | ||
|
|
e5d4bb64f0 | ||
|
|
3227bb3165 | ||
|
|
d53cc5e7d4 | ||
|
|
8c5cdc77d7 | ||
|
|
2ea211b5dd | ||
|
|
9212f486bf | ||
|
|
18d67d5c2b | ||
|
|
2e2939ce55 | ||
|
|
5255984f80 | ||
|
|
d32540d0e1 | ||
|
|
a80dc9fc2b | ||
|
|
f7dd8523a6 | ||
|
|
313158ea73 | ||
|
|
64c10726c7 | ||
|
|
cf4c4c5620 | ||
|
|
6b29a70aea | ||
|
|
49c6b4644c | ||
|
|
fd66a4c943 | ||
|
|
fdaeb79d4d | ||
|
|
765b4713a6 | ||
|
|
c275e08953 | ||
|
|
9cc229c2f7 | ||
|
|
40592a589d | ||
|
|
8197f41788 | ||
|
|
efb42916fd | ||
|
|
d75b0c2728 | ||
|
|
2fa05ffe92 | ||
|
|
b81d4abfcd | ||
|
|
047fb33332 | ||
|
|
bf512ada0b | ||
|
|
337184159b | ||
|
|
b9cf7b5bbe | ||
|
|
58365f5228 | ||
|
|
37774cfec3 | ||
|
|
c27505b927 | ||
|
|
bc12064b6a | ||
|
|
422d343c1f | ||
|
|
123b243f56 | ||
|
|
7d9bb9f637 | ||
|
|
17ba97be45 | ||
|
|
5810cbe621 | ||
|
|
b5d7596de7 | ||
|
|
18f52c1d40 | ||
|
|
79e82c4365 | ||
|
|
9259e73617 | ||
|
|
c58a3e8dd5 | ||
|
|
3e1657e575 | ||
|
|
161b310ba3 | ||
|
|
cf2c841d23 | ||
|
|
b258b02b91 | ||
|
|
e82c098e26 | ||
|
|
3910e0de2d | ||
|
|
98c13919c5 | ||
|
|
e718560689 | ||
|
|
57c7f83e6a | ||
|
|
e0ec3c0632 | ||
|
|
e4a1a17ded | ||
|
|
f15e53185b | ||
|
|
5923682d48 | ||
|
|
29a54e5a86 | ||
|
|
4a53b53789 | ||
|
|
6d4f1ac0a5 | ||
|
|
a522c98929 | ||
|
|
1de0b0781b | ||
|
|
1cf24fbcb5 | ||
|
|
c2b25e0cd2 | ||
|
|
9933c97e94 | ||
|
|
435fd1fa78 | ||
|
|
099ebcace5 | ||
|
|
906c719863 | ||
|
|
2a3d586302 | ||
|
|
753633e7d2 | ||
|
|
de82d5ac71 | ||
|
|
9912f24450 | ||
|
|
d056a20de2 | ||
|
|
a57b51870f | ||
|
|
dbc7acaf98 | ||
|
|
87b33706c0 | ||
|
|
affd4c6998 | ||
|
|
22982c3607 | ||
|
|
dec2cf6996 | ||
|
|
940f2d6afb | ||
|
|
d79f96a5b4 | ||
|
|
2f40ec467b | ||
|
|
e0d28633c3 | ||
|
|
0a20e270ed | ||
|
|
a5be9538d9 | ||
|
|
b31e41ca6b | ||
|
|
ba530ecd58 | ||
|
|
a3259c4fe3 | ||
|
|
5e390396ba | ||
|
|
ee6d13bfdf | ||
|
|
8fe7e5763b | ||
|
|
a7a79ea81e | ||
|
|
b62e8203b6 | ||
|
|
bfadd88542 | ||
|
|
bd4d21c5d8 | ||
|
|
148ce47c10 | ||
|
|
7969de50bf | ||
|
|
7394bf8d1d | ||
|
|
8ce2cab86f | ||
|
|
76723d2823 | ||
|
|
c58e5aba1e | ||
|
|
baf23403a0 | ||
|
|
3115eaa04e | ||
|
|
c8fda04ce8 | ||
|
|
7b8827fa94 | ||
|
|
b705e1341f | ||
|
|
8df17f208c | ||
|
|
22d735dd82 | ||
|
|
a325863aec | ||
|
|
9e1a90833d | ||
|
|
45e633b5ed | ||
|
|
298e6c4370 | ||
|
|
77b0798b65 | ||
|
|
27ee4c341f | ||
|
|
f7eb47a8a8 | ||
|
|
bc53737e02 | ||
|
|
539ad64c8d | ||
|
|
bbe28d9705 | ||
|
|
2998dfde54 | ||
|
|
d60eb1cb2d | ||
|
|
98edf0ad99 | ||
|
|
772fef0930 | ||
|
|
68cf28565d | ||
|
|
0c715d4f96 | ||
|
|
819126ce36 | ||
|
|
04a610cf84 | ||
|
|
e91eecffaa | ||
|
|
eb307337f6 | ||
|
|
5fd89a1f8a | ||
|
|
cb0500d0c0 | ||
|
|
7383583c43 | ||
|
|
dad7d2911f | ||
|
|
4fd582970d | ||
|
|
ad2ace942a | ||
|
|
989f6fa96e | ||
|
|
f303e7e01d | ||
|
|
38d61dcde7 | ||
|
|
feb719542d | ||
|
|
ea5b7d3196 | ||
|
|
28193cc120 | ||
|
|
908e7eeda9 | ||
|
|
e9a86334d9 | ||
|
|
2ea2edb6a6 | ||
|
|
37b3bfc7fb | ||
|
|
6abdac2eb4 | ||
|
|
13fe406b63 | ||
|
|
20a3c67fd4 | ||
|
|
b56a4332b9 | ||
|
|
73d34851fb | ||
|
|
10313ef5cf | ||
|
|
93c88333a6 | ||
|
|
50b7ede81b | ||
|
|
3e52edef2d | ||
|
|
4449e040ce | ||
|
|
aa6be1312e | ||
|
|
50c13c20cb | ||
|
|
964045f53e | ||
|
|
6bdaf4e5b7 | ||
|
|
5a3467538e | ||
|
|
50918e43fa | ||
|
|
7781bc6732 | ||
|
|
9ab5e09dd5 | ||
|
|
886c5b24b9 | ||
|
|
b314b2c883 | ||
|
|
0c798ec968 | ||
|
|
a5312365f9 | ||
|
|
53066d2d51 | ||
|
|
525904cf43 | ||
|
|
1e269780ce | ||
|
|
15d63ce3b8 | ||
|
|
c810b06718 | ||
|
|
825d788cfe | ||
|
|
48a04c58e3 | ||
|
|
733df33b71 | ||
|
|
593c8707af | ||
|
|
523393308d | ||
|
|
d65fa5b699 | ||
|
|
fd70045c6d | ||
|
|
f9453cc23c | ||
|
|
3d4e7803cc | ||
|
|
edb9cd6ab6 | ||
|
|
53d679e638 | ||
|
|
72558d3825 | ||
|
|
eea7df660a | ||
|
|
1b831a2d39 | ||
|
|
6d4e250b23 | ||
|
|
cebefd681e | ||
|
|
4264dc9b31 | ||
|
|
c67180138e | ||
|
|
b8bb3fb3f0 | ||
|
|
5f78a2c789 | ||
|
|
1157ed9e64 | ||
|
|
c7d3dab27c | ||
|
|
ba2f77a640 | ||
|
|
4c91c660a8 | ||
|
|
8272e34c12 | ||
|
|
4c982ff0f5 | ||
|
|
7a671d6625 | ||
|
|
5617275594 | ||
|
|
1673f7bb59 | ||
|
|
c76e36ad38 | ||
|
|
b0eee8ad9b | ||
|
|
9ff6b66c96 | ||
|
|
ffb8d5239c | ||
|
|
73d5306898 | ||
|
|
f301244306 | ||
|
|
867749ddd6 | ||
|
|
d0941ff909 | ||
|
|
434722df53 | ||
|
|
9131c940c5 | ||
|
|
8b670ba395 | ||
|
|
4ec8036c88 | ||
|
|
81764b589a | ||
|
|
acab7dd6d2 | ||
|
|
2177234d7f | ||
|
|
3f4d173ac0 | ||
|
|
0996059c5f | ||
|
|
0155c29fcc | ||
|
|
5d4ff9c5ed | ||
|
|
204937b784 | ||
|
|
e0827ac428 | ||
|
|
09a6906a63 | ||
|
|
30545ec0f3 | ||
|
|
56c19d97de | ||
|
|
ffc2aa1947 | ||
|
|
9417908994 | ||
|
|
06f97b132a | ||
|
|
20096a215b | ||
|
|
f1da1cfb50 | ||
|
|
5093823287 | ||
|
|
c1504987f1 | ||
|
|
d598a9739a | ||
|
|
7bf72de0f9 | ||
|
|
66f24e6891 | ||
|
|
bff34a8e38 | ||
|
|
886ef2a172 | ||
|
|
9c07d7dbc4 | ||
|
|
9f29d2b27b | ||
|
|
e827953d8d | ||
|
|
053028b283 | ||
|
|
2e867d1129 | ||
|
|
a0ea21d731 | ||
|
|
70a5c3e384 | ||
|
|
91796395ef | ||
|
|
610a73c061 | ||
|
|
4de022496e | ||
|
|
0603d8c244 | ||
|
|
2b92f8da9b | ||
|
|
d1ec1114ac | ||
|
|
acad07eed4 | ||
|
|
da4fec6bf1 | ||
|
|
07049ea6d8 | ||
|
|
2094799a51 | ||
|
|
c0c52f973e | ||
|
|
39b63aa420 | ||
|
|
017b5623f5 | ||
|
|
9be09bca10 | ||
|
|
23997f9423 | ||
|
|
e8a733cbd0 | ||
|
|
be76386584 | ||
|
|
b5a01e09e8 | ||
|
|
ecec3ee46e | ||
|
|
7099df93d1 | ||
|
|
d1e864c75b | ||
|
|
6f044cb228 | ||
|
|
2e7890784b | ||
|
|
c695353fce | ||
|
|
e0b473a6d4 | ||
|
|
8e9594172b | ||
|
|
8a749e63cf | ||
|
|
ddc65a6d58 | ||
|
|
58d894832e | ||
|
|
b9713792d7 | ||
|
|
45192531f9 | ||
|
|
72fde13ef4 | ||
|
|
78b9d5ae8b | ||
|
|
60140b4b74 | ||
|
|
e9143eb24c |
@@ -0,0 +1,17 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14",
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "pip install --user uv && uv sync --frozen --dev"
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
Vendored
+44
-35
@@ -1,46 +1,55 @@
|
||||
name: Publish Docker image to GHCR on a new version
|
||||
---
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dockertest
|
||||
# tags:
|
||||
# - [0-9]+.*
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
test_quality:
|
||||
uses: ./.github/workflows/quality.yml
|
||||
build_and_publish:
|
||||
uses: ./.github/workflows/quality.yml
|
||||
test_code:
|
||||
uses: ./.github/workflows/run_tests.yml
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
- 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: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- 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.
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
name: Upload Python Package to PyPI when a Release is Created
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Publish release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
url: https://pypi.org/p/netbox-zabbix-sync
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools wheel build
|
||||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
Vendored
+21
-18
@@ -1,26 +1,29 @@
|
||||
---
|
||||
name: Pylint Quality control
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
workflow_call
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11","3.12"]
|
||||
python-version: ["3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pylint
|
||||
pip install -r requirements.txt
|
||||
- name: Analysing the code with pylint
|
||||
run: |
|
||||
pylint --module-naming-style=any $(git ls-files '*.py')
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: uv sync --dev
|
||||
- name: Lint with ruff
|
||||
run: uv run ruff check .
|
||||
- name: Format check with ruff
|
||||
run: uv run ruff format --check .
|
||||
- name: Type check with ty
|
||||
run: uv run ty check
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: uv sync --dev
|
||||
- name: Copy example config
|
||||
run: cp config.py.example config.py
|
||||
- name: Run tests with coverage
|
||||
run: uv run pytest tests --cov --cov-report=term --cov-fail-under=70
|
||||
Vendored
+14
-1
@@ -1,5 +1,18 @@
|
||||
*.log
|
||||
config.py
|
||||
.venv
|
||||
.env
|
||||
/config.py
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.vscode
|
||||
.flake
|
||||
.coverage
|
||||
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
|
||||
netbox_zabbix_sync/_version.py
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+12
-1
@@ -1,9 +1,20 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-alpine
|
||||
RUN mkdir -p /opt/netbox-zabbix && chown -R 1000:1000 /opt/netbox-zabbix
|
||||
|
||||
RUN mkdir -p /opt/netbox-zabbix
|
||||
COPY . /opt/netbox-zabbix
|
||||
RUN addgroup -g 1000 -S netbox-zabbix && adduser -u 1000 -S netbox-zabbix -G netbox-zabbix
|
||||
RUN chown -R 1000:1000 /opt/netbox-zabbix
|
||||
|
||||
WORKDIR /opt/netbox-zabbix
|
||||
|
||||
COPY --chown=1000:1000 . /opt/netbox-zabbix
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
RUN if ! [ -f ./config.py ]; then cp ./config.py.example ./config.py; fi
|
||||
USER root
|
||||
RUN pip install -r ./requirements.txt
|
||||
USER 1000:1000
|
||||
ENTRYPOINT ["python"]
|
||||
CMD ["/opt/netbox-zabbix/netbox_zabbix_sync.py", "-v"]
|
||||
|
||||
@@ -1,58 +1,82 @@
|
||||
# NetBox to Zabbix synchronization
|
||||
|
||||
# Netbox to Zabbix synchronization
|
||||
A script to create, update and delete Zabbix hosts using NetBox device objects. Tested and compatible with all [currently supported Zabbix releases](https://www.zabbix.com/life_cycle_and_release_policy).
|
||||
|
||||
A script to create, update and delete Zabbix hosts using Netbox device objects.
|
||||
# Documentation
|
||||
|
||||
Documentation will be moved to the Github wiki of this project. Feel free to [check it out](https://github.com/TheNetworkGuy/netbox-zabbix-sync/wiki)!
|
||||
|
||||
## Installation via Docker
|
||||
|
||||
To pull the latest stable version to your local cache, use the following docker pull command:
|
||||
```
|
||||
To pull the latest stable version to your local cache, use the following docker
|
||||
pull command:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/thenetworkguy/netbox-zabbix-sync:main
|
||||
```
|
||||
|
||||
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 [env file](https://docs.docker.com/reference/cli/docker/container/run/#env).
|
||||
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
|
||||
[env file](https://docs.docker.com/reference/cli/docker/container/run/#env).
|
||||
|
||||
```
|
||||
```bash
|
||||
docker run -d -t -i -e ZABBIX_HOST='https://zabbix.local' \
|
||||
-e ZABBIX_TOKEN='othersecrettoken' \
|
||||
-e NETBOX_HOST='https://netbox.local' \
|
||||
-e NETBOX_TOKEN='secrettoken' \
|
||||
--name netbox-zabbix-sync ghcr.io/TheNetworkGuy/netbox-zabbix-sync:latest
|
||||
--name netbox-zabbix-sync ghcr.io/thenetworkguy/netbox-zabbix-sync:main
|
||||
```
|
||||
|
||||
This should run a one-time sync, you can check the sync with `docker logs netbox-zabbix-sync`.
|
||||
This should run a one-time sync. You can check the sync with
|
||||
`docker logs netbox-zabbix-sync`.
|
||||
|
||||
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 needed (see [config file](#config-file)):
|
||||
The image uses the default `config.py` for its configuration, you can use a
|
||||
volume mount in the docker run command to override with your own config file if
|
||||
needed (see [config file](#config-file)):
|
||||
|
||||
```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 ...
|
||||
```
|
||||
|
||||
## Installation from Source
|
||||
|
||||
### Cloning the repository
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://github.com/TheNetworkGuy/netbox-zabbix-sync.git
|
||||
```
|
||||
|
||||
### Packages
|
||||
Make sure that you have a python environment with the following packages installed. You can also use the `requirements.txt` file for installation with pip.
|
||||
```
|
||||
|
||||
Make sure that you have a python environment with the following packages
|
||||
installed. You can also use the `requirements.txt` file for installation with
|
||||
pip.
|
||||
|
||||
```sh
|
||||
# Packages:
|
||||
pynetbox
|
||||
pyzabbix
|
||||
zabbix-utils
|
||||
|
||||
# Install them through requirements.txt from a venv:
|
||||
virtualenv .venv
|
||||
source .venv/bin/activate
|
||||
.venv/bin/pip --require-virtualenv install -r requirements.txt
|
||||
```
|
||||
|
||||
### Config file
|
||||
First time user? Copy the `config.py.example` file to `config.py`. This file is used for modifying filters and setting variables such as custom field names.
|
||||
```
|
||||
|
||||
First time user? Copy the `config.py.example` file to `config.py`. This file is
|
||||
used for modifying filters and setting variables such as custom field names.
|
||||
|
||||
```sh
|
||||
cp config.py.example config.py
|
||||
```
|
||||
|
||||
### Set environment variables
|
||||
|
||||
Set the following environment variables:
|
||||
```
|
||||
|
||||
```bash
|
||||
ZABBIX_HOST="https://zabbix.local"
|
||||
ZABBIX_USER="username"
|
||||
ZABBIX_PASS="Password"
|
||||
@@ -60,15 +84,25 @@ NETBOX_HOST="https://netbox.local"
|
||||
NETBOX_TOKEN="secrettoken"
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```
|
||||
```bash
|
||||
ZABBIX_TOKEN=othersecrettoken
|
||||
```
|
||||
|
||||
### Netbox custom fields
|
||||
Use the following custom fields in Netbox (if you are using config context for the template information then the zabbix_template field is not required):
|
||||
If you are using custom SSL certificates for NetBox and/or Zabbix, you can set
|
||||
the following environment variable to the path of your CA bundle file:
|
||||
|
||||
```sh
|
||||
export REQUESTS_CA_BUNDLE=/path/to/your/ca-bundle.crt
|
||||
```
|
||||
|
||||
### NetBox custom fields
|
||||
|
||||
Use the following custom fields in NetBox (if you are using config context for
|
||||
the template information then the zabbix_template field is not required):
|
||||
|
||||
```
|
||||
* Type: Integer
|
||||
* Name: zabbix_hostid
|
||||
@@ -76,6 +110,7 @@ Use the following custom fields in Netbox (if you are using config context for t
|
||||
* Default: null
|
||||
* Object: dcim > device
|
||||
```
|
||||
|
||||
```
|
||||
* Type: Text
|
||||
* Name: zabbix_template
|
||||
@@ -83,124 +118,271 @@ Use the following custom fields in Netbox (if you are using config context for t
|
||||
* Default: null
|
||||
* Object: dcim > device_type
|
||||
```
|
||||
You can make the `zabbix_hostid` field hidden or read-only to prevent human intervention.
|
||||
|
||||
This is optional and there is a use case for leaving it read-write in the UI to manually change the ID. For example to re-run a sync.
|
||||
You can make the `zabbix_hostid` field hidden or read-only to prevent human
|
||||
intervention.
|
||||
|
||||
This is optional, but there may be cases where you want to leave it
|
||||
read-write in the UI. For example to manually change or clear the ID and re-run a sync.
|
||||
|
||||
## Virtual Machine (VM) Syncing
|
||||
|
||||
In order to use VM syncing, make sure that the `zabbix_id` custom field is also
|
||||
present on Virtual machine objects in NetBox.
|
||||
|
||||
Use the `config.py` file and set the `sync_vms` variable to `True`.
|
||||
|
||||
You can set the `vm_hostgroup_format` variable to a customizable value for VM
|
||||
hostgroups. The default is `cluster_type/cluster/role`.
|
||||
|
||||
To enable filtering for VM's, check the `nb_vm_filter` variable out. It works
|
||||
the same as with the device filter (see documentation under "Hostgroup layout").
|
||||
Note that not all filtering capabilities and properties of devices are
|
||||
applicable to VM's and vice-versa. Check the NetBox API documentation to see
|
||||
which filtering options are available for each object type.
|
||||
|
||||
## Config file
|
||||
|
||||
### Hostgroup
|
||||
Setting the `create_hostgroups` variable to `False` requires manual hostgroup creation for devices in a new category.
|
||||
|
||||
The format can be set with the `hostgroup_format` variable.
|
||||
Setting the `create_hostgroups` variable to `False` requires manual hostgroup
|
||||
creation for devices in a new category. I would recommend setting this variable
|
||||
to `True` since leaving it on `False` results in a lot of manual work.
|
||||
|
||||
Make sure that the Zabbix user has proper permissions to create hosts.
|
||||
The hostgroups are in a nested format. This means that proper permissions only need to be applied to the site name hostgroup and cascaded to any child hostgroups.
|
||||
The format can be set with the `hostgroup_format` variable for devices and
|
||||
`vm_hostgroup_format` for virtual machines.
|
||||
|
||||
Any nested parent hostgroups will also be created automatically. For instance
|
||||
the region `Berlin` with parent region `Germany` will create the hostgroup
|
||||
`Germany/Berlin`.
|
||||
|
||||
Make sure that the Zabbix user has proper permissions to create hosts. The
|
||||
hostgroups are in a nested format. This means that proper permissions only need
|
||||
to be applied to the site name hostgroup and cascaded to any child hostgroups.
|
||||
|
||||
#### Layout
|
||||
The default hostgroup layout is "site/manufacturer/device_role".
|
||||
|
||||
**Variables**
|
||||
The default hostgroup layout is "site/manufacturer/device_role". You can change
|
||||
this behaviour with the hostgroup_format variable. The following values can be
|
||||
used:
|
||||
|
||||
You can change this behaviour with the hostgroup_format variable. The following values can be used:
|
||||
| name | description |
|
||||
| ------------ | ------------ |
|
||||
|dev_location|The device location name|
|
||||
|dev_role|The device role name|
|
||||
|manufacturer|Manufacturer name|
|
||||
|region|The region name of the device|
|
||||
|site|Site name|
|
||||
|site_group|Site group name|
|
||||
|tenant|Tenant name|
|
||||
|tenant_group|Tenant group name|
|
||||
**Both devices and virtual machines**
|
||||
|
||||
| name | description |
|
||||
| ------------- | ------------------------------------------------------------------------------------ |
|
||||
| role | Role name of a device or VM |
|
||||
| region | The region name |
|
||||
| site | Site name |
|
||||
| site_group | Site group name |
|
||||
| tenant | Tenant name |
|
||||
| tenant_group | Tenant group name |
|
||||
| platform | Software platform of a device or VM |
|
||||
| custom fields | See the section "Layout -> Custom Fields" to use custom fields as hostgroup variable |
|
||||
|
||||
You can specify the value like so, sperated by a "/":
|
||||
```
|
||||
hostgroup_format = "tenant/site/dev_location/dev_role"
|
||||
**Only for devices**
|
||||
|
||||
| name | description |
|
||||
| ------------ | ------------------------ |
|
||||
| location | The device location name |
|
||||
| manufacturer | Device manufacturer name |
|
||||
| rack | Rack |
|
||||
|
||||
**Only for VMs**
|
||||
|
||||
| name | description |
|
||||
| ------------ | --------------- |
|
||||
| cluster | VM cluster name |
|
||||
| cluster_type | VM cluster type |
|
||||
| device | parent device |
|
||||
|
||||
You can specify the value separated by a "/" like so:
|
||||
|
||||
```python
|
||||
hostgroup_format = "tenant/site/location/role"
|
||||
```
|
||||
|
||||
You can also provice a list of groups like so:
|
||||
|
||||
```python
|
||||
hostgroup_format = ["region/site_group/site", "role", "tenant_group/tenant"]
|
||||
```
|
||||
|
||||
|
||||
**Group traversal**
|
||||
|
||||
The default behaviour for `region` is to only use the directly assigned region in the rendered hostgroup name.
|
||||
However, by setting `traverse_region` to `True` in `config.py` the script will render a full region path of all parent regions for the hostgroup name.
|
||||
`traverse_site_groups` controls the same behaviour for site_groups.
|
||||
The default behaviour for `region` is to only use the directly assigned region
|
||||
in the rendered hostgroup name. However, by setting `traverse_region` to `True`
|
||||
in `config.py` the script will render a full region path of all parent regions
|
||||
for the hostgroup name. `traverse_site_groups` controls the same behaviour for
|
||||
site_groups.
|
||||
|
||||
**Hardcoded text**
|
||||
|
||||
You can add hardcoded text in the hostgroup format by using quotes, this will
|
||||
insert the literal text:
|
||||
|
||||
```python
|
||||
hostgroup_format = "'MyDevices'/location/role"
|
||||
```
|
||||
|
||||
In this case, the prefix MyDevices will be used for all generated groups.
|
||||
|
||||
**Custom fields**
|
||||
|
||||
You can also use the value of custom fields under the device object.
|
||||
You can use the value of custom fields for hostgroup generation. This allows
|
||||
more freedom and even allows a full static mapping instead of a dynamic rendered
|
||||
hostgroup name.
|
||||
|
||||
For instance a custom field with the name `mycustomfieldname` and type string
|
||||
has the following values for 2 devices:
|
||||
|
||||
This allows more freedom and even allows a full static mapping instead of a dynamic rendered hostgroup name.
|
||||
```
|
||||
hostgroup_format = "site/mycustomfieldname"
|
||||
Device A has the value Train for custom field mycustomfieldname.
|
||||
Device B has the value Bus for custom field mycustomfieldname.
|
||||
Both devices are located in the site Paris.
|
||||
```
|
||||
|
||||
With the hostgroup format `site/mycustomfieldname` the following hostgroups will
|
||||
be generated:
|
||||
|
||||
```
|
||||
Device A: Paris/Train
|
||||
Device B: Paris/Bus
|
||||
```
|
||||
|
||||
**Empty variables or hostgroups**
|
||||
|
||||
Should the content of a variable be empty, then the hostgroup position is skipped.
|
||||
Should the content of a variable be empty, then the hostgroup position is
|
||||
skipped.
|
||||
|
||||
For example, consider the following scenario with 2 devices, both the same
|
||||
device type and site. One of them is linked to a tenant, the other one does not
|
||||
have a relationship with a tenant.
|
||||
|
||||
For example, consider the following scenario with 2 devices, both the same device type and site. One of them is linked to a tenant, the other one does not have a relationship with a tenant.
|
||||
- Device_role: PDU
|
||||
- Site: HQ-AMS
|
||||
|
||||
```python
|
||||
hostgroup_format = "site/tenant/role"
|
||||
```
|
||||
hostgroup_format = "site/tenant/device_role"
|
||||
```
|
||||
When running the script like above, the following hostgroup (HG) will be generated for both hosts:
|
||||
- Device A with no relationship with a tenant: HQ-AMS/PDU
|
||||
- Device B with a relationship to tenant "Fork Industries": HQ-AMS/Fork Industries/PDU
|
||||
|
||||
When running the script like above, the following hostgroup (HG) will be
|
||||
generated for both hosts:
|
||||
|
||||
- Device A with no relationship with a tenant: HQ-AMS/PDU
|
||||
- Device B with a relationship to tenant "Fork Industries": HQ-AMS/Fork
|
||||
Industries/PDU
|
||||
|
||||
The same logic applies to custom fields being used in the HG format:
|
||||
```
|
||||
|
||||
```python
|
||||
hostgroup_format = "site/mycustomfieldname"
|
||||
```
|
||||
For device A with the value "ABC123" in the custom field "mycustomfieldname" -> HQ-AMS/ABC123
|
||||
For a device which does not have a value in the custom field "mycustomfieldname" -> HQ-AMS
|
||||
|
||||
Should there be a scenario where a custom field does not have a value under a device, and the HG format only uses this single variable, then this will result in an error:
|
||||
For device A with the value "ABC123" in the custom field "mycustomfieldname" ->
|
||||
HQ-AMS/ABC123 For a device which does not have a value in the custom field
|
||||
"mycustomfieldname" -> HQ-AMS
|
||||
|
||||
Should there be a scenario where a custom field does not have a value under a
|
||||
device, and the HG format only uses this single variable, then this will result
|
||||
in an error:
|
||||
|
||||
```
|
||||
hostgroup_format = "mycustomfieldname"
|
||||
|
||||
Netbox-Zabbix-sync - ERROR - ESXI1 has no reliable hostgroup. This is most likely due to the use of custom fields that are empty.
|
||||
NetBox-Zabbix-sync - ERROR - ESXI1 has no reliable hostgroup. This is most likely due to the use of custom fields that are empty.
|
||||
```
|
||||
### Device status
|
||||
By setting a status on a Netbox device you determine how the host is added (or updated) in Zabbix. There are, by default, 3 options:
|
||||
* Delete the host from Zabbix (triggered by Netbox status "Decommissioning" and "Inventory")
|
||||
* Create the host in Zabbix but with a disabled status (Trigger by "Offline", "Planned", "Staged" and "Failed")
|
||||
* Create the host in Zabbix with an enabled status (For now only enabled with the "Active" status)
|
||||
|
||||
You can modify this behaviour by changing the following list variables in the script:
|
||||
- `zabbix_device_removal`
|
||||
- `zabbix_device_disable`
|
||||
### Extended site properties
|
||||
|
||||
By default, NetBox will only return the following properties under the 'site' key for a device:
|
||||
|
||||
- site id
|
||||
- (api) url
|
||||
- display name
|
||||
- name
|
||||
- slug
|
||||
- description
|
||||
|
||||
However, NetBox-Zabbix-Sync allows you to extend these site properties with the full site information
|
||||
so you can use this data in inventory fields, tags and usermacros.
|
||||
|
||||
To enable this functionality, enable the following setting in your configuration file:
|
||||
|
||||
`extended_site_properties = True`
|
||||
|
||||
Keep in mind that enabling this option will increase the number of API calls to your NetBox instance,
|
||||
this might impact performance on large syncs.
|
||||
|
||||
### Device status
|
||||
|
||||
By setting a status on a NetBox device you determine how the host is added (or
|
||||
updated) in Zabbix. There are, by default, 3 options:
|
||||
|
||||
- Delete the host from Zabbix (triggered by NetBox status "Decommissioning" and
|
||||
"Inventory")
|
||||
- Create the host in Zabbix but with a disabled status (Trigger by "Offline",
|
||||
"Planned", "Staged" and "Failed")
|
||||
- Create the host in Zabbix with an enabled status (For now only enabled with
|
||||
the "Active" status)
|
||||
|
||||
You can modify this behaviour by changing the following list variables in the
|
||||
script:
|
||||
|
||||
- `zabbix_device_removal`
|
||||
- `zabbix_device_disable`
|
||||
|
||||
### Zabbix Inventory
|
||||
This script allows you to enable the inventory on managed Zabbix hosts and sync NetBox device properties to the specified inventory fields.
|
||||
To enable, set `inventory_sync` to `True`.
|
||||
Set `inventory_automatic` to `False` to use manual inventory, or `True` for automatic.
|
||||
See [Zabix Manual](https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory) for more information about the modes.
|
||||
|
||||
Use the `inventory_map` variable to map which NetBox properties are used in which Zabbix Inventory fields.
|
||||
For nested properties, you can use the '/' seperator.
|
||||
For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field:
|
||||
```
|
||||
This script allows you to enable the inventory on managed Zabbix hosts and sync
|
||||
NetBox device properties to the specified inventory fields. To map NetBox
|
||||
information to NetBox inventory fields, set `inventory_sync` to `True`.
|
||||
|
||||
You can set the inventory mode to "disabled", "manual" or "automatic" with the
|
||||
`inventory_mode` variable. See
|
||||
[Zabbix Manual](https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory)
|
||||
for more information about the modes.
|
||||
|
||||
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 '/'
|
||||
seperator. For example, the following map will assign the custom field
|
||||
'mycustomfield' to the 'alias' Zabbix inventory field:
|
||||
|
||||
For Virtual Machines, use `vm_inventory_map`.
|
||||
|
||||
```python
|
||||
inventory_sync = True
|
||||
inventory_automatic = True
|
||||
inventory_map = { "custom_fields/mycustomfield/name": "alias"}
|
||||
inventory_mode = "manual"
|
||||
device_inventory_map = {"custom_fields/mycustomfield": "alias"}
|
||||
vm_inventory_map = {"custom_fields/mycustomfield": "alias"}
|
||||
```
|
||||
See `config.py.example` for an extensive example map.
|
||||
Any Zabix Inventory fields that are not included in the map will not be touched by the script,
|
||||
so you can safely add manual values or use items to automatically add values to other fields.
|
||||
|
||||
See `config.py.example` for an extensive example map. Any Zabbix Inventory fields
|
||||
that are not included in the map will not be touched by the script, so you can
|
||||
safely add manual values or use items to automatically add values to other
|
||||
fields.
|
||||
|
||||
### Template source
|
||||
You can either use a Netbox device type custom field or Netbox config context for the Zabbix template information.
|
||||
|
||||
Using a custom field allows for only one template. You can assign multiple templates to one host using the config context source.
|
||||
Should you make use of an advanced templating structure with lots of nesting then i would recommend sticking to the custom field.
|
||||
You can either use a NetBox device type custom field or NetBox config context
|
||||
for the Zabbix template information.
|
||||
|
||||
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:
|
||||
```
|
||||
Using a custom field allows for only one template. You can assign multiple
|
||||
templates to one host using the config context source. Should you make use of an
|
||||
advanced templating structure with lots of nesting then i would recommend
|
||||
sticking to the custom field.
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
templates_config_context = True
|
||||
```
|
||||
|
||||
After that make sure that for each host there is at least one template defined in the config context in this format:
|
||||
```
|
||||
After that make sure that for each host there is at least one template defined
|
||||
in the config context in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
"templates": [
|
||||
@@ -213,41 +395,251 @@ After that make sure that for each host there is at least one template defined i
|
||||
}
|
||||
```
|
||||
|
||||
You can also opt for the default device type custom field behaviour but with the added benefit of overwriting the template should a device in Netbox have a device specific context defined. In this case the device specific context template(s) will take priority over the device type custom field template.
|
||||
```
|
||||
You can also opt for the default device type custom field behaviour but with the
|
||||
added benefit of overwriting the template should a device in NetBox have a
|
||||
device specific context defined. In this case the device specific context
|
||||
template(s) will take priority over the device type custom field template.
|
||||
|
||||
```python
|
||||
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 context
|
||||
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 behavior 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 netbox tags.
|
||||
|
||||
By default the tag name is "NetBox", but you can change this to whatever you want.
|
||||
The value for the tag can be set to 'name', 'display', or 'slug', which refers to the
|
||||
property of the NetBox tag object that will be used as the value in Zabbix.
|
||||
|
||||
```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 behavior because Zabbix API won't return the value of
|
||||
secrets so the script cannot compare the values with those 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 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
|
||||
|
||||
### Netbox
|
||||
Make sure that the Netbox user has proper permissions for device read and modify (modify to set the Zabbix HostID custom field) operations. The user should also have read-only access to the device types.
|
||||
### NetBox
|
||||
|
||||
Make sure that the NetBox user has proper permissions for device read and modify
|
||||
(modify to set the Zabbix HostID custom field) operations. The user should also
|
||||
have read-only access to the device types.
|
||||
|
||||
### Zabbix
|
||||
Make sure that the Zabbix user has permissions to read hostgroups and proxy servers. The user should have full rights on creating, modifying and deleting hosts.
|
||||
|
||||
If you want to automatically create hostgroups then the create permission on host-groups should also be applied.
|
||||
Make sure that the Zabbix user has permissions to read hostgroups and proxy
|
||||
servers. The user should have full rights on creating, modifying and deleting
|
||||
hosts.
|
||||
|
||||
If you want to automatically create hostgroups then the create permission on
|
||||
host-groups should also be applied.
|
||||
|
||||
### Custom links
|
||||
To make the user experience easier you could add a custom link that redirects users to the Zabbix latest data.
|
||||
|
||||
To make the user experience easier you could add a custom link that redirects
|
||||
users to the Zabbix latest data.
|
||||
|
||||
```
|
||||
* Name: zabbix_latestData
|
||||
* Text: {% if obj.cf["zabbix_hostid"] %}Show host in Zabbix{% endif %}
|
||||
* URL: http://myzabbixserver.local/zabbix.php?action=latest.view&hostids[]={{ obj.cf["zabbix_hostid"] }}
|
||||
* Text: {% if object.cf["zabbix_hostid"] %}Show host in Zabbix{% endif %}
|
||||
* URL: http://myzabbixserver.local/zabbix.php?action=latest.view&hostids[]={{ object.cf["zabbix_hostid"] }}
|
||||
```
|
||||
|
||||
## Running the script
|
||||
|
||||
```
|
||||
python3 netbox_zabbix_sync.py
|
||||
```
|
||||
|
||||
### Flags
|
||||
| Flag | Option | Description |
|
||||
| ------------ | ------------ | ------------ |
|
||||
| -v | verbose | Log with debugging on. |
|
||||
|
||||
| Flag | Option | Description |
|
||||
| ---- | --------- | ------------------------------------- |
|
||||
| -v | verbose | Log with info on. |
|
||||
| -vv | debug | Log with debugging on. |
|
||||
| -vvv | debug-all | Log with debugging on for all modules |
|
||||
|
||||
## Config context
|
||||
|
||||
### Zabbix proxy
|
||||
You can set the proxy for a device using the 'proxy' key in config context.
|
||||
|
||||
#### Config Context
|
||||
You can set the proxy for a device using the `proxy` key in config context.
|
||||
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
@@ -255,31 +647,100 @@ You can set the proxy for a device using the 'proxy' key in config context.
|
||||
}
|
||||
}
|
||||
```
|
||||
Because of the possible amount of destruction when setting up Netbox but forgetting the proxy command, the sync works a bit different. By default everything is synced except in a situation where the Zabbix host has a proxy configured but nothing is configured in Netbox. To force deletion and a full sync, set the `full_proxy_sync` variable in the config file.
|
||||
|
||||
### Set interface parameters within Netbox
|
||||
When adding a new device, you can set the interface type with custom context. By default, the following configuration is applied when no config context is provided:
|
||||
It is now possible to specify proxy groups with the introduction of Proxy groups
|
||||
in Zabbix 7. Specifying a group in the config context on older Zabbix releases
|
||||
will have no impact and the script will ignore the statement.
|
||||
|
||||
* SNMPv2
|
||||
* UDP 161
|
||||
* Bulk requests enabled
|
||||
* SNMP community: {$SNMP_COMMUNITY}
|
||||
|
||||
Due to Zabbix limitations of changing interface type with a linked template, changing the interface type from within Netbox is not supported and the script will generate an error.
|
||||
|
||||
For example when changing a SNMP interface to an Agent interface:
|
||||
```
|
||||
Netbox-Zabbix-sync - WARNING - Device: Interface OUT of sync.
|
||||
Netbox-Zabbix-sync - ERROR - Device: changing interface type to 1 is not supported.
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
"proxy_group": "yourawesomeproxygroup.local"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To configure the interface parameters you'll need to use custom context. Custom context was used to make this script as customizable as posible for each environment. For example, you could:
|
||||
* Set the custom context directly on a device
|
||||
* Set the custom context on a label, which you would add to a device (for instance, SNMPv3)
|
||||
* Set the custom context on a device role
|
||||
* Set the custom context on a site or region
|
||||
The script will prefer groups when specifying both a proxy and group. This is
|
||||
done with the assumption that groups are more resilient and HA ready, making it
|
||||
a more logical choice to use for proxy linkage. This also makes migrating from a
|
||||
proxy to proxy group easier since the group take priority over the individual
|
||||
proxy.
|
||||
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
"proxy": "yourawesomeproxy.local",
|
||||
"proxy_group": "yourawesomeproxygroup.local"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the example above the host will use the group on Zabbix 7. On Zabbix 6 and
|
||||
below the host will use the proxy. Zabbix 7 will use the proxy value when
|
||||
omitting the proxy_group value.
|
||||
|
||||
#### Custom Field
|
||||
|
||||
Alternatively, you can use a custom field for assigning a device or VM to
|
||||
a Zabbix proxy or proxy group. The custom fields can be assigned to both
|
||||
Devices and VMs.
|
||||
|
||||
You can also assign these custom fields to a site to allow all devices/VMs
|
||||
in that site to be configured with the same proxy or proxy group.
|
||||
In order for this to work, `extended_site_properties` needs to be enabled in
|
||||
the configuration as well.
|
||||
|
||||
To use the custom fields for proxy configuration, configure one or both
|
||||
of the following settings in the configuration file with the actual names of your
|
||||
custom fields:
|
||||
|
||||
```python
|
||||
proxy_cf = "zabbix_proxy"
|
||||
proxy_group_cf = "zabbix_proxy_group"
|
||||
```
|
||||
|
||||
As with config context proxy configuration, proxy group will take precedence over
|
||||
standalone proxy when configured.
|
||||
Proxy settings configured on the device or VM will in their turn take precedence
|
||||
over any site configuration.
|
||||
|
||||
If the custom fields have no value but the proxy or proxy group is configured in config context,
|
||||
that setting will be used.
|
||||
|
||||
### Set interface parameters within NetBox
|
||||
|
||||
When adding a new device, you can set the interface type with custom context. By
|
||||
default, the following configuration is applied when no config context is
|
||||
provided:
|
||||
|
||||
- SNMPv2
|
||||
- UDP 161
|
||||
- Bulk requests enabled
|
||||
- SNMP community: {$SNMP_COMMUNITY}
|
||||
|
||||
Due to Zabbix limitations of changing interface type with a linked template,
|
||||
changing the interface type from within NetBox is not supported and the script
|
||||
will generate an error.
|
||||
|
||||
For example, when changing a SNMP interface to an Agent interface:
|
||||
|
||||
```
|
||||
NetBox-Zabbix-sync - WARNING - Device: Interface OUT of sync.
|
||||
NetBox-Zabbix-sync - ERROR - Device: changing interface type to 1 is not supported.
|
||||
```
|
||||
|
||||
To configure the interface parameters you'll need to use custom context. Custom
|
||||
context was used to make this script as customizable as possible for each
|
||||
environment. For example, you could:
|
||||
|
||||
- Set the custom context directly on a device
|
||||
- Set the custom context on a tag, which you would add to a device (for
|
||||
instance, SNMPv3)
|
||||
- Set the custom context on a device role
|
||||
- Set the custom context on a site or region
|
||||
|
||||
##### Agent interface configuration example
|
||||
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
@@ -288,7 +749,9 @@ To configure the interface parameters you'll need to use custom context. Custom
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### SNMPv2 interface configuration example
|
||||
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
@@ -302,7 +765,9 @@ To configure the interface parameters you'll need to use custom context. Custom
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### SNMPv3 interface configuration example
|
||||
|
||||
```json
|
||||
{
|
||||
"zabbix": {
|
||||
@@ -319,6 +784,13 @@ To configure the interface parameters you'll need to use custom context. Custom
|
||||
}
|
||||
```
|
||||
|
||||
I would recommend using macros for sensitive data such as community strings since the data in Netbox is plain-text.
|
||||
I would recommend using usermacros for sensitive data such as community strings
|
||||
since the data in NetBox is plain-text.
|
||||
|
||||
> **_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
|
||||
> are not all required, depending on your environment.
|
||||
|
||||
|
||||
|
||||
|
||||
> **_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 are not all required, depending on your environment.
|
||||
|
||||
+107
-26
@@ -3,15 +3,24 @@
|
||||
# coming from config context instead of a custom field.
|
||||
templates_config_context = False
|
||||
|
||||
# Set to true to give config context templates a
|
||||
# Set to true to give config context templates a
|
||||
# higher priority then custom field templates
|
||||
templates_config_context_overrule = False
|
||||
|
||||
# Set template and device Netbox "custom field" names
|
||||
# Set template and device NetBox "custom field" names
|
||||
# Template_cf is not used when templates_config_context is enabled
|
||||
template_cf = "zabbix_template"
|
||||
device_cf = "zabbix_hostid"
|
||||
|
||||
# Zabbix host description
|
||||
# The following options are available for the description of all created hosts in Zabbix
|
||||
# static: Uses the default static string "Host added by NetBox sync script."
|
||||
# dynamic: "Uses a predefined dynamic string which resolves the owner of an object and datetime. Recommended for users who use Netbox 4.5+
|
||||
# custom: Use a custom string such as "This host was created by Zabbix-sync on machine MGMT01.internal". It is also posible to resolve dynamic values in this string using {} markers.
|
||||
description = "static"
|
||||
# The timedate format which is used for generating the datetime macro when used in the dynamic description type or custom type.
|
||||
description_dt_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
## Enable clustering of devices with virtual chassis setup
|
||||
clustering = False
|
||||
|
||||
@@ -21,18 +30,26 @@ create_hostgroups = True
|
||||
## Create journal entries
|
||||
create_journal = False
|
||||
|
||||
## Virtual machine sync
|
||||
# Set sync_vms to True in order to use this new feature
|
||||
# Use the hostgroup vm_hostgroup_format mapper for specific
|
||||
# hostgroup atributes of VM's such as cluster_type and cluster
|
||||
sync_vms = False
|
||||
# Check the README documentation for values to use in the VM hostgroup format.
|
||||
vm_hostgroup_format = "cluster_type/cluster/role"
|
||||
|
||||
## Proxy Sync
|
||||
# Set to true to enable removal of proxy's under hosts. Use with caution and make sure that you specified
|
||||
# all the required proxy's in the device config context before enabeling this option.
|
||||
# With this option disabled proxy's will only be added and modified for Zabbix hosts.
|
||||
full_proxy_sync = False
|
||||
|
||||
## Netbox to Zabbix device state convertion
|
||||
## NetBox to Zabbix device state convertion
|
||||
zabbix_device_removal = ["Decommissioning", "Inventory"]
|
||||
zabbix_device_disable = ["Offline", "Planned", "Staged", "Failed"]
|
||||
|
||||
## Hostgroup mapping
|
||||
# Available choices: dev_location, dev_role, manufacturer, region, site, site_group, tenant, tenant_group
|
||||
# See the README documentation for available options
|
||||
# You can also use CF (custom field) names under the device. The CF content will be used for the hostgroup generation.
|
||||
#
|
||||
# When using region in the group name, the default behaviour is to use name of the directly assigned region.
|
||||
@@ -40,11 +57,17 @@ zabbix_device_disable = ["Offline", "Planned", "Staged", "Failed"]
|
||||
#
|
||||
# 'Global/Europe/Netherlands/Amsterdam' instead of just 'Amsterdam'.
|
||||
#
|
||||
# traverse_site_groups controls the same behaviour for any assigned site_groups.
|
||||
hostgroup_format = "site/manufacturer/dev_role"
|
||||
# traverse_site_groups controls the same behaviour for any assigned site_groups.
|
||||
hostgroup_format = "site/manufacturer/role"
|
||||
traverse_regions = False
|
||||
traverse_site_groups = False
|
||||
|
||||
## Extended site properties
|
||||
# By default, NetBox will only return basic site info for any device or VM.
|
||||
# By setting `extended_site_properties` to True, the script will query NetBox for additional site info.
|
||||
# Be aware that this will increase the number of API queries to NetBox.
|
||||
extended_site_properties = False
|
||||
|
||||
## Filtering
|
||||
# Custom device filter, variable must be present but can be left empty with no filtering.
|
||||
# A couple of examples:
|
||||
@@ -54,34 +77,92 @@ traverse_site_groups = False
|
||||
# nb_device_filter = {"site": ["HQ-AMS", "HQ-FRA"]} #Device must be in either one of these sites
|
||||
# nb_device_filter = {"site": "HQ-AMS", "tag": "zabbix", "role__n": ["PDU", "console-server"]} #Device must be in site HQ-AMS, have the tag zabbix and must not be part of the PDU or console-server role
|
||||
|
||||
# Default device filter, only get devices which have a name in Netbox:
|
||||
# Default device filter, only get devices which have a name in NetBox:
|
||||
nb_device_filter = {"name__n": "null"}
|
||||
# Default filter for VMs
|
||||
nb_vm_filter = {"name__n": "null"}
|
||||
|
||||
## Inventory
|
||||
# See https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory
|
||||
# Choice between disabled, manual or automatic.
|
||||
# Make sure to select at least manual or automatic in use with the inventory_sync function.
|
||||
inventory_mode = "disabled"
|
||||
|
||||
# To allow syncing of NetBox device properties, set inventory_sync to True
|
||||
inventory_sync = False
|
||||
|
||||
# Set inventory_automatic to False to use manual inventory, True for automatic
|
||||
# See https://www.zabbix.com/documentation/current/en/manual/config/hosts/inventory#building-inventory
|
||||
inventory_automatic = True
|
||||
|
||||
# inventory_map is used to map NetBox properties to Zabbix Inventory fields.
|
||||
# For nested properties, you can use the '/' seperator.
|
||||
# For example, the following map will assign the custom field 'mycustomfield' to the 'alias' Zabbix inventory field:
|
||||
#
|
||||
# inventory_map = { "custom_fields/mycustomfield/name": "alias"}
|
||||
# device_inventory_map = { "custom_fields/mycustomfield/name": "alias"}
|
||||
#
|
||||
# The following map should provide some nice defaults:
|
||||
inventory_map = { "asset_tag": "asset_tag",
|
||||
"virtual_chassis/name": "chassis",
|
||||
"status/label": "deployment_status",
|
||||
"location/name": "location",
|
||||
"latitude": "location_lat",
|
||||
"longitude": "location_lon",
|
||||
"comments": "notes",
|
||||
"name": "name",
|
||||
"rack/name": "site_rack",
|
||||
"serial": "serialno_a",
|
||||
"device_type/model": "type",
|
||||
"device_type/manufacturer/name": "vendor",
|
||||
"oob_ip/address": "oob_ip" }
|
||||
# The following maps should provide some nice defaults:
|
||||
device_inventory_map = { "asset_tag": "asset_tag",
|
||||
"virtual_chassis/name": "chassis",
|
||||
"status/label": "deployment_status",
|
||||
"location/name": "location",
|
||||
"latitude": "location_lat",
|
||||
"longitude": "location_lon",
|
||||
"comments": "notes",
|
||||
"name": "name",
|
||||
"rack/name": "site_rack",
|
||||
"serial": "serialno_a",
|
||||
"device_type/model": "type",
|
||||
"device_type/manufacturer/name": "vendor",
|
||||
"oob_ip/address": "oob_ip" }
|
||||
# Replace latitude and longitude with site/latitude and and site/longitude to use
|
||||
# site geo data. Enable extended_site_properties for this to work!
|
||||
|
||||
# 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}",
|
||||
"display_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}",
|
||||
"display_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 in 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"}
|
||||
|
||||
+6
-981
@@ -1,981 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# pylint: disable=invalid-name, logging-not-lazy, too-many-locals, logging-fstring-interpolation
|
||||
|
||||
|
||||
"""Netbox to Zabbix sync script."""
|
||||
import logging
|
||||
import argparse
|
||||
from os import environ, path, sys
|
||||
from packaging import version
|
||||
from pynetbox import api
|
||||
from pyzabbix import ZabbixAPI, ZabbixAPIException
|
||||
try:
|
||||
from config import (
|
||||
templates_config_context,
|
||||
templates_config_context_overrule,
|
||||
clustering, create_hostgroups,
|
||||
create_journal, full_proxy_sync,
|
||||
template_cf, device_cf,
|
||||
zabbix_device_removal,
|
||||
zabbix_device_disable,
|
||||
hostgroup_format,
|
||||
traverse_site_groups,
|
||||
traverse_regions,
|
||||
inventory_sync,
|
||||
inventory_automatic,
|
||||
inventory_map,
|
||||
nb_device_filter
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
print("Configuration file config.py not found in main directory."
|
||||
"Please create the file or rename the config.py.example file to config.py.")
|
||||
sys.exit(0)
|
||||
|
||||
# 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(
|
||||
path.realpath(__file__)), "sync.log"))
|
||||
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 convert_recordset(recordset):
|
||||
""" Converts netbox RedcordSet to list of dicts. """
|
||||
recordlist = []
|
||||
for record in recordset:
|
||||
recordlist.append(record.__dict__)
|
||||
return recordlist
|
||||
|
||||
def build_path(endpoint, list_of_dicts):
|
||||
"""
|
||||
Builds a path list of related parent/child items.
|
||||
This can be used to generate a joinable list to
|
||||
be used in hostgroups.
|
||||
"""
|
||||
item_path = []
|
||||
itemlist = [i for i in list_of_dicts if i['name'] == endpoint]
|
||||
item = itemlist[0] if len(itemlist) == 1 else None
|
||||
item_path.append(item['name'])
|
||||
while item['_depth'] > 0:
|
||||
itemlist = [i for i in list_of_dicts if i['name'] == str(item['parent'])]
|
||||
item = itemlist[0] if len(itemlist) == 1 else None
|
||||
item_path.append(item['name'])
|
||||
item_path.reverse()
|
||||
return item_path
|
||||
|
||||
def main(arguments):
|
||||
"""Run the sync process."""
|
||||
# pylint: disable=too-many-branches, too-many-statements
|
||||
# set environment variables
|
||||
if arguments.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"]
|
||||
if "ZABBIX_TOKEN" in environ:
|
||||
env_vars.append("ZABBIX_TOKEN")
|
||||
else:
|
||||
env_vars.append("ZABBIX_USER")
|
||||
env_vars.append("ZABBIX_PASS")
|
||||
for var in env_vars:
|
||||
if var not in environ:
|
||||
e = f"Environment variable {var} has not been defined."
|
||||
logger.error(e)
|
||||
raise EnvironmentVarError(e)
|
||||
# Get all virtual environment variables
|
||||
if "ZABBIX_TOKEN" in env_vars:
|
||||
zabbix_user = None
|
||||
zabbix_pass = None
|
||||
zabbix_token = environ.get("ZABBIX_TOKEN")
|
||||
else:
|
||||
zabbix_user = environ.get("ZABBIX_USER")
|
||||
zabbix_pass = environ.get("ZABBIX_PASS")
|
||||
zabbix_token = None
|
||||
zabbix_host = environ.get("ZABBIX_HOST")
|
||||
netbox_host = environ.get("NETBOX_HOST")
|
||||
netbox_token = environ.get("NETBOX_TOKEN")
|
||||
# Set Netbox API
|
||||
netbox = api(netbox_host, token=netbox_token, threading=True)
|
||||
# Check if the provided Hostgroup layout is valid
|
||||
hg_objects = hostgroup_format.split("/")
|
||||
allowed_objects = ["dev_location", "dev_role", "manufacturer", "region",
|
||||
"site", "site_group", "tenant", "tenant_group"]
|
||||
# Create API call to get all custom fields which are on the device objects
|
||||
device_cfs = netbox.extras.custom_fields.filter(type="text", content_type_id=23)
|
||||
for cf in device_cfs:
|
||||
allowed_objects.append(cf.name)
|
||||
for hg_object in hg_objects:
|
||||
if hg_object not in allowed_objects:
|
||||
e = (f"Hostgroup item {hg_object} is not valid. Make sure you"
|
||||
" use valid items and seperate them with '/'.")
|
||||
logger.error(e)
|
||||
raise HostgroupError(e)
|
||||
# Set Zabbix API
|
||||
try:
|
||||
zabbix = ZabbixAPI(zabbix_host)
|
||||
if "ZABBIX_TOKEN" in env_vars:
|
||||
zabbix.login(api_token=zabbix_token)
|
||||
else:
|
||||
m=("Logging in with Zabbix user and password,"
|
||||
" consider using an API token instead.")
|
||||
logger.warning(m)
|
||||
zabbix.login(zabbix_user, zabbix_pass)
|
||||
except ZabbixAPIException as e:
|
||||
e = f"Zabbix returned the following error: {str(e)}."
|
||||
logger.error(e)
|
||||
# Set API parameter mapping based on API version
|
||||
if version.parse(zabbix.api_version()) < version.parse("7.0.0"):
|
||||
proxy_name = "host"
|
||||
else:
|
||||
proxy_name = "name"
|
||||
# Get all Zabbix and Netbox data
|
||||
netbox_devices = netbox.dcim.devices.filter(**nb_device_filter)
|
||||
netbox_site_groups = convert_recordset((netbox.dcim.site_groups.all()))
|
||||
netbox_regions = convert_recordset(netbox.dcim.regions.all())
|
||||
netbox_journals = netbox.extras.journal_entries
|
||||
zabbix_groups = zabbix.hostgroup.get(output=['groupid', 'name'])
|
||||
zabbix_templates = zabbix.template.get(output=['templateid', 'name'])
|
||||
zabbix_proxies = zabbix.proxy.get(output=['proxyid', proxy_name])
|
||||
# Get Netbox API version
|
||||
nb_version = netbox.version
|
||||
# Sanitize data
|
||||
if proxy_name == "host":
|
||||
for proxy in zabbix_proxies:
|
||||
proxy['name'] = proxy.pop('host')
|
||||
|
||||
# Go through all Netbox devices
|
||||
for nb_device in netbox_devices:
|
||||
try:
|
||||
device = NetworkDevice(nb_device, zabbix, netbox_journals, nb_version,
|
||||
create_journal)
|
||||
device.set_hostgroup(hostgroup_format,netbox_site_groups,netbox_regions)
|
||||
device.set_template(templates_config_context, templates_config_context_overrule)
|
||||
device.set_inventory(nb_device)
|
||||
# Checks if device is part of cluster.
|
||||
# Requires clustering variable
|
||||
if device.isCluster() and clustering:
|
||||
# Check if device is master or slave
|
||||
if device.promoteMasterDevice():
|
||||
e = (f"Device {device.name} is "
|
||||
f"part of cluster and primary.")
|
||||
logger.info(e)
|
||||
else:
|
||||
# Device is secondary in cluster.
|
||||
# Don't continue with this device.
|
||||
e = (f"Device {device.name} is part of cluster "
|
||||
f"but not primary. Skipping this host...")
|
||||
logger.info(e)
|
||||
continue
|
||||
# Checks if device is in cleanup state
|
||||
if device.status in zabbix_device_removal:
|
||||
if device.zabbix_id:
|
||||
# Delete device from Zabbix
|
||||
# and remove hostID from Netbox.
|
||||
device.cleanup()
|
||||
logger.info(f"Cleaned up host {device.name}.")
|
||||
|
||||
else:
|
||||
# Device has been added to Netbox
|
||||
# but is not in Activate state
|
||||
logger.info(f"Skipping host {device.name} since its "
|
||||
f"not in the active state.")
|
||||
elif device.status in zabbix_device_disable:
|
||||
device.zabbix_state = 1
|
||||
else:
|
||||
device.zabbix_state = 0
|
||||
# Add hostgroup is variable is True
|
||||
# and Hostgroup is not present in Zabbix
|
||||
if create_hostgroups:
|
||||
for group in zabbix_groups:
|
||||
# If hostgroup is already present in Zabbix
|
||||
if group["name"] == device.hostgroup:
|
||||
break
|
||||
else:
|
||||
# Create new hostgroup
|
||||
hostgroup = device.createZabbixHostgroup()
|
||||
zabbix_groups.append(hostgroup)
|
||||
# Device is already present in Zabbix
|
||||
if device.zabbix_id:
|
||||
device.ConsistencyCheck(zabbix_groups, zabbix_templates,
|
||||
zabbix_proxies, full_proxy_sync)
|
||||
# Add device to Zabbix
|
||||
else:
|
||||
device.createInZabbix(zabbix_groups, zabbix_templates,
|
||||
zabbix_proxies)
|
||||
except SyncError:
|
||||
pass
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
""" Class SyncError """
|
||||
|
||||
class JournalError(Exception):
|
||||
""" Class SyncError """
|
||||
|
||||
class SyncExternalError(SyncError):
|
||||
""" Class SyncExternalError """
|
||||
|
||||
class SyncInventoryError(SyncError):
|
||||
""" Class SyncInventoryError """
|
||||
|
||||
class SyncDuplicateError(SyncError):
|
||||
""" Class SyncDuplicateError """
|
||||
|
||||
class EnvironmentVarError(SyncError):
|
||||
""" Class EnvironmentVarError """
|
||||
|
||||
class InterfaceConfigError(SyncError):
|
||||
""" Class InterfaceConfigError """
|
||||
|
||||
class ProxyConfigError(SyncError):
|
||||
""" Class ProxyConfigError """
|
||||
|
||||
class HostgroupError(SyncError):
|
||||
""" Class HostgroupError """
|
||||
|
||||
class TemplateError(SyncError):
|
||||
""" Class TemplateError """
|
||||
|
||||
class NetworkDevice():
|
||||
# pylint: disable=too-many-instance-attributes, too-many-arguments
|
||||
"""
|
||||
Represents Network device.
|
||||
INPUT: (Netbox device class, ZabbixAPI class, journal flag, NB journal class)
|
||||
"""
|
||||
|
||||
def __init__(self, nb, zabbix, nb_journal_class, nb_version, journal=None):
|
||||
self.nb = nb
|
||||
self.id = nb.id
|
||||
self.name = nb.name
|
||||
self.status = nb.status.label
|
||||
self.zabbix = zabbix
|
||||
self.zabbix_id = None
|
||||
self.group_id = None
|
||||
self.nb_api_version = nb_version
|
||||
self.zbx_template_names = []
|
||||
self.zbx_templates = []
|
||||
self.hostgroup = None
|
||||
self.tenant = nb.tenant
|
||||
self.config_context = nb.config_context
|
||||
self.zbxproxy = "0"
|
||||
self.zabbix_state = 0
|
||||
self.journal = journal
|
||||
self.nb_journals = nb_journal_class
|
||||
self.inventory_mode = -1
|
||||
self.inventory = {}
|
||||
self._setBasics()
|
||||
|
||||
def _setBasics(self):
|
||||
"""
|
||||
Sets basic information like IP address.
|
||||
"""
|
||||
# Return error if device does not have primary IP.
|
||||
if self.nb.primary_ip:
|
||||
self.cidr = self.nb.primary_ip.address
|
||||
self.ip = self.cidr.split("/")[0]
|
||||
else:
|
||||
e = f"Device {self.name}: no primary IP."
|
||||
logger.info(e)
|
||||
raise SyncInventoryError(e)
|
||||
|
||||
# Check if device has custom field for ZBX ID
|
||||
if device_cf in self.nb.custom_fields:
|
||||
self.zabbix_id = self.nb.custom_fields[device_cf]
|
||||
else:
|
||||
e = f"Custom field {device_cf} not found for {self.name}."
|
||||
logger.warning(e)
|
||||
raise SyncInventoryError(e)
|
||||
|
||||
def set_hostgroup(self, hg_format, nb_site_groups, nb_regions):
|
||||
"""Set the hostgroup for this device"""
|
||||
# Get all variables from the NB data
|
||||
dev_location = str(self.nb.location) if self.nb.location else None
|
||||
# Check the Netbox version. Use backwards compatibility for versions 2 and 3.
|
||||
if self.nb_api_version.startswith(("2", "3")):
|
||||
dev_role = self.nb.device_role.name
|
||||
else:
|
||||
dev_role = self.nb.role.name
|
||||
manufacturer = self.nb.device_type.manufacturer.name
|
||||
region = str(self.nb.site.region) if self.nb.site.region else None
|
||||
site = self.nb.site.name
|
||||
site_group = str(self.nb.site.group) if self.nb.site.group else None
|
||||
tenant = str(self.tenant) if self.tenant else None
|
||||
tenant_group = str(self.tenant.group) if tenant else None
|
||||
# Set mapper for string -> variable
|
||||
hostgroup_vars = {"dev_location": dev_location, "dev_role": dev_role,
|
||||
"manufacturer": manufacturer, "region": region,
|
||||
"site": site, "site_group": site_group,
|
||||
"tenant": tenant, "tenant_group": tenant_group}
|
||||
# Generate list based off string input format
|
||||
hg_items = hg_format.split("/")
|
||||
hostgroup = ""
|
||||
# Go through all hostgroup items
|
||||
for item in hg_items:
|
||||
# Check if the variable (such as Tenant) is empty.
|
||||
if not hostgroup_vars[item]:
|
||||
continue
|
||||
# Check if the item is a custom field name
|
||||
if item not in hostgroup_vars:
|
||||
cf_value = self.nb.custom_fields[item] if item in self.nb.custom_fields else None
|
||||
if cf_value:
|
||||
# If there is a cf match, add the value of this cf to the hostgroup
|
||||
hostgroup += cf_value + "/"
|
||||
# Should there not be a match, this means that
|
||||
# the variable is invalid. Skip regardless.
|
||||
continue
|
||||
# Add value of predefined variable to hostgroup format
|
||||
if item == "site_group" and nb_site_groups and traverse_site_groups:
|
||||
group_path = build_path(site_group, nb_site_groups)
|
||||
hostgroup += "/".join(group_path) + "/"
|
||||
elif item == "region" and nb_regions and traverse_regions:
|
||||
region_path = build_path(region, nb_regions)
|
||||
hostgroup += "/".join(region_path) + "/"
|
||||
else:
|
||||
hostgroup += hostgroup_vars[item] + "/"
|
||||
# If the final hostgroup variable is empty
|
||||
if not hostgroup:
|
||||
e = (f"{self.name} has no reliable hostgroup. This is"
|
||||
"most likely due to the use of custom fields that are empty.")
|
||||
logger.error(e)
|
||||
raise SyncInventoryError(e)
|
||||
# Remove final inserted "/" and set hostgroup to class var
|
||||
self.hostgroup = hostgroup.rstrip("/")
|
||||
|
||||
def set_template(self, prefer_config_context, overrule_custom):
|
||||
""" Set Template """
|
||||
self.zbx_template_names = None
|
||||
# Gather templates ONLY from the device specific context
|
||||
if prefer_config_context:
|
||||
try:
|
||||
self.zbx_template_names = self.get_templates_context()
|
||||
except TemplateError as e:
|
||||
logger.warning(e)
|
||||
return True
|
||||
# Gather templates from the custom field but overrule
|
||||
# them should there be any device specific templates
|
||||
if overrule_custom:
|
||||
try:
|
||||
self.zbx_template_names = self.get_templates_context()
|
||||
except TemplateError:
|
||||
pass
|
||||
if not self.zbx_template_names:
|
||||
self.zbx_template_names = self.get_templates_cf()
|
||||
return True
|
||||
# Gather templates ONLY from the custom field
|
||||
self.zbx_template_names = self.get_templates_cf()
|
||||
return True
|
||||
|
||||
def get_templates_cf(self):
|
||||
""" Get template from custom field """
|
||||
# Get Zabbix templates from the device type
|
||||
device_type_cfs = self.nb.device_type.custom_fields
|
||||
# Check if the ZBX Template CF is present
|
||||
if template_cf in device_type_cfs:
|
||||
# Set value to template
|
||||
return [device_type_cfs[template_cf]]
|
||||
# Custom field not found, return error
|
||||
e = (f"Custom field {template_cf} not "
|
||||
f"found for {self.nb.device_type.manufacturer.name}"
|
||||
f" - {self.nb.device_type.display}.")
|
||||
raise TemplateError(e)
|
||||
|
||||
def get_templates_context(self):
|
||||
""" Get Zabbix templates from the device context """
|
||||
if "zabbix" not in self.config_context:
|
||||
e = ("Key 'zabbix' not found in config "
|
||||
f"context for template host {self.name}")
|
||||
raise TemplateError(e)
|
||||
if "templates" not in self.config_context["zabbix"]:
|
||||
e = ("Key 'templates' not found in config "
|
||||
f"context 'zabbix' for template host {self.name}")
|
||||
raise TemplateError(e)
|
||||
return self.config_context["zabbix"]["templates"]
|
||||
|
||||
def set_inventory(self, nbdevice):
|
||||
""" Set host inventory """
|
||||
self.inventory_mode = -1
|
||||
self.inventory = {}
|
||||
if inventory_sync:
|
||||
# Set inventory mode to automatic or manual
|
||||
self.inventory_mode = 1 if inventory_automatic else 0
|
||||
|
||||
# Let's build an inventory dict for each property in the inventory_map
|
||||
for nb_inv_field, zbx_inv_field in inventory_map.items():
|
||||
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
|
||||
if value and isinstance(value, int | float | str ):
|
||||
self.inventory[zbx_inv_field] = str(value)
|
||||
elif not value:
|
||||
# empty value should just be an empty string for API compatibility
|
||||
logger.debug(f"Inventory lookup for '{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.
|
||||
logger.error(f"Inventory lookup for '{nb_inv_field}' returned"
|
||||
f" an unexpected type: it will be skipped.")
|
||||
return True
|
||||
|
||||
def isCluster(self):
|
||||
"""
|
||||
Checks if device is part of cluster.
|
||||
"""
|
||||
return bool(self.nb.virtual_chassis)
|
||||
|
||||
def getClusterMaster(self):
|
||||
"""
|
||||
Returns chassis master ID.
|
||||
"""
|
||||
if not self.isCluster():
|
||||
e = (f"Unable to proces {self.name} for cluster calculation: "
|
||||
f"not part of a cluster.")
|
||||
logger.warning(e)
|
||||
raise SyncInventoryError(e)
|
||||
if not self.nb.virtual_chassis.master:
|
||||
e = (f"{self.name} is part of a Netbox virtual chassis which does "
|
||||
"not have a master configured. Skipping for this reason.")
|
||||
logger.error(e)
|
||||
raise SyncInventoryError(e)
|
||||
return self.nb.virtual_chassis.master.id
|
||||
|
||||
def promoteMasterDevice(self):
|
||||
"""
|
||||
If device is Primary in cluster,
|
||||
promote device name to the cluster name.
|
||||
Returns True if succesfull, returns False if device is secondary.
|
||||
"""
|
||||
masterid = self.getClusterMaster()
|
||||
if masterid == self.id:
|
||||
logger.debug(f"Device {self.name} is primary cluster member. "
|
||||
f"Modifying hostname from {self.name} to " +
|
||||
f"{self.nb.virtual_chassis.name}.")
|
||||
self.name = self.nb.virtual_chassis.name
|
||||
return True
|
||||
logger.debug(f"Device {self.name} is non-primary cluster member.")
|
||||
return False
|
||||
|
||||
def zbxTemplatePrepper(self, templates):
|
||||
"""
|
||||
Returns Zabbix template IDs
|
||||
INPUT: list of templates from Zabbix
|
||||
OUTPUT: True
|
||||
"""
|
||||
# Check if there are templates defined
|
||||
if not self.zbx_template_names:
|
||||
e = f"No templates found for device {self.name}"
|
||||
logger.info(e)
|
||||
raise SyncInventoryError()
|
||||
# Set variable to empty list
|
||||
self.zbx_templates = []
|
||||
# Go through all templates definded in Netbox
|
||||
for nb_template in self.zbx_template_names:
|
||||
template_match = False
|
||||
# Go through all templates found in Zabbix
|
||||
for zbx_template in templates:
|
||||
# If the template names match
|
||||
if zbx_template['name'] == nb_template:
|
||||
# Set match variable to true, add template details
|
||||
# to class variable and return debug log
|
||||
template_match = True
|
||||
self.zbx_templates.append({"templateid": zbx_template['templateid'],
|
||||
"name": zbx_template['name']})
|
||||
e = (f"Found template {zbx_template['name']}"
|
||||
f" for host {self.name}.")
|
||||
logger.debug(e)
|
||||
# Return error should the template not be found in Zabbix
|
||||
if not template_match:
|
||||
e = (f"Unable to find template {nb_template} "
|
||||
f"for host {self.name} in Zabbix. Skipping host...")
|
||||
logger.warning(e)
|
||||
raise SyncInventoryError(e)
|
||||
|
||||
def getZabbixGroup(self, groups):
|
||||
"""
|
||||
Returns Zabbix group ID
|
||||
INPUT: list of hostgroups
|
||||
OUTPUT: True / False
|
||||
"""
|
||||
# Go through all groups
|
||||
for group in groups:
|
||||
if group['name'] == self.hostgroup:
|
||||
self.group_id = group['groupid']
|
||||
e = f"Found group {group['name']} for host {self.name}."
|
||||
logger.debug(e)
|
||||
return True
|
||||
e = (f"Unable to find group '{self.hostgroup}' "
|
||||
f"for host {self.name} in Zabbix.")
|
||||
logger.warning(e)
|
||||
raise SyncInventoryError(e)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Removes device from external resources.
|
||||
Resets custom fields in Netbox.
|
||||
"""
|
||||
if self.zabbix_id:
|
||||
try:
|
||||
self.zabbix.host.delete(self.zabbix_id)
|
||||
self.nb.custom_fields[device_cf] = None
|
||||
self.nb.save()
|
||||
e = f"Deleted host {self.name} from Zabbix."
|
||||
logger.info(e)
|
||||
self.create_journal_entry("warning", "Deleted host from Zabbix")
|
||||
except ZabbixAPIException as e:
|
||||
e = f"Zabbix returned the following error: {str(e)}."
|
||||
logger.error(e)
|
||||
raise SyncExternalError(e) from e
|
||||
|
||||
def _zabbixHostnameExists(self):
|
||||
"""
|
||||
Checks if hostname exists in Zabbix.
|
||||
"""
|
||||
host = self.zabbix.host.get(filter={'name': self.name}, output=[])
|
||||
return bool(host)
|
||||
|
||||
def setInterfaceDetails(self):
|
||||
"""
|
||||
Checks interface parameters from Netbox and
|
||||
creates a model for the interface to be used in Zabbix.
|
||||
"""
|
||||
try:
|
||||
# Initiate interface class
|
||||
interface = ZabbixInterface(self.nb.config_context, self.ip)
|
||||
# Check if Netbox has device context.
|
||||
# If not fall back to old config.
|
||||
if interface.get_context():
|
||||
# If device is SNMP type, add aditional information.
|
||||
if interface.interface["type"] == 2:
|
||||
interface.set_snmp()
|
||||
else:
|
||||
interface.set_default()
|
||||
return [interface.interface]
|
||||
except InterfaceConfigError as e:
|
||||
e = f"{self.name}: {e}"
|
||||
logger.warning(e)
|
||||
raise SyncInventoryError(e) from e
|
||||
|
||||
def setProxy(self, proxy_list):
|
||||
""" check if Zabbix Proxy has been defined in config context """
|
||||
if "zabbix" in self.nb.config_context:
|
||||
if "proxy" in self.nb.config_context["zabbix"]:
|
||||
proxy = self.nb.config_context["zabbix"]["proxy"]
|
||||
# Try matching proxy
|
||||
for px in proxy_list:
|
||||
if px["name"] == proxy:
|
||||
self.zbxproxy = px["proxyid"]
|
||||
logger.debug(f"Found proxy {proxy}"
|
||||
f" for {self.name}.")
|
||||
return True
|
||||
e = f"{self.name}: Defined proxy {proxy} not found."
|
||||
logger.warning(e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def createInZabbix(self, groups, templates, proxies,
|
||||
description="Host added by Netbox sync script."):
|
||||
"""
|
||||
Creates Zabbix host object with parameters from Netbox object.
|
||||
"""
|
||||
# Check if hostname is already present in Zabbix
|
||||
if not self._zabbixHostnameExists():
|
||||
# Get group and template ID's for host
|
||||
if not self.getZabbixGroup(groups):
|
||||
raise SyncInventoryError()
|
||||
self.zbxTemplatePrepper(templates)
|
||||
templateids = []
|
||||
for template in self.zbx_templates:
|
||||
templateids.append({'templateid': template['templateid']})
|
||||
# Set interface, group and template configuration
|
||||
interfaces = self.setInterfaceDetails()
|
||||
groups = [{"groupid": self.group_id}]
|
||||
# Set Zabbix proxy if defined
|
||||
self.setProxy(proxies)
|
||||
# Add host to Zabbix
|
||||
try:
|
||||
if version.parse(self.zabbix.api_version()) < version.parse("7.0.0"):
|
||||
host = self.zabbix.host.create(host=self.name,
|
||||
status=self.zabbix_state,
|
||||
interfaces=interfaces,
|
||||
groups=groups,
|
||||
templates=templateids,
|
||||
proxy_hostid=self.zbxproxy,
|
||||
description=description,
|
||||
inventory_mode=self.inventory_mode,
|
||||
inventory=self.inventory)
|
||||
else:
|
||||
host = self.zabbix.host.create(host=self.name,
|
||||
status=self.zabbix_state,
|
||||
interfaces=interfaces,
|
||||
groups=groups,
|
||||
templates=templateids,
|
||||
proxyid=self.zbxproxy,
|
||||
description=description,
|
||||
inventory_mode=self.inventory_mode,
|
||||
inventory=self.inventory)
|
||||
self.zabbix_id = host["hostids"][0]
|
||||
except ZabbixAPIException as e:
|
||||
e = f"Couldn't add {self.name}, Zabbix returned {str(e)}."
|
||||
logger.error(e)
|
||||
raise SyncExternalError(e) from e
|
||||
# Set Netbox custom field to hostID value.
|
||||
self.nb.custom_fields[device_cf] = int(self.zabbix_id)
|
||||
self.nb.save()
|
||||
msg = f"Created host {self.name} in Zabbix."
|
||||
logger.info(msg)
|
||||
self.create_journal_entry("success", msg)
|
||||
else:
|
||||
e = f"Unable to add {self.name} to Zabbix: host already present."
|
||||
logger.warning(e)
|
||||
|
||||
def createZabbixHostgroup(self):
|
||||
"""
|
||||
Creates Zabbix host group based on hostgroup format.
|
||||
"""
|
||||
try:
|
||||
groupid = self.zabbix.hostgroup.create(name=self.hostgroup)
|
||||
e = f"Added hostgroup '{self.hostgroup}'."
|
||||
logger.info(e)
|
||||
data = {'groupid': groupid["groupids"][0], 'name': self.hostgroup}
|
||||
return data
|
||||
except ZabbixAPIException as e:
|
||||
e = f"Couldn't add hostgroup, Zabbix returned {str(e)}."
|
||||
logger.error(e)
|
||||
raise SyncExternalError(e) from e
|
||||
|
||||
def updateZabbixHost(self, **kwargs):
|
||||
"""
|
||||
Updates Zabbix host with given parameters.
|
||||
INPUT: Key word arguments for Zabbix host object.
|
||||
"""
|
||||
try:
|
||||
self.zabbix.host.update(hostid=self.zabbix_id, **kwargs)
|
||||
except ZabbixAPIException as e:
|
||||
e = f"Zabbix returned the following error: {str(e)}."
|
||||
logger.error(e)
|
||||
raise SyncExternalError(e) from e
|
||||
logger.info(f"Updated host {self.name} with data {kwargs}.")
|
||||
self.create_journal_entry("info", "Updated host in Zabbix with latest NB data.")
|
||||
|
||||
def ConsistencyCheck(self, groups, templates, proxies, proxy_power):
|
||||
# pylint: disable=too-many-branches, too-many-statements
|
||||
"""
|
||||
Checks if Zabbix object is still valid with Netbox parameters.
|
||||
"""
|
||||
self.getZabbixGroup(groups)
|
||||
self.zbxTemplatePrepper(templates)
|
||||
self.setProxy(proxies)
|
||||
host = self.zabbix.host.get(filter={'hostid': self.zabbix_id},
|
||||
selectInterfaces=['type', 'ip',
|
||||
'port', 'details',
|
||||
'interfaceid'],
|
||||
selectGroups=["groupid"],
|
||||
selectParentTemplates=["templateid"],
|
||||
selectInventory=list(inventory_map.values()))
|
||||
if len(host) > 1:
|
||||
e = (f"Got {len(host)} results for Zabbix hosts "
|
||||
f"with ID {self.zabbix_id} - hostname {self.name}.")
|
||||
logger.error(e)
|
||||
raise SyncInventoryError(e)
|
||||
if len(host) == 0:
|
||||
e = (f"No Zabbix host found for {self.name}. "
|
||||
f"This is likely the result of a deleted Zabbix host "
|
||||
f"without zeroing the ID field in Netbox.")
|
||||
logger.error(e)
|
||||
raise SyncInventoryError(e)
|
||||
host = host[0]
|
||||
if host["host"] == self.name:
|
||||
logger.debug(f"Device {self.name}: hostname in-sync.")
|
||||
else:
|
||||
logger.warning(f"Device {self.name}: hostname OUT of sync. "
|
||||
f"Received value: {host['host']}")
|
||||
self.updateZabbixHost(host=self.name)
|
||||
|
||||
# Check if the templates are in-sync
|
||||
if not self.zbx_template_comparer(host["parentTemplates"]):
|
||||
logger.warning(f"Device {self.name}: template(s) OUT of sync.")
|
||||
# Update Zabbix with NB templates and clear any old / lost templates
|
||||
self.updateZabbixHost(templates_clear=host["parentTemplates"],
|
||||
templates=self.zbx_templates)
|
||||
else:
|
||||
logger.debug(f"Device {self.name}: template(s) in-sync.")
|
||||
|
||||
for group in host["groups"]:
|
||||
if group["groupid"] == self.group_id:
|
||||
logger.debug(f"Device {self.name}: hostgroup in-sync.")
|
||||
break
|
||||
else:
|
||||
logger.warning(f"Device {self.name}: hostgroup OUT of sync.")
|
||||
self.updateZabbixHost(groups={'groupid': self.group_id})
|
||||
|
||||
if int(host["status"]) == self.zabbix_state:
|
||||
logger.debug(f"Device {self.name}: status in-sync.")
|
||||
else:
|
||||
logger.warning(f"Device {self.name}: status OUT of sync.")
|
||||
self.updateZabbixHost(status=str(self.zabbix_state))
|
||||
|
||||
# Check if a proxy has been defined
|
||||
if self.zbxproxy != "0":
|
||||
# Check if expected proxyID matches with configured proxy
|
||||
if (("proxy_hostid" in host and host["proxy_hostid"] == self.zbxproxy) or
|
||||
("proxyid" in host and host["proxyid"] == self.zbxproxy)):
|
||||
logger.debug(f"Device {self.name}: proxy in-sync.")
|
||||
else:
|
||||
# Proxy diff, update value
|
||||
logger.warning(f"Device {self.name}: proxy OUT of sync.")
|
||||
if version.parse(self.zabbix.api_version()) < version.parse("7.0.0"):
|
||||
self.updateZabbixHost(proxy_hostid=self.zbxproxy)
|
||||
else:
|
||||
self.updateZabbixHost(proxyid=self.zbxproxy)
|
||||
else:
|
||||
if (("proxy_hostid" in host and not host["proxy_hostid"] == "0")
|
||||
or ("proxyid" in host and not host["proxyid"] == "0")):
|
||||
if proxy_power:
|
||||
# Variable full_proxy_sync has been enabled
|
||||
# delete the proxy link in Zabbix
|
||||
if version.parse(self.zabbix.api_version()) < version.parse("7.0.0"):
|
||||
self.updateZabbixHost(proxy_hostid=self.zbxproxy)
|
||||
else:
|
||||
self.updateZabbixHost(proxyid=self.zbxproxy)
|
||||
else:
|
||||
# Instead of deleting the proxy config in zabbix and
|
||||
# forcing potential data loss,
|
||||
# an error message is displayed.
|
||||
logger.error(f"Device {self.name} is configured "
|
||||
f"with proxy in Zabbix but not in Netbox. The"
|
||||
" -p flag was ommited: no "
|
||||
"changes have been made.")
|
||||
|
||||
# Check host inventory
|
||||
if inventory_sync:
|
||||
# check inventory mode first, as we need it set to parse
|
||||
# actual inventory values
|
||||
if str(host['inventory_mode']) == str(self.inventory_mode):
|
||||
logger.debug(f"Device {self.name}: inventory_mode in-sync.")
|
||||
else:
|
||||
logger.warning(f"Device {self.name}: inventory_mode OUT of sync.")
|
||||
self.updateZabbixHost(inventory_mode=str(self.inventory_mode))
|
||||
# Now we can check if inventory is in-sync.
|
||||
if host['inventory'] == self.inventory:
|
||||
logger.debug(f"Device {self.name}: inventory in-sync.")
|
||||
else:
|
||||
logger.warning(f"Device {self.name}: inventory OUT of sync.")
|
||||
self.updateZabbixHost(inventory=self.inventory)
|
||||
|
||||
# If only 1 interface has been found
|
||||
# pylint: disable=too-many-nested-blocks
|
||||
if len(host['interfaces']) == 1:
|
||||
updates = {}
|
||||
# Go through each key / item and check if it matches Zabbix
|
||||
for key, item in self.setInterfaceDetails()[0].items():
|
||||
# Check if Netbox value is found in Zabbix
|
||||
if key in host["interfaces"][0]:
|
||||
# If SNMP is used, go through nested dict
|
||||
# to compare SNMP parameters
|
||||
if isinstance(item,dict) and key == "details":
|
||||
for k, i in item.items():
|
||||
if k in host["interfaces"][0][key]:
|
||||
# Set update if values don't match
|
||||
if host["interfaces"][0][key][k] != str(i):
|
||||
# If dict has not been created, add it
|
||||
if key not in updates:
|
||||
updates[key] = {}
|
||||
updates[key][k] = str(i)
|
||||
# If SNMP version has been changed
|
||||
# break loop and force full SNMP update
|
||||
if k == "version":
|
||||
break
|
||||
# Force full SNMP config update
|
||||
# when version has changed.
|
||||
if key in updates:
|
||||
if "version" in updates[key]:
|
||||
for k, i in item.items():
|
||||
updates[key][k] = str(i)
|
||||
continue
|
||||
# Set update if values don't match
|
||||
if host["interfaces"][0][key] != str(item):
|
||||
updates[key] = item
|
||||
if updates:
|
||||
# If interface updates have been found: push to Zabbix
|
||||
logger.warning(f"Device {self.name}: Interface OUT of sync.")
|
||||
if "type" in updates:
|
||||
# Changing interface type not supported. Raise exception.
|
||||
e = (f"Device {self.name}: changing interface type to "
|
||||
f"{str(updates['type'])} is not supported.")
|
||||
logger.error(e)
|
||||
raise InterfaceConfigError(e)
|
||||
# Set interfaceID for Zabbix config
|
||||
updates["interfaceid"] = host["interfaces"][0]['interfaceid']
|
||||
try:
|
||||
# API call to Zabbix
|
||||
self.zabbix.hostinterface.update(updates)
|
||||
e = f"Solved {self.name} interface conflict."
|
||||
logger.info(e)
|
||||
self.create_journal_entry("info", e)
|
||||
except ZabbixAPIException as e:
|
||||
e = f"Zabbix returned the following error: {str(e)}."
|
||||
logger.error(e)
|
||||
raise SyncExternalError(e) from e
|
||||
else:
|
||||
# If no updates are found, Zabbix interface is in-sync
|
||||
e = f"Device {self.name}: interface in-sync."
|
||||
logger.debug(e)
|
||||
else:
|
||||
e = (f"Device {self.name} has unsupported interface configuration."
|
||||
f" Host has total of {len(host['interfaces'])} interfaces. "
|
||||
"Manual interfention required.")
|
||||
logger.error(e)
|
||||
raise SyncInventoryError(e)
|
||||
|
||||
def create_journal_entry(self, severity, message):
|
||||
"""
|
||||
Send a new Journal entry to Netbox. Usefull for viewing actions
|
||||
in Netbox without having to look in Zabbix or the script log output
|
||||
"""
|
||||
if self.journal:
|
||||
# Check if the severity is valid
|
||||
if severity not in ["info", "success", "warning", "danger"]:
|
||||
logger.warning(f"Value {severity} not valid for NB journal entries.")
|
||||
return False
|
||||
journal = {"assigned_object_type": "dcim.device",
|
||||
"assigned_object_id": self.id,
|
||||
"kind": severity,
|
||||
"comments": message
|
||||
}
|
||||
try:
|
||||
self.nb_journals.create(journal)
|
||||
logger.debug(f"Created journal entry in NB for host {self.name}")
|
||||
return True
|
||||
except JournalError(e) as e:
|
||||
logger.warning("Unable to create journal entry for "
|
||||
f"{self.name}: NB returned {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
def zbx_template_comparer(self, tmpls_from_zabbix):
|
||||
"""
|
||||
Compares the Netbox and Zabbix templates with each other.
|
||||
Should there be a mismatch then the function will return false
|
||||
|
||||
INPUT: list of NB and ZBX templates
|
||||
OUTPUT: Boolean True/False
|
||||
"""
|
||||
succesfull_templates = []
|
||||
# Go through each Netbox template
|
||||
for nb_tmpl in self.zbx_templates:
|
||||
# Go through each Zabbix template
|
||||
for pos, zbx_tmpl in enumerate(tmpls_from_zabbix):
|
||||
# Check if template IDs match
|
||||
if nb_tmpl["templateid"] == zbx_tmpl["templateid"]:
|
||||
# Templates match. Remove this template from the Zabbix templates
|
||||
# and add this NB template to the list of successfull templates
|
||||
tmpls_from_zabbix.pop(pos)
|
||||
succesfull_templates.append(nb_tmpl)
|
||||
logger.debug(f"Device {self.name}: template "
|
||||
f"{nb_tmpl['name']} is present in Zabbix.")
|
||||
break
|
||||
if len(succesfull_templates) == len(self.zbx_templates) and len(tmpls_from_zabbix) == 0:
|
||||
# All of the Netbox templates have been confirmed as successfull
|
||||
# and the ZBX template list is empty. This means that
|
||||
# all of the templates match.
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ZabbixInterface():
|
||||
"""Class that represents a Zabbix interface."""
|
||||
|
||||
def __init__(self, context, ip):
|
||||
self.context = context
|
||||
self.ip = ip
|
||||
self.skelet = {"main": "1", "useip": "1", "dns": "", "ip": self.ip}
|
||||
self.interface = self.skelet
|
||||
|
||||
def get_context(self):
|
||||
""" check if Netbox custom context has been defined. """
|
||||
if "zabbix" in self.context:
|
||||
zabbix = self.context["zabbix"]
|
||||
if("interface_type" in zabbix and "interface_port" in zabbix):
|
||||
self.interface["type"] = zabbix["interface_type"]
|
||||
self.interface["port"] = zabbix["interface_port"]
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
def set_snmp(self):
|
||||
""" Check if interface is type SNMP """
|
||||
# pylint: disable=too-many-branches
|
||||
if self.interface["type"] == 2:
|
||||
# Checks if SNMP settings are defined in Netbox
|
||||
if "snmp" in self.context["zabbix"]:
|
||||
snmp = self.context["zabbix"]["snmp"]
|
||||
self.interface["details"] = {}
|
||||
# Checks if bulk config has been defined
|
||||
if "bulk" in snmp:
|
||||
self.interface["details"]["bulk"] = str(snmp.pop("bulk"))
|
||||
else:
|
||||
# Fallback to bulk enabled if not specified
|
||||
self.interface["details"]["bulk"] = "1"
|
||||
# SNMP Version config is required in Netbox config context
|
||||
if snmp.get("version"):
|
||||
self.interface["details"]["version"] = str(snmp.pop("version"))
|
||||
else:
|
||||
e = "SNMP version option is not defined."
|
||||
raise InterfaceConfigError(e)
|
||||
# If version 1 or 2 is used, get community string
|
||||
if self.interface["details"]["version"] in ['1','2']:
|
||||
if "community" in snmp:
|
||||
# Set SNMP community to confix context value
|
||||
community = snmp["community"]
|
||||
else:
|
||||
# Set SNMP community to default
|
||||
community = "{$SNMP_COMMUNITY}"
|
||||
self.interface["details"]["community"] = str(community)
|
||||
# If version 3 has been used, get all
|
||||
# SNMPv3 Netbox related configs
|
||||
elif self.interface["details"]["version"] == '3':
|
||||
items = ["securityname", "securitylevel", "authpassphrase",
|
||||
"privpassphrase", "authprotocol", "privprotocol",
|
||||
"contextname"]
|
||||
for key, item in snmp.items():
|
||||
if key in items:
|
||||
self.interface["details"][key] = str(item)
|
||||
else:
|
||||
e = "Unsupported SNMP version."
|
||||
raise InterfaceConfigError(e)
|
||||
else:
|
||||
e = "Interface type SNMP but no parameters provided."
|
||||
raise InterfaceConfigError(e)
|
||||
else:
|
||||
e = "Interface type is not SNMP, unable to set SNMP details"
|
||||
raise InterfaceConfigError(e)
|
||||
|
||||
def set_default(self):
|
||||
""" Set default config to SNMPv2, port 161 and community macro. """
|
||||
self.interface = self.skelet
|
||||
self.interface["type"] = "2"
|
||||
self.interface["port"] = "161"
|
||||
self.interface["details"] = {"version": "2",
|
||||
"community": "{$SNMP_COMMUNITY}",
|
||||
"bulk": "1"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description='A script to sync Zabbix with Netbox device data.'
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", help="Turn on debugging.",
|
||||
action="store_true")
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from netbox_zabbix_sync.modules.cli import parse_cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
parse_cli()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Makes core module sync function available at package level for easier imports.
|
||||
"""
|
||||
|
||||
from netbox_zabbix_sync.modules.core import Sync as Sync
|
||||
@@ -0,0 +1,205 @@
|
||||
import argparse
|
||||
import logging
|
||||
from os import environ
|
||||
|
||||
from netbox_zabbix_sync.modules.core import Sync
|
||||
from netbox_zabbix_sync.modules.exceptions import EnvironmentVarError
|
||||
from netbox_zabbix_sync.modules.logging import get_logger, set_log_levels, setup_logger
|
||||
from netbox_zabbix_sync.modules.settings import load_config
|
||||
|
||||
# Boolean settings that can be toggled via --flag / --no-flag
|
||||
_BOOL_ARGS = [
|
||||
("clustering", "Enable clustering of devices with virtual chassis setup."),
|
||||
("create_hostgroups", "Enable hostgroup generation (requires Zabbix permissions)."),
|
||||
("create_journal", "Create NetBox journal entries on changes."),
|
||||
("sync_vms", "Enable virtual machine sync."),
|
||||
(
|
||||
"full_proxy_sync",
|
||||
"Enable full proxy sync (removes proxies not in config context).",
|
||||
),
|
||||
(
|
||||
"templates_config_context",
|
||||
"Use config context as the template source instead of a custom field.",
|
||||
),
|
||||
(
|
||||
"templates_config_context_overrule",
|
||||
"Give config context templates higher priority than custom field templates.",
|
||||
),
|
||||
("traverse_regions", "Use the full parent-region path in hostgroup names."),
|
||||
("traverse_site_groups", "Use the full parent-site-group path in hostgroup names."),
|
||||
(
|
||||
"extended_site_properties",
|
||||
"Fetch additional site info from NetBox (increases API queries).",
|
||||
),
|
||||
("inventory_sync", "Sync NetBox device properties to Zabbix inventory."),
|
||||
("usermacro_sync", "Sync usermacros from NetBox to Zabbix."),
|
||||
("tag_sync", "Sync host tags to Zabbix."),
|
||||
("tag_lower", "Lowercase tag names and values before syncing."),
|
||||
]
|
||||
|
||||
# String settings that can be set via --option VALUE
|
||||
_STR_ARGS = [
|
||||
("template_cf", "NetBox custom field name for the Zabbix template.", "FIELD"),
|
||||
("device_cf", "NetBox custom field name for the Zabbix host ID.", "FIELD"),
|
||||
(
|
||||
"hostgroup_format",
|
||||
"Hostgroup path pattern for physical devices (e.g. site/manufacturer/role).",
|
||||
"PATTERN",
|
||||
),
|
||||
(
|
||||
"vm_hostgroup_format",
|
||||
"Hostgroup path pattern for virtual machines (e.g. cluster_type/cluster/role).",
|
||||
"PATTERN",
|
||||
),
|
||||
(
|
||||
"inventory_mode",
|
||||
"Zabbix inventory mode: disabled, manual, or automatic.",
|
||||
"MODE",
|
||||
),
|
||||
("tag_name", "Zabbix tag name used when syncing NetBox tags.", "NAME"),
|
||||
(
|
||||
"tag_value",
|
||||
"NetBox tag property to use as the Zabbix tag value (name, slug, or display).",
|
||||
"PROPERTY",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _apply_cli_overrides(config: dict, arguments: argparse.Namespace) -> dict:
|
||||
"""Override loaded config with any values explicitly provided on the CLI."""
|
||||
for key, _help in _BOOL_ARGS:
|
||||
cli_val = getattr(arguments, key, None)
|
||||
if cli_val is not None:
|
||||
config[key] = cli_val
|
||||
for key, _help, _meta in _STR_ARGS:
|
||||
cli_val = getattr(arguments, key, None)
|
||||
if cli_val is not None:
|
||||
config[key] = cli_val
|
||||
return config
|
||||
|
||||
|
||||
def main(arguments):
|
||||
"""Run the sync process."""
|
||||
# Set logging
|
||||
setup_logger()
|
||||
logger = get_logger()
|
||||
# Set log levels based on verbosity flags
|
||||
if arguments.verbose:
|
||||
set_log_levels(logging.WARNING, logging.INFO)
|
||||
if arguments.debug:
|
||||
set_log_levels(logging.WARNING, logging.DEBUG)
|
||||
if arguments.debug_all:
|
||||
set_log_levels(logging.DEBUG, logging.DEBUG)
|
||||
if arguments.quiet:
|
||||
set_log_levels(logging.ERROR, logging.ERROR)
|
||||
|
||||
# Gather environment variables for Zabbix and Netbox communication
|
||||
env_vars = ["ZABBIX_HOST", "NETBOX_HOST", "NETBOX_TOKEN"]
|
||||
if "ZABBIX_TOKEN" in environ:
|
||||
env_vars.append("ZABBIX_TOKEN")
|
||||
else:
|
||||
env_vars.append("ZABBIX_USER")
|
||||
env_vars.append("ZABBIX_PASS")
|
||||
for var in env_vars:
|
||||
if var not in environ:
|
||||
e = f"Environment variable {var} has not been defined."
|
||||
logger.error(e)
|
||||
raise EnvironmentVarError(e)
|
||||
# Get all virtual environment variables
|
||||
if "ZABBIX_TOKEN" in env_vars:
|
||||
zabbix_user = None
|
||||
zabbix_pass = None
|
||||
zabbix_token = environ.get("ZABBIX_TOKEN")
|
||||
else:
|
||||
zabbix_user = environ.get("ZABBIX_USER")
|
||||
zabbix_pass = environ.get("ZABBIX_PASS")
|
||||
zabbix_token = None
|
||||
zabbix_host = environ.get("ZABBIX_HOST")
|
||||
netbox_host = environ.get("NETBOX_HOST")
|
||||
netbox_token = environ.get("NETBOX_TOKEN")
|
||||
|
||||
# Load config (defaults → config.py → env vars), then apply CLI overrides
|
||||
config = load_config(config_file=arguments.config)
|
||||
config = _apply_cli_overrides(config, arguments)
|
||||
|
||||
# Run main sync process
|
||||
syncer = Sync(config=config)
|
||||
syncer.connect(
|
||||
nb_host=netbox_host,
|
||||
nb_token=netbox_token,
|
||||
zbx_host=zabbix_host,
|
||||
zbx_user=zabbix_user,
|
||||
zbx_pass=zabbix_pass,
|
||||
zbx_token=zabbix_token,
|
||||
)
|
||||
syncer.start()
|
||||
|
||||
|
||||
def parse_cli():
|
||||
"""
|
||||
Parse command-line arguments and run the main function.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Synchronise NetBox device data to Zabbix."
|
||||
)
|
||||
|
||||
# ── Verbosity ──────────────────────────────────────────────────────────────
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", help="Turn on verbose logging.", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-vv", "--debug", help="Turn on debugging.", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-vvv",
|
||||
"--debug-all",
|
||||
help="Turn on debugging for all modules.",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument("-q", "--quiet", help="Turn off warnings.", action="store_true")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
help="Path to the config file (default: config.py next to the script or in the current directory).",
|
||||
metavar="FILE",
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version="NetBox-Zabbix Sync 3.4.0"
|
||||
)
|
||||
|
||||
# ── Boolean config overrides ───────────────────────────────────────────────
|
||||
bool_group = parser.add_argument_group(
|
||||
"config overrides (boolean)",
|
||||
"Override boolean settings from config.py. "
|
||||
"Use --flag to enable or --no-flag to disable. "
|
||||
"When omitted, the value from config.py (or the built-in default) is used.",
|
||||
)
|
||||
for key, help_text in _BOOL_ARGS:
|
||||
flag = key.replace("_", "-")
|
||||
bool_group.add_argument(
|
||||
f"--{flag}",
|
||||
dest=key,
|
||||
help=help_text,
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
)
|
||||
|
||||
# ── String config overrides ────────────────────────────────────────────────
|
||||
str_group = parser.add_argument_group(
|
||||
"config overrides (string)",
|
||||
"Override string settings from config.py. "
|
||||
"When omitted, the value from config.py (or the built-in default) is used.",
|
||||
)
|
||||
for key, help_text, metavar in _STR_ARGS:
|
||||
flag = key.replace("_", "-")
|
||||
str_group.add_argument(
|
||||
f"--{flag}",
|
||||
dest=key,
|
||||
help=help_text,
|
||||
metavar=metavar,
|
||||
default=None,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Core component of the sync process"""
|
||||
|
||||
import ssl
|
||||
from os import environ
|
||||
from typing import Any
|
||||
|
||||
from pynetbox import api as nbapi
|
||||
from pynetbox.core.query import RequestError as NetBoxRequestError
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
from zabbix_utils import APIRequestError, ProcessingError, ZabbixAPI
|
||||
|
||||
from netbox_zabbix_sync.modules.device import PhysicalDevice
|
||||
from netbox_zabbix_sync.modules.exceptions import SyncError
|
||||
from netbox_zabbix_sync.modules.logging import get_logger
|
||||
from netbox_zabbix_sync.modules.settings import DEFAULT_CONFIG
|
||||
from netbox_zabbix_sync.modules.tools import (
|
||||
convert_recordset,
|
||||
proxy_prepper,
|
||||
verify_hg_format,
|
||||
)
|
||||
from netbox_zabbix_sync.modules.virtual_machine import VirtualMachine
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class Sync:
|
||||
"""
|
||||
Class that hosts the main sync process.
|
||||
This class is used to connect to NetBox and Zabbix and run the sync process.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
"""
|
||||
Docstring for __init__
|
||||
|
||||
:param self: Description
|
||||
:param config: Description
|
||||
"""
|
||||
self.netbox = None
|
||||
self.zabbix = None
|
||||
self.nb_version = None
|
||||
|
||||
default_config = DEFAULT_CONFIG.copy()
|
||||
|
||||
combined_config = {
|
||||
**default_config,
|
||||
**(config if config else {}),
|
||||
}
|
||||
|
||||
self.config: dict[str, Any] = combined_config
|
||||
|
||||
def connect(
|
||||
self, nb_host, nb_token, zbx_host, zbx_user=None, zbx_pass=None, zbx_token=None
|
||||
):
|
||||
"""
|
||||
Docstring for connect
|
||||
|
||||
:param self: Description
|
||||
:param nb_host: Description
|
||||
:param nb_token: Description
|
||||
:param zbx_host: Description
|
||||
:param zbx_user: Description
|
||||
:param zbx_pass: Description
|
||||
:param zbx_token: Description
|
||||
"""
|
||||
# Initialize Netbox API connection
|
||||
netbox = nbapi(nb_host, token=nb_token, threading=True)
|
||||
try:
|
||||
# Get NetBox version
|
||||
nb_version = netbox.version
|
||||
# Test API access by attempting to access a basic endpoint
|
||||
# This will catch authorization errors early
|
||||
netbox.dcim.devices.count()
|
||||
logger.debug("NetBox version is %s.", nb_version)
|
||||
self.netbox = netbox
|
||||
self.nb_version = nb_version
|
||||
except RequestsConnectionError:
|
||||
logger.error(
|
||||
"Unable to connect to NetBox with URL %s. Please check the URL and status of NetBox.",
|
||||
nb_host,
|
||||
)
|
||||
return False
|
||||
except NetBoxRequestError as nb_error:
|
||||
e = f"NetBox returned the following error: {nb_error}."
|
||||
logger.error(e)
|
||||
return False
|
||||
# Check Netbox API token format based on NetBox version
|
||||
if not self._validate_netbox_token(nb_token, self.nb_version):
|
||||
return False
|
||||
# Set Zabbix API
|
||||
if (zbx_pass or zbx_user) and zbx_token:
|
||||
e = (
|
||||
"Both ZABBIX_PASS, ZABBIX_USER and ZABBIX_TOKEN environment variables are set. "
|
||||
"Please choose between token or password based authentication."
|
||||
)
|
||||
logger.error(e)
|
||||
return False
|
||||
try:
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
|
||||
# If a custom CA bundle is set for pynetbox (requests), also use it for the Zabbix API
|
||||
if environ.get("REQUESTS_CA_BUNDLE", None):
|
||||
ssl_ctx.load_verify_locations(environ["REQUESTS_CA_BUNDLE"])
|
||||
if not zbx_token:
|
||||
logger.debug("Using user/password authentication for Zabbix API.")
|
||||
self.zabbix = ZabbixAPI(
|
||||
zbx_host, user=zbx_user, password=zbx_pass, ssl_context=ssl_ctx
|
||||
)
|
||||
else:
|
||||
logger.debug("Using token authentication for Zabbix API.")
|
||||
self.zabbix = ZabbixAPI(zbx_host, token=zbx_token, ssl_context=ssl_ctx)
|
||||
self.zabbix.check_auth()
|
||||
logger.debug("Zabbix version is %s.", self.zabbix.version)
|
||||
except (APIRequestError, ProcessingError) as zbx_error:
|
||||
e = f"Zabbix returned the following error: {zbx_error}."
|
||||
logger.error(e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_netbox_token(self, token: str, nb_version: str) -> bool:
|
||||
"""Validate the format of the NetBox token based on the NetBox version.
|
||||
:param token: The NetBox token to validate.
|
||||
:param nb_version: The version of NetBox being used.
|
||||
:return: True if the token format is valid for the given NetBox version, False otherwise.
|
||||
"""
|
||||
support_token_url = (
|
||||
"https://netboxlabs.com/docs/netbox/integrations/rest-api/#v1-and-v2-tokens" # noqa: S105
|
||||
)
|
||||
token_prefix = "nbt_" # noqa: S105
|
||||
nb_v2_support_version = "4.5"
|
||||
v2_token = bool(token.startswith(token_prefix) and "." in token)
|
||||
v2_error_token = bool(token.startswith(token_prefix) and "." not in token)
|
||||
# Check if the token is passed without a proper key.token format
|
||||
if v2_error_token:
|
||||
logger.error(
|
||||
"It looks like an invalid v2 token was passed. For more info, see %s",
|
||||
support_token_url,
|
||||
)
|
||||
return False
|
||||
# Warning message for Netbox token v1 with Netbox v4.5 and higher
|
||||
if not v2_token and nb_version >= nb_v2_support_version:
|
||||
logger.warning(
|
||||
"Using Netbox v1 token format. "
|
||||
"Consider updating to a v2 token. For more info, see %s",
|
||||
support_token_url,
|
||||
)
|
||||
elif v2_token and nb_version < nb_v2_support_version:
|
||||
logger.error(
|
||||
"Using Netbox v2 token format with Netbox version lower than 4.5. "
|
||||
"Revert to v1 token or upgrade Netbox to 4.5 or higher. For more info, see %s",
|
||||
support_token_url,
|
||||
)
|
||||
return False
|
||||
elif v2_token and nb_version >= nb_v2_support_version:
|
||||
logger.debug("Using NetBox v2 token format.")
|
||||
else:
|
||||
logger.debug("Using NetBox v1 token format.")
|
||||
return True
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Run the NetBox to Zabbix sync process.
|
||||
"""
|
||||
if not self.netbox or not self.zabbix:
|
||||
e = "Not able to start sync: No connection to NetBox or Zabbix API."
|
||||
logger.error(e)
|
||||
return False
|
||||
device_cfs = []
|
||||
vm_cfs = []
|
||||
# Create API call to get all custom fields which are on the device objects
|
||||
device_cfs = list(
|
||||
self.netbox.extras.custom_fields.filter(
|
||||
type=["text", "object", "select"], content_types="dcim.device"
|
||||
)
|
||||
)
|
||||
# Check if the provided Hostgroup layout is valid
|
||||
verify_hg_format(
|
||||
self.config["hostgroup_format"],
|
||||
device_cfs=device_cfs,
|
||||
hg_type="dev",
|
||||
logger=logger,
|
||||
)
|
||||
if self.config["sync_vms"]:
|
||||
vm_cfs = list(
|
||||
self.netbox.extras.custom_fields.filter(
|
||||
type=["text", "object", "select"],
|
||||
content_types="virtualization.virtualmachine",
|
||||
)
|
||||
)
|
||||
verify_hg_format(
|
||||
self.config["vm_hostgroup_format"],
|
||||
vm_cfs=vm_cfs,
|
||||
hg_type="vm",
|
||||
logger=logger,
|
||||
)
|
||||
# Set API parameter mapping based on API version
|
||||
proxy_name = "host" if str(self.zabbix.version) < "7" else "name"
|
||||
# Get all Zabbix and NetBox data
|
||||
netbox_devices = list(
|
||||
self.netbox.dcim.devices.filter(**self.config["nb_device_filter"])
|
||||
)
|
||||
netbox_vms = []
|
||||
if self.config["sync_vms"]:
|
||||
netbox_vms = list(
|
||||
self.netbox.virtualization.virtual_machines.filter(
|
||||
**self.config["nb_vm_filter"]
|
||||
)
|
||||
)
|
||||
netbox_site_groups = convert_recordset(self.netbox.dcim.site_groups.all())
|
||||
netbox_regions = convert_recordset(self.netbox.dcim.regions.all())
|
||||
netbox_journals = self.netbox.extras.journal_entries
|
||||
zabbix_groups = self.zabbix.hostgroup.get( # type: ignore
|
||||
output=["groupid", "name"]
|
||||
)
|
||||
zabbix_templates = self.zabbix.template.get( # type: ignore
|
||||
output=["templateid", "name"]
|
||||
)
|
||||
zabbix_proxies = self.zabbix.proxy.get( # type: ignore
|
||||
output=["proxyid", proxy_name]
|
||||
)
|
||||
# Set empty list for proxy processing Zabbix <= 6
|
||||
zabbix_proxygroups = []
|
||||
if str(self.zabbix.version) >= "7":
|
||||
zabbix_proxygroups = self.zabbix.proxygroup.get( # type: ignore
|
||||
output=["proxy_groupid", "name"]
|
||||
)
|
||||
# Sanitize proxy data
|
||||
if proxy_name == "host":
|
||||
for proxy in zabbix_proxies:
|
||||
proxy["name"] = proxy.pop("host")
|
||||
# Prepare list of all proxy and proxy_groups
|
||||
zabbix_proxy_list = proxy_prepper(zabbix_proxies, zabbix_proxygroups)
|
||||
|
||||
# Go through all NetBox devices
|
||||
for nb_vm in netbox_vms:
|
||||
try:
|
||||
vm = VirtualMachine(
|
||||
nb_vm,
|
||||
self.zabbix,
|
||||
netbox_journals,
|
||||
self.nb_version,
|
||||
self.config["create_journal"],
|
||||
logger,
|
||||
config=self.config,
|
||||
)
|
||||
logger.debug("Host %s: Started operations on VM.", vm.name)
|
||||
vm.set_vm_template()
|
||||
# Check if a valid template has been found for this VM.
|
||||
if not vm.zbx_template_names:
|
||||
continue
|
||||
vm.set_hostgroup(
|
||||
self.config["vm_hostgroup_format"],
|
||||
netbox_site_groups,
|
||||
netbox_regions,
|
||||
)
|
||||
# Check if a valid hostgroup has been found for this VM.
|
||||
if not vm.hostgroups:
|
||||
continue
|
||||
if self.config["extended_site_properties"] and nb_vm.site:
|
||||
logger.debug("Host %s: extending site information.", vm.name)
|
||||
vm.site = convert_recordset(
|
||||
self.netbox.dcim.sites.filter(id=nb_vm.site.id)
|
||||
)
|
||||
vm.set_inventory(nb_vm)
|
||||
vm.set_usermacros()
|
||||
vm.set_tags()
|
||||
# Checks if device is in cleanup state
|
||||
if vm.status in self.config["zabbix_device_removal"]:
|
||||
if vm.zabbix_id:
|
||||
# Delete device from Zabbix
|
||||
# and remove hostID from self.netbox.
|
||||
vm.cleanup()
|
||||
logger.info("Host %s: cleanup complete", vm.name)
|
||||
continue
|
||||
# Device has been added to NetBox
|
||||
# but is not in Activate state
|
||||
logger.info(
|
||||
"Host %s: Skipping since this host is not in the active state.",
|
||||
vm.name,
|
||||
)
|
||||
continue
|
||||
# Check if the VM is in the disabled state
|
||||
if vm.status in self.config["zabbix_device_disable"]:
|
||||
vm.zabbix_state = 1
|
||||
# Add hostgroup if config is set
|
||||
if self.config["create_hostgroups"]:
|
||||
# Create new hostgroup. Potentially multiple groups if nested
|
||||
hostgroups = vm.create_zbx_hostgroup(zabbix_groups)
|
||||
# go through all newly created hostgroups
|
||||
for group in hostgroups:
|
||||
# Add new hostgroups to zabbix group list
|
||||
zabbix_groups.append(group)
|
||||
# Check if VM is already in Zabbix
|
||||
if vm.zabbix_id:
|
||||
vm.consistency_check(
|
||||
zabbix_groups,
|
||||
zabbix_templates,
|
||||
zabbix_proxy_list,
|
||||
self.config["full_proxy_sync"],
|
||||
self.config["create_hostgroups"],
|
||||
)
|
||||
continue
|
||||
# Add VM to Zabbix
|
||||
vm.create_in_zabbix(zabbix_groups, zabbix_templates, zabbix_proxy_list)
|
||||
except SyncError:
|
||||
pass
|
||||
|
||||
for nb_device in netbox_devices:
|
||||
try:
|
||||
# Set device instance set data such as hostgroup and template information.
|
||||
device = PhysicalDevice(
|
||||
nb_device,
|
||||
self.zabbix,
|
||||
netbox_journals,
|
||||
self.nb_version,
|
||||
self.config["create_journal"],
|
||||
logger,
|
||||
config=self.config,
|
||||
)
|
||||
logger.debug("Host %s: Started operations on device.", device.name)
|
||||
device.set_template(
|
||||
self.config["templates_config_context"],
|
||||
self.config["templates_config_context_overrule"],
|
||||
)
|
||||
# Check if a valid template has been found for this VM.
|
||||
if not device.zbx_template_names:
|
||||
continue
|
||||
device.set_hostgroup(
|
||||
self.config["hostgroup_format"], netbox_site_groups, netbox_regions
|
||||
)
|
||||
# Check if a valid hostgroup has been found for this VM.
|
||||
if not device.hostgroups:
|
||||
logger.warning(
|
||||
"Host %s: has no valid hostgroups, Skipping this host...",
|
||||
device.name,
|
||||
)
|
||||
continue
|
||||
if self.config["extended_site_properties"] and nb_device.site:
|
||||
logger.debug("Host %s: extending site information.", device.name)
|
||||
device.site = convert_recordset(
|
||||
self.netbox.dcim.sites.filter(id=nb_device.site.id)
|
||||
)
|
||||
device.set_inventory(nb_device)
|
||||
device.set_usermacros()
|
||||
device.set_tags()
|
||||
# Checks if device is part of cluster.
|
||||
# Requires clustering variable
|
||||
if device.is_cluster() and self.config["clustering"]:
|
||||
# Check if device is primary or secondary
|
||||
if device.promote_primary_device():
|
||||
logger.info(
|
||||
"Host %s: is part of cluster and primary.", device.name
|
||||
)
|
||||
else:
|
||||
# Device is secondary in cluster.
|
||||
# Don't continue with this device.
|
||||
logger.info(
|
||||
"Host %s: Is part of cluster but not primary. Skipping this host...",
|
||||
device.name,
|
||||
)
|
||||
continue
|
||||
# Checks if device is in cleanup state
|
||||
if device.status in self.config["zabbix_device_removal"]:
|
||||
if device.zabbix_id:
|
||||
# Delete device from Zabbix
|
||||
# and remove hostID from NetBox.
|
||||
device.cleanup()
|
||||
logger.info("Host %s: cleanup complete", device.name)
|
||||
continue
|
||||
# Device has been added to NetBox
|
||||
# but is not in Activate state
|
||||
logger.info(
|
||||
"Host %s: Skipping since this host is not in the active state.",
|
||||
device.name,
|
||||
)
|
||||
continue
|
||||
# Check if the device is in the disabled state
|
||||
if device.status in self.config["zabbix_device_disable"]:
|
||||
device.zabbix_state = 1
|
||||
# Add hostgroup is config is set
|
||||
if self.config["create_hostgroups"]:
|
||||
# Create new hostgroup. Potentially multiple groups if nested
|
||||
hostgroups = device.create_zbx_hostgroup(zabbix_groups)
|
||||
# go through all newly created hostgroups
|
||||
for group in hostgroups:
|
||||
# Add new hostgroups to zabbix group list
|
||||
zabbix_groups.append(group)
|
||||
# Check if device is already in Zabbix
|
||||
if device.zabbix_id:
|
||||
device.consistency_check(
|
||||
zabbix_groups,
|
||||
zabbix_templates,
|
||||
zabbix_proxy_list,
|
||||
self.config["full_proxy_sync"],
|
||||
self.config["create_hostgroups"],
|
||||
)
|
||||
continue
|
||||
# Add device to Zabbix
|
||||
device.create_in_zabbix(
|
||||
zabbix_groups, zabbix_templates, zabbix_proxy_list
|
||||
)
|
||||
except SyncError:
|
||||
pass
|
||||
self.zabbix.logout()
|
||||
return True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
All custom exceptions used for Exception generation
|
||||
"""
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
"""Class SyncError"""
|
||||
|
||||
|
||||
class JournalError(Exception):
|
||||
"""Class SyncError"""
|
||||
|
||||
|
||||
class SyncExternalError(SyncError):
|
||||
"""Class SyncExternalError"""
|
||||
|
||||
|
||||
class SyncInventoryError(SyncError):
|
||||
"""Class SyncInventoryError"""
|
||||
|
||||
|
||||
class SyncDuplicateError(SyncError):
|
||||
"""Class SyncDuplicateError"""
|
||||
|
||||
|
||||
class EnvironmentVarError(SyncError):
|
||||
"""Class EnvironmentVarError"""
|
||||
|
||||
|
||||
class InterfaceConfigError(SyncError):
|
||||
"""Class InterfaceConfigError"""
|
||||
|
||||
|
||||
class ProxyConfigError(SyncError):
|
||||
"""Class ProxyConfigError"""
|
||||
|
||||
|
||||
class HostgroupError(SyncError):
|
||||
"""Class HostgroupError"""
|
||||
|
||||
|
||||
class TemplateError(SyncError):
|
||||
"""Class TemplateError"""
|
||||
|
||||
|
||||
class UsermacroError(SyncError):
|
||||
"""Class UsermacroError"""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Modules that set description of a host in Zabbix
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from re import findall as re_findall
|
||||
|
||||
|
||||
class Description:
|
||||
"""
|
||||
Class that generates the description for a host in Zabbix based on the configuration provided.
|
||||
|
||||
INPUT:
|
||||
- netbox_object: The NetBox object that is being synced.
|
||||
- configuration: configuration of the syncer.
|
||||
Required keys in configuration:
|
||||
description: Can be "static", "dynamic" or a custom description with macros.
|
||||
- nb_version: The version of NetBox that is being used.
|
||||
"""
|
||||
|
||||
def __init__(self, netbox_object, configuration, nb_version, logger=None):
|
||||
self.netbox_object = netbox_object
|
||||
self.name = self.netbox_object.name
|
||||
self.configuration = configuration
|
||||
self.nb_version = nb_version
|
||||
self.logger = logger or getLogger(__name__)
|
||||
self._set_default_macro_values()
|
||||
self._set_defaults()
|
||||
|
||||
def _set_default_macro_values(self):
|
||||
"""
|
||||
Sets the default macro values for the description.
|
||||
"""
|
||||
# Get the datetime format from the configuration,
|
||||
# or use the default format if not provided
|
||||
dt_format = self.configuration.get("description_dt_format", "%Y-%m-%d %H:%M:%S")
|
||||
# Set the datetime macro
|
||||
try:
|
||||
datetime_value = datetime.now().strftime(dt_format)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.warning(
|
||||
"Host %s: invalid datetime format '%s': %s. Using default format.",
|
||||
self.name,
|
||||
dt_format,
|
||||
e,
|
||||
)
|
||||
datetime_value = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Set the owner macro
|
||||
owner = self.netbox_object.owner if self.nb_version >= "4.5" else ""
|
||||
# Set the macro list
|
||||
self.macros = {"{datetime}": datetime_value, "{owner}": owner}
|
||||
|
||||
def _resolve_macros(self, description):
|
||||
"""
|
||||
Takes a description and resolves the macro's in it.
|
||||
Returns the description with the macro's resolved.
|
||||
"""
|
||||
# Find all macros in the description
|
||||
provided_macros = re_findall(r"\{\w+\}", description)
|
||||
# Go through all macros provided in the NB description
|
||||
for macro in provided_macros:
|
||||
# If the macro is in the list of default macro values
|
||||
if macro in self.macros:
|
||||
# Replace the macro in the description with the value of the macro
|
||||
description = description.replace(macro, str(self.macros[macro]))
|
||||
else:
|
||||
# One of the macro's is invalid.
|
||||
self.logger.warning(
|
||||
"Host %s: macro %s is not valid. Failing back to default.",
|
||||
self.name,
|
||||
macro,
|
||||
)
|
||||
return False
|
||||
return description
|
||||
|
||||
def _set_defaults(self):
|
||||
"""
|
||||
Sets the default descriptions for the host.
|
||||
"""
|
||||
self.defaults = {
|
||||
"static": "Host added by NetBox sync script.",
|
||||
"dynamic": (
|
||||
"Host by owner {owner} added by NetBox sync script on {datetime}."
|
||||
),
|
||||
}
|
||||
|
||||
def _custom_override(self):
|
||||
"""
|
||||
Checks if the description is mentioned in the config context.
|
||||
"""
|
||||
zabbix_config = self.netbox_object.config_context.get("zabbix")
|
||||
if zabbix_config and "description" in zabbix_config:
|
||||
return zabbix_config["description"]
|
||||
return False
|
||||
|
||||
def generate(self):
|
||||
"""
|
||||
Generates the description for the host.
|
||||
"""
|
||||
# First: check if an override is present.
|
||||
config_context_description = self._custom_override()
|
||||
if config_context_description is not False:
|
||||
resolved = self._resolve_macros(config_context_description)
|
||||
return resolved if resolved else self.defaults["static"]
|
||||
# Override is not present: continue with config description
|
||||
description = ""
|
||||
if "description" not in self.configuration:
|
||||
# If no description config is provided, use default static
|
||||
return self.defaults["static"]
|
||||
if not self.configuration["description"]:
|
||||
# The configuration is set to False, meaning an empty description
|
||||
return description
|
||||
if self.configuration["description"] in self.defaults:
|
||||
# The description is one of the default options
|
||||
description = self.defaults[self.configuration["description"]]
|
||||
else:
|
||||
# The description is set to a custom description
|
||||
description = self.configuration["description"]
|
||||
# Resolve the macro's in the description
|
||||
final_description = self._resolve_macros(description)
|
||||
if final_description:
|
||||
return final_description
|
||||
return self.defaults["static"]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Module for all hostgroup related code"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from netbox_zabbix_sync.modules.exceptions import HostgroupError
|
||||
from netbox_zabbix_sync.modules.tools import build_path, cf_to_string
|
||||
|
||||
|
||||
class Hostgroup:
|
||||
"""Hostgroup class for devices and VM's
|
||||
Takes type (vm or dev) and NB object"""
|
||||
|
||||
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__)
|
||||
if obj_type not in ("vm", "dev"):
|
||||
msg = f"Unable to create hostgroup with type {type}"
|
||||
self.logger.error(msg)
|
||||
raise HostgroupError(msg)
|
||||
self.type = str(obj_type)
|
||||
self.nb = nb_obj
|
||||
self.name = self.nb.name
|
||||
self.nb_version = version
|
||||
# Used for nested data objects
|
||||
self.set_nesting(
|
||||
nested_sitegroup_flag, nested_region_flag, nb_groups, nb_regions
|
||||
)
|
||||
self._set_format_options()
|
||||
|
||||
def __str__(self):
|
||||
return f"Hostgroup for {self.type} {self.name}"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def _set_format_options(self):
|
||||
"""
|
||||
Set all available variables
|
||||
for hostgroup generation
|
||||
"""
|
||||
format_options = {}
|
||||
# Set variables for both type of devices
|
||||
if self.type in ("vm", "dev"):
|
||||
# Role fix for NetBox <=3
|
||||
role = None
|
||||
if self.nb_version.startswith(("2", "3")) and self.type == "dev":
|
||||
role = self.nb.device_role.name if self.nb.device_role else None
|
||||
else:
|
||||
role = self.nb.role.name if self.nb.role else None
|
||||
# Add default formatting options
|
||||
# Check if a site is configured. A site is optional for VMs
|
||||
format_options["region"] = None
|
||||
format_options["site_group"] = None
|
||||
if self.nb.site:
|
||||
if self.nb.site.region:
|
||||
format_options["region"] = self.generate_parents(
|
||||
"region", str(self.nb.site.region)
|
||||
)
|
||||
if self.nb.site.group:
|
||||
format_options["site_group"] = self.generate_parents(
|
||||
"site_group", str(self.nb.site.group)
|
||||
)
|
||||
format_options["role"] = role
|
||||
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_group"] = (
|
||||
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
|
||||
if self.type == "dev":
|
||||
format_options["manufacturer"] = self.nb.device_type.manufacturer.name
|
||||
format_options["location"] = (
|
||||
str(self.nb.location) if self.nb.location else None
|
||||
)
|
||||
format_options["rack"] = self.nb.rack.name if self.nb.rack else None
|
||||
# Variables only applicable for VM's such as clusters
|
||||
if self.type == "vm" and self.nb.cluster:
|
||||
format_options["cluster"] = self.nb.cluster.name
|
||||
format_options["cluster_type"] = self.nb.cluster.type.name
|
||||
self.format_options = format_options
|
||||
self.logger.debug(
|
||||
"Host %s: Resolved properties for use in hostgroups: %s",
|
||||
self.name,
|
||||
self.format_options,
|
||||
)
|
||||
|
||||
def set_nesting(
|
||||
self, nested_sitegroup_flag, nested_region_flag, nb_groups, nb_regions
|
||||
):
|
||||
"""Set nesting options for this Hostgroup"""
|
||||
self.nested_objects = {
|
||||
"site_group": {"flag": nested_sitegroup_flag, "data": nb_groups},
|
||||
"region": {"flag": nested_region_flag, "data": nb_regions},
|
||||
}
|
||||
|
||||
def generate(self, hg_format):
|
||||
"""Generate hostgroup based on a provided format"""
|
||||
# Split all given names
|
||||
hg_output = []
|
||||
hg_items = hg_format.split("/")
|
||||
for hg_item in hg_items:
|
||||
# Check if requested data is available as option for this host
|
||||
if hg_item not in self.format_options:
|
||||
# If the string is between quotes, use it as a literal in the hostgroup name
|
||||
minimum_length = 2
|
||||
if (
|
||||
len(hg_item) > minimum_length
|
||||
and hg_item[0] == hg_item[-1]
|
||||
and hg_item[0] in ("'", '"')
|
||||
):
|
||||
hg_output.append(hg_item[1:-1])
|
||||
else:
|
||||
# Check if a custom field exists with this name
|
||||
cf_data = self.custom_field_lookup(hg_item)
|
||||
# CF does not exist
|
||||
if not cf_data["result"]:
|
||||
msg = (
|
||||
f"Unable to generate hostgroup for host {self.name}. "
|
||||
f"Item type {hg_item} not supported."
|
||||
)
|
||||
self.logger.error(msg)
|
||||
raise HostgroupError(msg)
|
||||
# CF data is populated
|
||||
if cf_data["cf"]:
|
||||
hg_output.append(cf_to_string(cf_data["cf"]))
|
||||
continue
|
||||
# Check if there is a value associated to the variable.
|
||||
# For instance, if a device has no location, do not use it with hostgroup calculation
|
||||
hostgroup_value = self.format_options[hg_item]
|
||||
if hostgroup_value:
|
||||
hg_output.append(hostgroup_value)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Host %s: Used field '%s' has no value.", self.name, hg_item
|
||||
)
|
||||
# Check if the hostgroup is populated with at least one item.
|
||||
if bool(hg_output):
|
||||
return "/".join(hg_output)
|
||||
msg = (
|
||||
f"Host {self.name}: Generating hostgroup name for '{hg_format}' failed. "
|
||||
f"This is most likely due to fields that have no value."
|
||||
)
|
||||
self.logger.warning(msg)
|
||||
return None
|
||||
|
||||
def custom_field_lookup(self, hg_category):
|
||||
"""
|
||||
Checks if a valid custom field is present in NetBox.
|
||||
INPUT: Custom field name
|
||||
OUTPUT: dictionary with 'result' and 'cf' keys.
|
||||
"""
|
||||
# Check if the custom field exists
|
||||
if hg_category not in self.nb.custom_fields:
|
||||
return {"result": False, "cf": None}
|
||||
# Checks if the custom field has been populated
|
||||
if not bool(self.nb.custom_fields[hg_category]):
|
||||
return {"result": True, "cf": None}
|
||||
# Custom field exists and is populated
|
||||
return {"result": True, "cf": self.nb.custom_fields[hg_category]}
|
||||
|
||||
def generate_parents(self, nest_type, child_object):
|
||||
"""
|
||||
Generates parent objects to implement nested regions / nested site groups
|
||||
INPUT: nest_type to set which type of nesting is going to be processed
|
||||
child_object: the name of the child object (for instance the last NB region)
|
||||
OUTPUT: STRING - Either the single child name or child and parents.
|
||||
"""
|
||||
# Check if this type of nesting is supported.
|
||||
if nest_type not in self.nested_objects:
|
||||
return child_object
|
||||
# If the nested flag is True, perform parent calculation
|
||||
if self.nested_objects[nest_type]["flag"]:
|
||||
final_nested_object = build_path(
|
||||
child_object, self.nested_objects[nest_type]["data"]
|
||||
)
|
||||
return "/".join(final_nested_object)
|
||||
# Nesting is not allowed for this object. Return child_object
|
||||
return child_object
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
All of the Zabbix interface related configuration
|
||||
"""
|
||||
|
||||
from netbox_zabbix_sync.modules.exceptions import InterfaceConfigError
|
||||
|
||||
|
||||
class ZabbixInterface:
|
||||
"""Class that represents a Zabbix interface."""
|
||||
|
||||
def __init__(self, context, ip):
|
||||
self.context = context
|
||||
self.ip = ip
|
||||
self.skelet = {"main": "1", "useip": "1", "dns": "", "ip": self.ip}
|
||||
self.interface = self.skelet
|
||||
|
||||
def _set_default_port(self):
|
||||
"""Sets default TCP / UDP port for different interface types"""
|
||||
interface_mapping = {1: 10050, 2: 161, 3: 623, 4: 12345}
|
||||
# Check if interface type is listed in mapper.
|
||||
if self.interface["type"] not in interface_mapping:
|
||||
return False
|
||||
# Set default port to interface
|
||||
self.interface["port"] = str(interface_mapping[self.interface["type"]])
|
||||
return True
|
||||
|
||||
def get_context(self):
|
||||
"""check if NetBox custom context has been defined."""
|
||||
if "zabbix" in self.context:
|
||||
zabbix = self.context["zabbix"]
|
||||
if "interface_type" in zabbix:
|
||||
self.interface["type"] = zabbix["interface_type"]
|
||||
if "interface_port" not in zabbix:
|
||||
self._set_default_port()
|
||||
return True
|
||||
self.interface["port"] = zabbix["interface_port"]
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
def set_snmp(self):
|
||||
"""Check if interface is type SNMP"""
|
||||
snmp_interface_type = 2
|
||||
if self.interface["type"] == snmp_interface_type:
|
||||
# Checks if SNMP settings are defined in NetBox
|
||||
if "snmp" in self.context["zabbix"]:
|
||||
snmp = self.context["zabbix"]["snmp"]
|
||||
details: dict[str, str] = {}
|
||||
self.interface["details"] = details
|
||||
# Checks if bulk config has been defined
|
||||
if "bulk" in snmp:
|
||||
details["bulk"] = str(snmp.pop("bulk"))
|
||||
else:
|
||||
# Fallback to bulk enabled if not specified
|
||||
details["bulk"] = "1"
|
||||
# SNMP Version config is required in NetBox config context
|
||||
if snmp.get("version"):
|
||||
details["version"] = str(snmp.pop("version"))
|
||||
else:
|
||||
e = "SNMP version option is not defined."
|
||||
raise InterfaceConfigError(e)
|
||||
# If version 1 or 2 is used, get community string
|
||||
if details["version"] in ["1", "2"]:
|
||||
if "community" in snmp:
|
||||
# Set SNMP community to confix context value
|
||||
community = snmp["community"]
|
||||
else:
|
||||
# Set SNMP community to default
|
||||
community = "{$SNMP_COMMUNITY}"
|
||||
details["community"] = str(community)
|
||||
# If version 3 has been used, get all
|
||||
# SNMPv3 NetBox related configs
|
||||
elif details["version"] == "3":
|
||||
items = [
|
||||
"securityname",
|
||||
"securitylevel",
|
||||
"authpassphrase",
|
||||
"privpassphrase",
|
||||
"authprotocol",
|
||||
"privprotocol",
|
||||
"contextname",
|
||||
]
|
||||
for key, item in snmp.items():
|
||||
if key in items:
|
||||
details[key] = str(item)
|
||||
else:
|
||||
e = "Unsupported SNMP version."
|
||||
raise InterfaceConfigError(e)
|
||||
else:
|
||||
e = "Interface type SNMP but no parameters provided."
|
||||
raise InterfaceConfigError(e)
|
||||
else:
|
||||
e = "Interface type is not SNMP, unable to set SNMP details"
|
||||
raise InterfaceConfigError(e)
|
||||
|
||||
def set_default_snmp(self):
|
||||
"""Set default config to SNMPv2, port 161 and community macro."""
|
||||
self.interface = self.skelet
|
||||
self.interface["type"] = "2"
|
||||
self.interface["port"] = "161"
|
||||
self.interface["details"] = {
|
||||
"version": "2",
|
||||
"community": "{$SNMP_COMMUNITY}",
|
||||
"bulk": "1",
|
||||
}
|
||||
|
||||
def set_default_agent(self):
|
||||
"""Sets interface to Zabbix agent defaults"""
|
||||
self.interface["type"] = "1"
|
||||
self.interface["port"] = "10050"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
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()
|
||||
# Logfile in the project root
|
||||
project_root = path.dirname(path.dirname(path.realpath(__file__)))
|
||||
logfile_path = path.join(project_root, "sync.log")
|
||||
lgfile = logging.FileHandler(logfile_path)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Module for parsing configuration from the top level config.py file
|
||||
"""
|
||||
|
||||
from importlib import util
|
||||
from logging import getLogger
|
||||
from os import environ, path
|
||||
from pathlib import Path
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# PLEASE NOTE: This is a sample config file. Please do NOT make any edits in this file!
|
||||
# You should create your own config.py and it will overwrite the default config.
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"templates_config_context": False,
|
||||
"templates_config_context_overrule": False,
|
||||
"template_cf": "zabbix_template",
|
||||
"device_cf": "zabbix_hostid",
|
||||
"proxy_cf": False,
|
||||
"proxy_group_cf": False,
|
||||
"clustering": False,
|
||||
"create_hostgroups": True,
|
||||
"create_journal": False,
|
||||
"sync_vms": False,
|
||||
"vm_hostgroup_format": "cluster_type/cluster/role",
|
||||
"full_proxy_sync": False,
|
||||
"zabbix_device_removal": ["Decommissioning", "Inventory"],
|
||||
"zabbix_device_disable": ["Offline", "Planned", "Staged", "Failed"],
|
||||
"hostgroup_format": "site/manufacturer/role",
|
||||
"traverse_regions": False,
|
||||
"traverse_site_groups": False,
|
||||
"nb_device_filter": {"name__n": "null"},
|
||||
"nb_vm_filter": {"name__n": "null"},
|
||||
"inventory_mode": "disabled",
|
||||
"inventory_sync": False,
|
||||
"extended_site_properties": False,
|
||||
"device_inventory_map": {
|
||||
"asset_tag": "asset_tag",
|
||||
"virtual_chassis/name": "chassis",
|
||||
"status/label": "deployment_status",
|
||||
"location/name": "location",
|
||||
"latitude": "location_lat",
|
||||
"longitude": "location_lon",
|
||||
"comments": "notes",
|
||||
"name": "name",
|
||||
"rack/name": "site_rack",
|
||||
"serial": "serialno_a",
|
||||
"device_type/model": "type",
|
||||
"device_type/manufacturer/name": "vendor",
|
||||
"oob_ip/address": "oob_ip",
|
||||
},
|
||||
"vm_inventory_map": {
|
||||
"status/label": "deployment_status",
|
||||
"comments": "notes",
|
||||
"name": "name",
|
||||
},
|
||||
"usermacro_sync": False,
|
||||
"device_usermacro_map": {
|
||||
"serial": "{$HW_SERIAL}",
|
||||
"role/name": "{$DEV_ROLE}",
|
||||
"url": "{$NB_URL}",
|
||||
"id": "{$NB_ID}",
|
||||
},
|
||||
"vm_usermacro_map": {
|
||||
"memory": "{$TOTAL_MEMORY}",
|
||||
"role/name": "{$DEV_ROLE}",
|
||||
"url": "{$NB_URL}",
|
||||
"id": "{$NB_ID}",
|
||||
},
|
||||
"tag_sync": False,
|
||||
"tag_lower": True,
|
||||
"tag_name": "NetBox",
|
||||
"tag_value": "name",
|
||||
"device_tag_map": {
|
||||
"site/name": "site",
|
||||
"rack/name": "rack",
|
||||
"platform/name": "target",
|
||||
},
|
||||
"vm_tag_map": {
|
||||
"site/name": "site",
|
||||
"cluster/name": "cluster",
|
||||
"platform/name": "target",
|
||||
},
|
||||
"description_dt_format": "%Y-%m-%d %H:%M:%S",
|
||||
"description": "static",
|
||||
}
|
||||
|
||||
|
||||
def load_config(config_file=None):
|
||||
"""Returns combined config from all sources"""
|
||||
# Overwrite default config with config file.
|
||||
# Default config file is config.py but can be overridden by providing a different file path.
|
||||
conf = load_config_file(
|
||||
config_default=DEFAULT_CONFIG,
|
||||
config_file=config_file if config_file else "config.py",
|
||||
)
|
||||
# Overwrite default config and config.py with environment variables
|
||||
for key in conf:
|
||||
value_setting = load_env_variable(key)
|
||||
if value_setting is not None:
|
||||
conf[key] = value_setting
|
||||
return conf
|
||||
|
||||
|
||||
def load_env_variable(config_environvar):
|
||||
"""Returns config from environment variable"""
|
||||
prefix = "NBZX_"
|
||||
config_environvar = prefix + config_environvar.upper()
|
||||
if config_environvar in environ:
|
||||
return environ[config_environvar]
|
||||
return None
|
||||
|
||||
|
||||
def load_config_file(config_default, config_file="config.py"):
|
||||
"""Returns config from config.py file"""
|
||||
|
||||
# Find the script path and config file next to it.
|
||||
script_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__))))
|
||||
config_path = Path(path.join(script_dir, config_file))
|
||||
|
||||
# If the script directory is not found, try the current working directory
|
||||
if not config_path.exists():
|
||||
config_path = Path(config_file)
|
||||
|
||||
# If both checks fail then fallback to the default config
|
||||
if not config_path.exists():
|
||||
return config_default
|
||||
|
||||
dconf = config_default.copy()
|
||||
# Dynamically import the config module
|
||||
spec = util.spec_from_file_location("config", config_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot load config from {config_path}")
|
||||
config_module = util.module_from_spec(spec)
|
||||
spec.loader.exec_module(config_module)
|
||||
# Update DEFAULT_CONFIG with variables from the config module
|
||||
for key in dconf:
|
||||
if hasattr(config_module, key):
|
||||
dconf[key] = getattr(config_module, key)
|
||||
return dconf
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
All of the Zabbix Usermacro related configuration
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from netbox_zabbix_sync.modules.tools import field_mapper, remove_duplicates
|
||||
|
||||
|
||||
class ZabbixTags:
|
||||
"""Class that represents a Zabbix interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nb,
|
||||
tag_map,
|
||||
tag_sync=False,
|
||||
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
|
||||
"""
|
||||
max_tag_name_length = 256
|
||||
return (
|
||||
tag_name
|
||||
and isinstance(tag_name, str)
|
||||
and len(tag_name) <= max_tag_name_length
|
||||
)
|
||||
|
||||
def validate_value(self, tag_value):
|
||||
"""
|
||||
Validates tag value
|
||||
"""
|
||||
max_tag_value_length = 256
|
||||
return (
|
||||
tag_value
|
||||
and isinstance(tag_value, str)
|
||||
and len(tag_value) <= max_tag_value_length
|
||||
)
|
||||
|
||||
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("Tag '%s' is not a valid tag name, skipping.", tag_name)
|
||||
return False
|
||||
|
||||
if self.validate_value(tag_value):
|
||||
if self.lower:
|
||||
tag["value"] = tag_value.lower()
|
||||
else:
|
||||
tag["value"] = tag_value
|
||||
else:
|
||||
self.logger.info(
|
||||
"Tag '%s' has an invalid value: '%s', skipping.", tag_name, tag_value
|
||||
)
|
||||
return False
|
||||
return tag
|
||||
|
||||
def generate(self):
|
||||
"""
|
||||
Generate full set of Usermacros
|
||||
"""
|
||||
tags = []
|
||||
# Parse the field mapper for tags
|
||||
if self.tag_map:
|
||||
self.logger.debug("Host %s: Starting tag mapper.", self.nb.name)
|
||||
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
|
||||
and isinstance(self.tag_value, str)
|
||||
and 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)
|
||||
|
||||
tags = remove_duplicates(tags, sortkey="tag")
|
||||
self.logger.debug("Host %s: Resolved tags: %s", self.name, tags)
|
||||
return tags
|
||||
@@ -0,0 +1,257 @@
|
||||
"""A collection of tools used by several classes"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast, overload
|
||||
|
||||
from netbox_zabbix_sync.modules.exceptions import HostgroupError
|
||||
|
||||
|
||||
def convert_recordset(recordset):
|
||||
"""Converts netbox RedcordSet to list of dicts."""
|
||||
recordlist = []
|
||||
for record in recordset:
|
||||
recordlist.append(record.__dict__)
|
||||
return recordlist
|
||||
|
||||
|
||||
def build_path(endpoint, list_of_dicts):
|
||||
"""
|
||||
Builds a path list of related parent/child items.
|
||||
This can be used to generate a joinable list to
|
||||
be used in hostgroups.
|
||||
"""
|
||||
item_path = []
|
||||
itemlist = [i for i in list_of_dicts if i["name"] == endpoint]
|
||||
item = itemlist[0] if len(itemlist) == 1 else None
|
||||
if item is None:
|
||||
return []
|
||||
item_path.append(item["name"])
|
||||
while item["_depth"] > 0:
|
||||
itemlist = [i for i in list_of_dicts if i["name"] == str(item["parent"])]
|
||||
item = itemlist[0] if len(itemlist) == 1 else None
|
||||
if item is None:
|
||||
break
|
||||
item_path.append(item["name"])
|
||||
item_path.reverse()
|
||||
return item_path
|
||||
|
||||
|
||||
def proxy_prepper(proxy_list, proxy_group_list):
|
||||
"""
|
||||
Function that takes 2 lists and converts them using a
|
||||
standardized format for further processing.
|
||||
"""
|
||||
output = []
|
||||
for proxy in proxy_list:
|
||||
proxy["type"] = "proxy"
|
||||
proxy["id"] = proxy["proxyid"]
|
||||
proxy["idtype"] = "proxyid"
|
||||
proxy["monitored_by"] = 1
|
||||
output.append(proxy)
|
||||
for group in proxy_group_list:
|
||||
group["type"] = "proxy_group"
|
||||
group["id"] = group["proxy_groupid"]
|
||||
group["idtype"] = "proxy_groupid"
|
||||
group["monitored_by"] = 2
|
||||
output.append(group)
|
||||
return output
|
||||
|
||||
|
||||
def cf_to_string(cf, key="name", logger=None):
|
||||
"""
|
||||
Converts a dict custom fields to string
|
||||
"""
|
||||
if isinstance(cf, dict):
|
||||
if key in cf:
|
||||
return cf[key]
|
||||
if logger:
|
||||
logger.error(
|
||||
"Conversion of custom field failed, '%s' not found in cf dict.", key
|
||||
)
|
||||
return None
|
||||
return cf
|
||||
|
||||
|
||||
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.info(
|
||||
"Host %s: NetBox lookup for '%s' returned an empty value.",
|
||||
host,
|
||||
nb_field,
|
||||
)
|
||||
data[zbx_field] = ""
|
||||
else:
|
||||
# Value is not a string or numeral, probably not what the user expected.
|
||||
logger.info(
|
||||
"Host %s: Lookup for '%s' returned an unexpected type: it will be skipped.",
|
||||
host,
|
||||
nb_field,
|
||||
)
|
||||
logger.debug(
|
||||
"Host %s: Field mapping complete. Mapped %s field(s).",
|
||||
host,
|
||||
len(list(filter(None, data.values()))),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@overload
|
||||
def remove_duplicates(
|
||||
input_list: list[dict[Any, Any]],
|
||||
sortkey: str | Callable[[dict[str, Any]], str] | None = None,
|
||||
): ...
|
||||
|
||||
|
||||
@overload
|
||||
def remove_duplicates(
|
||||
input_list: dict[Any, Any],
|
||||
sortkey: str | Callable[[dict[str, Any]], str] | None = None,
|
||||
):
|
||||
"""
|
||||
deprecated: input_list as dict is deprecated, use list of dicts instead
|
||||
"""
|
||||
|
||||
|
||||
def remove_duplicates(
|
||||
input_list: list[dict[Any, Any]] | dict[Any, Any],
|
||||
sortkey: str | Callable[[dict[str, Any]], str] | None = None,
|
||||
):
|
||||
"""
|
||||
Removes duplicate entries from a list and sorts the list
|
||||
|
||||
sortkey: Optional; key to sort the list on. Can be a string or a callable function.
|
||||
"""
|
||||
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])
|
||||
|
||||
elif sortkey and callable(sortkey):
|
||||
output_list.sort(key=cast(Any, sortkey))
|
||||
|
||||
return output_list
|
||||
|
||||
|
||||
def verify_hg_format(
|
||||
hg_format, device_cfs=None, vm_cfs=None, hg_type="dev", logger=None
|
||||
):
|
||||
"""
|
||||
Verifies hostgroup field format
|
||||
"""
|
||||
if not device_cfs:
|
||||
device_cfs = []
|
||||
if not vm_cfs:
|
||||
vm_cfs = []
|
||||
allowed_objects = {
|
||||
"dev": [
|
||||
"location",
|
||||
"rack",
|
||||
"role",
|
||||
"manufacturer",
|
||||
"region",
|
||||
"site",
|
||||
"site_group",
|
||||
"tenant",
|
||||
"tenant_group",
|
||||
"platform",
|
||||
"cluster",
|
||||
],
|
||||
"vm": [
|
||||
"cluster_type",
|
||||
"role",
|
||||
"manufacturer",
|
||||
"region",
|
||||
"site",
|
||||
"site_group",
|
||||
"tenant",
|
||||
"tenant_group",
|
||||
"cluster",
|
||||
"device",
|
||||
"platform",
|
||||
],
|
||||
"cfs": {"dev": [], "vm": []},
|
||||
}
|
||||
for cf in device_cfs:
|
||||
allowed_objects["cfs"]["dev"].append(cf.name) # type: ignore[index]
|
||||
for cf in vm_cfs:
|
||||
allowed_objects["cfs"]["vm"].append(cf.name) # type: ignore[index]
|
||||
hg_objects = []
|
||||
if isinstance(hg_format, list):
|
||||
for f in hg_format:
|
||||
hg_objects = hg_objects + f.split("/")
|
||||
else:
|
||||
hg_objects = hg_format.split("/")
|
||||
hg_objects = sorted(set(hg_objects))
|
||||
for hg_object in hg_objects:
|
||||
if (
|
||||
hg_object not in allowed_objects[hg_type]
|
||||
and hg_object not in allowed_objects["cfs"][hg_type] # type: ignore[index]
|
||||
and not hg_object.startswith(('"', "'"))
|
||||
):
|
||||
e = (
|
||||
f"Hostgroup item {hg_object} is not valid. Make sure you"
|
||||
" use valid items and separate them with '/'."
|
||||
)
|
||||
if logger:
|
||||
logger.warning(e)
|
||||
raise HostgroupError(e)
|
||||
|
||||
|
||||
def sanatize_log_output(data):
|
||||
"""
|
||||
Used for the update function to Zabbix which
|
||||
shows the data that its using to update the host.
|
||||
Removes any sensitive data from the input.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
sanitized_data = data.copy()
|
||||
# Check if there are any sensitive macros defined in the data
|
||||
if "macros" in data:
|
||||
for macro in sanitized_data["macros"]:
|
||||
# Check if macro is secret type
|
||||
if not (macro["type"] == str(1) or macro["type"] == 1):
|
||||
continue
|
||||
macro["value"] = "********"
|
||||
# Check for interface data
|
||||
if "interfaceid" in data:
|
||||
# Interface ID is a value which is most likely not helpful
|
||||
# in logging output or for troubleshooting.
|
||||
del sanitized_data["interfaceid"]
|
||||
# InterfaceID also hints that this is a interface update.
|
||||
# A check is required if there are no macro's used for SNMP security parameters.
|
||||
if "details" not in data:
|
||||
return sanitized_data
|
||||
for key, detail in sanitized_data["details"].items():
|
||||
# If the detail is a secret, we don't want to log it.
|
||||
if key in ("authpassphrase", "privpassphrase", "securityname", "community"):
|
||||
# Check if a macro is used.
|
||||
# If so then logging the output is not a security issue.
|
||||
if detail.startswith("{$") and detail.endswith("}"):
|
||||
continue
|
||||
# A macro is not used, so we sanitize the value.
|
||||
sanitized_data["details"][key] = "********"
|
||||
return sanitized_data
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
All of the Zabbix Usermacro related configuration
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from re import match
|
||||
|
||||
from netbox_zabbix_sync.modules.tools import field_mapper, sanatize_log_output
|
||||
|
||||
|
||||
class ZabbixUsermacros:
|
||||
"""Class that represents Zabbix usermacros."""
|
||||
|
||||
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 "value" not in macro_properties:
|
||||
self.logger.info(
|
||||
"Host %s: Usermacro %s has no value in Netbox, skipping.",
|
||||
self.name,
|
||||
macro_name,
|
||||
)
|
||||
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.info(
|
||||
"Host %s: Usermacro %s has no value, skipping.",
|
||||
self.name,
|
||||
macro_name,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Host %s: Usermacro %s is not a valid usermacro name, skipping.",
|
||||
self.name,
|
||||
macro_name,
|
||||
)
|
||||
return False
|
||||
return macro
|
||||
|
||||
def generate(self):
|
||||
"""
|
||||
Generate full set of Usermacros
|
||||
"""
|
||||
macros = []
|
||||
data = {}
|
||||
# Parse the field mapper for usermacros
|
||||
if self.usermacro_map:
|
||||
self.logger.debug("Host %s: Starting usermacro mapper.", self.nb.name)
|
||||
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)
|
||||
data = {"macros": macros}
|
||||
self.logger.debug(
|
||||
"Host %s: Resolved macros: %s", self.name, sanatize_log_output(data)
|
||||
)
|
||||
return macros
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Module that hosts all functions for virtual machine processing"""
|
||||
|
||||
from netbox_zabbix_sync.modules.device import PhysicalDevice
|
||||
from netbox_zabbix_sync.modules.exceptions import (
|
||||
InterfaceConfigError,
|
||||
SyncInventoryError,
|
||||
TemplateError,
|
||||
)
|
||||
from netbox_zabbix_sync.modules.interface import ZabbixInterface
|
||||
|
||||
|
||||
class VirtualMachine(PhysicalDevice):
|
||||
"""Model for virtual machines"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.hostgroup = None
|
||||
self.zbx_template_names = None
|
||||
self.hostgroup_type = "vm"
|
||||
|
||||
def _inventory_map(self):
|
||||
"""use VM inventory maps"""
|
||||
return self.config["vm_inventory_map"]
|
||||
|
||||
def _usermacro_map(self):
|
||||
"""use VM usermacro maps"""
|
||||
return self.config["vm_usermacro_map"]
|
||||
|
||||
def _tag_map(self):
|
||||
"""use VM tag maps"""
|
||||
return self.config["vm_tag_map"]
|
||||
|
||||
def set_vm_template(self):
|
||||
"""Set Template for VMs. Overwrites default class
|
||||
to skip a lookup of custom fields."""
|
||||
# Gather templates ONLY from the device specific context
|
||||
try:
|
||||
self.zbx_template_names = self.get_templates_context()
|
||||
except TemplateError as e:
|
||||
self.logger.warning(e)
|
||||
return True
|
||||
|
||||
def set_interface_details(self):
|
||||
"""
|
||||
Overwrites device function to select an agent interface type by default
|
||||
Agent type interfaces are more likely to be used with VMs then SNMP
|
||||
"""
|
||||
zabbix_snmp_interface_type = 2
|
||||
try:
|
||||
# Initiate interface class
|
||||
interface = ZabbixInterface(self.nb.config_context, self.ip)
|
||||
# Check if NetBox has device context.
|
||||
# If not fall back to old config.
|
||||
if interface.get_context():
|
||||
# If device is SNMP type, add aditional information.
|
||||
if interface.interface["type"] == zabbix_snmp_interface_type:
|
||||
interface.set_snmp()
|
||||
else:
|
||||
interface.set_default_agent()
|
||||
return [interface.interface]
|
||||
except InterfaceConfigError as e:
|
||||
message = f"{self.name}: {e}"
|
||||
self.logger.warning(message)
|
||||
raise SyncInventoryError(message) from e
|
||||
@@ -0,0 +1,89 @@
|
||||
[project]
|
||||
name = "netbox-zabbix-sync"
|
||||
description = "Python script to synchronize Netbox devices to Zabbix."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["igraph>=1.0.0", "pynetbox>=7.6.1", "zabbix-utils>=2.0.4"]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/TheNetworkGuy/netbox-zabbix-sync"
|
||||
"Issues" = "https://github.com/TheNetworkGuy/netbox-zabbix-sync/issues"
|
||||
|
||||
[project.scripts]
|
||||
netbox-zabbix-sync = "netbox_zabbix_sync.modules.cli:parse_cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=64", "setuptools_scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["netbox_zabbix_sync*"]
|
||||
|
||||
[tool.setuptools_scm]
|
||||
version_file = "netbox_zabbix_sync/_version.py"
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = [
|
||||
# Ignore line-length
|
||||
"E501",
|
||||
# Ignore too many arguments
|
||||
"PLR0913",
|
||||
# Ignore too many statements
|
||||
"PLR0915",
|
||||
# Ignore too many branches
|
||||
"PLR0912",
|
||||
]
|
||||
|
||||
select = [
|
||||
# commented-out-code
|
||||
"ERA001",
|
||||
# flake8-bandit
|
||||
"S",
|
||||
# flake8-logging-format
|
||||
"G",
|
||||
# flake8-print
|
||||
"T20",
|
||||
# pep8-naming
|
||||
"N",
|
||||
# Pyflakes
|
||||
"F",
|
||||
# pycodestyle
|
||||
"E",
|
||||
# isort
|
||||
"I",
|
||||
# pep8-naming
|
||||
"N",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
# flake8-async
|
||||
"ASYNC",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# flake8-executable
|
||||
"EXE",
|
||||
# flake8-pie
|
||||
"PIE",
|
||||
# flake8-pyi
|
||||
"PYI",
|
||||
# flake8-simplify
|
||||
"SIM",
|
||||
# pylint
|
||||
"PL",
|
||||
# Ruff-specific rules
|
||||
"RUF",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
# Ignore use of assert
|
||||
"S101",
|
||||
# Ignore hardcoded passwords / tokens
|
||||
"S106",
|
||||
]
|
||||
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "ruff>=0.14.14", "ty>=0.0.14"]
|
||||
+22
-2
@@ -1,2 +1,22 @@
|
||||
pynetbox
|
||||
pyzabbix
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv export --format requirements-txt --no-hashes --no-dev
|
||||
certifi==2026.1.4
|
||||
# via requests
|
||||
charset-normalizer==3.4.4
|
||||
# via requests
|
||||
idna==3.11
|
||||
# via requests
|
||||
igraph==1.0.0
|
||||
# via netbox-zabbix-sync
|
||||
packaging==26.0
|
||||
# via pynetbox
|
||||
pynetbox==7.6.1
|
||||
# via netbox-zabbix-sync
|
||||
requests==2.32.5
|
||||
# via pynetbox
|
||||
texttable==1.7.0
|
||||
# via igraph
|
||||
urllib3==2.6.3
|
||||
# via requests
|
||||
zabbix-utils==2.0.4
|
||||
# via netbox-zabbix-sync
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for configuration parsing in the modules.config module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netbox_zabbix_sync.modules.settings import (
|
||||
DEFAULT_CONFIG,
|
||||
load_config,
|
||||
load_config_file,
|
||||
load_env_variable,
|
||||
)
|
||||
|
||||
|
||||
def test_load_config_defaults():
|
||||
"""Test that load_config returns default values when no config file or env vars are present"""
|
||||
with (
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_config_file",
|
||||
return_value=DEFAULT_CONFIG.copy(),
|
||||
),
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_env_variable", return_value=None
|
||||
),
|
||||
):
|
||||
config = load_config()
|
||||
assert config == DEFAULT_CONFIG
|
||||
assert config["templates_config_context"] is False
|
||||
assert config["create_hostgroups"] is True
|
||||
|
||||
|
||||
def test_load_config_file():
|
||||
"""Test that load_config properly loads values from config file"""
|
||||
mock_config = DEFAULT_CONFIG.copy()
|
||||
mock_config["templates_config_context"] = True
|
||||
mock_config["sync_vms"] = True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_config_file",
|
||||
return_value=mock_config,
|
||||
),
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_env_variable", return_value=None
|
||||
),
|
||||
):
|
||||
config = load_config()
|
||||
assert config["templates_config_context"] is True
|
||||
assert config["sync_vms"] is True
|
||||
# Unchanged values should remain as defaults
|
||||
assert config["create_journal"] is False
|
||||
|
||||
|
||||
def test_load_env_variables():
|
||||
"""Test that load_config properly loads values from environment variables"""
|
||||
|
||||
# Mock env variable loading to return values for specific keys
|
||||
def mock_load_env(key):
|
||||
if key == "sync_vms":
|
||||
return True
|
||||
if key == "create_journal":
|
||||
return True
|
||||
return None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_config_file",
|
||||
return_value=DEFAULT_CONFIG.copy(),
|
||||
),
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_env_variable",
|
||||
side_effect=mock_load_env,
|
||||
),
|
||||
):
|
||||
config = load_config()
|
||||
assert config["sync_vms"] is True
|
||||
assert config["create_journal"] is True
|
||||
# Unchanged values should remain as defaults
|
||||
assert config["templates_config_context"] is False
|
||||
|
||||
|
||||
def test_env_vars_override_config_file():
|
||||
"""Test that environment variables override values from config file"""
|
||||
mock_config = DEFAULT_CONFIG.copy()
|
||||
mock_config["templates_config_context"] = True
|
||||
mock_config["sync_vms"] = False
|
||||
|
||||
# Mock env variable that will override the config file value
|
||||
def mock_load_env(key):
|
||||
if key == "sync_vms":
|
||||
return True
|
||||
return None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_config_file",
|
||||
return_value=mock_config,
|
||||
),
|
||||
patch(
|
||||
"netbox_zabbix_sync.modules.settings.load_env_variable",
|
||||
side_effect=mock_load_env,
|
||||
),
|
||||
):
|
||||
config = load_config()
|
||||
# This should be overridden by the env var
|
||||
assert config["sync_vms"] is True
|
||||
# This should remain from the config file
|
||||
assert config["templates_config_context"] is True
|
||||
|
||||
|
||||
def test_load_config_file_function():
|
||||
"""Test the load_config_file function directly"""
|
||||
# Test when the file exists
|
||||
with (
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("importlib.util.spec_from_file_location") as mock_spec,
|
||||
):
|
||||
# Setup the mock module with attributes
|
||||
mock_module = MagicMock()
|
||||
mock_module.templates_config_context = True
|
||||
mock_module.sync_vms = True
|
||||
|
||||
# Setup the mock spec
|
||||
mock_spec_instance = MagicMock()
|
||||
mock_spec.return_value = mock_spec_instance
|
||||
mock_spec_instance.loader.exec_module = lambda x: None
|
||||
|
||||
# Patch module_from_spec to return our mock module
|
||||
with patch("importlib.util.module_from_spec", return_value=mock_module):
|
||||
config = load_config_file(DEFAULT_CONFIG.copy())
|
||||
assert config["templates_config_context"] is True
|
||||
assert config["sync_vms"] is True
|
||||
|
||||
|
||||
def test_load_config_file_not_found():
|
||||
"""Test load_config_file when the config file doesn't exist"""
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
result = load_config_file(DEFAULT_CONFIG.copy())
|
||||
# Should return a dict equal to DEFAULT_CONFIG, not a new object
|
||||
assert result == DEFAULT_CONFIG
|
||||
|
||||
|
||||
def test_load_env_variable_function():
|
||||
"""Test the load_env_variable function directly"""
|
||||
# Create a real environment variable for testing with correct prefix and uppercase
|
||||
test_var = "NBZX_TEMPLATES_CONFIG_CONTEXT"
|
||||
original_env = os.environ.get(test_var, None)
|
||||
try:
|
||||
# Set the environment variable with the proper prefix and case
|
||||
os.environ[test_var] = "True"
|
||||
|
||||
# Test that it's properly read (using lowercase in the function call)
|
||||
value = load_env_variable("templates_config_context")
|
||||
assert value == "True"
|
||||
|
||||
# Test when the environment variable doesn't exist
|
||||
value = load_env_variable("nonexistent_variable")
|
||||
assert value is None
|
||||
finally:
|
||||
# Clean up - restore original environment
|
||||
if original_env is not None:
|
||||
os.environ[test_var] = original_env
|
||||
else:
|
||||
os.environ.pop(test_var, None)
|
||||
+1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
"""Tests for device deletion functionality in the PhysicalDevice class."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from zabbix_utils import APIRequestError
|
||||
|
||||
from netbox_zabbix_sync.modules.device import PhysicalDevice
|
||||
from netbox_zabbix_sync.modules.exceptions import SyncExternalError
|
||||
|
||||
|
||||
class TestDeviceDeletion(unittest.TestCase):
|
||||
"""Test class for device deletion functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock NetBox device
|
||||
self.mock_nb_device = MagicMock()
|
||||
self.mock_nb_device.id = 123
|
||||
self.mock_nb_device.name = "test-device"
|
||||
self.mock_nb_device.status.label = "Decommissioning"
|
||||
self.mock_nb_device.custom_fields = {"zabbix_hostid": "456"}
|
||||
self.mock_nb_device.config_context = {}
|
||||
|
||||
# Set up a primary IP
|
||||
primary_ip = MagicMock()
|
||||
primary_ip.address = "192.168.1.1/24"
|
||||
self.mock_nb_device.primary_ip = primary_ip
|
||||
|
||||
# Create mock Zabbix API
|
||||
self.mock_zabbix = MagicMock()
|
||||
self.mock_zabbix.version = "6.0"
|
||||
|
||||
# Set up mock host.get response
|
||||
self.mock_zabbix.host.get.return_value = [{"hostid": "456"}]
|
||||
|
||||
# Mock NetBox journal class
|
||||
self.mock_nb_journal = MagicMock()
|
||||
|
||||
# Create logger mock
|
||||
self.mock_logger = MagicMock()
|
||||
|
||||
# Create PhysicalDevice instance with mocks
|
||||
self.device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
journal=True,
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
def test_cleanup_successful_deletion(self):
|
||||
"""Test successful device deletion from Zabbix."""
|
||||
# Setup
|
||||
self.mock_zabbix.host.get.return_value = [{"hostid": "456"}]
|
||||
self.mock_zabbix.host.delete.return_value = {"hostids": ["456"]}
|
||||
|
||||
# Execute
|
||||
self.device.cleanup()
|
||||
|
||||
# Verify
|
||||
self.mock_zabbix.host.get.assert_called_once_with(
|
||||
filter={"hostid": "456"}, output=[]
|
||||
)
|
||||
self.mock_zabbix.host.delete.assert_called_once_with("456")
|
||||
self.mock_nb_device.save.assert_called_once()
|
||||
self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"])
|
||||
self.mock_logger.info.assert_called_with(
|
||||
f"Host {self.device.name}: Deleted host from Zabbix."
|
||||
)
|
||||
|
||||
def test_cleanup_device_already_deleted(self):
|
||||
"""Test cleanup when device is already deleted from Zabbix."""
|
||||
# Setup
|
||||
self.mock_zabbix.host.get.return_value = [] # Empty list means host not found
|
||||
|
||||
# Execute
|
||||
self.device.cleanup()
|
||||
|
||||
# Verify
|
||||
self.mock_zabbix.host.get.assert_called_once_with(
|
||||
filter={"hostid": "456"}, output=[]
|
||||
)
|
||||
self.mock_zabbix.host.delete.assert_not_called()
|
||||
self.mock_nb_device.save.assert_called_once()
|
||||
self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"])
|
||||
self.mock_logger.info.assert_called_with(
|
||||
f"Host {self.device.name}: was already deleted from Zabbix. Removed link in NetBox."
|
||||
)
|
||||
|
||||
def test_cleanup_api_error(self):
|
||||
"""Test cleanup when Zabbix API returns an error."""
|
||||
# Setup
|
||||
self.mock_zabbix.host.get.return_value = [{"hostid": "456"}]
|
||||
self.mock_zabbix.host.delete.side_effect = APIRequestError("API Error")
|
||||
|
||||
# Execute and verify
|
||||
with self.assertRaises(SyncExternalError):
|
||||
self.device.cleanup()
|
||||
|
||||
# Verify correct calls were made
|
||||
self.mock_zabbix.host.get.assert_called_once_with(
|
||||
filter={"hostid": "456"}, output=[]
|
||||
)
|
||||
self.mock_zabbix.host.delete.assert_called_once_with("456")
|
||||
self.mock_nb_device.save.assert_not_called()
|
||||
self.mock_logger.error.assert_called()
|
||||
|
||||
def test_zeroize_cf(self):
|
||||
"""Test _zeroize_cf method that clears the custom field."""
|
||||
# Execute
|
||||
self.device._zeroize_cf()
|
||||
|
||||
# Verify
|
||||
self.assertIsNone(self.mock_nb_device.custom_fields["zabbix_hostid"])
|
||||
self.mock_nb_device.save.assert_called_once()
|
||||
|
||||
def test_create_journal_entry(self):
|
||||
"""Test create_journal_entry method."""
|
||||
# Setup
|
||||
test_message = "Test journal entry"
|
||||
|
||||
# Execute
|
||||
result = self.device.create_journal_entry("info", test_message)
|
||||
|
||||
# Verify
|
||||
self.assertTrue(result)
|
||||
self.mock_nb_journal.create.assert_called_once()
|
||||
journal_entry = self.mock_nb_journal.create.call_args[0][0]
|
||||
self.assertEqual(journal_entry["assigned_object_type"], "dcim.device")
|
||||
self.assertEqual(journal_entry["assigned_object_id"], 123)
|
||||
self.assertEqual(journal_entry["kind"], "info")
|
||||
self.assertEqual(journal_entry["comments"], test_message)
|
||||
|
||||
def test_create_journal_entry_invalid_severity(self):
|
||||
"""Test create_journal_entry with invalid severity."""
|
||||
# Execute
|
||||
result = self.device.create_journal_entry("invalid", "Test message")
|
||||
|
||||
# Verify
|
||||
self.assertFalse(result)
|
||||
self.mock_nb_journal.create.assert_not_called()
|
||||
self.mock_logger.warning.assert_called()
|
||||
|
||||
def test_create_journal_entry_when_disabled(self):
|
||||
"""Test create_journal_entry when journaling is disabled."""
|
||||
# Setup - create device with journal=False
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
journal=False, # Disable journaling
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = device.create_journal_entry("info", "Test message")
|
||||
|
||||
# Verify
|
||||
self.assertFalse(result)
|
||||
self.mock_nb_journal.create.assert_not_called()
|
||||
|
||||
def test_cleanup_updates_journal(self):
|
||||
"""Test that cleanup method creates a journal entry."""
|
||||
# Setup
|
||||
self.mock_zabbix.host.get.return_value = [{"hostid": "456"}]
|
||||
|
||||
# Execute
|
||||
with patch.object(self.device, "create_journal_entry") as mock_journal_entry:
|
||||
self.device.cleanup()
|
||||
|
||||
# Verify
|
||||
mock_journal_entry.assert_called_once_with(
|
||||
"warning", "Deleted host from Zabbix"
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for the Description class in the host_description module."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netbox_zabbix_sync.modules.host_description import Description
|
||||
|
||||
|
||||
class TestDescription(unittest.TestCase):
|
||||
"""Test class for Description functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock NetBox object
|
||||
self.mock_nb_object = MagicMock()
|
||||
self.mock_nb_object.name = "test-host"
|
||||
self.mock_nb_object.owner = "admin"
|
||||
self.mock_nb_object.config_context = {}
|
||||
|
||||
# Create logger mock
|
||||
self.mock_logger = MagicMock()
|
||||
|
||||
# Base configuration
|
||||
self.base_config = {}
|
||||
|
||||
# Test 1: Config context description override
|
||||
@patch("netbox_zabbix_sync.modules.host_description.datetime")
|
||||
def test_1_config_context_override_value(self, mock_datetime):
|
||||
"""Test 1: User that provides a config context description value should get this override value back."""
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "2026-02-25 10:30:00"
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
# Set config context with description
|
||||
self.mock_nb_object.config_context = {
|
||||
"zabbix": {"description": "Custom override for {owner}"}
|
||||
}
|
||||
|
||||
config = {"description": "static"}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
# Should use config context, not config
|
||||
self.assertEqual(result, "Custom override for admin")
|
||||
|
||||
# Test 2: Static description
|
||||
def test_2_static_description(
|
||||
self,
|
||||
):
|
||||
"""Test 2: User that provides static as description should get the default static value."""
|
||||
config = {"description": "static"}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
self.assertEqual(result, "Host added by NetBox sync script.")
|
||||
|
||||
# Test 3: Dynamic description
|
||||
@patch("netbox_zabbix_sync.modules.host_description.datetime")
|
||||
def test_3_dynamic_description(self, mock_datetime):
|
||||
"""Test 3: User that provides 'dynamic' should get the resolved description string back."""
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "2026-02-25 10:30:00"
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
config = {"description": "dynamic"}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
expected = (
|
||||
"Host by owner admin added by NetBox sync script on 2026-02-25 10:30:00."
|
||||
)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test 4: Invalid macro fallback
|
||||
def test_4_invalid_macro_fallback_to_static(self):
|
||||
"""Test 4: Users who provide invalid macros should fallback to the static variant."""
|
||||
config = {"description": "Host {owner} with {invalid_macro}"}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
# Should fall back to static default
|
||||
self.assertEqual(result, "Host added by NetBox sync script.")
|
||||
# Verify warning was logged
|
||||
self.mock_logger.warning.assert_called_once()
|
||||
|
||||
# Test 5: Custom time format
|
||||
@patch("netbox_zabbix_sync.modules.host_description.datetime")
|
||||
def test_5_custom_datetime_format(self, mock_datetime):
|
||||
"""Test 5: Users who change the time format."""
|
||||
mock_now = MagicMock()
|
||||
# Will be called twice: once with custom format, once for string
|
||||
mock_now.strftime.side_effect = ["25/02/2026", "25/02/2026"]
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
config = {
|
||||
"description": "Updated on {datetime}",
|
||||
"description_dt_format": "%d/%m/%Y",
|
||||
}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
self.assertEqual(result, "Updated on 25/02/2026")
|
||||
|
||||
# Test 6: Custom description format in config
|
||||
@patch("netbox_zabbix_sync.modules.host_description.datetime")
|
||||
def test_6_custom_description_format(self, mock_datetime):
|
||||
"""Test 6: Users who provide a custom description format in the config."""
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "2026-02-25 10:30:00"
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
config = {"description": "Server {owner} managed at {datetime}"}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
self.assertEqual(result, "Server admin managed at 2026-02-25 10:30:00")
|
||||
|
||||
# Test 7: Owner on lower NetBox version
|
||||
@patch("netbox_zabbix_sync.modules.host_description.datetime")
|
||||
def test_7_owner_on_lower_netbox_version(self, mock_datetime):
|
||||
"""Test 7: Users who try to resolve the owner property on a lower NetBox version (3.2)."""
|
||||
mock_now = MagicMock()
|
||||
mock_now.strftime.return_value = "2026-02-25 10:30:00"
|
||||
mock_datetime.now.return_value = mock_now
|
||||
|
||||
config = {"description": "Device owned by {owner}"}
|
||||
desc = Description(
|
||||
self.mock_nb_object,
|
||||
config,
|
||||
"3.2", # Lower NetBox version
|
||||
logger=self.mock_logger,
|
||||
)
|
||||
|
||||
result = desc.generate()
|
||||
# Owner should be empty string on version < 4.5
|
||||
self.assertEqual(result, "Device owned by ")
|
||||
|
||||
# Test 8: Missing or False description returns static
|
||||
def test_8a_missing_description_returns_static(self):
|
||||
"""Test 8a: When description option is not found, script should return the static variant."""
|
||||
config = {} # No description key
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
self.assertEqual(result, "Host added by NetBox sync script.")
|
||||
|
||||
def test_8b_false_description_returns_empty(self):
|
||||
"""Test 8b: When description is set to False, script should return empty string."""
|
||||
config = {"description": False}
|
||||
desc = Description(self.mock_nb_object, config, "4.5", logger=self.mock_logger)
|
||||
|
||||
result = desc.generate()
|
||||
self.assertEqual(result, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Tests for the Hostgroup class in the hostgroups module."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netbox_zabbix_sync.modules.exceptions import HostgroupError
|
||||
from netbox_zabbix_sync.modules.hostgroups import Hostgroup
|
||||
|
||||
|
||||
class TestHostgroups(unittest.TestCase):
|
||||
"""Test class for Hostgroup functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock logger
|
||||
self.mock_logger = MagicMock()
|
||||
|
||||
# *** Mock NetBox Device setup ***
|
||||
# Create mock device with all properties
|
||||
self.mock_device = MagicMock()
|
||||
self.mock_device.name = "test-device"
|
||||
|
||||
# Set up site information
|
||||
site = MagicMock()
|
||||
site.name = "TestSite"
|
||||
|
||||
# Set up region information
|
||||
region = MagicMock()
|
||||
region.name = "TestRegion"
|
||||
# Ensure region string representation returns the name
|
||||
region.__str__.return_value = "TestRegion"
|
||||
site.region = region
|
||||
|
||||
# Set up site group information
|
||||
site_group = MagicMock()
|
||||
site_group.name = "TestSiteGroup"
|
||||
# Ensure site group string representation returns the name
|
||||
site_group.__str__.return_value = "TestSiteGroup"
|
||||
site.group = site_group
|
||||
|
||||
self.mock_device.site = site
|
||||
|
||||
# Set up role information (varies based on NetBox version)
|
||||
self.mock_device_role = MagicMock()
|
||||
self.mock_device_role.name = "TestRole"
|
||||
# Ensure string representation returns the name
|
||||
self.mock_device_role.__str__.return_value = "TestRole"
|
||||
self.mock_device.device_role = self.mock_device_role
|
||||
self.mock_device.role = self.mock_device_role
|
||||
|
||||
# Set up tenant information
|
||||
tenant = MagicMock()
|
||||
tenant.name = "TestTenant"
|
||||
# Ensure tenant string representation returns the name
|
||||
tenant.__str__.return_value = "TestTenant"
|
||||
tenant_group = MagicMock()
|
||||
tenant_group.name = "TestTenantGroup"
|
||||
# Ensure tenant group string representation returns the name
|
||||
tenant_group.__str__.return_value = "TestTenantGroup"
|
||||
tenant.group = tenant_group
|
||||
self.mock_device.tenant = tenant
|
||||
|
||||
# Set up platform information
|
||||
platform = MagicMock()
|
||||
platform.name = "TestPlatform"
|
||||
self.mock_device.platform = platform
|
||||
|
||||
# Device-specific properties
|
||||
device_type = MagicMock()
|
||||
manufacturer = MagicMock()
|
||||
manufacturer.name = "TestManufacturer"
|
||||
device_type.manufacturer = manufacturer
|
||||
self.mock_device.device_type = device_type
|
||||
|
||||
location = MagicMock()
|
||||
location.name = "TestLocation"
|
||||
# Ensure location string representation returns the name
|
||||
location.__str__.return_value = "TestLocation"
|
||||
self.mock_device.location = location
|
||||
|
||||
rack = MagicMock()
|
||||
rack.name = "TestRack"
|
||||
self.mock_device.rack = rack
|
||||
|
||||
# Custom fields — empty_cf is intentionally None to test the empty CF path
|
||||
self.mock_device.custom_fields = {"test_cf": "TestCF", "empty_cf": None}
|
||||
|
||||
# *** Mock NetBox VM setup ***
|
||||
# Create mock VM with all properties
|
||||
self.mock_vm = MagicMock()
|
||||
self.mock_vm.name = "test-vm"
|
||||
|
||||
# Reuse site from device
|
||||
self.mock_vm.site = site
|
||||
|
||||
# Set up role for VM
|
||||
self.mock_vm.role = self.mock_device_role
|
||||
|
||||
# Set up tenant for VM (same as device)
|
||||
self.mock_vm.tenant = tenant
|
||||
|
||||
# Set up platform for VM (same as device)
|
||||
self.mock_vm.platform = platform
|
||||
|
||||
# VM-specific properties
|
||||
cluster = MagicMock()
|
||||
cluster.name = "TestCluster"
|
||||
cluster_type = MagicMock()
|
||||
cluster_type.name = "TestClusterType"
|
||||
cluster.type = cluster_type
|
||||
self.mock_vm.cluster = cluster
|
||||
|
||||
# Custom fields
|
||||
self.mock_vm.custom_fields = {"test_cf": "TestCF"}
|
||||
|
||||
# Mock data for nesting tests
|
||||
self.mock_regions_data = [
|
||||
{"name": "ParentRegion", "parent": None, "_depth": 0},
|
||||
{"name": "TestRegion", "parent": "ParentRegion", "_depth": 1},
|
||||
]
|
||||
|
||||
self.mock_groups_data = [
|
||||
{"name": "ParentSiteGroup", "parent": None, "_depth": 0},
|
||||
{"name": "TestSiteGroup", "parent": "ParentSiteGroup", "_depth": 1},
|
||||
]
|
||||
|
||||
def test_device_hostgroup_creation(self):
|
||||
"""Test basic device hostgroup creation."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Test the string representation
|
||||
self.assertEqual(str(hostgroup), "Hostgroup for dev test-device")
|
||||
|
||||
# Check format options were set correctly
|
||||
self.assertEqual(hostgroup.format_options["site"], "TestSite")
|
||||
self.assertEqual(hostgroup.format_options["region"], "TestRegion")
|
||||
self.assertEqual(hostgroup.format_options["site_group"], "TestSiteGroup")
|
||||
self.assertEqual(hostgroup.format_options["role"], "TestRole")
|
||||
self.assertEqual(hostgroup.format_options["tenant"], "TestTenant")
|
||||
self.assertEqual(hostgroup.format_options["tenant_group"], "TestTenantGroup")
|
||||
self.assertEqual(hostgroup.format_options["platform"], "TestPlatform")
|
||||
self.assertEqual(hostgroup.format_options["manufacturer"], "TestManufacturer")
|
||||
self.assertEqual(hostgroup.format_options["location"], "TestLocation")
|
||||
self.assertEqual(hostgroup.format_options["rack"], "TestRack")
|
||||
|
||||
def test_vm_hostgroup_creation(self):
|
||||
"""Test basic VM hostgroup creation."""
|
||||
hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger)
|
||||
|
||||
# Test the string representation
|
||||
self.assertEqual(str(hostgroup), "Hostgroup for vm test-vm")
|
||||
|
||||
# Check format options were set correctly
|
||||
self.assertEqual(hostgroup.format_options["site"], "TestSite")
|
||||
self.assertEqual(hostgroup.format_options["region"], "TestRegion")
|
||||
self.assertEqual(hostgroup.format_options["site_group"], "TestSiteGroup")
|
||||
self.assertEqual(hostgroup.format_options["role"], "TestRole")
|
||||
self.assertEqual(hostgroup.format_options["tenant"], "TestTenant")
|
||||
self.assertEqual(hostgroup.format_options["tenant_group"], "TestTenantGroup")
|
||||
self.assertEqual(hostgroup.format_options["platform"], "TestPlatform")
|
||||
self.assertEqual(hostgroup.format_options["cluster"], "TestCluster")
|
||||
self.assertEqual(hostgroup.format_options["cluster_type"], "TestClusterType")
|
||||
|
||||
def test_invalid_object_type(self):
|
||||
"""Test that an invalid object type raises an exception."""
|
||||
with self.assertRaises(HostgroupError):
|
||||
Hostgroup("invalid", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
def test_device_hostgroup_formats(self):
|
||||
"""Test different hostgroup formats for devices."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Custom format: site/region
|
||||
custom_result = hostgroup.generate("site/region")
|
||||
self.assertEqual(custom_result, "TestSite/TestRegion")
|
||||
|
||||
# Custom format: site/tenant/platform/location
|
||||
complex_result = hostgroup.generate("site/tenant/platform/location")
|
||||
self.assertEqual(
|
||||
complex_result, "TestSite/TestTenant/TestPlatform/TestLocation"
|
||||
)
|
||||
|
||||
def test_vm_hostgroup_formats(self):
|
||||
"""Test different hostgroup formats for VMs."""
|
||||
hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger)
|
||||
|
||||
# Default format: cluster/role
|
||||
default_result = hostgroup.generate("cluster/role")
|
||||
self.assertEqual(default_result, "TestCluster/TestRole")
|
||||
|
||||
# Custom format: site/tenant
|
||||
custom_result = hostgroup.generate("site/tenant")
|
||||
self.assertEqual(custom_result, "TestSite/TestTenant")
|
||||
|
||||
# Custom format: cluster/cluster_type/platform
|
||||
complex_result = hostgroup.generate("cluster/cluster_type/platform")
|
||||
self.assertEqual(complex_result, "TestCluster/TestClusterType/TestPlatform")
|
||||
|
||||
def test_device_netbox_version_differences(self):
|
||||
"""Test hostgroup generation with different NetBox versions.
|
||||
|
||||
device_role (v2/v3) and role (v4+) are set to different values so the
|
||||
test can verify that the correct attribute is read for each version.
|
||||
"""
|
||||
# Build a device with deliberately different names on each role attribute
|
||||
versioned_device = MagicMock()
|
||||
versioned_device.name = "versioned-device"
|
||||
versioned_device.site = self.mock_device.site
|
||||
versioned_device.tenant = self.mock_device.tenant
|
||||
versioned_device.platform = self.mock_device.platform
|
||||
versioned_device.location = self.mock_device.location
|
||||
versioned_device.rack = self.mock_device.rack
|
||||
versioned_device.device_type = self.mock_device.device_type
|
||||
versioned_device.custom_fields = self.mock_device.custom_fields
|
||||
|
||||
old_role = MagicMock()
|
||||
old_role.name = "OldRole"
|
||||
new_role = MagicMock()
|
||||
new_role.name = "NewRole"
|
||||
versioned_device.device_role = old_role # read by NetBox v2 / v3 code path
|
||||
versioned_device.role = new_role # read by NetBox v4+ code path
|
||||
|
||||
# v2 must use device_role
|
||||
hostgroup_v2 = Hostgroup("dev", versioned_device, "2.11", self.mock_logger)
|
||||
self.assertEqual(hostgroup_v2.format_options["role"], "OldRole")
|
||||
|
||||
# v3 must also use device_role
|
||||
hostgroup_v3 = Hostgroup("dev", versioned_device, "3.5", self.mock_logger)
|
||||
self.assertEqual(hostgroup_v3.format_options["role"], "OldRole")
|
||||
|
||||
# v4+ must use role
|
||||
hostgroup_v4 = Hostgroup("dev", versioned_device, "4.0", self.mock_logger)
|
||||
self.assertEqual(hostgroup_v4.format_options["role"], "NewRole")
|
||||
|
||||
def test_custom_field_lookup(self):
|
||||
"""Test custom field lookup functionality."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Test custom field exists and is populated
|
||||
cf_result = hostgroup.custom_field_lookup("test_cf")
|
||||
self.assertTrue(cf_result["result"])
|
||||
self.assertEqual(cf_result["cf"], "TestCF")
|
||||
|
||||
# Test custom field doesn't exist
|
||||
cf_result = hostgroup.custom_field_lookup("nonexistent_cf")
|
||||
self.assertFalse(cf_result["result"])
|
||||
self.assertIsNone(cf_result["cf"])
|
||||
|
||||
# Test custom field exists but has no value (None)
|
||||
cf_result = hostgroup.custom_field_lookup("empty_cf")
|
||||
self.assertTrue(cf_result["result"]) # key is present
|
||||
self.assertIsNone(cf_result["cf"]) # value is empty
|
||||
|
||||
def test_hostgroup_with_custom_field(self):
|
||||
"""Test hostgroup generation including a custom field."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Generate with custom field included
|
||||
result = hostgroup.generate("site/test_cf/role")
|
||||
self.assertEqual(result, "TestSite/TestCF/TestRole")
|
||||
|
||||
def test_missing_hostgroup_format_item(self):
|
||||
"""Test handling of missing hostgroup format items."""
|
||||
# Create a device with minimal attributes
|
||||
minimal_device = MagicMock()
|
||||
minimal_device.name = "minimal-device"
|
||||
minimal_device.site = None
|
||||
minimal_device.tenant = None
|
||||
minimal_device.platform = None
|
||||
minimal_device.custom_fields = {}
|
||||
|
||||
# Create role
|
||||
role = MagicMock()
|
||||
role.name = "MinimalRole"
|
||||
minimal_device.role = role
|
||||
|
||||
# Create device_type with manufacturer
|
||||
device_type = MagicMock()
|
||||
manufacturer = MagicMock()
|
||||
manufacturer.name = "MinimalManufacturer"
|
||||
device_type.manufacturer = manufacturer
|
||||
minimal_device.device_type = device_type
|
||||
|
||||
# Create hostgroup
|
||||
hostgroup = Hostgroup("dev", minimal_device, "4.0", self.mock_logger)
|
||||
|
||||
# Generate with default format
|
||||
result = hostgroup.generate("site/manufacturer/role")
|
||||
# Site is missing, so only manufacturer and role should be included
|
||||
self.assertEqual(result, "MinimalManufacturer/MinimalRole")
|
||||
|
||||
# Test with invalid format
|
||||
with self.assertRaises(HostgroupError):
|
||||
hostgroup.generate("site/nonexistent/role")
|
||||
|
||||
def test_nested_region_hostgroups(self):
|
||||
"""Test hostgroup generation with nested regions."""
|
||||
# Mock the build_path function to return a predictable result
|
||||
with patch(
|
||||
"netbox_zabbix_sync.modules.hostgroups.build_path"
|
||||
) as mock_build_path:
|
||||
# Configure the mock to return a list of regions in the path
|
||||
mock_build_path.return_value = ["ParentRegion", "TestRegion"]
|
||||
|
||||
# Create hostgroup with nested regions enabled
|
||||
hostgroup = Hostgroup(
|
||||
"dev",
|
||||
self.mock_device,
|
||||
"4.0",
|
||||
self.mock_logger,
|
||||
nested_region_flag=True,
|
||||
nb_regions=self.mock_regions_data,
|
||||
)
|
||||
|
||||
# Generate hostgroup with region
|
||||
result = hostgroup.generate("site/region/role")
|
||||
# Should include the parent region
|
||||
self.assertEqual(result, "TestSite/ParentRegion/TestRegion/TestRole")
|
||||
|
||||
def test_nested_sitegroup_hostgroups(self):
|
||||
"""Test hostgroup generation with nested site groups."""
|
||||
# Mock the build_path function to return a predictable result
|
||||
with patch(
|
||||
"netbox_zabbix_sync.modules.hostgroups.build_path"
|
||||
) as mock_build_path:
|
||||
# Configure the mock to return a list of site groups in the path
|
||||
mock_build_path.return_value = ["ParentSiteGroup", "TestSiteGroup"]
|
||||
|
||||
# Create hostgroup with nested site groups enabled
|
||||
hostgroup = Hostgroup(
|
||||
"dev",
|
||||
self.mock_device,
|
||||
"4.0",
|
||||
self.mock_logger,
|
||||
nested_sitegroup_flag=True,
|
||||
nb_groups=self.mock_groups_data,
|
||||
)
|
||||
|
||||
# Generate hostgroup with site_group
|
||||
result = hostgroup.generate("site/site_group/role")
|
||||
# Should include the parent site group
|
||||
self.assertEqual(result, "TestSite/ParentSiteGroup/TestSiteGroup/TestRole")
|
||||
|
||||
def test_vm_list_based_hostgroup_format(self):
|
||||
"""Test VM hostgroup generation with a list-based format."""
|
||||
hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger)
|
||||
|
||||
# Test with a list of format strings
|
||||
format_list = ["platform", "role", "cluster_type/cluster"]
|
||||
|
||||
# Generate hostgroups for each format in the list
|
||||
hostgroups = []
|
||||
for fmt in format_list:
|
||||
result = hostgroup.generate(fmt)
|
||||
if result: # Only add non-None results
|
||||
hostgroups.append(result)
|
||||
|
||||
# Verify each expected hostgroup is generated
|
||||
self.assertEqual(len(hostgroups), 3) # Should have 3 hostgroups
|
||||
self.assertIn("TestPlatform", hostgroups)
|
||||
self.assertIn("TestRole", hostgroups)
|
||||
self.assertIn("TestClusterType/TestCluster", hostgroups)
|
||||
|
||||
def test_nested_format_splitting(self):
|
||||
"""Test that formats with slashes correctly split and resolve each component."""
|
||||
hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger)
|
||||
|
||||
# Test a format with slashes that should be split
|
||||
complex_format = "cluster_type/cluster"
|
||||
result = hostgroup.generate(complex_format)
|
||||
|
||||
# Verify the format is correctly split and each component resolved
|
||||
self.assertEqual(result, "TestClusterType/TestCluster")
|
||||
|
||||
def test_multiple_hostgroup_formats_device(self):
|
||||
"""Test device hostgroup generation with multiple formats."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Test with various formats that would be in a list
|
||||
formats = [
|
||||
"site",
|
||||
"manufacturer/role",
|
||||
"platform/location",
|
||||
"tenant_group/tenant",
|
||||
]
|
||||
|
||||
# Generate and check each format
|
||||
results = {}
|
||||
for fmt in formats:
|
||||
results[fmt] = hostgroup.generate(fmt)
|
||||
|
||||
# Verify results
|
||||
self.assertEqual(results["site"], "TestSite")
|
||||
self.assertEqual(results["manufacturer/role"], "TestManufacturer/TestRole")
|
||||
self.assertEqual(results["platform/location"], "TestPlatform/TestLocation")
|
||||
self.assertEqual(results["tenant_group/tenant"], "TestTenantGroup/TestTenant")
|
||||
|
||||
def test_literal_string_in_format(self):
|
||||
"""Test that quoted literal strings in a format are used verbatim."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Single-quoted literal
|
||||
result = hostgroup.generate("'MyDevices'/role")
|
||||
self.assertEqual(result, "MyDevices/TestRole")
|
||||
|
||||
# Double-quoted literal
|
||||
result = hostgroup.generate('"MyDevices"/role')
|
||||
self.assertEqual(result, "MyDevices/TestRole")
|
||||
|
||||
def test_generate_returns_none_when_all_fields_empty(self):
|
||||
"""Test that generate() returns None when every format field resolves to no value."""
|
||||
empty_device = MagicMock()
|
||||
empty_device.name = "empty-device"
|
||||
empty_device.site = None
|
||||
empty_device.tenant = None
|
||||
empty_device.platform = None
|
||||
empty_device.role = None
|
||||
empty_device.location = None
|
||||
empty_device.rack = None
|
||||
empty_device.custom_fields = {}
|
||||
device_type = MagicMock()
|
||||
manufacturer = MagicMock()
|
||||
manufacturer.name = "SomeManufacturer"
|
||||
device_type.manufacturer = manufacturer
|
||||
empty_device.device_type = device_type
|
||||
|
||||
hostgroup = Hostgroup("dev", empty_device, "4.0", self.mock_logger)
|
||||
# site, tenant and platform all have no value → hg_output stays empty → None
|
||||
result = hostgroup.generate("site/tenant/platform")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_vm_without_cluster(self):
|
||||
"""Test that cluster/cluster_type are absent from format_options when VM has no cluster."""
|
||||
clusterless_vm = MagicMock()
|
||||
clusterless_vm.name = "clusterless-vm"
|
||||
clusterless_vm.site = self.mock_vm.site
|
||||
clusterless_vm.tenant = self.mock_vm.tenant
|
||||
clusterless_vm.platform = self.mock_vm.platform
|
||||
clusterless_vm.role = self.mock_device_role
|
||||
clusterless_vm.cluster = None
|
||||
clusterless_vm.custom_fields = {}
|
||||
|
||||
hostgroup = Hostgroup("vm", clusterless_vm, "4.0", self.mock_logger)
|
||||
|
||||
# cluster and cluster_type must not appear in format_options
|
||||
self.assertNotIn("cluster", hostgroup.format_options)
|
||||
self.assertNotIn("cluster_type", hostgroup.format_options)
|
||||
|
||||
# Requesting cluster in a format must raise HostgroupError
|
||||
with self.assertRaises(HostgroupError):
|
||||
hostgroup.generate("cluster/role")
|
||||
|
||||
def test_empty_custom_field_skipped_in_format(self):
|
||||
"""Test that an empty (None) custom field is silently omitted from the hostgroup name."""
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# empty_cf has no value → it is skipped; only site and role appear
|
||||
result = hostgroup.generate("site/empty_cf/role")
|
||||
self.assertEqual(result, "TestSite/TestRole")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Tests for the ZabbixInterface class in the interface module."""
|
||||
|
||||
import unittest
|
||||
from typing import cast
|
||||
|
||||
from netbox_zabbix_sync.modules.exceptions import InterfaceConfigError
|
||||
from netbox_zabbix_sync.modules.interface import ZabbixInterface
|
||||
|
||||
|
||||
class TestZabbixInterface(unittest.TestCase):
|
||||
"""Test class for ZabbixInterface functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.test_ip = "192.168.1.1"
|
||||
self.empty_context = {}
|
||||
self.default_interface = ZabbixInterface(self.empty_context, self.test_ip)
|
||||
|
||||
# Create some test contexts for different scenarios
|
||||
self.snmpv2_context = {
|
||||
"zabbix": {
|
||||
"interface_type": 2,
|
||||
"interface_port": "161",
|
||||
"snmp": {"version": 2, "community": "public", "bulk": 1},
|
||||
}
|
||||
}
|
||||
|
||||
self.snmpv3_context = {
|
||||
"zabbix": {
|
||||
"interface_type": 2,
|
||||
"snmp": {
|
||||
"version": 3,
|
||||
"securityname": "snmpuser",
|
||||
"securitylevel": "authPriv",
|
||||
"authprotocol": "SHA",
|
||||
"authpassphrase": "authpass123",
|
||||
"privprotocol": "AES",
|
||||
"privpassphrase": "privpass123",
|
||||
"contextname": "context1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
self.agent_context = {
|
||||
"zabbix": {"interface_type": 1, "interface_port": "10050"}
|
||||
}
|
||||
|
||||
def test_init(self):
|
||||
"""Test initialization of ZabbixInterface."""
|
||||
interface = ZabbixInterface(self.empty_context, self.test_ip)
|
||||
|
||||
# Check basic properties
|
||||
self.assertEqual(interface.ip, self.test_ip)
|
||||
self.assertEqual(interface.context, self.empty_context)
|
||||
self.assertEqual(interface.interface["ip"], self.test_ip)
|
||||
self.assertEqual(interface.interface["main"], "1")
|
||||
self.assertEqual(interface.interface["useip"], "1")
|
||||
self.assertEqual(interface.interface["dns"], "")
|
||||
|
||||
def test_get_context_empty(self):
|
||||
"""Test get_context with empty context."""
|
||||
interface = ZabbixInterface(self.empty_context, self.test_ip)
|
||||
result = interface.get_context()
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_get_context_with_interface_type(self):
|
||||
"""Test get_context with interface_type but no port."""
|
||||
context = {"zabbix": {"interface_type": 2}}
|
||||
interface = ZabbixInterface(context, self.test_ip)
|
||||
|
||||
# Should set type and default port
|
||||
result = interface.get_context()
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(interface.interface["type"], 2)
|
||||
self.assertEqual(interface.interface["port"], "161") # Default port for SNMP
|
||||
|
||||
def test_get_context_with_interface_type_and_port(self):
|
||||
"""Test get_context with both interface_type and port."""
|
||||
context = {"zabbix": {"interface_type": 1, "interface_port": "12345"}}
|
||||
interface = ZabbixInterface(context, self.test_ip)
|
||||
|
||||
# Should set type and specified port
|
||||
result = interface.get_context()
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(interface.interface["type"], 1)
|
||||
self.assertEqual(interface.interface["port"], "12345")
|
||||
|
||||
def test_set_default_port(self):
|
||||
"""Test _set_default_port for different interface types."""
|
||||
interface = ZabbixInterface(self.empty_context, self.test_ip)
|
||||
|
||||
# Test for agent type (1)
|
||||
interface.interface["type"] = 1
|
||||
interface._set_default_port()
|
||||
self.assertEqual(interface.interface["port"], "10050")
|
||||
|
||||
# Test for SNMP type (2)
|
||||
interface.interface["type"] = 2
|
||||
interface._set_default_port()
|
||||
self.assertEqual(interface.interface["port"], "161")
|
||||
|
||||
# Test for IPMI type (3)
|
||||
interface.interface["type"] = 3
|
||||
interface._set_default_port()
|
||||
self.assertEqual(interface.interface["port"], "623")
|
||||
|
||||
# Test for JMX type (4)
|
||||
interface.interface["type"] = 4
|
||||
interface._set_default_port()
|
||||
self.assertEqual(interface.interface["port"], "12345")
|
||||
|
||||
# Test for unsupported type
|
||||
interface.interface["type"] = 99
|
||||
result = interface._set_default_port()
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_set_snmp_v2(self):
|
||||
"""Test set_snmp with SNMPv2 configuration."""
|
||||
interface = ZabbixInterface(self.snmpv2_context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp
|
||||
interface.set_snmp()
|
||||
|
||||
# Check SNMP details
|
||||
details = cast(dict[str, str], interface.interface["details"])
|
||||
self.assertEqual(details["version"], "2")
|
||||
self.assertEqual(details["community"], "public")
|
||||
self.assertEqual(details["bulk"], "1")
|
||||
|
||||
def test_set_snmp_v3(self):
|
||||
"""Test set_snmp with SNMPv3 configuration."""
|
||||
interface = ZabbixInterface(self.snmpv3_context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp
|
||||
interface.set_snmp()
|
||||
|
||||
# Check SNMP details
|
||||
details = cast(dict[str, str], interface.interface["details"])
|
||||
self.assertEqual(details["version"], "3")
|
||||
self.assertEqual(details["securityname"], "snmpuser")
|
||||
self.assertEqual(details["securitylevel"], "authPriv")
|
||||
self.assertEqual(details["authprotocol"], "SHA")
|
||||
self.assertEqual(details["authpassphrase"], "authpass123")
|
||||
self.assertEqual(details["privprotocol"], "AES")
|
||||
self.assertEqual(details["privpassphrase"], "privpass123")
|
||||
self.assertEqual(details["contextname"], "context1")
|
||||
|
||||
def test_set_snmp_no_snmp_config(self):
|
||||
"""Test set_snmp with missing SNMP configuration."""
|
||||
# Create context with interface type but no SNMP config
|
||||
context = {"zabbix": {"interface_type": 2}}
|
||||
interface = ZabbixInterface(context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp - should raise exception
|
||||
with self.assertRaises(InterfaceConfigError):
|
||||
interface.set_snmp()
|
||||
|
||||
def test_set_snmp_unsupported_version(self):
|
||||
"""Test set_snmp with unsupported SNMP version."""
|
||||
# Create context with invalid SNMP version
|
||||
context = {
|
||||
"zabbix": {
|
||||
"interface_type": 2,
|
||||
"snmp": {
|
||||
"version": 4 # Invalid version
|
||||
},
|
||||
}
|
||||
}
|
||||
interface = ZabbixInterface(context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp - should raise exception
|
||||
with self.assertRaises(InterfaceConfigError):
|
||||
interface.set_snmp()
|
||||
|
||||
def test_set_snmp_no_version(self):
|
||||
"""Test set_snmp with missing SNMP version."""
|
||||
# Create context without SNMP version
|
||||
context = {
|
||||
"zabbix": {
|
||||
"interface_type": 2,
|
||||
"snmp": {
|
||||
"community": "public" # No version specified
|
||||
},
|
||||
}
|
||||
}
|
||||
interface = ZabbixInterface(context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp - should raise exception
|
||||
with self.assertRaises(InterfaceConfigError):
|
||||
interface.set_snmp()
|
||||
|
||||
def test_set_snmp_non_snmp_interface(self):
|
||||
"""Test set_snmp with non-SNMP interface type."""
|
||||
interface = ZabbixInterface(self.agent_context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp - should raise exception
|
||||
with self.assertRaises(InterfaceConfigError):
|
||||
interface.set_snmp()
|
||||
|
||||
def test_set_default_snmp(self):
|
||||
"""Test set_default_snmp method."""
|
||||
interface = ZabbixInterface(self.empty_context, self.test_ip)
|
||||
interface.set_default_snmp()
|
||||
|
||||
# Check interface properties
|
||||
self.assertEqual(interface.interface["type"], "2")
|
||||
self.assertEqual(interface.interface["port"], "161")
|
||||
details = cast(dict[str, str], interface.interface["details"])
|
||||
self.assertEqual(details["version"], "2")
|
||||
self.assertEqual(details["community"], "{$SNMP_COMMUNITY}")
|
||||
self.assertEqual(details["bulk"], "1")
|
||||
|
||||
def test_set_default_agent(self):
|
||||
"""Test set_default_agent method."""
|
||||
interface = ZabbixInterface(self.empty_context, self.test_ip)
|
||||
interface.set_default_agent()
|
||||
|
||||
# Check interface properties
|
||||
self.assertEqual(interface.interface["type"], "1")
|
||||
self.assertEqual(interface.interface["port"], "10050")
|
||||
|
||||
def test_snmpv2_no_community(self):
|
||||
"""Test SNMPv2 with no community string specified."""
|
||||
# Create context with SNMPv2 but no community
|
||||
context = {"zabbix": {"interface_type": 2, "snmp": {"version": 2}}}
|
||||
interface = ZabbixInterface(context, self.test_ip)
|
||||
interface.get_context() # Set the interface type
|
||||
|
||||
# Call set_snmp
|
||||
interface.set_snmp()
|
||||
|
||||
# Should use default community string
|
||||
details = cast(dict[str, str], interface.interface["details"])
|
||||
self.assertEqual(details["community"], "{$SNMP_COMMUNITY}")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Tests for list-based hostgroup formats in configuration."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from netbox_zabbix_sync.modules.exceptions import HostgroupError
|
||||
from netbox_zabbix_sync.modules.hostgroups import Hostgroup
|
||||
from netbox_zabbix_sync.modules.tools import verify_hg_format
|
||||
|
||||
|
||||
class TestListHostgroupFormats(unittest.TestCase):
|
||||
"""Test class for list-based hostgroup format functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock logger
|
||||
self.mock_logger = MagicMock()
|
||||
|
||||
# Create mock device
|
||||
self.mock_device = MagicMock()
|
||||
self.mock_device.name = "test-device"
|
||||
|
||||
# Set up site information
|
||||
site = MagicMock()
|
||||
site.name = "TestSite"
|
||||
|
||||
# Set up region information
|
||||
region = MagicMock()
|
||||
region.name = "TestRegion"
|
||||
region.__str__.return_value = "TestRegion"
|
||||
site.region = region
|
||||
|
||||
# Set device site
|
||||
self.mock_device.site = site
|
||||
|
||||
# Set up role information
|
||||
self.mock_device_role = MagicMock()
|
||||
self.mock_device_role.name = "TestRole"
|
||||
self.mock_device_role.__str__.return_value = "TestRole"
|
||||
self.mock_device.role = self.mock_device_role
|
||||
|
||||
# Set up rack information
|
||||
rack = MagicMock()
|
||||
rack.name = "TestRack"
|
||||
self.mock_device.rack = rack
|
||||
|
||||
# Set up platform information
|
||||
platform = MagicMock()
|
||||
platform.name = "TestPlatform"
|
||||
self.mock_device.platform = platform
|
||||
|
||||
# Device-specific properties
|
||||
device_type = MagicMock()
|
||||
manufacturer = MagicMock()
|
||||
manufacturer.name = "TestManufacturer"
|
||||
device_type.manufacturer = manufacturer
|
||||
self.mock_device.device_type = device_type
|
||||
|
||||
# Create mock VM
|
||||
self.mock_vm = MagicMock()
|
||||
self.mock_vm.name = "test-vm"
|
||||
|
||||
# Reuse site from device
|
||||
self.mock_vm.site = site
|
||||
|
||||
# Set up role for VM
|
||||
self.mock_vm.role = self.mock_device_role
|
||||
|
||||
# Set up platform for VM
|
||||
self.mock_vm.platform = platform
|
||||
|
||||
# VM-specific properties
|
||||
cluster = MagicMock()
|
||||
cluster.name = "TestCluster"
|
||||
cluster_type = MagicMock()
|
||||
cluster_type.name = "TestClusterType"
|
||||
cluster.type = cluster_type
|
||||
self.mock_vm.cluster = cluster
|
||||
|
||||
def test_verify_list_based_hostgroup_format(self):
|
||||
"""Test verification of list-based hostgroup formats."""
|
||||
# List format with valid items
|
||||
valid_format = ["region", "site", "rack"]
|
||||
|
||||
# List format with nested path
|
||||
valid_nested_format = ["region", "site/rack"]
|
||||
|
||||
# List format with invalid item
|
||||
invalid_format = ["region", "invalid_item", "rack"]
|
||||
|
||||
# Should not raise exception for valid formats
|
||||
verify_hg_format(valid_format, hg_type="dev", logger=self.mock_logger)
|
||||
verify_hg_format(valid_nested_format, hg_type="dev", logger=self.mock_logger)
|
||||
|
||||
# Should raise exception for invalid format
|
||||
with self.assertRaises(HostgroupError):
|
||||
verify_hg_format(invalid_format, hg_type="dev", logger=self.mock_logger)
|
||||
|
||||
def test_simulate_hostgroup_generation_from_config(self):
|
||||
"""Simulate how the main script would generate hostgroups from list-based config."""
|
||||
# Mock configuration with list-based hostgroup format
|
||||
config_format = ["region", "site", "rack"]
|
||||
hostgroup = Hostgroup("dev", self.mock_device, "4.0", self.mock_logger)
|
||||
|
||||
# Simulate the main script's hostgroup generation process
|
||||
hostgroups = []
|
||||
for fmt in config_format:
|
||||
result = hostgroup.generate(fmt)
|
||||
if result:
|
||||
hostgroups.append(result)
|
||||
|
||||
# Check results
|
||||
self.assertEqual(len(hostgroups), 3)
|
||||
self.assertIn("TestRegion", hostgroups)
|
||||
self.assertIn("TestSite", hostgroups)
|
||||
self.assertIn("TestRack", hostgroups)
|
||||
|
||||
def test_vm_hostgroup_format_from_config(self):
|
||||
"""Test VM hostgroup generation with list-based format."""
|
||||
# Mock VM configuration with mixed format
|
||||
config_format = ["platform", "role", "cluster_type/cluster"]
|
||||
hostgroup = Hostgroup("vm", self.mock_vm, "4.0", self.mock_logger)
|
||||
|
||||
# Simulate the main script's hostgroup generation process
|
||||
hostgroups = []
|
||||
for fmt in config_format:
|
||||
result = hostgroup.generate(fmt)
|
||||
if result:
|
||||
hostgroups.append(result)
|
||||
|
||||
# Check results
|
||||
self.assertEqual(len(hostgroups), 3)
|
||||
self.assertIn("TestPlatform", hostgroups)
|
||||
self.assertIn("TestRole", hostgroups)
|
||||
self.assertIn("TestClusterType/TestCluster", hostgroups)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Tests for the PhysicalDevice class in the device module."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netbox_zabbix_sync.modules.device import PhysicalDevice
|
||||
from netbox_zabbix_sync.modules.exceptions import TemplateError
|
||||
|
||||
|
||||
class TestPhysicalDevice(unittest.TestCase):
|
||||
"""Test class for PhysicalDevice functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock NetBox device
|
||||
self.mock_nb_device = MagicMock()
|
||||
self.mock_nb_device.id = 123
|
||||
self.mock_nb_device.name = "test-device"
|
||||
self.mock_nb_device.status.label = "Active"
|
||||
self.mock_nb_device.custom_fields = {"zabbix_hostid": None}
|
||||
self.mock_nb_device.config_context = {}
|
||||
|
||||
# Set up a primary IP
|
||||
primary_ip = MagicMock()
|
||||
primary_ip.address = "192.168.1.1/24"
|
||||
self.mock_nb_device.primary_ip = primary_ip
|
||||
|
||||
# Create mock Zabbix API
|
||||
self.mock_zabbix = MagicMock()
|
||||
self.mock_zabbix.version = "6.0"
|
||||
|
||||
# Mock NetBox journal class
|
||||
self.mock_nb_journal = MagicMock()
|
||||
|
||||
# Create logger mock
|
||||
self.mock_logger = MagicMock()
|
||||
|
||||
# Create PhysicalDevice instance with mocks
|
||||
self.device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
journal=True,
|
||||
logger=self.mock_logger,
|
||||
config={
|
||||
"device_cf": "zabbix_hostid",
|
||||
"template_cf": "zabbix_template",
|
||||
"templates_config_context": False,
|
||||
"templates_config_context_overrule": False,
|
||||
"traverse_regions": False,
|
||||
"traverse_site_groups": False,
|
||||
"inventory_mode": "disabled",
|
||||
"inventory_sync": False,
|
||||
"device_inventory_map": {},
|
||||
},
|
||||
)
|
||||
|
||||
def test_init(self):
|
||||
"""Test the initialization of the PhysicalDevice class."""
|
||||
# Check that basic properties are set correctly
|
||||
self.assertEqual(self.device.name, "test-device")
|
||||
self.assertEqual(self.device.id, 123)
|
||||
self.assertEqual(self.device.status, "Active")
|
||||
self.assertEqual(self.device.ip, "192.168.1.1")
|
||||
self.assertEqual(self.device.cidr, "192.168.1.1/24")
|
||||
|
||||
def test_set_basics_with_special_characters(self):
|
||||
"""Test _setBasics when device name contains special characters."""
|
||||
# Set name with special characters that
|
||||
# will actually trigger the special character detection
|
||||
self.mock_nb_device.name = "test-devïce"
|
||||
|
||||
# We need to patch the search function to simulate finding special characters
|
||||
with patch("netbox_zabbix_sync.modules.device.search") as mock_search:
|
||||
# Make the search function return True to simulate special characters
|
||||
mock_search.return_value = True
|
||||
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# With the mocked search function, the name should be changed to NETBOX_ID format
|
||||
self.assertEqual(device.name, f"NETBOX_ID{self.mock_nb_device.id}")
|
||||
# And visible_name should be set to the original name
|
||||
self.assertEqual(device.visible_name, "test-devïce")
|
||||
# use_visible_name flag should be set
|
||||
self.assertTrue(device.use_visible_name)
|
||||
|
||||
def test_get_templates_context(self):
|
||||
"""Test get_templates_context with valid config."""
|
||||
# Set up config_context with valid template data
|
||||
self.mock_nb_device.config_context = {
|
||||
"zabbix": {"templates": ["Template1", "Template2"]}
|
||||
}
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Test that templates are returned correctly
|
||||
templates = device.get_templates_context()
|
||||
self.assertEqual(templates, ["Template1", "Template2"])
|
||||
|
||||
def test_get_templates_context_with_string(self):
|
||||
"""Test get_templates_context with a string instead of list."""
|
||||
# Set up config_context with a string template
|
||||
self.mock_nb_device.config_context = {"zabbix": {"templates": "Template1"}}
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Test that template is wrapped in a list
|
||||
templates = device.get_templates_context()
|
||||
self.assertEqual(templates, ["Template1"])
|
||||
|
||||
def test_get_templates_context_no_zabbix_key(self):
|
||||
"""Test get_templates_context when zabbix key is missing."""
|
||||
# Set up config_context without zabbix key
|
||||
self.mock_nb_device.config_context = {}
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Test that TemplateError is raised
|
||||
with self.assertRaises(TemplateError):
|
||||
device.get_templates_context()
|
||||
|
||||
def test_get_templates_context_no_templates_key(self):
|
||||
"""Test get_templates_context when templates key is missing."""
|
||||
# Set up config_context without templates key
|
||||
self.mock_nb_device.config_context = {"zabbix": {}}
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Test that TemplateError is raised
|
||||
with self.assertRaises(TemplateError):
|
||||
device.get_templates_context()
|
||||
|
||||
def test_set_template_with_config_context(self):
|
||||
"""Test set_template with templates_config_context=True."""
|
||||
# Set up config_context with templates
|
||||
self.mock_nb_device.config_context = {"zabbix": {"templates": ["Template1"]}}
|
||||
|
||||
# Mock get_templates_context to return expected templates
|
||||
with patch.object(
|
||||
PhysicalDevice, "get_templates_context", return_value=["Template1"]
|
||||
):
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Call set_template with prefer_config_context=True
|
||||
result = device.set_template(
|
||||
prefer_config_context=True, overrule_custom=False
|
||||
)
|
||||
|
||||
# Check result and template names
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(device.zbx_template_names, ["Template1"])
|
||||
|
||||
def test_set_inventory_disabled_mode(self):
|
||||
"""Test set_inventory with inventory_mode=disabled."""
|
||||
# Configure with disabled inventory mode
|
||||
config_patch = {
|
||||
"device_cf": "zabbix_hostid",
|
||||
"inventory_mode": "disabled",
|
||||
"inventory_sync": False,
|
||||
}
|
||||
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config=config_patch,
|
||||
)
|
||||
result = device.set_inventory({})
|
||||
|
||||
# Check result
|
||||
self.assertTrue(result)
|
||||
# Default value for disabled inventory
|
||||
self.assertEqual(device.inventory_mode, -1)
|
||||
|
||||
def test_set_inventory_manual_mode(self):
|
||||
"""Test set_inventory with inventory_mode=manual."""
|
||||
# Configure with manual inventory mode
|
||||
config_patch = {
|
||||
"device_cf": "zabbix_hostid",
|
||||
"inventory_mode": "manual",
|
||||
"inventory_sync": False,
|
||||
}
|
||||
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config=config_patch,
|
||||
)
|
||||
result = device.set_inventory({})
|
||||
|
||||
# Check result
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(device.inventory_mode, 0) # Manual mode
|
||||
|
||||
def test_set_inventory_automatic_mode(self):
|
||||
"""Test set_inventory with inventory_mode=automatic."""
|
||||
# Configure with automatic inventory mode
|
||||
config_patch = {
|
||||
"device_cf": "zabbix_hostid",
|
||||
"inventory_mode": "automatic",
|
||||
"inventory_sync": False,
|
||||
}
|
||||
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config=config_patch,
|
||||
)
|
||||
result = device.set_inventory({})
|
||||
|
||||
# Check result
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(device.inventory_mode, 1) # Automatic mode
|
||||
|
||||
def test_set_inventory_with_inventory_sync(self):
|
||||
"""Test set_inventory with inventory_sync=True."""
|
||||
# Configure with inventory sync enabled
|
||||
config_patch = {
|
||||
"device_cf": "zabbix_hostid",
|
||||
"inventory_mode": "manual",
|
||||
"inventory_sync": True,
|
||||
"device_inventory_map": {"name": "name", "serial": "serialno_a"},
|
||||
}
|
||||
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config=config_patch,
|
||||
)
|
||||
|
||||
# Create a mock device with the required attributes
|
||||
mock_device_data = {"name": "test-device", "serial": "ABC123"}
|
||||
result = device.set_inventory(mock_device_data)
|
||||
|
||||
# Check result
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(device.inventory_mode, 0) # Manual mode
|
||||
self.assertEqual(
|
||||
device.inventory, {"name": "test-device", "serialno_a": "ABC123"}
|
||||
)
|
||||
|
||||
def test_iscluster_true(self):
|
||||
"""Test isCluster when device is part of a cluster."""
|
||||
# Set up virtual_chassis
|
||||
self.mock_nb_device.virtual_chassis = MagicMock()
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Check isCluster result
|
||||
self.assertTrue(device.is_cluster())
|
||||
|
||||
def test_is_cluster_false(self):
|
||||
"""Test isCluster when device is not part of a cluster."""
|
||||
# Set virtual_chassis to None
|
||||
self.mock_nb_device.virtual_chassis = None
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
config={"device_cf": "zabbix_hostid"},
|
||||
)
|
||||
|
||||
# Check isCluster result
|
||||
self.assertFalse(device.is_cluster())
|
||||
|
||||
def test_promote_master_device_primary(self):
|
||||
"""Test promoteMasterDevice when device is primary in cluster."""
|
||||
# Set up virtual chassis with master device
|
||||
mock_vc = MagicMock()
|
||||
mock_vc.name = "virtual-chassis-1"
|
||||
mock_master = MagicMock()
|
||||
mock_master.id = (
|
||||
self.mock_nb_device.id
|
||||
) # Set master ID to match the current device
|
||||
mock_vc.master = mock_master
|
||||
self.mock_nb_device.virtual_chassis = mock_vc
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
)
|
||||
|
||||
# Call promoteMasterDevice and check the result
|
||||
result = device.promote_primary_device()
|
||||
|
||||
# Should return True for primary device
|
||||
self.assertTrue(result)
|
||||
# Device name should be updated to virtual chassis name
|
||||
self.assertEqual(device.name, "virtual-chassis-1")
|
||||
|
||||
def test_promote_master_device_secondary(self):
|
||||
"""Test promoteMasterDevice when device is secondary in cluster."""
|
||||
# Set up virtual chassis with a different master device
|
||||
mock_vc = MagicMock()
|
||||
mock_vc.name = "virtual-chassis-1"
|
||||
mock_master = MagicMock()
|
||||
mock_master.id = (
|
||||
self.mock_nb_device.id + 1
|
||||
) # Different ID than the current device
|
||||
mock_vc.master = mock_master
|
||||
self.mock_nb_device.virtual_chassis = mock_vc
|
||||
|
||||
# Create device with the updated mock
|
||||
device = PhysicalDevice(
|
||||
self.mock_nb_device,
|
||||
self.mock_zabbix,
|
||||
self.mock_nb_journal,
|
||||
"3.0",
|
||||
logger=self.mock_logger,
|
||||
)
|
||||
|
||||
# Call promoteMasterDevice and check the result
|
||||
result = device.promote_primary_device()
|
||||
|
||||
# Should return False for secondary device
|
||||
self.assertFalse(result)
|
||||
# Device name should not be modified
|
||||
self.assertEqual(device.name, "test-device")
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Tests for the ZabbixTags class in the tags module."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from netbox_zabbix_sync.modules.tags import ZabbixTags
|
||||
|
||||
|
||||
class DummyNBForTags:
|
||||
"""Minimal NetBox object that supports field_mapper's dict-style access."""
|
||||
|
||||
def __init__(self, name="test-host", config_context=None, tags=None, site=None):
|
||||
self.name = name
|
||||
self.config_context = config_context or {}
|
||||
self.tags = tags or []
|
||||
# Stored as a plain dict so field_mapper can traverse "site/name"
|
||||
self.site = site if site is not None else {"name": "TestSite"}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return getattr(self, key)
|
||||
|
||||
|
||||
class TestZabbixTagsInit(unittest.TestCase):
|
||||
"""Tests for ZabbixTags initialisation."""
|
||||
|
||||
def test_sync_true_when_tag_sync_enabled(self):
|
||||
"""sync flag should be True when tag_sync=True."""
|
||||
nb = DummyNBForTags()
|
||||
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=MagicMock())
|
||||
self.assertTrue(tags.sync)
|
||||
|
||||
def test_sync_false_when_tag_sync_disabled(self):
|
||||
"""sync flag should be False when tag_sync=False (default)."""
|
||||
nb = DummyNBForTags()
|
||||
tags = ZabbixTags(nb, tag_map={}, logger=MagicMock())
|
||||
self.assertFalse(tags.sync)
|
||||
|
||||
def test_repr_and_str_return_host_name(self):
|
||||
nb = DummyNBForTags(name="my-host")
|
||||
tags = ZabbixTags(nb, tag_map={}, host="my-host", logger=MagicMock())
|
||||
self.assertEqual(repr(tags), "my-host")
|
||||
self.assertEqual(str(tags), "my-host")
|
||||
|
||||
|
||||
class TestRenderTag(unittest.TestCase):
|
||||
"""Tests for ZabbixTags.render_tag()."""
|
||||
|
||||
def setUp(self):
|
||||
nb = DummyNBForTags()
|
||||
self.logger = MagicMock()
|
||||
self.tags = ZabbixTags(
|
||||
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
|
||||
)
|
||||
|
||||
def test_valid_tag_lowercased(self):
|
||||
"""Valid name+value with tag_lower=True should produce lowercase keys."""
|
||||
result = self.tags.render_tag("Site", "Production")
|
||||
self.assertEqual(result, {"tag": "site", "value": "production"})
|
||||
|
||||
def test_valid_tag_not_lowercased(self):
|
||||
"""tag_lower=False should preserve original case."""
|
||||
nb = DummyNBForTags()
|
||||
tags = ZabbixTags(
|
||||
nb, tag_map={}, tag_sync=True, tag_lower=False, logger=self.logger
|
||||
)
|
||||
result = tags.render_tag("Site", "Production")
|
||||
self.assertEqual(result, {"tag": "Site", "value": "Production"})
|
||||
|
||||
def test_invalid_name_none_returns_false(self):
|
||||
"""None as tag name should return False."""
|
||||
result = self.tags.render_tag(None, "somevalue")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_invalid_name_too_long_returns_false(self):
|
||||
"""Name exceeding 256 characters should return False."""
|
||||
long_name = "x" * 257
|
||||
result = self.tags.render_tag(long_name, "somevalue")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_invalid_value_none_returns_false(self):
|
||||
"""None as tag value should return False."""
|
||||
result = self.tags.render_tag("site", None)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_invalid_value_empty_string_returns_false(self):
|
||||
"""Empty string as tag value should return False."""
|
||||
result = self.tags.render_tag("site", "")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_invalid_value_too_long_returns_false(self):
|
||||
"""Value exceeding 256 characters should return False."""
|
||||
long_value = "x" * 257
|
||||
result = self.tags.render_tag("site", long_value)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
class TestGenerateFromTagMap(unittest.TestCase):
|
||||
"""Tests for the field_mapper-driven tag generation path."""
|
||||
|
||||
def setUp(self):
|
||||
self.logger = MagicMock()
|
||||
|
||||
def test_generate_tag_from_field_map(self):
|
||||
"""Tags derived from tag_map fields are lowercased and returned correctly."""
|
||||
nb = DummyNBForTags(name="router01")
|
||||
# "site/name" → nb["site"]["name"] → "TestSite", mapped to tag name "site"
|
||||
tag_map = {"site/name": "site"}
|
||||
tags = ZabbixTags(
|
||||
nb,
|
||||
tag_map=tag_map,
|
||||
tag_sync=True,
|
||||
tag_lower=True,
|
||||
logger=self.logger,
|
||||
)
|
||||
result = tags.generate()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["tag"], "site")
|
||||
self.assertEqual(result[0]["value"], "testsite")
|
||||
|
||||
def test_generate_empty_field_map_produces_no_tags(self):
|
||||
"""An empty tag_map with no context or NB tags should return an empty list."""
|
||||
nb = DummyNBForTags()
|
||||
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
|
||||
result = tags.generate()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_generate_deduplicates_tags(self):
|
||||
"""Duplicate tags produced by the map should be deduplicated."""
|
||||
# Two map entries that resolve to the same tag/value pair
|
||||
nb = DummyNBForTags(name="router01")
|
||||
tag_map = {"site/name": "site", "site/name": "site"} # noqa: F601
|
||||
tags = ZabbixTags(
|
||||
nb,
|
||||
tag_map=tag_map,
|
||||
tag_sync=True,
|
||||
tag_lower=True,
|
||||
logger=self.logger,
|
||||
)
|
||||
result = tags.generate()
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
|
||||
class TestGenerateFromConfigContext(unittest.TestCase):
|
||||
"""Tests for the config_context-driven tag generation path."""
|
||||
|
||||
def setUp(self):
|
||||
self.logger = MagicMock()
|
||||
|
||||
def test_generates_tags_from_config_context(self):
|
||||
"""Tags listed in config_context['zabbix']['tags'] are added correctly."""
|
||||
nb = DummyNBForTags(
|
||||
config_context={
|
||||
"zabbix": {
|
||||
"tags": [
|
||||
{"environment": "production"},
|
||||
{"location": "DC1"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
tags = ZabbixTags(
|
||||
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
|
||||
)
|
||||
result = tags.generate()
|
||||
self.assertEqual(len(result), 2)
|
||||
tag_names = [t["tag"] for t in result]
|
||||
self.assertIn("environment", tag_names)
|
||||
self.assertIn("location", tag_names)
|
||||
|
||||
def test_skips_config_context_tags_with_invalid_values(self):
|
||||
"""Config context tags with None value should be silently dropped."""
|
||||
nb = DummyNBForTags(
|
||||
config_context={
|
||||
"zabbix": {
|
||||
"tags": [
|
||||
{"environment": None}, # invalid value
|
||||
{"location": "DC1"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
tags = ZabbixTags(
|
||||
nb, tag_map={}, tag_sync=True, tag_lower=True, logger=self.logger
|
||||
)
|
||||
result = tags.generate()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["tag"], "location")
|
||||
|
||||
def test_ignores_zabbix_tags_key_missing(self):
|
||||
"""Missing 'tags' key inside config_context['zabbix'] produces no tags."""
|
||||
nb = DummyNBForTags(config_context={"zabbix": {"templates": ["T1"]}})
|
||||
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
|
||||
result = tags.generate()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_ignores_config_context_tags_not_a_list(self):
|
||||
"""Non-list value for config_context['zabbix']['tags'] produces no tags."""
|
||||
nb = DummyNBForTags(config_context={"zabbix": {"tags": "not-a-list"}})
|
||||
tags = ZabbixTags(nb, tag_map={}, tag_sync=True, logger=self.logger)
|
||||
result = tags.generate()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestGenerateFromNetboxTags(unittest.TestCase):
|
||||
"""Tests for the NetBox device tags forwarding path."""
|
||||
|
||||
def setUp(self):
|
||||
self.logger = MagicMock()
|
||||
# Simulate a list of NetBox tag objects (as dicts, matching real API shape)
|
||||
self.nb_tags = [
|
||||
{"name": "ping", "slug": "ping", "display": "ping"},
|
||||
{"name": "snmp", "slug": "snmp", "display": "snmp"},
|
||||
]
|
||||
|
||||
def test_generates_tags_from_netbox_tags_using_name(self):
|
||||
"""NetBox device tags are forwarded using tag_name label and tag_value='name'."""
|
||||
nb = DummyNBForTags(tags=self.nb_tags)
|
||||
tags = ZabbixTags(
|
||||
nb,
|
||||
tag_map={},
|
||||
tag_sync=True,
|
||||
tag_lower=True,
|
||||
tag_name="NetBox",
|
||||
tag_value="name",
|
||||
logger=self.logger,
|
||||
)
|
||||
result = tags.generate()
|
||||
self.assertEqual(len(result), 2)
|
||||
for t in result:
|
||||
self.assertEqual(t["tag"], "netbox")
|
||||
values = {t["value"] for t in result}
|
||||
self.assertIn("ping", values)
|
||||
self.assertIn("snmp", values)
|
||||
|
||||
def test_generates_tags_from_netbox_tags_using_slug(self):
|
||||
"""tag_value='slug' should use the slug field from each NetBox tag."""
|
||||
nb = DummyNBForTags(tags=self.nb_tags)
|
||||
tags = ZabbixTags(
|
||||
nb,
|
||||
tag_map={},
|
||||
tag_sync=True,
|
||||
tag_lower=False,
|
||||
tag_name="NetBox",
|
||||
tag_value="slug",
|
||||
logger=self.logger,
|
||||
)
|
||||
result = tags.generate()
|
||||
values = {t["value"] for t in result}
|
||||
self.assertIn("ping", values)
|
||||
self.assertIn("snmp", values)
|
||||
|
||||
def test_generates_tags_from_netbox_tags_default_value_field(self):
|
||||
"""When tag_value is not a recognised field name, falls back to 'name'."""
|
||||
nb = DummyNBForTags(tags=self.nb_tags)
|
||||
tags = ZabbixTags(
|
||||
nb,
|
||||
tag_map={},
|
||||
tag_sync=True,
|
||||
tag_lower=True,
|
||||
tag_name="NetBox",
|
||||
tag_value="invalid_field", # not display/name/slug → fall back to "name"
|
||||
logger=self.logger,
|
||||
)
|
||||
result = tags.generate()
|
||||
values = {t["value"] for t in result}
|
||||
self.assertIn("ping", values)
|
||||
|
||||
def test_skips_netbox_tags_when_tag_name_not_set(self):
|
||||
"""NetBox tag forwarding is skipped when tag_name is not configured."""
|
||||
nb = DummyNBForTags(tags=self.nb_tags)
|
||||
tags = ZabbixTags(
|
||||
nb,
|
||||
tag_map={},
|
||||
tag_sync=True,
|
||||
tag_lower=True,
|
||||
tag_name=None,
|
||||
logger=self.logger,
|
||||
)
|
||||
result = tags.generate()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
from netbox_zabbix_sync.modules.tools import sanatize_log_output
|
||||
|
||||
|
||||
def test_sanatize_log_output_secrets():
|
||||
data = {
|
||||
"macros": [
|
||||
{"macro": "{$SECRET}", "type": "1", "value": "supersecret"},
|
||||
{"macro": "{$PLAIN}", "type": "0", "value": "notsecret"},
|
||||
]
|
||||
}
|
||||
sanitized = sanatize_log_output(data)
|
||||
assert sanitized["macros"][0]["value"] == "********"
|
||||
assert sanitized["macros"][1]["value"] == "notsecret"
|
||||
|
||||
|
||||
def test_sanatize_log_output_interface_secrets():
|
||||
data = {
|
||||
"interfaceid": 123,
|
||||
"details": {
|
||||
"authpassphrase": "supersecret",
|
||||
"privpassphrase": "anothersecret",
|
||||
"securityname": "sensitiveuser",
|
||||
"community": "public",
|
||||
"other": "normalvalue",
|
||||
},
|
||||
}
|
||||
sanitized = sanatize_log_output(data)
|
||||
# Sensitive fields should be sanitized
|
||||
assert sanitized["details"]["authpassphrase"] == "********"
|
||||
assert sanitized["details"]["privpassphrase"] == "********"
|
||||
assert sanitized["details"]["securityname"] == "********"
|
||||
# Non-sensitive fields should remain
|
||||
assert sanitized["details"]["community"] == "********"
|
||||
assert sanitized["details"]["other"] == "normalvalue"
|
||||
# interfaceid should be removed
|
||||
assert "interfaceid" not in sanitized
|
||||
|
||||
|
||||
def test_sanatize_log_output_interface_macros():
|
||||
data = {
|
||||
"interfaceid": 123,
|
||||
"details": {
|
||||
"authpassphrase": "{$SECRET_MACRO}",
|
||||
"privpassphrase": "{$SECRET_MACRO}",
|
||||
"securityname": "{$USER_MACRO}",
|
||||
"community": "{$SNNMP_COMMUNITY}",
|
||||
},
|
||||
}
|
||||
sanitized = sanatize_log_output(data)
|
||||
# Macro values should not be sanitized
|
||||
assert sanitized["details"]["authpassphrase"] == "{$SECRET_MACRO}"
|
||||
assert sanitized["details"]["privpassphrase"] == "{$SECRET_MACRO}"
|
||||
assert sanitized["details"]["securityname"] == "{$USER_MACRO}"
|
||||
assert sanitized["details"]["community"] == "{$SNNMP_COMMUNITY}"
|
||||
assert "interfaceid" not in sanitized
|
||||
|
||||
|
||||
def test_sanatize_log_output_plain_data():
|
||||
data = {"foo": "bar", "baz": 123}
|
||||
sanitized = sanatize_log_output(data)
|
||||
assert sanitized == data
|
||||
|
||||
|
||||
def test_sanatize_log_output_non_dict():
|
||||
data = [1, 2, 3]
|
||||
sanitized = sanatize_log_output(data)
|
||||
assert sanitized == data
|
||||
@@ -0,0 +1,189 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netbox_zabbix_sync.modules.device import PhysicalDevice
|
||||
from netbox_zabbix_sync.modules.usermacros import ZabbixUsermacros
|
||||
|
||||
|
||||
class DummyNB:
|
||||
def __init__(self, name="dummy", config_context=None, **kwargs):
|
||||
self.name = name
|
||||
self.config_context = config_context or {}
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return getattr(self, key)
|
||||
|
||||
|
||||
class TestUsermacroSync(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.nb = DummyNB(serial="1234")
|
||||
self.logger = MagicMock()
|
||||
self.usermacro_map = {"serial": "{$HW_SERIAL}"}
|
||||
|
||||
def create_mock_device(self, config=None):
|
||||
"""Helper method to create a properly mocked PhysicalDevice"""
|
||||
# Mock the NetBox device with all required attributes
|
||||
mock_nb = MagicMock()
|
||||
mock_nb.id = 1
|
||||
mock_nb.name = "dummy"
|
||||
mock_nb.status.label = "Active"
|
||||
mock_nb.tenant = None
|
||||
mock_nb.config_context = {}
|
||||
mock_nb.primary_ip.address = "192.168.1.1/24"
|
||||
mock_nb.custom_fields = {"zabbix_hostid": None}
|
||||
|
||||
device_config = config if config is not None else {"device_cf": "zabbix_hostid"}
|
||||
|
||||
# Create device with proper initialization
|
||||
device = PhysicalDevice(
|
||||
nb=mock_nb,
|
||||
zabbix=MagicMock(),
|
||||
nb_journal_class=MagicMock(),
|
||||
nb_version="3.0",
|
||||
logger=self.logger,
|
||||
config=device_config,
|
||||
)
|
||||
|
||||
return device
|
||||
|
||||
@patch.object(PhysicalDevice, "_usermacro_map")
|
||||
def test_usermacro_sync_false(self, mock_usermacro_map):
|
||||
mock_usermacro_map.return_value = self.usermacro_map
|
||||
device = self.create_mock_device(
|
||||
config={
|
||||
"usermacro_sync": False,
|
||||
"device_cf": "zabbix_hostid",
|
||||
"tag_sync": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Call set_usermacros
|
||||
result = device.set_usermacros()
|
||||
|
||||
self.assertEqual(device.usermacros, [])
|
||||
self.assertTrue(result is True or result is None)
|
||||
|
||||
@patch("netbox_zabbix_sync.modules.device.ZabbixUsermacros")
|
||||
@patch.object(PhysicalDevice, "_usermacro_map")
|
||||
def test_usermacro_sync_true(self, mock_usermacro_map, mock_usermacros_class):
|
||||
mock_usermacro_map.return_value = self.usermacro_map
|
||||
# Mock the ZabbixUsermacros class to return some test data
|
||||
mock_macros_instance = MagicMock()
|
||||
mock_macros_instance.sync = True # This is important - sync must be True
|
||||
mock_macros_instance.generate.return_value = [
|
||||
{"macro": "{$HW_SERIAL}", "value": "1234"}
|
||||
]
|
||||
mock_usermacros_class.return_value = mock_macros_instance
|
||||
|
||||
device = self.create_mock_device(
|
||||
config={
|
||||
"usermacro_sync": True,
|
||||
"device_cf": "zabbix_hostid",
|
||||
"tag_sync": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Call set_usermacros
|
||||
device.set_usermacros()
|
||||
|
||||
self.assertIsInstance(device.usermacros, list)
|
||||
self.assertGreater(len(device.usermacros), 0)
|
||||
|
||||
@patch("netbox_zabbix_sync.modules.device.ZabbixUsermacros")
|
||||
@patch.object(PhysicalDevice, "_usermacro_map")
|
||||
def test_usermacro_sync_full(self, mock_usermacro_map, mock_usermacros_class):
|
||||
mock_usermacro_map.return_value = self.usermacro_map
|
||||
# Mock the ZabbixUsermacros class to return some test data
|
||||
mock_macros_instance = MagicMock()
|
||||
mock_macros_instance.sync = True # This is important - sync must be True
|
||||
mock_macros_instance.generate.return_value = [
|
||||
{"macro": "{$HW_SERIAL}", "value": "1234"}
|
||||
]
|
||||
mock_usermacros_class.return_value = mock_macros_instance
|
||||
|
||||
device = self.create_mock_device(
|
||||
config={
|
||||
"usermacro_sync": "full",
|
||||
"device_cf": "zabbix_hostid",
|
||||
"tag_sync": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Call set_usermacros
|
||||
device.set_usermacros()
|
||||
|
||||
self.assertIsInstance(device.usermacros, list)
|
||||
self.assertGreater(len(device.usermacros), 0)
|
||||
|
||||
|
||||
class TestZabbixUsermacros(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.nb = DummyNB()
|
||||
self.logger = MagicMock()
|
||||
|
||||
def test_validate_macro_valid(self):
|
||||
macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger)
|
||||
self.assertTrue(macros.validate_macro("{$TEST_MACRO}"))
|
||||
self.assertTrue(macros.validate_macro("{$A1_2.3}"))
|
||||
self.assertTrue(macros.validate_macro("{$FOO:bar}"))
|
||||
|
||||
def test_validate_macro_invalid(self):
|
||||
macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger)
|
||||
self.assertFalse(macros.validate_macro("$TEST_MACRO"))
|
||||
self.assertFalse(macros.validate_macro("{TEST_MACRO}"))
|
||||
self.assertFalse(macros.validate_macro("{$test}")) # lower-case not allowed
|
||||
self.assertFalse(macros.validate_macro(""))
|
||||
|
||||
def test_render_macro_dict(self):
|
||||
macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger)
|
||||
macro = macros.render_macro(
|
||||
"{$FOO}", {"value": "bar", "type": "secret", "description": "desc"}
|
||||
)
|
||||
self.assertEqual(macro["macro"], "{$FOO}")
|
||||
self.assertEqual(macro["value"], "bar")
|
||||
self.assertEqual(macro["type"], "1")
|
||||
self.assertEqual(macro["description"], "desc")
|
||||
|
||||
def test_render_macro_dict_missing_value(self):
|
||||
macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger)
|
||||
result = macros.render_macro("{$FOO}", {"type": "text"})
|
||||
self.assertFalse(result)
|
||||
self.logger.info.assert_called()
|
||||
|
||||
def test_render_macro_str(self):
|
||||
macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger)
|
||||
macro = macros.render_macro("{$FOO}", "bar")
|
||||
self.assertEqual(macro["macro"], "{$FOO}")
|
||||
self.assertEqual(macro["value"], "bar")
|
||||
self.assertEqual(macro["type"], "0")
|
||||
self.assertEqual(macro["description"], "")
|
||||
|
||||
def test_render_macro_invalid_name(self):
|
||||
macros = ZabbixUsermacros(self.nb, {}, False, logger=self.logger)
|
||||
result = macros.render_macro("FOO", "bar")
|
||||
self.assertFalse(result)
|
||||
self.logger.warning.assert_called()
|
||||
|
||||
def test_generate_from_map(self):
|
||||
nb = DummyNB(memory="bar", role="baz")
|
||||
usermacro_map = {"memory": "{$FOO}", "role": "{$BAR}"}
|
||||
macros = ZabbixUsermacros(nb, usermacro_map, True, logger=self.logger)
|
||||
result = macros.generate()
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]["macro"], "{$FOO}")
|
||||
self.assertEqual(result[1]["macro"], "{$BAR}")
|
||||
|
||||
def test_generate_from_config_context(self):
|
||||
config_context = {"zabbix": {"usermacros": {"{$TEST_MACRO}": "test_value"}}}
|
||||
nb = DummyNB(config_context=config_context)
|
||||
macros = ZabbixUsermacros(nb, {}, True, logger=self.logger)
|
||||
result = macros.generate()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["macro"], "{$TEST_MACRO}")
|
||||
self.assertEqual(result[0]["value"], "test_value")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,384 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igraph"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "texttable" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/be/56bef1919005b4caf1f71522b300d359f7faeb7ae93a3b0baa9b4f146a87/igraph-1.0.0.tar.gz", hash = "sha256:2414d0be2e4d77ee5357807d100974b40f6082bb1bb71988ec46cfb6728651ee", size = 5077105, upload-time = "2025-10-23T12:22:50.127Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/03/3278ad0ceb3ea0e84d8ae3a85bdded4d0e57853aeb802a200feb43847b93/igraph-1.0.0-cp39-abi3-macosx_10_15_x86_64.whl", hash = "sha256:c2cbc415e02523e5a241eecee82319080bf928a70b1ba299f3b3e25bf029b6d4", size = 2257415, upload-time = "2025-10-23T12:22:27.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bc/6281ec7f9baaf71ee57c3b1748da2d3148d15d253e1a03006f204aa68ca5/igraph-1.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a27753cd80680a8f676c2d5a467aaa4a95e510b30748398ec4e4aeb982130e8", size = 2048555, upload-time = "2025-10-23T12:22:29.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/38/3cd6428a4ed4c09a56df05998438e7774fd1d799ee4fb8fc481674f5f7fc/igraph-1.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a55dc3a2a4e3fc3eba42479910c1511bfc3ecb33cdf5f0406891fd85f14b5aee", size = 5314141, upload-time = "2025-10-23T12:22:31.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/da/dd2867c25adbb41563720f14b5fc895c98bf88be682a3faff4f7b3118d2a/igraph-1.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d04c2c76f686fb1f554ee35dfd3085f5e73b7965ba6b4cf06d53e66b1955522", size = 5683134, upload-time = "2025-10-23T12:22:32.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/40/243c118d34ab80382d7009c4dcb99b887384c3d2ce84d29eeac19e2a007a/igraph-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2b52dc1757fff0fed29a9f7a276d971a11db4211569ed78b9eab36288dfcc9d", size = 6211583, upload-time = "2025-10-23T12:22:34.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b7/88f433819c54b496cb0315fce28e658970cb20ff5dbd52a5a605ce2888de/igraph-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:05c79a2a8fca695b2f217a6fa7f2549f896f757d4db41be32a055400cb19cc30", size = 6594509, upload-time = "2025-10-23T12:22:35.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5d/8f7f6f619d374e959aa3664ebc4b24c10abc90c2e8efbed97f2623fadaf5/igraph-1.0.0-cp39-abi3-win32.whl", hash = "sha256:c2bce3cd472fec3dd9c4d8a3ea5b6b9be65fb30edf760beb4850760dd4f2d479", size = 2725406, upload-time = "2025-10-23T12:22:37.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/77/a85b3745cf40a0572bae2de8cd9c2a2a8af78e5cf3e880fc0a249114e609/igraph-1.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:faeff8ede0cf15eb4ded44b0fcea6e1886740146e60504c24ad2da14e0939563", size = 3221663, upload-time = "2025-10-23T12:22:39.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/7e/5df541c37bdf6493035e89c22bd53f30d99b291bcda6c78e9a8afeecec2b/igraph-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:b607cafc24b10a615e713ee96e58208ef27e0764af80140c7cc45d4724a3f2df", size = 2785701, upload-time = "2025-10-23T12:22:41.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netbox-zabbix-sync"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "igraph" },
|
||||
{ name = "pynetbox" },
|
||||
{ name = "zabbix-utils" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "igraph", specifier = ">=1.0.0" },
|
||||
{ name = "pynetbox", specifier = ">=7.6.1" },
|
||||
{ name = "zabbix-utils", specifier = ">=2.0.4" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.14" },
|
||||
{ name = "ty", specifier = ">=0.0.14" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pynetbox"
|
||||
version = "7.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/0a/f0b733d44c4793ee3be0ee142a8ac92cfdd6232f64e4ae2dda256a08fb41/pynetbox-7.6.1.tar.gz", hash = "sha256:8a7ee99b89d08848be134793015afc17c85711a18e8c7e67c353362e1c8d7fc7", size = 92489, upload-time = "2026-01-28T16:50:50.223Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/a11612421017fdd8f93e653ea1505d4e64e9a24de0974c53a36b154cd945/pynetbox-7.6.1-py3-none-any.whl", hash = "sha256:daa064b1cc4e7d871124ddca1e0de3a36e7ff9e0814fb046a90e36024fd59e4b", size = 39319, upload-time = "2026-01-28T16:50:49.234Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "texttable"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zabbix-utils"
|
||||
version = "2.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d6/5e52b23074938833bf37426940965597eb8057bf5860014deda997b3c317/zabbix_utils-2.0.4.tar.gz", hash = "sha256:e46b15c5b51ade4692aa009939372bce68871cf64d6572e96e8cb193cb0590ea", size = 28658, upload-time = "2025-12-17T10:29:50.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/e5/9c655df064fa6fdf1796db5c6e5505b7d19695ce8fda34dab326a84f86cf/zabbix_utils-2.0.4-py3-none-any.whl", hash = "sha256:103e07c54d37c775781d7030788a5f9b2a361420963d7a458feae96892fb4c48", size = 37833, upload-time = "2025-12-17T10:29:48.293Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user