annet 2.3.1__py3-none-any.whl → 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of annet might be problematic. Click here for more details.

@@ -0,0 +1,60 @@
1
+ from abc import abstractmethod, ABC
2
+ from typing import Protocol, Generic, TypeVar
3
+
4
+ from annet.annlib.netdev.views.hardware import HardwareView
5
+ from .manufacturer import get_breed, get_hw
6
+ from .models import NetboxDevice, Interface, IpAddress, Prefix
7
+
8
+ NetboxDeviceT = TypeVar("NetboxDeviceT", bound=NetboxDevice)
9
+ InterfaceT = TypeVar("InterfaceT", bound=Interface)
10
+ IpAddressT = TypeVar("IpAddressT", bound=IpAddress)
11
+ PrefixT = TypeVar("PrefixT", bound=Prefix)
12
+
13
+
14
+ def get_device_breed(device: NetboxDeviceT) -> str:
15
+ if device.device_type and device.device_type.manufacturer:
16
+ return get_breed(
17
+ device.device_type.manufacturer.name,
18
+ device.device_type.model,
19
+ )
20
+ return ""
21
+
22
+
23
+ def get_device_hw(device: NetboxDeviceT) -> HardwareView:
24
+ if device.device_type and device.device_type.manufacturer:
25
+ return get_hw(
26
+ device.device_type.manufacturer.name,
27
+ device.device_type.model,
28
+ device.platform.name if device.platform else "",
29
+ )
30
+ return HardwareView("", "")
31
+
32
+
33
+ class NetboxAdapter(ABC, Generic[NetboxDeviceT, InterfaceT, IpAddressT, PrefixT]):
34
+ @abstractmethod
35
+ def list_all_fqdns(self) -> list[str]:
36
+ raise NotImplementedError()
37
+
38
+ @abstractmethod
39
+ def list_devices(self, query: dict[str, list[str]]) -> list[NetboxDeviceT]:
40
+ raise NotImplementedError()
41
+
42
+ @abstractmethod
43
+ def get_device(self, device_id: int) -> NetboxDeviceT:
44
+ raise NotImplementedError()
45
+
46
+ @abstractmethod
47
+ def list_interfaces_by_devices(self, device_ids: list[int]) -> list[InterfaceT]:
48
+ raise NotImplementedError()
49
+
50
+ @abstractmethod
51
+ def list_interfaces(self, ids: list[int]) -> list[InterfaceT]:
52
+ raise NotImplementedError()
53
+
54
+ @abstractmethod
55
+ def list_ipaddr_by_ifaces(self, iface_ids: list[int]) -> list[IpAddressT]:
56
+ raise NotImplementedError()
57
+
58
+ @abstractmethod
59
+ def list_ipprefixes(self, prefixes: list[str]) -> list[PrefixT]:
60
+ raise NotImplementedError()
@@ -1,7 +1,8 @@
1
+ from abc import abstractmethod
1
2
  from dataclasses import dataclass, field
2
- from datetime import datetime, timezone
3
+ from datetime import datetime
3
4
  from ipaddress import ip_interface, IPv6Interface
4
- from typing import List, Optional, Any, Dict, Sequence, Callable
5
+ from typing import List, Optional, Any, Dict, Sequence, TypeVar, Generic
5
6
 
6
7
  from annet.annlib.netdev.views.dump import DumpableView
7
8
  from annet.annlib.netdev.views.hardware import HardwareView, lag_name, svi_name
@@ -53,7 +54,7 @@ class DeviceIp(DumpableView):
53
54
  class Prefix(DumpableView):
54
55
  id: int
55
56
  prefix: str
56
- site: Optional[Entity]
57
+ # `site` deprecated since v4.2, replace in derived classes.
57
58
  vrf: Optional[Entity]
58
59
  tenant: Optional[Entity]
59
60
  vlan: Optional[Entity]
@@ -70,10 +71,13 @@ class Prefix(DumpableView):
70
71
  return self.prefix
71
72
 
72
73
 
74
+ _PrefixT = TypeVar("_PrefixT", bound=Prefix)
75
+
76
+
73
77
  @dataclass
