Add topology v3

This is a re-implemented topology, which should be more compatible with
OSPF's idea of a network system.
styling
LEdoian 1 year ago
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…
Cancel
Save