Add topology v3
This is a re-implemented topology, which should be more compatible with OSPF's idea of a network system.styling
parent
ac3508727d
commit
610198ea6b
@ -0,0 +1,313 @@
|
||||
"""Topology, version 3
|
||||
|
||||
We now adhere to the RFCs (mostly RFC 2328) and represent the topology in the
|
||||
same way, that is, as a directed multigraph on the union of all the networks and routers.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from socket import AddressFamily
|
||||
from collections import defaultdict
|
||||
from ipaddress import IPv4Network, IPv6Network
|
||||
|
||||
class TopologyV3:
|
||||
"""This class represents the topology, that is the graph itself.
|
||||
|
||||
We implement a DAG of ancestors, so that a joined topology can be created.
|
||||
The class :class:VertexFinder is used to keep track about which vertices
|
||||
are present in which topologies. The ancestor topologies are named, so that
|
||||
they are easy to distinguish. At present, we expect that the only use of
|
||||
this mechanism is to join a "reference" topology to the "current" one,
|
||||
using these exact string to name the ancestors. However, the mechanism is
|
||||
not restricted to this use case.
|
||||
|
||||
Once a topology is an ancestor to another, it must not be changed.
|
||||
Therefore, we implement a :attr:frozen flag, which specifies exactly
|
||||
that. Therefore, the topology must only be modified using the methods, not
|
||||
directly; all the methods check whether the topology is frozen. (Since this
|
||||
is Python, it would be impractical to make the frozen topology actually
|
||||
immutable. We trust the developers to be cooperative and not try to fix
|
||||
this until the current state is shown to be prone to accidental
|
||||
modification.)
|
||||
|
||||
Vertices are stored in a dictionary by their :class:`VertexID`'s, so that
|
||||
we can quickly both iterate over them and check their presence. The
|
||||
:class:Vertex objects are not hashable."""
|
||||
def __init__(self):
|
||||
self.frozen = False
|
||||
self.ancestors: dict[str, TopologyV3] = dict()
|
||||
self.vertices: dict[VertexID, Vertex] = dict()
|
||||
self.edges: set[Edge] = set()
|
||||
|
||||
def freeze(self):
|
||||
self.frozen = True
|
||||
|
||||
def add_ancestor(self, label, ancestor):
|
||||
if label in self.ancestors: raise KeyError('Ancestor with this label already present')
|
||||
if not ancestor.frozen: raise ValueError('Ancestor must be a frozen topology')
|
||||
self.ancestors[label] = ancestor
|
||||
for v in ancestor.vertices.values():
|
||||
self.add_vertex(v)
|
||||
for e in ancestor.edges:
|
||||
self.add_edge(e)
|
||||
def add_vertex(self, vertex):
|
||||
"""When adding a vertex, the original vertex may already be present. In
|
||||
that case, we check if the details match, and if not, we unset the
|
||||
specific detail. (Currently, only detail to be checked is the type,
|
||||
since when the ID differs, we create two vertices).
|
||||
|
||||
When adding a vertex from different topology, we must not modify it, so
|
||||
we create our own copy."""
|
||||
if self.frozen: raise ValueError('Cannot add vertex to frozen topology.')
|
||||
if vertex.topology is not None and vertex.topology is not self:
|
||||
new_vertex = Vertex(id=vertex.id, topology=self, type=vertex.type)
|
||||
else:
|
||||
# Not a vertex from different topology, so we can reuse.
|
||||
new_vertex = vertex
|
||||
new_vertex.topology = self
|
||||
|
||||
cur_vertex = self.vertices.get(new_vertex.id, None)
|
||||
if cur_vertex is not None:
|
||||
# Check consistency
|
||||
if new_vertex.type != cur_vertex.type: new_vertex.type = None
|
||||
# Overwriting the old vertex is fine, new_vertex has better info.
|
||||
self.vertices[new_vertex.id] = new_vertex
|
||||
|
||||
def add_edge(self, edge):
|
||||
if self.frozen: raise ValueError('Cannot add edge to frozen topology.')
|
||||
# We need to merge relevant edges. Thus, this is more like adding a
|
||||
# multi-edge, which falls to no-op when the specific edge is already
|
||||
# present. In other words, this means that we _may_ have multiple Edge
|
||||
# objects linking the same vertices, if they only differ by count or
|
||||
# cost.
|
||||
#
|
||||
# Also, since the edges are immutable and only concern generic details,
|
||||
# the Edge objects are not directly linked to Topology; to make the
|
||||
# connection, one needs to resolve one of the VertexIDs to the specific
|
||||
# Vertex to see if the edge is present.
|
||||
self.vertices[source].outgoing_edges.add(edge)
|
||||
self.vertices[target].incoming_edges.add(edge)
|
||||
self.edges.add(edge)
|
||||
|
||||
@classmethod
|
||||
def combine_topologies(cls, **topologies) -> 'TopologyV3':
|
||||
result = cls()
|
||||
for label, topo in topologies:
|
||||
result.add_ancestor(label, topo)
|
||||
return result
|
||||
|
||||
# Removing parts of the topology is currently not supported. Not that it
|
||||
# would be hard, just needless.
|
||||
|
||||
class VertexType(IntEnum):
|
||||
"""Type of entity this vertex represents.
|
||||
|
||||
IntEnum just so we can order by this."""
|
||||
|
||||
# Routers first:
|
||||
Router = 100
|
||||
ExternalRouter = 101
|
||||
|
||||
# Transit networks
|
||||
TransitNet = 200
|
||||
|
||||
# The various leaves of the topology: external routes, stubnets, …
|
||||
ExternalNet = 300
|
||||
ExtraAreaNet = 301
|
||||
StubNet = 302
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VertexID:
|
||||
"""An identifier of a particular vertex.
|
||||
|
||||
This class serves as an immutable identifier of a router/network. The
|
||||
:class:VertexFinder is used to find the specific :class:Vertex instances
|
||||
across known topologies.
|
||||
|
||||
We want to keep the following invariant: when two vertices share the same
|
||||
VertexID, they correspond to the same network object. This requires a bit
|
||||
of creative interpretation of which network objects are the same and which
|
||||
are not.
|
||||
|
||||
Conversely, two vertices corresponding to the same object may not share
|
||||
IDs. While it would be simpler if they did, the :class:VertexFinder is
|
||||
implemented to aid matching in such cases. Therefore, we rather assign
|
||||
multiple IDs to the same object than mistakenly share IDs.
|
||||
|
||||
Different network objects are identified by different properties:
|
||||
- Routers in OSPF always have a Router ID (RID)
|
||||
- External networks are uniquely determined by its IP range
|
||||
- Stub networks also need to be linked to the router which sees them
|
||||
(in case of network split there might be several stubnets with the
|
||||
same range)
|
||||
- Transit networks in OSPFv2 always have a single range, but when
|
||||
split, the RID of the DR can distinguish between the components
|
||||
- In OSPFv3, transit networks need to be identified by the RID of
|
||||
the DR and the interface ID leading to the network. Split networks
|
||||
may have the same ranges and DR in all components and the iface ID is
|
||||
the only thing BIRD provides us to differentiate. Moreover, in OSPFv3
|
||||
a network can have zero or more ranges in it, so identifying by
|
||||
ranges does not always work.
|
||||
|
||||
While the identifying properties of different objects are different, a
|
||||
single VertexID object needs to be able to identify the same network across
|
||||
topologies even in case a network shifts its shape (by splitting, becoming
|
||||
stub &c.). We therefore do not mark, which kind of network we are dealing
|
||||
with (i.e. the :class:VertexType), but we rather try to record as much
|
||||
information as possible. Even then, this might be insufficient for matching
|
||||
networks across topologies (e.g. when a network changes its DR, the state
|
||||
before and after may have distinct properties).
|
||||
|
||||
We could try to collect more information (like the list of all the RIDs
|
||||
present in the network), but that might instead lead us to create a
|
||||
different VertexID for the same network (e.g. when a router goes down),
|
||||
which is also undesirable. We therefore do not try to overengineer the
|
||||
description of the network, leaving such heuristics and advanced matching
|
||||
to another algorithm."""
|
||||
|
||||
family: AddressFamily
|
||||
|
||||
# We do _not_ use NodeType, because what is an extra-area network in one
|
||||
# topology might become a transit network in another. So we only distinguish routers and networks.
|
||||
is_router: bool
|
||||
|
||||
# We do not want to have multiple competing VertexID implementations, so we
|
||||
# include all the possible elements here and set them to None when not
|
||||
# applicable.
|
||||
|
||||
address: IPv4Network | IPv6Network | None
|
||||
router_id: int | None
|
||||
# For discriminating networks
|
||||
dr_id: int | None
|
||||
discriminator: str | None
|
||||
|
||||
@property
|
||||
def is_network(self): return not self.is_router
|
||||
@is_network.setter
|
||||
def _set_network(self, val: bool):
|
||||
assert isinstance(val, bool)
|
||||
self.is_router = not val
|
||||
|
||||
@dataclass
|
||||
class Vertex:
|
||||
"""Holds the details about a vertex that are specific to this topology.
|
||||
|
||||
The generic information is present in the :class:VertexID.
|
||||
|
||||
This class is not hashable, because while building the topology, the set of
|
||||
links needs to be mutable and we consider changing the type when freezing
|
||||
to be premature optimisation and/or needles paranoia. The :class:VertexID
|
||||
can be used as a hashable handle to the vertex when combined with the
|
||||
specific :class:TopologyV3 reference."""
|
||||
id: VertexID
|
||||
topology: TopologyV3 | None
|
||||
type: VertexType | None
|
||||
outgoing_edges: set['Edge'] = field(default_factory=set)
|
||||
incoming_edges: set['Edge'] = field(default_factory=set)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Edge:
|
||||
"""An edge of the topology.
|
||||
|
||||
Even though it is an edge, it is not suitable for being directly used in
|
||||
shortest-path tree calculation, since for tree calculation we need to track
|
||||
distances, not costs.
|
||||
|
||||
Since the main identifier of an edge is one of the vertices, we do not
|
||||
implement dedicated registry for edges."""
|
||||
source: VertexID
|
||||
target: VertexID
|
||||
cost: int
|
||||
# The network is a multigraph, so we need to know how many times to count this edge
|
||||
count: int
|
||||
# Virtual links are basically an implementation detail of OSPF.
|
||||
# Nonetheless, it might be useful to be able to show them, so we allow
|
||||
# adding them to a topology.
|
||||
virtual: bool = False
|
||||
|
||||
@dataclass
|
||||
class VertexFinder:
|
||||
"""Tracker of presence of vertices in topologies.
|
||||
|
||||
This is a central object that keeps track of multiple instances of the same
|
||||
vertex (that is, whatever has the same :class:VertexID) and can point to
|
||||
the relevant :class:Vertex or :class:TopologyV3 objects.
|
||||
|
||||
Apart from just keeping track, this class also provides methods to easy
|
||||
filtering of the vertices in order to aid with matching corresponding
|
||||
vertices even when they end up with different VertexIDs."""
|
||||
|
||||
# In future, this class may also provide keeping track of aliases between
|
||||
# different VertexIDs. Just noting that this is a good place, but such
|
||||
# functionality is not required at this time.
|
||||
|
||||
def __init__(self):
|
||||
self.topologies: list[TopologyV3] = []
|
||||
self.vertices: dict[VertexID, list[TopologyV3]] = defaultdict(lambda: [])
|
||||
|
||||
# All of the following dictionaries have scalars as keys and sets of VertexIDs as value.
|
||||
self.by_addr: dict[IPv4Network | IPv6Network, set[VertexID]] = defaultdict(lambda: set())
|
||||
self.by_rid = defaultdict(lambda: set())
|
||||
self.by_dr = defaultdict(lambda: set())
|
||||
self.by_discriminator = defaultdict(lambda: set())
|
||||
self.by_type = defaultdict(lambda: set())
|
||||
|
||||
def add_topology(self, topo) -> None:
|
||||
if not topo.frozen: raise ValueError("Can only add frozen topologies.")
|
||||
if topo in self.topologies: raise KeyError("This topology is already in the finder")
|
||||
self.topologies.append(topo)
|
||||
for v in topo.vertices:
|
||||
id = v.id
|
||||
self.vertices[id].add(topo)
|
||||
# Add to various "indices"
|
||||
self.by_addr[id.address].add(id)
|
||||
self.by_rid[id.router_id].add(id)
|
||||
self.by_dr[id.dr_id].add(id)
|
||||
self.by_discriminator[id.discriminator].add(id)
|
||||
self.by_type[v.type].add(id)
|
||||
|
||||
def get_vertex(self, vertex_id) -> list[TopologyV3]:
|
||||
return self.vertices[vertex_id]
|
||||
|
||||
def find(self, **kwa) -> set[VertexID]:
|
||||
"""This allows finding vertices only based by partial information.
|
||||
|
||||
The interface is a bit similar to Django's QuerySet filters, we take
|
||||
various keyword arguments and return everything that matches all of
|
||||
them. When an argument is present and is None, we find only those that
|
||||
have that property unset.
|
||||
|
||||
This function at this time does not check whether argument types are
|
||||
correct, and fails silently by returning empty set.
|
||||
|
||||
We only do non-trivial matching for addresses (network ranges, we find
|
||||
any overlaps).
|
||||
|
||||
We cannot do non-None matches (please give me all vertices that have XX
|
||||
set) at the moment."""
|
||||
# Shouldn't be to big.
|
||||
# TODO: Maybe return a dictionary?
|
||||
result = set(self.vertices.keys())
|
||||
if 'address' in kwa:
|
||||
addr = kwa['address']
|
||||
if addr is None:
|
||||
result &= self.by_addr[None]
|
||||
else:
|
||||
matches = set()
|
||||
# This might be slow when we do general overlaps.
|
||||
for net in self.by_addr.keys():
|
||||
if net is None: continue
|
||||
if net.overlaps(addr):
|
||||
matches |= self.by_addr[net]
|
||||
result &= matches
|
||||
if 'rid' in kwa:
|
||||
result &= self.by_rid[kwa['rid']]
|
||||
if 'dr' in kwa:
|
||||
result &= self.by_dr[kwa['dr']]
|
||||
if 'discriminator' in kwa:
|
||||
result &= self.by_discriminator[kwa['discriminator']]
|
||||
if 'type' in kwa:
|
||||
result &= self.by_type[kwa['type']]
|
||||
|
||||
return result
|
||||
|
Loading…
Reference in New Issue