74
- class IpAddress(DumpableView):
78
+ class IpAddress(DumpableView, Generic[_PrefixT]):
75
79
  id: int
76
- assigned_object_id: int
80
+ assigned_object_id: int | None
77
81
  display: str
78
82
  family: IpFamily
79
83
  address: str
@@ -81,7 +85,7 @@ class IpAddress(DumpableView):
81
85
  tags: List[Entity]
82
86
  created: datetime
83
87
  last_updated: datetime
84
- prefix: Optional[Prefix] = None
88
+ prefix: Optional[_PrefixT] = None
85
89
  vrf: Optional[Entity] = None
86
90
 
87
91
  @property
@@ -111,8 +115,18 @@ class InterfaceVlan(Entity):
111
115
  vid: int
112
116
 
113
117
 
118
+ def vrf_object(vrf: str | None) -> Entity | None:
119
+ if vrf is None:
120
+ return None
121
+ else:
122
+ return Entity(id=0, name=vrf)
123
+
124
+
125
+ _IpAddressT = TypeVar("_IpAddressT", bound=IpAddress)
126
+
127
+
114
128
  @dataclass
115
- class Interface(Entity):
129
+ class Interface(Entity, Generic[_IpAddressT]):
116
130
  device: Entity
117
131
  enabled: bool
118
132
  description: str
@@ -122,53 +136,44 @@ class Interface(Entity):
122
136
  untagged_vlan: Optional[InterfaceVlan]
123
137
  tagged_vlans: Optional[List[InterfaceVlan]]
124
138
  display: str = ""
125
- ip_addresses: List[IpAddress] = field(default_factory=list)
139
+ ip_addresses: List[_IpAddressT] = field(default_factory=list)
126
140
  vrf: Optional[Entity] = None
127
141
  mtu: int | None = None
128
142
  lag: Entity | None = None
129
143
  lag_min_links: int | None = None
130
144
 
131
145
  def add_addr(self, address_mask: str, vrf: str | None) -> None:
132
- addr = ip_interface(address_mask)
133
- if vrf is None:
134
- vrf_obj = None
135
- else:
136
- vrf_obj = Entity(id=0, name=vrf)
146
+ for existing_addr in self.ip_addresses:
147
+ if existing_addr.address == address_mask and (
148
+ (existing_addr.vrf is None and vrf is None) or
149
+ (existing_addr.vrf is not None and existing_addr.vrf.name == vrf)
150
+ ):
151
+ return
137
152
 
153
+ addr = ip_interface(address_mask)
154
+ vrf_obj = vrf_object(vrf)
138
155
  if isinstance(addr, IPv6Interface):
139
156
  family = IpFamily(value=6, label="IPv6")
140
157
  else:
141
158
  family = IpFamily(value=4, label="IPv4")
159
+ self._add_new_addr(address_mask, vrf_obj, family)
142
160
 
143
- for existing_addr in self.ip_addresses:
144
- if existing_addr.address == address_mask and (
145
- (existing_addr.vrf is None and vrf is None) or
146
- (existing_addr.vrf is not None and existing_addr.vrf.name == vrf)
147
- ):
148
- return
149
- self.ip_addresses.append(IpAddress(
150
- id=0,
151
- display=address_mask,
152
- address=address_mask,
153
- vrf=vrf_obj,
154
- prefix=None,
155
- family=family,
156
- created=datetime.now(timezone.utc),
157
- last_updated=datetime.now(timezone.utc),
158
- tags=[],
159
- status=Label(value="active", label="Active"),
160
- assigned_object_id=self.id,
161
- ))
161
+ @abstractmethod
162
+ def _add_new_addr(self, address_mask: str, vrf: Entity | None, family: IpFamily):
163
+ raise NotImplementedError
164
+
165
+
166
+ _InterfaceT = TypeVar("_InterfaceT", bound=Interface)
162
167
 
163
168
 
164
169
  @dataclass
165
- class NetboxDevice(Entity):
170
+ class NetboxDevice(Entity, Generic[_InterfaceT]):
166
171
  url: str
167
172
  storage: Storage
168
173
 
169
174
  display: str
170
175
  device_type: DeviceType
171
- device_role: Entity
176
+ # `device_role` deprecated since v4.0, replace in derived classes.
172
177
  tenant: Optional[Entity]
173
178
  platform: Optional[Entity]
174
179
  serial: str
@@ -191,7 +196,7 @@ class NetboxDevice(Entity):
191
196
  hw: Optional[HardwareView]
192
197
  breed: str
193
198
 
194
- interfaces: List[Interface]
199
+ interfaces: List[_InterfaceT]
195
200
 
196
201
  @property
197
202
  def neighbors(self) -> List["Entity"]:
@@ -222,27 +227,14 @@ class NetboxDevice(Entity):
222
227
  custom_breed_pc = ("Mellanox", "NVIDIA", "Moxa", "Nebius")
223
228
  return self.device_type.manufacturer.name in custom_breed_pc or self.breed == "pc"
224
229
 
225
- def _make_interface(self, name: str, type: InterfaceType) -> Interface:
226
- return Interface(
227
- name=name,
228
- device=self,
229
- enabled=True,
230
- description="",
231
- type=type,
232
- id=0,
233
- vrf=None,
234
- display=name,
235
- untagged_vlan=None,
236
- tagged_vlans=[],
237
- ip_addresses=[],
238
- connected_endpoints=[],
239
- mode=None,
240
- )
230
+ @abstractmethod
231
+ def _make_interface(self, name: str, type: InterfaceType) -> _InterfaceT:
232
+ raise NotImplementedError
241
233
 
242
234
  def _lag_name(self, lag: int) -> str:
243
235
  return lag_name(self.hw, lag)
244
236
 
245
- def make_lag(self, lag: int, ports: Sequence[str], lag_min_links: int | None) -> Interface:
237
+ def make_lag(self, lag: int, ports: Sequence[str], lag_min_links: int | None) -> _InterfaceT:
246
238
  new_name = self._lag_name(lag)
247
239
  for target_interface in self.interfaces:
248
240
  if target_interface.name == new_name:
@@ -261,7 +253,7 @@ class NetboxDevice(Entity):
261
253
  def _svi_name(self, svi: int) -> str:
262
254
  return svi_name(self.hw, svi)
263
255
 
264
- def add_svi(self, svi: int) -> Interface:
256
+ def add_svi(self, svi: int) -> _InterfaceT:
265
257
  name = self._svi_name(svi)
266
258
  for interface in self.interfaces:
267
259
  if interface.name == name:
@@ -276,7 +268,7 @@ class NetboxDevice(Entity):
276
268
  def _subif_name(self, interface: str, subif: int) -> str:
277
269
  return f"{interface}.{subif}"
278
270
 
279
- def add_subif(self, interface: str, subif: int) -> Interface:
271
+ def add_subif(self, interface: str, subif: int) -> _InterfaceT:
280
272
  name = self._subif_name(interface, subif)
281
273
  for target_port in self.interfaces:
282
274
  if target_port.name == name:
@@ -288,7 +280,7 @@ class NetboxDevice(Entity):
288
280
  self.interfaces.append(target_port)
289
281
  return target_port
290
282
 
291
- def find_interface(self, name: str) -> Optional[Interface]:
283
+ def find_interface(self, name: str) -> Optional[_InterfaceT]:
292
284
  for iface in self.interfaces:
293
285
  if iface.name == name:
294
286
  return iface
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import Dict
3
+ import re
3
4
 
4
5
  from adaptix import Retort, name_mapping, NameStyle
5
6
  from dataclass_rest import get
@@ -13,6 +14,12 @@ class Status:
13
14
  netbox_version: str
14
15
  plugins: Dict[str, str]
15
16
 
17
+ @property
18
+ def minor_version(self) -> str:
19
+ if match := re.match(r"\d+\.\d+", self.netbox_version):
20
+ return match.group(0)
21
+ return ""
22
+
16
23
 
17
24
  class NetboxStatusClient(BaseNetboxClient):
18
25
  def _init_response_body_factory(self) -> FactoryProtocol:
