From 33ddedef53043da699aa0165d2350cdd97416276 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:07:18 -0500 Subject: [PATCH 01/20] Add OpenStack configuration options --- module/sources/openstack/config.py | 394 +++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 module/sources/openstack/config.py diff --git a/module/sources/openstack/config.py b/module/sources/openstack/config.py new file mode 100644 index 00000000..fd5d0668 --- /dev/null +++ b/module/sources/openstack/config.py @@ -0,0 +1,394 @@ +# -*- 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 + +log = get_logger() + + +class OpenStackConfig(ConfigBase): + + section_name = source_config_section_name + source_name = None + source_name_example = "my-openstack-example" + + def __init__(self): + self.options = [ + ConfigOption(**config_option_enabled_definition), + + ConfigOption(**{**config_option_type_definition, "config_example": "openstack"}), + + ConfigOption("auth_url", + str, + description="OpenStack Identity v3 authentication URL", + config_example="https://api.openstack-instance.local:5000/v3", + mandatory=True), + + ConfigOption("project", + str, + description="Name of the OpenStack project", + config_example="MyProject", + mandatory=True), + + ConfigOption("username", + str, + description="username to use to log into OpenStack", + config_example="openstackuser", + mandatory=True), + + ConfigOption("password", + str, + description="password to use to log into OpenStack", + config_example="supersecret", + sensitive=True, + mandatory=True), + + ConfigOption("region", + str, + description="Which OpenStack region to use", + config_example="RegionOne", + mandatory=True), + + ConfigOption("user_domain", + str, + description="Which OpenStack user domain to use", + config_example="Default", + mandatory=True), + + ConfigOption("project_domain", + str, + description="Which OpenStack project domain to use", + config_example="Default", + mandatory=True), + + ConfigOption("group_name", + str, + description="OpenStack group name", + default_value="Openstack"), + + ConfigOption("validate_tls_certs", + bool, + description="""Enforces TLS certificate validation. + Disable only for an OpenStack endpoint with a self-signed certificate.""", + default_value=True), + + ConfigOption("all_projects", bool, default_value=True, + description="Discover servers and volumes across projects (requires administrative access)."), + + ConfigOption(**config_option_permitted_subnets_definition), + ConfigOption(**config_option_ip_tenant_inheritance_order_definition), + ConfigOption("preserve_primary_ips", bool, default_value=False, + description="Keep existing primary IP assignments when an address is no longer discovered."), + ConfigOption("skip_fhrp_group_ips", bool, default_value=True, + description="Do not assign addresses belonging to an existing FHRP group."), + ConfigOption("host_interface_exclude_filter", str, + description="Regular expression matching host interface names to ignore."), + ConfigOption("vm_interface_exclude_filter", str, + description="Regular expression matching VM interface names to ignore."), + ConfigOption("disable_vlan_sync", bool, default_value=False, + description="Do not synchronize VLANs discovered through existing prefixes."), + + 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 availability zone will be ignored!"""), + 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) + ]), + ConfigOptionGroup(title="relations", + options=[ + ConfigOption("cluster_site_relation", + str, + description="""\ + This option defines which OpenStack availability zone 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 + """, + config_example="nova = Cloud Site"), + 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 + "availability-zone-name" + """, + config_example="Cluster_NYC.* = Customer A"), + ConfigOption("host_tenant_relation", str, config_example="compute.* = 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) + Match the availability zone name to assign cluster tags.""", + config_example="nova = Infrastructure"), + ConfigOption("host_tag_relation", str, config_example="compute.* = 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 use the OpenStack flavor name as the NetBox VM 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. + use OpenStack SDK resource attribute names; dotted paths are supported. + """, + options=[ + ConfigOption("host_custom_object_attributes", + str, + config_example="id"), + ConfigOption("vm_custom_object_attributes", + str, + config_example="id, project_id") + ]), + 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 Exception 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 = relation.split("=")[0].strip(' "') + if "=" not in relation: + log.error(f"Config relation '{self.source_name}.{option.key}' must contain '='.") + self.set_validation_failed() + continue + relation_name = relation.split("=", 1)[1].strip(' "') + + if 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 Exception 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 any(value not in ("device", "prefix", "disabled") for value in order): + log.error(f"Invalid IP tenant inheritance order: '{option.value}'") + 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 153cf57b95ef2d7dfc8eeada3ef16a35fe8e3c4b Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:08:16 -0500 Subject: [PATCH 02/20] Add Connection for Openstack source --- module/sources/openstack/connection.py | 1251 ++++++++++++++++++++++++ 1 file changed, 1251 insertions(+) create mode 100644 module/sources/openstack/connection.py diff --git a/module/sources/openstack/connection.py b/module/sources/openstack/connection.py new file mode 100644 index 00000000..3914850d --- /dev/null +++ b/module/sources/openstack/connection.py @@ -0,0 +1,1251 @@ +# -*- 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_interface +from importlib import import_module +from urllib.parse import unquote + +from packaging import version + +from module.sources.common.source_base import SourceBase +from module.sources.openstack.config import OpenStackConfig +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 OpenStackHandler(SourceBase): + """ + Source class to import data from a Openstack instance and add/update NetBox objects based on gathered information + """ + + dependent_netbox_objects = [ + NBTag, + NBManufacturer, + NBDeviceType, + NBPlatform, + NBClusterType, + NBClusterGroup, + NBDeviceRole, + NBSite, + NBCluster, + NBDevice, + NBVM, + NBVirtualDisk, + NBVMInterface, + NBInterface, + NBIPAddress, + NBPrefix, + NBTenant, + NBVRF, + NBVLAN, + NBVLANGroup, + NBMACAddress, + NBFHRPGroupItem, + NBCustomField + ] + + source_type = "openstack" + + # internal vars + 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 + self.session = None + self.init_successful = False + + # parse settings + settings_handler = OpenStackConfig() + settings_handler.source_name = self.name + self.settings = settings_handler.parse() + + self.set_source_tag() + self.site_name = f"OpenStack: {name}" + + if self.settings.enabled is False: + log.info(f"Source '{name}' is currently disabled. Skipping") + return + + self.create_openstack_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.cluster_host_map = dict() + self.volume_map = dict() + self.processed_host_names = dict() + self.processed_vm_names = dict() + self.object_cache = dict() + + def create_openstack_session(self): + """ + Initialize session with OpenStack + + Returns + ------- + bool: if initialization was successful or not + """ + + if self.session is not None: + return True + + log.debug(f"Starting OpenStack connection to '{self.settings.auth_url}'") + + try: + openstack = import_module("openstack") + except ImportError: + log.error("OpenStack source requires openstacksdk. Install with " + "'pip install -r requirements-openstack.txt' or 'uv sync --extra openstack'.") + return False + + try: + self.session = openstack.connect( + auth_url=self.settings.auth_url, + project_name=self.settings.project, + username=self.settings.username, + password=self.settings.password, + region_name=self.settings.region, + user_domain_name=self.settings.user_domain, + project_domain_name=self.settings.project_domain, + verify=self.settings.validate_tls_certs, + app_name='netbox-sync', + app_version='0.1', + ) + self.session.authorize() + except Exception as e: + log.error(f"Unable to connect to OpenStack instance '{self.settings.auth_url}': {e}") + self.finish() + return False + + log.info(f"Successfully connected to OpenStack '{self.settings.auth_url}'") + + return True + + def finish(self): + if self.session is not None: + self.session.close() + self.session = None + + 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 OpenStack: '{self.settings.auth_url}'") + self.permitted_clusters.clear() + self.cluster_host_map.clear() + self.volume_map.clear() + self.processed_host_names.clear() + self.processed_vm_names.clear() + self.object_cache.clear() + + self.inventory.add_update_object(NBClusterGroup, data={"name": self.settings.group_name}, source=self) + + availability_zones = self.session.compute.availability_zones(details=True) + for availability_zone in availability_zones: + self.add_cluster(availability_zone) + + hypervisors = self.session.compute.hypervisors(details=True) + for hypervisor in hypervisors: + self.add_host(hypervisor) + + volumes = self.session.block_storage.volumes(details=True, all_projects=self.settings.all_projects) + for volume in volumes: + self.add_volume(volume) + + servers = self.session.compute.servers(details=True, all_projects=self.settings.all_projects) + # Prefer active machines when duplicate names occur during a migration. + for server in sorted(servers, key=lambda server: server.status != "ACTIVE"): + self.add_virtual_machine(server) + + 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): + + mac_address = grab(interface, "data.primary_mac_address.data.mac_address") or \ + grab(interface, "data.mac_address") + if mac_address 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'" % + (grab(interface, "data.mac_address"), 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 = device_primary_ip.data.get("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 get_cluster_for_host(self, hostname): + for cluster, hosts in self.cluster_host_map.items(): + if hostname in hosts: + return cluster + return None + + def object_synced_by_other_source(self, nb_object): + if nb_object.source is not None: + return nb_object.source is not self + other_tags = [source.source_tag for source in self.inventory.source_list if source is not self] + return bool(set(nb_object.get_tags()).intersection(other_tags)) + + def add_custom_attributes(self, object_type, obj, data): + 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: + continue + field = self.add_update_custom_field({ + "name": f"openstack_{attribute}", "label": attribute[:50], + "object_types": [object_type.object_type], "type": "text", + "description": f"OpenStack resource attribute '{attribute}'", + }) + data.setdefault("custom_fields", {})[field.data["name"]] = str(value) + + 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): + """ + 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) + + # 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} + exclude_filter = self.settings.vm_interface_exclude_filter if object_type == NBVM else \ + self.settings.host_interface_exclude_filter + if exclude_filter is not None: + nic_data = {name: data for name, data in nic_data.items() if not exclude_filter.match(name)} + + # 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())) + + # 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, obj_type, key, netbox_object): + + if obj_type is None or key is None or netbox_object is None: + return + + # noinspection PyBroadException + try: + vm_class_name = obj_type.__name__ + except Exception: + return + + if self.object_cache.get(vm_class_name) is None: + self.object_cache[vm_class_name] = dict() + + self.object_cache[vm_class_name][key] = netbox_object + + def get_object_from_cache(self, obj_type, key): + + if obj_type is None or key is None: + return + + # noinspection PyBroadException + try: + vm_class_name = obj_type.__name__ + except Exception: + return + + if self.object_cache.get(vm_class_name) is None: + return + + return self.object_cache[vm_class_name].get(key) + + def add_cluster(self, obj): + """ + Add a OpenStack Availability Zone 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: openstack.compute.v2.availability_zone.AvailabilityZone + cluster to add + """ + + name = get_string_or_none(grab(obj, "name")) + group = self.inventory.get_by_data(NBClusterGroup, data={"name": self.settings.group_name}) + + if name is None or group is None: + return + + group_name = grab(group, "data.name") + + log.debug(f"Parsing OpenStack AZ: {name}") + + if self.passes_filter(name, self.settings.cluster_include_filter, + self.settings.cluster_exclude_filter) is False: + return + + site_name = self.get_site_name(NBCluster, name) + + data = { + "name": name, + "type": {"name": "Openstack"}, + "group": group, + "site": {"name": site_name} + } + if version.parse(self.inventory.netbox_api_version) >= version.parse("4.2.0"): + data.pop("site") + data["scope_type"] = NBSite.object_type + data["scope_id"] = self.inventory.add_update_object(NBSite, data={"name": site_name}, source=self) + tags = self.get_object_relation(name, "cluster_tag_relation") + if tags: + data["tags"] = tags + + tenant_name = self.get_object_relation(name, "cluster_tenant_relation") + if tenant_name is not None: + data["tenant"] = {"name": tenant_name} + + # try to find cluster including cluster group + log.debug2("Trying to find a matching existing cluster") + 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 + candidate_group = grab(cluster_candidate, "data.group.data.name") + if candidate_group not in (None, group_name): + continue + + 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 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(NBCluster, name, cluster_object) + + self.cluster_host_map[name] = list() + log.debug2(f"Found an existing host where 'name' matches (NetBox id: {obj.hosts})") + # trap for a cluster with no hosts + if obj.hosts is not None: + for host in obj.hosts: + self.cluster_host_map[name].append(host) + + self.permitted_clusters[name] = site_name + + def add_host(self, obj): + """ + Parse a Openstack 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 'host_ip' value + + Parameters + ---------- + obj: Hypervisor + host object to parse + """ + + name = get_string_or_none(grab(obj, "name")) + if name is None: + return + + if name is not None and self.settings.strip_host_domain_name is True: + name = name.split(".")[0] + + # parse data + log.debug(f"Parsing Openstack host: {name}") + + # + # Filtering + # + + # manage site and cluster + short_name = get_string_or_none(obj.service_details["host"]) + cluster_name = self.get_cluster_for_host(short_name) + + if cluster_name is None: + log.error(f"Requesting cluster for host '{name}' failed. Skipping.") + return + + if log.level == DEBUG3: + try: + log.info("Cluster data") + dump(obj.service_details.to_dict()) + except Exception as e: + log.error(e) + + if self.permitted_clusters.get(cluster_name) is None: + log.debug(f"Host '{name}' is not part of a permitted cluster. Skipping") + return + + # get cluster object + nb_cluster_object = self.get_object_from_cache(NBCluster, cluster_name) + + # get a site for this host + site_name = self.get_site_name(NBDevice, 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 = None + model = None + product_name = get_string_or_none(obj.hypervisor_type) + product_version = get_string_or_none(obj.hypervisor_version) + platform = f"{product_name} {product_version}" + + # 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 get_string_or_none(obj.status) == "enabled" and grab(obj, "state", fallback="up") == "up": + status = "active" + + # 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 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(NBDevice, obj, host_data) + + pnic_data_dict = dict() + pnic_data = { + "name": "eth0", + "enabled": True, + "type": "virtual" + } + pnic_data_dict["eth0"] = pnic_data + + ip_addr = grab(obj, "host_ip") + if ip_addr is not None and not self.settings.permitted_subnets.permitted(ip_addr, interface_name="eth0"): + ip_addr = None + prefix = None + matched_prefix = None + if ip_addr is not None: + matched_prefix = self.return_longest_matching_prefix_for_ip(ip_interface(ip_addr), site_name) + if matched_prefix is None: + matched_prefix = self.return_longest_matching_prefix_for_ip(ip_interface(ip_addr)) + if matched_prefix is not None: + prefix = matched_prefix.data["prefix"].prefixlen + ip_addr = f"{ip_addr}/{prefix}" + + vnic_ips = dict() + vnic_ips["eth0"] = list() + if ip_addr is not None: + ip_addr = str(ip_interface(ip_addr)) + vnic_ips["eth0"].append(ip_addr) + + host_primary_ip4 = ip_addr if ip_addr is not None and ip_interface(ip_addr).version == 4 else None + host_primary_ip6 = ip_addr if ip_addr is not None and ip_interface(ip_addr).version == 6 else None + + # add host to inventory + host = self.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data_dict, + vnic_data=dict(), nic_ips=vnic_ips, + p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6) + self.add_object_to_cache(NBDevice, short_name, host) + + return + + def add_volume(self, obj): + """ + Parse OpenStack volume and store in in a map. + """ + + id = obj.id + size = obj.size + + self.volume_map[id] = size + + def add_virtual_machine(self, obj): + """ + Parse a OpenStack VM add to NetBox once all data is gathered. + + Parameters + ---------- + obj: openstack.compute.v2.server.Server + virtual machine object to parse + """ + + name = get_string_or_none(grab(obj, "name")) + if name is None: + return + + if name is not None and self.settings.strip_vm_domain_name is True: + name = name.split(".")[0] + + if self.settings.set_vm_name_to_uuid: + name = get_string_or_none(obj.id) + + log.debug(f"Parsing OpenStack VM: {name}") + + # get VM power state + status = "active" if get_string_or_none(obj.status) == "ACTIVE" else "offline" + + cluster_name = get_string_or_none(obj.availability_zone) + + # check VM cluster + if cluster_name is None: + log.error(f"Requesting cluster for Virtual Machine '{name}' failed. Skipping.") + return + + elif self.permitted_clusters.get(cluster_name) is None: + log.debug(f"Virtual machine '{name}' is not part of a permitted cluster. Skipping") + return + + nb_cluster_object = self.get_object_from_cache(NBCluster, cluster_name) + + if name in self.processed_vm_names.get(cluster_name, list()): + log.warning(f"Virtual machine '{name}' for cluster '{cluster_name}' already parsed. " + "Make sure to use unique VM names. Skipping") + return + + # add host to processed list + if self.processed_vm_names.get(cluster_name) is None: + self.processed_vm_names[cluster_name] = list() + + self.processed_vm_names[cluster_name].append(name) + + # filter VMs by name + if self.passes_filter(name, self.settings.vm_include_filter, self.settings.vm_exclude_filter) is False: + return + + # + # Collect data + # + + site_name = self.get_site_name(NBCluster, cluster_name) + flavor = dict(grab(obj, "flavor") or {}) + if not all(key in flavor for key in ("vcpus", "ram", "disk")) and flavor.get("id"): + flavor = self.session.compute.get_flavor(flavor["id"]).to_dict() + # first check against vm_platform_relation + platform = None + if self.settings.skip_vm_platform is False: + platform = get_string_or_none(flavor.get("original_name") or flavor.get("name")) + + if platform is not None: + platform = self.get_object_relation(platform, "vm_platform_relation", fallback=platform) + + disks = {} + if grab(obj, "image") and flavor.get("disk"): + disks["root"] = int(flavor["disk"]) + if flavor.get("ephemeral"): + disks["ephemeral"] = int(flavor["ephemeral"]) + disk_complete = True + for volume in grab(obj, "attached_volumes", fallback=[]) or []: + size = self.volume_map.get(volume["id"]) + if size is None: + log.warning(f"Volume '{volume['id']}' of VM '{name}' was not discovered; preserving disk size.") + disk_complete = False + disks[volume["id"]] = int(size) if size is not None else None + + annotation = None + if self.settings.skip_vm_comments is False: + if not self.settings.set_vm_name_to_uuid: + annotation = get_string_or_none(obj.id) + + # assign vm_tenant_relation + tenant_name = self.get_object_relation(name, "vm_tenant_relation") + + vm_data = { + "name": name, + "cluster": nb_cluster_object, + "status": status, + } + if flavor.get("vcpus"): + vm_data["vcpus"] = float(flavor["vcpus"]) + if flavor.get("ram") is not None: + vm_data["memory"] = int(flavor["ram"]) + if disk_complete and version.parse(self.inventory.netbox_api_version) < version.parse("3.7.0"): + vm_data["disk"] = sum(disks.values()) + tags = self.get_object_relation(name, "vm_tag_relation") + if tags: + vm_data["tags"] = tags + host = self.get_object_from_cache(NBDevice, grab(obj, "compute_host")) + if host is not None and version.parse(self.inventory.netbox_api_version) >= version.parse("3.3.0"): + vm_data["device"] = host + + # Add adaption for change in NetBox 3.3.0 VM model + # issue: https://github.com/netbox-community/netbox/issues/10131#issuecomment-1225783758 + if version.parse(self.inventory.netbox_api_version) >= version.parse("3.3.0"): + vm_data["site"] = {"name": site_name} + if platform is not None: + vm_data["platform"] = {"name": platform} + if annotation is not None: + vm_data["comments"] = annotation + if tenant_name is not None: + vm_data["tenant"] = {"name": tenant_name} + + if self.settings.set_vm_name_to_uuid: + custom_field = self.add_update_custom_field({ + "name": "openstack_vm_name", + "label": "name", + "object_types": ["virtualization.virtualmachine"], + "type": "text", + "description": f"Openstack '{self.name}' synced object attribute 'name'" + }) + vm_data["custom_fields"] = { + grab(custom_field, "data.name"): get_string_or_none(grab(obj, "name")) + } + self.add_custom_attributes(NBVM, obj, vm_data) + + vm_primary_ip4 = None + vm_primary_ip6 = None + vm_nic_dict = dict() + nic_ips = dict() + count = 0 + + for network, addresses in (grab(obj, "addresses") or {}).items(): + default_mac = next((address.get("OS-EXT-IPS-MAC:mac_addr") for address in addresses + if address.get("OS-EXT-IPS-MAC:mac_addr")), None) + interfaces = {} + for address in addresses: + mac = address.get("OS-EXT-IPS-MAC:mac_addr") or default_mac + if mac not in interfaces: + count += 1 + full_name = unquote(f"vNIC{count} ({network})") + interfaces[mac] = full_name + vm_nic_dict[full_name] = { + "name": full_name, + "description": full_name, + "enabled": True, + } + if mac: + vm_nic_dict[full_name]["mac_address"] = normalize_mac_address(mac) + nic_ips[full_name] = [] + full_name = interfaces[mac] + try: + ip_addr = str(ip_interface(address["addr"])) + except (KeyError, ValueError): + log.warning(f"Invalid address on OpenStack VM '{name}'; skipping.") + continue + if not self.settings.permitted_subnets.permitted(ip_addr, interface_name=full_name): + continue + + matched_prefix = self.return_longest_matching_prefix_for_ip(ip_interface(ip_addr), site_name) + if matched_prefix is None: + matched_prefix = self.return_longest_matching_prefix_for_ip(ip_interface(ip_addr)) + if matched_prefix is not None: + prefix = matched_prefix.data["prefix"].prefixlen + ip_addr = f"{ip_interface(ip_addr).ip}/{prefix}" + + nic_ips[full_name].append(ip_addr) + if ip_interface(ip_addr).version == 4 and ( + vm_primary_ip4 is None or address.get("OS-EXT-IPS:type") == "fixed"): + vm_primary_ip4 = ip_addr + if ip_interface(ip_addr).version == 6 and ( + vm_primary_ip6 is None or address.get("OS-EXT-IPS:type") == "fixed"): + vm_primary_ip6 = ip_addr + + # add VM to inventory + vm = 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) + if version.parse(self.inventory.netbox_api_version) >= version.parse("3.7.0"): + for disk_name, disk_size in disks.items(): + data = {"name": disk_name, "virtual_machine": vm} + if disk_size is None: + existing = self.inventory.get_by_data(NBVirtualDisk, data=data) + if existing is not None: + existing.update(data=data, source=self) + continue + multiplier = 1000 if version.parse(self.inventory.netbox_api_version) >= version.parse("4.1.0") else 1 + data["size"] = disk_size * multiplier + self.inventory.add_update_object(NBVirtualDisk, data=data, source=self) + + return + + 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 Openstack '{self.name}' " + f"({self.settings.auth_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 Openstack 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 1fea341177ae933b7cefed884d1844e0f8b8545c Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:08:59 -0500 Subject: [PATCH 03/20] Add OpenStack SDK requirements to requirements file --- requirements-openstack.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements-openstack.txt diff --git a/requirements-openstack.txt b/requirements-openstack.txt new file mode 100644 index 00000000..99abdacb --- /dev/null +++ b/requirements-openstack.txt @@ -0,0 +1,2 @@ +-r requirements.txt +openstacksdk>=4.0,<5 From 5377fb42bd52ce9ef5b6110aa7acd5a42f74f195 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:09:47 -0500 Subject: [PATCH 04/20] Add unit tests for OpenStack source integration This test file implements various unit tests for OpenStack discovery against fake SDK resources and real NetBox models. It includes tests for authentication, resource handling, and configuration generation without requiring the SDK. --- tests/test_openstack_source.py | 353 +++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 tests/test_openstack_source.py diff --git a/tests/test_openstack_source.py b/tests/test_openstack_source.py new file mode 100644 index 00000000..a588a1a1 --- /dev/null +++ b/tests/test_openstack_source.py @@ -0,0 +1,353 @@ +"""OpenStack discovery against fake SDK resources and the real NetBox models.""" + +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock + +import pytest + +from module.config.file_output import ConfigFileOutput +from module.config.files import ConfigFileINI, ConfigFileYAML +from module.config.parser import ConfigParser +from module.netbox import ( + NBCluster, NBClusterGroup, NBCustomField, NBDevice, NBIPAddress, NBMACAddress, + NBPrefix, NBSite, NBTenant, NBVirtualDisk, NBVM, NBVMInterface, NBVRF, +) +from module.sources import instantiate_sources, valid_sources +from module.sources.openstack.config import OpenStackConfig +from module.sources.openstack.connection import OpenStackHandler + + +def resource(**values): + obj = SimpleNamespace(**values) + obj.to_dict = lambda: values.copy() + return obj + + +def server(**overrides): + values = dict( + name="app.example.org", id="vm-uuid", status="ACTIVE", availability_zone="zone.example", + flavor={"original_name": "m1.small", "vcpus": 2, "ram": 2048, "disk": 10}, + image={"id": "image"}, attached_volumes=[{"id": "volume-1"}], compute_host="compute1", + addresses={"private": [ + {"addr": "10.0.0.8", "version": 4, "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:00:00:01", + "OS-EXT-IPS:type": "fixed"}, + {"addr": "fd00::8", "version": 6, "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:00:00:01", + "OS-EXT-IPS:type": "fixed"}, + {"addr": "192.0.2.8", "version": 4, "OS-EXT-IPS:type": "floating"}, + ]}, + ) + values.update(overrides) + return resource(**values) + + +@pytest.fixture +def cloud(monkeypatch, inventory): + settings = dict( + type="openstack", auth_url="https://identity.example/v3", project="admin", + username="sync", password="test-only", region="RegionOne", user_domain="Default", + project_domain="Default", permitted_subnets="10.0.0.0/8, fd00::/8", + dns_name_lookup=False, cluster_site_relation=".* = Cloud", vm_tenant_relation=".* = Operations", + cluster_tag_relation=".* = OpenStack", vm_tag_relation=".* = Production", + ) + parser = ConfigParser() + monkeypatch.setattr(parser, "content", {"source": {"cloud": settings}}) + monkeypatch.setattr(parser, "parsing_finished", True) + sdk = ModuleType("openstack") + session = Mock() + session.compute.availability_zones.return_value = [ + resource(name="zone.example", hosts={"compute1": {}}), + resource(name="empty", hosts=None), + ] + session.compute.hypervisors.return_value = [ + resource(name="compute1.example", service_details={"host": "compute1"}, status="enabled", + state="up", host_ip="10.0.0.2", hypervisor_type="QEMU", hypervisor_version=8000000), + ] + session.block_storage.volumes.return_value = [resource(id="volume-1", size=20)] + session.compute.servers.return_value = [server()] + sdk.connect = Mock(return_value=session) + monkeypatch.setitem(sys.modules, "openstack", sdk) + return SimpleNamespace(settings=settings, sdk=sdk, session=session, inventory=inventory) + + +@pytest.mark.parametrize("netbox_version", ["3.6.0", "3.7.0", "4.0.0", "4.1.0", "4.2.0", "4.3.0"]) +def test_discovery_uses_current_inventory_apis(cloud, netbox_version): + inventory = cloud.inventory + inventory.netbox_api_version = netbox_version + site = inventory.add_object(NBSite, data={"id": 1, "name": "Cloud"}, read_from_netbox=True) + tenant = inventory.add_object(NBTenant, data={"id": 1, "name": "Network"}, read_from_netbox=True) + vrf = inventory.add_object(NBVRF, data={"id": 1, "name": "Cloud VRF"}, read_from_netbox=True) + prefix_data = {"prefix": "10.0.0.0/24", "tenant": tenant, "vrf": vrf} + if netbox_version >= "4.2.0": + prefix_data.update(scope_type=NBSite.object_type, scope_id=site) + else: + prefix_data["site"] = site + prefix = inventory.add_object(NBPrefix, data=prefix_data, read_from_netbox=True) + # Model the resolved relations returned by the real NetBox inventory loader. + prefix.data.update(tenant=tenant, vrf=vrf) + prefix.data["scope_id" if netbox_version >= "4.2.0" else "site"] = site + inventory.add_object(NBPrefix, data={"prefix": "fd00::/64"}, read_from_netbox=True) + handler = OpenStackHandler("cloud") + assert handler.init_successful + assert cloud.sdk.connect.call_args.kwargs["verify"] is True + handler.apply() + cluster = handler.get_object_from_cache(NBCluster, "zone.example") + assert cluster.get_site_name() == "Cloud" + if netbox_version >= "4.2.0": + assert cluster.data["scope_id"] is site + assert cluster.data["scope_type"] == "dcim.site" + assert "site" not in cluster.data + host = inventory.get_all_items(NBDevice)[0] + vm = inventory.get_all_items(NBVM)[0] + assert host.data["primary_ip4"].data["address"] == "10.0.0.2/24" + assert vm.data["device"] is host + assert vm.data["status"] == "active" + assert vm.data["vcpus"] == 2 + assert vm.data["memory"] == 2048 + if netbox_version < "3.7.0": + assert vm.data["disk"] == 30 + assert not vm.get_virtual_disks() + else: + assert "disk" not in vm.data + disks = {disk.data["name"]: disk.data["size"] for disk in vm.get_virtual_disks()} + multiplier = 1000 if netbox_version >= "4.1.0" else 1 + assert disks == {"root": 10 * multiplier, "volume-1": 20 * multiplier} + assert vm.data["platform"].data["name"] == "m1.small" + assert "Production" in [tag.data["name"] for tag in vm.data["tags"]] + assert "OpenStack" in [tag.data["name"] for tag in cluster.data["tags"]] + nic = inventory.get_all_items(NBVMInterface)[0] + assert len(inventory.get_all_items(NBVMInterface)) == 1 + assert nic.data["name"] == "vNIC1 (private)" + assert {ip.data["address"] for ip in nic.get_ip_addresses()} == {"10.0.0.8/24", "fd00::8/64"} + assert vm.data["primary_ip4"].data["vrf"].data["name"] == "Cloud VRF" + assert vm.data["primary_ip4"].data["tenant"].data["name"] == "Operations" + assert vm.data["primary_ip6"].data["address"] == "fd00::8/64" + if netbox_version >= "4.2.0": + assert len(inventory.get_all_items(NBMACAddress)) == 1 + assert nic.data["primary_mac_address"].data["mac_address"] == "FA:16:3E:00:00:01" + else: + assert nic.data["mac_address"] == "FA:16:3E:00:00:01" + handler.apply() + assert len(inventory.get_all_items(NBVM)) == 1 + assert len(inventory.get_all_items(NBVMInterface)) == 1 + assert len(inventory.get_all_items(NBCluster)) == 2 + if netbox_version >= "3.7.0": + assert len(inventory.get_all_items(NBVirtualDisk)) == 2 + handler.finish() + cloud.session.close.assert_called_once() + + +def test_registration_authentication_and_project_scope(cloud): + assert OpenStackHandler in valid_sources + assert OpenStackConfig in ConfigFileOutput.source_config_list + cloud.settings.update(validate_tls_certs=False, all_projects=False) + handlers = instantiate_sources() + assert len(handlers) == 1 + handler = handlers[0] + cloud.sdk.connect.assert_called_once_with( + auth_url=cloud.settings["auth_url"], project_name="admin", username="sync", + password="test-only", region_name="RegionOne", user_domain_name="Default", + project_domain_name="Default", verify=False, app_name="netbox-sync", app_version="0.1", + ) + cloud.session.authorize.assert_called_once() + handler.apply() + cloud.session.compute.servers.assert_called_once_with(details=True, all_projects=False) + cloud.session.block_storage.volumes.assert_called_once_with(details=True, all_projects=False) + + +def test_missing_optional_sdk_is_explicit_and_disabled_sources_do_not_import(cloud, monkeypatch, caplog): + def missing_sdk(name): + raise ModuleNotFoundError(name) + monkeypatch.setattr("module.sources.openstack.connection.import_module", missing_sdk) + handler = OpenStackHandler("cloud") + assert not handler.init_successful + assert "requirements-openstack.txt" in caplog.text + assert handler.session is None + cloud.settings["enabled"] = False + import_mock = Mock(side_effect=AssertionError("disabled source imported SDK")) + monkeypatch.setattr("module.sources.openstack.connection.import_module", import_mock) + assert not OpenStackHandler("cloud").init_successful + import_mock.assert_not_called() + + +def test_authentication_failure_without_legacy_msg_attribute(cloud, caplog): + cloud.session.authorize.side_effect = RuntimeError("identity unavailable") + handler = OpenStackHandler("cloud") + assert not handler.init_successful + assert handler.session is None + assert "identity unavailable" in caplog.text + cloud.session.close.assert_called_once() + + +def test_filters_active_duplicates_and_uuid_custom_field(cloud): + cloud.settings.update( + strip_host_domain_name=True, strip_vm_domain_name=True, + vm_exclude_filter="ignore.*", cluster_exclude_filter="empty", + vm_role_relation=".* = Application", + ) + cloud.session.compute.servers.return_value = [ + server(status="SHUTOFF", id="inactive"), server(), + server(name="ignore.example", id="ignored"), + ] + handler = OpenStackHandler("cloud") + handler.apply() + vms = cloud.inventory.get_all_items(NBVM) + assert len(vms) == 1 + assert vms[0].data["name"] == "app" + assert vms[0].data["status"] == "active" + assert vms[0].data["role"].data["name"] == "Application" + assert cloud.inventory.get_all_items(NBDevice)[0].data["name"] == "compute1" + assert len(cloud.inventory.get_all_items(NBCluster)) == 1 + cloud.settings["set_vm_name_to_uuid"] = True + cloud.session.compute.servers.return_value = [server()] + handler = OpenStackHandler("cloud") + handler.apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + assert vm.data["name"] == "vm-uuid" + assert vm.data["custom_fields"]["openstack_vm_name"] == "app.example.org" + field = cloud.inventory.get_all_items(NBCustomField)[0] + assert field.data["object_types"] == ["virtualization.virtualmachine"] + handler.apply() + assert field.data["object_types"] == ["virtualization.virtualmachine"] + + +def test_network_ports_invalid_addresses_and_floating_primary_preference(cloud): + cloud.settings["permitted_subnets"] += ", 192.0.2.0/24" + vm_resource = server() + vm_resource.addresses["private"].extend([ + {"addr": "invalid", "version": 4}, + {"addr": "10.0.0.9", "version": 4, "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:00:00:02"}, + ]) + vm_resource.addresses["without-mac"] = [{"addr": "10.0.1.9", "version": 4}] + cloud.session.compute.servers.return_value = [vm_resource] + OpenStackHandler("cloud").apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + nics = cloud.inventory.get_all_items(NBVMInterface) + assert len(nics) == 3 + assert vm.data["primary_ip4"].data["address"] == "10.0.0.8/32" + assert {ip.data["address"] for ip in nics[0].get_ip_addresses()} == { + "10.0.0.8/32", "fd00::8/128", "192.0.2.8/32", + } + + +def test_flavor_lookup_unknown_volume_and_ipv6_hypervisor(cloud): + cloud.session.compute.servers.return_value = [server(flavor={"id": "flavor-1"}, + attached_volumes=[{"id": "missing"}])] + cloud.session.compute.get_flavor.return_value = resource(name="large", vcpus=4, ram=4096, disk=20) + cloud.session.compute.hypervisors.return_value[0].host_ip = "fd00::2" + handler = OpenStackHandler("cloud") + handler.apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + assert vm.data["memory"] == 4096 + assert vm.data["vcpus"] == 4 + assert "disk" not in vm.data + host = cloud.inventory.get_all_items(NBDevice)[0] + assert "primary_ip4" not in host.data + assert host.data["primary_ip6"].data["address"] == "fd00::2/128" + cloud.session.compute.get_flavor.assert_called_once_with("flavor-1") + + +def test_cluster_matching_does_not_reassign_another_cloud(cloud): + other_group = cloud.inventory.add_object(NBClusterGroup, data={"name": "Other"}) + other_site = cloud.inventory.add_object(NBSite, data={"name": "Other Site"}) + foreign = cloud.inventory.add_object(NBCluster, data={ + "name": "zone.example", "group": other_group, "type": {"name": "Openstack"}, + "scope_type": NBSite.object_type, "scope_id": other_site, + }) + handler = OpenStackHandler("cloud") + handler.apply() + assert foreign.data["group"] is other_group + assert foreign.get_site_name() == "Other Site" + assert handler.get_object_from_cache(NBCluster, "zone.example") is not foreign + + +def test_clusters_with_same_default_group_are_isolated_by_source_tags(cloud): + other_source = SimpleNamespace(source_tag="Source: other") + cloud.inventory.source_list = [other_source] + other_site = cloud.inventory.add_object(NBSite, data={"name": "Other Site"}) + foreign = cloud.inventory.add_object(NBCluster, data={ + "name": "zone.example", "group": {"name": "Openstack"}, "type": {"name": "Openstack"}, + "scope_type": NBSite.object_type, "scope_id": other_site, "tags": ["Source: other"], + }) + handler = OpenStackHandler("cloud") + handler.apply() + assert foreign.get_site_name() == "Other Site" + assert handler.get_object_from_cache(NBCluster, "zone.example") is not foreign + + +def test_missing_volume_retains_existing_virtual_disk_size(cloud): + handler = OpenStackHandler("cloud") + handler.apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + disk = next(disk for disk in vm.get_virtual_disks() if disk.data["name"] == "volume-1") + cloud.session.block_storage.volumes.return_value = [] + handler.apply() + assert disk.data["size"] == 20000 + assert disk.source is handler + assert len(vm.get_virtual_disks()) == 2 + + +@pytest.mark.parametrize("setting,value", [ + ("vm_include_filter", "["), ("vm_tenant_relation", "missing separator"), + ("set_primary_ip", "sometimes"), ("ip_tenant_inheritance_order", "unknown"), +]) +def test_invalid_settings_fail_cleanly(cloud, setting, value): + cloud.settings[setting] = value + with pytest.raises(SystemExit): + OpenStackHandler("cloud") + + +def test_mac_and_primary_ip_matching_use_current_netbox_objects(cloud): + handler = OpenStackHandler("cloud") + handler.apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + assert handler.get_object_based_on_macs(NBVM, ["FA:16:3E:00:00:01"]) is vm + assert handler.get_object_based_on_primary_ip(NBVM, primary_ip4="10.0.0.8") is vm + assert handler.get_object_from_cache(NBVM, "zone.example") is None + + +def test_interface_exclusions_and_resource_custom_attributes(cloud): + cloud.settings.update( + vm_interface_exclude_filter="vNIC1.*", host_interface_exclude_filter="eth0", + vm_custom_object_attributes="id, flavor.vcpus", host_custom_object_attributes="name", + ) + OpenStackHandler("cloud").apply() + assert not cloud.inventory.get_all_items(NBVMInterface) + assert not cloud.inventory.get_all_items(NBIPAddress) + vm = cloud.inventory.get_all_items(NBVM)[0] + assert vm.data["custom_fields"]["openstack_id"] == "vm-uuid" + assert vm.data["custom_fields"]["openstack_flavor_vcpus"] == "2" + host = cloud.inventory.get_all_items(NBDevice)[0] + assert host.data["custom_fields"]["openstack_name"] == "compute1.example" + + +def test_source_neutral_vm_ip_updates_remove_undiscovered_addresses(cloud): + handler = OpenStackHandler("cloud") + handler.apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + old_ip = vm.data["primary_ip4"] + cloud.session.compute.servers.return_value = [server(addresses={ + "private": [{"addr": "10.0.0.9", "version": 4, + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:00:00:01"}], + })] + handler.settings.set_primary_ip = "always" + handler.apply() + assert vm.data["primary_ip4"].data["address"] == "10.0.0.9/32" + assert "assigned_object_id" in old_ip.unset_items + assert len(cloud.inventory.get_all_items(NBVMInterface)) == 1 + + +@pytest.mark.parametrize("file_type", [ConfigFileINI, ConfigFileYAML]) +def test_config_generation_does_not_require_sdk(monkeypatch, file_type): + monkeypatch.setattr("module.sources.openstack.connection.import_module", + Mock(side_effect=AssertionError("config generation imported SDK"))) + output = ConfigFileOutput.__new__(ConfigFileOutput) + output.config_file_type = file_type + output.comment_prefix = file_type.comment_prefix + output.lines = [] + output.format() + text = "\n".join(output.lines) + assert "my-openstack-example" in text + assert "openstack" in text + assert "all_projects" in text + assert "ip_tenant_inheritance_order" in text From c902600e24a9893f8800e842b8ddecb241f5bb78 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:12:20 -0500 Subject: [PATCH 05/20] Add OpenStack as an available source type --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f395b1e7..8f777453 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 +* OpenStack (optional `openstacksdk` dependency) * [bb-ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) inventory files **IMPORTANT: READ INSTRUCTIONS CAREFULLY BEFORE RUNNING THIS PROGRAM** @@ -38,6 +39,8 @@ 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: OpenStack (if used) +* Identity v3, Nova and Cinder APIs; `openstacksdk>=4.0,<5` # Installing * here we assume we install in ```/opt``` From 29101e556fde9c11dce2f588384acb7df6224f13 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:13:45 -0500 Subject: [PATCH 06/20] Add OpenStackConfig to source_config_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..62a1962b 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.openstack.config import OpenStackConfig 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, + OpenStackConfig ] header = f"Welcome to the {__description__} configuration file." From e1ac9bb32c0008b114f93c8c5e9b6a1f63029034 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:14:29 -0500 Subject: [PATCH 07/20] Add OpenStackHandler to valid sources --- 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..627bd760 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.openstack.connection import OpenStackHandler 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, OpenStackHandler] def validate_source(source_class_object=None, state="pre"): From e207f2beeeddfcabe21ec651d5b2c9462cc06af7 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:15:09 -0500 Subject: [PATCH 08/20] Fix VM guest tools status check for IP handling --- module/sources/common/source_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index 7537f514..3ad4f109 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -435,7 +435,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 c07aa6e7a852ae2fc82c35dd92d957c7335babb8 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:16:01 -0500 Subject: [PATCH 09/20] Add optional dependency for OpenStack SDK --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9c284b23..3fda6f1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ ] [project.optional-dependencies] +openstack = [ + "openstacksdk>=4.0,<5", +] test = [ "pytest>=9.1.1", ] From a7e4b4cce0e557bf48a2b93e130cf9eb5d75ed26 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:16:31 -0500 Subject: [PATCH 10/20] Add OpenStack configuration to settings-example.ini --- settings-example.ini | 192 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/settings-example.ini b/settings-example.ini index 75b725ff..9f8207cb 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -611,4 +611,196 @@ inventory_file_path = /full/path/to/inventory/files ; Hetzner Cloud API token used to authenticate against the Hetzner Cloud API. api_token = +[source/my-openstack-example] + +; Defines if this source is enabled or not +;enabled = True + +; type of source. This defines which source handler to use +type = openstack + +; OpenStack Identity v3 authentication URL +auth_url = https://api.openstack-instance.local:5000/v3 + +; Name of the OpenStack project +project = MyProject + +; username to use to log into OpenStack +username = openstackuser + +; password to use to log into OpenStack +password = supersecret + +; Which OpenStack region to use +region = RegionOne + +; Which OpenStack user domain to use +user_domain = Default + +; Which OpenStack project domain to use +project_domain = Default + +; OpenStack group name +;group_name = Openstack + +; Enforces TLS certificate validation. Disable only for an OpenStack endpoint with a self- +; signed certificate. +;validate_tls_certs = True + +; Discover servers and volumes across projects (requires administrative access). +;all_projects = True + +; IP networks eligible to be synced to NetBox. If an IP address is not part of this +; networks then it WON'T be synced to NetBox. To excluded small blocks from bigger IP +; blocks a leading '!' has to be added +;permitted_subnets = 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16, fd00::/8, !10.23.42.0/24 + +; define in which order the IP address tenant will be assigned if tenant is undefined. +; possible values: +; * device : host or VM tenant will be assigned to the IP address +; * prefix : if the IP address belongs to an existing prefix and this prefix has a tenant assigned, then this one is used +; * disabled : no tenant assignment to the IP address will be performed +; the order of the definition is important, the default is "device, prefix" which means: +; If the device has a tenant then this one will be used. If not, the prefix tenant will be used if defined +;ip_tenant_inheritance_order = device, prefix + +; Keep existing primary IP assignments when an address is no longer discovered. +;preserve_primary_ips = False + +; Do not assign addresses belonging to an existing FHRP group. +;skip_fhrp_group_ips = True + +; Regular expression matching host interface names to ignore. +;host_interface_exclude_filter = + +; Regular expression matching VM interface names to ignore. +;vm_interface_exclude_filter = + +; Do not synchronize VLANs discovered through existing prefixes. +;disable_vlan_sync = False + +; filter options + +; 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 +; +; Example: (exclude all VMs with "replica" in their name and all VMs starting with +; "backup"): vm_exclude_filter = .*replica.*|^backup.* + +; If a cluster is excluded from sync then ALL VMs and HOSTS inside the availability zone +; will be ignored! +;cluster_exclude_filter = +;cluster_include_filter = + +; This will only include/exclude the host, not the VM if Host is part of a multi host +; cluster +;host_exclude_filter = +;host_include_filter = + +; simply include/exclude VMs +;vm_exclude_filter = +;vm_include_filter = + +; relations options + +; This option defines which OpenStack availability zone 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 +;cluster_site_relation = nova = Cloud Site + +; Same as cluster site but on host level. If unset it will fall back to +; cluster_site_relation +;host_site_relation = nyc02.* = New York, ffm01.* = Frankfurt + +; 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 +; "availability-zone-name" +;cluster_tenant_relation = Cluster_NYC.* = Customer A +;host_tenant_relation = compute.* = Infrastructure +;vm_tenant_relation = grafana.* = Infrastructure + +; 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 +;vm_platform_relation = centos-7.* = centos7, microsoft-windows-server-2016.* = Windows2016 + +; 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) +;host_role_relation = .* = Server + +; 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) +;vm_role_relation = .* = Server + +; 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) +; Match the availability zone name to assign cluster tags. +;cluster_tag_relation = nova = Infrastructure +;host_tag_relation = compute.* = Infrastructure +;vm_tag_relation = grafana.* = Infrastructure + +; 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 +;dns_name_lookup = True + +; use custom DNS server to do the reverse lookups +;custom_dns_servers = 192.168.1.11, 192.168.1.12 + +; 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" +;set_primary_ip = when-undefined + +; do not set notes to the UUID or name of the VM +;skip_vm_comments = False + +; Do not use the OpenStack flavor name as the NetBox VM platform +;skip_vm_platform = False + +; strip domain part from host name before syncing device to NetBox +;strip_host_domain_name = False + +; strip domain part from VM name before syncing VM to NetBox +;strip_vm_domain_name = False + +; custom object attributes options + +; add arbitrary host/vm object attributes as custom fields to NetBox. +; multiple attributes can be defined comma separated. +; use OpenStack SDK resource attribute names; dotted paths are supported. +;host_custom_object_attributes = id +;vm_custom_object_attributes = id, project_id + +; Set the name in Netbox to the VM UUID instead of name +;set_vm_name_to_uuid = False + ;EOF From fe0fe8fbf34916b61ed4f6ba478cf75862cf01bb Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 09:16:54 -0500 Subject: [PATCH 11/20] Change greeting from 'Hello World' to 'Goodbye World' --- uv.lock | 305 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 301 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 0af21d5f..98bd5226 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" }, @@ -224,6 +252,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + [[package]] name = "hcloud" version = "2.25.1" @@ -255,9 +368,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/39/c534bb172e9318400d5631074b1eda6c78b7c918d0f04233b50caa8e2c10/jsonpatch-1.36.tar.gz", hash = "sha256:7e110cb3c5a1c8768f7170bf8fb9887c4aa575fba74c95239e868b494bd9a522", size = 39985, upload-time = "2026-10-19T10:59:20.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c0/f2b34ce5fa98bde1bab4ff25f48064d09a078c40f4f53f65276a812d5e8a/jsonpatch-1.36-py3-none-any.whl", hash = "sha256:aa3ffbc15816644ffcc6b13712dacbc99f6f6dde302abc66983f9831d35630a5", size = 17094, upload-time = "2026-10-19T10:59:19.066Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/77/9be5cce647fa4d7afa9047c0c1029bc73292c27430e64fc9d2393ccf6727/jsonpointer-3.4.tar.gz", hash = "sha256:e57015ae65af3821c6a3976069860789cf326103f38b22b2178d41d021adad25", size = 13113, upload-time = "2026-10-19T10:33:24.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/d0/3d41544018b879e9c608c32bd5fb468fd87669d53c2dbf5ae67c1bad9882/jsonpointer-3.4-py3-none-any.whl", hash = "sha256:136e2e3273fd82a1502274f56ec172f331b6a1703d86a1824d503af4d9a96142", size = 9047, upload-time = "2026-10-19T10:33:22.963Z" }, +] + +[[package]] +name = "keystoneauth1" +version = "5.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso8601" }, + { name = "os-service-types" }, + { name = "requests" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/268e14554c6bd4a2614872fb2b8431e466ce01415a9783b260ce7fb7ae5c/keystoneauth1-5.18.1.tar.gz", hash = "sha256:8970f9d103f335c39151366d70c001d1bb99b00d73845f03d47825e8dc35ff78", size = 319181, upload-time = "2026-10-12T09:04:48.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/24/38ccf4476c81435c834d4791804099f305b91087fad171ac17d18fc84ca9/keystoneauth1-5.18.1-py3-none-any.whl", hash = "sha256:2440f1f29e4aaaaf771893e8da7c098ca27548210dc13e964b53fd2aaaedd6b0", size = 367402, upload-time = "2026-10-12T09:04:46.753Z" }, +] + [[package]] name = "netbox-sync" -version = "1.9.0" +version = "1.9.1" source = { virtual = "." } dependencies = [ { name = "aiodns" }, @@ -280,6 +448,9 @@ dependencies = [ ] [package.optional-dependencies] +openstack = [ + { name = "openstacksdk" }, +] test = [ { name = "pytest" }, ] @@ -292,6 +463,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.5.2" }, { name = "hcloud", specifier = "==2.25.1" }, { name = "idna", specifier = "==3.20" }, + { name = "openstacksdk", marker = "extra == 'openstack'", specifier = ">=4.0,<5" }, { name = "packaging", specifier = "==26.3" }, { name = "pycares", specifier = "==5.0.1" }, { name = "pycparser", specifier = "==3.0" }, @@ -305,7 +477,43 @@ requires-dist = [ { name = "urllib3", specifier = "==2.8.0" }, { name = "wheel", specifier = "==0.48.0" }, ] -provides-extras = ["test"] +provides-extras = ["openstack", "test"] + +[[package]] +name = "openstacksdk" +version = "4.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "decorator" }, + { name = "dogpile-cache" }, + { name = "iso8601" }, + { name = "jmespath" }, + { name = "jsonpatch" }, + { name = "keystoneauth1" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/de/da119a0fd6ad3794ccc55c4723a50e6db415d495f77ae083622a275d94a3/openstacksdk-4.22.0.tar.gz", hash = "sha256:757f399346f804d0a70b83fead919c498521e509889cd8973ece5cf7a640e6f9", size = 1473175, upload-time = "2026-10-22T13:33:28.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ce/62e8f3a5ffc6769dcc666826178a7bf572b8e01b41a74dc893203ed1d5ec/openstacksdk-4.22.0-py3-none-any.whl", hash = "sha256:e5aafe38e4c338bcb3844f1f446d7761c68477db8cec0e4bb91f4de9d35877c1", size = 2057402, upload-time = "2026-10-22T13:33:26.678Z" }, +] + +[[package]] +name = "os-service-types" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/ae/fe7ac23155ae0b4b9779e06e9c5bb4070f2315dc4ca886a88fa3230d344b/os_service_types-1.9.0.tar.gz", hash = "sha256:1f2e5fb71d1f6f4ff31d8992674f2368465bc2f25cd94018015c3ddbfc5c617f", size = 28087, upload-time = "2026-08-04T13:07:43.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/79/f8ae99ae9bb54cd63e7ab89251b2a4ff4ff07f02847403948b6a6f50f816/os_service_types-1.9.0-py3-none-any.whl", hash = "sha256:f268545c896434177bf55c43f7d5129e65210d5d456df3f00a1c9832a71c3a4b", size = 25282, upload-time = "2026-08-04T13:07:42.258Z" }, +] [[package]] name = "packaging" @@ -316,6 +524,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pbr" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/8d/ce438c28c7958e33184e8ac851ea2225b47a41e5e9e708fa3bddba631135/pbr-7.1.3.tar.gz", hash = "sha256:9a4a85b84e906337708009af0b5f5cdabeeb72d4dc213c9e97974da54fd9acc5", size = 140100, upload-time = "2026-10-07T10:38:15.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a2/79a926b7ab54b247c3419bfa00cfbeb78ad495d21c6d787c978c6261623d/pbr-7.1.3-py2.py3-none-any.whl", hash = "sha256:6583e878a1d97cb135fdc509811f31b9235905cde8d4dacd3dbadf9efc45d745", size = 136717, upload-time = "2026-10-07T10:38:14.069Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -325,6 +554,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pycares" version = "5.0.1" @@ -334,6 +591,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" }, @@ -432,6 +701,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" }, @@ -495,6 +774,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "stevedore" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "urllib3" version = "2.8.0" From 69d794e2dff2120cc18137da461d814d6ca9e8c0 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:32:26 -0500 Subject: [PATCH 12/20] Add OpenStack SDK installation instructions to README Added optional OpenStack SDK installation instructions and configuration details. --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 8f777453..d7dbd5e9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,34 @@ The `vcf-sdk` must be installed if tags should be synced from vCenter to NetBox pip install --upgrade vcf-sdk ``` +### OpenStack (optional) +Install the optional SDK only when using an OpenStack source: +```shell +pip install -r requirements-openstack.txt +# or, with uv: +uv sync --extra openstack +``` + +Set `type = openstack` in a `[source/my-cloud]` section. The generated example +includes Identity v3 URL, project, username/password, user/project domains and +region settings. TLS verification is enabled by default. Set `permitted_subnets` +explicitly; without it no IP addresses are imported. Start with a dry run (`-n`). + +Availability zones become clusters, hypervisors become devices, and servers become +VMs with flavor resources, attached-volume capacity, interfaces, fixed/floating IPs +and IPv4/IPv6 primary addresses. Existing NetBox prefixes supply masks, VRFs, +tenants and VLANs. Filters and site/tenant/role/tag relations are supported, as are +UUID-based VM names and SDK resource attributes as custom fields. + +The historical all-project discovery remains the default and requires access to +Nova availability-zone details/hypervisors and Cinder volumes. `all_projects = false` +limits server/volume queries to the authenticated project, but does not remove +the need for hypervisor discovery permissions. Flavor details are fetched when +Nova returns only a flavor ID. Floating addresses are attached to the matching +MAC interface; fixed addresses are preferred for primary IPs. Multiple servers +with the same name in a zone prefer the active server; use `set_vm_name_to_uuid` +for otherwise indistinguishable VM names. + ## NetBox API token In order to updated data in NetBox you need a NetBox API token. * API token with all permissions (read, write) except: From bb10f34bdbe0ee671973bba86aa70a067508d552 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Sat, 24 Oct 2026 10:19:02 -0500 Subject: [PATCH 13/20] Add OpenStack source documentation This document provides comprehensive details on the OpenStack source integration, including installation, authentication, configuration, object mapping, and validation steps for syncing with NetBox. --- docs/source_openstack.md | 200 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/source_openstack.md diff --git a/docs/source_openstack.md b/docs/source_openstack.md new file mode 100644 index 00000000..0791070a --- /dev/null +++ b/docs/source_openstack.md @@ -0,0 +1,200 @@ +# OpenStack source + +The `openstack` source reads availability zones, compute hypervisors, servers, +flavors, and Cinder volumes, then reconciles their inventory into NetBox. +OpenStack is queried read-only; inventory writes are made to NetBox. +All identifiers and credentials below are fictitious placeholders. + +## Installation + +The SDK is optional; other sources do not require it: + +```sh +pip install -r requirements-openstack.txt +# Alternatively: +uv sync --extra openstack +``` + +The supported SDK range is `openstacksdk>=4.0,<5`. Selecting an OpenStack source +without the SDK logs an error and skips that source. + +On the consolidated branch, Docker builds can enable the SDK with: + +```sh +docker build --build-arg INSTALL_OPENSTACK=true -t netbox-sync:openstack . +``` + +## Authentication and permissions + +The current source uses Keystone Identity v3 username/password authentication +with an explicit project, region, user domain, and project domain. It passes +these source settings directly to `openstacksdk`; it does not expose +`clouds.yaml` cloud selection or application-credential authentication. + +The account must be allowed to read: + +* Detailed Nova availability zones, including their host information. +* Detailed Nova hypervisors and servers. +* Nova flavors when server responses do not include their full specifications. +* Detailed Cinder volumes, including sizes for attached volumes. + +`all_projects = True` is the default for server and volume discovery and +requires cross-project access, normally administrative or suitably delegated +read permissions. Exact roles depend on the cloud's Nova and Cinder policy. +`all_projects = False` requests servers and volumes within the authenticated +project, but does not remove the availability-zone or hypervisor queries; +project-only credentials may still lack the permissions needed by this source. + +Use TLS certificate validation (the default). Configure the runtime's trusted +CA certificates for private endpoints rather than disabling verification. +The sync host needs outbound access to Keystone and the Nova/Cinder endpoints +advertised in the service catalog. No guest SSH access or guest agent is required +for addresses returned by Nova. + +## Configuration + +Add this section to the application's existing NetBox settings: + +```ini +[source/example-openstack] +type = openstack +auth_url = https://identity.example.org:5000/v3 +project = inventory +username = inventory-reader +password = REPLACE_WITH_PASSWORD +region = RegionOne +user_domain = Default +project_domain = Default +validate_tls_certs = True +all_projects = True +group_name = Example OpenStack +cluster_site_relation = .* = Example Cloud Site +permitted_subnets = 10.0.0.0/8, fd00::/8 +dns_name_lookup = False +``` + +All seven authentication/scope fields (`auth_url`, `project`, `username`, +`password`, `region`, `user_domain`, and `project_domain`) are mandatory. +`group_name` defaults to `Openstack`. Use separate named sources for other +regions or clouds; there is no automatic region enumeration. + +Keep real passwords out of version control and restrict access to configuration +files. The application also supports its standard source environment settings; +for example, a source defined entirely through environment variables can use: + +```sh +NBS_SOURCE_1_NAME=example-openstack +NBS_SOURCE_1_TYPE=openstack +NBS_SOURCE_1_AUTH_URL=https://identity.example.org:5000/v3 +NBS_SOURCE_1_PROJECT=inventory +NBS_SOURCE_1_USERNAME=inventory-reader +NBS_SOURCE_1_PASSWORD=REPLACE_WITH_PASSWORD +NBS_SOURCE_1_REGION=RegionOne +NBS_SOURCE_1_USER_DOMAIN=Default +NBS_SOURCE_1_PROJECT_DOMAIN=Default +``` + +These are example variable assignments; supply secrets through your runtime's +secret mechanism rather than storing this block in a repository. +Generate the complete supported configuration with: + +```sh +python netbox-sync.py -g +``` + +### Site, network, and tenant relations + +`cluster_site_relation` maps availability-zone names to NetBox sites using +regular expressions. `host_site_relation` can override sites for hypervisors. +Correct site mapping matters for prefix, VLAN, and VRF selection. + +Only addresses permitted by `permitted_subnets` are imported. Pre-create the +NetBox prefixes, VRFs, and VLANs appropriate to your cloud's networks. Address +prefix matching prefers the mapped site and can fall back to other matching +prefixes; without a matching prefix, a bare IPv4/IPv6 address retains its +host-length mask. This source does not implement AWS-style per-VPC isolation: +carefully inspect overlapping tenant address spaces before permitting IP writes. + +Interfaces and addresses come from Nova server address data, not a separate +Neutron topology inventory. Fixed and floating addresses may both be imported +when permitted; fixed addresses are preferred for primary IP selection. +`set_primary_ip`, `preserve_primary_ips`, `skip_fhrp_group_ips`, +`ip_tenant_inheritance_order`, interface exclusion filters, and +`disable_vlan_sync` control the shared network reconciliation behavior. + +Tenants and tags are configured through relations rather than automatically +mirroring Keystone project names: + +```ini +cluster_site_relation = ^zone-a$ = Example Cloud Site +vm_tenant_relation = ^production-.* = Example Tenant +vm_tag_relation = .* = OpenStack +``` + +Host, VM, and cluster site/tenant/role/tag options and platform mappings are +described in the generated configuration and [example settings](../settings-example.ini). + +## Object mapping + +| OpenStack | NetBox | +| --- | --- | +| Configured group name | Cluster group | +| Availability zone | Cluster | +| Hypervisor | Device and reported host interface/IP | +| Server | Virtual machine | +| Flavor vCPUs / RAM | VM CPU / memory | +| Flavor name | VM platform unless disabled or mapped | +| Image-backed root / ephemeral disks | VM disk capacity or virtual disks | +| Attached Cinder volume | VM disk capacity or virtual disk named by volume ID | +| Server network/MAC/address data | VM interfaces, MACs, and permitted IP addresses | + +Servers with status `ACTIVE` are active in NetBox; other states map to offline. +By default VM names are used, with duplicate names within an availability zone +logged and skipped. Consider `set_vm_name_to_uuid = True` for cross-project +inventories where names repeat; the original name is then stored in the +`openstack_vm_name` custom field. Changing naming settings can affect matching, +so review a dry run before applying them to existing inventory. + +`skip_vm_platform` disables flavor-to-platform mapping. `skip_vm_comments` +disables VM UUID comments. `strip_host_domain_name` and +`strip_vm_domain_name` control domain stripping. +`host_custom_object_attributes` and `vm_custom_object_attributes` copy selected +SDK resource attributes into custom fields; dotted paths are supported. + +NetBox 3.7+ uses separate virtual disks; older versions retain aggregate VM +disk capacity. Disk units follow the existing convention: GiB before NetBox 4.1 +and MiB from 4.1 onward. Missing attached-volume sizes are logged rather than +replaced with fabricated zero sizes. NetBox 4.2+ uses scoped cluster sites and +separate MAC address objects. + +## Filters and lifecycle + +Cluster, host, and VM include/exclude filters are regular expressions matched +from the start of the name. Include checks run before exclude checks. +Excluding an availability zone excludes its hosts and VMs; excluding a host +does not by itself exclude its VMs. + +Objects participate in the application's normal source tagging, orphaning, and +pruning lifecycle. Changing project scope, filters, naming, or permitted networks +can change which objects are considered discovered. Avoid overlapping sources +and inspect the dry-run log before allowing writes or pruning. Do not assume +this source has the AWS implementation's collect-all-accounts failure contract. + +## Validation + +Start with a dry run using credentials with the intended scope: + +```sh +python netbox-sync.py -n -c settings.ini +``` + +Check authentication, availability-zone coverage, hypervisor access, server and +volume counts, site mappings, duplicate names, and address/VRF assignments. +Unit tests use a fake SDK and the real in-memory NetBox models: + +```sh +python -m pytest tests/test_openstack_source.py +``` + +These tests do not replace validation against your cloud's API versions and +authorization policies. From 3966e3da81035b3b2e3e1c0c3d0d3380fb4d1ad4 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Sat, 24 Oct 2026 10:33:51 -0500 Subject: [PATCH 14/20] Update README with OpenStack documentation link Added link to OpenStack source documentation for clarity. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7dbd5e9..49f0de1a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a tool to sync data from different sources to a NetBox instance. Available source types: * VMware vCenter Server -* OpenStack (optional `openstacksdk` dependency) +* OpenStack (optional `openstacksdk` dependency; see [OpenStack source](docs/source_openstack.md)) * [bb-ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) inventory files **IMPORTANT: READ INSTRUCTIONS CAREFULLY BEFORE RUNNING THIS PROGRAM** @@ -41,6 +41,7 @@ This ensures stale objects are removed from NetBox keeping an accurate current s * check_redfish >= 1.2.0 #### Source: OpenStack (if used) * Identity v3, Nova and Cinder APIs; `openstacksdk>=4.0,<5` +* See [OpenStack installation and configuration](docs/source_openstack.md) for credentials, permissions, and inventory mapping # Installing * here we assume we install in ```/opt``` From 5b123445d781867b4e26f1aa523fcb6dd4a880e7 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Tue, 27 Oct 2026 20:39:56 -0500 Subject: [PATCH 15/20] Update OpenStack documentation and remove SDK build instructions Removed Docker build instructions for enabling the OpenStack SDK and clarified disk capacity reporting for NetBox versions. --- docs/source_openstack.md | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/source_openstack.md b/docs/source_openstack.md index 0791070a..f00869e1 100644 --- a/docs/source_openstack.md +++ b/docs/source_openstack.md @@ -18,12 +18,6 @@ uv sync --extra openstack The supported SDK range is `openstacksdk>=4.0,<5`. Selecting an OpenStack source without the SDK logs an error and skips that source. -On the consolidated branch, Docker builds can enable the SDK with: - -```sh -docker build --build-arg INSTALL_OPENSTACK=true -t netbox-sync:openstack . -``` - ## Authentication and permissions The current source uses Keystone Identity v3 username/password authentication @@ -112,8 +106,8 @@ Only addresses permitted by `permitted_subnets` are imported. Pre-create the NetBox prefixes, VRFs, and VLANs appropriate to your cloud's networks. Address prefix matching prefers the mapped site and can fall back to other matching prefixes; without a matching prefix, a bare IPv4/IPv6 address retains its -host-length mask. This source does not implement AWS-style per-VPC isolation: -carefully inspect overlapping tenant address spaces before permitting IP writes. +host-length mask. Carefully inspect overlapping tenant address spaces before +permitting IP writes. Interfaces and addresses come from Nova server address data, not a separate Neutron topology inventory. Fixed and floating addresses may both be imported @@ -162,10 +156,11 @@ disables VM UUID comments. `strip_host_domain_name` and SDK resource attributes into custom fields; dotted paths are supported. NetBox 3.7+ uses separate virtual disks; older versions retain aggregate VM -disk capacity. Disk units follow the existing convention: GiB before NetBox 4.1 -and MiB from 4.1 onward. Missing attached-volume sizes are logged rather than -replaced with fabricated zero sizes. NetBox 4.2+ uses scoped cluster sites and -separate MAC address objects. +disk capacity. OpenStack reports flavor and volume sizes in gigabytes. NetBox +versions before 4.1 store the reported value as gigabytes; from 4.1, the source +converts it to megabytes by multiplying by 1000. Missing attached-volume sizes +are logged rather than replaced with fabricated zero sizes. NetBox 4.2+ uses +scoped cluster sites and separate MAC address objects. ## Filters and lifecycle @@ -177,8 +172,7 @@ does not by itself exclude its VMs. Objects participate in the application's normal source tagging, orphaning, and pruning lifecycle. Changing project scope, filters, naming, or permitted networks can change which objects are considered discovered. Avoid overlapping sources -and inspect the dry-run log before allowing writes or pruning. Do not assume -this source has the AWS implementation's collect-all-accounts failure contract. +and inspect the dry-run log before allowing writes or pruning. ## Validation From 15c4fb71b11ec36ea49bfa8ff06f1bfeacfd5b62 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Tue, 27 Oct 2026 20:40:22 -0500 Subject: [PATCH 16/20] Enhance OpenStack source tests with new scenarios Added tests for handling duplicate VM names and zone hosts. --- tests/test_openstack_source.py | 86 +++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/tests/test_openstack_source.py b/tests/test_openstack_source.py index a588a1a1..04ede292 100644 --- a/tests/test_openstack_source.py +++ b/tests/test_openstack_source.py @@ -10,7 +10,7 @@ from module.config.files import ConfigFileINI, ConfigFileYAML from module.config.parser import ConfigParser from module.netbox import ( - NBCluster, NBClusterGroup, NBCustomField, NBDevice, NBIPAddress, NBMACAddress, + NBCluster, NBClusterGroup, NBCustomField, NBDevice, NBInterface, NBIPAddress, NBMACAddress, NBPrefix, NBSite, NBTenant, NBVirtualDisk, NBVM, NBVMInterface, NBVRF, ) from module.sources import instantiate_sources, valid_sources @@ -100,6 +100,7 @@ def test_discovery_uses_current_inventory_apis(cloud, netbox_version): host = inventory.get_all_items(NBDevice)[0] vm = inventory.get_all_items(NBVM)[0] assert host.data["primary_ip4"].data["address"] == "10.0.0.2/24" + assert host.data["platform"].data["name"] == "QEMU" assert vm.data["device"] is host assert vm.data["status"] == "active" assert vm.data["vcpus"] == 2 @@ -211,6 +212,73 @@ def test_filters_active_duplicates_and_uuid_custom_field(cloud): assert field.data["object_types"] == ["virtualization.virtualmachine"] +def test_duplicate_vm_names_keep_the_existing_server_identity(cloud): + cloud.session.compute.servers.return_value = [ + server(id="server-a", status="SHUTOFF"), + server(id="server-b", status="ACTIVE"), + ] + handler = OpenStackHandler("cloud") + handler.apply() + vm = cloud.inventory.get_all_items(NBVM)[0] + assert vm.data["comments"] == "server-b" + + cloud.session.compute.servers.return_value = [ + server(id="server-a", status="ACTIVE"), + server(id="server-b", status="SHUTOFF"), + ] + handler.apply() + assert vm.data["comments"] == "server-b" + assert vm.data["status"] == "offline" + assert len(cloud.inventory.get_all_items(NBVM)) == 1 + + +def test_duplicate_zone_hosts_are_assigned_to_the_nova_compute_zone(cloud): + cloud.session.compute.availability_zones.return_value = [ + resource(name="internal", hosts={ + "compute1": {"nova-scheduler": {}, "nova-conductor": {}}, + }), + resource(name="nova", hosts={"compute1": {"nova-compute": {}}}), + ] + cloud.session.compute.servers.return_value = [server(availability_zone="nova")] + + handler = OpenStackHandler("cloud") + handler.apply() + + host = cloud.inventory.get_all_items(NBDevice)[0] + vm = cloud.inventory.get_all_items(NBVM)[0] + assert host.data["cluster"].data["name"] == "nova" + assert vm.data["cluster"].data["name"] == "nova" + assert vm.data["device"] is host + + +def test_interface_names_and_host_interface_type_are_preserved(cloud): + addresses = { + "private": [{ + "addr": "10.0.0.8", "version": 4, + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:00:00:01", + }], + "secondary": [{ + "addr": "10.0.0.9", "version": 4, + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:00:00:02", + }], + } + cloud.session.compute.servers.return_value = [server(addresses=addresses)] + handler = OpenStackHandler("cloud") + handler.apply() + vm_nic = next(nic for nic in cloud.inventory.get_all_items(NBVMInterface) + if nic.data["name"] == "vNIC2 (secondary)") + host_interface = cloud.inventory.get_all_items(NBInterface)[0] + host_interface.data["type"] = "1000base-t" + + cloud.session.compute.servers.return_value = [ + server(addresses={"secondary": addresses["secondary"]}), + ] + handler.apply() + + assert vm_nic.data["name"] == "vNIC2 (secondary)" + assert host_interface.data["type"] == "1000base-t" + + def test_network_ports_invalid_addresses_and_floating_primary_preference(cloud): cloud.settings["permitted_subnets"] += ", 192.0.2.0/24" vm_resource = server() @@ -230,6 +298,22 @@ def test_network_ports_invalid_addresses_and_floating_primary_preference(cloud): } +def test_primary_ip_uses_first_fixed_address(cloud): + cloud.settings["permitted_subnets"] += ", 192.0.2.0/24" + cloud.session.compute.servers.return_value = [server(addresses={ + "private": [ + {"addr": "192.0.2.8", "version": 4, "OS-EXT-IPS:type": "floating"}, + {"addr": "10.0.0.8", "version": 4, "OS-EXT-IPS:type": "fixed"}, + {"addr": "10.0.0.9", "version": 4, "OS-EXT-IPS:type": "fixed"}, + ], + })] + + OpenStackHandler("cloud").apply() + + vm = cloud.inventory.get_all_items(NBVM)[0] + assert vm.data["primary_ip4"].data["address"] == "10.0.0.8/32" + + def test_flavor_lookup_unknown_volume_and_ipv6_hypervisor(cloud): cloud.session.compute.servers.return_value = [server(flavor={"id": "flavor-1"}, attached_volumes=[{"id": "missing"}])] From e5a88c24f7e2616c6a12a0b38fcc08ffbdb29be0 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Tue, 27 Oct 2026 20:40:45 -0500 Subject: [PATCH 17/20] Update deprecation messages for sync_tags options --- module/sources/openstack/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/sources/openstack/config.py b/module/sources/openstack/config.py index fd5d0668..63238580 100644 --- a/module/sources/openstack/config.py +++ b/module/sources/openstack/config.py @@ -274,13 +274,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 2d01ab86e8fab95dff83960dae9db9d7ca0aea1d Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Tue, 27 Oct 2026 20:41:05 -0500 Subject: [PATCH 18/20] Refactor server selection and host mapping logic Refactor server selection logic to prioritize associated servers and introduce a new method for server priority determination. Update cluster host mapping to retrieve compute host names directly. --- module/sources/openstack/connection.py | 70 +++++++++++++++++++++----- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/module/sources/openstack/connection.py b/module/sources/openstack/connection.py index 3914850d..28e6e55b 100644 --- a/module/sources/openstack/connection.py +++ b/module/sources/openstack/connection.py @@ -179,8 +179,8 @@ def apply(self): self.add_volume(volume) servers = self.session.compute.servers(details=True, all_projects=self.settings.all_projects) - # Prefer active machines when duplicate names occur during a migration. - for server in sorted(servers, key=lambda server: server.status != "ACTIVE"): + # Prefer an already-associated server before using status to break ties. + for server in sorted(servers, key=self.get_server_priority): self.add_virtual_machine(server) self.update_basic_data() @@ -477,6 +477,43 @@ def get_cluster_for_host(self, hostname): return cluster return None + def get_server_priority(self, server): + name = get_string_or_none(grab(server, "name")) + if name is not None and self.settings.strip_vm_domain_name: + name = name.split(".")[0] + if self.settings.set_vm_name_to_uuid: + name = get_string_or_none(grab(server, "id")) + + cluster_name = get_string_or_none(grab(server, "availability_zone")) + cluster = self.get_object_from_cache(NBCluster, cluster_name) + if name is not None and cluster is not None and not self.settings.skip_vm_comments: + existing_vm = self.inventory.get_by_data( + NBVM, data={"name": name, "cluster": cluster} + ) + if grab(existing_vm, "data.comments") == get_string_or_none(grab(server, "id")): + return 0, server.status != "ACTIVE" + + return 1, server.status != "ACTIVE" + + @staticmethod + def get_compute_host_names(hosts): + compute_host_names = [] + for hostname, services in (hosts or {}).items(): + if not isinstance(services, dict) or not services: + compute_host_names.append(hostname) + continue + + service_names = {str(name).lower() for name in services} + service_names.update( + str(grab(service, "binary", fallback="")).lower() + for service in services.values() + ) + has_nova_services = any(name.startswith("nova-") for name in service_names) + if not has_nova_services or "nova-compute" in service_names: + compute_host_names.append(hostname) + + return compute_host_names + def object_synced_by_other_source(self, nb_object): if nb_object.source is not None: return nb_object.source is not self @@ -660,6 +697,13 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v 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(): + existing_interface = nic_object_dict.get(int_name) + if existing_interface is not None: + existing_name = grab(existing_interface, "data.name") + if existing_name is not None: + int_data["name"] = existing_name + if object_type == NBDevice: + int_data.pop("type", None) # add/update interface with retrieved data nic_object, ip_address_objects = self.add_update_interface(nic_object_dict.get(int_name), device_vm_object, @@ -834,12 +878,9 @@ def add_cluster(self, obj): self.add_object_to_cache(NBCluster, name, cluster_object) - self.cluster_host_map[name] = list() - log.debug2(f"Found an existing host where 'name' matches (NetBox id: {obj.hosts})") - # trap for a cluster with no hosts - if obj.hosts is not None: - for host in obj.hosts: - self.cluster_host_map[name].append(host) + self.cluster_host_map[name] = self.get_compute_host_names(grab(obj, "hosts")) + log.debug2(f"Found compute hosts for availability zone '{name}': " + f"{self.cluster_host_map[name]}") self.permitted_clusters[name] = site_name @@ -923,9 +964,7 @@ def add_host(self, obj): # collect all necessary data manufacturer = None model = None - product_name = get_string_or_none(obj.hypervisor_type) - product_version = get_string_or_none(obj.hypervisor_version) - platform = f"{product_name} {product_version}" + platform = get_string_or_none(obj.hypervisor_type) # if the device vendor/model cannot be retrieved (due to problem on the host), # set a dummy value so the host still gets synced @@ -1150,6 +1189,8 @@ def add_virtual_machine(self, obj): vm_primary_ip4 = None vm_primary_ip6 = None + vm_primary_ip4_is_fixed = False + vm_primary_ip6_is_fixed = False vm_nic_dict = dict() nic_ips = dict() count = 0 @@ -1189,12 +1230,15 @@ def add_virtual_machine(self, obj): ip_addr = f"{ip_interface(ip_addr).ip}/{prefix}" nic_ips[full_name].append(ip_addr) + is_fixed = address.get("OS-EXT-IPS:type") == "fixed" if ip_interface(ip_addr).version == 4 and ( - vm_primary_ip4 is None or address.get("OS-EXT-IPS:type") == "fixed"): + vm_primary_ip4 is None or (is_fixed and not vm_primary_ip4_is_fixed)): vm_primary_ip4 = ip_addr + vm_primary_ip4_is_fixed = is_fixed if ip_interface(ip_addr).version == 6 and ( - vm_primary_ip6 is None or address.get("OS-EXT-IPS:type") == "fixed"): + vm_primary_ip6 is None or (is_fixed and not vm_primary_ip6_is_fixed)): vm_primary_ip6 = ip_addr + vm_primary_ip6_is_fixed = is_fixed # add VM to inventory vm = self.add_device_vm_to_inventory(NBVM, object_data=vm_data, vnic_data=vm_nic_dict, From 7201b0f3eacbccc11a9e9fde05f439ebe5565041 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Wed, 28 Oct 2026 23:33:49 -0500 Subject: [PATCH 19/20] Enhance error logging for host availability checks Add logging for disabled hosts in Nova service. --- module/sources/openstack/connection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/module/sources/openstack/connection.py b/module/sources/openstack/connection.py index 28e6e55b..01122ac4 100644 --- a/module/sources/openstack/connection.py +++ b/module/sources/openstack/connection.py @@ -922,7 +922,11 @@ def add_host(self, obj): cluster_name = self.get_cluster_for_host(short_name) if cluster_name is None: - log.error(f"Requesting cluster for host '{name}' failed. Skipping.") + # Nova omits disabled compute services from availability zone host lists + if get_string_or_none(grab(obj, "status")) == "disabled": + log.info(f"Host '{name}' is disabled in Nova and not part of any availability zone. Skipping.") + else: + log.error(f"Unable to find an availability zone for host '{name}'. Skipping.") return if log.level == DEBUG3: From 3645b136ca3078a3460449e01f9ce9ac1ae2823b Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Wed, 28 Oct 2026 23:35:13 -0500 Subject: [PATCH 20/20] Add test for host without availability zone handling --- tests/test_openstack_source.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_openstack_source.py b/tests/test_openstack_source.py index 04ede292..2a80bfc8 100644 --- a/tests/test_openstack_source.py +++ b/tests/test_openstack_source.py @@ -331,6 +331,23 @@ def test_flavor_lookup_unknown_volume_and_ipv6_hypervisor(cloud): cloud.session.compute.get_flavor.assert_called_once_with("flavor-1") +@pytest.mark.parametrize("status, level, message", [ + ("disabled", "INFO", "is disabled in Nova"), + ("enabled", "ERROR", "Unable to find an availability zone"), +]) +def test_host_without_availability_zone_is_skipped(cloud, caplog, status, level, message): + cloud.session.compute.hypervisors.return_value.append( + resource(name="compute2.example", service_details={"host": "compute2"}, status=status, + state="up", host_ip="10.0.0.3", hypervisor_type="QEMU", hypervisor_version=8000000)) + handler = OpenStackHandler("cloud") + with caplog.at_level("INFO"): + handler.apply() + names = [device.data["name"] for device in cloud.inventory.get_all_items(NBDevice)] + assert "compute2.example" not in names + records = [r for r in caplog.records if "compute2.example" in r.getMessage() and message in r.getMessage()] + assert [r.levelname for r in records] == [level] + + def test_cluster_matching_does_not_reassign_another_cloud(cloud): other_group = cloud.inventory.add_object(NBClusterGroup, data={"name": "Other"}) other_site = cloud.inventory.add_object(NBSite, data={"name": "Other Site"})