From a714c85d0bd81219b0f4ce904192c411e629143d Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 1 Oct 2024 17:23:29 +0200 Subject: [PATCH 01/20] Update .gitignore to ignore .venv folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1ce52bd..651f6de 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ celerybeat-schedule .env # virtualenv +/.venv/ /venv/ /ENV/ From 53bdcc6d55ae2444f687f6abff560cf3c4eefab0 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 1 Oct 2024 17:42:23 +0200 Subject: [PATCH 02/20] Suppress warning if running from Windows OS --- src/kathara_lab_checker/checks/ReachabilityCheck.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/kathara_lab_checker/checks/ReachabilityCheck.py b/src/kathara_lab_checker/checks/ReachabilityCheck.py index 2dc1d39..0855618 100644 --- a/src/kathara_lab_checker/checks/ReachabilityCheck.py +++ b/src/kathara_lab_checker/checks/ReachabilityCheck.py @@ -32,12 +32,14 @@ def check(self, device_name: str, destination: str, lab: Lab) -> CheckResult: output = get_output(exec_output_gen).replace("ERROR: ", "") try: - parsed_output = jc.parse("ping", output) - if int(parsed_output['packets_received']) > 0: + parsed_output = jc.parse("ping", output, quiet=True) + if int(parsed_output["packets_received"]) > 0: reason = f"`{device_name}` can reach `{destination}`." if invert else "OK" return CheckResult(self.description, invert ^ True, reason) else: - reason = "OK" if invert else f"`{device_name}` does not receive any answer from `{destination}`." + reason = ( + "OK" if invert else f"`{device_name}` does not receive any answer from `{destination}`." + ) return CheckResult(self.description, invert ^ False, reason) except Exception: return CheckResult(self.description, False, output.strip()) From cce955ba8f9375271c5df6602725483bba0e783a Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 1 Oct 2024 18:31:44 +0200 Subject: [PATCH 03/20] More readable error for RouteCheck --- src/kathara_lab_checker/checks/KernelRouteCheck.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index bae0a33..8ce1724 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -30,7 +30,7 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis check_result = CheckResult( self.description, False, - f"The routing table of {device_name} about route {dst} have the wrong next-hops: {nexthops ^ actual_nh}", + f"The routing table of {device_name} about route {dst} have the wrong next-hops: {actual_nh}, expected: {nexthops}", ) results.append(check_result) self.logger.info(check_result) From 946a17f4bb44754dedc585be90a1afdf9d0682ef Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 1 Oct 2024 18:47:52 +0200 Subject: [PATCH 04/20] Allow to check ip_mapping for full name of interface --- README.md | 5 +++-- src/kathara_lab_checker/checks/InterfaceIPCheck.py | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 18bdaaa..26ce0c1 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ In the following you will find the possible values for the configuration file. ], "ip_mapping": { "": { - ">": "" # Check that the ip/netmask is configured on the interface of the device + ">": "" # Check that the ip/netmask is configured on the interface of the device + ">": "" # Check that the ip/netmask is configured on the interface eth# of the device }, }, "daemons": { @@ -81,7 +82,7 @@ In the following you will find the possible values for the configuration file. "kernel_routes": { "": [ "", # Check the presence of the route in the data-plane of the device - "[, [, ]]" # Check the presence of the route in the data-plane of the device + "[, [, , ]]" # Check the presence of the route in the data-plane of the device # And checks also that the nexthops are set on the correct interfaces ] }, diff --git a/src/kathara_lab_checker/checks/InterfaceIPCheck.py b/src/kathara_lab_checker/checks/InterfaceIPCheck.py index 72b4dca..4b10324 100644 --- a/src/kathara_lab_checker/checks/InterfaceIPCheck.py +++ b/src/kathara_lab_checker/checks/InterfaceIPCheck.py @@ -11,14 +11,13 @@ class InterfaceIPCheck(AbstractCheck): def check(self, device_name: str, interface_number: int, ip: str, dumped_iface: dict) -> CheckResult: - interface_name = f"eth{interface_number}" + interface_name = f"eth{interface_number}" if interface_number.isnumeric() else interface_number self.description = f"Verifying the IP address ({ip}) assigned to {interface_name} of {device_name}" try: iface_info = next(filter(lambda x: x["ifname"] == f"{interface_name}", dumped_iface)) except StopIteration: - return CheckResult(self.description, False, - f"Interface `{interface_name}` not found on `{device_name}`") + return CheckResult(self.description, False, f"Interface `{interface_name}` not found on `{device_name}`") ip_address = ipaddress.ip_interface(ip) @@ -35,8 +34,10 @@ def check(self, device_name: str, interface_number: int, ip: str, dumped_iface: reason = f"The IP address has a wrong netmask ({prefix_len})" return CheckResult(self.description, False, reason) - reason = (f"The interface `{iface_info['ifname']}` of `{device_name}` " - f"has the following IP addresses: {assigned_ips}`.") + reason = ( + f"The interface `{iface_info['ifname']}` of `{device_name}` " + f"has the following IP addresses: {assigned_ips}`." + ) return CheckResult(self.description, False, reason) def run(self, ip_mapping: dict, lab: Lab) -> list[CheckResult]: From e5bae05b8069d58c7b5894e724478cf8eae46b51 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 1 Oct 2024 21:40:13 +0200 Subject: [PATCH 05/20] Fix nexthop check --- .../checks/KernelRouteCheck.py | 21 ++++- src/kathara_lab_checker/utils.py | 81 ++++++++++++++----- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index 8ce1724..d6f28b5 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -3,7 +3,7 @@ from Kathara.exceptions import MachineNotRunningError from Kathara.model.Lab import Lab -from kathara_lab_checker.utils import get_kernel_routes, load_routes_from_ip_route, load_routes_from_expected +from kathara_lab_checker.utils import is_valid_ip, load_routes_from_device, load_routes_from_expected from .AbstractCheck import AbstractCheck from .CheckResult import CheckResult @@ -11,7 +11,7 @@ class KernelRouteCheck(AbstractCheck): def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> list[CheckResult]: self.description = f"Checking the routing table of {device_name}" - actual_routing_table = load_routes_from_ip_route(get_kernel_routes(device_name, lab)) + actual_routing_table = load_routes_from_device(device_name, lab) expected_routing_table = load_routes_from_expected(expected_routing_table) results = [] @@ -26,14 +26,27 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis continue if nexthops: actual_nh = actual_routing_table[dst] - if actual_nh != nexthops: + if len(nexthops) != len(actual_nh): check_result = CheckResult( self.description, False, - f"The routing table of {device_name} about route {dst} have the wrong next-hops: {actual_nh}, expected: {nexthops}", + f"The routing table of {device_name} about route {dst} have the wrong number of next-hops: {len(actual_nh)}, expected: {len(nexthops)}", ) results.append(check_result) self.logger.info(check_result) + continue + for nh in nexthops: + valid_ip = is_valid_ip(nh) + if (valid_ip and not any(item[0] == nh for item in actual_nh)) or ( + not valid_ip and not any(item[1] == nh for item in actual_nh) + ): + check_result = CheckResult( + self.description, + False, + f"The routing table of {device_name} about route {dst} does not contain next-hop: {nh}, actual: {actual_nh}", + ) + results.append(check_result) + self.logger.info(check_result) if not results: check_result = CheckResult(self.description, True, f"OK") diff --git a/src/kathara_lab_checker/utils.py b/src/kathara_lab_checker/utils.py index 3117e33..9db92c1 100644 --- a/src/kathara_lab_checker/utils.py +++ b/src/kathara_lab_checker/utils.py @@ -1,3 +1,4 @@ +import ipaddress import json import os from typing import Any, Optional @@ -51,19 +52,6 @@ def get_interfaces_addresses(device_name: str, lab: Lab) -> dict: return json.loads(get_output(exec_output_gen)) -def get_kernel_routes(device_name: str, lab: Lab) -> list[dict[str, Any]]: - kathara_manager = Kathara.get_instance() - - try: - output = get_output( - kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j route") - ) - except MachineNotRunningError: - return [] - - return json.loads(output) - - def find_device_name_from_ip(ip_mapping: dict[str, dict[str, str]], ip_search: str) -> Optional[str]: for device, ip_addresses in ip_mapping.items(): for _, ip in ip_addresses.items(): @@ -174,9 +162,7 @@ def write_result_to_excel(check_results: list["CheckResultPackage.CheckResult"], check_result.reason, ) failed_index += 1 - _write_sheet_row( - sheet_all, index, check_result.description, str(check_result.passed), check_result.reason - ) + _write_sheet_row(sheet_all, index, check_result.description, str(check_result.passed), check_result.reason) workbook.save(os.path.join(path, f"{os.path.basename(path)}_result.xlsx")) @@ -198,17 +184,70 @@ def load_routes_from_expected(expected_routes: list) -> dict[str, set]: return routes -def load_routes_from_ip_route(ip_route_output: list) -> dict[str, set]: +def get_kernel_routes(device_name: str, lab: Lab) -> list[dict[str, Any]]: + kathara_manager = Kathara.get_instance() + + try: + output = get_output(kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j route")) + except MachineNotRunningError: + return [] + + return json.loads(output) + + +def get_nexthops(device_name: str, lab: Lab) -> list[dict[str, Any]]: + kathara_manager = Kathara.get_instance() + + try: + output = get_output(kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j nexthop")) + except MachineNotRunningError: + return [] + + return json.loads(output) + + +def load_routes_from_device(device_name: str, lab: Lab) -> dict[str, set]: + ip_route_output = get_kernel_routes(device_name, lab) routes = {} + kernel_nexthops = None + for route in ip_route_output: dst = route["dst"] if dst == "default": dst = "0.0.0.0/0" nexthops = None - if "nexthops" in route: + if "scope" in route and route["scope"] == "link": + nexthops = [("d.c.", route["dev"])] + elif "nexthops" in route: + print("ao ci sta un nexthop\n\n\n\n\n") nexthops = list(map(lambda x: x["dev"], route["nexthops"])) - if "gateway" in route: - nexthops = [route["gateway"]] - routes[dst] = set(nexthops) if nexthops else set() + elif "gateway" in route: + nexthops = [(route["gateway"], route["dev"])] + elif "via" in route: + nexthops = [(route["via"]["host"], route["dev"])] + elif "nhid" in route: + # Lazy load nexthops + kernel_nexthops = get_nexthops(device_name, lab) if kernel_nexthops is None else kernel_nexthops + + current_nexthop = [obj for obj in kernel_nexthops if obj["id"] == route["nhid"]][0] + if "gateway" in current_nexthop: + nexthops = [(current_nexthop["gateway"], current_nexthop["dev"])] + elif "group" in current_nexthop: + nexthops = [ + (obj["gateway"], obj["dev"]) + for obj in kernel_nexthops + if obj["id"] in (nhid["id"] for nhid in current_nexthop["group"]) + ] + else: + raise Exception("Strange nexthop: ", current_nexthop) + routes[dst] = set(nexthops) return routes + + +def is_valid_ip(ip_str): + try: + ipaddress.ip_address(ip_str) + return True + except ValueError: + return False From 752e21d3f0745c8dd7ea50e29d92f6f733055cb1 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Wed, 2 Oct 2024 12:52:38 +0200 Subject: [PATCH 06/20] Add equality check for routing table --- .../checks/KernelRouteCheck.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index d6f28b5..d08a8ba 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -11,11 +11,26 @@ class KernelRouteCheck(AbstractCheck): def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> list[CheckResult]: self.description = f"Checking the routing table of {device_name}" - actual_routing_table = load_routes_from_device(device_name, lab) + actual_routing_table = dict( + filter( + lambda item: not any("d.c." in elem for elem in item[1]), + load_routes_from_device(device_name, lab).items(), + ) + ) expected_routing_table = load_routes_from_expected(expected_routing_table) results = [] + if len(expected_routing_table) != len(actual_routing_table): + check_result = CheckResult( + self.description, + False, + f"The routing table of {device_name} have the wrong number of routes: {len(expected_routing_table)}, expected: {len(actual_routing_table)}", + ) + results.append(check_result) + self.logger.info(check_result) + return results + for dst, nexthops in expected_routing_table.items(): if not dst in actual_routing_table: check_result = CheckResult( From d7f3c5d4eaa8922b9680b508dc88e6c5399bb509 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Wed, 2 Oct 2024 14:19:59 +0200 Subject: [PATCH 07/20] Remove useless print --- src/kathara_lab_checker/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/kathara_lab_checker/utils.py b/src/kathara_lab_checker/utils.py index 9db92c1..a2895cd 100644 --- a/src/kathara_lab_checker/utils.py +++ b/src/kathara_lab_checker/utils.py @@ -220,7 +220,6 @@ def load_routes_from_device(device_name: str, lab: Lab) -> dict[str, set]: if "scope" in route and route["scope"] == "link": nexthops = [("d.c.", route["dev"])] elif "nexthops" in route: - print("ao ci sta un nexthop\n\n\n\n\n") nexthops = list(map(lambda x: x["dev"], route["nexthops"])) elif "gateway" in route: nexthops = [(route["gateway"], route["dev"])] From b4808e29d0e75f69d9c2c501c346a78cc547226d Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Wed, 2 Oct 2024 18:01:05 +0200 Subject: [PATCH 08/20] Add check for DNS records --- README.md | 11 ++-- .../checks/KernelRouteCheck.py | 2 +- .../checks/applications/dns/DNSRecordCheck.py | 40 +++++++++++++ src/main.py | 57 ++++++++++++------- 4 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py diff --git a/README.md b/README.md index 26ce0c1..19d7c63 100644 --- a/README.md +++ b/README.md @@ -120,16 +120,19 @@ In the following you will find the possible values for the configuration file. "", # Check if the device has the local_ns_ip as local name server. ] }, - "reachability": { - "": [ - "", # Check if device name reaches the dns_name - ] + "records": { + "A": { # The software can check for every type of DNS records + "": [ + "" # Check if the dns_name is resolved to the ip + ] + } } } }, "reachability": { # Check reachability between devices "": [ "", # Check if the device reaches the ip + "", # Check if the device reaches the dns_name ], } } diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index d08a8ba..65ab704 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -25,7 +25,7 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis check_result = CheckResult( self.description, False, - f"The routing table of {device_name} have the wrong number of routes: {len(expected_routing_table)}, expected: {len(actual_routing_table)}", + f"The routing table of {device_name} have the wrong number of routes: {len(actual_routing_table)}, expected: {len(expected_routing_table)}", ) results.append(check_result) self.logger.info(check_result) diff --git a/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py b/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py new file mode 100644 index 0000000..5997a76 --- /dev/null +++ b/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py @@ -0,0 +1,40 @@ +from kathara_lab_checker.checks.AbstractCheck import AbstractCheck +from kathara_lab_checker.checks.CheckResult import CheckResult +from Kathara.model.Lab import Lab +from Kathara.manager.Kathara import Kathara + +from kathara_lab_checker.utils import get_output + + +class DNSRecordCheck(AbstractCheck): + + def run( + self, + records: dict[str, dict[str, list[str]]], + machines_with_dns: list[str], + lab: Lab, + ) -> list[CheckResult]: + results = [] + kathara_manager: Kathara = Kathara.get_instance() + + for recordtype, recordvalue in records.items(): + for record, addresses in recordvalue.items(): + for client in machines_with_dns: + exec_output_gen = kathara_manager.exec( + machine_name=client, + command=f"dig +short {recordtype} {record}", + lab_hash=lab.hash, + ) + ip = get_output(exec_output_gen).strip() + if ip in addresses: + check_result = CheckResult("Checking correctness of DNS records", True, "OK") + else: + check_result = CheckResult( + "Checking correctness of DNS records", + False, + f"{client} resolve {recordtype} {record} with IP {ip} instead of {addresses}", + ) + check_result + self.logger.info(check_result) + results.append(check_result) + return results diff --git a/src/main.py b/src/main.py index 651bd3f..87f9fd1 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import argparse -import importlib.metadata import json import logging import os @@ -37,6 +36,7 @@ from kathara_lab_checker.checks.protocols.evpn.EVPNSessionCheck import EVPNSessionCheck from kathara_lab_checker.checks.protocols.evpn.VTEPCheck import VTEPCheck from kathara_lab_checker.utils import reverse_dictionary, write_final_results_to_excel, write_result_to_excel +from kathara_lab_checker.checks.applications.dns.DNSRecordCheck import DNSRecordCheck VERSION = "0.1.2" @@ -51,9 +51,15 @@ def handler(signum, frame, live=False): exit(1) -def run_on_single_network_scenario(lab_path: str, configuration: dict, lab_template: Lab, - no_cache: bool = False, live: bool = False, keep_open: bool = False, - skip_report: bool = False): +def run_on_single_network_scenario( + lab_path: str, + configuration: dict, + lab_template: Lab, + no_cache: bool = False, + live: bool = False, + keep_open: bool = False, + skip_report: bool = False, +): global CURRENT_LAB logger = logging.getLogger("kathara-lab-checker") @@ -204,8 +210,10 @@ def run_on_single_network_scenario(lab_path: str, configuration: dict, lab_templ check_results = LocalNSCheck().run(application["local_ns"], lab) test_collector.add_check_results(lab_name, check_results) - logger.info(f"Starting reachability test for DNS...") - check_results = ReachabilityCheck().run(reverse_dictionary(application["reachability"]), lab) + logger.info(f"Starting test for DNS records...") + check_results = DNSRecordCheck().run( + application["records"], reverse_dictionary(application["local_ns"]).keys(), lab + ) test_collector.add_check_results(lab_name, check_results) if not live and not keep_open: @@ -224,8 +232,15 @@ def run_on_single_network_scenario(lab_path: str, configuration: dict, lab_templ return test_collector -def run_on_multiple_network_scenarios(labs_path: str, configuration: dict, lab_template: Lab, no_cache: bool = False, - live: bool = False, keep_open: bool = False, skip_report: bool = False): +def run_on_multiple_network_scenarios( + labs_path: str, + configuration: dict, + lab_template: Lab, + no_cache: bool = False, + live: bool = False, + keep_open: bool = False, + skip_report: bool = False, +): logger = logging.getLogger("kathara-lab-checker") labs_path = os.path.abspath(labs_path) @@ -233,12 +248,12 @@ def run_on_multiple_network_scenarios(labs_path: str, configuration: dict, lab_t test_collector = TestCollector() for lab_name in tqdm( - list( - filter( - lambda x: os.path.isdir(os.path.join(labs_path, x)) and x != ".DS_Store", - os.listdir(labs_path), - ) + list( + filter( + lambda x: os.path.isdir(os.path.join(labs_path, x)) and x != ".DS_Store", + os.listdir(labs_path), ) + ) ): test_results = run_on_single_network_scenario( os.path.join(labs_path, lab_name), configuration, lab_template, no_cache, live, keep_open, skip_report @@ -264,11 +279,7 @@ def parse_arguments(): help="The path to the configuration file for the tests", ) - parser.add_argument( - '-v', '--version', - action='version', - version=f'kathara-lab-checker {VERSION}' - ) + parser.add_argument("-v", "--version", action="version", version=f"kathara-lab-checker {VERSION}") parser.add_argument( "--no-cache", @@ -344,11 +355,13 @@ def main(): ) if args.lab: - run_on_single_network_scenario(args.lab, conf, template_lab, args.no_cache, args.live, args.keep_open, - args.skip_report) + run_on_single_network_scenario( + args.lab, conf, template_lab, args.no_cache, args.live, args.keep_open, args.skip_report + ) elif args.labs: - run_on_multiple_network_scenarios(args.labs, conf, template_lab, args.no_cache, args.live, args.keep_open, - args.skip_report) + run_on_multiple_network_scenarios( + args.labs, conf, template_lab, args.no_cache, args.live, args.keep_open, args.skip_report + ) if __name__ == "__main__": From 5f02dad0e48c8c720c47f7971161bef9c76fffc0 Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Thu, 3 Oct 2024 12:47:59 +0200 Subject: [PATCH 09/20] Reformat files and optimize imports --- src/kathara_lab_checker/TqdmLoggingHandler.py | 1 + src/kathara_lab_checker/checks/BridgeCheck.py | 25 +++++------ .../checks/CollisionDomainCheck.py | 2 +- src/kathara_lab_checker/checks/DaemonCheck.py | 2 +- .../checks/DeviceExistenceCheck.py | 2 +- .../checks/IPv6EnabledCheck.py | 3 +- .../checks/KernelRouteCheck.py | 2 +- src/kathara_lab_checker/checks/SysctlCheck.py | 3 +- .../applications/dns/DNSAuthorityCheck.py | 12 +++--- .../checks/applications/dns/DNSRecordCheck.py | 14 +++---- .../checks/applications/dns/LocalNSCheck.py | 2 +- .../protocols/ProtocolRedistributionCheck.py | 2 +- .../protocols/evpn/AnnouncedVNICheck.py | 2 +- src/main.py | 41 +++++++++---------- 14 files changed, 56 insertions(+), 57 deletions(-) diff --git a/src/kathara_lab_checker/TqdmLoggingHandler.py b/src/kathara_lab_checker/TqdmLoggingHandler.py index c2fea27..438fc85 100644 --- a/src/kathara_lab_checker/TqdmLoggingHandler.py +++ b/src/kathara_lab_checker/TqdmLoggingHandler.py @@ -1,4 +1,5 @@ import logging + import tqdm diff --git a/src/kathara_lab_checker/checks/BridgeCheck.py b/src/kathara_lab_checker/checks/BridgeCheck.py index 0e5ea4d..06b76e1 100644 --- a/src/kathara_lab_checker/checks/BridgeCheck.py +++ b/src/kathara_lab_checker/checks/BridgeCheck.py @@ -1,4 +1,5 @@ import json + from Kathara.exceptions import MachineNotRunningError from Kathara.manager.Kathara import Kathara from Kathara.model.Lab import Lab @@ -22,8 +23,8 @@ def get_inteface_by_vni(interface_vni: str, interfaces: list[dict]): return list( filter( lambda x: "linkinfo" in x - and "id" in x["linkinfo"]["info_data"] - and x["linkinfo"]["info_data"]["id"] == int(interface_vni), + and "id" in x["linkinfo"]["info_data"] + and x["linkinfo"]["info_data"]["id"] == int(interface_vni), interfaces, ) ).pop() @@ -31,7 +32,7 @@ def get_inteface_by_vni(interface_vni: str, interfaces: list[dict]): class BridgeCheck(AbstractCheck): def check_bridge_interfaces( - self, device_name: str, expected_interfaces: list[str], actual_interfaces: list[dict] + self, device_name: str, expected_interfaces: list[str], actual_interfaces: list[dict] ) -> (CheckResult, set[str]): self.description = ( f"Checking that interfaces {expected_interfaces} " @@ -90,8 +91,8 @@ def check_vlan_filtering(self, device_name: str, bridge_info: dict) -> CheckResu f"Checking if VLAN filtering is enabled on `{bridge_info['ifname']}` of `{device_name}`" ) if ( - "vlan_filtering" in bridge_info["linkinfo"]["info_data"] - and bridge_info["linkinfo"]["info_data"]["vlan_filtering"] == 1 + "vlan_filtering" in bridge_info["linkinfo"]["info_data"] + and bridge_info["linkinfo"]["info_data"]["vlan_filtering"] == 1 ): return CheckResult(self.description, True, "OK") else: @@ -102,11 +103,11 @@ def check_vlan_filtering(self, device_name: str, bridge_info: dict) -> CheckResu ) def check_vlan_tags( - self, - device_name: str, - interface_name: str, - interface_configuration: dict, - actual_interface_vlan: dict, + self, + device_name: str, + interface_name: str, + interface_configuration: dict, + actual_interface_vlan: dict, ): self.description = ( f"Checking that vlans `{interface_configuration['vlan_tags']}` " @@ -124,7 +125,7 @@ def check_vlan_tags( return CheckResult(self.description, False, reason) def check_vxlan_pvid( - self, device_name: str, vni: str, pvid: str, actual_interfaces: list[dict], vlans_info: list[dict] + self, device_name: str, vni: str, pvid: str, actual_interfaces: list[dict], vlans_info: list[dict] ): self.description = f"Checking that `{device_name}` manages VNI `{vni}` with PVID `{pvid}`" @@ -151,7 +152,7 @@ def check_vxlan_pvid( return CheckResult(self.description, False, f"VNI `{vni}` not found on `{device_name}`") def check_vlan_pvid( - self, device_name: str, interface_name: str, interface_pvid: str, actual_interface_vlan: dict + self, device_name: str, interface_name: str, interface_pvid: str, actual_interface_vlan: dict ): self.description = f"Checking that `{interface_name}` of `{device_name}` has pvid {interface_pvid}" pvid = set( diff --git a/src/kathara_lab_checker/checks/CollisionDomainCheck.py b/src/kathara_lab_checker/checks/CollisionDomainCheck.py index d313c28..78e7d45 100644 --- a/src/kathara_lab_checker/checks/CollisionDomainCheck.py +++ b/src/kathara_lab_checker/checks/CollisionDomainCheck.py @@ -29,4 +29,4 @@ def run(self, template_cds: list[Link], lab: Lab) -> list[CheckResult]: check_result = self.check(cd_t, lab) self.logger.info(check_result) results.append(check_result) - return results \ No newline at end of file + return results diff --git a/src/kathara_lab_checker/checks/DaemonCheck.py b/src/kathara_lab_checker/checks/DaemonCheck.py index 386e07d..6966fa9 100644 --- a/src/kathara_lab_checker/checks/DaemonCheck.py +++ b/src/kathara_lab_checker/checks/DaemonCheck.py @@ -41,4 +41,4 @@ def run(self, devices_to_daemons: dict[str, list[str]], lab: Lab) -> list[CheckR check_result = self.check(device_name, daemon_name, lab) self.logger.info(check_result) results.append(check_result) - return results \ No newline at end of file + return results diff --git a/src/kathara_lab_checker/checks/DeviceExistenceCheck.py b/src/kathara_lab_checker/checks/DeviceExistenceCheck.py index 9b1862d..bef891a 100644 --- a/src/kathara_lab_checker/checks/DeviceExistenceCheck.py +++ b/src/kathara_lab_checker/checks/DeviceExistenceCheck.py @@ -22,4 +22,4 @@ def run(self, template_machines: list[str], lab: Lab) -> list[CheckResult]: check_result = self.check(device_name, lab) self.logger.info(check_result) results.append(check_result) - return results \ No newline at end of file + return results diff --git a/src/kathara_lab_checker/checks/IPv6EnabledCheck.py b/src/kathara_lab_checker/checks/IPv6EnabledCheck.py index df5c04a..5af3915 100644 --- a/src/kathara_lab_checker/checks/IPv6EnabledCheck.py +++ b/src/kathara_lab_checker/checks/IPv6EnabledCheck.py @@ -1,5 +1,4 @@ -from Kathara.exceptions import LinkNotFoundError, MachineNotFoundError -from Kathara.model import Link +from Kathara.exceptions import MachineNotFoundError from Kathara.model.Lab import Lab from .AbstractCheck import AbstractCheck diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index 65ab704..50c4d39 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -53,7 +53,7 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis for nh in nexthops: valid_ip = is_valid_ip(nh) if (valid_ip and not any(item[0] == nh for item in actual_nh)) or ( - not valid_ip and not any(item[1] == nh for item in actual_nh) + not valid_ip and not any(item[1] == nh for item in actual_nh) ): check_result = CheckResult( self.description, diff --git a/src/kathara_lab_checker/checks/SysctlCheck.py b/src/kathara_lab_checker/checks/SysctlCheck.py index be2a3be..f1b0516 100644 --- a/src/kathara_lab_checker/checks/SysctlCheck.py +++ b/src/kathara_lab_checker/checks/SysctlCheck.py @@ -1,5 +1,4 @@ -from Kathara.exceptions import LinkNotFoundError, MachineNotFoundError -from Kathara.model import Link +from Kathara.exceptions import MachineNotFoundError from Kathara.model.Lab import Lab from .AbstractCheck import AbstractCheck diff --git a/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py b/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py index 203a0dd..3f739aa 100644 --- a/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py +++ b/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py @@ -12,7 +12,7 @@ class DNSAuthorityCheck(AbstractCheck): def check( - self, domain: str, authority_ip: str, device_name: str, device_ip: str, lab: Lab + self, domain: str, authority_ip: str, device_name: str, device_ip: str, lab: Lab ) -> CheckResult: self.description = ( f"Checking on `{device_name}` that `{authority_ip}` is the authority for domain `{domain}`" @@ -80,11 +80,11 @@ def check( return CheckResult(self.description, False, reason) def run( - self, - zone_to_authoritative_ips: dict[str, list[str]], - local_nameservers: list[str], - ip_mapping: dict[str, dict[str, str]], - lab: Lab, + self, + zone_to_authoritative_ips: dict[str, list[str]], + local_nameservers: list[str], + ip_mapping: dict[str, dict[str, str]], + lab: Lab, ) -> list[CheckResult]: results = [] for domain, name_servers in zone_to_authoritative_ips.items(): diff --git a/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py b/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py index 5997a76..22a5435 100644 --- a/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py +++ b/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py @@ -1,18 +1,18 @@ -from kathara_lab_checker.checks.AbstractCheck import AbstractCheck -from kathara_lab_checker.checks.CheckResult import CheckResult -from Kathara.model.Lab import Lab from Kathara.manager.Kathara import Kathara +from Kathara.model.Lab import Lab +from kathara_lab_checker.checks.AbstractCheck import AbstractCheck +from kathara_lab_checker.checks.CheckResult import CheckResult from kathara_lab_checker.utils import get_output class DNSRecordCheck(AbstractCheck): def run( - self, - records: dict[str, dict[str, list[str]]], - machines_with_dns: list[str], - lab: Lab, + self, + records: dict[str, dict[str, list[str]]], + machines_with_dns: list[str], + lab: Lab, ) -> list[CheckResult]: results = [] kathara_manager: Kathara = Kathara.get_instance() diff --git a/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py b/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py index 7e4f5c6..2f21234 100644 --- a/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py +++ b/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py @@ -40,4 +40,4 @@ def run(self, local_nameservers_to_devices: dict[str, list[str]], lab: Lab) -> l check_result = self.check(local_ns, device_name, lab) self.logger.info(check_result) results.append(check_result) - return results \ No newline at end of file + return results diff --git a/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py b/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py index 6e4e8fd..98c8f1b 100644 --- a/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py +++ b/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py @@ -49,4 +49,4 @@ def run(self, protocol, devices_to_redistributed: dict[str, list[str]], lab: Lab check_result = self.check(device_name, protocol, injected_protocol, lab) self.logger.info(check_result) results.append(check_result) - return results \ No newline at end of file + return results diff --git a/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py b/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py index 895b3f3..5fa4362 100644 --- a/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py +++ b/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py @@ -42,7 +42,7 @@ def check(self, device_name: str, lab: Lab, invert: bool = False) -> CheckResult def run(self, device_to_vnis_info: dict[str, dict], evpn_devices: list[str], lab: Lab) -> list[CheckResult]: results = [] for device_name in device_to_vnis_info.keys(): - check_result = self.check(device_name, lab) + check_result = self.check(device_name, lab) self.logger.info(check_result) results.append(check_result) diff --git a/src/main.py b/src/main.py index 87f9fd1..72d05d0 100644 --- a/src/main.py +++ b/src/main.py @@ -28,6 +28,7 @@ from kathara_lab_checker.checks.StartupExistenceCheck import StartupExistenceCheck from kathara_lab_checker.checks.SysctlCheck import SysctlCheck from kathara_lab_checker.checks.applications.dns.DNSAuthorityCheck import DNSAuthorityCheck +from kathara_lab_checker.checks.applications.dns.DNSRecordCheck import DNSRecordCheck from kathara_lab_checker.checks.applications.dns.LocalNSCheck import LocalNSCheck from kathara_lab_checker.checks.protocols.AnnouncedNetworkCheck import AnnouncedNetworkCheck from kathara_lab_checker.checks.protocols.ProtocolRedistributionCheck import ProtocolRedistributionCheck @@ -36,8 +37,6 @@ from kathara_lab_checker.checks.protocols.evpn.EVPNSessionCheck import EVPNSessionCheck from kathara_lab_checker.checks.protocols.evpn.VTEPCheck import VTEPCheck from kathara_lab_checker.utils import reverse_dictionary, write_final_results_to_excel, write_result_to_excel -from kathara_lab_checker.checks.applications.dns.DNSRecordCheck import DNSRecordCheck - VERSION = "0.1.2" CURRENT_LAB: Optional[Lab] = None @@ -52,13 +51,13 @@ def handler(signum, frame, live=False): def run_on_single_network_scenario( - lab_path: str, - configuration: dict, - lab_template: Lab, - no_cache: bool = False, - live: bool = False, - keep_open: bool = False, - skip_report: bool = False, + lab_path: str, + configuration: dict, + lab_template: Lab, + no_cache: bool = False, + live: bool = False, + keep_open: bool = False, + skip_report: bool = False, ): global CURRENT_LAB logger = logging.getLogger("kathara-lab-checker") @@ -233,13 +232,13 @@ def run_on_single_network_scenario( def run_on_multiple_network_scenarios( - labs_path: str, - configuration: dict, - lab_template: Lab, - no_cache: bool = False, - live: bool = False, - keep_open: bool = False, - skip_report: bool = False, + labs_path: str, + configuration: dict, + lab_template: Lab, + no_cache: bool = False, + live: bool = False, + keep_open: bool = False, + skip_report: bool = False, ): logger = logging.getLogger("kathara-lab-checker") labs_path = os.path.abspath(labs_path) @@ -248,12 +247,12 @@ def run_on_multiple_network_scenarios( test_collector = TestCollector() for lab_name in tqdm( - list( - filter( - lambda x: os.path.isdir(os.path.join(labs_path, x)) and x != ".DS_Store", - os.listdir(labs_path), + list( + filter( + lambda x: os.path.isdir(os.path.join(labs_path, x)) and x != ".DS_Store", + os.listdir(labs_path), + ) ) - ) ): test_results = run_on_single_network_scenario( os.path.join(labs_path, lab_name), configuration, lab_template, no_cache, live, keep_open, skip_report From 512625ebce1f63e346ca65c035ab2d8307d9924b Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Thu, 3 Oct 2024 15:48:06 +0200 Subject: [PATCH 10/20] Add custom command check --- src/kathara_lab_checker/checks/CheckResult.py | 4 ++ .../checks/CustomCommandCheck.py | 57 +++++++++++++++++++ src/main.py | 13 ++++- 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/kathara_lab_checker/checks/CustomCommandCheck.py diff --git a/src/kathara_lab_checker/checks/CheckResult.py b/src/kathara_lab_checker/checks/CheckResult.py index 563b94b..28ce78f 100644 --- a/src/kathara_lab_checker/checks/CheckResult.py +++ b/src/kathara_lab_checker/checks/CheckResult.py @@ -1,3 +1,5 @@ +import logging + from kathara_lab_checker.utils import green, red @@ -7,6 +9,8 @@ def __init__(self, description: str, passed: bool, reason: str) -> None: self.description: str = description self.passed: bool = passed self.reason: str = reason + logging.getLogger("kathara-lab-checker").info(self) + def __str__(self) -> str: return f"{self.description}: {green(self.reason) if self.passed else red(self.reason)}" diff --git a/src/kathara_lab_checker/checks/CustomCommandCheck.py b/src/kathara_lab_checker/checks/CustomCommandCheck.py new file mode 100644 index 0000000..432ae2d --- /dev/null +++ b/src/kathara_lab_checker/checks/CustomCommandCheck.py @@ -0,0 +1,57 @@ +import re + +from Kathara.exceptions import MachineNotFoundError +from Kathara.manager.Kathara import Kathara +from Kathara.model.Lab import Lab + +from .AbstractCheck import AbstractCheck +from .CheckResult import CheckResult + + +class CustomCommandCheck(AbstractCheck): + + def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) -> list[CheckResult]: + kathara_manager: Kathara = Kathara.get_instance() + + self.description = f"Checking the output of the command '{command_entry['command']}' on '{device_name}'" + results = [] + try: + device = lab.get_machine(device_name) + stdout, stderr, exit_code = kathara_manager.exec(machine_name=device.name, lab_hash=lab.hash, + command=command_entry["command"], stream=False) + stdout = stdout.decode("utf-8").strip() + if "exit_code" in command_entry: + if exit_code == command_entry["exit_code"]: + results.append(CheckResult(self.description, True, "OK")) + else: + reason = (f"The exit_code of the command differs from the expected one." + f"\nActual: {exit_code}\nExpected: {command_entry['exit_code']}") + results.append(CheckResult(self.description, False, reason)) + + if "output" in command_entry: + if stdout == command_entry["output"]: + results.append(CheckResult(self.description, True, "OK")) + else: + reason = (f"The output of the command differs from the expected one." + f"\nActual: {stdout}\nExpected: {command_entry['output']}") + results.append(CheckResult(self.description, False, reason)) + if "regex_match" in command_entry: + if re.match(command_entry["regex_match"], stdout): + results.append(CheckResult(self.description, True, "OK")) + else: + reason = (f"The output of the command do not match the expected regex." + f"\nActual: {stdout}\nRegex: {command_entry['regex_match']}") + results.append(CheckResult(self.description, False, reason)) + + except MachineNotFoundError as e: + results.append(CheckResult(self.description, False, str(e))) + + return results + + def run(self, devices_to_commands: dict[str, list[dict[str, str | int]]], lab: Lab) -> list[CheckResult]: + results = [] + for device_name, command_entries in devices_to_commands.items(): + for command_entry in command_entries: + check_result = self.check(device_name, command_entry, lab) + results.extend(check_result) + return results diff --git a/src/main.py b/src/main.py index 72d05d0..e7ea849 100644 --- a/src/main.py +++ b/src/main.py @@ -19,6 +19,7 @@ from kathara_lab_checker.TestCollector import TestCollector from kathara_lab_checker.checks.BridgeCheck import BridgeCheck from kathara_lab_checker.checks.CollisionDomainCheck import CollisionDomainCheck +from kathara_lab_checker.checks.CustomCommandCheck import CustomCommandCheck from kathara_lab_checker.checks.DaemonCheck import DaemonCheck from kathara_lab_checker.checks.DeviceExistenceCheck import DeviceExistenceCheck from kathara_lab_checker.checks.IPv6EnabledCheck import IPv6EnabledCheck @@ -116,9 +117,10 @@ def run_on_single_network_scenario( check_results = CollisionDomainCheck().run(list(lab_template.links.values()), lab) test_collector.add_check_results(lab_name, check_results) - logger.info("Checking that all required startup files exist...") - check_results = StartupExistenceCheck().run(configuration["test"]["requiring_startup"], lab) - test_collector.add_check_results(lab_name, check_results) + if "requiring_startup" in configuration["test"]: + logger.info("Checking that all required startup files exist...") + check_results = StartupExistenceCheck().run(configuration["test"]["requiring_startup"], lab) + test_collector.add_check_results(lab_name, check_results) if "ipv6_enabled" in configuration["test"]: logger.info(f"Checking that IPv6 is enabled on devices: {configuration['test']['ipv6_enabled']}") @@ -215,6 +217,11 @@ def run_on_single_network_scenario( ) test_collector.add_check_results(lab_name, check_results) + if "custom_commands" in configuration["test"]: + logger.info("Checking custom commands output...") + check_results = CustomCommandCheck().run(configuration["test"]["custom_commands"], lab) + test_collector.add_check_results(lab_name, check_results) + if not live and not keep_open: logger.info("Undeploying Network Scenario") manager.undeploy_lab(lab=lab) From c6b1ffdc99a2cc9cc18e4bb73be74ac59f8cb572 Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Thu, 3 Oct 2024 15:57:54 +0200 Subject: [PATCH 11/20] Refactor check result print --- src/kathara_lab_checker/checks/BridgeCheck.py | 7 ------- .../checks/CollisionDomainCheck.py | 1 - src/kathara_lab_checker/checks/CustomCommandCheck.py | 12 +++++++----- src/kathara_lab_checker/checks/DaemonCheck.py | 1 - .../checks/DeviceExistenceCheck.py | 1 - src/kathara_lab_checker/checks/IPv6EnabledCheck.py | 1 - src/kathara_lab_checker/checks/InterfaceIPCheck.py | 1 - src/kathara_lab_checker/checks/KernelRouteCheck.py | 5 ----- src/kathara_lab_checker/checks/ReachabilityCheck.py | 1 - .../checks/StartupExistenceCheck.py | 1 - src/kathara_lab_checker/checks/SysctlCheck.py | 1 - .../checks/applications/dns/DNSAuthorityCheck.py | 3 --- .../checks/applications/dns/DNSRecordCheck.py | 1 - .../checks/applications/dns/LocalNSCheck.py | 1 - .../checks/protocols/AnnouncedNetworkCheck.py | 1 - .../checks/protocols/ProtocolRedistributionCheck.py | 1 - .../checks/protocols/bgp/BGPPeeringCheck.py | 1 - .../checks/protocols/evpn/AnnouncedVNICheck.py | 2 -- .../checks/protocols/evpn/EVPNSessionCheck.py | 1 - .../checks/protocols/evpn/VTEPCheck.py | 1 - 20 files changed, 7 insertions(+), 37 deletions(-) diff --git a/src/kathara_lab_checker/checks/BridgeCheck.py b/src/kathara_lab_checker/checks/BridgeCheck.py index 06b76e1..85c94ed 100644 --- a/src/kathara_lab_checker/checks/BridgeCheck.py +++ b/src/kathara_lab_checker/checks/BridgeCheck.py @@ -216,13 +216,11 @@ def run(self, devices_to_bridge_configuration: dict[str, list[dict]], lab: Lab) device_name, expected_bridge_interfaces, actual_interfaces ) results.append(check_result) - self.logger.info(check_result) if check_result.passed: check_result = self.check_vlan_filtering( device_name, get_interface_by_name(masters.pop(), actual_interfaces) ) - self.logger.info(check_result) results.append(check_result) for interface_name, interface_conf in bridge_conf["interfaces"].items(): @@ -231,7 +229,6 @@ def run(self, devices_to_bridge_configuration: dict[str, list[dict]], lab: Lab) try: actual_interface_vlans = get_interface_by_name(interface_name, actual_vlans) check_result = CheckResult(description, True, "OK") - self.logger.info(check_result) results.append(check_result) except IndexError: check_result = CheckResult( @@ -239,7 +236,6 @@ def run(self, devices_to_bridge_configuration: dict[str, list[dict]], lab: Lab) False, f"No VLAN found for for `{interface_name}` on `{device_name}`", ) - self.logger.info(check_result) results.append(check_result) if actual_interface_vlans: @@ -248,7 +244,6 @@ def run(self, devices_to_bridge_configuration: dict[str, list[dict]], lab: Lab) device_name, interface_name, interface_conf, actual_interface_vlans ) results.append(check_result) - self.logger.info(check_result) if "pvid" in interface_conf: check_result = self.check_vlan_pvid( @@ -258,7 +253,6 @@ def run(self, devices_to_bridge_configuration: dict[str, list[dict]], lab: Lab) actual_interface_vlans, ) results.append(check_result) - self.logger.info(check_result) if "vxlan" in bridge_conf: for vni, vni_info in bridge_conf["vxlan"].items(): @@ -266,5 +260,4 @@ def run(self, devices_to_bridge_configuration: dict[str, list[dict]], lab: Lab) device_name, vni, vni_info["pvid"], actual_interfaces, actual_vlans ) results.append(check_result) - self.logger.info(check_result) return results diff --git a/src/kathara_lab_checker/checks/CollisionDomainCheck.py b/src/kathara_lab_checker/checks/CollisionDomainCheck.py index 78e7d45..afb51a9 100644 --- a/src/kathara_lab_checker/checks/CollisionDomainCheck.py +++ b/src/kathara_lab_checker/checks/CollisionDomainCheck.py @@ -27,6 +27,5 @@ def run(self, template_cds: list[Link], lab: Lab) -> list[CheckResult]: results = [] for cd_t in template_cds: check_result = self.check(cd_t, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/CustomCommandCheck.py b/src/kathara_lab_checker/checks/CustomCommandCheck.py index 432ae2d..da26c23 100644 --- a/src/kathara_lab_checker/checks/CustomCommandCheck.py +++ b/src/kathara_lab_checker/checks/CustomCommandCheck.py @@ -13,7 +13,6 @@ class CustomCommandCheck(AbstractCheck): def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) -> list[CheckResult]: kathara_manager: Kathara = Kathara.get_instance() - self.description = f"Checking the output of the command '{command_entry['command']}' on '{device_name}'" results = [] try: device = lab.get_machine(device_name) @@ -21,26 +20,29 @@ def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) command=command_entry["command"], stream=False) stdout = stdout.decode("utf-8").strip() if "exit_code" in command_entry: + self.description = f"Checking the exit code of the command '{command_entry['command']}' on '{device_name}'" if exit_code == command_entry["exit_code"]: results.append(CheckResult(self.description, True, "OK")) else: - reason = (f"The exit_code of the command differs from the expected one." - f"\nActual: {exit_code}\nExpected: {command_entry['exit_code']}") + reason = (f"The exit code of the command differs from the expected one." + f"\n Actual: {exit_code}\n Expected: {command_entry['exit_code']}") results.append(CheckResult(self.description, False, reason)) + self.description = f"Checking the output of the command '{command_entry['command']}' on '{device_name}'" if "output" in command_entry: if stdout == command_entry["output"]: results.append(CheckResult(self.description, True, "OK")) else: reason = (f"The output of the command differs from the expected one." - f"\nActual: {stdout}\nExpected: {command_entry['output']}") + f"\n Actual: {stdout}\n Expected: {command_entry['output']}") results.append(CheckResult(self.description, False, reason)) if "regex_match" in command_entry: + if re.match(command_entry["regex_match"], stdout): results.append(CheckResult(self.description, True, "OK")) else: reason = (f"The output of the command do not match the expected regex." - f"\nActual: {stdout}\nRegex: {command_entry['regex_match']}") + f"\n Actual: {stdout}\n Regex: {command_entry['regex_match']}") results.append(CheckResult(self.description, False, reason)) except MachineNotFoundError as e: diff --git a/src/kathara_lab_checker/checks/DaemonCheck.py b/src/kathara_lab_checker/checks/DaemonCheck.py index 6966fa9..b0e32da 100644 --- a/src/kathara_lab_checker/checks/DaemonCheck.py +++ b/src/kathara_lab_checker/checks/DaemonCheck.py @@ -39,6 +39,5 @@ def run(self, devices_to_daemons: dict[str, list[str]], lab: Lab) -> list[CheckR self.logger.info(f"Checking if daemons are running on `{device_name}`...") for daemon_name in daemons: check_result = self.check(device_name, daemon_name, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/DeviceExistenceCheck.py b/src/kathara_lab_checker/checks/DeviceExistenceCheck.py index bef891a..b33ad36 100644 --- a/src/kathara_lab_checker/checks/DeviceExistenceCheck.py +++ b/src/kathara_lab_checker/checks/DeviceExistenceCheck.py @@ -20,6 +20,5 @@ def run(self, template_machines: list[str], lab: Lab) -> list[CheckResult]: results = [] for device_name in template_machines: check_result = self.check(device_name, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/IPv6EnabledCheck.py b/src/kathara_lab_checker/checks/IPv6EnabledCheck.py index 5af3915..5b5a483 100644 --- a/src/kathara_lab_checker/checks/IPv6EnabledCheck.py +++ b/src/kathara_lab_checker/checks/IPv6EnabledCheck.py @@ -22,6 +22,5 @@ def run(self, ipv6_devices: list[str], lab: Lab) -> list[CheckResult]: results = [] for device_name in ipv6_devices: check_result = self.check(device_name, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/InterfaceIPCheck.py b/src/kathara_lab_checker/checks/InterfaceIPCheck.py index 4b10324..bb057fe 100644 --- a/src/kathara_lab_checker/checks/InterfaceIPCheck.py +++ b/src/kathara_lab_checker/checks/InterfaceIPCheck.py @@ -48,7 +48,6 @@ def run(self, ip_mapping: dict, lab: Lab) -> list[CheckResult]: dumped_iface = get_interfaces_addresses(device_name, lab) for interface_number, ip in iface_to_ips.items(): check_result = self.check(device_name, interface_number, ip, dumped_iface) - self.logger.info(check_result) results.append(check_result) except MachineNotRunningError: self.logger.warning(f"`{device_name}` is not running. Skipping checks...") diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index 50c4d39..a4192ab 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -28,7 +28,6 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis f"The routing table of {device_name} have the wrong number of routes: {len(actual_routing_table)}, expected: {len(expected_routing_table)}", ) results.append(check_result) - self.logger.info(check_result) return results for dst, nexthops in expected_routing_table.items(): @@ -37,7 +36,6 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis self.description, False, f"The routing table of {device_name} is missing route {dst}" ) results.append(check_result) - self.logger.info(check_result) continue if nexthops: actual_nh = actual_routing_table[dst] @@ -48,7 +46,6 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis f"The routing table of {device_name} about route {dst} have the wrong number of next-hops: {len(actual_nh)}, expected: {len(nexthops)}", ) results.append(check_result) - self.logger.info(check_result) continue for nh in nexthops: valid_ip = is_valid_ip(nh) @@ -61,12 +58,10 @@ def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> lis f"The routing table of {device_name} about route {dst} does not contain next-hop: {nh}, actual: {actual_nh}", ) results.append(check_result) - self.logger.info(check_result) if not results: check_result = CheckResult(self.description, True, f"OK") results.append(check_result) - self.logger.info(check_result) return results diff --git a/src/kathara_lab_checker/checks/ReachabilityCheck.py b/src/kathara_lab_checker/checks/ReachabilityCheck.py index 0855618..ccf9fca 100644 --- a/src/kathara_lab_checker/checks/ReachabilityCheck.py +++ b/src/kathara_lab_checker/checks/ReachabilityCheck.py @@ -49,6 +49,5 @@ def run(self, devices_to_destinations: dict[str, list[str]], lab: Lab) -> list[C for device_name, destinations in devices_to_destinations.items(): for destination in destinations: check_result = self.check(device_name, destination, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/StartupExistenceCheck.py b/src/kathara_lab_checker/checks/StartupExistenceCheck.py index 7467408..b7b632b 100644 --- a/src/kathara_lab_checker/checks/StartupExistenceCheck.py +++ b/src/kathara_lab_checker/checks/StartupExistenceCheck.py @@ -18,6 +18,5 @@ def run(self, machines_to_check: list[str], lab: Lab) -> list[CheckResult]: results = [] for device_name in machines_to_check: check_result = self.check(device_name, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/SysctlCheck.py b/src/kathara_lab_checker/checks/SysctlCheck.py index f1b0516..aa0ba42 100644 --- a/src/kathara_lab_checker/checks/SysctlCheck.py +++ b/src/kathara_lab_checker/checks/SysctlCheck.py @@ -33,6 +33,5 @@ def run(self, devices_sysctls: dict[str, list[str]], lab: Lab) -> list[CheckResu for device_name, sysctls in devices_sysctls.items(): for sysctl in sysctls: check_result = self.check(device_name, sysctl, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py b/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py index 3f739aa..10cda75 100644 --- a/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py +++ b/src/kathara_lab_checker/checks/applications/dns/DNSAuthorityCheck.py @@ -91,7 +91,6 @@ def run( self.logger.info(f"Checking authority ip for domain `{domain}`") for ns in name_servers: check_result = self.check(domain, ns, find_device_name_from_ip(ip_mapping, ns), ns, lab) - self.logger.info(check_result) results.append(check_result) if domain == ".": @@ -106,13 +105,11 @@ def run( generic_ns_ip, lab, ) - self.logger.info(check_result) results.append(check_result) for local_ns in local_nameservers: check_result = self.check( domain, ns, find_device_name_from_ip(ip_mapping, local_ns), local_ns, lab ) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py b/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py index 22a5435..71bf0f0 100644 --- a/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py +++ b/src/kathara_lab_checker/checks/applications/dns/DNSRecordCheck.py @@ -35,6 +35,5 @@ def run( f"{client} resolve {recordtype} {record} with IP {ip} instead of {addresses}", ) check_result - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py b/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py index 2f21234..91ad489 100644 --- a/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py +++ b/src/kathara_lab_checker/checks/applications/dns/LocalNSCheck.py @@ -38,6 +38,5 @@ def run(self, local_nameservers_to_devices: dict[str, list[str]], lab: Lab) -> l for local_ns, managed_devices in local_nameservers_to_devices.items(): for device_name in managed_devices: check_result = self.check(local_ns, device_name, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/protocols/AnnouncedNetworkCheck.py b/src/kathara_lab_checker/checks/protocols/AnnouncedNetworkCheck.py index e665cbf..ff990e5 100644 --- a/src/kathara_lab_checker/checks/protocols/AnnouncedNetworkCheck.py +++ b/src/kathara_lab_checker/checks/protocols/AnnouncedNetworkCheck.py @@ -34,6 +34,5 @@ def run(self, protocol: str, devices_to_networks: dict[str, list[str]], lab: Lab self.logger.info(f"Checking {device_name} BGP announces...") for network in networks: check_result = self.check(device_name, protocol, network, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py b/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py index 98c8f1b..56d23d2 100644 --- a/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py +++ b/src/kathara_lab_checker/checks/protocols/ProtocolRedistributionCheck.py @@ -47,6 +47,5 @@ def run(self, protocol, devices_to_redistributed: dict[str, list[str]], lab: Lab for device_name, injected_protocols in devices_to_redistributed.items(): for injected_protocol in injected_protocols: check_result = self.check(device_name, protocol, injected_protocol, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/protocols/bgp/BGPPeeringCheck.py b/src/kathara_lab_checker/checks/protocols/bgp/BGPPeeringCheck.py index 525126c..0d36952 100644 --- a/src/kathara_lab_checker/checks/protocols/bgp/BGPPeeringCheck.py +++ b/src/kathara_lab_checker/checks/protocols/bgp/BGPPeeringCheck.py @@ -47,6 +47,5 @@ def run(self, device_to_neighbours: dict[str, list[str]], lab: Lab) -> list[Chec for neighbor in neighbors: self.description = f"{device_name} has bgp peer {neighbor}" check_result = self.check(device_name, neighbor, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py b/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py index 5fa4362..dedcec4 100644 --- a/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py +++ b/src/kathara_lab_checker/checks/protocols/evpn/AnnouncedVNICheck.py @@ -43,13 +43,11 @@ def run(self, device_to_vnis_info: dict[str, dict], evpn_devices: list[str], lab results = [] for device_name in device_to_vnis_info.keys(): check_result = self.check(device_name, lab) - self.logger.info(check_result) results.append(check_result) not_advertise = set(evpn_devices).difference(set(device_to_vnis_info.keys())) for device_name in not_advertise: check_result = self.check(device_name, lab, invert=True) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/protocols/evpn/EVPNSessionCheck.py b/src/kathara_lab_checker/checks/protocols/evpn/EVPNSessionCheck.py index 4686ed0..b020960 100644 --- a/src/kathara_lab_checker/checks/protocols/evpn/EVPNSessionCheck.py +++ b/src/kathara_lab_checker/checks/protocols/evpn/EVPNSessionCheck.py @@ -54,6 +54,5 @@ def run(self, device_to_neighbours: dict[str, list[str]], lab: Lab) -> list[Chec for neighbor in neighbors: self.description = f"{device_name} has bgp peer {neighbor}" check_result = self.check(device_name, neighbor, lab) - self.logger.info(check_result) results.append(check_result) return results diff --git a/src/kathara_lab_checker/checks/protocols/evpn/VTEPCheck.py b/src/kathara_lab_checker/checks/protocols/evpn/VTEPCheck.py index 310cb35..e6b00e1 100644 --- a/src/kathara_lab_checker/checks/protocols/evpn/VTEPCheck.py +++ b/src/kathara_lab_checker/checks/protocols/evpn/VTEPCheck.py @@ -44,6 +44,5 @@ def run(self, device_to_vnis_info: dict[str, dict], lab: Lab) -> list[CheckResul for vni in vnis: self.description = f"Checking that `{device_name}` VTEP has vni `{vni}` with VTEP IP `{vtep_ip}`" check_result = self.check(device_name, vni, vtep_ip, lab) - self.logger.info(check_result) results.append(check_result) return results From 2712f056cc6996b6fd0e27ece91ea00e5f96c446 Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Thu, 3 Oct 2024 16:21:37 +0200 Subject: [PATCH 12/20] Refactor utils --- src/kathara_lab_checker/checks/CheckResult.py | 1 - .../checks/KernelRouteCheck.py | 86 ++++++++- src/kathara_lab_checker/excel_utils.py | 93 ++++++++++ src/kathara_lab_checker/utils.py | 174 +----------------- src/main.py | 38 ++-- 5 files changed, 199 insertions(+), 193 deletions(-) create mode 100644 src/kathara_lab_checker/excel_utils.py diff --git a/src/kathara_lab_checker/checks/CheckResult.py b/src/kathara_lab_checker/checks/CheckResult.py index 28ce78f..4a72380 100644 --- a/src/kathara_lab_checker/checks/CheckResult.py +++ b/src/kathara_lab_checker/checks/CheckResult.py @@ -11,6 +11,5 @@ def __init__(self, description: str, passed: bool, reason: str) -> None: self.reason: str = reason logging.getLogger("kathara-lab-checker").info(self) - def __str__(self) -> str: return f"{self.description}: {green(self.reason) if self.passed else red(self.reason)}" diff --git a/src/kathara_lab_checker/checks/KernelRouteCheck.py b/src/kathara_lab_checker/checks/KernelRouteCheck.py index a4192ab..0b5b052 100644 --- a/src/kathara_lab_checker/checks/KernelRouteCheck.py +++ b/src/kathara_lab_checker/checks/KernelRouteCheck.py @@ -1,13 +1,95 @@ -from typing import Union +import ipaddress +import json +from typing import Union, Any from Kathara.exceptions import MachineNotRunningError +from Kathara.manager.Kathara import Kathara from Kathara.model.Lab import Lab -from kathara_lab_checker.utils import is_valid_ip, load_routes_from_device, load_routes_from_expected from .AbstractCheck import AbstractCheck from .CheckResult import CheckResult +def load_routes_from_expected(expected_routes: list) -> dict[str, set]: + routes = {} + for route in expected_routes: + if type(route) is list: + routes[route[0]] = set(route[1]) + else: + routes[route] = set() + return routes + + +def get_kernel_routes(device_name: str, lab: Lab) -> list[dict[str, Any]]: + kathara_manager = Kathara.get_instance() + try: + stdout, _, _ = kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j route", + stream=False) + stdout = stdout.decode("utf-8").strip() + except MachineNotRunningError: + return [] + return json.loads(stdout) + + +def get_nexthops(device_name: str, lab: Lab) -> list[dict[str, Any]]: + kathara_manager = Kathara.get_instance() + + try: + stdout, _, _ = kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j nexthop", + stream=False) + stdout = stdout.decode("utf-8").strip() + except MachineNotRunningError: + return [] + + return json.loads(stdout) + + +def load_routes_from_device(device_name: str, lab: Lab) -> dict[str, set]: + ip_route_output = get_kernel_routes(device_name, lab) + routes = {} + kernel_nexthops = None + + for route in ip_route_output: + + dst = route["dst"] + if dst == "default": + dst = "0.0.0.0/0" + nexthops = None + if "scope" in route and route["scope"] == "link": + nexthops = [("d.c.", route["dev"])] + elif "nexthops" in route: + nexthops = list(map(lambda x: x["dev"], route["nexthops"])) + elif "gateway" in route: + nexthops = [(route["gateway"], route["dev"])] + elif "via" in route: + nexthops = [(route["via"]["host"], route["dev"])] + elif "nhid" in route: + # Lazy load nexthops + kernel_nexthops = get_nexthops(device_name, lab) if kernel_nexthops is None else kernel_nexthops + + current_nexthop = [obj for obj in kernel_nexthops if obj["id"] == route["nhid"]][0] + if "gateway" in current_nexthop: + nexthops = [(current_nexthop["gateway"], current_nexthop["dev"])] + elif "group" in current_nexthop: + nexthops = [ + (obj["gateway"], obj["dev"]) + for obj in kernel_nexthops + if obj["id"] in (nhid["id"] for nhid in current_nexthop["group"]) + ] + else: + raise Exception("Strange nexthop: ", current_nexthop) + routes[dst] = set(nexthops) + return routes + + +def is_valid_ip(ip_str): + try: + ipaddress.ip_address(ip_str) + return True + except ValueError: + return False + + class KernelRouteCheck(AbstractCheck): def check(self, device_name: str, expected_routing_table: list, lab: Lab) -> list[CheckResult]: self.description = f"Checking the routing table of {device_name}" diff --git a/src/kathara_lab_checker/excel_utils.py b/src/kathara_lab_checker/excel_utils.py new file mode 100644 index 0000000..8bebc24 --- /dev/null +++ b/src/kathara_lab_checker/excel_utils.py @@ -0,0 +1,93 @@ +import os + +from openpyxl import Workbook +from openpyxl.styles import Alignment +from openpyxl.worksheet.worksheet import Worksheet + + +def write_final_results_to_excel(test_collector: "TestCollectorPackage.TestCollector", path: str): + # Create a new Excel workbook + workbook = Workbook() + + # Select the active sheet + sheet = workbook.active + + sheet["A1"] = "Student Name" + sheet["B1"] = "Tests Passed" + sheet["C1"] = "Tests Failed" + sheet["D1"] = "Tests Total Number" + sheet["E1"] = "Problems" + + for index, (test_name, test_results) in enumerate(test_collector.tests.items()): + failed_tests = test_collector.get_failed(test_name) + passed_tests = test_collector.get_passed(test_name) + sheet["A" + str(index + 2)] = test_name + sheet["B" + str(index + 2)] = len(passed_tests) + sheet["C" + str(index + 2)] = len(failed_tests) + sheet["D" + str(index + 2)] = len(test_results) + + if failed_tests: + failed_string = "" + for idx, failed in enumerate(failed_tests): + failed_string += f"{(idx + 1)}: {failed.reason}\n" + if len(failed_string) >= 32767: + raise Exception("ERROR: Excel cell too big") + sheet["E" + str(index + 2)] = failed_string + sheet["E" + str(index + 2)].alignment = Alignment(wrapText=True) + else: + sheet["E" + str(index + 2)] = "None" + + excel_file = os.path.join(path, "results.xlsx") + workbook.save(excel_file) + + +def _write_sheet_row(sheet: Worksheet, column: int, description: str, passed: str, reason: str) -> None: + sheet["A" + str(column + 2)] = description + sheet["B" + str(column + 2)] = passed + sheet["C" + str(column + 2)] = reason + + +def write_result_to_excel(check_results: list["CheckResultPackage.CheckResult"], path: str): + # Create a new Excel workbook + workbook: Workbook = Workbook() + + workbook.create_sheet("Summary", 0) + sheet_summary = workbook.get_sheet_by_name("Summary") + sheet_summary["A1"] = "Total Tests" + sheet_summary["B1"] = "Passed Tests" + sheet_summary["C1"] = "Failed" + + _write_sheet_row( + sheet_summary, + 0, + str(len(check_results)), + str(len(list(filter(lambda x: x.passed, check_results)))), + str(len(list(filter(lambda x: not x.passed, check_results)))), + ) + + # Select the active sheet + workbook.create_sheet("All", 1) + sheet_all = workbook.get_sheet_by_name("All") + sheet_all["A1"] = "Tests Description" + sheet_all["B1"] = "Passed" + sheet_all["C1"] = "Reason" + + workbook.create_sheet("Failed", 2) + sheet_failed = workbook.get_sheet_by_name("Failed") + sheet_failed["A1"] = "Tests Description" + sheet_failed["B1"] = "Passed" + sheet_failed["C1"] = "Reason" + + failed_index = 0 + for index, check_result in enumerate(check_results): + if not check_result.passed: + _write_sheet_row( + sheet_failed, + failed_index, + check_result.description, + str(check_result.passed), + check_result.reason, + ) + failed_index += 1 + _write_sheet_row(sheet_all, index, check_result.description, str(check_result.passed), check_result.reason) + workbook.save(os.path.join(path, f"{os.path.basename(path)}_result.xlsx")) diff --git a/src/kathara_lab_checker/utils.py b/src/kathara_lab_checker/utils.py index a2895cd..7105357 100644 --- a/src/kathara_lab_checker/utils.py +++ b/src/kathara_lab_checker/utils.py @@ -1,14 +1,8 @@ -import ipaddress import json -import os -from typing import Any, Optional +from typing import Optional -from Kathara.exceptions import MachineNotRunningError from Kathara.manager.Kathara import Kathara from Kathara.model.Lab import Lab -from openpyxl.styles import Alignment -from openpyxl.workbook import Workbook -from openpyxl.worksheet.worksheet import Worksheet def red(s): @@ -78,175 +72,9 @@ def find_lines_with_string(file_content, search_string): return matching_lines -def write_final_results_to_excel(test_collector: "TestCollectorPackage.TestCollector", path: str): - # Create a new Excel workbook - workbook = Workbook() - - # Select the active sheet - sheet = workbook.active - - sheet["A1"] = "Student Name" - sheet["B1"] = "Tests Passed" - sheet["C1"] = "Tests Failed" - sheet["D1"] = "Tests Total Number" - sheet["E1"] = "Problems" - - for index, (test_name, test_results) in enumerate(test_collector.tests.items()): - failed_tests = test_collector.get_failed(test_name) - passed_tests = test_collector.get_passed(test_name) - sheet["A" + str(index + 2)] = test_name - sheet["B" + str(index + 2)] = len(passed_tests) - sheet["C" + str(index + 2)] = len(failed_tests) - sheet["D" + str(index + 2)] = len(test_results) - - if failed_tests: - failed_string = "" - for idx, failed in enumerate(failed_tests): - failed_string += f"{(idx + 1)}: {failed.reason}\n" - if len(failed_string) >= 32767: - raise Exception("ERROR: Excel cell too big") - sheet["E" + str(index + 2)] = failed_string - sheet["E" + str(index + 2)].alignment = Alignment(wrapText=True) - else: - sheet["E" + str(index + 2)] = "None" - - excel_file = os.path.join(path, "results.xlsx") - workbook.save(excel_file) - - -def _write_sheet_row(sheet: Worksheet, column: int, description: str, passed: str, reason: str) -> None: - sheet["A" + str(column + 2)] = description - sheet["B" + str(column + 2)] = passed - sheet["C" + str(column + 2)] = reason - - -def write_result_to_excel(check_results: list["CheckResultPackage.CheckResult"], path: str): - # Create a new Excel workbook - workbook: Workbook = Workbook() - - workbook.create_sheet("Summary", 0) - sheet_summary = workbook.get_sheet_by_name("Summary") - sheet_summary["A1"] = "Total Tests" - sheet_summary["B1"] = "Passed Tests" - sheet_summary["C1"] = "Failed" - - _write_sheet_row( - sheet_summary, - 0, - str(len(check_results)), - str(len(list(filter(lambda x: x.passed, check_results)))), - str(len(list(filter(lambda x: not x.passed, check_results)))), - ) - - # Select the active sheet - workbook.create_sheet("All", 1) - sheet_all = workbook.get_sheet_by_name("All") - sheet_all["A1"] = "Tests Description" - sheet_all["B1"] = "Passed" - sheet_all["C1"] = "Reason" - - workbook.create_sheet("Failed", 2) - sheet_failed = workbook.get_sheet_by_name("Failed") - sheet_failed["A1"] = "Tests Description" - sheet_failed["B1"] = "Passed" - sheet_failed["C1"] = "Reason" - - failed_index = 0 - for index, check_result in enumerate(check_results): - if not check_result.passed: - _write_sheet_row( - sheet_failed, - failed_index, - check_result.description, - str(check_result.passed), - check_result.reason, - ) - failed_index += 1 - _write_sheet_row(sheet_all, index, check_result.description, str(check_result.passed), check_result.reason) - workbook.save(os.path.join(path, f"{os.path.basename(path)}_result.xlsx")) - - def reverse_dictionary(dictionary: dict): reversed_dict = {} for k, values in dictionary.items(): for v in values: reversed_dict[v] = reversed_dict.get(v, []) + [k] return reversed_dict - - -def load_routes_from_expected(expected_routes: list) -> dict[str, set]: - routes = {} - for route in expected_routes: - if type(route) is list: - routes[route[0]] = set(route[1]) - else: - routes[route] = set() - return routes - - -def get_kernel_routes(device_name: str, lab: Lab) -> list[dict[str, Any]]: - kathara_manager = Kathara.get_instance() - - try: - output = get_output(kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j route")) - except MachineNotRunningError: - return [] - - return json.loads(output) - - -def get_nexthops(device_name: str, lab: Lab) -> list[dict[str, Any]]: - kathara_manager = Kathara.get_instance() - - try: - output = get_output(kathara_manager.exec(machine_name=device_name, lab_hash=lab.hash, command="ip -j nexthop")) - except MachineNotRunningError: - return [] - - return json.loads(output) - - -def load_routes_from_device(device_name: str, lab: Lab) -> dict[str, set]: - ip_route_output = get_kernel_routes(device_name, lab) - routes = {} - kernel_nexthops = None - - for route in ip_route_output: - - dst = route["dst"] - if dst == "default": - dst = "0.0.0.0/0" - nexthops = None - if "scope" in route and route["scope"] == "link": - nexthops = [("d.c.", route["dev"])] - elif "nexthops" in route: - nexthops = list(map(lambda x: x["dev"], route["nexthops"])) - elif "gateway" in route: - nexthops = [(route["gateway"], route["dev"])] - elif "via" in route: - nexthops = [(route["via"]["host"], route["dev"])] - elif "nhid" in route: - # Lazy load nexthops - kernel_nexthops = get_nexthops(device_name, lab) if kernel_nexthops is None else kernel_nexthops - - current_nexthop = [obj for obj in kernel_nexthops if obj["id"] == route["nhid"]][0] - if "gateway" in current_nexthop: - nexthops = [(current_nexthop["gateway"], current_nexthop["dev"])] - elif "group" in current_nexthop: - nexthops = [ - (obj["gateway"], obj["dev"]) - for obj in kernel_nexthops - if obj["id"] in (nhid["id"] for nhid in current_nexthop["group"]) - ] - else: - raise Exception("Strange nexthop: ", current_nexthop) - routes[dst] = set(nexthops) - return routes - - -def is_valid_ip(ip_str): - try: - ipaddress.ip_address(ip_str) - return True - except ValueError: - return False diff --git a/src/main.py b/src/main.py index e7ea849..5d18d03 100644 --- a/src/main.py +++ b/src/main.py @@ -37,7 +37,8 @@ from kathara_lab_checker.checks.protocols.evpn.AnnouncedVNICheck import AnnouncedVNICheck from kathara_lab_checker.checks.protocols.evpn.EVPNSessionCheck import EVPNSessionCheck from kathara_lab_checker.checks.protocols.evpn.VTEPCheck import VTEPCheck -from kathara_lab_checker.utils import reverse_dictionary, write_final_results_to_excel, write_result_to_excel +from kathara_lab_checker.excel_utils import write_final_results_to_excel, write_result_to_excel +from kathara_lab_checker.utils import reverse_dictionary VERSION = "0.1.2" CURRENT_LAB: Optional[Lab] = None @@ -198,24 +199,27 @@ def run_on_single_network_scenario( if "applications" in configuration["test"]: for application_name, application in configuration["test"]["applications"].items(): if application_name == "dns": - logger.info("Checking DNS configurations...") - check_results = DNSAuthorityCheck().run( - application["authoritative"], - list(application["local_ns"].keys()), - configuration["test"]["ip_mapping"], - lab, - ) - test_collector.add_check_results(lab_name, check_results) + if "authoritative" in application: + logger.info("Checking DNS configurations...") + check_results = DNSAuthorityCheck().run( + application["authoritative"], + list(application["local_ns"].keys()), + configuration["test"]["ip_mapping"], + lab, + ) + test_collector.add_check_results(lab_name, check_results) - logger.info("Checking local name servers configurations...") - check_results = LocalNSCheck().run(application["local_ns"], lab) - test_collector.add_check_results(lab_name, check_results) + if "local_ns" in application: + logger.info("Checking local name servers configurations...") + check_results = LocalNSCheck().run(application["local_ns"], lab) + test_collector.add_check_results(lab_name, check_results) - logger.info(f"Starting test for DNS records...") - check_results = DNSRecordCheck().run( - application["records"], reverse_dictionary(application["local_ns"]).keys(), lab - ) - test_collector.add_check_results(lab_name, check_results) + if "records" in application: + logger.info(f"Starting test for DNS records...") + check_results = DNSRecordCheck().run( + application["records"], reverse_dictionary(application["local_ns"]).keys(), lab + ) + test_collector.add_check_results(lab_name, check_results) if "custom_commands" in configuration["test"]: logger.info("Checking custom commands output...") From 5b0e8fb74415284e0654f8b2633e07f05154fcaa Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Fri, 4 Oct 2024 17:58:02 +0200 Subject: [PATCH 13/20] Fix CustomCommandCheck --- .../checks/CustomCommandCheck.py | 121 +++++++++--------- 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/src/kathara_lab_checker/checks/CustomCommandCheck.py b/src/kathara_lab_checker/checks/CustomCommandCheck.py index da26c23..d5289cd 100644 --- a/src/kathara_lab_checker/checks/CustomCommandCheck.py +++ b/src/kathara_lab_checker/checks/CustomCommandCheck.py @@ -1,59 +1,62 @@ -import re - -from Kathara.exceptions import MachineNotFoundError -from Kathara.manager.Kathara import Kathara -from Kathara.model.Lab import Lab - -from .AbstractCheck import AbstractCheck -from .CheckResult import CheckResult - - -class CustomCommandCheck(AbstractCheck): - - def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) -> list[CheckResult]: - kathara_manager: Kathara = Kathara.get_instance() - - results = [] - try: - device = lab.get_machine(device_name) - stdout, stderr, exit_code = kathara_manager.exec(machine_name=device.name, lab_hash=lab.hash, - command=command_entry["command"], stream=False) - stdout = stdout.decode("utf-8").strip() - if "exit_code" in command_entry: - self.description = f"Checking the exit code of the command '{command_entry['command']}' on '{device_name}'" - if exit_code == command_entry["exit_code"]: - results.append(CheckResult(self.description, True, "OK")) - else: - reason = (f"The exit code of the command differs from the expected one." - f"\n Actual: {exit_code}\n Expected: {command_entry['exit_code']}") - results.append(CheckResult(self.description, False, reason)) - - self.description = f"Checking the output of the command '{command_entry['command']}' on '{device_name}'" - if "output" in command_entry: - if stdout == command_entry["output"]: - results.append(CheckResult(self.description, True, "OK")) - else: - reason = (f"The output of the command differs from the expected one." - f"\n Actual: {stdout}\n Expected: {command_entry['output']}") - results.append(CheckResult(self.description, False, reason)) - if "regex_match" in command_entry: - - if re.match(command_entry["regex_match"], stdout): - results.append(CheckResult(self.description, True, "OK")) - else: - reason = (f"The output of the command do not match the expected regex." - f"\n Actual: {stdout}\n Regex: {command_entry['regex_match']}") - results.append(CheckResult(self.description, False, reason)) - - except MachineNotFoundError as e: - results.append(CheckResult(self.description, False, str(e))) - - return results - - def run(self, devices_to_commands: dict[str, list[dict[str, str | int]]], lab: Lab) -> list[CheckResult]: - results = [] - for device_name, command_entries in devices_to_commands.items(): - for command_entry in command_entries: - check_result = self.check(device_name, command_entry, lab) - results.extend(check_result) - return results +import re + +from Kathara.exceptions import MachineNotFoundError +from Kathara.manager.Kathara import Kathara +from Kathara.model.Lab import Lab + +from .AbstractCheck import AbstractCheck +from .CheckResult import CheckResult + + +class CustomCommandCheck(AbstractCheck): + + def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) -> list[CheckResult]: + kathara_manager: Kathara = Kathara.get_instance() + + results = [] + try: + device = lab.get_machine(device_name) + stdout, stderr, exit_code = kathara_manager.exec(machine_name=device.name, lab_hash=lab.hash, + command=command_entry["command"], stream=False) + stdout = stdout.decode("utf-8").strip() + if "exit_code" in command_entry: + self.description = f"Checking the exit code of the command '{command_entry['command']}' on '{device_name}'" + if exit_code == command_entry["exit_code"]: + results.append(CheckResult(self.description, True, "OK")) + else: + reason = (f"The exit code of the command differs from the expected one." + f"\n Actual: {exit_code}\n Expected: {command_entry['exit_code']}") + results.append(CheckResult(self.description, False, reason)) + + self.description = f"Checking the output of the command '{command_entry['command']}' on '{device_name}'" + if "output" in command_entry: + stdout = stdout.replace("\r\n", "\n").replace("\r", "\n") + command_entry["output"] = command_entry["output"].replace("\r\n", "\n").replace("\r", "\n") + + if stdout == command_entry["output"].replace("\r\n", "\n").replace("\r", "\n"): + results.append(CheckResult(self.description, True, "OK")) + else: + reason = (f"The output of the command differs from the expected one." + f"\n Actual: {stdout}\n Expected: {command_entry['output']}") + results.append(CheckResult(self.description, False, reason)) + if "regex_match" in command_entry: + + if re.match(command_entry["regex_match"], stdout): + results.append(CheckResult(self.description, True, "OK")) + else: + reason = (f"The output of the command do not match the expected regex." + f"\n Actual: {stdout}\n Regex: {command_entry['regex_match']}") + results.append(CheckResult(self.description, False, reason)) + + except MachineNotFoundError as e: + results.append(CheckResult(self.description, False, str(e))) + + return results + + def run(self, devices_to_commands: dict[str, list[dict[str, str | int]]], lab: Lab) -> list[CheckResult]: + results = [] + for device_name, command_entries in devices_to_commands.items(): + for command_entry in command_entries: + check_result = self.check(device_name, command_entry, lab) + results.extend(check_result) + return results From fa569f2186e5be9a218ca71fc4a2ceab68e71d8f Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Fri, 4 Oct 2024 19:01:23 +0200 Subject: [PATCH 14/20] Fix CustomCommandCheck --- src/kathara_lab_checker/checks/CustomCommandCheck.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/kathara_lab_checker/checks/CustomCommandCheck.py b/src/kathara_lab_checker/checks/CustomCommandCheck.py index d5289cd..486b72e 100644 --- a/src/kathara_lab_checker/checks/CustomCommandCheck.py +++ b/src/kathara_lab_checker/checks/CustomCommandCheck.py @@ -1,3 +1,4 @@ +import logging import re from Kathara.exceptions import MachineNotFoundError @@ -18,7 +19,9 @@ def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) device = lab.get_machine(device_name) stdout, stderr, exit_code = kathara_manager.exec(machine_name=device.name, lab_hash=lab.hash, command=command_entry["command"], stream=False) - stdout = stdout.decode("utf-8").strip() + + stdout = stdout.decode("utf-8").strip() if stdout else stderr.decode("utf-8").strip() + if "exit_code" in command_entry: self.description = f"Checking the exit code of the command '{command_entry['command']}' on '{device_name}'" if exit_code == command_entry["exit_code"]: From 527584c88f8521613fbea7ce739efb820c0b0913 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Sun, 6 Oct 2024 01:04:19 +0200 Subject: [PATCH 15/20] Change how regex is applied in custom command --- .../checks/CustomCommandCheck.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/kathara_lab_checker/checks/CustomCommandCheck.py b/src/kathara_lab_checker/checks/CustomCommandCheck.py index 486b72e..3fbadc4 100644 --- a/src/kathara_lab_checker/checks/CustomCommandCheck.py +++ b/src/kathara_lab_checker/checks/CustomCommandCheck.py @@ -17,18 +17,23 @@ def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) results = [] try: device = lab.get_machine(device_name) - stdout, stderr, exit_code = kathara_manager.exec(machine_name=device.name, lab_hash=lab.hash, - command=command_entry["command"], stream=False) + stdout, stderr, exit_code = kathara_manager.exec( + machine_name=device.name, lab_hash=lab.hash, command=command_entry["command"], stream=False + ) stdout = stdout.decode("utf-8").strip() if stdout else stderr.decode("utf-8").strip() if "exit_code" in command_entry: - self.description = f"Checking the exit code of the command '{command_entry['command']}' on '{device_name}'" + self.description = ( + f"Checking the exit code of the command '{command_entry['command']}' on '{device_name}'" + ) if exit_code == command_entry["exit_code"]: results.append(CheckResult(self.description, True, "OK")) else: - reason = (f"The exit code of the command differs from the expected one." - f"\n Actual: {exit_code}\n Expected: {command_entry['exit_code']}") + reason = ( + f"The exit code of the command differs from the expected one." + f"\n Actual: {exit_code}\n Expected: {command_entry['exit_code']}" + ) results.append(CheckResult(self.description, False, reason)) self.description = f"Checking the output of the command '{command_entry['command']}' on '{device_name}'" @@ -39,16 +44,20 @@ def check(self, device_name: str, command_entry: dict[str, str | int], lab: Lab) if stdout == command_entry["output"].replace("\r\n", "\n").replace("\r", "\n"): results.append(CheckResult(self.description, True, "OK")) else: - reason = (f"The output of the command differs from the expected one." - f"\n Actual: {stdout}\n Expected: {command_entry['output']}") + reason = ( + f"The output of the command differs from the expected one." + f"\n Actual: {stdout}\n Expected: {command_entry['output']}" + ) results.append(CheckResult(self.description, False, reason)) if "regex_match" in command_entry: - if re.match(command_entry["regex_match"], stdout): + if re.search(command_entry["regex_match"], stdout): results.append(CheckResult(self.description, True, "OK")) else: - reason = (f"The output of the command do not match the expected regex." - f"\n Actual: {stdout}\n Regex: {command_entry['regex_match']}") + reason = ( + f"The output of the command do not match the expected regex." + f"\n Actual: {stdout}\n Regex: {command_entry['regex_match']}" + ) results.append(CheckResult(self.description, False, reason)) except MachineNotFoundError as e: From 2025c9c739ef0b3088c6e72ee4dc302af3edbb46 Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Mon, 7 Oct 2024 11:00:48 +0200 Subject: [PATCH 16/20] Bump version (0.1.3) --- pyproject.toml | 2 +- src/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9aa6309..8040697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kathara-lab-checker" - version = "0.1.2" + version = "0.1.3" description = "Tool to automatically check Kathará network scenarios based on a configuration file." readme = "README.md" requires-python = ">=3.11" diff --git a/src/main.py b/src/main.py index 5d18d03..b459997 100644 --- a/src/main.py +++ b/src/main.py @@ -40,7 +40,7 @@ from kathara_lab_checker.excel_utils import write_final_results_to_excel, write_result_to_excel from kathara_lab_checker.utils import reverse_dictionary -VERSION = "0.1.2" +VERSION = "0.1.3" CURRENT_LAB: Optional[Lab] = None From ceb813a6d249b8ff205ebab16f0cc5849c7a5501 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Mon, 7 Oct 2024 13:10:03 +0200 Subject: [PATCH 17/20] Add docs for Custom Command check --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 19d7c63..126e536 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,17 @@ In the following you will find the possible values for the configuration file. "", # Check if the device reaches the ip "", # Check if the device reaches the dns_name ], - } + }, + "custom_commands": { # Execute a command inside a device and checks the output + "": [ + { + "command": "", # Command to execute + "regex_match": "", # Check if the output matches the regex + "output": "", # Check if the output is the expected one + "exit_code": # Check if the command exit code is the expected one + } + ] + } } } ``` From 5d384ef1a379f2649eb8ffa16ee6c5524c378185 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Mon, 7 Oct 2024 13:24:00 +0200 Subject: [PATCH 18/20] Add custom error if structure file does not exist --- src/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.py b/src/main.py index b459997..aa2d85c 100644 --- a/src/main.py +++ b/src/main.py @@ -359,6 +359,10 @@ def main(): Setting.get_instance().load_from_dict({"image": conf["default_image"]}) logger.info(f"Parsing network scenarios template in: {conf['structure']}") + if not os.path.exists(conf["structure"]): + logger.error(f"The structure file {conf["structure"]} does not exist") + exit(1) + template_lab = LabParser().parse( os.path.dirname(conf["structure"]), conf_name=os.path.basename(conf["structure"]), From bb6bc7c0d6e9905433bbf2495f688528e6b2656f Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Mon, 7 Oct 2024 19:23:30 +0200 Subject: [PATCH 19/20] Fix f-string error --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index aa2d85c..247625c 100644 --- a/src/main.py +++ b/src/main.py @@ -360,7 +360,7 @@ def main(): logger.info(f"Parsing network scenarios template in: {conf['structure']}") if not os.path.exists(conf["structure"]): - logger.error(f"The structure file {conf["structure"]} does not exist") + logger.error(f"The structure file {conf['structure']} does not exist") exit(1) template_lab = LabParser().parse( From 0fa1f859c96e6c18eb70e2f132e4ca19790517be Mon Sep 17 00:00:00 2001 From: Tommaso Caiazzi Date: Thu, 17 Oct 2024 16:17:07 +0200 Subject: [PATCH 20/20] Refactor code structure + fix pip package issue (#9) --- README.md | 4 +- examples/palabra/labs/lab1/lab1_result.xlsx | Bin 17131 -> 16671 bytes examples/palabra/labs/lab2/lab2_result.xlsx | Bin 13212 -> 13274 bytes examples/palabra/labs/lab3/lab3_result.xlsx | Bin 20366 -> 19078 bytes examples/palabra/labs/lab4/lab4_result.xlsx | Bin 12522 -> 12835 bytes examples/palabra/labs/results.xlsx | Bin 7692 -> 7524 bytes examples/palabra/results.xlsx | Bin 7650 -> 0 bytes pyproject.toml | 2 +- src/{main.py => kathara_lab_checker.py} | 46 +++++++++--------- .../TestCollector.py | 0 .../TqdmLoggingHandler.py | 0 .../__init__.py | 0 .../checks/AbstractCheck.py | 0 .../checks/BridgeCheck.py | 2 +- .../checks/CheckResult.py | 2 +- .../checks/CollisionDomainCheck.py | 0 .../checks/CustomCommandCheck.py | 0 .../checks/DaemonCheck.py | 2 +- .../checks/DeviceExistenceCheck.py | 0 .../checks/IPv6EnabledCheck.py | 0 .../checks/InterfaceIPCheck.py | 2 +- .../checks/KernelRouteCheck.py | 0 .../checks/ReachabilityCheck.py | 2 +- .../checks/StartupExistenceCheck.py | 0 .../checks/SysctlCheck.py | 0 .../checks/__init__.py | 0 .../checks/applications/__init__.py | 0 .../applications/dns/DNSAuthorityCheck.py | 6 +-- .../checks/applications/dns/DNSRecordCheck.py | 6 +-- .../checks/applications/dns/LocalNSCheck.py | 6 +-- .../checks/applications/dns/__init__.py | 0 .../checks/protocols/AnnouncedNetworkCheck.py | 6 +-- .../protocols/ProtocolRedistributionCheck.py | 6 +-- .../checks/protocols/__init__.py | 0 .../checks/protocols/bgp/BGPPeeringCheck.py | 6 +-- .../checks/protocols/bgp/__init__.py | 0 .../protocols/evpn/AnnouncedVNICheck.py | 6 +-- .../checks/protocols/evpn/EVPNSessionCheck.py | 6 +-- .../checks/protocols/evpn/VTEPCheck.py | 6 +-- .../checks/protocols/evpn/__init__.py | 0 .../excel_utils.py | 0 .../utils.py | 0 42 files changed, 58 insertions(+), 58 deletions(-) delete mode 100644 examples/palabra/results.xlsx rename src/{main.py => kathara_lab_checker.py} (86%) rename src/{kathara_lab_checker => lab_checker}/TestCollector.py (100%) rename src/{kathara_lab_checker => lab_checker}/TqdmLoggingHandler.py (100%) rename src/{kathara_lab_checker => lab_checker}/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/AbstractCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/BridgeCheck.py (97%) rename src/{kathara_lab_checker => lab_checker}/checks/CheckResult.py (86%) rename src/{kathara_lab_checker => lab_checker}/checks/CollisionDomainCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/CustomCommandCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/DaemonCheck.py (95%) rename src/{kathara_lab_checker => lab_checker}/checks/DeviceExistenceCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/IPv6EnabledCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/InterfaceIPCheck.py (95%) rename src/{kathara_lab_checker => lab_checker}/checks/KernelRouteCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/ReachabilityCheck.py (95%) rename src/{kathara_lab_checker => lab_checker}/checks/StartupExistenceCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/SysctlCheck.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/applications/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/applications/dns/DNSAuthorityCheck.py (93%) rename src/{kathara_lab_checker => lab_checker}/checks/applications/dns/DNSRecordCheck.py (88%) rename src/{kathara_lab_checker => lab_checker}/checks/applications/dns/LocalNSCheck.py (87%) rename src/{kathara_lab_checker => lab_checker}/checks/applications/dns/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/AnnouncedNetworkCheck.py (87%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/ProtocolRedistributionCheck.py (89%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/bgp/BGPPeeringCheck.py (89%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/bgp/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/evpn/AnnouncedVNICheck.py (89%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/evpn/EVPNSessionCheck.py (92%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/evpn/VTEPCheck.py (92%) rename src/{kathara_lab_checker => lab_checker}/checks/protocols/evpn/__init__.py (100%) rename src/{kathara_lab_checker => lab_checker}/excel_utils.py (100%) rename src/{kathara_lab_checker => lab_checker}/utils.py (100%) diff --git a/README.md b/README.md index 126e536..fd1699f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The Kathará Lab Checker takes in input a configuration file specifying the test To run the tool you only need to run the `main.py` passing the desired configuration file. ```bash -python3 -m kathara-lab-checker --config +python3 -m kathara_lab_checker --config --labs ``` At this point, the tool parses the provided configuration file and executes the tests. For each network scenario the @@ -47,7 +47,7 @@ The repository already provide a complete example with the results of the tests. You can re-run the example by typing the following command in the root directory of the project: ```bash -python3 -m kathara-lab-checker --config examples/configuration_palabra.json --no-cache +python3 -m kathara_lab_checker --config examples/palabra/configuration_palabra.json --no-cache --labs examples/palabra/labs ``` The `--no-cache` flag force to repeat already executed tests. diff --git a/examples/palabra/labs/lab1/lab1_result.xlsx b/examples/palabra/labs/lab1/lab1_result.xlsx index e13621ec6161c33be67126e572f175be22eb7223..ea7e58269966486c05a346678d37fdbfd6d3a5ea 100644 GIT binary patch delta 12267 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 Oupbf-lnar&=>Gx6?YpZ0 delta 12710 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 diff --git a/examples/palabra/labs/lab2/lab2_result.xlsx b/examples/palabra/labs/lab2/lab2_result.xlsx index 22582802d2d05fff129d478870f7b85e5f1dd38e..78fc32e43316d4a99a3eec65f18a76bae38326bf 100644 GIT binary patch delta 8777 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 IAW5|RAMip(SO5S3 delta 8658 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
)urpKi!To;y>@;qT!vF{W$I|md~A)a zdzW!Bi5jB)n7#5%=Z|n#z;%Z+6X0+iThkxECAr_Az8k!oMO!*s?<})Cmp3?R&rz&a z6EnBDf8cvmS?_tUH;>QXA`DL`QQ-Stbs##yZ2(ERdtj0etCzc@huWQ^A1Jkd;gTzF&GMM=`l)Utc?kw1sw2<2FiHs z`oZ2lp8tshad_&f{{SLRx^zZkNL;ew!@s#HOUcW~N; zz}&Di!ceula19phO~)}Qdm(?eb3v)SGwSJ$Q%AvlPfKTI7rwiAPgfuHRXpBa>zP?8 zKI${^t1)&}?b`M|$e$TZ)yOlQnB+VjDB+w=bFuG>d@wCr!nqB+bGMgqtvLu?J!vv8 zIcT-)t)?wKpqoFwJ$1zS-{(eoW4pC-!~_KCbXX5=4xq}|)O}10ax7%{SjlZGnxG)3 zusZV2-SGz{;-4tWevQ0ZZQftEZ;(<-X=S2FQ^yA`G>m+|&+A-Z@qpB-Gd-E=7Pa90 zWKYP_yU^rcy3$uyuzH2bERWtK#<@Yhv^;U*$ngxM|BAy{NpIYzb&`&I`$Mz&3AFyL zbK#U*NY`ILNY*l_dqDLcLffxMu>q*0_!n zE|WPodTZfR!f_>Q^N+--ZTKi(B=%LRmQtON$F(j1x9najUUhBbIg4Bhe;w~AWv)kQ)H=QAH!#o?`;qaSR4|f;^YL z+LpPf6KANiL}tlqwp8lK8!n`IQ3s8QH+z$&J;(5v1e*|#FmK!B^H;Nw55m<|#(3qw zdk^ig0k0L*lKQgy_zW-n#Sr3ksK)@RqL*gT@gk;nBQg zvaofL3vU$LRBxb#p#G$4&C+3=3={m=iq?kFSNfI@eV(@h*j8$++P$taf5jln-z$m9 zNzq-575EZgqWluBitPBH;~`*rbNytX{AGK*pT>ldTe}r!+l8#1}vR{9a%CtGrfDi|LxJxs{It(-kWv zCd;Z7wC5z+GpJB_f0$G?(k_IM4DIP*&b?9n3PWFgx#8wZ_v_D#KB*A$Rs^y22m$q_ z?{eb*0xql$oTS=VzBX&PlU#OH_<5XcAKB-1)mVR?h0&I^j$i(yitUa!IW*hpz zN#*eVIKe%(ofllsmw|D4USmJ$ZbdDLlP8Z55Kp#~{@%K8>=S@)m8~)(O%D25tA<9& z6m848jH>i;MpJA@=a{9T5|!>6>A^ePfRD`D)u|&s@dK*=YOac#{BS~I@MyduRNx`e z=@0CQ(EJ&(DE#DX1nYKv=ZtU=H9jX=#cuxR{Q~R-ap^NVC2>~539Oko^yCc4!xfT4c*I@25 zp`ygOFyUslC_Nay6a4;n_}_e>koPR(R}!r9G6H9>2zdJhxS+jH52ST92#FxX|GZ0K zQlqvU-3(^WL_|P9_3v4L*I>d*>~9H{Ge^{ttc0mp0ZEQCgp_0@x^^8Kbe&D;f1M3t dn`ZgJl)OmKUrzY9zhFXkY_TLSnXlL>^M6$1_-6nB diff --git a/examples/palabra/labs/lab3/lab3_result.xlsx b/examples/palabra/labs/lab3/lab3_result.xlsx index 03a54d50edc0280f2fac4f66f629fd7d9aa3f4a3..acf07bf2dbc52f95ae27c756aa6b769c2c7eb1c9 100644 GIT binary patch delta 14697 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 delta 16008 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 c9!DD8lQ_6zhB^K{Lz@_R7Agn+a delta 3487 zcmZWsc{J32_a93tdu53kktKv_q>P;`jV()d#*#g|29fxXCTnEI&|;E3`!e<+A^Q>u z$ylRsT(6b zQB%K+fo;^#lIh)DY;$R6e9sLkK?Mvv?|gIsBgJ~-NxO@J3T)epnKqjhJ{A4+x`*A_ z`8$%%F4?UbTuSy*4C2(+XY=N0%b)o;y~xl9qZ@G5hjy9WX64FeViVzX% zsN|c{#It#rq=p>|p6mA6Y#~{f=d?kRG0WQ1iYS+rTB#)FRcqVE+1AxLEGurk-5HOA zr|6qh;&0{7O>22z?qR$&E|nDivd0?#@?4XVs77lhb5~gCzgVOjyoCXrQ>sUH2`sJ4 z{_hHg?_N@3)2kixz)UY*vBx)(ke}ubBN7&7jIx!VPuO^45o=n;MNtU2P;)8XsAk;#>Y;>i$v0Ie-;i5%7`(h=x zR^Kep`z8gEOFtUgzzZCjHH6AGL}syhuWvREI&J!)xUA)L%CrL($qt9V%mxbcuD24i zw7p7V_gh6j+k2OAlF|1>nFe)FEEt(|d^B3ft2;1WPUbI2rF7mz>Wj&q+uZRRmkabvvo6*#_o_xwCYwqj5EzBeK#o&m{^r&$@rl%NdeN8*>+kiJ-Uo~jBhTm zUJf|LzR2Z>SNK`XI<$2eoaP;|{a}|AueQgZ`*VXl-Z>;9)9~O*NwVA>Voqkj$3oI2 z249qDH9|DT#WzSt>bssoE`y<5tU|Zrg_vxkxnI2x@Z<`cdACIw@6;otkPy4yCg*oI zV$JTfAlfH+!3_|i$FUUfPs$8oeSYc6e6ndM{NPN~yL>TSpNOIdFo{tV>y(eVl7J8RuHppfu*Ssk=o6R5uR!RS6y9l;1OP&+igm59gbbA_kUhZalz9YT;xyOTvTMy{uf=!1o)fn_Kb@6L=$^e_&}ziLn!p*?lnklbu+!nKVzldK zt%|ejK3_Qt_0TdUN#_#mQ(97epGz^L+V1qp06kohvNJFj$b3(P?D|y5ToWV+hPcp^ zcoH@HAFu_o14AhFB(k7p{~J>E7=lo2?;$5(i2s4j5Ht*NvB&TfitWGQnI1!06k8Jj z(SRZT!6yhUEbQym8M~1&yLxO*aT+j(Gd<>vQXDODtuP14KX6Z4;q#>4+$zf^ig&_E zvd4lE#nFexk^GNnGBhRCU1uUh z)g5Ql1nfyC2MM?Dog`=8c=nX#x|mStl+v|FGfIJqVl0#aK5?1vn}y;s-mHi|f}_9_ zK7Y}K$LcV_b2#b`FqITOYnaeW60pCWkU%KL*=o{Ul9tO2v=X=M<|IbyDc3teE}!j{7(f{~IOLS);(>$3fkk z0VG~{JtxUv9Lkmo_-!LVqvnlueENCNs!;bg@V+bVIm=_a<1`Uvp^JATw?f_RuJv8< z&fyu`^$ct3sGr`fpjoW{O7n`_{)90+Sv%zZZT3vL_0M`=vYhgYf21dj8K~CsBvrq8 z&Et0zHiExrmJ!k|3)ipV_Jh%d{N!PNW6|F8N<&KDUcgG62PiA!scJR)@%4t40@_h_ zCP%j--b5zJs^LdSR!jN@*>df4_cwzln;9(c_N3zc=Uv1c4;F?_I~CEM-oqB04Ay;C zs$45q=9#>QDhRF=7DpT`To0Q-$dNymt_Y=-LN;B#fOe;d$}L~|ZIUm>?hY25`x+B) zEy=1)k)0_!#TPJBKRDgS6vVUn-AuT`AEa@xGjw{cs3LxkP;jz4{>veWY_qJDHFHSf zR#`=ft$Zw6@xePflTvKR?GtHDUgzA=OWnm@j(Z&EDA~#TT5?N-D+N`tcaXN){@?U4 z8CTLbR+lfPk(Rr2FSv@O)nG0!+Rl)PIIq$I0Q@%RC4dbjs<}U-EtBN)qH!~(D^Ajd zWTWYd1$%=oc0D{$NSstQTPHlZv2)Z~OK!#0+&y7W&t@5JzARc!dsn}VT~iE!jDBOv zH>cH^4s#_ML`7V#v(^0*;$ray^$AMvOvvl#aCBfKI`DuYy%-%> zv|#X1dbC>DEaHcU=tSr3zT%1C$$k-=$ry54?yhSWH&qTYN?i0){m@v^s$xWrkMI^{ zMVVsIPWdWXK&(lz5FN3bq@8$wJs_j59Qd{${MKf= zGqS>JKc?^N6M3tCiQv%^=AmDEc>(Y&tAHx_ct<(Q>6%9+ZK-R(wzrD8M9o06 zl;!j3ysG?E7NUH6LO#L z<_F(X19mh!n@}oQIE>S+L^)x8VVlbaMucT#@d3F~bhQ;~R?k z&s8a531T_@ir*Y!MExRVm>oS+r03W?6GDdAo?K-d*HHqB8PzDs3F0mNz4I-ToqpKb z&JX$K8KPzVA@eO1t;s6sNAv!fwVpT7Ub58^Z~J?{SDj$5V;Zk|qYBR&<1p@j2dUMg zIjFEzR)Y44iEd;j`vrgI9%~?*3egs|`RkBs&PJQ+5QV&u(dq@G2f;Yg?Z_C~#zy{d ze-pv&<$QoT0dFE$QG&T|`fQ42*mOIS{%oDMdSUIEW%pltUUs)0C1-q2s53fXeTY-) z5`p3)@k*JmjAox6Ixv!9d5`mrN)IZ=0 ztO?G0qgyypTqB)vb`w|Mdtu^jAnE#y#37nQx ze+o1^4fTjP?Qo&#<61E1+&-=GW2Z7EX8~A^puy7vi4p^A) zHwO-Ad<>)pfrjZpAf|s4j&m5*Yc{M31pKG14FpVmgA*GJ;j;d%nJsl+fl+}#BF8$G zzciPR#~iN-_r34A+k1PvLjyfM40Ncd*=YW6qq2}%oT)217VRB3;QmV-$%S2mi2NA{ X;Q>>7aAT*q*s*#%taLTt-^70b@vDg1 diff --git a/examples/palabra/results.xlsx b/examples/palabra/results.xlsx deleted file mode 100644 index 79a8ba891371a9e9faecb527b5119232a6660642..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7650 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