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