@@ -0,0 +1,239 @@
1
+ import ssl
2
+ from ipaddress import ip_interface
3
+ from logging import getLogger
4
+ from typing import Any, Optional, List, Union, Dict, cast, Generic, TypeVar
5
+
6
+ from annetbox.v37 import models as api_models
7
+
8
+ from annet.adapters.netbox.common.query import NetboxQuery, FIELD_VALUE_SEPARATOR
9
+ from annet.adapters.netbox.common.storage_opts import NetboxStorageOpts
10
+ from annet.storage import Storage
11
+ from .adapter import NetboxAdapter
12
+ from .models import IpAddress, Interface, NetboxDevice, Prefix
13
+
14
+ logger = getLogger(__name__)
15
+ NetboxDeviceT = TypeVar("NetboxDeviceT", bound=NetboxDevice)
16
+ InterfaceT = TypeVar("InterfaceT", bound=Interface)
17
+ IpAddressT = TypeVar("IpAddressT", bound=IpAddress)
18
+ PrefixT = TypeVar("PrefixT", bound=Prefix)
19
+
20
+
21
+ class BaseNetboxStorage(
22
+ Storage,
23
+ Generic[
24
+ NetboxDeviceT,
25
+ InterfaceT,
26
+ IpAddressT,
27
+ PrefixT,
28
+ ],
29
+ ):
30
+ """
31
+ Base class for Netbox storage
32
+ """
33
+
34
+ def __init__(self, opts: Optional[NetboxStorageOpts] = None):
35
+ ctx: Optional[ssl.SSLContext] = None
36
+ url = ""
37
+ token = ""
38
+ self.exact_host_filter = False
39
+ threads = 1
40
+ if opts:
41
+ if opts.insecure:
42
+ ctx = ssl.create_default_context()
43
+ ctx.check_hostname = False
44
+ ctx.verify_mode = ssl.CERT_NONE
45
+ url = opts.url
46
+ token = opts.token
47
+ threads = opts.threads
48
+ self.exact_host_filter = opts.exact_host_filter
49
+ self.netbox = self._init_adapter(url=url, token=token, ssl_context=ctx, threads=threads)
50
+ self._all_fqdns: Optional[list[str]] = None
51
+ self._id_devices: dict[int, NetboxDeviceT] = {}
52
+ self._name_devices: dict[str, NetboxDeviceT] = {}
53
+ self._short_name_devices: dict[str, NetboxDeviceT] = {}
54
+
55
+ def _init_adapter(
56
+ self,
57
+ url: str,
58
+ token: str,
59
+ ssl_context: Optional[ssl.SSLContext],
60
+ threads: int,
61
+ ) -> NetboxAdapter[NetboxDeviceT, InterfaceT, IpAddressT, PrefixT]:
62
+ raise NotImplementedError()
63
+
64
+ def __enter__(self):
65
+ return self
66
+
67
+ def __exit__(self, _, __, ___):
68
+ pass
69
+
70
+ def resolve_object_ids_by_query(self, query: NetboxQuery):
71
+ return [
72
+ d.id for d in self._load_devices(query)
73
+ ]
74
+
75
+ def resolve_fdnds_by_query(self, query: NetboxQuery):
76
+ return [
77
+ d.name for d in self._load_devices(query)
78
+ ]
79
+
80
+ def resolve_all_fdnds(self) -> list[str]:
81
+ if self._all_fqdns is None:
82
+ self._all_fqdns = self.netbox.list_all_fqdns()
83
+ return self._all_fqdns
84
+
85
+ def make_devices(
86
+ self,
87
+ query: Union[NetboxQuery, list],
88
+ preload_neighbors=False,
89
+ use_mesh=None,
90
+ preload_extra_fields=False,
91
+ **kwargs,
92
+ ) -> List[NetboxDeviceT]:
93
+ if isinstance(query, list):
94
+ query = NetboxQuery.new(query)
95
+
96
+ devices = []
97
+ if query.is_host_query():
98
+ globs = []
99
+ for glob in query.globs:
100
+ if glob in self._name_devices:
101
+ devices.append(self._name_devices[glob])
102
+ if glob in self._short_name_devices:
103
+ devices.append(self._short_name_devices[glob])
104
+ else:
105
+ globs.append(glob)
106
+ if not globs:
107
+ return devices
108
+ query = NetboxQuery.new(globs)
109
+
110
+ new_devices = self._load_devices(query)
111
+ self._fill_device_interfaces(new_devices)
112
+ for device in new_devices:
113
+ self._record_device(device)
114
+ return devices + new_devices
115
+
116
+ def _load_devices(self, query: NetboxQuery) -> List[NetboxDeviceT]:
117
+ if not query.globs:
118
+ return []
119
+ query_groups = parse_glob(self.exact_host_filter, query)
120
+ devices = [
121
+ device
122
+ for device in self.netbox.list_devices(query_groups)
123
+ if _match_query(self.exact_host_filter, query, device)
124
+ ]
125
+ return devices
126
+
127
+ def _fill_device_interfaces(self, devices: list[NetboxDeviceT]) -> None:
128
+ device_mapping = {d.id: d for d in devices}
129
+ interfaces = self.netbox.list_interfaces_by_devices(list(device_mapping))
130
+ for interface in interfaces:
131
+ device_mapping[interface.device.id].interfaces.append(interface)
132
+ self._fill_interface_ipaddress(interfaces)
133
+
134
+ def _fill_interface_ipaddress(self, interfaces: list[InterfaceT]) -> None:
135
+ interface_mapping = {i.id: i for i in interfaces}
136
+ ips = self.netbox.list_ipaddr_by_ifaces(list(interface_mapping))
137
+ for ip in ips:
138
+ interface_mapping[ip.assigned_object_id].ip_addresses.append(ip)
139
+ self._fill_ipaddr_prefixes(ips)
140
+
141
+ def _fill_ipaddr_prefixes(self, ips: list[IpAddressT]) -> None:
142
+ ip_to_cidrs: Dict[str, str] = {ip.address: str(ip_interface(ip.address).network) for ip in ips}
143
+ prefixes = self.netbox.list_ipprefixes(list(ip_to_cidrs.values()))
144
+ cidr_to_prefix: Dict[str, PrefixT] = {x.prefix: x for x in prefixes}
145
+ for ip in ips:
146
+ cidr = ip_to_cidrs[ip.address]
147
+ ip.prefix = cidr_to_prefix.get(cidr)
148
+
149
+ def _record_device(self, device: NetboxDeviceT):
150
+ self._id_devices[device.id] = device
151
+ self._short_name_devices[device.name] = device
152
+ if not self.exact_host_filter:
153
+ short_name = device.name.split(".")[0]
154
+ self._short_name_devices[short_name] = device
155
+
156
+ def get_device(
157
+ self, obj_id, preload_neighbors=False, use_mesh=None,
158
+ **kwargs,
159
+ ) -> NetboxDeviceT:
160
+ if obj_id in self._id_devices:
161
+ return self._id_devices[obj_id]
162
+
163
+ device = self.netbox.get_device(obj_id)
164
+ self._record_device(device)
165
+ return device
166
+
167
+ def flush_perf(self):
168
+ pass
169
+
170
+ def search_connections(
171
+ self,
172
+ device: NetboxDeviceT,
173
+ neighbor: NetboxDeviceT,
174
+ ) -> list[tuple[InterfaceT, InterfaceT]]:
175
+ if device.storage is not self:
176
+ raise ValueError("device does not belong to this storage")
177
+ if neighbor.storage is not self:
178
+ raise ValueError("neighbor does not belong to this storage")
179
+ # both devices are NetboxDevice if they are loaded from this storage
180
+ res = []
181
+ for local_port in device.interfaces:
182
+ if not local_port.connected_endpoints:
183
+ continue
184
+ for endpoint in local_port.connected_endpoints:
185
+ if endpoint.device.id == neighbor.id:
186
+ for remote_port in neighbor.interfaces:
187
+ if remote_port.name == endpoint.name:
188
+ res.append((local_port, remote_port))
189
+ break
190
+ return res
191
+
192
+
193
+ def _match_query(exact_host_filter: bool, query: NetboxQuery, device_data: api_models.Device) -> bool:
194
+ """
195
+ Additional filtering after netbox due to limited backend logic.
196
+ """
197
+ if exact_host_filter:
198
+ return True # nothing to check, all filtering is done by netbox
199
+ hostnames = [subquery.strip() for subquery in query.globs if FIELD_VALUE_SEPARATOR not in subquery]
200
+ if not hostnames:
201
+ return True # no hostnames to check
202
+
203
+ short_name = device_data.name.split(".")[0]
204
+ for hostname in hostnames:
205
+ hostname = hostname.strip().rstrip(".")
206
+ if short_name == hostname or device_data.name == hostname:
207
+ return True
208
+ return False
209
+
210
+
211
+ def _hostname_dot_hack(raw_query: str) -> str:
212
+ # there is no proper way to lookup host by its hostname
213
+ # ie find "host" with fqdn "host.example.com"
214
+ # besides using name__ic (ie startswith)
215
+ # since there is no direct analogue for this field in netbox
216
+ # so we need to add a dot to hostnames (top-level fqdn part)
217
+ # so we would not receive devices with a common name prefix
218
+ def add_dot(raw_query: Any) -> Any:
219
+ if isinstance(raw_query, str) and "." not in raw_query:
220
+ raw_query = raw_query + "."
221
+ return raw_query
222
+
223
+ if isinstance(raw_query, list):
224
+ for i, name in enumerate(raw_query):
225
+ raw_query[i] = add_dot(name)
226
+ elif isinstance(raw_query, str):
227
+ raw_query = add_dot(raw_query)
228
+
229
+ return raw_query
230
+
231
+
232
+ def parse_glob(exact_host_filter: bool, query: NetboxQuery) -> dict[str, list[str]]:
233
+ query_groups = cast(dict[str, list[str]], query.parse_query())
234
+ if names := query_groups.pop("name", None):
235
+ if exact_host_filter:
236
+ query_groups["name__ie"] = names
237
+ else:
238
+ query_groups["name__ic"] = [_hostname_dot_hack(name) for name in names]
239
+ return query_groups
@@ -1,6 +1,6 @@
1
1
  from typing import Dict, Any, Optional
