From 810a282a306ca4fefc024468b2afdb35806607f0 Mon Sep 17 00:00:00 2001 From: "th.l" <thl-cmk@outlook.com> Date: Wed, 5 Feb 2025 19:06:11 +0100 Subject: [PATCH] - reworked CLI options - reworkwd neighbour to host matching - added support for filter by folder/labels/tags - added post support for RESTAPI --- README.md | 2 +- mkp/nvdct-0.9.8-20250205.mkp | Bin 0 -> 51806 bytes source/bin/nvdct/conf/nvdct.toml | 83 +++-- source/bin/nvdct/lib/args.py | 241 ++++++++------- source/bin/nvdct/lib/backends.py | 478 +++++++++++++++++++---------- source/bin/nvdct/lib/constants.py | 186 ++++++----- source/bin/nvdct/lib/settings.py | 382 ++++++++++++++--------- source/bin/nvdct/lib/topologies.py | 322 +++++++++++++------ source/bin/nvdct/lib/utils.py | 165 +++------- source/bin/nvdct/nvdct.py | 161 +++++++--- source/packages/nvdct | 2 +- 11 files changed, 1226 insertions(+), 796 deletions(-) create mode 100644 mkp/nvdct-0.9.8-20250205.mkp mode change 100755 => 100644 source/bin/nvdct/conf/nvdct.toml diff --git a/README.md b/README.md index 09483c9..ac47e7c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.7-20241230.mkp "nvdct-0.9.7-20241230.mkp" +[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.8-20250205.mkp "nvdct-0.9.8-20250205.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.8-20250205.mkp b/mkp/nvdct-0.9.8-20250205.mkp new file mode 100644 index 0000000000000000000000000000000000000000..823b375ef1b71252b7cb8541fee51db3b1a2e5e8 GIT binary patch literal 51806 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%0A< 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 n000000000000000000000000000000{;&Q4o$Bcv0B{2U9~!9F literal 0 HcmV?d00001 diff --git a/source/bin/nvdct/conf/nvdct.toml b/source/bin/nvdct/conf/nvdct.toml old mode 100755 new mode 100644 index d31f64e..524e395 --- a/source/bin/nvdct/conf/nvdct.toml +++ b/source/bin/nvdct/conf/nvdct.toml @@ -16,8 +16,8 @@ # [0-9-a-zA-Z\.\_\-]{1,253} -> host L2_SEED_DEVICES = [ # "CORE01", - # "LOCATION01", - # "LOCATION02", + # "CORE2", + # "router01", ] # drop CDP/LLDP neighbours names @@ -44,11 +44,11 @@ L3_IGNORE_IP = [ # ignore IPs by wildcard # if comparing an ip address: -# each 0 bit in the wildcad has to be exacly as in the pattern -# each 1 bit in the wildacrd will be ignored +# each 0 bit in the wildcard has to be exactly as in the pattern +# each 1 bit in the wildcard will be ignored L3V4_IGNORE_WILDCARD = [ # [ pattern , wildcard ] - # ["172.17.0.1", "0.0.255.0"], # ignore all IPs ending with 1 from 172.17.128.0/16 + # ["172.17.0.1", "0.0.255.0"], # ignore all IPs ending with 1 from 172.17.0.0/16 # ["172.17.128.0", "0.0.127.3"], # ignore all IPs ending with 0-3 from 172.17.128.0/17 # ["172.17.128.3", "0.0.127.0"], # ignore all IPs ending with 3 from 172.17.128.0/17 ] @@ -62,7 +62,7 @@ L3_SUMMARIZE = [ # "fd00::/8" ] -# topologies will not be deleted by "--keep" +# topologies will not be deleted by "--keep_max_topologies" PROTECTED_TOPOLOGIES = [ # "2023-10-17T14:08:05.10", # "your_important_topology" @@ -82,17 +82,17 @@ STATIC_CONNECTIONS = [ # connection: "left_host"<->"right_host" ] -# list customers to include/excluse, use with option --filter-costumers INCLUDE/EXCLUDE +# list customers to include/exclude, use with option --filter-costumers INCLUDE/EXCLUDE # [0-9-a-zA-Z\.\_\-]{1,16} -> customer -CUSTOMERS = [ +FILTER_BY_CUSTOMER = [ # "customer1", # "customer2", # "customer3", ] -# list site to include/excluse, use with option --filter-sites INCLUDE/EXCLUDE +# list site to include/exclude, use with option --filter-sites INCLUDE/EXCLUDE # [0-9-a-zA-Z\.\_\-]{1,16} -> site -SITES = [ +FILTER_BY_SITE = [ # "site1", # "site2", # "site3", @@ -100,7 +100,7 @@ SITES = [ # map inventory CDP/LLDP neighbour name to Checkmk host name # [0-9-a-zA-Z\.\_\-]{1,253} -> host -[L2_HOST_MAP] +[L2_NEIGHBOUR_TO_HOST_MAP] # "inventory_neighbour1" = "cmk_host1" # "inventory_neighbour2" = "cmk_host2" # "inventory_neighbour3" = "cmk_host3" @@ -114,7 +114,7 @@ SITES = [ # replace _network objects_ in L§ topologies (takes place after summarize) # [0-9-a-zA-Z\.\_\-]{1,253} -> host -[L3_REPLACE] +[L3_REPLACE_NETWORKS] # "10.193.172.0/24" = "MPLS" # "10.194.8.0/23" = "MPLS" # "10.194.12.0/24" = "MPLS" @@ -123,7 +123,7 @@ SITES = [ [EMBLEMS] # 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 +# for built-in icons use "icon_" as l2_prefix to the name from CMK # max size 80x80px # emblems will only be used for non CMK objects # "host_node" = "icon_alert_unreach" @@ -145,31 +145,46 @@ SITES = [ "1e9" = 3 # 1 gbit "1e10" = 5 # 10 gbit +[FILTER_BY_FOLDER] +# "/folder1/subfolder1" = "INCLUDE" | "EXCLUDE" +# "/folder2/subfolder2" = "INCLUDE" | "EXCLUDE" + +[FILTER_BY_HOST_LABEL] +# "hostlabel1:value" = "INCLUDE" | "EXCLUDE" +# "hostlabel2:value" = "INCLUDE" | "EXCLUDE" + +[FILTER_BY_HOST_TAG] +# "host_tag1:value" = "INCLUDE" | "EXCLUDE" +# "host_tag2:value" = "INCLUDE" | "EXCLUDE" + [SETTINGS] # api_port = 5001 # backend = "MULTISITE" | "RESTAPI" | "LIVESTATUS" -# case = "LOWER" | "UPPER" -# default = false -# display_l2_neighbours = false -# dont_compare = false +# default = false | true +# dont_compare = false | true # 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"] +# keep_max_topologies = 10 +# l2_case = "OFF" | "LOWER" | "UPPER" | "IGNORE" | "AUTO" +# l2_display_neighbours = false | true +# l2_display_ports = false | true +# l2_prefix = "" +# l2_remove_domain = "OFF" | "ON" | "AUTO" +# l2_skip_external = false | true +# l3_display_devices = false | true +# l3_include_hosts = false | true +# l3_include_loopback = false | true # most likely dropped from inventory (SNMP) before +# l3_skip_cidr_0 = false | true +# l3_skip_cidr_32_128 = false | true +# l3_skip_if = false | true +# l3_skip_ip = false | true +# l3_skip_public = false | true +# layers = ["LLDP", "CDP", "L3v4", "STATIC"] # log_file = "~/var/log/nvdct.log" -# log_level = "WARNING" -# log_to_stdout = false -min_age = 1 -output_directory = 'nvdct' # remove to get date formated directory -# pre_fetch = false -# 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 +# log_level = "WARNING" | "DEBUG" | "INFO" | "EROR" | "FATAL" | "CRITICAL" | "OFF" +# log_to_stdout = false | true +# min_topology_age = 1 +output_directory = "nvdct" # remove to get date formated directory +# pre_fetch = false | true +# quiet = false | true # 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 1d1be87..3f9a81f 100755 --- a/source/bin/nvdct/lib/args.py +++ b/source/bin/nvdct/lib/args.py @@ -10,32 +10,39 @@ # # options used # -b --backend +# -c --config # -d --default # -l --layers # -o --output-directory -# -p --prefix -# -u --user-data-file # -v --version # --api-port (deprecated ?) -# --case -# --check-user-data-only -# --display-l2-neighbours +# --check-config-only # --dont-compare # --filter-customers # --filter-sites -# --fix-toml -# --include-l3-hosts -# --keep +# --keep-max-topologies +# --l2-case +# --l2-display-ports +# --l2-display-neighbours +# --l2-prefix +# --l2-remove-domain +# --l2-skip-external +# --l3-display-devices +# --l3-include-hosts +# --l3-include-loopback +# --l3-skip-cidr-0 +# --l3-skip-cidr-32-128 +# --l3-skip-if +# --l3-skip-ip +# --l3-skip-public # --log-file # --log-level # --log-to-stdout -# --min-age +# --min-topology-age # --pre-fetch # --quiet -# --remove-domain -# --skip-l3-if -# --skip-l3-ip # --time-format +# --update-config from argparse import ( @@ -47,19 +54,21 @@ from pathlib import Path from lib.constants import ( Backends, + CONFIG_FILE, Case, CliLong, + CliShort, ExitCodes, IncludeExclude, Layers, LogLevels, MinVersions, NVDCT_VERSION, + RemoveDomain, SCRIPT, TIME_FORMAT_ARGPARSER, TomlSections, URLs, - USER_DATA_FILE, ) @@ -86,36 +95,29 @@ def parse_arguments() -> arg_Namespace: f' {ExitCodes.AUTOMATION_SECRET_NOT_FOUND} - Automation secret not found\n' f' {ExitCodes.NO_LAYER_CONFIGURED} - No layer to work on\n' '\nUsage:\n' - f'{SCRIPT} -u ~/local/bin/nvdct/conf/my_{USER_DATA_FILE} \n\n' + f'{SCRIPT} -u ~/local/bin/nvdct/conf/my_{CONFIG_FILE} \n\n' ) - parser.add_argument( - '-b', CliLong.BACKEND, + CliShort.BACKEND, CliLong.BACKEND, choices=[Backends.LIVESTATUS, Backends.MULTISITE, Backends.RESTAPI], # default='MULTISITE', help='Backend used to retrieve the topology data\n' 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.', + f' - {Backends.RESTAPI} : uses the Checkmk REST API.', ) parser.add_argument( - '-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 doesnt exists.', + CliShort.CONFIG, CliLong.CONFIG, type=str, + help='Set the name of the config file.\n' + f'Default is ~/local/bin/nvdct/conf/{CONFIG_FILE}', ) parser.add_argument( - '-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' - f'in "{CliLong.TIME_FORMAT}" format.\n' - 'NOTE: the directory is a sub directory under "~/var/check_mk/topology/data/"\n', - ) - parser.add_argument( - '-p', CliLong.PREFIX, type=str, - help='Prepends each host with the prefix. (Needs more testing)\n' + CliShort.DEFAULT, CliLong.DEFAULT, action='store_const', const=True, # default=False, + help='Set the created topology data as default. Will be created automatically\n' + 'if it doesnt exists.', ) parser.add_argument( - '-l', CliLong.LAYERS, + CliShort.LAYERS, CliLong.LAYERS, nargs='+', choices=[ Layers.CDP, @@ -125,69 +127,34 @@ def parse_arguments() -> arg_Namespace: ], # default=['CDP'], help=( - 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' + 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', 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', + CliShort.OUTPUT_DIRECTORY, 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' + f'in "{CliLong.TIME_FORMAT}" format.\n' + 'NOTE: the directory is a sub directory under "~/var/check_mk/topology/data/"\n', ) parser.add_argument( - '-v', CliLong.VERSION, action='version', + CliShort.VERSION, CliLong.VERSION, action='version', version=f'{Path(SCRIPT).name} version: {NVDCT_VERSION}', help='Print version of this script and exit', ) - parser.add_argument( - 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( - CliLong.CASE, - choices=[Case.LOWER, Case.UPPER], - # default='NONE', - help='Change L2 neighbour name to all lower/upper case before matching to CMK host', - ) - parser.add_argument( - 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( - CliLong.LOG_FILE, type=str, - help='Set the log file. Default is "~/var/log/nvdct.log"\n', - ) - parser.add_argument( - CliLong.LOG_LEVEL, - # nargs='+', - choices=[ - LogLevels.CRITICAL, - LogLevels.FATAL, - LogLevels.ERROR, - LogLevels.WARNING, - LogLevels.INFO, - LogLevels.DEBUG, - LogLevels.OFF - ], - # default='WARNING', - help=f'Sets the log level. The default is "{LogLevels.WARNING}"\n' - ) - parser.add_argument( - CliLong.LOG_TO_STDOUT, action='store_const', const=True, # default=False, - help='Send log to stdout.', - ) - parser.add_argument( - CliLong.DISPLAY_L2_NEIGHBOURS, action='store_const', const=True, # default=False, - help='Use L2 neighbour name as display name in L2 topologies', + CliLong.CHECK_CONFIG, action='store_const', const=True, # default=False, + help=f'Only tries to read/parse the config file from {CONFIG_FILE} and exits.', ) parser.add_argument( CliLong.DONT_COMPARE, action='store_const', const=True, # default=False, @@ -201,69 +168,133 @@ def parse_arguments() -> arg_Namespace: CliLong.FILTER_CUSTOMERS, choices=[IncludeExclude.INCLUDE, IncludeExclude.EXCLUDE], # default='INCLUDE', - help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} customer list "[{TomlSections.CUSTOMERS}]" from TOML file.\n' - f'Note: {Backends.MULTISITE} backend only.', + help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} customer list "[{TomlSections.FILTER_BY_CUSTOMER}]" from TOML file.' + f'NOTE: {Backends.MULTISITE} backend only.', ) parser.add_argument( CliLong.FILTER_SITES, choices=[IncludeExclude.EXCLUDE, IncludeExclude.EXCLUDE], # default='INCLUDE', - help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} site list "[{TomlSections.SITES}]" from TOML file.\n' + help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} site list "[{TomlSections.FILTER_BY_SITE}]" from TOML file.' ) parser.add_argument( - CliLong.INCLUDE_L3_HOSTS, action='store_const', const=True, # default=False, - help='Include hosts (single IP objects) in layer 3 topologies', + CliLong.KEEP_MAX_TOPOLOGIES, type=int, + help='Number of topologies to keep. The oldest topologies above keep\n' + 'will be deleted.\n' + 'NOTE: The default/protected topologies will be kept always.' ) parser.add_argument( - CliLong.INCLUDE_L3_LOOPBACK, action='store_const', const=True, # default=False, - help='Include loopback ip-addresses in layer 3 topologies', + CliLong.L2_CASE, + choices=[Case.INSENSITIVE, Case.LOWER, Case.UPPER, Case.AUTO, Case.OFF], + help='Change L2 neighbour name case before matching to Checkmk host.\n' + f'- {Case.OFF} : Do not change the case of the neighbour name.' + f'- {Case.INSENSITIVE} : search for a matching host by ignoring the case of neighbour name and host name\n' + f'- {Case.LOWER} : change to all lower case\n' + f'- {Case.UPPER} : change to all upper case, without the domain part of the neighbour name\n' + f'- {Case.AUTO} : try all the above variants\n' + f'Default is let the case of the neighbour name untouched ("OFF").\n' + f'Takes place after "{CliLong.L2_REMOVE_DOMAIN}" and before "{CliLong.L2_PREFIX}"', ) parser.add_argument( - CliLong.REMOVE_DOMAIN, action='store_const', const=True, # default=False, - help='Remove the domain name from the L2 neighbor name before matching CMK host.', + CliLong.L2_DISPLAY_PORTS, action='store_const', const=True, # default=False, + help='Use L2 port names as display name for interfaces in L2 topologies', ) parser.add_argument( - CliLong.KEEP, type=int, - help='Number of topologies to keep. The oldest topologies above keep\n' - 'will be deleted.\n' - 'NOTE: The default/protected topologies will be kept always.\n' + CliLong.L2_DISPLAY_NEIGHBOURS, action='store_const', const=True, # default=False, + help='Use L2 neighbour name as display name in L2 topologies', ) parser.add_argument( - CliLong.MIN_AGE, type=int, - help=f'The minimum number of days before a topology is deleted by "{CliLong.KEEP}"' + CliLong.L2_PREFIX, type=str, + help=f'Prepends each L2 neighbour name with the prefix before matching to a Checkmk host name.\n' + f'Takes place after "{CliLong.L2_REMOVE_DOMAIN}" and "{CliLong.L2_CASE}"' ) parser.add_argument( - 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', + CliLong.L2_REMOVE_DOMAIN, + choices=[RemoveDomain.OFF, RemoveDomain.ON, RemoveDomain.AUTO], + help=f'Handle the the domain name part of a neighbour name before matching it to a Checkmk host.\n' + f'- {RemoveDomain.OFF} : dont touch the neighbour name, keep host name and domain part\n' + f'- {RemoveDomain.ON} : will remove the domain part from the neighbour name, keep only the host name part\n' + f'- {RemoveDomain.AUTO} : try all of the above variants\n' + f'Default: "{RemoveDomain.OFF}". Takes place after "{CliLong.L2_REMOVE_DOMAIN}" and before "{CliLong.L2_PREFIX}"', ) parser.add_argument( - CliLong.QUIET, action='store_const', const=True, # default=False, - help='Suppress all output to stdtout', + CliLong.L2_SKIP_EXTERNAL, action='store_const', const=True, # default=False, + help='Skip L2 neighbours external to Checkmk (that have no matching host)', ) parser.add_argument( - CliLong.SKIP_L3_CIDR_0, action='store_const', const=True, # default=False, + CliLong.L3_DISPLAY_DEVICES, action='store_const', const=True, # default=False, + help='Use L3 device names as display name for interfaces in L3 topologies', + ) + parser.add_argument( + CliLong.L3_INCLUDE_HOSTS, action='store_const', const=True, # default=False, + help='Include hosts (single IP objects) in layer 3 topologies', + ) + parser.add_argument( + CliLong.L3_INCLUDE_LOOPBACK, action='store_const', const=True, # default=False, + help='Include loopback ip-addresses in layer 3 topologies', + ) + parser.add_argument( + CliLong.L3_SKIP_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, + CliLong.L3_SKIP_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, + CliLong.L3_SKIP_IF, action='store_const', const=True, # default=False, help='Dont show interface in layer 3 topologies', ) parser.add_argument( - CliLong.SKIP_L3_IP, action='store_const', const=True, # default=False, + CliLong.L3_SKIP_IP, action='store_const', const=True, # default=False, help='Dont show ip-addresses in layer 3 topologies', ) parser.add_argument( - CliLong.SKIP_L3_PUBLIC, action='store_const', const=True, # default=False, + CliLong.L3_SKIP_PUBLIC, action='store_const', const=True, # default=False, help='Skip public ip-addresses in layer 3 topologies', ) + parser.add_argument( + CliLong.LOG_FILE, type=str, + help='Set the log file. Default is "~/var/log/nvdct.log"', + ) + parser.add_argument( + CliLong.LOG_LEVEL, + # nargs='+', + choices=[ + LogLevels.CRITICAL, + LogLevels.FATAL, + LogLevels.ERROR, + LogLevels.WARNING, + LogLevels.INFO, + LogLevels.DEBUG, + LogLevels.OFF + ], + # default='WARNING', + help=f'Sets the log level. The default is "{LogLevels.WARNING}"' + ) + parser.add_argument( + CliLong.LOG_TO_STDOUT, action='store_const', const=True, # default=False, + help='Send log to stdout.', + ) + parser.add_argument( + CliLong.MIN_TOPOLOGY_AGE, type=int, + help=f'The minimum number of days before a topology is deleted by "{CliLong.KEEP_MAX_TOPOLOGIES}"' + ) + parser.add_argument( + 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( + CliLong.QUIET, action='store_const', const=True, # default=False, + help='Suppress all output to stdtout', + ) parser.add_argument( CliLong.TIME_FORMAT, type=str, help=f'Format string to render the time. (default: "{TIME_FORMAT_ARGPARSER}")', ) - + parser.add_argument( + CliLong.UPDATE_CONFIG, action='store_const', const=True, # default=False, + help='Adjusts old options in TOML file.', + ) return parser.parse_args() diff --git a/source/bin/nvdct/lib/backends.py b/source/bin/nvdct/lib/backends.py index 80adf5a..8fce63c 100755 --- a/source/bin/nvdct/lib/backends.py +++ b/source/bin/nvdct/lib/backends.py @@ -12,14 +12,18 @@ # 2024-09-25: fixed crash on missing "customer" section in site config file # 2024-12-22: refactoring, leave only backend specific stuff in the backend # removed not strictly needed properties, renamed functions to better understand what the do +# 2025-01-21: added support for RESTAPI post requests (CMK >= 2.3.0p23, Werk #17003) +# fixed REST API query for interface services +# 2025-01-25: fixed check if post can be used from abc import abstractmethod from ast import literal_eval -from collections.abc import Mapping, MutableSequence, Sequence +from collections.abc import Mapping, MutableMapping, MutableSet, MutableSequence, Sequence +from json import dumps from pathlib import Path from requests import session from sys import exit as sys_exit -from typing import Dict, List, Tuple, MutableMapping +from typing import Dict, List, Tuple, Set from livestatus import MultiSiteConnection, SiteConfigurations, SiteId @@ -29,102 +33,135 @@ from lib.constants import ( CacheItems, Case, ExitCodes, + HostFilter, IncludeExclude, InvPaths, + L2InvColumns, + LiveStatusOperator, + MIN_CMK_VERSION_POST, OMD_ROOT, - TomlSections, + RemoveDomain, ) from lib.utils import ( LOGGER, get_data_form_live_status, - get_table_from_inventory, + get_data_from_inventory, + get_local_cmk_version, ) HOST_EXIST: Dict = {'exists': True} -def hosts_to_query(hosts: List[str]) -> Tuple[str, List[str]]: - # WORKAROUND for: Apache HTTP Error 414: Request URI too long - # https://httpd.apache.org/docs/current/mod/core.html#limitrequestfieldsize - # Default: LimitRequestFieldSize 8190 - # max seems to be 8013 (inventory)/7831 (interfaces), so 7800 is a save distance from it - _max_str_len = 7800 - # 8190 - 8013 - 168 = 9 chrs -> Inventory - # 8190 - 7813 - 356 = 21 chrs -> Interfaces - # host (19/19): http://localhost:80 -> sould not count - # uri (57/60): /build/check_mk/api/1.0/domain-types/host/collections/all / /build/check_mk/api/1.0/domain-types/service/collections/all # noqa: E501 - # query (77/231): ?query=%7B%22op%22%3A+%22~~%22%2C+%22left%22%3A+%22name%22%2C+%22right%22%3A+ / ?query=%7B%22op%22%3A+%22and%22%2C+%22expr%22%3A+%5B%7B%22op%22%3A+%22~%22%2C+%22left%22%3A+%22description%22%2C+%22right%22%3A+%22Interface+%22%7D%2C%7B%22op%22%3A+%22~~%22%2C+%22left%22%3A+%22host_name%22%2C+%22right%22%3A+%22%5E # noqa: E501 - # collumns (34/65): &columns=name&columns=mk_inventory / &columns=host_name&columns=description&columns=long_plugin_output # noqa: E501 - # %22 -> " - # %24 -> $ - # %2C -> , - # %3A -> : - # %5B -> [ - # %5D -> ] - # %7B -> { - # %7D -> } - - temp_hosts = [] - open_hosts = [] - for host in hosts: - temp_hosts.append(f'^{host}$') - hosts_str = '|'.join(temp_hosts) - # 6 comes from 3 chrs ($|^) between hosts, coded as 9 chr(%24%7C%5E) in the URL - # 3 are already in the str, so we need to add 6 for each host - if len(hosts_str) > _max_str_len - len(hosts) * 6: - open_hosts = hosts.copy() - hosts_str = f'^{hosts[0]}$' - hosts = hosts[1:] - count = 1 - for host in hosts: - count += 1 - if len(hosts_str) + 9 + len(host) + (6 * count) < _max_str_len: - hosts_str = hosts_str + f'|^{host}$' - open_hosts.remove(host) - else: - break - - LOGGER.debug(f'hosts len: {len(hosts_str) + (len(hosts) - len(open_hosts)) * 6}') - LOGGER.debug(f'open hosts {open_hosts}') - - return hosts_str, open_hosts class HostCache: def __init__( self, backend: str, pre_fetch: bool, + ): - LOGGER.info('init HOST_CACHE') + LOGGER.info(f'{backend} init HOST_CACHE') self.cache: Dict = {} self.neighbour_to_host: MutableMapping[str, str] = {} - self._inventory_pre_fetch_list: List[str] = [ - InvPaths.INTERFACES, - ] + self._inventory_pre_fetch_list: List[str] = [InvPaths.INTERFACES] self.backend: str = str(backend) self.case: str = '' - self.l2_host_map: Dict[str, str] = {} + self.l2_neighbour_to_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 + self.remove_domain: str = RemoveDomain.OFF + self.filter_include: MutableSequence[str] = [] + self.filter_exclude: MutableSequence[str] = [] + self.no_case_host_map: MutableMapping[str, str] = {} + + self.use_post = True + + if self.backend == f'[{Backends.RESTAPI}]' and get_local_cmk_version() < MIN_CMK_VERSION_POST: + self.use_post = False + LOGGER.info( + f'{self.backend} Using get request, Checkmk version {get_local_cmk_version()} < {MIN_CMK_VERSION_POST}' + ) + else: + LOGGER.info( + f'{self.backend} Using post request, Checkmk version {get_local_cmk_version()} >= {MIN_CMK_VERSION_POST}' + ) if self.pre_fetch: for host in self.query_all_hosts(): self.cache[host] = HOST_EXIST.copy() - def init_neighbour_to_host( + def init_filter_lists( + self, + filter_by_folder: Mapping[str, Sequence[str]], + filter_by_host_label: Mapping[str, Sequence[str]], + filter_by_host_tag: Mapping[str, Sequence[str]], + ): + for folder in filter_by_folder[IncludeExclude.INCLUDE]: + self.filter_include += self.query_hosts_by_filter(HostFilter.FOLDER, folder, LiveStatusOperator.SUPERSET) + + for folder in filter_by_folder[IncludeExclude.EXCLUDE]: + self.filter_exclude += self.query_hosts_by_filter(HostFilter.FOLDER, folder, LiveStatusOperator.SUPERSET) + + for host_label in filter_by_host_label[IncludeExclude.INCLUDE]: + self.filter_include += self.query_hosts_by_filter(HostFilter.LABELS, host_label, LiveStatusOperator.EQUAL) + + for host_label in filter_by_host_label[IncludeExclude.EXCLUDE]: + self.filter_exclude += self.query_hosts_by_filter(HostFilter.LABELS, host_label, LiveStatusOperator.EQUAL) + + for host_tag in filter_by_host_tag[IncludeExclude.INCLUDE]: + self.filter_include += self.query_hosts_by_filter(HostFilter.TAGS, host_tag, LiveStatusOperator.EQUAL) + + for host_tag in filter_by_host_tag[IncludeExclude.EXCLUDE]: + self.filter_exclude += self.query_hosts_by_filter(HostFilter.TAGS, host_tag, LiveStatusOperator.EQUAL) + + # drop any host from the include list that is on the exclude list + # -> Issue: can render filter_include empty -> disabling include + if self.filter_include: + self.filter_include = [host for host in self.filter_include if host not in self.filter_exclude] + if not self.filter_include: + self.filter_include.append(self.filter_exclude[0]) + + LOGGER.info(f'{self.backend} {IncludeExclude.INCLUDE} # of hosts: {len(self.filter_include)}') + LOGGER.info(f'{self.backend} {IncludeExclude.EXCLUDE} # of hosts: {len(self.filter_exclude)}') + LOGGER.debug(f'{self.backend} {IncludeExclude.INCLUDE} hosts: {self.filter_include}') + LOGGER.debug(f'{self.backend} {IncludeExclude.EXCLUDE} hosts: {self.filter_exclude}') + + def init_neighbour_to_host_map( self, case: str, l2_host_map: Dict[str, str], prefix: str, - remove_domain: bool, + remove_domain: str, ): self.case: str = case - self.l2_host_map: Dict[str, str] = l2_host_map self.prefix: str = prefix - self.remove_domain: bool = remove_domain + self.remove_domain: str = remove_domain + + for host, data in self.cache.items(): + try: + self.neighbour_to_host[data[CacheItems.inventory][InvPaths.LLDP_GLOBAL][L2InvColumns.GLOBALID]] = host + except (KeyError, TypeError): + pass + try: + self.neighbour_to_host[data[CacheItems.inventory][InvPaths.LLDP_GLOBAL][L2InvColumns.GLOBALNAME]] = host + except (KeyError, TypeError): + pass + try: + self.neighbour_to_host[data[CacheItems.inventory][InvPaths.CDP_GLOBAL][L2InvColumns.GLOBALNAME]] = host + except (KeyError, TypeError): + pass + + for neighbour, host in l2_host_map.items(): + self.neighbour_to_host[neighbour] = host + + def filter_host_list(self, host_list: MutableSequence[str] | MutableSet[str]) -> Sequence[str] | MutableSet[str]: + if self.filter_include: + host_list = [host for host in host_list if host in self.filter_include] + if self.filter_exclude: + host_list = [host for host in host_list if host not in self.filter_exclude] + return host_list def get_inventory_data(self, hosts: List[str]) -> Dict[str, Dict]: """ @@ -136,17 +173,13 @@ class HostCache: the inventory data as dictionary """ - inventory_data: Dict[str, Dict | None] = {} # init inventory_data with None - for host in hosts: - inventory_data[host] = None - + inventory_data: Dict[str, Dict | None] = {host: None for host in hosts} open_hosts = hosts.copy() while open_hosts: - hosts_str, open_hosts = hosts_to_query(open_hosts) + hosts_str, open_hosts = self.hosts_to_query(open_hosts) for host, inventory in self.query_inventory_data(hosts_str).items(): inventory_data[host] = inventory - return inventory_data def get_interface_data(self, hosts: List[str]) -> Dict[str, Dict | None]: @@ -165,7 +198,7 @@ class HostCache: host_data[host] = None open_hosts = hosts.copy() while open_hosts: - hosts_str, open_hosts = hosts_to_query(open_hosts) + hosts_str, open_hosts = self.hosts_to_query(open_hosts) host_data.update(self.query_interface_data(hosts_str)) return host_data @@ -179,6 +212,9 @@ class HostCache: except KeyError: pass + if self.pre_fetch: + return False + # get host from CMK and init host in cache if exists := self.query_host(host): self.cache[host] = HOST_EXIST.copy() @@ -187,16 +223,13 @@ class HostCache: return exists - def get_hosts_by_label(self, label: str) -> Sequence[str]: - """ - Returns list of hosts from CMK filtered by label - Args: - label: hostlabel to filter by + def is_host_allowed(self, host: str) -> bool: + if self.filter_include and host not in self.filter_include: + return False + if self.filter_exclude and host in self.filter_exclude: + return False - Returns: - List of hosts - """ - return self.query_hosts_by_label(label) + return True def fill_cache(self, hosts: List[str]) -> None: """ @@ -209,6 +242,7 @@ class HostCache: Returns: None, the data is directly writen to self.cache """ + inventory_of_hosts: Mapping[str, Mapping | None] = self.get_inventory_data(hosts=hosts) if inventory_of_hosts: for host, inventory in inventory_of_hosts.items(): @@ -216,14 +250,13 @@ class HostCache: self.cache[host] = HOST_EXIST.copy() self.cache[host][CacheItems.inventory] = {} self.cache[host][CacheItems.inventory].update({ - entry: get_table_from_inventory( + entry: get_data_from_inventory( inventory=inventory, raw_path=entry ) for entry in self._inventory_pre_fetch_list }) interfaces_of_hosts: Mapping[str, Mapping | None] = self.get_interface_data(hosts) - for host, interfaces in interfaces_of_hosts.items(): if host not in self.cache: self.cache[host] = HOST_EXIST.copy() @@ -244,7 +277,7 @@ class HostCache: """ if self.host_exists(host=host): if self.cache[host] == HOST_EXIST: - LOGGER.info(f'fetch data for: {host}') + LOGGER.info(f'{self.backend} fetch data for: {host}') self.fill_cache(hosts=[host]) try: return self.cache[host][item][path] @@ -256,61 +289,161 @@ 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: + def get_host_from_neighbour( + self, + raw_neighbour: str, + neighbour: str, + neighbour_id: str | None = None, + ) -> str | None: """ Tries to get the CMK host name from a L2 neighbour name. It will test: + - the neighbour id + - the raw neighbour name + - the adjusted neighbour name + - map the neighbour to a host via L2_NEIGHBOUR_TO_HOST_MAP - 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 + - the neighbour with prefix (UPPER/lower case, with/without domain) Args: + raw_neighbour: the L2 neighbour name "as is" in the HW/SW inventory neighbour: the L2 neighbour name to find a CMK host for + neighbour_id: the L2 neighbour id to find a CMK host for (LLDP only) Returns: The CMK host name for the L2 neighbour or None if no host is found - """ + + try: + match = self.neighbour_to_host[neighbour_id] + LOGGER.debug(f'{self.backend} Match by neighbour id: |{neighbour_id}| -> |{match}|') + return match + except KeyError: + pass + + try: + match = self.neighbour_to_host[raw_neighbour] + LOGGER.debug(f'{self.backend} Match by raw neighbour: |{raw_neighbour}| -> |{match}|') + return match + except KeyError: + pass + try: - return self.neighbour_to_host[neighbour] + match = self.neighbour_to_host[neighbour] + LOGGER.debug(f'{self.backend} Match by neighbour: |{neighbour}| -> |{match}|') + return match except KeyError: pass - host = neighbour + LOGGER.debug(f'{self.backend} neighbour not in list: |{neighbour}|') + + host: str = 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] + possible_hosts: Set[str] | List[str] = set() - if self.remove_domain: - LOGGER.debug(f'Remove domain: {host} -> {host.split(".")[0]}') - host = host.split('.')[0] + host_no_domain: str = host.split('.')[0] + match self.remove_domain: + case RemoveDomain.ON: + LOGGER.debug(f'{self.backend} Remove domain: {host} -> {host_no_domain}') + host = host_no_domain + possible_hosts.add(host_no_domain) + case RemoveDomain.AUTO: + possible_hosts.add(host) + possible_hosts.add(host_no_domain) + case RemoveDomain.OFF | _: + possible_hosts.add(host) match self.case: case Case.UPPER: - LOGGER.debug(f'Change neighbour to upper case: {host} -> {host.upper()}') - host = host.upper() - + if not '.' in host: # use UPPER only for names without domain + LOGGER.debug(f'{self.backend} Change neighbour to upper case: {host} -> {host.upper()}') + possible_hosts.add(host.upper) case Case.LOWER: - LOGGER.debug(f'Change neighbour to lower case: {host} -> {host.lower()}') - host = host.lower() - case _: - pass + LOGGER.debug(f'{self.backend} Change neighbour to lower case: {host} -> {host.lower()}') + possible_hosts.add(host.lower()) + case Case.AUTO: + possible_hosts.add(host) + possible_hosts.add(host.lower()) + possible_hosts.add(host_no_domain.lower()) + possible_hosts.add(host_no_domain.upper()) + case Case.OFF | _: + possible_hosts.add(host) + + possible_hosts = [f'{self.prefix}{host}' for host in possible_hosts] + possible_hosts = set(self.filter_host_list(possible_hosts)) + # try longest match first + possible_hosts = list(possible_hosts) + possible_hosts.sort(key=len, reverse=True) + + for entry in possible_hosts: + if self.host_exists(entry): + self.neighbour_to_host[neighbour] = entry + LOGGER.debug(f'{self.backend} Matched neighbour to host: |{neighbour}| -> |{entry}|') + return entry + + if self.case in [Case.AUTO, Case.INSENSITIVE]: + if not self.no_case_host_map: + self.no_case_host_map = {host.lower():host for host in self.cache} + for entry in possible_hosts: + if entry.lower() in self.no_case_host_map: + self.neighbour_to_host[neighbour] = self.no_case_host_map[entry.lower()] + LOGGER.debug( + f'{self.backend} Matched neighbour to host: ' + f'|{neighbour}| -> |{self.no_case_host_map[entry.lower()]}|' + ) + return entry + + self.neighbour_to_host[neighbour] = None + LOGGER.debug(f'{self.backend} No match found for neighbour: |{neighbour}|') + return None - if self.prefix: - LOGGER.debug(f'Prepend neighbour with prefix: {host} -> {self.prefix}{host}') - host = f'{self.prefix}{host}' + def hosts_to_query(self, hosts: List[str]) -> Tuple[str, List[str]]: + if self.use_post: + return '|'.join(hosts), [] + + # WORKAROUND for: Apache HTTP Error 414: Request URI too long + # https://httpd.apache.org/docs/current/mod/core.html#limitrequestfieldsize + # Default: LimitRequestFieldSize 8190 + # max seems to be 8013 (inventory)/7831 (interfaces), so 7800 is a save distance from it + _max_str_len = 7800 + + # 8190 - 8013 - 168 = 9 chrs -> Inventory + # 8190 - 7813 - 356 = 21 chrs -> Interfaces + # host (19/19): http://localhost:80 -> sould not count + # uri (57/60): /build/check_mk/api/1.0/domain-types/host/collections/all / /build/check_mk/api/1.0/domain-types/service/collections/all # noqa: E501 + # query (77/231): ?query=%7B%22op%22%3A+%22~~%22%2C+%22left%22%3A+%22name%22%2C+%22right%22%3A+ / ?query=%7B%22op%22%3A+%22and%22%2C+%22expr%22%3A+%5B%7B%22op%22%3A+%22~%22%2C+%22left%22%3A+%22description%22%2C+%22right%22%3A+%22Interface+%22%7D%2C%7B%22op%22%3A+%22~~%22%2C+%22left%22%3A+%22host_name%22%2C+%22right%22%3A+%22%5E # noqa: E501 + # collumns (34/65): &columns=name&columns=mk_inventory / &columns=host_name&columns=description&columns=long_plugin_output # noqa: E501 + # %22 -> " + # %24 -> $ + # %2C -> , + # %3A -> : + # %5B -> [ + # %5D -> ] + # %7B -> { + # %7D -> } + + hosts = [host for host in hosts if self.is_host_allowed(host)] + open_hosts = [] + temp_hosts: Sequence[str] = [f'^{host}$' for host in hosts] + hosts_str = '|'.join(temp_hosts) + # 6 comes from 3 chrs ($|^) between hosts, coded as 9 chr(%24%7C%5E) in the URL + # 3 are already in the str, so we need to add 6 for each host + if len(hosts_str) > _max_str_len - len(hosts) * 6: + open_hosts = hosts.copy() + hosts_str = f'^{hosts.pop()}$' + count = 1 + for host in hosts: + count += 1 + if len(hosts_str) + 9 + len(host) + (6 * count) < _max_str_len: + hosts_str = hosts_str + f'|^{host}$' + open_hosts.remove(host) + else: + break + LOGGER.debug(f'{self.backend} hosts len: {len(hosts_str) + (len(hosts) - len(open_hosts)) * 6}') + LOGGER.debug(f'{self.backend} open hosts {open_hosts}') - 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 + return hosts_str, open_hosts @abstractmethod def query_host(self, host: str) -> bool: @@ -335,12 +468,13 @@ class HostCache: raise NotImplementedError @abstractmethod - def query_hosts_by_label(self, label: str) -> Sequence[str]: + def query_hosts_by_filter(self, filter_name: str, filter_value: str, filter_operator: str) -> MutableSequence[str]: """ Queries Livestatus for a list of hosts filtered by a host label Args: - label: Host label to filter list of host by - + filter_name: The column name to filter on + filter_value: the value for the attribute to filter on + filter_operator: Live status filter operator Returns: List of hosts """ raise NotImplementedError @@ -353,6 +487,7 @@ class HostCache: def query_interface_data(self, hosts: str) -> Dict[str, Dict]: raise NotImplementedError + class HostCacheLiveStatus(HostCache): def __init__( self, @@ -361,8 +496,8 @@ class HostCacheLiveStatus(HostCache): ): self.backend = backend super().__init__( - pre_fetch = pre_fetch, - backend = self.backend, + pre_fetch=pre_fetch, + backend=self.backend, ) def get_raw_data(self, query: str) -> any: @@ -378,9 +513,9 @@ class HostCacheLiveStatus(HostCache): data: Sequence[Sequence[str]] = self.get_raw_data(query=query) LOGGER.debug(f'{self.backend} data for host {host}: {data}') if [host] in data: - LOGGER.debug(f'{self.backend} Host {host} found in CMK') + LOGGER.debug(f'{self.backend} Host found in Checkmk: {host} ') return True - LOGGER.warning(f'{self.backend} Host {host} not found in CMK') + LOGGER.warning(f'{self.backend} Host not found in Checkmk: {host}') return False def query_all_hosts(self) -> Sequence[str]: @@ -397,20 +532,20 @@ class HostCacheLiveStatus(HostCache): LOGGER.warning(f'{self.backend} no hosts found') return [] - def query_hosts_by_label(self, label: str) -> Sequence[str]: + def query_hosts_by_filter(self, filter_name: str, filter_value: str, filter_operator: str) -> MutableSequence[str]: query = ( 'GET hosts\n' 'Columns: name\n' 'OutputFormat: python3\n' - f'Filter: labels = {label}\n' + f'Filter: {filter_name} {filter_operator} {filter_value}\n' ) data: Sequence[Sequence[str]] = self.get_raw_data(query=query) - LOGGER.debug(f'{self.backend} hosts matching label: {data}') + LOGGER.debug(f'{self.backend} hosts matching filter {filter_value}: {data}') if data: - LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') + LOGGER.info(f'{self.backend} # of hosts matching filter {filter_value}: {len(data)}') return [host[0] for host in data] - LOGGER.warning(f'{self.backend} no hosts found matching label {label}') + LOGGER.warning(f'{self.backend} no hosts found matching filter: {filter_value}') return [] def query_inventory_data(self, hosts: str) -> Dict[str, Dict]: @@ -426,7 +561,7 @@ class HostCacheLiveStatus(HostCache): if data: for host, inventory in data: if not inventory: - LOGGER.warning(f'{self.backend} Device: {host}: no inventory data found!') + LOGGER.warning(f'{self.backend} No inventory data found for host: {host}') continue inventory = literal_eval(inventory.decode('utf-8')) inventory_data[host] = inventory @@ -445,7 +580,7 @@ class HostCacheLiveStatus(HostCache): ) interface_data = {} data: List[Tuple[str, str, str]] = self.get_raw_data(query=query) - LOGGER.debug(f'{self.backend} interface data for hosts {hosts}: {data}') + LOGGER.debug(f'{self.backend} interface data for hosts: {hosts}: {data}') if data: for host, description, long_plugin_output in data: if interface_data.get(host) is None: @@ -454,18 +589,19 @@ class HostCacheLiveStatus(HostCache): 'long_plugin_output': long_plugin_output.split('\\n') } else: - LOGGER.warning(f'{self.backend} No Interfaces items found for hosts {hosts}') + LOGGER.warning(f'{self.backend} No Interfaces items found for hosts: {hosts}') return interface_data + class HostCacheMultiSite(HostCacheLiveStatus): def __init__( - self, - pre_fetch: bool, - filter_sites: str | None = None, - sites: List[str] | None = None, - filter_customers: str | None = None, - customers: List[str] = None, + self, + pre_fetch: bool, + filter_sites: str | None = None, + sites: List[str] | None = None, + filter_customers: str | None = None, + customers: List[str] = None, ): if not sites: sites = [] @@ -481,7 +617,7 @@ 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} use of dead site(s) {dead_sites} is disabled') + LOGGER.warning(f'{self.backend} use of dead site(s) is disabled: {dead_sites}') self.c.set_only_sites(self.c.alive_sites()) super().__init__( pre_fetch=pre_fetch, @@ -535,7 +671,7 @@ class HostCacheMultiSite(HostCacheLiveStatus): }}) LOGGER.critical( - f'{self.backend} file {str(sites_mk.absolute())} not found. Fallback to ' + f'{self.backend} file {str(sites_mk.absolute())} not found. Falling back to ' 'local site only. Try -b RESTAPI if you have a distributed environment.' ) @@ -561,6 +697,7 @@ class HostCacheMultiSite(HostCacheLiveStatus): case _: return + class HostCacheRestApi(HostCache): def __init__( self, @@ -580,7 +717,7 @@ class HostCacheRestApi(HostCache): ).read_text().strip('\n') except FileNotFoundError as e: LOGGER.exception(f'{self.backend} automation.secret not found, {e}') - print(f'{self.backend} automation.secret not found, {e}') + print(f'{self.backend} automation secret not found, {e}') sys_exit(ExitCodes.AUTOMATION_SECRET_NOT_FOUND) self.__api_port = api_port @@ -593,21 +730,30 @@ class HostCacheRestApi(HostCache): self.__session = session() self.__session.headers['Authorization'] = f"Bearer {self.__user} {self.__secret}" self.__session.headers['Accept'] = 'application/json' - - self.sites: MutableSequence[str] = self.query_sites() + self.__session.headers['Content-Type'] = 'application/json' + self.sites: MutableSequence[str] = self.query_sites() self.filter_sites(filter_=filter_sites, sites=sites) - LOGGER.info(f'{self.backend} filtered sites : {self.sites}') + LOGGER.info(f'{self.backend} filtered sites: {self.sites}') 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( - url=url, - params=params, - ) + def get_raw_data(self, url: str, params: Mapping[str, object] | None, allow_post: bool = False): + if allow_post and self.use_post: + resp = self.__session.post( + url=url, + data=dumps(params), + # timeout=3, + ) + else: + resp = self.__session.get( + url=url, + params=params, + # timeout=3, + ) LOGGER.debug(f'{self.backend} raw data: {resp.text}') + if resp.status_code == 200: return resp.json() else: @@ -617,10 +763,10 @@ class HostCacheRestApi(HostCache): LOGGER.debug(f'{self.backend} get_sites') url = f"{self.__api_url}/domain-types/site_connection/collections/all" sites = [] - if raw_data:= self.get_raw_data(url, None): + if raw_data := self.get_raw_data(url, None): raw_sites = raw_data.get("value") sites = [site.get('id') for site in raw_sites] - LOGGER.debug(f'{self.backend} sites : {sites}') + LOGGER.debug(f'{self.backend} sites: {sites}') else: LOGGER.warning(f'{self.backend} got no site information!') @@ -644,14 +790,15 @@ class HostCacheRestApi(HostCache): 'sites': self.sites, } - if raw_data := self.get_raw_data(url, params): + if raw_data := self.get_raw_data(url, params, self.use_post): try: data = raw_data['value'][0]['extensions']['name'] LOGGER.debug(f'{self.backend} data for host {host}: {data}') except IndexError: - LOGGER.warning(f'Host {host} not found in CMK') + LOGGER.warning(f'{self.backend} Host not found in Checkmk: {host}') else: if data == host: + LOGGER.debug(f'{self.backend} Host found in Checkmk: {host}') return True return False @@ -663,29 +810,31 @@ class HostCacheRestApi(HostCache): 'sites': self.sites, } - if raw_data := self.get_raw_data(url, params): + if raw_data := self.get_raw_data(url, params, self.use_post): if data := raw_data.get('value', []): LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') return [host.get('extensions', {}).get('name') for host in data] + LOGGER.warning(f'{self.backend} no hosts found') return [] - def query_hosts_by_label(self, label: str) -> Sequence[str]: - query = '{"op": "=", "left": "labels", "right": "' + label + '"}' - + def query_hosts_by_filter(self, filter_name: str, filter_value: str, filter_operator: str) -> MutableSequence[str]: + query = '{"op": "' + filter_operator + '", "left": "' + filter_name + '", "right": "' + filter_value + '"}' url = f'{self.__api_url}/domain-types/host/collections/all' params = { - 'columns': ['name', 'labels'], + 'columns': ['name'], 'query': query, 'sites': self.sites, } - if raw_data := self.get_raw_data(url, params): + raw_data = self.get_raw_data(url, params, self.use_post) + LOGGER.debug(f'{self.backend} hosts matching filter {filter_value}: {raw_data}') + if raw_data: if data := raw_data.get('value'): - LOGGER.info(f'{self.backend} # of hosts found: {len(data)}') + LOGGER.info(f'{self.backend} # of hosts matching filter {filter_value}: {len(data)}') return [host['extensions']['name'] for host in data] - LOGGER.warning(f'{self.backend} no hosts found matching label {label}') + LOGGER.warning(f'{self.backend} no hosts found matching filter: {filter_value}') return [] def query_inventory_data(self, hosts: str) -> Dict[str, Dict]: @@ -699,23 +848,23 @@ class HostCacheRestApi(HostCache): inventory_data = {} - if raw_data := self.get_raw_data(url, params): - LOGGER.debug(f'{self.backend} raw inventory data: {raw_data}') + raw_data = self.get_raw_data(url, params, self.use_post) + LOGGER.debug(f'{self.backend} raw inventory data: {raw_data}') + if raw_data: if data := raw_data.get('value', []): for raw_host in data: if host := raw_host.get('extensions', {}).get('name'): inventory = raw_host['extensions'].get('mk_inventory') if not inventory: - LOGGER.warning(f'{self.backend} Device: {host}: no inventory data found!') + LOGGER.warning(f'{self.backend} No inventory data found for host: {host}!') inventory_data[host] = inventory return inventory_data def query_interface_data(self, hosts: str) -> Dict[str, Dict]: - query_host = f'{{"op": "~~", "left": "host_name", "right": "{hosts}"}}' - query_item = '{"op": "~", "left": "description", "right": "Interface "}' - query = f'{{"op": "and", "expr": [{query_item},{query_host}]}}' - + query_host = '{"op": "~~", "left": "host_name", "right": "' + hosts + '"}' + query_item = '{"op": "~", "left": "description", "right": "^Interface "}' + query = '{"op": "and", "expr": [' + query_host + ',' + query_item +']}' url = f'{self.__api_url}/domain-types/service/collections/all' params = { 'query': query, @@ -725,15 +874,16 @@ class HostCacheRestApi(HostCache): interface_data = {} - if raw_data := self.get_raw_data(url, params): - LOGGER.debug(f'{self.backend} raw interface data: {raw_data}') - + raw_data = self.get_raw_data(url, params) # , self.use_post -> Endpoint not changed to Post :-( + LOGGER.debug(f'{self.backend} interface data for host(s) {hosts}: {raw_data}') + if raw_data: if data := raw_data.get('value', []): for raw_service in data: - LOGGER.debug(f'{self.backend} data for service : {raw_service}') + LOGGER.debug(f'{self.backend} data for service: {raw_service}') service = raw_service.get('extensions') host, description, long_plugin_output = service.values() if interface_data.get(host) is None: + # LOGGER.warning(f'{self.backend} No Interfaces items found for hosts {host}') interface_data[host] = {} interface_data[host][description[10:]] = { 'long_plugin_output': long_plugin_output.split('\\n') diff --git a/source/bin/nvdct/lib/constants.py b/source/bin/nvdct/lib/constants.py index 4dd6c55..579eb0d 100755 --- a/source/bin/nvdct/lib/constants.py +++ b/source/bin/nvdct/lib/constants.py @@ -13,45 +13,47 @@ from os import environ from typing import Final # -NVDCT_VERSION: Final[str] = '0.9.7-20241230' +NVDCT_VERSION: Final[str] = '0.9.8-20250107' # OMD_ROOT: Final[str] = environ["OMD_ROOT"] # -API_PORT: Final[int] = 5001 +API_PORT_DEFAULT: Final[int] = 5001 CACHE_INTERFACES_DATA: Final[str] = 'interface_data' CMK_SITE_CONF: Final[str] = f'{OMD_ROOT}/etc/omd/site.conf' +CONFIG_FILE: Final[str] = 'nvdct.toml' +DATAPATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data' LOGGER: Logger = getLogger('root)') -LOG_FILE: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log' +LOG_FILE_DEFAULT: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log' +MIN_CMK_VERSION_POST: Final[str] = '2.3.0p23' 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' +TIME_FORMAT_DEFAULT: Final[str] = '%Y-%m-%dT%H:%M:%S.%m' -class MyEnum(Enum): +class EnumValue(Enum): def __get__(self, instance, owner): return self.value @unique -class ExitCodes(MyEnum): +class ExitCodes(EnumValue): OK = 0 BAD_OPTION_LIST = auto() BAD_TOML_FORMAT = auto() BACKEND_NOT_IMPLEMENTED = auto() AUTOMATION_SECRET_NOT_FOUND = auto() NO_LAYER_CONFIGURED = auto() + FILE_NOT_FOUND = auto() @unique -class IPVersion(MyEnum): +class IPVersion(EnumValue): IPv4 = 4 IPv6 = 6 @unique -class URLs(MyEnum): +class URLs(EnumValue): 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' @@ -63,60 +65,84 @@ class URLs(MyEnum): @unique -class Backends(MyEnum): +class Backends(EnumValue): LIVESTATUS: Final[str] = 'LIVESTATUS' MULTISITE: Final[str] = 'MULTISITE' RESTAPI: Final[str] = 'RESTAPI' @unique -class Case(MyEnum): +class Case(EnumValue): + AUTO: Final[str] = 'AUTO' + INSENSITIVE: Final[str] = 'INSENSITIVE' LOWER: Final[str] = 'LOWER' UPPER: Final[str] = 'UPPER' + OFF: Final[str] = 'OFF' + + +@unique +class RemoveDomain(EnumValue): + ON: Final[str] = 'ON' + OFF: Final[str] = 'OFF' + AUTO: Final[str] = 'AUTO' + @unique -class CacheItems(MyEnum): +class CacheItems(EnumValue): inventory = 'inventory' interfaces = 'interfaces' + @unique -class CliLong(MyEnum): - ADJUST_TOML: Final[str] = '--adjust-toml' +class CliLong(EnumValue): API_PORT: Final[str] = '--api-port' BACKEND: Final[str] = '--backend' - CASE: Final[str] = '--case' - CHECK_USER_DATA_ONLY: Final[str] = '--check-user-data-only' + CHECK_CONFIG: Final[str] = '--check-config' + CONFIG: Final[str] = '--config' 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' + KEEP_MAX_TOPOLOGIES: Final[str] = '--keep-max-topologies' + L2_CASE: Final[str] = '--l2-case' + L2_DISPLAY_NEIGHBOURS: Final[str] = '--l2-display-neighbours' + L2_DISPLAY_PORTS: Final[str] = '--l2-display-ports' + L2_PREFIX: Final[str] = '--l2-prefix' + L2_REMOVE_DOMAIN: Final[str] = '--l2-remove-domain' + L2_SKIP_EXTERNAL: Final[str] = '--l2-skip-external' + L3_DISPLAY_DEVICES: Final[str] = '--l3-display-devices' + L3_INCLUDE_HOSTS: Final[str] = '--l3-include-hosts' + L3_INCLUDE_LOOPBACK: Final[str] = '--l3-include-loopback' + L3_SKIP_CIDR_0: Final[str] = '--l3-skip-cidr-0' + L3_SKIP_CIDR_32_128: Final[str] = '--l3-skip-cidr-32-128' + L3_SKIP_IF: Final[str] = '--l3-skip-if' + L3_SKIP_IP: Final[str] = '--l3-skip-ip' + L3_SKIP_PUBLIC: Final[str] = '--l3-skip-public' 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' + MIN_TOPOLOGY_AGE: Final[str] = '--min-topology-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' + UPDATE_CONFIG: Final[str] = '--update-config' VERSION: Final[str] = '--version' @unique -class EmblemNames(MyEnum): +class CliShort(EnumValue): + BACKEND: Final[str] = '-b' + CONFIG: Final[str] = '-c' + DEFAULT: Final[str] = '-d' + LAYERS: Final[str] = '-l' + OUTPUT_DIRECTORY: Final[str] = '-o' + VERSION: Final[str] = '-v' + + +@unique +class EmblemNames(EnumValue): HOST_NODE: Final[str] = 'host_node' IP_ADDRESS: Final[str] = 'ip_address' IP_NETWORK: Final[str] = 'ip_network' @@ -126,7 +152,7 @@ class EmblemNames(MyEnum): @unique -class EmblemValues(MyEnum): +class EmblemValues(EnumValue): ICON_AGGREGATION: Final[str] = 'icon_aggr' ICON_ALERT_UNREACHABLE: Final[str] = 'icon_alert_unreach' ICON_PLUGINS_CLOUD: Final[str] = 'icon_plugins_cloud' @@ -135,7 +161,7 @@ class EmblemValues(MyEnum): @unique -class HostLabels(MyEnum): +class HostLabels(EnumValue): CDP: Final[str] = "'nvdct/has_cdp_neighbours' 'yes'" L3V4_HOSTS: Final[str] = "'nvdct/l3v4_topology' 'host'" L3V4_ROUTER: Final[str] = "'nvdct/l3v4_topology' 'router'" @@ -145,36 +171,53 @@ class HostLabels(MyEnum): @unique -class IncludeExclude(MyEnum): +class HostFilter(EnumValue): + FOLDER: Final[str] = 'filename' + LABELS: Final[str] = 'labels' + TAGS: Final[str] = 'tags' + + +@unique +class LiveStatusOperator(EnumValue): + EQUAL: Final[str] = '=' + SUPERSET: Final[str] = '~' + + +@unique +class IncludeExclude(EnumValue): INCLUDE: Final[str] = 'INCLUDE' EXCLUDE: Final[str] = 'EXCLUDE' @unique -class L2InvColumns(MyEnum): +class L2InvColumns(EnumValue): NEIGHBOUR: Final[str] = 'neighbour_name' LOCALPORT: Final[str] = 'local_port' NEIGHBOURPORT: Final[str] = 'neighbour_port' + NEIGHBOURID: Final[str] = 'neighbour_id' + GLOBALID: Final[str] = 'local_id' + GLOBALNAME: Final[str] = 'local_name' @unique -class L3InvColumns(MyEnum): +class L3InvColumns(EnumValue): 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' +class InvPaths(EnumValue): + CDP: Final[str] = 'networking,cdp_cache,neighbours,Table,Rows' + CDP_GLOBAL: Final[str] = 'networking,cdp_cache,Attributes,Pairs' + INTERFACES: Final[str] = 'networking,interfaces,Table,Rows' + L3: Final[str] = 'networking,addresses,Table,Rows' + LLDP: Final[str] = 'networking,lldp_cache,neighbours,Table,Rows' + LLDP_GLOBAL: Final[str] = 'networking,lldp_cache,Attributes,Pairs' @unique -class Layers(MyEnum): +class Layers(EnumValue): CDP: Final[str] = 'CDP' LLDP: Final[str] = 'LLDP' L3V4: Final[str] = 'L3v4' @@ -183,7 +226,7 @@ class Layers(MyEnum): @unique -class LogLevels(MyEnum): +class LogLevels(EnumValue): CRITICAL: Final[str] = 'CRITICAL' FATAL: Final[str] = 'FATAL' ERROR: Final[str] = 'ERROR' @@ -193,7 +236,7 @@ class LogLevels(MyEnum): OFF: Final[str] = 'OFF' -class MinVersions(MyEnum): +class MinVersions(EnumValue): CDP: Final[str] = '0.7.1-20240320' LLDP: Final[str] = '0.9.3-20240320' LINUX_IP_ADDRESSES: Final[str] = '0.0.4-20241210' @@ -202,22 +245,25 @@ class MinVersions(MyEnum): @unique -class TomlSections(MyEnum): - CUSTOMERS: Final[str] = 'CUSTOMERS' +class TomlSections(EnumValue): EMBLEMS: Final[str] = 'EMBLEMS' + FILTER_BY_CUSTOMER: Final[str] = 'FILTER_BY_CUSTOMER' + FILTER_BY_FOLDER: Final[str] = 'FILTER_BY_FOLDER' + FILTER_BY_HOST_LABEL: Final[str] = 'FILTER_BY_HOST_LABEL' + FILTER_BY_HOST_TAG: Final[str] = 'FILTER_BY_HOST_TAG' + FILTER_BY_SITE: Final[str] = 'FILTER_BY_SITE' 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_NEIGHBOUR_TO_HOST_MAP: Final[str] = 'L2_NEIGHBOUR_TO_HOST_MAP' 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_REPLACE_NETWORKS: Final[str] = 'L3_REPLACE_NETWORKS' 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' @@ -226,35 +272,37 @@ def cli_long_to_toml(cli_param: str) -> str: @unique -class TomlSettings(MyEnum): - ADJUST_TOML: Final[str] = cli_long_to_toml(CliLong.ADJUST_TOML) +class TomlSettings(EnumValue): 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) + CHECK_CONFIG: Final[str] = cli_long_to_toml(CliLong.CHECK_CONFIG) + CONFIG: Final[str] = cli_long_to_toml(CliLong.CONFIG) 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) + KEEP_MAX_TOPOLOGIES: Final[str] = cli_long_to_toml(CliLong.KEEP_MAX_TOPOLOGIES) + L2_CASE: Final[str] = cli_long_to_toml(CliLong.L2_CASE) + L2_DISPLAY_NEIGHBOURS: Final[str] = cli_long_to_toml(CliLong.L2_DISPLAY_NEIGHBOURS) + L2_DISPLAY_PORTS: Final[str] = cli_long_to_toml(CliLong.L2_DISPLAY_PORTS) + L2_PREFIX: Final[str] = cli_long_to_toml(CliLong.L2_PREFIX) + L2_REMOVE_DOMAIN: Final[str] = cli_long_to_toml(CliLong.L2_REMOVE_DOMAIN) + L2_SKIP_EXTERNAL: Final[str] = cli_long_to_toml(CliLong.L2_SKIP_EXTERNAL) + L3_DISPLAY_DEVICES: Final[str] = cli_long_to_toml(CliLong.L3_DISPLAY_DEVICES) + L3_INCLUDE_HOSTS: Final[str] = cli_long_to_toml(CliLong.L3_INCLUDE_HOSTS) + L3_INCLUDE_LOOPBACK: Final[str] = cli_long_to_toml(CliLong.L3_INCLUDE_LOOPBACK) + L3_SKIP_CIDR_0: Final[str] = cli_long_to_toml(CliLong.L3_SKIP_CIDR_0) + L3_SKIP_CIDR_32_128: Final[str] = cli_long_to_toml(CliLong.L3_SKIP_CIDR_32_128) + L3_SKIP_IF: Final[str] = cli_long_to_toml(CliLong.L3_SKIP_IF) + L3_SKIP_IP: Final[str] = cli_long_to_toml(CliLong.L3_SKIP_IP) + L3_SKIP_PUBLIC: Final[str] = cli_long_to_toml(CliLong.L3_SKIP_PUBLIC) 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) + MIN_TOPOLOGY_AGE: Final[str] = cli_long_to_toml(CliLong.MIN_TOPOLOGY_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) - + UPDATE_CONFIG: Final[str] = cli_long_to_toml(CliLong.UPDATE_CONFIG) diff --git a/source/bin/nvdct/lib/settings.py b/source/bin/nvdct/lib/settings.py index 5b82bff..e069f0c 100755 --- a/source/bin/nvdct/lib/settings.py +++ b/source/bin/nvdct/lib/settings.py @@ -9,6 +9,7 @@ # fixed path to default user data file # 2024-12-17: fixed wrong import for OMD_ROOT (copy&paste) (ThX to BH2005@forum.checkmk.com) +# 2025-01-19: moved --check-toml to utils/get_data_from_toml from collections.abc import Mapping from ipaddress import AddressValueError, NetmaskValueError, ip_address, ip_network @@ -16,32 +17,31 @@ 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 typing import Dict, List, NamedTuple, Set, Tuple from lib.constants import ( - API_PORT, + API_PORT_DEFAULT, Backends, + CONFIG_FILE, Case, - EmblemValues, EmblemNames, + EmblemValues, ExitCodes, IncludeExclude, LOGGER, - LOG_FILE, + LOG_FILE_DEFAULT, LogLevels, OMD_ROOT, - TIME_FORMAT, + RemoveDomain, + TIME_FORMAT_DEFAULT, TomlSections, TomlSettings, - USER_DATA_FILE, - ) from lib.utils import ( get_data_from_toml, get_local_cmk_api_port, is_valid_customer_name, is_valid_hostname, - is_valid_log_file, is_valid_output_directory, is_valid_site_name, ) @@ -83,36 +83,39 @@ class Settings: ): # cli defaults self.__settings = { - TomlSettings.ADJUST_TOML: False, TomlSettings.API_PORT: None, TomlSettings.BACKEND: Backends.MULTISITE, - TomlSettings.CASE: None, - TomlSettings.CHECK_USER_DATA_ONLY: False, + TomlSettings.CHECK_CONFIG: False, + TomlSettings.CONFIG: f'{OMD_ROOT}/local/bin/nvdct/conf/{CONFIG_FILE}', 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.KEEP_MAX_TOPOLOGIES: False, + TomlSettings.L2_CASE: None, + TomlSettings.L2_DISPLAY_PORTS: False, + TomlSettings.L2_DISPLAY_NEIGHBOURS: False, + TomlSettings.L2_PREFIX: None, + TomlSettings.L2_REMOVE_DOMAIN: None, + TomlSettings.L2_SKIP_EXTERNAL: False, + TomlSettings.L3_DISPLAY_DEVICES: False, + TomlSettings.L3_INCLUDE_HOSTS: False, + TomlSettings.L3_INCLUDE_LOOPBACK: False, + TomlSettings.L3_SKIP_CIDR_0: False, + TomlSettings.L3_SKIP_CIDR_32_128: False, + TomlSettings.L3_SKIP_IF: False, + TomlSettings.L3_SKIP_IP: False, + TomlSettings.L3_SKIP_PUBLIC: False, TomlSettings.LAYERS: [], - TomlSettings.LOG_FILE: LOG_FILE, + TomlSettings.LOG_FILE: LOG_FILE_DEFAULT, TomlSettings.LOG_LEVEL: LogLevels.WARNING, TomlSettings.LOG_TO_STDOUT: False, - TomlSettings.MIN_AGE: 0, + TomlSettings.MIN_TOPOLOGY_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}', + TomlSettings.TIME_FORMAT: TIME_FORMAT_DEFAULT, + TomlSettings.UPDATE_CONFIG: False, } # args in the form {'s, __seed_devices': 'CORE01', 'p, __path_in_inventory': None, ... }} # we will remove 's, __' @@ -121,14 +124,10 @@ class Settings: ) self.__user_data = get_data_from_toml( - file=self.__args.get(TomlSettings.USER_DATA_FILE, self.user_data_file) + file=self.__args.get(TomlSettings.CONFIG, self.config), + check_only=bool(self.__args.get(TomlSettings.CHECK_CONFIG)), ) - 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(TomlSections.SETTINGS, {})) self.__settings.update(self.__args) @@ -136,20 +135,23 @@ class Settings: if self.layers: layers = list(set(self.layers)) if len(layers) != len(self.layers): - LOGGER.error( - msg='-l/--layers options must be unique. Don\'t use any layer more than once.' - ) + # logger not initialized here + # LOGGER.fatal('-l/--layers options must be unique. Don\'t use any layer more than once.') print('-l/--layers options must be unique. Don\'t use any layer more than once.') sys_exit(ExitCodes.BAD_OPTION_LIST) self.__api_port: int | None = None # init user data with defaults - self.__customers: List[str] | None = None self.__emblems: Emblems | None = None + self.__filter_by_customer: List[str] | None = None + self.__filter_by_folder: Dict[str, Set[str]] | None = None + self.__filter_by_host_label: Dict[str, Set[str]] | None = None + self.__filter_by_host_tag: Dict[str, Set[str]] | None = None + self.__filter_by_site: 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_neighbour_to_host_map: Dict[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 @@ -158,7 +160,6 @@ class Settings: 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 self.__static_connections: List[StaticConnection] | None = None # @@ -172,7 +173,7 @@ class Settings: 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_DEFAULT return self.__api_port @@ -185,35 +186,24 @@ class Settings: ]: return str(self.__settings[TomlSettings.BACKEND]) else: # fallback to defaukt -> exit ?? - LOGGER.warning( + LOGGER.error( f'Unknown backend: {self.__settings[TomlSettings.BACKEND]}. Accepted backends are: ' - f'{Backends.LIVESTATUS}, {Backends.MULTISITE}, {Backends.RESTAPI}. Fall back to {Backends.MULTISITE}.' + f'{Backends.LIVESTATUS}, {Backends.MULTISITE}, {Backends.RESTAPI}. Falling back to {Backends.MULTISITE}.' ) return Backends.MULTISITE - @property # --case - def case(self) -> str | 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'Unknown case setting {self.__settings[TomlSettings.CASE]}. ' - f'Accepted are {Case.LOWER}|{Case.UPPER}. Fallback to no change.' - ) - return None + @property # --check-config + def check_config(self) -> bool: + return bool(self.__settings[TomlSettings.CHECK_CONFIG]) - @property # --check-user-data-only - def check_user_data_only(self) -> bool: - return bool(self.__settings[TomlSettings.CHECK_USER_DATA_ONLY]) + @property # --config + def config(self) -> str: + return str(self.__settings[TomlSettings.CONFIG]) @property # -d --default def default(self) -> bool: 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[TomlSettings.DONT_COMPARE]) @@ -240,27 +230,74 @@ class Settings: ) return None - @property # --include-l3-hosts - def fix_toml(self) -> bool: - return bool(self.__settings[TomlSettings.ADJUST_TOML]) + @property # --l2-case + def l2_case(self) -> str | None: + if self.__settings[TomlSettings.L2_CASE] in [Case.LOWER, Case.UPPER, Case.INSENSITIVE, Case.AUTO, Case.OFF]: + return self.__settings[TomlSettings.L2_CASE] + elif self.__settings[TomlSettings.L2_CASE] is not None: + LOGGER.error( + f'Unknown case setting {self.__settings[TomlSettings.L2_CASE]}. ' + f'Accepted are {Case.LOWER}|{Case.UPPER}|{Case.INSENSITIVE}|{Case.AUTO}|{Case.OFF}. Falling back to "OFF" (no change).' + ) + return None + + @property # --l2-display-ports + def l2_display_ports(self) -> bool: + return bool(self.__settings[TomlSettings.L2_DISPLAY_PORTS]) + + @property # --l2-display-neighbours + def l2_display_neighbours(self) -> bool: + return bool(self.__settings[TomlSettings.L2_DISPLAY_NEIGHBOURS]) + + @property # --l2-prefix + def l2_prefix(self) -> str: + if self.__settings[TomlSettings.L2_PREFIX] is not None: + return str(self.__settings[TomlSettings.L2_PREFIX]) + return '' + + @property # --l2-remove-domain + def l2_remove_domain(self) -> str: + if self.__settings[TomlSettings.L2_REMOVE_DOMAIN] in [RemoveDomain.ON, RemoveDomain.OFF, RemoveDomain.AUTO]: + return self.__settings[TomlSettings.L2_REMOVE_DOMAIN] + else: + self.__settings[TomlSettings.L2_REMOVE_DOMAIN] = RemoveDomain.OFF + return RemoveDomain.OFF + + @property # --l2-skip-external + def l2_skip_external(self) -> bool: + return bool(self.__settings[TomlSettings.L2_SKIP_EXTERNAL]) + + @property # --l3-display-devices + def l3_display_devices(self) -> bool: + return bool(self.__settings[TomlSettings.L3_DISPLAY_DEVICES]) @property # --include-l3-hosts - def include_l3_hosts(self) -> bool: - return bool(self.__settings[TomlSettings.INCLUDE_L3_HOSTS]) + def l3_include_hosts(self) -> bool: + return bool(self.__settings[TomlSettings.L3_INCLUDE_HOSTS]) - @property # --skip-l3-ip - def include_l3_loopback(self) -> bool: - return bool(self.__settings[TomlSettings.INCLUDE_L3_LOOPBACK]) + @property # --l3-skip-ip + def l3_include_loopback(self) -> bool: + return bool(self.__settings[TomlSettings.L3_INCLUDE_LOOPBACK]) - @property # --keep - def keep(self) -> int | None: - if isinstance(self.__settings[TomlSettings.KEEP], int): - return max(self.__settings[TomlSettings.KEEP], 0) - return None + @property # --l3-skip-cidr-0 + def l3_skip_cidr_0(self) -> bool: + return bool(self.__settings[TomlSettings.L3_SKIP_CIDR_0]) + + @property # --l3-skip-cidr-32-128 + def l3_skip_cidr_32_128(self) -> bool: + return bool(self.__settings[TomlSettings.L3_SKIP_CIDR_32_128]) - @property # --keep-domain - def remove_domain(self) -> bool: - return bool(self.__settings[TomlSettings.REMOVE_DOMAIN]) + @property # --l3-skip-if + def l3_skip_if(self) -> bool: + return bool(self.__settings[TomlSettings.L3_SKIP_IF]) + + @property # --l3-skip-ip + def l3_skip_ip(self) -> bool: + return bool(self.__settings[TomlSettings.L3_SKIP_IP]) + + @property # --l3-skip-public + def l3_skip_public(self) -> bool: + return bool(self.__settings[TomlSettings.L3_SKIP_PUBLIC]) @property # --layers def layers(self) -> List[str]: @@ -269,11 +306,11 @@ class Settings: @property # --log-file def log_file(self) -> str: 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: - LOGGER.error(f'Falling back to {LOG_FILE}') - return LOG_FILE + if not raw_log_file.startswith(f'{OMD_ROOT}/var/log/'): + # logger not ready yet + print(f'\nInvalid log file {raw_log_file}. Falling back to {LOG_FILE_DEFAULT}') + return LOG_FILE_DEFAULT + return raw_log_file @property # --log-level def loglevel(self) -> int: @@ -292,10 +329,16 @@ class Settings: def log_to_stdtout(self) -> bool: return bool(self.__settings[TomlSettings.LOG_TO_STDOUT]) - @property # --min-age - def min_age(self) -> int: - if isinstance(self.__settings[TomlSettings.MIN_AGE], int): - return max(self.__settings[TomlSettings.MIN_AGE], 0) + @property # --keep-max-topologies + def keep_max_topologies(self) -> int | None: + if isinstance(self.__settings[TomlSettings.KEEP_MAX_TOPOLOGIES], int): + return self.__settings[TomlSettings.KEEP_MAX_TOPOLOGIES] + return None + + @property # --min-topology-age + def min_topology_age(self) -> int: + if isinstance(self.__settings[TomlSettings.MIN_TOPOLOGY_AGE], int): + return max(self.__settings[TomlSettings.MIN_TOPOLOGY_AGE], 0) else: return 0 @@ -304,19 +347,14 @@ class Settings: # init output directory with current time if not set 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]) + raw_output_directory = str(self.__settings[TomlSettings.OUTPUT_DIRECTORY]) + if is_valid_output_directory(raw_output_directory): + return raw_output_directory else: - LOGGER.error('Falling back to "nvdct"') + LOGGER.error(f'Invalid output directory {raw_output_directory}. Falling back to "nvdct".') return 'nvdct' - @property # --prefix - def prefix(self) -> str | None: - if self.__settings[TomlSettings.PREFIX] is not None: - return str(self.__settings[TomlSettings.PREFIX]) - return None - - @property # --pre-fill-cache + @property # --pre-fetch def pre_fetch(self) -> bool: return bool(self.__settings[TomlSettings.PRE_FETCH]) @@ -324,45 +362,17 @@ class Settings: def quiet(self) -> bool: 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[TomlSettings.SKIP_L3_IF]) - - @property # --skip-l3-ip - def skip_l3_ip(self) -> bool: - 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[TomlSettings.TIME_FORMAT]) + return str(self.__settings[TomlSettings.TIME_FORMAT].replace('/', '_').replace('..', '__')) - @property # --user-data-file - def user_data_file(self) -> str: - return str(self.__settings[TomlSettings.USER_DATA_FILE]) + @property # --update-config + def update_config(self) -> bool: + return bool(self.__settings[TomlSettings.UPDATE_CONFIG]) # # user data setting # - @property - def customers(self) -> List[str]: - if self.__customers is None: - self.__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 emblems(self) -> Emblems: @@ -378,6 +388,82 @@ class Settings: ) return self.__emblems + @property + def filter_by_customer(self) -> List[str]: + if self.__filter_by_customer is None: + self.__filter_by_customer = [ + str(customer) for customer in set(self.__user_data.get(TomlSections.FILTER_BY_CUSTOMER, [])) + if is_valid_customer_name(customer)] + LOGGER.info(f'Found {len(self.__filter_by_customer)} customer(s) to filter on') + return self.__filter_by_customer + + @property + def filter_by_site(self) -> List[str]: + if self.__filter_by_site is None: + self.__filter_by_site = [str(site) for site in set(self.__user_data.get(TomlSections.FILTER_BY_SITE, [])) if is_valid_site_name(site)] + LOGGER.info(f'Found {len(self.__filter_by_site)} site(s) to filter on') + return self.__filter_by_site + + @staticmethod + def parse_key_value_section(section: str, data: Mapping[str, str]) -> Dict[str, set[str]]: + parsed = { + IncludeExclude.INCLUDE: set(), + IncludeExclude.EXCLUDE: set() + } + for key_value, mode in data.items(): + if mode not in [IncludeExclude.INCLUDE, IncludeExclude.EXCLUDE]: + LOGGER.error( + f'Invalid mode in {section} found: {key_value}={mode} -> line ignored' + ) + continue + match section: + case TomlSections.FILTER_BY_HOST_LABEL: + try: + key, value = key_value.split(':', 1) + except ValueError: + LOGGER.error( + f'Invalid host label found missing ":": {key_value}={mode} -> line ignored' + ) + continue + if ':' in value: + LOGGER.error( + f'Invalid host label found can not contain more than one ":": "{key_value}={mode}" -> line ignored' + ) + continue + parsed[mode].add(f"'{key}' '{value}'") + case TomlSections.FILTER_BY_FOLDER: + parsed[mode].add(f'^/wato/{key_value.strip("/")}/') + case _: + parsed[mode].add(key_value) + return parsed + + @property + def filter_by_folder(self) -> Dict[str, set[str]]: + if self.__filter_by_folder is None: + self.__filter_by_folder = self.parse_key_value_section( + section=TomlSections.FILTER_BY_FOLDER, + data=self.__user_data.get(TomlSections.FILTER_BY_FOLDER, {}) + ) + return self.__filter_by_folder + + @property + def filter_by_host_label(self) -> Dict[str, Set[str]]: + if self.__filter_by_host_label is None: + self.__filter_by_host_label = self.parse_key_value_section( + section=TomlSections.FILTER_BY_HOST_LABEL, + data=self.__user_data.get(TomlSections.FILTER_BY_HOST_LABEL, {}) + ) + return self.__filter_by_host_label + + @property + def filter_by_host_tag(self) -> Dict[str, Set[str]]: + if self.__filter_by_host_tag is None: + self.__filter_by_host_tag = self.parse_key_value_section( + section=TomlSections.FILTER_BY_HOST_TAG, + data=self.__user_data.get(TomlSections.FILTER_BY_HOST_TAG, {}) + ) + return self.__filter_by_host_tag + @property def l2_drop_neighbours(self) -> List[str]: if self.__l2_drop_neighbours is None: @@ -392,14 +478,14 @@ class Settings: return self.__l2_seed_devices @property - def l2_host_map(self) -> Dict[str, str]: - 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( - TomlSections.L2_HOST_MAP, {} - ).items() if is_valid_hostname(host) + def l2_neighbour_to_host_map(self) -> Dict[str, str]: + if self.__l2_neighbour_to_host_map is None: + self.__l2_neighbour_to_host_map = { + str(neighbour): str(host) for neighbour, host in self.__user_data.get( + TomlSections.L2_NEIGHBOUR_TO_HOST_MAP, {} + ).items() if is_valid_hostname(neighbour) } - return self.__l2_host_map + return self.__l2_neighbour_to_host_map @property def l2_neighbour_replace_regex(self) -> List[Tuple[str, str]] | None: @@ -426,7 +512,7 @@ class Settings: 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): + except (AddressValueError, NetmaskValueError, ValueError): LOGGER.error( f'Invalid entry in {TomlSections.L3_IGNORE_IP} found: {raw_ip_network} -> ignored' ) @@ -454,6 +540,7 @@ class Settings: inverted_wildcard = '.'.join( [str(255 - int(octet)) for octet in wildcard.split('.')] ) + self.__l3v4_ignore_wildcard.append(Wildcard( int_ip_address=int(ip_address(raw_ip_address)), int_wildcard=int(ip_address(inverted_wildcard)), @@ -463,7 +550,7 @@ class Settings: ip_address(inverted_wildcard) ) )) - except (AddressValueError, NetmaskValueError): + except (AddressValueError, NetmaskValueError, ValueError): LOGGER.error( f'Invalid entry in {TomlSections.L3V4_IGNORE_WILDCARD} -> {entry} -> ignored' ) @@ -475,24 +562,24 @@ class Settings: return self.__l3v4_ignore_wildcard @property - def l3_replace(self) -> Dict[str, str]: + def l3_replace_networks(self) -> Dict[str, str]: if self.__l3_replace is None: self.__l3_replace = {} - for raw_ip_network, node in self.__user_data.get(TomlSections.L3_REPLACE, {}).items(): + for raw_ip_network, node in self.__user_data.get(TomlSections.L3_REPLACE_NETWORKS, {}).items(): try: _ip_network = ip_network(raw_ip_network) # noqa: F841 - except (AddressValueError, NetmaskValueError): + except (AddressValueError, NetmaskValueError, ValueError): LOGGER.error( - f'Invalid entry in {TomlSections.L3_REPLACE} found: {raw_ip_network} -> line ignored' + f'Invalid entry in {TomlSections.L3_REPLACE_NETWORKS} found: "{raw_ip_network}" = "{node}" -> line ignored' ) continue if not is_valid_hostname(node): - LOGGER.error(f'Invalid node name found: {node} -> line ignored ') + LOGGER.error(f'Invalid node name found: {node} -> line ignored') continue self.__l3_replace[raw_ip_network] = str(node) LOGGER.info( - f'Valid entries in {TomlSections.L3_REPLACE} found: {len(self.__l3_replace)}/' - f'{len(self.__user_data.get(TomlSections.L3_REPLACE, {}))}' + f'Valid entries in {TomlSections.L3_REPLACE_NETWORKS} found: {len(self.__l3_replace)}/' + f'{len(self.__user_data.get(TomlSections.L3_REPLACE_NETWORKS, {}))}' ) return self.__l3_replace @@ -503,7 +590,7 @@ class Settings: 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): + except (AddressValueError, NetmaskValueError, ValueError): LOGGER.error( f'Invalid entry in {TomlSections.L3_SUMMARIZE} -> {raw_ip_network} -> ignored' ) @@ -555,11 +642,11 @@ class Settings: left_host, left_service, right_service, right_host = connection except ValueError: LOGGER.error( - f'Wrong entry in {TomlSections.STATIC_CONNECTIONS} -> {connection} -> ignored' + f'Invalid entry in {TomlSections.STATIC_CONNECTIONS} needs to be 4 tuples: -> {connection} -> ignored' ) continue if not right_host or not left_host: - LOGGER.warning(f'Both hosts must be set, got {connection}') + LOGGER.warning(f'Both hosts must be set, got: {connection} -> ignored') continue if not is_valid_hostname(right_host) or not is_valid_hostname(left_host): continue @@ -575,9 +662,4 @@ class Settings: ) return self.__static_connections - @property - def sites(self) -> List[str]: - if self.__sites is None: - self.__sites = [str(site) for site in set(self.__user_data.get(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 8594526..7a4634f 100755 --- a/source/bin/nvdct/lib/topologies.py +++ b/source/bin/nvdct/lib/topologies.py @@ -6,11 +6,12 @@ # Author: thl-cmk[at]outlook[dot]com # URL : https://thl-cmk.hopto.org # Date : 2024-06-09 -# File : lib/topologies.py +# File : nvdct/lib/topologies.py # 2024-12-22: refactoring topology creation into classes # made L3 topology IP version independent # 2024-12-25: refactoring, moved function into classes +# 2025-01-22: changed: show interface service in L2 and L3 topology instead of Port/Device from HW/SW inventory from abc import abstractmethod from collections.abc import Mapping, MutableMapping, Sequence, MutableSet @@ -24,12 +25,14 @@ from lib.backends import ( ) from lib.constants import ( CACHE_INTERFACES_DATA, + HostFilter, HostLabels, IPVersion, InvPaths, - LOGGER, L2InvColumns, L3InvColumns, + LOGGER, + LiveStatusOperator, TomlSections, ) from lib.settings import ( @@ -57,10 +60,22 @@ class NvObjects: self, host: str, emblem: str | None = None, - name: str | None = None, + display_name: str | None = None, ) -> None: - if name and host in self.nv_objects: - self.nv_objects[host]['name'] = name + """ + Adds a host object to the topology. + - if there is no display name, "host" will be used as display_name + - the emblem for the host will only be used if the host is not a Checkmk host object + - if there is no "emblem" the Checkmk will use its default emblem for missing objects (white question mark on blue ground) + Args: + host: the name of the host + emblem: the emblem for the object + display_name: the name for the host to show in the topology + Returns: + None + """ + if display_name and host in self.nv_objects: + self.nv_objects[host]['name'] = display_name if host not in self.nv_objects: self.host_count += 1 @@ -69,7 +84,7 @@ class NvObjects: metadata: Dict = {} # LOGGER.debug(f'host: {host}, {host_cache.host_exists(host=host)}') if self.host_cache.host_exists(host=host) is True: - LOGGER.debug(f'host: {host} exists') + LOGGER.debug(f'host exists : {host}') link = {'core': host} else: if emblem is not None: @@ -84,7 +99,7 @@ class NvObjects: } self.nv_objects[host] = { - 'name': name if name is not None else host, + 'name': display_name if display_name is not None else host, 'link': link, 'metadata': metadata, } @@ -96,12 +111,29 @@ class NvObjects: service: str, emblem: str | None = None, metadata: Dict | None = None, - name: str | None = None, + display_name: str | None = None, ) -> None: + """ + Adds a service object to the topology. + - the service object will be added as "service@host" to the topology + - service should be the complete service name as in Checkmk, i.e. "Interface X440G2-48p-10G4 Port 52" + - if the host is a Checkmk host object it will be linked to the core + - if there is no display name, "service" will be used as display_name + - the emblem for the service will only be used if the host is not a Checkmk host object + - if there is no "emblem" the Checkmk will use its default emblem for missing objects (white question mark on blue ground) + Args: + host: the name of the host + service: the name of the service + emblem: the emblem for the object + metadata: additional data for the service + display_name: the name for the host to show in the topology + Returns: + None + """ if metadata is None: metadata = {} - if name is None: - name = service + if display_name is None: + display_name = service self.add_host(host=host) service_object = f'{service}@{host}' @@ -119,7 +151,7 @@ class NvObjects: }) self.nv_objects[service_object] = { - 'name': name, + 'name': display_name, 'link': link, 'metadata': metadata, } @@ -133,12 +165,32 @@ class NvObjects: item: str | None, emblem: str | None = None, metadata: Dict | None = None, - name: str | None = None, + display_name: str | None = None, ) -> None: + """ + Adds an interface service object to the topology. + - the interface service object will be added as "service@host" to the topology + - service ist the interface name from the H/W-inventory + - "item" should be the interface item as in Checkmk, i.e. "X440G2-48p-10G4 Port 52" + - if there is no "item" service will be used as "item" + - if the host is a Checkmk host object it will be linked to the core + - if there is no display name, "service" will be used as display_name + - the "emblem" for the service will only be used if the host is not a Checkmk host object + - if there is no "emblem" the Checkmk will use its default emblem for missing objects (white question mark on blue ground) + Args: + host: the name of the host + service: the name of the service + item: the interface name as in Checkmk + emblem: the emblem for the object + metadata: additional data for the service + display_name: the name for the host to show in the topology + Returns: + None + """ if metadata is None: metadata = {} - if name is None: - name = service + if display_name is None: + display_name = service speed = None self.add_host(host=host) @@ -167,7 +219,7 @@ class NvObjects: metadata.update({'native_speed': speed}) self.nv_objects[service_object] = { - 'name': name, + 'name': display_name, 'link': link, 'metadata': metadata, } @@ -181,6 +233,19 @@ class NvObjects: emblem: str, interface: str | None, ) -> None: + """ + Adds an ip address object to the topology. + - the ip address object will be added as "raw_ip_address@interface@host" to the topology + - if interface is None (--l2-skip-if) the object is added as "raw_ip_address@host" + Args: + host: the name of the Checkmk host + raw_ip_address: the ip address as string (i.e. "10.10.10.10") + emblem: the emblem for the object + interface: the interface of the host where the ip address belongs to + Returns: + None + """ + if interface is not None: service_object = f'{raw_ip_address}@{interface}@{host}' else: @@ -251,7 +316,20 @@ class NvObjects: return operational_data return None - def add_ip_network(self, network: str, emblem: str, ) -> None: + def add_ip_network( + self, + network: str, + emblem: str, + ) -> None: + """ + Adds an ip network object to the topology. + - the ip network object will be added as "network" to the topology + Args: + network: the network as string (i.e. "10.10.10.0/24") + emblem: the emblem for the object + Returns: + None + """ if network not in self.nv_objects: self.nv_objects[network] = { 'name': network, @@ -269,7 +347,7 @@ class NvObjects: } } - def add_tooltip_quickinfo(self, nv_object: str, name: str, value: str) -> Dict: + def add_tooltip_quickinfo(self, nv_object: str, display_name: str, value: str) -> Dict: metadata = self.nv_objects[nv_object]['metadata'] if metadata.get('tooltip') is None: metadata['tooltip'] = {} @@ -277,7 +355,7 @@ class NvObjects: metadata['tooltip']['quickinfo'] = [] metadata['tooltip']['quickinfo'].append({ - 'name': name, + 'name': display_name, 'value': value, }) @@ -343,8 +421,8 @@ class NvConnections: # metadata = add_tooltip_quickinfo(metadata, right, right_speed_str) LOGGER.warning( - f'Connection speed mismatch: {left} (speed: {left_speed_str})' - f'<->{right} (speed: {right_speed_str})' + f'Connection speed mismatch: {left} ({left_speed_str})' + f'<->{right} ({right_speed_str})' ) # for duplex/native vlan it might be a good idea to change left/right @@ -358,8 +436,8 @@ class NvConnections: ) LOGGER.warning( - f'Connection duplex mismatch: {left} (duplex: {left_duplex})' - f'<->{right} (duplex: {right_duplex})' + f'Connection duplex mismatch: {left} ({left_duplex})' + f'<->{right} ({right_duplex})' ) if left_native_vlan and right_native_vlan: if left_native_vlan != '0' and right_native_vlan != '0': # ignore VLAN 0 (Native VLAN on routed ports) @@ -372,7 +450,7 @@ class NvConnections: LOGGER.warning( f'Connection native vlan mismatch: ' - f'{left} (vlan: {left_native_vlan})<->{right} (vlan: {right_native_vlan})' + f'{left} ({left_native_vlan})<->{right} ({right_native_vlan})' ) if warning: metadata['line_config'].update({ @@ -452,9 +530,9 @@ class Topology: # 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() + interface_entry.get('name', '').strip(), + interface_entry.get('description','').strip(), + interface_entry.get('alias', '').strip() ] for value in values: if value in services: @@ -465,13 +543,13 @@ class Topology: # 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}|') + 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}|') + LOGGER.info(f'{self.topology} match found by description-index |{interface_entry}| <-> |{description_index}|') return description_index # for index try with padding @@ -489,7 +567,7 @@ class Topology: if f'{value} {index_padded}' in services: return f'{value} {index_padded}' - LOGGER.warning(f'{self.topology} no match found |{interface_entry}| <-> |{services}|') + LOGGER.warning(f'{self.topology} no match found |{interface_entry} | <-> |{services}|') return None # empty host/neighbour should never happen here @@ -534,7 +612,7 @@ class Topology: 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') + LOGGER.warning(f'{self.topology} Service for interface not found: {host}, |{interface}|') class TopologyStatic(Topology): @@ -553,7 +631,7 @@ class TopologyStatic(Topology): def create(self): for connection in self.connections: - LOGGER.debug(msg=f'{self.topology} connection from {TomlSections.STATIC_CONNECTIONS}: {connection}') + LOGGER.debug(f'{self.topology} connection from {TomlSections.STATIC_CONNECTIONS}: {connection}') self.nv_objects.add_host( host=connection.right_host, emblem=self.emblems.host_node @@ -609,29 +687,33 @@ class TopologyStatic(Topology): class TopologyL2(Topology): def __init__( self, + display_neighbours: bool, + display_ports: bool, + drop_neighbours: List[str], emblems: Emblems, host_cache: HostCache, - l2_drop_neighbours: List[str], - l2_neighbour_replace_regex: List[Tuple[str, str]], label: str, + neighbour_replace_regex: List[Tuple[str, str]], path_in_inventory: str, seed_devices: Sequence[str], - display_l2_neighbours: bool, + skip_external: bool, ): super().__init__( emblems=emblems, host_cache=host_cache, topology = f'[L2 {label}]', ) - self.l2_drop_neighbours: List[str] = l2_drop_neighbours + self.display_neighbours: bool = display_neighbours + self.display_ports: bool = display_ports + self.drop_neighbours: List[str] = drop_neighbours + self.hosts_done: MutableSet[str] = set() + self.hosts_to_go: MutableSet[str] = set(seed_devices) self.label: str = label + self.neighbour_replace_regex: List[Tuple[str, str]] = neighbour_replace_regex self.neighbour_to_host: MutableMapping[str, str] = {} self.path_in_inventory: str = path_in_inventory - 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 + self.skip_external: bool = skip_external def create(self): if not self.hosts_to_go: @@ -641,6 +723,9 @@ class TopologyL2(Topology): while self.hosts_to_go: host = self.hosts_to_go.pop() self.hosts_done.add(host) + if not self.host_cache.is_host_allowed(host): + LOGGER.info(f'{self.topology} host dropped by filter: {host}') + continue topo_data: Sequence[Mapping[str, str]] = self.host_cache.get_data( host=host, item=CacheItems.inventory, path=self.path_in_inventory @@ -651,7 +736,7 @@ class TopologyL2(Topology): inv_data=topo_data, ) - LOGGER.info(msg=f'{self.topology} host done : {host}') + LOGGER.info(f'{self.topology} host done: {host}') def host_from_inventory( self, @@ -661,48 +746,62 @@ class TopologyL2(Topology): for topo_neighbour in inv_data: # check if required data are not empty 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(L2InvColumns.LOCALPORT)): - LOGGER.warning(f'{self.topology} incomplete data: local port missing {topo_neighbour}') - continue - if not (raw_neighbour_port := topo_neighbour.get(L2InvColumns.NEIGHBOURPORT)): - LOGGER.warning(f'{self.topology} incomplete data: neighbour port missing {topo_neighbour}') + LOGGER.warning(f'{self.topology} incomplete data neighbour missing: {topo_neighbour}') continue + # stop here if neighbour is dropped anyway... 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 + 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(L2InvColumns.NEIGHBOURPORT)): + LOGGER.warning(f'{self.topology} incomplete data neighbour port missing: {topo_neighbour}') + continue - # getting/checking interfaces - local_port = self.get_service_by_interface(host, raw_local_port) - if not local_port: + # get local interface service + if not (local_port := self.get_service_by_interface(host, raw_local_port)): local_port = raw_local_port - LOGGER.warning(msg=f'{self.topology} service not found for local_port: {host}, {raw_local_port}') + LOGGER.warning(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'{self.topology} map raw_local_port -> local_port: {host}, {raw_local_port} -> {local_port}' + f'{self.topology} map raw_local_port -> local_port: {host}, {raw_local_port} -> {local_port}' ) - 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'{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'{self.topology} map raw_neighbour_port -> neighbour_port: {neighbour_host}, {raw_neighbour_port} ' - f'-> {neighbour_port}' - ) + if neighbour_host := self.host_cache.get_host_from_neighbour( + neighbour=neighbour, + neighbour_id=topo_neighbour.get(L2InvColumns.NEIGHBOURID), + raw_neighbour=raw_neighbour, + ): + if not self.host_cache.is_host_allowed(neighbour_host): + LOGGER.info(f'{self.topology} neighbour dropped by include/exclude filter: {neighbour_host}') + continue + if neighbour_host not in self.hosts_done: + self.hosts_to_go.add(neighbour_host) + + if not (neighbour_port := self.get_service_by_interface(neighbour_host, raw_neighbour_port)): + neighbour_port = raw_neighbour_port + LOGGER.warning( + 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( + f'{self.topology} map raw_neighbour_port -> ' + f'neighbour_port: {neighbour_host}, {raw_neighbour_port} -> {neighbour_port}' + ) + neighbour_name = raw_neighbour if self.display_neighbours else None + else: + # neighbour is external to cmk, use neighbour id if available as neighbour + if self.skip_external: + continue + if not (neighbour_host := topo_neighbour.get(L2InvColumns.NEIGHBOURID)): + neighbour_host = neighbour + neighbour_port = raw_neighbour_port + neighbour_name = raw_neighbour if self.display_neighbours else neighbour metadata = { 'duplex': topo_neighbour.get('duplex'), 'native_vlan': topo_neighbour.get('native_vlan'), @@ -714,21 +813,21 @@ class TopologyL2(Topology): ) self.nv_objects.add_host( host=neighbour_host, - name=raw_neighbour if self.display_l2_neighbours else None, + display_name=neighbour_name, emblem=self.emblems.host_node, ) self.nv_objects.add_interface( host=str(host), service=str(local_port), metadata=metadata, - name=str(raw_local_port), + display_name=str(raw_local_port) if self.display_ports else str(local_port), item=str(local_port), emblem=self.emblems.service_node, ) self.nv_objects.add_interface( host=str(neighbour_host), service=str(neighbour_port), - name=str(raw_neighbour_port), + display_name=str(raw_neighbour_port) if self.display_ports else str(neighbour_port), item=str(neighbour_port), emblem=self.emblems.service_node, ) @@ -760,22 +859,28 @@ class TopologyL2(Topology): except KeyError: pass - if raw_neighbour in self.l2_drop_neighbours: - LOGGER.info(msg=f'{self.topology} drop in {TomlSections.L2_DROP_NEIGHBOURS}: {raw_neighbour}') + if raw_neighbour in self.drop_neighbours: + LOGGER.info(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: + if self.neighbour_replace_regex: + for re_str, replace_str in self.neighbour_replace_regex: 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}|)') + LOGGER.info( + f'{self.topology} removed by {TomlSections.L2_NEIGHBOUR_REPLACE_REGEX}: ' + f'(|{adjusted_neighbour}|, |{re_str}|, |{replace_str}|)' + ) self.neighbour_to_host[raw_neighbour] = None return None if re_neighbour != adjusted_neighbour: - LOGGER.info(f'{self.topology} changed by {TomlSections.L2_NEIGHBOUR_REPLACE_REGEX} |{adjusted_neighbour}| to |{re_neighbour}|') + LOGGER.info( + f'{self.topology} changed by {TomlSections.L2_NEIGHBOUR_REPLACE_REGEX}: ' + f'|{adjusted_neighbour}| to |{re_neighbour}|' + ) adjusted_neighbour = re_neighbour self.neighbour_to_host[raw_neighbour] = adjusted_neighbour @@ -785,6 +890,7 @@ class TopologyL2(Topology): class TopologyL3(Topology): def __init__( self, + display_devices: bool, emblems: Emblems, host_cache: HostCache, ignore_hosts: Sequence[str], @@ -806,42 +912,58 @@ class TopologyL3(Topology): host_cache=host_cache, topology=f'[L3 IPv{version}]' ) + self.diplay_devices = display_devices 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.show_loopback: bool = include_loopback 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(HostLabels.L3V4_ROUTER) + host_list: Sequence[str] = ( + self.host_cache.query_hosts_by_filter( + HostFilter.LABELS, HostLabels.L3V4_ROUTER, LiveStatusOperator.EQUAL + ) + ) if self.include_hosts: - host_list += self.host_cache.get_hosts_by_label(HostLabels.L3V4_HOSTS) + host_list += self.host_cache.query_hosts_by_filter( + HostFilter.LABELS, HostLabels.L3V4_HOSTS, LiveStatusOperator.EQUAL + ) case IPVersion.IPv6: - host_list: Sequence[str] = self.host_cache.get_hosts_by_label(HostLabels.L3V6_ROUTER) + host_list: Sequence[str] = self.host_cache.query_hosts_by_filter( + HostFilter.LABELS, HostLabels.L3V6_ROUTER, LiveStatusOperator.EQUAL + ) if self.include_hosts: - host_list += self.host_cache.get_hosts_by_label(HostLabels.L3V6_HOSTS) + host_list += self.host_cache.query_hosts_by_filter( + HostFilter.LABELS, HostLabels.L3V6_HOSTS, LiveStatusOperator.EQUAL + ) case _: host_list = [] - LOGGER.debug(f'{self.topology} host to work on: {host_list}') + # check against filter list (host labels/attributes include/exclude) + pre_filter_len = len(host_list) + host_list = [host for host in host_list if self.host_cache.is_host_allowed(host)] + LOGGER.info(f'{self.topology} # hosts allowed: {len(host_list)}/{pre_filter_len}') + + LOGGER.debug(f'{self.topology} host(s) to work on: {host_list}') if not host_list: 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.' + 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 @@ -898,7 +1020,7 @@ class TopologyL3(Topology): continue 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}') + LOGGER.info(f'{self.topology} drop IP in {TomlSections.L3_IGNORE_IP}: {host}, {interface_address.compressed}') continue if self.is_ignore_wildcard(interface_address.ip.compressed): @@ -914,7 +1036,7 @@ class TopologyL3(Topology): network = f'{interface_address.network.compressed}' if network in self.replace.keys(): - LOGGER.info(f'{self.topology} Replaced network in {TomlSections.L3_REPLACE}: {network} -> {self.replace[network]}') + LOGGER.info(f'{self.topology} Replaced network in {TomlSections.L3_REPLACE_NETWORKS}: {network} -> {self.replace[network]}') network = self.replace[network] emblem = self.emblems.l3_replace @@ -939,7 +1061,12 @@ class TopologyL3(Topology): 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=device, item=item) + self.nv_objects.add_interface( + display_name=device if self.diplay_devices else item, + host=host, + item=item, + service=device, + ) self.nv_objects.add_tooltip_quickinfo( f'{device}@{host}', 'IP-address', interface_address.ip.compressed ) @@ -952,7 +1079,12 @@ class TopologyL3(Topology): raw_ip_address=interface_address.ip.compressed, emblem=self.emblems.ip_address, ) - self.nv_objects.add_interface(host=host, service=device, item=item) + self.nv_objects.add_interface( + display_name=device if self.diplay_devices else item, + host=host, + item=item, + service=device, + ) self.nv_connections.add_connection( left=host, right=f'{device}@{host}') self.nv_connections.add_connection( @@ -967,7 +1099,7 @@ class TopologyL3(Topology): 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})') + LOGGER.debug(f'{self.topology} IP address is in subnet: {raw_ip_address} -> ({network})') return network.compressed except TypeError: pass @@ -977,7 +1109,7 @@ class TopologyL3(Topology): 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})') + LOGGER.debug(f'{self.topology} IP address is in ignore list: {raw_ip_address} -> ({ip})') return True except TypeError: continue @@ -988,8 +1120,8 @@ class TopologyL3(Topology): 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})' + f'{self.topology} IP address matches ignore wildcard list: ' + f'{raw_ip_address} -> ({wildcard.ip_address}/{wildcard.wildcard})' ) return True return False diff --git a/source/bin/nvdct/lib/utils.py b/source/bin/nvdct/lib/utils.py index 9d15149..4eb62ee 100755 --- a/source/bin/nvdct/lib/utils.py +++ b/source/bin/nvdct/lib/utils.py @@ -13,7 +13,7 @@ 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, findall as re_findall, sub as re_sub +from re import match as re_match 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 @@ -21,18 +21,12 @@ from tomllib import loads as toml_loads, TOMLDecodeError from typing import Dict, List, TextIO from lib.constants import ( - Backends, CMK_SITE_CONF, - Case, + CONFIG_FILE, DATAPATH, - EmblemValues, - EmblemNames, ExitCodes, LOGGER, - LogLevels, OMD_ROOT, - TomlSections, - TomlSettings, ) @@ -64,7 +58,7 @@ def get_data_form_live_status(query: str) -> Dict | List | None: return None -def get_data_from_toml(file: str) -> Dict: +def get_data_from_toml(file: str, check_only:bool = False) -> Dict| bool: data = {} toml_file = Path(file) if toml_file.exists(): @@ -72,22 +66,28 @@ def get_data_from_toml(file: str) -> Dict: data = toml_loads(toml_file.read_text()) except TOMLDecodeError as e: LOGGER.exception( - msg=f'ERROR: data file {toml_file} is not in valid TOML format! ({e}),' - f' (see https://toml.io/en/)' + f'Config file {toml_file} is not in valid TOML format! ({e}),' + f' (see https://toml.io/en/)' ) sys_exit(ExitCodes.BAD_TOML_FORMAT) - + if check_only: + message: str = f'Could read/parse the data from {toml_file}' + # will not be logged as logger si not initialized yet + # LOGGER.info(message) + print(message) + sys_exit(ExitCodes.OK) else: - LOGGER.error(msg=f'WARNING: User data {file} not found.') - LOGGER.info(msg=f'TOML file read: {file}') - LOGGER.debug(msg=f'Data from toml file: {data}') + LOGGER.error(f'\nConfig {file} not found. Falling back to default config file "{CONFIG_FILE}"') + # will not be logged as logger ist not initialized. + # LOGGER.info(f'Config file read: {file}') + # LOGGER.debug(f'Data from config file: {data}') return data def rm_tree(root: Path) -> None: # safety if not str(root).startswith(DATAPATH): - LOGGER.warning(msg=f"WARNING: bad path to remove, {str(root)}, don\'t delete it.") + LOGGER.warning(f"Bad path to remove, {str(root)}, don\'t delete it.") return for p in root.iterdir(): if p.is_dir(): @@ -98,6 +98,8 @@ def rm_tree(root: Path) -> None: def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[str]) -> None: + if not keep: + return path: Path = Path(raw_path) default_topo = path.joinpath('default') directories = [str(directory) for directory in list(path.iterdir())] @@ -119,7 +121,7 @@ def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[ except ValueError: pass else: - LOGGER.info(msg=f'Protected topology: {directory}, will not be deleted.') + LOGGER.info(f'Protected topology: {directory}, will not be deleted.') if len(directories) < keep < 1: return @@ -137,8 +139,8 @@ def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[ entry = topo_age.pop() if min_age * 86400 > now_time() - entry: LOGGER.info( - msg=f'Topology "{Path(topo_by_age[entry]).name}' - f'" not older then {min_age} day(s). not deleted.' + 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[entry]}') @@ -182,26 +184,6 @@ def save_data_to_file( Path(f'{DATAPATH}/default').symlink_to(target=Path(path), target_is_directory=True) -def is_mac_address(mac_address: str) -> bool: - """ - Checks if mac_address is a valid MAC address. - Will only accept MAC address in the form "AA:BB:CC:DD:EE:FF" (lower case is also ok). - Args: - mac_address: the MAC address to check - - Returns: - True if mac_address is a valid MAC address - False if mac_address not a valid MAC address - """ - re_mac_pattern = '([0-9A-Z]{2}\\:){5}[0-9A-Z]{2}' - if re_match(re_mac_pattern, mac_address.upper()): - LOGGER.debug(msg=f'mac: {mac_address}, match') - return True - else: - LOGGER.debug(msg=f'mac: {mac_address}, no match') - return False - - def is_list_of_str_equal(list1: List[str], list2: List[str]) -> bool: """ Compares two list of strings. Before comparing the list will internal sorted. @@ -228,7 +210,7 @@ def is_valid_hostname(host: str) -> bool: if re_match(re_host_pattern, host): return True else: - LOGGER.error(f'Invalid hostname found: {host}') + LOGGER.error(f'Invalid hostname found: |{host}|') return False @@ -237,7 +219,7 @@ def is_valid_site_name(site: str) -> bool: if re_match(re_host_pattern, site): return True else: - LOGGER.error(f'Invalid site name found: {site}') + LOGGER.error(f'Invalid site name found: |{site}|') return False @@ -246,7 +228,7 @@ def is_valid_customer_name(customer: str) -> bool: if re_match(re_host_pattern, customer): return True else: - LOGGER.error(f'Invalid customer name found: {customer}') + LOGGER.error(f'Invalid customer name found: |{customer}|') return False @@ -255,18 +237,12 @@ def is_valid_output_directory(directory: str) -> bool: re_host_pattern = r'^[0-9a-z-A-Z\.\-\_\:]{1,30}$' if re_match(re_host_pattern, directory): return True + return True else: LOGGER.error(f'Invalid output directory name found: {directory}') return False -def is_valid_log_file(log_file: str) -> bool: - if not log_file.startswith(f'{OMD_ROOT}/var/log/'): - LOGGER.error(f'Logg file needs to be under "{OMD_ROOT}/var/log/"! Got {Path(log_file).absolute()}') - return False - return True - - 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())): @@ -317,24 +293,15 @@ def is_equal_with_default(data: Mapping, file: str) -> bool: 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_data_from_inventory(inventory: Dict[str, object], raw_path: str) -> List | Dict | None: + # path: List[str] = ('Nodes,' + ',Nodes,'.join(raw_path.split(',')) + ',Table,Rows').split(',') + split_path: List[str] = raw_path.split(',') + path: MutableSequence[str] = [] + for entry in split_path[0:-2]: + path += ['Nodes', entry] + path += split_path[-2:] -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(',') try: table = inventory.copy() except AttributeError: @@ -343,9 +310,10 @@ def get_table_from_inventory(inventory: Dict[str, object], raw_path: str) -> Lis try: table = table[m] except KeyError: - LOGGER.info(msg=f'Inventory table for {path} not found') + LOGGER.info(f'Inventory table not found for: {path}') return None - return list(table) + + return table def configure_logger(log_file: str, log_level: int, log_to_console: bool) -> None: @@ -396,66 +364,3 @@ 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 8d93b27..48d4716 100755 --- a/source/bin/nvdct/nvdct.py +++ b/source/bin/nvdct/nvdct.py @@ -6,7 +6,7 @@ # Author: thl-cmk[at]outlook[dot]com # URL : https://thl-cmk.hopto.org # Date : 2023-10-08 -# File : nvdct_data.py +# File : nvdct/nvdct.py # 2023-10-10: initial release # 2023-10-16: added options --keep-max and --min-age @@ -171,8 +171,56 @@ # fixed: cleanup -> remove the oldest topologies not the newest # INCOMPATIBLE: removed: CUSTOM_LAYERS # refactoring constants -# - +# 2024-12-30: added support for lldp device id/name and cdp name from global device data to map L2 neighbour to CMK host +# 2025-01-01: added support for filter by host label/tag +# INCOMPATIBLE: +# cli options: +# changed: +# "--case" to "--l2-case" +# "--display-l2-neighbours" to "--l2-display-neighbours" +# "--include-l3-hosts" to "--l3-include-hosts" +# "--include-l3-loopback" to "--l3-include-loopback" +# "--keep" to "--keep-max-topologies" +# "--min-age" to "--min-topology-age" +# "--prefix" to "--l2-prefix" +# "--remove-domain" to "--l2-remove-domain" +# "--skip-l3-cidr-0" to "--l3-skip-cidr-0" +# "--skip-l3-cidr-32-128" to "--l3-skip-cidr-32-128" +# "--skip-l3-if" to "--l3-skip-if" +# "--skip-l3-ip" to "--l3-skip-ip" +# "--skip-l3-public" to "--l3-skip-public" +# "--check-user-data-only" ot "--check-config" +# "-u"/"--user-data-file" to "-c"/"-config" +# removed "-p" use "--l2-prefix" instead +# TOML file: +# changed: +# "L2_HOST_MAP" to "L2_NEIGHBOUR_TO_HOST_MAP" +# "L3_REPLACE" to "L3_REPLACE_NETWORKS" +# "case" to "l2_case" +# "display_l2_neighbours" to "l2_display_neighbours" +# "include_l3_hosts" to "l3_include_hosts" +# "include_l3_loopback" to "l3_include_loopback" +# "keep" to "keep_max_topologies" +# "min-age" to "min_topology_age" +# "prefix" to "l2_prefix" +# "remove_domain" to "l2_remove_domain" +# "skip_l3_cidr_0" to "l3_skip_cidr_0" +# "skip_l3_cidr_32_128" to "l3_skip_cidr_32_128" +# "skip_l3_if" to "l3_skip_if" +# "skip_l3_ip" to "l3_skip_ip" +# "skip_l3_public" to "l3_skip_public" +# 2025-01-05: added "AUTO" to --l2-case parameters +# INCOMPATIBLE: changed --l2-remove-domain from bool to "OFF" | "ON" | "AUTO" +# added support for filter by folder +# 2024-01-06: added option "--l2-skip-external" +# 2024-01-07: added option "INSENSITIVE" to --l2-case +# 2025-01-11: INCOMPATIBLE: changed "--adjust-toml" -> "--update-config" +# 2025-01-18: INCOMPATIBLE: changed "CUSTOMERS = []" -> "FILTER_BY_CUSTOMER = []" +# "SITES = []" -> "FILTER_BY_SITE = []" +# 2025-01-21: added support for Post requests (Werk #17003) +# fixed REST API query for interface services +# 2025-01-24: added option --l2-display-ports, --l3-display-devices +# 2025-02-05: added option "OFF" to --l2-case # # creating topology data json from inventory data # @@ -181,12 +229,12 @@ # https://forum.checkmk.com/t/network-visualization/41680 (from 2023-10-05) # https://exchange.checkmk.com/p/network-visualization (from 2023-10-05) # -# NOTE: the topology_data configuration (layout etc.) is saved under ~/var/check_mk/topology +# NOTE: the topology_data configuration (layout etc.) is saved under ~/var/check_mk/topology/ # # The inventory data could be created with my CDP/LLDP/IP Address/Interface nane inventory plugins: # CDP.....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache # LLDP....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cache -# L3v4....: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_addresses +# L3......: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_addresses # : https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lnx_if_ip # : https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_win_if_ip # IF Name.: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ifname @@ -277,6 +325,8 @@ from time import strftime, time_ns from typing import List from lib.args import parse_arguments + +from lib.update_config import update_config from lib.backends import ( HostCache, HostCacheLiveStatus, @@ -286,15 +336,18 @@ from lib.backends import ( from lib.constants import ( Backends, DATAPATH, - URLs, + ExitCodes, + HostFilter, HostLabels, IPVersion, InvPaths, LOGGER, Layers, + LiveStatusOperator, NVDCT_VERSION, TomlSections, TomlSettings, + URLs, ) from lib.settings import Settings from lib.topologies import ( @@ -303,9 +356,7 @@ from lib.topologies import ( TopologyStatic, ) from lib.utils import ( - ExitCodes, StdoutQuiet, - adjust_toml, configure_logger, remove_old_data, ) @@ -323,7 +374,7 @@ def main(): log_level=settings.loglevel, ) # always logg start and end of a session (except --log-level OFF) - LOGGER.critical(msg='Data creation started') + LOGGER.critical('Data creation started') print('') print( @@ -334,8 +385,8 @@ def main(): print('') print(f'Start time....: {strftime(settings.time_format)}') - if settings.fix_toml: - adjust_toml(settings.user_data_file) + if settings.update_config: + update_config(settings.config) print(f'Time taken....: {(time_ns() - start_time) / 1e9}/s') print(f'End time......: {strftime(settings.time_format)}') print('') @@ -344,37 +395,30 @@ def main(): sys.exit() match settings.backend: + case Backends.MULTISITE: + host_cache: HostCache = HostCacheMultiSite( + customers=settings.filter_by_customer, + filter_customers=settings.filter_customers, + filter_sites=settings.filter_sites, + pre_fetch=settings.pre_fetch, + sites=settings.filter_by_site, + ) 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, - ) - case Backends.MULTISITE: - host_cache: HostCache = HostCacheMultiSite( pre_fetch=settings.pre_fetch, - filter_sites=settings.filter_sites, - sites=settings.sites, - filter_customers=settings.filter_customers, - customers=settings.customers, + sites=settings.filter_by_site, ) case Backends.LIVESTATUS: host_cache: HostCache = HostCacheLiveStatus( pre_fetch=settings.pre_fetch, ) case _: - LOGGER.error(msg=f'Backend {settings.backend} not implemented') + LOGGER.error(f'Backend {settings.backend} not implemented') host_cache: HostCache | None = None # to keep linter happy sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED) - 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] = [] @@ -390,6 +434,7 @@ def main(): case Layers.CDP | Layers.LLDP: jobs.append(layer) host_cache.add_inventory_path(InvPaths.CDP if layer == Layers.CDP else InvPaths.LLDP) + host_cache.add_inventory_path(InvPaths.CDP_GLOBAL if layer == Layers.CDP else InvPaths.LLDP_GLOBAL) pre_fetch_layers.append(HostLabels.CDP if layer == Layers.CDP else HostLabels.LLDP) case _: LOGGER.warning(f'Unknown layer {layer} dropped.') @@ -398,16 +443,25 @@ def main(): if not jobs: 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}' + f'See {settings.config} -> {TomlSections.SETTINGS} -> {TomlSettings.LAYERS}' ) LOGGER.warning(message) print(message) sys.exit(ExitCodes.NO_LAYER_CONFIGURED) + # init filter lists before pre-fetch + host_cache.init_filter_lists( + filter_by_folder=settings.filter_by_folder, + filter_by_host_label=settings.filter_by_host_label, + filter_by_host_tag=settings.filter_by_host_tag, + ) + if settings.pre_fetch: LOGGER.info('Pre fill cache...') for host_label in pre_fetch_layers: - if host_list := host_cache.get_hosts_by_label(host_label): + if host_list := host_cache.filter_host_list( + host_cache.query_hosts_by_filter(HostFilter.LABELS, host_label, LiveStatusOperator.EQUAL) + ): 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)}') @@ -416,6 +470,14 @@ def main(): LOGGER.info(f'Fetching data for {len(pre_fetch_host_list)} hosts end') print(f'Prefetch end..: {strftime(settings.time_format)}') + # must not be used before pre-fetch is done + host_cache.init_neighbour_to_host_map( + case=settings.l2_case, + l2_host_map=settings.l2_neighbour_to_host_map, + prefix=settings.l2_prefix, + remove_domain=settings.l2_remove_domain, + ) + for job in jobs: match job: case Layers.STATIC: @@ -428,21 +490,22 @@ def main(): case Layers.L3V4: label = job topology = TopologyL3( + display_devices=settings.l3_display_devices, emblems=settings.emblems, host_cache=host_cache, ignore_hosts=settings.l3_ignore_hosts, ignore_ips=settings.l3_ignore_ips, ignore_wildcard=settings.l3v4_ignore_wildcard, - include_hosts=settings.include_l3_hosts, - replace=settings.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, + include_hosts=settings.l3_include_hosts, + include_loopback=settings.l3_include_loopback, + replace=settings.l3_replace_networks, + skip_cidr_0=settings.l3_skip_cidr_0, + skip_cidr_32_128=settings.l3_skip_cidr_32_128, + skip_if=settings.l3_skip_if, + skip_ip=settings.l3_skip_ip, + skip_public=settings.l3_skip_public, summarize=settings.l3_summarize, - version=IPVersion.IPv4 if job == Layers.L3V4 else IPVersion.IPv6 + version=IPVersion.IPv4 if job == Layers.L3V4 else IPVersion.IPv6, ) case Layers.CDP | Layers.LLDP: label = job @@ -453,16 +516,20 @@ def main(): 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) + seed_devices = host_cache.query_hosts_by_filter( + HostFilter.LABELS, host_label, LiveStatusOperator.EQUAL + ) topology = TopologyL2( + display_neighbours=settings.l2_display_neighbours, + display_ports = settings.l2_display_ports, + drop_neighbours=settings.l2_drop_neighbours, emblems=settings.emblems, host_cache=host_cache, - l2_drop_neighbours=settings.l2_drop_neighbours, - l2_neighbour_replace_regex=settings.l2_neighbour_replace_regex, label=label, + neighbour_replace_regex=settings.l2_neighbour_replace_regex, path_in_inventory=inv_path, seed_devices=seed_devices, - display_l2_neighbours=settings.display_l2_neighbours, + skip_external=settings.l2_skip_external, ) case _: LOGGER.warning(f'Unknown layer {job}, ignoring.') @@ -487,13 +554,13 @@ def main(): 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=f'{pre_message} {message}') + LOGGER.info(f'{pre_message} {message}') print(message) - if settings.keep: + if settings.keep_max_topologies: remove_old_data( - keep=settings.keep, - min_age=settings.min_age, + keep=settings.keep_max_topologies, + min_age=settings.min_topology_age, raw_path=DATAPATH, protected=settings.protected_topologies, ) diff --git a/source/packages/nvdct b/source/packages/nvdct index 32ad046..d277e55 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.7-20241230', + 'version': '0.9.8-20250205', 'version.min_required': '2.3.0b1', 'version.packaged': 'cmk-mkp-tool 0.2.0', 'version.usable_until': '2.4.0p1'} -- GitLab