From 41ae4a3702bc6216f30ecaeae8a0ce060c64daca Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Mon, 25 May 2026 15:29:16 +0200 Subject: [PATCH 1/2] Add support for eol-checker. The pull request contains several analysis. * Analysis lifecycle-defs * Analysis Jira issues if the deprecation was already filed * Summary report for maintainers. * Some variables needs to be defined before running the checker, like LIFECYCLE_DEFS_URL, JIRA_USERNAME, JIRA_PASSWORD --- eol-checker/eol_checker/__init__.py | 0 eol-checker/eol_checker/checker.py | 225 ++++++++++++++++++++ eol-checker/eol_checker/constants.py | 45 ++++ eol-checker/eol_checker/custom_logger.py | 74 +++++++ eol-checker/eol_checker/jira.py | 101 +++++++++ eol-checker/eol_checker/utils.py | 70 +++++++ eol-checker/eol_checker/yaml_loader.py | 75 +++++++ eol-checker/tests/__init__.py | 0 eol-checker/tests/test_checker.py | 254 +++++++++++++++++++++++ eol-checker/tests/test_jira.py | 131 ++++++++++++ eol-checker/tests/test_utils.py | 47 +++++ eol-checker/tests/test_yaml_loader.py | 61 ++++++ eol-checker/tox.ini | 14 ++ 13 files changed, 1097 insertions(+) create mode 100644 eol-checker/eol_checker/__init__.py create mode 100755 eol-checker/eol_checker/checker.py create mode 100644 eol-checker/eol_checker/constants.py create mode 100644 eol-checker/eol_checker/custom_logger.py create mode 100644 eol-checker/eol_checker/jira.py create mode 100644 eol-checker/eol_checker/utils.py create mode 100644 eol-checker/eol_checker/yaml_loader.py create mode 100644 eol-checker/tests/__init__.py create mode 100644 eol-checker/tests/test_checker.py create mode 100644 eol-checker/tests/test_jira.py create mode 100644 eol-checker/tests/test_utils.py create mode 100644 eol-checker/tests/test_yaml_loader.py create mode 100644 eol-checker/tox.ini diff --git a/eol-checker/eol_checker/__init__.py b/eol-checker/eol_checker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eol-checker/eol_checker/checker.py b/eol-checker/eol_checker/checker.py new file mode 100755 index 0000000..8e1264a --- /dev/null +++ b/eol-checker/eol_checker/checker.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# The MIT License (MIT) +# +# Copyright (c) 2025 Authors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Authors: Petr Hracek + +import logging +import urllib3 + +from datetime import date, datetime +from typing import Any, Dict + +from eol_checker.jira import JiraFetcher +from eol_checker.yaml_loader import YamlLoader +from eol_checker.utils import get_jira_ticket_url, is_eol_version, get_lifecycles +from eol_checker.constants import OS_NAMES, CONTAINER_NAMES + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) + + +class ContainerEolChecker(object): + """ + Checker for container image EOL dates from lifecycle YAML. + """ + + def __init__(self): + self.today = date.today() + self.lifecycle_data: Any = None + self.eol_images: dict = {} + self.approaching_eol_images: dict = {} + self.os_name: str = "" + self.container_to_analyze: str = "" + self.jira_fetcher = JiraFetcher() + + def check_enddate(self, lifecycle: Dict[str, Any]) -> None: + """ + Check the enddate of the lifecycle. + Args: + lifecycle: The lifecycle. + """ + if "enddate" not in lifecycle: + return + + application_stream_name = lifecycle["application_stream_name"] + enddate = datetime.strptime(lifecycle["enddate"], "%Y%m%d").date() + + logger.debug( + "Enddate('%s'): '%s' and today is '%s'", + application_stream_name, + enddate, + self.today, + ) + is_eol = is_eol_version(enddate, self.today) + if is_eol == 1: + logger.info( + "Deprecation should be processed for image stream %s: enddate is '%s'", + application_stream_name, + enddate, + ) + self.eol_images[self.os_name][self.container_to_analyze] = { + "name": application_stream_name + } + if is_eol == 2: + logger.info( + "Deprecation of image stream %s is approaching next month should be scheduled: enddate is '%s'", + application_stream_name, + enddate, + ) + self.approaching_eol_images[self.os_name][self.container_to_analyze] = { + "name": application_stream_name + } + + def analyze_lifecycle_yaml(self, data: Any) -> None: + """ + Analyze the lifecycle YAML file. + For each lifecycle, check the enddate and log the result. + Args: + data: The lifecycle YAML file content. + """ + for lifecycle in get_lifecycles(data): + self.check_enddate(lifecycle) + + def summary_for_eol_images(self, os_name: str) -> str: + """ + Generate a summary report for the EOL images. + Args: + os_name: The OS name. + Returns: + The summary report. + """ + report = "Summary report that reached EOL dates:\n" + logger.debug("EOL images: '%s'", self.eol_images) + for container_name, values in self.eol_images[os_name].items(): + logger.info( + "Processing container: '%s' with values: '%s'", container_name, values + ) + stream_name = values["name"] + if self.jira_fetcher.jira is None: + logger.error("Connection to Jira failed") + jira_msg = ( + "reached EOL. Jira ticket is not filled. Use Jira issue template:" + ) + jira_id = self.jira_fetcher.jira_deprecation_ticket + report += f"{stream_name} for {os_name} {jira_msg} {get_jira_ticket_url(jira_issue_id=jira_id)}\n" + continue + jira_msg = "reached EOL. Jira ticket is already filed:" + jira_id = self.jira_fetcher.is_jira_filled_for_container( + stream_name=stream_name + ) + logger.info("Jira id for stream '%s' is '%s'", stream_name, jira_id) + if jira_id == "": + jira_msg = ( + "reached EOL. Jira ticket is not filled. Use Jira issue template:" + ) + jira_id = self.jira_fetcher.jira_deprecation_ticket + report += f"{stream_name} for {os_name} {jira_msg} {get_jira_ticket_url(jira_issue_id=jira_id)}\n" + + return report + + def summary_for_approaching_eol_images(self, os_name: str) -> str: + """ + Generate a summary report for the approaching EOL images. + Args: + os_name: The OS name. + Returns: + The summary report. + """ + if len(self.approaching_eol_images[os_name]) == 0: + return "" + report = "Summary report that approaching EOL dates:\n" + for container_name, values in self.approaching_eol_images[os_name].items(): + logger.info( + "Processing container: '%s' with values: '%s'", container_name, values + ) + stream_name = values["name"] + jira_msg = "is approaching EOL. Jira ticket should be already filed:" + jira_id = self.jira_fetcher.is_jira_filled_for_container( + stream_name=stream_name + ) + logger.info("Jira id for stream '%s' is '%s'", stream_name, jira_id) + if jira_id == "": + jira_msg = "is approaching EOL. Jira ticket is not filled. Use Jira issue template:" + jira_id = self.jira_fetcher.jira_deprecation_ticket + report += f"{stream_name} for {os_name} {jira_msg} ({get_jira_ticket_url(jira_issue_id=jira_id)})\n" + + return report + + def summary_report(self) -> str: + """ + Generate a summary report of the container EOL checker. + Returns: + The summary report. + """ + report = "\n" + for os_name in OS_NAMES: + if len(self.eol_images[os_name]) != 0: + report += self.summary_for_eol_images(os_name) + if len(self.approaching_eol_images[os_name]) != 0: + report += self.summary_for_approaching_eol_images(os_name) + report += "\n" + return report + + def analyze_containers(self): + """ + Run the container EOL checker. + """ + for os_name in OS_NAMES: + logger.info("Analyzing OS %s", os_name) + self.os_name = os_name + self.eol_images[os_name] = {} + self.approaching_eol_images[os_name] = {} + for container_name in CONTAINER_NAMES: + self.container_to_analyze = container_name + yaml_url = YamlLoader.get_yaml_url(os_name, container_name) + if yaml_url == "": + logger.error( + "YAML URL is not set for container '%s' and OS '%s'", + container_name, + os_name, + ) + continue + self.lifecycle_data = YamlLoader.download_yaml(yaml_url) + if self.lifecycle_data is None: + logger.error( + "Failed to download lifecycle YAML file from '%s'", yaml_url + ) + continue + self.analyze_lifecycle_yaml(self.lifecycle_data) + logger.info("Analyzing OS %s completed", self.os_name) + + def run(self): + """ + Run the container EOL checker. + """ + if self.jira_fetcher.jira is None: + logger.error("Connection to Jira failed") + else: + self.jira_fetcher.get_jira_deprecation_details() + self.jira_fetcher.check_if_jira_is_filled() + self.analyze_containers() + logger.info(self.summary_report()) + return 0 diff --git a/eol-checker/eol_checker/constants.py b/eol-checker/eol_checker/constants.py new file mode 100644 index 0000000..d1becb1 --- /dev/null +++ b/eol-checker/eol_checker/constants.py @@ -0,0 +1,45 @@ +# MIT License +# +# Copyright (c) 2024 Red Hat, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +JIRA_URL = "https://redhat.atlassian.net" +OS_NAMES = ["RHEL8", "RHEL9", "RHEL10"] +ALLOWED_STATUSES = ["Open", "In Progress", "To Do"] +CONTAINER_NAMES = [ + "nodejs", + "httpd", + "mysql", + "mariadb", + "nginx", + "postgresql", + "redis", + "varnish", + "valkey", + "perl", + "php", + "python36", + "python38", + "python39", + "python311", + "python312", + "ruby", +] +JIRA_DEPRECATION_TICKET = "RHELMISC-20810" diff --git a/eol-checker/eol_checker/custom_logger.py b/eol-checker/eol_checker/custom_logger.py new file mode 100644 index 0000000..23ba267 --- /dev/null +++ b/eol-checker/eol_checker/custom_logger.py @@ -0,0 +1,74 @@ +# MIT License +# +# Copyright (c) 2024 Red Hat, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import logging +from colorama import Fore, Style + + +class ColoredFormatter(logging.Formatter): + COLORS = { + "DEBUG": Fore.LIGHTBLUE_EX, + "INFO": Fore.GREEN, + "WARNING": Fore.YELLOW, + "ERROR": Fore.RED, + "CRITICAL": Fore.RED + Style.BRIGHT, + } + FORMAT_INFO = "%(message)s" + FORMAT_DEBUG = "%(levelname)s - %(name)s - %(message)s" + + FORMATS = { + logging.DEBUG: COLORS["DEBUG"] + FORMAT_DEBUG + Style.RESET_ALL, + logging.INFO: COLORS["INFO"] + FORMAT_INFO + Style.RESET_ALL, + logging.WARNING: COLORS["WARNING"] + FORMAT_INFO + Style.RESET_ALL, + logging.ERROR: COLORS["ERROR"] + FORMAT_DEBUG + Style.RESET_ALL, + logging.CRITICAL: COLORS["CRITICAL"] + FORMAT_DEBUG + Style.RESET_ALL, + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +def setup_logger(logger_name: str = "eol_checker", level=logging.INFO): + logger = logging.getLogger(logger_name) + + # Check if handlers already exist (to avoid duplicate logs) + if not logger.handlers: + # Create a console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(ColoredFormatter()) + + # Create a file handler + file_handler = logging.FileHandler("eol_checker.log") + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(name)s - %(message)s" + ) + file_handler.setFormatter(file_formatter) + + logger.addHandler(console_handler) + logger.addHandler(file_handler) + logger.setLevel(level=logging.DEBUG) + + return logger diff --git a/eol-checker/eol_checker/jira.py b/eol-checker/eol_checker/jira.py new file mode 100644 index 0000000..b396456 --- /dev/null +++ b/eol-checker/eol_checker/jira.py @@ -0,0 +1,101 @@ +import os +import logging + +from typing import List, Dict + +from requests.exceptions import HTTPError +from atlassian import Jira + +from eol_checker.constants import JIRA_URL, JIRA_DEPRECATION_TICKET, ALLOWED_STATUSES + +logger = logging.getLogger(__name__) + + +class JiraFetcher: + def __init__(self): + self._jira_api = None + self.jira_deprecation_ticket: str = os.getenv( + "JIRA_DEPRECATION_TICKET", JIRA_DEPRECATION_TICKET + ) + self.jira_url = os.getenv("JIRA_URL", JIRA_URL) + self.jira_details: dict = None + self.jira_deprecated_opened_issues: List[Dict[str, str]] = [] + + @property + def jira(self) -> Jira: + if self._jira_api is None: + username = os.getenv("JIRA_USERNAME", "") + password = os.getenv("JIRA_PASSWORD", "") + if username == "" or password == "": + logger.error("JIRA_USERNAME and/or JIRA_PASSWORD are not set") + return None + self._jira_api = Jira( + url=self.jira_url, username=username, password=password + ) + self._jira_api.http_status_code_handler(self._jira_api.http_status_code) + if self._jira_api.http_status_code != 200: + logger.error( + "Failed to get JIRA details: %s", self._jira_api.http_status_code + ) + return None + return self._jira_api + + def get_jira_deprecation_details(self): + """ + Get the JIRA details. + Returns: + The JIRA details. + """ + issue = self.jira.issue(self.jira_deprecation_ticket) + if "fields" in issue and "issuelinks" in issue["fields"]: + self.jira_details = issue["fields"]["issuelinks"] + + def is_jira_filled_for_container(self, stream_name: str) -> str: + jira_id = "" + for issue in self.jira_deprecated_opened_issues: + logger.info("Check is stream '%s' in issue '%s'", stream_name, issue) + if "summary" in issue and stream_name in issue["summary"]: + jira_id = issue["jira_issue_id"] + break + return jira_id + + def check_if_jira_is_filled(self) -> bool: + """ + Check if the JIRA ticket is filled. + Returns: + True if the JIRA is filled, False otherwise. + """ + if self.jira_details is None: + return False + for link in self.jira_details: + if "inwardIssue" not in link: + logger.info( + "No cloned issue found for main deprecation ticket: %s", + self.jira_deprecation_ticket, + ) + continue + inward_issue = link["inwardIssue"] + if ( + "status" not in inward_issue["fields"] + or inward_issue["fields"]["status"]["name"] not in ALLOWED_STATUSES + ): + continue + logger.debug("Cloned issue found: '%s'", inward_issue) + issue_status = inward_issue["fields"]["status"]["name"] + summary = inward_issue["fields"]["summary"] + logger.info( + "Cloned issue found with\nSummary:%s\nStatus:%s\nJira issue id: %s", + summary, + issue_status, + inward_issue["key"], + ) + + self.jira_deprecated_opened_issues.append( + { + "issue_status": issue_status, + "summary": summary, + "jira_issue_id": inward_issue["key"], + } + ) + logger.info("Deprecated issues: '%s'", self.jira_deprecated_opened_issues) + return True diff --git a/eol-checker/eol_checker/utils.py b/eol-checker/eol_checker/utils.py new file mode 100644 index 0000000..9a49a2e --- /dev/null +++ b/eol-checker/eol_checker/utils.py @@ -0,0 +1,70 @@ +# MIT License +# +# Copyright (c) 2024 Red Hat, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import logging + +from typing import Any, Iterable, Dict +from datetime import date + +from eol_checker.constants import JIRA_URL + +logger = logging.getLogger(__name__) + + +def is_eol_version(enddate: date, today: date) -> int: + """ + Check if the version is EOL. + Args: + enddate: The enddate. + Returns: + 1 if the version is EOL, 2 if the version is approaching EOL, 0 if the version is not approaching EOL. + """ + if enddate.year == today.year and enddate.month == today.month: + return 1 + elif enddate.year == today.year and enddate.month == today.month + 1: + return 2 + else: + return 0 + + +def get_lifecycles(data: Any) -> Iterable[Dict[str, Any]]: + """ + Get the lifecycles from the lifecycle YAML file. + Args: + data: The lifecycle YAML file content. + Returns: + The lifecycles. + """ + if isinstance(data, dict) and "lifecycles" in data: + return data["lifecycles"] + return [] + + +def get_jira_ticket_url(jira_issue_id: str) -> str: + """ + Get the JIRA ticket URL. + Args: + jira_issue_id: The JIRA issue ID. + Returns: + The JIRA ticket URL. + """ + return f"{JIRA_URL}/browse/{jira_issue_id}" diff --git a/eol-checker/eol_checker/yaml_loader.py b/eol-checker/eol_checker/yaml_loader.py new file mode 100644 index 0000000..9284bac --- /dev/null +++ b/eol-checker/eol_checker/yaml_loader.py @@ -0,0 +1,75 @@ +# MIT License +# +# Copyright (c) 2024 Red Hat, Inc. + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import logging +import requests +import yaml +import os + +logger = logging.getLogger(__name__) + + +class YamlLoader: + @staticmethod + def get_yaml_url(os_name: str, container_to_analyze: str) -> str: + """ + Get the URL for the lifecycle YAML file. + Args: + os_name: The name of the OS. + container_to_analyze: The name of the container to analyze. + Returns: + The URL for the lifecycle YAML file. + """ + url = os.getenv("LIFECYCLE_DEFS_URL", "") + if url == "": + logger.error("LIFECYCLE_DEFS_URL is not set") + return "" + return f"{url}/-/raw/main/{os_name}/{container_to_analyze}.yaml?ref_type=heads" + + @staticmethod + def download_yaml(url: str) -> dict: + """ + Download the lifecycle YAML file. + Args: + url: The URL of the lifecycle YAML file. + Returns: + The lifecycle YAML file content. + """ + try: + response = requests.get(url, timeout=30, verify=False) + response.raise_for_status() + if response.status_code != 200: + logger.error("Failed to download lifecycle YAML file from %s", url) + return None + except requests.exceptions.RequestException as e: + logger.error( + "RequestException: Failed to download lifecycle YAML file from %s: %s", + url, + e, + ) + return None + + try: + return yaml.safe_load(response.content) + except yaml.YAMLError as e: + logger.error("Failed to parse lifecycle YAML file from %s: %s", url, e) + return None diff --git a/eol-checker/tests/__init__.py b/eol-checker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eol-checker/tests/test_checker.py b/eol-checker/tests/test_checker.py new file mode 100644 index 0000000..4f5250e --- /dev/null +++ b/eol-checker/tests/test_checker.py @@ -0,0 +1,254 @@ +from datetime import date + +import pytest +from flexmock import flexmock + +from eol_checker import checker as checker_module +from eol_checker.checker import ContainerEolChecker +from eol_checker.constants import ( + DEFAULT_YAML_URL, + JIRA_DEPRECATION_TICKET, + JIRA_URL, + OS_NAMES, +) + + +@pytest.fixture +def checker(): + return ContainerEolChecker(today=date(2025, 5, 15)) + + +@pytest.fixture +def checker_with_os_context(checker): + checker.os_name = "RHEL9" + checker.container_to_analyze = "nodejs" + checker.eol_images["RHEL9"] = {} + checker.approaching_eol_images["RHEL9"] = {} + return checker + + +def test_init_uses_defaults(): + instance = ContainerEolChecker() + + assert instance.url == DEFAULT_YAML_URL + assert instance.today == date.today() + assert instance.eol_images == {} + assert instance.approaching_eol_images == {} + + +def test_init_uses_provided_today_and_url(): + custom_today = date(2024, 1, 1) + instance = ContainerEolChecker(url="http://custom/", today=custom_today) + + assert instance.url == "http://custom/" + assert instance.today == custom_today + + +def test_check_enddate_skips_when_enddate_missing(checker_with_os_context): + checker_with_os_context.check_enddate({"application_stream_name": "nodejs-18"}) + + assert checker_with_os_context.eol_images["RHEL9"] == {} + assert checker_with_os_context.approaching_eol_images["RHEL9"] == {} + + +def test_check_enddate_records_eol_image(checker_with_os_context): + checker_with_os_context.check_enddate( + {"application_stream_name": "nodejs-18", "enddate": "20250530"} + ) + + assert checker_with_os_context.eol_images["RHEL9"]["nodejs"] == { + "name": "nodejs-18" + } + assert "nodejs" not in checker_with_os_context.approaching_eol_images["RHEL9"] + + +def test_check_enddate_records_approaching_eol_image(checker_with_os_context): + checker_with_os_context.check_enddate( + {"application_stream_name": "nodejs-20", "enddate": "20250615"} + ) + + assert checker_with_os_context.approaching_eol_images["RHEL9"]["nodejs"] == { + "name": "nodejs-20" + } + assert "nodejs" not in checker_with_os_context.eol_images["RHEL9"] + + +def test_check_enddate_ignores_distant_enddate(checker_with_os_context): + checker_with_os_context.check_enddate( + {"application_stream_name": "nodejs-22", "enddate": "20251231"} + ) + + assert checker_with_os_context.eol_images["RHEL9"] == {} + assert checker_with_os_context.approaching_eol_images["RHEL9"] == {} + + +def test_analyze_lifecycle_yaml_processes_all_lifecycles(checker_with_os_context): + flexmock(checker_with_os_context).should_receive("check_enddate").twice() + + checker_with_os_context.analyze_lifecycle_yaml( + { + "lifecycles": [ + {"application_stream_name": "nodejs-18", "enddate": "20250501"}, + {"application_stream_name": "nodejs-20", "enddate": "20250601"}, + ] + } + ) + + +def test_analyze_lifecycle_yaml_integrates_check_enddate(checker_with_os_context): + checker_with_os_context.analyze_lifecycle_yaml( + { + "lifecycles": [ + {"application_stream_name": "nodejs-18", "enddate": "20250501"} + ] + } + ) + + assert checker_with_os_context.eol_images["RHEL9"]["nodejs"] == { + "name": "nodejs-18" + } + + +def test_summary_for_eol_images_with_existing_jira_ticket(checker): + checker.eol_images["RHEL9"] = {"nodejs": {"name": "nodejs-18"}} + flexmock(checker.jira_fetcher).should_receive( + "is_jira_filled_for_container" + ).with_args(stream_name="nodejs-18").and_return("RHELMISC-100") + + report = checker.summary_for_eol_images("RHEL9") + + assert report.startswith("Summary report that reached EOL dates:\n") + assert "nodejs-18 for RHEL9" in report + assert "Jira ticket is already filed" in report + assert f"{JIRA_URL}/browse/RHELMISC-100" in report + + +def test_summary_for_eol_images_without_jira_ticket(checker): + checker.eol_images["RHEL9"] = {"nodejs": {"name": "nodejs-18"}} + flexmock(checker.jira_fetcher).should_receive( + "is_jira_filled_for_container" + ).with_args(stream_name="nodejs-18").and_return("") + + report = checker.summary_for_eol_images("RHEL9") + + assert "Jira ticket is not filled" in report + assert f"{JIRA_URL}/browse/{JIRA_DEPRECATION_TICKET}" in report + + +def test_summary_for_approaching_eol_images_returns_empty_when_none(checker): + checker.approaching_eol_images["RHEL9"] = {} + + assert checker.summary_for_approaching_eol_images("RHEL9") == "" + + +def test_summary_for_approaching_eol_images_with_existing_jira_ticket(checker): + checker.approaching_eol_images["RHEL9"] = {"nodejs": {"name": "nodejs-20"}} + flexmock(checker.jira_fetcher).should_receive( + "is_jira_filled_for_container" + ).with_args(stream_name="nodejs-20").and_return("RHELMISC-200") + + report = checker.summary_for_approaching_eol_images("RHEL9") + + assert report.startswith("Summary report that approaching EOL dates:\n") + assert "nodejs-20 for RHEL9" in report + assert "Jira ticket should be already filed" in report + assert f"{JIRA_URL}/browse/RHELMISC-200" in report + + +def test_summary_for_approaching_eol_images_without_jira_ticket(checker): + checker.approaching_eol_images["RHEL9"] = {"nodejs": {"name": "nodejs-20"}} + flexmock(checker.jira_fetcher).should_receive( + "is_jira_filled_for_container" + ).with_args(stream_name="nodejs-20").and_return("") + + report = checker.summary_for_approaching_eol_images("RHEL9") + + assert "Jira ticket is not filled" in report + assert f"{JIRA_URL}/browse/{JIRA_DEPRECATION_TICKET}" in report + + +def test_summary_report_includes_eol_and_approaching_sections(checker): + for os_name in OS_NAMES: + checker.eol_images[os_name] = {} + checker.approaching_eol_images[os_name] = {} + checker.eol_images["RHEL9"] = {"nodejs": {"name": "nodejs-18"}} + checker.approaching_eol_images["RHEL10"] = {"httpd": {"name": "httpd-26"}} + checker.jira_fetcher.jira = flexmock() # Ensure jira connection is non-None + flexmock(checker.jira_fetcher).should_receive( + "is_jira_filled_for_container" + ).with_args(stream_name="nodejs-18").and_return("RHELMISC-100") + flexmock(checker.jira_fetcher).should_receive( + "is_jira_filled_for_container" + ).with_args(stream_name="httpd-26").and_return("") + + report = checker.summary_report() + + assert "Summary report that reached EOL dates:" in report + assert "nodejs-18 for RHEL9" in report + assert "Summary report that approaching EOL dates:" in report + assert "httpd-26 for RHEL10" in report + + +def test_summary_report_returns_newline_when_no_images(checker): + for os_name in OS_NAMES: + checker.eol_images[os_name] = {} + checker.approaching_eol_images[os_name] = {} + + assert checker.summary_report() == "\n\n" + + +def test_analyze_containers_skips_when_yaml_download_fails(checker): + flexmock(checker_module.YamlLoader).should_receive("get_yaml_url").and_return( + "http://test/yaml" + ) + flexmock(checker_module.YamlLoader).should_receive("download_yaml").and_return(None) + flexmock(checker).should_receive("analyze_lifecycle_yaml").never() + + checker.analyze_containers() + + for os_name in OS_NAMES: + assert checker.eol_images[os_name] == {} + assert checker.approaching_eol_images[os_name] == {} + + +def test_analyze_containers_analyzes_downloaded_yaml(checker): + lifecycle_data = { + "lifecycles": [{"application_stream_name": "nodejs-18", "enddate": "20250501"}] + } + flexmock(checker_module.YamlLoader).should_receive("get_yaml_url").and_return( + "http://test/yaml" + ) + flexmock(checker_module.YamlLoader).should_receive("download_yaml").and_return( + lifecycle_data + ) + flexmock(checker).should_receive("analyze_lifecycle_yaml").with_args( + lifecycle_data + ).at_least().once() + + checker.analyze_containers() + + +def test_analyze_containers_populates_eol_from_yaml(checker): + lifecycle_data = { + "lifecycles": [{"application_stream_name": "nodejs-18", "enddate": "20250501"}] + } + flexmock(checker_module.YamlLoader).should_receive("get_yaml_url").and_return( + "http://test/yaml" + ) + flexmock(checker_module.YamlLoader).should_receive("download_yaml").and_return( + lifecycle_data + ) + + checker.analyze_containers() + + for os_name in OS_NAMES: + assert checker.eol_images[os_name]["nodejs"] == {"name": "nodejs-18"} + + +def test_run_fetches_jira_and_analyzes_containers(checker): + flexmock(checker.jira_fetcher).should_receive("get_jira_deprecation_details").once() + flexmock(checker.jira_fetcher).should_receive("check_if_jira_is_filled").once() + flexmock(checker).should_receive("analyze_containers").once() + flexmock(checker).should_receive("summary_report").and_return("\nreport\n") + + checker.run() diff --git a/eol-checker/tests/test_jira.py b/eol-checker/tests/test_jira.py new file mode 100644 index 0000000..f7943aa --- /dev/null +++ b/eol-checker/tests/test_jira.py @@ -0,0 +1,131 @@ +import pytest +from flexmock import flexmock + +from eol_checker import jira as jira_module +from eol_checker.constants import ALLOWED_STATUSES, JIRA_DEPRECATION_TICKET, JIRA_URL +from eol_checker.jira import JiraFetcher + + +@pytest.fixture +def fetcher(): + return JiraFetcher() + + +def test_init_uses_defaults_when_env_unset(monkeypatch): + monkeypatch.delenv("JIRA_DEPRECATION_TICKET", raising=False) + monkeypatch.delenv("JIRA_URL", raising=False) + + instance = JiraFetcher() + + assert instance.jira_deprecation_ticket == JIRA_DEPRECATION_TICKET + assert instance.jira_url == JIRA_URL + assert instance.jira_details is None + assert instance.jira_deprecated_opened_issues == [] + + +def test_init_uses_env_overrides(monkeypatch): + monkeypatch.setenv("JIRA_DEPRECATION_TICKET", "CUSTOM-1") + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + + instance = JiraFetcher() + + assert instance.jira_deprecation_ticket == "CUSTOM-1" + assert instance.jira_url == "https://jira.example.com" + + +def test_jira_property_creates_client_once(fetcher): + mock_client = flexmock() + flexmock(jira_module).should_receive("Jira").once().and_return(mock_client) + + assert fetcher.jira is mock_client + assert fetcher.jira is mock_client + + +def test_get_jira_deprecation_details_stores_issuelinks(fetcher): + mock_client = flexmock() + mock_client.should_receive("issue").with_args( + fetcher.jira_deprecation_ticket + ).and_return({"fields": {"issuelinks": [{"inwardIssue": {"key": "CLONE-1"}}]}}) + flexmock(jira_module).should_receive("Jira").and_return(mock_client) + + fetcher.get_jira_deprecation_details() + + assert fetcher.jira_details == [{"inwardIssue": {"key": "CLONE-1"}}] + + +def test_get_jira_deprecation_details_skips_when_no_issuelinks(fetcher): + mock_client = flexmock() + mock_client.should_receive("issue").with_args( + fetcher.jira_deprecation_ticket + ).and_return({"fields": {}}) + flexmock(jira_module).should_receive("Jira").and_return(mock_client) + + fetcher.get_jira_deprecation_details() + + assert fetcher.jira_details is None + + +def test_is_jira_filled_for_container_returns_matching_issue_id(fetcher): + fetcher.jira_deprecated_opened_issues = [ + { + "summary": "EOL nodejs RHEL9", + "jira_issue_id": "RHELMISC-100", + "issue_status": "Open", + }, + { + "summary": "EOL httpd RHEL9", + "jira_issue_id": "RHELMISC-101", + "issue_status": "Open", + }, + ] + + assert fetcher.is_jira_filled_for_container("nodejs") == "RHELMISC-100" + assert fetcher.is_jira_filled_for_container("unknown") == "" + + +def test_check_if_jira_is_filled_returns_false_when_details_missing(fetcher): + fetcher.jira_details = None + + assert fetcher.check_if_jira_is_filled() is False + assert fetcher.jira_deprecated_opened_issues == [] + + +def test_check_if_jira_is_filled_collects_allowed_status_issues(fetcher): + allowed_status = ALLOWED_STATUSES[0] + fetcher.jira_details = [ + {"outwardIssue": {"key": "SKIP-1"}}, + { + "inwardIssue": { + "key": "RHELMISC-200", + "fields": { + "summary": "EOL python39 RHEL9", + "status": {"name": "Closed"}, + }, + } + }, + { + "inwardIssue": { + "key": "RHELMISC-201", + "fields": { + "summary": "EOL nodejs RHEL9", + "status": {"name": allowed_status}, + }, + } + }, + ] + + assert fetcher.check_if_jira_is_filled() is True + assert fetcher.jira_deprecated_opened_issues == [ + { + "issue_status": allowed_status, + "summary": "EOL nodejs RHEL9", + "jira_issue_id": "RHELMISC-201", + } + ] + + +def test_check_if_jira_is_filled_skips_links_without_inward_issue(fetcher): + fetcher.jira_details = [{"type": {"name": "Relates"}}] + + assert fetcher.check_if_jira_is_filled() is True + assert fetcher.jira_deprecated_opened_issues == [] diff --git a/eol-checker/tests/test_utils.py b/eol-checker/tests/test_utils.py new file mode 100644 index 0000000..113cb7e --- /dev/null +++ b/eol-checker/tests/test_utils.py @@ -0,0 +1,47 @@ +from datetime import date + +from eol_checker.constants import JIRA_URL +from eol_checker.utils import get_jira_ticket_url, get_lifecycles, is_eol_version + + +def test_is_eol_version_same_month_returns_one(): + today = date(2025, 5, 15) + enddate = date(2025, 5, 1) + assert is_eol_version(enddate, today) == 1 + + +def test_is_eol_version_next_month_returns_two(): + today = date(2025, 5, 15) + enddate = date(2025, 6, 1) + assert is_eol_version(enddate, today) == 2 + + +def test_is_eol_version_other_month_returns_zero(): + today = date(2025, 5, 15) + enddate = date(2025, 7, 1) + assert is_eol_version(enddate, today) == 0 + + +def test_is_eol_version_different_year_returns_zero(): + today = date(2025, 5, 15) + enddate = date(2026, 5, 1) + assert is_eol_version(enddate, today) == 0 + + +def test_get_lifecycles_returns_lifecycles_from_dict(): + data = {"lifecycles": [{"name": "nodejs", "eol": "2025-01-01"}]} + assert list(get_lifecycles(data)) == data["lifecycles"] + + +def test_get_lifecycles_returns_empty_when_key_missing(): + assert list(get_lifecycles({"other": []})) == [] + + +def test_get_lifecycles_returns_empty_for_non_dict(): + assert list(get_lifecycles([])) == [] + assert list(get_lifecycles(None)) == [] + + +def test_get_jira_ticket_url_builds_browse_link(): + issue_id = "RHELMISC-12345" + assert get_jira_ticket_url(issue_id) == f"{JIRA_URL}/browse/{issue_id}" diff --git a/eol-checker/tests/test_yaml_loader.py b/eol-checker/tests/test_yaml_loader.py new file mode 100644 index 0000000..251450f --- /dev/null +++ b/eol-checker/tests/test_yaml_loader.py @@ -0,0 +1,61 @@ +import requests +import yaml +from flexmock import flexmock + +from eol_checker import yaml_loader as yaml_loader_module +from eol_checker.yaml_loader import YamlLoader + + +def test_get_yaml_url_builds_gitlab_raw_url(monkeypatch): + monkeypatch.setenv("LIFECYCLE_DEFS_URL", "https://gitlab.example.com/group/repo") + url = YamlLoader.get_yaml_url("RHEL9", "nodejs") + assert ( + url + == "https://gitlab.example.com/group/repo/-/raw/main/RHEL9/nodejs.yaml?ref_type=heads" + ) + +def test_download_yaml_returns_parsed_content(): + mock_response = flexmock( + status_code=200, content=b"lifecycles:\n - name: nodejs\n" + ) + mock_response.should_receive("raise_for_status").once() + flexmock(yaml_loader_module.requests).should_receive("get").with_args( + "https://example.com/lifecycle.yaml", timeout=30, verify=False + ).once().and_return(mock_response) + + result = YamlLoader.download_yaml("https://example.com/lifecycle.yaml") + + assert result == {"lifecycles": [{"name": "nodejs"}]} + + +def test_download_yaml_returns_none_on_request_error(): + flexmock(yaml_loader_module.requests).should_receive("get").and_raise( + requests.exceptions.RequestException("connection failed") + ) + + assert YamlLoader.download_yaml("https://example.com/lifecycle.yaml") is None + + +def test_download_yaml_returns_none_on_invalid_yaml(): + mock_response = flexmock(status_code=200, content=b"[\n unclosed") + mock_response.should_receive("raise_for_status") + flexmock(yaml_loader_module.requests).should_receive("get").and_return( + mock_response + ) + flexmock(yaml_loader_module.yaml).should_receive("safe_load").and_raise( + yaml.YAMLError("parse error") + ) + + assert YamlLoader.download_yaml("https://example.com/lifecycle.yaml") is None + + +def test_download_yaml_returns_none_on_http_error(): + mock_response = flexmock() + mock_response.should_receive("raise_for_status").and_raise( + requests.exceptions.HTTPError("404") + ) + flexmock(yaml_loader_module.requests).should_receive("get").and_return( + mock_response + ) + + assert YamlLoader.download_yaml("https://example.com/missing.yaml") is None diff --git a/eol-checker/tox.ini b/eol-checker/tox.ini new file mode 100644 index 0000000..b4bf21f --- /dev/null +++ b/eol-checker/tox.ini @@ -0,0 +1,14 @@ +[tox] +package = skip + +[testenv] +commands = python3 -m pytest --color=yes -v --cov=eol_checker --cov-report=xml:coverage.xml +deps = + pytest + pytest-cov + flexmock + PyYAML + requests + xmltodict + atlassian-python-api + colorama From 553813739d7015b5ca7ed9916cea888cc2e6caf2 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 26 May 2026 09:30:55 +0200 Subject: [PATCH 2/2] Both as "enddate" and "application_stream_name" should be mentioned in lifecycle otherwise continue Signed-off-by: Petr "Stone" Hracek --- eol-checker/eol_checker/checker.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/eol-checker/eol_checker/checker.py b/eol-checker/eol_checker/checker.py index 8e1264a..a2cec80 100755 --- a/eol-checker/eol_checker/checker.py +++ b/eol-checker/eol_checker/checker.py @@ -61,11 +61,16 @@ def check_enddate(self, lifecycle: Dict[str, Any]) -> None: Args: lifecycle: The lifecycle. """ - if "enddate" not in lifecycle: + if "enddate" not in lifecycle or "application_stream_name" not in lifecycle: + logger.warning("Skipping lifecycle missing required fields: %s", lifecycle) return application_stream_name = lifecycle["application_stream_name"] - enddate = datetime.strptime(lifecycle["enddate"], "%Y%m%d").date() + try: + enddate = datetime.strptime(str(lifecycle["enddate"]), "%Y%m%d").date() + except (TypeError, ValueError): + logger.warning("Skipping lifecycle with invalid enddate: %s", lifecycle) + return logger.debug( "Enddate('%s'): '%s' and today is '%s'",