From c73205230a1528d6a36795233ff7b89eecc86729 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 10:54:14 -0500 Subject: [PATCH 01/26] Add AWSConfig class for AWS source configuration --- module/sources/aws/config.py | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 module/sources/aws/config.py diff --git a/module/sources/aws/config.py b/module/sources/aws/config.py new file mode 100644 index 00000000..4a58abff --- /dev/null +++ b/module/sources/aws/config.py @@ -0,0 +1,99 @@ +from module.common.logging import get_logger +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.sources.common.config import ( + config_option_enabled_definition, + config_option_permitted_subnets_definition, + config_option_type_definition, +) +from module.sources.common.permitted_subnets import PermittedSubnets + +log = get_logger() + + +class AWSConfig(ConfigBase): + section_name = source_config_section_name + source_name_example = "my-aws-example" + source_name = None + + def __init__(self): + self.options = [ + ConfigOption(**config_option_enabled_definition), + ConfigOption(**{**config_option_type_definition, "config_example": "aws"}), + ConfigOption("regions", str, mandatory=True, config_example="us-east-1, us-west-2", + description="Explicit AWS regions to import (comma-separated or a YAML list)."), + ConfigOption("profile", str, + description="AWS SDK profile. Otherwise use the standard credential chain."), + ConfigOption("account_id", str, + description="Optional expected 12-digit account ID; fail if credentials use another account."), + ConfigOption(**config_option_permitted_subnets_definition), + ConfigOption("sync_private_ips", bool, default_value=True, + description="Import private IPv4 and IPv6 into pre-existing VPC-specific VRFs."), + ConfigOption("vpc_vrf_relation", str, + description="""Comma-separated region/vpc-id = NetBox VRF name mappings. + Unmapped VPCs require a VRF named AWS //. + Each VPC must have a distinct VRF; global address space is never used.""", + config_example="us-east-1/vpc-0123456789abcdef0 = Production VPC"), + ConfigOption("tag_keys", str, + description="AWS tag keys to copy as namespaced NetBox tags; unset imports none."), + ConfigOption("tenant", str, description="Optional NetBox tenant for AWS VMs and IPs."), + ConfigOption("dns_name_lookup", bool, default_value=False, + description="Perform reverse DNS lookups for imported private IPs."), + ] + super().__init__() + + def validate_options(self): + for key in ("profile", "tenant"): + value = self.get_option_by_name(key).value + if value is not None and not isinstance(value, str): + log.error(f"AWS '{self.source_name}' option '{key}' must be a string.") + self.set_validation_failed() + + for key in ("regions", "tag_keys"): + option = self.get_option_by_name(key) + value = option.value + if value is not None and not isinstance(value, (str, list)): + log.error(f"AWS '{self.source_name}' option '{key}' must be a string or list.") + self.set_validation_failed() + continue + entries = value if isinstance(value, list) else quoted_split(value) if value else [] + if any(not isinstance(entry, str) or not entry.strip() for entry in entries): + log.error(f"AWS '{self.source_name}' option '{key}' contains an invalid entry.") + self.set_validation_failed() + continue + entries = list(dict.fromkeys(entry.strip() for entry in entries)) + if key == "regions" and not entries: + log.error(f"AWS '{self.source_name}' requires at least one explicit region.") + self.set_validation_failed() + option.set_value(entries) + + account = self.get_option_by_name("account_id").value + if account is not None and (not isinstance(account, str) or len(account) != 12 or not account.isdigit()): + log.error(f"AWS '{self.source_name}' account_id must be a 12-digit string.") + self.set_validation_failed() + + relation = self.get_option_by_name("vpc_vrf_relation") + mappings = {} + relation_value = relation.value + if relation_value is not None and not isinstance(relation_value, str): + log.error(f"AWS '{self.source_name}' vpc_vrf_relation must be a string.") + self.set_validation_failed() + relation_value = None + for entry in quoted_split(relation_value) if relation_value else []: + key, separator, value = entry.partition("=") + key, value = key.strip(' "\''), value.strip(' "\'') + parts = key.split("/") + if not separator or len(parts) != 2 or not all(parts) or not value or key in mappings: + log.error(f"Malformed or duplicate AWS VPC/VRF mapping '{entry}'.") + self.set_validation_failed() + continue + mappings[key] = value + relation.set_value(mappings) + + permitted = self.get_option_by_name("permitted_subnets") + subnets = PermittedSubnets(permitted.value) + if subnets.validation_failed: + self.set_validation_failed() + permitted.set_value(subnets) From 5c60826674f364834d6f85edd7ee8a53aef15587 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 10:55:18 -0500 Subject: [PATCH 02/26] Implement AWSHandler class for AWS integration --- module/sources/aws/connection.py | 288 +++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 module/sources/aws/connection.py diff --git a/module/sources/aws/connection.py b/module/sources/aws/connection.py new file mode 100644 index 00000000..1979ae06 --- /dev/null +++ b/module/sources/aws/connection.py @@ -0,0 +1,288 @@ +from ipaddress import ip_address, ip_interface, ip_network + +from packaging.version import Version + +from module.common.logging import get_logger +from module.common.misc import do_error_exit +from module.common.support import normalize_mac_address +from module.netbox import ( + NBCustomField, NBCluster, NBClusterGroup, NBClusterType, NBIPAddress, + NBMACAddress, NBSite, NBTag, NBTenant, NBVirtualDisk, NBVM, NBVMInterface, NBVRF, +) +from module.netbox.inventory import NetBoxInventory +from module.sources.aws.client import AWSClient, AWSDiscoveryError +from module.sources.aws.config import AWSConfig +from module.sources.common.source_base import SourceBase + +log = get_logger() + + +class AWSHandler(SourceBase): + source_type = "aws" + dependent_netbox_objects = [ + NBTag, NBCustomField, NBTenant, NBSite, NBClusterGroup, NBClusterType, + NBCluster, NBVM, NBVMInterface, NBMACAddress, NBIPAddress, NBVRF, NBVirtualDisk, + ] + + def __init__(self, name=None): + if not name: + raise ValueError(f"Invalid source name '{name}'.") + self.name = name + self.inventory = NetBoxInventory() + config = AWSConfig() + config.source_name = name + self.settings = config.parse() + self.set_source_tag() + self.init_successful = False + if not self.settings.enabled: + log.info(f"Source '{name}' is disabled. Skipping.") + return + try: + self.client = AWSClient(profile=self.settings.profile) + except AWSDiscoveryError as exc: + self.abort(exc) + self.init_successful = True + + def abort(self, error): + message = f"AWS source '{self.name}': {error}. No NetBox writes or pruning will be performed." + log.error(message) + do_error_exit(message) + + def apply(self): + try: + account, snapshot = self.client.discover(self.settings.regions, self.settings.account_id) + self.validate_snapshot(account, snapshot) + self.sync_snapshot(account, snapshot) + except (AWSDiscoveryError, ValueError, KeyError) as exc: + self.abort(exc) + + def vrf_name(self, account, region, vpc): + return self.settings.vpc_vrf_relation.get( + f"{region}/{vpc}", f"AWS {account}/{region}/{vpc}") + + def get_vrf(self, account, region, vpc): + name = self.vrf_name(account, region, vpc) + vrf = self.inventory.get_by_data(NBVRF, data={"name": name}) + if vrf is None: + raise ValueError(f"Create NetBox VRF '{name}' for VPC '{vpc}' before importing its IPs") + return vrf + + def addresses(self, eni, subnet): + networks = [ip_network(subnet["CidrBlock"])] + networks.extend( + ip_network(entry["Ipv6CidrBlock"]) + for entry in subnet.get("Ipv6CidrBlockAssociationSet", []) + if entry.get("Ipv6CidrBlockState", {}).get("State") == "associated" + ) + addresses = [ + (entry["PrivateIpAddress"], entry.get("Primary", False)) + for entry in eni.get("PrivateIpAddresses", []) + ] + addresses.extend( + (entry["Ipv6Address"], entry.get("IsPrimaryIpv6", False)) + for entry in eni.get("Ipv6Addresses", []) + ) + result = [] + for value, primary in addresses: + address = ip_address(value) + network = next((net for net in networks + if net.version == address.version and address in net), None) + if network is None: + raise ValueError(f"ENI '{eni['NetworkInterfaceId']}' IP '{value}' has no matching AWS subnet") + value = f"{address}/{network.prefixlen}" + if self.settings.permitted_subnets.permitted(value, interface_name=eni["NetworkInterfaceId"]): + result.append((value, primary)) + return result + + def validate_snapshot(self, account, snapshot): + vrfs = {} + for region, data in snapshot.items(): + for instance in data["instances"]: + spec = data["InstanceTypes"].get(instance["InstanceType"]) + if spec is None: + raise ValueError(f"Missing AWS instance type '{instance['InstanceType']}'") + if "DefaultVCpus" not in spec["VCpuInfo"] or "SizeInMiB" not in spec["MemoryInfo"]: + raise ValueError(f"Incomplete AWS specifications for '{instance['InstanceType']}'") + for tag in instance.get("Tags", []): + if tag["Key"] in self.settings.tag_keys and len(f"AWS:{tag['Key']}={tag['Value']}") > 100: + raise ValueError(f"AWS tag '{tag['Key']}' exceeds the NetBox tag name limit (100 characters)") + for attachment in instance.get("BlockDeviceMappings", []): + volume_id = attachment.get("Ebs", {}).get("VolumeId") + if volume_id and volume_id not in data["Volumes"]: + raise ValueError(f"Missing attached EBS volume '{volume_id}'") + for reference in instance.get("NetworkInterfaces", []): + eni = data["NetworkInterfaces"].get(reference["NetworkInterfaceId"]) + if eni is None: + raise ValueError(f"Missing attached ENI '{reference['NetworkInterfaceId']}'") + if eni.get("Attachment", {}).get("InstanceId") != instance["InstanceId"]: + raise ValueError(f"ENI '{eni['NetworkInterfaceId']}' changed attachment during discovery") + if not eni.get("MacAddress"): + raise ValueError(f"ENI '{eni['NetworkInterfaceId']}' has no MAC address") + if not self.settings.sync_private_ips: + continue + vpc, subnet = eni["VpcId"], data["Subnets"].get(eni["SubnetId"]) + if vpc not in data["Vpcs"] or subnet is None or subnet["VpcId"] != vpc: + raise ValueError(f"Incomplete VPC/subnet discovery for ENI '{eni['NetworkInterfaceId']}'") + if self.addresses(eni, subnet): + vrf = self.get_vrf(account, region, vpc) + identity = (account, region, vpc) + if vrf in vrfs and vrfs[vrf] != identity: + raise ValueError(f"VRF '{vrf.data['name']}' is mapped to more than one AWS VPC") + vrfs[vrf] = identity + + def custom_fields(self): + fields = ( + ("aws_account_id", "AWS account ID", [NBVM.object_type]), + ("aws_region", "AWS region", [NBVM.object_type]), + ("aws_instance_id", "AWS instance ID", [NBVM.object_type]), + ("aws_name", "AWS Name tag", [NBVM.object_type]), + ("aws_instance_type", "AWS instance type", [NBVM.object_type]), + ("aws_availability_zone", "AWS Availability Zone", [NBVM.object_type]), + ("aws_vpc_id", "AWS VPC ID", [NBVM.object_type, NBVMInterface.object_type]), + ("aws_eni_id", "AWS ENI ID", [NBVMInterface.object_type]), + ("aws_subnet_id", "AWS subnet ID", [NBVMInterface.object_type]), + ("aws_public_ipv4", "AWS public IPv4", [NBVMInterface.object_type]), + ) + for name, label, types in fields: + self.add_update_custom_field({ + "name": name, "label": label, "object_types": types, "type": "text", + "description": f"Reported by AWS ({self.name})", + }) + + def sync_snapshot(self, account, snapshot): + self.custom_fields() + group = self.inventory.add_update_object( + NBClusterGroup, data={"name": f"AWS {account}"}, source=self) + api_version = Version(self.inventory.netbox_api_version) + for region, data in snapshot.items(): + site = self.inventory.add_update_object( + NBSite, data={"name": f"AWS {region}"}, source=self) + cluster_data = { + "name": f"AWS {account}/{region}", "group": group, + "type": {"name": "AWS EC2"}, + } + if api_version >= Version("4.2"): + cluster_data.update(scope_type="dcim.site", scope_id=site) + else: + cluster_data["site"] = site + cluster = self.inventory.add_update_object(NBCluster, data=cluster_data, source=self) + for instance in data["instances"]: + self.sync_instance(account, region, instance, data, site, cluster, api_version) + + def sync_instance(self, account, region, instance, data, site, cluster, api_version): + instance_id = instance["InstanceId"] + tags = {tag["Key"]: tag["Value"] for tag in instance.get("Tags", [])} + spec = data["InstanceTypes"][instance["InstanceType"]] + vm_data = { + "name": instance_id, "cluster": cluster, + "status": "active" if instance["State"]["Name"] == "running" else "offline", + "vcpus": spec["VCpuInfo"]["DefaultVCpus"], + "memory": spec["MemoryInfo"]["SizeInMiB"], + "custom_fields": { + "aws_account_id": account, "aws_region": region, + "aws_instance_id": instance_id, "aws_name": tags.get("Name", ""), + "aws_instance_type": instance["InstanceType"], + "aws_availability_zone": instance["Placement"]["AvailabilityZone"], + "aws_vpc_id": instance.get("VpcId", ""), + }, + "tags": [f"AWS:{key}={tags[key]}" for key in self.settings.tag_keys if key in tags], + } + cpu = instance.get("CpuOptions", {}) + if cpu.get("CoreCount") is not None and cpu.get("ThreadsPerCore") is not None: + vm_data["vcpus"] = cpu["CoreCount"] * cpu["ThreadsPerCore"] + if api_version >= Version("3.3"): + vm_data["site"] = site + if api_version >= Version("4.1"): + vm_data["serial"] = instance_id + if self.settings.tenant: + vm_data["tenant"] = {"name": self.settings.tenant} + disks = [ + data["Volumes"][entry["Ebs"]["VolumeId"]] + for entry in instance.get("BlockDeviceMappings", []) if "Ebs" in entry + ] + if api_version < Version("3.7"): + vm_data["disk"] = sum(volume["Size"] for volume in disks) + vm = self.inventory.add_update_object(NBVM, data=vm_data, source=self) + for tag in vm.get_tags(): + if any(tag.startswith(f"AWS:{key}=") for key in self.settings.tag_keys) and tag not in vm_data["tags"]: + vm.remove_tags(tag) + if api_version >= Version("3.7"): + for volume in disks: + self.inventory.add_update_object(NBVirtualDisk, data={ + "name": volume["VolumeId"], "virtual_machine": vm, + "size": volume["Size"] * (1024 if api_version >= Version("4.1") else 1), + "description": f"AWS EBS {volume['VolumeType']}", + }, source=self) + for reference in instance.get("NetworkInterfaces", []): + eni = data["NetworkInterfaces"][reference["NetworkInterfaceId"]] + self.sync_interface(account, region, vm, eni, data, api_version) + + def sync_interface(self, account, region, vm, eni, data, api_version): + interface_data = { + "name": eni["NetworkInterfaceId"], "virtual_machine": vm, "enabled": True, + "description": eni.get("Description", ""), + "custom_fields": { + "aws_eni_id": eni["NetworkInterfaceId"], "aws_vpc_id": eni.get("VpcId", ""), + "aws_subnet_id": eni.get("SubnetId", ""), + "aws_public_ipv4": ", ".join(sorted({ + address["Association"]["PublicIp"] + for address in eni.get("PrivateIpAddresses", []) + if address.get("Association", {}).get("PublicIp") + })), + }, + } + mac = normalize_mac_address(eni.get("MacAddress")) + if api_version < Version("4.2"): + interface_data["mac_address"] = mac + interface = self.inventory.add_update_object(NBVMInterface, data=interface_data, source=self) + if api_version >= Version("4.2") and mac is not None: + mac_object = next((item for item in self.inventory.get_all_items(NBMACAddress) + if item.data.get("mac_address") == mac and + item.data.get("assigned_object_id") in (None, interface)), None) + mac_data = {"mac_address": mac, "assigned_object_id": interface, + "assigned_object_type": NBVMInterface} + if mac_object is None: + mac_object = self.inventory.add_object(NBMACAddress, data=mac_data, source=self) + else: + mac_object.update(data=mac_data, source=self) + for old in interface.get_mac_addresses(): + if old is not mac_object: + old.remove_interface_association() + interface.update(data={"primary_mac_address": mac_object}, source=self) + if not self.settings.sync_private_ips: + for address in interface.get_ip_addresses(): + if self.source_tag in address.get_tags(): + address.set_source(self) + return + subnet = data["Subnets"][eni["SubnetId"]] + addresses = self.addresses(eni, subnet) + discovered = [] + for address, primary in addresses: + vrf = self.get_vrf(account, region, eni["VpcId"]) + parsed = ip_interface(address) + candidates = [ + item for item in self.inventory.get_all_items(NBIPAddress) + if ip_interface(item.data["address"]).ip == parsed.ip and item.data.get("vrf") is vrf + ] + if len(candidates) > 1: + raise ValueError(f"Multiple NetBox IPs match '{address}' in VRF '{vrf.data['name']}'") + existing = candidates[0] if candidates else None + if existing is not None and existing.data.get("assigned_object_id") is not None and \ + existing.get_interface() is not interface: + owner = existing.get_device_vm() + if owner is None or (owner.source is not self and self.source_tag not in owner.get_tags()): + raise ValueError(f"IP '{address}' in VRF '{vrf.data['name']}' belongs to another source") + ip_data = {"address": address, "vrf": vrf, "assigned_object_id": interface, + "assigned_object_type": NBVMInterface} + if self.settings.tenant: + ip_data["tenant"] = {"name": self.settings.tenant} + if existing is None: + existing = self.inventory.add_object(NBIPAddress, data=ip_data, source=self) + else: + existing.update(data=ip_data, source=self) + discovered.append(existing) + if eni.get("Attachment", {}).get("DeviceIndex") == 0 and primary: + vm.update(data={f"primary_ip{parsed.version}": existing}, source=self) + for old in interface.get_ip_addresses(): + if old not in discovered and (old.source is self or self.source_tag in old.get_tags()): + old.remove_interface_association() From b74902785317c0ead5514026ed614774db836fd6 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 10:56:10 -0500 Subject: [PATCH 03/26] Add AWS requirements to requirements-aws.txt --- requirements-aws.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements-aws.txt diff --git a/requirements-aws.txt b/requirements-aws.txt new file mode 100644 index 00000000..368a9493 --- /dev/null +++ b/requirements-aws.txt @@ -0,0 +1,2 @@ +-r requirements.txt +boto3>=1.40,<2 From ac73426b4162210dc34fd4286e81edebf10530d9 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 10:58:38 -0500 Subject: [PATCH 04/26] Implement tests for AWS source functionality Add comprehensive tests for AWS source integration with NetBox, covering instance discovery, tagging, and error handling. --- tests/test_aws_source.py | 527 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 527 insertions(+) create mode 100644 tests/test_aws_source.py diff --git a/tests/test_aws_source.py b/tests/test_aws_source.py new file mode 100644 index 00000000..955a3ba7 --- /dev/null +++ b/tests/test_aws_source.py @@ -0,0 +1,527 @@ +import copy +import os +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +boto3 = pytest.importorskip("boto3") +from botocore.stub import Stubber + +from module.config.file_output import ConfigFileOutput +from module.config.parser import ConfigParser +from module.netbox import ( + NBCluster, NBIPAddress, NBMACAddress, NBVirtualDisk, NBVM, NBVMInterface, NBVRF, +) +from module.netbox.connection import NetBoxHandler +from module.sources import instantiate_sources, valid_sources +from module.sources.aws import client as aws_client +from module.sources.aws.client import AWSClient +from module.sources.aws.config import AWSConfig +from module.sources.aws.connection import AWSHandler + +ACCOUNT = "123456789012" +REGION = "us-east-1" +INSTANCE = "i-0123456789abcdef0" +VPC = "vpc-0123456789abcdef0" +SUBNET = "subnet-0123456789abcdef0" +ENI = "eni-0123456789abcdef0" +VOLUME = "vol-0123456789abcdef0" + + +def inventory_data(): + return { + "instances": [{ + "InstanceId": INSTANCE, "InstanceType": "t3.small", + "State": {"Name": "running"}, + "Placement": {"AvailabilityZone": "us-east-1a"}, + "VpcId": VPC, "SubnetId": SUBNET, + "Tags": [{"Key": "Name", "Value": "web"}, {"Key": "Environment", "Value": "test"}], + "NetworkInterfaces": [{"NetworkInterfaceId": ENI}], + "BlockDeviceMappings": [{"DeviceName": "/dev/sda1", "Ebs": {"VolumeId": VOLUME}}], + }], + "InstanceTypes": {"t3.small": { + "InstanceType": "t3.small", "VCpuInfo": {"DefaultVCpus": 2}, + "MemoryInfo": {"SizeInMiB": 2048}, + }}, + "NetworkInterfaces": {ENI: { + "NetworkInterfaceId": ENI, "VpcId": VPC, "SubnetId": SUBNET, + "Attachment": {"InstanceId": INSTANCE, "DeviceIndex": 0}, + "MacAddress": "02:aa:bb:cc:dd:ee", + "PrivateIpAddresses": [ + {"PrivateIpAddress": "10.0.0.10", "Primary": True, + "Association": {"PublicIp": "198.51.100.10"}}, + {"PrivateIpAddress": "10.0.0.11", "Primary": False}, + ], + "Ipv6Addresses": [{"Ipv6Address": "2001:db8::10", "IsPrimaryIpv6": True}], + }}, + "Volumes": {VOLUME: {"VolumeId": VOLUME, "Size": 20, "VolumeType": "gp3"}}, + "Subnets": {SUBNET: { + "SubnetId": SUBNET, "VpcId": VPC, "CidrBlock": "10.0.0.0/24", + "Ipv6CidrBlockAssociationSet": [{ + "Ipv6CidrBlock": "2001:db8::/64", "Ipv6CidrBlockState": {"State": "associated"}, + }], + }}, + "Vpcs": {VPC: {"VpcId": VPC}}, + } + + +@pytest.fixture(autouse=True) +def windows_config_paths(monkeypatch): + if os.name == "nt": + monkeypatch.setattr(ConfigParser, "get_config_file_path", staticmethod(os.path.abspath)) + + +@pytest.fixture +def make_source(inventory, load_config, monkeypatch): + def make(extra="", data=None, regions=REGION): + load_config(f""" +[source/test-aws] +type = aws +regions = {regions} +permitted_subnets = 10.0.0.0/8, 2001:db8::/32 +tag_keys = Environment +{extra} +""") + discovery = Mock(return_value=(ACCOUNT, {REGION: data if data is not None else inventory_data()})) + monkeypatch.setattr(AWSClient, "__init__", lambda self, profile=None: None) + monkeypatch.setattr(AWSClient, "discover", discovery) + sources = instantiate_sources() + assert len(sources) == 1 and sources[0].init_successful + return sources[0] + return make + + +@pytest.fixture +def vpc_vrf(inventory): + return inventory.add_object(NBVRF, data={ + "id": 1, "name": f"AWS {ACCOUNT}/{REGION}/{VPC}", + }, read_from_netbox=True) + + +def test_registration(): + assert AWSHandler in valid_sources + assert AWSConfig in ConfigFileOutput.source_config_list + assert NBVRF in AWSHandler.dependent_netbox_objects + assert NBMACAddress in AWSHandler.dependent_netbox_objects + + +@pytest.mark.parametrize("api_version", ["3.6.0", "3.7.0", "4.1.0", "4.3.0"]) +def test_ec2_ebs_eni_import(make_source, inventory, vpc_vrf, api_version): + inventory.netbox_api_version = api_version + source = make_source("tenant = Operations") + source.apply() + vm, = inventory.get_all_items(NBVM) + cluster, = inventory.get_all_items(NBCluster) + interface, = inventory.get_all_items(NBVMInterface) + assert vm.data["name"] == INSTANCE + assert vm.data["status"] == "active" + assert vm.data["vcpus"] == 2 + assert vm.data["memory"] == 2048 + assert vm.data["custom_fields"]["aws_name"] == "web" + assert vm.data["tenant"].data["name"] == "Operations" + assert "AWS:Environment=test" in vm.get_tags() + assert interface.data["name"] == ENI + assert interface.data["custom_fields"]["aws_public_ipv4"] == "198.51.100.10" + ips = inventory.get_all_items(NBIPAddress) + assert {item.data["address"] for item in ips} == { + "10.0.0.10/24", "10.0.0.11/24", "2001:db8::10/64", + } + assert all(item.data["vrf"] is vpc_vrf for item in ips) + assert vm.data["primary_ip4"].data["address"] == "10.0.0.10/24" + assert vm.data["primary_ip6"].data["address"] == "2001:db8::10/64" + if api_version >= "4.2": + assert cluster.data["scope_type"] == "dcim.site" + assert cluster.data["scope_id"].data["name"] == f"AWS {REGION}" + mac, = inventory.get_all_items(NBMACAddress) + assert mac is interface.data["primary_mac_address"] + if api_version < "3.7": + assert vm.data["disk"] == 20 + else: + disk, = inventory.get_all_items(NBVirtualDisk) + assert disk.data["name"] == VOLUME + assert disk.data["size"] == (20 if api_version < "4.1" else 20480) + source.apply() + assert len(inventory.get_all_items(NBVM)) == 1 + assert len(inventory.get_all_items(NBVMInterface)) == 1 + assert len(inventory.get_all_items(NBIPAddress)) == 3 + + +def test_overlapping_vpcs_have_distinct_ips(make_source, inventory, vpc_vrf): + data = inventory_data() + second = copy.deepcopy(data) + other_instance, other_eni = "i-11111111111111111", "eni-11111111111111111" + other_vpc, other_subnet = "vpc-11111111111111111", "subnet-11111111111111111" + vm = second["instances"][0] + vm.update(InstanceId=other_instance, VpcId=other_vpc, SubnetId=other_subnet, + NetworkInterfaces=[{"NetworkInterfaceId": other_eni}], BlockDeviceMappings=[]) + eni = second["NetworkInterfaces"].pop(ENI) + eni.update(NetworkInterfaceId=other_eni, VpcId=other_vpc, SubnetId=other_subnet, + MacAddress="02:aa:bb:cc:dd:ff") + eni["Attachment"]["InstanceId"] = other_instance + subnet = second["Subnets"].pop(SUBNET) + subnet.update(SubnetId=other_subnet, VpcId=other_vpc) + data["instances"].append(vm) + data["NetworkInterfaces"][other_eni] = eni + data["Subnets"][other_subnet] = subnet + data["Vpcs"][other_vpc] = {"VpcId": other_vpc} + inventory.add_object(NBVRF, data={ + "id": 2, "name": f"AWS {ACCOUNT}/{REGION}/{other_vpc}", + }, read_from_netbox=True) + source = make_source(data=data) + source.apply() + assert len(inventory.get_all_items(NBVM)) == 2 + assert len(inventory.get_all_items(NBIPAddress)) == 6 + + +def test_missing_vrf_aborts_before_inventory_changes(make_source, inventory, caplog): + source = make_source() + with pytest.raises(SystemExit): + source.apply() + assert inventory.get_all_items(NBVM) == [] + assert inventory.get_all_items(NBCluster) == [] + assert "Create NetBox VRF" in caplog.text + + +def test_missing_volume_aborts_before_inventory_changes(make_source, inventory, vpc_vrf): + data = inventory_data() + data["Volumes"].clear() + source = make_source(data=data) + with pytest.raises(SystemExit): + source.apply() + assert inventory.get_all_items(NBVM) == [] + + +def test_stopped_instance_without_public_ip_is_retained(make_source, inventory, vpc_vrf): + data = inventory_data() + data["instances"][0]["State"]["Name"] = "stopped" + data["NetworkInterfaces"][ENI]["PrivateIpAddresses"][0].pop("Association") + source = make_source(data=data) + source.apply() + vm, = inventory.get_all_items(NBVM) + assert vm.data["status"] == "offline" + assert len(inventory.get_all_items(NBIPAddress)) == 3 + assert inventory.get_all_items(NBVMInterface)[0].data["custom_fields"]["aws_public_ipv4"] == "" + + +def test_name_tag_rename_does_not_duplicate_vm(make_source, inventory, vpc_vrf): + data = inventory_data() + source = make_source(data=data) + source.apply() + data["instances"][0]["Tags"][0]["Value"] = "renamed" + source.apply() + vm, = inventory.get_all_items(NBVM) + assert vm.data["name"] == INSTANCE + assert vm.data["custom_fields"]["aws_name"] == "renamed" + + +def test_removed_aws_tag_is_reconciled_but_user_tag_is_kept(make_source, inventory, vpc_vrf): + data = inventory_data() + source = make_source(data=data) + source.apply() + vm, = inventory.get_all_items(NBVM) + vm.add_tags("user-managed") + data["instances"][0]["Tags"] = [{"Key": "Environment", "Value": "production"}] + source.apply() + assert "AWS:Environment=test" not in vm.get_tags() + assert "AWS:Environment=production" in vm.get_tags() + assert "user-managed" in vm.get_tags() + + +def test_removed_secondary_ip_and_mac_are_detached(make_source, inventory, vpc_vrf): + data = inventory_data() + source = make_source(data=data) + source.apply() + secondary = next(ip for ip in inventory.get_all_items(NBIPAddress) + if ip.data["address"] == "10.0.0.11/24") + old_mac, = inventory.get_all_items(NBMACAddress) + data["NetworkInterfaces"][ENI]["PrivateIpAddresses"].pop() + data["NetworkInterfaces"][ENI]["MacAddress"] = "02:00:00:00:00:01" + source.apply() + assert "assigned_object_id" in secondary.unset_items + assert "assigned_object_id" in old_mac.unset_items + interface, = inventory.get_all_items(NBVMInterface) + assert interface.data["primary_mac_address"].data["mac_address"] == "02:00:00:00:00:01" + + +def test_primary_address_removal_clears_primary_ip(make_source, inventory, vpc_vrf): + data = inventory_data() + source = make_source(data=data) + source.apply() + vm, = inventory.get_all_items(NBVM) + data["NetworkInterfaces"][ENI]["PrivateIpAddresses"] = [] + data["NetworkInterfaces"][ENI]["Ipv6Addresses"] = [] + source.apply() + assert "primary_ip4" in vm.unset_items + assert "primary_ip6" in vm.unset_items + + +def test_ip_owned_by_other_source_is_not_reassigned(make_source, inventory, vpc_vrf): + source = make_source() + other = inventory.add_object(NBVM, data={"name": "other", "status": "active"}) + other_interface = inventory.add_object(NBVMInterface, data={"name": "eth0", "virtual_machine": other}) + existing = inventory.add_object(NBIPAddress, data={ + "address": "10.0.0.10/24", "vrf": vpc_vrf, "assigned_object_id": other_interface, + "assigned_object_type": NBVMInterface, + }) + with pytest.raises(SystemExit): + source.apply() + assert existing.get_interface() is other_interface + + +def test_tagging_and_successful_empty_discovery_use_existing_orphan_lifecycle( + make_source, inventory, vpc_vrf): + data = inventory_data() + source = make_source(data=data) + source.apply() + handler = object.__new__(NetBoxHandler) + handler.settings = SimpleNamespace( + prune_enabled=True, ignore_unknown_source_object_pruning=False, + orphaned_device_status=None, orphaned_vm_status=None, + ) + inventory.tag_all_the_things(handler) + vm, = inventory.get_all_items(NBVM) + assert source.source_tag in vm.get_tags() + assert handler.primary_tag in vm.get_tags() + for items in inventory.base_structure.values(): + for item in items: + item.source = None + data["instances"] = [] + source.apply() + inventory.tag_all_the_things(handler) + assert handler.orphaned_tag in vm.get_tags() + + +def test_customized_cpu_options_override_type_default(make_source, inventory, vpc_vrf): + data = inventory_data() + data["instances"][0]["CpuOptions"] = {"CoreCount": 1, "ThreadsPerCore": 1} + source = make_source(data=data) + source.apply() + vm, = inventory.get_all_items(NBVM) + assert vm.data["vcpus"] == 1 + + +def test_multiple_enis_only_primary_eni_sets_primary_ip(make_source, inventory, vpc_vrf): + data = inventory_data() + secondary_id = "eni-11111111111111111" + secondary = copy.deepcopy(data["NetworkInterfaces"][ENI]) + secondary.update(NetworkInterfaceId=secondary_id, MacAddress="02:00:00:00:00:01") + secondary["Attachment"]["DeviceIndex"] = 1 + secondary["PrivateIpAddresses"] = [{"PrivateIpAddress": "10.0.0.20", "Primary": True}] + secondary["Ipv6Addresses"] = [] + data["NetworkInterfaces"][secondary_id] = secondary + data["instances"][0]["NetworkInterfaces"].append({"NetworkInterfaceId": secondary_id}) + source = make_source(data=data) + source.apply() + vm, = inventory.get_all_items(NBVM) + assert len(inventory.get_all_items(NBVMInterface)) == 2 + assert vm.data["primary_ip4"].data["address"] == "10.0.0.10/24" + + +def test_optional_network_import_does_not_require_vrf(make_source, inventory): + source = make_source("sync_private_ips = False") + source.apply() + assert len(inventory.get_all_items(NBVMInterface)) == 1 + assert inventory.get_all_items(NBIPAddress) == [] + + +def test_explicit_vrf_mapping(make_source, inventory): + inventory.add_object(NBVRF, data={"id": 1, "name": "Production"}, read_from_netbox=True) + source = make_source(f"vpc_vrf_relation = {REGION}/{VPC} = Production") + source.apply() + assert {item.data["vrf"].data["name"] for item in inventory.get_all_items(NBIPAddress)} == {"Production"} + + +def test_quoted_vrf_name_with_comma(make_source, inventory): + inventory.add_object(NBVRF, data={"id": 1, "name": "Production, New York"}, read_from_netbox=True) + source = make_source(f'vpc_vrf_relation = {REGION}/{VPC} = "Production, New York"') + source.apply() + assert {item.data["vrf"].data["name"] for item in inventory.get_all_items(NBIPAddress)} == { + "Production, New York", + } + + +def test_disallowed_addresses_are_not_imported(make_source, inventory, vpc_vrf): + source = make_source() + from module.sources.common.permitted_subnets import PermittedSubnets + source.settings.permitted_subnets = PermittedSubnets("192.168.0.0/16") + source.apply() + assert inventory.get_all_items(NBIPAddress) == [] + assert len(inventory.get_all_items(NBVMInterface)) == 1 + + +def test_unmapped_subnet_address_aborts_before_changes(make_source, inventory, vpc_vrf): + data = inventory_data() + data["NetworkInterfaces"][ENI]["PrivateIpAddresses"][0]["PrivateIpAddress"] = "10.1.1.1" + source = make_source(data=data) + with pytest.raises(SystemExit): + source.apply() + assert inventory.get_all_items(NBCluster) == [] + + +def test_missing_sdk_aborts_enabled_source_but_disabled_source_is_safe( + inventory, load_config, monkeypatch, caplog): + monkeypatch.setattr(aws_client, "boto3", None) + load_config(f""" +[source/test-aws] +type = aws +regions = {REGION} +enabled = False +""") + assert instantiate_sources() == [] + load_config(f""" +[source/test-aws] +type = aws +regions = {REGION} +""") + with pytest.raises(SystemExit): + instantiate_sources() + assert "requirements-aws.txt" in caplog.text + + +def test_yaml_lists_and_account_guard_parse(inventory, load_config, monkeypatch): + monkeypatch.setattr(AWSClient, "__init__", lambda self, profile=None: None) + load_config(f""" +source: + test-aws: + type: aws + regions: [us-east-1, us-west-2] + account_id: "{ACCOUNT}" + tag_keys: [Environment, Owner] +""", "settings.yaml") + source, = instantiate_sources() + assert source.settings.regions == ["us-east-1", "us-west-2"] + assert source.settings.tag_keys == ["Environment", "Owner"] + assert source.settings.account_id == ACCOUNT + + +@pytest.mark.parametrize("extra", [ + "account_id = not-an-account", + "vpc_vrf_relation = malformed", + f"vpc_vrf_relation = {REGION}/{VPC} = One, {REGION}/{VPC} = Two", +]) +def test_invalid_config_fails(make_source, extra): + with pytest.raises(SystemExit): + make_source(extra) + + +@pytest.fixture +def sdk(monkeypatch): + session = boto3.Session( + aws_access_key_id="test-only", aws_secret_access_key="test-only", region_name=REGION) + sts = session.client("sts") + ec2 = session.client("ec2") + sts_stub, ec2_stub = Stubber(sts), Stubber(ec2) + fake_session = Mock() + fake_session.client.side_effect = lambda service, **kwargs: sts if service == "sts" else ec2 + monkeypatch.setattr(aws_client.boto3, "Session", Mock(return_value=fake_session)) + with sts_stub, ec2_stub: + yield sts_stub, ec2_stub + sts_stub.assert_no_pending_responses() + ec2_stub.assert_no_pending_responses() + + +def identity(stub): + stub.add_response("get_caller_identity", { + "Account": ACCOUNT, "Arn": f"arn:aws:iam::{ACCOUNT}:user/test", "UserId": "test", + }, {}) + + +def stub_region(stub, data): + stub.add_response("describe_instances", {"Reservations": [{ + "Instances": data["instances"], + }], "NextToken": "next-page"}, {}) + stub.add_response("describe_instances", {"Reservations": []}, {"NextToken": "next-page"}) + for operation, result_key in ( + ("describe_network_interfaces", "NetworkInterfaces"), + ("describe_volumes", "Volumes"), ("describe_subnets", "Subnets"), + ("describe_vpcs", "Vpcs"), + ): + stub.add_response(operation, {result_key: list(data[result_key].values())}, {}) + if data["instances"]: + stub.add_response("describe_instance_types", { + "InstanceTypes": list(data["InstanceTypes"].values()), + }, {"InstanceTypes": ["t3.small"]}) + + +def test_sdk_paginated_discovery(sdk): + sts, ec2 = sdk + identity(sts) + data = inventory_data() + stub_region(ec2, data) + account, snapshot = AWSClient().discover([REGION], ACCOUNT) + assert account == ACCOUNT + assert snapshot[REGION]["instances"] == data["instances"] + assert snapshot[REGION]["Volumes"] == data["Volumes"] + + +def test_stubbed_sdk_to_real_inventory(sdk, inventory, load_config, vpc_vrf): + sts, ec2 = sdk + identity(sts) + stub_region(ec2, inventory_data()) + load_config(f""" +[source/test-aws] +type = aws +regions = {REGION} +account_id = {ACCOUNT} +permitted_subnets = 10.0.0.0/8, 2001:db8::/32 +""") + source, = instantiate_sources() + source.apply() + vm, = inventory.get_all_items(NBVM) + assert vm.data["name"] == INSTANCE + assert vm.data["primary_ip4"].data["vrf"] is vpc_vrf + assert len(inventory.get_all_items(NBVirtualDisk)) == 1 + + +def test_profile_is_forwarded_to_sdk(sdk): + AWSClient(profile="inventory") + aws_client.boto3.Session.assert_called_once_with(profile_name="inventory") + + +def test_sdk_error_after_first_region_aborts_entire_apply(sdk, inventory, load_config, caplog): + sts, ec2 = sdk + identity(sts) + stub_region(ec2, inventory_data()) + ec2.add_client_error("describe_instances", service_error_code="UnauthorizedOperation", + service_message="not permitted", http_status_code=403, expected_params={}) + load_config(f""" +[source/test-aws] +type = aws +regions = {REGION}, us-west-2 +sync_private_ips = False +""") + source, = instantiate_sources() + with pytest.raises(SystemExit): + source.apply() + assert inventory.get_all_items(NBVM) == [] + assert inventory.get_all_items(NBCluster) == [] + assert "No NetBox writes or pruning" in caplog.text + + +def test_wrong_account_aborts(sdk): + sts, _ = sdk + identity(sts) + with pytest.raises(aws_client.AWSDiscoveryError, match="account mismatch"): + AWSClient().discover([REGION], "999999999999") + + +def test_terminated_instances_are_excluded(sdk): + sts, ec2 = sdk + identity(sts) + terminated = inventory_data()["instances"][0] + terminated["State"]["Name"] = "terminated" + ec2.add_response("describe_instances", {"Reservations": [{"Instances": [terminated]}]}, {}) + for operation, result_key in ( + ("describe_network_interfaces", "NetworkInterfaces"), ("describe_volumes", "Volumes"), + ("describe_subnets", "Subnets"), ("describe_vpcs", "Vpcs"), + ): + ec2.add_response(operation, {result_key: []}, {}) + _, snapshot = AWSClient().discover([REGION]) + assert snapshot[REGION]["instances"] == [] + + +def test_missing_sdk_fails_explicitly(monkeypatch): + monkeypatch.setattr(aws_client, "boto3", None) + with pytest.raises(aws_client.AWSDiscoveryError, match="requirements-aws.txt"): + AWSClient() From c3cbc12da5698d6a106b0e927eb98be534c9e1e0 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 10:59:37 -0500 Subject: [PATCH 05/26] Add documentation for AWS EC2 source integration This document provides detailed information on the AWS EC2 source, including installation, authentication, configuration, object mapping, lifecycle, and validation for integrating AWS with NetBox. --- docs/source_aws.md | 183 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/source_aws.md diff --git a/docs/source_aws.md b/docs/source_aws.md new file mode 100644 index 00000000..dccd89e9 --- /dev/null +++ b/docs/source_aws.md @@ -0,0 +1,183 @@ +# AWS EC2 source + +The `aws` source imports EC2 instances, attached EBS volumes, attached network +interfaces (ENIs), MAC addresses, and permitted private IPv4 / IPv6 addresses. +AWS is queried read-only; writes are only made to NetBox. This branch is based +on current main and does not depend on the oVirt or OpenStack branches. + +## Installation and authentication + +```sh +pip install -r requirements-aws.txt +# Alternatively: +uv sync --extra aws +``` + +Other sources do not require boto3. An enabled AWS source without its SDK or +credentials fails explicitly rather than continuing with empty inventory. +For Docker, enable the SDK explicitly: + +```sh +docker build --build-arg INSTALL_AWS=true -t netbox-sync:aws . +``` + +Use the standard boto3 credential chain: + +* EC2 instance roles, ECS task roles, or EKS workload identity. +* A shared AWS profile (`profile` below), including an assume-role profile for + cross-account access. +* `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`; temporary credentials also need + `AWS_SESSION_TOKEN`. Prefer roles over stored access keys. + +Each configured source handles one AWS account and an explicit region list. +Configure another named source/profile for another account. The optional +`account_id` guard verifies the account returned by STS before importing anything. +Profile-based assume-role credential refresh is handled by boto3. Credentials +must allow access to every configured region. + +No SSH access, guest agents, inbound connections, or AWS write permissions are +needed. Outbound HTTPS to the regional EC2 and STS endpoints is required. + +Example IAM policy (attach to a dedicated role or non-root identity): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceTypes", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeVolumes", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "sts:GetCallerIdentity" + ], + "Resource": "*" + } + ] +} +``` + +An assume-role profile additionally needs `sts:AssumeRole` permission on the +target role and a matching target-account trust policy (and external ID where +required). No Organizations permissions are used. + +## Configuration + +```ini +[source/production-aws] +type = aws +regions = us-east-1, us-west-2 +profile = inventory +account_id = 123456789012 +permitted_subnets = 10.0.0.0/8, 2001:db8::/32 +vpc_vrf_relation = us-east-1/vpc-0123456789abcdef0 = Production VPC +tag_keys = Environment, Owner +tenant = Operations +``` + +All options also support the application's usual `NBS_SOURCE__*` +environment variables. `regions` and `tag_keys` accept YAML lists as well as +comma-separated strings. Quote the account ID in YAML so it remains a string. +`python netbox-sync.py -g` generates the full configuration. + +### VPC address-space isolation + +The current NetBox integration reads VRFs and prefixes but does not create them. +Before enabling IP import, create a **distinct VRF for every VPC** in NetBox. +The default VRF name is: + +```text +AWS // +``` + +For example: `AWS 123456789012/us-east-1/vpc-0123456789abcdef0`. +Use `vpc_vrf_relation` to select an existing differently named VRF. A mapping +must not share a VRF between discovered VPCs. VRFs must remain distinct across +separately configured sources too. + +Missing VRFs for permitted addresses abort the run before inventory updates. +There is no fallback to global address space. Subnet mask lengths come from AWS +subnet CIDRs; NetBox prefixes are neither required nor created. Identical IPs in +different VPCs remain separate NetBox IPs. + +`permitted_subnets` is required to populate IP addresses; leaving it undefined +imports no IPs, matching other source behavior. `sync_private_ips = False` +imports VMs, disks, and interfaces without requiring VRFs; previously managed +IPs on discovered interfaces are retained. DNS lookup defaults to false because +private AWS DNS is often inaccessible from the sync host. + +## Object mapping and lifecycle + +| AWS | NetBox | +| --- | --- | +| Account ID | Cluster group `AWS ` | +| Account and region | Cluster `AWS /`, type `AWS EC2` | +| Region | Logical site `AWS ` | +| Instance ID | VM name; serial on NetBox 4.1+ | +| Instance type | CPU and memory, plus custom field | +| Name tag / Availability Zone | Custom fields, not identity or physical sites | +| ENI ID | VM interface name and custom field | +| ENI MAC | MAC object on NetBox 4.2+; legacy interface field on older versions | +| Private IPv4 / IPv6 | IP in the VPC's existing VRF, assigned to the ENI | +| EBS volume | Virtual disk named by volume ID | + +Region sites and clusters are logical containers, not claims about physical +AWS facilities or hypervisor placement. Instance IDs within account/region +clusters avoid duplicate or changing Name tags merging unrelated VMs. Memory +uses the instance type's MiB capacity; CPU uses instance CPU options when +reported, otherwise the type's default vCPU count. This is provisioned capacity, +not utilization. + +Running instances are `active`; stopped, pending, stopping, and shutting-down +instances are `offline`. Terminated instances are excluded and subsequently +follow the existing NetBox orphaning/pruning configuration. An empty, successfully +queried account/region is authoritative. All configured regions and paginated +responses are collected before reconciliation. AWS access failures, credential +failures, missing attached resources, and missing VRF mappings abort the run +before the application's NetBox write/prune phase. Transient attachment changes +may therefore require retrying the run rather than importing partial data. + +NetBox 3.7+ gets separate virtual disks; older versions get aggregate VM disk +size. Disk sizes use GiB before NetBox 4.1 and MiB from 4.1 onward, consistent +with the existing VMware source's binary-size convention. Only attached EBS +volumes are imported; instance-store disks and unattached volumes are excluded. + +The primary IPv4 is the primary private address on device-index-zero ENI; a +reported primary IPv6 on that ENI is handled similarly. Public IPv4 mappings +are stored on the ENI as `aws_public_ipv4`, not assigned as guest IPs or made +primary. IPv6 addresses are actual interface addresses even when globally +routable. Delegated address prefixes are not imported. + +Only explicitly selected `tag_keys` become tags (`AWS:=`). Their +combined names must fit NetBox's 100-character limit. Existing unrelated NetBox +tags are preserved. Metadata custom fields use the `aws_` prefix. Their names +should not conflict with user-defined fields of incompatible types. + +Avoid overlapping source definitions for the same account/region. Narrowing +regions, disabling IP import, or changing permitted networks affects the +source's managed scope; review a dry run before making such changes. The +existing pruning settings determine what happens to objects no longer reported. + +## Validation + +Start with: + +```sh +python netbox-sync.py -n -c settings.ini +``` + +Tests use botocore `Stubber` and the real in-memory NetBox inventory, without +AWS credentials or network calls: + +```sh +pip install -r requirements-aws.txt pytest +python -m pytest tests/test_aws_source.py +``` + +Live AWS/NetBox validation is still required before production use. This source +does not inventory RDS, load balancers, ECS/EKS workloads, security groups, +software, or guest operating-system configuration. From 682210749418ae84c8f72ec2c8109806daa9041f Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:04:31 -0500 Subject: [PATCH 06/26] Implement AWSClient for AWS service discovery --- module/sources/aws/client.py | 72 ++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 module/sources/aws/client.py diff --git a/module/sources/aws/client.py b/module/sources/aws/client.py new file mode 100644 index 00000000..39a799f1 --- /dev/null +++ b/module/sources/aws/client.py @@ -0,0 +1,72 @@ +try: + import boto3 + from botocore.config import Config + from botocore.exceptions import BotoCoreError, ClientError +except ModuleNotFoundError as exc: + if exc.name != "boto3": + raise + boto3 = None + + +class AWSDiscoveryError(RuntimeError): + """Discovery failed; do not reconcile this run with NetBox.""" + + +class AWSClient: + def __init__(self, profile=None): + if boto3 is None: + raise AWSDiscoveryError("AWS SDK missing. Install dependencies from requirements-aws.txt.") + try: + self.session = boto3.Session(profile_name=profile) + except BotoCoreError as exc: + raise AWSDiscoveryError(f"Unable to initialize AWS credentials: {exc}") from exc + + def discover(self, regions, expected_account=None): + """Fully collect all configured regions before any inventory changes.""" + clients = [] + try: + config = Config(retries={"mode": "standard", "max_attempts": 5}, + connect_timeout=10, read_timeout=60) + sts = self.session.client("sts", region_name=regions[0], config=config) + clients.append(sts) + account = sts.get_caller_identity()["Account"] + if expected_account is not None and account != expected_account: + raise AWSDiscoveryError( + f"AWS account mismatch: expected {expected_account}, authenticated as {account}.") + snapshot = {} + for region in regions: + ec2 = self.session.client("ec2", region_name=region, config=config) + clients.append(ec2) + instances = [ + instance + for page in ec2.get_paginator("describe_instances").paginate() + for reservation in page.get("Reservations", []) + for instance in reservation.get("Instances", []) + if instance["State"]["Name"] != "terminated" + ] + data = {"instances": instances} + for operation, result_key, key in ( + ("describe_network_interfaces", "NetworkInterfaces", "NetworkInterfaceId"), + ("describe_volumes", "Volumes", "VolumeId"), + ("describe_subnets", "Subnets", "SubnetId"), + ("describe_vpcs", "Vpcs", "VpcId"), + ): + data[result_key] = { + item[key]: item + for page in ec2.get_paginator(operation).paginate() + for item in page.get(result_key, []) + } + instance_types = {} + names = sorted({item["InstanceType"] for item in instances}) + for start in range(0, len(names), 100): + for page in ec2.get_paginator("describe_instance_types").paginate( + InstanceTypes=names[start:start + 100]): + instance_types.update({item["InstanceType"]: item for item in page["InstanceTypes"]}) + data["InstanceTypes"] = instance_types + snapshot[region] = data + return account, snapshot + except (BotoCoreError, ClientError, KeyError) as exc: + raise AWSDiscoveryError(f"Incomplete AWS discovery; synchronization aborted: {exc}") from exc + finally: + for client in clients: + client.close() From 0a4793df0f9513f7ba2bc4413e36a10f66ea7350 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:07:10 -0500 Subject: [PATCH 07/26] Add AWSConfig import to file_output.py --- module/config/file_output.py | 1 + 1 file changed, 1 insertion(+) diff --git a/module/config/file_output.py b/module/config/file_output.py index 6a002f15..0566d935 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.aws.config import AWSConfig 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 From 02568b481e1c5ae6f6c1cb479b60613fabc54568 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:08:20 -0500 Subject: [PATCH 08/26] Add optional AWS dependency to pyproject.toml --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9c284b23..0cf28b08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ ] [project.optional-dependencies] +aws = [ + "boto3>=1.40,<2", +] test = [ "pytest>=9.1.1", ] From d47b99918ff7add4d54000a1dd86537145854128 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:20:01 -0500 Subject: [PATCH 09/26] Add AWS example configuration to settings-example.ini Added AWS example configuration to settings. --- settings-example.ini | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/settings-example.ini b/settings-example.ini index 75b725ff..302ec1be 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -611,4 +611,20 @@ inventory_file_path = /full/path/to/inventory/files ; Hetzner Cloud API token used to authenticate against the Hetzner Cloud API. api_token = +[source/my-aws-example] + +; Install optional SDK: pip install -r requirements-aws.txt +; See docs/source_aws.md for read-only IAM permissions and VPC VRF setup. +;enabled = True +;type = aws +regions = us-east-1 +;profile = inventory +;account_id = 123456789012 +;sync_private_ips = True +;permitted_subnets = 10.0.0.0/8, fd00::/8 +;vpc_vrf_relation = us-east-1/vpc-0123456789abcdef0 = Production VPC +;tag_keys = Environment, Owner +;tenant = Operations +;dns_name_lookup = False + ;EOF From 9ad88aee12e4df393ef88000a23c8a4297601eec Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:23:14 -0500 Subject: [PATCH 10/26] Add custom_fields to object_classes --- module/netbox/object_classes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/module/netbox/object_classes.py b/module/netbox/object_classes.py index e6e8c813..dacc36fe 100644 --- a/module/netbox/object_classes.py +++ b/module/netbox/object_classes.py @@ -2032,6 +2032,7 @@ def __init__(self, *args, **kwargs): "untagged_vlan": NBVLAN, "tagged_vlans": NBVLANList, "description": 200, + "custom_fields": NBCustomField, "tags": NBTagList } super().__init__(*args, **kwargs) From f782ed87352fa25bacb293e11663955a2895b903 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:23:52 -0500 Subject: [PATCH 11/26] Add optional AWS dependency installation in Dockerfile Added support for optional AWS dependencies by including a new requirements-aws.txt file and modifying the installation command to conditionally install it. --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0852fa81..be3a2093 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,16 @@ FROM python:3.14-slim AS builder COPY requirements.txt . +COPY requirements-aws.txt . ARG VENV=/opt/netbox-sync/venv +ARG INSTALL_AWS=false # Install dependencies RUN python3 -m venv $VENV && \ $VENV/bin/python3 -m pip install --upgrade pip && \ $VENV/bin/pip install -r requirements.txt && \ + if [ "$INSTALL_AWS" = "true" ]; then $VENV/bin/pip install -r requirements-aws.txt; fi && \ $VENV/bin/pip install vmware-vcenter==9.1.1.0 && \ $VENV/bin/python3 -m pip uninstall -y pip && \ find $VENV -type d -name "__pycache__" -print0 | xargs -0 -n1 rm -rf From 0c853a1785b646df1f37aad2d41c467318009b6a Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:24:45 -0500 Subject: [PATCH 12/26] Update README with AWS source type details Added AWS source type and requirements to README. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f395b1e7..b2d98515 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ This is a tool to sync data from different sources to a NetBox instance. Available source types: * VMware vCenter Server -* [bb-ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) inventory files +* AWS EC2, EBS, and network interfaces (see [AWS source](docs/source_aws.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: AWS (if used) +* Optional boto3 SDK, AWS read-only credentials, and explicit regions +* Existing VPC-specific NetBox VRFs for IP import; see [AWS setup](docs/source_aws.md) # Installing * here we assume we install in ```/opt``` From 96e77be974d675f7723d226a578ba60142cc6fcb Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:26:21 -0500 Subject: [PATCH 13/26] Add AWSHandler to valid sources list --- module/sources/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/sources/__init__.py b/module/sources/__init__.py index 0666da30..baa1cc12 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.aws.connection import AWSHandler 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, HetznerHandlerr, AWSHandler] def validate_source(source_class_object=None, state="pre"): From 1983cb0e386477bb2c29ce59a5f2e0a1cf8192a0 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:33:18 -0500 Subject: [PATCH 14/26] Fix typo in valid_sources list --- module/sources/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/sources/__init__.py b/module/sources/__init__.py index baa1cc12..5cdc0f1b 100644 --- a/module/sources/__init__.py +++ b/module/sources/__init__.py @@ -20,7 +20,7 @@ from module.config import source_config_section_name # list of valid sources -valid_sources = [VMWareHandler, CheckRedfish, HetznerHandlerr, AWSHandler] +valid_sources = [VMWareHandler, CheckRedfish, HetznerHandler, AWSHandler] def validate_source(source_class_object=None, state="pre"): From 0e1bb7a441b8d91191a07ceb7a54a03ff80a8e48 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:34:46 -0500 Subject: [PATCH 15/26] Enhance configuration file output and validation logic Refactor source configuration handling and validation. --- module/sources/__init__.py | 298 ++++++++++++++++++++++++++----------- 1 file changed, 212 insertions(+), 86 deletions(-) diff --git a/module/sources/__init__.py b/module/sources/__init__.py index 5cdc0f1b..62a1962b 100644 --- a/module/sources/__init__.py +++ b/module/sources/__init__.py @@ -7,122 +7,248 @@ # For a copy, see file LICENSE.txt included in this # repository or visit: . -# define all available sources here -from module.sources.vmware.connection import VMWareHandler -from module.sources.hetzner.connection import HetznerHandler -from module.sources.aws.connection import AWSHandler -from module.sources.check_redfish.import_inventory import CheckRedfish - +import os + +from module.config.formatter import DescriptionFormatterMixin +from module.config.group import ConfigOptionGroup +from module.config.option import ConfigOption +from module.common.config import CommonConfig +from module.netbox.config import NetBoxConfig +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.netbox.inventory import NetBoxInventory -from module.config.parser import ConfigParser -from module.config.base import ConfigOptions -from module.config import source_config_section_name +from module.config import default_config_file_path, source_config_section_name +from module.config.files import ConfigFile, ConfigFileINI, ConfigFileYAML +from module import __version__, __version_date__, __description__, __url__ + +log = get_logger() + + +class ConfigFileOutput(DescriptionFormatterMixin): + + base_config_list = [ + CommonConfig, + NetBoxConfig + ] + + source_config_list = [ + VMWareConfig, + CheckRedfishConfig, + HetznerConfig, + OpenStackConfig + ] + + header = f"Welcome to the {__description__} configuration file." + + _description = """The values in this file override the default values used by the system if + a config option is not specified. The commented out lines are the configuration + field and the default value used. Uncommenting a line and changing the value + will change the value used at runtime when the process is restarted. + """ + + source_description = """Controls the parameters of a defined source. The string past the slash + will be used as a sources name. Sources can be defined multiple times to + represent different sources. + """ + + config_file_type = None + indentation_level = 0 + lines = list() + + def __init__(self, args): + + if args is None or args.generate_config is False: + return + + if len(args.config_files) == 0: + self.output_file = default_config_file_path + else: + self.output_file = args.config_files[0] + + if os.path.exists(self.output_file): + log.error(f'ERROR: Config file "{self.output_file}" already present') + exit(1) + + self.config_file_type = ConfigFile.get_file_type(self.output_file) + + if self.config_file_type is None: + log.error(f"ERROR: Unknown/Unsupported config file type " + f"'{ConfigFile.get_suffix(self.output_file)}' for {self.output_file}") + exit(1) + + self.comment_prefix = self.config_file_type.comment_prefix + + self.format() + + self._set_indent_level(0) + self._add_blank_line() + self._add_line(f"{self.comment_prefix}EOF") + self._add_blank_line() + + self.lines = [x.rstrip() for x in self.lines] + + try: + with open(self.output_file, "w") as fp: + fp.write('\n'.join(self.lines)) + except Exception as e: + log.error(f"Error: Unable to write to file '{self.output_file}': {e}") + exit(1) + + exit(0) + + def format(self): + + self._add_line(f"{self.comment_prefix*3} {self.header}\n") + self._add_line(f"{self.comment_prefix*3} Version: {__version__} ({__version_date__})\n") + self._add_line(f"{self.comment_prefix * 3} Project URL: {__url__}\n") + self._add_blank_line() + self._add_lines(self.config_description(prefix=self.comment_prefix).split("\n")) + self._add_blank_line() + + for config_section in self.base_config_list: + + config_instance = config_section() + + self._format_section_description(config_instance) + self._add_blank_line() + + if self.config_file_type is ConfigFileINI: + self._add_line(f"[{config_instance.section_name}]") + + elif self.config_file_type is ConfigFileYAML: + self._set_indent_level(0) + self._add_line(f"{config_section.section_name}:") + + self._set_indent_level(1) + self._format_options(config_instance.options) + self._set_indent_level(0) + + # write out section description + self._format_section_description(section_name="source/*", section_description=self.source_description) + + self._add_blank_line() + + if self.config_file_type is ConfigFileYAML: + self._add_line(f"{source_config_section_name}:") -# list of valid sources -valid_sources = [VMWareHandler, CheckRedfish, HetznerHandler, AWSHandler] + for config_section in self.source_config_list: + config_instance = config_section() -def validate_source(source_class_object=None, state="pre"): - """ - Validate class and object attributes of a $source_class_object - Check if all needed attributes in class definition are present before initialization. - Check if all needed attributes and their type in object are present - after initialization + self._format_section_description(config_instance) + self._add_blank_line() - Parameters - ---------- - source_class_object: Source handler class/object - class/object to investigate - state: str - pre if validating a class, post if validating an initialized object + if self.config_file_type is ConfigFileINI: + self._add_line(f"[{config_instance.section_name}/{config_instance.source_name_example}]") - """ + elif self.config_file_type is ConfigFileYAML: + self._set_indent_level(1) + self._add_line(f"{config_instance.source_name_example}:") - necessary_attributes = { - "dependent_netbox_objects": list, - "init_successful": bool, - "inventory": NetBoxInventory, - "name": str, - "settings": ConfigOptions, - "source_tag": str, - "source_type": str, - } + self._set_indent_level(2) + self._format_options(config_instance.options) + self._set_indent_level(1) - for attr in necessary_attributes.keys(): + def _add_line(self, line: str): + indent = "" + indent_size = 2 - # raise exception if attribute not present - getattr(source_class_object, attr) + if self.config_file_type is ConfigFileYAML: + indent = " " * indent_size * self.indentation_level - if state == "pre": - return + self.lines.append(f"{indent}{line}") - # post initialization validation - for attr, value_type in necessary_attributes.items(): + def _add_lines(self, lines: list): + for line in lines: + self._add_line(line) - value = getattr(source_class_object, attr, None) + def _set_indent_level(self, level: int): + self.indentation_level = level - if not isinstance(value, value_type): - raise ValueError(f"Value for attribute '{attr}' needs to be {value_type}") + def _format_options(self, option_list: list): - if value_type in [list, str] and len(value) == 0: - raise ValueError(f"Value for attribute '{attr}' can't be empty.") + for option in option_list: + if isinstance(option, ConfigOption): + self._format_config_option(option) + elif isinstance(option, ConfigOptionGroup): + self._format_config_option_group(option) -def instantiate_sources(): - """ - Instantiate a source handler and add necessary attributes. Also - validate source handler on pre- and post-initialization. + self._add_blank_line() - Returns - ------- - source handler object: instantiated source handler - """ + def _format_section_description(self, + config_instance=None, + section_name: str = None, + section_description: str = None): - config = ConfigParser() - inventory = NetBoxInventory() + wide_prefix = self.comment_prefix * 3 - log = get_logger() + if config_instance is not None: + if section_description is None: + section_description = config_instance.__doc__ + if section_name is None: + section_name = f"{config_instance.section_name}" - # first validate all available sources - for possible_source_class in valid_sources: - validate_source(possible_source_class) + if section_description is not None and section_name is not None: + section_formatter = DescriptionFormatterMixin() + section_formatter._description = section_description - sources = list() + self._add_blank_line() + self._add_line(wide_prefix) + self._add_line(f"{wide_prefix} [{section_name}]") + self._add_line(wide_prefix) + self._add_lines(section_formatter.config_description(prefix=wide_prefix).split("\n")) + self._add_line(wide_prefix) - source_config = dict() - if isinstance(config.content, dict): - source_config = config.content.get(source_config_section_name) + def _add_blank_line(self): + if len(self.lines) > 0 and self.lines[-1] != "": + self.lines.append("") - for source_name, source_config in source_config.items(): + def _format_config_option(self, option): - source_config_type = source_config.get("type") - if source_config_type is None: - log.error(f"Source {source_name} option 'type' is undefined") - continue + if option is None or option.removed is True or option.deprecated is True: + return - source_class = None - for possible_source_class in valid_sources: - validate_source(possible_source_class) + if len(option.description()) > 0: + self._add_blank_line() + self._add_lines(option.config_description(prefix=self.comment_prefix).split("\n")) - if possible_source_class.implements(source_config_type): - source_class = possible_source_class - break + option_key = option.key + option_value = None + if option.mandatory is False: + option_key = f"{self.comment_prefix}{option_key}" + if option_value is None and option.default_value is not None: + option_value = option.default_value + if option_value is None and option.config_example is not None: + option_value = option.config_example + if option_value is None: + option_value = "" - if source_class is None: - log.error(f"Unknown source type '{source_config_type}' defined for '{source_name}'") - continue + if self.config_file_type is ConfigFileINI: + self._add_line(f"{option_key} = {option_value}") + elif self.config_file_type is ConfigFileYAML: + self._add_line(f"{option_key}: {option_value}") - source_handler = source_class(name=source_name) + def _format_config_option_group(self, group): - validate_source(source_handler, "post") + if group is None: + return - # add to list of source handlers - if source_handler.init_successful is True: - sources.append(source_handler) + if isinstance(group.title, str) and len(group.title) > 0: + self._add_blank_line() + self._add_line(f"{self.comment_prefix} {group.title} options") - inventory.add_source(source_handler) + if len(group.description()) > 0: + self._add_blank_line() + self._add_lines(group.config_description(prefix=self.comment_prefix).split("\n")) - return sources + if isinstance(group.config_example, str) and len(group.config_example) > 0: + formatter = DescriptionFormatterMixin() + formatter._description = group.config_example + self._add_line(self.comment_prefix) + self._add_lines(formatter.config_description(prefix=self.comment_prefix).split("\n")) -# EOF + for option in group.options: + self._format_config_option(option) From c033df7b6ff04ef5230c435ce1a9c49387a3a26d Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:35:52 -0500 Subject: [PATCH 16/26] boto3 and AWS extra declaration --- uv.lock | 305 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 301 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 0af21d5f..98bd5226 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 3 -requires-python = ">=3.13" +revision = 5 +requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version < '3.14'", @@ -36,6 +36,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, @@ -106,6 +118,22 @@ version = "3.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, @@ -224,6 +252,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + [[package]] name = "hcloud" version = "2.25.1" @@ -255,9 +368,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/39/c534bb172e9318400d5631074b1eda6c78b7c918d0f04233b50caa8e2c10/jsonpatch-1.36.tar.gz", hash = "sha256:7e110cb3c5a1c8768f7170bf8fb9887c4aa575fba74c95239e868b494bd9a522", size = 39985, upload-time = "2026-10-19T10:59:20.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c0/f2b34ce5fa98bde1bab4ff25f48064d09a078c40f4f53f65276a812d5e8a/jsonpatch-1.36-py3-none-any.whl", hash = "sha256:aa3ffbc15816644ffcc6b13712dacbc99f6f6dde302abc66983f9831d35630a5", size = 17094, upload-time = "2026-10-19T10:59:19.066Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/77/9be5cce647fa4d7afa9047c0c1029bc73292c27430e64fc9d2393ccf6727/jsonpointer-3.4.tar.gz", hash = "sha256:e57015ae65af3821c6a3976069860789cf326103f38b22b2178d41d021adad25", size = 13113, upload-time = "2026-10-19T10:33:24.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/d0/3d41544018b879e9c608c32bd5fb468fd87669d53c2dbf5ae67c1bad9882/jsonpointer-3.4-py3-none-any.whl", hash = "sha256:136e2e3273fd82a1502274f56ec172f331b6a1703d86a1824d503af4d9a96142", size = 9047, upload-time = "2026-10-19T10:33:22.963Z" }, +] + +[[package]] +name = "keystoneauth1" +version = "5.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso8601" }, + { name = "os-service-types" }, + { name = "requests" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/268e14554c6bd4a2614872fb2b8431e466ce01415a9783b260ce7fb7ae5c/keystoneauth1-5.18.1.tar.gz", hash = "sha256:8970f9d103f335c39151366d70c001d1bb99b00d73845f03d47825e8dc35ff78", size = 319181, upload-time = "2026-10-12T09:04:48.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/24/38ccf4476c81435c834d4791804099f305b91087fad171ac17d18fc84ca9/keystoneauth1-5.18.1-py3-none-any.whl", hash = "sha256:2440f1f29e4aaaaf771893e8da7c098ca27548210dc13e964b53fd2aaaedd6b0", size = 367402, upload-time = "2026-10-12T09:04:46.753Z" }, +] + [[package]] name = "netbox-sync" -version = "1.9.0" +version = "1.9.1" source = { virtual = "." } dependencies = [ { name = "aiodns" }, @@ -280,6 +448,9 @@ dependencies = [ ] [package.optional-dependencies] +openstack = [ + { name = "openstacksdk" }, +] test = [ { name = "pytest" }, ] @@ -292,6 +463,7 @@ requires-dist = [ { name = "charset-normalizer", specifier = "==3.5.2" }, { name = "hcloud", specifier = "==2.25.1" }, { name = "idna", specifier = "==3.20" }, + { name = "openstacksdk", marker = "extra == 'openstack'", specifier = ">=4.0,<5" }, { name = "packaging", specifier = "==26.3" }, { name = "pycares", specifier = "==5.0.1" }, { name = "pycparser", specifier = "==3.0" }, @@ -305,7 +477,43 @@ requires-dist = [ { name = "urllib3", specifier = "==2.8.0" }, { name = "wheel", specifier = "==0.48.0" }, ] -provides-extras = ["test"] +provides-extras = ["openstack", "test"] + +[[package]] +name = "openstacksdk" +version = "4.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "decorator" }, + { name = "dogpile-cache" }, + { name = "iso8601" }, + { name = "jmespath" }, + { name = "jsonpatch" }, + { name = "keystoneauth1" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/de/da119a0fd6ad3794ccc55c4723a50e6db415d495f77ae083622a275d94a3/openstacksdk-4.22.0.tar.gz", hash = "sha256:757f399346f804d0a70b83fead919c498521e509889cd8973ece5cf7a640e6f9", size = 1473175, upload-time = "2026-10-22T13:33:28.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ce/62e8f3a5ffc6769dcc666826178a7bf572b8e01b41a74dc893203ed1d5ec/openstacksdk-4.22.0-py3-none-any.whl", hash = "sha256:e5aafe38e4c338bcb3844f1f446d7761c68477db8cec0e4bb91f4de9d35877c1", size = 2057402, upload-time = "2026-10-22T13:33:26.678Z" }, +] + +[[package]] +name = "os-service-types" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/ae/fe7ac23155ae0b4b9779e06e9c5bb4070f2315dc4ca886a88fa3230d344b/os_service_types-1.9.0.tar.gz", hash = "sha256:1f2e5fb71d1f6f4ff31d8992674f2368465bc2f25cd94018015c3ddbfc5c617f", size = 28087, upload-time = "2026-08-04T13:07:43.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/79/f8ae99ae9bb54cd63e7ab89251b2a4ff4ff07f02847403948b6a6f50f816/os_service_types-1.9.0-py3-none-any.whl", hash = "sha256:f268545c896434177bf55c43f7d5129e65210d5d456df3f00a1c9832a71c3a4b", size = 25282, upload-time = "2026-08-04T13:07:42.258Z" }, +] [[package]] name = "packaging" @@ -316,6 +524,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pbr" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/8d/ce438c28c7958e33184e8ac851ea2225b47a41e5e9e708fa3bddba631135/pbr-7.1.3.tar.gz", hash = "sha256:9a4a85b84e906337708009af0b5f5cdabeeb72d4dc213c9e97974da54fd9acc5", size = 140100, upload-time = "2026-10-07T10:38:15.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a2/79a926b7ab54b247c3419bfa00cfbeb78ad495d21c6d787c978c6261623d/pbr-7.1.3-py2.py3-none-any.whl", hash = "sha256:6583e878a1d97cb135fdc509811f31b9235905cde8d4dacd3dbadf9efc45d745", size = 136717, upload-time = "2026-10-07T10:38:14.069Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -325,6 +554,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pycares" version = "5.0.1" @@ -334,6 +591,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/df/a0/9c823651872e6a0face3f0311de2a40c8bbcb9c8dcb15680bd019ac56ac7/pycares-5.0.1.tar.gz", hash = "sha256:5a3c249c830432631439815f9a818463416f2a8cbdb1e988e78757de9ae75081", size = 652222, upload-time = "2026-01-01T12:37:00.604Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/75/67/e84ba11d3fec3bf1322c3b302c4df13c85e0a1bc48f16d65cd0f59ad9853/pycares-5.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ee551be4f3f3ac814ac8547586c464c9035e914f5122a534d25de147fa745e1", size = 136241, upload-time = "2026-01-01T12:35:25.439Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/50fbb3b4e52b9f1d16a36ffabd051ef8b2106b3f0a0d1c1113904d187a9d/pycares-5.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:252d4e5a52a68f825eaa90e16b595f9baee22c760f51e286ab612c6829b96de3", size = 131069, upload-time = "2026-01-01T12:35:26.293Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ea/f431599f1ac42149ea4768e516db7cdae3a503a6646319ae63ab66da1486/pycares-5.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c1aa549b8c2f2e224215c793d660270778dcba9abc3b85abbc7c41eabe4f1e5", size = 221120, upload-time = "2026-01-01T12:35:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4f/0a7a6c8b3a64ee5149e935c167cd8ba5d1fdd766ec03e273dbc7502f7bea/pycares-5.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db7c9c9f16e8311998667a7488e817f8cbeedec2447bac827c71804663f1437e", size = 252228, upload-time = "2026-01-01T12:35:28.443Z" }, + { url = "https://files.pythonhosted.org/packages/49/3d/7f9fd20e97ee30c4b959f87ab26e47ddcef666e5e7717e45f2245fe9d70a/pycares-5.0.1-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9c4c8bb69bab863f677fa166653bb872bfa5d5a742f1f30bebc2d53b6e71db", size = 239473, upload-time = "2026-01-01T12:35:29.794Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d0/c67967a10abd89529cb9aded9d73f43e5de00cf21243638ef529f6757262/pycares-5.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09ef90da8da3026fcba4ed223bd71e8057608d5b3fec4f5990b52ae1e8c855cc", size = 223831, upload-time = "2026-01-01T12:35:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9a/94aacaf22a20b7d342c8f18bf006be57967beef6319adc668d4d86b627be/pycares-5.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ce193ebd54f4c74538b751ebb0923a9208c234ff180589d4d3cec134c001840e", size = 223963, upload-time = "2026-01-01T12:35:31.691Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e1/3666aab6fc5e7d0c669b981fe0407e6a4b67e4e6a37ac429d440274663d5/pycares-5.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36b9ff18ef231277f99a846feade50b417187a96f742689a9d08b9594e386de4", size = 251813, upload-time = "2026-01-01T12:35:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/ddab5fbc16ad0084a827167ae8628f54c7a55ce6b743585e6f47a5dd527e/pycares-5.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5e40ea4a0ef0c01a02ef7f7390a58c62d237d5ad48d36bc3245e9c2ac181cc22", size = 238181, upload-time = "2026-01-01T12:35:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/05467933e0e5c4e712302a2d7499797bc3029bf4d0d8ffbfe737254482b7/pycares-5.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f323b0ddfd2c7896af6fba4f8851d34d3d13387566aa573d93330fb01cb1038", size = 223552, upload-time = "2026-01-01T12:35:35.076Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e2/14f3837e943d46ee12441fe6aaa418fdb2f698d42e179f368eaa9829744b/pycares-5.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:bdc6bcafb72a97b3cdd529fc87210e59e67feb647a7e138110656023599b84da", size = 117478, upload-time = "2026-01-01T12:35:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3284061f18188d5085338e1f1fd4f03d9c135657acf16f8020b9dd3be5fc/pycares-5.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:f8ef4c70c1edaf022875a8f9ff6c0c064f82831225acc91aa1b4f4d389e2e03a", size = 108889, upload-time = "2026-01-01T12:35:37.135Z" }, { url = "https://files.pythonhosted.org/packages/92/0a/6bd9bdc2d0ee23ff3aabab7747212e2c5323a081b9b745624d62df88f7e9/pycares-5.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d1b2c6b152c65f14d0e12d741fabb78a487f0f0d22773eede8d8cfc97af612b", size = 136242, upload-time = "2026-01-01T12:35:38.372Z" }, { url = "https://files.pythonhosted.org/packages/18/2a/2e9f888fc076cfe7a3493a3c4113e787cc4b4533f531dfb562ac9b04898f/pycares-5.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8c8ffcc9a48cfc296fe1aefc07d2c8e29a7f97e4bb366ce17effea6a38825f70", size = 131070, upload-time = "2026-01-01T12:35:39.262Z" }, { url = "https://files.pythonhosted.org/packages/ec/5b/83b5aaf7b6ed102f63cd768a747b6cb5d4624f2eaecd84868d103b9dbf39/pycares-5.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8efc38c2703e3530b823a4165a7b28d7ce0fdcf41960fb7a4ca834a0f8cfe79", size = 221137, upload-time = "2026-01-01T12:35:40.155Z" }, @@ -432,6 +701,16 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, @@ -495,6 +774,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "stevedore" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "urllib3" version = "2.8.0" From 71d336b5619b1b329ed6970a68f9a12b20cd1694 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:36:45 -0500 Subject: [PATCH 17/26] Fix formatting in test workflow file From 3aab4c1f4bce40c44eb5a221db12c011185a3397 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:37:10 -0500 Subject: [PATCH 18/26] Fix formatting of inventory files link in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b2d98515..a06dc32a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This is a tool to sync data from different sources to a NetBox instance. Available source types: * VMware vCenter Server * AWS EC2, EBS, and network interfaces (see [AWS source](docs/source_aws.md)) -* * [bb-ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) inventory files +* [bb-ricardo/check_redfish](https://github.com/bb-Ricardo/check_redfish) inventory files **IMPORTANT: READ INSTRUCTIONS CAREFULLY BEFORE RUNNING THIS PROGRAM** From 2581957d54e9c0c38b1453ea154e214042b96d3c Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:39:30 -0500 Subject: [PATCH 19/26] Refactor source handlers and validation logic Refactor source handlers and validation logic in __init__.py. --- module/sources/__init__.py | 298 +++++++++++-------------------------- 1 file changed, 86 insertions(+), 212 deletions(-) diff --git a/module/sources/__init__.py b/module/sources/__init__.py index 62a1962b..5cdc0f1b 100644 --- a/module/sources/__init__.py +++ b/module/sources/__init__.py @@ -7,248 +7,122 @@ # For a copy, see file LICENSE.txt included in this # repository or visit: . -import os - -from module.config.formatter import DescriptionFormatterMixin -from module.config.group import ConfigOptionGroup -from module.config.option import ConfigOption -from module.common.config import CommonConfig -from module.netbox.config import NetBoxConfig -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 -from module import __version__, __version_date__, __description__, __url__ - -log = get_logger() - - -class ConfigFileOutput(DescriptionFormatterMixin): - - base_config_list = [ - CommonConfig, - NetBoxConfig - ] - - source_config_list = [ - VMWareConfig, - CheckRedfishConfig, - HetznerConfig, - OpenStackConfig - ] - - header = f"Welcome to the {__description__} configuration file." - - _description = """The values in this file override the default values used by the system if - a config option is not specified. The commented out lines are the configuration - field and the default value used. Uncommenting a line and changing the value - will change the value used at runtime when the process is restarted. - """ - - source_description = """Controls the parameters of a defined source. The string past the slash - will be used as a sources name. Sources can be defined multiple times to - represent different sources. - """ - - config_file_type = None - indentation_level = 0 - lines = list() - - def __init__(self, args): - - if args is None or args.generate_config is False: - return - - if len(args.config_files) == 0: - self.output_file = default_config_file_path - else: - self.output_file = args.config_files[0] - - if os.path.exists(self.output_file): - log.error(f'ERROR: Config file "{self.output_file}" already present') - exit(1) - - self.config_file_type = ConfigFile.get_file_type(self.output_file) - - if self.config_file_type is None: - log.error(f"ERROR: Unknown/Unsupported config file type " - f"'{ConfigFile.get_suffix(self.output_file)}' for {self.output_file}") - exit(1) - - self.comment_prefix = self.config_file_type.comment_prefix +# define all available sources here +from module.sources.vmware.connection import VMWareHandler +from module.sources.hetzner.connection import HetznerHandler +from module.sources.aws.connection import AWSHandler +from module.sources.check_redfish.import_inventory import CheckRedfish - self.format() - - self._set_indent_level(0) - self._add_blank_line() - self._add_line(f"{self.comment_prefix}EOF") - self._add_blank_line() - - self.lines = [x.rstrip() for x in self.lines] - - try: - with open(self.output_file, "w") as fp: - fp.write('\n'.join(self.lines)) - except Exception as e: - log.error(f"Error: Unable to write to file '{self.output_file}': {e}") - exit(1) - - exit(0) - - def format(self): - - self._add_line(f"{self.comment_prefix*3} {self.header}\n") - self._add_line(f"{self.comment_prefix*3} Version: {__version__} ({__version_date__})\n") - self._add_line(f"{self.comment_prefix * 3} Project URL: {__url__}\n") - self._add_blank_line() - self._add_lines(self.config_description(prefix=self.comment_prefix).split("\n")) - self._add_blank_line() - - for config_section in self.base_config_list: - - config_instance = config_section() - - self._format_section_description(config_instance) - self._add_blank_line() - - if self.config_file_type is ConfigFileINI: - self._add_line(f"[{config_instance.section_name}]") - - elif self.config_file_type is ConfigFileYAML: - self._set_indent_level(0) - self._add_line(f"{config_section.section_name}:") - - self._set_indent_level(1) - self._format_options(config_instance.options) - self._set_indent_level(0) - - # write out section description - self._format_section_description(section_name="source/*", section_description=self.source_description) - - self._add_blank_line() - - if self.config_file_type is ConfigFileYAML: - self._add_line(f"{source_config_section_name}:") +from module.common.logging import get_logger +from module.netbox.inventory import NetBoxInventory +from module.config.parser import ConfigParser +from module.config.base import ConfigOptions +from module.config import source_config_section_name - for config_section in self.source_config_list: +# list of valid sources +valid_sources = [VMWareHandler, CheckRedfish, HetznerHandler, AWSHandler] - config_instance = config_section() - self._format_section_description(config_instance) - self._add_blank_line() +def validate_source(source_class_object=None, state="pre"): + """ + Validate class and object attributes of a $source_class_object + Check if all needed attributes in class definition are present before initialization. + Check if all needed attributes and their type in object are present + after initialization - if self.config_file_type is ConfigFileINI: - self._add_line(f"[{config_instance.section_name}/{config_instance.source_name_example}]") + Parameters + ---------- + source_class_object: Source handler class/object + class/object to investigate + state: str + pre if validating a class, post if validating an initialized object - elif self.config_file_type is ConfigFileYAML: - self._set_indent_level(1) - self._add_line(f"{config_instance.source_name_example}:") + """ - self._set_indent_level(2) - self._format_options(config_instance.options) - self._set_indent_level(1) + necessary_attributes = { + "dependent_netbox_objects": list, + "init_successful": bool, + "inventory": NetBoxInventory, + "name": str, + "settings": ConfigOptions, + "source_tag": str, + "source_type": str, + } - def _add_line(self, line: str): - indent = "" - indent_size = 2 + for attr in necessary_attributes.keys(): - if self.config_file_type is ConfigFileYAML: - indent = " " * indent_size * self.indentation_level + # raise exception if attribute not present + getattr(source_class_object, attr) - self.lines.append(f"{indent}{line}") + if state == "pre": + return - def _add_lines(self, lines: list): - for line in lines: - self._add_line(line) + # post initialization validation + for attr, value_type in necessary_attributes.items(): - def _set_indent_level(self, level: int): - self.indentation_level = level + value = getattr(source_class_object, attr, None) - def _format_options(self, option_list: list): + if not isinstance(value, value_type): + raise ValueError(f"Value for attribute '{attr}' needs to be {value_type}") - for option in option_list: + if value_type in [list, str] and len(value) == 0: + raise ValueError(f"Value for attribute '{attr}' can't be empty.") - if isinstance(option, ConfigOption): - self._format_config_option(option) - elif isinstance(option, ConfigOptionGroup): - self._format_config_option_group(option) - self._add_blank_line() +def instantiate_sources(): + """ + Instantiate a source handler and add necessary attributes. Also + validate source handler on pre- and post-initialization. - def _format_section_description(self, - config_instance=None, - section_name: str = None, - section_description: str = None): + Returns + ------- + source handler object: instantiated source handler + """ - wide_prefix = self.comment_prefix * 3 + config = ConfigParser() + inventory = NetBoxInventory() - if config_instance is not None: - if section_description is None: - section_description = config_instance.__doc__ - if section_name is None: - section_name = f"{config_instance.section_name}" + log = get_logger() - if section_description is not None and section_name is not None: - section_formatter = DescriptionFormatterMixin() - section_formatter._description = section_description + # first validate all available sources + for possible_source_class in valid_sources: + validate_source(possible_source_class) - self._add_blank_line() - self._add_line(wide_prefix) - self._add_line(f"{wide_prefix} [{section_name}]") - self._add_line(wide_prefix) - self._add_lines(section_formatter.config_description(prefix=wide_prefix).split("\n")) - self._add_line(wide_prefix) + sources = list() - def _add_blank_line(self): - if len(self.lines) > 0 and self.lines[-1] != "": - self.lines.append("") + source_config = dict() + if isinstance(config.content, dict): + source_config = config.content.get(source_config_section_name) - def _format_config_option(self, option): + for source_name, source_config in source_config.items(): - if option is None or option.removed is True or option.deprecated is True: - return + source_config_type = source_config.get("type") + if source_config_type is None: + log.error(f"Source {source_name} option 'type' is undefined") + continue - if len(option.description()) > 0: - self._add_blank_line() - self._add_lines(option.config_description(prefix=self.comment_prefix).split("\n")) + source_class = None + for possible_source_class in valid_sources: + validate_source(possible_source_class) - option_key = option.key - option_value = None - if option.mandatory is False: - option_key = f"{self.comment_prefix}{option_key}" - if option_value is None and option.default_value is not None: - option_value = option.default_value - if option_value is None and option.config_example is not None: - option_value = option.config_example - if option_value is None: - option_value = "" + if possible_source_class.implements(source_config_type): + source_class = possible_source_class + break - if self.config_file_type is ConfigFileINI: - self._add_line(f"{option_key} = {option_value}") - elif self.config_file_type is ConfigFileYAML: - self._add_line(f"{option_key}: {option_value}") + if source_class is None: + log.error(f"Unknown source type '{source_config_type}' defined for '{source_name}'") + continue - def _format_config_option_group(self, group): + source_handler = source_class(name=source_name) - if group is None: - return + validate_source(source_handler, "post") - if isinstance(group.title, str) and len(group.title) > 0: - self._add_blank_line() - self._add_line(f"{self.comment_prefix} {group.title} options") + # add to list of source handlers + if source_handler.init_successful is True: + sources.append(source_handler) - if len(group.description()) > 0: - self._add_blank_line() - self._add_lines(group.config_description(prefix=self.comment_prefix).split("\n")) + inventory.add_source(source_handler) - if isinstance(group.config_example, str) and len(group.config_example) > 0: - formatter = DescriptionFormatterMixin() - formatter._description = group.config_example - self._add_line(self.comment_prefix) - self._add_lines(formatter.config_description(prefix=self.comment_prefix).split("\n")) + return sources - for option in group.options: - self._format_config_option(option) +# EOF From aecddd35bb79b1b2c4ffb6a00b9f45eb80951aa0 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:41:26 -0500 Subject: [PATCH 20/26] Add AWSConfig to source_config_list --- module/config/file_output.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/config/file_output.py b/module/config/file_output.py index 0566d935..9acf4e5a 100644 --- a/module/config/file_output.py +++ b/module/config/file_output.py @@ -36,7 +36,8 @@ class ConfigFileOutput(DescriptionFormatterMixin): source_config_list = [ VMWareConfig, CheckRedfishConfig, - HetznerConfig + HetznerConfig, + AWSConfig ] header = f"Welcome to the {__description__} configuration file." From aa6c962ab7386fe2063650ee913661b4f7b9145d Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:42:06 -0500 Subject: [PATCH 21/26] AWS adjustments for boto3 --- uv.lock | 282 +++++++++----------------------------------------------- 1 file changed, 44 insertions(+), 238 deletions(-) diff --git a/uv.lock b/uv.lock index 98bd5226..df188b4f 100644 --- a/uv.lock +++ b/uv.lock @@ -18,6 +18,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/70/72e4ab117425ccdc4d10bd523a94c1baa051a15586057d64a4c6888f9e3f/aiodns-4.0.4-py3-none-any.whl", hash = "sha256:c24dd605bac70a1676ce503f967a98483ff163507198557d8e9db16267e6cfd2", size = 12696, upload-time = "2026-05-20T01:54:14.134Z" }, ] +[[package]] +name = "boto3" +version = "1.43.118" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/8b/5694c8433b5202f34a82def4edee274f7df1eba2919c66d9d389d2202ec8/boto3-1.43.118.tar.gz", hash = "sha256:c0620f799e0f0c222ba42c337b65cdbcd3da38a3121187f634d8926cbbb28a9c", size = 112649, upload-time = "2026-10-22T19:22:09.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a3/65bb3d64928f3c2da17640b92044084150317f02adf22442cb0c52af2a8b/boto3-1.43.118-py3-none-any.whl", hash = "sha256:ab8f3d2c622ef039338b4f228d97ea55f1abd573d84bb9b9a5ee8cb6780c0ccd", size = 140045, upload-time = "2026-10-22T19:22:08.353Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.118" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/b8/3e55154ddaf1a6d1d4ddda85c0e5983205a3b7657edacd8ebba8c6fd774c/botocore-1.43.118.tar.gz", hash = "sha256:7f3f9d88cc9e847587690f82a42e3c3c292c3026c92c1e60d68a9310018e003f", size = 16420126, upload-time = "2026-10-22T19:22:04.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/61b39a8e7a69af5ea43c1c3e1b0065a4866b42ccddf107e16686a3143614/botocore-1.43.118-py3-none-any.whl", hash = "sha256:a45d7ae570674161e4a3542d8c68633bf7503f92d1de781ef7361b79488eda6f", size = 16122016, upload-time = "2026-10-22T19:22:00.414Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -252,91 +280,6 @@ 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" @@ -368,15 +311,6 @@ 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" @@ -386,43 +320,6 @@ 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.1" @@ -448,8 +345,8 @@ dependencies = [ ] [package.optional-dependencies] -openstack = [ - { name = "openstacksdk" }, +aws = [ + { name = "boto3" }, ] test = [ { name = "pytest" }, @@ -458,12 +355,12 @@ test = [ [package.metadata] requires-dist = [ { name = "aiodns", specifier = "==4.0.4" }, + { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.40,<2" }, { name = "certifi", specifier = "==2026.7.22" }, { name = "cffi", specifier = "==2.1.1" }, { 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" }, @@ -477,43 +374,7 @@ requires-dist = [ { name = "urllib3", specifier = "==2.8.0" }, { name = "wheel", specifier = "==0.48.0" }, ] -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" }, -] +provides-extras = ["aws", "test"] [[package]] name = "packaging" @@ -524,27 +385,6 @@ 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" @@ -554,34 +394,6 @@ 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" @@ -756,6 +568,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" }, +] + [[package]] name = "setuptools" version = "84.0.0" @@ -774,24 +598,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "stevedore" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - [[package]] name = "urllib3" version = "2.8.0" From 065331835ad8bdec9cd3c39023ee6956b6e3875b Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Fri, 23 Oct 2026 11:42:31 -0500 Subject: [PATCH 22/26] Update test workflow for AWS branch and requirements --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c870977..661e28c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: push: - branches: [ "main", "development" ] + branches: [ "main", "development", "AWS" ] pull_request: branches: [ "main", "development" ] workflow_dispatch: @@ -31,7 +31,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements-aws.txt pip install pytest - name: Install vcsim From a5772017238f41eed61cd2c6180e3cb065dbbed9 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Sat, 24 Oct 2026 09:35:20 -0500 Subject: [PATCH 23/26] Refactor AWSClient for organization discovery --- module/sources/aws/client.py | 92 ++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/module/sources/aws/client.py b/module/sources/aws/client.py index 39a799f1..8fc2f308 100644 --- a/module/sources/aws/client.py +++ b/module/sources/aws/client.py @@ -1,3 +1,7 @@ +from module.common.logging import get_logger + +log = get_logger() + try: import boto3 from botocore.config import Config @@ -13,26 +17,106 @@ class AWSDiscoveryError(RuntimeError): class AWSClient: - def __init__(self, profile=None): + def __init__(self, profile=None, *, session=None): if boto3 is None: raise AWSDiscoveryError("AWS SDK missing. Install dependencies from requirements-aws.txt.") try: - self.session = boto3.Session(profile_name=profile) + self.session = session if session is not None else boto3.Session(profile_name=profile) except BotoCoreError as exc: raise AWSDiscoveryError(f"Unable to initialize AWS credentials: {exc}") from exc - def discover(self, regions, expected_account=None): + def discover_organization(self, regions, role_name, expected_account=None, discovery_region=None, + include_management=False, external_id=None, account_ids=None): + """Collect all selected accounts; never return a partial organization.""" + clients = [] + current_account = None + try: + bootstrap = (discovery_region or self.session.region_name) if regions == ["auto"] else regions[0] + if not bootstrap: + raise AWSDiscoveryError( + "Organization discovery requires discovery_region or an AWS profile/environment region " + "(AWS_DEFAULT_REGION).") + config = Config(retries={"mode": "standard", "max_attempts": 5}, + connect_timeout=10, read_timeout=60) + sts = self.session.client("sts", region_name=bootstrap, config=config) + clients.append(sts) + identity = sts.get_caller_identity() + if expected_account is not None and identity["Account"] != expected_account: + raise AWSDiscoveryError( + f"AWS account mismatch: expected {expected_account}, authenticated as {identity['Account']}.") + partition = identity["Arn"].split(":")[1] + organizations = self.session.client("organizations", region_name=bootstrap, config=config) + clients.append(organizations) + management_account = organizations.describe_organization()["Organization"]["MasterAccountId"] + accounts = set() + for page in organizations.get_paginator("list_accounts").paginate(): + for account in page["Accounts"]: + account_id = account["Id"] + if account["State"] != "ACTIVE": + log.info(f"AWS organization: excluding account '{account_id}' in state '{account['State']}'.") + continue + if account_id == management_account and not include_management: + continue + accounts.add(account_id) + if account_ids: + missing = set(account_ids) - accounts + if missing: + raise AWSDiscoveryError( + f"Requested organization accounts are not active/eligible: {', '.join(sorted(missing))}.") + accounts.intersection_update(account_ids) + if not accounts: + raise AWSDiscoveryError("Organization discovery returned no eligible accounts.") + snapshots = {} + for current_account in sorted(accounts): + role_arn = f"arn:{partition}:iam::{current_account}:role/{role_name}" + log.info(f"AWS organization: discovering account '{current_account}' using '{role_arn}'.") + request = {"RoleArn": role_arn, "RoleSessionName": "netbox-sync", "DurationSeconds": 3600} + if external_id: + request["ExternalId"] = external_id + credentials = sts.assume_role(**request)["Credentials"] + member = AWSClient(session=boto3.Session( + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], region_name=bootstrap)) + _, snapshot = member.discover(regions, current_account, discovery_region) + snapshots[current_account] = snapshot + return snapshots + except (BotoCoreError, ClientError, KeyError, AWSDiscoveryError) as exc: + context = f" for account '{current_account}'" if current_account else "" + raise AWSDiscoveryError(f"Incomplete AWS organization discovery{context}: {exc}") from exc + finally: + for client in clients: + client.close() + + def discover(self, regions, expected_account=None, discovery_region=None): """Fully collect all configured regions before any inventory changes.""" clients = [] try: config = Config(retries={"mode": "standard", "max_attempts": 5}, connect_timeout=10, read_timeout=60) - sts = self.session.client("sts", region_name=regions[0], config=config) + automatic = regions == ["auto"] + bootstrap = (discovery_region or self.session.region_name) if automatic else regions[0] + if not bootstrap: + raise AWSDiscoveryError( + "Automatic region discovery requires discovery_region or an AWS profile/environment region " + "(AWS_DEFAULT_REGION).") + sts = self.session.client("sts", region_name=bootstrap, config=config) clients.append(sts) account = sts.get_caller_identity()["Account"] if expected_account is not None and account != expected_account: raise AWSDiscoveryError( f"AWS account mismatch: expected {expected_account}, authenticated as {account}.") + if automatic: + discovery_client = self.session.client("ec2", region_name=bootstrap, config=config) + clients.append(discovery_client) + response = discovery_client.describe_regions(AllRegions=True) + regions = sorted({ + entry["RegionName"] for entry in response["Regions"] + if entry["OptInStatus"] in ("opt-in-not-required", "opted-in") + }) + if not regions: + raise AWSDiscoveryError("Automatic region discovery returned no enabled regions.") + log.info(f"AWS account '{account}' enabled regions: {', '.join(regions)}") snapshot = {} for region in regions: ec2 = self.session.client("ec2", region_name=region, config=config) From 8403e2b3fe0ca207d4c761ffd6659f60c6708b70 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Sat, 24 Oct 2026 09:35:36 -0500 Subject: [PATCH 24/26] Refactor AWS config options and validation logic --- module/sources/aws/config.py | 62 ++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/module/sources/aws/config.py b/module/sources/aws/config.py index 4a58abff..75bfc774 100644 --- a/module/sources/aws/config.py +++ b/module/sources/aws/config.py @@ -1,3 +1,5 @@ +import re + from module.common.logging import get_logger from module.common.misc import quoted_split from module.config import source_config_section_name @@ -22,17 +24,34 @@ def __init__(self): self.options = [ ConfigOption(**config_option_enabled_definition), ConfigOption(**{**config_option_type_definition, "config_example": "aws"}), - ConfigOption("regions", str, mandatory=True, config_example="us-east-1, us-west-2", - description="Explicit AWS regions to import (comma-separated or a YAML list)."), + ConfigOption("regions", str, default_value="auto", + description="""Use 'auto' to discover all account-enabled regions, or specify + regions to import (comma-separated or a YAML list). Do not mix auto with region names."""), + ConfigOption("discovery_region", str, + description="""Bootstrap region for automatic region discovery. Defaults to the + AWS profile/environment region. This selects the API endpoint, not resource placement."""), ConfigOption("profile", str, description="AWS SDK profile. Otherwise use the standard credential chain."), ConfigOption("account_id", str, - description="Optional expected 12-digit account ID; fail if credentials use another account."), + description="""Optional expected 12-digit credential account ID. + In organization mode this guards the collector account, not member accounts."""), + ConfigOption("organization", bool, default_value=False, + description="Discover active AWS Organizations accounts and assume an inventory role in each."), + ConfigOption("organization_role_name", str, default_value="NetBoxInventoryReadOnly", + description="Inventory role name (including any path) deployed in each selected account."), + ConfigOption("organization_external_id", str, sensitive=True, + description="Optional external ID required by the member inventory roles' trust policies."), + ConfigOption("organization_include_management", bool, default_value=False, + description="Also scan the organization management account using its inventory role."), + ConfigOption("organization_account_ids", str, + description="""Optional account allowlist (comma-separated or YAML list). + Unset selects all active member accounts. Management inclusion is controlled separately."""), ConfigOption(**config_option_permitted_subnets_definition), ConfigOption("sync_private_ips", bool, default_value=True, description="Import private IPv4 and IPv6 into pre-existing VPC-specific VRFs."), ConfigOption("vpc_vrf_relation", str, description="""Comma-separated region/vpc-id = NetBox VRF name mappings. + Use account-id/region/vpc-id keys for account-specific mappings in organization mode. Unmapped VPCs require a VRF named AWS //. Each VPC must have a distinct VRF; global address space is never used.""", config_example="us-east-1/vpc-0123456789abcdef0 = Production VPC"), @@ -45,13 +64,24 @@ def __init__(self): super().__init__() def validate_options(self): - for key in ("profile", "tenant"): + for key in ("profile", "tenant", "discovery_region", + "organization_role_name", "organization_external_id"): value = self.get_option_by_name(key).value if value is not None and not isinstance(value, str): log.error(f"AWS '{self.source_name}' option '{key}' must be a string.") self.set_validation_failed() - for key in ("regions", "tag_keys"): + role = self.get_option_by_name("organization_role_name").value + if isinstance(role, str) and ( + len(role) > 512 or not re.fullmatch(r"(?:[A-Za-z0-9_+=,.@-]+/)*[A-Za-z0-9_+=,.@-]{1,64}", role)): + log.error(f"AWS '{self.source_name}' organization_role_name must be a role name with an optional path.") + self.set_validation_failed() + external_id = self.get_option_by_name("organization_external_id").value + if isinstance(external_id, str) and not re.fullmatch(r"[A-Za-z0-9_+=,.@:/-]{2,1224}", external_id): + log.error(f"AWS '{self.source_name}' organization_external_id has an invalid format.") + self.set_validation_failed() + + for key in ("regions", "tag_keys", "organization_account_ids"): option = self.get_option_by_name(key) value = option.value if value is not None and not isinstance(value, (str, list)): @@ -65,10 +95,27 @@ def validate_options(self): continue entries = list(dict.fromkeys(entry.strip() for entry in entries)) if key == "regions" and not entries: - log.error(f"AWS '{self.source_name}' requires at least one explicit region.") + log.error(f"AWS '{self.source_name}' requires 'auto' or at least one explicit region.") + self.set_validation_failed() + if key == "regions" and "auto" in entries and entries != ["auto"]: + log.error(f"AWS '{self.source_name}' cannot mix 'auto' with explicit regions.") + self.set_validation_failed() + if key == "organization_account_ids" and any( + not re.fullmatch(r"[0-9]{12}", entry) for entry in entries): + log.error(f"AWS '{self.source_name}' organization_account_ids must contain 12-digit strings.") + self.set_validation_failed() + if key == "organization_account_ids" and value is not None and not entries: + log.error(f"AWS '{self.source_name}' organization_account_ids must be unset or nonempty.") self.set_validation_failed() option.set_value(entries) + if not self.get_option_by_name("organization").value and ( + self.get_option_by_name("organization_account_ids").value or + self.get_option_by_name("organization_external_id").value or + self.get_option_by_name("organization_include_management").value): + log.error(f"AWS '{self.source_name}' organization options require organization = True.") + self.set_validation_failed() + account = self.get_option_by_name("account_id").value if account is not None and (not isinstance(account, str) or len(account) != 12 or not account.isdigit()): log.error(f"AWS '{self.source_name}' account_id must be a 12-digit string.") @@ -85,7 +132,8 @@ def validate_options(self): key, separator, value = entry.partition("=") key, value = key.strip(' "\''), value.strip(' "\'') parts = key.split("/") - if not separator or len(parts) != 2 or not all(parts) or not value or key in mappings: + if not separator or len(parts) not in (2, 3) or not all(parts) or not value or key in mappings or ( + len(parts) == 3 and not re.fullmatch(r"[0-9]{12}", parts[0])): log.error(f"Malformed or duplicate AWS VPC/VRF mapping '{entry}'.") self.set_validation_failed() continue From 3b8b50f837fc075f2a1a8d097ca97f2906e0e65e Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Sat, 24 Oct 2026 09:35:52 -0500 Subject: [PATCH 25/26] Refactor AWS connection methods for organization support --- module/sources/aws/connection.py | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/module/sources/aws/connection.py b/module/sources/aws/connection.py index 1979ae06..913df14f 100644 --- a/module/sources/aws/connection.py +++ b/module/sources/aws/connection.py @@ -50,15 +50,30 @@ def abort(self, error): def apply(self): try: - account, snapshot = self.client.discover(self.settings.regions, self.settings.account_id) - self.validate_snapshot(account, snapshot) - self.sync_snapshot(account, snapshot) + if self.settings.organization: + snapshots = self.client.discover_organization( + self.settings.regions, self.settings.organization_role_name, + expected_account=self.settings.account_id, + discovery_region=self.settings.discovery_region, + include_management=self.settings.organization_include_management, + external_id=self.settings.organization_external_id, + account_ids=self.settings.organization_account_ids) + else: + account, snapshot = self.client.discover( + self.settings.regions, self.settings.account_id, self.settings.discovery_region) + snapshots = {account: snapshot} + vrfs = {} + for account, snapshot in snapshots.items(): + self.validate_snapshot(account, snapshot, vrfs) + for account, snapshot in snapshots.items(): + self.sync_snapshot(account, snapshot) except (AWSDiscoveryError, ValueError, KeyError) as exc: self.abort(exc) def vrf_name(self, account, region, vpc): return self.settings.vpc_vrf_relation.get( - f"{region}/{vpc}", f"AWS {account}/{region}/{vpc}") + f"{account}/{region}/{vpc}", self.settings.vpc_vrf_relation.get( + f"{region}/{vpc}", f"AWS {account}/{region}/{vpc}")) def get_vrf(self, account, region, vpc): name = self.vrf_name(account, region, vpc) @@ -94,8 +109,9 @@ def addresses(self, eni, subnet): result.append((value, primary)) return result - def validate_snapshot(self, account, snapshot): - vrfs = {} + def validate_snapshot(self, account, snapshot, vrfs=None): + if vrfs is None: + vrfs = {} for region, data in snapshot.items(): for instance in data["instances"]: spec = data["InstanceTypes"].get(instance["InstanceType"]) @@ -208,15 +224,25 @@ def sync_instance(self, account, region, instance, data, site, cluster, api_vers vm.remove_tags(tag) if api_version >= Version("3.7"): for volume in disks: - self.inventory.add_update_object(NBVirtualDisk, data={ + self.add_update_vm_child(NBVirtualDisk, vm, { "name": volume["VolumeId"], "virtual_machine": vm, "size": volume["Size"] * (1024 if api_version >= Version("4.1") else 1), "description": f"AWS EBS {volume['VolumeType']}", - }, source=self) + }) for reference in instance.get("NetworkInterfaces", []): eni = data["NetworkInterfaces"][reference["NetworkInterfaceId"]] self.sync_interface(account, region, vm, eni, data, api_version) + def add_update_vm_child(self, object_type, vm, data): + # Display-name matching loses the VM's account/cluster when names repeat. + existing = next((item for item in self.inventory.get_all_items(object_type) + if item.data.get("virtual_machine") is vm and + item.data.get("name") == data["name"]), None) + if existing is None: + return self.inventory.add_object(object_type, data=data, source=self) + existing.update(data=data, source=self) + return existing + def sync_interface(self, account, region, vm, eni, data, api_version): interface_data = { "name": eni["NetworkInterfaceId"], "virtual_machine": vm, "enabled": True, @@ -234,7 +260,7 @@ def sync_interface(self, account, region, vm, eni, data, api_version): mac = normalize_mac_address(eni.get("MacAddress")) if api_version < Version("4.2"): interface_data["mac_address"] = mac - interface = self.inventory.add_update_object(NBVMInterface, data=interface_data, source=self) + interface = self.add_update_vm_child(NBVMInterface, vm, interface_data) if api_version >= Version("4.2") and mac is not None: mac_object = next((item for item in self.inventory.get_all_items(NBMACAddress) if item.data.get("mac_address") == mac and From cefdf42a509ca9bcacfee56e8b1ea6c6e56281e9 Mon Sep 17 00:00:00 2001 From: Eric Krichbaum Date: Sat, 24 Oct 2026 09:36:19 -0500 Subject: [PATCH 26/26] Enhance AWS source documentation for regions and accounts Updated AWS source documentation to clarify region handling and organization mode. Added details on automatic region discovery and explicit region configuration. --- docs/source_aws.md | 201 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 195 insertions(+), 6 deletions(-) diff --git a/docs/source_aws.md b/docs/source_aws.md index dccd89e9..18ed5a9f 100644 --- a/docs/source_aws.md +++ b/docs/source_aws.md @@ -29,8 +29,10 @@ Use the standard boto3 credential chain: * `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`; temporary credentials also need `AWS_SESSION_TOKEN`. Prefer roles over stored access keys. -Each configured source handles one AWS account and an explicit region list. -Configure another named source/profile for another account. The optional +By default each configured source handles one AWS account. Regions are discovered +automatically by default (`regions = auto`); an explicit list is also supported. +For multiple accounts, use organization mode below or configure separate named +sources/profiles. The optional `account_id` guard verifies the account returned by STS before importing anything. Profile-based assume-role credential refresh is handled by boto3. Credentials must allow access to every configured region. @@ -53,6 +55,7 @@ Example IAM policy (attach to a dedicated role or non-root identity): "ec2:DescribeVolumes", "ec2:DescribeSubnets", "ec2:DescribeVpcs", + "ec2:DescribeRegions", "sts:GetCallerIdentity" ], "Resource": "*" @@ -63,14 +66,15 @@ Example IAM policy (attach to a dedicated role or non-root identity): An assume-role profile additionally needs `sts:AssumeRole` permission on the target role and a matching target-account trust policy (and external ID where -required). No Organizations permissions are used. +required). Single-account mode does not use Organizations permissions. ## Configuration ```ini [source/production-aws] type = aws -regions = us-east-1, us-west-2 +regions = auto +discovery_region = us-east-1 profile = inventory account_id = 123456789012 permitted_subnets = 10.0.0.0/8, 2001:db8::/32 @@ -84,6 +88,183 @@ environment variables. `regions` and `tag_keys` accept YAML lists as well as comma-separated strings. Quote the account ID in YAML so it remains a string. `python netbox-sync.py -g` generates the full configuration. +### Automatic or explicit regions + +`regions = auto` (also the default when omitted) calls EC2 `DescribeRegions` and +queries regions whose opt-in status is `opt-in-not-required` or `opted-in`. +Disabled regions (`not-opted-in`) are excluded. Each resource is mapped to the +region that returned it, never to the bootstrap region. + +Auto discovery requires a bootstrap endpoint: set `discovery_region`, or provide +a region through the AWS profile or `AWS_DEFAULT_REGION`. There is no hardcoded +commercial-region fallback; choose an endpoint in the appropriate AWS partition +(for example GovCloud or China when applicable). The account's enabled regions +in that partition are discovered. A missing bootstrap region fails explicitly. + +To restrict scope, use `regions = us-east-1, us-west-2` or a YAML list. Explicit +mode does not call `DescribeRegions`, does not need that permission, and ignores +`discovery_region`. Do not combine `auto` and named regions in the same list. + +Auto mode requires `ec2:DescribeRegions` in addition to the inventory permissions. +Failure to discover regions, an empty enabled-region result, or failure to query +any enabled region aborts synchronization before NetBox writes or pruning. +There is no silent fallback to a partial region list. Use explicit mode when +credentials are intentionally restricted to a subset of enabled regions. +Switching between modes changes discovery scope; inspect a dry run before +allowing writes, particularly if narrowing to an explicit list. + +### Organization / multi-account discovery + +For an organization with many member accounts, enable organization mode: + +```ini +[source/aws-organization] +type = aws +profile = inventory-collector +organization = True +organization_role_name = NetBoxInventoryReadOnly +discovery_region = us-east-1 +regions = auto +sync_private_ips = False +; Optional safety check: the account hosting the collector credentials +account_id = 665801164956 +; Start with one or two member accounts before enabling the full organization: +;organization_account_ids = 111122223333, 444455556666 +;organization_external_id = your-administrator-provided-external-id +;organization_include_management = False +``` + +The collector enumerates all account pages (including empty pages with a +continuation token), selects accounts with `State = ACTIVE`, and excludes the +organization's management account by default. It assumes the named role in each +selected account, verifies the resulting account identity, then discovers that +account's regions and resources. No EC2 permissions are needed on the collector +identity when management-account scanning is disabled. + +`organization_account_ids` is an optional allowlist, not an account-ID guard. +Quote IDs in YAML lists. An explicitly empty list is invalid; omit the option to +select all active members. Unknown, inactive, or excluded IDs in that list cause +an error rather than silently reducing scope. To scan the management account, set +`organization_include_management = True` and deploy the same inventory role +there. A role path such as `service-accounts/NetBoxInventoryReadOnly` is supported. +Role ARNs use the collector's AWS partition; cross-partition discovery is not +supported. + +All selected accounts are collected and validated before reconciliation. An +Organizations API error, role-assumption failure, account mismatch, or regional +discovery failure aborts the entire run before NetBox writes or pruning. No +inaccessible account is treated as empty. A selection with no eligible accounts +also fails. Account discovery and scanning are sequential. Fresh one-hour STS +credentials are obtained immediately before each account scan, so time spent +scanning earlier accounts does not consume later accounts' credential lifetime. +These member credentials are held only in memory and are not automatically +renewed within an individual account scan; expiration fails the run safely. +Collector profile credential refresh remains managed by boto3. + +#### AWS profile + +The profile supplies **collector credentials**, not a separate profile for every +member. No custom organization settings are needed in the AWS profile; the +organization options belong in the application's source section. + +For an existing named credentials entry `inventory-collector`, the AWS shared +config can contain: + +```ini +[profile inventory-collector] +region = us-east-1 +``` + +Store its credentials in the standard AWS shared credentials file under +`[inventory-collector]`, or use an existing assume-role/SSO profile supported by +boto3. On Windows the standard files are `%USERPROFILE%\.aws\config` and +`%USERPROFILE%\.aws\credentials`; on Linux they are `~/.aws/config` and +`~/.aws/credentials`. Authenticate SSO profiles before an interactive test. +For unattended jobs prefer a workload IAM role or an automatically refreshable +credential provider, not an expiring interactive SSO login. + +For example, if the collector itself uses an assume-role profile: + +```ini +[profile inventory-collector] +role_arn = arn:aws:iam::665801164956:role/NetBoxOrganizationCollector +source_profile = existing-base-profile +region = us-east-1 +``` + +The base profile must be authorized to assume that collector role, and the role +must trust it. The collector role then needs the permissions below. If the +collector runs with an attached IAM role or environment credentials, omit the +application `profile` setting entirely. Never store access keys in the source +configuration or commit credential files. + +#### Collector permissions + +Run from the management account or an appropriately delegated administrator +allowed to list organization accounts. Attach this policy to the collector user +or role, adjusting the role path and partition if needed: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "organizations:DescribeOrganization", + "organizations:ListAccounts" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/NetBoxInventoryReadOnly" + } + ] +} +``` + +Restrict the role ARN account list to your member accounts where feasible. +AWS-managed `ReadOnlyAccess` covers the EC2 inventory permissions but does not +replace the required cross-account `sts:AssumeRole` grant. + +#### Member-account inventory role + +Deploy `NetBoxInventoryReadOnly` in every selected member account, for example +with CloudFormation StackSets. Attach the EC2 read policy above or AWS-managed +`ReadOnlyAccess`. Set its trust policy to allow the **exact collector principal**. +For the IAM user from the reported test, that policy is: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::665801164956:user/service-accounts/netbox-reports-client" + }, + "Action": "sts:AssumeRole" + } + ] +} +``` + +If using the collector-role profile above, replace the principal with +`arn:aws:iam::665801164956:role/NetBoxOrganizationCollector`. +If your administrator requires an external ID, add a +`Condition: {"StringEquals": {"sts:ExternalId": ""}}` to the trust +statement and set the same `organization_external_id` in the application. +MFA-protected member-role assumption is not interactive; use an administrator- +approved unattended trust policy. SCPs and permissions boundaries must also +allow the calls. No roles or IAM policies are created by the importer. + +Adding an allowlist, excluding management, or removing/inactivating an account +changes the source's authoritative scope and can orphan previously imported +objects under the existing pruning rules. Review a dry run before changing +scope. Avoid overlapping single-account and organization source definitions. + ### VPC address-space isolation The current NetBox integration reads VRFs and prefixes but does not create them. @@ -96,8 +277,16 @@ AWS // For example: `AWS 123456789012/us-east-1/vpc-0123456789abcdef0`. Use `vpc_vrf_relation` to select an existing differently named VRF. A mapping -must not share a VRF between discovered VPCs. VRFs must remain distinct across -separately configured sources too. +key may be `account-id/region/vpc-id` in organization mode; this takes precedence +over a legacy `region/vpc-id` mapping. For example: + +```ini +vpc_vrf_relation = 111122223333/us-east-1/vpc-0123456789abcdef0 = Production VPC +``` + +A mapping must not share a VRF between discovered VPCs. VRFs must remain distinct across +accounts and separately configured sources too. Organization mode validates +VRF isolation across all selected accounts before reconciliation. Missing VRFs for permitted addresses abort the run before inventory updates. There is no fallback to global address space. Subnet mask lengths come from AWS