umnetdb-utils 0.1.5__py3-none-any.whl → 0.2.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.
umnetdb_utils/cli.py ADDED
@@ -0,0 +1,114 @@
1
+ import typer
2
+ from umnetdb_utils import UMnetdb
3
+ import inspect
4
+ from functools import wraps
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from typing import Callable, List
10
+ from typing_extensions import Annotated
11
+
12
+ app = typer.Typer()
13
+
14
+ def print_result(result:List[dict]):
15
+ """
16
+ Takes the result of a umnetdb call and prints it as a table
17
+ """
18
+ if len(result) == 0:
19
+ print("No results found")
20
+ return
21
+
22
+ if isinstance(result, dict):
23
+ result = [result]
24
+
25
+ # instantiate table with columns based on entry dict keys
26
+ table = Table(*result[0].keys())
27
+ for row in result:
28
+ table.add_row(*[str(i) for i in row.values()])
29
+
30
+ console = Console()
31
+ console.print(table)
32
+
33
+
34
+ def command_generator(method_name:str, method:Callable):
35
+ """
36
+ Generates a typer command function for an arbitrary method
37
+ in the umnetdb class. The generated function opens a connection with
38
+ the database, executes the method, and prints out the results.
39
+
40
+ Note that the docstring of each method is interrogated to generate
41
+ help text for each typer command.
42
+
43
+ :method_name: The name of the method
44
+ :method: The method itself
45
+ """
46
+
47
+ # first we're going to tease out the 'help' portions of the method
48
+ # from the docstring.
49
+ docstr = method.__doc__
50
+ docstr_parts = docstr.split("\n:")
51
+
52
+ # first section of the docstring is always a generic 'this is what the method does'.
53
+ cmd_help = docstr_parts.pop(0)
54
+
55
+ # next sections are details on the specific arguments that we want to pass to typer as
56
+ # special annotated type hints
57
+ arg_help = {}
58
+ for arg_str in docstr_parts:
59
+ if ":" in arg_str:
60
+ arg, help = arg_str.split(":")
61
+ arg_help[arg] = help.strip()
62
+
63
+ sig = inspect.signature(method)
64
+
65
+ # going through the method's arguments and augmenting the 'help' section for each one
66
+ # from the docstring if applicable
67
+ new_params = []
68
+ for p_name, p in sig.parameters.items():
69
+
70
+ # need to skip self
71
+ if p_name == "self":
72
+ continue
73
+
74
+ # if there wasn't any helper text then just append the parameter as is
75
+ if p_name not in arg_help:
76
+ new_params.append(p)
77
+ continue
78
+
79
+ # params without default values should be typer 'arguments'
80
+ if p.default == inspect._empty:
81
+ new_params.append(p.replace(annotation=Annotated[p.annotation, typer.Argument(help=arg_help[p_name])]))
82
+ continue
83
+
84
+ # params with default values should be typer 'options'
85
+ new_params.append(p.replace(annotation=Annotated[p.annotation, typer.Option(help=arg_help[p_name])]))
86
+
87
+ new_sig = sig.replace(parameters=new_params)
88
+
89
+
90
+ # new munged function based on the origional method, with a new signature
91
+ # and docstring for typer
92
+ @wraps(method)
93
+ def wrapper(*args, **kwargs):
94
+ with UMnetdb() as db:
95
+ result = getattr(db, method_name)(*args, **kwargs)
96
+ print_result(result)
97
+
98
+ wrapper.__signature__ = new_sig
99
+ wrapper.__doc__ = cmd_help
100
+
101
+ return wrapper
102
+
103
+
104
+ def main():
105
+ for f_name,f in UMnetdb.__dict__.items():
106
+ if not(f_name.startswith("_")) and callable(f):
107
+ app.command()(command_generator(f_name, f))
108
+
109
+ app()
110
+
111
+ if __name__ == "__main__":
112
+ main()
113
+
114
+
umnetdb_utils/umnetdb.py CHANGED
@@ -1,16 +1,18 @@
1
- from typing import List
1
+ from typing import List, Optional
2
2
  import logging
3
3
  import re
4
+ import ipaddress
5
+ from copy import deepcopy
4
6
 
5
-
6
- from sqlalchemy import text
7
7
  from .base import UMnetdbBase
8
+ from .utils import is_ip_address, Packet, Hop, Path, LOCAL_PROTOCOLS
8
9
 
10
+ logger = logging.getLogger(__name__)
9
11
 
10
12
  class UMnetdb(UMnetdbBase):