2
2
 
3
- from dataclass_rest.exceptions import ClientError
3
+ from dataclass_rest.exceptions import ClientError, ClientLibraryError
4
4
 
5
5
  from annet.storage import StorageProvider, Storage
6
6
  from annet.connectors import AdapterWithName, AdapterWithConfig, T
@@ -9,21 +9,34 @@ from .common.storage_opts import NetboxStorageOpts
9
9
  from .common.query import NetboxQuery
10
10
  from .v24.storage import NetboxStorageV24
11
11
  from .v37.storage import NetboxStorageV37
12
+ from .v41.storage import NetboxStorageV41
13
+ from .v42.storage import NetboxStorageV42
12
14
 
13
15
 
14
16
  def storage_factory(opts: NetboxStorageOpts) -> Storage:
15
17
  client = NetboxStatusClient(opts.url, opts.token, opts.insecure)
18
+ version_class_map = {
19
+ "3.7": NetboxStorageV37,
20
+ "4.0": NetboxStorageV41,
21
+ "4.1": NetboxStorageV41,
22
+ "4.2": NetboxStorageV42,
23
+ }
24
+
25
+ status = None
26
+
16
27
  try:
17
28
  status = client.status()
29
+ for version_prefix, storage_class in version_class_map.items():
30
+ if version_prefix == status.minor_version:
31
+ return storage_class(opts)
32
+
18
33
  except ClientError as e:
19
34
  if e.status_code == 404:
20
- # old version do not support status reqeust
21
35
  return NetboxStorageV24(opts)
22
- raise
23
- if status.netbox_version.startswith("3."):
24
- return NetboxStorageV37(opts)
25
- else:
26
- raise ValueError(f"Unsupported version: {status.netbox_version}")
36
+ else:
37
+ raise ValueError(f"Unsupported version: {status.netbox_version}")
38
+ except ClientLibraryError:
39
+ raise ValueError(f"Connection error: Unable to reach Netbox at URL: {opts.url}")
27
40
 
28
41
 
29
42
  class NetboxProvider(StorageProvider, AdapterWithName, AdapterWithConfig):