diff options
Diffstat (limited to 'test/security/check_versions/versions')
6 files changed, 1467 insertions, 0 deletions
diff --git a/test/security/check_versions/versions/__init__.py b/test/security/check_versions/versions/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/test/security/check_versions/versions/__init__.py diff --git a/test/security/check_versions/versions/k8s_bin_versions_inspector.py b/test/security/check_versions/versions/k8s_bin_versions_inspector.py new file mode 100644 index 000000000..bd3041d63 --- /dev/null +++ b/test/security/check_versions/versions/k8s_bin_versions_inspector.py @@ -0,0 +1,769 @@ +#!/usr/bin/env python3 + +# COPYRIGHT NOTICE STARTS HERE +# +# Copyright 2020 Samsung Electronics Co., Ltd. +# Copyright 2023 Deutsche Telekom AG +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# COPYRIGHT NOTICE ENDS HERE + +""" +k8s_bin_versions_inspector is a module for verifying versions of CPython and +OpenJDK binaries installed in the kubernetes cluster containers. +""" + +__title__ = "k8s_bin_versions_inspector" +__summary__ = ( + "Module for verifying versions of CPython and OpenJDK binaries installed" + " in the kubernetes cluster containers." +) +__version__ = "0.1.0" +__author__ = "kkkk.k@samsung.com" +__license__ = "Apache-2.0" +__copyright__ = "Copyright 2020 Samsung Electronics Co., Ltd." + +from typing import Iterable, List, Optional, Pattern, Union + +import argparse +import dataclasses +import itertools +import json +import logging +import pathlib +import pprint +import re +import string +import sys +from typing import Iterable, List, Optional, Pattern, Union +import tabulate +import yaml + +import kubernetes + +RECOMMENDED_VERSIONS_FILE = "/tmp/recommended_versions.yaml" +WAIVER_LIST_FILE = "/tmp/versions_xfail.txt" + +# Logger +logging.basicConfig() +LOGGER = logging.getLogger("onap-versions-status-inspector") +LOGGER.setLevel("INFO") + + +def parse_argv(argv: Optional[List[str]] = None) -> argparse.Namespace: + """Function for parsing command line arguments. + + Args: + argv: Unparsed list of command line arguments. + + Returns: + Namespace with values from parsed arguments. + """ + + epilog = ( + f"Author: {__author__}\n" + f"License: {__license__}\n" + f"Copyright: {__copyright__}\n" + ) + + parser = argparse.ArgumentParser( + formatter_class=argparse.RawTextHelpFormatter, + prog=__title__, + description=__summary__, + epilog=epilog, + add_help=False, + ) + + parser.add_argument("-c", "--config-file", help="Name of the kube-config file.") + + parser.add_argument( + "-s", + "--field-selector", + default="", + help="Kubernetes field selector, to filter out containers objects.", + ) + + parser.add_argument( + "-o", + "--output-file", + type=pathlib.Path, + help="Path to file, where output will be saved.", + ) + + parser.add_argument( + "-f", + "--output-format", + choices=("tabulate", "pprint", "json"), + default="tabulate", + help="Format of the output file (tabulate, pprint, json).", + ) + + parser.add_argument( + "-i", + "--ignore-empty", + action="store_true", + help="Ignore containers without any versions.", + ) + + parser.add_argument( + "-a", + "--acceptable", + type=pathlib.Path, + help="Path to YAML file, with list of acceptable software versions.", + ) + + parser.add_argument( + "-n", + "--namespace", + help="Namespace to use to list pods." + "If empty pods are going to be listed from all namespaces", + ) + + parser.add_argument( + "--check-istio-sidecar", + action="store_true", + help="Add if you want to check istio sidecars also", + ) + + parser.add_argument( + "--istio-sidecar-name", + default="istio-proxy", + help="Name of istio sidecar to filter out", + ) + + parser.add_argument( + "-d", + "--debug", + action="store_true", + help="Enable debugging mode in the k8s API.", + ) + + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Suppress printing text on standard output.", + ) + + parser.add_argument( + "-w", + "--waiver", + type=pathlib.Path, + help="Path of the waiver xfail file.", + ) + + parser.add_argument( + "-V", + "--version", + action="version", + version=f"{__title__} {__version__}", + help="Display version information and exit.", + ) + + parser.add_argument( + "-h", "--help", action="help", help="Display this help text and exit." + ) + + args = parser.parse_args(argv) + + return args + + +@dataclasses.dataclass +class ContainerExtra: + "Data class, to storage extra informations about container." + + running: bool + image: str + identifier: str + + +@dataclasses.dataclass +class ContainerVersions: + "Data class, to storage software versions from container." + + python: list + java: list + + +@dataclasses.dataclass +class ContainerInfo: + "Data class, to storage multiple informations about container." + + namespace: str + pod: str + container: str + extra: ContainerExtra + versions: ContainerVersions = None + + +def is_container_running( + status: kubernetes.client.models.v1_container_status.V1ContainerStatus, +) -> bool: + """Function to determine if k8s cluster container is in running state. + + Args: + status: Single item from container_statuses list, that represents container status. + + Returns: + If container is in running state. + """ + + if status.state.terminated: + return False + + if status.state.waiting: + return False + + if not status.state.running: + return False + + return True + + +def list_all_containers( + api: kubernetes.client.api.core_v1_api.CoreV1Api, + field_selector: str, + namespace: Union[None, str], + check_istio_sidecars: bool, + istio_sidecar_name: str, +) -> Iterable[ContainerInfo]: + """Get list of all containers names. + + Args: + api: Client of the k8s cluster API. + field_selector: Kubernetes field selector, to filter out containers objects. + namespace: Namespace to limit reading pods from + check_istio_sidecars: Flag to enable/disable istio sidecars check. + Default to False + istio_sidecar_name: If checking istio sidecars is disabled the name to filter + containers out + + Yields: + Objects for all containers in k8s cluster. + """ + + if namespace: + pods = api.list_namespaced_pod(namespace, field_selector=field_selector).items + else: + pods = api.list_pod_for_all_namespaces(field_selector=field_selector).items + + # Filtering to avoid testing integration or replica pods + pods = [ + pod + for pod in pods + if "replica" not in pod.metadata.name and "integration" not in pod.metadata.name + ] + + containers_statuses = ( + (pod.metadata.namespace, pod.metadata.name, pod.status.container_statuses) + for pod in pods + if pod.status.container_statuses + ) + + containers_status = ( + itertools.product([namespace], [pod], statuses) + for namespace, pod, statuses in containers_statuses + ) + + containers_chained = itertools.chain.from_iterable(containers_status) + + containers_fields = ( + ( + namespace, + pod, + status.name, + is_container_running(status), + status.image, + status.container_id, + ) + for namespace, pod, status in containers_chained + ) + + container_items = ( + ContainerInfo( + namespace, pod, container, ContainerExtra(running, image, identifier) + ) + for namespace, pod, container, running, image, identifier in containers_fields + ) + + if not check_istio_sidecars: + container_items = filter( + lambda container: container.container != istio_sidecar_name, container_items + ) + + yield from container_items + + +def sync_post_namespaced_pod_exec( + api: kubernetes.client.api.core_v1_api.CoreV1Api, + container: ContainerInfo, + command: Union[List[str], str], +) -> dict: + """Function to execute command on selected container. + + Args: + api: Client of the k8s cluster API. + container: Object, that represents container in k8s cluster. + command: Command to execute as a list of arguments or single string. + + Returns: + Dictionary that store informations about command execution. + * stdout - Standard output captured from execution. + * stderr - Standard error captured from execution. + * error - Error object that was received from kubernetes API. + * code - Exit code returned by executed process + or -1 if container is not running + or -2 if other failure occurred. + """ + + stdout = "" + stderr = "" + error = {} + code = -1 + LOGGER.debug("sync_post_namespaced_pod_exec container= %s", container.pod) + try: + client_stream = kubernetes.stream.stream( + api.connect_post_namespaced_pod_exec, + namespace=container.namespace, + name=container.pod, + container=container.container, + command=command, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _request_timeout=1.0, + _preload_content=False, + ) + client_stream.run_forever(timeout=5) + stdout = client_stream.read_stdout() + stderr = client_stream.read_stderr() + error = yaml.safe_load( + client_stream.read_channel(kubernetes.stream.ws_client.ERROR_CHANNEL) + ) + + code = ( + 0 + if error["status"] == "Success" + else -2 + if error["reason"] != "NonZeroExitCode" + else int(error["details"]["causes"][0]["message"]) + ) + except ( + kubernetes.client.rest.ApiException, + kubernetes.client.exceptions.ApiException, + ): + LOGGER.debug("Discard unexpected k8s client Error..") + except TypeError: + LOGGER.debug("Type Error, no error status") + pass + + return { + "stdout": stdout, + "stderr": stderr, + "error": error, + "code": code, + } + + +def generate_python_binaries() -> List[str]: + """Function to generate list of names and paths for CPython binaries. + + Returns: + List of names and paths, to CPython binaries. + """ + + dirnames = ["", "/usr/bin/", "/usr/local/bin/"] + + majors_minors = [ + f"{major}.{minor}" for major, minor in itertools.product("23", string.digits) + ] + + suffixes = ["", "2", "3"] + majors_minors + + basenames = [f"python{suffix}" for suffix in suffixes] + + binaries = [f"{dir}{base}" for dir, base in itertools.product(dirnames, basenames)] + + return binaries + + +def generate_java_binaries() -> List[str]: + """Function to generate list of names and paths for OpenJDK binaries. + + Returns: + List of names and paths, to OpenJDK binaries. + """ + + binaries = [ + "java", + "/usr/bin/java", + "/usr/local/bin/java", + "/etc/alternatives/java", + "/usr/java/openjdk-14/bin/java", + ] + + return binaries + + +def determine_versions_abstraction( + api: kubernetes.client.api.core_v1_api.CoreV1Api, + container: ContainerInfo, + binaries: List[str], + extractor: Pattern, +) -> List[str]: + """Function to determine list of software versions, that are installed in + given container. + + Args: + api: Client of the k8s cluster API. + container: Object, that represents container in k8s cluster. + binaries: List of names and paths to the abstract software binaries. + extractor: Pattern to extract the version string from the output of the binary execution. + + Returns: + List of installed software versions. + """ + + commands = ([binary, "--version"] for binary in binaries) + commands_old = ([binary, "-version"] for binary in binaries) + commands_all = itertools.chain(commands, commands_old) + + # TODO: This list comprehension should be parallelized + results = ( + sync_post_namespaced_pod_exec(api, container, command) + for command in commands_all + ) + + successes = ( + f"{result['stdout']}{result['stderr']}" + for result in results + if result["code"] == 0 + ) + + extractions = (extractor.search(success) for success in successes) + + versions = sorted( + set(extraction.group(1) for extraction in extractions if extraction) + ) + + return versions + + +def determine_versions_of_python( + api: kubernetes.client.api.core_v1_api.CoreV1Api, container: ContainerInfo +) -> List[str]: + """Function to determine list of CPython versions, + that are installed in given container. + + Args: + api: Client of the k8s cluster API. + container: Object, that represents container in k8s cluster. + + Returns: + List of installed CPython versions. + """ + + extractor = re.compile("Python ([0-9.]+)") + + binaries = generate_python_binaries() + + versions = determine_versions_abstraction(api, container, binaries, extractor) + + return versions + + +def determine_versions_of_java( + api: kubernetes.client.api.core_v1_api.CoreV1Api, container: ContainerInfo +) -> List[str]: + """Function to determine list of OpenJDK versions, + that are installed in given container. + + Args: + api: Client of the k8s cluster API. + container: Object, that represents container in k8s cluster. + + Returns: + List of installed OpenJDK versions. + """ + + extractor = re.compile('openjdk [version" ]*([0-9._]+)') + + binaries = generate_java_binaries() + + versions = determine_versions_abstraction(api, container, binaries, extractor) + + return versions + + +def gather_containers_informations( + api: kubernetes.client.api.core_v1_api.CoreV1Api, + field_selector: str, + ignore_empty: bool, + namespace: Union[None, str], + check_istio_sidecars: bool, + istio_sidecar_name: str, +) -> List[ContainerInfo]: + """Get list of all containers names. + + Args: + api: Client of the k8s cluster API. + field_selector: Kubernetes field selector, to filter out containers objects. + ignore_empty: Determines, if containers with empty versions should be ignored. + namespace: Namespace to limit reading pods from + check_istio_sidecars: Flag to enable/disable istio sidecars check. + Default to False + istio_sidecar_name: If checking istio sidecars is disabled the name to filter + containers out + + Returns: + List of initialized objects for containers in k8s cluster. + """ + + containers = list( + list_all_containers( + api, field_selector, namespace, check_istio_sidecars, istio_sidecar_name + ) + ) + LOGGER.info("List of containers: %s", containers) + + # TODO: This loop should be parallelized + for container in containers: + LOGGER.info("Container -----------------> %s", container) + python_versions = determine_versions_of_python(api, container) + java_versions = determine_versions_of_java(api, container) + container.versions = ContainerVersions(python_versions, java_versions) + LOGGER.info("Container versions: %s", container.versions) + + if ignore_empty: + containers = [c for c in containers if c.versions.python or c.versions.java] + + return containers + + +def generate_output_tabulate(containers: Iterable[ContainerInfo]) -> str: + """Function for generate output string in tabulate format. + + Args: + containers: List of items, that represents containers in k8s cluster. + + Returns: + Output string formatted by tabulate module. + """ + + headers = [ + "Namespace", + "Pod", + "Container", + "Running", + "CPython", + "OpenJDK", + ] + + rows = [ + [ + container.namespace, + container.pod, + container.container, + container.extra.running, + " ".join(container.versions.python), + " ".join(container.versions.java), + ] + for container in containers + ] + + output = tabulate.tabulate(rows, headers=headers) + + return output + + +def generate_output_pprint(containers: Iterable[ContainerInfo]) -> str: + """Function for generate output string in pprint format. + + Args: + containers: List of items, that represents containers in k8s cluster. + + Returns: + Output string formatted by pprint module. + """ + + output = pprint.pformat(containers) + + return output + + +def generate_output_json(containers: Iterable[ContainerInfo]) -> str: + """Function for generate output string in JSON format. + + Args: + containers: List of items, that represents containers in k8s cluster. + + Returns: + Output string formatted by json module. + """ + + data = [ + { + "namespace": container.namespace, + "pod": container.pod, + "container": container.container, + "extra": { + "running": container.extra.running, + "image": container.extra.image, + "identifier": container.extra.identifier, + }, + "versions": { + "python": container.versions.python, + "java": container.versions.java, + }, + } + for container in containers + ] + + output = json.dumps(data, indent=4) + + return output + + +def generate_and_handle_output( + containers: List[ContainerInfo], + output_format: str, + output_file: pathlib.Path, + quiet: bool, +) -> None: + """Generate and handle the output of the containers software versions. + + Args: + containers: List of items, that represents containers in k8s cluster. + output_format: String that will determine output format (tabulate, pprint, json). + output_file: Path to file, where output will be save. + quiet: Determines if output should be printed, to stdout. + """ + + output_generators = { + "tabulate": generate_output_tabulate, + "pprint": generate_output_pprint, + "json": generate_output_json, + } + LOGGER.debug("output_generators: %s", output_generators) + + output = output_generators[output_format](containers) + + if output_file: + try: + output_file.write_text(output) + except AttributeError: + LOGGER.error("Not possible to write_text") + + if not quiet: + LOGGER.info(output) + + +def verify_versions_acceptability( + containers: List[ContainerInfo], acceptable: pathlib.Path, quiet: bool +) -> bool: + """Function for verification of software versions installed in containers. + + Args: + containers: List of items, that represents containers in k8s cluster. + acceptable: Path to the YAML file, with the software verification parameters. + quiet: Determines if output should be printed, to stdout. + + Returns: + 0 if the verification was successful or 1 otherwise. + """ + + if not acceptable: + return 0 + + try: + acceptable.is_file() + except AttributeError: + LOGGER.error("No acceptable file found") + return -1 + + if not acceptable.is_file(): + raise FileNotFoundError( + "File with configuration for acceptable does not exists!" + ) + + with open(acceptable) as stream: + data = yaml.safe_load(stream) + + python_acceptable = data.get("python3", []) + java_acceptable = data.get("java11", []) + + python_not_acceptable = [ + (container, "python3", version) + for container in containers + for version in container.versions.python + if version not in python_acceptable + ] + + java_not_acceptable = [ + (container, "java11", version) + for container in containers + for version in container.versions.java + if version not in java_acceptable + ] + + if not python_not_acceptable and not java_not_acceptable: + return 0 + + if quiet: + return 1 + + LOGGER.error("List of not acceptable versions") + pprint.pprint(python_not_acceptable) + pprint.pprint(java_not_acceptable) + + return 1 + + +def main(argv: Optional[List[str]] = None) -> str: + """Main entrypoint of the module for verifying versions of CPython and + OpenJDK installed in k8s cluster containers. + + Args: + argv: List of command line arguments. + """ + + args = parse_argv(argv) + + kubernetes.config.load_kube_config(args.config_file) + + api = kubernetes.client.CoreV1Api() + api.api_client.configuration.debug = args.debug + + containers = gather_containers_informations( + api, + args.field_selector, + args.ignore_empty, + args.namespace, + args.check_istio_sidecar, + args.istio_sidecar_name, + ) + + generate_and_handle_output( + containers, args.output_format, args.output_file, args.quiet + ) + + code = verify_versions_acceptability(containers, args.acceptable, args.quiet) + + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/security/check_versions/versions/k8s_bin_versions_inspector_test_case.py b/test/security/check_versions/versions/k8s_bin_versions_inspector_test_case.py new file mode 100644 index 000000000..30e46cad5 --- /dev/null +++ b/test/security/check_versions/versions/k8s_bin_versions_inspector_test_case.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 + +# COPYRIGHT NOTICE STARTS HERE +# +# Copyright 2020 Samsung Electronics Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# COPYRIGHT NOTICE ENDS HERE + +import logging +import pathlib +import time +import os +import wget +from kubernetes import client, config +from xtesting.core import testcase # pylint: disable=import-error + +import versions.reporting as Reporting +from versions.k8s_bin_versions_inspector import ( + gather_containers_informations, + generate_and_handle_output, + verify_versions_acceptability, +) + +RECOMMENDED_VERSIONS_FILE = "/tmp/recommended_versions.yaml" +WAIVER_LIST_FILE = "/tmp/versions_xfail.txt" + +# Logger +logging.basicConfig() +LOGGER = logging.getLogger("onap-versions-status-inspector") +LOGGER.setLevel("INFO") + + +class Inspector(testcase.TestCase): + """Inspector CLass.""" + + def __init__(self, **kwargs): + """Init the testcase.""" + if "case_name" not in kwargs: + kwargs["case_name"] = "check_versions" + super().__init__(**kwargs) + + version = os.getenv("ONAP_VERSION", "master") + base_url = "https://git.onap.org/integration/seccom/plain" + + self.namespace = "onap" + # if no Recommended file found, download it + if pathlib.Path(RECOMMENDED_VERSIONS_FILE).is_file(): + self.acceptable = pathlib.Path(RECOMMENDED_VERSIONS_FILE) + else: + self.acceptable = wget.download( + base_url + "/recommended_versions.yaml?h=" + version, + out=RECOMMENDED_VERSIONS_FILE, + ) + self.output_file = "/tmp/versions.json" + # if no waiver file found, download it + if pathlib.Path(WAIVER_LIST_FILE).is_file(): + self.waiver = pathlib.Path(WAIVER_LIST_FILE) + else: + self.waiver = wget.download( + base_url + "/waivers/versions/versions_xfail.txt?h=" + version, + out=WAIVER_LIST_FILE, + ) + self.result = 0 + self.start_time = None + self.stop_time = None + + def run(self): + """Execute the version Inspector.""" + self.start_time = time.time() + config.load_kube_config() + api = client.CoreV1Api() + + field_selector = "metadata.namespace==onap" + + containers = gather_containers_informations(api, field_selector, True, None, False, "istio-proxy") + LOGGER.info("gather_containers_informations") + LOGGER.info(containers) + LOGGER.info("---------------------------------") + + generate_and_handle_output( + containers, "json", pathlib.Path(self.output_file), True + ) + LOGGER.info("generate_and_handle_output in %s", self.output_file) + LOGGER.info("---------------------------------") + + code = verify_versions_acceptability(containers, self.acceptable, True) + LOGGER.info("verify_versions_acceptability") + LOGGER.info(code) + LOGGER.info("---------------------------------") + + # Generate reporting + test = Reporting.OnapVersionsReporting(result_file=self.output_file) + LOGGER.info("Prepare reporting") + self.result = test.generate_reporting(self.output_file) + LOGGER.info("Reporting generated") + + self.stop_time = time.time() + if self.result >= 90: + return testcase.TestCase.EX_OK + return testcase.TestCase.EX_TESTCASE_FAILED + + def set_namespace(self, namespace): + """Set namespace.""" + self.namespace = namespace diff --git a/test/security/check_versions/versions/reporting.py b/test/security/check_versions/versions/reporting.py new file mode 100644 index 000000000..9053600c2 --- /dev/null +++ b/test/security/check_versions/versions/reporting.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +# Copyright 2020 Orange, Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Generate result page +""" +import logging +import pathlib +import json +from dataclasses import dataclass +import os +import statistics +import wget +import yaml + +from packaging.version import Version + +from jinja2 import ( # pylint: disable=import-error + Environment, + select_autoescape, + PackageLoader, +) + +# Logger +LOG_LEVEL = "INFO" +logging.basicConfig() +LOGGER = logging.getLogger("onap-versions-status-reporting") +LOGGER.setLevel(LOG_LEVEL) + +REPORTING_FILE = "/var/lib/xtesting/results/versions_reporting.html" +# REPORTING_FILE = "/tmp/versions_reporting.html" +RESULT_FILE = "/tmp/versions.json" +RECOMMENDED_VERSIONS_FILE = "/tmp/recommended_versions.yaml" +WAIVER_LIST_FILE = "/tmp/versions_xfail.txt" + + +@dataclass +class TestResult: + """Test results retrieved from xtesting.""" + + pod_name: str + container: str + image: str + python_version: str + python_status: int + java_version: str + java_status: int + + +@dataclass +class SerieResult: + """Serie of tests.""" + + serie_id: str + success_rate: int = 0 + min: int = 0 + max: int = 0 + mean: float = 0.0 + median: float = 0.0 + nb_occurences: int = 0 + + +class OnapVersionsReporting: + """Build html summary page.""" + + def __init__(self, result_file) -> None: + """Initialization of the report.""" + version = os.getenv("ONAP_VERSION", "master") + base_url = "https://git.onap.org/integration/seccom/plain" + if pathlib.Path(WAIVER_LIST_FILE).is_file(): + self._waiver_file = pathlib.Path(WAIVER_LIST_FILE) + else: + self._waiver_file = wget.download( + base_url + "/waivers/versions/versions_xfail.txt?h=" + version, + out=WAIVER_LIST_FILE, + ) + if pathlib.Path(RECOMMENDED_VERSIONS_FILE).is_file(): + self._recommended_versions_file = pathlib.Path(RECOMMENDED_VERSIONS_FILE) + else: + self._recommended_versions_file = wget.download( + base_url + "/recommended_versions.yaml?h=" + version, + out=RECOMMENDED_VERSIONS_FILE, + ) + + def get_versions_scan_results(self, result_file, waiver_list): + """Get all the versions from the scan.""" + testresult = [] + # Get the recommended version list for java and python + min_java_version = self.get_recommended_version( + RECOMMENDED_VERSIONS_FILE, "java11" + ) + min_python_version = self.get_recommended_version( + RECOMMENDED_VERSIONS_FILE, "python3" + ) + + LOGGER.info("Min Java recommended version: %s", min_java_version) + LOGGER.info("Min Python recommended version: %s", min_python_version) + + with open(result_file) as json_file: + data = json.load(json_file) + LOGGER.info("Number of pods: %s", len(data)) + for component in data: + if component["container"] not in waiver_list: + testresult.append( + TestResult( + pod_name=component["pod"], + container=component["container"], + image=component["extra"]["image"], + python_version=component["versions"]["python"], + java_version=component["versions"]["java"], + python_status=self.get_version_status( + component["versions"]["python"], min_python_version[0] + ), + java_status=self.get_version_status( + component["versions"]["java"], min_java_version[0] + ), + ) + ) + LOGGER.info("Nb of pods (after waiver filtering) %s", len(testresult)) + return testresult + + @staticmethod + def get_version_status(versions, min_version): + """Based on the min version set the status of the component version.""" + # status_code + # 0: only recommended version found + # 1: recommended version found but not alone + # 2: recommended version not found but not far + # 3: recommended version not found but not far but not alone + # 4: recommended version not found + # we assume that versions are given accordign to usual java way + # X.Y.Z + LOGGER.debug("Version = %s", versions) + LOGGER.debug("Min Version = %s", min_version) + nb_versions_found = len(versions) + status_code = -1 + LOGGER.debug("Nb versions found :%s", nb_versions_found) + # if no version found retrieved -1 + if nb_versions_found > 0: + for version in versions: + clean_version = Version(version.replace("_", ".")) + min_version_ok = str(min_version) + + if clean_version >= Version(min_version_ok): + if nb_versions_found < 2: + status_code = 0 + else: + status_code = 2 + elif clean_version.major >= Version(min_version_ok).major: + if nb_versions_found < 2: + status_code = 1 + else: + status_code = 3 + else: + status_code = 4 + LOGGER.debug("Version status code = %s", status_code) + return status_code + + @staticmethod + def get_recommended_version(recommended_versions_file, component): + """Retrieve data from the json file.""" + with open(recommended_versions_file) as stream: + data = yaml.safe_load(stream) + try: + recommended_version = data[component]["recommended_versions"] + except KeyError: + recommended_version = None + return recommended_version + + @staticmethod + def get_waiver_list(waiver_file_path): + """Get the waiver list.""" + pods_to_be_excluded = [] + with open(waiver_file_path) as waiver_list: + for line in waiver_list: + line = line.strip("\n") + line = line.strip("\t") + if not line.startswith("#"): + pods_to_be_excluded.append(line) + return pods_to_be_excluded + + @staticmethod + def get_score(component_type, scan_res): + # Look at the java and python results + # 0 = recommended version + # 1 = acceptable version + nb_good_versions = 0 + nb_results = 0 + + for res in scan_res: + if component_type == "java": + if res.java_status >= 0: + nb_results += 1 + if res.java_status < 2: + nb_good_versions += 1 + elif component_type == "python": + if res.python_status >= 0: + nb_results += 1 + if res.python_status < 2: + nb_good_versions += 1 + try: + return round(nb_good_versions * 100 / nb_results, 1) + except ZeroDivisionError: + LOGGER.error("Impossible to calculate the success rate") + return 0 + + def generate_reporting(self, result_file): + """Generate HTML reporting page.""" + LOGGER.info("Generate versions HTML report.") + + # Get the waiver list + waiver_list = self.get_waiver_list(self._waiver_file) + LOGGER.info("Waiver list: %s", waiver_list) + + # Get the Versions results + scan_res = self.get_versions_scan_results(result_file, waiver_list) + + LOGGER.info("scan_res: %s", scan_res) + + # Evaluate result + status_res = {"java": 0, "python": 0} + for component_type in "java", "python": + status_res[component_type] = self.get_score(component_type, scan_res) + + LOGGER.info("status_res: %s", status_res) + + # Calculate the average score + numbers = [status_res[key] for key in status_res] + mean_ = statistics.mean(numbers) + + # Create reporting page + jinja_env = Environment( + autoescape=select_autoescape(["html"]), + loader=PackageLoader("versions"), + ) + page_info = { + "title": "ONAP Integration versions reporting", + "success_rate": status_res, + "mean": mean_, + } + jinja_env.get_template("versions.html.j2").stream( + info=page_info, data=scan_res + ).dump("{}".format(REPORTING_FILE)) + + return mean_ + + +if __name__ == "__main__": + test = OnapVersionsReporting( + RESULT_FILE, WAIVER_LIST_FILE, RECOMMENDED_VERSIONS_FILE + ) + test.generate_reporting(RESULT_FILE) diff --git a/test/security/check_versions/versions/templates/base.html.j2 b/test/security/check_versions/versions/templates/base.html.j2 new file mode 100644 index 000000000..025c0ad25 --- /dev/null +++ b/test/security/check_versions/versions/templates/base.html.j2 @@ -0,0 +1,232 @@ +{% macro color(failing, total) %} +{% if failing == 0 %} +is-success +{% else %} +{% if (failing / total) <= 0.1 %} +is-warning +{% else %} +is-danger +{% endif %} +{% endif %} +{% endmacro %} + +{% macro percentage(failing, total) %} +{{ ((total - failing) / total) | round }} +{% endmacro %} + +{% macro statistic(resource_name, failing, total) %} +{% set success = total - failing %} +<div class="level-item has-text-centered"> + <div> + <p class="heading">{{ resource_name | capitalize }}</p> + <p class="title">{{ success }}/{{ total }}</p> + <progress class="progress {{ color(failing, total) }}" value="{{ success }}" max="{{ total }}">{{ percentage(failing, total) }}</progress> + </div> + </div> +{% endmacro %} + +{% macro pods_table(pods) %} +<div id="pods" class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable"> + <thead> + <tr> + <th>Name</th> + <th>Ready</th> + <th>Status</th> + <th>Reason</th> + <th>Restarts</th> + </tr> + </thead> + <tbody> + {% for pod in pods %} + <tr> + <td><a href="./pod-{{ pod.name }}.html" title="{{ pod.name }}">{{ pod.k8s.metadata.name }}</a></td> + {% if pod.init_done %} + <td>{{ pod.running_containers }}/{{ (pod.containers | length) }}</td> + {% else %} + <td>Init:{{ pod.runned_init_containers }}/{{ (pod.init_containers | length) }}</td> + {% endif %} + <td>{{ pod.k8s.status.phase }}</td> + <td>{{ pod.k8s.status.reason }}</td> + {% if pod.init_done %} + <td>{{ pod.restart_count }}</td> + {% else %} + <td>{{ pod.init_restart_count }}</td> + {% endif %} + </tr> + {% endfor %} + </tbody> + </table> +</div> +{% endmacro %} + +{% macro key_value_description_list(title, dict) %} +<dt><strong>{{ title | capitalize }}:</strong></dt> +<dd> + {% if dict %} + {% for key, value in dict.items() %} + {% if loop.first %} + <dl> + {% endif %} + <dt>{{ key }}:</dt> + <dd>{{ value }}</dd> + {% if loop.last %} + </dl> + {% endif %} + {% endfor %} + {% endif %} +</dd> +{% endmacro %} + +{% macro description(k8s) %} +<div class="container"> + <h1 class="title is-1">Description</h1> + <div class="content"> + <dl> + {% if k8s.spec.type %} + <dt><strong>Type:</strong></dt> + <dd>{{ k8s.spec.type }}</dd> + {% if (k8s.spec.type | lower) == "clusterip" %} + <dt><strong>Headless:</strong></dt> + <dd>{% if (k8s.spec.cluster_ip | lower) == "none" %}Yes{% else %}No{% endif %}</dd> + {% endif %} + {% endif %} + {{ key_value_description_list('Labels', k8s.metadata.labels) | indent(width=6) }} + {{ key_value_description_list('Annotations', k8s.metadata.annotations) | indent(width=6) }} + {% if k8s.spec.selector %} + {% if k8s.spec.selector.match_labels %} + {{ key_value_description_list('Selector', k8s.spec.selector.match_labels) | indent(width=6) }} + {% else %} + {{ key_value_description_list('Selector', k8s.spec.selector) | indent(width=6) }} + {% endif %} + {% endif %} + {% if k8s.phase %} + <dt><strong>Status:</strong></dt> + <dd>{{ k8s.phase }}</dd> + {% endif %} + {% if k8s.metadata.owner_references %} + <dt><strong>Controlled By:</strong></dt> + <dd>{{ k8s.metadata.owner_references[0].kind }}/{{ k8s.metadata.owner_references[0].name }}</dd> + {% endif %} + </dl> + </div> +</div> +{% endmacro %} + +{% macro pods_container(pods, parent, has_title=True) %} +<div class="container"> + {% if has_title %} + <h1 class="title is-1">Pods</h1> + {% endif %} + {% if (pods | length) > 0 %} + {{ pods_table(pods) | indent(width=2) }} + {% else %} + <div class="notification is-warning">{{ parent }} has no pods!</div> + {% endif %} +</div> +{% endmacro %} + +{% macro two_level_breadcrumb(title, name) %} +<section class="section"> + <div class="container"> + <nav class="breadcrumb" aria-label="breadcrumbs"> + <ul> + <li><a href="./index.html">Summary</a></li> + <li class="is-active"><a href="#" aria-current="page">{{ title | capitalize }} {{ name }}</a></li> + </ul> + </nav> + </div> +</section> +{% endmacro %} + +{% macro pod_parent_summary(title, name, failed_pods, pods) %} +{{ summary(title, name, [{'title': 'Pod', 'failing': failed_pods, 'total': (pods | length)}]) }} +{% endmacro %} + +{% macro number_ok(number, none_value, total=None) %} +{% if number %} +{% if total and number < total %} +<span class="tag is-warning">{{ number }}</span> +{% else %} +{{ number }} +{% endif %} +{% else %} +<span class="tag is-warning">{{ none_value }}</span> +{% endif %} +{% endmacro %} + +{% macro summary(title, name, statistics) %} +<section class="hero is-light"> + <div class="hero-body"> + <div class="container"> + <h1 class="title is-1"> + {{ title | capitalize }} {{ name }} Summary + </h1> + <nav class="level"> + {% for stat in statistics %} + {% if stat.total > 0 %} + {{ statistic(stat.title, stat.failing, stat.total) | indent(width=8) }} + {% endif %} + {% endfor %} + </nav> + </div> + </div> +</section> +{% endmacro %} + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Tests results - {% block title %}{% endblock %}</title> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"> + <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script> + {% block more_head %}{% endblock %} + </head> + <body> + <nav class="navbar" role="navigation" aria-label="main navigation"> + <div class="navbar-brand"> + <a class="navbar-item" href="https://www.onap.org"> + <img src="https://www.onap.org/wp-content/uploads/sites/20/2017/02/logo_onap_2017.png" width="234" height="50"> + </a> + + <a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample"> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + <span aria-hidden="true"></span> + </a> + </div> + + <div id="navbarBasicExample" class="navbar-menu"> + <div class="navbar-start"> + <a class="navbar-item"> + Summary + </a> + </div> + </div> + </nav> + + {% block content %}{% endblock %} + + <footer class="footer"> + <div class="container"> + <div class="columns"> + <div class="column"> + <p class="has-text-grey-light"> + <a href="https://bulma.io/made-with-bulma/"> + <img src="https://bulma.io/images/made-with-bulma.png" alt="Made with Bulma" width="128" height="24"> + </a> + </div> + <div class="column"> + <a class="has-text-grey" href="https://gitlab.com/Orange-OpenSource/lfn/tools/kubernetes-status" style="border-bottom: 1px solid currentColor;"> + Improve this page on Gitlab + </a> + </p> + </div> + </div> + </div> + </footer> + </body> +</html> + diff --git a/test/security/check_versions/versions/templates/versions.html.j2 b/test/security/check_versions/versions/templates/versions.html.j2 new file mode 100644 index 000000000..4860a72da --- /dev/null +++ b/test/security/check_versions/versions/templates/versions.html.j2 @@ -0,0 +1,85 @@ +{% extends "base.html.j2" %} +{% block title %}ONAPTEST Bench{% endblock %} + +{% block content %} +<h1 class="title is-1">{{ info.title }}</h1> + +<div class="container"> + +<article class="message"> +<div class="message-header"> + <p>Results</p> +</div> +<div class="message-body"> +SECCOM recommended versions (global success rate: {{ info.mean }}): + <ul> + <li>Java: {{ info.success_rate.java }}% </li> + <li>Python: {{ info.success_rate.python }}%</li> + </ul> +</div> +</article> + +<article class="message"> + <div class="message-header"> + <p>Legend</p> + </div> + <div class="message-body"> + <div class="has-background-success">SECCOM recommended version</div> + <div class="has-background-success-light">Not the recommended version but at least the major version</div> + <div class="has-background-warning-light">Ambiguous versions but at least 1 is the SECCOM recommended version</div> + <div class="has-background-warning">Ambiguous versions but at least 1 is the major recommended version</div> + <div class="has-background-danger">Wrong Versions</div> + </div> +</article> +<br> + +<h2 class="title is-1">JAVA versions</h2> + +<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth"> + <thead> + <tr> + <th>Component</th> + <th>Versions</th> + </tr> + </thead> + <tbody> + {% for component in data %} + <tr {% if component.java_status == 4 %} class="has-background-danger" {%elif component.java_status == 0 %} class="has-background-success" {%elif component.java_status == 1 %} class="has-background-success-light" {%elif component.java_status == 2 %} class="has-background-warning-light" {%elif component.java_status == 3 %} class="has-background-warning" {% endif %}> + + {% if component.java_version is defined and component.java_version|length > 0 %} + <td>{{ component.container }}</td> + <td>{{ component.java_version}}</td> + {% endif %} + </tr> + {% endfor %} + </tbody> + </table> +</div> +<br> + +<div class="container"> +<h2 class="title is-1">Python versions</h2> + +<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth"> + <thead> + <tr> + <th>Component</th> + <th>Versions</th> + </tr> + </thead> + <tbody> + {% for component in data %} + <tr {% if component.python_status == 4 %} class="has-background-danger" {%elif component.python_status == 0 %} class="has-background-success" {%elif component.python_status == 1 %} class="has-background-success-light" {%elif component.python_status == 2 %} class="has-background-warning-light" {%elif component.python_status == 3 %} class="has-background-warning" {% endif %}> + {% if component.python_version is defined and component.python_version|length > 0 %} + <td>{{ component.container }}</td> + <td>{{ component.python_version}}</td> + {% endif %} + </tr> + {% endfor %} + </tbody> + </table> +</div> + +{% endblock %} +</div> +</section> |