Collection of CheckMK checks (see https://checkmk.com/). All checks and plugins are provided as is. Absolutely no warranty. Send any comments to thl-cmk[at]outlook[dot]com

Skip to content
Snippets Groups Projects
Commit cf691900 authored by thl-cmk's avatar thl-cmk :flag_na:
Browse files

refactoring/cleanup

  - streamlining topology log messages
  added:
   --adjust-toml
   --display-l2-neighbours
   --include-l3-loopback
   --skip-l3-cidr-0
   --skip-l3-cidr-32-128
   --skip-l3-public
  fixed:
   --dont-compare
   --keep
  changed:
   L2_DROP_HOSTS -> L2_DROP_NEIGHBOURS
  removed:
   CUSTOM_LAYERS
parent 5a642d67
No related branches found
No related tags found
No related merge requests found
[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.6-20241222.mkp "nvdct-0.9.6-20241222.mkp"
[PACKAGE]: ../../raw/master/mkp/nvdct-0.9.7-20241230.mkp "nvdct-0.9.7-20241230.mkp"
# Network Visualization Data Creation Tool (NVDCT)
This script creates the topology data file needed for the [Checkmk Exchange Network visualization](https://exchange.checkmk.com/p/network-visualization) plugin.\
......
File added
......@@ -12,7 +12,7 @@
# contains the user data and settings for nvdct.py
#
# list of (additional to -s/--seed-devices) seed devices
# list of CDP/LLDP seed devices (if empty, all CDP/LLDP devices will be used)
# [0-9-a-zA-Z\.\_\-]{1,253} -> host
L2_SEED_DEVICES = [
# "CORE01",
......@@ -20,8 +20,8 @@ L2_SEED_DEVICES = [
# "LOCATION02",
]
# drop neighbours with invalid names (only L2 Topologies (i.e. CDP, LLDP, CUSTOM)
L2_DROP_HOSTS = [
# drop CDP/LLDP neighbours names
L2_DROP_NEIGHBOURS = [
# "not advertised",
# "a nother invalid name",
]
......@@ -53,7 +53,7 @@ L3V4_IGNORE_WILDCARD = [
# ["172.17.128.3", "0.0.127.0"], # ignore all IPs ending with 3 from 172.17.128.0/17
]
# networks to summarize
# IP _networks_ to summarize
L3_SUMMARIZE = [
# "10.193.172.0/24",
# "10.194.8.0/23",
......@@ -82,17 +82,7 @@ STATIC_CONNECTIONS = [
# connection: "left_host"<->"right_host"
]
# THIS OPTION IS DEPRECATED
# optional custom layers use option -l/--layers CUSTOM to include these layers
# don't use --pre-fetch without a host_label that matches all host you want to add
# THIS OPTION IS DEPRECATED
CUSTOM_LAYERS = [
# { path = "path,in,inventory", columns = "columns from inventory", label = "label for the layer", host_label = "CMK host label to find matching hosts" },
# { path = "networking,lldp_cache,neighbours", columns = "neighbour_name,local_port,neighbour_port", label = "custom_LLDP", host_label = "nvdct/has_lldp_neighbours" },
# { path = "networking,cdp_cache,neighbours", columns = "neighbour_name,local_port,neighbour_port", label = "custom_CDP", host_label = "nvdct/has_cdp_neighbours" },
]
# list customers to include/excluse, use option --filter-costumers INCLUDE/EXCLUDE
# list customers to include/excluse, use with option --filter-costumers INCLUDE/EXCLUDE
# [0-9-a-zA-Z\.\_\-]{1,16} -> customer
CUSTOMERS = [
# "customer1",
......@@ -100,7 +90,7 @@ CUSTOMERS = [
# "customer3",
]
# list site to include/excluse, use option --filter-sites INCLUDE/EXCLUDE
# list site to include/excluse, use with option --filter-sites INCLUDE/EXCLUDE
# [0-9-a-zA-Z\.\_\-]{1,16} -> site
SITES = [
# "site1",
......@@ -108,20 +98,21 @@ SITES = [
# "site3",
]
# map inventory neighbour name to Checkmk host name
# map inventory CDP/LLDP neighbour name to Checkmk host name
# [0-9-a-zA-Z\.\_\-]{1,253} -> host
[L2_HOST_MAP]
# inventory_neighbour1 = "cmk_host1"
# inventory_neighbour2 = "cmk_host2"
# inventory_neighbour3 = "cmk_host3"
# "inventory_neighbour1" = "cmk_host1"
# "inventory_neighbour2" = "cmk_host2"
# "inventory_neighbour3" = "cmk_host3"
# modify CDP/LLDP neighbour name with regex before mapping to CMK host names
[L2_NEIGHBOUR_REPLACE_REGEX]
# "regex string to replace" = "string to replace with"
# "^(([0-9a-fA-F]){2}[:.-]?){5}([0-9a-fA-F]){2}$" = ""
# "\\([0-9a-zA-Z]+\\)$" = ""
# "^Meraki.*\\s-\\s" = ""
# replace network objects (takes place after summarize)
# replace _network objects_ in L§ topologies (takes place after summarize)
# [0-9-a-zA-Z\.\_\-]{1,253} -> host
[L3_REPLACE]
# "10.193.172.0/24" = "MPLS"
......@@ -134,17 +125,18 @@ SITES = [
# can use misc icons from CMK or upload your own in the misc category
# for built-in icons use "icon_" as prefix to the name from CMK
# max size 80x80px
# "host_node" = "icon_missinc"
# emblems will only be used for non CMK objects
# "host_node" = "icon_alert_unreach"
# "ip_address" = "ip-address_80"
# "ip_network" = "ip-network_80"
# "l3_replace" = "icon_plugins_cloud"
# "l3_summarize" = "icon_aggr"
# "service_node" = "icon_missing"
# "service_node" = "icon_alert_unreach"
[MAP_SPEED_TO_THICKNESS]
# must be sorted from slower to faster speed
# use only one entry to have all conections with the same thickness
# bits per second = thickness
# use only one/no entry to have all conections with the same thickness
# "bits per second" = thickness
# "2000000" = 1 # 2 mbit
# "5000000" = 2 # 5 mbit
# "1e7" = 3 # 10 mbit
......@@ -158,10 +150,12 @@ SITES = [
# backend = "MULTISITE" | "RESTAPI" | "LIVESTATUS"
# case = "LOWER" | "UPPER"
# default = false
# display_l2_neighbours = false
# dont_compare = false
# filter_customers = "INCLUDE" |"EXCLUDE"
# filter_sites = "INCLUDE" | "EXCLUDE"
# include_l3_hosts = false
# include_l3_loopback = false # most likely dropped from inventory (SNMP) before
keep = 10
# layers = ["LLDP", "CDP", "L3v4", "STATIC", "CUSTOM"]
# log_file = "~/var/log/nvdct.log"
......@@ -173,6 +167,9 @@ output_directory = 'nvdct' # remove to get date formated directory
# prefix = ""
# quiet = true
# remove_domain = false
# skip_l3_cidr_0 = false
# skip_l3_cidr_32_128 = false
# skip_l3_if = false
# skip_l3_ip = false
# skip_l3_public = false
# time_format = "%Y-%m-%dT%H:%M:%S.%m"
......@@ -11,18 +11,19 @@
# options used
# -b --backend
# -d --default
# -l --layer
# -l --layers
# -o --output-directory
# -p --prefix
# # -s --seed-devices
# -u --user-data-file
# -v --version
# --api-port (deprecated ?)
# --case
# --check-user-data-only
# --display-l2-neighbours
# --dont-compare
# --filter-customers
# --filter-sites
# --fix-toml
# --include-l3-hosts
# --keep
# --log-file
......@@ -45,16 +46,19 @@ from argparse import (
from pathlib import Path
from lib.constants import (
Backends,
Case,
CliLong,
ExitCodes,
HOME_URL,
MIN_CDP_VERSION,
MIN_LINUX_IP_ADDRESSES,
MIN_LLDP_VERSION,
MIN_SNMP_IP_ADDRESSES,
MIN_WINDOWS_IP_ADDRESSES,
IncludeExclude,
Layers,
LogLevels,
MinVersions,
NVDCT_VERSION,
SCRIPT,
TIME_FORMAT_ARGPARSER,
TomlSections,
URLs,
USER_DATA_FILE,
)
......@@ -63,20 +67,15 @@ def parse_arguments() -> arg_Namespace:
parser = ArgumentParser(
prog='nvdct.py',
description=(
'This script creates the topology data file needed for the Checkmk "network_visualization"\n' # noqa: E501
'plugin by Andreas Boesl and schnetz. For more information see\n'
'the announcement from schnetz: '
'https://forum.checkmk.com/t/network-visualization/41680\n'
'and the plugin on the Exchange: '
'https://exchange.checkmk.com/p/network-visualization .\n'
'This script creates the topology data file needed for the Checkmk Network Visualization,\n' # noqa: E501
'For more information see the announcement from schnetz in the Checkmk forum:\n'
f'{URLs.FORUM_SCHNETZ}\n'
'\n'
'The required inventory data can be created with my inventory plugins:\n'
'CDP: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache\n' # noqa: E501
'LLDP: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cache\n' # noqa: E501
'L3v4: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_address\n' # noqa: E501
'The required plugins to create the inventory data can be found here:\n'
f'{URLs.TOPIC_NV}\n'
'\n'
f'\nVersion: {NVDCT_VERSION} | Written by: thl-cmk\n'
f'for more information see: {HOME_URL}'
f'for more information see: {URLs.NVDCT}'
),
formatter_class=RawTextHelpFormatter,
epilog='Exit codes:\n'
......@@ -91,156 +90,180 @@ def parse_arguments() -> arg_Namespace:
)
parser.add_argument(
'-b', '--backend',
choices=['LIVESTATUS', 'MULTISITE', 'RESTAPI'],
'-b', CliLong.BACKEND,
choices=[Backends.LIVESTATUS, Backends.MULTISITE, Backends.RESTAPI],
# default='MULTISITE',
help='Backend used to retrieve the topology data\n'
' - LIVESTATUS : fetches data via local Livestatus (local site only)\n'
' - MULTISITE : like LIVESTATUS but for distributed environments (default)\n'
' - RESTAPI : uses the CMK REST API.',
f' - {Backends.LIVESTATUS} : fetches data via local Livestatus (local site only)\n'
f' - {Backends.MULTISITE} : like LIVESTATUS but for distributed environments (default)\n'
f' - {Backends.RESTAPI} : uses the CMK REST API.',
)
parser.add_argument(
'-d', '--default', action='store_const', const=True, # default=False,
'-d', CliLong.DEFAULT, action='store_const', const=True, # default=False,
help='Set the created topology data as default. Will be created automatically\n'
'if it doesn\'t exists.',
'if it doesnt exists.',
)
parser.add_argument(
'-o', '--output-directory', type=str,
'-o', CliLong.OUTPUT_DIRECTORY, type=str,
help='Directory name where to save the topology data.\n'
'I.e.: my_topology. Default is the actual date/time\n'
'in "--time-format" format.\n'
f'in "{CliLong.TIME_FORMAT}" format.\n'
'NOTE: the directory is a sub directory under "~/var/check_mk/topology/data/"\n',
)
# parser.add_argument(
# '-s', '--seed-devices', type=str, nargs='+',
# help=f'List of devices to start the topology discovery from.\n'
# f'I.e. {SAMPLE_SEEDS}',
# )
parser.add_argument(
'-p', '--prefix', type=str,
help='Prepends each host with the prefix. (Needs testing)\n'
'-p', CliLong.PREFIX, type=str,
help='Prepends each host with the prefix. (Needs more testing)\n'
)
parser.add_argument(
'-l', '--layers',
'-l', CliLong.LAYERS,
nargs='+',
choices=[
'CDP',
'CUSTOM',
'L3v4',
'LLDP',
'STATIC',
Layers.CDP,
Layers.LLDP,
Layers.L3V4,
Layers.STATIC,
],
# default=['CDP'],
help=(
f' - CDP : needs inv_cdp_cache package at least in version {MIN_CDP_VERSION}\n'
f' - LLDP : needs inv_lldp_cache package at least in version {MIN_LLDP_VERSION}\n'
f' - L3v4 : needs inv_ip_address package at least in version {MIN_SNMP_IP_ADDRESSES} for SNMP based hosts\n'
f' for Linux based hosts inv_lnx_ip_if in version {MIN_LINUX_IP_ADDRESSES}\n'
f' for Windows based hosts inv_win_ip_if in version {MIN_WINDOWS_IP_ADDRESSES}\n'
f' - STATIC : creates a topology base on the "STATIC_CONNECTIONS" in the toml file\n'
f' - CUSTOM : (deprecated)\n'
f' - {Layers.CDP} : needs inv_cdp_cache package at least in version {MinVersions.CDP}\n'
f' - {Layers.LLDP} : needs inv_lldp_cache package at least in version {MinVersions.LLDP}\n'
f' - {Layers.L3V4} : needs inv_ip_address package at least in version {MinVersions.SNMP_IP_ADDRESSES} for SNMP based hosts\n'
f' for Linux based hosts inv_lnx_ip_if in version {MinVersions.LINUX_IP_ADDRESSES}\n'
f' for Windows based hosts inv_win_ip_if in version {MinVersions.WINDOWS_IP_ADDRESSES}\n'
f' - {Layers.STATIC} : creates a topology base on the "[{TomlSections.STATIC_CONNECTIONS}]" section in the TOML file\n'
)
)
parser.add_argument(
'-u', '--user-data-file', type=str,
help='Set the name uf the user provided data file\n'
'-u', CliLong.USER_DATA_FILE, type=str,
help='Set the name of the user provided data file\n'
f'Default is ~/local/bin/nvdct/conf/{USER_DATA_FILE}\n',
)
parser.add_argument(
'-v', '--version', action='version',
'-v', CliLong.VERSION, action='version',
version=f'{Path(SCRIPT).name} version: {NVDCT_VERSION}',
help='Print version of this script and exit',
)
parser.add_argument(
'--api-port', type=int, # default=False,
help='TCP Port to access the REST API. Default is 80. NVDCT will try to automatically\n'
CliLong.ADJUST_TOML, action='store_const', const=True, # default=False,
help='Adjusts old options in TOML file.',
)
parser.add_argument(
CliLong.API_PORT, type=int, # default=False,
help='TCP Port to access the REST API. By NVDCT will try to automatically\n'
'detect the site apache port.',
)
parser.add_argument(
'--case',
choices=['LOWER', 'UPPER'],
CliLong.CASE,
choices=[Case.LOWER, Case.UPPER],
# default='NONE',
help='Change neighbour name to all lower/upper case',
help='Change L2 neighbour name to all lower/upper case before matching to CMK host',
)
parser.add_argument(
'--check-user-data-only', action='store_const', const=True, # default=False,
CliLong.CHECK_USER_DATA_ONLY, action='store_const', const=True, # default=False,
help=f'Only tries to read/parse the user data from {USER_DATA_FILE} and exits.',
)
parser.add_argument(
'--log-file', type=str,
help='Set the log file. Default is ~/var/log/nvdct.log\n',
CliLong.LOG_FILE, type=str,
help='Set the log file. Default is "~/var/log/nvdct.log"\n',
)
parser.add_argument(
'--log-level',
CliLong.LOG_LEVEL,
# nargs='+',
choices=['CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'OFF'],
choices=[
LogLevels.CRITICAL,
LogLevels.FATAL,
LogLevels.ERROR,
LogLevels.WARNING,
LogLevels.INFO,
LogLevels.DEBUG,
LogLevels.OFF
],
# default='WARNING',
help='Sets the log level. The default is "WARNING"\n'
help=f'Sets the log level. The default is "{LogLevels.WARNING}"\n'
)
parser.add_argument(
'--log-to-stdout', action='store_const', const=True, # default=False,
CliLong.LOG_TO_STDOUT, action='store_const', const=True, # default=False,
help='Send log to stdout.',
)
parser.add_argument(
'--dont-compare', action='store_const', const=True, # default=False,
CliLong.DISPLAY_L2_NEIGHBOURS, action='store_const', const=True, # default=False,
help='Use L2 neighbour name as display name in L2 topologies',
)
parser.add_argument(
CliLong.DONT_COMPARE, action='store_const', const=True, # default=False,
help='Do not compare the actual topology data with the default topology\n'
'data. By default, the actual topology is compared with the default\n'
'topology. If the data matches, the actual topology is not saved.\n'
'So, if you run this tool in a cron job, a new topology will be\n'
'created only if there was a change, unless you use "--dont-compare".'
f'created only if there was a change, unless you use "{CliLong.DONT_COMPARE}".'
)
parser.add_argument(
'--filter-customers',
choices=['INCLUDE', 'EXCLUDE'],
CliLong.FILTER_CUSTOMERS,
choices=[IncludeExclude.INCLUDE, IncludeExclude.EXCLUDE],
# default='INCLUDE',
help='INCLUDE/EXCLUDE customer list from config file.\n'
'Note: MULTISITE backend only.',
help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} customer list "[{TomlSections.CUSTOMERS}]" from TOML file.\n'
f'Note: {Backends.MULTISITE} backend only.',
)
parser.add_argument(
'--filter-sites',
choices=['INCLUDE', 'EXCLUDE'],
CliLong.FILTER_SITES,
choices=[IncludeExclude.EXCLUDE, IncludeExclude.EXCLUDE],
# default='INCLUDE',
help='INCLUDE/EXCLUDE site list from config file.\n'
'Note: MULTISITE backend only.',
help=f'{IncludeExclude.INCLUDE}/{IncludeExclude.EXCLUDE} site list "[{TomlSections.SITES}]" from TOML file.\n'
)
parser.add_argument(
CliLong.INCLUDE_L3_HOSTS, action='store_const', const=True, # default=False,
help='Include hosts (single IP objects) in layer 3 topologies',
)
parser.add_argument(
'--include-l3-hosts', action='store_const', const=True, # default=False,
help='Include hosts (single IP objects) in layer 3 topology',
CliLong.INCLUDE_L3_LOOPBACK, action='store_const', const=True, # default=False,
help='Include loopback ip-addresses in layer 3 topologies',
)
parser.add_argument(
'--remove-domain', action='store_const', const=True, # default=False,
help='Remove the domain name from the neighbor name',
CliLong.REMOVE_DOMAIN, action='store_const', const=True, # default=False,
help='Remove the domain name from the L2 neighbor name before matching CMK host.',
)
parser.add_argument(
'--keep', type=int,
CliLong.KEEP, type=int,
help='Number of topologies to keep. The oldest topologies above keep\n'
'max will be deleted.\n'
'NOTE: The default topologies will be always kept.\n'
'will be deleted.\n'
'NOTE: The default/protected topologies will be kept always.\n'
)
parser.add_argument(
'--min-age', type=int,
help='The minimum number of days before a topology is deleted by "--keep".'
CliLong.MIN_AGE, type=int,
help=f'The minimum number of days before a topology is deleted by "{CliLong.KEEP}"'
)
parser.add_argument(
'--pre-fetch', action='store_const', const=True, # default=False,
help='Try to fetch host data, with less API calls. Can improve RESTAPI backend\n'
CliLong.PRE_FETCH, action='store_const', const=True, # default=False,
help=f'Try to fetch host data, with less API calls. Can improve {Backends.RESTAPI} backend\n'
'performance',
)
parser.add_argument(
'--quiet', action='store_const', const=True, # default=False,
help='Suppress output to stdtout',
CliLong.QUIET, action='store_const', const=True, # default=False,
help='Suppress all output to stdtout',
)
parser.add_argument(
CliLong.SKIP_L3_CIDR_0, action='store_const', const=True, # default=False,
help='Skip ip-address with CIDR "/0" in layer 3 topologies',
)
parser.add_argument(
CliLong.SKIP_L3_CIDR_32_128, action='store_const', const=True, # default=False,
help='Skip ip-address with CIDR "/32" or "/128" in layer 3 topologies',
)
parser.add_argument(
CliLong.SKIP_L3_IF, action='store_const', const=True, # default=False,
help='Dont show interface in layer 3 topologies',
)
parser.add_argument(
'--skip-l3-if', action='store_const', const=True, # default=False,
help='Skip interface in layer 3 topology',
CliLong.SKIP_L3_IP, action='store_const', const=True, # default=False,
help='Dont show ip-addresses in layer 3 topologies',
)
parser.add_argument(
'--skip-l3-ip', action='store_const', const=True, # default=False,
help='Skip ip-address in layer 3 topology',
CliLong.SKIP_L3_PUBLIC, action='store_const', const=True, # default=False,
help='Skip public ip-addresses in layer 3 topologies',
)
parser.add_argument(
'--time-format', type=str,
help=f'Format string to render the time. (default: {TIME_FORMAT_ARGPARSER})',
CliLong.TIME_FORMAT, type=str,
help=f'Format string to render the time. (default: "{TIME_FORMAT_ARGPARSER}")',
)
return parser.parse_args()
......@@ -16,19 +16,23 @@
from abc import abstractmethod
from ast import literal_eval
from collections.abc import Mapping, MutableSequence, Sequence
from enum import Enum, unique
from pathlib import Path
from requests import session
from sys import exit as sys_exit
from typing import Dict, List, Tuple
from typing import Dict, List, Tuple, MutableMapping
from livestatus import MultiSiteConnection, SiteConfigurations, SiteId
from lib.constants import (
Backends,
CACHE_INTERFACES_DATA,
CacheItems,
Case,
ExitCodes,
IncludeExclude,
InvPaths,
OMD_ROOT,
PATH_INTERFACES,
TomlSections,
)
from lib.utils import (
LOGGER,
......@@ -84,36 +88,45 @@ def hosts_to_query(hosts: List[str]) -> Tuple[str, List[str]]:
return hosts_str, open_hosts
@unique
class CacheItems(Enum):
inventory = 'inventory'
interfaces = 'interfaces'
def __get__(self, instance, owner):
return self.value
class HostCache:
def __init__(
self,
pre_fetch: bool,
backend: str,
pre_fetch: bool,
):
LOGGER.info('init HOST_CACHE')
self.cache: Dict = {}
self.neighbour_to_host: MutableMapping[str, str] = {}
self._inventory_pre_fetch_list: List[str] = [
PATH_INTERFACES,
InvPaths.INTERFACES,
]
self.pre_fetch: bool = bool(pre_fetch)
self.backend: str = str(backend)
self.case: str = ''
self.l2_host_map: Dict[str, str] = {}
self.l2_neighbour_replace_regex: List[Tuple[str, str]] = []
self.pre_fetch: bool = bool(pre_fetch)
self.prefix: str = ''
self.remove_domain: bool = False
if self.pre_fetch:
for host in self.query_all_hosts():
self.cache[host] = HOST_EXIST.copy()
def get_inventory_data(self, hosts: Sequence[str]) -> Dict[str, Dict]:
def init_neighbour_to_host(
self,
case: str,
l2_host_map: Dict[str, str],
prefix: str,
remove_domain: bool,
):
self.case: str = case
self.l2_host_map: Dict[str, str] = l2_host_map
self.prefix: str = prefix
self.remove_domain: bool = remove_domain
def get_inventory_data(self, hosts: List[str]) -> Dict[str, Dict]:
"""
Returns a dictionary of hosts and there inventory data.
Args:
......@@ -136,7 +149,7 @@ class HostCache:
return inventory_data
def get_interface_data(self, hosts: Sequence[str]) -> Dict[str, Dict | None]:
def get_interface_data(self, hosts: List[str]) -> Dict[str, Dict | None]:
"""
Returns Dictionary of hosts and there interface services from CMK.
The interface information consists of the "Item", the "Description (summary)" and the service details
......@@ -185,7 +198,7 @@ class HostCache:
"""
return self.query_hosts_by_label(label)
def fill_cache(self, hosts: Sequence[str]) -> None:
def fill_cache(self, hosts: List[str]) -> None:
"""
Gets the host data from CMK and puts them in the host cache. Data collected:
- inventory
......@@ -218,13 +231,13 @@ class HostCache:
self.cache[host][CacheItems.interfaces] = {}
self.cache[host][CacheItems.interfaces][CACHE_INTERFACES_DATA] = interfaces
def get_data(self, host: str, item: CacheItems, path: str) -> Dict[str, any] | None:
def get_data(self, host: str, item: CacheItems, path: str) -> Mapping | Sequence | None:
"""
Returns data from self.cache. If the cache for "host" is empty, data will be fetched from CMK
Args:
host: host to get data from cache
item: item in cache (inventory/interface)
path: path in cache item
path: path in cache to data
Returns:
the requested data or None
......@@ -243,6 +256,62 @@ class HostCache:
def add_inventory_path(self, path: str) -> None:
self._inventory_pre_fetch_list = list(set(self._inventory_pre_fetch_list + [path]))
def get_host_from_neighbour(self, neighbour: str) -> str | None:
"""
Tries to get the CMK host name from a L2 neighbour name. It will test:
- the neighbour without domain name
- map the neighbour to a host via L2_HOST_MAP
- the neighbour in UPPER case (without domain)
- the neighbour in lower case (including domain)
- the neighbour with prefix
Args:
neighbour: the L2 neighbour name to find a CMK host for
Returns:
The CMK host name for the L2 neighbour or None if no host is found
"""
try:
return self.neighbour_to_host[neighbour]
except KeyError:
pass
host = neighbour
# rewrite neighbour if inventory neighbour and checkmk host don't match
if host in self.l2_host_map:
LOGGER.info(f'Replace neighbour by [{TomlSections.L2_HOST_MAP}]: {neighbour} -> {host}')
host = self.l2_host_map[host]
if self.remove_domain:
LOGGER.debug(f'Remove domain: {host} -> {host.split(".")[0]}')
host = host.split('.')[0]
match self.case:
case Case.UPPER:
LOGGER.debug(f'Change neighbour to upper case: {host} -> {host.upper()}')
host = host.upper()
case Case.LOWER:
LOGGER.debug(f'Change neighbour to lower case: {host} -> {host.lower()}')
host = host.lower()
case _:
pass
if self.prefix:
LOGGER.debug(f'Prepend neighbour with prefix: {host} -> {self.prefix}{host}')
host = f'{self.prefix}{host}'
if self.host_exists(host):
self.neighbour_to_host[neighbour] = host
LOGGER.debug(f'Matched neighbour to host: |{neighbour}| -> |{host}|')
return host
else:
self.neighbour_to_host[neighbour] = None
LOGGER.debug(f'No match found for neighbour: |{neighbour}|')
return None
@abstractmethod
def query_host(self, host: str) -> bool:
"""
......@@ -285,8 +354,16 @@ class HostCache:
raise NotImplementedError
class HostCacheLiveStatus(HostCache):
def __init__(self, pre_fetch: bool, backend: str = '[LIVESTATUS]'):
super().__init__(pre_fetch, backend)
def __init__(
self,
pre_fetch: bool,
backend: str = f'[{Backends.LIVESTATUS}]',
):
self.backend = backend
super().__init__(
pre_fetch = pre_fetch,
backend = self.backend,
)
def get_raw_data(self, query: str) -> any:
return get_data_form_live_status(query=query)
......@@ -294,9 +371,9 @@ class HostCacheLiveStatus(HostCache):
def query_host(self, host: str) -> bool:
query = (
'GET hosts\n'
'Columns: host_name\n'
'Columns: name\n'
'OutputFormat: python3\n'
f'Filter: host_name = {host}\n'
f'Filter: name = {host}\n'
)
data: Sequence[Sequence[str]] = self.get_raw_data(query=query)
LOGGER.debug(f'{self.backend} data for host {host}: {data}')
......@@ -309,7 +386,7 @@ class HostCacheLiveStatus(HostCache):
def query_all_hosts(self) -> Sequence[str]:
query = (
'GET hosts\n'
'Columns: host_name\n'
'Columns: name\n'
'OutputFormat: python3\n'
)
data: Sequence[Sequence[str]] = self.get_raw_data(query=query)
......@@ -339,9 +416,9 @@ class HostCacheLiveStatus(HostCache):
def query_inventory_data(self, hosts: str) -> Dict[str, Dict]:
query = (
'GET hosts\n'
'Columns: host_name mk_inventory\n'
'Columns: name mk_inventory\n'
'OutputFormat: python3\n'
f'Filter: host_name ~~ {hosts}\n'
f'Filter: name ~~ {hosts}\n'
)
inventory_data = {}
data: Sequence[Tuple[str, bytes]] = self.get_raw_data(query=query)
......@@ -386,11 +463,13 @@ class HostCacheMultiSite(HostCacheLiveStatus):
self,
pre_fetch: bool,
filter_sites: str | None = None,
sites: List[str] = [],
sites: List[str] | None = None,
filter_customers: str | None = None,
customers: List[str] = None,
):
self.backend = '[MULTISITE]'
if not sites:
sites = []
self.backend = f'[{Backends.MULTISITE}]'
self.sites: SiteConfigurations = SiteConfigurations({})
self.get_sites()
self.filter_sites(filter_sites, sites)
......@@ -402,9 +481,12 @@ class HostCacheMultiSite(HostCacheLiveStatus):
self.dead_sites = [site['site']['alias'] for site in self.c.dead_sites().values()]
if self.dead_sites:
dead_sites = ', '.join(self.dead_sites)
LOGGER.warning(f'{self.backend} WARNING: use of dead site(s) {dead_sites} is disabled')
LOGGER.warning(f'{self.backend} use of dead site(s) {dead_sites} is disabled')
self.c.set_only_sites(self.c.alive_sites())
super().__init__(pre_fetch, self.backend)
super().__init__(
pre_fetch=pre_fetch,
backend=self.backend,
)
def get_raw_data(self, query: str) -> object:
return self.c.query(query=query)
......@@ -459,20 +541,20 @@ class HostCacheMultiSite(HostCacheLiveStatus):
def filter_sites(self, filter_: str | None, sites: List[str]):
match filter_:
case 'INCLUDE':
case IncludeExclude.INCLUDE:
self.sites = {site: data for site, data in self.sites.items() if site in sites}
case 'EXCLUDE':
case IncludeExclude.EXCLUDE:
self.sites = {site: data for site, data in self.sites.items() if site not in sites}
case _:
return
def filter_costumers(self, filter_: str | None, costumers: List[str]):
match filter_:
case 'INCLUDE':
case IncludeExclude.INCLUDE:
self.sites = {
site: data for site, data in self.sites.items() if data.get('customer') in costumers
}
case 'EXCLUDE':
case IncludeExclude.EXCLUDE:
self.sites = {
site: data for site, data in self.sites.items() if data.get('customer') not in costumers
}
......@@ -485,9 +567,11 @@ class HostCacheRestApi(HostCache):
pre_fetch: bool,
api_port: int,
filter_sites: str | None = None,
sites: List[str] = [],
sites: List[str] | None = None,
):
self.backend = '[RESTAPI]'
if not sites:
sites = []
self.backend = f'[{Backends.RESTAPI}]'
LOGGER.debug(f'{self.backend} init backend')
try:
......@@ -513,7 +597,10 @@ class HostCacheRestApi(HostCache):
self.sites: MutableSequence[str] = self.query_sites()
self.filter_sites(filter_=filter_sites, sites=sites)
LOGGER.info(f'{self.backend} filtered sites : {self.sites}')
super().__init__(pre_fetch, self.backend)
super().__init__(
pre_fetch=pre_fetch,
backend=self.backend,
)
def get_raw_data(self, url: str, params: Mapping[str, object] | None):
resp = self.__session.get(
......@@ -541,9 +628,9 @@ class HostCacheRestApi(HostCache):
def filter_sites(self, filter_: str | None, sites: List[str]):
match filter_:
case 'INCLUDE':
case IncludeExclude.INCLUDE:
self.sites = [site for site in self.sites if site in sites]
case 'EXCLUDE':
case IncludeExclude.EXCLUDE:
self.sites = [site for site in self.sites if site not in sites]
case _:
return
......@@ -652,4 +739,4 @@ class HostCacheRestApi(HostCache):
'long_plugin_output': long_plugin_output.split('\\n')
}
return interface_data
\ No newline at end of file
return interface_data
......@@ -7,18 +7,35 @@
# Date : 2024-12-11
# File : nvdct/lib/constants.py
from dataclasses import dataclass
from enum import Enum, unique, auto
from logging import getLogger
from logging import Logger, getLogger
from os import environ
from typing import Final
#
NVDCT_VERSION: Final[str] = '0.9.6-20241222'
NVDCT_VERSION: Final[str] = '0.9.7-20241230'
#
OMD_ROOT: Final[str] = environ["OMD_ROOT"]
#
API_PORT: Final[int] = 5001
CACHE_INTERFACES_DATA: Final[str] = 'interface_data'
CMK_SITE_CONF: Final[str] = f'{OMD_ROOT}/etc/omd/site.conf'
LOGGER: Logger = getLogger('root)')
LOG_FILE: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log'
SCRIPT: Final[str] = '~/local/bin/nvdct/nvdct.py'
TIME_FORMAT: Final[str] = '%Y-%m-%dT%H:%M:%S.%m'
TIME_FORMAT_ARGPARSER: Final[str] = '%%Y-%%m-%%dT%%H:%%M:%%S.%%m'
USER_DATA_FILE: Final[str] = 'nvdct.toml'
DATAPATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data'
class MyEnum(Enum):
def __get__(self, instance, owner):
return self.value
@unique
class ExitCodes(Enum):
class ExitCodes(MyEnum):
OK = 0
BAD_OPTION_LIST = auto()
BAD_TOML_FORMAT = auto()
......@@ -26,104 +43,218 @@ class ExitCodes(Enum):
AUTOMATION_SECRET_NOT_FOUND = auto()
NO_LAYER_CONFIGURED = auto()
def __get__(self, instance, owner):
return self.value
@unique
class IPVersion(Enum):
class IPVersion(MyEnum):
IPv4 = 4
IPv6 = 6
def __get__(self, instance, owner):
return self.value
@dataclass(frozen=True)
class Layer:
path: str
columns: str
label: str
host_label: str
@unique
class URLs(MyEnum):
NVDCT: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct'
# CDP: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_cdp_cache'
# LLDP: Final[str] = 'LLDP: https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lldp_cach'
# SNMP_IP_ADDRESS: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_ip_address'
# LINUX_SNM_APPRESS: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_lnx_if_ip'
# WINDOWS_IP_ADDRESS: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/inventory/inv_win_if_ip'
TOPIC_NV: Final[str] = 'https://thl-cmk.hopto.org/gitlab/explore/projects/topics/Network%20Visualization'
FORUM_SCHNETZ: Final[str] = 'https://forum.checkmk.com/t/network-visualization/41680'
#
OMD_ROOT: Final[str] = environ["OMD_ROOT"]
#
API_PORT: Final[int] = 5001
CACHE_INTERFACES_DATA: Final[str] = 'interface_data'
CMK_SITE_CONF: Final[str] = f'{OMD_ROOT}/etc/omd/site.conf'
COLUMNS_CDP: Final[str] = 'neighbour_name,local_port,neighbour_port'
COLUMNS_L3: Final[str] = 'address,device,cidr,network,type'
COLUMNS_LLDP: Final[str] = 'neighbour_name,local_port,neighbour_port'
DATAPATH: Final[str] = f'{OMD_ROOT}/var/check_mk/topology/data'
HOME_URL: Final[str] = 'https://thl-cmk.hopto.org/gitlab/checkmk/vendor-independent/nvdct'
HOST_LABEL_CDP: Final[str] = "'nvdct/has_cdp_neighbours' 'yes'"
HOST_LABEL_L3V4_HOSTS: Final[str] = "'nvdct/l3v4_topology' 'host'"
HOST_LABEL_L3V4_ROUTER: Final[str] = "'nvdct/l3v4_topology' 'router'"
HOST_LABEL_L3V6_HOSTS: Final[str] = "'nvdct/l3v6_topology' 'host'"
HOST_LABEL_L3V6_ROUTER: Final[str] = "'nvdct/l3v6_topology' 'router'"
HOST_LABEL_LLDP: Final[str] = "'nvdct/has_lldp_neighbours' 'yes'"
LABEL_CDP: Final[str] = 'CDP'
LABEL_L3v4: Final[str] = 'L3v4'
LABEL_L3v6: Final[str] = 'L3v6'
LABEL_LLDP: Final[str] = 'LLDP'
LABEL_STATIC: Final[str] = 'STATIC'
LOGGER: Final[str] = getLogger('root)')
LOG_FILE: Final[str] = f'{OMD_ROOT}/var/log/nvdct.log'
MIN_CDP_VERSION: Final[str] = '0.7.1-20240320'
MIN_LINUX_IP_ADDRESSES: Final[str] = '0.0.4-20241210'
MIN_SNMP_IP_ADDRESSES: Final[str] = '0.0.6-20241210'
MIN_WINDOWS_IP_ADDRESSES: Final[str] = '0.0.3-20241210'
MIN_LLDP_VERSION: Final[str] = '0.9.3-20240320'
PATH_CDP: Final[str] = 'networking,cdp_cache,neighbours'
PATH_INTERFACES: Final[str] = 'networking,interfaces'
PATH_L3: Final[str] = 'networking,addresses'
PATH_LLDP: Final[str] = 'networking,lldp_cache,neighbours'
SCRIPT: Final[str] = '~/local/bin/nvdct/nvdct.py'
TIME_FORMAT: Final[str] = '%Y-%m-%dT%H:%M:%S.%m'
TIME_FORMAT_ARGPARSER: Final[str] = '%%Y-%%m-%%dT%%H:%%M:%%S.%%m'
USER_DATA_FILE: Final[str] = 'nvdct.toml'
#
TOML_CUSTOMERS : Final[str] = 'CUSTOMERS'
TOML_CUSTOM_LAYERS : Final[str] = 'CUSTOM_LAYERS'
TOML_EMBLEMS : Final[str] = 'EMBLEMS'
TOML_L2_DROP_HOSTS: Final[str] = 'L2_DROP_HOSTS'
TOML_L2_HOST_MAP : Final[str] = 'L2_HOST_MAP'
TOML_L2_NEIGHBOUR_REPLACE_REGEX : Final[str] = 'L2_NEIGHBOUR_REPLACE_REGEX'
TOML_L2_SEED_DEVICES: Final[str] = 'L2_SEED_DEVICES'
TOML_L3V4_IGNORE_WILDCARD : Final[str] = 'L3V4_IGNORE_WILDCARD'
TOML_L3_IGNORE_HOSTS : Final[str] = 'L3_IGNORE_HOSTS'
TOML_L3_IGNORE_IP : Final[str] = 'L3_IGNORE_IP'
TOML_L3_REPLACE : Final[str] = 'L3_REPLACE'
TOML_L3_SUMMARIZE : Final[str] = 'L3_SUMMARIZE'
TOML_MAP_SPEED_TO_THICKNESS : Final[str] = 'MAP_SPEED_TO_THICKNESS'
TOML_PROTECTED_TOPOLOGIES : Final[str] = 'PROTECTED_TOPOLOGIES'
TOML_SETTINGS : Final[str] = 'SETTINGS'
TOML_SITES : Final[str] = 'SITES'
TOML_STATIC_CONNECTIONS : Final[str] = 'STATIC_CONNECTIONS'
#
LAYERS = {
'CDP': Layer(
path=PATH_CDP,
columns=COLUMNS_CDP,
label=LABEL_CDP,
host_label=HOST_LABEL_CDP,
),
'LLDP': Layer(
path=PATH_LLDP,
columns=COLUMNS_LLDP,
label=LABEL_LLDP,
host_label=HOST_LABEL_LLDP,
),
'L3v4': Layer(
path=PATH_L3,
columns='',
label=LABEL_L3v4,
host_label=HOST_LABEL_L3V4_ROUTER,
),
'L3v6': Layer(
path=PATH_L3,
columns='',
label=LABEL_L3v6,
host_label=HOST_LABEL_L3V6_ROUTER,
),
}
@unique
class Backends(MyEnum):
LIVESTATUS: Final[str] = 'LIVESTATUS'
MULTISITE: Final[str] = 'MULTISITE'
RESTAPI: Final[str] = 'RESTAPI'
@unique
class Case(MyEnum):
LOWER: Final[str] = 'LOWER'
UPPER: Final[str] = 'UPPER'
@unique
class CacheItems(MyEnum):
inventory = 'inventory'
interfaces = 'interfaces'
@unique
class CliLong(MyEnum):
ADJUST_TOML: Final[str] = '--adjust-toml'
API_PORT: Final[str] = '--api-port'
BACKEND: Final[str] = '--backend'
CASE: Final[str] = '--case'
CHECK_USER_DATA_ONLY: Final[str] = '--check-user-data-only'
DEFAULT: Final[str] = '--default'
DISPLAY_L2_NEIGHBOURS: Final[str] = '--display-l2-neighbours'
DONT_COMPARE: Final[str] = '--dont-compare'
FILTER_CUSTOMERS: Final[str] = '--filter-customers'
FILTER_SITES: Final[str] = '--filter-sites'
INCLUDE_L3_HOSTS: Final[str] = '--include-l3-hosts'
INCLUDE_L3_LOOPBACK: Final[str] = '--include-l3-loopback'
KEEP: Final[str] = '--keep'
LAYERS: Final[str] = '--layers'
LOG_FILE: Final[str] = '--log-file'
LOG_LEVEL: Final[str] = '--log-level'
LOG_TO_STDOUT: Final[str] = '--log-to-stdout'
MIN_AGE: Final[str] = '--min-age'
OUTPUT_DIRECTORY: Final[str] = '--output-directory'
PREFIX: Final[str] = '--prefix'
PRE_FETCH: Final[str] = '--pre-fetch'
QUIET: Final[str] = '--quiet'
REMOVE_DOMAIN: Final[str] = '--remove-domain'
SEED_DEVICES: Final[str] = '--seed-devices'
SKIP_L3_CIDR_0: Final[str] = '--skip-l3-cidr-0'
SKIP_L3_CIDR_32_128: Final[str] = '--skip-l3-cidr-32-128'
SKIP_L3_IF: Final[str] = '--skip-l3-if'
SKIP_L3_IP: Final[str] = '--skip-l3-ip'
SKIP_L3_PUBLIC: Final[str] = '--skip-l3-public'
TIME_FORMAT: Final[str] = '--time-format'
USER_DATA_FILE: Final[str] = '--user-data-file'
VERSION: Final[str] = '--version'
@unique
class EmblemNames(MyEnum):
HOST_NODE: Final[str] = 'host_node'
IP_ADDRESS: Final[str] = 'ip_address'
IP_NETWORK: Final[str] = 'ip_network'
L3_REPLACE: Final[str] = 'l3_replace'
L3_SUMMARIZE: Final[str] = 'l3_summarize'
SERVICE_NODE: Final[str] = 'service_node'
@unique
class EmblemValues(MyEnum):
ICON_AGGREGATION: Final[str] = 'icon_aggr'
ICON_ALERT_UNREACHABLE: Final[str] = 'icon_alert_unreach'
ICON_PLUGINS_CLOUD: Final[str] = 'icon_plugins_cloud'
IP_ADDRESS_80: Final[str] = 'ip-address_80'
IP_NETWORK_80: Final[str] = 'ip-network_80'
@unique
class HostLabels(MyEnum):
CDP: Final[str] = "'nvdct/has_cdp_neighbours' 'yes'"
L3V4_HOSTS: Final[str] = "'nvdct/l3v4_topology' 'host'"
L3V4_ROUTER: Final[str] = "'nvdct/l3v4_topology' 'router'"
L3V6_HOSTS: Final[str] = "'nvdct/l3v6_topology' 'host'"
L3V6_ROUTER: Final[str] = "'nvdct/l3v6_topology' 'router'"
LLDP: Final[str] = "'nvdct/has_lldp_neighbours' 'yes'"
@unique
class IncludeExclude(MyEnum):
INCLUDE: Final[str] = 'INCLUDE'
EXCLUDE: Final[str] = 'EXCLUDE'
@unique
class L2InvColumns(MyEnum):
NEIGHBOUR: Final[str] = 'neighbour_name'
LOCALPORT: Final[str] = 'local_port'
NEIGHBOURPORT: Final[str] = 'neighbour_port'
@unique
class L3InvColumns(MyEnum):
ADDRESS: Final[str] = 'address'
DEVICE: Final[str] = 'device'
CIDR: Final[str] = 'cidr'
@unique
class InvPaths(MyEnum):
CDP: Final[str] = 'networking,cdp_cache,neighbours'
INTERFACES: Final[str] = 'networking,interfaces'
L3: Final[str] = 'networking,addresses'
LLDP: Final[str] = 'networking,lldp_cache,neighbours'
LLDP_ATTRIBUTE: Final[str] = 'networking,lldp_cache'
@unique
class Layers(MyEnum):
CDP: Final[str] = 'CDP'
LLDP: Final[str] = 'LLDP'
L3V4: Final[str] = 'L3v4'
L3V6: Final[str] = 'L3v6'
STATIC: Final[str] = 'STATIC'
@unique
class LogLevels(MyEnum):
CRITICAL: Final[str] = 'CRITICAL'
FATAL: Final[str] = 'FATAL'
ERROR: Final[str] = 'ERROR'
WARNING: Final[str] = 'WARNING'
INFO: Final[str] = 'INFO'
DEBUG: Final[str] = 'DEBUG'
OFF: Final[str] = 'OFF'
class MinVersions(MyEnum):
CDP: Final[str] = '0.7.1-20240320'
LLDP: Final[str] = '0.9.3-20240320'
LINUX_IP_ADDRESSES: Final[str] = '0.0.4-20241210'
SNMP_IP_ADDRESSES: Final[str] = '0.0.6-20241210'
WINDOWS_IP_ADDRESSES: Final[str] = '0.0.3-20241210'
@unique
class TomlSections(MyEnum):
CUSTOMERS: Final[str] = 'CUSTOMERS'
EMBLEMS: Final[str] = 'EMBLEMS'
L2_DROP_NEIGHBOURS: Final[str] = 'L2_DROP_NEIGHBOURS'
L2_HOST_MAP: Final[str] = 'L2_HOST_MAP'
L2_NEIGHBOUR_REPLACE_REGEX: Final[str] = 'L2_NEIGHBOUR_REPLACE_REGEX'
L2_SEED_DEVICES: Final[str] = 'L2_SEED_DEVICES'
L3V4_IGNORE_WILDCARD: Final[str] = 'L3V4_IGNORE_WILDCARD'
L3_IGNORE_HOSTS: Final[str] = 'L3_IGNORE_HOSTS'
L3_IGNORE_IP: Final[str] = 'L3_IGNORE_IP'
L3_REPLACE: Final[str] = 'L3_REPLACE'
L3_SUMMARIZE: Final[str] = 'L3_SUMMARIZE'
MAP_SPEED_TO_THICKNESS: Final[str] = 'MAP_SPEED_TO_THICKNESS'
PROTECTED_TOPOLOGIES: Final[str] = 'PROTECTED_TOPOLOGIES'
SETTINGS: Final[str] = 'SETTINGS'
SITES: Final[str] = 'SITES'
STATIC_CONNECTIONS: Final[str] = 'STATIC_CONNECTIONS'
def cli_long_to_toml(cli_param: str) -> str:
return cli_param.strip('-').replace('-', '_')
@unique
class TomlSettings(MyEnum):
ADJUST_TOML: Final[str] = cli_long_to_toml(CliLong.ADJUST_TOML)
API_PORT: Final[str] = cli_long_to_toml(CliLong.API_PORT)
BACKEND: Final[str] = cli_long_to_toml(CliLong.BACKEND)
CASE: Final[str] = cli_long_to_toml(CliLong.CASE)
CHECK_USER_DATA_ONLY: Final[str] = cli_long_to_toml(CliLong.CHECK_USER_DATA_ONLY)
DEFAULT: Final[str] = cli_long_to_toml(CliLong.DEFAULT)
DISPLAY_L2_NEIGHBOURS: Final[str] = cli_long_to_toml(CliLong.DISPLAY_L2_NEIGHBOURS)
DONT_COMPARE: Final[str] = cli_long_to_toml(CliLong.DONT_COMPARE)
FILTER_CUSTOMERS: Final[str] = cli_long_to_toml(CliLong.FILTER_CUSTOMERS)
FILTER_SITES: Final[str] = cli_long_to_toml(CliLong.FILTER_SITES)
INCLUDE_L3_HOSTS: Final[str] = cli_long_to_toml(CliLong.INCLUDE_L3_HOSTS)
INCLUDE_L3_LOOPBACK: Final[str] = cli_long_to_toml(CliLong.INCLUDE_L3_LOOPBACK)
KEEP: Final[str] = cli_long_to_toml(CliLong.KEEP)
LAYERS: Final[str] = cli_long_to_toml(CliLong.LAYERS)
LOG_FILE: Final[str] = cli_long_to_toml(CliLong.LOG_FILE)
LOG_LEVEL: Final[str] = cli_long_to_toml(CliLong.LOG_LEVEL)
LOG_TO_STDOUT: Final[str] = cli_long_to_toml(CliLong.LOG_TO_STDOUT)
MIN_AGE: Final[str] = cli_long_to_toml(CliLong.MIN_AGE)
OUTPUT_DIRECTORY: Final[str] = cli_long_to_toml(CliLong.OUTPUT_DIRECTORY)
PREFIX: Final[str] = cli_long_to_toml(CliLong.PREFIX)
PRE_FETCH: Final[str] = cli_long_to_toml(CliLong.PRE_FETCH)
QUIET: Final[str] = cli_long_to_toml(CliLong.QUIET)
REMOVE_DOMAIN: Final[str] = cli_long_to_toml(CliLong.REMOVE_DOMAIN)
SKIP_L3_CIDR_0: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_CIDR_0)
SKIP_L3_CIDR_32_128: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_CIDR_32_128)
SKIP_L3_IF: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_IF)
SKIP_L3_IP: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_IP)
SKIP_L3_PUBLIC: Final[str] = cli_long_to_toml(CliLong.SKIP_L3_PUBLIC)
TIME_FORMAT: Final[str] = cli_long_to_toml(CliLong.TIME_FORMAT)
USER_DATA_FILE: Final[str] = cli_long_to_toml(CliLong.USER_DATA_FILE)
This diff is collapsed.
This diff is collapsed.
......@@ -8,45 +8,34 @@
# File : nvdct/lib/utils.py
from ast import literal_eval
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from json import dumps
from collections.abc import Mapping, MutableSequence, Sequence
from json import dumps, loads
from logging import disable as log_off, Formatter, getLogger, StreamHandler
from logging.handlers import RotatingFileHandler
from pathlib import Path
from re import match as re_match
from re import match as re_match, findall as re_findall, sub as re_sub
from socket import socket, AF_UNIX, AF_INET, SOCK_STREAM, SHUT_WR
from sys import stdout, exit as sys_exit
from time import time as now_time
from tomllib import loads as toml_loads, TOMLDecodeError
from typing import List, Dict, TextIO
from typing import Dict, List, TextIO
from lib.constants import (
Backends,
CMK_SITE_CONF,
Case,
DATAPATH,
EmblemValues,
EmblemNames,
ExitCodes,
LOGGER,
LogLevels,
OMD_ROOT,
TomlSections,
TomlSettings,
)
@dataclass(frozen=True)
class IpInfo:
address: str
device: str
broadcast: str
cidr: int
netmask: str
network: str
type: str
scope_id: str | None
@dataclass(frozen=True)
class InventoryColumns:
neighbour: str
local_port: str
neighbour_port: str
def get_local_cmk_version() -> str:
return Path(f'{OMD_ROOT}/version').readlink().name
......@@ -64,7 +53,7 @@ def get_data_form_live_status(query: str) -> Dict | List | None:
sock.connect(address)
sock.sendall(query.encode())
sock.shutdown(SHUT_WR)
chunks: List = []
chunks: MutableSequence = []
while len(chunks) == 0 or chunks[-1] != "":
chunks.append(sock.recv(4096).decode())
sock.close()
......@@ -112,13 +101,14 @@ def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[
path: Path = Path(raw_path)
default_topo = path.joinpath('default')
directories = [str(directory) for directory in list(path.iterdir())]
# keep default top
# keep default topo
if str(default_topo) in directories:
directories.remove(str(default_topo))
keep -= 1
if default_topo.is_symlink():
try:
directories.remove(str(default_topo.readlink()))
directories.remove(str(path.joinpath(str(default_topo.readlink()))))
except ValueError:
pass
......@@ -140,80 +130,56 @@ def remove_old_data(keep: int, min_age: int, raw_path: str, protected: Sequence[
if Path(directory).is_dir():
topo_by_age[Path(directory).stat().st_ctime] = directory
topo_age = list(topo_by_age.keys())
topo_age.sort()
topo_age: List = list(topo_by_age.keys())
topo_age.sort(reverse=True)
while len(topo_by_age) > keep:
if min_age * 86400 > now_time() - topo_age[0]:
entry = topo_age.pop()
if min_age * 86400 > now_time() - entry:
LOGGER.info(
msg=f'Topology "{Path(topo_by_age[topo_age[0]]).name}'
msg=f'Topology "{Path(topo_by_age[entry]).name}'
f'" not older then {min_age} day(s). not deleted.'
)
return
LOGGER.info(f'delete old topology: {topo_by_age[topo_age[0]]}')
rm_tree(Path(topo_by_age[topo_age[0]]))
topo_by_age.pop(topo_age[0])
topo_age.pop(0)
LOGGER.info(f'delete old topology: {topo_by_age[entry]}')
rm_tree(Path(topo_by_age[entry]))
topo_by_age.pop(entry)
def save_data_to_file(data: Mapping, path: str, file: str, make_default: bool) -> None:
def save_data_to_file(
data: Mapping,
path: str,
file: str,
make_default: bool,
dont_compare: bool,
) -> None:
"""
Save the data as json file.
Args:
data: the topology data
path: the path were to save the dat
file: the file name to save data in
path: the path inder DATATAPATH
file: the file name to save the data in
make_default: if True, create the symlink "default" with path as target
dont_compare: if True, data will not be compared with default data
Returns:
None
"""
if not Path(f'{DATAPATH}/default').exists():
make_default = True
elif Path(f'{DATAPATH}/default/{file}').exists() and not dont_compare:
if is_equal_with_default(data, f'{DATAPATH}/default/{file}'):
LOGGER.warning(f'Data identical to default. Not saved! Use "--dont-compare".')
return
path_file = f'{path}/{file}'
path_file = f'{DATAPATH}/{path}/{file}'
save_file = Path(f'{path_file}')
save_file.parent.mkdir(exist_ok=True, parents=True)
save_file.write_text(dumps(data))
parent_path = Path(f'{path}').parent
if not Path(f'{parent_path}/default').exists():
make_default = True
if make_default:
Path(f'{parent_path}/default').unlink(missing_ok=True)
Path(f'{parent_path}/default').symlink_to(target=Path(path), target_is_directory=True)
# CMK version 2.2.x format
def save_topology(
data: dict,
base_directory: str,
output_directory: str,
dont_compare: bool,
make_default: bool,
topology_file_name: str,
) -> None:
path = f'{base_directory}/{output_directory}'
def _save():
save_data_to_file(
data=data,
path=path,
file=topology_file_name,
make_default=make_default,
)
if dont_compare:
_save()
else:
if not is_equal_with_default(
data=data,
file=f'{base_directory}/default/{topology_file_name}'
):
_save()
else:
LOGGER.warning(
msg='Topology matches default topology, not saved! Use'
'"--dont-compare" to save identical topologies.'
)
if make_default:
Path(f'{DATAPATH}/default').unlink(missing_ok=True)
Path(f'{DATAPATH}/default').symlink_to(target=Path(path), target_is_directory=True)
def is_mac_address(mac_address: str) -> bool:
......@@ -249,8 +215,11 @@ def is_list_of_str_equal(list1: List[str], list2: List[str]) -> bool:
"""
tmp_list1 = list1.copy()
tmp_list2 = list2.copy()
tmp_list1.sort()
tmp_list2.sort()
try:
tmp_list1.sort()
tmp_list2.sort()
except TypeError: # list of dict cant be sorted (?)
pass
return tmp_list1 == tmp_list2
......@@ -262,6 +231,7 @@ def is_valid_hostname(host: str) -> bool:
LOGGER.error(f'Invalid hostname found: {host}')
return False
def is_valid_site_name(site: str) -> bool:
re_host_pattern = r'^[0-9a-z-A-Z\.\-\_]{1,16}$'
if re_match(re_host_pattern, site):
......@@ -270,6 +240,7 @@ def is_valid_site_name(site: str) -> bool:
LOGGER.error(f'Invalid site name found: {site}')
return False
def is_valid_customer_name(customer: str) -> bool:
re_host_pattern = r'^[0-9a-z-A-Z\.\-\_]{1,16}$'
if re_match(re_host_pattern, customer):
......@@ -288,6 +259,7 @@ def is_valid_output_directory(directory: str) -> bool:
LOGGER.error(f'Invalid output directory name found: {directory}')
return False
def is_valid_log_file(log_file: str) -> bool:
if not log_file.startswith(f'{OMD_ROOT}/var/log/'):
LOGGER.error(f'Logg file needs to be under "{OMD_ROOT}/var/log/"! Got {Path(log_file).absolute()}')
......@@ -295,32 +267,6 @@ def is_valid_log_file(log_file: str) -> bool:
return True
# not used in cmk 2.3.x format
def merge_topologies(topo_pri: Dict, topo_sec: Dict) -> Dict:
"""
Merge dict_prim into dict_sec
Args:
topo_pri: data of dict_pri will overwrite the data in dict_sec
topo_sec: dict where the data of dict_pri will be merged to
Returns:
Dict: topo_sec that contains merged data from top_sec and top_pri
"""
keys_pri = list(topo_pri.keys())
# first transfer all completely missing items from dict_prim to dict_sec
for key in keys_pri:
if key not in topo_sec.keys():
topo_sec[key] = topo_pri[key]
else:
topo_sec[key]['connections'].update(topo_pri[key].get('connections', {}))
topo_sec[key]['interfaces'] = list(
set((topo_sec[key]['interfaces'] + topo_pri[key].get('interfaces', [])))
)
topo_pri.pop(key)
return topo_sec
def compare_dicts(dict1: Mapping, dict2: Mapping) -> bool:
# check top level keys
if not is_list_of_str_equal(list(dict1.keys()), list(dict2.keys())):
......@@ -329,23 +275,35 @@ def compare_dicts(dict1: Mapping, dict2: Mapping) -> bool:
LOGGER.debug(f'dict1: {list(dict2.keys())}')
return False
LOGGER.debug('Top level matches')
for key, value in dict1.items():
_type = type(value)
if _type == dict:
type_ = type(value)
if type_ == dict:
LOGGER.debug(f'compare dict: {key}')
if not compare_dicts(value, dict2[key]):
return False
elif _type == list:
elif type_ == list:
if not is_list_of_str_equal(value, dict2[key]):
LOGGER.debug(f'list1: {value}')
LOGGER.debug(f'list2: {dict2[key]}')
return False
elif _type == str:
elif type_ in [str, int]:
if not value == dict2[key]:
LOGGER.debug('value dont match')
LOGGER.debug(f'value1: {value}')
LOGGER.debug(f'value2: {dict2[key]}')
return False
elif value is None:
if not value == dict2[key]:
LOGGER.debug('value dont match')
LOGGER.debug(f'value1: {value}')
LOGGER.debug(f'value2 {dict2[key]}')
LOGGER.debug(f'value2: {dict2[key]}')
return False
else:
LOGGER.debug(f'Compare unknown type {type_}')
LOGGER.debug(f'key: {key}')
LOGGER.debug(f'value1: {value}')
LOGGER.debug(f'value2: {dict2[key]}')
return False
return True
......@@ -354,10 +312,26 @@ def compare_dicts(dict1: Mapping, dict2: Mapping) -> bool:
def is_equal_with_default(data: Mapping, file: str) -> bool:
default_file = Path(file)
if default_file.exists():
default_data = literal_eval(default_file.read_text())
LOGGER.info(f'compare data with {file}')
default_data = loads(default_file.read_text())
return compare_dicts(data, default_data)
return False
def get_attributes_from_inventory(inventory: Dict[str, object], raw_path: str):
# print(inventory['Nodes']['networking']['Nodes']['lldp_cache']['Attributes']['Pairs'])
path: List[str] = ('Nodes,' + ',Nodes,'.join(raw_path.split(',')) + ',Attributes,Pairs').split(',')
try:
table = inventory.copy()
except AttributeError:
return None
for m in path:
try:
table = table[m]
except KeyError:
LOGGER.info(msg=f'Inventory attributes for {path} not found')
return None
return dict(table)
def get_table_from_inventory(inventory: Dict[str, object], raw_path: str) -> List | None:
path: List[str] = ('Nodes,' + ',Nodes,'.join(raw_path.split(',')) + ',Table,Rows').split(',')
......@@ -422,3 +396,66 @@ class StdoutQuiet:
def flush(self):
self._org_stdout.flush()
def adjust_toml(toml_file: str):
fix_options = {
'DROP_HOSTS': TomlSections.L2_DROP_NEIGHBOURS,
'HOST_MAP': TomlSections.L2_HOST_MAP,
'L2_DROP_HOSTS': TomlSections.L2_DROP_NEIGHBOURS, # needs to be before DROP_HOST
'L3V4_IGNORE_HOSTS': TomlSections.L3_IGNORE_HOSTS,
'L3V4_IGNORE_IP': TomlSections.L3_IGNORE_IP,
'L3V4_IRNORE_WILDCARD': TomlSections.L3V4_IGNORE_WILDCARD,
'L3V4_REPLACE': TomlSections.L3_REPLACE,
'L3V3_REPLACE': TomlSections.L3_REPLACE,
'L3V4_SUMMARIZE': TomlSections.L3_SUMMARIZE,
'SEED_DEVICES': TomlSections.L2_SEED_DEVICES,
'icon_missinc': EmblemValues.ICON_ALERT_UNREACHABLE,
'icon_missing': EmblemValues.ICON_ALERT_UNREACHABLE,
'l3v4_replace': EmblemNames.L3_REPLACE,
'l3v4_summarize': EmblemNames.L3_SUMMARIZE,
'keep_domain = true': f'{TomlSettings.REMOVE_DOMAIN} = false',
'keep_domain = false': f'{TomlSettings.REMOVE_DOMAIN} = true',
}
old_options = {
'lowercase': f'{TomlSettings.CASE} = {Case.LOWER}',
'uppercase': f'{TomlSettings.CASE} = {Case.UPPER}',
f'FILESYSTEM': {Backends.MULTISITE},
'debug': f'{TomlSettings.LOG_LEVEL} = {LogLevels.DEBUG}',
'keep_domain': f'{TomlSettings.REMOVE_DOMAIN} = true/false'
}
changed: bool = False
org_file = Path(toml_file)
if org_file.exists():
print(f'Checking file.: {org_file.name}')
org_content: str = org_file.read_text()
content: str = org_content
for old, new in fix_options.items():
re_pattern = f'\\b{old}\\b'
count = len(re_findall(re_pattern, org_content))
if count > 0:
changed = True
content = re_sub(re_pattern, new, content)
print(f'Found value...: "{old}" {count} times, replaced by "{new}"')
for old, new in old_options.items():
re_pattern = f'\\b{old}\\b'
count = len(re_findall(re_pattern, org_content))
if count > 0:
print(f'Obsolete......: "{old}", use "{new}" instead')
if changed:
backup_file = Path(f'{toml_file}.backup')
if not backup_file.exists():
org_file.rename(backup_file)
print(f'Renamed TOML..: {backup_file.name}')
new_file = Path(toml_file)
new_file.open('w').write(content)
print(f'Written fixed.: {new_file.name}')
else:
print(
f'Can not create backup file {backup_file.name}, file exists. Aborting!\n'
f'Nothing has changed.'
)
else:
print('Finished......: Nothing found to fix.')
......@@ -157,7 +157,23 @@
# [EMBLEMS]
# l3v4_replace -> l3_replace
# l3v4_summarize -> l3_summarize
# 2024-12-25: fixed "--dont-compare", data will only be saved if the are different from the default
# changed: is seed devices is not configured use all CDP/LLDP devices (by host label)
# 2024-12-26: INCOMPATIBLE: renamed L2_DROP_HOSTS -> L2_DROP_NEIGHBOURS
# added option --display-l2-neighbours
# 2024-12-27: added options
# --adjust-toml
# --include-l3-loopback
# --skip-l3-cidr-0
# --skip-l3-cidr-32-128
# --skip-l3-public
# fixed: keep default topology
# fixed: cleanup -> remove the oldest topologies not the newest
# INCOMPATIBLE: removed: CUSTOM_LAYERS
# refactoring constants
#
#
# creating topology data json from inventory data
#
# This script creates the topology data file needed for the Checkmk "network_visualization" plugin
......@@ -254,7 +270,10 @@ __data = {
"""
import sys
from collections.abc import MutableSequence
from time import strftime, time_ns
from typing import List
from lib.args import parse_arguments
......@@ -265,14 +284,17 @@ from lib.backends import (
HostCacheRestApi,
)
from lib.constants import (
Backends,
DATAPATH,
HOME_URL,
URLs,
HostLabels,
IPVersion,
LABEL_L3v4,
LAYERS,
InvPaths,
LOGGER,
Layer,
Layers,
NVDCT_VERSION,
TomlSections,
TomlSettings,
)
from lib.settings import Settings
from lib.topologies import (
......@@ -282,8 +304,8 @@ from lib.topologies import (
)
from lib.utils import (
ExitCodes,
InventoryColumns,
StdoutQuiet,
adjust_toml,
configure_logger,
remove_old_data,
)
......@@ -307,20 +329,29 @@ def main():
print(
f'Network Visualisation Data Creation Tool (NVDCT)\n'
f'by thl-cmk[at]outlook[dot]com, version {NVDCT_VERSION}\n'
f'see {HOME_URL}'
f'see {URLs.NVDCT}'
)
print('')
print(f'Start time....: {strftime(settings.time_format)}')
if settings.fix_toml:
adjust_toml(settings.user_data_file)
print(f'Time taken....: {(time_ns() - start_time) / 1e9}/s')
print(f'End time......: {strftime(settings.time_format)}')
print('')
LOGGER.critical('Data creation finished')
sys.exit()
match settings.backend:
case 'RESTAPI':
case Backends.RESTAPI:
host_cache: HostCache = HostCacheRestApi(
pre_fetch=settings.pre_fetch,
api_port=settings.api_port,
filter_sites=settings.filter_sites,
sites=settings.sites
sites=settings.sites,
)
case 'MULTISITE':
case Backends.MULTISITE:
host_cache: HostCache = HostCacheMultiSite(
pre_fetch=settings.pre_fetch,
filter_sites=settings.filter_sites,
......@@ -328,42 +359,47 @@ def main():
filter_customers=settings.filter_customers,
customers=settings.customers,
)
case 'LIVESTATUS':
case Backends.LIVESTATUS:
host_cache: HostCache = HostCacheLiveStatus(
pre_fetch=settings.pre_fetch,
)
case _:
LOGGER.error(msg=f'Backend {settings.backend} not (yet) implemented')
LOGGER.error(msg=f'Backend {settings.backend} not implemented')
host_cache: HostCache | None = None # to keep linter happy
sys.exit(ExitCodes.BACKEND_NOT_IMPLEMENTED)
jobs: List[Layer] = []
host_cache.init_neighbour_to_host(
case=settings.case,
l2_host_map=settings.l2_host_map,
prefix=settings.prefix,
remove_domain=settings.remove_domain,
)
jobs: MutableSequence = []
pre_fetch_layers: List[str] = []
pre_fetch_host_list: List[str] = []
for layer in settings.layers:
match layer:
case 'STATIC':
case Layers.STATIC:
jobs.append(layer)
case Layers.L3V4:
jobs.append(layer)
case 'L3v4':
host_cache.add_inventory_path(path=InvPaths.L3)
pre_fetch_layers.append(HostLabels.L3V4_ROUTER)
case Layers.CDP | Layers.LLDP:
jobs.append(layer)
host_cache.add_inventory_path(path=LAYERS[layer].path)
pre_fetch_layers.append(LAYERS[layer].host_label)
case 'CUSTOM':
for entry in settings.custom_layers:
jobs.append(entry)
host_cache.add_inventory_path(entry.path)
case 'CDP' | 'LLDP':
jobs.append(LAYERS[layer])
host_cache.add_inventory_path(path=LAYERS[layer].path)
pre_fetch_layers.append(LAYERS[layer].host_label)
host_cache.add_inventory_path(InvPaths.CDP if layer == Layers.CDP else InvPaths.LLDP)
pre_fetch_layers.append(HostLabels.CDP if layer == Layers.CDP else HostLabels.LLDP)
case _:
LOGGER.warning(f'Unknown layer {layer} dropped.')
continue
if not jobs:
message = ('No layer to work on. Please configura at least one layer (i.e. CLI option "-l CDP")\n'
'See ~/local/bin/nvdct/conf/nvdct.toml -> SETTINGS -> layers')
message = (
f'No layer to work on. Please configura at least one layer (i.e. CLI option "-l {Layers.CDP}")\n'
f'See {settings.user_data_file} -> {TomlSections.SETTINGS} -> {TomlSettings.LAYERS}'
)
LOGGER.warning(message)
print(message)
sys.exit(ExitCodes.NO_LAYER_CONFIGURED)
......@@ -371,8 +407,8 @@ def main():
if settings.pre_fetch:
LOGGER.info('Pre fill cache...')
for host_label in pre_fetch_layers:
if _host_list := host_cache.get_hosts_by_label(host_label):
pre_fetch_host_list = list(set(pre_fetch_host_list + _host_list))
if host_list := host_cache.get_hosts_by_label(host_label):
pre_fetch_host_list = list(set(pre_fetch_host_list + list(host_list)))
LOGGER.info(f'Fetching data for {len(pre_fetch_host_list)} hosts start')
print(f'Prefetch start: {strftime(settings.time_format)}')
print(f'Prefetch hosts: {len(pre_fetch_host_list)} of {len(host_cache.cache)}')
......@@ -382,16 +418,14 @@ def main():
for job in jobs:
match job:
case 'STATIC':
case Layers.STATIC:
label = job
topology = TopologyStatic(
connections=settings.static_connections,
emblems=settings.emblems,
host_cache=host_cache,
)
topology.create()
case 'L3v4':
case Layers.L3V4:
label = job
topology = TopologyL3(
emblems=settings.emblems,
......@@ -401,37 +435,42 @@ def main():
ignore_wildcard=settings.l3v4_ignore_wildcard,
include_hosts=settings.include_l3_hosts,
replace=settings.l3_replace,
skip_cidr_0=settings.skip_l3_cidr_0,
skip_cidr_32_128=settings.skip_l3_cidr_32_128,
skip_if=settings.skip_l3_if,
skip_ip=settings.skip_l3_ip,
skip_public=settings.skip_l3_public,
include_loopback=settings.include_l3_loopback,
summarize=settings.l3_summarize,
version=IPVersion.IPv4 if job == LABEL_L3v4 else IPVersion.IPv6
version=IPVersion.IPv4 if job == Layers.L3V4 else IPVersion.IPv6
)
topology.create()
case _:
label = job.label.upper()
columns = job.columns.split(',')
case Layers.CDP | Layers.LLDP:
label = job
if job == Layers.CDP:
host_label = HostLabels.CDP
inv_path = InvPaths.CDP
else:
host_label = HostLabels.LLDP
inv_path = InvPaths.LLDP
if not (seed_devices := settings.l2_seed_devices):
seed_devices = host_cache.get_hosts_by_label(host_label)
topology = TopologyL2(
case=settings.case,
emblems=settings.emblems,
host_cache=host_cache,
inv_columns=InventoryColumns(
neighbour=columns[0],
local_port=columns[1],
neighbour_port=columns[2]
),
l2_drop_hosts=settings.l2_drop_hosts,
l2_host_map=settings.l2_host_map,
l2_drop_neighbours=settings.l2_drop_neighbours,
l2_neighbour_replace_regex=settings.l2_neighbour_replace_regex,
label=label,
path_in_inventory=job.path,
prefix=settings.prefix,
remove_domain=settings.remove_domain,
seed_devices=settings.l2_seed_devices,
path_in_inventory=inv_path,
seed_devices=seed_devices,
display_l2_neighbours=settings.display_l2_neighbours,
)
topology.create()
case _:
LOGGER.warning(f'Unknown layer {job}, ignoring.')
continue
pre_message = f'Layer {label:.<8s}: '
print(pre_message, end ='', flush=True)
topology.create()
topology.nv_connections.add_meta_data_to_connections(
nv_objects=topology.nv_objects,
speed_map=settings.map_speed_to_thickness,
......@@ -440,14 +479,15 @@ def main():
topology.save(
label=label,
output_directory=settings.output_directory,
make_default=settings.default
make_default=settings.default,
dont_compare=settings.dont_compare,
)
message = (
f'Layer {label:.<8s}: Devices/Objects/Connections added {topology.nv_objects.host_count}/'
f'Devices/Objects/Connections added {topology.nv_objects.host_count}/'
f'{len(topology.nv_objects.nv_objects)}/{len(topology.nv_connections.nv_connections)}'
)
LOGGER.info(msg=message)
LOGGER.info(msg=f'{pre_message} {message}')
print(message)
if settings.keep:
......
......@@ -47,7 +47,7 @@
'htdocs/images/icons/location_80.png']},
'name': 'nvdct',
'title': 'Network Visualization Data Creation Tool (NVDCT)',
'version': '0.9.6-20241222',
'version': '0.9.7-20241230',
'version.min_required': '2.3.0b1',
'version.packaged': 'cmk-mkp-tool 0.2.0',
'version.usable_until': '2.4.0p1'}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment