From cf6919008f783677caae798071e3f4ec46ad2bd3 Mon Sep 17 00:00:00 2001 From: "th.l" <thl-cmk@outlook.com> Date: Mon, 30 Dec 2024 11:29:19 +0100 Subject: [PATCH] refactoring/cleanup - streamlining topology log messages added: --adjust-toml --display-l2-neighbours --include-l3-loopback --skip-l3-cidr-0 --skip-l3-cidr-32-128 --skip-l3-public fixed: --dont-compare --keep changed: L2_DROP_HOSTS -> L2_DROP_NEIGHBOURS removed: CUSTOM_LAYERS --- README.md | 2 +- mkp/nvdct-0.9.7-20241230.mkp | Bin 0 -> 47429 bytes source/bin/nvdct/conf/nvdct.toml | 47 +- source/bin/nvdct/lib/args.py | 207 ++++--- source/bin/nvdct/lib/backends.py | 169 ++++-- source/bin/nvdct/lib/constants.py | 327 +++++++--- source/bin/nvdct/lib/settings.py | 349 ++++++----- source/bin/nvdct/lib/topologies.py | 946 ++++++++++++++--------------- source/bin/nvdct/lib/utils.py | 259 ++++---- source/bin/nvdct/nvdct.py | 150 +++-- source/packages/nvdct | 2 +- 11 files changed, 1373 insertions(+), 1085 deletions(-) create mode 100644 mkp/nvdct-0.9.7-20241230.mkp diff --git a/README.md b/README.md index f4a9179..9ba3b1d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.6-20241222.mkp "nvdct-0.9.6-20241222.mkp" +[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.7-20241230.mkp "nvdct-0.9.7-20241230.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.7-20241230.mkp b/mkp/nvdct-0.9.7-20241230.mkp new file mode 100644 index 0000000000000000000000000000000000000000..6d8891a76db8f05493e079d6b60d689cc0533ed5 GIT binary patch literal 47429 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 Z{$CEz|8<QbHRoPX%f9lHqtK$F{12KxT)Y4P literal 0 HcmV?d00001 diff --git a/source/bin/nvdct/conf/nvdct.toml b/source/bin/nvdct/conf/nvdct.toml index e907197..d31f64e 100755 --- a/source/bin/nvdct/conf/nvdct.toml +++ b/source/bin/nvdct/conf/nvdct.toml @@ -12,7 +12,7 @@ # contains the user data and settings for nvdct.py # -# list of (additional to -s/--seed-devices) seed devices +# list of CDP/LLDP seed devices (if empty, all CDP/LLDP devices will be used) # [0-9-a-zA-Z\.\_\-]{1,253} -> host L2_SEED_DEVICES = [ # "CORE01", @@ -20,8 +20,8 @@ L2_SEED_DEVICES = [ # "LOCATION02", ] -# drop neighbours with invalid names (only L2 Topologies (i.e. CDP, LLDP, CUSTOM) -L2_DROP_HOSTS = [ +# drop CDP/LLDP neighbours names +L2_DROP_NEIGHBOURS = [ # "not advertised", # "a nother invalid name", ] @@ -53,7 +53,7 @@ L3V4_IGNORE_WILDCARD = [ # ["172.17.128.3", "0.0.127.0"], # ignore all IPs ending with 3 from 172.17.128.0/17 ] -# networks to summarize +# IP _networks_ to summarize L3_SUMMARIZE = [ # "10.193.172.0/24", # "10.194.8.0/23", @@ -82,17 +82,7 @@ STATIC_CONNECTIONS = [ # connection: "left_host"<->"right_host" ] -# 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 to include/excluse, use option --filter-costumers INCLUDE/EXCLUDE +# list customers to include/excluse, use with option --filter-costumers INCLUDE/EXCLUDE # [0-9-a-zA-Z\.\_\-]{1,16} -> customer CUSTOMERS = [ # "customer1", @@ -100,7 +90,7 @@ CUSTOMERS = [ # "customer3", ] -# list site to include/excluse, use option --filter-sites INCLUDE/EXCLUDE +# list site to include/excluse, use with option --filter-sites INCLUDE/EXCLUDE # [0-9-a-zA-Z\.\_\-]{1,16} -> site SITES = [ # "site1", @@ -108,20 +98,21 @@ SITES = [ # "site3", ] -# map inventory neighbour name to Checkmk host name +# map inventory CDP/LLDP 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" -# inventory_neighbour3 = "cmk_host3" +# "inventory_neighbour1" = "cmk_host1" +# "inventory_neighbour2" = "cmk_host2" +# "inventory_neighbour3" = "cmk_host3" +# modify CDP/LLDP neighbour name with regex before mapping to CMK host names [L2_NEIGHBOUR_REPLACE_REGEX] # "regex string to replace" = "string to replace with" # "^(([0-9a-fA-F]){2}[:.-]?){5}([0-9a-fA-F]){2}$" = "" # "\\([0-9a-zA-Z]+\\)$" = "" # "^Meraki.*\\s-\\s" = "" -# replace network objects (takes place after summarize) +# replace _network objects_ in L§ topologies (takes place after summarize) # [0-9-a-zA-Z\.\_\-]{1,253} -> host [L3_REPLACE] # "10.193.172.0/24" = "MPLS" @@ -134,17 +125,18 @@ SITES = [ # can use misc icons from CMK or upload your own in the misc category # for built-in icons use "icon_" as prefix to the name from CMK # max size 80x80px -# "host_node" = "icon_missinc" +# emblems will only be used for non CMK objects +# "host_node" = "icon_alert_unreach" # "ip_address" = "ip-address_80" # "ip_network" = "ip-network_80" # "l3_replace" = "icon_plugins_cloud" # "l3_summarize" = "icon_aggr" -# "service_node" = "icon_missing" +# "service_node" = "icon_alert_unreach" [MAP_SPEED_TO_THICKNESS] # must be sorted from slower to faster speed -# use only one entry to have all conections with the same thickness -# bits per second = thickness +# use only one/no 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 @@ -158,10 +150,12 @@ SITES = [ # backend = "MULTISITE" | "RESTAPI" | "LIVESTATUS" # case = "LOWER" | "UPPER" # default = false +# display_l2_neighbours = false # dont_compare = false # filter_customers = "INCLUDE" |"EXCLUDE" # filter_sites = "INCLUDE" | "EXCLUDE" # include_l3_hosts = false +# include_l3_loopback = false # most likely dropped from inventory (SNMP) before keep = 10 # layers = ["LLDP", "CDP", "L3v4", "STATIC", "CUSTOM"] # log_file = "~/var/log/nvdct.log" @@ -173,6 +167,9 @@ output_directory = 'nvdct' # remove to get date formated directory # prefix = "" # quiet = true # remove_domain = false +# skip_l3_cidr_0 = false +# skip_l3_cidr_32_128 = false # skip_l3_if = false # skip_l3_ip = false +# skip_l3_public = false # time_format = "%Y-%m-%dT%H:%M:%S.%m" diff --git a/source/bin/nvdct/lib/args.py b/source/bin/nvdct/lib/args.py index 5ad06bd..1d1be87 100755 --- a/source/bin/nvdct/lib/args.py +++ b/source/bin/nvdct/lib/args.py @@ -11,18 +11,19 @@ # options used # -b --backend # -d --default -# -l --layer +# -l --layers # -o --output-directory # -p --prefix -# # -s --seed-devices # -u --user-data-file # -v --version # --api-port (deprecated ?) # --case # --check-user-data-only +# --display-l2-neighbours # --dont-compare # --filter-customers # --filter-sites +# --fix-toml # --include-l3-hosts # --keep # --log-file @@ -45,16 +46,19 @@ from argparse import ( from pathlib import Path from lib.constants import ( + Backends, + Case, + CliLong, ExitCodes, - HOME_URL, - MIN_CDP_VERSION, - MIN_LINUX_IP_ADDRESSES, - MIN_LLDP_VERSION, - MIN_SNMP_IP_ADDRESSES, - MIN_WINDOWS_IP_ADDRESSES, + IncludeExclude, + Layers, + LogLevels, + MinVersions, NVDCT_VERSION, SCRIPT, TIME_FORMAT_ARGPARSER, + TomlSections, + URLs, USER_DATA_FILE, ) @@ -63,20 +67,15 @@ def parse_arguments() -> arg_Namespace: parser = ArgumentParser( prog='nvdct.py', description=( - 'This script creates the topology data file needed for the Checkmk "network_visualization"\n' # noqa: E501 - 'plugin by Andreas Boesl and schnetz. For more information see\n' - 'the announcement from schnetz: ' - 'https://forum.checkmk.com/t/network-visualization/41680\n' - 'and the plugin on the Exchange: ' - 'https://exchange.checkmk.com/p/network-visualization .\n' + 'This script creates the topology data file needed for the Checkmk Network Visualization,\n' # noqa: E501 + 'For more information see the announcement from schnetz in the Checkmk forum:\n' + f'{URLs.FORUM_SCHNETZ}\n' '\n' - 'The required inventory data can be created with my inventory plugins:\n' - 'CDP: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache\n' # noqa: E501 - 'LLDP: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cache\n' # noqa: E501 - 'L3v4: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_address\n' # noqa: E501 + 'The required plugins to create the inventory data can be found here:\n' + f'{URLs.TOPIC_NV}\n' '\n' f'\nVersion: {NVDCT_VERSION} | Written by: thl-cmk\n' - f'for more information see: {HOME_URL}' + f'for more information see: {URLs.NVDCT}' ), formatter_class=RawTextHelpFormatter, epilog='Exit codes:\n' @@ -91,156 +90,180 @@ def parse_arguments() -> arg_Namespace: ) parser.add_argument( - '-b', '--backend', - choices=['LIVESTATUS', 'MULTISITE', 'RESTAPI'], + '-b', CliLong.BACKEND, + choices=[Backends.LIVESTATUS, Backends.MULTISITE, Backends.RESTAPI], # default='MULTISITE', help='Backend used to retrieve the topology data\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.', + f' - {Backends.LIVESTATUS} : fetches data via local Livestatus (local site only)\n' + f' - {Backends.MULTISITE} : like LIVESTATUS but for distributed environments (default)\n' + f' - {Backends.RESTAPI} : uses the CMK REST API.', ) parser.add_argument( - '-d', '--default', action='store_const', const=True, # default=False, + '-d', CliLong.DEFAULT, action='store_const', const=True, # default=False, help='Set the created topology data as default. Will be created automatically\n' - 'if it doesn\'t exists.', + 'if it doesnt exists.', ) parser.add_argument( - '-o', '--output-directory', type=str, + '-o', CliLong.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' + f'in "{CliLong.TIME_FORMAT}" format.\n' 'NOTE: the directory is a sub directory under "~/var/check_mk/topology/data/"\n', ) - # parser.add_argument( - # '-s', '--seed-devices', type=str, nargs='+', - # help=f'List of devices to start the topology discovery from.\n' - # f'I.e. {SAMPLE_SEEDS}', - # ) parser.add_argument( - '-p', '--prefix', type=str, - help='Prepends each host with the prefix. (Needs testing)\n' + '-p', CliLong.PREFIX, type=str, + help='Prepends each host with the prefix. (Needs more testing)\n' ) parser.add_argument( - '-l', '--layers', + '-l', CliLong.LAYERS, nargs='+', choices=[ - 'CDP', - 'CUSTOM', - 'L3v4', - 'LLDP', - 'STATIC', + Layers.CDP, + Layers.LLDP, + Layers.L3V4, + Layers.STATIC, ], # default=['CDP'], help=( - f' - CDP : needs inv_cdp_cache package at least in version {MIN_CDP_VERSION}\n' - f' - LLDP : needs inv_lldp_cache package at least in version {MIN_LLDP_VERSION}\n' - f' - L3v4 : needs inv_ip_address package at least in version {MIN_SNMP_IP_ADDRESSES} for SNMP based hosts\n' - f' for Linux based hosts inv_lnx_ip_if in version {MIN_LINUX_IP_ADDRESSES}\n' - f' for Windows based hosts inv_win_ip_if in version {MIN_WINDOWS_IP_ADDRESSES}\n' - f' - STATIC : creates a topology base on the "STATIC_CONNECTIONS" in the toml file\n' - f' - CUSTOM : (deprecated)\n' + f' - {Layers.CDP} : needs inv_cdp_cache package at least in version {MinVersions.CDP}\n' + f' - {Layers.LLDP} : needs inv_lldp_cache package at least in version {MinVersions.LLDP}\n' + f' - {Layers.L3V4} : needs inv_ip_address package at least in version {MinVersions.SNMP_IP_ADDRESSES} for SNMP based hosts\n' + f' for Linux based hosts inv_lnx_ip_if in version {MinVersions.LINUX_IP_ADDRESSES}\n' + f' for Windows based hosts inv_win_ip_if in version {MinVersions.WINDOWS_IP_ADDRESSES}\n' + f' - {Layers.STATIC} : creates a topology base on the "[{TomlSections.STATIC_CONNECTIONS}]" section in the TOML file\n' ) ) parser.add_argument( - '-u', '--user-data-file', type=str, - help='Set the name uf the user provided data file\n' + '-u', CliLong.USER_DATA_FILE, type=str, + help='Set the name of the user provided data file\n' f'Default is ~/local/bin/nvdct/conf/{USER_DATA_FILE}\n', ) parser.add_argument( - '-v', '--version', action='version', + '-v', CliLong.VERSION, action='version', version=f'{Path(SCRIPT).name} version: {NVDCT_VERSION}', help='Print version of this script and exit', ) parser.add_argument( - '--api-port', type=int, # default=False, - help='TCP Port to access the REST API. Default is 80. NVDCT will try to automatically\n' + CliLong.ADJUST_TOML, action='store_const', const=True, # default=False, + help='Adjusts old options in TOML file.', + ) + parser.add_argument( + CliLong.API_PORT, type=int, # default=False, + help='TCP Port to access the REST API. By NVDCT will try to automatically\n' 'detect the site apache port.', ) parser.add_argument( - '--case', - choices=['LOWER', 'UPPER'], + CliLong.CASE, + choices=[Case.LOWER, Case.UPPER], # default='NONE', - help='Change neighbour name to all lower/upper case', + help='Change L2 neighbour name to all lower/upper case before matching to CMK host', ) parser.add_argument( - '--check-user-data-only', action='store_const', const=True, # default=False, + CliLong.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.', ) parser.add_argument( - '--log-file', type=str, - help='Set the log file. Default is ~/var/log/nvdct.log\n', + CliLong.LOG_FILE, type=str, + help='Set the log file. Default is "~/var/log/nvdct.log"\n', ) parser.add_argument( - '--log-level', + CliLong.LOG_LEVEL, # nargs='+', - choices=['CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'OFF'], + choices=[ + LogLevels.CRITICAL, + LogLevels.FATAL, + LogLevels.ERROR, + LogLevels.WARNING, + LogLevels.INFO, + LogLevels.DEBUG, + LogLevels.OFF + ], # default='WARNING', - help='Sets the log level. The default is "WARNING"\n' + help=f'Sets the log level. The default is "{LogLevels.WARNING}"\n' ) parser.add_argument( - '--log-to-stdout', action='store_const', const=True, # default=False, + CliLong.LOG_TO_STDOUT, action='store_const', const=True, # default=False, help='Send log to stdout.', ) parser.add_argument( - '--dont-compare', action='store_const', const=True, # default=False, + CliLong.DISPLAY_L2_NEIGHBOURS, action='store_const', const=True, # default=False, + help='Use L2 neighbour name as display name in L2 topologies', + ) + parser.add_argument( + CliLong.DONT_COMPARE, action='store_const', const=True, # default=False, help='Do not compare the actual topology data with the default topology\n' 'data. By default, the actual topology is compared with the default\n' 'topology. If the data matches, the actual topology is not saved.\n' '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".' + f'created only if there was a change, unless you use "{CliLong.DONT_COMPARE}".' ) parser.add_argument( - '--filter-customers', - choices=['INCLUDE', 'EXCLUDE'], + CliLong.FILTER_CUSTOMERS, + choices=[IncludeExclude.INCLUDE, IncludeExclude.EXCLUDE], # default='INCLUDE', - help='INCLUDE/EXCLUDE customer list from config file.\n' - 'Note: MULTISITE backend only.', + help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} customer list "[{TomlSections.CUSTOMERS}]" from TOML file.\n' + f'Note: {Backends.MULTISITE} backend only.', ) parser.add_argument( - '--filter-sites', - choices=['INCLUDE', 'EXCLUDE'], + CliLong.FILTER_SITES, + choices=[IncludeExclude.EXCLUDE, IncludeExclude.EXCLUDE], # default='INCLUDE', - help='INCLUDE/EXCLUDE site list from config file.\n' - 'Note: MULTISITE backend only.', + help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} site list "[{TomlSections.SITES}]" from TOML file.\n' + ) + parser.add_argument( + CliLong.INCLUDE_L3_HOSTS, action='store_const', const=True, # default=False, + help='Include hosts (single IP objects) in layer 3 topologies', ) parser.add_argument( - '--include-l3-hosts', action='store_const', const=True, # default=False, - help='Include hosts (single IP objects) in layer 3 topology', + CliLong.INCLUDE_L3_LOOPBACK, action='store_const', const=True, # default=False, + help='Include loopback ip-addresses in layer 3 topologies', ) parser.add_argument( - '--remove-domain', action='store_const', const=True, # default=False, - help='Remove the domain name from the neighbor name', + CliLong.REMOVE_DOMAIN, action='store_const', const=True, # default=False, + help='Remove the domain name from the L2 neighbor name before matching CMK host.', ) parser.add_argument( - '--keep', type=int, + CliLong.KEEP, type=int, help='Number of topologies to keep. The oldest topologies above keep\n' - 'max will be deleted.\n' - 'NOTE: The default topologies will be always kept.\n' + 'will be deleted.\n' + 'NOTE: The default/protected topologies will be kept always.\n' ) parser.add_argument( - '--min-age', type=int, - help='The minimum number of days before a topology is deleted by "--keep".' + CliLong.MIN_AGE, type=int, + help=f'The minimum number of days before a topology is deleted by "{CliLong.KEEP}"' ) 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' + CliLong.PRE_FETCH, action='store_const', const=True, # default=False, + help=f'Try to fetch host data, with less API calls. Can improve {Backends.RESTAPI} backend\n' 'performance', ) parser.add_argument( - '--quiet', action='store_const', const=True, # default=False, - help='Suppress output to stdtout', + CliLong.QUIET, action='store_const', const=True, # default=False, + help='Suppress all output to stdtout', + ) + parser.add_argument( + CliLong.SKIP_L3_CIDR_0, action='store_const', const=True, # default=False, + help='Skip ip-address with CIDR "/0" in layer 3 topologies', + ) + parser.add_argument( + CliLong.SKIP_L3_CIDR_32_128, action='store_const', const=True, # default=False, + help='Skip ip-address with CIDR "/32" or "/128" in layer 3 topologies', + ) + parser.add_argument( + CliLong.SKIP_L3_IF, action='store_const', const=True, # default=False, + help='Dont show interface in layer 3 topologies', ) parser.add_argument( - '--skip-l3-if', action='store_const', const=True, # default=False, - help='Skip interface in layer 3 topology', + CliLong.SKIP_L3_IP, action='store_const', const=True, # default=False, + help='Dont show ip-addresses in layer 3 topologies', ) parser.add_argument( - '--skip-l3-ip', action='store_const', const=True, # default=False, - help='Skip ip-address in layer 3 topology', + CliLong.SKIP_L3_PUBLIC, action='store_const', const=True, # default=False, + help='Skip public ip-addresses in layer 3 topologies', ) parser.add_argument( - '--time-format', type=str, - help=f'Format string to render the time. (default: {TIME_FORMAT_ARGPARSER})', + CliLong.TIME_FORMAT, type=str, + help=f'Format string to render the time. (default: "{TIME_FORMAT_ARGPARSER}")', ) return parser.parse_args() diff --git a/source/bin/nvdct/lib/backends.py b/source/bin/nvdct/lib/backends.py index 78a7d4b..80adf5a 100755 --- a/source/bin/nvdct/lib/backends.py +++ b/source/bin/nvdct/lib/backends.py @@ -16,19 +16,23 @@ from abc import abstractmethod from ast import literal_eval from collections.abc import Mapping, MutableSequence, Sequence -from enum import Enum, unique from pathlib import Path from requests import session from sys import exit as sys_exit -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, MutableMapping from livestatus import MultiSiteConnection, SiteConfigurations, SiteId from lib.constants import ( + Backends, CACHE_INTERFACES_DATA, + CacheItems, + Case, ExitCodes, + IncludeExclude, + InvPaths, OMD_ROOT, - PATH_INTERFACES, + TomlSections, ) from lib.utils import ( LOGGER, @@ -84,36 +88,45 @@ def hosts_to_query(hosts: List[str]) -> Tuple[str, List[str]]: return hosts_str, open_hosts - -@unique -class CacheItems(Enum): - inventory = 'inventory' - interfaces = 'interfaces' - - def __get__(self, instance, owner): - return self.value - class HostCache: def __init__( self, - pre_fetch: bool, backend: str, + pre_fetch: bool, ): LOGGER.info('init HOST_CACHE') self.cache: Dict = {} + self.neighbour_to_host: MutableMapping[str, str] = {} self._inventory_pre_fetch_list: List[str] = [ - PATH_INTERFACES, + InvPaths.INTERFACES, ] - self.pre_fetch: bool = bool(pre_fetch) self.backend: str = str(backend) + self.case: str = '' + self.l2_host_map: Dict[str, str] = {} + self.l2_neighbour_replace_regex: List[Tuple[str, str]] = [] + self.pre_fetch: bool = bool(pre_fetch) + self.prefix: str = '' + self.remove_domain: bool = False if self.pre_fetch: for host in self.query_all_hosts(): self.cache[host] = HOST_EXIST.copy() - def get_inventory_data(self, hosts: Sequence[str]) -> Dict[str, Dict]: + def init_neighbour_to_host( + self, + case: str, + l2_host_map: Dict[str, str], + prefix: str, + remove_domain: bool, + ): + self.case: str = case + self.l2_host_map: Dict[str, str] = l2_host_map + self.prefix: str = prefix + self.remove_domain: bool = remove_domain + + def get_inventory_data(self, hosts: List[str]) -> Dict[str, Dict]: """ Returns a dictionary of hosts and there inventory data. Args: @@ -136,7 +149,7 @@ class HostCache: return inventory_data - def get_interface_data(self, hosts: Sequence[str]) -> Dict[str, Dict | None]: + def get_interface_data(self, hosts: List[str]) -> Dict[str, Dict | None]: """ Returns Dictionary of hosts and there interface services from CMK. The interface information consists of the "Item", the "Description (summary)" and the service details @@ -185,7 +198,7 @@ class HostCache: """ return self.query_hosts_by_label(label) - def fill_cache(self, hosts: Sequence[str]) -> None: + def fill_cache(self, hosts: List[str]) -> None: """ Gets the host data from CMK and puts them in the host cache. Data collected: - inventory @@ -218,13 +231,13 @@ class HostCache: self.cache[host][CacheItems.interfaces] = {} self.cache[host][CacheItems.interfaces][CACHE_INTERFACES_DATA] = interfaces - def get_data(self, host: str, item: CacheItems, path: str) -> Dict[str, any] | None: + def get_data(self, host: str, item: CacheItems, path: str) -> Mapping | Sequence | None: """ Returns data from self.cache. If the cache for "host" is empty, data will be fetched from CMK Args: host: host to get data from cache item: item in cache (inventory/interface) - path: path in cache item + path: path in cache to data Returns: the requested data or None @@ -243,6 +256,62 @@ class HostCache: def add_inventory_path(self, path: str) -> None: self._inventory_pre_fetch_list = list(set(self._inventory_pre_fetch_list + [path])) + def get_host_from_neighbour(self, neighbour: str) -> str | None: + """ + Tries to get the CMK host name from a L2 neighbour name. It will test: + - the neighbour without domain name + - map the neighbour to a host via L2_HOST_MAP + - the neighbour in UPPER case (without domain) + - the neighbour in lower case (including domain) + - the neighbour with prefix + Args: + neighbour: the L2 neighbour name to find a CMK host for + + Returns: + The CMK host name for the L2 neighbour or None if no host is found + + """ + try: + return self.neighbour_to_host[neighbour] + except KeyError: + pass + + host = neighbour + + # rewrite neighbour if inventory neighbour and checkmk host don't match + if host in self.l2_host_map: + LOGGER.info(f'Replace neighbour by [{TomlSections.L2_HOST_MAP}]: {neighbour} -> {host}') + host = self.l2_host_map[host] + + if self.remove_domain: + LOGGER.debug(f'Remove domain: {host} -> {host.split(".")[0]}') + host = host.split('.')[0] + + match self.case: + case Case.UPPER: + LOGGER.debug(f'Change neighbour to upper case: {host} -> {host.upper()}') + host = host.upper() + + case Case.LOWER: + LOGGER.debug(f'Change neighbour to lower case: {host} -> {host.lower()}') + host = host.lower() + case _: + pass + + if self.prefix: + LOGGER.debug(f'Prepend neighbour with prefix: {host} -> {self.prefix}{host}') + host = f'{self.prefix}{host}' + + + if self.host_exists(host): + self.neighbour_to_host[neighbour] = host + LOGGER.debug(f'Matched neighbour to host: |{neighbour}| -> |{host}|') + return host + else: + self.neighbour_to_host[neighbour] = None + LOGGER.debug(f'No match found for neighbour: |{neighbour}|') + return None + @abstractmethod def query_host(self, host: str) -> bool: """ @@ -285,8 +354,16 @@ class HostCache: raise NotImplementedError class HostCacheLiveStatus(HostCache): - def __init__(self, pre_fetch: bool, backend: str = '[LIVESTATUS]'): - super().__init__(pre_fetch, backend) + def __init__( + self, + pre_fetch: bool, + backend: str = f'[{Backends.LIVESTATUS}]', + ): + self.backend = backend + super().__init__( + pre_fetch = pre_fetch, + backend = self.backend, + ) def get_raw_data(self, query: str) -> any: return get_data_form_live_status(query=query) @@ -294,9 +371,9 @@ class HostCacheLiveStatus(HostCache): def query_host(self, host: str) -> bool: query = ( 'GET hosts\n' - 'Columns: host_name\n' + 'Columns: name\n' 'OutputFormat: python3\n' - f'Filter: host_name = {host}\n' + f'Filter: name = {host}\n' ) data: Sequence[Sequence[str]] = self.get_raw_data(query=query) LOGGER.debug(f'{self.backend} data for host {host}: {data}') @@ -309,7 +386,7 @@ class HostCacheLiveStatus(HostCache): def query_all_hosts(self) -> Sequence[str]: query = ( 'GET hosts\n' - 'Columns: host_name\n' + 'Columns: name\n' 'OutputFormat: python3\n' ) data: Sequence[Sequence[str]] = self.get_raw_data(query=query) @@ -339,9 +416,9 @@ class HostCacheLiveStatus(HostCache): def query_inventory_data(self, hosts: str) -> Dict[str, Dict]: query = ( 'GET hosts\n' - 'Columns: host_name mk_inventory\n' + 'Columns: name mk_inventory\n' 'OutputFormat: python3\n' - f'Filter: host_name ~~ {hosts}\n' + f'Filter: name ~~ {hosts}\n' ) inventory_data = {} data: Sequence[Tuple[str, bytes]] = self.get_raw_data(query=query) @@ -386,11 +463,13 @@ class HostCacheMultiSite(HostCacheLiveStatus): self, pre_fetch: bool, filter_sites: str | None = None, - sites: List[str] = [], + sites: List[str] | None = None, filter_customers: str | None = None, customers: List[str] = None, ): - self.backend = '[MULTISITE]' + if not sites: + sites = [] + self.backend = f'[{Backends.MULTISITE}]' self.sites: SiteConfigurations = SiteConfigurations({}) self.get_sites() self.filter_sites(filter_sites, sites) @@ -402,9 +481,12 @@ class HostCacheMultiSite(HostCacheLiveStatus): self.dead_sites = [site['site']['alias'] for site in self.c.dead_sites().values()] if self.dead_sites: dead_sites = ', '.join(self.dead_sites) - LOGGER.warning(f'{self.backend} WARNING: use of dead site(s) {dead_sites} is disabled') + LOGGER.warning(f'{self.backend} use of dead site(s) {dead_sites} is disabled') self.c.set_only_sites(self.c.alive_sites()) - super().__init__(pre_fetch, self.backend) + super().__init__( + pre_fetch=pre_fetch, + backend=self.backend, + ) def get_raw_data(self, query: str) -> object: return self.c.query(query=query) @@ -459,20 +541,20 @@ class HostCacheMultiSite(HostCacheLiveStatus): def filter_sites(self, filter_: str | None, sites: List[str]): match filter_: - case 'INCLUDE': + case IncludeExclude.INCLUDE: self.sites = {site: data for site, data in self.sites.items() if site in sites} - case 'EXCLUDE': + case IncludeExclude.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': + case IncludeExclude.INCLUDE: self.sites = { site: data for site, data in self.sites.items() if data.get('customer') in costumers } - case 'EXCLUDE': + case IncludeExclude.EXCLUDE: self.sites = { site: data for site, data in self.sites.items() if data.get('customer') not in costumers } @@ -485,9 +567,11 @@ class HostCacheRestApi(HostCache): pre_fetch: bool, api_port: int, filter_sites: str | None = None, - sites: List[str] = [], + sites: List[str] | None = None, ): - self.backend = '[RESTAPI]' + if not sites: + sites = [] + self.backend = f'[{Backends.RESTAPI}]' LOGGER.debug(f'{self.backend} init backend') try: @@ -513,7 +597,10 @@ class HostCacheRestApi(HostCache): self.sites: MutableSequence[str] = self.query_sites() self.filter_sites(filter_=filter_sites, sites=sites) LOGGER.info(f'{self.backend} filtered sites : {self.sites}') - super().__init__(pre_fetch, self.backend) + super().__init__( + pre_fetch=pre_fetch, + backend=self.backend, + ) def get_raw_data(self, url: str, params: Mapping[str, object] | None): resp = self.__session.get( @@ -541,9 +628,9 @@ class HostCacheRestApi(HostCache): def filter_sites(self, filter_: str | None, sites: List[str]): match filter_: - case 'INCLUDE': + case IncludeExclude.INCLUDE: self.sites = [site for site in self.sites if site in sites] - case 'EXCLUDE': + case IncludeExclude.EXCLUDE: self.sites = [site for site in self.sites if site not in sites] case _: return @@ -652,4 +739,4 @@ class HostCacheRestApi(HostCache): 'long_plugin_output': long_plugin_output.split('\\n') } - return interface_data \ No newline at end of file + return interface_data diff --git a/source/bin/nvdct/lib/constants.py b/source/bin/nvdct/lib/constants.py index 590cba1..4dd6c55 100755 --- a/source/bin/nvdct/lib/constants.py +++ b/source/bin/nvdct/lib/constants.py @@ -7,18 +7,35 @@ # Date : 2024-12-11 # File : nvdct/lib/constants.py - -from dataclasses import dataclass from enum import Enum, unique, auto -from logging import getLogger +from logging import Logger, getLogger from os import environ from typing import Final # -NVDCT_VERSION: Final[str] = '0.9.6-20241222' +NVDCT_VERSION: Final[str] = '0.9.7-20241230' +# +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' +LOGGER: Logger = getLogger('root)') +LOG_FILE: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log' +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' +DATAPATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data' + + +class MyEnum(Enum): + def __get__(self, instance, owner): + return self.value + + @unique -class ExitCodes(Enum): +class ExitCodes(MyEnum): OK = 0 BAD_OPTION_LIST = auto() BAD_TOML_FORMAT = auto() @@ -26,104 +43,218 @@ class ExitCodes(Enum): AUTOMATION_SECRET_NOT_FOUND = auto() NO_LAYER_CONFIGURED = auto() - def __get__(self, instance, owner): - return self.value @unique -class IPVersion(Enum): +class IPVersion(MyEnum): IPv4 = 4 IPv6 = 6 - def __get__(self, instance, owner): - return self.value -@dataclass(frozen=True) -class Layer: - path: str - columns: str - label: str - host_label: str +@unique +class URLs(MyEnum): + NVDCT: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct' + # CDP: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache' + # LLDP: Final[str] = 'LLDP: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cach' + # SNMP_IP_ADDRESS: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_address' + # LINUX_SNM_APPRESS: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lnx_if_ip' + # WINDOWS_IP_ADDRESS: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_win_if_ip' + TOPIC_NV: Final[str] = 'https://thl-cmk.hopto.org/gitlab/explore/projects/topics/Network%20Visualization' + FORUM_SCHNETZ: Final[str] = 'https://forum.checkmk.com/t/network-visualization/41680' -# -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_L3: Final[str] = 'address,device,cidr,network,type' -COLUMNS_LLDP: Final[str] = 'neighbour_name,local_port,neighbour_port' -DATAPATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data' -HOME_URL: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct' -HOST_LABEL_CDP: Final[str] = "'nvdct/has_cdp_neighbours' 'yes'" -HOST_LABEL_L3V4_HOSTS: Final[str] = "'nvdct/l3v4_topology' 'host'" -HOST_LABEL_L3V4_ROUTER: Final[str] = "'nvdct/l3v4_topology' 'router'" -HOST_LABEL_L3V6_HOSTS: Final[str] = "'nvdct/l3v6_topology' 'host'" -HOST_LABEL_L3V6_ROUTER: Final[str] = "'nvdct/l3v6_topology' 'router'" -HOST_LABEL_LLDP: Final[str] = "'nvdct/has_lldp_neighbours' 'yes'" -LABEL_CDP: Final[str] = 'CDP' -LABEL_L3v4: Final[str] = 'L3v4' -LABEL_L3v6: Final[str] = 'L3v6' -LABEL_LLDP: Final[str] = 'LLDP' -LABEL_STATIC: Final[str] = 'STATIC' -LOGGER: Final[str] = getLogger('root)') -LOG_FILE: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log' -MIN_CDP_VERSION: Final[str] = '0.7.1-20240320' -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_L3: Final[str] = 'networking,addresses' -PATH_LLDP: Final[str] = 'networking,lldp_cache,neighbours' -SCRIPT: Final[str] = '~/local/bin/nvdct/nvdct.py' -TIME_FORMAT: Final[str] = '%Y-%m-%dT%H:%M:%S.%m' -TIME_FORMAT_ARGPARSER: Final[str] = '%%Y-%%m-%%dT%%H:%%M:%%S.%%m' -USER_DATA_FILE: Final[str] = 'nvdct.toml' -# -TOML_CUSTOMERS : Final[str] = 'CUSTOMERS' -TOML_CUSTOM_LAYERS : Final[str] = 'CUSTOM_LAYERS' -TOML_EMBLEMS : Final[str] = 'EMBLEMS' -TOML_L2_DROP_HOSTS: Final[str] = 'L2_DROP_HOSTS' -TOML_L2_HOST_MAP : Final[str] = 'L2_HOST_MAP' -TOML_L2_NEIGHBOUR_REPLACE_REGEX : Final[str] = 'L2_NEIGHBOUR_REPLACE_REGEX' -TOML_L2_SEED_DEVICES: Final[str] = 'L2_SEED_DEVICES' -TOML_L3V4_IGNORE_WILDCARD : Final[str] = 'L3V4_IGNORE_WILDCARD' -TOML_L3_IGNORE_HOSTS : Final[str] = 'L3_IGNORE_HOSTS' -TOML_L3_IGNORE_IP : Final[str] = 'L3_IGNORE_IP' -TOML_L3_REPLACE : Final[str] = 'L3_REPLACE' -TOML_L3_SUMMARIZE : Final[str] = 'L3_SUMMARIZE' -TOML_MAP_SPEED_TO_THICKNESS : Final[str] = 'MAP_SPEED_TO_THICKNESS' -TOML_PROTECTED_TOPOLOGIES : Final[str] = 'PROTECTED_TOPOLOGIES' -TOML_SETTINGS : Final[str] = 'SETTINGS' -TOML_SITES : Final[str] = 'SITES' -TOML_STATIC_CONNECTIONS : Final[str] = 'STATIC_CONNECTIONS' -# -LAYERS = { - 'CDP': Layer( - path=PATH_CDP, - columns=COLUMNS_CDP, - label=LABEL_CDP, - host_label=HOST_LABEL_CDP, - ), - 'LLDP': Layer( - path=PATH_LLDP, - columns=COLUMNS_LLDP, - label=LABEL_LLDP, - host_label=HOST_LABEL_LLDP, - ), - 'L3v4': Layer( - path=PATH_L3, - columns='', - label=LABEL_L3v4, - host_label=HOST_LABEL_L3V4_ROUTER, - ), - 'L3v6': Layer( - path=PATH_L3, - columns='', - label=LABEL_L3v6, - host_label=HOST_LABEL_L3V6_ROUTER, - ), -} + +@unique +class Backends(MyEnum): + LIVESTATUS: Final[str] = 'LIVESTATUS' + MULTISITE: Final[str] = 'MULTISITE' + RESTAPI: Final[str] = 'RESTAPI' + + +@unique +class Case(MyEnum): + LOWER: Final[str] = 'LOWER' + UPPER: Final[str] = 'UPPER' + +@unique +class CacheItems(MyEnum): + inventory = 'inventory' + interfaces = 'interfaces' + +@unique +class CliLong(MyEnum): + ADJUST_TOML: Final[str] = '--adjust-toml' + API_PORT: Final[str] = '--api-port' + BACKEND: Final[str] = '--backend' + CASE: Final[str] = '--case' + CHECK_USER_DATA_ONLY: Final[str] = '--check-user-data-only' + DEFAULT: Final[str] = '--default' + DISPLAY_L2_NEIGHBOURS: Final[str] = '--display-l2-neighbours' + DONT_COMPARE: Final[str] = '--dont-compare' + FILTER_CUSTOMERS: Final[str] = '--filter-customers' + FILTER_SITES: Final[str] = '--filter-sites' + INCLUDE_L3_HOSTS: Final[str] = '--include-l3-hosts' + INCLUDE_L3_LOOPBACK: Final[str] = '--include-l3-loopback' + KEEP: Final[str] = '--keep' + LAYERS: Final[str] = '--layers' + LOG_FILE: Final[str] = '--log-file' + LOG_LEVEL: Final[str] = '--log-level' + LOG_TO_STDOUT: Final[str] = '--log-to-stdout' + MIN_AGE: Final[str] = '--min-age' + OUTPUT_DIRECTORY: Final[str] = '--output-directory' + PREFIX: Final[str] = '--prefix' + PRE_FETCH: Final[str] = '--pre-fetch' + QUIET: Final[str] = '--quiet' + REMOVE_DOMAIN: Final[str] = '--remove-domain' + SEED_DEVICES: Final[str] = '--seed-devices' + SKIP_L3_CIDR_0: Final[str] = '--skip-l3-cidr-0' + SKIP_L3_CIDR_32_128: Final[str] = '--skip-l3-cidr-32-128' + SKIP_L3_IF: Final[str] = '--skip-l3-if' + SKIP_L3_IP: Final[str] = '--skip-l3-ip' + SKIP_L3_PUBLIC: Final[str] = '--skip-l3-public' + TIME_FORMAT: Final[str] = '--time-format' + USER_DATA_FILE: Final[str] = '--user-data-file' + VERSION: Final[str] = '--version' + + +@unique +class EmblemNames(MyEnum): + HOST_NODE: Final[str] = 'host_node' + IP_ADDRESS: Final[str] = 'ip_address' + IP_NETWORK: Final[str] = 'ip_network' + L3_REPLACE: Final[str] = 'l3_replace' + L3_SUMMARIZE: Final[str] = 'l3_summarize' + SERVICE_NODE: Final[str] = 'service_node' + + +@unique +class EmblemValues(MyEnum): + ICON_AGGREGATION: Final[str] = 'icon_aggr' + ICON_ALERT_UNREACHABLE: Final[str] = 'icon_alert_unreach' + ICON_PLUGINS_CLOUD: Final[str] = 'icon_plugins_cloud' + IP_ADDRESS_80: Final[str] = 'ip-address_80' + IP_NETWORK_80: Final[str] = 'ip-network_80' + + +@unique +class HostLabels(MyEnum): + CDP: Final[str] = "'nvdct/has_cdp_neighbours' 'yes'" + L3V4_HOSTS: Final[str] = "'nvdct/l3v4_topology' 'host'" + L3V4_ROUTER: Final[str] = "'nvdct/l3v4_topology' 'router'" + L3V6_HOSTS: Final[str] = "'nvdct/l3v6_topology' 'host'" + L3V6_ROUTER: Final[str] = "'nvdct/l3v6_topology' 'router'" + LLDP: Final[str] = "'nvdct/has_lldp_neighbours' 'yes'" + + +@unique +class IncludeExclude(MyEnum): + INCLUDE: Final[str] = 'INCLUDE' + EXCLUDE: Final[str] = 'EXCLUDE' + + +@unique +class L2InvColumns(MyEnum): + NEIGHBOUR: Final[str] = 'neighbour_name' + LOCALPORT: Final[str] = 'local_port' + NEIGHBOURPORT: Final[str] = 'neighbour_port' + + +@unique +class L3InvColumns(MyEnum): + ADDRESS: Final[str] = 'address' + DEVICE: Final[str] = 'device' + CIDR: Final[str] = 'cidr' + + +@unique +class InvPaths(MyEnum): + CDP: Final[str] = 'networking,cdp_cache,neighbours' + INTERFACES: Final[str] = 'networking,interfaces' + L3: Final[str] = 'networking,addresses' + LLDP: Final[str] = 'networking,lldp_cache,neighbours' + LLDP_ATTRIBUTE: Final[str] = 'networking,lldp_cache' + + +@unique +class Layers(MyEnum): + CDP: Final[str] = 'CDP' + LLDP: Final[str] = 'LLDP' + L3V4: Final[str] = 'L3v4' + L3V6: Final[str] = 'L3v6' + STATIC: Final[str] = 'STATIC' + + +@unique +class LogLevels(MyEnum): + CRITICAL: Final[str] = 'CRITICAL' + FATAL: Final[str] = 'FATAL' + ERROR: Final[str] = 'ERROR' + WARNING: Final[str] = 'WARNING' + INFO: Final[str] = 'INFO' + DEBUG: Final[str] = 'DEBUG' + OFF: Final[str] = 'OFF' + + +class MinVersions(MyEnum): + CDP: Final[str] = '0.7.1-20240320' + LLDP: Final[str] = '0.9.3-20240320' + LINUX_IP_ADDRESSES: Final[str] = '0.0.4-20241210' + SNMP_IP_ADDRESSES: Final[str] = '0.0.6-20241210' + WINDOWS_IP_ADDRESSES: Final[str] = '0.0.3-20241210' + + +@unique +class TomlSections(MyEnum): + CUSTOMERS: Final[str] = 'CUSTOMERS' + EMBLEMS: Final[str] = 'EMBLEMS' + L2_DROP_NEIGHBOURS: Final[str] = 'L2_DROP_NEIGHBOURS' + L2_HOST_MAP: Final[str] = 'L2_HOST_MAP' + L2_NEIGHBOUR_REPLACE_REGEX: Final[str] = 'L2_NEIGHBOUR_REPLACE_REGEX' + L2_SEED_DEVICES: Final[str] = 'L2_SEED_DEVICES' + L3V4_IGNORE_WILDCARD: Final[str] = 'L3V4_IGNORE_WILDCARD' + L3_IGNORE_HOSTS: Final[str] = 'L3_IGNORE_HOSTS' + L3_IGNORE_IP: Final[str] = 'L3_IGNORE_IP' + L3_REPLACE: Final[str] = 'L3_REPLACE' + L3_SUMMARIZE: Final[str] = 'L3_SUMMARIZE' + MAP_SPEED_TO_THICKNESS: Final[str] = 'MAP_SPEED_TO_THICKNESS' + PROTECTED_TOPOLOGIES: Final[str] = 'PROTECTED_TOPOLOGIES' + SETTINGS: Final[str] = 'SETTINGS' + SITES: Final[str] = 'SITES' + STATIC_CONNECTIONS: Final[str] = 'STATIC_CONNECTIONS' + + +def cli_long_to_toml(cli_param: str) -> str: + return cli_param.strip('-').replace('-', '_') + + +@unique +class TomlSettings(MyEnum): + ADJUST_TOML: Final[str] = cli_long_to_toml(CliLong.ADJUST_TOML) + API_PORT: Final[str] = cli_long_to_toml(CliLong.API_PORT) + BACKEND: Final[str] = cli_long_to_toml(CliLong.BACKEND) + CASE: Final[str] = cli_long_to_toml(CliLong.CASE) + CHECK_USER_DATA_ONLY: Final[str] = cli_long_to_toml(CliLong.CHECK_USER_DATA_ONLY) + DEFAULT: Final[str] = cli_long_to_toml(CliLong.DEFAULT) + DISPLAY_L2_NEIGHBOURS: Final[str] = cli_long_to_toml(CliLong.DISPLAY_L2_NEIGHBOURS) + DONT_COMPARE: Final[str] = cli_long_to_toml(CliLong.DONT_COMPARE) + FILTER_CUSTOMERS: Final[str] = cli_long_to_toml(CliLong.FILTER_CUSTOMERS) + FILTER_SITES: Final[str] = cli_long_to_toml(CliLong.FILTER_SITES) + INCLUDE_L3_HOSTS: Final[str] = cli_long_to_toml(CliLong.INCLUDE_L3_HOSTS) + INCLUDE_L3_LOOPBACK: Final[str] = cli_long_to_toml(CliLong.INCLUDE_L3_LOOPBACK) + KEEP: Final[str] = cli_long_to_toml(CliLong.KEEP) + LAYERS: Final[str] = cli_long_to_toml(CliLong.LAYERS) + LOG_FILE: Final[str] = cli_long_to_toml(CliLong.LOG_FILE) + LOG_LEVEL: Final[str] = cli_long_to_toml(CliLong.LOG_LEVEL) + LOG_TO_STDOUT: Final[str] = cli_long_to_toml(CliLong.LOG_TO_STDOUT) + MIN_AGE: Final[str] = cli_long_to_toml(CliLong.MIN_AGE) + OUTPUT_DIRECTORY: Final[str] = cli_long_to_toml(CliLong.OUTPUT_DIRECTORY) + PREFIX: Final[str] = cli_long_to_toml(CliLong.PREFIX) + PRE_FETCH: Final[str] = cli_long_to_toml(CliLong.PRE_FETCH) + QUIET: Final[str] = cli_long_to_toml(CliLong.QUIET) + REMOVE_DOMAIN: Final[str] = cli_long_to_toml(CliLong.REMOVE_DOMAIN) + SKIP_L3_CIDR_0: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_CIDR_0) + SKIP_L3_CIDR_32_128: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_CIDR_32_128) + SKIP_L3_IF: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_IF) + SKIP_L3_IP: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_IP) + SKIP_L3_PUBLIC: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_PUBLIC) + TIME_FORMAT: Final[str] = cli_long_to_toml(CliLong.TIME_FORMAT) + USER_DATA_FILE: Final[str] = cli_long_to_toml(CliLong.USER_DATA_FILE) diff --git a/source/bin/nvdct/lib/settings.py b/source/bin/nvdct/lib/settings.py index e625634..5b82bff 100755 --- a/source/bin/nvdct/lib/settings.py +++ b/source/bin/nvdct/lib/settings.py @@ -12,39 +12,29 @@ from collections.abc import Mapping from ipaddress import AddressValueError, NetmaskValueError, ip_address, ip_network -from logging import CRITICAL, FATAL, ERROR, WARNING, INFO, DEBUG +from logging import CRITICAL, DEBUG, ERROR, FATAL, INFO, WARNING +from pathlib import Path 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, + Backends, + Case, + EmblemValues, + EmblemNames, ExitCodes, + IncludeExclude, LOGGER, LOG_FILE, - Layer, + LogLevels, OMD_ROOT, TIME_FORMAT, + TomlSections, + TomlSettings, USER_DATA_FILE, - TOML_CUSTOMERS, - TOML_CUSTOM_LAYERS, - TOML_EMBLEMS, - TOML_L2_DROP_HOSTS, - TOML_L2_HOST_MAP, - TOML_L2_NEIGHBOUR_REPLACE_REGEX, - TOML_L2_SEED_DEVICES, - TOML_L3V4_IGNORE_WILDCARD, - TOML_L3_IGNORE_HOSTS, - TOML_L3_IGNORE_IP, - TOML_L3_REPLACE, - TOML_L3_SUMMARIZE, - TOML_MAP_SPEED_TO_THICKNESS, - TOML_PROTECTED_TOPOLOGIES, - TOML_SETTINGS, - TOML_SITES, - TOML_STATIC_CONNECTIONS, ) from lib.utils import ( get_data_from_toml, @@ -72,18 +62,18 @@ class Thickness(NamedTuple): class StaticConnection(NamedTuple): + left_host: str + left_service: str right_host: str right_service: str - left_service: str - left_host: str class Wildcard(NamedTuple): + bit_pattern: int int_ip_address: int int_wildcard: int ip_address: str wildcard: str - bit_pattern: int class Settings: @@ -93,30 +83,36 @@ class Settings: ): # 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, - 'include_l3_hosts': False, - 'keep': False, - 'remove_domain': False, - 'layers': [], - 'log_file': LOG_FILE, - 'log_level': 'WARNING', - 'log_to_stdout': False, - 'min_age': 0, - 'output_directory': None, - 'prefix': None, - 'quiet': False, - 'pre_fetch': False, - 'skip_l3_if': False, - 'skip_l3_ip': False, - 'time_format': TIME_FORMAT, - 'user_data_file': f'{OMD_ROOT}/local/bin/nvdct/conf/{USER_DATA_FILE}', + TomlSettings.ADJUST_TOML: False, + TomlSettings.API_PORT: None, + TomlSettings.BACKEND: Backends.MULTISITE, + TomlSettings.CASE: None, + TomlSettings.CHECK_USER_DATA_ONLY: False, + TomlSettings.DEFAULT: False, + TomlSettings.DISPLAY_L2_NEIGHBOURS: False, + TomlSettings.DONT_COMPARE: False, + TomlSettings.FILTER_CUSTOMERS: None, + TomlSettings.FILTER_SITES: None, + TomlSettings.INCLUDE_L3_HOSTS: False, + TomlSettings.INCLUDE_L3_LOOPBACK: False, + TomlSettings.KEEP: False, + TomlSettings.LAYERS: [], + TomlSettings.LOG_FILE: LOG_FILE, + TomlSettings.LOG_LEVEL: LogLevels.WARNING, + TomlSettings.LOG_TO_STDOUT: False, + TomlSettings.MIN_AGE: 0, + TomlSettings.OUTPUT_DIRECTORY: None, + TomlSettings.PREFIX: None, + TomlSettings.PRE_FETCH: False, + TomlSettings.QUIET: False, + TomlSettings.REMOVE_DOMAIN: False, + TomlSettings.SKIP_L3_CIDR_0: False, + TomlSettings.SKIP_L3_CIDR_32_128: False, + TomlSettings.SKIP_L3_IF: False, + TomlSettings.SKIP_L3_IP: False, + TomlSettings.SKIP_L3_PUBLIC: False, + TomlSettings.TIME_FORMAT: TIME_FORMAT, + TomlSettings.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, __' @@ -125,16 +121,16 @@ class Settings: ) self.__user_data = get_data_from_toml( - file=self.__args.get('user_data_file', self.user_data_file) + file=self.__args.get(TomlSettings.USER_DATA_FILE, self.user_data_file) ) - if self.__args.get('check_user_data_only'): + if self.__args.get(TomlSettings.CHECK_USER_DATA_ONLY): LOGGER.info(msg=f'Could read/parse the user data from {self.user_data_file}') print(f'Could read/parse the user data from {self.user_data_file}') sys_exit(ExitCodes.OK) # defaults -> overridden by toml -> overridden by cli - self.__settings.update(self.__user_data.get(TOML_SETTINGS, {})) + self.__settings.update(self.__user_data.get(TomlSections.SETTINGS, {})) self.__settings.update(self.__args) if self.layers: @@ -149,18 +145,17 @@ class Settings: self.__api_port: int | None = None # init user data with defaults - self.__custom_layers: List[StaticConnection] | None = None self.__customers: List[str] | None = None self.__emblems: Emblems | None = None - self.__l2_drop_host: List[str] | None = None + self.__l2_drop_neighbours: List[str] | None = None self.__l2_host_map: Dict[str, str] | None = None self.__l2_neighbour_replace_regex: List[Tuple[str, str]] | None = None self.__l2_seed_devices: List[str] | None = None self.__l3_ignore_hosts: List[str] | None = None self.__l3_ignore_ip: List[ip_network] | None = None - self.__l3v4_ignore_wildcard: List[Wildcard] | None = None self.__l3_replace: Dict[str, str] | None = None self.__l3_summarize: List[ip_network] | None = None + self.__l3v4_ignore_wildcard: List[Wildcard] | None = None self.__map_speed_to_thickness: List[Thickness] | None = None self.__protected_topologies: List[str] | None = None self.__sites: List[str] | None = None @@ -172,92 +167,108 @@ class Settings: @property # --api-port def api_port(self) -> int: if self.__api_port is None: - if self.__settings.get('api_port'): - self.__api_port = int(self.__settings.get('api_port')) + if self.__settings.get(TomlSettings.API_PORT): + self.__api_port = int(self.__settings.get(TomlSettings.API_PORT)) else: self.__api_port = get_local_cmk_api_port() if self.__api_port is None: - self.__api_port = API_PORT + self.__api_port = API_PORT return self.__api_port @property # -b --backend def backend(self) -> str: - if str(self.__settings['backend']) in ['LIVESTATUS', 'MULTISITE', 'RESTAPI']: - return str(self.__settings['backend']) + if str(self.__settings[TomlSettings.BACKEND]) in [ + Backends.LIVESTATUS, + Backends.MULTISITE, + Backends.RESTAPI + ]: + return str(self.__settings[TomlSettings.BACKEND]) else: # fallback to defaukt -> exit ?? LOGGER.warning( - f'Unknown backend: {self.__settings["backend"]}. Accepted backends are: ' - 'LIVESTATUS, MULTISITE, RESTAPI. Fall back zo MULTISITE.' + f'Unknown backend: {self.__settings[TomlSettings.BACKEND]}. Accepted backends are: ' + f'{Backends.LIVESTATUS}, {Backends.MULTISITE}, {Backends.RESTAPI}. Fall back to {Backends.MULTISITE}.' ) - return 'MULTISITE' + return Backends.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: + if self.__settings[TomlSettings.CASE] in [Case.LOWER, Case.UPPER]: + return self.__settings[TomlSettings.CASE] + elif self.__settings[TomlSettings.CASE] is not None: LOGGER.warning( - f'Unknon case setting {self.__settings["case"]}. ' - 'Accepted are LOWER|UPPER. Fallback to no change.' + f'Unknown case setting {self.__settings[TomlSettings.CASE]}. ' + f'Accepted are {Case.LOWER}|{Case.UPPER}. Fallback to no change.' ) return None @property # --check-user-data-only def check_user_data_only(self) -> bool: - return bool(self.__settings['check_user_data_only']) + return bool(self.__settings[TomlSettings.CHECK_USER_DATA_ONLY]) @property # -d --default def default(self) -> bool: - return bool(self.__settings['default']) + return bool(self.__settings[TomlSettings.DEFAULT]) + + @property # --display-l2-neighbours + def display_l2_neighbours(self) -> bool: + return bool(self.__settings[TomlSettings.DISPLAY_L2_NEIGHBOURS]) @property # --dont-compare def dont_compare(self) -> bool: - return bool(self.__settings['dont_compare']) + return bool(self.__settings[TomlSettings.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: + if self.__settings[TomlSettings.FILTER_CUSTOMERS] in [IncludeExclude.INCLUDE, IncludeExclude.EXCLUDE]: + return self.__settings[TomlSettings.FILTER_CUSTOMERS] + elif self.__settings[TomlSettings.FILTER_CUSTOMERS] is not None: LOGGER.error( - f'Wrong setting for "filter_customers": ' - f'{self.__settings["filter_customers"]}, supported settings INCLUDE|EXCLUDE.' + f'Wrong setting for "{TomlSettings.FILTER_CUSTOMERS}": ' + f'{self.__settings[TomlSettings.FILTER_CUSTOMERS]}, supported settings {IncludeExclude.INCLUDE}|{IncludeExclude.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: + if self.__settings[TomlSettings.FILTER_SITES] in [IncludeExclude.INCLUDE, IncludeExclude.EXCLUDE]: + return self.__settings[TomlSettings.FILTER_SITES] + elif self.__settings[TomlSettings.FILTER_SITES] is not None: LOGGER.error( - f'Wrong setting for "filter_sites": ' - f'{self.__settings["filter_sites"]}, supported settings INCLUDE|EXCLUDE.' + f'Wrong setting for "{TomlSettings.FILTER_SITES}": ' + f'{self.__settings[TomlSettings.FILTER_SITES]}, supported settings {IncludeExclude.INCLUDE}|{IncludeExclude.EXCLUDE}.' ) return None + @property # --include-l3-hosts + def fix_toml(self) -> bool: + return bool(self.__settings[TomlSettings.ADJUST_TOML]) + @property # --include-l3-hosts def include_l3_hosts(self) -> bool: - return bool(self.__settings['include_l3_hosts']) + return bool(self.__settings[TomlSettings.INCLUDE_L3_HOSTS]) + + @property # --skip-l3-ip + def include_l3_loopback(self) -> bool: + return bool(self.__settings[TomlSettings.INCLUDE_L3_LOOPBACK]) @property # --keep def keep(self) -> int | None: - if isinstance(self.__settings['keep'], int): - return max(self.__settings['keep'], 0) + if isinstance(self.__settings[TomlSettings.KEEP], int): + return max(self.__settings[TomlSettings.KEEP], 0) return None @property # --keep-domain def remove_domain(self) -> bool: - return bool(self.__settings['remove_domain']) + return bool(self.__settings[TomlSettings.REMOVE_DOMAIN]) @property # --layers def layers(self) -> List[str]: - return self.__settings['layers'] + return self.__settings[TomlSettings.LAYERS] @property # --log-file def log_file(self) -> str: - raw_log_file = str(Path(str(self.__settings['log_file'])).expanduser()) + raw_log_file = str(Path(str(self.__settings[TomlSettings.LOG_FILE])).expanduser()) if is_valid_log_file(raw_log_file): return raw_log_file else: @@ -267,67 +278,79 @@ class Settings: @property # --log-level def loglevel(self) -> int: log_levels = { - 'DEBUG': DEBUG, - 'INFO': INFO, - 'WARNING': WARNING, - 'ERROR': ERROR, - 'FATAL': FATAL, - 'CRITICAL': CRITICAL, - 'OFF': -1, + LogLevels.DEBUG: DEBUG, + LogLevels.INFO: INFO, + LogLevels.WARNING: WARNING, + LogLevels.ERROR: ERROR, + LogLevels.FATAL: FATAL, + LogLevels.CRITICAL: CRITICAL, + LogLevels.OFF: -1, } - return log_levels.get(self.__settings['log_level'], WARNING) + return log_levels.get(self.__settings[TomlSettings.LOG_LEVEL], WARNING) @property # --log-to-stdout def log_to_stdtout(self) -> bool: - return bool(self.__settings['log_to_stdout']) + return bool(self.__settings[TomlSettings.LOG_TO_STDOUT]) @property # --min-age def min_age(self) -> int: - if isinstance(self.__settings['min_age'], int): - return max(self.__settings['min_age'], 0) + if isinstance(self.__settings[TomlSettings.MIN_AGE], int): + return max(self.__settings[TomlSettings.MIN_AGE], 0) else: return 0 @property # --output-directory def output_directory(self) -> str: # 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"])}' - if is_valid_output_directory(str(self.__settings['output_directory'])): - return str(self.__settings['output_directory']) + if not self.__settings[TomlSettings.OUTPUT_DIRECTORY]: + self.__settings[TomlSettings.OUTPUT_DIRECTORY] = f'{strftime(self.__settings[TomlSettings.TIME_FORMAT])}' + if is_valid_output_directory(str(self.__settings[TomlSettings.OUTPUT_DIRECTORY])): + return str(self.__settings[TomlSettings.OUTPUT_DIRECTORY]) else: LOGGER.error('Falling back to "nvdct"') return 'nvdct' @property # --prefix def prefix(self) -> str | None: - if self.__settings['prefix'] is not None: - return str(self.__settings['prefix']) + if self.__settings[TomlSettings.PREFIX] is not None: + return str(self.__settings[TomlSettings.PREFIX]) return None @property # --pre-fill-cache def pre_fetch(self) -> bool: - return bool(self.__settings['pre_fetch']) + return bool(self.__settings[TomlSettings.PRE_FETCH]) @property # --quiet def quiet(self) -> bool: - return bool(self.__settings['quiet']) + return bool(self.__settings[TomlSettings.QUIET]) + + @property # --skip-l3-cidr-0 + def skip_l3_cidr_0(self) -> bool: + return bool(self.__settings[TomlSettings.SKIP_L3_CIDR_0]) + + @property # --skip-l3-cidr-32-128 + def skip_l3_cidr_32_128(self) -> bool: + return bool(self.__settings[TomlSettings.SKIP_L3_CIDR_32_128]) @property # --skip-l3-if def skip_l3_if(self) -> bool: - return bool(self.__settings['skip_l3_if']) + return bool(self.__settings[TomlSettings.SKIP_L3_IF]) @property # --skip-l3-ip def skip_l3_ip(self) -> bool: - return bool(self.__settings['skip_l3_ip']) + return bool(self.__settings[TomlSettings.SKIP_L3_IP]) + + @property # --skip-l3-public + def skip_l3_public(self) -> bool: + return bool(self.__settings[TomlSettings.SKIP_L3_PUBLIC]) @property # --time-format def time_format(self) -> str: - return str(self.__settings['time_format']) + return str(self.__settings[TomlSettings.TIME_FORMAT]) @property # --user-data-file def user_data_file(self) -> str: - return str(self.__settings['user_data_file']) + return str(self.__settings[TomlSettings.USER_DATA_FILE]) # # user data setting @@ -336,59 +359,36 @@ class Settings: def customers(self) -> List[str]: if self.__customers is None: self.__customers = [ - str(customer) for customer in set(self.__user_data.get(TOML_CUSTOMERS, [])) + str(customer) for customer in set(self.__user_data.get(TomlSections.CUSTOMERS, [])) if is_valid_customer_name(customer)] LOGGER.info(f'Found {len(self.__customers)} to filter on') return self.__customers - @property - def custom_layers(self) -> List[Layer]: - if self.__custom_layers is None: - self.__custom_layers = [] - for _layer in self.__user_data.get(TOML_CUSTOM_LAYERS, []): - try: - self.__custom_layers.append(Layer( - path=_layer['path'], - columns=_layer['columns'], - label=_layer['label'], - host_label=_layer['host_label'] - )) - except KeyError: - LOGGER.error( - f'Invalid entry in {TOML_CUSTOM_LAYERS} -> {_layer} -> ignored' - ) - continue - LOGGER.critical( - f'Valid entries in {TOML_CUSTOM_LAYERS} found: {len(self.__custom_layers)}/' - f'{len(self.__user_data.get(TOML_CUSTOM_LAYERS, []))}' - ) - return self.__custom_layers - @property def emblems(self) -> Emblems: if self.__emblems is None: - raw_emblems = self.__user_data.get(TOML_EMBLEMS, {}) + raw_emblems = self.__user_data.get(TomlSections.EMBLEMS, {}) self.__emblems = Emblems( - host_node=str(raw_emblems.get('host_node', 'icon_missing')), - ip_address=str(raw_emblems.get('ip_address', 'ip-address_80')), - ip_network=str(raw_emblems.get('ip_network', 'ip-network_80')), - l3_replace=str(raw_emblems.get('l3_replace', 'icon_plugins_cloud')), - l3_summarize=str(raw_emblems.get('l3_summarize', 'icon_aggr')), - service_node=str(raw_emblems.get('service_node', 'icon_missing')), + host_node=str(raw_emblems.get(EmblemNames.HOST_NODE, EmblemValues.ICON_ALERT_UNREACHABLE)), + ip_address=str(raw_emblems.get(EmblemNames.IP_ADDRESS, EmblemValues.IP_ADDRESS_80)), + ip_network=str(raw_emblems.get(EmblemNames.IP_NETWORK, EmblemValues.IP_NETWORK_80)), + l3_replace=str(raw_emblems.get(EmblemNames.L3_REPLACE, EmblemValues.ICON_PLUGINS_CLOUD)), + l3_summarize=str(raw_emblems.get(EmblemNames.L3_SUMMARIZE, EmblemValues.ICON_AGGREGATION)), + service_node=str(raw_emblems.get(EmblemNames.SERVICE_NODE, EmblemValues.ICON_ALERT_UNREACHABLE)), ) return self.__emblems @property - def l2_drop_hosts(self) -> List[str]: - if self.__l2_drop_host is None: - self.__l2_drop_host = [str(host) for host in set(self.__user_data.get(TOML_L2_DROP_HOSTS, []))] - return self.__l2_drop_host + def l2_drop_neighbours(self) -> List[str]: + if self.__l2_drop_neighbours is None: + self.__l2_drop_neighbours = [str(host) for host in set(self.__user_data.get(TomlSections.L2_DROP_NEIGHBOURS, []))] + return self.__l2_drop_neighbours @property def l2_seed_devices(self) -> List[str]: if self.__l2_seed_devices is None: self.__l2_seed_devices = list(set(str(host) for host in ( - self.__user_data.get(TOML_L2_SEED_DEVICES, [])) if is_valid_hostname(host))) + self.__user_data.get(TomlSections.L2_SEED_DEVICES, [])) if is_valid_hostname(host))) return self.__l2_seed_devices @property @@ -396,7 +396,7 @@ class Settings: if self.__l2_host_map is None: self.__l2_host_map = { str(host): str(replace_host) for host, replace_host in self.__user_data.get( - TOML_L2_HOST_MAP, {} + TomlSections.L2_HOST_MAP, {} ).items() if is_valid_hostname(host) } return self.__l2_host_map @@ -407,7 +407,7 @@ class Settings: self.__l2_neighbour_replace_regex = [ ( str(regex), str(replace) - ) for regex, replace in self.__user_data.get(TOML_L2_NEIGHBOUR_REPLACE_REGEX, {}).items() + ) for regex, replace in self.__user_data.get(TomlSections.L2_NEIGHBOUR_REPLACE_REGEX, {}).items() ] return self.__l2_neighbour_replace_regex @@ -415,7 +415,7 @@ class Settings: def l3_ignore_hosts(self) -> List[str]: if self.__l3_ignore_hosts is None: self.__l3_ignore_hosts = [str(host) for host in set(self.__user_data.get( - TOML_L3_IGNORE_HOSTS, [] + TomlSections.L3_IGNORE_HOSTS, [] )) if is_valid_hostname(host)] return self.__l3_ignore_hosts @@ -423,31 +423,30 @@ class Settings: def l3_ignore_ips(self) -> List[ip_network]: if self.__l3_ignore_ip is None: self.__l3_ignore_ip = [] - for raw_ip_network in self.__user_data.get(TOML_L3_IGNORE_IP, []): + for raw_ip_network in self.__user_data.get(TomlSections.L3_IGNORE_IP, []): try: self.__l3_ignore_ip.append(ip_network(raw_ip_network, strict=False)) except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in {TOML_L3_IGNORE_IP} found: {raw_ip_network} -> ignored' + f'Invalid entry in {TomlSections.L3_IGNORE_IP} found: {raw_ip_network} -> ignored' ) continue LOGGER.info( - f'Valid entries in {TOML_L3_IGNORE_IP} found: {len(self.__l3_ignore_ip)}/' - f'{len(self.__user_data.get(TOML_L3_IGNORE_IP, []))}' + f'Valid entries in {TomlSections.L3_IGNORE_IP} found: {len(self.__l3_ignore_ip)}/' + f'{len(self.__user_data.get(TomlSections.L3_IGNORE_IP, []))}' ) - return self.__l3_ignore_ip @property def l3v4_ignore_wildcard(self) -> List[Wildcard]: if self.__l3v4_ignore_wildcard is None: self.__l3v4_ignore_wildcard = [] - for entry in self.__user_data.get(TOML_L3V4_IGNORE_WILDCARD, []): + for entry in self.__user_data.get(TomlSections.L3V4_IGNORE_WILDCARD, []): try: raw_ip_address, wildcard = entry except ValueError: LOGGER.error( - f'Invalid entry in {TOML_L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' + f'Invalid entry in {TomlSections.L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' ) continue try: @@ -466,12 +465,12 @@ class Settings: )) except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in {TOML_L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' + f'Invalid entry in {TomlSections.L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' ) continue LOGGER.info( - f'Valid entries in {TOML_L3V4_IGNORE_WILDCARD} found: {len(self.__l3v4_ignore_wildcard)}/' - f'{len(self.__user_data.get(TOML_L3V4_IGNORE_WILDCARD, []))}' + f'Valid entries in {TomlSections.L3V4_IGNORE_WILDCARD} found: {len(self.__l3v4_ignore_wildcard)}/' + f'{len(self.__user_data.get(TomlSections.L3V4_IGNORE_WILDCARD, []))}' ) return self.__l3v4_ignore_wildcard @@ -479,12 +478,12 @@ class Settings: def l3_replace(self) -> Dict[str, str]: if self.__l3_replace is None: self.__l3_replace = {} - for raw_ip_network, node in self.__user_data.get(TOML_L3_REPLACE, {}).items(): + for raw_ip_network, node in self.__user_data.get(TomlSections.L3_REPLACE, {}).items(): try: _ip_network = ip_network(raw_ip_network) # noqa: F841 except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in {TOML_L3_REPLACE} found: {raw_ip_network} -> line ignored' + f'Invalid entry in {TomlSections.L3_REPLACE} found: {raw_ip_network} -> line ignored' ) continue if not is_valid_hostname(node): @@ -492,8 +491,8 @@ class Settings: continue self.__l3_replace[raw_ip_network] = str(node) LOGGER.info( - f'Valid entries in {TOML_L3_REPLACE} found: {len(self.__l3_replace)}/' - f'{len(self.__user_data.get(TOML_L3_REPLACE, {}))}' + f'Valid entries in {TomlSections.L3_REPLACE} found: {len(self.__l3_replace)}/' + f'{len(self.__user_data.get(TomlSections.L3_REPLACE, {}))}' ) return self.__l3_replace @@ -501,17 +500,17 @@ class Settings: def l3_summarize(self) -> List[ip_network]: if self.__l3_summarize is None: self.__l3_summarize = [] - for raw_ip_network in self.__user_data.get(TOML_L3_SUMMARIZE, []): + for raw_ip_network in self.__user_data.get(TomlSections.L3_SUMMARIZE, []): try: self.__l3_summarize.append(ip_network(raw_ip_network, strict=False)) except (AddressValueError, NetmaskValueError): LOGGER.error( - f'Invalid entry in {TOML_L3_SUMMARIZE} -> {raw_ip_network} -> ignored' + f'Invalid entry in {TomlSections.L3_SUMMARIZE} -> {raw_ip_network} -> ignored' ) continue LOGGER.info( - f'Valid entries in {TOML_L3_SUMMARIZE} found: {len(self.__l3_summarize)}/' - f'{len(self.__user_data.get(TOML_L3_SUMMARIZE, []))}' + f'Valid entries in {TomlSections.L3_SUMMARIZE} found: {len(self.__l3_summarize)}/' + f'{len(self.__user_data.get(TomlSections.L3_SUMMARIZE, []))}' ) return self.__l3_summarize @@ -520,7 +519,7 @@ class Settings: if self.__map_speed_to_thickness is None: self.__map_speed_to_thickness = [] map_speed_to_thickness = self.__user_data.get( - TOML_MAP_SPEED_TO_THICKNESS, {} + TomlSections.MAP_SPEED_TO_THICKNESS, {} ) for speed, thickness in map_speed_to_thickness.items(): try: @@ -530,12 +529,12 @@ class Settings: )) except ValueError: LOGGER.error( - f'Invalid entry in {TOML_MAP_SPEED_TO_THICKNESS} -> {speed}={thickness} -> ignored' + f'Invalid entry in {TomlSections.MAP_SPEED_TO_THICKNESS} -> {speed}={thickness} -> ignored' ) continue LOGGER.info( - f'Valid entries in {TOML_MAP_SPEED_TO_THICKNESS} found: {len(self.__map_speed_to_thickness)}' # noqa: E501 - f'/{len(self.__user_data.get(TOML_MAP_SPEED_TO_THICKNESS, []))}' + f'Valid entries in {TomlSections.MAP_SPEED_TO_THICKNESS} found: {len(self.__map_speed_to_thickness)}' + f'/{len(self.__user_data.get(TomlSections.MAP_SPEED_TO_THICKNESS, []))}' ) return self.__map_speed_to_thickness @@ -543,7 +542,7 @@ class Settings: def protected_topologies(self) -> List[str]: if self.__protected_topologies is None: self.__protected_topologies = [str(topology) for topology in self.__user_data.get( - TOML_PROTECTED_TOPOLOGIES, [] + TomlSections.PROTECTED_TOPOLOGIES, [] )] return self.__protected_topologies @@ -551,12 +550,12 @@ class Settings: def static_connections(self) -> List[StaticConnection]: if self.__static_connections is None: self.__static_connections = [] - for connection in self.__user_data.get(TOML_STATIC_CONNECTIONS, []): + for connection in self.__user_data.get(TomlSections.STATIC_CONNECTIONS, []): try: left_host, left_service, right_service, right_host = connection except ValueError: LOGGER.error( - f'Wrong entry in {TOML_STATIC_CONNECTIONS} -> {connection} -> ignored' + f'Wrong entry in {TomlSections.STATIC_CONNECTIONS} -> {connection} -> ignored' ) continue if not right_host or not left_host: @@ -571,14 +570,14 @@ class Settings: left_host=str(left_host), )) LOGGER.info( - f'Valid entries in {TOML_STATIC_CONNECTIONS} found: {len(self.__static_connections)}/' - f'{len(self.__user_data.get(TOML_STATIC_CONNECTIONS, []))}' + f'Valid entries in {TomlSections.STATIC_CONNECTIONS} found: {len(self.__static_connections)}/' + f'{len(self.__user_data.get(TomlSections.STATIC_CONNECTIONS, []))}' ) return self.__static_connections @property def sites(self) -> List[str]: if self.__sites is None: - self.__sites = [str(site) for site in set(self.__user_data.get(TOML_SITES, [])) if is_valid_site_name(site)] + self.__sites = [str(site) for site in set(self.__user_data.get(TomlSections.SITES, [])) if is_valid_site_name(site)] LOGGER.info(f'Found {len(self.__sites)} to filter on') return self.__sites diff --git a/source/bin/nvdct/lib/topologies.py b/source/bin/nvdct/lib/topologies.py index 3fa4cbb..8594526 100755 --- a/source/bin/nvdct/lib/topologies.py +++ b/source/bin/nvdct/lib/topologies.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- # # License: GNU General Public License v2 -import sys # Author: thl-cmk[at]outlook[dot]com # URL : https://thl-cmk.hopto.org @@ -11,10 +10,11 @@ import sys # 2024-12-22: refactoring topology creation into classes # made L3 topology IP version independent +# 2024-12-25: refactoring, moved function into classes from abc import abstractmethod -from collections.abc import Mapping, MutableMapping, Sequence -from ipaddress import ip_address, ip_network, ip_interface +from collections.abc import Mapping, MutableMapping, Sequence, MutableSet +from ipaddress import ip_address, ip_interface, ip_network from re import sub as re_sub from typing import Dict, List, Tuple @@ -24,15 +24,13 @@ from lib.backends import ( ) from lib.constants import ( CACHE_INTERFACES_DATA, - HOST_LABEL_L3V4_HOSTS, - HOST_LABEL_L3V4_ROUTER, - HOST_LABEL_L3V6_HOSTS, - HOST_LABEL_L3V6_ROUTER, + HostLabels, IPVersion, + InvPaths, LOGGER, - PATH_INTERFACES, - PATH_L3, - DATAPATH, + L2InvColumns, + L3InvColumns, + TomlSections, ) from lib.settings import ( Emblems, @@ -41,32 +39,36 @@ from lib.settings import ( Wildcard, ) from lib.utils import ( - InventoryColumns, - IpInfo, - # is_valid_hostname, save_data_to_file, ) class NvObjects: - def __init__(self) -> None: + def __init__( + self, + host_cache: HostCache + ) -> None: self.nv_objects: Dict[str, any] = {} self.host_count: int = 0 self.host_list: List[str] = [] + self.host_cache = host_cache def add_host( - self, - host: str, - host_cache: HostCache, - emblem: str | None = None + self, + host: str, + emblem: str | None = None, + name: str | None = None, ) -> None: + if name and host in self.nv_objects: + self.nv_objects[host]['name'] = name + 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)}') - if host_cache.host_exists(host=host) is True: + if self.host_cache.host_exists(host=host) is True: LOGGER.debug(f'host: {host} exists') link = {'core': host} else: @@ -82,31 +84,30 @@ class NvObjects: } self.nv_objects[host] = { - 'name': host, + 'name': name if name is not None else host, 'link': link, 'metadata': metadata, } LOGGER.debug(f'host: {host}, link: {link}, metadata: {metadata}') def add_service( - self, - host: str, - service: str, - host_cache: HostCache, - emblem: str | None = None, - metadata: Dict | None = None, - name: str | None = None, + self, + host: str, + service: str, + 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) + self.add_host(host=host) service_object = f'{service}@{host}' if service_object not in self.nv_objects: link: Dict = {} - if host_cache.host_exists(host=host): + if self.host_cache.host_exists(host=host): link = {'core': [host, service]} elif emblem is not None: metadata.update({ @@ -125,16 +126,14 @@ class NvObjects: elif metadata is not {}: self.nv_objects[service_object]['metadata'].update(metadata) - def add_interface( - self, - host: str, - service: str, - host_cache: HostCache, - emblem: str | None = None, - metadata: Dict | None = None, - name: str | None = None, - item: str | None = None, + self, + host: str, + service: str, + item: str | None, + emblem: str | None = None, + metadata: Dict | None = None, + name: str | None = None, ) -> None: if metadata is None: metadata = {} @@ -142,16 +141,16 @@ class NvObjects: name = service speed = None - self.add_host(host=host, host_cache=host_cache) + self.add_host(host=host) service_object = f'{service}@{host}' if service_object not in self.nv_objects: link: Dict = {} - if item is None: - item = get_service_by_interface(host, service.lstrip('0'), host_cache) - if item and host_cache.host_exists(host=host): + # if item is None: + # item = get_service_by_interface(host, service.lstrip('0'), host_cache) + if item and self.host_cache.host_exists(host=host): service_long = f'Interface {item}' link = {'core': [host, service_long]} - if op_data := get_operational_interface_data(host, item, host_cache): + if op_data := self.get_operational_interface_data(host, item): metadata.update(op_data) speed = op_data.get('op_speed_int') elif emblem is not None: @@ -176,11 +175,11 @@ class NvObjects: self.nv_objects[service_object]['metadata'].update(metadata) def add_ip_address( - self, - host: str, - raw_ip_address: str, - emblem: str, - interface: str | None, + self, + host: str, + raw_ip_address: str, + emblem: str, + interface: str | None, ) -> None: if interface is not None: service_object = f'{raw_ip_address}@{interface}@{host}' @@ -204,6 +203,54 @@ class NvObjects: } } + def get_operational_interface_data( + self, + host: str, + item: str, + ) -> Dict[str, str | int] | None: + unit_to_bits_per_second = { + 'Bit/s': 1, + 'kBit/s': 1000, + 'Kbps': 1000, + 'MBit/s': 1000000, + 'Mbps': 1000000, + 'GBit/s': 1000000000, + 'Gbps': 1000000000, + } + + # get dict of interfaces with the item as key + interface_data: Dict[str, any] | None = self.host_cache.get_data( + host=host, item=CacheItems.interfaces, path=CACHE_INTERFACES_DATA + ) + try: + raw_operational_data = interface_data[item]['long_plugin_output'] + except (KeyError, TypeError): + return None + + if raw_operational_data: + operational_data: Dict[str, str | int] = {} + for _entry in raw_operational_data: + try: + key, value = _entry.split(': ', 1) # split only at the first colon + except ValueError: + continue + value = value.strip(' ') + match key: + case 'MAC': + if len(value) == 17: # valid MAC: 6C:DD:30:DD:51:8B' + operational_data['mac'] = value + case 'Speed': + try: # *_ -> ignore rest of string, i.e: (expected: 1 GBit/s)WARN + speed, unit, *_ = value.split(' ') + except ValueError: + pass + else: + operational_data['op_sped_str'] = f'{speed} {unit}' + operational_data['op_speed_int'] = int(float(speed) * unit_to_bits_per_second[unit]) + + return operational_data + return None + def add_ip_network(self, network: str, emblem: str, ) -> None: if network not in self.nv_objects: self.nv_objects[network] = { @@ -254,9 +301,9 @@ class NvConnections: self.nv_connections.append([connection]) def add_meta_data_to_connections( - self, - nv_objects: NvObjects, - speed_map: Sequence[Thickness], + self, + nv_objects: NvObjects, + speed_map: Sequence[Thickness], ): for connection in self.nv_connections: warning = False @@ -281,7 +328,6 @@ class NvConnections: left_native_vlan = nv_objects.nv_objects[left].get('metadata', {}).get('native_vlan') right_native_vlan = nv_objects.nv_objects[right].get('metadata', {}).get('native_vlan') - if right_speed and left_speed: right_thickness = map_speed_to_thickness(right_speed, speed_map) # left_thickness = map_speed_to_thickness(left_speed, speed_map) @@ -297,7 +343,7 @@ class NvConnections: # metadata = add_tooltip_quickinfo(metadata, right, right_speed_str) LOGGER.warning( - f'Connection with speed mismatch: {left} (speed: {left_speed_str})' + f'Connection speed mismatch: {left} (speed: {left_speed_str})' f'<->{right} (speed: {right_speed_str})' ) @@ -312,7 +358,7 @@ class NvConnections: ) LOGGER.warning( - f'Connection with duplex mismatch: {left} (duplex: {left_duplex})' + f'Connection duplex mismatch: {left} (duplex: {left_duplex})' f'<->{right} (duplex: {right_duplex})' ) if left_native_vlan and right_native_vlan: @@ -325,13 +371,13 @@ class NvConnections: ) LOGGER.warning( - f'Connection with native vlan mismatch: ' + f'Connection native vlan mismatch: ' f'{left} (vlan: {left_native_vlan})<->{right} (vlan: {right_native_vlan})' ) if warning: metadata['line_config'].update({ - 'color': 'red', - 'thickness': 5, + 'color': 'red', + 'thickness': 5, }) metadata['line_config']['css_styles']['stroke-dasharray'] = '10 5' nv_objects.add_icon_to_object(left, 'icon_warning') @@ -345,17 +391,20 @@ class Topology: self, emblems: Emblems, host_cache: HostCache, + topology: str, ): - self.nv_objects: NvObjects = NvObjects() + self.nv_objects: NvObjects = NvObjects(host_cache=host_cache) self.nv_connections: NvConnections = NvConnections() self.emblems: Emblems = emblems self.host_cache: HostCache = host_cache + self.topology = topology @abstractmethod def create(self): raise NotImplementedError - def save(self, label:str, output_directory: str, make_default: bool): + def save(self, label: str, output_directory: str, make_default: bool, dont_compare): + LOGGER.info(f'{self.topology} saving...') data = { 'version': 1, 'name': label, @@ -364,13 +413,130 @@ class Topology: } save_data_to_file( data=data, - path=( - f'{DATAPATH}/{output_directory}' - ), + path=output_directory, file=f'data_{label}.json', make_default=make_default, + dont_compare=dont_compare, ) + def get_service_by_interface(self, host: str, interface: str) -> str | None: + """ + Returns: + Tuple of interface item + """ + + short_if_names = { + 'ethernet': 'eth', + # 'fastethernet': 'Fa', + # 'gigabitethernet': 'gi', + # 'tengigabitethernet': 'te', + # 'fortygigabitethernet': 'Fo', + # 'hundredgigabitethernet': 'Hu', + # 'management': 'Ma', + } + + def get_short_if_name(long_interface: str) -> str: + """ + returns short interface name from long interface name + interface: is the long interface name + :type long_interface: str + """ + if not long_interface: + return long_interface + for interface_prefix in short_if_names.keys(): + if long_interface.lower().startswith(interface_prefix.lower()): + interface_short = short_if_names[interface_prefix] + return long_interface.lower().replace(interface_prefix.lower(), interface_short, 1) + return long_interface + + # try to find the item for an interface + def match_entry_with_item(interface_entry: Mapping[str, str], services: Sequence[str]) -> str | None: + values = [ + interface_entry.get('name'.strip()), + interface_entry.get('description'.strip()), + interface_entry.get('alias').strip() + ] + for value in values: + if value in services: + return value + + index = str(interface_entry.get('index')) + + # try alias+index + alias_index = str(interface_entry.get('alias')).strip() + ' ' + index + if alias_index in services: + LOGGER.info(f'{self.topology} match found by alias-index|{interface_entry}| <-> |{alias_index}|') + return alias_index + + # try description+index + description_index = str(interface_entry.get('description')).strip() + ' ' + index + if description_index in services: + LOGGER.info(f'{self.topology} match found by description-index|{interface_entry}| <-> |{description_index}|') + return description_index + + # for index try with padding + pad_services: List[str] = [x for x in services if x.isdigit()] + if pad_services: + max_pad: int = len(max(pad_services, key=len)) + 1 + # min_pad: int = len(min(pad_services, key=len)) + min_pad: int = len(min(pad_services, key=len)) + for i in range(min_pad, max_pad): + index_padded = f'{index:0>{i}}' + if index_padded in pad_services: + return index_padded + # still not found try values + index + for value in values: + if f'{value} {index_padded}' in services: + return f'{value} {index_padded}' + + LOGGER.warning(f'{self.topology} no match found |{interface_entry}| <-> |{services}|') + return None + + # empty host/neighbour should never happen here + if not host: + LOGGER.warning(f'{self.topology} no host name |{host}|') + return None + + # get dict of interfaces with the item as key + interface_data: Mapping[str, Mapping[str, object]] = self.host_cache.get_data( + host=host, item=CacheItems.interfaces, path=CACHE_INTERFACES_DATA + ) + if not interface_data: + LOGGER.warning(f'{self.topology} no interface data for: {host}') + return None + + # try to find the interface in the host interface inventory list + inventory = self.host_cache.get_data( + host=host, item=CacheItems.inventory, path=InvPaths.INTERFACES + ) + if not inventory: + LOGGER.warning(f'{self.topology} no interface inventory for: {host}') + return None + + interface_items: Sequence[str] = list(interface_data.keys()) + + # the easy case + if interface in interface_items: + return interface + + for entry in inventory: + if interface in [ + entry.get('name'), + entry.get('description'), + entry.get('alias'), + str(entry.get('index')), + entry.get('phys_address'), + ]: + return match_entry_with_item(entry, interface_items) + elif f'1:{interface}' == entry.get('name'): # Extreme non stack: + return match_entry_with_item(entry, interface_items) + elif entry.get('name') is not None and get_short_if_name( + entry.get('name')) == str(interface).lower(): # Cisco NXOS + return match_entry_with_item(entry, interface_items) + + LOGGER.warning(msg=f'{self.topology} Device: {host}: service for interface |{interface}| not found') + + class TopologyStatic(Topology): def __init__( self, @@ -381,26 +547,24 @@ class TopologyStatic(Topology): super().__init__( emblems=emblems, host_cache=host_cache, + topology='[STATIC]', ) self.connections: Sequence[StaticConnection] = connections def create(self): for connection in self.connections: - LOGGER.info(msg=f'connection: {connection}') + LOGGER.debug(msg=f'{self.topology} connection from {TomlSections.STATIC_CONNECTIONS}: {connection}') self.nv_objects.add_host( host=connection.right_host, - host_cache=self.host_cache, emblem=self.emblems.host_node ) self.nv_objects.add_host( host=connection.left_host, - host_cache=self.host_cache, emblem=self.emblems.host_node ) if connection.right_service: self.nv_objects.add_service( host=connection.right_host, - host_cache=self.host_cache, emblem=self.emblems.service_node, service=connection.right_service ) @@ -412,7 +576,6 @@ class TopologyStatic(Topology): if connection.left_service: self.nv_objects.add_service( host=connection.left_host, - host_cache=self.host_cache, emblem=self.emblems.service_node, service=connection.left_service ) @@ -442,118 +605,102 @@ class TopologyStatic(Topology): right=f'{connection.left_host}', ) + class TopologyL2(Topology): def __init__( self, emblems: Emblems, host_cache: HostCache, - case: str, - inv_columns: InventoryColumns, - l2_drop_hosts: List[str], - l2_host_map: Dict[str, str], + l2_drop_neighbours: List[str], l2_neighbour_replace_regex: List[Tuple[str, str]], label: str, path_in_inventory: str, - prefix: str, - remove_domain: bool, seed_devices: Sequence[str], + display_l2_neighbours: bool, ): super().__init__( emblems=emblems, host_cache=host_cache, + topology = f'[L2 {label}]', ) - self.case: str = case - self.inv_columns: InventoryColumns = inv_columns - self.l2_drop_hosts: List[str] = l2_drop_hosts - self.l2_host_map: Dict[str, str] = l2_host_map - self.l2_neighbour_replace_regex: List[Tuple[str, str]] = l2_neighbour_replace_regex + self.l2_drop_neighbours: List[str] = l2_drop_neighbours self.label: str = label self.neighbour_to_host: MutableMapping[str, str] = {} self.path_in_inventory: str = path_in_inventory - self.prefix: str = prefix - self.remove_domain: bool = remove_domain - self.seed_devices: Sequence[str] = seed_devices + self.hosts_to_go: MutableSet[str] = set(seed_devices) + self.raw_neighbour_to_neighbour: Dict[str, str] = {} + self.l2_neighbour_replace_regex: List[Tuple[str, str]] = l2_neighbour_replace_regex + self.hosts_done: MutableSet[str] = set() + self.display_l2_neighbours: bool = display_l2_neighbours def create(self): - if not (devices_to_go := list(set(self.seed_devices))): # remove duplicates - LOGGER.error('No seed devices configured!') + if not self.hosts_to_go: + LOGGER.error(f'{self.topology} no seed devices !') return - devices_done = [] + while self.hosts_to_go: + host = self.hosts_to_go.pop() + self.hosts_done.add(host) - while devices_to_go: - device = devices_to_go[0] - - if device in self.l2_host_map.keys(): - try: - devices_to_go.remove(device) - except ValueError: - pass - device = self.l2_host_map[device] - if device in devices_done: - continue - - topo_data = self.host_cache.get_data( - host=device, item=CacheItems.inventory, path=self.path_in_inventory + topo_data: Sequence[Mapping[str, str]] = self.host_cache.get_data( + host=host, item=CacheItems.inventory, path=self.path_in_inventory ) if topo_data: - self.device_from_inventory( - host=device, + self.host_from_inventory( + host=host, inv_data=topo_data, ) - for _entry in self.nv_objects.host_list: - if _entry not in devices_done: - devices_to_go.append(_entry) - - devices_to_go = list(set(devices_to_go)) - devices_done.append(device) - devices_to_go.remove(device) - LOGGER.info(msg=f'Device done: {device}, source: {self.label}') + LOGGER.info(msg=f'{self.topology} host done : {host}') - def device_from_inventory( + def host_from_inventory( self, host: str, - inv_data, + inv_data: Sequence[Mapping[str,str]], ): for topo_neighbour in inv_data: # check if required data are not empty - if not (neighbour := topo_neighbour.get(self.inv_columns.neighbour)): - LOGGER.warning(f'incomplete data, neighbour missing {topo_neighbour}') + if not (raw_neighbour := topo_neighbour.get(L2InvColumns.NEIGHBOUR)): + LOGGER.warning(f'{self.topology} incomplete data: neighbour missing {topo_neighbour}') continue - if not (raw_local_port := topo_neighbour.get(self.inv_columns.local_port)): - LOGGER.warning(f'incomplete data, local port missing {topo_neighbour}') + if not (raw_local_port := topo_neighbour.get(L2InvColumns.LOCALPORT)): + LOGGER.warning(f'{self.topology} incomplete data: local port missing {topo_neighbour}') continue - if not (raw_neighbour_port := topo_neighbour.get(self.inv_columns.neighbour_port)): - LOGGER.warning(f'incomplete data, neighbour port missing {topo_neighbour}') + if not (raw_neighbour_port := topo_neighbour.get(L2InvColumns.NEIGHBOURPORT)): + LOGGER.warning(f'{self.topology} incomplete data: neighbour port missing {topo_neighbour}') continue - if not (neighbour:= self.get_host_from_neighbour(neighbour)): + if not (neighbour := self.adjust_raw_neighbour(raw_neighbour)): continue + if neighbour_host := self.host_cache.get_host_from_neighbour(neighbour): + if neighbour_host not in self.hosts_done: + self.hosts_to_go.add(neighbour_host) + else: + neighbour_host = neighbour + # getting/checking interfaces - local_port = get_service_by_interface(host, raw_local_port, self.host_cache) + local_port = self.get_service_by_interface(host, raw_local_port) if not local_port: local_port = raw_local_port - LOGGER.warning(msg=f'service not found: host: {host}, raw_local_port: {raw_local_port}') + LOGGER.warning(msg=f'{self.topology} service not found for local_port: {host}, {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}' + msg=f'{self.topology} map raw_local_port -> local_port: {host}, {raw_local_port} -> {local_port}' ) - neighbour_port = get_service_by_interface(neighbour, raw_neighbour_port, self.host_cache) + neighbour_port = self.get_service_by_interface(neighbour_host, raw_neighbour_port) 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}' + msg=f'{self.topology} service not found for neighbour port: {neighbour_host}, {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}' + msg=f'{self.topology} map raw_neighbour_port -> neighbour_port: {neighbour_host}, {raw_neighbour_port} ' + f'-> {neighbour_port}' ) metadata = { @@ -561,83 +708,79 @@ class TopologyL2(Topology): 'native_vlan': topo_neighbour.get('native_vlan'), } - self.nv_objects.add_host(host=host, host_cache=self.host_cache) - self.nv_objects.add_host(host=neighbour, host_cache=self.host_cache) + self.nv_objects.add_host( + host=host, + emblem=self.emblems.host_node, + ) + self.nv_objects.add_host( + host=neighbour_host, + name=raw_neighbour if self.display_l2_neighbours else None, + emblem=self.emblems.host_node, + ) self.nv_objects.add_interface( host=str(host), service=str(local_port), - host_cache=self.host_cache, metadata=metadata, name=str(raw_local_port), - item=str(local_port) + item=str(local_port), + emblem=self.emblems.service_node, ) self.nv_objects.add_interface( - host=str(neighbour), + host=str(neighbour_host), service=str(neighbour_port), - host_cache=self.host_cache, name=str(raw_neighbour_port), - item=str(neighbour_port) + item=str(neighbour_port), + emblem=self.emblems.service_node, ) self.nv_connections.add_connection( left=str(host), right=f'{local_port}@{host}', ) self.nv_connections.add_connection( - left=str(neighbour), - right=f'{neighbour_port}@{neighbour}', + left=str(neighbour_host), + right=f'{neighbour_port}@{neighbour_host}', ) self.nv_connections.add_connection( left=f'{local_port}@{host}', - right=f'{neighbour_port}@{neighbour}', + right=f'{neighbour_port}@{neighbour_host}', ) - def get_host_from_neighbour(self, neighbour: str) -> str | None: + def adjust_raw_neighbour(self, raw_neighbour: str) -> str | None: + """ + Checks if neighbour should be dropped or adjusted via regex. + The next request for the same neighbour will be served from cache. + Args: + raw_neighbour: the neighbour name to check/adjust + + Returns: + the adjusted neighbour name or None + """ try: - return self.neighbour_to_host[neighbour] + return self.neighbour_to_host[raw_neighbour] except KeyError: pass - if neighbour in self.l2_drop_hosts: - LOGGER.info(msg=f'drop neighbour: {neighbour}') - self.neighbour_to_host[neighbour] = None + if raw_neighbour in self.l2_drop_neighbours: + LOGGER.info(msg=f'{self.topology} drop in {TomlSections.L2_DROP_NEIGHBOURS}: {raw_neighbour}') + self.neighbour_to_host[raw_neighbour] = None return None + adjusted_neighbour = raw_neighbour if self.l2_neighbour_replace_regex: for re_str, replace_str in self.l2_neighbour_replace_regex: - re_neighbour = re_sub(re_str, replace_str, neighbour) - if re_neighbour != neighbour: - LOGGER.info(f'regex changed Neighbor |{neighbour}| to |{re_neighbour}|') - neighbour = re_neighbour - if not neighbour: - LOGGER.info(f'Neighbour removed by regex (|{neighbour}|, |{re_str}|, |{replace_str}|)') - break - if not neighbour: - self.neighbour_to_host[neighbour] = None - return None - - if self.remove_domain: - neighbour = neighbour.split('.')[0] - - # drop neighbour after domain split - if neighbour in self.l2_drop_hosts: - LOGGER.info(msg=f'drop neighbour: {neighbour}') - self.neighbour_to_host[neighbour] = None - return None + re_neighbour = re_sub(re_str, replace_str, adjusted_neighbour) + if not re_neighbour: + LOGGER.info(f'{self.topology} removed by {TomlSections.L2_NEIGHBOUR_REPLACE_REGEX}: (|{adjusted_neighbour}|, |{re_str}|, |{replace_str}|)') + self.neighbour_to_host[raw_neighbour] = None + return None - if self.case == 'UPPER': - neighbour = neighbour.upper() - LOGGER.debug(f'Changed neighbour to upper case: {neighbour}') - elif self.case == 'LOWER': - neighbour = neighbour.lower() - LOGGER.debug(f'Changed neighbour to lower case: {neighbour}') + if re_neighbour != adjusted_neighbour: + LOGGER.info(f'{self.topology} changed by {TomlSections.L2_NEIGHBOUR_REPLACE_REGEX} |{adjusted_neighbour}| to |{re_neighbour}|') + adjusted_neighbour = re_neighbour - if self.prefix: - neighbour = f'{self.prefix}{neighbour}' - # rewrite neighbour if inventory neighbour and checkmk host don't match - if neighbour in self.l2_host_map.keys(): - neighbour = self.l2_host_map[neighbour] + self.neighbour_to_host[raw_neighbour] = adjusted_neighbour + return adjusted_neighbour - return neighbour class TopologyL3(Topology): def __init__( @@ -648,172 +791,209 @@ class TopologyL3(Topology): ignore_ips: Sequence[ip_network], ignore_wildcard: Sequence[Wildcard], include_hosts: bool, + include_loopback: bool, replace: Mapping[str, str], + skip_cidr_0: bool, + skip_cidr_32_128: bool, skip_if: bool, skip_ip: bool, + skip_public: bool, summarize: Sequence[ip_network], version: int ): super().__init__( emblems=emblems, host_cache=host_cache, + topology=f'[L3 IPv{version}]' ) self.ignore_hosts: Sequence[str] = ignore_hosts self.ignore_ips: Sequence[ip_network] = ignore_ips self.ignore_wildcard: Sequence[Wildcard] = ignore_wildcard self.include_hosts: bool = include_hosts self.replace: Mapping[str, str] = replace + self.skip_cidr_0: bool = skip_cidr_0 + self.skip_cidr_32_128: bool = skip_cidr_32_128 self.skip_if: bool = skip_if self.skip_ip: bool = skip_ip + self.skip_public: bool = skip_public + self.show_loopback: bool = include_loopback self.summarize: Sequence[ip_network] = summarize self.version = version def create(self): match self.version: case IPVersion.IPv4: - host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HOST_LABEL_L3V4_ROUTER) + host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HostLabels.L3V4_ROUTER) if self.include_hosts: - host_list += self.host_cache.get_hosts_by_label(HOST_LABEL_L3V4_HOSTS) + host_list += self.host_cache.get_hosts_by_label(HostLabels.L3V4_HOSTS) case IPVersion.IPv6: - host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HOST_LABEL_L3V6_ROUTER) + host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HostLabels.L3V6_ROUTER) if self.include_hosts: - host_list += self.host_cache.get_hosts_by_label(HOST_LABEL_L3V6_HOSTS) + host_list += self.host_cache.get_hosts_by_label(HostLabels.L3V6_HOSTS) case _: host_list = [] - LOGGER.debug(f'host list: {host_list}') + LOGGER.debug(f'{self.topology} host to work on: {host_list}') if not host_list: - LOGGER.warning( - msg='No (routing capable) host found. Check if "inv_ip_addresses.mkp" ' + LOGGER.error( + msg=f'{self.topology} No (routing capable) host found. Check if "inv_ip_addresses.mkp" ' 'added/enabled and inventory and host label discovery has run.' ) return - LOGGER.debug(f'L3 ignore hosts: {self.ignore_hosts}') + LOGGER.debug(f'{self.topology} ignore hosts: {self.ignore_hosts}') for raw_host in host_list: host = raw_host if host in self.ignore_hosts: - LOGGER.info(f'L3 host {host} ignored') + LOGGER.info(f'{self.topology} host ignored in {TomlSections.L3_IGNORE_HOSTS}: {host}') continue if not (inv_ip_addresses := self.host_cache.get_data( - host=host, item=CacheItems.inventory, path=PATH_L3) + host=host, item=CacheItems.inventory, path=InvPaths.L3) ): - LOGGER.warning(f'No IP address inventory found for host: {host}') + LOGGER.warning(f'{self.topology} no IP address inventory found for host: {host}') continue - self.nv_objects.add_host(host=host, host_cache=self.host_cache) + self.nv_objects.add_host(host=host) for inv_ip_address in inv_ip_addresses: emblem = self.emblems.ip_network try: - ip_info = IpInfo( - address=inv_ip_address['address'], - device=inv_ip_address['device'], - broadcast=inv_ip_address['broadcast'], - cidr=inv_ip_address['cidr'], - netmask=inv_ip_address['netmask'], - network=inv_ip_address['network'], - type=inv_ip_address['type'], - scope_id=inv_ip_address.get('scope_id'), # this is an optional field + device = inv_ip_address[L3InvColumns.DEVICE] + interface_address: ip_interface = ip_interface( + f'{inv_ip_address[L3InvColumns.ADDRESS]}/{inv_ip_address[L3InvColumns.CIDR]}' ) - except KeyError: - LOGGER.warning(f'Drop IP address data for host: {host}, data: {inv_ip_address}') + except KeyError as e: + LOGGER.warning(f'{self.topology} drop IP missing data: {host}, {inv_ip_address}, {e}.') + continue + + # skip entries without prefix-length/netmask + if self.skip_cidr_0 and interface_address.network.prefixlen == 0: + LOGGER.info(f'{self.topology} drop IP with CIDR "0": {host}, {interface_address.compressed}') continue - interface_address: ip_interface = ip_interface(f'{ip_info.address}/{ip_info.cidr}') if interface_address.version != self.version: LOGGER.info( - f'host: {host} dropped non IPv{self.version} address: {ip_info.address},' - f' type: {ip_info.type}' + f'{self.topology} drop IP non IPv{self.version} version: {host}, {interface_address.compressed}' ) continue - if interface_address.is_loopback: - LOGGER.info(f'host: {host} dropped loopback address: {ip_info.address}') + if not self.show_loopback and interface_address.is_loopback: + LOGGER.info(f'{self.topology} drop IP is loopback: {host}, {interface_address.ip.compressed}') continue - if interface_address.is_link_local: - LOGGER.info(f'host: {host} dropped link-local address: {ip_info.address}') + if self.skip_public and not interface_address.is_private: + LOGGER.info(f'{self.topology} drop IP is public: {host}, {interface_address.ip.compressed}') continue - # if interface_address.network.prefixlen == 32 or interface_address.network.prefixlen == 128: # drop host addresses - # LOGGER.info( - # f'host: {host} dropped host address: {ip_info.address}/{ip_info.cidr}' - # ) - # continue + if interface_address.is_link_local and interface_address.version == 6: + LOGGER.info(f'{self.topology} drop IP is link-local: {host}, {interface_address.ip.compressed}') + continue - if is_ignore_ip(ip_info.address, self.ignore_ips): - LOGGER.info(f'host: {host} dropped ignore address: {ip_info.address}') + # drop host addresses /32 or /128 -> one IP ine network + if self.skip_cidr_32_128 and interface_address.network.num_addresses == 1: + LOGGER.info(f'{self.topology} drop IP with CIDR (32 or /128): {host}, {interface_address.compressed}') continue - if is_ignore_wildcard(ip_info.address, self.ignore_wildcard): - LOGGER.info(f'host: {host} dropped wildcard address: {ip_info.address}') + if self.is_ignore_ip(interface_address.ip.compressed): + LOGGER.info(f'{self.topology} rop IP in {TomlSections.L3_IGNORE_IP}: {host}, {interface_address.compressed}') continue - if network := get_network_summary( - raw_ip_address=ip_info.address, - summarize=self.summarize, - ): + if self.is_ignore_wildcard(interface_address.ip.compressed): + LOGGER.info(f'{self.topology} drop IP in {TomlSections.L3V4_IGNORE_WILDCARD}: {host}, {interface_address.compressed}') + continue + + if network := self.get_network_summary(raw_ip_address=interface_address.ip.compressed): emblem = self.emblems.l3_summarize LOGGER.info( - f'Network summarized: {ip_info.network}/{ip_info.cidr} -> {network}' + f'{self.topology} Summarized IP in {TomlSections.L3_SUMMARIZE}: {interface_address.compressed} -> {network}' ) else: - network = f'{ip_info.network}/{ip_info.cidr}' + network = f'{interface_address.network.compressed}' if network in self.replace.keys(): - LOGGER.info(f'Replaced network {network} with {self.replace[network]}') + LOGGER.info(f'{self.topology} Replaced network in {TomlSections.L3_REPLACE}: {network} -> {self.replace[network]}') network = self.replace[network] emblem = self.emblems.l3_replace self.nv_objects.add_ip_network(network=network, emblem=emblem) + item = None + if not self.skip_if: + item = self.get_service_by_interface(host=host, interface=device) + if self.skip_if is True and self.skip_ip is True: self.nv_connections.add_connection(left=host, right=network) elif self.skip_if is True and self.skip_ip is False: self.nv_objects.add_ip_address( host=host, interface=None, - raw_ip_address=ip_info.address, + raw_ip_address=interface_address.ip.compressed, emblem=self.emblems.ip_address, ) self.nv_objects.add_tooltip_quickinfo( - '{ip_info.address}@{host}', 'Interface', ip_info.device + f'{interface_address.ip.compressed}@{host}', 'Interface', device ) - self.nv_connections.add_connection(left=f'{host}', right=f'{ip_info.address}@{host}') - self.nv_connections.add_connection(left=network, right=f'{ip_info.address}@{host}') + self.nv_connections.add_connection(left=f'{host}', right=f'{interface_address.ip.compressed}@{host}') + self.nv_connections.add_connection(left=network, right=f'{interface_address.ip.compressed}@{host}') elif self.skip_if is False and self.skip_ip is True: - self.nv_objects.add_interface( - host=host, service=ip_info.device, host_cache=self.host_cache - ) + self.nv_objects.add_interface(host=host, service=device, item=item) self.nv_objects.add_tooltip_quickinfo( - f'{ip_info.device}@{host}', 'IP-address', ip_info.address + f'{device}@{host}', 'IP-address', interface_address.ip.compressed ) - self.nv_connections.add_connection(left=f'{host}', right=f'{ip_info.device}@{host}') - self.nv_connections.add_connection(left=network, right=f'{ip_info.device}@{host}') + self.nv_connections.add_connection(left=f'{host}', right=f'{device}@{host}') + self.nv_connections.add_connection(left=network, right=f'{device}@{host}') else: self.nv_objects.add_ip_address( host=host, - interface=ip_info.device, - raw_ip_address=ip_info.address, + interface=device, + raw_ip_address=interface_address.ip.compressed, emblem=self.emblems.ip_address, ) - self.nv_objects.add_interface( - host=host, service=ip_info.device, host_cache=self.host_cache, - ) + self.nv_objects.add_interface(host=host, service=device, item=item) self.nv_connections.add_connection( - left=host, right=f'{ip_info.device}@{host}') + left=host, right=f'{device}@{host}') self.nv_connections.add_connection( - left=f'{ip_info.device}@{host}', - right=f'{ip_info.address}@{ip_info.device}@{host}', + left=f'{device}@{host}', + right=f'{interface_address.ip.compressed}@{device}@{host}', ) self.nv_connections.add_connection( - left=network, right=f'{ip_info.address}@{ip_info.device}@{host}', + left=network, right=f'{interface_address.ip.compressed}@{device}@{host}', ) + def get_network_summary(self, raw_ip_address: str) -> str | None: + for network in self.summarize: + try: + if ip_network(raw_ip_address).subnet_of(network): + LOGGER.debug(f'{self.topology} IP address {raw_ip_address} is in subnet -> ({network})') + return network.compressed + except TypeError: + pass + return None + + def is_ignore_ip(self, raw_ip_address: str) -> bool: + for ip in self.ignore_ips: + try: + if ip_network(raw_ip_address).subnet_of(ip): + LOGGER.debug(f'{self.topology} IP address {raw_ip_address} is in ignore list -> ({ip})') + return True + except TypeError: + continue + return False + + def is_ignore_wildcard(self, raw_ip_address: str) -> bool: + int_ip_address = int(ip_address(raw_ip_address)) + for wildcard in self.ignore_wildcard: + if int_ip_address & wildcard.int_wildcard == wildcard.bit_pattern: + LOGGER.debug( + f'{self.topology} IP address {raw_ip_address} matches ignore wildcard ' + f'list ({wildcard.ip_address}/{wildcard.wildcard})' + ) + return True + return False + def map_speed_to_thickness(speed_to_map: int, speed_map: Sequence[Thickness]) -> int: thickness: int = 1 # use in case of empty MAP_SPEED_TO_THICKNESS @@ -823,173 +1003,6 @@ def map_speed_to_thickness(speed_to_map: int, speed_map: Sequence[Thickness]) -> return thickness -def get_operational_interface_data( - host: str, - item: str, - host_cache: HostCache, - -) -> Dict[str, str | int] | None: - unit_to_bits_per_second = { - 'Bit/s': 1, - 'kBit/s': 1000, - 'Kbps': 1000, - 'MBit/s': 1000000, - 'Mbps': 1000000, - 'GBit/s': 1000000000, - 'Gbps': 1000000000, - } - - # get dict of interfaces with the item as key - interface_data: Dict[str, any] | None = host_cache.get_data( - host=host, item=CacheItems.interfaces, path=CACHE_INTERFACES_DATA - ) - try: - raw_opdata = interface_data[item]['long_plugin_output'] - except (KeyError, TypeError): - return None - - if raw_opdata: - opdata: Dict[str, str | int] = {} - for _entry in raw_opdata: - try: - key, value = _entry.split(': ', 1) # split only at the first colon - except ValueError: - continue - value = value.strip(' ') - match key: - case 'MAC': - if len(value) == 17: # valid MAC: 6C:DD:30:DD:51:8B' - opdata['mac'] = value - case 'Speed': - try: # *_ -> ignore rest of string, i.e: (expected: 1 GBit/s)WARN - speed, unit, *_ = value.split(' ') - except ValueError: - pass - else: - opdata['op_sped_str'] = f'{speed} {unit}' - opdata['op_speed_int'] = int(float(speed) * unit_to_bits_per_second[unit]) - - return opdata - return None - - -def get_service_by_interface(host: str, interface: str, host_cache: HostCache) -> str | None: - """ - Returns: - Tuple of interface item - """ - - _alternate_if_name = { - 'ethernet': 'eth', - # 'fastethernet': 'Fa', - # 'gigabitethernet': 'gi', - # 'tengigabitethernet': 'te', - # 'fortygigabitethernet': 'Fo', - # 'hundredgigabitethernet': 'Hu', - # 'management': 'Ma', - } - - 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 - """ - if not interface_: - return interface_ - for interface_prefix in _alternate_if_name.keys(): - 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_ - - # try to find the item for an interface - def _match_entry_with_item(_entry: Mapping[str, str], services: Sequence[str]) -> str | None: - values = [ - _entry.get('name'.strip()), - _entry.get('description'.strip()), - _entry.get('alias').strip() - ] - for value in values: - if value in services: - return value - - index = str(_entry.get('index')) - - # try alias+index - alias_index = str(_entry.get('alias')).strip() + ' ' + index - if alias_index in services: - LOGGER.info(f'match found by alias-index|{_entry}|{alias_index}|') - return alias_index - - # try descrption+index - description_index = str(_entry.get('description')).strip() + ' ' + index - if description_index in services: - LOGGER.info(f'match found by alias-index|{_entry}|{description_index}|') - return description_index - - # for index try with padding - pad_services: List[str] = [x for x in services if x.isdigit()] - if pad_services: - max_pad = len(max(pad_services, key=len)) + 1 - min_pad = len(min(pad_services, key=len)) - for i in range(min_pad, max_pad): - index_padded = f'{index:0>{i}}' - if index_padded in pad_services: - return index_padded - # still not found try values + index - for value in values: - if f'{value} {index_padded}' in services: - return f'{value} {index_padded}' - - LOGGER.warning(f'no match found |{_entry}|{services}|') - return None - - # empty host/neighbour should never happen here - if not host: - LOGGER.warning(f'no host name |{host}|') - return None - - # get dict of interfaces with the item as key - interface_data: Mapping[str, Mapping[str, object]] = host_cache.get_data( - host=host, item=CacheItems.interfaces, path=CACHE_INTERFACES_DATA - ) - if not interface_data: - LOGGER.warning(f'got no interface data for: {host}') - return None - - # try to find the interface in the host interface inventory list - inventory = host_cache.get_data( - host=host, item=CacheItems.inventory, path=PATH_INTERFACES - ) - if not inventory: - LOGGER.warning(f'no interface inventory for host: {host}') - return None - - interface_items: Sequence[str] = list(interface_data.keys()) - - # the easy case - if interface in interface_items: - return interface - - for _entry in inventory: - if interface in [ - _entry.get('name'), - _entry.get('description'), - _entry.get('alias'), - str(_entry.get('index')), - _entry.get('phys_address'), - ]: - return _match_entry_with_item(_entry, interface_items) - elif f'1:{interface}' == _entry.get('name'): # Extreme non stack: - return _match_entry_with_item(_entry, interface_items) - elif _entry.get('name') is not None and _get_short_if_name( - _entry.get('name')) == str(interface).lower(): # Cisco NXOS - return _match_entry_with_item(_entry, interface_items) - - LOGGER.warning(msg=f'Device: {host}: service for interface |{interface}| not found') - - def add_tooltip_html( metadata: Dict, type_: str, @@ -1004,61 +1017,61 @@ def add_tooltip_html( if metadata['tooltip'].get('html') is None: css_style = ( '<style>' - 'div.mismatch {' - 'background-color: rgba(70,70,70,.1);' - 'border-radius: 5px;' - 'padding: 5px;' - '}' - 'p.mismatch {' - 'text-align: center;' - '}' - 'table.mismatch {' - 'text-align: left;' - 'border-radius: 5px;' - '}' - 'tr.mismatch {' - '}' - 'tr.mismatch:nth-child(even) {' - 'background-color: rgba(100,100,100,.3);' - - '}' - 'tr.mismatch:nth-child(odd) {' - 'background-color: rgba(100,100,100,.2);' - - '}' - 'th.mismatch {' - 'background-color: rgba(120,120,120,.3);' - 'padding: 5px;' # inside the element - # 'margin: 5px;' # outside of the lement - '}' - 'td.mismatch {' - 'padding: 5px;' - '}' + 'div.mismatch {' + 'background-color: rgba(70,70,70,.1);' + 'border-radius: 5px;' + 'padding: 5px;' + '}' + 'p.mismatch {' + 'text-align: center;' + '}' + 'table.mismatch {' + 'text-align: left;' + 'border-radius: 5px;' + '}' + 'tr.mismatch {' + '}' + 'tr.mismatch:nth-child(even) {' + 'background-color: rgba(100,100,100,.3);' + + '}' + 'tr.mismatch:nth-child(odd) {' + 'background-color: rgba(100,100,100,.2);' + + '}' + 'th.mismatch {' + 'background-color: rgba(120,120,120,.3);' + 'padding: 5px;' # inside the element + # 'margin: 5px;' # outside of the lement + '}' + 'td.mismatch {' + 'padding: 5px;' + '}' '</style>' ) header = ( '<thead>' - '<tr>' - f'<th {css_class}>Node</th>' - f'<th {css_class}>{left}</th>' - f'<th {css_class}>{right}</th>' - '</tr>' + '<tr>' + f'<th {css_class}>Node</th>' + f'<th {css_class}>{left}</th>' + f'<th {css_class}>{right}</th>' + '</tr>' '</thead>' ) metadata['tooltip']['html'] = ( f'{css_style}' f'<div {css_class}>' - f'<p {css_class}>WARNING: Mismatch found!</p>' - f'<table {css_class}>' - f'{header}' - '<tbody>' + f'<p {css_class}>WARNING: Mismatch found!</p>' + f'<table {css_class}>' + f'{header}' + '<tbody>' ) metadata['tooltip']['html'] += ( f'<tr {css_class}>' - f'<td {css_class}>{type_}</td>' - f'<td {css_class}>{left_value}</td>' - f'<td {css_class}>{right_value}</td>' + f'<td {css_class}>{type_}</td>' + f'<td {css_class}>{left_value}</td>' + f'<td {css_class}>{right_value}</td>' '</tr>' ) @@ -1066,45 +1079,6 @@ def add_tooltip_html( def close_tooltip_html(metadata: Dict) -> Dict: - metadata['tooltip']['html'] += '</table></div>' + if metadata.get('tooltip', {}).get('html'): + metadata['tooltip']['html'] += '</table></div>' return metadata - - -def get_network_summary(raw_ip_address: str, summarize: Sequence[ip_network]) -> str | None: - for network in summarize: - try: - if ip_network(raw_ip_address).subnet_of(network): - return network.compressed - except TypeError: - pass - return None - - -def is_ignore_ip(raw_ip_address: str, ignore_ips: Sequence[ip_network]) -> bool: - for ip in ignore_ips: - try: - if ip_network(raw_ip_address).subnet_of(ip): - LOGGER.info(f'IP address {raw_ip_address} is in ignore list -> ({ip})') - return True - except TypeError: - continue - return False - - -def is_ignore_wildcard(raw_ip_address: str, ignore_wildcard: Sequence[Wildcard]) -> bool: - int_ip_address = int(ip_address(raw_ip_address)) - for wildcard in ignore_wildcard: - if int_ip_address & wildcard.int_wildcard == wildcard.bit_pattern: - LOGGER.info( - f'IP address {raw_ip_address} matches ignore wildcard ' - f'list ({wildcard.ip_address}/{wildcard.wildcard})' - ) - return True - return False - - -def get_list_of_devices(data: Mapping) -> List[str]: - devices: List[str] = [] - for connection in data.values(): - devices.append(connection[0]) - return list(set(devices)) diff --git a/source/bin/nvdct/lib/utils.py b/source/bin/nvdct/lib/utils.py index c504922..9d15149 100755 --- a/source/bin/nvdct/lib/utils.py +++ b/source/bin/nvdct/lib/utils.py @@ -8,45 +8,34 @@ # File : nvdct/lib/utils.py from ast import literal_eval -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from json import dumps +from collections.abc import Mapping, MutableSequence, Sequence +from json import dumps, loads from logging import disable as log_off, Formatter, getLogger, StreamHandler from logging.handlers import RotatingFileHandler from pathlib import Path -from re import match as re_match +from re import match as re_match, findall as re_findall, sub as re_sub from socket import socket, AF_UNIX, AF_INET, SOCK_STREAM, SHUT_WR from sys import stdout, exit as sys_exit from time import time as now_time from tomllib import loads as toml_loads, TOMLDecodeError -from typing import List, Dict, TextIO +from typing import Dict, List, TextIO from lib.constants import ( + Backends, CMK_SITE_CONF, + Case, DATAPATH, + EmblemValues, + EmblemNames, ExitCodes, LOGGER, + LogLevels, OMD_ROOT, + TomlSections, + TomlSettings, ) -@dataclass(frozen=True) -class IpInfo: - address: str - device: str - broadcast: str - cidr: int - netmask: str - network: str - type: str - scope_id: str | None - -@dataclass(frozen=True) -class InventoryColumns: - neighbour: str - local_port: str - neighbour_port: str - def get_local_cmk_version() -> str: return Path(f'{OMD_ROOT}/version').readlink().name @@ -64,7 +53,7 @@ def get_data_form_live_status(query: str) -> Dict | List | None: sock.connect(address) sock.sendall(query.encode()) sock.shutdown(SHUT_WR) - chunks: List = [] + chunks: MutableSequence = [] while len(chunks) == 0 or chunks[-1] != "": chunks.append(sock.recv(4096).decode()) sock.close() @@ -112,13 +101,14 @@ def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[ path: Path = Path(raw_path) default_topo = path.joinpath('default') directories = [str(directory) for directory in list(path.iterdir())] - # keep default top + + # keep default topo if str(default_topo) in directories: directories.remove(str(default_topo)) keep -= 1 if default_topo.is_symlink(): try: - directories.remove(str(default_topo.readlink())) + directories.remove(str(path.joinpath(str(default_topo.readlink())))) except ValueError: pass @@ -140,80 +130,56 @@ def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[ if Path(directory).is_dir(): topo_by_age[Path(directory).stat().st_ctime] = directory - topo_age = list(topo_by_age.keys()) - topo_age.sort() + topo_age: List = list(topo_by_age.keys()) + topo_age.sort(reverse=True) while len(topo_by_age) > keep: - if min_age * 86400 > now_time() - topo_age[0]: + entry = topo_age.pop() + if min_age * 86400 > now_time() - entry: LOGGER.info( - msg=f'Topology "{Path(topo_by_age[topo_age[0]]).name}' + msg=f'Topology "{Path(topo_by_age[entry]).name}' f'" not older then {min_age} day(s). not deleted.' ) return - LOGGER.info(f'delete old topology: {topo_by_age[topo_age[0]]}') - rm_tree(Path(topo_by_age[topo_age[0]])) - topo_by_age.pop(topo_age[0]) - topo_age.pop(0) + LOGGER.info(f'delete old topology: {topo_by_age[entry]}') + rm_tree(Path(topo_by_age[entry])) + topo_by_age.pop(entry) -def save_data_to_file(data: Mapping, path: str, file: str, make_default: bool) -> None: +def save_data_to_file( + data: Mapping, + path: str, + file: str, + make_default: bool, + dont_compare: bool, +) -> None: """ Save the data as json file. Args: data: the topology data - path: the path were to save the dat - file: the file name to save data in + path: the path inder DATATAPATH + file: the file name to save the data in make_default: if True, create the symlink "default" with path as target - + dont_compare: if True, data will not be compared with default data Returns: None """ + if not Path(f'{DATAPATH}/default').exists(): + make_default = True + elif Path(f'{DATAPATH}/default/{file}').exists() and not dont_compare: + if is_equal_with_default(data, f'{DATAPATH}/default/{file}'): + LOGGER.warning(f'Data identical to default. Not saved! Use "--dont-compare".') + return - path_file = f'{path}/{file}' + path_file = f'{DATAPATH}/{path}/{file}' save_file = Path(f'{path_file}') save_file.parent.mkdir(exist_ok=True, parents=True) save_file.write_text(dumps(data)) - parent_path = Path(f'{path}').parent - if not Path(f'{parent_path}/default').exists(): - make_default = True - if make_default: - Path(f'{parent_path}/default').unlink(missing_ok=True) - 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, - output_directory: str, - dont_compare: bool, - make_default: bool, - topology_file_name: str, -) -> None: - path = f'{base_directory}/{output_directory}' - - def _save(): - save_data_to_file( - data=data, - path=path, - file=topology_file_name, - make_default=make_default, - ) - - if dont_compare: - _save() - else: - if not is_equal_with_default( - data=data, - file=f'{base_directory}/default/{topology_file_name}' - ): - _save() - else: - LOGGER.warning( - msg='Topology matches default topology, not saved! Use' - '"--dont-compare" to save identical topologies.' - ) + if make_default: + Path(f'{DATAPATH}/default').unlink(missing_ok=True) + Path(f'{DATAPATH}/default').symlink_to(target=Path(path), target_is_directory=True) def is_mac_address(mac_address: str) -> bool: @@ -249,8 +215,11 @@ def is_list_of_str_equal(list1: List[str], list2: List[str]) -> bool: """ tmp_list1 = list1.copy() tmp_list2 = list2.copy() - tmp_list1.sort() - tmp_list2.sort() + try: + tmp_list1.sort() + tmp_list2.sort() + except TypeError: # list of dict cant be sorted (?) + pass return tmp_list1 == tmp_list2 @@ -262,6 +231,7 @@ def is_valid_hostname(host: str) -> bool: 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): @@ -270,6 +240,7 @@ def is_valid_site_name(site: str) -> bool: 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): @@ -288,6 +259,7 @@ def is_valid_output_directory(directory: str) -> bool: 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()}') @@ -295,32 +267,6 @@ def is_valid_log_file(log_file: str) -> bool: return True -# not used in cmk 2.3.x format -def merge_topologies(topo_pri: Dict, topo_sec: Dict) -> Dict: - """ - Merge dict_prim into dict_sec - Args: - topo_pri: data of dict_pri will overwrite the data in dict_sec - topo_sec: dict where the data of dict_pri will be merged to - - Returns: - Dict: topo_sec that contains merged data from top_sec and top_pri - """ - keys_pri = list(topo_pri.keys()) - - # first transfer all completely missing items from dict_prim to dict_sec - for key in keys_pri: - if key not in topo_sec.keys(): - topo_sec[key] = topo_pri[key] - else: - topo_sec[key]['connections'].update(topo_pri[key].get('connections', {})) - topo_sec[key]['interfaces'] = list( - set((topo_sec[key]['interfaces'] + topo_pri[key].get('interfaces', []))) - ) - topo_pri.pop(key) - return topo_sec - - def compare_dicts(dict1: Mapping, dict2: Mapping) -> bool: # check top level keys if not is_list_of_str_equal(list(dict1.keys()), list(dict2.keys())): @@ -329,23 +275,35 @@ def compare_dicts(dict1: Mapping, dict2: Mapping) -> bool: LOGGER.debug(f'dict1: {list(dict2.keys())}') return False + LOGGER.debug('Top level matches') for key, value in dict1.items(): - _type = type(value) - if _type == dict: + type_ = type(value) + if type_ == dict: + LOGGER.debug(f'compare dict: {key}') if not compare_dicts(value, dict2[key]): return False - elif _type == list: + elif type_ == list: if not is_list_of_str_equal(value, dict2[key]): LOGGER.debug(f'list1: {value}') LOGGER.debug(f'list2: {dict2[key]}') return False - elif _type == str: + elif type_ in [str, int]: + if not value == dict2[key]: + LOGGER.debug('value dont match') + LOGGER.debug(f'value1: {value}') + LOGGER.debug(f'value2: {dict2[key]}') + return False + elif value is None: if not value == dict2[key]: LOGGER.debug('value dont match') LOGGER.debug(f'value1: {value}') - LOGGER.debug(f'value2 {dict2[key]}') + LOGGER.debug(f'value2: {dict2[key]}') return False else: + LOGGER.debug(f'Compare unknown type {type_}') + LOGGER.debug(f'key: {key}') + LOGGER.debug(f'value1: {value}') + LOGGER.debug(f'value2: {dict2[key]}') return False return True @@ -354,10 +312,26 @@ def compare_dicts(dict1: Mapping, dict2: Mapping) -> bool: def is_equal_with_default(data: Mapping, file: str) -> bool: default_file = Path(file) if default_file.exists(): - default_data = literal_eval(default_file.read_text()) + LOGGER.info(f'compare data with {file}') + default_data = loads(default_file.read_text()) return compare_dicts(data, default_data) return False +def get_attributes_from_inventory(inventory: Dict[str, object], raw_path: str): + # print(inventory['Nodes']['networking']['Nodes']['lldp_cache']['Attributes']['Pairs']) + path: List[str] = ('Nodes,' + ',Nodes,'.join(raw_path.split(',')) + ',Attributes,Pairs').split(',') + try: + table = inventory.copy() + except AttributeError: + return None + for m in path: + try: + table = table[m] + except KeyError: + LOGGER.info(msg=f'Inventory attributes for {path} not found') + return None + return dict(table) + def get_table_from_inventory(inventory: Dict[str, object], raw_path: str) -> List | None: path: List[str] = ('Nodes,' + ',Nodes,'.join(raw_path.split(',')) + ',Table,Rows').split(',') @@ -422,3 +396,66 @@ class StdoutQuiet: def flush(self): self._org_stdout.flush() + + +def adjust_toml(toml_file: str): + fix_options = { + 'DROP_HOSTS': TomlSections.L2_DROP_NEIGHBOURS, + 'HOST_MAP': TomlSections.L2_HOST_MAP, + 'L2_DROP_HOSTS': TomlSections.L2_DROP_NEIGHBOURS, # needs to be before DROP_HOST + 'L3V4_IGNORE_HOSTS': TomlSections.L3_IGNORE_HOSTS, + 'L3V4_IGNORE_IP': TomlSections.L3_IGNORE_IP, + 'L3V4_IRNORE_WILDCARD': TomlSections.L3V4_IGNORE_WILDCARD, + 'L3V4_REPLACE': TomlSections.L3_REPLACE, + 'L3V3_REPLACE': TomlSections.L3_REPLACE, + 'L3V4_SUMMARIZE': TomlSections.L3_SUMMARIZE, + 'SEED_DEVICES': TomlSections.L2_SEED_DEVICES, + 'icon_missinc': EmblemValues.ICON_ALERT_UNREACHABLE, + 'icon_missing': EmblemValues.ICON_ALERT_UNREACHABLE, + 'l3v4_replace': EmblemNames.L3_REPLACE, + 'l3v4_summarize': EmblemNames.L3_SUMMARIZE, + 'keep_domain = true': f'{TomlSettings.REMOVE_DOMAIN} = false', + 'keep_domain = false': f'{TomlSettings.REMOVE_DOMAIN} = true', + } + old_options = { + 'lowercase': f'{TomlSettings.CASE} = {Case.LOWER}', + 'uppercase': f'{TomlSettings.CASE} = {Case.UPPER}', + f'FILESYSTEM': {Backends.MULTISITE}, + 'debug': f'{TomlSettings.LOG_LEVEL} = {LogLevels.DEBUG}', + 'keep_domain': f'{TomlSettings.REMOVE_DOMAIN} = true/false' + } + changed: bool = False + org_file = Path(toml_file) + if org_file.exists(): + print(f'Checking file.: {org_file.name}') + org_content: str = org_file.read_text() + content: str = org_content + for old, new in fix_options.items(): + re_pattern = f'\\b{old}\\b' + count = len(re_findall(re_pattern, org_content)) + if count > 0: + changed = True + content = re_sub(re_pattern, new, content) + print(f'Found value...: "{old}" {count} times, replaced by "{new}"') + + for old, new in old_options.items(): + re_pattern = f'\\b{old}\\b' + count = len(re_findall(re_pattern, org_content)) + if count > 0: + print(f'Obsolete......: "{old}", use "{new}" instead') + + if changed: + backup_file = Path(f'{toml_file}.backup') + if not backup_file.exists(): + org_file.rename(backup_file) + print(f'Renamed TOML..: {backup_file.name}') + new_file = Path(toml_file) + new_file.open('w').write(content) + print(f'Written fixed.: {new_file.name}') + else: + print( + f'Can not create backup file {backup_file.name}, file exists. Aborting!\n' + f'Nothing has changed.' + ) + else: + print('Finished......: Nothing found to fix.') diff --git a/source/bin/nvdct/nvdct.py b/source/bin/nvdct/nvdct.py index ffd8468..8d93b27 100755 --- a/source/bin/nvdct/nvdct.py +++ b/source/bin/nvdct/nvdct.py @@ -157,7 +157,23 @@ # [EMBLEMS] # l3v4_replace -> l3_replace # l3v4_summarize -> l3_summarize +# 2024-12-25: fixed "--dont-compare", data will only be saved if the are different from the default +# changed: is seed devices is not configured use all CDP/LLDP devices (by host label) +# 2024-12-26: INCOMPATIBLE: renamed L2_DROP_HOSTS -> L2_DROP_NEIGHBOURS +# added option --display-l2-neighbours +# 2024-12-27: added options +# --adjust-toml +# --include-l3-loopback +# --skip-l3-cidr-0 +# --skip-l3-cidr-32-128 +# --skip-l3-public +# fixed: keep default topology +# fixed: cleanup -> remove the oldest topologies not the newest +# INCOMPATIBLE: removed: CUSTOM_LAYERS +# refactoring constants +# +# # creating topology data json from inventory data # # This script creates the topology data file needed for the Checkmk "network_visualization" plugin @@ -254,7 +270,10 @@ __data = { """ import sys + +from collections.abc import MutableSequence from time import strftime, time_ns + from typing import List from lib.args import parse_arguments @@ -265,14 +284,17 @@ from lib.backends import ( HostCacheRestApi, ) from lib.constants import ( + Backends, DATAPATH, - HOME_URL, + URLs, + HostLabels, IPVersion, - LABEL_L3v4, - LAYERS, + InvPaths, LOGGER, - Layer, + Layers, NVDCT_VERSION, + TomlSections, + TomlSettings, ) from lib.settings import Settings from lib.topologies import ( @@ -282,8 +304,8 @@ from lib.topologies import ( ) from lib.utils import ( ExitCodes, - InventoryColumns, StdoutQuiet, + adjust_toml, configure_logger, remove_old_data, ) @@ -307,20 +329,29 @@ def main(): 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}' + f'see {URLs.NVDCT}' ) print('') print(f'Start time....: {strftime(settings.time_format)}') + if settings.fix_toml: + adjust_toml(settings.user_data_file) + print(f'Time taken....: {(time_ns() - start_time) / 1e9}/s') + print(f'End time......: {strftime(settings.time_format)}') + print('') + + LOGGER.critical('Data creation finished') + sys.exit() + match settings.backend: - case 'RESTAPI': + case Backends.RESTAPI: host_cache: HostCache = HostCacheRestApi( pre_fetch=settings.pre_fetch, api_port=settings.api_port, filter_sites=settings.filter_sites, - sites=settings.sites + sites=settings.sites, ) - case 'MULTISITE': + case Backends.MULTISITE: host_cache: HostCache = HostCacheMultiSite( pre_fetch=settings.pre_fetch, filter_sites=settings.filter_sites, @@ -328,42 +359,47 @@ def main(): filter_customers=settings.filter_customers, customers=settings.customers, ) - case 'LIVESTATUS': + case Backends.LIVESTATUS: 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 implemented') host_cache: HostCache | None = None # to keep linter happy sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED) - jobs: List[Layer] = [] + host_cache.init_neighbour_to_host( + case=settings.case, + l2_host_map=settings.l2_host_map, + prefix=settings.prefix, + remove_domain=settings.remove_domain, + ) + + jobs: MutableSequence = [] pre_fetch_layers: List[str] = [] pre_fetch_host_list: List[str] = [] for layer in settings.layers: match layer: - case 'STATIC': + case Layers.STATIC: + jobs.append(layer) + case Layers.L3V4: jobs.append(layer) - case 'L3v4': + host_cache.add_inventory_path(path=InvPaths.L3) + pre_fetch_layers.append(HostLabels.L3V4_ROUTER) + case Layers.CDP | Layers.LLDP: jobs.append(layer) - host_cache.add_inventory_path(path=LAYERS[layer].path) - pre_fetch_layers.append(LAYERS[layer].host_label) - case 'CUSTOM': - for entry in settings.custom_layers: - jobs.append(entry) - host_cache.add_inventory_path(entry.path) - case 'CDP' | 'LLDP': - jobs.append(LAYERS[layer]) - host_cache.add_inventory_path(path=LAYERS[layer].path) - pre_fetch_layers.append(LAYERS[layer].host_label) + host_cache.add_inventory_path(InvPaths.CDP if layer == Layers.CDP else InvPaths.LLDP) + pre_fetch_layers.append(HostLabels.CDP if layer == Layers.CDP else HostLabels.LLDP) case _: LOGGER.warning(f'Unknown layer {layer} dropped.') continue if not jobs: - message = ('No layer to work on. Please configura at least one layer (i.e. CLI option "-l CDP")\n' - 'See ~/local/bin/nvdct/conf/nvdct.toml -> SETTINGS -> layers') + message = ( + f'No layer to work on. Please configura at least one layer (i.e. CLI option "-l {Layers.CDP}")\n' + f'See {settings.user_data_file} -> {TomlSections.SETTINGS} -> {TomlSettings.LAYERS}' + ) LOGGER.warning(message) print(message) sys.exit(ExitCodes.NO_LAYER_CONFIGURED) @@ -371,8 +407,8 @@ def main(): 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): - pre_fetch_host_list = list(set(pre_fetch_host_list + _host_list)) + if host_list := host_cache.get_hosts_by_label(host_label): + pre_fetch_host_list = list(set(pre_fetch_host_list + 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)}') @@ -382,16 +418,14 @@ def main(): for job in jobs: match job: - case 'STATIC': + case Layers.STATIC: label = job topology = TopologyStatic( connections=settings.static_connections, emblems=settings.emblems, host_cache=host_cache, ) - topology.create() - - case 'L3v4': + case Layers.L3V4: label = job topology = TopologyL3( emblems=settings.emblems, @@ -401,37 +435,42 @@ def main(): ignore_wildcard=settings.l3v4_ignore_wildcard, include_hosts=settings.include_l3_hosts, replace=settings.l3_replace, + skip_cidr_0=settings.skip_l3_cidr_0, + skip_cidr_32_128=settings.skip_l3_cidr_32_128, skip_if=settings.skip_l3_if, skip_ip=settings.skip_l3_ip, + skip_public=settings.skip_l3_public, + include_loopback=settings.include_l3_loopback, summarize=settings.l3_summarize, - version=IPVersion.IPv4 if job == LABEL_L3v4 else IPVersion.IPv6 + version=IPVersion.IPv4 if job == Layers.L3V4 else IPVersion.IPv6 ) - topology.create() - - case _: - label = job.label.upper() - columns = job.columns.split(',') + case Layers.CDP | Layers.LLDP: + label = job + if job == Layers.CDP: + host_label = HostLabels.CDP + inv_path = InvPaths.CDP + else: + host_label = HostLabels.LLDP + inv_path = InvPaths.LLDP + if not (seed_devices := settings.l2_seed_devices): + seed_devices = host_cache.get_hosts_by_label(host_label) topology = TopologyL2( - case=settings.case, emblems=settings.emblems, host_cache=host_cache, - inv_columns=InventoryColumns( - neighbour=columns[0], - local_port=columns[1], - neighbour_port=columns[2] - ), - l2_drop_hosts=settings.l2_drop_hosts, - l2_host_map=settings.l2_host_map, + l2_drop_neighbours=settings.l2_drop_neighbours, l2_neighbour_replace_regex=settings.l2_neighbour_replace_regex, label=label, - path_in_inventory=job.path, - prefix=settings.prefix, - remove_domain=settings.remove_domain, - seed_devices=settings.l2_seed_devices, + path_in_inventory=inv_path, + seed_devices=seed_devices, + display_l2_neighbours=settings.display_l2_neighbours, ) - topology.create() - + case _: + LOGGER.warning(f'Unknown layer {job}, ignoring.') + continue + pre_message = f'Layer {label:.<8s}: ' + print(pre_message, end ='', flush=True) + topology.create() topology.nv_connections.add_meta_data_to_connections( nv_objects=topology.nv_objects, speed_map=settings.map_speed_to_thickness, @@ -440,14 +479,15 @@ def main(): topology.save( label=label, output_directory=settings.output_directory, - make_default=settings.default + make_default=settings.default, + dont_compare=settings.dont_compare, ) message = ( - f'Layer {label:.<8s}: Devices/Objects/Connections added {topology.nv_objects.host_count}/' + f'Devices/Objects/Connections added {topology.nv_objects.host_count}/' f'{len(topology.nv_objects.nv_objects)}/{len(topology.nv_connections.nv_connections)}' ) - LOGGER.info(msg=message) + LOGGER.info(msg=f'{pre_message} {message}') print(message) if settings.keep: diff --git a/source/packages/nvdct b/source/packages/nvdct index 329c316..32ad046 100644 --- a/source/packages/nvdct +++ b/source/packages/nvdct @@ -47,7 +47,7 @@ 'htdocs/images/icons/location_80.png']}, 'name': 'nvdct', 'title': 'Network Visualization Data Creation Tool (NVDCT)', - 'version': '0.9.6-20241222', + 'version': '0.9.7-20241230', 'version.min_required': '2.3.0b1', 'version.packaged': 'cmk-mkp-tool 0.2.0', 'version.usable_until': '2.4.0p1'} -- GitLab