diff --git a/README.md b/README.md index f395b1e7..49f0de1a 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; 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** @@ -38,6 +39,9 @@ This ensures stale objects are removed from NetBox keeping an accurate current s * VMWare vCenter >= 6.0 #### Source: check_redfish (if used) * check_redfish >= 1.2.0 +#### Source: 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``` @@ -72,6 +76,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: diff --git a/docs/source_openstack.md b/docs/source_openstack.md new file mode 100644 index 00000000..f00869e1 --- /dev/null +++ b/docs/source_openstack.md @@ -0,0 +1,194 @@ +# 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. + +## 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. 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. 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 + +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. + +## 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. 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." 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"): 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 diff --git a/module/sources/openstack/config.py b/module/sources/openstack/config.py new file mode 100644 index 00000000..63238580 --- /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_relation', " + + "'vm_tag_relation' or 'cluster_tag_relation'", + removed=True), + ConfigOption("sync_parent_tags", + bool, + deprecation_message="You need to switch to 'host_tag_relation', " + + "'vm_tag_relation' or 'cluster_tag_relation'", + 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) diff --git a/module/sources/openstack/connection.py b/module/sources/openstack/connection.py new file mode 100644 index 00000000..01122ac4 --- /dev/null +++ b/module/sources/openstack/connection.py @@ -0,0 +1,1299 @@ +# -*- 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 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() + + @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 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 + 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(): + 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, + 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] = 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 + + 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: + # 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: + 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 + 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 + 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_primary_ip4_is_fixed = False + vm_primary_ip6_is_fixed = False + 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) + is_fixed = address.get("OS-EXT-IPS:type") == "fixed" + if ip_interface(ip_addr).version == 4 and ( + 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 (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, + 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 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", ] 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 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 diff --git a/tests/test_openstack_source.py b/tests/test_openstack_source.py new file mode 100644 index 00000000..2a80bfc8 --- /dev/null +++ b/tests/test_openstack_source.py @@ -0,0 +1,454 @@ +"""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, NBInterface, 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 host.data["platform"].data["name"] == "QEMU" + 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_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() + 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_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"}])] + 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") + + +@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"}) + 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 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"