From 27104d7c2db285bc7c480b63aa9bca62e2f0fed4 Mon Sep 17 00:00:00 2001 From: "th.l" <thl-cmk@outlook.com> Date: Wed, 11 Dec 2024 21:25:41 +0100 Subject: [PATCH] added option --include-l3-hosts, changed default layers to None, reworked static topology --- README.md | 2 +- mkp/nvdct-0.9.4-20241210.mkp | Bin 0 -> 42490 bytes source/bin/nvdct/conf/nvdct.toml | 49 ++- source/bin/nvdct/lib/args.py | 29 +- source/bin/nvdct/lib/backends.py | 86 +++-- source/bin/nvdct/lib/constants.py | 48 +++ source/bin/nvdct/lib/settings.py | 144 ++++---- source/bin/nvdct/lib/topologies.py | 480 +++++++++++++++++++++++- source/bin/nvdct/lib/utils.py | 118 +++--- source/bin/nvdct/nvdct.py | 574 +++++++---------------------- source/packages/nvdct | 17 +- 11 files changed, 899 insertions(+), 648 deletions(-) create mode 100644 mkp/nvdct-0.9.4-20241210.mkp create mode 100755 source/bin/nvdct/lib/constants.py diff --git a/README.md b/README.md index 35905d2..274747b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.3-20241209.mkp "nvdct-0.9.3-20241209.mkp" +[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.4-20241210.mkp "nvdct-0.9.4-20241210.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.4-20241210.mkp b/mkp/nvdct-0.9.4-20241210.mkp new file mode 100644 index 0000000000000000000000000000000000000000..f1bbfad565be60d5a472b0fba691807e167707c0 GIT binary patch literal 42490 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 V|JRco3SfE`JyDXfdpC#p?tkgLtuX)q literal 0 HcmV?d00001 diff --git a/source/bin/nvdct/conf/nvdct.toml b/source/bin/nvdct/conf/nvdct.toml index b5632b5..414861a 100755 --- a/source/bin/nvdct/conf/nvdct.toml +++ b/source/bin/nvdct/conf/nvdct.toml @@ -13,6 +13,7 @@ # # list of (additional to -s/--seed-devices) seed devices +# [0-9-a-zA-Z\.\_\-]{1,253} -> host L2_SEED_DEVICES = [ # "CORE01", # "LOCATION01", @@ -26,6 +27,7 @@ L2_DROP_HOSTS = [ ] # hosts will be ignored in L3v4 topology +# [0-9-a-zA-Z\.\_\-]{1,253} -> host L3V4_IGNORE_HOSTS = [ # "host1", # "host2", @@ -64,29 +66,39 @@ PROTECTED_TOPOLOGIES = [ ] # user defined static connections -# these connections will be added from host to neighbour and in reverese -# hosts/neighbours in this section will be added to SEED_DEVICES +# [0-9-a-zA-Z\.\_\-]{1,253} -> host STATIC_CONNECTIONS = [ - # ["cmk_host1", "local-port1", "neighbour-port1", "neighbour1", "label"], - # ["cmk_host1", "local-port2", "neighbour-port2", "neighbour2", "label"], + # valid entry formats + # ["left_host", "left_service", "right_service", "right_host"], + # connection: "left_host"<->"left_service"<->"right_service"<->"right_host" + # ["left_host", "", "right_service", "right_host"], + # connection: "left_host"<->"right_service"<->"right_host" + # ["left_host", "left_service", "", "right_host"], + # connection: "left_host"<->"left_service"<->"right_host" + # ["left_host", "", "", "right_host"], + # connection: "left_host"<->"right_host" ] -# optional custom layers use option -l/--layers CUSTOM to include this layers +# THIS OPTION IS DEPRECATED +# optional custom layers use option -l/--layers CUSTOM to include these layers # don't use --pre-fetch without a host_label that matches all host you want to add +# THIS OPTION IS DEPRECATED CUSTOM_LAYERS = [ # { path = "path,in,inventory", columns = "columns from inventory", label = "label for the layer", host_label = "CMK host label to find matching hosts" }, # { path = "networking,lldp_cache,neighbours", columns = "neighbour_name,local_port,neighbour_port", label = "custom_LLDP", host_label = "nvdct/has_lldp_neighbours" }, # { path = "networking,cdp_cache,neighbours", columns = "neighbour_name,local_port,neighbour_port", label = "custom_CDP", host_label = "nvdct/has_cdp_neighbours" }, ] -# list customers so include/excluse, use option --filter-costumers INCLUDE/EXCLUDE +# list customers to include/excluse, use option --filter-costumers INCLUDE/EXCLUDE +# [0-9-a-zA-Z\.\_\-]{1,16} -> customer CUSTOMERS = [ # "customer1", # "customer2", # "customer3", ] -# list site so include/excluse, use option --filter-sites INCLUDE/EXCLUDE +# list site to include/excluse, use option --filter-sites INCLUDE/EXCLUDE +# [0-9-a-zA-Z\.\_\-]{1,16} -> site SITES = [ # "site1", # "site2", @@ -94,6 +106,7 @@ SITES = [ ] # map inventory neighbour name to Checkmk host name +# [0-9-a-zA-Z\.\_\-]{1,253} -> host [L2_HOST_MAP] # inventory_neighbour1 = "cmk_host1" # inventory_neighbour2 = "cmk_host2" @@ -106,6 +119,7 @@ SITES = [ # "^Meraki.*\\s-\\s" = "" # replace network objects (takes place after summarize) +# [0-9-a-zA-Z\.\_\-]{1,253} -> host [L3V4_REPLACE] # "10.193.172.0/24" = "MPLS" # "10.194.8.0/23" = "MPLS" @@ -127,29 +141,30 @@ SITES = [ # must be sorted from slower to faster speed # use only one entry to have all conections with the same thickness # bits per second = thickness -# 2000000 = 1 # 2 mbit -# 5000000 = 2 # 5 mbit -# 1e7 = 3 # 10 mbit -# 51e7 = 4 # 51 mbit -1e8 = 1 # 100 mbit -1e9 = 3 # 1 gbit -1e10 = 5 # 10 gbit +# "2000000" = 1 # 2 mbit +# "5000000" = 2 # 5 mbit +# "1e7" = 3 # 10 mbit +# "51e7" = 4 # 51 mbit +"1e8" = 1 # 100 mbit +"1e9" = 3 # 1 gbit +"1e10" = 5 # 10 gbit [SETTINGS] -# api_port = 80 +# api_port = 5001 # backend = "MULTISITE" | "RESTAPI" | "LIVESTATUS" # case = "LOWER" | "UPPER" # default = false # dont_compare = false # filter_customers = "INCLUDE" |"EXCLUDE" # filter_sites = "INCLUDE" | "EXCLUDE" +# include_l3_hosts = false # keep = 0 -# layers = ["LLDP", "CDP", "STATIC", "CUSTOM", "L3v4"] +# layers = ["LLDP", "CDP", L3v4, "STATIC", "CUSTOM"] # log_file = "~/var/log/nvdct.log" # log_level = "WARNING" # log_to_stdout = false # min_age = 0 -# output_directory = '' +# output_directory = '' # # pre_fetch = false # prefix = "" # quiet = true diff --git a/source/bin/nvdct/lib/args.py b/source/bin/nvdct/lib/args.py index d113171..cf77c81 100755 --- a/source/bin/nvdct/lib/args.py +++ b/source/bin/nvdct/lib/args.py @@ -23,6 +23,7 @@ # --dont-compare # --filter-customers # --filter-sites +# --include-l3-hosts # --keep # --log-file # --log-level @@ -43,18 +44,19 @@ from argparse import ( ) from pathlib import Path -from lib.utils import ( - ExitCodes, +from lib.constants import ( HOME_URL, MIN_CDP_VERSION, + MIN_LINUX_IP_ADDRESSES, + MIN_SNMP_IP_ADDRESSES, + MIN_WINDOWS_IP_ADDRESSES, MIN_LLDP_VERSION, - MIN_IP_ADDRESSES, NVDCT_VERSION, - # SAMPLE_SEEDS, SCRIPT, TIME_FORMAT_ARGPARSER, USER_DATA_FILE, ) +from lib.utils import ExitCodes def parse_arguments() -> arg_Namespace: @@ -83,7 +85,7 @@ def parse_arguments() -> arg_Namespace: f' {ExitCodes.BACKEND_NOT_IMPLEMENTED.value} - Backend not implemented\n' f' {ExitCodes.AUTOMATION_SECRET_NOT_FOUND.value} - Automation secret not found\n' '\nUsage:\n' - f'{SCRIPT} -u ~/local/bin/nvdct/conf/{USER_DATA_FILE} \n\n' + f'{SCRIPT} -u ~/local/bin/nvdct/conf/my_{USER_DATA_FILE} \n\n' ) parser.add_argument( @@ -122,12 +124,13 @@ def parse_arguments() -> arg_Namespace: choices=['CDP', 'CUSTOM', 'LLDP', 'STATIC', 'L3v4'], # 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_IP_ADDRESSES}\n' - f' adds, layer 3 topology fpr IPv4\n' - f' - STATIC (deprecated)\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( @@ -192,6 +195,10 @@ def parse_arguments() -> arg_Namespace: help='INCLUDE/EXCLUDE site list from config file.\n' 'Note: MULTISITE backend only.', ) + parser.add_argument( + '--include-l3-hosts', action='store_const', const=True, # default=False, + help='Include hosts (single IP objects) in layer 3 topology', + ) parser.add_argument( '--remove-domain', action='store_const', const=True, # default=False, help='Remove the domain name from the neighbor name', diff --git a/source/bin/nvdct/lib/backends.py b/source/bin/nvdct/lib/backends.py index 611b7a6..64cce3a 100755 --- a/source/bin/nvdct/lib/backends.py +++ b/source/bin/nvdct/lib/backends.py @@ -23,14 +23,17 @@ from sys import exit as sys_exit from livestatus import MultiSiteConnection, SiteConfigurations, SiteId -from lib.utils import ( +from lib.constants import ( CACHE_INTERFACES_DATA, + OMD_ROOT, + PATH_INTERFACES, +) +from lib.utils import ( ExitCodes, get_data_form_live_status, get_table_from_inventory, LOGGER, - OMD_ROOT, - PATH_INTERFACES, + ) @@ -410,8 +413,8 @@ class HostCacheMultiSite(HostCacheLiveStatus): 'local site only. Try -b RESTAPI if you have a distributed environment.' ) - def filter_sites(self, filter: str| None, sites:List[str]): - match filter: + def filter_sites(self, filter_: str | None, sites: List[str]): + match filter_: case 'INCLUDE': self.sites = {site: data for site, data in self.sites.items() if site in sites} case 'EXCLUDE': @@ -419,8 +422,8 @@ class HostCacheMultiSite(HostCacheLiveStatus): case _: return - def filter_costumers(self, filter: str | None, costumers:List[str]): - match filter: + def filter_costumers(self, filter_: str | None, costumers: List[str]): + match filter_: case 'INCLUDE': self.sites = { site: data for site, data in self.sites.items() if data.get('customer') in costumers @@ -437,10 +440,17 @@ class HostCacheMultiSite(HostCacheLiveStatus): class HostCacheRestApi(HostCache): - def __init__(self, pre_fetch: bool, api_port: int): + def __init__( + self, + pre_fetch: bool, + api_port: int, + filter_sites: str | None = None, + sites: List[str] = [], + ): super().__init__(pre_fetch, '[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,9 +468,33 @@ class HostCacheRestApi(HostCache): self.__session.headers['Authorization'] = f"Bearer {self.__user} {self.__secret}" self.__session.headers['Accept'] = 'application/json' + self.get_sites() + self.filter_sites(filter_=filter_sites, sites=sites) + LOGGER.info(f'{self.backend} filtered sites : {self.sites}') + if self.pre_fetch: self.pre_fetch_hosts() + def get_sites(self): + 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}') + else: + LOGGER.warning(f'{self.backend} got no site information! status code {resp.status_code}') + LOGGER.debug(f'{self.backend} response text: {resp.text}') + + def filter_sites(self, filter_: str | None, sites: List[str]): + match filter_: + case 'INCLUDE': + self.sites = [site for site in self.sites if site in sites] + case 'EXCLUDE': + self.sites = [site for site in self.sites if site not in sites] + 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] = {} @@ -475,8 +509,9 @@ class HostCacheRestApi(HostCache): resp = self.__session.get( url=f"{self.__api_url}/domain-types/host/collections/all", params={ - "query": query, - "columns": ['name', 'mk_inventory'], + 'query': query, + 'columns': ['name', 'mk_inventory'], + 'sites': self.sites, }, ) if resp.status_code == 200: @@ -487,7 +522,7 @@ class HostCacheRestApi(HostCache): if host: host_data[host] = raw_host['extensions'].get('mk_inventory') else: - LOGGER.info( + LOGGER.warning( f'{self.backend} got no inventory data found!, status code {resp.status_code}' ) LOGGER.debug(f'{self.backend} response query: {query}') @@ -511,10 +546,11 @@ class HostCacheRestApi(HostCache): query = f'{{"op": "and", "expr": [{query_item},{query_host}]}}' resp = self.__session.get( - url=f"{self.__api_url}/domain-types/service/collections/all", + url=f'{self.__api_url}/domain-types/service/collections/all', params={ - "query": query, - "columns": ['host_name', 'description', 'long_plugin_output'], + 'query': query, + 'columns': ['host_name', 'description', 'long_plugin_output'], + 'sites': self.sites, }, ) @@ -556,10 +592,11 @@ class HostCacheRestApi(HostCache): # return False query = '{"op": "=", "left": "name", "right": "' + host + '"}' resp = self.__session.get( - url=f"{self.__api_url}/domain-types/host/collections/all", + url=f'{self.__api_url}/domain-types/host/collections/all', params={ - "query": query, - "columns": ['name'], + 'query': query, + 'columns': ['name'], + 'sites': self.sites, }, ) if resp.status_code == 200: @@ -579,14 +616,14 @@ class HostCacheRestApi(HostCache): def get_hosts_by_label(self, label: str) -> List[str] | None: LOGGER.debug(f'{self.backend} get_hosts_by_label {label}') - # query = '{"op": "=", "left": "label_names", "right": "' + label + '"}' query = '{"op": "=", "left": "labels", "right": "' + label + '"}' resp = self.__session.get( - url=f"{self.__api_url}/domain-types/host/collections/all", + url=f'{self.__api_url}/domain-types/host/collections/all', params={ - "query": query, - "columns": ['name', 'labels'], + 'columns': ['name', 'labels'], + 'query': query, + 'sites': self.sites, }, ) if resp.status_code == 200: @@ -608,11 +645,12 @@ class HostCacheRestApi(HostCache): 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", + url=f'{self.__api_url}/domain-types/host/collections/all', params={ - "columns": ['name'], + 'columns': ['name'], + 'sites': self.sites, }, ) if resp.status_code == 200: diff --git a/source/bin/nvdct/lib/constants.py b/source/bin/nvdct/lib/constants.py new file mode 100755 index 0000000..076f649 --- /dev/null +++ b/source/bin/nvdct/lib/constants.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# License: GNU General Public License v2 +# Author: thl-cmk[at]outlook[dot]com +# URL : https://thl-cmk.hopto.org +# Date : 2024-12-11 +# File : nvdct/lib/constants.py + + +from logging import getLogger +from os import environ +from typing import Final + +NVDCT_VERSION: Final[str] = '0.9.4-20241210' +# +OMD_ROOT: Final[str] = environ["OMD_ROOT"] +# +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_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_LLDP: Final[str] = "'nvdct/has_lldp_neighbours' 'yes'" +LABEL_CDP: Final[str] = 'CDP' +LABEL_L3v4: Final[str] = 'LAYER3v4' +LABEL_LLDP: Final[str] = 'LLDP' +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' +MIN_LINUX_IP_ADDRESSES: Final[str] = '0.0.4-20241210' +MIN_SNMP_IP_ADDRESSES: Final[str] = '0.0.6-20241210' +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_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' diff --git a/source/bin/nvdct/lib/settings.py b/source/bin/nvdct/lib/settings.py index f8f4bc8..d40af53 100755 --- a/source/bin/nvdct/lib/settings.py +++ b/source/bin/nvdct/lib/settings.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # License: GNU General Public License v2 - +from cmk_addons.plugins.bgp_topology.lib.utils import OMD_ROOT # Author: thl-cmk[at]outlook[dot]com # URL : https://thl-cmk.hopto.org # Date : 2023-10-12 @@ -13,20 +13,29 @@ from collections.abc import Mapping from ipaddress import AddressValueError, IPv4Address, IPv4Network, NetmaskValueError from logging import CRITICAL, FATAL, ERROR, WARNING, INFO, DEBUG -from os import environ from sys import exit as sys_exit from time import strftime from typing import Dict, List, NamedTuple, Tuple +from pathlib import Path +from lib.constants import ( + API_PORT, + LOGGER, + LOG_FILE, + OMD_ROOT, + TIME_FORMAT, + USER_DATA_FILE, +) from lib.utils import ( ExitCodes, + Layer, get_data_from_toml, get_local_cmk_api_port, - # get_local_cmk_version, - LOGGER, - TIME_FORMAT, - USER_DATA_FILE, - Layer + is_valid_customer_name, + is_valid_hostname, + is_valid_log_file, + is_valid_output_directory, + is_valid_site_name, ) @@ -45,11 +54,10 @@ class Thickness(NamedTuple): class StaticConnection(NamedTuple): - host: str - local_port: str - neighbour_port: str - neighbour: str - label: str + right_host: str + right_service: str + left_service: str + left_host: str class Wildcard(NamedTuple): @@ -65,11 +73,6 @@ class Settings: self, cli_args: Mapping[str, object], ): - self.__omd_root = environ['OMD_ROOT'] - self.__path_to_if_table = 'networking,interfaces' - self.__topology_file_name = 'network_data.json' - # self.__topology_save_path = 'var/topology_data' - self.__topology_save_path_cmk_2_3 = 'var/check_mk/topology/data' # cli defaults self.__settings = { # 'api_port': 80, @@ -80,10 +83,11 @@ class Settings: 'dont_compare': False, 'filter_customers': None, 'filter_sites': None, + 'include_l3_hosts': False, 'keep': False, 'remove_domain': False, - 'layers': ['CDP'], - 'log_file': f'{self.omd_root}/var/log/nvdct.log', + 'layers': [], + 'log_file': LOG_FILE, 'log_level': 'WARNING', 'log_to_stdout': False, 'min_age': 0, @@ -91,11 +95,10 @@ class Settings: 'prefix': None, 'quiet': False, 'pre_fetch': False, - # 'seed_devices': [], 'skip_l3_if': False, 'skip_l3_ip': False, 'time_format': TIME_FORMAT, - 'user_data_file': f'{self.omd_root}/local/bin/nvdct/conf/{USER_DATA_FILE}', + 'user_data_file': f'{OMD_ROOT}/local/bin/nvdct/conf/{USER_DATA_FILE}', } # args in the form {'s, __seed_devices': 'CORE01', 'p, __path_in_inventory': None, ... }} # we will remove 's, __' @@ -128,7 +131,6 @@ class Settings: self.__api_port: int | None = None # init user data with defaults - # self.__drop_host_regex: List[str] | None = None self.__custom_layers: List[StaticConnection] | None = None self.__customers: List[str] | None = None self.__emblems: Emblems | None = None @@ -157,7 +159,7 @@ class Settings: else: self.__api_port = get_local_cmk_api_port() if self.__api_port is None: - self.__api_port = 80 + self.__api_port = API_PORT return self.__api_port @@ -217,6 +219,10 @@ class Settings: ) return None + @property # --include-l3-hosts + def include_l3_hosts(self) -> bool: + return bool(self.__settings['include_l3_hosts']) + @property # --keep def keep(self) -> int | None: if isinstance(self.__settings['keep'], int): @@ -233,27 +239,25 @@ class Settings: @property # --log-file def log_file(self) -> str: - return str(self.__settings['log_file']) + raw_log_file = str(Path(str(self.__settings['log_file'])).expanduser()) + if is_valid_log_file(raw_log_file): + return raw_log_file + else: + LOGGER.error(f'Falling back to {LOG_FILE}') + return LOG_FILE @property # --log-level def loglevel(self) -> int: - match self.__settings['log_level']: - case 'DEBUG': - return DEBUG - case 'INFO': - return INFO - case 'WARNING': - return WARNING - case 'ERROR': - return ERROR - case 'FATAL': - return FATAL - case 'CRITICAL': - return CRITICAL - case 'OFF': - return -1 - case _: - return WARNING + log_levels = { + 'DEBUG': DEBUG, + 'INFO': INFO, + 'WARNING': WARNING, + 'ERROR': ERROR, + 'FATAL': FATAL, + 'CRITICAL': CRITICAL, + 'OFF': -1, + } + return log_levels.get(self.__settings['log_level'], WARNING) @property # --log-to-stdout def log_to_stdtout(self) -> bool: @@ -271,7 +275,11 @@ class Settings: # init output directory with current time if not set if not self.__settings['output_directory']: self.__settings['output_directory'] = f'{strftime(self.__settings["time_format"])}' - return str(self.__settings['output_directory']) + if is_valid_output_directory(str(self.__settings['output_directory'])): + return str(self.__settings['output_directory']) + else: + LOGGER.error('Falling back to "nvdct"') + return 'nvdct' @property # --prefix def prefix(self) -> str | None: @@ -311,7 +319,7 @@ class Settings: if self.__customers is None: self.__customers = [ str(customer) for customer in set(self.__user_data.get('CUSTOMERS', [])) - ] + if is_valid_customer_name(customer)] LOGGER.info(f'Found {len(self.__customers)} to filter on') return self.__customers @@ -362,9 +370,7 @@ class Settings: 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', []) + self.__settings.get('seed_devices', []) - self.__user_data.get('L2_SEED_DEVICES', []) - ))) + self.__user_data.get('L2_SEED_DEVICES', [])) if is_valid_hostname(host))) return self.__l2_seed_devices @property @@ -373,7 +379,7 @@ class Settings: self.__l2_host_map = { str(host): str(replace_host) for host, replace_host in self.__user_data.get( 'L2_HOST_MAP', {} - ).items() + ).items() if is_valid_hostname(host) } return self.__l2_host_map @@ -392,7 +398,7 @@ class Settings: if self.__l3v4_ignore_hosts is None: self.__l3v4_ignore_hosts = [str(host) for host in set(self.__user_data.get( 'L3V4_IGNORE_HOSTS', [] - ))] + )) if is_valid_hostname(host)] return self.__l3v4_ignore_hosts @property @@ -460,9 +466,12 @@ class Settings: _ip_network = IPv4Network(ip_network) # noqa: F841 except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in L3V4_REPLACE found: {ip_network} -> ignored' + f'Invalid entry in L3V4_REPLACE found: {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) LOGGER.info( f'Valid entries in L3V4_REPLACE found: {len(self.__l3v4_replace)}/' @@ -526,18 +535,22 @@ class Settings: self.__static_connections = [] for connection in self.__user_data.get('STATIC_CONNECTIONS', []): try: - host, local_port, neighbour_port, neighbour, label = connection + left_host, left_service, right_service, right_host = connection except ValueError: LOGGER.error( - f'Worng entry in STATIC_CONNECTIONS -> {connection} -> ignored' + f'Wrong entry in STATIC_CONNECTIONS -> {connection} -> ignored' ) continue + if not right_host or not left_host: + LOGGER.warning(f'Both hosts must be set, got {connection}') + continue + if not is_valid_hostname(right_host) or not is_valid_hostname(left_host): + continue self.__static_connections.append(StaticConnection( - host=str(host), - local_port=str(local_port), - neighbour_port=str(neighbour_port), - neighbour=str(neighbour), - label=str(label), + right_host=str(right_host), + right_service=str(right_service), + left_service=str(left_service), + left_host=str(left_host), )) LOGGER.info( f'Valid entries in STATIC_CONNECTIONS found: {len(self.__static_connections)}/' @@ -548,21 +561,6 @@ class Settings: @property def sites(self) -> List[str]: if self.__sites is None: - self.__sites = [str(site) for site in set(self.__user_data.get('SITES', []))] - LOGGER.info(f'fFound {len(self.__sites)} to filter on') + self.__sites = [str(site) for site in set(self.__user_data.get('SITES', [])) if is_valid_site_name(site)] + LOGGER.info(f'Found {len(self.__sites)} to filter on') return self.__sites - - # - # all other settings - # - @property - def omd_root(self) -> str: - return self.__omd_root - - @property - def topology_save_path(self) -> str: - return self.__topology_save_path_cmk_2_3 - - @property - def topology_file_name(self) -> str: - return self.__topology_file_name diff --git a/source/bin/nvdct/lib/topologies.py b/source/bin/nvdct/lib/topologies.py index 8c35a45..026b6fc 100755 --- a/source/bin/nvdct/lib/topologies.py +++ b/source/bin/nvdct/lib/topologies.py @@ -10,11 +10,32 @@ from collections.abc import Mapping, Sequence from ipaddress import IPv4Address, IPv4Network -from typing import Dict, List - -from lib.backends import CACHE_INTERFACES_DATA, CacheItems, HostCache, PATH_INTERFACES -from lib.settings import Thickness, Wildcard -from lib.utils import LOGGER +from typing import Dict, List, Tuple +from re import sub as re_sub + +from lib.backends import ( + CacheItems, + HostCache, +) +from lib.constants import ( + CACHE_INTERFACES_DATA, + HOST_LABEL_L3V4_HOSTS, + HOST_LABEL_L3V4_ROUTER, + LOGGER, + PATH_INTERFACES, + PATH_L3v4, +) +from lib.settings import ( + Emblems, + StaticConnection, + Thickness, + Wildcard, +) +from lib.utils import ( + InventoryColumns, + Ipv4Info, + is_valid_hostname, +) class NvObjects: @@ -23,12 +44,15 @@ class NvObjects: self.host_count: int = 0 self.host_list: List[str] = [] - def add_host_object( + def add_host( self, host: str, host_cache: HostCache, emblem: str | None = None ) -> None: + if not is_valid_hostname(host): + LOGGER.error(f'host not added! Invalid name {host}') + return if host not in self.nv_objects: self.host_count += 1 self.host_list.append(host) @@ -57,7 +81,45 @@ class NvObjects: } LOGGER.debug(f'host: {host}, link: {link}, metadata: {metadata}') - def add_service_object( + def add_service( + self, + host: str, + service: str, + host_cache: HostCache, + emblem: str | None = None, + metadata: Dict | None = None, + name: str | None = None, + ) -> None: + if metadata is None: + metadata = {} + if name is None: + name = service + + self.add_host(host=host, host_cache=host_cache) + service_object = f'{service}@{host}' + if service_object not in self.nv_objects: + link: Dict = {} + if host_cache.host_exists(host=host): + link = {'core': [host, service]} + elif emblem is not None: + metadata.update({ + 'images': { + 'emblem': emblem, # node image + # 'icon': 'icon_tick', # status image, top left from emblem + }, + **({'tooltip': {'quickinfo': [{'name': 'Service node', 'value': service}]}} if not link else {}) + }) + + self.nv_objects[service_object] = { + 'name': name, + 'link': link, + 'metadata': metadata, + } + elif metadata is not {}: + self.nv_objects[service_object]['metadata'].update(metadata) + + + def add_interface( self, host: str, service: str, @@ -73,7 +135,7 @@ class NvObjects: name = service speed = None - self.add_host_object(host=host, host_cache=host_cache) + self.add_host(host=host, host_cache=host_cache) service_object = f'{service}@{host}' if service_object not in self.nv_objects: link: Dict = {} @@ -106,7 +168,7 @@ class NvObjects: elif metadata is not {}: self.nv_objects[service_object]['metadata'].update(metadata) - def add_ipv4_address_object( + def add_ipv4_address( self, host: str, ipv4_address: str, @@ -135,7 +197,7 @@ class NvObjects: } } - def add_ipv4_network_object(self, network: str, emblem: str, ) -> None: + def add_ipv4_network(self, network: str, emblem: str, ) -> None: if network not in self.nv_objects: self.nv_objects[network] = { 'name': network, @@ -339,19 +401,19 @@ def get_service_by_interface(host: str, interface: str, host_cache: HostCache) - # 'management': 'Ma', } - def _get_short_if_name(interface: str) -> str: + def _get_short_if_name(interface_: str) -> str: """ returns short interface name from long interface name interface: is the long interface name - :type interface: str + :type interface_: str """ - if not interface: - return interface + if not interface_: + return interface_ for interface_prefix in _alternate_if_name.keys(): - if interface.lower().startswith(interface_prefix.lower()): + if interface_.lower().startswith(interface_prefix.lower()): interface_short = _alternate_if_name[interface_prefix] - return interface.lower().replace(interface_prefix.lower(), interface_short, 1) - return interface + return interface_.lower().replace(interface_prefix.lower(), interface_short, 1) + return interface_ # try to find the item for an interface def _match_entry_with_item(_entry: Mapping[str, str], services: Sequence[str]) -> str | None: @@ -552,3 +614,387 @@ 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=host, + service=local_port, + host_cache=host_cache, + metadata=metadata, + name=raw_local_port, + item=local_port + ) + nv_objects.add_interface( + host=neighbour, + service=neighbour_port, + host_cache=host_cache, + name=raw_neighbour_port, + item=neighbour_port + ) + nv_connections.add_connection( + left=host, + right=f'{local_port}@{host}', + ) + nv_connections.add_connection( + left=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},' + ' 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 1857bce..4bd7f57 100755 --- a/source/bin/nvdct/lib/utils.py +++ b/source/bin/nvdct/lib/utils.py @@ -14,7 +14,6 @@ from enum import Enum, unique from json import dumps from logging import disable as log_off, Formatter, getLogger, StreamHandler from logging.handlers import RotatingFileHandler -from os import environ from pathlib import Path from re import match as re_match from socket import socket, AF_UNIX, AF_INET, SOCK_STREAM, SHUT_WR @@ -23,8 +22,23 @@ from time import time as now_time from tomllib import loads as toml_loads, TOMLDecodeError from typing import List, Dict, TextIO -NVDCT_VERSION = '0.9.3-20241209' - +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, + LOGGER, + OMD_ROOT, + PATH_CDP, + PATH_L3v4, + PATH_LLDP, + DATAPATH, +) @unique class ExitCodes(Enum): @@ -33,15 +47,7 @@ class ExitCodes(Enum): BAD_TOML_FORMAT = 2 BACKEND_NOT_IMPLEMENTED = 3 AUTOMATION_SECRET_NOT_FOUND = 4 - - -@dataclass(frozen=True) -class Layer: - path: str - columns: str - label: str - host_label: str - + NO_LAYER_CONFIGURED = 5 @dataclass(frozen=True) class Ipv4Info: @@ -53,44 +59,18 @@ class Ipv4Info: network: str type: str - @dataclass(frozen=True) class InventoryColumns: neighbour: str local_port: str neighbour_port: str - -# constants -OMD_ROOT = environ["OMD_ROOT"] - -CACHE_INTERFACES_DATA = 'interface_data' -CMK_SITE_CONF = f'{OMD_ROOT}/etc/omd/site.conf' -COLUMNS_CDP = 'neighbour_name,local_port,neighbour_port' -COLUMNS_L3v4 = 'address,device,cidr,network,type' -COLUMNS_LLDP = 'neighbour_name,local_port,neighbour_port' -HOME_URL = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct' -HOST_LABEL_CDP = "'nvdct/has_cdp_neighbours' 'yes'" -HOST_LABEL_L3V4 = "'nvdct/l3v4_topology' 'router'" -HOST_LABEL_LLDP = "'nvdct/has_lldp_neighbours' 'yes'" -LABEL_CDP = 'CDP' -LABEL_L3v4 = 'LAYER3v4' -LABEL_LLDP = 'LLDP' -LOG_FILE = f'{OMD_ROOT}/var/log/nvdct.log' -LOGGER = getLogger('root)') -MIN_CDP_VERSION = '0.7.1-20240320' -MIN_IP_ADDRESSES = '0.0.5-2024120' -MIN_LLDP_VERSION = '0.9.3-20240320' -PATH_CDP = 'networking,cdp_cache,neighbours' -PATH_INTERFACES = 'networking,interfaces' -PATH_L3v4 = 'networking,addresses' -PATH_LLDP = 'networking,lldp_cache,neighbours' -SAMPLE_SEEDS = 'Core01 Core02' -SCRIPT = '~/local/bin/nvdct/nvdct.py' -TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%m' -TIME_FORMAT_ARGPARSER = '%%Y-%%m-%%dT%%H:%%M:%%S.%%m' -USER_DATA_FILE = 'nvdct.toml' - +@dataclass(frozen=True) +class Layer: + path: str + columns: str + label: str + host_label: str LAYERS = { 'CDP': Layer( @@ -109,7 +89,7 @@ LAYERS = { path=PATH_L3v4, columns='', label=LABEL_L3v4, - host_label=HOST_LABEL_L3V4, + host_label=HOST_LABEL_L3V4_ROUTER, ), } @@ -164,9 +144,7 @@ def get_data_from_toml(file: str) -> Dict: def rm_tree(root: Path) -> None: # safety - if not str(root).startswith( - f'{OMD_ROOT}/var/topology_data' - ) and not str(root).startswith(f'{OMD_ROOT}/var/check_mk/topology'): + if not str(root).startswith(DATAPATH): LOGGER.warning(msg=f"WARNING: bad path to remove, {str(root)}, don\'t delete it.") return for p in root.iterdir(): @@ -323,6 +301,47 @@ def is_list_of_str_equal(list1: List[str], list2: List[str]) -> bool: return tmp_list1 == tmp_list2 +def is_valid_hostname(host: str) -> bool: + re_host_pattern = r'^[0-9a-z-A-Z\.\-\_]{1,253}$' + if re_match(re_host_pattern, host): + return True + else: + LOGGER.error(f'Invalid hostname found: {host}') + return False + +def is_valid_site_name(site: str) -> bool: + re_host_pattern = r'^[0-9a-z-A-Z\.\-\_]{1,16}$' + if re_match(re_host_pattern, site): + return True + else: + LOGGER.error(f'Invalid site name found: {site}') + return False + +def is_valid_customer_name(customer: str) -> bool: + re_host_pattern = r'^[0-9a-z-A-Z\.\-\_]{1,16}$' + if re_match(re_host_pattern, customer): + return True + else: + LOGGER.error(f'Invalid customer name found: {customer}') + return False + + +def is_valid_output_directory(directory: str) -> bool: + # 2024-12-11T17:35:08.12 + re_host_pattern = r'^[0-9a-z-A-Z\.\-\_\:]{1,30}$' + if re_match(re_host_pattern, directory): + return True + else: + LOGGER.error(f'Invalid output directory name found: {directory}') + return False + +def is_valid_log_file(log_file: str) -> bool: + if not log_file.startswith(f'{OMD_ROOT}/var/log/'): + LOGGER.error(f'Logg file needs to be under "{OMD_ROOT}/var/log/"! Got {Path(log_file).absolute()}') + return False + return True + + # not used in cmk 2.3.x format def merge_topologies(topo_pri: Dict, topo_sec: Dict) -> Dict: """ @@ -410,8 +429,7 @@ def configure_logger(log_file: str, log_level: int, log_to_console: bool) -> Non log = getLogger() log_formatter = Formatter( - fmt='%(asctime)s :: %(levelname)s :: %(module)s :' - ': %(funcName)s() :: %(lineno)s :: %(message)s', + fmt='%(asctime)s :: %(levelname)s :: %(module)s :: %(funcName)s() :: %(lineno)s :: %(message)s', ) log.setLevel(log_level) diff --git a/source/bin/nvdct/nvdct.py b/source/bin/nvdct/nvdct.py index 16254c3..80755bf 100755 --- a/source/bin/nvdct/nvdct.py +++ b/source/bin/nvdct/nvdct.py @@ -133,7 +133,14 @@ # 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 +# exactly like in CMK. See ~/local/bin/nvdct/conf/nfdct.toml +# moved string constants to lib/constants.py # creating topology data json from inventory data # @@ -147,7 +154,9 @@ # The inventory data could be created with my CDP/LLDP/IP Address/Interface nane inventory plugins: # CDP.....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache # LLDP....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cache -# L3v4....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ipv4_addresses +# L3v4....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_addresses +# : https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lnx_if_ip +# : https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_win_if_ip # IF Name.: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ifname # # USAGE: @@ -229,554 +238,233 @@ __data = { """ import sys -from collections.abc import Mapping, Sequence -from ipaddress import IPv4Network from logging import DEBUG -from re import sub as re_sub from time import strftime, time_ns -from typing import Dict, List, Tuple +from typing import List from lib.args import parse_arguments from lib.backends import ( - CacheItems, HostCache, HostCacheLiveStatus, HostCacheMultiSite, HostCacheRestApi, ) +from lib.constants import ( + HOME_URL, + LOGGER, + NVDCT_VERSION, + DATAPATH, +) +from lib.settings import Settings from lib.topologies import ( NvConnections, NvObjects, - # get_list_of_devices, - get_network_summary, - get_service_by_interface, - is_ignore_ipv4, - is_ignore_wildcard, + create_l2_topology, + create_l3v4_topology, + create_static_connections, ) from lib.utils import ( - configure_logger, ExitCodes, - HOME_URL, - HOST_LABEL_L3V4, InventoryColumns, - Ipv4Info, - Layer, LAYERS, - LOGGER, - # merge_topologies, - NVDCT_VERSION, - PATH_L3v4, + Layer, + StdoutQuiet, + configure_logger, remove_old_data, save_data_to_file, - # save_topology, - StdoutQuiet, -) -from lib.settings import ( - Emblems, - Settings, - StaticConnection, - Thickness, - Wildcard, ) -EMBLEMS: Emblems -HOST_CACHE: HostCache -L2_DROP_HOSTS: List[str] = [] -L2_HOST_MAP: Dict[str, str] = {} -L2_NEIGHBOUR_REPLACE_REGEX: List[Tuple[str, str]] | None = None -MAP_SPEED_TO_THICKNESS: List[Thickness] = [] -NV_CONNECTIONS = NvConnections() -NV_OBJECTS = NvObjects() -SETTINGS: Settings - - -def create_l2_device_from_inv( - host: str, - inv_data: Sequence[Mapping[str, str]], - inv_columns: InventoryColumns, - # label: str, -) -> 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 SETTINGS.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 SETTINGS.case == 'UPPER': - neighbour = neighbour.upper() - LOGGER.debug(f'Changed neighbour to upper case: {neighbour}') - elif SETTINGS.case == 'LOWER': - neighbour = neighbour.lower() - LOGGER.debug(f'Changed neighbour to lower case: {neighbour}') - - if SETTINGS.prefix: - neighbour = f'{SETTINGS.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_object(host=host, host_cache=HOST_CACHE) - NV_OBJECTS.add_host_object(host=neighbour, host_cache=HOST_CACHE) - NV_OBJECTS.add_service_object( - host=host, - service=local_port, - host_cache=HOST_CACHE, - metadata=metadata, - name=raw_local_port, - item=local_port - ) - NV_OBJECTS.add_service_object( - host=neighbour, - service=neighbour_port, - host_cache=HOST_CACHE, - name=raw_neighbour_port, - item=neighbour_port - ) - NV_CONNECTIONS.add_connection( - left=host, - right=f'{local_port}@{host}', - ) - NV_CONNECTIONS.add_connection( - left=neighbour, - right=f'{neighbour_port}@{neighbour}', - ) - NV_CONNECTIONS.add_connection( - left=f'{local_port}@{host}', - right=f'{neighbour_port}@{neighbour}', - ) - - -def create_static_connections(connections: Sequence[StaticConnection]): - for connection in connections: - LOGGER.info(msg=f'connection: {connection}') - NV_OBJECTS.add_host_object( - host=connection.host, - host_cache=HOST_CACHE, - emblem=EMBLEMS.host_node - ) - NV_OBJECTS.add_host_object( - host=connection.neighbour, - host_cache=HOST_CACHE, - emblem=EMBLEMS.host_node - ) - NV_OBJECTS.add_service_object( - host=connection.host, - host_cache=HOST_CACHE, - emblem=EMBLEMS.service_node, - service=connection.local_port - ) - NV_OBJECTS.add_service_object( - host=connection.neighbour, - host_cache=HOST_CACHE, - emblem=EMBLEMS.service_node, - service=connection.neighbour_port - ) - NV_CONNECTIONS.add_connection( - left=connection.host, - right=f'{connection.local_port}@{connection.host}', - ) - NV_CONNECTIONS.add_connection( - left=connection.neighbour, - right=f'{connection.neighbour_port}@{connection.neighbour}', - ) - NV_CONNECTIONS.add_connection( - left=f'{connection.local_port}@{connection.host}', - right=f'{connection.neighbour_port}@{connection.neighbour}', - ) - - -def create_l2_topology( - seed_devices: Sequence[str], - path_in_inventory: str, - inv_columns: InventoryColumns, - label: 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, - # label=label, - ) - - 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( - ignore_hosts: Sequence[str], - ignore_ips: Sequence[IPv4Network], - ignore_wildcard: Sequence[Wildcard], - summarize: Sequence[IPv4Network], - replace: Mapping[str, str], - skip_if: bool, - skip_ip: bool, -) -> None: - host_list: Sequence[str] = HOST_CACHE.get_hosts_by_label(HOST_LABEL_L3V4) - LOGGER.debug(f'host list: {host_list}') - if not host_list: - LOGGER.warning( - msg='No routing capable host found. Check if "inv_ipv4_addresses.mkp" ' - 'added/enabled and inventory 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_object(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},' - ' 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_object(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_object( - 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_service_object( - 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_object( - host=host, - interface=ipv4_info.device, - ipv4_address=ipv4_info.address, - emblem=EMBLEMS.ip_address, - ) - NV_OBJECTS.add_service_object( - 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}', - ) - - -if __name__ == '__main__': +def main(): start_time = time_ns() - SETTINGS = Settings(vars(parse_arguments())) - sys.stdout = StdoutQuiet(quiet=SETTINGS.quiet) + nv_connections = NvConnections() + nv_objects = NvObjects() + + settings: Settings = Settings(vars(parse_arguments())) + sys.stdout = StdoutQuiet(quiet=settings.quiet) configure_logger( - log_file=SETTINGS.log_file, - log_to_console=SETTINGS.log_to_stdtout, - log_level=SETTINGS.loglevel, + log_file=settings.log_file, + log_to_console=settings.log_to_stdtout, + log_level=settings.loglevel, ) - LOGGER.info(msg='Data creation started') + # always logg start and end of a session (except --log-level OFF) + LOGGER.critical(msg='Data creation started') - print() + print('') print( f'Network Visualisation Data Creation Tool (NVDCT)\n' f'by thl-cmk[at]outlook[dot]com, version {NVDCT_VERSION}\n' f'see {HOME_URL}' ) - print() - print(f'Start time....: {strftime(SETTINGS.time_format)}') + print('') + print(f'Start time....: {strftime(settings.time_format)}') - match SETTINGS.backend: + match settings.backend: case 'RESTAPI': - HOST_CACHE = HostCacheRestApi( - pre_fetch=SETTINGS.pre_fetch, - api_port=SETTINGS.api_port + host_cache: HostCache = HostCacheRestApi( + pre_fetch=settings.pre_fetch, + api_port=settings.api_port, + filter_sites=settings.filter_sites, + sites=settings.sites ) case 'MULTISITE': - HOST_CACHE = HostCacheMultiSite( - pre_fetch=SETTINGS.pre_fetch, - filter_sites=SETTINGS.filter_sites, - sites=SETTINGS.sites, + host_cache: HostCache = HostCacheMultiSite( + pre_fetch=settings.pre_fetch, + filter_sites=settings.filter_sites, + sites=settings.sites, + filter_customers=settings.filter_customers, + customers=settings.customers, ) case 'LIVESTATUS': - HOST_CACHE = HostCacheLiveStatus( - pre_fetch=SETTINGS.pre_fetch, + host_cache: HostCache = HostCacheLiveStatus( + pre_fetch=settings.pre_fetch, ) case _: - LOGGER.error(msg=f'Backend {SETTINGS.backend} not (yet) implemented') + 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) - EMBLEMS = SETTINGS.emblems - L2_DROP_HOSTS = SETTINGS.l2_drop_hosts - L2_HOST_MAP = SETTINGS.l2_host_map - L2_NEIGHBOUR_REPLACE_REGEX = SETTINGS.l2_neighbour_replace_regex - MAP_SPEED_TO_THICKNESS = SETTINGS.map_speed_to_thickness - jobs: List[Layer] = [] - final_topology: Dict = {} pre_fetch_layers: List[str] = [] pre_fetch_host_list: List[str] = [] - for layer in SETTINGS.layers: + 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) + 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) + 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: + for entry in settings.custom_layers: jobs.append(entry) - HOST_CACHE.add_inventory_prefetch_path(entry.path) + host_cache.add_inventory_prefetch_path(entry.path) + + 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) - if SETTINGS.pre_fetch: + if settings.pre_fetch: LOGGER.info('Pre fill cache...') for host_label in pre_fetch_layers: - if _host_list := HOST_CACHE.get_hosts_by_label(host_label): + if _host_list := host_cache.get_hosts_by_label(host_label): 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 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) LOGGER.info(f'Fetching data for {len(pre_fetch_host_list)} hosts end') - print(f'Prefetch end..: {strftime(SETTINGS.time_format)}') + print(f'Prefetch end..: {strftime(settings.time_format)}') for job in jobs: match job: case 'STATIC': - label = 'static' + label = 'STATIC' create_static_connections( - connections=SETTINGS.static_connections + connections_=settings.static_connections, + emblems=settings.emblems, + host_cache=host_cache, + nv_objects=nv_objects, + nv_connections=nv_connections, ) case 'L3v4': - topology = None - label = 'l3v4' + label = 'L3v4' create_l3v4_topology( - ignore_hosts=SETTINGS.l3v4_ignore_hosts, - ignore_ips=SETTINGS.l3v4_ignore_ips, - ignore_wildcard=SETTINGS.l3v4_ignore_wildcard, - summarize=SETTINGS.l3v4_summarize, - replace=SETTINGS.l3v4_replace, - skip_if=SETTINGS.skip_l3_if, - skip_ip=SETTINGS.skip_l3_ip + ignore_hosts=settings.l3v4_ignore_hosts, + ignore_ips=settings.l3v4_ignore_ips, + ignore_wildcard=settings.l3v4_ignore_wildcard, + include_hosts=settings.include_l3_hosts, + replace=settings.l3v4_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, ) case _: - label = job.label.lower() + label = job.label.upper() columns = job.columns.split(',') create_l2_topology( - seed_devices=SETTINGS.l2_seed_devices, + seed_devices=settings.l2_seed_devices, path_in_inventory=job.path, inv_columns=InventoryColumns( neighbour=columns[0], local_port=columns[1], neighbour_port=columns[2] ), - label=label, + 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, + prefix=settings.prefix, + remove_domain=settings.remove_domain, ) - NV_CONNECTIONS.add_meta_data_to_connections( - nv_objects=NV_OBJECTS, - speed_map=MAP_SPEED_TO_THICKNESS, + nv_connections.add_meta_data_to_connections( + nv_objects=nv_objects, + speed_map=settings.map_speed_to_thickness, ) - if not SETTINGS.loglevel == DEBUG: - connections = NV_CONNECTIONS.nv_connections - else: - connections = sorted(NV_CONNECTIONS.nv_connections) + _data = { 'version': 1, 'name': label, - 'objects': NV_OBJECTS.nv_objects if not SETTINGS.loglevel == DEBUG else dict( - sorted(NV_OBJECTS.nv_objects.items()) + 'objects': nv_objects.nv_objects if not settings.loglevel == DEBUG else dict( + sorted(nv_objects.nv_objects.items()) ), - 'connections': connections + 'connections': nv_connections.nv_connections if not settings.loglevel == DEBUG else sorted( + nv_connections.nv_connections + ) } save_data_to_file( data=_data, path=( - f'{SETTINGS.omd_root}/{SETTINGS.topology_save_path}/' - f'{SETTINGS.output_directory}' + f'{DATAPATH}/{settings.output_directory}' ), file=f'data_{label}.json', - make_default=SETTINGS.default, + make_default=settings.default, ) message = ( - f'Source {label:.<7s}: 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 {nv_objects.host_count}/' + f'{len(nv_objects.nv_objects)}/{len(nv_connections.nv_connections)}' ) LOGGER.info(msg=message) print(message) - NV_OBJECTS = NvObjects() - NV_CONNECTIONS = NvConnections() + nv_objects = NvObjects() + nv_connections = NvConnections() - if SETTINGS.keep: + if settings.keep: remove_old_data( - keep=SETTINGS.keep, - min_age=SETTINGS.min_age, - raw_path=f'{SETTINGS.omd_root}/{SETTINGS.topology_save_path}', - protected=SETTINGS.protected_topologies, + keep=settings.keep, + min_age=settings.min_age, + raw_path=DATAPATH, + protected=settings.protected_topologies, ) print(f'Time taken....: {(time_ns() - start_time) / 1e9}/s') - print(f'End time......: {strftime(SETTINGS.time_format)}') - print() + print(f'End time......: {strftime(settings.time_format)}') + print('') + + LOGGER.critical('Data creation finished') - LOGGER.info('Data creation finished') + +if __name__ == '__main__': + main() diff --git a/source/packages/nvdct b/source/packages/nvdct index 73ff4a1..d2a4e8f 100644 --- a/source/packages/nvdct +++ b/source/packages/nvdct @@ -17,8 +17,7 @@ 'environments\n' ' - Add custom connections (STATIC) for connections that are ' 'not in the inventory\n' - ' - Optimized for my CDP, LLDP and IPv4 inventory plugins\n' - ' - Can also be used with custom inventory plugins\n' + ' - Optimized for my CDP, LLDP and IP inventory plugins\n' '\n' 'For more information about the network visualization plugin ' 'see: \n' @@ -28,14 +27,7 @@ '\n' 'The inventory data could be created with my inventory ' 'plugins:\n' - 'CDP: ' - 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache\n' - 'LLDP: ' - 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cache\n' - 'L3v4: ' - 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ipv4_addresses\n' - 'IF_name: ' - 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ifname\n' + 'https://thl-cmk.hopto.org/gitlab/explore/projects/topics/Network%20Visualization\n' '\n' 'For the latest version and documentation see:\n' 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct\n', @@ -47,14 +39,15 @@ 'nvdct/lib/utils.py', 'nvdct/lib/__init__.py', 'nvdct/conf/nvdct.toml', - 'nvdct/lib/topologies.py'], + 'nvdct/lib/topologies.py', + 'nvdct/lib/constants.py'], 'web': ['htdocs/images/icons/cloud_80.png', 'htdocs/images/icons/ip-address_80.png', 'htdocs/images/icons/ip-network_80.png', 'htdocs/images/icons/location_80.png']}, 'name': 'nvdct', 'title': 'Network Visualization Data Creation Tool (NVDCT)', - 'version': '0.9.3-20241209', + 'version': '0.9.4-20241210', 'version.min_required': '2.3.0b1', 'version.packaged': 'cmk-mkp-tool 0.2.0', 'version.usable_until': '2.4.0p1'} -- GitLab