diff --git a/README.md b/README.md index bbc24b316faf1e1fd64ad3291106fbc55532e7ba..e77139d4f50cc9fa69570d288208f436d7e8c1aa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/mkp/nvdct-0.8.12-20240702.mkp "nvdct-0.8.12-20240702.mkp" +[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.0-20240923.mkp "nvdct-0.9.0-20240923.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.0-20240923.mkp b/mkp/nvdct-0.9.0-20240923.mkp new file mode 100644 index 0000000000000000000000000000000000000000..e7925df7a841b83c72474e9ec4a66687335ad8bf Binary files /dev/null and b/mkp/nvdct-0.9.0-20240923.mkp differ diff --git a/source/bin/nvdct/conf/nvdct.toml b/source/bin/nvdct/conf/nvdct.toml index ac4235ed8f563cd796131e056dec0a6887a23fad..48d787bc9502e2280b76208eab3e784f90a20f8e 100755 --- a/source/bin/nvdct/conf/nvdct.toml +++ b/source/bin/nvdct/conf/nvdct.toml @@ -84,6 +84,20 @@ CUSTOM_LAYERS = [ # { path = "networking,cdp_cache,neighbours", columns = "neighbour_name,local_port,neighbour_port", label = "custom_CDP", host_label = "nvdct/has_cdp_neighbours" }, ] +# list site so include/excluse, use option --filter-sites INCLUDE/EXCLUDE +SITES = [ + # "site1", + # "site2", + # "site3", +] + +# list customers so include/excluse, use option --filter-costumers INCLUDE/EXCLUDE +CUSTOMERS = [ + # "customer1", + # "customer2", + # "customer3", +] + [MAP_SPEED_TO_THICKNESS] # must be sorted from slower to faster speed # use only one entry to have all conections with the same thickness @@ -122,23 +136,23 @@ CUSTOM_LAYERS = [ [SETTINGS] # api_port = 80 -# backend = "MULTISITE" +# backend = "MULTISITE" | "RESTAPI" | "LIVESTATUS" +# case = "LOWER" | "UPPER" # default = false # dont_compare = false +# filter_customers = "INCLUDE" |"EXCLUDE" +# filter_sites = "INCLUDE" | "EXCLUDE" # keep = 0 # keep_domain = false # layers = ["LLDP", "CDP", "STATIC", "CUSTOM", "L3v4"] # log_file = "~/var/log/nvdct.log" # log_level = "WARNING" # log_to_stdout = false -# lowercase = false # min_age = 0 -# new_format = false # output_directory = '' -# prefix = "" # pre_fetch = false +# prefix = "" # quiet = true # skip_l3_if = false # skip_l3_ip = false # time_format = "%Y-%m-%dT%H:%M:%S.%m" -# uppercase = false diff --git a/source/bin/nvdct/lib/args.py b/source/bin/nvdct/lib/args.py index 9376073dacd175c9c4343b4aa075046b058256bf..080a0758e7f9aacfb4e3f50112f6cb852d996660 100755 --- a/source/bin/nvdct/lib/args.py +++ b/source/bin/nvdct/lib/args.py @@ -18,22 +18,22 @@ # -u --user-data-file # -v --version # --api-port (deprecated ?) +# --case # --check-user-data-only # --dont-compare +# --filter-customers +# --filter-sites # --keep # --keep-domain # --log-file # --log-level # --log-to-stdout -# --lowercase # --min-age -# --new-format (deprecated) # --pre-fetch # --quiet # --skip-l3-if # --skip-l3-ip # --time-format -# --uppercase from argparse import ( @@ -41,6 +41,8 @@ from argparse import ( Namespace as arg_Namespace, RawTextHelpFormatter, ) +from pathlib import Path + from lib.utils import ( ExitCodes, HOME_URL, @@ -83,15 +85,12 @@ def parse_arguments() -> arg_Namespace: '\nUsage:\n' f'{SCRIPT} -s {SAMPLE_SEEDS} -d\n\n' ) - command_group = parser.add_mutually_exclusive_group() parser.add_argument( '-b', '--backend', - # nargs='+', - choices=['FILESYSTEM', 'LIVESTATUS', 'MULTISITE', 'RESTAPI'], + choices=['LIVESTATUS', 'MULTISITE', 'RESTAPI'], # default='MULTISITE', help='Backend used to retrieve the topology data\n' - ' - FILESYSTEM : fetches the data directly form the inventory files (deprecatred)\n' ' - LIVESTATUS : fetches data via local Livestatus (local site only)\n' ' - MULTISITE : like LIVESTATUS but for distributed environments (default)\n' ' - RESTAPI : uses the CMK REST API.', @@ -101,14 +100,12 @@ def parse_arguments() -> arg_Namespace: help='Set the created topology data as default. Will be created automatically\n' 'if it doesn\'t exists.', ) - parser.add_argument( '-o', '--output-directory', type=str, help='Directory name where to save the topology data.\n' 'I.e.: my_topology. Default is the actual date/time\n' 'in "--time-format" format.\n' - 'NOTE: the directory is a sub directory under "~/var/topology_data/" (CMK2.2.0)\n' - 'For CMK 2.3.0 the path is under "~/var/check_mk/topology/data/".', + 'NOTE: the directory is a sub directory under "~/var/check_mk/topology/data/"\n', ) parser.add_argument( '-s', '--seed-devices', type=str, nargs='+', @@ -125,12 +122,10 @@ def parse_arguments() -> arg_Namespace: choices=['CDP', 'CUSTOM', 'LLDP', 'STATIC', 'L3v4'], # default=['CDP'], help=( - f'Layers with least significant layer first. Listed layers\n' - f'will be merged automatically (CMK2.2 only).\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_ipv4_addresses package at least in version {MIN_IPV4_ADDRESSES}\n' - f' adds, layer 3 topology fpr IPv4 (CMK 2.3.0 only)' + f' adds, layer 3 topology fpr IPv4 ' ) ) parser.add_argument( @@ -139,7 +134,8 @@ def parse_arguments() -> arg_Namespace: f'Default is ~/local/bin/nvdct/conf/conf/{USER_DATA_FILE}\n', ) parser.add_argument( - '-v', '--version', action='store_const', const=True, # default=False, + '-v', '--version', action='version', + version=f'{Path(SCRIPT).name} version: {NVDCT_VERSION}', help='Print version of this script and exit', ) parser.add_argument( @@ -147,6 +143,12 @@ def parse_arguments() -> arg_Namespace: help='TCP Port to access the REST API. Default is 80. NVDCT will try to automatically\n' 'detect the site apache port.', ) + parser.add_argument( + '--case', + choices=['LOWER', 'UPPER'], + # default='NONE', + help='Change neighbour name to all lower/upper case', + ) parser.add_argument( '--check-user-data-only', action='store_const', const=True, # default=False, help=f'Only tries to read/parse the user data from {USER_DATA_FILE} and exits.', @@ -174,6 +176,20 @@ def parse_arguments() -> arg_Namespace: 'So, if you run this tool in a cron job, a new topology will be\n' 'created only if there was a change, unless you use "--dont-compare".' ) + parser.add_argument( + '--filter-customers', + choices=['INCLUDE', 'EXCLUDE'], + # default='INCLUDE', + help='INCLUDE/EXCLUDE customer list from config file.\n' + 'Note: MULTISITE backend only.', + ) + parser.add_argument( + '--filter-sites', + choices=['INCLUDE', 'EXCLUDE'], + # default='INCLUDE', + help='INCLUDE/EXCLUDE site list from config file.\n' + 'Note: MULTISITE backend only.', + ) parser.add_argument( '--keep-domain', action='store_const', const=True, # default=False, help='Do not remove the domain name from the neighbor name', @@ -184,19 +200,10 @@ def parse_arguments() -> arg_Namespace: 'max will be deleted.\n' 'NOTE: The default topologies will be always kept.\n' ) - command_group.add_argument( - '--lowercase', action='store_const', const=True, # default=False, - help='Change neighbour names to all lower case', - ) parser.add_argument( '--min-age', type=int, help='The minimum number of days before a topology is deleted by "--keep".' ) - parser.add_argument( - '--new-format', action='store_const', const=True, # default=False, - help='Save data in new format. Use for CMK 2.3.x\n' - 'NVDCT will try to automatically detect the correct format. (deprecated)', - ) parser.add_argument( '--pre-fetch', action='store_const', const=True, # default=False, help='Try to fetch host data, with less API calls. Can improve RESTAPI backend\n' @@ -218,8 +225,5 @@ def parse_arguments() -> arg_Namespace: '--time-format', type=str, help=f'Format string to render the time. (default: {TIME_FORMAT_ARGPARSER})', ) - command_group.add_argument( - '--uppercase', action='store_const', const=True, # default=False, - help='Change neighbour names to all upper case', - ) + return parser.parse_args() diff --git a/source/bin/nvdct/lib/backends.py b/source/bin/nvdct/lib/backends.py index bfccbb2545eedbface265a5d958bd4ab75d4dcc0..6d003fd855470c40d713d9e2b6ecc50c51b90246 100755 --- a/source/bin/nvdct/lib/backends.py +++ b/source/bin/nvdct/lib/backends.py @@ -10,7 +10,7 @@ # 2024-06-18: fixed host_exist returns always True if host was in host_cache, even with host=None -from _collections_abc import Mapping, Sequence +from collections.abc import Mapping, Sequence from abc import abstractmethod from ast import literal_eval from enum import Enum, unique @@ -334,10 +334,19 @@ class HostCacheLiveStatus(HostCache): class HostCacheMultiSite(HostCacheLiveStatus): - def __init__(self, pre_fetch: bool): + def __init__( + self, + pre_fetch: bool, + filter_sites: str | None = None, + sites: List[str] = [], + filter_customers: str | None = None, + customers: List[str] = None, + ): self._backend = '[MULTISITE]' self.sites: SiteConfigurations = SiteConfigurations({}) self.get_sites() + self.filter_sites(filter_sites, sites) + self.filter_costumers(filter_customers, customers) LOGGER.info(f'{self.backend} Create livestatus connection(s)') self.c = MultiSiteConnection(self.sites) # self.c.set_prepend_site(False) # is default @@ -351,9 +360,8 @@ class HostCacheMultiSite(HostCacheLiveStatus): if self.pre_fetch: self.pre_fetch_hosts() + # https://github.com/Checkmk/checkmk/blob/master/packages/cmk-livestatus-client/example_multisite.py def get_sites(self): - # see: https://github.com/Checkmk/checkmk/blob/master/packages/cmk-livestatus-client/example_multisite.py - sites_mk = Path(f'{OMD_ROOT}/etc/check_mk/multisite.d/sites.mk') socket_path = f'unix:{OMD_ROOT}/tmp/run/live' if sites_mk.exists(): @@ -372,6 +380,7 @@ class HostCacheMultiSite(HostCacheLiveStatus): self.sites.update({site: { 'alias': data['alias'], 'timeout': data['timeout'], + 'customer': data['customer'] # needed to filter by customer # 'nagios_url': '/nagios/', }}) if data['socket'] == ('local', None): @@ -395,10 +404,32 @@ class HostCacheMultiSite(HostCacheLiveStatus): }}) LOGGER.critical( - f'{self.backend} file {str(sites_mk.absolute())} not found. Fallback to' + f'{self.backend} file {str(sites_mk.absolute())} not found. Fallback to ' 'local site only. Try -b RESTAPI if you have a distributed environment.' ) + 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': + self.sites = {site: data for site, data in self.sites.items() if site not in sites} + case _: + return + + 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 + } + case 'EXCLUDE': + self.sites = { + site: data for site, data in self.sites.items() if data.get('customer') not in costumers + } + case _: + return + def get_raw_data(self, query: str) -> object: return self.c.query(query=query) @@ -589,89 +620,3 @@ class HostCacheRestApi(HostCache): LOGGER.debug(f'{self.backend} # of host found: {len(self.cache.keys())}') else: LOGGER.warning(f'{self.backend} respons: {resp.text}') - - -class HostCacheFileSystem(HostCache): - def __init__(self, pre_fetch: bool): - super().__init__(pre_fetch, '[FILESYSTEM]') - - def get_inventory_data(self, hosts: Sequence[str]) -> Dict[str, Dict | None]: - host_data: Dict[str, Dict | None] = {} - __inventory_path = 'var/check_mk/inventory' - for host in hosts: - # init host_data with None - host_data[host] = None - inventory_file: Path = Path(f'{OMD_ROOT}/{__inventory_path}/{host}') - try: - data = literal_eval(inventory_file.read_text()) - except FileNotFoundError: - LOGGER.warning( - msg=f'{self.backend} Device: {host}: not found in inventory data path!' - ) - continue - - LOGGER.debug(f'{self.backend} data for host {host}: {data}') - host_data[host] = data - - return host_data - - def get_interface_data(self, hosts: Sequence[str]) -> Dict[str, Dict | None]: - """ - Sample autochecks data, we keep only the item - [ - {'check_plugin_name': 'if64', 'item': 'Fa0', 'parameters': {'discovered_oper_status': ['2'], ...}},\n # noqa: E501 - {'check_plugin_name': 'if64', 'item': 'Fa1/0/1', 'parameters': {'discovered_oper_status': ['1'], ...}},\n # noqa: E501 - {'check_plugin_name': 'if64', 'item': 'Fa1/0/10', 'parameters': {'discovered_oper_status': ['2'], ...}},\n # noqa: E501 - {'check_plugin_name': 'if64', 'item': 'Fa1/0/11', 'parameters': {'discovered_oper_status': ['2'], ...}},\n # noqa: E501 - {'check_plugin_name': 'if64', 'item': 'Fa1/0/12', 'parameters': {'discovered_oper_status': ['1'], ...}}\n # noqa: E501 - ] - - Args: - hosts: list of names of the host objects in cmk to fetch the data for - - Returns: - List of interface service items - - """ - host_data: Dict[str, Dict | None] = {} - __autochecks_path = 'var/check_mk/autochecks' - for host in hosts: - # init host_data with None - host_data[host] = None - autochecks_file: Path = Path(f'{OMD_ROOT}/{__autochecks_path}/{host}.mk') - items: Dict[str, object] = {} - try: - data: Sequence[Mapping[str, str]] = literal_eval(autochecks_file.read_text()) - except FileNotFoundError: - LOGGER.warning( - msg=f'{self.backend} Device: {host}: not found in auto checks path!' - ) - continue - LOGGER.debug(f'{self.backend} data for host {host}: {data}') - - for service in data: - if service['check_plugin_name'] in ['if64']: - items[service['item']] = {} - - if items: - LOGGER.info( - msg=f'{self.backend} Interfaces items found: {len(items)} an host {host}' - ) - host_data[host] = items - else: - LOGGER.warning( - msg=f'{self.backend} No Interfaces items found for host {host}' - ) - return host_data - - def host_exists(self, host: str) -> bool: - # always True, don't works in distributed environments as autocheck files - # are only locally available - return False - - def get_hosts_by_label(self, label: str) -> List[str] | None: - # not implemented, no (good) way to get a list of hosts from file system - return None - - def pre_fetch_hosts(self): - pass diff --git a/source/bin/nvdct/lib/settings.py b/source/bin/nvdct/lib/settings.py index 610abdc61e0a40aea07ba4e51053713aa5e3764b..8a55f5553528b266fbe2e180d82da55d50905afe 100755 --- a/source/bin/nvdct/lib/settings.py +++ b/source/bin/nvdct/lib/settings.py @@ -10,11 +10,10 @@ # fixed path to default user data file -from _collections_abc import Mapping +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 pathlib import Path from sys import exit as sys_exit from time import strftime from typing import Dict, List, NamedTuple @@ -23,10 +22,8 @@ from lib.utils import ( ExitCodes, get_data_from_toml, get_local_cmk_api_port, - get_local_cmk_version, + # get_local_cmk_version, LOGGER, - NVDCT_VERSION, - SCRIPT, TIME_FORMAT, USER_DATA_FILE, Layer @@ -71,24 +68,25 @@ class Settings: 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 = 'var/topology_data' self.__topology_save_path_cmk_2_3 = 'var/check_mk/topology/data' # cli defaults self.__settings = { # 'api_port': 80, 'backend': 'MULTISITE', + 'case': None, 'check_user_data_only': False, 'default': False, 'dont_compare': False, + 'filter_customers': None, + 'filter_sites': None, 'keep': False, 'keep_domain': False, 'layers': ['CDP'], 'log_file': f'{self.omd_root}/var/log/nvdct.log', 'log_level': 'WARNING', 'log_to_stdout': False, - 'lowercase': False, 'min_age': 0, - # 'new_format': False, 'output_directory': None, 'prefix': None, 'quiet': False, @@ -97,9 +95,8 @@ class Settings: 'skip_l3_if': False, 'skip_l3_ip': False, 'time_format': TIME_FORMAT, - 'uppercase': False, 'user_data_file': f'{self.omd_root}/local/bin/nvdct/conf/{USER_DATA_FILE}', - 'version': False, + # 'version': False, } # args in the form {'s, __seed_devices': 'CORE01', 'p, __path_in_inventory': None, ... }} # we will remove 's, __' @@ -107,10 +104,6 @@ class Settings: {k.split(',')[-1].strip(' ').strip('_'): v for k, v in cli_args.items() if v} ) - if self.__args.get('version'): - print(f'{Path(SCRIPT).name} version: {NVDCT_VERSION}') - sys_exit(ExitCodes.OK.value) - self.__user_data = get_data_from_toml( file=self.__args.get('user_data_file', self.user_data_file) ) @@ -134,7 +127,6 @@ class Settings: sys_exit(ExitCodes.BAD_OPTION_LIST.value) self.__api_port: int | None = None - self.__new_format: bool | None = None # init user data with defaults self.__custom_layers: List[StaticConnection] | None = None @@ -151,6 +143,8 @@ class Settings: self.__seed_devices: List[str] | None = None self.__static_connections: List[StaticConnection] | None = None self.__emblems: Emblems | None = None + self.__sites: List[str] | None = None + self.__customers: List[str] | None = None # # CLI settings @@ -169,7 +163,25 @@ class Settings: @property # -b --backend def backend(self) -> str: - return str(self.__settings['backend']) + if str(self.__settings['backend']) in ['LIVESTATUS', 'MULTISITE', 'RESTAPI']: + return str(self.__settings['backend']) + else: # fallback to defaukt -> exit ?? + LOGGER.warning( + f'Unknown backend: {self.__settings['backend']}. Accepted backends are: ' + 'LIVESTATUS, MULTISITE, RESTAPI. Fall back zo MULTISITE.' + ) + return 'MULTISITE' + + @property # --case + def case(self) -> str| None: + if self.__settings['case'] in ['LOWER', 'UPPER']: + return self.__settings['case'] + elif self.__settings['case'] is not None: + LOGGER.warning( + f'Unknon case setting {self.__settings["case"]}. ' + 'Accepted are LOWER|UPPER. Fallback to no change.' + ) + return None @property # --check-user-data-only def check_user_data_only(self) -> bool: @@ -183,6 +195,28 @@ class Settings: def dont_compare(self) -> bool: return bool(self.__settings['dont_compare']) + @property # --filter-customers + def filter_customers(self) -> str | None: + if self.__settings['filter_customers'] in ['INCLUDE', 'EXCLUDE']: + return self.__settings['filter_customers'] + elif self.__settings['filter_customers'] is not None: + LOGGER.error( + f'Wrong setting for "filter_customers": ' + f'{self.__settings["filter_customers"]}, supported settings INCLUDE|EXCLUDE.' + ) + return None + + @property # --filter-sites + def filter_sites(self) -> str | None: + if self.__settings['filter_sites'] in ['INCLUDE', 'EXCLUDE']: + return self.__settings['filter_sites'] + elif self.__settings['filter_sites'] is not None: + LOGGER.error( + f'Wrong setting for "filter_sites": ' + f'{self.__settings["filter_sites"]}, supported settings INCLUDE|EXCLUDE.' + ) + return None + @property # --keep def keep(self) -> int | None: if isinstance(self.__settings['keep'], int): @@ -225,10 +259,6 @@ class Settings: def log_to_stdtout(self) -> bool: return bool(self.__settings['log_to_stdout']) - @property # --lowercase - def lowercase(self) -> bool: - return bool(self.__settings['lowercase']) - @property # --min-age def min_age(self) -> int: if isinstance(self.__settings['min_age'], int): @@ -236,18 +266,6 @@ class Settings: else: return 0 - @property # --new-format - def new_format(self) -> bool: - if self.__new_format is None: - if self.__settings.get('new_format') is True: - self.__new_format = True - elif get_local_cmk_version().startswith('2.2'): - self.__new_format = False - else: - self.__new_format = True - - return self.__new_format - @property # --output-directory def output_directory(self) -> str: # init output directory with current time if not set @@ -289,17 +307,13 @@ class Settings: def time_format(self) -> str: return str(self.__settings['time_format']) - @property # --uppercase - def uppercase(self) -> bool: - return bool(self.__settings['uppercase']) - @property # --user-data-file def user_data_file(self) -> str: return str(self.__settings['user_data_file']) - @property # --version - def version(self) -> bool: - return bool(self.__settings['version']) + # @property # --version + # def version(self) -> bool: + # return bool(self.__settings['version']) # # user data setting @@ -330,7 +344,7 @@ class Settings: @property def drop_hosts(self) -> List[str]: if self.__drop_host is None: - self.__drop_host = [str(host) for host in self.__user_data.get('DROP_HOSTS', [])] + self.__drop_host = [str(host) for host in set(self.__user_data.get('DROP_HOSTS', []))] return self.__drop_host @property @@ -368,9 +382,9 @@ class Settings: @property def l3v4_ignore_hosts(self) -> List[str]: if self.__l3v4_ignore_hosts is None: - self.__l3v4_ignore_hosts = [str(host) for host in self.__user_data.get( + self.__l3v4_ignore_hosts = [str(host) for host in set(self.__user_data.get( 'L3V4_IGNORE_HOSTS', [] - )] + ))] return self.__l3v4_ignore_hosts @property @@ -523,6 +537,22 @@ class Settings: ) 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', []))] + LOGGER.info(f'fFound {len(self.__sites)} to filter on') + return self.__sites + + @property + def customers(self) -> List[str]: + if self.__customers is None: + self.__customers = [ + str(customer) for customer in set(self.__user_data.get('CUSTOMERS', [])) + ] + LOGGER.info(f'fFound {len(self.__customers)} to filter on') + return self.__customers + # # all other settings # @@ -532,10 +562,7 @@ class Settings: @property def topology_save_path(self) -> str: - if self.new_format: - return self.__topology_save_path_cmk_2_3 - - return self.__topology_save_path + return self.__topology_save_path_cmk_2_3 @property def topology_file_name(self) -> str: diff --git a/source/bin/nvdct/lib/topologies.py b/source/bin/nvdct/lib/topologies.py index 712fb332c40881e9fe81edc6784cf7d8e32ded2f..8c35a45b2a6062d07f37c1198a2f994e6b748a7f 100755 --- a/source/bin/nvdct/lib/topologies.py +++ b/source/bin/nvdct/lib/topologies.py @@ -8,7 +8,7 @@ # Date : 2024-06-09 # File : lib/topologies.py -from _collections_abc import Mapping, Sequence +from collections.abc import Mapping, Sequence from ipaddress import IPv4Address, IPv4Network from typing import Dict, List @@ -20,6 +20,8 @@ from lib.utils import LOGGER class NvObjects: def __init__(self) -> None: self.nv_objects: Dict[str, any] = {} + self.host_count: int = 0 + self.host_list: List[str] = [] def add_host_object( self, @@ -28,6 +30,8 @@ class NvObjects: emblem: str | None = None ) -> None: if host not in self.nv_objects: + self.host_count += 1 + self.host_list.append(host) link: Dict = {} metadata: Dict = {} # LOGGER.debug(f'host: {host}, {host_cache.host_exists(host=host)}') diff --git a/source/bin/nvdct/lib/utils.py b/source/bin/nvdct/lib/utils.py index 572fb9e36fd703720fc9340cc9a433c72c298ab1..d92a87de116cc4343da4454f6fd9cdc6eb350802 100755 --- a/source/bin/nvdct/lib/utils.py +++ b/source/bin/nvdct/lib/utils.py @@ -7,7 +7,7 @@ # Date : 2023-10-12 # File : nvdct/lib/utils.py -from _collections_abc import Mapping, Sequence +from collections.abc import Mapping, Sequence from ast import literal_eval from dataclasses import dataclass from enum import Enum, unique @@ -23,7 +23,7 @@ from time import time as now_time from tomllib import loads as toml_loads, TOMLDecodeError from typing import List, Dict, TextIO -NVDCT_VERSION = '0.8.12-20240702' +NVDCT_VERSION = '0.9.0-20240923' @unique @@ -251,6 +251,7 @@ def save_data_to_file(data: Mapping, path: str, file: str, make_default: bool) - Path(f'{parent_path}/default').symlink_to(target=Path(path), target_is_directory=True) +# CMK version 2.2.x format def save_topology( data: dict, base_directory: str, @@ -322,6 +323,7 @@ def is_list_of_str_equal(list1: List[str], list2: List[str]) -> bool: return tmp_list1 == tmp_list2 +# not used in cmk 2.3.x format def merge_topologies(topo_pri: Dict, topo_sec: Dict) -> Dict: """ Merge dict_prim into dict_sec diff --git a/source/bin/nvdct/nvdct.py b/source/bin/nvdct/nvdct.py index 39c334ac9c543fe0f559e5454baf42b18cfc83fe..12eda3b07cf50d6727c1da7c1d2445a471e63a4f 100755 --- a/source/bin/nvdct/nvdct.py +++ b/source/bin/nvdct/nvdct.py @@ -113,6 +113,12 @@ # 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: replaced options --lowercase/--uppercase with --case LOWER|UPPER +# changed version output from settings to argparse action +# removed backend FILESYSTEM -> will fallback to MULTISITE +# 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 # creating topology data json from inventory data # @@ -208,7 +214,7 @@ __data = { """ import sys -from _collections_abc import Mapping, Sequence +from collections.abc import Mapping, Sequence from ipaddress import IPv4Network from logging import DEBUG from re import compile as re_compile @@ -219,7 +225,6 @@ from lib.args import parse_arguments from lib.backends import ( CacheItems, HostCache, - HostCacheFileSystem, HostCacheLiveStatus, HostCacheMultiSite, HostCacheRestApi, @@ -227,7 +232,7 @@ from lib.backends import ( from lib.topologies import ( NvConnections, NvObjects, - get_list_of_devices, + # get_list_of_devices, get_network_summary, get_service_by_interface, is_ignore_ipv4, @@ -243,12 +248,12 @@ from lib.utils import ( Layer, LAYERS, LOGGER, - merge_topologies, + # merge_topologies, NVDCT_VERSION, PATH_L3v4, remove_old_data, save_data_to_file, - save_topology, + # save_topology, StdoutQuiet, ) from lib.settings import ( @@ -276,9 +281,7 @@ def create_l2_device_from_inv( inv_data: Sequence[Mapping[str, str]], inv_columns: InventoryColumns, label: str, -) -> Dict[str, object] | None: - data: Dict = {'connections': {}, "interfaces": []} - +) -> None: for topo_neighbour in inv_data: # check if required data are not empty if not (neighbour := topo_neighbour.get(inv_columns.neighbour)): @@ -311,10 +314,13 @@ def create_l2_device_from_inv( LOGGER.info(msg=f'drop neighbour: {neighbour}') continue - if SETTINGS.uppercase: + if SETTINGS.case == 'UPPER': neighbour = neighbour.upper() - if SETTINGS.lowercase: + 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 @@ -346,17 +352,11 @@ def create_l2_device_from_inv( f'-> neighbour_port {neighbour_port}' ) - if neighbour and local_port and neighbour_port: - data['connections'].update({local_port: [neighbour, neighbour_port, label]}) - if local_port not in data['interfaces']: - data['interfaces'].append(local_port) - metadata = { 'duplex': topo_neighbour.get('duplex'), 'native_vlan': topo_neighbour.get('native_vlan'), } - # add to new object list 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( @@ -387,32 +387,10 @@ def create_l2_device_from_inv( right=f'{neighbour_port}@{neighbour}', ) - return {host: data} - -def create_static_connections(connections: Sequence[StaticConnection]) -> Dict: - data: Dict = {} +def create_static_connections(connections: Sequence[StaticConnection]): for connection in connections: LOGGER.info(msg=f'connection: {connection}') - if connection.host not in data: - data[connection.host] = {'connections': {}, 'interfaces': []} - - if connection.neighbour not in data: - data[connection.neighbour] = {'connections': {}, 'interfaces': []} - - # add connection from host to neighbour - data[connection.host]['connections'].update({connection.local_port: [ - connection.neighbour, connection.neighbour_port, connection.label - ]}) - data[connection.host]['interfaces'].append(connection.local_port) - - # add connection from neighbour to host - data[connection.neighbour]['connections'].update({ - connection.neighbour_port: [connection.host, connection.local_port, connection.label] - }) - data[connection.neighbour]['interfaces'].append(connection.neighbour_port) - - # new format NV_OBJECTS.add_host_object( host=connection.host, host_cache=HOST_CACHE, @@ -448,22 +426,14 @@ def create_static_connections(connections: Sequence[StaticConnection]) -> Dict: right=f'{connection.neighbour_port}@{connection.neighbour}', ) - LOGGER.debug(msg=data) - LOGGER.info(msg=f'Devices added: {len(data)}, source STATIC') - # if not SETTINGS.quiet: - print(f'Devices added.: {len(data)}, source STATIC') - - return data - def create_l2_topology( seed_devices: Sequence[str], path_in_inventory: str, inv_columns: InventoryColumns, label: str, -) -> Dict: +) -> None: devices_to_go = list(set(seed_devices)) # remove duplicates - topology_data: Dict = {} devices_done = [] while devices_to_go: @@ -482,15 +452,14 @@ def create_l2_topology( host=device, item=CacheItems.inventory, path=path_in_inventory ) if topo_data: - topology_data.update( - create_l2_device_from_inv( - host=device, - inv_data=topo_data, - inv_columns=inv_columns, - label=label, - )) - devices_list = get_list_of_devices(topology_data[device]['connections']) - for _entry in devices_list: + 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) @@ -499,12 +468,6 @@ def create_l2_topology( devices_to_go.remove(device) LOGGER.info(msg=f'Device done: {device}, source: {label}') - LOGGER.info(f'Devices added: {len(devices_done)}, source {label}') - # if not SETTINGS.quiet: - print(f'Devices added.: {len(devices_done)}, source {label}') - - return topology_data - def create_l3v4_topology( ignore_hosts: Sequence[str], @@ -541,7 +504,7 @@ def create_l3v4_topology( emblem = EMBLEMS.ip_network try: ipv4_info = Ipv4Info(**_entry) - except TypeError as e: + except TypeError: # as e LOGGER.warning(f'Drop IPv4 address data for host: {host}, data: {_entry}') continue @@ -550,11 +513,16 @@ def create_l3v4_topology( continue if ipv4_info.cidr == 32: # drop host addresses - LOGGER.info(f'host: {host} dropped host address: {ipv4_info.address}/{ipv4_info.cidr}') + 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}') + 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): @@ -626,14 +594,11 @@ def create_l3v4_topology( left=network, right=f'{ipv4_info.address}@{ipv4_info.device}@{host}', ) - LOGGER.info(msg=f'Devices added: {len(host_list)}, source L3v4') - # if not SETTINGS.quiet: - print(f'Devices added.: {len(host_list)}, source L3v4') - if __name__ == '__main__': start_time = time_ns() SETTINGS = Settings(vars(parse_arguments())) + sys.stdout = StdoutQuiet(quiet=SETTINGS.quiet) configure_logger( @@ -643,7 +608,6 @@ if __name__ == '__main__': ) LOGGER.info(msg='Data creation started') - # if not SETTINGS.quiet: print() print( f'Network Visualisation Data Creation Tool (NVDCT)\n' @@ -653,25 +617,25 @@ if __name__ == '__main__': print() print(f'Start time....: {strftime(SETTINGS.time_format)}') - _backends = { - 'LIVESTATUS': HostCacheLiveStatus, - 'FILESYSTEM': HostCacheFileSystem, - 'MULTISITE': HostCacheMultiSite, - 'RESTAPI': HostCacheRestApi, - } - host_cache_backend = _backends.get(SETTINGS.backend, None) - if not host_cache_backend: - LOGGER.error(msg=f'Backend {SETTINGS.backend} not (yet) implemented') - sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED.value) - elif SETTINGS.backend == 'RESTAPI': - HOST_CACHE = host_cache_backend( - pre_fetch=SETTINGS.pre_fetch, - api_port=SETTINGS.api_port - ) - else: - HOST_CACHE = host_cache_backend( - pre_fetch=SETTINGS.pre_fetch, - ) + match SETTINGS.backend: + case 'RESTAPI': + HOST_CACHE = HostCacheRestApi( + pre_fetch=SETTINGS.pre_fetch, + api_port=SETTINGS.api_port + ) + case 'MULTISITE': + HOST_CACHE = HostCacheMultiSite( + pre_fetch=SETTINGS.pre_fetch, + filter_sites=SETTINGS.filter_sites, + sites = SETTINGS.sites, + ) + case 'LIVESTATUS': + HOST_CACHE = HostCacheLiveStatus( + pre_fetch=SETTINGS.pre_fetch, + ) + case _: + LOGGER.error(msg=f'Backend {SETTINGS.backend} not (yet) implemented') + sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED.value) HOST_MAP = SETTINGS.host_map DROP_HOSTS = SETTINGS.drop_hosts @@ -717,27 +681,26 @@ if __name__ == '__main__': for job in jobs: match job: case 'STATIC': - topology: Dict | None = create_static_connections( + label = 'static' + create_static_connections( connections=SETTINGS.static_connections ) - label = 'static' case 'L3v4': topology = None label = 'l3v4' - if SETTINGS.new_format: - 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 - ) + 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 + ) case _: label = job.label.lower() columns = job.columns.split(',') - topology = create_l2_topology( + create_l2_topology( seed_devices=SETTINGS.seed_devices, path_in_inventory=job.path, inv_columns=InventoryColumns( @@ -748,52 +711,42 @@ if __name__ == '__main__': label=label, ) - if SETTINGS.new_format: - LOGGER.info(f'Save topology {label} in new format') - NV_CONNECTIONS.add_meta_data_to_connections( - nv_objects=NV_OBJECTS, - speed_map=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()) - ), - 'connections': connections - } - save_data_to_file( - data=_data, - path=( - f'{SETTINGS.omd_root}/{SETTINGS.topology_save_path}/' - f'{SETTINGS.output_directory}' - ), - file=f'data_{label}.json', - make_default=SETTINGS.default, - ) + NV_CONNECTIONS.add_meta_data_to_connections( + nv_objects=NV_OBJECTS, + speed_map=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()) + ), + 'connections': connections + } + save_data_to_file( + data=_data, + path=( + f'{SETTINGS.omd_root}/{SETTINGS.topology_save_path}/' + f'{SETTINGS.output_directory}' + ), + file=f'data_{label}.json', + 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)}' + ) + LOGGER.info(msg=message) + print(message) NV_OBJECTS = NvObjects() NV_CONNECTIONS = NvConnections() - if topology: - final_topology = merge_topologies(topo_pri=topology, topo_sec=final_topology) - - if final_topology and not SETTINGS.new_format: - LOGGER.info('Save topology in CMK 2.2.x format') - - save_topology( - data=final_topology, - base_directory=f'{SETTINGS.omd_root}/{SETTINGS.topology_save_path}', - output_directory=SETTINGS.output_directory, - topology_file_name=SETTINGS.topology_file_name, - dont_compare=SETTINGS.dont_compare, - make_default=SETTINGS.default, - ) - if SETTINGS.keep: remove_old_data( keep=SETTINGS.keep, @@ -802,7 +755,6 @@ if __name__ == '__main__': protected=SETTINGS.protected_topologies, ) - # if not SETTINGS.quiet: print(f'Time taken....: {(time_ns() - start_time) / 1e9}/s') print(f'End time......: {strftime(SETTINGS.time_format)}') print() diff --git a/source/packages/nvdct b/source/packages/nvdct index da6ca1483c51a0fa6f3cb5246b5d4fc473bbd8fe..61fa09521d0092bca60c49a0fa3e63a38ec50510 100644 --- a/source/packages/nvdct +++ b/source/packages/nvdct @@ -8,9 +8,8 @@ '\n' 'Features:\n' '\n' - ' - Ready for CMK 2.3.0\n' ' - Create Layer 2 (CDP/LLDP) topology data\n' - ' - Create Layer 3 (IPv4) topology data (CMK2.3.0 only)\n' + ' - Create Layer 3 (IPv4) topology data\n' ' - Highlight connection issues (Speed, Duplex/Half duplex, ' 'Native VLAN)\n' ' - Read connection data from the Checkmk HW/SW inventory.\n' @@ -19,16 +18,13 @@ ' - Add custom connections (STATIC) for connections that are ' 'not in the inventory\n' ' - Optimized for my CDP, LLDP and IPv4 inventory plugins\n' - ' - Merge CDP, LLDP, STATIC topologies (CMK 2.2.0 only)\n' ' - Can also be used with custom inventory plugins\n' '\n' 'For more information about the network visualization plugin ' 'see: \n' 'the announcement from schnetz ' 'https://forum.checkmk.com/t/network-visualization/41680\n' - 'and the plugin on the Exchange (CMK 2.2.0 only, CMK 2.3.0 has ' - 'this built-in) ' - 'https://exchange.checkmk.com/p/network-visualization\n' + '(CMK 2.3.0 has this plugin built-in) \n' '\n' 'The inventory data could be created with my inventory ' 'plugins:\n' @@ -58,7 +54,7 @@ 'htdocs/images/icons/location_80.png']}, 'name': 'nvdct', 'title': 'Network Visualization Data Creation Tool (NVDCT)', - 'version': '0.8.12-20240702', - 'version.min_required': '2.2.0b1', + 'version': '0.9.0-20240923', + 'version.min_required': '2.3.0b1', 'version.packaged': 'cmk-mkp-tool 0.2.0', 'version.usable_until': '2.4.0p1'}