From 4d463fb5b36c7df1c91759c4e862f49474d1e418 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:28:23 -0500 Subject: [PATCH 01/21] Update README with oVirt source information Added oVirt / Red Hat Virtualization as a source type and included relevant installation documentation. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f395b1e7..3dc7d619 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This is a tool to sync data from different sources to a NetBox instance. Available source types: * VMware vCenter Server +* oVirt / Red Hat Virtualization (see [oVirt source](docs/source_ovirt.md)) * [bb-ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) inventory files **IMPORTANT: READ INSTRUCTIONS CAREFULLY BEFORE RUNNING THIS PROGRAM** @@ -38,6 +39,9 @@ This ensures stale objects are removed from NetBox keeping an accurate current s * VMWare vCenter >= 6.0 #### Source: check_redfish (if used) * check_redfish >= 1.2.0 +#### Source: oVirt (if used) +* oVirt / Red Hat Virtualization Engine v4 API +* Optional SDK and native libraries: see [installation and configuration](docs/source_ovirt.md) # Installing * here we assume we install in ```/opt``` From 43b388efd6b865e212f51208036676ceb8c1728a Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:30:39 -0500 Subject: [PATCH 02/21] Add OVirtConfig to source configuration list --- module/config/file_output.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/config/file_output.py b/module/config/file_output.py index 6a002f15..409eab91 100644 --- a/module/config/file_output.py +++ b/module/config/file_output.py @@ -17,6 +17,7 @@ from module.sources.vmware.config import VMWareConfig from module.sources.check_redfish.config import CheckRedfishConfig from module.sources.hetzner.config import HetznerConfig +from module.sources.ovirt.config import OVirtConfig from module.common.logging import get_logger from module.config import default_config_file_path, source_config_section_name from module.config.files import ConfigFile, ConfigFileINI, ConfigFileYAML @@ -35,7 +36,8 @@ class ConfigFileOutput(DescriptionFormatterMixin): source_config_list = [ VMWareConfig, CheckRedfishConfig, - HetznerConfig + HetznerConfig, + OVirtConfig ] header = f"Welcome to the {__description__} configuration file." From f378c1b6d3f2a5d6aa940212bf4cd4f73bc5ea42 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:34:01 -0500 Subject: [PATCH 03/21] Add OVirtHandler to valid sources list --- module/sources/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/sources/__init__.py b/module/sources/__init__.py index 0666da30..691931b2 100644 --- a/module/sources/__init__.py +++ b/module/sources/__init__.py @@ -10,6 +10,7 @@ # define all available sources here from module.sources.vmware.connection import VMWareHandler from module.sources.hetzner.connection import HetznerHandler +from module.sources.ovirt.connection import OVirtHandler from module.sources.check_redfish.import_inventory import CheckRedfish from module.common.logging import get_logger @@ -19,7 +20,7 @@ from module.config import source_config_section_name # list of valid sources -valid_sources = [VMWareHandler, CheckRedfish, HetznerHandler] +valid_sources = [VMWareHandler, CheckRedfish, HetznerHandler, OVirtHandler] def validate_source(source_class_object=None, state="pre"): From 3df2084c8413e23ca6d3f28571d3096bd6108b41 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:34:47 -0500 Subject: [PATCH 04/21] Implement object ownership check for synced sources Added a method to check if an object is synced by another source based on ownership and tags. --- module/sources/common/source_base.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index 7537f514..98541437 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -50,6 +50,14 @@ def implements(cls, source_type): def finish(self): pass + def object_synced_by_other_source(self, nb_object): + """Check the current source or stored tags for ownership by another configured source.""" + if nb_object.source is not None: + return nb_object.source is not self + + other_source_tags = [x.source_tag for x in self.inventory.source_list if x is not self] + return len(set(nb_object.get_tags()).intersection(other_source_tags)) > 0 + def ip_is_primary_ip_of_object(self, ip_object, device_vm_object) -> bool: """ Check if a NBIPAddress object is currently set as primary IPv4 or IPv6 @@ -435,7 +443,8 @@ def add_update_interface(self, interface_object, device_object, interface_data, # skip handling of IPs for VMs with not installed/running guest tools skip_ip_handling = False - if type(device_object) == NBVM and grab(vmware_object,'guest.toolsRunningStatus') != "guestToolsRunning": + if type(device_object) == NBVM and vmware_object is not None and \ + grab(vmware_object, 'guest.toolsRunningStatus') != "guestToolsRunning": log.debug(f"VM '{device_object.name}' guest tool status is 'NotRunning', skipping IP handling") skip_ip_handling = True From 2e45854293ee89214a5a665eeba511de8deaf93d Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:36:47 -0500 Subject: [PATCH 05/21] Add optional dependency for ovirt-engine-sdk-python --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9c284b23..161c1ae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ ] [project.optional-dependencies] +ovirt = [ + "ovirt-engine-sdk-python>=4.6,<5", +] test = [ "pytest>=9.1.1", ] From 37aada357d9e74921c54d5011c63b2ddf108ab72 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:37:34 -0500 Subject: [PATCH 06/21] Add oVirt example configuration to settings Added configuration options for oVirt integration. --- settings-example.ini | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/settings-example.ini b/settings-example.ini index 75b725ff..1826c4da 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -611,4 +611,38 @@ inventory_file_path = /full/path/to/inventory/files ; Hetzner Cloud API token used to authenticate against the Hetzner Cloud API. api_token = +[source/my-ovirt-example] + +; Optional SDK: pip install -r requirements-ovirt.txt +; Full options can be generated with -g; see docs/source_ovirt.md. +;enabled = True +;type = ovirt +url = +username = +password = +;validate_tls_certs = True +;ca_file = /etc/pki/ovirt-engine/ca.pem +;permitted_subnets = 10.0.0.0/8, fd00::/8 +;cluster_site_relation = DC/Production = Main Site +;cluster_include_filter = +;cluster_exclude_filter = +;host_include_filter = +;host_exclude_filter = +;vm_include_filter = +;vm_exclude_filter = +;host_interface_exclude_filter = +;vm_interface_exclude_filter = +;ip_tenant_inheritance_order = device, prefix +;set_primary_ip = when-undefined +;preserve_primary_ips = False +;skip_fhrp_group_ips = False +;skip_vm_comments = False +;skip_vm_platform = False +;set_vm_name_to_uuid = False +;set_source_name_as_cluster_group = False +;strip_host_domain_name = False +;strip_vm_domain_name = False +;host_custom_object_attributes = id +;vm_custom_object_attributes = id + ;EOF From b134b87c265934a3b62a26a864547b82800db47b Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:38:15 -0500 Subject: [PATCH 07/21] Update fmt.Println message from 'Hello' to 'Goodbye' --- uv.lock | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 0af21d5f..7321e20f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 3 -requires-python = ">=3.13" +revision = 5 +requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version < '3.14'", @@ -36,6 +36,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, @@ -106,6 +118,22 @@ version = "3.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, @@ -257,7 +285,7 @@ wheels = [ [[package]] name = "netbox-sync" -version = "1.9.0" +version = "1.9.1" source = { virtual = "." } dependencies = [ { name = "aiodns" }, @@ -280,6 +308,9 @@ dependencies = [ ] [package.optional-dependencies] +ovirt = [ + { name = "ovirt-engine-sdk-python" }, +] test = [ { name = "pytest" }, ] @@ -292,6 +323,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.5.2" }, { name = "hcloud", specifier = "==2.25.1" }, { name = "idna", specifier = "==3.20" }, + { name = "ovirt-engine-sdk-python", marker = "extra == 'ovirt'", specifier = ">=4.6,<5" }, { name = "packaging", specifier = "==26.3" }, { name = "pycares", specifier = "==5.0.1" }, { name = "pycparser", specifier = "==3.0" }, @@ -305,7 +337,16 @@ requires-dist = [ { name = "urllib3", specifier = "==2.8.0" }, { name = "wheel", specifier = "==0.48.0" }, ] -provides-extras = ["test"] +provides-extras = ["ovirt", "test"] + +[[package]] +name = "ovirt-engine-sdk-python" +version = "4.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/74/587919f9e67db50bb44b6ea5ae2761554075eb2f72e3ac3fd4d88660a62f/ovirt_engine_sdk_python-4.6.3.tar.gz", hash = "sha256:9cf0242792b8910cb7b2decc3886db2ccfd779507e46ac1ce2a52573075fd3df", size = 357182, upload-time = "2025-10-15T14:15:20.568Z" } [[package]] name = "packaging" @@ -334,6 +375,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/df/a0/9c823651872e6a0face3f0311de2a40c8bbcb9c8dcb15680bd019ac56ac7/pycares-5.0.1.tar.gz", hash = "sha256:5a3c249c830432631439815f9a818463416f2a8cbdb1e988e78757de9ae75081", size = 652222, upload-time = "2026-01-01T12:37:00.604Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/75/67/e84ba11d3fec3bf1322c3b302c4df13c85e0a1bc48f16d65cd0f59ad9853/pycares-5.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ee551be4f3f3ac814ac8547586c464c9035e914f5122a534d25de147fa745e1", size = 136241, upload-time = "2026-01-01T12:35:25.439Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/50fbb3b4e52b9f1d16a36ffabd051ef8b2106b3f0a0d1c1113904d187a9d/pycares-5.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:252d4e5a52a68f825eaa90e16b595f9baee22c760f51e286ab612c6829b96de3", size = 131069, upload-time = "2026-01-01T12:35:26.293Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ea/f431599f1ac42149ea4768e516db7cdae3a503a6646319ae63ab66da1486/pycares-5.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c1aa549b8c2f2e224215c793d660270778dcba9abc3b85abbc7c41eabe4f1e5", size = 221120, upload-time = "2026-01-01T12:35:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4f/0a7a6c8b3a64ee5149e935c167cd8ba5d1fdd766ec03e273dbc7502f7bea/pycares-5.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db7c9c9f16e8311998667a7488e817f8cbeedec2447bac827c71804663f1437e", size = 252228, upload-time = "2026-01-01T12:35:28.443Z" }, + { url = "https://files.pythonhosted.org/packages/49/3d/7f9fd20e97ee30c4b959f87ab26e47ddcef666e5e7717e45f2245fe9d70a/pycares-5.0.1-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9c4c8bb69bab863f677fa166653bb872bfa5d5a742f1f30bebc2d53b6e71db", size = 239473, upload-time = "2026-01-01T12:35:29.794Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d0/c67967a10abd89529cb9aded9d73f43e5de00cf21243638ef529f6757262/pycares-5.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09ef90da8da3026fcba4ed223bd71e8057608d5b3fec4f5990b52ae1e8c855cc", size = 223831, upload-time = "2026-01-01T12:35:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9a/94aacaf22a20b7d342c8f18bf006be57967beef6319adc668d4d86b627be/pycares-5.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ce193ebd54f4c74538b751ebb0923a9208c234ff180589d4d3cec134c001840e", size = 223963, upload-time = "2026-01-01T12:35:31.691Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e1/3666aab6fc5e7d0c669b981fe0407e6a4b67e4e6a37ac429d440274663d5/pycares-5.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36b9ff18ef231277f99a846feade50b417187a96f742689a9d08b9594e386de4", size = 251813, upload-time = "2026-01-01T12:35:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/ddab5fbc16ad0084a827167ae8628f54c7a55ce6b743585e6f47a5dd527e/pycares-5.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5e40ea4a0ef0c01a02ef7f7390a58c62d237d5ad48d36bc3245e9c2ac181cc22", size = 238181, upload-time = "2026-01-01T12:35:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/05467933e0e5c4e712302a2d7499797bc3029bf4d0d8ffbfe737254482b7/pycares-5.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f323b0ddfd2c7896af6fba4f8851d34d3d13387566aa573d93330fb01cb1038", size = 223552, upload-time = "2026-01-01T12:35:35.076Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e2/14f3837e943d46ee12441fe6aaa418fdb2f698d42e179f368eaa9829744b/pycares-5.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:bdc6bcafb72a97b3cdd529fc87210e59e67feb647a7e138110656023599b84da", size = 117478, upload-time = "2026-01-01T12:35:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3284061f18188d5085338e1f1fd4f03d9c135657acf16f8020b9dd3be5fc/pycares-5.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:f8ef4c70c1edaf022875a8f9ff6c0c064f82831225acc91aa1b4f4d389e2e03a", size = 108889, upload-time = "2026-01-01T12:35:37.135Z" }, { url = "https://files.pythonhosted.org/packages/92/0a/6bd9bdc2d0ee23ff3aabab7747212e2c5323a081b9b745624d62df88f7e9/pycares-5.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d1b2c6b152c65f14d0e12d741fabb78a487f0f0d22773eede8d8cfc97af612b", size = 136242, upload-time = "2026-01-01T12:35:38.372Z" }, { url = "https://files.pythonhosted.org/packages/18/2a/2e9f888fc076cfe7a3493a3c4113e787cc4b4533f531dfb562ac9b04898f/pycares-5.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8c8ffcc9a48cfc296fe1aefc07d2c8e29a7f97e4bb366ce17effea6a38825f70", size = 131070, upload-time = "2026-01-01T12:35:39.262Z" }, { url = "https://files.pythonhosted.org/packages/ec/5b/83b5aaf7b6ed102f63cd768a747b6cb5d4624f2eaecd84868d103b9dbf39/pycares-5.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8efc38c2703e3530b823a4165a7b28d7ce0fdcf41960fb7a4ca834a0f8cfe79", size = 221137, upload-time = "2026-01-01T12:35:40.155Z" }, @@ -381,6 +434,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pycurl" +version = "7.48.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/62/5851dbbaba9b8ba69019ee74213f1c31b0b2b7ba643ad48e9407638b0dea/pycurl-7.48.0.tar.gz", hash = "sha256:b70961a76c412cd34f9cc2c9558e63f89fb37045c59eee396c585b52973be280", size = 320614, upload-time = "2026-09-16T17:55:09.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f7/f54fefc124b9d5db7a903a5c9929662799c7f3f15c824307e35599fd0ec5/pycurl-7.48.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2961681620568cedf38b2231efe7a2b5f14949eb56eb8d7242de83e7ff6681b5", size = 4925157, upload-time = "2026-09-16T17:53:37.988Z" }, + { url = "https://files.pythonhosted.org/packages/72/b2/69118af46fa26788026af740c2f84a0278caa6769bd860198cd95d9aa1d4/pycurl-7.48.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2a3542d263e4269a252b8ec6953a2a46cb25ac4641779524f107253b71450641", size = 4941127, upload-time = "2026-09-16T17:53:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/67cdb96028ca3c0e0de3342bc1fefb0631b905d8b153dd5f3a70f1f74b15/pycurl-7.48.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:038fb9b99786862fa491cc873eb72a2dc8d09155c285cd3733ab2e07819b40c8", size = 6688397, upload-time = "2026-09-16T17:53:42.152Z" }, + { url = "https://files.pythonhosted.org/packages/25/b5/7cc7cfd949efbdc39ca44934f6e94cd3efcb7ad06d4948e485d77bed7e35/pycurl-7.48.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8a5b168d41eea2cf69c522967f5520e8490e117cd217fb8d6e7d7544c81ea51c", size = 6385399, upload-time = "2026-09-16T17:53:44.267Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cc/b2728f69de959c1d4899b7e320880322722ea2274914d06408bb2ac5994a/pycurl-7.48.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe2e993e42f07dc935c757fe22cb4d23a92d73374e0665fd8ef4894b3046719e", size = 17288231, upload-time = "2026-09-16T17:53:46.703Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5d/21b0a88e2f1bc37a0ce3e383a5858f8a24e56fd208ab2fb1caefd2c9f126/pycurl-7.48.0-cp312-cp312-win_arm64.whl", hash = "sha256:37507595b03a7a2e5d98d50774d4f9ce3b12ef9a26f84d61981a05e0a80fb513", size = 17531011, upload-time = "2026-09-16T17:53:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d9/166372a03709117d687ed9fae96a377dcad35f4b9ba770a71cf3aab71d2c/pycurl-7.48.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38b532325c0ba4d0b102c20c9e187022b2f2ae1728fedc2a57d87a01614ebc2a", size = 4925116, upload-time = "2026-09-16T17:53:52.243Z" }, + { url = "https://files.pythonhosted.org/packages/17/19/10ce5f09f59c59e202d87abd78de0889ed894009fafc4bc89a2e6d4a59f6/pycurl-7.48.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1838f2fac5be41ac009a0f14fb4801204d4455dde764398f8481e8f3c594e31f", size = 4940911, upload-time = "2026-09-16T17:53:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fb/4435aa8091e87eac04157fb29cfe99d3aa1ea2fb992ed9136bbec4f685bc/pycurl-7.48.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f3613b4720f7b7b93321bfb8e7fa78f6a7b1bd35793bb10844efb5f86eab2243", size = 6694523, upload-time = "2026-09-16T17:53:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/b6/20/c1b3a90aa44bab3cf5f4f5bddb6ba85b9a57f3d7b95b807b497290f9f92e/pycurl-7.48.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:aaed3cc92d380075909b5b5ed3c385d34fc0364bd0c5f6b984c1148674ae1e57", size = 6389965, upload-time = "2026-09-16T17:53:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/17a805d963eae87ee97b5844554251f3674dc79ef2c77a9b21b11a0a7951/pycurl-7.48.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c3b9d3d4fb7a499f278398d319973ad7e1b1d99f672eb64d1417b01881c4ecd", size = 17288118, upload-time = "2026-09-16T17:54:01.389Z" }, + { url = "https://files.pythonhosted.org/packages/85/8a/d03f9d406f2e63b548ce2c9eaa136cef4c95d4adc5968ddc85fb9e64fa48/pycurl-7.48.0-cp313-cp313-win_arm64.whl", hash = "sha256:70e0b9d1880aa13893712c6929cb9fb60c655e82fad72ca1fbc5716adb06d8c9", size = 17531050, upload-time = "2026-09-16T17:54:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6a/5e4e7e5e9ba143fb3473ee7adfd3242fc873345e9e06126bd421d8e05bac/pycurl-7.48.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:07f1d61dec76475968b2776875d0be1fee02fb7e6098a1c269831623e42a18f1", size = 4924656, upload-time = "2026-09-16T17:54:07.923Z" }, + { url = "https://files.pythonhosted.org/packages/24/d7/43416130504f369686bb6f051d9c072f35641c062982b61ca14efb1b3d89/pycurl-7.48.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:42853e884e1d42adb9571fd797fc478fca8f32a012e468f71f6c9d3597bc2ded", size = 4940661, upload-time = "2026-09-16T17:54:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/1c/de/52c09eee00f8a840894d2e6a07ed773b83ce7f1617586298823467c6c556/pycurl-7.48.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:331c051c7702a7943ff25af22fefcd55c600527a249c5b7790c8325c461595ef", size = 6692879, upload-time = "2026-09-16T17:54:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/d8533778b30b88caf17b5eb36ac6d1f0c4cb8a0019d42af286f9a208e237/pycurl-7.48.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c7ffcd5acf2050287657126467a6f79bea43f536c0a851fe92c068e4b938ce24", size = 6387472, upload-time = "2026-09-16T17:54:14.443Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e3/dc5f19bcb39a7e9dcd9e0135a64699f5039863401df5cf596520e622c331/pycurl-7.48.0-cp314-cp314-win_amd64.whl", hash = "sha256:39ff8b11725caa4d0adb59c8df7ee68384ad3b94167f5710355cb0a1cf4a5b91", size = 18029379, upload-time = "2026-09-16T17:54:17.36Z" }, + { url = "https://files.pythonhosted.org/packages/92/6e/6d1db0ea20c9320dd6b76c42eec0ef7e3a5d84facf29abd83b64dba06402/pycurl-7.48.0-cp314-cp314-win_arm64.whl", hash = "sha256:a7470c81b483197b68eff922019e5111a9d58d6983c5792ee2f7459aca6fb1fc", size = 18282172, upload-time = "2026-09-16T17:54:21.147Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/2ec29df0d4106d7026c58c504c5797799f7c6dade690524d6ce7c38f74c4/pycurl-7.48.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27db3307c7ee51e4b60ff953bcc7cc5ac4e8f37e92879dc1ba633750f58a6025", size = 4928291, upload-time = "2026-09-16T17:54:23.818Z" }, + { url = "https://files.pythonhosted.org/packages/db/93/2e26350d277572015f31c47a0932df33f4882a15b6002ed7c7a1639141c7/pycurl-7.48.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:899fdfb54f281139b7d7e5213192e1d6eedfa37373d39fa7b5d6e523d68ddcaa", size = 4943829, upload-time = "2026-09-16T17:54:25.952Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3b/1466e938b3b241ce4d02f74ea2c1b85cb041afe39a18ed284b41bd75b708/pycurl-7.48.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f12a2fea62f9b803c7c92cd3a327b229c337a8e87a6e7d5e351136e6844ba9b1", size = 6784238, upload-time = "2026-09-16T17:54:28.181Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b5/79c10ba9b634cb3995f2291e67afc70173eb974045ec42588dd21eac791a/pycurl-7.48.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e00cfc1a687df6b95a91d5fe3b4adcd7e5c4599348d21bdb7ab3b809c8a0184a", size = 6470355, upload-time = "2026-09-16T17:54:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/16/1b/7668c130af5f33eb967e46944e444170957c572425b885eb6c89d7d793ac/pycurl-7.48.0-cp314-cp314t-win_amd64.whl", hash = "sha256:72971af5dc4d86b957c3bcb30877389a15aaa94653a145412f074c332d2aeaca", size = 18033821, upload-time = "2026-09-16T17:54:33.424Z" }, + { url = "https://files.pythonhosted.org/packages/16/db/c7e1c2e4868815e5856e5f77ccb4fccaf6f474cea6b5c9ea54d0939c1f0f/pycurl-7.48.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0ae37ba689b520887a41ac39a3f4ca06697fd1f0ea7a1f7be5f26cf657e7dffe", size = 18286746, upload-time = "2026-09-16T17:54:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/8ccc7f423c3216975e9cb1084f6a6abcb678971ced9733f29440761a4d1e/pycurl-7.48.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:b1d32b4cdb433270104b9f3be46d6158c034260396ac0aadf8fc1d3fa638f0b0", size = 4924763, upload-time = "2026-09-16T17:54:39.533Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ce/2fb25e748fccd50bc859a47a6ad0b73fdd641e9621d3d4e513ea176c44e4/pycurl-7.48.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:16f2fd724a7327005d8362806a88bc69753add73e717810361b5645de0b10511", size = 4940719, upload-time = "2026-09-16T17:54:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/68/6b/1eabecb83c8ab4fb5c6111854bff45df7df93c7b801c032ea4029e34254b/pycurl-7.48.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:874290496776909751d52b6c0869c222b398403a100631ac92e1ee440527d108", size = 6696743, upload-time = "2026-09-16T17:54:44.099Z" }, + { url = "https://files.pythonhosted.org/packages/91/48/33763b5c923ac7dea16e1cd1b761244ffcee28be7966eadcb08be52c20b6/pycurl-7.48.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:a9099c31bba526bb4564898fb4ec2e0c23c04a0a0a4a6406ccfedb3666fe28d8", size = 6391471, upload-time = "2026-09-16T17:54:46.423Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/01eb511f166e0372d86f13aa3b921e1e3c1bb417f6bd1b6c2592e3d2876f/pycurl-7.48.0-cp315-cp315-win_amd64.whl", hash = "sha256:5d651bf55b55d70a69200a3a3bbbb39eab9ccc1a5cfdf964afe4d7a92607c7f2", size = 18029421, upload-time = "2026-09-16T17:54:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d5/5ef43b751f7bcc851f7e951ef364869193f8923dff893b7fb001a62c0f5f/pycurl-7.48.0-cp315-cp315-win_arm64.whl", hash = "sha256:4617bc6dedac3a102edd956febc7e743d06daa614cf6f62ccc626fed35ac15cf", size = 18282261, upload-time = "2026-09-16T17:54:52.101Z" }, + { url = "https://files.pythonhosted.org/packages/c6/55/5476065216d5617ae1072afc7ad6f7eaa444fe3a83b14ff6785c4c7b4b4c/pycurl-7.48.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3fc112c22a655e838f08828dd09c6d20f4f60670417ac852e774af06c570eab7", size = 4928016, upload-time = "2026-09-16T17:54:55.061Z" }, + { url = "https://files.pythonhosted.org/packages/ac/07/7cf52e7fd5e2f1c24eb2b73280b8de03640104704fe762859db43a7f6c73/pycurl-7.48.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:dd88350b01b788b8bbc784396a776ed67d555aed252965dbbbf2c43008db5383", size = 4943567, upload-time = "2026-09-16T17:54:57.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/7284352ac2ffb7c633bb868dd91ed1fffff9696684e239d459e09b64249c/pycurl-7.48.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:43eab1febd121f0674a9b3dfb91ff92c54d0de574a7bb37989e96cf887b3f728", size = 6782728, upload-time = "2026-09-16T17:54:59.228Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/ae8bb8d95979cdfe114d5f18b4a1aeb8534feb10525079f8afbc5b696a64/pycurl-7.48.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:74ba8e64799492358cd80dece5a9472ffd141d89f641d0d142ee174d37d9bc6a", size = 6469157, upload-time = "2026-09-16T17:55:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/1e7b2bd9f99aafd40a6bc5d322e252554b118c02c897304a1c601e34a20b/pycurl-7.48.0-cp315-cp315t-win_amd64.whl", hash = "sha256:ca342b5b9af2931e753d54928b27187dd400e9596ab2563888ab5cda547fe08f", size = 18033611, upload-time = "2026-09-16T17:55:03.563Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c7/e5ea11b1b96034da2bfe8386a701ea89cb6296a1c086a9c7464b3bdf9c47/pycurl-7.48.0-cp315-cp315t-win_arm64.whl", hash = "sha256:e8e20a09764d3bdb85a96b812b410bb2d80d6105a3c79e4e355a871d0d6f0d56", size = 18286649, upload-time = "2026-09-16T17:55:06.662Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -432,6 +529,16 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, From 925ee0bd2f437ad3add91378f32a243dacc4b73f Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:40:25 -0500 Subject: [PATCH 08/21] Add oVirt source documentation --- docs/source_ovirt.md | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/source_ovirt.md diff --git a/docs/source_ovirt.md b/docs/source_ovirt.md new file mode 100644 index 00000000..3646851e --- /dev/null +++ b/docs/source_ovirt.md @@ -0,0 +1,62 @@ +# Source: Ovirt + +## Installation + +The SDK is optional: installations using other sources do not need it. Selecting +an oVirt source without the SDK logs an error and skips that source. + +On Debian/Ubuntu, install the SDK's build dependencies before installing Python +packages: + +```sh +sudo apt-get install gcc python3-dev libxml2-dev libcurl4-openssl-dev +pip install -r requirements-ovirt.txt +``` + +With uv, use `uv sync --extra ovirt` after installing the native libraries. +The SDK contains a native extension; a compiler and Python development headers +matching your Python version are needed when no wheel is available. The runtime +needs libxml2 and libcurl. + +For Docker, enable the optional source in both build stages: + +```sh +docker build --build-arg INSTALL_OVIRT=true -t netbox-sync:ovirt . +``` + +## Configuration + +```ini +[source/production-ovirt] +type = ovirt +url = https://engine.example.com/ovirt-engine/api +username = inventory@internal +password = replace-me +validate_tls_certs = True +ca_file = /etc/pki/ovirt-engine/ca.pem +permitted_subnets = 10.0.0.0/8, fd00::/8 +cluster_site_relation = DC/Production = Main Site +``` + +Use an account with read access to datacenters, clusters, hosts, VMs, disks, +interfaces, and reported devices. The existing source options remain supported, +including name filters, site/tenant/role/tag relations, domain stripping, +`set_vm_name_to_uuid`, `skip_vm_comments`, `skip_vm_platform`, and +`host_custom_object_attributes` / `vm_custom_object_attributes` (dot-separated +SDK attribute paths). Generate a full example with `python netbox-sync.py -g`. + +Guest IP discovery requires the guest agent to report network devices. VMs +without agent data still receive their configured interfaces and disks. As with +other sources, bare guest IPs need a matching prefix in NetBox to determine their +prefix length. Only addresses allowed by `permitted_subnets` are imported. +`ip_tenant_inheritance_order`, `preserve_primary_ips`, `skip_fhrp_group_ips`, and +interface exclusion filters use the current shared network-sync behavior. + +NetBox 4.2+ cluster sites use scoped relations and interfaces use MAC address +objects. NetBox 3.7+ disks are separate virtual disk objects, with the size units +appropriate to the NetBox version. Older versions retain aggregate VM disk size. +Missing CPU or disk size data is not replaced with fabricated values. + +Begin with a dry run (`python netbox-sync.py -n`) and inspect the log before +allowing writes. Unit tests use a fake SDK and the real in-memory NetBox +inventory; they do not replace validation against your live oVirt Engine. From 5d29e2927e6be33cf8824c0c9378d37aac7bd040 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:41:34 -0500 Subject: [PATCH 09/21] Add OVirtConfig class for oVirt API integration This file defines the OVirtConfig class, which manages configuration options for connecting to the oVirt API, including authentication, TLS settings, and filtering options for syncing with NetBox. --- module/sources/ovirt/config.py | 377 +++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 module/sources/ovirt/config.py diff --git a/module/sources/ovirt/config.py b/module/sources/ovirt/config.py new file mode 100644 index 00000000..757c91ad --- /dev/null +++ b/module/sources/ovirt/config.py @@ -0,0 +1,377 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2023 Ricardo Bartels. All rights reserved. +# +# netbox-sync.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +import re +from ipaddress import ip_address + +from module.common.misc import quoted_split +from module.config import source_config_section_name +from module.config.base import ConfigBase +from module.config.option import ConfigOption +from module.config.group import ConfigOptionGroup +from module.sources.common.config import * +from module.sources.common.permitted_subnets import PermittedSubnets +from module.common.logging import get_logger +from module.common.support import normalize_mac_address + +log = get_logger() + + +class OVirtConfig(ConfigBase): + + section_name = source_config_section_name + source_name = None + source_name_example = "my-ovirt-example" + + def __init__(self): + self.options = [ + ConfigOption(**config_option_enabled_definition), + + ConfigOption(**{**config_option_type_definition, "config_example": "ovirt"}), + + ConfigOption("url", + str, + description="host name / IP address of the oVirt API", + config_example="https://engine40.example.com/ovirt-engine/api", + mandatory=True), + + ConfigOption("username", + str, + description="username to use to log into oVirt", + config_example="ovirtuser", + mandatory=True), + + ConfigOption("password", + str, + description="password to use to log into oVirt", + config_example="supersecret", + sensitive=True, + mandatory=True), + + ConfigOption("ca_file", + str, + description="path to the CA file for oVirt", + config_example="ca.pem"), + + ConfigOption("validate_tls_certs", + bool, + description="""Enforces TLS certificate validation. + If oVirt uses a valid TLS certificate then this option should be set + to 'true' to ensure a secure connection.""", + default_value=False), + + ConfigOption(**config_option_permitted_subnets_definition), + ConfigOption(**config_option_ip_tenant_inheritance_order_definition), + ConfigOption("preserve_primary_ips", bool, default_value=False, + description="Keep primary IPs when no longer reported by the guest agent."), + ConfigOption("skip_fhrp_group_ips", bool, default_value=False, + description="Do not reassign IPs belonging to an FHRP group."), + ConfigOption("disable_vlan_sync", bool, default_value=False, + description="Do not create newly discovered VLANs."), + + ConfigOptionGroup(title="filter", + description="""filters can be used to include/exclude certain objects from importing + into NetBox. Include filters are checked first and exclude filters after. + An object name has to pass both filters to be synced to NetBox. + If a filter is unset it will be ignored. Filters are all treated as regex expressions! + If more then one expression should match, a '|' needs to be used + """, + config_example="""Example: (exclude all VMs with "replica" in their name + and all VMs starting with "backup"): vm_exclude_filter = .*replica.*|^backup.*""", + options=[ + ConfigOption("cluster_exclude_filter", + str, + description="""If a cluster is excluded from sync then ALL VMs and HOSTS + inside the cluster will be ignored! a cluster can be specified + as "Cluster-name" or "Datacenter-name/Cluster-name" if + multiple clusters have the same name"""), + ConfigOption("cluster_include_filter", str), + ConfigOption("host_exclude_filter", + str, + description="""This will only include/exclude the host, + not the VM if Host is part of a multi host cluster"""), + ConfigOption("host_include_filter", str), + ConfigOption("vm_exclude_filter", + str, description="simply include/exclude VMs"), + ConfigOption("vm_include_filter", str), + ConfigOption("host_interface_exclude_filter", str), + ConfigOption("vm_interface_exclude_filter", str) + ]), + ConfigOptionGroup(title="relations", + options=[ + ConfigOption("cluster_site_relation", + str, + description="""\ + This option defines which oVirt cluster is part of a NetBox site. + This is done with a comma separated key = value list. + key: defines the cluster name as regex + value: defines the NetBox site name (use quotes if name contains commas) + This is a quite important config setting as IP addresses, prefixes, VLANs + and VRFs are site dependent. In order to assign the correct prefix to an IP + address it is important to pick the correct site. + A VM always depends on the cluster site relation + a cluster can be specified as "Cluster-name" or + "Datacenter-name/Cluster-name" if multiple clusters have the same name + """, + config_example="Cluster_NYC = New York, Cluster_FFM.* = Frankfurt, Datacenter_TOKIO/.* = Tokio"), + ConfigOption("host_site_relation", + str, + description="""Same as cluster site but on host level. + If unset it will fall back to cluster_site_relation""", + config_example="nyc02.* = New York, ffm01.* = Frankfurt"), + ConfigOption("cluster_tenant_relation", + str, + description="""\ + This option defines which cluster/host/VM belongs to which tenant. + This is done with a comma separated key = value list. + key: defines a hosts/VM name as regex + value: defines the NetBox tenant name (use quotes if name contains commas) + a cluster can be specified as "Cluster-name" or + "Datacenter-name/Cluster-name" if multiple clusters have the same name + """, + config_example="Cluster_NYC.* = Customer A"), + ConfigOption("host_tenant_relation", str, config_example="esxi300.* = Infrastructure"), + ConfigOption("vm_tenant_relation", str, config_example="grafana.* = Infrastructure"), + ConfigOption("vm_platform_relation", + str, + description="""\ + This option defines custom platforms if the VMWare created platforms are not suitable. + Pretty much a mapping of VMWare platform name to your own platform name. + This is done with a comma separated key = value list. + key: defines a VMWare returned platform name + value: defines the desired NetBox platform name""", + config_example="centos-7.* = centos7, microsoft-windows-server-2016.* = Windows2016"), + ConfigOption("host_role_relation", + str, + description="""\ + Define the NetBox device role used for hosts. The default is + set to "Server". This is done with a comma separated key = value list. + key: defines host(s) name as regex + value: defines the NetBox role name (use quotes if name contains commas) + """, + default_value=".* = Server"), + ConfigOption("vm_role_relation", + str, + description="""\ + Define the NetBox device role used for VMs. This is done with a + comma separated key = value list, same as 'host_role_relation'. + key: defines VM(s) name as regex + value: defines the NetBox role name (use quotes if name contains commas) + """, + config_example=".* = Server"), + ConfigOption("cluster_tag_relation", + str, + description="""\ + Define NetBox tags which are assigned to a cluster, host or VM. This is + done with a comma separated key = value list. + key: defines a hosts/VM name as regex + value: defines the NetBox tag (use quotes if name contains commas) + a cluster can be specified as "Cluster-name" or + "Datacenter-name/Cluster-name" if multiple clusters have the same name""", + config_example="Cluster_NYC.* = Infrastructure"), + ConfigOption("host_tag_relation", str, config_example="esxi300.* = Infrastructure"), + ConfigOption("vm_tag_relation", str, config_example="grafana.* = Infrastructure") + ]), + ConfigOption("dns_name_lookup", + bool, + description="""Perform a reverse lookup for all collected IP addresses. + If a dns name was found it will be added to the IP address object in NetBox + """, + default_value=True), + ConfigOption("custom_dns_servers", + str, + description="use custom DNS server to do the reverse lookups", + config_example="192.168.1.11, 192.168.1.12"), + ConfigOption("set_primary_ip", + str, + description="""\ + define how the primary IPs should be set + possible values: + + always: will remove primary IP from the object where this address is + currently set as primary and moves it to new object + + when-undefined: + only sets primary IP if undefined, will cause ERRORs if same IP is + assigned more then once to different hosts and IP is set as the + objects primary IP + + never: don't set any primary IPs, will cause the same ERRORs + as "when-undefined" + """, + default_value="when-undefined"), + ConfigOption("skip_vm_comments", + bool, + description="do not set notes to the UUID or name of the VM", + default_value=False), + ConfigOption("skip_vm_platform", + bool, + description="do not sync the VM operating system to the NetBox platform", + default_value=False), + ConfigOption("strip_host_domain_name", + bool, + description="strip domain part from host name before syncing device to NetBox", + default_value=False), + ConfigOption("strip_vm_domain_name", + bool, + description="strip domain part from VM name before syncing VM to NetBox", + default_value=False), + ConfigOptionGroup(title="custom object attributes", + description="""\ + add arbitrary host/vm object attributes as custom fields to NetBox. + multiple attributes can be defined comma separated. + to get a list of available attributes use '-l DEBUG3' as cli param (CAREFUL: output might be long) + and here 'https://gist.github.com/bb-Ricardo/538768487bdac4efafabe56e005cb4ef' can be seen how to + access these attributes + """, + options=[ + ConfigOption("host_custom_object_attributes", + str, + config_example="uuid"), + ConfigOption("vm_custom_object_attributes", + str, + config_example="uuid") + ]), + ConfigOption("set_source_name_as_cluster_group", + bool, + description="""this will set the sources name as cluster group name instead of the datacenter. + This works if the oVirt CP has ONLY ONE datacenter configured. + Otherwise it will rename all datacenters to the source name!""", + default_value=False), + ConfigOption("set_vm_name_to_uuid", + bool, + description="Set the name in Netbox to the VM UUID instead of name", + default_value=False), + + # removed settings + ConfigOption("netbox_host_device_role", + str, + deprecation_message="You need to switch to 'host_role_relation'.", + removed=True), + ConfigOption("netbox_vm_device_role", + str, + deprecation_message="You need to switch to 'vm_role_relation'.", + removed=True), + ConfigOption("sync_tags", + bool, + deprecation_message="You need to switch to 'host_tag_source', " + + "'vm_tag_source' or 'cluster_tag_source'", + removed=True), + ConfigOption("sync_parent_tags", + bool, + deprecation_message="You need to switch to 'host_tag_source', " + + "'vm_tag_source' or 'cluster_tag_source'", + removed=True) + ] + + super().__init__() + + def validate_options(self): + + for option in self.options: + + if option.value is None: + continue + + if "filter" in option.key: + + re_compiled = None + try: + re_compiled = re.compile(option.value) + except re.error as e: + log.error(f"Problem parsing regular expression for '{self.source_name}.{option.key}': {e}") + self.set_validation_failed() + + option.set_value(re_compiled) + + continue + + if "relation" in option.key: + + relation_data = list() + + relation_type = option.key.split("_")[1] + + for relation in quoted_split(option.value): + + object_name, separator, relation_name = relation.partition("=") + object_name = object_name.strip(' "') + relation_name = relation_name.strip(' "') + + if not separator or len(object_name) == 0 or len(relation_name) == 0: + log.error(f"Config option '{relation}' malformed got '{object_name}' for " + f"object name and '{relation_name}' for {relation_type} name.") + self.set_validation_failed() + continue + + try: + re_compiled = re.compile(object_name) + except re.error as e: + log.error(f"Problem parsing regular expression '{object_name}' for '{relation}': {e}") + self.set_validation_failed() + continue + + relation_data.append({ + "object_regex": re_compiled, + "assigned_name": relation_name + }) + + option.set_value(relation_data) + + continue + + if "custom_object_attributes" in option.key: + + option.set_value(quoted_split(option.value)) + + continue + + if option.key == "ip_tenant_inheritance_order": + order = quoted_split(option.value) + if len(order) > 2 or any(item not in ("device", "prefix", "disabled") for item in order): + log.error(f"Invalid IP tenant inheritance order '{option.value}' for '{self.source_name}'.") + self.set_validation_failed() + option.set_value(order) + continue + + if option.key == "set_primary_ip": + if option.value not in ["always", "when-undefined", "never"]: + log.error(f"Primary IP option '{option.key}' value '{option.value}' invalid.") + self.set_validation_failed() + + if option.key == "custom_dns_servers": + + dns_name_lookup = self.get_option_by_name("dns_name_lookup") + + if not isinstance(dns_name_lookup, ConfigOption) or dns_name_lookup.value is False: + continue + + custom_dns_servers = quoted_split(option.value) + + tested_custom_dns_servers = list() + for custom_dns_server in custom_dns_servers: + try: + tested_custom_dns_servers.append(str(ip_address(custom_dns_server))) + except ValueError: + log.error(f"Config option 'custom_dns_servers' value '{custom_dns_server}' " + f"does not appear to be an IP address.") + self.set_validation_failed() + + option.set_value(tested_custom_dns_servers) + + continue + + permitted_subnets_option = self.get_option_by_name("permitted_subnets") + + if permitted_subnets_option is not None: + permitted_subnets = PermittedSubnets(permitted_subnets_option.value) + if permitted_subnets.validation_failed is True: + self.set_validation_failed() + + permitted_subnets_option.set_value(permitted_subnets) From 774afac7c82f424635da350772c63cb919854ea3 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:42:36 -0500 Subject: [PATCH 10/21] Add Connection for Ovirt source --- module/sources/ovirt/connection.py | 1300 ++++++++++++++++++++++++++++ 1 file changed, 1300 insertions(+) create mode 100644 module/sources/ovirt/connection.py diff --git a/module/sources/ovirt/connection.py b/module/sources/ovirt/connection.py new file mode 100644 index 00000000..c2b7be3e --- /dev/null +++ b/module/sources/ovirt/connection.py @@ -0,0 +1,1300 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2021 Ricardo Bartels. All rights reserved. +# +# netbox-sync.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +import pprint +from ipaddress import ip_address, ip_interface + +from packaging import version +try: + import ovirtsdk4 as sdk + import ovirtsdk4.types as types +except ModuleNotFoundError as exc: + if exc.name != "ovirtsdk4": + raise + sdk = None + types = None + +from module.sources.common.source_base import SourceBase +from module.sources.ovirt.config import OVirtConfig +from module.common.logging import get_logger, DEBUG3 +from module.common.misc import grab, dump, get_string_or_none +from module.common.support import normalize_mac_address +from module.netbox.inventory import NetBoxInventory +from module.netbox import * + +log = get_logger() + + +# noinspection PyTypeChecker +class OVirtHandler(SourceBase): + """ + Source class to import data from a oVirt instance and add/update NetBox objects based on gathered information + """ + + dependent_netbox_objects = [ + NBTag, + NBManufacturer, + NBDeviceType, + NBPlatform, + NBClusterType, + NBClusterGroup, + NBDeviceRole, + NBSite, + NBCluster, + NBDevice, + NBVM, + NBVMInterface, + NBInterface, + NBIPAddress, + NBPrefix, + NBTenant, + NBVRF, + NBVLAN, + NBCustomField, + NBMACAddress, + NBVirtualDisk + ] + + source_type = "ovirt" + + # internal vars + session = None + tag_session = None + + site_name = None + + def __init__(self, name=None): + + if name is None: + raise ValueError(f"Invalid value for attribute 'name': '{name}'.") + + self.inventory = NetBoxInventory() + self.name = name + + # parse settings + settings_handler = OVirtConfig() + settings_handler.source_name = self.name + self.settings = settings_handler.parse() + + self.set_source_tag() + self.site_name = f"oVirt: {name}" + + if self.settings.enabled is False: + log.info(f"Source '{name}' is currently disabled. Skipping") + return + + self.create_ovirt_session() + + if self.session is None: + log.info(f"Source '{name}' is currently unavailable. Skipping") + return + + self.init_successful = True + self.permitted_clusters = dict() + self.processed_host_names = dict() + self.processed_vm_names = dict() + self.processed_vm_uuid = list() + self.object_cache = dict() + self.parsing_vms_the_first_time = True + self.disk_cache = dict() + + def create_ovirt_session(self): + """ + Initialize session with oVirt + + Returns + ------- + bool: if initialization was successful or not + """ + + if self.session is not None: + return True + + if sdk is None: + log.error("The oVirt SDK is missing. Install dependencies from requirements-ovirt.txt.") + return False + + log.debug(f"Starting oVirt connection to '{self.settings.url}'") + + try: + self.session = sdk.Connection( + url=self.settings.url, + username=self.settings.username, + password=self.settings.password, + ca_file=self.settings.ca_file, + insecure=(not self.settings.validate_tls_certs), + debug=False, + log=log, + ) + except Exception as e: + log.error(f"Unable to connect to oVirt instance '{self.settings.url}'. {e}") + return False + + log.info(f"Successfully connected to oVirt '{self.settings.url}'") + + return True + + def finish(self): + if self.session is not None: + self.session.close() + + def apply(self): + """ + Main source handler method. This method is called for each source from "main" program + to retrieve data from it source and apply it to the NetBox inventory. + + Every update of new/existing objects fot this source has to happen here. + """ + + log.info(f"Query data from oVirt: '{self.settings.url}'") + + """ + Mapping of object type keywords to view types and handlers + + iterate over all VMs twice. + + To handle VMs with the same name in a cluster we first + iterate over all VMs and look only at the active ones + and sync these first. + Then we iterate a second time to catch the rest. + + This has been implemented to support migration scenarios + where you create the same machines with a different setup + like a new version or something. This way NetBox will be + updated primarily with the actual active VM data. + """ + + system_service = self.session.system_service() + + disks = system_service.disks_service().list() + for disk in disks: + self.disk_cache[disk.id] = disk + + datacenters = system_service.data_centers_service().list() + for datacenter in datacenters: + self.add_datacenter(datacenter) + + clusters = system_service.clusters_service().list() + for cluster in clusters: + self.add_cluster(cluster) + + hosts = system_service.hosts_service().list(follow="nics") + for host in hosts: + self.add_host(host) + + vms = system_service.vms_service().list(follow="nics,disk_attachments,reported_devices") + for vm in sorted(vms, key=lambda item: item.status != types.VmStatus.UP): + self.add_virtual_machine(vm) + + self.update_basic_data() + + @staticmethod + def passes_filter(name, include_filter, exclude_filter): + """ + checks if object name passes a defined object filter. + + Parameters + ---------- + name: str + name of the object to check + include_filter: regex object + regex object of include filter + exclude_filter: regex object + regex object of exclude filter + + Returns + ------- + bool: True if all filter passed, otherwise False + """ + + # first includes + if include_filter is not None and not include_filter.match(name): + log.debug(f"Object '{name}' did not match include filter '{include_filter.pattern}'. Skipping") + return False + + # second excludes + if exclude_filter is not None and exclude_filter.match(name): + log.debug(f"Object '{name}' matched exclude filter '{exclude_filter.pattern}'. Skipping") + return False + + return True + + def get_site_name(self, object_type, object_name, cluster_name=""): + """ + Return a site name for a NBCluster or NBDevice depending on config options + host_site_relation and cluster_site_relation + + Parameters + ---------- + object_type: (NBCluster, NBDevice) + object type to check site relation for + object_name: str + object name to check site relation for + cluster_name: str + cluster name of NBDevice to check for site name + + Returns + ------- + str: site name if a relation was found + """ + + if object_type not in [NBCluster, NBDevice]: + raise ValueError(f"Object must be a '{NBCluster.name}' or '{NBDevice.name}'.") + + log.debug2(f"Trying to find site name for {object_type.name} '{object_name}'") + + # check if site was provided in config + relation_name = "host_site_relation" if object_type == NBDevice else "cluster_site_relation" + + site_name = self.get_object_relation(object_name, relation_name) + + if object_type == NBDevice and site_name is None: + site_name = self.get_site_name(NBCluster, cluster_name) + if site_name is not None: + log.debug2(f"Found a matching cluster site for {object_name}, using site '{site_name}'") + + # set default site name + if site_name is None: + site_name = self.site_name + log.debug(f"No site relation for '{object_name}' found, using default site '{site_name}'") + + return site_name + + def get_object_based_on_macs(self, object_type, mac_list=None): + """ + Try to find a NetBox object based on list of MAC addresses. + + Iterate over all interfaces of this object type and compare MAC address with list of desired MAC + addresses. If match was found store related machine object and count every correct match. + + If exactly one machine with matching interfaces was found then this one will be returned. + + If two or more machines with matching MACs are found compare the two machines with + the highest amount of matching interfaces. If the ration of matching interfaces + exceeds 2.0 then the top matching machine is chosen as desired object. + + If the ration is below 2.0 then None will be returned. The probability is too low that + this one is the correct one. + + None will also be returned if no machine was found at all. + + Parameters + ---------- + object_type: (NBDevice, NBVM) + type of NetBox device to find in inventory + mac_list: list + list of MAC addresses to compare against NetBox interface objects + + Returns + ------- + (NBDevice, NBVM, None): object instance of found device, otherwise None + """ + + object_to_return = None + + if object_type not in [NBDevice, NBVM]: + raise ValueError(f"Object must be a '{NBVM.name}' or '{NBDevice.name}'.") + + if mac_list is None or not isinstance(mac_list, list) or len(mac_list) == 0: + return + + interface_typ = NBInterface if object_type == NBDevice else NBVMInterface + + objects_with_matching_macs = dict() + matching_object = None + + for interface in self.inventory.get_all_items(interface_typ): + + if version.parse(self.inventory.netbox_api_version) >= version.parse("4.2.0"): + mac = grab(grab(interface, "data.primary_mac_address"), "data.mac_address") + else: + mac = grab(interface, "data.mac_address") + if mac in mac_list: + + matching_object = grab(interface, f"data.{interface.secondary_key}") + if not isinstance(matching_object, (NBDevice, NBVM)): + continue + + log.debug2("Found matching MAC '%s' on %s '%s'" % + (mac, object_type.name, + matching_object.get_display_name(including_second_key=True))) + + if objects_with_matching_macs.get(matching_object) is None: + objects_with_matching_macs[matching_object] = 1 + else: + objects_with_matching_macs[matching_object] += 1 + + # try to find object based on amount of matching MAC addresses + num_devices_witch_matching_macs = len(objects_with_matching_macs.keys()) + + if num_devices_witch_matching_macs == 1 and isinstance(matching_object, (NBDevice, NBVM)): + + log.debug2("Found one %s '%s' based on MAC addresses and using it" % + (object_type.name, matching_object.get_display_name(including_second_key=True))) + + object_to_return = list(objects_with_matching_macs.keys())[0] + + elif num_devices_witch_matching_macs > 1: + + log.debug2(f"Found {num_devices_witch_matching_macs} {object_type.name}s with matching MAC addresses") + + # now select the two top matches + first_choice, second_choice = \ + sorted(objects_with_matching_macs, key=objects_with_matching_macs.get, reverse=True)[0:2] + + first_choice_matches = objects_with_matching_macs.get(first_choice) + second_choice_matches = objects_with_matching_macs.get(second_choice) + + log.debug2(f"The top candidate {first_choice.get_display_name()} with {first_choice_matches} matches") + log.debug2(f"The second candidate {second_choice.get_display_name()} with {second_choice_matches} matches") + + # get ratio between + matching_ration = first_choice_matches / second_choice_matches + + # only pick the first one if the ration exceeds 2 + if matching_ration >= 2.0: + log.debug2(f"The matching ratio of {matching_ration} is high enough " + f"to select {first_choice.get_display_name()} as desired {object_type.name}") + object_to_return = first_choice + else: + log.debug2("Both candidates have a similar amount of " + "matching interface MAC addresses. Using NONE of them!") + + return object_to_return + + def get_object_based_on_primary_ip(self, object_type, primary_ip4=None, primary_ip6=None): + """ + Try to find a NBDevice or NBVM based on the primary IP address. If an exact + match was found the device/vm object will be returned immediately without + checking of the other primary IP address (if defined). + + Parameters + ---------- + object_type: (NBDevice, NBVM) + object type to look for + primary_ip4: str + primary IPv4 address of object to find + primary_ip6: str + primary IPv6 address of object to find + + Returns + ------- + + """ + + def _matches_device_primary_ip(device_primary_ip, ip_needle): + + ip = None + if device_primary_ip is not None and ip_needle is not None: + if isinstance(device_primary_ip, dict): + ip = grab(device_primary_ip, "address") + + elif isinstance(device_primary_ip, NBIPAddress): + ip = grab(device_primary_ip, "data.address") + + elif isinstance(device_primary_ip, int): + ip = self.inventory.get_by_id(NBIPAddress, nb_id=device_primary_ip) + ip = grab(ip, "data.address") + + if ip is not None and ip.split("/")[0] == ip_needle: + return True + + return False + + if object_type not in [NBDevice, NBVM]: + raise ValueError(f"Object must be a '{NBVM.name}' or '{NBDevice.name}'.") + + if primary_ip4 is None and primary_ip6 is None: + return + + if primary_ip4 is not None: + primary_ip4 = str(primary_ip4).split("/")[0] + + if primary_ip6 is not None: + primary_ip6 = str(primary_ip6).split("/")[0] + + for device in self.inventory.get_all_items(object_type): + + if _matches_device_primary_ip(grab(device, "data.primary_ip4"), primary_ip4) is True: + log.debug2(f"Found existing host '{device.get_display_name()}' " + f"based on the primary IPv4 '{primary_ip4}'") + return device + + if _matches_device_primary_ip(grab(device, "data.primary_ip6"), primary_ip6) is True: + log.debug2(f"Found existing host '{device.get_display_name()}' " + f"based on the primary IPv6 '{primary_ip6}'") + return device + + def get_object_relation(self, name, relation, fallback=None): + """ + + Parameters + ---------- + name: str + name of the object to find a relation for + relation: str + name of the config variable relation (i.e: vm_tag_relation) + fallback: str + fallback string if no relation matched + + Returns + ------- + data: str, list, None + string of matching relation or list of matching tags + """ + + resolved_list = list() + for single_relation in grab(self.settings, relation, fallback=list()): + object_regex = single_relation.get("object_regex") + match_found = False + if object_regex.match(name): + resolved_name = single_relation.get("assigned_name") + log.debug2(f"Found a matching {relation} '{resolved_name}' ({object_regex.pattern}) for {name}") + resolved_list.append(resolved_name) + match_found = True + + # special cluster condition + if match_found is False and grab(f"{relation}".split("_"), "0") == "cluster": + + stripped_name = "/".join(name.split("/")[1:]) + if object_regex.match(stripped_name): + + resolved_name = single_relation.get("assigned_name") + log.debug2(f"Found a matching {relation} '{resolved_name}' ({object_regex.pattern}) " + f"for {stripped_name}") + resolved_list.append(resolved_name) + + if grab(f"{relation}".split("_"), "1") == "tag": + return resolved_list + + else: + resolved_name = fallback + if len(resolved_list) >= 1: + resolved_name = resolved_list[0] + if len(resolved_list) > 1: + log.debug(f"Found {len(resolved_list)} matches for {name} in {relation}." + f" Using first on: {resolved_name}") + + return resolved_name + + def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, vnic_data=None, + nic_ips=None, p_ipv4=None, p_ipv6=None, ovirt_object=None, + keep_undiscovered_ips=False): + """ + Add/update device/VM object in inventory based on gathered data. + + Try to find object first based on the object data, interface MAC addresses and primary IPs. + 1. try to find by name and cluster/site + 2. try to find by mac addresses interfaces + 3. try to find by primary IP + + IP addresses for each interface are added here as well. First they will be checked and added + if all checks pass. For each IP address a matching IP prefix will be searched for. First we + look for longest matching IP Prefix in the same site. If this failed we try to find the longest + matching global IP Prefix. + + If a IP Prefix was found then we try to get the VRF and VLAN for this prefix. Now we compare + if interface VLAN and prefix VLAN match up and warn if they don't. Then we try to add data to + the IP address if not already set: + + add prefix VRF if VRF for this IP is undefined + add tenant if tenant for this IP is undefined + 1. try prefix tenant + 2. if prefix tenant is undefined try VLAN tenant + + And we also set primary IP4/6 for this object depending on the "set_primary_ip" setting. + + If a IP address is set as primary IP for another device then using this IP on another + device will be rejected by NetBox. + + Setting "always": + check all NBDevice and NBVM objects if this IP address is set as primary IP to any + other object then this one. If we found another object, then we unset the primary_ip* + for the found object and assign it to this object. + + This setting will also reset the primary IP if it has been changed in NetBox + + Setting "when-undefined": + Will set the primary IP for this object if primary_ip4/6 is undefined. Will cause a + NetBox error if IP has been assigned to a different object as well + + Setting "never": + Well, the attribute primary_ip4/6 will never be touched/changed. + + Parameters + ---------- + object_type: (NBDevice, NBVM) + NetBoxObject sub class of object to add + object_data: dict + data of object to add/update + pnic_data: dict + data of physical interfaces of this object, interface name as key + vnic_data: dict + data of virtual interfaces of this object, interface name as key + nic_ips: dict + dict of ips per interface of this object, interface name as key + p_ipv4: str + primary IPv4 as string including netmask/prefix + p_ipv6: str + primary IPv6 as string including netmask/prefix + + """ + + if object_type not in [NBDevice, NBVM]: + raise ValueError(f"Object must be a '{NBVM.name}' or '{NBDevice.name}'.") + + pnic_data = pnic_data or {} + vnic_data = vnic_data or {} + nic_ips = nic_ips or {} + + if log.level == DEBUG3: + + log.debug3("function: add_device_vm_to_inventory") + log.debug3(f"Object type {object_type}") + pprint.pprint(object_data) + pprint.pprint(pnic_data) + pprint.pprint(vnic_data) + pprint.pprint(nic_ips) + pprint.pprint(p_ipv4) + pprint.pprint(p_ipv6) + + # check existing Devices for matches + log.debug2(f"Trying to find a {object_type.name} based on the collected name, cluster, IP and MAC addresses") + + device_vm_object = self.inventory.get_by_data(object_type, data=object_data) + + if device_vm_object is not None: + log.debug2("Found a exact matching %s object: %s" % + (object_type.name, device_vm_object.get_display_name(including_second_key=True))) + + # keep searching if no exact match was found + else: + + log.debug2(f"No exact match found. Trying to find {object_type.name} based on MAC addresses") + + # on VMs vnic data is used, on physical devices pnic data is used + mac_source_data = vnic_data if object_type == NBVM else pnic_data + + nic_macs = [x.get("mac_address") for x in mac_source_data.values() if x.get("mac_address") is not None] + + device_vm_object = self.get_object_based_on_macs(object_type, nic_macs) + + if device_vm_object is not None: + log.debug2("Found a matching %s object: %s" % + (object_type.name, device_vm_object.get_display_name(including_second_key=True))) + + # keep looking for devices with the same primary IP + else: + + log.debug2(f"No match found. Trying to find {object_type.name} based on primary IP addresses") + + device_vm_object = self.get_object_based_on_primary_ip(object_type, p_ipv4, p_ipv6) + + if device_vm_object is None: + object_name = object_data.get(object_type.primary_key) + log.debug(f"No existing {object_type.name} object for {object_name}. Creating a new {object_type.name}.") + device_vm_object = self.inventory.add_object(object_type, data=object_data, source=self) + else: + device_vm_object.update(data=object_data, source=self) + + # add object to cache + self.add_object_to_cache(ovirt_object, device_vm_object) + + # update role according to config settings + object_name = object_data.get(object_type.primary_key) + role_name = self.get_object_relation(object_name, + "host_role_relation" if object_type == NBDevice else "vm_role_relation") + + if object_type == NBDevice: + if role_name is None: + role_name = "Server" + device_vm_object.update(data={"device_role": {"name": role_name}}) + if object_type == NBVM and role_name is not None: + device_vm_object.update(data={"role": {"name": role_name}}) + + # compile all nic data into one dictionary + if object_type == NBVM: + nic_data = vnic_data + else: + nic_data = {**pnic_data, **vnic_data} + + # map interfaces of existing object with discovered interfaces + nic_object_dict = self.map_object_interfaces_to_current_interfaces(device_vm_object, nic_data) + + if object_data.get("status", "") == "active" and (nic_ips is None or len(nic_ips.keys()) == 0): + log.debug(f"No IP addresses for '{object_name}' found!") + + primary_ipv4_object = None + primary_ipv6_object = None + + if p_ipv4 is not None: + try: + primary_ipv4_object = ip_interface(p_ipv4) + except ValueError: + log.error(f"Primary IPv4 ({p_ipv4}) does not appear to be a valid IP address (needs included suffix).") + + if p_ipv6 is not None: + try: + primary_ipv6_object = ip_interface(p_ipv6) + except ValueError: + log.error(f"Primary IPv6 ({p_ipv6}) does not appear to be a valid IP address (needs included suffix).") + + for int_name, int_data in nic_data.items(): + + # add/update interface with retrieved data + nic_object, ip_address_objects = self.add_update_interface(nic_object_dict.get(int_name), device_vm_object, + int_data, nic_ips.get(int_name, list()), + keep_undiscovered_ips=keep_undiscovered_ips) + + # add all interface IPs + for ip_object in ip_address_objects: + + if ip_object is None: + continue + + ip_interface_object = ip_interface(grab(ip_object, "data.address")) + + # continue if address is not a primary IP + if ip_interface_object not in [primary_ipv4_object, primary_ipv6_object]: + continue + + # set/update/remove primary IP addresses + set_this_primary_ip = False + ip_version = ip_interface_object.ip.version + if self.settings.set_primary_ip == "always": + + for object_type in [NBDevice, NBVM]: + + # new IPs don't need to be removed from other devices/VMs + if ip_object.is_new is True: + break + + for devices_vms in self.inventory.get_all_items(object_type): + + # device has no primary IP of this version + this_primary_ip = grab(devices_vms, f"data.primary_ip{ip_version}") + + # we found this exact object + if devices_vms == device_vm_object: + continue + + # device has the same object assigned + if this_primary_ip == ip_object: + devices_vms.unset_attribute(f"primary_ip{ip_version}") + + set_this_primary_ip = True + + elif self.settings.set_primary_ip != "never" and \ + grab(device_vm_object, f"data.primary_ip{ip_version}") is None: + set_this_primary_ip = True + + if set_this_primary_ip is True: + + log.debug(f"Setting IP '{grab(ip_object, 'data.address')}' as primary IPv{ip_version} for " + f"'{device_vm_object.get_display_name()}'") + device_vm_object.update(data={f"primary_ip{ip_version}": ip_object}) + + return device_vm_object + + def add_object_to_cache(self, vm_object, netbox_object): + + if None in [vm_object, netbox_object]: + return + + vm_class_name = vm_object.__class__.__name__ + vm_object_id = vm_object.id + + if self.object_cache.get(vm_class_name) is None: + self.object_cache[vm_class_name] = dict() + + self.object_cache[vm_class_name][vm_object_id] = netbox_object + + def get_object_from_cache(self, vm_object): + + if vm_object is None: + return + + vm_class_name = vm_object.__class__.__name__ + vm_object_id = vm_object.id + + if self.object_cache.get(vm_class_name) is None: + return + + return self.object_cache[vm_class_name].get(vm_object_id) + + def add_datacenter(self, obj): + """ + Add a oVirt datacenter as a NBClusterGroup to NetBox + + Parameters + ---------- + obj: types.DataCenter + datacenter object + + """ + if self.settings.set_source_name_as_cluster_group is True: + name = self.name + else: + name = get_string_or_none(grab(obj, "name")) + + if name is None: + return + + log.debug(f"Parsing oVirt datacenter: {name}") + + object_data = {"name": name} + + if self.settings.set_source_name_as_cluster_group is True: + label = "Datacenter Name" + custom_field = self.add_update_custom_field({ + "name": f"ovirt_{label}", + "label": label, + "object_types": ["virtualization.clustergroup"], + "type": "text", + "description": f"oVirt '{self.name}' synced custom attribute '{label}'" + }) + + object_data["custom_fields"] = { + grab(custom_field, "data.name"): get_string_or_none(grab(obj, "name")) + } + + self.add_object_to_cache(obj, self.inventory.add_update_object(NBClusterGroup, data=object_data, source=self)) + + def add_cluster(self, obj): + """ + Add a oVirt Cluster as a NBCluster to NetBox. Cluster name is checked against + cluster_include_filter and cluster_exclude_filter config setting. Also adds + cluster and site_name to "self.permitted_clusters" so hosts and VMs can be + checked if they are part of a permitted cluster. + + Parameters + ---------- + obj: types.Cluster + cluster to add + """ + + name = get_string_or_none(grab(obj, "name")) + if self.settings.set_source_name_as_cluster_group is True: + group = self.inventory.get_by_data(NBClusterGroup, data={"name": self.name}) + else: + group = self.get_object_from_cache(obj.data_center) + + if name is None or group is None: + return + + group_name = grab(group, "data.name") + full_cluster_name = f"{group_name}/{name}" + + log.debug(f"Parsing oVirt Cluster: {full_cluster_name}") + + # check for full name and then for cluster name only + include = self.settings.cluster_include_filter + exclude = self.settings.cluster_exclude_filter + if (include is not None and not any(include.match(value) for value in (full_cluster_name, name))) or \ + (exclude is not None and any(exclude.match(value) for value in (full_cluster_name, name))): + return + + site_name = self.get_site_name(NBCluster, full_cluster_name) + + data = { + "name": name, + "type": {"name": "oVirt"}, + "group": group + } + if version.parse(self.inventory.netbox_api_version) >= version.parse("4.2.0"): + data["scope_type"] = "dcim.site" + data["scope_id"] = self.inventory.add_update_object(NBSite, data={"name": site_name}) + else: + data["site"] = {"name": site_name} + + tenant_name = self.get_object_relation(full_cluster_name, "cluster_tenant_relation") + if tenant_name is not None: + data["tenant"] = {"name": tenant_name} + + cluster_tags = self.get_object_relation(full_cluster_name, "cluster_tag_relation") + if len(cluster_tags) > 0: + data["tags"] = cluster_tags + + # try to find cluster including cluster group + log.debug2("Trying to find a matching existing cluster") + cluster_object = None + fallback_cluster_object = None + for cluster_candidate in self.inventory.get_all_items(NBCluster): + if grab(cluster_candidate, "data.name") != name: + continue + if cluster_candidate.get_site_name() not in (None, site_name) and \ + self.object_synced_by_other_source(cluster_candidate): + continue + + # try to find a cluster with matching site + if cluster_candidate.get_site_name() == site_name: + cluster_object = cluster_candidate + log.debug2("Found an existing cluster where 'name' and 'site' are matching") + break + + if grab(cluster_candidate, "data.group") is not None and \ + grab(cluster_candidate, "data.group.data.name") == group_name: + cluster_object = cluster_candidate + log.debug2("Found an existing cluster where 'name' and 'cluster group' are matching") + break + + if grab(cluster_candidate, "data.tenant") is not None and \ + tenant_name is not None and \ + grab(cluster_candidate, "data.tenant.data.name") == tenant_name: + cluster_object = cluster_candidate + log.debug2("Found an existing cluster where 'name' and 'tenant' are matching") + break + + # if only the name matches and there are multiple cluster with the same name we choose the first + # cluster returned from netbox. This needs to be done to not ignore possible matches in one of + # the next iterations + if fallback_cluster_object is None: + fallback_cluster_object = cluster_candidate + + if cluster_object is None and fallback_cluster_object is not None: + log.debug2(f"Found an existing cluster where 'name' " + f"matches (NetBox id: {fallback_cluster_object.get_nb_reference()})") + cluster_object = fallback_cluster_object + + if cluster_object is not None: + cluster_object.update(data=data, source=self) + else: + cluster_object = self.inventory.add_update_object(NBCluster, data=data, source=self) + + self.add_object_to_cache(obj, cluster_object) + + def add_host(self, obj): + """ + Parse a oVirt host to NetBox once all data is gathered. + + First host is filtered: + host has a cluster and is it permitted + was host with same name and site already parsed + does the host pass the host_include_filter and host_exclude_filter + + Then all necessary host data will be collected. + + Primary IPv4/6 will be determined by 'address' value + Other IP's are listed in the 'network attachments' + + Parameters + ---------- + obj: types.Host + host object to parse + """ + + name = get_string_or_none(grab(obj, "name")) + + if name is not None and self.settings.strip_host_domain_name is True: + name = name.split(".")[0] + + # parse data + log.debug(f"Parsing oVirt host: {name}") + + # + # Filtering + # + + # manage site and cluster + cluster_object = obj.cluster + + if cluster_object is None: + log.error(f"Requesting cluster for host '{name}' failed. Skipping.") + return + + if log.level == DEBUG3: + try: + log.info("Cluster data") + dump(cluster_object) + except Exception as e: + log.error(e) + + # get cluster object + nb_cluster_object = self.get_object_from_cache(cluster_object) + + if nb_cluster_object is None: + log.debug(f"Host '{name}' is not part of a permitted cluster. Skipping") + return + + cluster_name = get_string_or_none(grab(nb_cluster_object, "data.name")) + + # get a site for this host + if self.settings.set_source_name_as_cluster_group is True: + group = self.inventory.get_by_data(NBClusterGroup, data={"name": self.name}) + else: + group = grab(nb_cluster_object, "data.group") + group_name = grab(group, "data.name") + site_name = self.get_site_name(NBDevice, name, f"{group_name}/{cluster_name}") + + if name in self.processed_host_names.get(site_name, list()): + log.warning(f"Host '{name}' for site '{site_name}' already parsed. " + "Make sure to use unique host names. Skipping") + return + + # add host to processed list + if self.processed_host_names.get(site_name) is None: + self.processed_host_names[site_name] = list() + + self.processed_host_names[site_name].append(name) + + # filter hosts by name + if self.passes_filter(name, self.settings.host_include_filter, self.settings.host_exclude_filter) is False: + return + + # + # Collecting data + # + + # collect all necessary data + manufacturer = get_string_or_none(grab(obj, "hardware_information.manufacturer")) + model = get_string_or_none(grab(obj, "hardware_information.product_name")) + platform = " ".join(str(value) for value in ( + grab(obj, "os.type"), grab(obj, "os.version.full_version")) if value is not None) or None + + # if the device vendor/model cannot be retrieved (due to problem on the host), + # set a dummy value so the host still gets synced + if manufacturer is None: + manufacturer = "Generic Vendor" + if model is None: + model = "Generic Model" + + # get status + status = "offline" + if obj.status == types.HostStatus.UP: + status = "active" + + # try to find serial + serial = get_string_or_none(grab(obj, "hardware_information.uuid")) + + # add asset tag if desired and present + asset_tag = get_string_or_none(grab(obj, "hardware_information.serial_number")) + + # get host_tenant_relation + tenant_name = self.get_object_relation(name, "host_tenant_relation") + + # get host_tag_relation + host_tags = self.get_object_relation(name, "host_tag_relation") + + # prepare host data model + host_data = { + "name": name, + "device_type": { + "model": model, + "manufacturer": { + "name": manufacturer + } + }, + "site": {"name": site_name}, + "cluster": nb_cluster_object, + "status": status + } + + # add data if present + if serial is not None: + host_data["serial"] = serial + if asset_tag is not None: + host_data["asset_tag"] = asset_tag + if platform is not None: + host_data["platform"] = {"name": platform} + if tenant_name is not None: + host_data["tenant"] = {"name": tenant_name} + if len(host_tags) > 0: + host_data["tags"] = host_tags + self.add_custom_attributes(obj, host_data, NBDevice) + + # Get all NICs information + pnic_data_dict = dict() + vnic_data_dict = dict() + vnic_ips = dict() + + host_primary_ip4 = None + host_primary_ip6 = None + + for nic in obj.nics or []: + nic_type = "other" + if nic.speed: + nic_type = NetBoxInterfaceType(nic.speed / 1000000).get_this_netbox_type() + + mac_address = None + if nic.mac: + mac_address = normalize_mac_address(grab(nic, "mac.address")) + if nic.base_interface is None and nic.bonding is None: + # Physical Interface + pnic_data = { + "name": nic.name, + "device": None, + "enabled": (nic.status == types.NicStatus.UP), + "type": nic_type, + "mtu": nic.mtu, + "mac_address": mac_address + } + pnic_data_dict[nic.name] = pnic_data + elif nic.bonding is not None: + # Bond + vnic_data = { + "name": nic.name, + "device": None, + "enabled": (nic.status == types.NicStatus.UP), + "type": "virtual", + "mtu": nic.mtu, + "mac_address": mac_address + } + vnic_data_dict[nic.name] = vnic_data + else: + # Bridge + vnic_data = { + "name": nic.name, + "device": None, + "enabled": (nic.status == types.NicStatus.UP), + "type": "virtual", + "mtu": nic.mtu, + "mac_address": mac_address + } + if nic.vlan is not None: + vnic_data["tagged_vlans"] = [{ + "name": f"VLAN-{nic.vlan.id}", + "vid": nic.vlan.id, + "site": { + "name": site_name + }}] + vnic_data_dict[nic.name] = vnic_data + # IP Info + if nic.ip is not None and nic.ip.address is not None and nic.ip.netmask is not None: + int_v4 = "{}/{}".format(nic.ip.address, nic.ip.netmask) + vnic_name = nic.name + if vnic_ips.get(vnic_name) is None: + vnic_ips[vnic_name] = list() + + if self.settings.permitted_subnets.permitted(int_v4, interface_name=vnic_name) is True: + vnic_ips[vnic_name].append(int_v4) + + if host_primary_ip4 is None and nic.ip.address == obj.address: + host_primary_ip4 = int_v4 + + if nic.ipv6 is not None and nic.ipv6.address is not None and nic.ipv6.netmask is not None: + int_v6 = "{}/{}".format(nic.ipv6.address, nic.ipv6.netmask) + vnic_name = nic.name + if vnic_ips.get(vnic_name) is None: + vnic_ips[vnic_name] = list() + + if self.settings.permitted_subnets.permitted(int_v6, interface_name=vnic_name) is True: + vnic_ips[vnic_name].append(int_v6) + if host_primary_ip6 is None and nic.ipv6.address == obj.address: + host_primary_ip6 = int_v6 + + # add host to inventory + self.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data_dict, + vnic_data=vnic_data_dict, nic_ips=vnic_ips, + p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6, ovirt_object=obj) + + return + + def add_virtual_machine(self, obj): + """Sync a VM, including stopped VMs without host or guest-agent data.""" + name = get_string_or_none(obj.name) + if name is None: + log.error(f"VM '{obj.id}' has no name. Skipping.") + return + if name is not None and self.settings.strip_vm_domain_name is True: + name = name.split(".")[0] + + vm_uuid = obj.id + if self.settings.set_vm_name_to_uuid: + name = vm_uuid + log.debug(f"Parsing oVirt VM: {name}") + status = "active" if obj.status == types.VmStatus.UP else "offline" + nb_cluster_object = self.get_object_from_cache(obj.cluster) + if nb_cluster_object is None: + log.debug(f"Virtual machine '{name}' is not part of a permitted cluster. Skipping") + return + group_name = grab(nb_cluster_object, "data.group.data.name") + cluster_name = grab(nb_cluster_object, "data.name") + cluster_full_name = f"{group_name}/{cluster_name}" + if name in self.processed_vm_names.get(cluster_full_name, []) and \ + vm_uuid not in self.processed_vm_uuid: + log.warning(f"Virtual machine '{name}' for cluster '{cluster_full_name}' already parsed. " + "Make sure to use unique VM names. Skipping") + return + + if self.passes_filter(name, self.settings.vm_include_filter, self.settings.vm_exclude_filter) is False: + return + self.processed_vm_names.setdefault(cluster_full_name, []).append(name) + self.processed_vm_uuid.append(vm_uuid) + + site_name = nb_cluster_object.get_site_name() + api_version = version.parse(self.inventory.netbox_api_version) + tenant_name = self.get_object_relation(name, "vm_tenant_relation") + vm_tags = self.get_object_relation(name, "vm_tag_relation") + vm_data = { + "name": name, + "cluster": nb_cluster_object, + "status": status, + "memory": int(obj.memory / 1024**2) if obj.memory is not None else None, + } + cores = grab(obj, "cpu.topology.cores") + sockets = grab(obj, "cpu.topology.sockets") + if cores is not None and sockets is not None: + vm_data["vcpus"] = cores * sockets + if api_version >= version.parse("3.3.0"): + vm_data["site"] = {"name": site_name} + if api_version >= version.parse("4.1.0"): + vm_data["serial"] = vm_uuid + if self.settings.skip_vm_platform is False: + platform = " ".join(str(value) for value in ( + grab(obj, "guest_operating_system.distribution"), + grab(obj, "guest_operating_system.version.full_version")) if value is not None) + platform = platform or grab(obj, "os.type") + if platform is not None: + platform = self.get_object_relation(platform, "vm_platform_relation", fallback=platform) + vm_data["platform"] = {"name": platform} + if self.settings.skip_vm_comments is False and not self.settings.set_vm_name_to_uuid: + vm_data["comments"] = vm_uuid + if tenant_name is not None: + vm_data["tenant"] = {"name": tenant_name} + if len(vm_tags) > 0: + vm_data["tags"] = vm_tags + + self.add_custom_attributes(obj, vm_data, NBVM) + if self.settings.set_vm_name_to_uuid: + custom_field = self.add_update_custom_field({ + "name": "ovirt_vm_name", + "label": "name", + "object_types": ["virtualization.virtualmachine"], + "type": "text", + "description": f"oVirt '{self.name}' synced object attribute 'name'", + }) + vm_data.setdefault("custom_fields", {})[custom_field.data["name"]] = obj.name + + disk_data = [] + total_disk = 0 + disk_sizes_complete = True + for attachment in obj.disk_attachments or []: + disk = self.disk_cache.get(attachment.disk.id) + size = grab(disk, "provisioned_size") + if size is None: + log.warning(f"VM '{name}' disk '{attachment.disk.id}' has no provisioned size. Skipping disk.") + disk_sizes_complete = False + continue + total_disk += size + unit = 1024**2 if api_version >= version.parse("4.1.0") else 1024**3 + disk_data.append({ + "name": grab(disk, "alias") or grab(disk, "name") or attachment.disk.id, + "size": max(1, int(size / unit)), + }) + if api_version < version.parse("3.7.0") and disk_sizes_complete: + vm_data["disk"] = int(total_disk / 1024**3) + + vm_primary_ip4 = None + vm_primary_ip6 = None + vm_nic_dict = {} + nic_ips = {} + nic_names_by_mac = {} + for nic in obj.nics or []: + mac = normalize_mac_address(grab(nic, "mac.address")) + vm_nic_dict[nic.name] = { + "name": nic.name, "mac_address": mac, "enabled": nic.plugged is not False, + } + if mac is not None: + nic_names_by_mac[mac] = nic.name + + for reporteddevice in obj.reported_devices or []: + if reporteddevice.type != types.ReportedDeviceType.NETWORK: + continue + nic = reporteddevice.name + mac = normalize_mac_address(grab(reporteddevice, "mac.address")) + hardware_name = nic_names_by_mac.get(mac) + if hardware_name is not None and hardware_name != nic: + vm_nic_dict.pop(hardware_name, None) + vm_nic_dict[nic] = { + "name": nic, "mac_address": mac, "description": nic, "enabled": True, + } + for ip in reporteddevice.ips or []: + try: + address = ip_address(ip.address) + except ValueError: + log.warning(f"VM '{name}' reported invalid address '{ip.address}'. Skipping address.") + continue + matched_prefix = self.return_longest_matching_prefix_for_ip(address, site_name) + if matched_prefix is None: + matched_prefix = self.return_longest_matching_prefix_for_ip(address) + ip_addr = str(address) + if matched_prefix is not None: + this_prefix = grab(matched_prefix, "data.prefix") + ip_addr = f"{ip_addr}/{this_prefix.prefixlen}" + if self.settings.permitted_subnets.permitted(ip_addr, interface_name=nic) is True: + nic_ips.setdefault(nic, []).append(ip_addr) + if address.version == 4 and vm_primary_ip4 is None: + vm_primary_ip4 = ip_addr + if address.version == 6 and vm_primary_ip6 is None: + vm_primary_ip6 = ip_addr + else: + log.debug(f"Virtual machine '{name}' address '{ip_addr}' is not valid to add. Skipping") + + nbvm = self.add_device_vm_to_inventory( + NBVM, object_data=vm_data, vnic_data=vm_nic_dict, nic_ips=nic_ips, + p_ipv4=vm_primary_ip4, p_ipv6=vm_primary_ip6, ovirt_object=obj, + keep_undiscovered_ips=not bool(obj.reported_devices)) + if api_version >= version.parse("3.7.0"): + for data in disk_data: + data["virtual_machine"] = nbvm + self.inventory.add_update_object(NBVirtualDisk, data=data, source=self) + + def add_custom_attributes(self, obj, data, object_type): + attributes = self.settings.host_custom_object_attributes if object_type == NBDevice \ + else self.settings.vm_custom_object_attributes + for attribute in attributes or []: + value = grab(obj, attribute) + if value is None: + log.debug(f"oVirt object '{obj.id}' has no custom attribute '{attribute}'.") + continue + field = self.add_update_custom_field({ + "name": f"ovirt_{attribute}", + "label": attribute, + "object_types": [object_type.object_type], + "type": "text", + "description": f"oVirt '{self.name}' synced object attribute '{attribute}'", + }) + data.setdefault("custom_fields", {})[field.data["name"]] = str(value) + + def update_basic_data(self): + """ + + Returns + ------- + + """ + + # add source identification tag + self.inventory.add_update_object(NBTag, data={ + "name": self.source_tag, + "description": f"Marks objects synced from oVirt '{self.name}' " + f"({self.settings.url}) to this NetBox Instance." + }) + + # update virtual site if present + this_site_object = self.inventory.get_by_data(NBSite, data={"name": self.site_name}) + + if this_site_object is not None: + this_site_object.update(data={ + "name": self.site_name, + "comments": "A default virtual site created to house objects " + "that have been synced from this oVirt instance " + "and have no predefined site assigned." + }) + + server_role_object = self.inventory.get_by_data(NBDeviceRole, data={"name": "Server"}) + + if server_role_object is not None: + role_data = {"name": "Server", "vm_role": True} + if server_role_object.is_new is True: + role_data["color"] = "9e9e9e" + + server_role_object.update(data=role_data) + +# EOF From 9b99519caab9e3fa6263518daab167329cfe03ea Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:43:31 -0500 Subject: [PATCH 11/21] Add ovirt-engine-sdk-python requirement --- requirements-ovirt.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements-ovirt.txt diff --git a/requirements-ovirt.txt b/requirements-ovirt.txt new file mode 100644 index 00000000..5666b3fb --- /dev/null +++ b/requirements-ovirt.txt @@ -0,0 +1,2 @@ +-r requirements.txt +ovirt-engine-sdk-python>=4.6,<5 From d6dbdcad0242e1d651d06e789667660384078301 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:44:19 -0500 Subject: [PATCH 12/21] Add tests for OVirt source functionality --- tests/test_ovirt_source.py | 270 +++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 tests/test_ovirt_source.py diff --git a/tests/test_ovirt_source.py b/tests/test_ovirt_source.py new file mode 100644 index 00000000..11a12e6c --- /dev/null +++ b/tests/test_ovirt_source.py @@ -0,0 +1,270 @@ +import os +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from module.config.file_output import ConfigFileOutput +from module.config.parser import ConfigParser +from module.netbox import ( + NBCluster, NBDevice, NBInterface, NBIPAddress, NBMACAddress, NBPrefix, NBSite, + NBVirtualDisk, NBVM, NBVMInterface, +) +from module.sources import instantiate_sources, valid_sources +from module.sources.ovirt import connection +from module.sources.ovirt.config import OVirtConfig + + +def obj(**kwargs): + return SimpleNamespace(**kwargs) + + +@pytest.fixture(autouse=True) +def windows_config_paths(monkeypatch): + if os.name == "nt": + monkeypatch.setattr(ConfigParser, "get_config_file_path", staticmethod(os.path.abspath)) + + +@pytest.fixture +def ovirt_source(inventory, load_config, monkeypatch): + def make(extra="", api_version="4.3.0"): + inventory.netbox_api_version = api_version + load_config(f""" +[source/test] +type = ovirt +url = https://engine.example.com/ovirt-engine/api +username = admin@internal +password = test-only +permitted_subnets = 10.0.0.0/8, 2001:db8::/32 +dns_name_lookup = False +{extra} +""") + monkeypatch.setattr(connection, "types", obj( + HostStatus=obj(UP="up"), NicStatus=obj(UP="up"), + VmStatus=obj(UP="up"), ReportedDeviceType=obj(NETWORK="network"), + IpVersion=obj(V4="v4", V6="v6"), + )) + session = Mock() + monkeypatch.setattr(connection, "sdk", obj(Connection=Mock(return_value=session))) + sources = instantiate_sources() + assert len(sources) == 1 + source = sources[0] + assert source.init_successful + return source + return make + + +def populate(source, status="up", reported=True, disks=True): + datacenter = obj(id="dc", name="DC") + cluster = obj(id="cluster", name="Production", data_center=datacenter) + source.add_datacenter(datacenter) + source.add_cluster(cluster) + nic = obj( + name="eth0", mac=obj(address="52:54:00:00:00:01"), speed=1000000000, + base_interface=None, bonding=None, status="up", mtu=1500, vlan=None, + ip=obj(address="10.0.0.1", netmask="255.255.255.0"), ipv6=None, + ) + host = obj( + id="host", name="host.example.com", cluster=obj(id="cluster"), + hardware_information=obj( + manufacturer="Example", product_name="Server", uuid="host-uuid", + serial_number="asset", + ), + os=obj(type="Linux", version=obj(full_version="9")), status="up", + nics=[nic], address="10.0.0.1", + ) + source.add_host(host) + vm = obj( + id="vm-uuid", name="vm.example.com", status=status, + host=obj(id="host") if status == "up" else None, cluster=obj(id="cluster"), + memory=2 * 1024**3, cpu=obj(topology=obj(cores=2, sockets=2, threads=1)), + os=obj(type="linux"), + guest_operating_system=obj(distribution="Debian", version=obj(full_version="12")), + disk_attachments=[obj(disk=obj(id="disk"))] if disks else [], + nics=[obj(name="nic1", mac=obj(address="52:54:00:00:00:02"), plugged=True)], + reported_devices=[ + obj(name="ens3", type="network", mac=obj(address="52:54:00:00:00:02"), + ips=[obj(address="10.0.0.2", version="v4"), + obj(address="2001:db8::2", version="v6")]), + ] if reported else [], + ) + source.disk_cache["disk"] = obj( + id="disk", name="root", alias="root", provisioned_size=10 * 1024**3, + actual_size=5 * 1024**3, + ) + return vm, host + + +def test_source_and_config_are_registered(): + assert connection.OVirtHandler in valid_sources + assert OVirtConfig in ConfigFileOutput.source_config_list + assert NBMACAddress in connection.OVirtHandler.dependent_netbox_objects + assert NBVirtualDisk in connection.OVirtHandler.dependent_netbox_objects + + +@pytest.mark.parametrize("api_version", ["3.6.0", "3.7.0", "4.1.0", "4.3.0"]) +def test_host_vm_network_and_disk_sync(ovirt_source, inventory, api_version): + source = ovirt_source(api_version=api_version) + inventory.add_object(NBPrefix, data={"id": 1, "prefix": "10.0.0.0/24"}, read_from_netbox=True) + inventory.add_object(NBPrefix, data={"id": 2, "prefix": "2001:db8::/64"}, read_from_netbox=True) + vm, _ = populate(source) + source.add_virtual_machine(vm) + nbvm, = inventory.get_all_items(NBVM) + host, = inventory.get_all_items(NBDevice) + cluster, = inventory.get_all_items(NBCluster) + assert nbvm.data["vcpus"] == 4 + assert nbvm.data["memory"] == 2048 + assert nbvm.data["platform"].data["name"] == "Debian 12" + assert cluster.get_site_name() == source.site_name + assert nbvm.data["primary_ip4"].data["address"] == "10.0.0.2/24" + assert nbvm.data["primary_ip6"].data["address"] == "2001:db8::2/64" + assert host.data["primary_ip4"].data["address"] == "10.0.0.1/24" + assert inventory.get_all_items(NBInterface)[0].data["type"] == "1000base-t" + interface, = inventory.get_all_items(NBVMInterface) + assert interface.data["name"] == "ens3" + assert len(inventory.get_all_items(NBIPAddress)) == 3 + if api_version >= "4.2": + assert cluster.data["scope_type"] == "dcim.site" + assert isinstance(cluster.data["scope_id"], NBSite) + assert len(inventory.get_all_items(NBMACAddress)) == 2 + assert interface.data["primary_mac_address"].data["mac_address"] == "52:54:00:00:00:02" + if api_version < "3.7": + assert nbvm.data["disk"] == 10 + else: + disk, = inventory.get_all_items(NBVirtualDisk) + assert disk.data["virtual_machine"] is nbvm + assert disk.data["size"] == (10 if api_version < "4.1" else 10240) + source.add_virtual_machine(vm) + assert len(inventory.get_all_items(NBVM)) == 1 + assert len(inventory.get_all_items(NBVMInterface)) == 1 + + +def test_offline_vm_without_agent_or_cpu_is_synced(ovirt_source, inventory): + source = ovirt_source() + vm, _ = populate(source, status="down", reported=False) + vm.cpu = None + vm.guest_operating_system = None + source.add_virtual_machine(vm) + nbvm, = inventory.get_all_items(NBVM) + assert nbvm.data["status"] == "offline" + assert "vcpus" not in nbvm.data + interface, = inventory.get_all_items(NBVMInterface) + assert interface.data["name"] == "nic1" + + +def test_unknown_disk_size_is_not_invented(ovirt_source, inventory, caplog): + source = ovirt_source() + vm, _ = populate(source) + source.disk_cache["disk"].provisioned_size = None + source.add_virtual_machine(vm) + assert len(inventory.get_all_items(NBVM)) == 1 + assert inventory.get_all_items(NBVirtualDisk) == [] + assert "provisioned size" in caplog.text + + +def test_vm_rename_matches_existing_primary_mac(ovirt_source, inventory): + source = ovirt_source() + vm, _ = populate(source) + source.add_virtual_machine(vm) + original, = inventory.get_all_items(NBVM) + vm.name = "renamed-vm" + source.add_virtual_machine(vm) + assert inventory.get_all_items(NBVM) == [original] + assert original.data["name"] == "renamed-vm" + + +def test_lost_guest_agent_preserves_existing_ip(ovirt_source, inventory): + source = ovirt_source() + inventory.add_object(NBPrefix, data={"id": 1, "prefix": "10.0.0.0/24"}, read_from_netbox=True) + vm, _ = populate(source) + source.add_virtual_machine(vm) + nbvm, = inventory.get_all_items(NBVM) + primary = nbvm.data["primary_ip4"] + vm.reported_devices = [] + vm.status = "down" + source.add_virtual_machine(vm) + assert nbvm.data["primary_ip4"] is primary + assert primary.data["assigned_object_id"].data["virtual_machine"] is nbvm + + +def test_cluster_filter_accepts_qualified_name(ovirt_source, inventory): + source = ovirt_source("cluster_include_filter = DC/Production") + vm, _ = populate(source) + source.add_virtual_machine(vm) + assert len(inventory.get_all_items(NBVM)) == 1 + + +def test_different_sources_do_not_move_each_others_clusters(ovirt_source, inventory): + source = ovirt_source() + vm, _ = populate(source) + original, = inventory.get_all_items(NBCluster) + other = connection.OVirtHandler.__new__(connection.OVirtHandler) + other.name = "other" + other.set_source_tag() + other.settings = source.settings + other.site_name = "Other site" + other.inventory = inventory + other.object_cache = {} + inventory.add_source(other) + datacenter = obj(id="dc-other", name="DC") + other.add_datacenter(datacenter) + other.add_cluster(obj(id="other-cluster", name="Production", data_center=datacenter)) + assert len(inventory.get_all_items(NBCluster)) == 2 + assert original.get_site_name() == source.site_name + + +def test_uuid_name_custom_attributes_and_skip_platform(ovirt_source, inventory): + source = ovirt_source(""" +set_vm_name_to_uuid = True +vm_custom_object_attributes = id +host_custom_object_attributes = id +skip_vm_platform = True +""") + vm, _ = populate(source) + source.add_virtual_machine(vm) + nbvm, = inventory.get_all_items(NBVM) + host, = inventory.get_all_items(NBDevice) + assert nbvm.data["name"] == vm.id + assert "platform" not in nbvm.data + assert nbvm.data["custom_fields"]["ovirt_vm_name"] == vm.name + assert nbvm.data["custom_fields"]["ovirt_id"] == vm.id + assert host.data["custom_fields"]["ovirt_id"] == "host" + + +def test_missing_sdk_is_reported_without_breaking_other_sources( + inventory, load_config, monkeypatch, caplog): + load_config(""" +[source/test] +type = ovirt +url = https://engine.example.com/ovirt-engine/api +username = admin@internal +password = test-only +""") + monkeypatch.setattr(connection, "sdk", None) + assert instantiate_sources() == [] + assert "requirements-ovirt.txt" in caplog.text + + +def test_connection_error_is_reported(ovirt_source, monkeypatch, caplog): + source = ovirt_source() + source.session = None + monkeypatch.setattr(connection.sdk, "Connection", Mock(side_effect=RuntimeError("unavailable"))) + assert source.create_ovirt_session() is False + assert "unavailable" in caplog.text + + +def test_apply_queries_related_data_and_closes_session(ovirt_source, inventory): + source = ovirt_source() + vm, host = populate(source) + system = source.session.system_service.return_value + system.disks_service.return_value.list.return_value = list(source.disk_cache.values()) + system.data_centers_service.return_value.list.return_value = [] + system.clusters_service.return_value.list.return_value = [] + system.hosts_service.return_value.list.return_value = [] + system.vms_service.return_value.list.return_value = [vm] + source.apply() + assert len(inventory.get_all_items(NBVM)) == 1 + system.vms_service.return_value.list.assert_called_once_with( + follow="nics,disk_attachments,reported_devices") + source.finish() + source.session.close.assert_called_once() From 052afa59c4d0373ab4fd70d7cffb81fc5564d598 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:50:13 -0500 Subject: [PATCH 13/21] Add INSTALL_OVIRT argument for conditional dependencies Added support for optional oVirt installation in Dockerfile. --- Dockerfile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0852fa81..e27f7a89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,19 @@ FROM python:3.14-slim AS builder COPY requirements.txt . ARG VENV=/opt/netbox-sync/venv +ARG INSTALL_OVIRT=false +COPY requirements-ovirt.txt . # Install dependencies -RUN python3 -m venv $VENV && \ +RUN if [ "$INSTALL_OVIRT" = "true" ]; then \ + apt-get update && \ + apt-get install -y --no-install-recommends gcc libc6-dev libxml2-dev libcurl4-openssl-dev && \ + rm -rf /var/lib/apt/lists/*; \ + fi && \ + python3 -m venv $VENV && \ $VENV/bin/python3 -m pip install --upgrade pip && \ $VENV/bin/pip install -r requirements.txt && \ + if [ "$INSTALL_OVIRT" = "true" ]; then $VENV/bin/pip install -r requirements-ovirt.txt; fi && \ $VENV/bin/pip install vmware-vcenter==9.1.1.0 && \ $VENV/bin/python3 -m pip uninstall -y pip && \ find $VENV -type d -name "__pycache__" -print0 | xargs -0 -n1 rm -rf @@ -15,6 +23,7 @@ RUN python3 -m venv $VENV && \ FROM python:3.14-slim AS netbox-sync ARG VENV=/opt/netbox-sync/venv +ARG INSTALL_OVIRT=false # Copy installed packages COPY --from=builder $VENV $VENV @@ -30,6 +39,9 @@ COPY . . # can use it) RUN apt-get update && \ apt-get dist-upgrade -y && \ + if [ "$INSTALL_OVIRT" = "true" ]; then \ + apt-get install -y --no-install-recommends libxml2 libcurl4t64; \ + fi && \ rm -rf /var/lib/apt/lists/* && \ python3 -m pip uninstall -y --root-user-action=ignore pip && \ groupadd --gid 1000 netbox-sync && \ From 676f662eb6e48d9c0f2f91144991f2f16635335e Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 08:51:52 -0500 Subject: [PATCH 14/21] Remove object_synced_by_other_source method Removed the method object_synced_by_other_source from the connection.py file, which checked if a NetBox object was maintained by a different configured source. --- module/sources/vmware/connection.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 60cf56de..b7cbcd2e 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -1944,29 +1944,6 @@ def add_datacenter(self, obj): self.add_object_to_cache(obj, self.inventory.add_update_object(NBClusterGroup, data=object_data, source=self)) - def object_synced_by_other_source(self, nb_object): - """ - Check if a NetBox object is maintained by a different configured source. During - this run that is the source which touched the object, for objects read from NetBox - the source tags decide. - - Parameters - ---------- - nb_object: NetBoxObject - object to check - - Returns - ------- - bool: True if another configured source synced this object - """ - - if nb_object.source is not None: - return nb_object.source is not self - - other_source_tags = [x.source_tag for x in self.inventory.source_list if x is not self] - - return len(set(nb_object.get_tags()).intersection(other_source_tags)) > 0 - def add_cluster(self, obj): """ Add a vCenter cluster as a NBCluster to NetBox. Cluster name is checked against From bca2b97157296ccf34c02b385412116b82e83c09 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:33:42 -0500 Subject: [PATCH 15/21] Update deprecation messages for sync_tags options --- module/sources/ovirt/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/sources/ovirt/config.py b/module/sources/ovirt/config.py index 757c91ad..36456e16 100644 --- a/module/sources/ovirt/config.py +++ b/module/sources/ovirt/config.py @@ -260,13 +260,13 @@ def __init__(self): removed=True), ConfigOption("sync_tags", bool, - deprecation_message="You need to switch to 'host_tag_source', " + - "'vm_tag_source' or 'cluster_tag_source'", + deprecation_message="You need to switch to 'host_tag_relation', " + + "'vm_tag_relation' or 'cluster_tag_relation'", removed=True), ConfigOption("sync_parent_tags", bool, - deprecation_message="You need to switch to 'host_tag_source', " + - "'vm_tag_source' or 'cluster_tag_source'", + deprecation_message="You need to switch to 'host_tag_relation', " + + "'vm_tag_relation' or 'cluster_tag_relation'", removed=True) ] From 1427c482d11cecec70bc5d6bcb770a21025c4c5b Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:34:29 -0500 Subject: [PATCH 16/21] Refactor oVirt connection handling and IP resolution --- module/sources/ovirt/connection.py | 50 +++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/module/sources/ovirt/connection.py b/module/sources/ovirt/connection.py index c2b7be3e..67b64240 100644 --- a/module/sources/ovirt/connection.py +++ b/module/sources/ovirt/connection.py @@ -8,6 +8,7 @@ # repository or visit: . import pprint +import socket from ipaddress import ip_address, ip_interface from packaging import version @@ -122,8 +123,9 @@ def create_ovirt_session(self): log.debug(f"Starting oVirt connection to '{self.settings.url}'") + session = None try: - self.session = sdk.Connection( + session = sdk.Connection( url=self.settings.url, username=self.settings.username, password=self.settings.password, @@ -132,10 +134,17 @@ def create_ovirt_session(self): debug=False, log=log, ) + session.test(raise_exception=True) except Exception as e: + if session is not None: + try: + session.close() + except Exception as close_error: + log.warning(f"Unable to close failed oVirt session: {close_error}") log.error(f"Unable to connect to oVirt instance '{self.settings.url}'. {e}") return False + self.session = session log.info(f"Successfully connected to oVirt '{self.settings.url}'") return True @@ -621,8 +630,24 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v # compile all nic data into one dictionary if object_type == NBVM: nic_data = vnic_data + interface_exclude_filter = self.settings.vm_interface_exclude_filter else: nic_data = {**pnic_data, **vnic_data} + interface_exclude_filter = self.settings.host_interface_exclude_filter + + if interface_exclude_filter is not None: + for interface in self.inventory.get_all_interfaces(device_vm_object): + interface_name = grab(interface, "data.name") + if interface_name is not None and interface_exclude_filter.match(interface_name) and \ + self.source_tag in interface.get_tags(): + interface.set_source(self) + + for interface_name in list(nic_data): + if interface_exclude_filter.match(interface_name): + log.debug(f"Discovered interface '{interface_name}' matches interface_exclude_filter. " + "Excluding it from sync") + del nic_data[interface_name] + nic_ips.pop(interface_name, None) # map interfaces of existing object with discovered interfaces nic_object_dict = self.map_object_interfaces_to_current_interfaces(device_vm_object, nic_data) @@ -1015,6 +1040,19 @@ def add_host(self, obj): host_primary_ip4 = None host_primary_ip6 = None + host_address = get_string_or_none(grab(obj, "address")) + host_addresses = set() + if host_address is not None: + try: + host_addresses.add(ip_address(host_address)) + except ValueError: + try: + host_addresses.update( + ip_address(result[4][0]) + for result in socket.getaddrinfo(host_address, None, type=socket.SOCK_STREAM) + ) + except (OSError, ValueError) as e: + log.warning(f"Unable to resolve oVirt host address '{host_address}': {e}") for nic in obj.nics or []: nic_type = "other" @@ -1074,7 +1112,7 @@ def add_host(self, obj): if self.settings.permitted_subnets.permitted(int_v4, interface_name=vnic_name) is True: vnic_ips[vnic_name].append(int_v4) - if host_primary_ip4 is None and nic.ip.address == obj.address: + if host_primary_ip4 is None and ip_address(nic.ip.address) in host_addresses: host_primary_ip4 = int_v4 if nic.ipv6 is not None and nic.ipv6.address is not None and nic.ipv6.netmask is not None: @@ -1085,7 +1123,7 @@ def add_host(self, obj): if self.settings.permitted_subnets.permitted(int_v6, interface_name=vnic_name) is True: vnic_ips[vnic_name].append(int_v6) - if host_primary_ip6 is None and nic.ipv6.address == obj.address: + if host_primary_ip6 is None and ip_address(nic.ipv6.address) in host_addresses: host_primary_ip6 = int_v6 # add host to inventory @@ -1145,11 +1183,13 @@ def add_virtual_machine(self, obj): vm_data["site"] = {"name": site_name} if api_version >= version.parse("4.1.0"): vm_data["serial"] = vm_uuid + host_object = self.get_object_from_cache(obj.host) + if host_object is not None: + vm_data["device"] = host_object if self.settings.skip_vm_platform is False: platform = " ".join(str(value) for value in ( grab(obj, "guest_operating_system.distribution"), - grab(obj, "guest_operating_system.version.full_version")) if value is not None) - platform = platform or grab(obj, "os.type") + grab(obj, "guest_operating_system.version.full_version")) if value).strip() or None if platform is not None: platform = self.get_object_relation(platform, "vm_platform_relation", fallback=platform) vm_data["platform"] = {"name": platform} From d58681f4f2f516d7fbc87901c3120f077047330a Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:35:14 -0500 Subject: [PATCH 17/21] Update ovirt dependency version in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 161c1ae6..29015c6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ [project.optional-dependencies] ovirt = [ - "ovirt-engine-sdk-python>=4.6,<5", + "ovirt-engine-sdk-python==4.6.3", ] test = [ "pytest>=9.1.1", From 2f9883b8365c0d19d9b23649196732df5902e6b8 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:35:42 -0500 Subject: [PATCH 18/21] Update ovirt-engine-sdk-python version constraint --- requirements-ovirt.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-ovirt.txt b/requirements-ovirt.txt index 5666b3fb..2a582b34 100644 --- a/requirements-ovirt.txt +++ b/requirements-ovirt.txt @@ -1,2 +1,2 @@ -r requirements.txt -ovirt-engine-sdk-python>=4.6,<5 +ovirt-engine-sdk-python==4.6.3 From 0246bd64a9af774f97af3bcb9889b0b810ca1179 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:36:01 -0500 Subject: [PATCH 19/21] Update ovirt-engine-sdk-python version to 4.6.3 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 7321e20f..3d66ce26 100644 --- a/uv.lock +++ b/uv.lock @@ -323,7 +323,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.5.2" }, { name = "hcloud", specifier = "==2.25.1" }, { name = "idna", specifier = "==3.20" }, - { name = "ovirt-engine-sdk-python", marker = "extra == 'ovirt'", specifier = ">=4.6,<5" }, + { name = "ovirt-engine-sdk-python", marker = "extra == 'ovirt'", specifier = "==4.6.3" }, { name = "packaging", specifier = "==26.3" }, { name = "pycares", specifier = "==5.0.1" }, { name = "pycparser", specifier = "==3.0" }, From 3d027fb8fb6d2bd9d6149ac41ddff08850e26f83 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:36:25 -0500 Subject: [PATCH 20/21] Update guest IP discovery section in source_ovirt.md Clarify behavior of address importation regarding prefix matching and permitted subnets. --- docs/source_ovirt.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source_ovirt.md b/docs/source_ovirt.md index 3646851e..4ebde754 100644 --- a/docs/source_ovirt.md +++ b/docs/source_ovirt.md @@ -48,7 +48,9 @@ SDK attribute paths). Generate a full example with `python netbox-sync.py -g`. Guest IP discovery requires the guest agent to report network devices. VMs without agent data still receive their configured interfaces and disks. As with other sources, bare guest IPs need a matching prefix in NetBox to determine their -prefix length. Only addresses allowed by `permitted_subnets` are imported. +prefix length. Addresses without a matching prefix are not imported; netbox-sync +does not guess a host-length prefix. Only addresses allowed by `permitted_subnets` +are imported. `ip_tenant_inheritance_order`, `preserve_primary_ips`, `skip_fhrp_group_ips`, and interface exclusion filters use the current shared network-sync behavior. From 531189f2767f3d582c7ed552781439f16df05b0e Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Mon, 26 Oct 2026 20:37:45 -0500 Subject: [PATCH 21/21] Enhance populate function and add new tests Added host_address parameter to populate function and updated its usage in tests. Implemented test for host primary IP matching FQDN and added tests for excluded VM interfaces. --- tests/test_ovirt_source.py | 83 +++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/tests/test_ovirt_source.py b/tests/test_ovirt_source.py index 11a12e6c..3dc49940 100644 --- a/tests/test_ovirt_source.py +++ b/tests/test_ovirt_source.py @@ -1,4 +1,5 @@ import os +import socket from types import SimpleNamespace from unittest.mock import Mock @@ -6,6 +7,7 @@ from module.config.file_output import ConfigFileOutput from module.config.parser import ConfigParser +from module.netbox.connection import NetBoxHandler from module.netbox import ( NBCluster, NBDevice, NBInterface, NBIPAddress, NBMACAddress, NBPrefix, NBSite, NBVirtualDisk, NBVM, NBVMInterface, @@ -54,7 +56,7 @@ def make(extra="", api_version="4.3.0"): return make -def populate(source, status="up", reported=True, disks=True): +def populate(source, status="up", reported=True, disks=True, host_address="10.0.0.1"): datacenter = obj(id="dc", name="DC") cluster = obj(id="cluster", name="Production", data_center=datacenter) source.add_datacenter(datacenter) @@ -71,7 +73,7 @@ def populate(source, status="up", reported=True, disks=True): serial_number="asset", ), os=obj(type="Linux", version=obj(full_version="9")), status="up", - nics=[nic], address="10.0.0.1", + nics=[nic], address=host_address, ) source.add_host(host) vm = obj( @@ -115,6 +117,7 @@ def test_host_vm_network_and_disk_sync(ovirt_source, inventory, api_version): assert nbvm.data["vcpus"] == 4 assert nbvm.data["memory"] == 2048 assert nbvm.data["platform"].data["name"] == "Debian 12" + assert nbvm.data["device"] is host assert cluster.get_site_name() == source.site_name assert nbvm.data["primary_ip4"].data["address"] == "10.0.0.2/24" assert nbvm.data["primary_ip6"].data["address"] == "2001:db8::2/64" @@ -139,6 +142,17 @@ def test_host_vm_network_and_disk_sync(ovirt_source, inventory, api_version): assert len(inventory.get_all_items(NBVMInterface)) == 1 +def test_host_primary_ip_matches_fqdn(ovirt_source, inventory, monkeypatch): + source = ovirt_source() + inventory.add_object(NBPrefix, data={"id": 1, "prefix": "10.0.0.0/24"}, read_from_netbox=True) + monkeypatch.setattr(connection.socket, "getaddrinfo", Mock(return_value=[ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.1", 0)), + ])) + populate(source, host_address="host.example.com") + host, = inventory.get_all_items(NBDevice) + assert host.data["primary_ip4"].data["address"] == "10.0.0.1/24" + + def test_offline_vm_without_agent_or_cpu_is_synced(ovirt_source, inventory): source = ovirt_source() vm, _ = populate(source, status="down", reported=False) @@ -152,6 +166,57 @@ def test_offline_vm_without_agent_or_cpu_is_synced(ovirt_source, inventory): assert interface.data["name"] == "nic1" +def test_excluded_vm_interface_is_not_created(ovirt_source, inventory): + source = ovirt_source("vm_interface_exclude_filter = ^nic1$") + vm, _ = populate(source) + vm.reported_devices[0].name = "nic1" + mac_count = len(inventory.get_all_items(NBMACAddress)) + + source.add_virtual_machine(vm) + + assert inventory.get_all_items(NBVMInterface) == [] + assert len(inventory.get_all_items(NBMACAddress)) == mac_count + + +def test_excluded_existing_vm_interface_remains_claimed(ovirt_source, inventory): + source = ovirt_source("vm_interface_exclude_filter = ^nic1$") + vm, _ = populate(source) + vm.reported_devices[0].name = "nic1" + cluster, = inventory.get_all_items(NBCluster) + existing_vm = inventory.add_object( + NBVM, data={"id": 1, "name": vm.name, "cluster": cluster}, read_from_netbox=True) + existing_interface = inventory.add_object( + NBVMInterface, + data={"id": 2, "name": "nic1", "virtual_machine": existing_vm, + "mac_address": "52:54:00:00:00:02"}, + read_from_netbox=True, + ) + existing_interface.add_tags([NetBoxHandler.primary_tag, source.source_tag]) + + source.add_virtual_machine(vm) + + assert existing_interface.source is source + assert inventory.get_all_items(NBVMInterface) == [existing_interface] + + +@pytest.mark.parametrize("guest_os", [ + None, + obj(distribution="", version=obj(full_version="")), +], ids=["missing", "empty"]) +def test_offline_vm_keeps_last_guest_platform(ovirt_source, inventory, guest_os): + source = ovirt_source() + vm, _ = populate(source) + source.add_virtual_machine(vm) + nbvm, = inventory.get_all_items(NBVM) + platform = nbvm.data["platform"] + + vm.status = "down" + vm.guest_operating_system = guest_os + source.add_virtual_machine(vm) + + assert nbvm.data["platform"] is platform + + def test_unknown_disk_size_is_not_invented(ovirt_source, inventory, caplog): source = ovirt_source() vm, _ = populate(source) @@ -253,6 +318,19 @@ def test_connection_error_is_reported(ovirt_source, monkeypatch, caplog): assert "unavailable" in caplog.text +def test_authentication_error_skips_unavailable_source(ovirt_source, monkeypatch, caplog): + source = ovirt_source() + source.session = None + session = Mock() + session.test.side_effect = RuntimeError("access denied") + monkeypatch.setattr(connection.sdk, "Connection", Mock(return_value=session)) + + assert source.create_ovirt_session() is False + assert source.session is None + session.close.assert_called_once() + assert "access denied" in caplog.text + + def test_apply_queries_related_data_and_closes_session(ovirt_source, inventory): source = ovirt_source() vm, host = populate(source) @@ -264,6 +342,7 @@ def test_apply_queries_related_data_and_closes_session(ovirt_source, inventory): system.vms_service.return_value.list.return_value = [vm] source.apply() assert len(inventory.get_all_items(NBVM)) == 1 + source.session.test.assert_called_once_with(raise_exception=True) system.vms_service.return_value.list.assert_called_once_with( follow="nics,disk_attachments,reported_devices") source.finish()