11
13
  URL = "postgresql+psycopg://{UMNETDB_USER}:{UMNETDB_PASSWORD}@wintermute.umnet.umich.edu/umnetdb"
12
14
 
13
- def get_neighbors(self, device: str, known_devices_only: bool = True) -> List[dict]:
15
+ def get_neighbors(self, device: str, known_devices_only: bool = True, interface:Optional[str]=None) -> List[dict]:
14
16
  """
15
17
  Gets a list of the neighbors of a particular device. If the port
16
18
  has a parent in the LAG table that is included as well.
@@ -20,9 +22,12 @@ class UMnetdb(UMnetdbBase):
20
22
 
21
23
  Setting 'known_devices_only' to true only returns neighbors that are found
22
24
  in umnet_db's device table. Setting it to false will return all lldp neighbors
23
- and will include things like phones and APs
25
+ and will include things like phones and APs.
24
26
 
25
27
  Returns results as a list of dictionary entries keyed on column names.
28
+ :device: Name of the device
29
+ :known_devices_only: If set to true, will only return neighbors found in umnetdb's device table.
30
+ :interface: If supplied, restrict to only find neighbors on a particular interface on the device
26
31
  """
27
32
 
28
33
  if known_devices_only:
@@ -55,6 +60,8 @@ class UMnetdb(UMnetdbBase):
55
60
  table = "neighbor n"
56
61
 
57
62
  where = [f"n.device='{device}'"]
63
+ if interface:
64
+ where.append(f"n.port='{interface}'")
58
65
 
59
66
  query = self._build_select(select, table, joins, where)
60
67
 
@@ -64,9 +71,11 @@ class UMnetdb(UMnetdbBase):
64
71
  """
65
72
  Gets all devices within a DL zone based on walking the 'neighbors'
66
73
  table.
67
-
74
+
68
75
  For each device, the following attributes are returned:
69
76
  "name", "ip", "version", "vendor", "model", "serial"
77
+
78
+ :zone_name: Name of the DL zone
70
79
  """
71
80
  device_cols = ["name", "ip", "version", "vendor", "model", "serial"]
72
81
 
@@ -90,13 +99,12 @@ class UMnetdb(UMnetdbBase):
90
99
  while len(todo) != 0:
91
100
  device = todo.pop()
92
101
 
93
- print(f"Processing device {device}")
94
102
  # note that by default this method only returns neighbors in the 'device' table,
95
103
  # any others are ignored
96
104
  neighs = self.get_neighbors(device)
97
105
  devices_by_name[device]["neighbors"] = {}
98
106
  for neigh in neighs:
99
- print(f"EVALUATING {neigh}")
107
+
100
108
  # only want 'd- or 'dl-' or 's-' devices, and we don't want out of band devices
101
109
  if re.match(r"(dl?-|s-)", neigh["remote_device"]) and not re.match(
102
110
  r"s-oob-", neigh["remote_device"]
@@ -120,3 +128,291 @@ class UMnetdb(UMnetdbBase):
120
128
  todo.append(neigh_device["name"])
121
129
 
122
130
  return list(devices_by_name.values())
131
+
132
+
133
+ def l3info(self, search_str:str, detail:bool=False, num_results:int=10, exact:bool=False)->list[dict]:
134
+ """
135
+ Does a search of the umnetdb ip_interface table.
136
+
137
+ :search_str: Can be an IP address, 'VlanX', or a full or partial netname
138
+ :detail: Adds admin/oper status, primary/secondary, helpers, and timestamps to output.
139
+ :num_results: Limits number of results printed out.
140
+ :exact: Only return exact matches, either for IP addresses or for string matches.
141
+ """
142
+
143
+ cols = ["device", "ip_address", "interface", "description", "vrf"]
144
+ if detail:
145
+ cols.extend(["admin_up", "oper_up", "secondary", "helpers", "first_seen", "last_updated"])
146
+
147
+ # 'is contained within' IP search - reference:
148
+ # https://www.postgresql.org/docs/9.3/functions-net.html
149
+
150
+ # VlanX based searches are always 'exact'
151
+ if re.match(r"Vlan\d+$", search_str):
152
+ where = [f"interface = '{search_str}'"]
153
+
154
+ # ip or description based searches can be 'exact' or inexact
155
+ elif exact:
156
+ if is_ip_address(search_str):
157
+ where = [f"host(ip_address) = '{search_str}'"]
158
+ else:
159
+ where = [f"description = '{search_str}'"]
160
+
161
+ else:
162
+ if is_ip_address(search_str):
163
+ where = [f"ip_address >>= '{search_str}'"]
164
+ else:
165
+ where = [f"description like '{search_str}%'"]
166
+
167
+ # removing IPs assigned to mgmt interfaces
168
+ where.append("vrf != 'management'")
169
+
170
+ query = self._build_select(
171
+ select=cols,
172
+ table="ip_interface",
173
+ where=where,
174
+ limit=num_results,
175
+ )
176
+ return self.execute(query)
177
+
178
+
179
+ def route(self, router:str, prefix:str, vrf:str, resolve_nh:bool=True, details:bool=False) -> list[dict]:
180
+ """
181
+ Does an lpm query on a particular router for a particular prefix
182
+ in a particular VRF.
183
+
184
+ :router: Name of the router to query
185
+ :prefix: Prefix to query for
186
+ :vrf: Name of the VRF to query against
187
+ :resolve_nh: If no nh_interface is present in the database, recursively resolve for it.
188
+ :details: Set to true to get output of all columns in the route table.
189
+ """
190
+ if details:
191
+ cols = ["*"]
192
+ else:
193
+ cols = ["device", "vrf", "prefix", "nh_ip", "nh_table", "nh_interface"]
194
+
195
+ lpms = self.lpm_query(router, prefix, vrf, columns=cols)
196
+
197
+ if not lpms:
198
+ return []
199
+
200
+ if not resolve_nh:
201
+ return lpms
202
+
203
+ resolved_results = []
204
+ for route in lpms:
205
+ if route["nh_interface"]:
206
+ resolved_results.append(route)
207
+ else:
208
+ self._resolve_nh(route, resolved_results, 0)
209
+
210
+ return resolved_results
211
+
212
+ def lpm_query(self, router:str, prefix:str, vrf:str, columns:Optional[str]=None)->list[dict]:
213
+ """
214
+ Does an lpm query against a particular router, prefix, and vrf. Optionally specify
215
+ which columns you want to limit the query to.
216
+ """
217
+
218
+ select = columns if columns else ["*"]
219
+
220
+ query = self._build_select(
221
+ select=select,
222
+ table="route",
223
+ where=[f"device='{router}'", f"prefix >>= '{prefix}'", f"vrf='{vrf}'"],
224
+ order_by="prefix"
225
+ )
226
+
227
+ result = self.execute(query)
228
+
229
+ if not result:
230
+ return None
231
+
232
+ if len(result) == 1:
233
+ return result
234
+
235
+ # peeling the longest matching prefix of the end of the results, which
236
+ # are ordered by ascending prefixlength
237
+ lpm_results = [result.pop()]
238
+
239
+ # finding any other equivalent lpms. As soon as we run into one that
240
+ # doesn't match we know we're done.
241
+ result.reverse()
242
+ for r in result:
243
+ if r["prefix"] == lpm_results[0]["prefix"]:
244
+ lpm_results.append(r)
245
+ else:
246
+ break
247
+
248
+ return lpm_results
249
+
250
+ def mpls_label(self, router:str, label:str) -> list[dict]:
251
+ """
252
+ Looks up a particular label for a particular device in the mpls table.
253
+ :router: device name
254
+ :label: label value
255
+ """
256
+ query = self._build_select(
257
+ select=["*"],
258
+ table="mpls",
259
+ where=[f"device='{router}'", f"in_label='{label}'"]
260
+ )
261
+ return self.execute(query)
262
+
263
+ def vni(self, router:str, vni:int) -> dict:
264
+ """
265
+ Looks up a particular vni on the router and returns
266
+ the VRF or vlan_id it's associated with
267
+ """
268
+
269
+ query = self._build_select(
270
+ select=["*"],
271
+ table="vni",
272
+ where=[f"device='{router}'", f"vni='{vni}'"]
273
+ )
274
+ return self.execute(query, fetch_one=True)
275
+
276
+
277
+ def _resolve_nh(self, route:dict, resolved_routes:list[dict], depth:int) -> dict:
278
+ """
279
+ Recursively resolves next hop of a route till we find a nh interface.
280
+ If we hit a recursion depth of 4 then an exception is thrown - the max depth on our
281
+ network I've seen is like 2 (for a static route to an indirect next hop)
282
+ """
283
+ if route["nh_interface"]:
284
+ return route
285
+
286
+ depth += 1
287
+ if depth == 4:
288
+ raise RecursionError(f"Reached max recursion depth of 4 trying to resolve route {route}")
289
+
290
+ nh_ip = route["nh_ip"]
291
+ nh_table = route["nh_table"]
292
+ router = route["device"]
293
+
294
+ nh_routes = self.lpm_query(router, nh_ip, nh_table)
295
+
296
+ for nh_route in nh_routes:
297
+ r = self._resolve_nh(nh_route, resolved_routes, depth)
298
+ resolved_route = deepcopy(route)
299
+ resolved_route["nh_interface"] = r["nh_interface"]
300
+ if r["mpls_label"] and resolved_route["mpls_label"] is not None:
301
+ resolved_route["mpls_label"].extend(r["mpls_label"])
302
+ elif r["mpls_label"]:
303
+ resolved_route["mpls_label"] = r["mpls_label"]
304
+ resolved_route["nh_ip"] = r["nh_ip"]
305
+ resolved_routes.append(resolved_route)
306
+
307
+
308
+ def get_all_paths(self, src_ip:str, dst_ip:str):
309
+ """
310
+ Traces the path between a particular source and destination IP
311
+ :src_ip: A source IP address, must be somewhere on our network
312
+ :dst_ip: A destination IP address, does not have to be on our network.
313
+ """
314
+ for ip_name, ip in [("source", src_ip), ("destination", dst_ip)]:
315
+ if not is_ip_address(ip):
316
+ raise ValueError(f"invalid {ip_name} IP address {ip}")
317
+
318
+ src_l3info = self.l3info(src_ip, num_results=1)
319
+ if not src_l3info:
320
+ raise ValueError(f"Could not find where {src_ip} is routed")
321
+
322
+ packet = Packet(dst_ip=ipaddress.ip_address(dst_ip))
323
+ hop = Hop(src_l3info[0]["device"], src_l3info[0]["vrf"], src_l3info[0]["interface"], packet)
324
+ path = Path(hop)
325
+
326
+ self._walk_path(path, hop, hop.router, hop.vrf, packet)
327
+
328
+ return path.get_path()
329
+
330
+
331
+ def _walk_path(self, path:Path, curr_hop:Hop, nh_router:str, nh_table:str, packet:Packet):
332
+
333
+ logger.debug(f"\n******* walking path - current hop: {curr_hop}, nh_route: {nh_router}, nh_table {nh_table} *******")
334
+ logger.debug(f"Known hops: {path.hops.keys()}")
335
+
336
+ # mpls-based lookup
337
+ if packet.label_stack:
338
+ logger.debug("")
339
+ routes = self.mpls_label(router=nh_router, label=packet.label_stack[-1])
340
+
341
+ # otherwise we want to do an ip based lokup
342
+ else:
343
+ routes = self.route(router=nh_router, prefix=packet.dst_ip, vrf=nh_table, details=True)
344
+
345
+ if not routes:
346
+ raise ValueError(f"No route found for {curr_hop}")
347
+
348
+ for idx, route in zip(range(1,len(routes)+1), routes):
349
+
350
+ logger.debug(f"*** Processing route {idx} of {len(routes)}:{route} ***")
351
+ nh_router = None
352
+ nh_table = None
353
+ new_packet = deepcopy(packet)
354
+
355
+ # if the packet is not encapsulated and the route is local, we have reached our destination
356
+ if not packet.is_encapped() and route.get("protocol") in LOCAL_PROTOCOLS:
357
+ logger.debug(f"Destination reached at {curr_hop}")
358
+ final_hop = Hop(route["device"], vrf=route["vrf"], interface=route["nh_interface"], packet=new_packet)
359
+ path.add_hop(curr_hop, final_hop)
360
+ continue
361
+
362
+ # VXLAN decap - requires local lookup in the vrf that maps to the packet's VNI
363
+ if route.get("protocol") == "direct" and packet.vni and curr_hop.router == route["device"]:
364
+ vni = self.vni(router=route["device"], vni=packet.vni)
365
+ new_packet.vxlan_decap()
366
+ nh_table = vni["vrf"]
367
+ nh_router = route["device"]
368
+ logger.debug(f"vxlan-decapping packet, new packet {new_packet}")
369
+
370
+ # MPLS decap - requires local lookup in the vrf indicated by 'nh_interface' field
371
+ # of this aggregate route
372
+ elif route.get("aggregate"):
373
+ new_packet.mpls_pop()
374
+ nh_table = route["nh_interface"]
375
+ nh_router = route["device"]
376
+ logger.debug(f"mpls aggregate route, new packet {new_packet}")
377
+
378
+ # VXLAN encap - requires local lookup of encapped packet in the nh table
379
+ elif route.get("vxlan_vni") and not packet.is_encapped():
380
+ new_packet.vxlan_encap(route["vxlan_vni"], route["vxlan_endpoint"])
381
+ logger.debug(f"vxlan-encapping packet, new packet {new_packet}")
382
+
383
+ # MPLS encap for an IP route. Resolved routes will have both transport and vrf
384
+ # labels if applicable - this 'push' will add both to the packet.
385
+ elif route.get("mpls_label"):
386
+ new_packet.mpls_push(route["mpls_label"])
387
+ logger.debug(f"mpls-encapping packet, new packet {new_packet}")
388
+
389
+ # MPLS route - note in our environment we don't have anything that requires a
390
+ # push on an already-labeled packet (!)
391
+ elif route.get("in_label"):
392
+
393
+ if route["out_label"] == ["pop"]:
394
+ new_packet.mpls_pop()
395
+ else:
396
+ new_packet.mpls_swap(route["out_label"])
397
+ logger.debug(f"mpls push or swap, new packet {new_packet}")
398
+
399
+ # if the next hop isn't local we need to figure out which router it's on. In our environment
400
+ # the easiest way to do that is to use l3info against the nh_ip of the route.
401
+ if not nh_router and route["nh_ip"]:
402
+
403
+ l3i_router = self.l3info(str(route["nh_ip"]), exact=True)
404
+ if l3i_router:
405
+ nh_router = l3i_router[0]["device"]
406
+ nh_table = nh_table if nh_table else l3i_router[0]["vrf"]
407
+ logger.debug(f"found router {nh_router} for nh ip {route['nh_ip']}")
408
+
409
+ if not nh_router:
410
+ raise ValueError(f"Unknown next hop for {curr_hop} route {route}")
411
+
412
+ # add this hop to our path and if it's a new hop, keep waking
413
+ new_hop = Hop(route["device"], vrf=route.get("nh_table", "default"), interface=route["nh_interface"], packet=new_packet)
414
+ logger.debug(f"new hop generated: {new_hop}")
415
+ new_path = path.add_hop(curr_hop, new_hop)
416
+ if new_path:
417
+ logger.debug("New path detected - still walking")
418
+ self._walk_path(path, new_hop, nh_router, nh_table, new_packet)
umnetdb_utils/utils.py CHANGED
@@ -1,8 +1,26 @@
1
1
  import ipaddress
2
2
  import re
3
3
 
4
+ from dataclasses import dataclass, field
5
+ from copy import copy, deepcopy
4
6
 
5
- def is_ip_address(input_str, version=None):
7
+ from typing import Optional, Union, List, Dict
8
+
9
+ type IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
10
+
11
+ LOCAL_PROTOCOLS = [
12
+ "Access-internal",
13
+ "am",
14
+ "connected",
15
+ "direct",
16
+ "Direct",
17
+ "hmm",
18
+ "local",
19
+ "VPN",
20
+ "vrrpv3",
21
+ ]
22
+
23
+ def is_ip_address(input_str:str, version:Optional[int]=None):
6
24
  try:
7
25
  ip = ipaddress.ip_address(input_str)
8
26
  except ValueError:
@@ -14,7 +32,7 @@ def is_ip_address(input_str, version=None):
14
32
  return True
15
33
 
16
34
 
17
- def is_ip_network(input_str, version=None):
35
+ def is_ip_network(input_str:str, version:Optional[int]=None):
18
36
  # First check that this is a valid IP or network
19
37
  try:
20
38
  net = ipaddress.ip_network(input_str)
@@ -27,7 +45,7 @@ def is_ip_network(input_str, version=None):
27
45
  return True
28
46
 
29
47
 
30
- def is_mac_address(input_str):
48
+ def is_mac_address(input_str:str):
31
49
  """
32
50
  Validates the input string as a mac address. Valid formats are
33
51
  XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, XXXX.XXXX.XXXX
@@ -40,3 +58,266 @@ def is_mac_address(input_str):
40
58
  return True
41
59
 
42
60
  return False
61
+
62
+
63
+ @dataclass(order=True)
64
+ class Packet:
65
+ """
66
+ Modeling a packet as it traverses the network
67
+ """
68
+
69
+ dst_ip: IPAddress
70
+ #ttl: int = 255
71
+ inner_dst_ip: Optional[IPAddress] = None
72
+ label_stack: Optional[list[int]] = None
73
+ vni: Optional[int] = None
74
+
75
+ def __str__(self):
76
+ if self.label_stack:
77
+ return f"MPLS: {self.label_stack}"
78
+ if self.vni:
79
+ return f"VXLAN: {self.dst_ip}:{self.vni}"
80
+ return f"{self.dst_ip}"
81
+
82
+ # def decrement_ttl(self):
83
+ # new_packet = copy(self)
84
+ # new_packet.ttl -= 1
85
+ # return new_packet
86
+
87
+ def vxlan_encap(self, vni:int, tunnel_destination: ipaddress.IPv4Address) -> "Packet":
88
+ """
89
+ Return a copy of the existing packet, but with a vxlan encap
90
+ """
91
+ if self.vni or self.inner_dst_ip or self.label_stack:
92
+ raise ValueError(f"Can't encapsulate an already-encapsulated packet: {self}")
93
+
94
+ # new_packet = deepcopy(self)
95
+ # new_packet.inner_dst_ip = self.dst_ip
96
+ # new_packet.vni = vni
97
+ # new_packet.dst_ip = tunnel_destination
98
+
99
+ # return new_packet
100
+ self.inner_dst_ip = self.dst_ip
101
+ self.vni = vni
102
+ self.dst_ip = tunnel_destination
103
+
104
+ def vxlan_decap(self) -> "Packet":
105
+ """
106
+ Return a copy of the existing packet, but with a vxlan decap
107
+ """
108
+ if not self.vni or not self.inner_dst_ip or self.label_stack:
109
+ raise ValueError(f"Can't decap a non-VXLAN packet: {self}")
110
+
111
+ # new_packet = deepcopy(self)
112
+ # new_packet.dst_ip = self.inner_dst_ip
113
+ # new_packet.vni = None
114
+ # new_packet.inner_dst_ip = None
115
+
116
+ # return new_packet
117
+ self.dst_ip = self.inner_dst_ip
118
+ self.vni = None
119
+ self.inner_dst_ip = None
120
+
121
+ def mpls_push(self, label:Union[int,list[int]]) -> "Packet":
122
+ """
123
+ Retrun a copy of the existing packet but with MPLS labels pushed on the stack
124
+ """
125
+ if self.vni or self.inner_dst_ip:
126
+ raise ValueError(f"Can't do mpls on a VXLAN packet: {self}")
127
+
128
+ # new_packet = deepcopy(self)
129
+ # if not new_packet.label_stack:
130
+ # new_packet.label_stack = []
131
+
132
+ # if isinstance(label, int):
133
+ # new_packet.label_stack.append(label)
134
+ # else:
135
+ # new_packet.label_stack.extend(label)
136
+
137
+ # return new_packet
138
+ if not self.label_stack:
139
+ self.label_stack = []
140
+
141
+ if isinstance(label, list):
142
+ self.label_stack.extend(label)
143
+ else:
144
+ self.label_stack.append(label)
145
+
146
+ def mpls_pop(self, num_pops:int=1) -> "Packet":
147
+ """
148
+ Return a copy of the existing packet but with MPLS label(s) popped
149
+ """
150
+ if not self.label_stack:
151
+ raise ValueError(f"Can't pop from an empty label stack!: {self}")
152
+ if len(self.label_stack) < num_pops:
153
+ raise ValueError(f"Can't pop {num_pops} labels from packet: {self}")
154
+
155
+ # new_packet = copy(self)
156
+ # for _ in range(num_pops):
157
+ # new_packet.label_stack.pop()
158
+
159
+ # return new_packet
160
+
161
+ for _ in range(num_pops):
162
+ self.label_stack.pop()
163
+
164
+
165
+ def mpls_swap(self, label:Union[int,list[int]]) -> "Packet":
166
+ """
167
+ Rerturn a copy of the existing packet but with MPLS label swap
168
+ """
169
+ if not self.label_stack:
170
+ raise ValueError(f"Can't pop from an empty label stack!: {self}")
171
+
172
+ # new_packet = copy(self)
173
+ # new_packet.label_stack.pop()
174
+ # if isinstance(label, int):
175
+ # new_packet.label_stack.append(label)
176
+
177
+ # # inconsistency in mpls table where this really should be a 'pop' but
178
+ # # it shows up as a single blank entry in a list.
179
+ # elif label == [""]:
180
+ # new_packet.label_stack = []
181
+ # else:
182
+ # new_packet.label_stack.extend(label)
183
+
184
+ # return new_packet
185
+ self.label_stack.pop()
186
+ if label == [""]:
187
+ self.lable_stack = []
188
+ elif isinstance(label, list):
189
+ self.label_stack.extend(label)
190
+ else:
191
+ self.label_stack.append(label)
192
+
193
+ def is_encapped(self):
194
+ return bool(self.vni or self.label_stack)
195
+
196
+
197
+ @dataclass(order=True)
198
+ class Hop:
199
+ """
200
+ A hop along the path and the corresponding packet at that location
201
+ """
202
+ router: str
203
+ vrf: str
204
+ interface: str
205
+ packet: Packet
206
+
207
+ _parent: "Hop" = field(init=False, compare=False, default=None)
208
+ _children: List["Hop"] = field(init=False, compare=False, default_factory=list)
209
+
210
+ def __str__(self):
211
+ vrf = "" if self.vrf == "default" else f" VRF {self.vrf}"
212
+ return f"{self.router}{vrf} {self.interface}, Packet: {self.packet}"
213
+
214
+ def copy(self):
215
+ return deepcopy(self)
216
+
217
+ @classmethod
218
+ def unknown(self, packet=Packet):
219
+ """
220
+ Returns an "unknown" hop for a specific packet
221
+ """
222
+ return Hop("","","", packet)
223
+
224
+ def is_unknown(self):
225
+ return self.router == ""
226
+
227
+ def __hash__(self):
228
+ return(hash(str(self)))
229
+
230
+ def add_next_hop(self, other:"Hop"):
231
+ """
232
+ creates parent/child relationship between this hop and another one
233
+ """
234
+ self._children.append(other)
235
+ other._parent = self
236
+
237
+
238
+
239
+ class Path:
240
+ """
241
+ Keeps track of all our paths
242
+ """
243
+ def __init__(self, first_hop:Hop):
244
+
245
+ self.first_hop = first_hop
246
+ self.hops = {str(first_hop): first_hop}
247
+
248
+ def add_hop(self, curr_hop:Hop, next_hop:Hop) -> bool:
249
+ """
250
+ Adds a hop to our current hop. If this is a new hop,
251
+ which means we're on a new path, returns true. Otherwise
252
+ returns false to indicate we don't have to walk this path
253
+ since it's a duplicate of another.
254
+ """
255
+
256
+ # if we've seen this hop already we will check for loops.
257
+ if str(next_hop) in self.hops:
258
+ new_path = False
259
+
260
+ # parent = self.curr_hop._parent
261
+ # while parent:
262
+ # if parent == next_hop:
263
+ # raise ValueError(f"Loop detected for {self.curr_hop} -> {next_hop}")
264
+ # parent = parent._parent
265
+
266
+ self.hops[str(next_hop)]
267
+
268
+ else:
269
+ new_path = True
270
+ self.hops[str(next_hop)] = next_hop
271
+
272
+ curr_hop.add_next_hop(next_hop)
273
+ return new_path
274
+
275
+
276
+
277
+ def walk_path(self) -> Dict[int, List[Hop]]:
278
+ """
279
+ 'Flattens' path from first hop to last into a dict
280
+ of list of hops keyed on hop number.
281
+ """
282
+ hop_num = 1
283
+ result = { hop_num: {self.first_hop}}
284
+ self._walk_path(self.first_hop, hop_num, result)
285
+ return result
286
+
287
+
288
+ def _walk_path(self, curr_hop, hop_num, result):
289
+ hop_num +=1
290
+ for child in curr_hop._children:
291
+ if hop_num not in result:
292
+ result[hop_num] = set()
293
+ result[hop_num].add(child)
294
+ self._walk_path(child, hop_num, result)
295
+
296
+
297
+ def print_path(self):
298
+ """
299
+ Prints out the path, hop by hop
300
+ """
301
+ for hop_num, hops in self.walk_path().items():
302
+ hop_list = list(hops)
303
+ hop_list.sort()
304
+ print(f"***** hop {hop_num} *****")
305
+ for hop in hop_list:
306
+ print(f"\t{hop}")
307
+
308
+
309
+ def get_path(self) -> list[dict]:
310
+ """
311
+ Returns hops as a list of dicts - for very basic displaying in
312
+ a table. In the future want to beef up the cli script
313
+ so that it can do nested tables, which is really
314
+ what we need for this.
315
+ """
316
+ result = []
317
+ for hop_num, hops in self.walk_path().items():
318
+ hop_list = list(hops)
319
+ hop_list.sort()
320
+ hop_str = "\n".join([str(h) for h in hop_list]) + "\n"
321
+ result.append({'hop_num':hop_num, 'hops': hop_str})
322
+
323
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: umnetdb-utils
3
- Version: 0.1.5
3
+ Version: 0.2.0
4
4
  Summary: Helper classes for querying UMnet databases
5
5
  License: MIT
6
6
  Author: Amy Liebowitz
@@ -16,6 +16,7 @@ Requires-Dist: oracledb (>=3.1.0,<4.0.0)
16
16
  Requires-Dist: psycopg[binary] (>=3.2.9,<4.0.0)
17
17
  Requires-Dist: python-decouple (>=3.8,<4.0)
18
18
  Requires-Dist: sqlalchemy (>=2.0.41,<3.0.0)
19
+ Requires-Dist: typer (>=0.16.0,<0.17.0)
19
20
  Description-Content-Type: text/markdown
20
21
 
21
22
  # umnetdb-utils
@@ -0,0 +1,12 @@
1
+ umnetdb_utils/__init__.py,sha256=YrT7drJ2MlU287z4--8EQI3fKLQZBlVxaCacU1yoDtg,132
2
+ umnetdb_utils/base.py,sha256=LWVhrbShhuYekXZxwvhhVUkH6DHd_wnJ2QZ_zw3fKqM,6139
3
+ umnetdb_utils/cli.py,sha256=tq_XxGrCWJacP3tzxTULPHeuvbh1AU_GHXHY1jkI4-U,3349
4
+ umnetdb_utils/umnetdb.py,sha256=bzMwItOfCgpKhCGwmghqAyp9gKvckGsUIGvSJC1iUho,17122
5
+ umnetdb_utils/umnetdisco.py,sha256=D9MiF_QrYznYL_ozLUmD1ASzkGDs1jqZ_XksNV7wW3o,8018
6
+ umnetdb_utils/umnetequip.py,sha256=Nnh_YCcqGpYRp2KsS8qbAGJ9AgTeYB4umeYeP9_B-6A,5440
7
+ umnetdb_utils/umnetinfo.py,sha256=vqFvLbIu7Wu650RTqzaELpbKhq6y91fgBXM5Q_elRl4,18443
8
+ umnetdb_utils/utils.py,sha256=kAVLjX4UvN5VdllYPN2t-q386iDjsZZ-Bp8G_ZTchbI,9418
9
+ umnetdb_utils-0.2.0.dist-info/METADATA,sha256=kkv7rHDme5K2VXBfe24KFg6qBOsezgLZc8XYku5dpwU,1595
10
+ umnetdb_utils-0.2.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
11
+ umnetdb_utils-0.2.0.dist-info/entry_points.txt,sha256=nNmxVRlYLaeepjDKNCyARBF3d8igHtZGTVA_zoglevI,50
12
+ umnetdb_utils-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ umnetdb=umnetdb_utils.cli:main
3
+
@@ -1,10 +0,0 @@
1
- umnetdb_utils/__init__.py,sha256=YrT7drJ2MlU287z4--8EQI3fKLQZBlVxaCacU1yoDtg,132
2
- umnetdb_utils/base.py,sha256=LWVhrbShhuYekXZxwvhhVUkH6DHd_wnJ2QZ_zw3fKqM,6139
3
- umnetdb_utils/umnetdb.py,sha256=jHifl5eJXzye28mjLnfn7A7zwN2Y4YuKgrFvjV5MQBI,4953
4
- umnetdb_utils/umnetdisco.py,sha256=D9MiF_QrYznYL_ozLUmD1ASzkGDs1jqZ_XksNV7wW3o,8018
5
- umnetdb_utils/umnetequip.py,sha256=Nnh_YCcqGpYRp2KsS8qbAGJ9AgTeYB4umeYeP9_B-6A,5440
6
- umnetdb_utils/umnetinfo.py,sha256=vqFvLbIu7Wu650RTqzaELpbKhq6y91fgBXM5Q_elRl4,18443
7
- umnetdb_utils/utils.py,sha256=VLxfBKq1iOQVxTuySvxV4goE_jtX4jCshjf9a5ky1vI,989
8
- umnetdb_utils-0.1.5.dist-info/METADATA,sha256=fPpx67_VnM0Xp6JGk13mRrxOte8wPAkau0O0PtM9vW0,1555
9
- umnetdb_utils-0.1.5.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
10
- umnetdb_utils-0.1.5.dist-info/RECORD,,