diff --git a/birdvisu/maps.py b/birdvisu/maps.py deleted file mode 100644 index 45fd67b..0000000 --- a/birdvisu/maps.py +++ /dev/null @@ -1,87 +0,0 @@ -from birdvisu.ospffile import load -import re - -class Topology: - """Basically a graph""" - - def __init__(self): - self.routers = [] # Tuples like in ospffile for router section - self.networks = [] # Ditto, but the key is the address, not the ad-hoc string from bird - self.network_addrs = {} # Keeps mapping from network to address - self.links = [] # Tuples (router, network_addr, cost) - # FIXME: we are ugly, so everything are strings and I don't care as of now. - - def add_area(self, dirtuple): - # FIXME: Do not use BIRD's strings, create an abstraction above them. - _area, topo = dirtuple # Ignoring the area - for obj in topo: - directive, details = obj - if directive.startswith('router'): - self.routers.append(obj) - elif directive.startswith('network'): - net_addr = None - for d, _ in details: - if d.startswith('address'): - net_addr = d - break - assert net_addr is not None - fixed_network = (net_addr, details + [directive]) - self.networks.append(fixed_network) - self.network_addrs[directive] = net_addr - - # Fix the topology: find other networks (stubnets, external, ...) and links: - known = set() - for r, rd in self.routers: - for n, nd in rd: - if n.startswith(('network', 'stubnet', 'external')): - net_id = re.match(r'((network|stubnet|external) [^ ]+)', n).group(1) - if n.startswith(('stubnet', 'external')) and net_id not in known: - known.add(net_id) - self.networks.append((net_id, [])) - # Add dummy mapping - self.network_addrs[net_id] = net_id - metric = int(re.search(r'metric ([0-9]+)', n).group(1)) - net_addr = self.network_addrs[net_id] - self.links.append((r, net_addr, metric)) - - @classmethod - def from_ospffile(cls, f): - # FIXME: We should create own classes from the OSPF file as soon as - # possible to avoid changing whole codebase when BIRD's format changes. - result = cls() - parsed = load(f) - for directive, details in parsed: - if directive.startswith('area'): - result.add_area((directive, details)) - return result - - -class TopologyDifference: - def __init__(self, actual, reference): - self.actual = actual - self.reference = reference - - def compare(self): - # FIXME: This also relies on BIRD's current format. - just_ident = lambda x: x[0] - act_routers = set(map(just_ident, self.actual.routers)) - ref_routers = set(map(just_ident, self.reference.routers)) - act_networks = set(map(just_ident, self.actual.networks)) - ref_networks = set(map(just_ident, self.reference.networks)) - act_links = set(self.actual.links) - ref_links = set(self.reference.links) - - # *_missing: in reference, not in actual network. - # *_extra: in actual network, not in reference. - # *_discrepancies: different settings (currently only metrics) - self.routers_missing = ref_routers - act_routers - self.routers_extra = act_routers - ref_routers - self.networks_missing = ref_networks - act_networks - self.networks_extra = act_networks - ref_networks - - # FIXME: be more clever. Discrepancy is not missing and extra… - self.links_missing = ref_links - act_links - self.links_extra = act_links - ref_links - self.links_discrepancies = act_links ^ ref_links - - diff --git a/birdvisu/maps_new/__init__.py b/birdvisu/maps_new/__init__.py index 3b82517..09a7912 100644 --- a/birdvisu/maps_new/__init__.py +++ b/birdvisu/maps_new/__init__.py @@ -1,199 +1,3 @@ -from dataclasses import dataclass, field -from abc import ABC, abstractmethod -from typing import Sequence, Callable - -# The topologies provided by TopologyProviders are independent and do not have -# common routers, networks and links. We therefore join them on identifiers, -# which we expect to be canonical, somehow (i.e. the names BIRD has used) - -@dataclass -class Router: - ident: str - links: list['Link'] - # The full details in ospffile format. This allows extracting original - # details (e.g. visualisation details) from the router objects. - # FIXME: Topology handling does not need this, ideally this would be in - # some separate part of code. But everything else takes data from a - # CombinedTopology object, so we would still need a way of resolving this, - # and adding a separate object would complicate the code even more. - details: tuple[str,list] | None = None - # source objects for this Router in case of combined topology. Key is some - # source identifier - # FIXME: This allows for different identifiers in sources, which should be - # forbidden - # XXX: This is here, because Python mandates so: "TypeError: non-default - # argument 'links' follows default argument". I would rather have it right - # after `.ident`, but to no avail. - sources: dict[str, 'Router'] = field(default_factory=dict) - - @property - def all_details(self): - if self.details is not None: - yield self.details - for src in self.sources.values(): - for x in src.all_details: yield x - -@dataclass -class Network: - ident: str - links: list['Link'] - details: tuple[str,list] | None = None - sources: dict[str, 'Network'] = field(default_factory=dict) - - @property - def all_details(self): - if self.details is not None: - yield self.details - for src in self.sources.values(): - for x in src.all_details: yield x - -@dataclass -class Link: - """A connection from a Router to Network""" - router: Router - network: Network - metric: int - sources: dict[str, 'Link'] = field(default_factory=dict) - # Are there any other details that help describe a link? - -# This could be just a (named) tuple, dataclass is only used for consistency. -@dataclass -class Topology: - # Keys: idents - routers: dict[str, Router] = field(default_factory=dict) - networks: dict[str, Network] = field(default_factory=dict) - links: dict[tuple[str,str], Link] = field(default_factory=dict) # (router.ident, network.ident) -> Link - - # TODO: function for validating the topology, namely: - # - Routers and networks all know their links - # - Objects from this topology are referenced, not their sources nor other objects - # - Keys in dictionaries are same as identifiers of the values - # - maybe sth else… - -class TopologyProvider(ABC): - """Allows obtaining topology description from somewhere (usually OSPF - files)""" - - @abstractmethod - def get_topology(self) -> Topology: ... - -class TopologyCombiner: - """Takes multiple topologies (e.g. reference and actual one) and creates a - super-topology out of that. - - The rest of code relies mainly on such super-topologies""" - - def __init__(self): - self.topology = Topology() - - def add_topology(self, source_name: str, topo: Topology) -> None: - # We handle Routers and Networks pretty much the same way - for which_dict, which_class in [ - ('routers', Router), - ('networks', Network), - ]: - own_dict = getattr(self.topology, which_dict) - their_dict = getattr(topo, which_dict) - for nid, n in their_dict.items(): - assert nid == n.ident - if nid not in own_dict: - # FIXME: should only add complete objects. This will require - # different approach. - own_dict[nid] = which_class( - ident = nid, - links = [], - ) - assert source_name not in own_dict[nid].sources - own_dict[nid].sources[source_name] = n - # TODO: Can we avoid code duplication? - for lkey, l in topo.links.items(): - assert lkey == (l.router.ident, l.network.ident) - if lkey not in self.topology.links: - new_link = Link( - # Use own objects, not their children - router = self.topology.routers[l.router.ident], - network = self.topology.networks[l.network.ident], - metric = l.metric, - ) - self.topology.links[lkey] = new_link - # We add the link to our nodes - # TODO: This should ensure that only one link exists between - # this Router and Network, but currently it is based mostly on - # belief. - self.topology.routers[l.router.ident].links.append(new_link) - self.topology.networks[l.network.ident].links.append(new_link) - own_link = self.topology.links[lkey] - assert source_name not in own_link.sources - own_link.sources[source_name] = l - if own_link.metric != l.metric: - own_link.metric = -1 # Discrepant metrics! - - def get_complete_topology(self) -> Topology: - return self.topology - -@dataclass -class AnnotatedTopology: - topology: Topology - # Router.ident -> some tags. What they are is up to users / - # annotators. Similarly for other fields. - router_annotations: dict[str, list[object]] - network_annotations: dict[str, list[object]] - link_annotations: dict[tuple[str, str], list[object]] - # FIXME: this should rather be immutable, to allow saving different annotations. - -# TODO: maybe class to handle sets of annotators? -def annotate_topology(topology, - annotators: Sequence[Callable[[AnnotatedTopology], AnnotatedTopology]], - initial_annotation: AnnotatedTopology | None = None, - ) -> AnnotatedTopology: - """Runs all the annotators and assigns all the tags. - - The tags will change how the topology will be visualized. Examples: - - Which parts of network were expected and are missing - - Which paths are used in a routing tree of particular router - - Where should be nodes placed - - The annotators are run in order, therefore later annotators may depend on - pre-existing tags. This allows pipelining, for example to first analyse the - networks, then set styles, then layout the graph. An AnnotatedTopology - should therefore contain everything the visualisation (or other user) - needs. - - Initial topology contains all the dictionaries with all keys and empty lists. - - Annotators are not allowed to remove tags (by policy, code unfortunately - currently enables that.).""" - # This is maybe too general… - # The use of lists leaves namespacing to users, but can hinder performance - # in case of many tags. - # Removal of annotators is problematic, since tags are not tied to them. - # Unless users can distinguish tag ownership somehow (i.e. using own types - # for the tags), the only option is to recalculate the unannotated topology - # with a different set of annotators. - # TODO: We are going to abuse the fact that the annotators run in order and - # the tags are in lists to avoid searching through the whole list of tags – - # when we depend on previous annotator, its output is at the end. This - # should be more explicit (maybe an AnnotationPipeline object?), since this - # can easily be broken by misordering the annotators. - # - Idea: allow using `annotate_topology` itself as annotator. This way, a - # single call can hold the pipeline segment and can be managed - # separately (and possibly in more readable manner) - if initial_annotation is None: - initial_annotation = AnnotatedTopology( - topology = topology, - router_annotations = {k: [] for k in topology.routers.keys()}, - network_annotations = {k: [] for k in topology.networks.keys()}, - link_annotations = {k: [] for k in topology.links.keys()}, - ) - assert initial_annotation.topology is topology - # FIXME: probably shouldn't rely on topology having correct keys. - assert all(k in initial_annotation.router_annotations for k in topology.routers.keys()) - assert all(k in initial_annotation.network_annotations for k in topology.networks.keys()) - assert all(k in initial_annotation.link_annotations for k in topology.links.keys()) - - for annotator in annotators: - new_annotation = annotator(initial_annotation) - assert isinstance(new_annotation, AnnotatedTopology) - initial_annotation = new_annotation - - return initial_annotation +# - Which parts of network were expected and are missing +# - Which paths are used in a routing tree of particular router +# - Where should be nodes placed diff --git a/birdvisu/maps_new/providers.py b/birdvisu/maps_new/providers.py deleted file mode 100644 index 41e3e32..0000000 --- a/birdvisu/maps_new/providers.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Common TopologyProviders""" - -from birdvisu import ospffile, ospfsock -from birdvisu.maps_new import TopologyProvider, Router, Network, Link, Topology -import subprocess -import re - -class OspfDataTopologyProvider(TopologyProvider): - """Common provider for parsing BIRD's OSPF data. - - At the moment it can only process static data.""" - def __init__(self, data: str): - parsed = ospffile.loads(data) - self.topology = Topology() - # Networks will need changed names, since 'network' directives do not - # feel trustworthy (I don't really understand them, but I think they - # depend on designated routers and interface numbers, changes of which - # should not cause us to think that is a different network) - self.network_renames : dict[str,str] = {} - # We only care for areas: - # TODO: Should we support global visualisation configuration? - for directive, details in parsed: - if directive.startswith('area'): - self.add_area((directive, details)) - def add_area(self, ospf_area): - _area, topo = ospf_area # Ignoring the area - for obj in topo: - directive, details = obj - if directive.startswith('router'): - self.add_router(obj) - elif directive.startswith('network'): - self.add_network(obj) - self.find_links() - # assert self.topology.is_valid() - - def add_router(self, tree): - ident, details = tree - assert ident not in self.topology.routers - self.topology.routers[ident] = Router( - ident = ident, - links = [], # Will be filled by find_links - details = tree, - ) - # Stubnets and external routes are only mentioned here, so add them now - for det in details: - n, _nd = det - if n.startswith(('stubnet', 'external')): - net_id = re.match(r'((stubnet|external) [^ ]+)', n).group(1) - if net_id not in self.topology.networks: - self.topology.networks[net_id] = Network( - ident = net_id, - links = [], - details = det, - ) - - def add_network(self, tree): - bird_name, details = tree - assert bird_name not in self.network_renames - details.append((bird_name, [])) # Keep old name for reference - ident_candidates = [] - for d, dd in details: - if d.startswith('address'): - ident_candidates.append(d.replace('address', 'network')) - assert len(ident_candidates) > 0, "Network without address?" - ident = sorted(ident_candidates)[0] - self.network_renames[bird_name] = ident - assert ident not in self.topology.networks, "Multiple networks with same address?" - self.topology.networks[ident] = Network( - ident = ident, - links = [], - details = tree, - ) - - def find_links(self): - for r in self.topology.routers.values(): - det = r.details[1] - for n, nd in det: - if n.startswith(('network', 'stubnet', 'external')): - net_id = re.match(r'((network|stubnet|external) [^ ]+)', n).group(1) - if net_id in self.network_renames: - net_id = self.network_renames[net_id] - # FIXME: Hacking metric and metric2 together is a bad idea. - metric = int(re.search(r'metric2? ([0-9]+)', n).group(1)) - ident = (r.ident, net_id) - # I really hope that one router has at most one link to each network (incl. external) - assert ident not in self.topology.links - link = Link( - router = self.topology.routers[r.ident], - network = self.topology.networks[net_id], - metric = metric, - ) - self.topology.links[ident] = link - link.router.links.append(link) - link.network.links.append(link) - - def get_topology(self): - return self.topology - -class OspfFileTopologyProvider(TopologyProvider): - def __init__(self, ospf_file): - ospf_data = ospf_file.read() - self.ospfdataprovider = OspfDataTopologyProvider(ospf_data) - def get_topology(self): - return self.ospfdataprovider.get_topology() - - -class RunningBirdTopologyProvider(TopologyProvider): - def __init__(self): - self.load_topology() - - def load_topology(self): - bird = subprocess.Popen(['birdcl', 'show', 'ospf', 'state'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - # Probably not needed, but will work and is tested from interactive python shell - stdin=subprocess.PIPE, - text=True) - stdout, stderr = bird.communicate() - if bird.returncode != 0 or stderr != '': - raise RuntimeError(f'BIRD failed: returncode={bird.returncode}, stderr={stderr}') - # FIXME: this probably causes memory leaks, since TopologyCombiner will - # keep complete history from all runs that have been processed. This - # needs to be solved at TopologyCombiner level or above, since we need - # to be able to remove old data from the topology. - self.topology = OspfDataTopologyProvider(stdout).get_topology() - - def get_topology(self): - return self.topology - -class BirdSocketTopologyProvider(TopologyProvider): - """This one is dynamic, provides new topology every time.""" - def __init__(self, socket_filename='/run/bird/bird.ctl'): - self.socket_filename = socket_filename - - def get_topology(self): - birdsock = ospfsock.BirdSocketConnection() - resp = birdsock.request('show ospf state') - # TODO: Naming things – this is not exactly parser… - parser = OspfDataTopologyProvider(resp.text) - return parser.get_topology()