From 6e851d9fc8667461253e75b5d77bde163f721142 Mon Sep 17 00:00:00 2001 From: "th.l" <thl-cmk@outlook.com> Date: Wed, 25 Dec 2024 11:57:40 +0100 Subject: [PATCH] streamlined L3v4 in preparation for L3v6 topology --- README.md | 2 +- mkp/nvdct-0.9.6-20241222.mkp | Bin 0 -> 44241 bytes source/bin/nvdct/conf/nvdct.toml | 30 +- source/bin/nvdct/lib/args.py | 36 +- source/bin/nvdct/lib/backends.py | 665 ++++++++++---------- source/bin/nvdct/lib/constants.py | 89 ++- source/bin/nvdct/lib/settings.py | 174 +++--- source/bin/nvdct/lib/topologies.py | 944 ++++++++++++++++------------- source/bin/nvdct/lib/utils.py | 60 +- source/bin/nvdct/nvdct.py | 182 +++--- source/packages/nvdct | 2 +- 11 files changed, 1170 insertions(+), 1014 deletions(-) create mode 100644 mkp/nvdct-0.9.6-20241222.mkp diff --git a/README.md b/README.md index 075d246..f4a9179 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.5-20241217.mkp "nvdct-0.9.5-20241217.mkp" +[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.6-20241222.mkp "nvdct-0.9.6-20241222.mkp" # Network Visualization Data Creation Tool (NVDCT) This script creates the topology data file needed for the [Checkmk Exchange Network visualization](https://exchange.checkmk.com/p/network-visualization) plugin.\ diff --git a/mkp/nvdct-0.9.6-20241222.mkp b/mkp/nvdct-0.9.6-20241222.mkp new file mode 100644 index 0000000000000000000000000000000000000000..a245e33f052657a47f08c939ec5ed9bfe5ec7575 GIT binary patch literal 44241 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 literal 0 HcmV?d00001 diff --git a/source/bin/nvdct/conf/nvdct.toml b/source/bin/nvdct/conf/nvdct.toml index 414861a..e907197 100755 --- a/source/bin/nvdct/conf/nvdct.toml +++ b/source/bin/nvdct/conf/nvdct.toml @@ -26,25 +26,27 @@ L2_DROP_HOSTS = [ # "a nother invalid name", ] -# hosts will be ignored in L3v4 topology +# hosts will be ignored in L3 topologies # [0-9-a-zA-Z\.\_\-]{1,253} -> host -L3V4_IGNORE_HOSTS = [ +L3_IGNORE_HOSTS = [ # "host1", # "host2", ] -# drop IP address that match network -L3V4_IGNORE_IP = [ +# drop IP address that matches ip/network +L3_IGNORE_IP = [ # "192.168.100.231", # "192.168.100.0/16", # "192.168.150.0/255.255.255.0", + # "fd00::1" + # "fd00::/8" ] # ignore IPs by wildcard # if comparing an ip address: # each 0 bit in the wildcad has to be exacly as in the pattern # each 1 bit in the wildacrd will be ignored -L3V4_IRNORE_WILDCARD = [ +L3V4_IGNORE_WILDCARD = [ # [ pattern , wildcard ] # ["172.17.0.1", "0.0.255.0"], # ignore all IPs ending with 1 from 172.17.128.0/16 # ["172.17.128.0", "0.0.127.3"], # ignore all IPs ending with 0-3 from 172.17.128.0/17 @@ -52,11 +54,12 @@ L3V4_IRNORE_WILDCARD = [ ] # networks to summarize -L3V4_SUMMARIZE = [ +L3_SUMMARIZE = [ # "10.193.172.0/24", # "10.194.8.0/23", # "10.194.12.0/24", # "10.194.115.0/255.255.255.0", + # "fd00::/8" ] # topologies will not be deleted by "--keep" @@ -120,11 +123,12 @@ SITES = [ # replace network objects (takes place after summarize) # [0-9-a-zA-Z\.\_\-]{1,253} -> host -[L3V4_REPLACE] +[L3_REPLACE] # "10.193.172.0/24" = "MPLS" # "10.194.8.0/23" = "MPLS" # "10.194.12.0/24" = "MPLS" # "10.194.115.0/24" = "MPLS" +# "fc00::/7" = "Unique-local" [EMBLEMS] # can use misc icons from CMK or upload your own in the misc category @@ -133,8 +137,8 @@ SITES = [ # "host_node" = "icon_missinc" # "ip_address" = "ip-address_80" # "ip_network" = "ip-network_80" -# "l3v4_replace" = "icon_plugins_cloud" -# "l3v4_summarize" = "icon_aggr" +# "l3_replace" = "icon_plugins_cloud" +# "l3_summarize" = "icon_aggr" # "service_node" = "icon_missing" [MAP_SPEED_TO_THICKNESS] @@ -158,13 +162,13 @@ SITES = [ # filter_customers = "INCLUDE" |"EXCLUDE" # filter_sites = "INCLUDE" | "EXCLUDE" # include_l3_hosts = false -# keep = 0 -# layers = ["LLDP", "CDP", L3v4, "STATIC", "CUSTOM"] +keep = 10 +# layers = ["LLDP", "CDP", "L3v4", "STATIC", "CUSTOM"] # log_file = "~/var/log/nvdct.log" # log_level = "WARNING" # log_to_stdout = false -# min_age = 0 -# output_directory = '' # +min_age = 1 +output_directory = 'nvdct' # remove to get date formated directory # pre_fetch = false # prefix = "" # quiet = true diff --git a/source/bin/nvdct/lib/args.py b/source/bin/nvdct/lib/args.py index cf77c81..5ad06bd 100755 --- a/source/bin/nvdct/lib/args.py +++ b/source/bin/nvdct/lib/args.py @@ -45,18 +45,18 @@ from argparse import ( from pathlib import Path from lib.constants import ( + ExitCodes, HOME_URL, MIN_CDP_VERSION, MIN_LINUX_IP_ADDRESSES, + MIN_LLDP_VERSION, MIN_SNMP_IP_ADDRESSES, MIN_WINDOWS_IP_ADDRESSES, - MIN_LLDP_VERSION, NVDCT_VERSION, SCRIPT, TIME_FORMAT_ARGPARSER, USER_DATA_FILE, ) -from lib.utils import ExitCodes def parse_arguments() -> arg_Namespace: @@ -80,10 +80,12 @@ def parse_arguments() -> arg_Namespace: ), formatter_class=RawTextHelpFormatter, epilog='Exit codes:\n' - f' {ExitCodes.OK.value} - No error\n' - f' {ExitCodes.BAD_OPTION_LIST.value} - Bad options list\n' - f' {ExitCodes.BACKEND_NOT_IMPLEMENTED.value} - Backend not implemented\n' - f' {ExitCodes.AUTOMATION_SECRET_NOT_FOUND.value} - Automation secret not found\n' + f' {ExitCodes.OK} - No error\n' + f' {ExitCodes.BAD_OPTION_LIST} - Bad options list\n' + f' {ExitCodes.BAD_TOML_FORMAT} - Bad TOML file format\n' + f' {ExitCodes.BACKEND_NOT_IMPLEMENTED} - Backend not implemented\n' + f' {ExitCodes.AUTOMATION_SECRET_NOT_FOUND} - Automation secret not found\n' + f' {ExitCodes.NO_LAYER_CONFIGURED} - No layer to work on\n' '\nUsage:\n' f'{SCRIPT} -u ~/local/bin/nvdct/conf/my_{USER_DATA_FILE} \n\n' ) @@ -121,16 +123,22 @@ def parse_arguments() -> arg_Namespace: parser.add_argument( '-l', '--layers', nargs='+', - choices=['CDP', 'CUSTOM', 'LLDP', 'STATIC', 'L3v4'], + choices=[ + 'CDP', + 'CUSTOM', + 'L3v4', + 'LLDP', + 'STATIC', + ], # default=['CDP'], help=( - f' - CDP : needs inv_cdp_cache package at least in version {MIN_CDP_VERSION}\n' - f' - LLDP : needs inv_lldp_cache package at least in version {MIN_LLDP_VERSION}\n' - f' - L3v4 : needs inv_ip_address package at least in version {MIN_SNMP_IP_ADDRESSES} for SNMP based hosts\n' - f' for Linux based hosts inv_lnx_ip_if in version {MIN_LINUX_IP_ADDRESSES}\n' - f' for Windows based hosts inv_win_ip_if in version {MIN_WINDOWS_IP_ADDRESSES}\n' - f' - STATIC: creates a topology base on the "STATIC_CONNECTIONS" in the toml file\n' - f' - CUSTOM: (deprecated)\n' + f' - CDP : needs inv_cdp_cache package at least in version {MIN_CDP_VERSION}\n' + f' - LLDP : needs inv_lldp_cache package at least in version {MIN_LLDP_VERSION}\n' + f' - L3v4 : needs inv_ip_address package at least in version {MIN_SNMP_IP_ADDRESSES} for SNMP based hosts\n' + f' for Linux based hosts inv_lnx_ip_if in version {MIN_LINUX_IP_ADDRESSES}\n' + f' for Windows based hosts inv_win_ip_if in version {MIN_WINDOWS_IP_ADDRESSES}\n' + f' - STATIC : creates a topology base on the "STATIC_CONNECTIONS" in the toml file\n' + f' - CUSTOM : (deprecated)\n' ) ) parser.add_argument( diff --git a/source/bin/nvdct/lib/backends.py b/source/bin/nvdct/lib/backends.py index 64cce3a..78a7d4b 100755 --- a/source/bin/nvdct/lib/backends.py +++ b/source/bin/nvdct/lib/backends.py @@ -10,32 +10,33 @@ # 2024-06-18: fixed host_exist returns always True if host was in host_cache, even with host=None # 2024-09-25: fixed crash on missing "customer" section in site config file +# 2024-12-22: refactoring, leave only backend specific stuff in the backend +# removed not strictly needed properties, renamed functions to better understand what the do -from collections.abc import Mapping, Sequence from abc import abstractmethod from ast import literal_eval +from collections.abc import Mapping, MutableSequence, Sequence from enum import Enum, unique from pathlib import Path from requests import session -from time import time_ns -from typing import Dict, List, Tuple from sys import exit as sys_exit +from typing import Dict, List, Tuple from livestatus import MultiSiteConnection, SiteConfigurations, SiteId from lib.constants import ( CACHE_INTERFACES_DATA, + ExitCodes, OMD_ROOT, PATH_INTERFACES, ) from lib.utils import ( - ExitCodes, + LOGGER, get_data_form_live_status, get_table_from_inventory, - LOGGER, - ) +HOST_EXIST: Dict = {'exists': True} def hosts_to_query(hosts: List[str]) -> Tuple[str, List[str]]: # WORKAROUND for: Apache HTTP Error 414: Request URI too long @@ -89,6 +90,8 @@ class CacheItems(Enum): inventory = 'inventory' interfaces = 'interfaces' + def __get__(self, instance, owner): + return self.value class HostCache: def __init__( @@ -96,247 +99,287 @@ class HostCache: pre_fetch: bool, backend: str, ): - self._cache: Dict = {} + LOGGER.info('init HOST_CACHE') + + self.cache: Dict = {} self._inventory_pre_fetch_list: List[str] = [ PATH_INTERFACES, ] - self._count: int = 0 - self._pre_fetch = pre_fetch - self._backend = backend - LOGGER.info('init HOST_CACHE') - @abstractmethod - def get_inventory_data(self, hosts: Sequence[str]) -> Dict[str, Dict | None]: + self.pre_fetch: bool = bool(pre_fetch) + self.backend: str = str(backend) + + if self.pre_fetch: + for host in self.query_all_hosts(): + self.cache[host] = HOST_EXIST.copy() + + def get_inventory_data(self, hosts: Sequence[str]) -> Dict[str, Dict]: """ + Returns a dictionary of hosts and there inventory data. Args: - hosts: the host name to return the inventory data for + hosts: list of host names to return the inventory data for Returns: the inventory data as dictionary """ - raise NotImplementedError() - @abstractmethod + inventory_data: Dict[str, Dict | None] = {} + # init inventory_data with None + for host in hosts: + inventory_data[host] = None + + open_hosts = hosts.copy() + while open_hosts: + hosts_str, open_hosts = hosts_to_query(open_hosts) + for host, inventory in self.query_inventory_data(hosts_str).items(): + inventory_data[host] = inventory + + return inventory_data + def get_interface_data(self, hosts: Sequence[str]) -> Dict[str, Dict | None]: """ - + Returns Dictionary of hosts and there interface services from CMK. + The interface information consists of the "Item", the "Description (summary)" and the service details Args: hosts: lit of host names to return the interface data for Returns: - dictionary of the interface data with the item as key + dictionary of the interface data with the host -> item as key """ - raise NotImplementedError() + host_data: Dict[str, any] = {} # to make pylint happy + # init host_data with None + for host in hosts: + host_data[host] = None + open_hosts = hosts.copy() + while open_hosts: + hosts_str, open_hosts = hosts_to_query(open_hosts) + host_data.update(self.query_interface_data(hosts_str)) - @abstractmethod - def host_exists(self, host: str) -> bool: - raise NotImplementedError() + return host_data - @abstractmethod - def get_hosts_by_label(self, label: str) -> List[str] | None: - raise NotImplementedError() + def host_exists(self, host: str) -> bool: + """ + Returns True if host exists in CMK, else False + """ + try: + return bool(self.cache[host]) + except KeyError: + pass - @abstractmethod - def pre_fetch_hosts(self): - raise NotImplementedError() + # get host from CMK and init host in cache + if exists := self.query_host(host): + self.cache[host] = HOST_EXIST.copy() + else: + self.cache[host] = None - @property - def cache(self) -> Dict: - return self._cache + return exists - @property - def backend(self) -> str: - return self._backend + def get_hosts_by_label(self, label: str) -> Sequence[str]: + """ + Returns list of hosts from CMK filtered by label + Args: + label: hostlabel to filter by - @property - def pre_fetch(self) -> bool: - return self._pre_fetch + Returns: + List of hosts + """ + return self.query_hosts_by_label(label) - def stop_host(self, host: str) -> None: - if host not in self._cache: - self._cache[host] = None + def fill_cache(self, hosts: Sequence[str]) -> None: + """ + Gets the host data from CMK and puts them in the host cache. Data collected: + - inventory + - interfaces - def pre_fetch_cache(self, hosts: Sequence[str]) -> None: - # pre fill inventory data - self._count += 1 - _pre_query = time_ns() + Args: + hosts: List of hosts the get data from CMK + Returns: None, the data is directly writen to self.cache + """ inventory_of_hosts: Mapping[str, Mapping | None] = self.get_inventory_data(hosts=hosts) - LOGGER.debug(f'{(time_ns() - _pre_query) / 1e9}|{self._count:0>4}|inventory|{hosts}') - if inventory_of_hosts: for host, inventory in inventory_of_hosts.items(): - if host not in self._cache: - self._cache[host] = {} - self._cache[host][CacheItems.inventory.value] = {} - self._cache[host][CacheItems.inventory.value].update({ + if host not in self.cache: + self.cache[host] = HOST_EXIST.copy() + self.cache[host][CacheItems.inventory] = {} + self.cache[host][CacheItems.inventory].update({ entry: get_table_from_inventory( inventory=inventory, raw_path=entry ) for entry in self._inventory_pre_fetch_list }) - _pre_query = time_ns() - interfaces_of_hosts: Mapping[str, Mapping | None] = self.get_interface_data(hosts) for host, interfaces in interfaces_of_hosts.items(): - if host not in self._cache: - self._cache[host] = {} - if not self._cache[host].get(CacheItems.interfaces.value): - self._cache[host][CacheItems.interfaces.value] = {} - self._cache[host][CacheItems.interfaces.value][CACHE_INTERFACES_DATA] = interfaces - - LOGGER.debug(f'{(time_ns() - _pre_query) / 1e9}|{self._count:0>4}|items|{host}') + if host not in self.cache: + self.cache[host] = HOST_EXIST.copy() + if not self.cache[host].get(CacheItems.interfaces): + self.cache[host][CacheItems.interfaces] = {} + self.cache[host][CacheItems.interfaces][CACHE_INTERFACES_DATA] = interfaces def get_data(self, host: str, item: CacheItems, path: str) -> Dict[str, any] | None: + """ + Returns data from self.cache. If the cache for "host" is empty, data will be fetched from CMK + Args: + host: host to get data from cache + item: item in cache (inventory/interface) + path: path in cache item + + Returns: + the requested data or None + """ if self.host_exists(host=host): - if host not in self._cache: - self._cache[host] = {} - LOGGER.info(f'Host not in cache: {host}') - self.pre_fetch_cache(hosts=[host]) + if self.cache[host] == HOST_EXIST: + LOGGER.info(f'fetch data for: {host}') + self.fill_cache(hosts=[host]) try: - return self._cache[host][item.value][path] + return self.cache[host][item][path] except (KeyError, TypeError): return None - else: - self._cache[host] = None + return None - def add_inventory_prefetch_path(self, path: str) -> None: + def add_inventory_path(self, path: str) -> None: self._inventory_pre_fetch_list = list(set(self._inventory_pre_fetch_list + [path])) + @abstractmethod + def query_host(self, host: str) -> bool: + """ + Query Livestatus for "host" + + Args: + host: CMK host name to query livestus for + Returns: + True: if host was found + False: if host is not found + """ + raise NotImplementedError + + @abstractmethod + def query_all_hosts(self) -> Sequence[str]: + """ + Queries Livestatus for a list of all hosts + Returns: + List of all hosts + """ + raise NotImplementedError + + @abstractmethod + def query_hosts_by_label(self, label: str) -> Sequence[str]: + """ + Queries Livestatus for a list of hosts filtered by a host label + Args: + label: Host label to filter list of host by + + Returns: List of hosts + """ + raise NotImplementedError + + @abstractmethod + def query_inventory_data(self, hosts: str) -> Dict[str, Dict]: + raise NotImplementedError + + @abstractmethod + def query_interface_data(self, hosts: str) -> Dict[str, Dict]: + raise NotImplementedError class HostCacheLiveStatus(HostCache): def __init__(self, pre_fetch: bool, backend: str = '[LIVESTATUS]'): super().__init__(pre_fetch, backend) - if self.pre_fetch: - self.pre_fetch_hosts() def get_raw_data(self, query: str) -> any: return get_data_form_live_status(query=query) - def get_inventory_data(self, hosts: List[str]) -> Dict[str, Dict | None]: - host_data: Dict[str, Dict | None] = {} - # int host_data with None - for host in hosts: - host_data[host] = None - open_hosts = hosts.copy() - while open_hosts: - hosts_str, open_hosts = hosts_to_query(open_hosts) - query = ( - 'GET hosts\n' - 'Columns: host_name mk_inventory\n' - 'OutputFormat: python3\n' - f'Filter: host_name ~~ {hosts_str}\n' - ) - data: Sequence[Tuple[str, bytes]] = self.get_raw_data(query=query) - LOGGER.debug(f'{self.backend} data for hosts {hosts}: {data}') - if data: - for host, inventory in data: - if inventory == b'': - LOGGER.warning(f'{self.backend} Device: {hosts}: no inventory data found!') - continue - try: - host_data[host] = literal_eval(inventory.decode('utf-8')) - except SyntaxError as e: - LOGGER.exception(f'inventory: |{inventory!r}|') # output raw data - LOGGER.exception(f'type: {type(inventory)}') - LOGGER.exception(f'exception: {e}') - continue - else: - LOGGER.warning(f'{self.backend} Device: {hosts}: no inventory data found!') - return host_data - - def get_interface_data(self, hosts: List[str]) -> Dict[str, Dict | None]: - # host_data: Dict[str, Dict[str, Dict[str, List[str]] ] | None] = {} - host_data: Dict[str, any] = {} # to make pylint happy - # int host_data with None - for host in hosts: - host_data[host] = None - open_hosts = hosts.copy() - while open_hosts: - hosts_str, open_hosts = hosts_to_query(open_hosts) - query = ( - 'GET services\n' - 'Columns: host_name description long_plugin_output\n' - 'Filter: description ~ ^Interface\n' - f'Filter: host_name ~~ {hosts_str}\n' - 'OutputFormat: python3\n' - ) - data: List[Tuple[str, str, str]] = self.get_raw_data(query=query) - LOGGER.debug(f'{self.backend} data for host {hosts}: {data}') - if data: - for host, description, long_plugin_output in data: - if host_data.get(host) is None: - host_data[host] = {} - host_data[host][description[10:]] = { # remove 'Interface ' from description - 'long_plugin_output': long_plugin_output.split('\\n') - } - else: - LOGGER.warning(f'{self.backend} No Interfaces items found for hosts {hosts}') - - return host_data - - def host_exists(self, host: str) -> bool: + def query_host(self, host: str) -> bool: query = ( 'GET hosts\n' 'Columns: host_name\n' 'OutputFormat: python3\n' f'Filter: host_name = {host}\n' ) - if self.cache.get(host) is not None: - return True - elif host in self.cache: - return False - # if self.pre_fetch: - # LOGGER.warning(f'{self.backend} pre_fetch host not found in cache {host}') - # return False data: Sequence[Sequence[str]] = self.get_raw_data(query=query) LOGGER.debug(f'{self.backend} data for host {host}: {data}') if [host] in data: LOGGER.debug(f'{self.backend} Host {host} found in CMK') return True - LOGGER.warning(f'{self.backend} Host {host} not found in CMK') - self.stop_host(host) - return False - def get_hosts_by_label(self, label: str) -> List[str] | None: + def query_all_hosts(self) -> Sequence[str]: + query = ( + 'GET hosts\n' + 'Columns: host_name\n' + 'OutputFormat: python3\n' + ) + data: Sequence[Sequence[str]] = self.get_raw_data(query=query) + if data: + LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') + return [host[0] for host in data] + + LOGGER.warning(f'{self.backend} no hosts found') + return [] + + def query_hosts_by_label(self, label: str) -> Sequence[str]: query = ( 'GET hosts\n' 'Columns: name\n' 'OutputFormat: python3\n' - # f'Filter: label_names ~ {label}\n' f'Filter: labels = {label}\n' ) data: Sequence[Sequence[str]] = self.get_raw_data(query=query) - LOGGER.debug(f'{self.backend} routing capable hosts: {data}') + LOGGER.debug(f'{self.backend} hosts matching label: {data}') if data: - hosts = [] - for host in data: - hosts.append(host[0]) - return hosts + LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') + return [host[0] for host in data] - LOGGER.debug(f'{self.backend} no routing capable hosts found') - return None + LOGGER.warning(f'{self.backend} no hosts found matching label {label}') + return [] - def pre_fetch_hosts(self): - LOGGER.debug(f'{self.backend} pre_fetch_hosts') + def query_inventory_data(self, hosts: str) -> Dict[str, Dict]: query = ( 'GET hosts\n' - 'Columns: host_name\n' + 'Columns: host_name mk_inventory\n' 'OutputFormat: python3\n' + f'Filter: host_name ~~ {hosts}\n' ) - data: Sequence[Sequence[str]] = self.get_raw_data(query=query) + inventory_data = {} + data: Sequence[Tuple[str, bytes]] = self.get_raw_data(query=query) + LOGGER.debug(f'{self.backend} data for hosts {hosts}: {data}') if data: - for host in data: - self._cache[host[0]] = {} - LOGGER.debug(f'{self.backend} # of host found: {len(self.cache.keys())}') + for host, inventory in data: + if not inventory: + LOGGER.warning(f'{self.backend} Device: {host}: no inventory data found!') + continue + inventory = literal_eval(inventory.decode('utf-8')) + inventory_data[host] = inventory else: - LOGGER.warning(f'{self.backend} no hosts found') + LOGGER.warning(f'{self.backend} Device: {hosts}: no inventory data found!') + return inventory_data + + def query_interface_data(self, hosts: str) -> Dict[str, Dict]: + query = ( + 'GET services\n' + 'Columns: host_name description long_plugin_output\n' + 'Filter: description ~ ^Interface\n' + f'Filter: host_name ~~ {hosts}\n' + 'OutputFormat: python3\n' + ) + interface_data = {} + data: List[Tuple[str, str, str]] = self.get_raw_data(query=query) + LOGGER.debug(f'{self.backend} interface data for hosts {hosts}: {data}') + if data: + for host, description, long_plugin_output in data: + if interface_data.get(host) is None: + interface_data[host] = {} + interface_data[host][description[10:]] = { # remove 'Interface ' from description + 'long_plugin_output': long_plugin_output.split('\\n') + } + else: + LOGGER.warning(f'{self.backend} No Interfaces items found for hosts {hosts}') + + return interface_data class HostCacheMultiSite(HostCacheLiveStatus): def __init__( @@ -347,7 +390,7 @@ class HostCacheMultiSite(HostCacheLiveStatus): filter_customers: str | None = None, customers: List[str] = None, ): - self._backend = '[MULTISITE]' + self.backend = '[MULTISITE]' self.sites: SiteConfigurations = SiteConfigurations({}) self.get_sites() self.filter_sites(filter_sites, sites) @@ -361,9 +404,10 @@ class HostCacheMultiSite(HostCacheLiveStatus): dead_sites = ', '.join(self.dead_sites) LOGGER.warning(f'{self.backend} WARNING: use of dead site(s) {dead_sites} is disabled') self.c.set_only_sites(self.c.alive_sites()) - super().__init__(pre_fetch, self._backend) - if self.pre_fetch: - self.pre_fetch_hosts() + super().__init__(pre_fetch, self.backend) + + def get_raw_data(self, query: str) -> object: + return self.c.query(query=query) # https://github.com/Checkmk/checkmk/blob/master/packages/cmk-livestatus-client/example_multisite.py def get_sites(self): @@ -435,10 +479,6 @@ class HostCacheMultiSite(HostCacheLiveStatus): case _: return - def get_raw_data(self, query: str) -> object: - return self.c.query(query=query) - - class HostCacheRestApi(HostCache): def __init__( self, @@ -447,10 +487,9 @@ class HostCacheRestApi(HostCache): filter_sites: str | None = None, sites: List[str] = [], ): - super().__init__(pre_fetch, '[RESTAPI]') + self.backend = '[RESTAPI]' LOGGER.debug(f'{self.backend} init backend') - self._api_port = api_port - self.sites = [] + try: self.__secret = Path( f'{OMD_ROOT}/var/check_mk/web/automation/automation.secret' @@ -458,33 +497,47 @@ class HostCacheRestApi(HostCache): except FileNotFoundError as e: LOGGER.exception(f'{self.backend} automation.secret not found, {e}') print(f'{self.backend} automation.secret not found, {e}') - sys_exit(ExitCodes.AUTOMATION_SECRET_NOT_FOUND.value) + sys_exit(ExitCodes.AUTOMATION_SECRET_NOT_FOUND) + + self.__api_port = api_port self.__hostname = 'localhost' self.__site = OMD_ROOT.split('/')[-1] - self.__api_url = f"http://{self.__hostname}:{self._api_port}/{self.__site}/check_mk/api/1.0" + self.__api_url = f"http://{self.__hostname}:{self.__api_port}/{self.__site}/check_mk/api/1.0" self.__user = 'automation' + LOGGER.info(f'{self.backend} Create REST API session') self.__session = session() self.__session.headers['Authorization'] = f"Bearer {self.__user} {self.__secret}" self.__session.headers['Accept'] = 'application/json' - self.get_sites() + self.sites: MutableSequence[str] = self.query_sites() self.filter_sites(filter_=filter_sites, sites=sites) LOGGER.info(f'{self.backend} filtered sites : {self.sites}') + super().__init__(pre_fetch, self.backend) - if self.pre_fetch: - self.pre_fetch_hosts() + def get_raw_data(self, url: str, params: Mapping[str, object] | None): + resp = self.__session.get( + url=url, + params=params, + ) + LOGGER.debug(f'{self.backend} raw data: {resp.text}') + if resp.status_code == 200: + return resp.json() + else: + LOGGER.warning(f'{self.backend} response: {resp.status_code}') - def get_sites(self): + def query_sites(self) -> MutableSequence[str]: LOGGER.debug(f'{self.backend} get_sites') - resp = self.__session.get(url=f"{self.__api_url}/domain-types/site_connection/collections/all") - if resp.status_code == 200: - sites = resp.json().get("value") - self.sites = [site.get('id') for site in sites] - LOGGER.debug(f'{self.backend} sites : {self.sites}') + url = f"{self.__api_url}/domain-types/site_connection/collections/all" + sites = [] + if raw_data:= self.get_raw_data(url, None): + raw_sites = raw_data.get("value") + sites = [site.get('id') for site in raw_sites] + LOGGER.debug(f'{self.backend} sites : {sites}') else: - LOGGER.warning(f'{self.backend} got no site information! status code {resp.status_code}') - LOGGER.debug(f'{self.backend} response text: {resp.text}') + LOGGER.warning(f'{self.backend} got no site information!') + + return sites def filter_sites(self, filter_: str | None, sites: List[str]): match filter_: @@ -495,170 +548,108 @@ class HostCacheRestApi(HostCache): case _: return - def get_inventory_data(self, hosts: List[str]) -> Dict[str, Dict | None]: - LOGGER.debug(f'{self.backend} get_inventory_data {hosts}') - host_data: Dict[str, Dict | None] = {} - # init host_data with None - for host in hosts: - host_data[host] = None - open_hosts = hosts.copy() - while open_hosts: - hosts_str, open_hosts = hosts_to_query(open_hosts) - LOGGER.debug(f'{self.backend} open hosts: {open_hosts}, len: {len(open_hosts)}') - query = '{"op": "~~", "left": "name", "right": "' + hosts_str + '"}' - resp = self.__session.get( - url=f"{self.__api_url}/domain-types/host/collections/all", - params={ - 'query': query, - 'columns': ['name', 'mk_inventory'], - 'sites': self.sites, - }, - ) - if resp.status_code == 200: - LOGGER.debug(f'{self.backend} {resp.elapsed}|{self._count:0>4}|inventory|{hosts}') - data = resp.json().get('value', []) - for raw_host in data: - host = raw_host.get('extensions', {}).get('name') - if host: - host_data[host] = raw_host['extensions'].get('mk_inventory') + def query_host(self, host: str) -> bool: + query = '{"op": "=", "left": "name", "right": "' + host + '"}' + url = f'{self.__api_url}/domain-types/host/collections/all' + params = { + 'query': query, + 'columns': ['name'], + 'sites': self.sites, + } + + if raw_data := self.get_raw_data(url, params): + try: + data = raw_data['value'][0]['extensions']['name'] + LOGGER.debug(f'{self.backend} data for host {host}: {data}') + except IndexError: + LOGGER.warning(f'Host {host} not found in CMK') else: - LOGGER.warning( - f'{self.backend} got no inventory data found!, status code {resp.status_code}' - ) - LOGGER.debug(f'{self.backend} response query: {query}') - LOGGER.debug(f'{self.backend} response text: {resp.text}') - LOGGER.debug(f'{self.backend} response url: {resp.url}, len: {len(resp.url)}') + if data == host: + return True - return host_data + return False - def get_interface_data(self, hosts: List[str]) -> Dict[str, Dict | None]: - LOGGER.debug(f'{self.backend} get_interface_data {hosts}') - host_data: Dict[str, Dict | None] = {} - # init host_data with None - for host in hosts: - host_data[host] = None - open_hosts = hosts.copy() - while open_hosts: - hosts_str, open_hosts = hosts_to_query(open_hosts) + def query_all_hosts(self) -> Sequence[str]: + url = f'{self.__api_url}/domain-types/host/collections/all' + params = { + 'columns': ['name'], + 'sites': self.sites, + } - query_host = f'{{"op": "~~", "left": "host_name", "right": "{hosts_str}"}}' - query_item = '{"op": "~", "left": "description", "right": "Interface "}' - query = f'{{"op": "and", "expr": [{query_item},{query_host}]}}' - - resp = self.__session.get( - url=f'{self.__api_url}/domain-types/service/collections/all', - params={ - 'query': query, - 'columns': ['host_name', 'description', 'long_plugin_output'], - 'sites': self.sites, - }, - ) + if raw_data := self.get_raw_data(url, params): + if data := raw_data.get('value', []): + LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') + return [host.get('extensions', {}).get('name') for host in data] + + return [] + + def query_hosts_by_label(self, label: str) -> Sequence[str]: + query = '{"op": "=", "left": "labels", "right": "' + label + '"}' + + url = f'{self.__api_url}/domain-types/host/collections/all' + params = { + 'columns': ['name', 'labels'], + 'query': query, + 'sites': self.sites, + } + + if raw_data := self.get_raw_data(url, params): + if data := raw_data.get('value'): + LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') + return [host['extensions']['name'] for host in data] + + LOGGER.warning(f'{self.backend} no hosts found matching label {label}') + return [] + + def query_inventory_data(self, hosts: str) -> Dict[str, Dict]: + query = '{"op": "~~", "left": "name", "right": "' + hosts + '"}' + url = f"{self.__api_url}/domain-types/host/collections/all" + params = { + 'query': query, + 'columns': ['name', 'mk_inventory'], + 'sites': self.sites, + } + + inventory_data = {} + + if raw_data := self.get_raw_data(url, params): + LOGGER.debug(f'{self.backend} raw inventory data: {raw_data}') + if data := raw_data.get('value', []): + for raw_host in data: + if host := raw_host.get('extensions', {}).get('name'): + inventory = raw_host['extensions'].get('mk_inventory') + if not inventory: + LOGGER.warning(f'{self.backend} Device: {host}: no inventory data found!') + inventory_data[host] = inventory + + return inventory_data + + def query_interface_data(self, hosts: str) -> Dict[str, Dict]: + query_host = f'{{"op": "~~", "left": "host_name", "right": "{hosts}"}}' + query_item = '{"op": "~", "left": "description", "right": "Interface "}' + query = f'{{"op": "and", "expr": [{query_item},{query_host}]}}' + + url = f'{self.__api_url}/domain-types/service/collections/all' + params = { + 'query': query, + 'columns': ['host_name', 'description', 'long_plugin_output'], + 'sites': self.sites, + } - if resp.status_code == 200: - LOGGER.debug(f'{resp.elapsed}|{self._count:0>4}|items|{hosts}') + interface_data = {} - data = resp.json().get('value', []) + if raw_data := self.get_raw_data(url, params): + LOGGER.debug(f'{self.backend} raw interface data: {raw_data}') + + if data := raw_data.get('value', []): for raw_service in data: LOGGER.debug(f'{self.backend} data for service : {raw_service}') service = raw_service.get('extensions') host, description, long_plugin_output = service.values() - host = service['host_name'] - description = service['description'] - if host_data.get(host) is None: - host_data[host] = {} - host_data[host][description[10:]] = { + if interface_data.get(host) is None: + interface_data[host] = {} + interface_data[host][description[10:]] = { 'long_plugin_output': long_plugin_output.split('\\n') } - else: - LOGGER.warning( - f'{self.backend} got no interface data, response code {resp.status_code}' - ) - LOGGER.debug(f'{self.backend} response query: {query}') - LOGGER.debug(f'{self.backend} response text: {resp.text}') - LOGGER.debug(f'{self.backend} response url: {resp.url}, len: {len(resp.url)}') - - return host_data - - def host_exists(self, host: str) -> bool: - LOGGER.debug(f'{self.backend} host_exists {host}') - if self.cache.get(host) is not None: - LOGGER.debug(f'{self.backend} host found in cache {host}') - return True - elif host in self.cache: - return False - - # if self.pre_fetch: - # LOGGER.warning(f'{self.backend} pre_fetch host not found in cache {host}') - # return False - query = '{"op": "=", "left": "name", "right": "' + host + '"}' - resp = self.__session.get( - url=f'{self.__api_url}/domain-types/host/collections/all', - params={ - 'query': query, - 'columns': ['name'], - 'sites': self.sites, - }, - ) - if resp.status_code == 200: - LOGGER.debug(f'{resp.elapsed}|{self._count:0>4}|name|{host}') - try: - data = resp.json()['value'][0]['extensions']['name'] - LOGGER.debug(f'{self.backend} data for host {host}: {resp.json()}') - except IndexError: - LOGGER.warning(f'Host {host} not found in CMK') - self.stop_host(host) - return False - if data == host: - return True - else: - LOGGER.warning(f'{self.backend} response: {resp.status_code}') - return False - - def get_hosts_by_label(self, label: str) -> List[str] | None: - LOGGER.debug(f'{self.backend} get_hosts_by_label {label}') - query = '{"op": "=", "left": "labels", "right": "' + label + '"}' - - resp = self.__session.get( - url=f'{self.__api_url}/domain-types/host/collections/all', - params={ - 'columns': ['name', 'labels'], - 'query': query, - 'sites': self.sites, - }, - ) - if resp.status_code == 200: - LOGGER.debug(f'{self.backend} data for routing_capable: {resp.json()}') - LOGGER.debug(f'{resp.elapsed}|{self._count:0>4}|name|routing_capable') - try: - data = resp.json().get('value') - hosts = [] - for host in data: - hosts.append(host['extensions']['name']) - LOGGER.debug(f'{self.backend} host list {hosts}') - return hosts - except IndexError: - LOGGER.debug(f'{self.backend} no routing capable hosts found') - return None - else: - LOGGER.warning(f'{self.backend} response: {resp.status_code}') - return None - def pre_fetch_hosts(self): - LOGGER.debug(f'{self.backend} pre_fetch_hosts') - LOGGER.critical(f'{self.backend} pre_fetch_hosts sites {self.sites}') - resp = self.__session.get( - url=f'{self.__api_url}/domain-types/host/collections/all', - params={ - 'columns': ['name'], - 'sites': self.sites, - }, - ) - if resp.status_code == 200: - data = resp.json().get('value', []) - for raw_host in data: - host = raw_host.get('extensions', {}).get('name') - if host: - self._cache[host] = {} - LOGGER.debug(f'{self.backend} # of host found: {len(self.cache.keys())}') - else: - LOGGER.warning(f'{self.backend} respons: {resp.text}') + return interface_data \ No newline at end of file diff --git a/source/bin/nvdct/lib/constants.py b/source/bin/nvdct/lib/constants.py index 4bf5240..590cba1 100755 --- a/source/bin/nvdct/lib/constants.py +++ b/source/bin/nvdct/lib/constants.py @@ -8,11 +8,42 @@ # File : nvdct/lib/constants.py +from dataclasses import dataclass +from enum import Enum, unique, auto from logging import getLogger from os import environ from typing import Final -NVDCT_VERSION: Final[str] = '0.9.5-20241217' +# +NVDCT_VERSION: Final[str] = '0.9.6-20241222' +# +@unique +class ExitCodes(Enum): + OK = 0 + BAD_OPTION_LIST = auto() + BAD_TOML_FORMAT = auto() + BACKEND_NOT_IMPLEMENTED = auto() + AUTOMATION_SECRET_NOT_FOUND = auto() + NO_LAYER_CONFIGURED = auto() + + def __get__(self, instance, owner): + return self.value + +@unique +class IPVersion(Enum): + IPv4 = 4 + IPv6 = 6 + + def __get__(self, instance, owner): + return self.value + +@dataclass(frozen=True) +class Layer: + path: str + columns: str + label: str + host_label: str + # OMD_ROOT: Final[str] = environ["OMD_ROOT"] # @@ -20,17 +51,21 @@ API_PORT: Final[int] = 5001 CACHE_INTERFACES_DATA: Final[str] = 'interface_data' CMK_SITE_CONF: Final[str] = f'{OMD_ROOT}/etc/omd/site.conf' COLUMNS_CDP: Final[str] = 'neighbour_name,local_port,neighbour_port' -COLUMNS_L3v4: Final[str] = 'address,device,cidr,network,type' +COLUMNS_L3: Final[str] = 'address,device,cidr,network,type' COLUMNS_LLDP: Final[str] = 'neighbour_name,local_port,neighbour_port' DATAPATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data' HOME_URL: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct' HOST_LABEL_CDP: Final[str] = "'nvdct/has_cdp_neighbours' 'yes'" HOST_LABEL_L3V4_HOSTS: Final[str] = "'nvdct/l3v4_topology' 'host'" HOST_LABEL_L3V4_ROUTER: Final[str] = "'nvdct/l3v4_topology' 'router'" +HOST_LABEL_L3V6_HOSTS: Final[str] = "'nvdct/l3v6_topology' 'host'" +HOST_LABEL_L3V6_ROUTER: Final[str] = "'nvdct/l3v6_topology' 'router'" HOST_LABEL_LLDP: Final[str] = "'nvdct/has_lldp_neighbours' 'yes'" LABEL_CDP: Final[str] = 'CDP' -LABEL_L3v4: Final[str] = 'LAYER3v4' +LABEL_L3v4: Final[str] = 'L3v4' +LABEL_L3v6: Final[str] = 'L3v6' LABEL_LLDP: Final[str] = 'LLDP' +LABEL_STATIC: Final[str] = 'STATIC' LOGGER: Final[str] = getLogger('root)') LOG_FILE: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log' MIN_CDP_VERSION: Final[str] = '0.7.1-20240320' @@ -40,9 +75,55 @@ MIN_WINDOWS_IP_ADDRESSES: Final[str] = '0.0.3-20241210' MIN_LLDP_VERSION: Final[str] = '0.9.3-20240320' PATH_CDP: Final[str] = 'networking,cdp_cache,neighbours' PATH_INTERFACES: Final[str] = 'networking,interfaces' -PATH_L3v4: Final[str] = 'networking,addresses' +PATH_L3: Final[str] = 'networking,addresses' PATH_LLDP: Final[str] = 'networking,lldp_cache,neighbours' SCRIPT: Final[str] = '~/local/bin/nvdct/nvdct.py' TIME_FORMAT: Final[str] = '%Y-%m-%dT%H:%M:%S.%m' TIME_FORMAT_ARGPARSER: Final[str] = '%%Y-%%m-%%dT%%H:%%M:%%S.%%m' USER_DATA_FILE: Final[str] = 'nvdct.toml' +# +TOML_CUSTOMERS : Final[str] = 'CUSTOMERS' +TOML_CUSTOM_LAYERS : Final[str] = 'CUSTOM_LAYERS' +TOML_EMBLEMS : Final[str] = 'EMBLEMS' +TOML_L2_DROP_HOSTS: Final[str] = 'L2_DROP_HOSTS' +TOML_L2_HOST_MAP : Final[str] = 'L2_HOST_MAP' +TOML_L2_NEIGHBOUR_REPLACE_REGEX : Final[str] = 'L2_NEIGHBOUR_REPLACE_REGEX' +TOML_L2_SEED_DEVICES: Final[str] = 'L2_SEED_DEVICES' +TOML_L3V4_IGNORE_WILDCARD : Final[str] = 'L3V4_IGNORE_WILDCARD' +TOML_L3_IGNORE_HOSTS : Final[str] = 'L3_IGNORE_HOSTS' +TOML_L3_IGNORE_IP : Final[str] = 'L3_IGNORE_IP' +TOML_L3_REPLACE : Final[str] = 'L3_REPLACE' +TOML_L3_SUMMARIZE : Final[str] = 'L3_SUMMARIZE' +TOML_MAP_SPEED_TO_THICKNESS : Final[str] = 'MAP_SPEED_TO_THICKNESS' +TOML_PROTECTED_TOPOLOGIES : Final[str] = 'PROTECTED_TOPOLOGIES' +TOML_SETTINGS : Final[str] = 'SETTINGS' +TOML_SITES : Final[str] = 'SITES' +TOML_STATIC_CONNECTIONS : Final[str] = 'STATIC_CONNECTIONS' +# +LAYERS = { + 'CDP': Layer( + path=PATH_CDP, + columns=COLUMNS_CDP, + label=LABEL_CDP, + host_label=HOST_LABEL_CDP, + ), + 'LLDP': Layer( + path=PATH_LLDP, + columns=COLUMNS_LLDP, + label=LABEL_LLDP, + host_label=HOST_LABEL_LLDP, + ), + 'L3v4': Layer( + path=PATH_L3, + columns='', + label=LABEL_L3v4, + host_label=HOST_LABEL_L3V4_ROUTER, + ), + 'L3v6': Layer( + path=PATH_L3, + columns='', + label=LABEL_L3v6, + host_label=HOST_LABEL_L3V6_ROUTER, + ), +} + diff --git a/source/bin/nvdct/lib/settings.py b/source/bin/nvdct/lib/settings.py index 0252f74..e625634 100755 --- a/source/bin/nvdct/lib/settings.py +++ b/source/bin/nvdct/lib/settings.py @@ -11,7 +11,7 @@ # 2024-12-17: fixed wrong import for OMD_ROOT (copy&paste) (ThX to BH2005@forum.checkmk.com) from collections.abc import Mapping -from ipaddress import AddressValueError, IPv4Address, IPv4Network, NetmaskValueError +from ipaddress import AddressValueError, NetmaskValueError, ip_address, ip_network from logging import CRITICAL, FATAL, ERROR, WARNING, INFO, DEBUG from sys import exit as sys_exit from time import strftime @@ -20,15 +20,33 @@ from pathlib import Path from lib.constants import ( API_PORT, + ExitCodes, LOGGER, LOG_FILE, + Layer, OMD_ROOT, TIME_FORMAT, USER_DATA_FILE, + + TOML_CUSTOMERS, + TOML_CUSTOM_LAYERS, + TOML_EMBLEMS, + TOML_L2_DROP_HOSTS, + TOML_L2_HOST_MAP, + TOML_L2_NEIGHBOUR_REPLACE_REGEX, + TOML_L2_SEED_DEVICES, + TOML_L3V4_IGNORE_WILDCARD, + TOML_L3_IGNORE_HOSTS, + TOML_L3_IGNORE_IP, + TOML_L3_REPLACE, + TOML_L3_SUMMARIZE, + TOML_MAP_SPEED_TO_THICKNESS, + TOML_PROTECTED_TOPOLOGIES, + TOML_SETTINGS, + TOML_SITES, + TOML_STATIC_CONNECTIONS, ) from lib.utils import ( - ExitCodes, - Layer, get_data_from_toml, get_local_cmk_api_port, is_valid_customer_name, @@ -43,8 +61,8 @@ class Emblems(NamedTuple): host_node: str ip_address: str ip_network: str - l3v4_replace: str - l3v4_summarize: str + l3_replace: str + l3_summarize: str service_node: str @@ -113,10 +131,10 @@ class Settings: if self.__args.get('check_user_data_only'): LOGGER.info(msg=f'Could read/parse the user data from {self.user_data_file}') print(f'Could read/parse the user data from {self.user_data_file}') - sys_exit(ExitCodes.OK.value) + sys_exit(ExitCodes.OK) # defaults -> overridden by toml -> overridden by cli - self.__settings.update(self.__user_data.get('SETTINGS', {})) + self.__settings.update(self.__user_data.get(TOML_SETTINGS, {})) self.__settings.update(self.__args) if self.layers: @@ -126,7 +144,7 @@ class Settings: msg='-l/--layers options must be unique. Don\'t use any layer more than once.' ) print('-l/--layers options must be unique. Don\'t use any layer more than once.') - sys_exit(ExitCodes.BAD_OPTION_LIST.value) + sys_exit(ExitCodes.BAD_OPTION_LIST) self.__api_port: int | None = None @@ -138,11 +156,11 @@ class Settings: self.__l2_host_map: Dict[str, str] | None = None self.__l2_neighbour_replace_regex: List[Tuple[str, str]] | None = None self.__l2_seed_devices: List[str] | None = None - self.__l3v4_ignore_hosts: List[str] | None = None - self.__l3v4_ignore_ip: List[IPv4Network] | None = None + self.__l3_ignore_hosts: List[str] | None = None + self.__l3_ignore_ip: List[ip_network] | None = None self.__l3v4_ignore_wildcard: List[Wildcard] | None = None - self.__l3v4_replace: Dict[str, str] | None = None - self.__l3v4_summarize: List[IPv4Network] | None = None + self.__l3_replace: Dict[str, str] | None = None + self.__l3_summarize: List[ip_network] | None = None self.__map_speed_to_thickness: List[Thickness] | None = None self.__protected_topologies: List[str] | None = None self.__sites: List[str] | None = None @@ -318,7 +336,7 @@ class Settings: def customers(self) -> List[str]: if self.__customers is None: self.__customers = [ - str(customer) for customer in set(self.__user_data.get('CUSTOMERS', [])) + str(customer) for customer in set(self.__user_data.get(TOML_CUSTOMERS, [])) if is_valid_customer_name(customer)] LOGGER.info(f'Found {len(self.__customers)} to filter on') return self.__customers @@ -327,7 +345,7 @@ class Settings: def custom_layers(self) -> List[Layer]: if self.__custom_layers is None: self.__custom_layers = [] - for _layer in self.__user_data.get('CUSTOM_LAYERS', []): + for _layer in self.__user_data.get(TOML_CUSTOM_LAYERS, []): try: self.__custom_layers.append(Layer( path=_layer['path'], @@ -337,25 +355,25 @@ class Settings: )) except KeyError: LOGGER.error( - f'Invalid entry in CUSTOM_LAYERS -> {_layer} -> ignored' + f'Invalid entry in {TOML_CUSTOM_LAYERS} -> {_layer} -> ignored' ) continue LOGGER.critical( - f'Valid entries in CUSTOM_LAYERS found: {len(self.__custom_layers)}/' - f'{len(self.__user_data.get("CUSTOM_LAYERS", []))}' + f'Valid entries in {TOML_CUSTOM_LAYERS} found: {len(self.__custom_layers)}/' + f'{len(self.__user_data.get(TOML_CUSTOM_LAYERS, []))}' ) return self.__custom_layers @property def emblems(self) -> Emblems: if self.__emblems is None: - raw_emblems = self.__user_data.get('EMBLEMS', {}) + raw_emblems = self.__user_data.get(TOML_EMBLEMS, {}) self.__emblems = Emblems( host_node=str(raw_emblems.get('host_node', 'icon_missing')), ip_address=str(raw_emblems.get('ip_address', 'ip-address_80')), ip_network=str(raw_emblems.get('ip_network', 'ip-network_80')), - l3v4_replace=str(raw_emblems.get('l3v4_replace', 'icon_plugins_cloud')), - l3v4_summarize=str(raw_emblems.get('l3v4_summarize', 'icon_aggr')), + l3_replace=str(raw_emblems.get('l3_replace', 'icon_plugins_cloud')), + l3_summarize=str(raw_emblems.get('l3_summarize', 'icon_aggr')), service_node=str(raw_emblems.get('service_node', 'icon_missing')), ) return self.__emblems @@ -363,14 +381,14 @@ class Settings: @property def l2_drop_hosts(self) -> List[str]: if self.__l2_drop_host is None: - self.__l2_drop_host = [str(host) for host in set(self.__user_data.get('L2_DROP_HOSTS', []))] + self.__l2_drop_host = [str(host) for host in set(self.__user_data.get(TOML_L2_DROP_HOSTS, []))] return self.__l2_drop_host @property def l2_seed_devices(self) -> List[str]: if self.__l2_seed_devices is None: self.__l2_seed_devices = list(set(str(host) for host in ( - self.__user_data.get('L2_SEED_DEVICES', [])) if is_valid_hostname(host))) + self.__user_data.get(TOML_L2_SEED_DEVICES, [])) if is_valid_hostname(host))) return self.__l2_seed_devices @property @@ -378,7 +396,7 @@ class Settings: if self.__l2_host_map is None: self.__l2_host_map = { str(host): str(replace_host) for host, replace_host in self.__user_data.get( - 'L2_HOST_MAP', {} + TOML_L2_HOST_MAP, {} ).items() if is_valid_hostname(host) } return self.__l2_host_map @@ -389,47 +407,47 @@ class Settings: self.__l2_neighbour_replace_regex = [ ( str(regex), str(replace) - ) for regex, replace in self.__user_data.get('L2_NEIGHBOUR_REPLACE_REGEX', {}).items() + ) for regex, replace in self.__user_data.get(TOML_L2_NEIGHBOUR_REPLACE_REGEX, {}).items() ] return self.__l2_neighbour_replace_regex @property - def l3v4_ignore_hosts(self) -> List[str]: - if self.__l3v4_ignore_hosts is None: - self.__l3v4_ignore_hosts = [str(host) for host in set(self.__user_data.get( - 'L3V4_IGNORE_HOSTS', [] + def l3_ignore_hosts(self) -> List[str]: + if self.__l3_ignore_hosts is None: + self.__l3_ignore_hosts = [str(host) for host in set(self.__user_data.get( + TOML_L3_IGNORE_HOSTS, [] )) if is_valid_hostname(host)] - return self.__l3v4_ignore_hosts + return self.__l3_ignore_hosts @property - def l3v4_ignore_ips(self) -> List[IPv4Network]: - if self.__l3v4_ignore_ip is None: - self.__l3v4_ignore_ip = [] - for ip_network in self.__user_data.get('L3V4_IGNORE_IP', []): + def l3_ignore_ips(self) -> List[ip_network]: + if self.__l3_ignore_ip is None: + self.__l3_ignore_ip = [] + for raw_ip_network in self.__user_data.get(TOML_L3_IGNORE_IP, []): try: - self.__l3v4_ignore_ip.append(IPv4Network(ip_network, strict=False)) + self.__l3_ignore_ip.append(ip_network(raw_ip_network, strict=False)) except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in L3V4_IGNORE_IP found: {ip_network} -> ignored' + f'Invalid entry in {TOML_L3_IGNORE_IP} found: {raw_ip_network} -> ignored' ) continue LOGGER.info( - f'Valid entries in L3V4_IGNORE_IP found: {len(self.__l3v4_ignore_ip)}/' - f'{len(self.__user_data.get("L3V4_IGNORE_IP", []))}' + f'Valid entries in {TOML_L3_IGNORE_IP} found: {len(self.__l3_ignore_ip)}/' + f'{len(self.__user_data.get(TOML_L3_IGNORE_IP, []))}' ) - return self.__l3v4_ignore_ip + return self.__l3_ignore_ip @property def l3v4_ignore_wildcard(self) -> List[Wildcard]: if self.__l3v4_ignore_wildcard is None: self.__l3v4_ignore_wildcard = [] - for entry in self.__user_data.get('L3V4_IRNORE_WILDCARD', []): + for entry in self.__user_data.get(TOML_L3V4_IGNORE_WILDCARD, []): try: - ip_address, wildcard = entry + raw_ip_address, wildcard = entry except ValueError: LOGGER.error( - f'Invalid entry in L3V4_IRNORE_WILDCARD -> {entry} -> ignored' + f'Invalid entry in {TOML_L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' ) continue try: @@ -438,71 +456,71 @@ class Settings: [str(255 - int(octet)) for octet in wildcard.split('.')] ) self.__l3v4_ignore_wildcard.append(Wildcard( - int_ip_address=int(IPv4Address(ip_address)), - int_wildcard=int(IPv4Address(inverted_wildcard)), - ip_address=ip_address, + int_ip_address=int(ip_address(raw_ip_address)), + int_wildcard=int(ip_address(inverted_wildcard)), + ip_address=raw_ip_address, wildcard=wildcard, - bit_pattern=int(IPv4Address(ip_address)) & int( - IPv4Address(inverted_wildcard) + bit_pattern=int(ip_address(raw_ip_address)) & int( + ip_address(inverted_wildcard) ) )) except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in L3V4_IRNORE_WILDCARD -> {entry} -> ignored' + f'Invalid entry in {TOML_L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' ) continue LOGGER.info( - f'Valid entries in L3V4_IRNORE_WILDCARD found: {len(self.__l3v4_ignore_wildcard)}/' - f'{len(self.__user_data.get("L3V4_IRNORE_WILDCARD", []))}' + f'Valid entries in {TOML_L3V4_IGNORE_WILDCARD} found: {len(self.__l3v4_ignore_wildcard)}/' + f'{len(self.__user_data.get(TOML_L3V4_IGNORE_WILDCARD, []))}' ) return self.__l3v4_ignore_wildcard @property - def l3v4_replace(self) -> Dict[str, str]: - if self.__l3v4_replace is None: - self.__l3v4_replace = {} - for ip_network, node in self.__user_data.get('L3V4_REPLACE', {}).items(): + def l3_replace(self) -> Dict[str, str]: + if self.__l3_replace is None: + self.__l3_replace = {} + for raw_ip_network, node in self.__user_data.get(TOML_L3_REPLACE, {}).items(): try: - _ip_network = IPv4Network(ip_network) # noqa: F841 + _ip_network = ip_network(raw_ip_network) # noqa: F841 except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in L3V4_REPLACE found: {ip_network} -> line ignored' + f'Invalid entry in {TOML_L3_REPLACE} found: {raw_ip_network} -> line ignored' ) continue if not is_valid_hostname(node): LOGGER.error(f'Invalid node name found: {node} -> line ignored ') continue - self.__l3v4_replace[ip_network] = str(node) + self.__l3_replace[raw_ip_network] = str(node) LOGGER.info( - f'Valid entries in L3V4_REPLACE found: {len(self.__l3v4_replace)}/' - f'{len(self.__user_data.get("L3V4_REPLACE", {}))}' + f'Valid entries in {TOML_L3_REPLACE} found: {len(self.__l3_replace)}/' + f'{len(self.__user_data.get(TOML_L3_REPLACE, {}))}' ) - return self.__l3v4_replace + return self.__l3_replace @property - def l3v4_summarize(self) -> List[IPv4Network]: - if self.__l3v4_summarize is None: - self.__l3v4_summarize = [] - for ip_network in self.__user_data.get('L3V4_SUMMARIZE', []): + def l3_summarize(self) -> List[ip_network]: + if self.__l3_summarize is None: + self.__l3_summarize = [] + for raw_ip_network in self.__user_data.get(TOML_L3_SUMMARIZE, []): try: - self.__l3v4_summarize.append(IPv4Network(ip_network, strict=False)) + self.__l3_summarize.append(ip_network(raw_ip_network, strict=False)) except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in L3V4_SUMMARIZE -> {ip_network} -> ignored' + f'Invalid entry in {TOML_L3_SUMMARIZE} -> {raw_ip_network} -> ignored' ) continue LOGGER.info( - f'Valid entries in L3V4_SUMMARIZE found: {len(self.__l3v4_summarize)}/' - f'{len(self.__user_data.get("L3V4_SUMMARIZE", []))}' + f'Valid entries in {TOML_L3_SUMMARIZE} found: {len(self.__l3_summarize)}/' + f'{len(self.__user_data.get(TOML_L3_SUMMARIZE, []))}' ) - return self.__l3v4_summarize + return self.__l3_summarize @property def map_speed_to_thickness(self) -> List[Thickness]: if self.__map_speed_to_thickness is None: self.__map_speed_to_thickness = [] map_speed_to_thickness = self.__user_data.get( - 'MAP_SPEED_TO_THICKNESS', {} + TOML_MAP_SPEED_TO_THICKNESS, {} ) for speed, thickness in map_speed_to_thickness.items(): try: @@ -512,12 +530,12 @@ class Settings: )) except ValueError: LOGGER.error( - f'Invalid entry in MAP_SPEED_TO_THICKNESS -> {speed}={thickness} -> ignored' + f'Invalid entry in {TOML_MAP_SPEED_TO_THICKNESS} -> {speed}={thickness} -> ignored' ) continue LOGGER.info( - f'Valid entries in MAP_SPEED_TO_THICKNESS found: {len(self.__map_speed_to_thickness)}' # noqa: E501 - f'/{len(self.__user_data.get("MAP_SPEED_TO_THICKNESS", []))}' + f'Valid entries in {TOML_MAP_SPEED_TO_THICKNESS} found: {len(self.__map_speed_to_thickness)}' # noqa: E501 + f'/{len(self.__user_data.get(TOML_MAP_SPEED_TO_THICKNESS, []))}' ) return self.__map_speed_to_thickness @@ -525,7 +543,7 @@ class Settings: def protected_topologies(self) -> List[str]: if self.__protected_topologies is None: self.__protected_topologies = [str(topology) for topology in self.__user_data.get( - 'PROTECTED_TOPOLOGIES', [] + TOML_PROTECTED_TOPOLOGIES, [] )] return self.__protected_topologies @@ -533,12 +551,12 @@ class Settings: def static_connections(self) -> List[StaticConnection]: if self.__static_connections is None: self.__static_connections = [] - for connection in self.__user_data.get('STATIC_CONNECTIONS', []): + for connection in self.__user_data.get(TOML_STATIC_CONNECTIONS, []): try: left_host, left_service, right_service, right_host = connection except ValueError: LOGGER.error( - f'Wrong entry in STATIC_CONNECTIONS -> {connection} -> ignored' + f'Wrong entry in {TOML_STATIC_CONNECTIONS} -> {connection} -> ignored' ) continue if not right_host or not left_host: @@ -553,14 +571,14 @@ class Settings: left_host=str(left_host), )) LOGGER.info( - f'Valid entries in STATIC_CONNECTIONS found: {len(self.__static_connections)}/' - f'{len(self.__user_data.get("STATIC_CONNECTIONS", []))}' + f'Valid entries in {TOML_STATIC_CONNECTIONS} found: {len(self.__static_connections)}/' + f'{len(self.__user_data.get(TOML_STATIC_CONNECTIONS, []))}' ) return self.__static_connections @property def sites(self) -> List[str]: if self.__sites is None: - self.__sites = [str(site) for site in set(self.__user_data.get('SITES', [])) if is_valid_site_name(site)] + self.__sites = [str(site) for site in set(self.__user_data.get(TOML_SITES, [])) if is_valid_site_name(site)] LOGGER.info(f'Found {len(self.__sites)} to filter on') return self.__sites diff --git a/source/bin/nvdct/lib/topologies.py b/source/bin/nvdct/lib/topologies.py index 32710db..3fa4cbb 100755 --- a/source/bin/nvdct/lib/topologies.py +++ b/source/bin/nvdct/lib/topologies.py @@ -2,16 +2,21 @@ # -*- coding: utf-8 -*- # # License: GNU General Public License v2 +import sys # Author: thl-cmk[at]outlook[dot]com # URL : https://thl-cmk.hopto.org # Date : 2024-06-09 # File : lib/topologies.py -from collections.abc import Mapping, Sequence -from ipaddress import IPv4Address, IPv4Network -from typing import Dict, List, Tuple +# 2024-12-22: refactoring topology creation into classes +# made L3 topology IP version independent + +from abc import abstractmethod +from collections.abc import Mapping, MutableMapping, Sequence +from ipaddress import ip_address, ip_network, ip_interface from re import sub as re_sub +from typing import Dict, List, Tuple from lib.backends import ( CacheItems, @@ -21,9 +26,13 @@ from lib.constants import ( CACHE_INTERFACES_DATA, HOST_LABEL_L3V4_HOSTS, HOST_LABEL_L3V4_ROUTER, + HOST_LABEL_L3V6_HOSTS, + HOST_LABEL_L3V6_ROUTER, + IPVersion, LOGGER, PATH_INTERFACES, - PATH_L3v4, + PATH_L3, + DATAPATH, ) from lib.settings import ( Emblems, @@ -33,8 +42,9 @@ from lib.settings import ( ) from lib.utils import ( InventoryColumns, - Ipv4Info, - is_valid_hostname, + IpInfo, + # is_valid_hostname, + save_data_to_file, ) @@ -165,21 +175,21 @@ class NvObjects: elif metadata is not {}: self.nv_objects[service_object]['metadata'].update(metadata) - def add_ipv4_address( + def add_ip_address( self, host: str, - ipv4_address: str, + raw_ip_address: str, emblem: str, interface: str | None, ) -> None: if interface is not None: - service_object = f'{ipv4_address}@{interface}@{host}' + service_object = f'{raw_ip_address}@{interface}@{host}' else: - service_object = f'{ipv4_address}@{host}' + service_object = f'{raw_ip_address}@{host}' if service_object not in self.nv_objects: self.nv_objects[service_object] = { - 'name': ipv4_address, + 'name': raw_ip_address, 'link': {}, 'metadata': { 'images': { @@ -188,13 +198,13 @@ class NvObjects: }, 'tooltip': { 'quickinfo': [ - {'name': 'IP-Address', 'value': ipv4_address}, + {'name': 'IP-Address', 'value': raw_ip_address}, ] } } } - def add_ipv4_network(self, network: str, emblem: str, ) -> None: + def add_ip_network(self, network: str, emblem: str, ) -> None: if network not in self.nv_objects: self.nv_objects[network] = { 'name': network, @@ -306,17 +316,18 @@ class NvConnections: f'<->{right} (duplex: {right_duplex})' ) if left_native_vlan and right_native_vlan: - if left_native_vlan != right_native_vlan: - warning = True - - metadata = add_tooltip_html( - metadata, 'Native<br>VLAN', left, left_native_vlan, right, right_native_vlan - ) - - LOGGER.warning( - f'Connection with native vlan mismatch: ' - f'{left} (vlan: {left_native_vlan})<->{right} (vlan: {right_native_vlan})' - ) + if left_native_vlan != '0' and right_native_vlan != '0': # ignore VLAN 0 (Native VLAN on routed ports) + if left_native_vlan != right_native_vlan: + warning = True + + metadata = add_tooltip_html( + metadata, 'Native<br>VLAN', left, left_native_vlan, right, right_native_vlan + ) + + LOGGER.warning( + f'Connection with native vlan mismatch: ' + f'{left} (vlan: {left_native_vlan})<->{right} (vlan: {right_native_vlan})' + ) if warning: metadata['line_config'].update({ 'color': 'red', @@ -329,6 +340,481 @@ class NvConnections: connection.append(metadata) +class Topology: + def __init__( + self, + emblems: Emblems, + host_cache: HostCache, + ): + self.nv_objects: NvObjects = NvObjects() + self.nv_connections: NvConnections = NvConnections() + self.emblems: Emblems = emblems + self.host_cache: HostCache = host_cache + + @abstractmethod + def create(self): + raise NotImplementedError + + def save(self, label:str, output_directory: str, make_default: bool): + data = { + 'version': 1, + 'name': label, + 'objects': dict(sorted(self.nv_objects.nv_objects.items())), + 'connections': sorted(self.nv_connections.nv_connections) + } + save_data_to_file( + data=data, + path=( + f'{DATAPATH}/{output_directory}' + ), + file=f'data_{label}.json', + make_default=make_default, + ) + +class TopologyStatic(Topology): + def __init__( + self, + connections: Sequence[StaticConnection], + emblems: Emblems, + host_cache: HostCache, + ): + super().__init__( + emblems=emblems, + host_cache=host_cache, + ) + self.connections: Sequence[StaticConnection] = connections + + def create(self): + for connection in self.connections: + LOGGER.info(msg=f'connection: {connection}') + self.nv_objects.add_host( + host=connection.right_host, + host_cache=self.host_cache, + emblem=self.emblems.host_node + ) + self.nv_objects.add_host( + host=connection.left_host, + host_cache=self.host_cache, + emblem=self.emblems.host_node + ) + if connection.right_service: + self.nv_objects.add_service( + host=connection.right_host, + host_cache=self.host_cache, + emblem=self.emblems.service_node, + service=connection.right_service + ) + self.nv_connections.add_connection( + left=connection.right_host, + right=f'{connection.right_service}@{connection.right_host}', + ) + + if connection.left_service: + self.nv_objects.add_service( + host=connection.left_host, + host_cache=self.host_cache, + emblem=self.emblems.service_node, + service=connection.left_service + ) + self.nv_connections.add_connection( + left=connection.left_host, + right=f'{connection.left_service}@{connection.left_host}', + ) + + if connection.right_service and connection.left_service: + self.nv_connections.add_connection( + left=f'{connection.right_service}@{connection.right_host}', + right=f'{connection.left_service}@{connection.left_host}', + ) + elif connection.right_service: # connect right_service with left_host + self.nv_connections.add_connection( + left=f'{connection.right_service}@{connection.right_host}', + right=f'{connection.left_host}', + ) + elif connection.left_service: # connect left_service with right_host + self.nv_connections.add_connection( + left=f'{connection.right_host}', + right=f'{connection.left_service}@{connection.left_host}', + ) + else: # connect right_host with left_host + self.nv_connections.add_connection( + left=f'{connection.right_host}', + right=f'{connection.left_host}', + ) + +class TopologyL2(Topology): + def __init__( + self, + emblems: Emblems, + host_cache: HostCache, + case: str, + inv_columns: InventoryColumns, + l2_drop_hosts: List[str], + l2_host_map: Dict[str, str], + l2_neighbour_replace_regex: List[Tuple[str, str]], + label: str, + path_in_inventory: str, + prefix: str, + remove_domain: bool, + seed_devices: Sequence[str], + ): + super().__init__( + emblems=emblems, + host_cache=host_cache, + ) + self.case: str = case + self.inv_columns: InventoryColumns = inv_columns + self.l2_drop_hosts: List[str] = l2_drop_hosts + self.l2_host_map: Dict[str, str] = l2_host_map + self.l2_neighbour_replace_regex: List[Tuple[str, str]] = l2_neighbour_replace_regex + self.label: str = label + self.neighbour_to_host: MutableMapping[str, str] = {} + self.path_in_inventory: str = path_in_inventory + self.prefix: str = prefix + self.remove_domain: bool = remove_domain + self.seed_devices: Sequence[str] = seed_devices + + def create(self): + if not (devices_to_go := list(set(self.seed_devices))): # remove duplicates + LOGGER.error('No seed devices configured!') + return + + devices_done = [] + + while devices_to_go: + device = devices_to_go[0] + + if device in self.l2_host_map.keys(): + try: + devices_to_go.remove(device) + except ValueError: + pass + device = self.l2_host_map[device] + if device in devices_done: + continue + + topo_data = self.host_cache.get_data( + host=device, item=CacheItems.inventory, path=self.path_in_inventory + ) + if topo_data: + self.device_from_inventory( + host=device, + inv_data=topo_data, + ) + + for _entry in self.nv_objects.host_list: + if _entry not in devices_done: + devices_to_go.append(_entry) + + devices_to_go = list(set(devices_to_go)) + devices_done.append(device) + devices_to_go.remove(device) + LOGGER.info(msg=f'Device done: {device}, source: {self.label}') + + def device_from_inventory( + self, + host: str, + inv_data, + ): + for topo_neighbour in inv_data: + # check if required data are not empty + if not (neighbour := topo_neighbour.get(self.inv_columns.neighbour)): + LOGGER.warning(f'incomplete data, neighbour missing {topo_neighbour}') + continue + if not (raw_local_port := topo_neighbour.get(self.inv_columns.local_port)): + LOGGER.warning(f'incomplete data, local port missing {topo_neighbour}') + continue + if not (raw_neighbour_port := topo_neighbour.get(self.inv_columns.neighbour_port)): + LOGGER.warning(f'incomplete data, neighbour port missing {topo_neighbour}') + continue + + if not (neighbour:= self.get_host_from_neighbour(neighbour)): + continue + + # getting/checking interfaces + local_port = get_service_by_interface(host, raw_local_port, self.host_cache) + if not local_port: + local_port = raw_local_port + LOGGER.warning(msg=f'service not found: host: {host}, raw_local_port: {raw_local_port}') + elif local_port != raw_local_port: + # local_port = raw_local_port # don't reset local_port + LOGGER.info( + msg=f'host: {host}, raw_local_port: {raw_local_port} -> local_port: {local_port}' + ) + + neighbour_port = get_service_by_interface(neighbour, raw_neighbour_port, self.host_cache) + if not neighbour_port: + neighbour_port = raw_neighbour_port + LOGGER.warning( + msg=f'service not found: neighbour: {neighbour}, ' + f'raw_neighbour_port: {raw_neighbour_port}' + ) + elif neighbour_port != raw_neighbour_port: + # neighbour_port = raw_neighbour_port # don't reset neighbour_port + LOGGER.info( + msg=f'neighbour: {neighbour}, raw_neighbour_port {raw_neighbour_port} ' + f'-> neighbour_port {neighbour_port}' + ) + + metadata = { + 'duplex': topo_neighbour.get('duplex'), + 'native_vlan': topo_neighbour.get('native_vlan'), + } + + self.nv_objects.add_host(host=host, host_cache=self.host_cache) + self.nv_objects.add_host(host=neighbour, host_cache=self.host_cache) + self.nv_objects.add_interface( + host=str(host), + service=str(local_port), + host_cache=self.host_cache, + metadata=metadata, + name=str(raw_local_port), + item=str(local_port) + ) + self.nv_objects.add_interface( + host=str(neighbour), + service=str(neighbour_port), + host_cache=self.host_cache, + name=str(raw_neighbour_port), + item=str(neighbour_port) + ) + self.nv_connections.add_connection( + left=str(host), + right=f'{local_port}@{host}', + ) + self.nv_connections.add_connection( + left=str(neighbour), + right=f'{neighbour_port}@{neighbour}', + ) + self.nv_connections.add_connection( + left=f'{local_port}@{host}', + right=f'{neighbour_port}@{neighbour}', + ) + + def get_host_from_neighbour(self, neighbour: str) -> str | None: + try: + return self.neighbour_to_host[neighbour] + except KeyError: + pass + + if neighbour in self.l2_drop_hosts: + LOGGER.info(msg=f'drop neighbour: {neighbour}') + self.neighbour_to_host[neighbour] = None + return None + + if self.l2_neighbour_replace_regex: + for re_str, replace_str in self.l2_neighbour_replace_regex: + re_neighbour = re_sub(re_str, replace_str, neighbour) + if re_neighbour != neighbour: + LOGGER.info(f'regex changed Neighbor |{neighbour}| to |{re_neighbour}|') + neighbour = re_neighbour + if not neighbour: + LOGGER.info(f'Neighbour removed by regex (|{neighbour}|, |{re_str}|, |{replace_str}|)') + break + if not neighbour: + self.neighbour_to_host[neighbour] = None + return None + + if self.remove_domain: + neighbour = neighbour.split('.')[0] + + # drop neighbour after domain split + if neighbour in self.l2_drop_hosts: + LOGGER.info(msg=f'drop neighbour: {neighbour}') + self.neighbour_to_host[neighbour] = None + return None + + if self.case == 'UPPER': + neighbour = neighbour.upper() + LOGGER.debug(f'Changed neighbour to upper case: {neighbour}') + elif self.case == 'LOWER': + neighbour = neighbour.lower() + LOGGER.debug(f'Changed neighbour to lower case: {neighbour}') + + if self.prefix: + neighbour = f'{self.prefix}{neighbour}' + # rewrite neighbour if inventory neighbour and checkmk host don't match + if neighbour in self.l2_host_map.keys(): + neighbour = self.l2_host_map[neighbour] + + return neighbour + +class TopologyL3(Topology): + def __init__( + self, + emblems: Emblems, + host_cache: HostCache, + ignore_hosts: Sequence[str], + ignore_ips: Sequence[ip_network], + ignore_wildcard: Sequence[Wildcard], + include_hosts: bool, + replace: Mapping[str, str], + skip_if: bool, + skip_ip: bool, + summarize: Sequence[ip_network], + version: int + ): + super().__init__( + emblems=emblems, + host_cache=host_cache, + ) + self.ignore_hosts: Sequence[str] = ignore_hosts + self.ignore_ips: Sequence[ip_network] = ignore_ips + self.ignore_wildcard: Sequence[Wildcard] = ignore_wildcard + self.include_hosts: bool = include_hosts + self.replace: Mapping[str, str] = replace + self.skip_if: bool = skip_if + self.skip_ip: bool = skip_ip + self.summarize: Sequence[ip_network] = summarize + self.version = version + + def create(self): + match self.version: + case IPVersion.IPv4: + host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HOST_LABEL_L3V4_ROUTER) + + if self.include_hosts: + host_list += self.host_cache.get_hosts_by_label(HOST_LABEL_L3V4_HOSTS) + + case IPVersion.IPv6: + host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HOST_LABEL_L3V6_ROUTER) + + if self.include_hosts: + host_list += self.host_cache.get_hosts_by_label(HOST_LABEL_L3V6_HOSTS) + + case _: + host_list = [] + + LOGGER.debug(f'host list: {host_list}') + if not host_list: + LOGGER.warning( + msg='No (routing capable) host found. Check if "inv_ip_addresses.mkp" ' + 'added/enabled and inventory and host label discovery has run.' + ) + return + + LOGGER.debug(f'L3 ignore hosts: {self.ignore_hosts}') + for raw_host in host_list: + host = raw_host + if host in self.ignore_hosts: + LOGGER.info(f'L3 host {host} ignored') + continue + if not (inv_ip_addresses := self.host_cache.get_data( + host=host, item=CacheItems.inventory, path=PATH_L3) + ): + LOGGER.warning(f'No IP address inventory found for host: {host}') + continue + + self.nv_objects.add_host(host=host, host_cache=self.host_cache) + for inv_ip_address in inv_ip_addresses: + emblem = self.emblems.ip_network + try: + ip_info = IpInfo( + address=inv_ip_address['address'], + device=inv_ip_address['device'], + broadcast=inv_ip_address['broadcast'], + cidr=inv_ip_address['cidr'], + netmask=inv_ip_address['netmask'], + network=inv_ip_address['network'], + type=inv_ip_address['type'], + scope_id=inv_ip_address.get('scope_id'), # this is an optional field + ) + except KeyError: + LOGGER.warning(f'Drop IP address data for host: {host}, data: {inv_ip_address}') + continue + + interface_address: ip_interface = ip_interface(f'{ip_info.address}/{ip_info.cidr}') + if interface_address.version != self.version: + LOGGER.info( + f'host: {host} dropped non IPv{self.version} address: {ip_info.address},' + f' type: {ip_info.type}' + ) + continue + + if interface_address.is_loopback: + LOGGER.info(f'host: {host} dropped loopback address: {ip_info.address}') + continue + + if interface_address.is_link_local: + LOGGER.info(f'host: {host} dropped link-local address: {ip_info.address}') + continue + + # if interface_address.network.prefixlen == 32 or interface_address.network.prefixlen == 128: # drop host addresses + # LOGGER.info( + # f'host: {host} dropped host address: {ip_info.address}/{ip_info.cidr}' + # ) + # continue + + if is_ignore_ip(ip_info.address, self.ignore_ips): + LOGGER.info(f'host: {host} dropped ignore address: {ip_info.address}') + continue + + if is_ignore_wildcard(ip_info.address, self.ignore_wildcard): + LOGGER.info(f'host: {host} dropped wildcard address: {ip_info.address}') + continue + + if network := get_network_summary( + raw_ip_address=ip_info.address, + summarize=self.summarize, + ): + emblem = self.emblems.l3_summarize + LOGGER.info( + f'Network summarized: {ip_info.network}/{ip_info.cidr} -> {network}' + ) + else: + network = f'{ip_info.network}/{ip_info.cidr}' + + if network in self.replace.keys(): + LOGGER.info(f'Replaced network {network} with {self.replace[network]}') + network = self.replace[network] + emblem = self.emblems.l3_replace + + self.nv_objects.add_ip_network(network=network, emblem=emblem) + + if self.skip_if is True and self.skip_ip is True: + self.nv_connections.add_connection(left=host, right=network) + elif self.skip_if is True and self.skip_ip is False: + self.nv_objects.add_ip_address( + host=host, + interface=None, + raw_ip_address=ip_info.address, + emblem=self.emblems.ip_address, + ) + self.nv_objects.add_tooltip_quickinfo( + '{ip_info.address}@{host}', 'Interface', ip_info.device + ) + self.nv_connections.add_connection(left=f'{host}', right=f'{ip_info.address}@{host}') + self.nv_connections.add_connection(left=network, right=f'{ip_info.address}@{host}') + elif self.skip_if is False and self.skip_ip is True: + self.nv_objects.add_interface( + host=host, service=ip_info.device, host_cache=self.host_cache + ) + self.nv_objects.add_tooltip_quickinfo( + f'{ip_info.device}@{host}', 'IP-address', ip_info.address + ) + self.nv_connections.add_connection(left=f'{host}', right=f'{ip_info.device}@{host}') + self.nv_connections.add_connection(left=network, right=f'{ip_info.device}@{host}') + else: + self.nv_objects.add_ip_address( + host=host, + interface=ip_info.device, + raw_ip_address=ip_info.address, + emblem=self.emblems.ip_address, + ) + self.nv_objects.add_interface( + host=host, service=ip_info.device, host_cache=self.host_cache, + ) + self.nv_connections.add_connection( + left=host, right=f'{ip_info.device}@{host}') + self.nv_connections.add_connection( + left=f'{ip_info.device}@{host}', + right=f'{ip_info.address}@{ip_info.device}@{host}', + ) + self.nv_connections.add_connection( + left=network, right=f'{ip_info.address}@{ip_info.device}@{host}', + ) + + def map_speed_to_thickness(speed_to_map: int, speed_map: Sequence[Thickness]) -> int: thickness: int = 1 # use in case of empty MAP_SPEED_TO_THICKNESS for speed, thickness in speed_map: @@ -584,27 +1070,33 @@ def close_tooltip_html(metadata: Dict) -> Dict: return metadata -def get_network_summary(ipv4_address: str, summarize: Sequence[IPv4Network]) -> str | None: +def get_network_summary(raw_ip_address: str, summarize: Sequence[ip_network]) -> str | None: for network in summarize: - if IPv4Network(ipv4_address).subnet_of(network): - return network.exploded + try: + if ip_network(raw_ip_address).subnet_of(network): + return network.compressed + except TypeError: + pass return None -def is_ignore_ipv4(ip_address: str, ignore_ips: Sequence[IPv4Network]) -> bool: +def is_ignore_ip(raw_ip_address: str, ignore_ips: Sequence[ip_network]) -> bool: for ip in ignore_ips: - if IPv4Network(ip_address).subnet_of(ip): - LOGGER.info(f'IP address {ip_address} is in ignore list -> ({ip})') - return True + try: + if ip_network(raw_ip_address).subnet_of(ip): + LOGGER.info(f'IP address {raw_ip_address} is in ignore list -> ({ip})') + return True + except TypeError: + continue return False -def is_ignore_wildcard(ip_address: str, ignore_wildcard: Sequence[Wildcard]) -> bool: - int_ip_address = int(IPv4Address(ip_address)) +def is_ignore_wildcard(raw_ip_address: str, ignore_wildcard: Sequence[Wildcard]) -> bool: + int_ip_address = int(ip_address(raw_ip_address)) for wildcard in ignore_wildcard: if int_ip_address & wildcard.int_wildcard == wildcard.bit_pattern: LOGGER.info( - f'IP address {ip_address} matches ignore wildcard ' + f'IP address {raw_ip_address} matches ignore wildcard ' f'list ({wildcard.ip_address}/{wildcard.wildcard})' ) return True @@ -616,387 +1108,3 @@ def get_list_of_devices(data: Mapping) -> List[str]: for connection in data.values(): devices.append(connection[0]) return list(set(devices)) - - -def create_static_connections( - connections_: Sequence[StaticConnection], - emblems: Emblems, - host_cache: HostCache, - nv_connections: NvConnections, - nv_objects: NvObjects, -): - for connection in connections_: - LOGGER.info(msg=f'connection: {connection}') - nv_objects.add_host( - host=connection.right_host, - host_cache=host_cache, - emblem=emblems.host_node - ) - nv_objects.add_host( - host=connection.left_host, - host_cache=host_cache, - emblem=emblems.host_node - ) - if connection.right_service: - nv_objects.add_service( - host=connection.right_host, - host_cache=host_cache, - emblem=emblems.service_node, - service=connection.right_service - ) - nv_connections.add_connection( - left=connection.right_host, - right=f'{connection.right_service}@{connection.right_host}', - ) - - if connection.left_service: - nv_objects.add_service( - host=connection.left_host, - host_cache=host_cache, - emblem=emblems.service_node, - service=connection.left_service - ) - nv_connections.add_connection( - left=connection.left_host, - right=f'{connection.left_service}@{connection.left_host}', - ) - - if connection.right_service and connection.left_service: - nv_connections.add_connection( - left=f'{connection.right_service}@{connection.right_host}', - right=f'{connection.left_service}@{connection.left_host}', - ) - elif connection.right_service: # connect right_service with left_host - nv_connections.add_connection( - left=f'{connection.right_service}@{connection.right_host}', - right=f'{connection.left_host}', - ) - elif connection.left_service: # connect left_service with right_host - nv_connections.add_connection( - left=f'{connection.right_host}', - right=f'{connection.left_service}@{connection.left_host}', - ) - else: # connect right_host with left_host - nv_connections.add_connection( - left=f'{connection.right_host}', - right=f'{connection.left_host}', - ) - - -def create_l2_device_from_inv( - case: str, - host: str, - host_cache: HostCache, - inv_columns: InventoryColumns, - inv_data: Sequence[Mapping[str, str]], - l2_drop_hosts: List, - l2_host_map: Dict[str, str], - l2_neighbour_replace_regex: List[Tuple[str, str]] | None, - nv_connections: NvConnections, - nv_objects: NvObjects, - prefix: str, - remove_domain: bool, -) -> None: - for topo_neighbour in inv_data: - # check if required data are not empty - if not (neighbour := topo_neighbour.get(inv_columns.neighbour)): - LOGGER.warning(f'incomplete data, neighbour missing {topo_neighbour}') - continue - if not (raw_local_port := topo_neighbour.get(inv_columns.local_port)): - LOGGER.warning(f'incomplete data, local port missing {topo_neighbour}') - continue - if not (raw_neighbour_port := topo_neighbour.get(inv_columns.neighbour_port)): - LOGGER.warning(f'incomplete data, neighbour port missing {topo_neighbour}') - continue - - # drop neighbour before domain split - if neighbour in l2_drop_hosts: - LOGGER.info(msg=f'drop neighbour: {neighbour}') - continue - - if l2_neighbour_replace_regex: - for re_str, replace_str in l2_neighbour_replace_regex: - re_neighbour = re_sub(re_str, replace_str, neighbour) - if re_neighbour != neighbour: - LOGGER.info(f'regex changed Neighbor |{neighbour}| to |{re_neighbour}|') - neighbour = re_neighbour - if not neighbour: - LOGGER.info(f'Neighbour removed by regex (|{neighbour}|, |{re_str}|, |{replace_str}|)') - break - if not neighbour: - continue - - if remove_domain: - neighbour = neighbour.split('.')[0] - - # drop neighbour after domain split - if neighbour in l2_drop_hosts: - LOGGER.info(msg=f'drop neighbour: {neighbour}') - continue - - if case == 'UPPER': - neighbour = neighbour.upper() - LOGGER.debug(f'Changed neighbour to upper case: {neighbour}') - elif case == 'LOWER': - neighbour = neighbour.lower() - LOGGER.debug(f'Changed neighbour to lower case: {neighbour}') - - if prefix: - neighbour = f'{prefix}{neighbour}' - # rewrite neighbour if inventory neighbour and checkmk host don't match - if neighbour in l2_host_map.keys(): - neighbour = l2_host_map[neighbour] - - # getting/checking interfaces - local_port = get_service_by_interface(host, raw_local_port, host_cache) - if not local_port: - local_port = raw_local_port - LOGGER.warning(msg=f'service not found: host: {host}, raw_local_port: {raw_local_port}') - elif local_port != raw_local_port: - # local_port = raw_local_port # don't reset local_port - LOGGER.info( - msg=f'host: {host}, raw_local_port: {raw_local_port} -> local_port: {local_port}' - ) - - neighbour_port = get_service_by_interface(neighbour, raw_neighbour_port, host_cache) - if not neighbour_port: - neighbour_port = raw_neighbour_port - LOGGER.warning( - msg=f'service not found: neighbour: {neighbour}, ' - f'raw_neighbour_port: {raw_neighbour_port}' - ) - elif neighbour_port != raw_neighbour_port: - # neighbour_port = raw_neighbour_port # don't reset neighbour_port - LOGGER.info( - msg=f'neighbour: {neighbour}, raw_neighbour_port {raw_neighbour_port} ' - f'-> neighbour_port {neighbour_port}' - ) - - metadata = { - 'duplex': topo_neighbour.get('duplex'), - 'native_vlan': topo_neighbour.get('native_vlan'), - } - - nv_objects.add_host(host=host, host_cache=host_cache) - nv_objects.add_host(host=neighbour, host_cache=host_cache) - nv_objects.add_interface( - host=str(host), - service=str(local_port), - host_cache=host_cache, - metadata=metadata, - name=str(raw_local_port), - item=str(local_port) - ) - nv_objects.add_interface( - host=str(neighbour), - service=str(neighbour_port), - host_cache=host_cache, - name=str(raw_neighbour_port), - item=str(neighbour_port) - ) - nv_connections.add_connection( - left=str(host), - right=f'{local_port}@{host}', - ) - nv_connections.add_connection( - left=str(neighbour), - right=f'{neighbour_port}@{neighbour}', - ) - nv_connections.add_connection( - left=f'{local_port}@{host}', - right=f'{neighbour_port}@{neighbour}', - ) - - -def create_l2_topology( - case: str, - host_cache: HostCache, - inv_columns: InventoryColumns, - l2_drop_hosts: List[str], - l2_host_map: Dict[str, str], - l2_neighbour_replace_regex: List[Tuple[str, str]], - label_: str, - nv_connections: NvConnections, - nv_objects: NvObjects, - path_in_inventory: str, - prefix: str, - remove_domain: bool, - seed_devices: Sequence[str], -) -> None: - devices_to_go = list(set(seed_devices)) # remove duplicates - devices_done = [] - - while devices_to_go: - device = devices_to_go[0] - - if device in l2_host_map.keys(): - try: - devices_to_go.remove(device) - except ValueError: - pass - device = l2_host_map[device] - if device in devices_done: - continue - - topo_data = host_cache.get_data( - host=device, item=CacheItems.inventory, path=path_in_inventory - ) - if topo_data: - create_l2_device_from_inv( - host=device, - inv_data=topo_data, - inv_columns=inv_columns, - l2_host_map=l2_host_map, - l2_drop_hosts=l2_drop_hosts, - l2_neighbour_replace_regex=l2_neighbour_replace_regex, - host_cache=host_cache, - nv_objects=nv_objects, - nv_connections=nv_connections, - case=case, - prefix=prefix, - remove_domain=remove_domain - ) - - for _entry in nv_objects.host_list: - if _entry not in devices_done: - devices_to_go.append(_entry) - - devices_to_go = list(set(devices_to_go)) - devices_done.append(device) - devices_to_go.remove(device) - LOGGER.info(msg=f'Device done: {device}, source: {label_}') - - -def create_l3v4_topology( - emblems: Emblems, - host_cache: HostCache, - ignore_hosts: Sequence[str], - ignore_ips: Sequence[IPv4Network], - ignore_wildcard: Sequence[Wildcard], - include_hosts: bool, - nv_connections: NvConnections, - nv_objects: NvObjects, - replace: Mapping[str, str], - skip_if: bool, - skip_ip: bool, - summarize: Sequence[IPv4Network], -) -> None: - host_list: Sequence[str] = host_cache.get_hosts_by_label(HOST_LABEL_L3V4_ROUTER) - - if include_hosts: - host_list += host_cache.get_hosts_by_label(HOST_LABEL_L3V4_HOSTS) - - LOGGER.debug(f'host list: {host_list}') - if not host_list: - LOGGER.warning( - msg='No (routing capable) host found. Check if "inv_ip_addresses.mkp" ' - 'added/enabled and inventory and host label discovery has run.' - ) - return - - LOGGER.debug(f'L3v4 ignore hosts: {ignore_hosts}') - for raw_host in host_list: - host = raw_host - if host in ignore_hosts: - LOGGER.info(f'L3v4 host {host} ignored') - continue - if not (ipv4_addresses := host_cache.get_data( - host=host, item=CacheItems.inventory, path=PATH_L3v4) - ): - LOGGER.warning(f'No IPv4 address inventory found for host: {host}') - continue - - nv_objects.add_host(host=host, host_cache=host_cache) - for _entry in ipv4_addresses: - emblem = emblems.ip_network - try: - ipv4_info = Ipv4Info(**_entry) - except TypeError: # as e - LOGGER.warning(f'Drop IPv4 address data for host: {host}, data: {_entry}') - continue - - if ipv4_info.address.startswith('127.'): # drop loopback addresses - LOGGER.info(f'host: {host} dropped loopback address: {ipv4_info.address}') - continue - - if ipv4_info.cidr == 32: # drop host addresses - LOGGER.info( - f'host: {host} dropped host address: {ipv4_info.address}/{ipv4_info.cidr}' - ) - continue - - if ipv4_info.type.lower() != 'ipv4': # drop if not ipv4 - LOGGER.warning( - f'host: {host} dropped non ipv4 address: {ipv4_info.address},' - f' type: {ipv4_info.type}' - ) - continue - - if is_ignore_ipv4(ipv4_info.address, ignore_ips): - LOGGER.info(f'host: {host} dropped ignore address: {ipv4_info.address}') - continue - - if is_ignore_wildcard(ipv4_info.address, ignore_wildcard): - LOGGER.info(f'host: {host} dropped wildcard address: {ipv4_info.address}') - continue - - if network := get_network_summary( - ipv4_address=ipv4_info.address, - summarize=summarize, - ): - emblem = emblems.l3v4_summarize - LOGGER.info( - f'Network summarized: {ipv4_info.network}/{ipv4_info.cidr} -> {network}' - ) - else: - network = f'{ipv4_info.network}/{ipv4_info.cidr}' - - if network in replace.keys(): - LOGGER.info(f'Replaced network {network} with {replace[network]}') - network = replace[network] - emblem = emblems.l3v4_replace - - nv_objects.add_ipv4_network(network=network, emblem=emblem) - - if skip_if is True and skip_ip is True: - nv_connections.add_connection(left=host, right=network) - elif skip_if is True and skip_ip is False: - nv_objects.add_ipv4_address( - host=host, - interface=None, - ipv4_address=ipv4_info.address, - emblem=emblems.ip_address, - ) - nv_objects.add_tooltip_quickinfo( - '{ipv4_info.address}@{host}', 'Interface', ipv4_info.device - ) - nv_connections.add_connection(left=f'{host}', right=f'{ipv4_info.address}@{host}') - nv_connections.add_connection(left=network, right=f'{ipv4_info.address}@{host}') - elif skip_if is False and skip_ip is True: - nv_objects.add_interface( - host=host, service=ipv4_info.device, host_cache=host_cache - ) - nv_objects.add_tooltip_quickinfo( - f'{ipv4_info.device}@{host}', 'IP-address', ipv4_info.address - ) - nv_connections.add_connection(left=f'{host}', right=f'{ipv4_info.device}@{host}') - nv_connections.add_connection(left=network, right=f'{ipv4_info.device}@{host}') - else: - nv_objects.add_ipv4_address( - host=host, - interface=ipv4_info.device, - ipv4_address=ipv4_info.address, - emblem=emblems.ip_address, - ) - nv_objects.add_interface( - host=host, service=ipv4_info.device, host_cache=host_cache, - ) - nv_connections.add_connection( - left=host, right=f'{ipv4_info.device}@{host}') - nv_connections.add_connection( - left=f'{ipv4_info.device}@{host}', - right=f'{ipv4_info.address}@{ipv4_info.device}@{host}', - ) - nv_connections.add_connection( - left=network, right=f'{ipv4_info.address}@{ipv4_info.device}@{host}', - ) diff --git a/source/bin/nvdct/lib/utils.py b/source/bin/nvdct/lib/utils.py index 4bd7f57..c504922 100755 --- a/source/bin/nvdct/lib/utils.py +++ b/source/bin/nvdct/lib/utils.py @@ -7,10 +7,9 @@ # Date : 2023-10-12 # File : nvdct/lib/utils.py -from collections.abc import Mapping, Sequence from ast import literal_eval +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from enum import Enum, unique from json import dumps from logging import disable as log_off, Formatter, getLogger, StreamHandler from logging.handlers import RotatingFileHandler @@ -24,33 +23,15 @@ from typing import List, Dict, TextIO from lib.constants import ( CMK_SITE_CONF, - COLUMNS_CDP, - COLUMNS_LLDP, - HOST_LABEL_CDP, - HOST_LABEL_L3V4_ROUTER, - HOST_LABEL_LLDP, - LABEL_CDP, - LABEL_L3v4, - LABEL_LLDP, + DATAPATH, + ExitCodes, LOGGER, OMD_ROOT, - PATH_CDP, - PATH_L3v4, - PATH_LLDP, - DATAPATH, ) -@unique -class ExitCodes(Enum): - OK = 0 - BAD_OPTION_LIST = 1 - BAD_TOML_FORMAT = 2 - BACKEND_NOT_IMPLEMENTED = 3 - AUTOMATION_SECRET_NOT_FOUND = 4 - NO_LAYER_CONFIGURED = 5 @dataclass(frozen=True) -class Ipv4Info: +class IpInfo: address: str device: str broadcast: str @@ -58,6 +39,7 @@ class Ipv4Info: netmask: str network: str type: str + scope_id: str | None @dataclass(frozen=True) class InventoryColumns: @@ -65,35 +47,6 @@ class InventoryColumns: local_port: str neighbour_port: str -@dataclass(frozen=True) -class Layer: - path: str - columns: str - label: str - host_label: str - -LAYERS = { - 'CDP': Layer( - path=PATH_CDP, - columns=COLUMNS_CDP, - label=LABEL_CDP, - host_label=HOST_LABEL_CDP, - ), - 'LLDP': Layer( - path=PATH_LLDP, - columns=COLUMNS_LLDP, - label=LABEL_LLDP, - host_label=HOST_LABEL_LLDP, - ), - 'L3v4': Layer( - path=PATH_L3v4, - columns='', - label=LABEL_L3v4, - host_label=HOST_LABEL_L3V4_ROUTER, - ), -} - - def get_local_cmk_version() -> str: return Path(f'{OMD_ROOT}/version').readlink().name @@ -133,7 +86,7 @@ def get_data_from_toml(file: str) -> Dict: msg=f'ERROR: data file {toml_file} is not in valid TOML format! ({e}),' f' (see https://toml.io/en/)' ) - sys_exit(ExitCodes.BAD_TOML_FORMAT.value) + sys_exit(ExitCodes.BAD_TOML_FORMAT) else: LOGGER.error(msg=f'WARNING: User data {file} not found.') @@ -414,7 +367,6 @@ def get_table_from_inventory(inventory: Dict[str, object], raw_path: str) -> Lis return None for m in path: try: - # print(raw_table[m]) table = table[m] except KeyError: LOGGER.info(msg=f'Inventory table for {path} not found') diff --git a/source/bin/nvdct/nvdct.py b/source/bin/nvdct/nvdct.py index 8966b10..ffd8468 100755 --- a/source/bin/nvdct/nvdct.py +++ b/source/bin/nvdct/nvdct.py @@ -82,7 +82,7 @@ # added duplex mismatch ('yellow') # added native vlan mismatch ('orange') # 2024-03-25: added --pre-fetch, this will speed up the RESTAPI backend performance -# min inv_cdp_cahe version: 0.7.1-20240320 -> host label nvdct/has_cdp_neighbours +# min inv_cdp_cache version: 0.7.1-20240320 -> host label nvdct/has_cdp_neighbours # min inv_lldp_cache version: 0.9.3-20240320 -> host label nvdct/has_lldp_neighbours # 2024-03-27: added option --api-port, defaults to 80 # 2024-03-28: changed restapi get_interface_data to use one call to fetch all data @@ -107,38 +107,37 @@ # added IP-Address/IP-Network as quickinfo # 2024-05-18: fixed crash non empty neighbour port (ThX to andreas doehler) # 2024-06-05: fixed interface index padding -# 2024-06-06: added interfcae detection alias+index +# 2024-06-06: added interface detection alias+index # added interface detection description + index -# 2024-06-09: moved topologiy helpers to lib/topologies.py +# 2024-06-09: moved topology helpers to lib/topologies.py # moved (default) config file(s) to ./conf/ # 2024-06-14: added debug code for bad IPv4 address data # 2024-06-17: fixed bad IPv4 address data (just drop it) -# 2024-09-23: incompatible replaced options --lowercase/--uppercase with --case LOWER|UPPER +# 2024-09-23: INCOMPATIBLE: replaced options --lowercase/--uppercase with --case LOWER|UPPER # changed version output from settings to argparse action -# incompatible removed backend FILESYSTEM -> will fallback to MULTISITE -# incompatible -# removed support for CMK2.2.x file format (removed option --new-format) +# INCOMPATIBLE: removed backend FILESYSTEM -> will fallback to MULTISITE +# INCOMPATIBLE: removed support for CMK2.2.x file format (removed option --new-format) # 2024-09-24: added site filter for multisite deployments (MULTISITE only), option --filter-sites # and SITES section in toml file # added customer filter for MSP deployments (MULTISITE only), option --filter-customers # and section CUSTOMERS in toml file # 2024-11-16: added better logging for missing L2 data # 2024-11-17: added L2_NEIGHBOUR_REPLACE_REGEX (ThX to Frankb@checkmk forum for the base idea) -# incompatible removed DROP_HOST_REGEX -> use L2_NEIGHBOUR_REPLACE_REGEX instead -# incompatible changed section names in TOML file to better distinguish between L2 and L3v4 +# INCOMPATIBLE: removed DROP_HOST_REGEX -> use L2_NEIGHBOUR_REPLACE_REGEX instead +# INCOMPATIBLE: changed section names in TOML file to better distinguish between L2 and L3v4 # HOST_MAP -> L2_HOST_MAP # DROP_HOSTS -> L2_DROP_HOSTS # SEED_DEVICES -> L2_SEED_DEVICES -# incompatible removed option -s, --seed-devices from CLI -> use TOML section L2_SEED_DEVICES instead -# incompatible changed the option keep-domain to remove-domain -> don't mess with neighbor names by default -# 2024-12-08: incompatible: changed hostlabel for L3v4 topology to nvdct/l3v4_topology +# INCOMPATIBLE_ removed option -s, --seed-devices from CLI -> use TOML section L2_SEED_DEVICES instead +# INCOMPATIBLE: changed the option keep-domain to remove-domain -> don't mess with neighbor names by default +# 2024-12-08: INCOMPATIBLE: changed hostlabel for L3v4 topology to nvdct/l3v4_topology # needs at least inv_ip_address inv_ip_address-0.0.5-20241209.mkp # 2024-12-09: added option --include-l3-hosts # added site filter for RESTAPI backend # enabled customer filter for MULTISITE backend # 2024-12-10: refactoring: moved topology code to topologies, removed all global variables, created main() function -# 2024-12-11: incompatible: changed default layers to None -> use the CLI option -l CDP or the configfile instead -# incompatible: reworked static topology -> can now be used for each service, host/service name has to be +# 2024-12-11: INCOMPATIBLE: changed default layers to None -> use the CLI option -l CDP or the configfile instead +# INCOMPATIBLE: reworked static topology -> can now be used for each service, host/service name has to be # exactly like in CMK. See ~/local/bin/nvdct/conf/nfdct.toml # moved string constants to lib/constants.py # @@ -146,6 +145,18 @@ # - inv_lnx_if_ip-0.0.4-20241210.mkp # - inv_ip_address-0.0.6-20241210.mkp # - inv_win_if_ip-0.0.3-20241210.mkp +# 2024-12-20: fixed typo in TOML L3v4 -> "L3v4" (ThX to BH2005@checkmk_forum) +# fixed crash in topologies (devices_to_go.remove(device) ValueError if device not in list) (ThX to BH2005) +# 2024-12-23: streamlined L3v4 in preparation for L3v6 topology +# INCOMPATIBLE: changes in TOML: +# L3V4_IGNORE_HOSTS -> L3_IGNORE_HOSTS +# L3V4_IGNORE_IP -> L3_IGNORE_IP +# L3V4_SUMMARIZE -> L3_SUMMARIZE +# L3V4_REPLACE -> L3_REPLACE +# L3V4_IRNORE_WILDCARD -> L3V4_IGNORE_WILDCARD # Typo +# [EMBLEMS] +# l3v4_replace -> l3_replace +# l3v4_summarize -> l3_summarize # creating topology data json from inventory data # @@ -243,7 +254,6 @@ __data = { """ import sys -from logging import DEBUG from time import strftime, time_ns from typing import List @@ -255,37 +265,33 @@ from lib.backends import ( HostCacheRestApi, ) from lib.constants import ( + DATAPATH, HOME_URL, + IPVersion, + LABEL_L3v4, + LAYERS, LOGGER, + Layer, NVDCT_VERSION, - DATAPATH, ) from lib.settings import Settings from lib.topologies import ( - NvConnections, - NvObjects, - create_l2_topology, - create_l3v4_topology, - create_static_connections, + TopologyL2, + TopologyL3, + TopologyStatic, ) from lib.utils import ( ExitCodes, InventoryColumns, - LAYERS, - Layer, StdoutQuiet, configure_logger, remove_old_data, - save_data_to_file, ) def main(): start_time = time_ns() - nv_connections = NvConnections() - nv_objects = NvObjects() - settings: Settings = Settings(vars(parse_arguments())) sys.stdout = StdoutQuiet(quiet=settings.quiet) @@ -329,36 +335,38 @@ def main(): case _: LOGGER.error(msg=f'Backend {settings.backend} not (yet) implemented') host_cache: HostCache | None = None # to keep linter happy - sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED.value) + sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED) jobs: List[Layer] = [] pre_fetch_layers: List[str] = [] pre_fetch_host_list: List[str] = [] for layer in settings.layers: - if layer == 'STATIC': - jobs.append(layer) - if layer == 'L3v4': - jobs.append(layer) - host_cache.add_inventory_prefetch_path(path=LAYERS[layer].path) - pre_fetch_layers.append(LAYERS[layer].host_label) - - elif layer in LAYERS: - jobs.append(LAYERS[layer]) - host_cache.add_inventory_prefetch_path(path=LAYERS[layer].path) - pre_fetch_layers.append(LAYERS[layer].host_label) - - elif layer == 'CUSTOM': - for entry in settings.custom_layers: - jobs.append(entry) - host_cache.add_inventory_prefetch_path(entry.path) + match layer: + case 'STATIC': + jobs.append(layer) + case 'L3v4': + jobs.append(layer) + host_cache.add_inventory_path(path=LAYERS[layer].path) + pre_fetch_layers.append(LAYERS[layer].host_label) + case 'CUSTOM': + for entry in settings.custom_layers: + jobs.append(entry) + host_cache.add_inventory_path(entry.path) + case 'CDP' | 'LLDP': + jobs.append(LAYERS[layer]) + host_cache.add_inventory_path(path=LAYERS[layer].path) + pre_fetch_layers.append(LAYERS[layer].host_label) + case _: + LOGGER.warning(f'Unknown layer {layer} dropped.') + continue if not jobs: message = ('No layer to work on. Please configura at least one layer (i.e. CLI option "-l CDP")\n' 'See ~/local/bin/nvdct/conf/nvdct.toml -> SETTINGS -> layers') LOGGER.warning(message) print(message) - sys.exit(ExitCodes.NO_LAYER_CONFIGURED.value) + sys.exit(ExitCodes.NO_LAYER_CONFIGURED) if settings.pre_fetch: LOGGER.info('Pre fill cache...') @@ -367,95 +375,81 @@ def main(): pre_fetch_host_list = list(set(pre_fetch_host_list + _host_list)) LOGGER.info(f'Fetching data for {len(pre_fetch_host_list)} hosts start') print(f'Prefetch start: {strftime(settings.time_format)}') - print(f'Prefetch hosts: {len(pre_fetch_host_list)} of {len(host_cache.cache.keys())}') - host_cache.pre_fetch_cache(pre_fetch_host_list) + print(f'Prefetch hosts: {len(pre_fetch_host_list)} of {len(host_cache.cache)}') + host_cache.fill_cache(pre_fetch_host_list) LOGGER.info(f'Fetching data for {len(pre_fetch_host_list)} hosts end') print(f'Prefetch end..: {strftime(settings.time_format)}') for job in jobs: match job: case 'STATIC': - label = 'STATIC' - create_static_connections( - connections_=settings.static_connections, + label = job + topology = TopologyStatic( + connections=settings.static_connections, emblems=settings.emblems, host_cache=host_cache, - nv_objects=nv_objects, - nv_connections=nv_connections, ) + topology.create() + case 'L3v4': - label = 'L3v4' - create_l3v4_topology( - ignore_hosts=settings.l3v4_ignore_hosts, - ignore_ips=settings.l3v4_ignore_ips, + label = job + topology = TopologyL3( + emblems=settings.emblems, + host_cache=host_cache, + ignore_hosts=settings.l3_ignore_hosts, + ignore_ips=settings.l3_ignore_ips, ignore_wildcard=settings.l3v4_ignore_wildcard, include_hosts=settings.include_l3_hosts, - replace=settings.l3v4_replace, + replace=settings.l3_replace, skip_if=settings.skip_l3_if, skip_ip=settings.skip_l3_ip, - summarize=settings.l3v4_summarize, - emblems=settings.emblems, - host_cache=host_cache, - nv_objects=nv_objects, - nv_connections=nv_connections, + summarize=settings.l3_summarize, + version=IPVersion.IPv4 if job == LABEL_L3v4 else IPVersion.IPv6 ) + topology.create() + case _: label = job.label.upper() columns = job.columns.split(',') - create_l2_topology( - seed_devices=settings.l2_seed_devices, - path_in_inventory=job.path, + topology = TopologyL2( + case=settings.case, + emblems=settings.emblems, + host_cache=host_cache, inv_columns=InventoryColumns( neighbour=columns[0], local_port=columns[1], neighbour_port=columns[2] ), - label_=label, l2_drop_hosts=settings.l2_drop_hosts, l2_host_map=settings.l2_host_map, l2_neighbour_replace_regex=settings.l2_neighbour_replace_regex, - host_cache=host_cache, - nv_objects=nv_objects, - nv_connections=nv_connections, - case=settings.case, + label=label, + path_in_inventory=job.path, prefix=settings.prefix, remove_domain=settings.remove_domain, + seed_devices=settings.l2_seed_devices, ) + topology.create() + - nv_connections.add_meta_data_to_connections( - nv_objects=nv_objects, + topology.nv_connections.add_meta_data_to_connections( + nv_objects=topology.nv_objects, speed_map=settings.map_speed_to_thickness, ) - _data = { - 'version': 1, - 'name': label, - 'objects': nv_objects.nv_objects if not settings.loglevel == DEBUG else dict( - sorted(nv_objects.nv_objects.items()) - ), - 'connections': nv_connections.nv_connections if not settings.loglevel == DEBUG else sorted( - nv_connections.nv_connections - ) - } - save_data_to_file( - data=_data, - path=( - f'{DATAPATH}/{settings.output_directory}' - ), - file=f'data_{label}.json', - make_default=settings.default, + topology.save( + label=label, + output_directory=settings.output_directory, + make_default=settings.default ) message = ( - f'Layer {label:.<8s}: Devices/Objects/Connections added {nv_objects.host_count}/' - f'{len(nv_objects.nv_objects)}/{len(nv_connections.nv_connections)}' + f'Layer {label:.<8s}: Devices/Objects/Connections added {topology.nv_objects.host_count}/' + f'{len(topology.nv_objects.nv_objects)}/{len(topology.nv_connections.nv_connections)}' ) LOGGER.info(msg=message) print(message) - nv_objects = NvObjects() - nv_connections = NvConnections() - if settings.keep: remove_old_data( keep=settings.keep, diff --git a/source/packages/nvdct b/source/packages/nvdct index a41f73e..329c316 100644 --- a/source/packages/nvdct +++ b/source/packages/nvdct @@ -47,7 +47,7 @@ 'htdocs/images/icons/location_80.png']}, 'name': 'nvdct', 'title': 'Network Visualization Data Creation Tool (NVDCT)', - 'version': '0.9.5-20241217', + 'version': '0.9.6-20241222', 'version.min_required': '2.3.0b1', 'version.packaged': 'cmk-mkp-tool 0.2.0', 'version.usable_until': '2.4.0p1'} -- GitLab