From e19d4358fc7761c27d884bd683f5f1cd451190b6 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 26 May 2026 14:34:27 +0200 Subject: [PATCH 1/3] Support sending emails for eol-checker. Signed-off-by: Petr "Stone" Hracek --- .github/workflows/build-and-push.yml | 2 +- Dockerfile.daily-tests | 2 +- Makefile | 2 +- eol-checker/eol-checker | 77 +++++++ eol-checker/eol_checker/checker.py | 225 ++++++++++++++------ eol-checker/eol_checker/utils.py | 43 +++- eol-checker/tests/test_checker.py | 302 +++++++++++++++++++-------- eol-checker/tests/test_jira.py | 59 +++++- 8 files changed, 545 insertions(+), 167 deletions(-) create mode 100755 eol-checker/eol-checker diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 91d8fc2..fb94e2d 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -19,6 +19,6 @@ jobs: registry_username: ${{ secrets.QUAY_IMAGE_SCLORG_BUILDER_USERNAME }} registry_token: ${{ secrets.QUAY_IMAGE_SCLORG_BUILDER_TOKEN }} dockerfile: Dockerfile.daily-tests - tag: "0.9.0" + tag: "0.10.0" image_name: "upstream-daily-tests" quay_application_token: ${{ secrets.QUAY_IMAGE_SCLORG_UPDATE_DESC }} diff --git a/Dockerfile.daily-tests b/Dockerfile.daily-tests index ffd3ac9..2a70a4e 100644 --- a/Dockerfile.daily-tests +++ b/Dockerfile.daily-tests @@ -2,7 +2,7 @@ FROM quay.io/fedora/fedora:42 ENV SHARED_DIR="/var/ci-scripts" \ VERSION="42" \ - RELEASE_UPSTREAM="0.9.0" \ + RELEASE_UPSTREAM="0.10.0" \ UPSTREAM_TMT_REPO="https://github.com/sclorg/sclorg-testing-farm" \ UPSTREAM_TMT_DIR="sclorg-testing-farm" \ HOME="/home/nightly" \ diff --git a/Makefile b/Makefile index cee6f25..c6674d0 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,4 @@ shellcheck: ./run-shellcheck.sh `git ls-files *.sh` build_images: - podman build -t quay.io/sclorg/upstream-daily-tests:0.9.0 -f Dockerfile.daily-tests . + podman build -t quay.io/sclorg/upstream-daily-tests:0.10.0 -f Dockerfile.daily-tests . diff --git a/eol-checker/eol-checker b/eol-checker/eol-checker new file mode 100755 index 0000000..d497950 --- /dev/null +++ b/eol-checker/eol-checker @@ -0,0 +1,77 @@ +#!/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 sys +import argparse +import urllib3 + +from eol_checker.custom_logger import setup_logger +from eol_checker.checker import ContainerEolChecker + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) + + +def parse_args(): + """ + Parse the arguments. + Returns: + The parsed arguments. + """ + parser = argparse.ArgumentParser() + parser.add_argument( + "-d", "--debug", action="store_true", default=False, help="Enable debug mode" + ) + parser.add_argument( + "--send-email", + action="store_true", + default=False, + help="Send email to the SCL org members", + ) + return parser + + +def main(args): + """ + Main function to run the container EOL checker. + Args: + args: The parsed arguments. + Returns: + The exit code. 0 if successful, 1 if error. + """ + print(f"Arguments: {args}") + setup_logger(level=logging.DEBUG if args.debug else logging.INFO) + checker = ContainerEolChecker(send_email=args.send_email) + checker.run() + sys.exit(0) + + +if __name__ == "__main__": + parser = parse_args() + main(parser.parse_args()) diff --git a/eol-checker/eol_checker/checker.py b/eol-checker/eol_checker/checker.py index a2cec80..7d69a21 100755 --- a/eol-checker/eol_checker/checker.py +++ b/eol-checker/eol_checker/checker.py @@ -26,14 +26,25 @@ # Authors: Petr Hracek import logging +import os import urllib3 +import smtplib +from smtplib import SMTP +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from datetime import date, datetime -from typing import Any, Dict +from typing import Any, Dict, List 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.utils import ( + get_jira_ticket_url, + is_eol_version, + get_lifecycles, + load_mails_from_environment, + get_env_variable, +) from eol_checker.constants import OS_NAMES, CONTAINER_NAMES urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -46,14 +57,25 @@ class ContainerEolChecker(object): Checker for container image EOL dates from lifecycle YAML. """ - def __init__(self): + def __init__(self, send_email: bool = False): self.today = date.today() self.lifecycle_data: Any = None self.eol_images: dict = {} + self.already_eol_images: dict = {} self.approaching_eol_images: dict = {} self.os_name: str = "" + self.default_mails: List[str] = os.getenv("DEFAULT_EMAILS", "").split(",") self.container_to_analyze: str = "" self.jira_fetcher = JiraFetcher() + self.eol_sme_mails = load_mails_from_environment() + self.send_email = send_email + self.smtp_port = 25 + self.smtp_server = "smtp.redhat.com" + self.end_line = "
" if self.send_email else "\n" + self.bold_line = "" if self.send_email else "" + self.bold_line_end = "" if self.send_email else "" + self.mime_msg = MIMEMultipart() + self.body = "" def check_enddate(self, lifecycle: Dict[str, Any]) -> None: """ @@ -66,8 +88,9 @@ def check_enddate(self, lifecycle: Dict[str, Any]) -> None: return application_stream_name = lifecycle["application_stream_name"] + str_enddate = str(lifecycle["enddate"]) try: - enddate = datetime.strptime(str(lifecycle["enddate"]), "%Y%m%d").date() + enddate = datetime.strptime(str_enddate, "%Y%m%d").date() except (TypeError, ValueError): logger.warning("Skipping lifecycle with invalid enddate: %s", lifecycle) return @@ -78,25 +101,24 @@ def check_enddate(self, lifecycle: Dict[str, Any]) -> None: enddate, self.today, ) + container_struct = {"name": application_stream_name, "enddate": str_enddate} is_eol = is_eol_version(enddate, self.today) + eol_msg = "" 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 - } + eol_msg = f"Deprecation should be processed for image stream {application_stream_name}: enddate is {enddate}" + self.eol_images[self.os_name][self.container_to_analyze] = container_struct 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 - } + eol_msg = f"Deprecation of image stream {application_stream_name} is approaching next month should be scheduled: enddate is {enddate}" + self.approaching_eol_images[self.os_name][ + self.container_to_analyze + ] = container_struct + if is_eol == 3: + eol_msg = f"Deprecation of image stream {application_stream_name} already approached one month ago should be scheduled: enddate is {enddate}" + self.already_eol_images[self.os_name][ + self.container_to_analyze + ] = container_struct + if is_eol != 0: + logger.info(eol_msg) def analyze_lifecycle_yaml(self, data: Any) -> None: """ @@ -108,68 +130,94 @@ def analyze_lifecycle_yaml(self, data: Any) -> None: for lifecycle in get_lifecycles(data): self.check_enddate(lifecycle) - def summary_for_eol_images(self, os_name: str) -> str: + def _get_jira_msg(self, report_type: str, enddate: str) -> str: """ - Generate a summary report for the EOL images. + Generate a Jira message. Args: - os_name: The OS name. + report_type: The report type. + enddate: The enddate. Returns: - The summary report. + The Jira message. """ - 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 + jira_msg = ( + "Connection to Jira not available" + if self.jira_fetcher.jira is None + else "Jira ticket is not filled. Use Jira issue template:" + ) + return ( + self.bold_line + + f"{report_type} in {enddate}" + + self.bold_line_end + + f". {jira_msg}" + ) - def summary_for_approaching_eol_images(self, os_name: str) -> str: + def summary_for_images( + self, images: dict, os_name: str, eol_type: bool = True + ) -> str: """ - Generate a summary report for the approaching EOL images. + Generate a summary report for the images. Args: + images: The images. os_name: The OS name. + eol_type: The EOL type. True for EOL, False for approaching EOL. Returns: The summary report. """ - if len(self.approaching_eol_images[os_name]) == 0: + if len(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(): + report_type = "reached EOL" if eol_type else "approaching EOL" + report = "\n" + report += ( + self.bold_line + + f"Summary report for {os_name}:" + + self.bold_line_end + + self.end_line + ) + report += self.end_line + "\n" + logger.debug("EOL images: '%s'", images) + for container_name, values in 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:" + if self.send_email: + for mail in self.eol_sme_mails[container_name]: + if mail and mail not in self.default_mails: + self.default_mails.append(mail) + if self.jira_fetcher.jira is None: + logger.error("Connection to Jira failed") + jira_msg = self._get_jira_msg( + report_type=report_type, enddate=values["enddate"] + ) + jira_id = self.jira_fetcher.jira_deprecation_ticket + jira_url = get_jira_ticket_url(jira_issue_id=jira_id) + url = ( + f"{jira_url}" + if self.send_email + else jira_url + ) + report += f"{stream_name} for {os_name} {jira_msg} {url}{self.end_line}" + continue + jira_msg = self._get_jira_msg( + report_type=report_type, enddate=values["enddate"] + ) + jira_msg += "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 = "is approaching EOL. Jira ticket is not filled. Use Jira issue template:" + jira_msg = self._get_jira_msg( + report_type=report_type, enddate=values["enddate"] + ) 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" + jira_url = get_jira_ticket_url(jira_issue_id=jira_id) + url = ( + f"{jira_url}" + if self.send_email + else jira_url + ) + report += f"{stream_name} for {os_name} {jira_msg} {url}{self.end_line}" + report += "\n" return report @@ -181,10 +229,18 @@ def summary_report(self) -> str: """ report = "\n" for os_name in OS_NAMES: + if len(self.already_eol_images[os_name]) != 0: + report += self.summary_for_images( + images=self.already_eol_images, os_name=os_name + ) if len(self.eol_images[os_name]) != 0: - report += self.summary_for_eol_images(os_name) + report += self.summary_for_images( + images=self.eol_images, os_name=os_name + ) if len(self.approaching_eol_images[os_name]) != 0: - report += self.summary_for_approaching_eol_images(os_name) + report += self.summary_for_images( + images=self.approaching_eol_images, os_name=os_name, eol_type=False + ) report += "\n" return report @@ -197,6 +253,7 @@ def analyze_containers(self): self.os_name = os_name self.eol_images[os_name] = {} self.approaching_eol_images[os_name] = {} + self.already_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) @@ -216,15 +273,55 @@ def analyze_containers(self): self.analyze_lifecycle_yaml(self.lifecycle_data) logger.info("Analyzing OS %s completed", self.os_name) + def send_emails(self): + """ + Send emails with the container EOL information. + """ + logger.debug("Sending emails is enabled") + logger.debug(", ".join(self.default_mails)) + self.smtp_server = get_env_variable("SMTP_SERVER", "smtp.redhat.com") + self.smtp_port = int(get_env_variable("SMTP_PORT", "25")) + self.send_email = bool(get_env_variable("SEND_EMAIL", "False")) + + send_from = "phracek@redhat.com" + send_to = self.default_mails + self.mime_msg["From"] = send_from + self.mime_msg["To"] = ", ".join(send_to) + self.mime_msg["Subject"] = "Container EOL Checker Report" + logger.debug( + "Sending email with subject: 'Container EOL Checker Report' to: '%s'", + send_to, + ) + logger.debug("Email body: '%s'", self.body) + logger.debug("Message: '%s'", self.mime_msg) + self.mime_msg.attach(MIMEText(self.body, "html")) + try: + smtp = SMTP(self.smtp_server, int(self.smtp_port)) + smtp.set_debuglevel(5) + smtp.sendmail(send_from, send_to, self.mime_msg.as_string()) + except smtplib.SMTPRecipientsRefused as e: + logger.error("Error sending email(SMTPRecipientsRefused): %s", e.strerror) + except smtplib.SMTPException as e: + logger.error("Error sending email(SMTPException): %s", e) + finally: + smtp.close() + logger.debug("Sending email finished") + def run(self): """ Run the container EOL checker. """ + logger.info("Running 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()) + self.body = self.summary_report() + logger.info(self.body) + if self.send_email: + self.send_emails() + else: + logger.info("Sending emails is disabled") return 0 diff --git a/eol-checker/eol_checker/utils.py b/eol-checker/eol_checker/utils.py index 9a49a2e..583cc56 100644 --- a/eol-checker/eol_checker/utils.py +++ b/eol-checker/eol_checker/utils.py @@ -21,6 +21,7 @@ # SOFTWARE. import logging +import os from typing import Any, Iterable, Dict from datetime import date @@ -40,10 +41,11 @@ def is_eol_version(enddate: date, today: date) -> int: """ if enddate.year == today.year and enddate.month == today.month: return 1 - elif enddate.year == today.year and enddate.month == today.month + 1: + if enddate.year == today.year and enddate.month == today.month + 1: return 2 - else: - return 0 + if enddate.year == today.year and enddate.month == today.month - 1: + return 3 + return 0 def get_lifecycles(data: Any) -> Iterable[Dict[str, Any]]: @@ -68,3 +70,38 @@ def get_jira_ticket_url(jira_issue_id: str) -> str: The JIRA ticket URL. """ return f"{JIRA_URL}/browse/{jira_issue_id}" + + +def get_env_variable(var_name: str, default_value: str = "") -> str: + """ + Get environment variable value or return default value if not set. + :param var_name: Name of the environment variable + :param default_value: Default value to return if environment variable is not set + :return: Value of the environment variable or default value + """ + if var_name in os.environ: + value = os.getenv(var_name, default_value) + print(f"Environment variable '{var_name}': '{value}'") + return value + return default_value + + +def load_mails_from_environment(): + """ + Load email addresses from environment variables. + """ + sclorg_mails = {} + sclorg_mails["mariadb"] = get_env_variable("DB_SME").split(",") + sclorg_mails["mysql"] = get_env_variable("DB_SME").split(",") + sclorg_mails["postgresql"] = get_env_variable("DB_SME").split(",") + sclorg_mails["ruby"] = get_env_variable("RUBY_SME").split(",") + sclorg_mails["python"] = get_env_variable("PYTHON_SME").split(",") + sclorg_mails["nodejs"] = get_env_variable("NODEJS_SME").split(",") + sclorg_mails["perl"] = get_env_variable("PERL_SME").split(",") + sclorg_mails["php"] = get_env_variable("PHP_SME").split(",") + sclorg_mails["redis"] = get_env_variable("REDIS_SME").split(",") + sclorg_mails["varnish"] = get_env_variable("VARNISH_SME").split(",") + sclorg_mails["valkey"] = get_env_variable("VALKEY_SME").split(",") + sclorg_mails["httpd"] = get_env_variable("HTTPD_SME").split(",") + sclorg_mails["nginx"] = get_env_variable("NGINX_SME").split(",") + return sclorg_mails diff --git a/eol-checker/tests/test_checker.py b/eol-checker/tests/test_checker.py index 4f5250e..c3c52df 100644 --- a/eol-checker/tests/test_checker.py +++ b/eol-checker/tests/test_checker.py @@ -1,21 +1,20 @@ from datetime import date import pytest +import smtplib 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, -) +from eol_checker.constants import JIRA_DEPRECATION_TICKET, JIRA_URL, OS_NAMES @pytest.fixture -def checker(): - return ContainerEolChecker(today=date(2025, 5, 15)) +def checker(monkeypatch): + monkeypatch.setenv("DEFAULT_EMAILS", "default@redhat.com") + instance = ContainerEolChecker(send_email=False) + instance.today = date(2025, 5, 15) + return instance @pytest.fixture @@ -24,31 +23,56 @@ def checker_with_os_context(checker): checker.container_to_analyze = "nodejs" checker.eol_images["RHEL9"] = {} checker.approaching_eol_images["RHEL9"] = {} + checker.already_eol_images["RHEL9"] = {} return checker -def test_init_uses_defaults(): +def _container_struct(name, enddate): + return {"name": name, "enddate": enddate} + + +def test_init_defaults(monkeypatch): + monkeypatch.delenv("DEFAULT_EMAILS", raising=False) instance = ContainerEolChecker() - assert instance.url == DEFAULT_YAML_URL assert instance.today == date.today() + assert instance.send_email is False + assert instance.end_line == "\n" + assert instance.bold_line == "" assert instance.eol_images == {} - assert instance.approaching_eol_images == {} + assert instance.body == "" + +def test_init_send_email_formatting(): + instance = ContainerEolChecker(send_email=True) -def test_init_uses_provided_today_and_url(): - custom_today = date(2024, 1, 1) - instance = ContainerEolChecker(url="http://custom/", today=custom_today) + assert instance.end_line == "
" + assert instance.bold_line == "" + assert instance.bold_line_end == "" - assert instance.url == "http://custom/" - assert instance.today == custom_today +def test_init_loads_default_emails_from_environment(monkeypatch): + monkeypatch.setenv("DEFAULT_EMAILS", "one@redhat.com,two@redhat.com") + + instance = ContainerEolChecker() + + assert instance.default_mails == ["one@redhat.com", "two@redhat.com"] -def test_check_enddate_skips_when_enddate_missing(checker_with_os_context): + +def test_check_enddate_skips_when_required_fields_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"] == {} + assert checker_with_os_context.already_eol_images["RHEL9"] == {} + + +def test_check_enddate_skips_invalid_enddate(checker_with_os_context): + checker_with_os_context.check_enddate( + {"application_stream_name": "nodejs-18", "enddate": "not-a-date"} + ) + + assert checker_with_os_context.eol_images["RHEL9"] == {} def test_check_enddate_records_eol_image(checker_with_os_context): @@ -56,10 +80,9 @@ def test_check_enddate_records_eol_image(checker_with_os_context): {"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"] + assert checker_with_os_context.eol_images["RHEL9"]["nodejs"] == _container_struct( + "nodejs-18", "20250530" + ) def test_check_enddate_records_approaching_eol_image(checker_with_os_context): @@ -67,10 +90,19 @@ def test_check_enddate_records_approaching_eol_image(checker_with_os_context): {"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"] + assert checker_with_os_context.approaching_eol_images["RHEL9"]["nodejs"] == ( + _container_struct("nodejs-20", "20250615") + ) + + +def test_check_enddate_records_already_eol_image(checker_with_os_context): + checker_with_os_context.check_enddate( + {"application_stream_name": "nodejs-16", "enddate": "20250415"} + ) + + assert checker_with_os_context.already_eol_images["RHEL9"]["nodejs"] == ( + _container_struct("nodejs-16", "20250415") + ) def test_check_enddate_ignores_distant_enddate(checker_with_os_context): @@ -80,6 +112,7 @@ def test_check_enddate_ignores_distant_enddate(checker_with_os_context): assert checker_with_os_context.eol_images["RHEL9"] == {} assert checker_with_os_context.approaching_eol_images["RHEL9"] == {} + assert checker_with_os_context.already_eol_images["RHEL9"] == {} def test_analyze_lifecycle_yaml_processes_all_lifecycles(checker_with_os_context): @@ -95,108 +128,118 @@ def test_analyze_lifecycle_yaml_processes_all_lifecycles(checker_with_os_context ) -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"} - ] - } - ) +def test_get_jira_msg_when_jira_unavailable(checker): + flexmock(checker.jira_fetcher).should_receive("jira").and_return(None) - assert checker_with_os_context.eol_images["RHEL9"]["nodejs"] == { - "name": "nodejs-18" - } + message = checker._get_jira_msg("reached EOL", "20250501") + assert "reached EOL in 20250501" in message + assert "Connection to Jira not available" in message -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") +def test_get_jira_msg_when_jira_available(checker): + flexmock(checker.jira_fetcher).should_receive("jira").and_return(flexmock()) - 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 + message = checker._get_jira_msg("approaching EOL", "20250601") + assert "approaching EOL in 20250601" in message + assert "Jira ticket is not filled" in message -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") +def test_summary_for_images_returns_empty_when_no_containers(checker): + checker.eol_images["RHEL9"] = {} + + assert checker.summary_for_images(checker.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_images_when_jira_unavailable(checker): + checker.eol_images["RHEL9"] = {"nodejs": _container_struct("nodejs-18", "20250501")} + flexmock(checker.jira_fetcher).should_receive("jira").and_return(None) -def test_summary_for_approaching_eol_images_returns_empty_when_none(checker): - checker.approaching_eol_images["RHEL9"] = {} + report = checker.summary_for_images(checker.eol_images, "RHEL9") - assert checker.summary_for_approaching_eol_images("RHEL9") == "" + assert "Summary report for RHEL9:" in report + assert "nodejs-18 for RHEL9" in report + assert "Connection to Jira not available" in report + assert f"{JIRA_URL}/browse/{JIRA_DEPRECATION_TICKET}" in report -def test_summary_for_approaching_eol_images_with_existing_jira_ticket(checker): - checker.approaching_eol_images["RHEL9"] = {"nodejs": {"name": "nodejs-20"}} +def test_summary_for_images_without_jira_ticket(checker): + checker.eol_images["RHEL9"] = {"nodejs": _container_struct("nodejs-18", "20250501")} + flexmock(checker.jira_fetcher).should_receive("jira").and_return(flexmock()) flexmock(checker.jira_fetcher).should_receive( "is_jira_filled_for_container" - ).with_args(stream_name="nodejs-20").and_return("RHELMISC-200") + ).with_args(stream_name="nodejs-18").and_return("") - report = checker.summary_for_approaching_eol_images("RHEL9") + report = checker.summary_for_images(checker.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 + assert "nodejs-18 for RHEL9" in report + 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_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("") +def test_summary_for_images_adds_sme_mails_when_sending_email(checker): + checker.send_email = True + checker.end_line = "
" + checker.bold_line = "" + checker.bold_line_end = "" + checker.eol_sme_mails = {"nodejs": ["sme@redhat.com", ""]} + checker.default_mails = ["default@redhat.com"] + checker.eol_images["RHEL9"] = {"nodejs": _container_struct("nodejs-18", "20250501")} + flexmock(checker.jira_fetcher).should_receive("jira").and_return(None) - report = checker.summary_for_approaching_eol_images("RHEL9") + checker.summary_for_images(checker.eol_images, "RHEL9") - assert "Jira ticket is not filled" in report - assert f"{JIRA_URL}/browse/{JIRA_DEPRECATION_TICKET}" in report + assert "sme@redhat.com" in checker.default_mails + assert checker.default_mails.count("default@redhat.com") == 1 -def test_summary_report_includes_eol_and_approaching_sections(checker): +def test_summary_report_includes_all_eol_categories(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("") + checker.already_eol_images[os_name] = {} + checker.already_eol_images["RHEL8"] = { + "nodejs": _container_struct("nodejs-16", "20250401") + } + checker.eol_images["RHEL9"] = {"nodejs": _container_struct("nodejs-18", "20250501")} + checker.approaching_eol_images["RHEL10"] = { + "httpd": _container_struct("httpd-26", "20250601") + } + flexmock(checker.jira_fetcher).should_receive("jira").and_return(None) report = checker.summary_report() - assert "Summary report that reached EOL dates:" in report + assert "Summary report for RHEL8:" in report + assert "nodejs-16 for RHEL8" in report + assert "Summary report for RHEL9:" in report assert "nodejs-18 for RHEL9" in report - assert "Summary report that approaching EOL dates:" in report + assert "Summary report for RHEL10:" in report assert "httpd-26 for RHEL10" in report -def test_summary_report_returns_newline_when_no_images(checker): +def test_summary_report_returns_newlines_when_no_images(checker): for os_name in OS_NAMES: checker.eol_images[os_name] = {} checker.approaching_eol_images[os_name] = {} + checker.already_eol_images[os_name] = {} assert checker.summary_report() == "\n\n" +def test_analyze_containers_skips_when_yaml_url_missing(checker): + flexmock(checker_module.YamlLoader).should_receive("get_yaml_url").and_return("") + flexmock(checker_module.YamlLoader).should_receive("download_yaml").never() + 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] == {} + assert checker.already_eol_images[os_name] == {} + + def test_analyze_containers_skips_when_yaml_download_fails(checker): flexmock(checker_module.YamlLoader).should_receive("get_yaml_url").and_return( "http://test/yaml" @@ -206,10 +249,6 @@ def test_analyze_containers_skips_when_yaml_download_fails(checker): 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 = { @@ -242,13 +281,92 @@ def test_analyze_containers_populates_eol_from_yaml(checker): checker.analyze_containers() for os_name in OS_NAMES: - assert checker.eol_images[os_name]["nodejs"] == {"name": "nodejs-18"} + assert checker.eol_images[os_name]["nodejs"] == _container_struct( + "nodejs-18", "20250501" + ) + + +def _mock_send_email_env(): + flexmock(checker_module).should_receive("get_env_variable").with_args( + "SMTP_SERVER", "smtp.redhat.com" + ).and_return("smtp.test") + flexmock(checker_module).should_receive("get_env_variable").with_args( + "SMTP_PORT", "25" + ).and_return("2525") + flexmock(checker_module).should_receive("get_env_variable").with_args( + "SEND_EMAIL", "False" + ).and_return("True") + + +def test_send_emails_sends_html_message(checker): + checker.send_email = True + checker.default_mails = ["recipient@redhat.com"] + checker.body = "report" + mock_smtp = flexmock() + mock_smtp.should_receive("set_debuglevel").with_args(5).once() + mock_smtp.should_receive("sendmail").once() + mock_smtp.should_receive("close").once() + _mock_send_email_env() + flexmock(checker_module).should_receive("SMTP").with_args( + "smtp.test", 2525 + ).and_return(mock_smtp) + + checker.send_emails() + + assert checker.smtp_server == "smtp.test" + assert checker.smtp_port == 2525 + assert checker.mime_msg["Subject"] == "Container EOL Checker Report" + assert "recipient@redhat.com" in checker.mime_msg["To"] + + +def test_send_emails_logs_smtp_exception(checker, caplog): + checker.send_email = True + checker.default_mails = ["recipient@redhat.com"] + checker.body = "report" + mock_smtp = flexmock() + mock_smtp.should_receive("set_debuglevel").and_return(None) + mock_smtp.should_receive("sendmail").and_raise( + smtplib.SMTPException("smtp failure") + ) + mock_smtp.should_receive("close").once() + _mock_send_email_env() + flexmock(checker_module).should_receive("SMTP").and_return(mock_smtp) + + with caplog.at_level("ERROR"): + checker.send_emails() + + assert "Error sending email(SMTPException)" in caplog.text + + +def test_run_skips_jira_when_connection_unavailable(checker): + flexmock(checker.jira_fetcher).should_receive("jira").and_return(None) + flexmock(checker.jira_fetcher).should_receive( + "get_jira_deprecation_details" + ).never() + flexmock(checker).should_receive("analyze_containers").once() + flexmock(checker).should_receive("summary_report").and_return("\nreport\n") + flexmock(checker).should_receive("send_emails").never() + + assert checker.run() == 0 + assert checker.body == "\nreport\n" def test_run_fetches_jira_and_analyzes_containers(checker): + flexmock(checker.jira_fetcher).should_receive("jira").and_return(flexmock()) 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") + flexmock(checker).should_receive("send_emails").never() + + assert checker.run() == 0 + + +def test_run_sends_email_when_enabled(checker): + checker.send_email = True + flexmock(checker.jira_fetcher).should_receive("jira").and_return(None) + flexmock(checker).should_receive("analyze_containers").once() + flexmock(checker).should_receive("summary_report").and_return("\nreport\n") + flexmock(checker).should_receive("send_emails").once() - checker.run() + assert checker.run() == 0 diff --git a/eol-checker/tests/test_jira.py b/eol-checker/tests/test_jira.py index f7943aa..f894209 100644 --- a/eol-checker/tests/test_jira.py +++ b/eol-checker/tests/test_jira.py @@ -33,9 +33,32 @@ def test_init_uses_env_overrides(monkeypatch): 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) +def test_jira_property_returns_none_without_credentials(monkeypatch, fetcher): + monkeypatch.delenv("JIRA_USERNAME", raising=False) + monkeypatch.delenv("JIRA_PASSWORD", raising=False) + flexmock(jira_module).should_receive("Jira").never() + + assert fetcher.jira is None + + +def test_jira_property_returns_none_when_http_status_not_ok(monkeypatch, fetcher): + monkeypatch.setenv("JIRA_USERNAME", "user@redhat.com") + monkeypatch.setenv("JIRA_PASSWORD", "secret") + mock_client = flexmock(http_status_code=403) + mock_client.should_receive("http_status_code_handler").with_args(403).once() + flexmock(jira_module).should_receive("Jira").and_return(mock_client) + + assert fetcher.jira is None + + +def test_jira_property_creates_client_once(monkeypatch, fetcher): + monkeypatch.setenv("JIRA_USERNAME", "user@redhat.com") + monkeypatch.setenv("JIRA_PASSWORD", "secret") + mock_client = flexmock(http_status_code=200) + mock_client.should_receive("http_status_code_handler").with_args(200).once() + flexmock(jira_module).should_receive("Jira").with_args( + url=fetcher.jira_url, username="user@redhat.com", password="secret" + ).once().and_return(mock_client) assert fetcher.jira is mock_client assert fetcher.jira is mock_client @@ -46,7 +69,7 @@ def test_get_jira_deprecation_details_stores_issuelinks(fetcher): 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._jira_api = mock_client fetcher.get_jira_deprecation_details() @@ -58,7 +81,19 @@ def test_get_jira_deprecation_details_skips_when_no_issuelinks(fetcher): 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._jira_api = mock_client + + fetcher.get_jira_deprecation_details() + + assert fetcher.jira_details is None + + +def test_get_jira_deprecation_details_skips_when_fields_missing(fetcher): + mock_client = flexmock() + mock_client.should_receive("issue").with_args( + fetcher.jira_deprecation_ticket + ).and_return({}) + fetcher._jira_api = mock_client fetcher.get_jira_deprecation_details() @@ -129,3 +164,17 @@ def test_check_if_jira_is_filled_skips_links_without_inward_issue(fetcher): assert fetcher.check_if_jira_is_filled() is True assert fetcher.jira_deprecated_opened_issues == [] + + +def test_check_if_jira_is_filled_skips_disallowed_status(fetcher): + fetcher.jira_details = [ + { + "inwardIssue": { + "key": "RHELMISC-300", + "fields": {"summary": "EOL nodejs RHEL9", "status": {"name": "Closed"}}, + } + } + ] + + assert fetcher.check_if_jira_is_filled() is True + assert fetcher.jira_deprecated_opened_issues == [] From 724894df0e1d2ca714cd478bd33537c8cb66a72c Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 26 May 2026 14:44:22 +0200 Subject: [PATCH 2/3] Add CodeCov coverage for eol-checker Signed-off-by: Petr "Stone" Hracek --- .github/workflows/python-eol-checker.yml | 29 ++++++++++++++++++++++++ codecov.yml | 2 ++ 2 files changed, 31 insertions(+) create mode 100644 .github/workflows/python-eol-checker.yml diff --git a/.github/workflows/python-eol-checker.yml b/.github/workflows/python-eol-checker.yml new file mode 100644 index 0000000..db650b0 --- /dev/null +++ b/.github/workflows/python-eol-checker.yml @@ -0,0 +1,29 @@ +--- +on: + push: + branches: + - master + pull_request: + branches: + - master + +name: Run Tox tests on eol_checker +jobs: + tox_test: + strategy: + matrix: + tox_env: [py311, py312, py313, py314] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: fedora-python/tox-github-action@main + with: + tox_env: ${{ matrix.tox_env }} + workdir: "eol-checker/" + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: eol-checker-unit + files: eol-checker/coverage.xml + fail_ci_if_error: false diff --git a/codecov.yml b/codecov.yml index 61dc696..e04d68f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,6 +11,8 @@ flags: carryforward: true ocp-stream-generator-unit: carryforward: true + eol-checker-unit: + carryforward: true comment: layout: "reach,diff,flags,files" From 22f350f2d9bf0700aecdb6faf446c1285ce03d62 Mon Sep 17 00:00:00 2001 From: "Petr \"Stone\" Hracek" Date: Tue, 26 May 2026 14:45:48 +0200 Subject: [PATCH 3/3] Add action/checkout@v6 Signed-off-by: Petr "Stone" Hracek --- .github/workflows/python-daily-tests.yml | 2 +- .github/workflows/python-eol-checker.yml | 2 +- .github/workflows/python-ocp-generator-tests.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-daily-tests.yml b/.github/workflows/python-daily-tests.yml index 3147630..8b1d40d 100644 --- a/.github/workflows/python-daily-tests.yml +++ b/.github/workflows/python-daily-tests.yml @@ -15,7 +15,7 @@ jobs: tox_env: [py311, py312, py313, py314] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - uses: fedora-python/tox-github-action@main with: tox_env: ${{ matrix.tox_env }} diff --git a/.github/workflows/python-eol-checker.yml b/.github/workflows/python-eol-checker.yml index db650b0..d8acfcd 100644 --- a/.github/workflows/python-eol-checker.yml +++ b/.github/workflows/python-eol-checker.yml @@ -15,7 +15,7 @@ jobs: tox_env: [py311, py312, py313, py314] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - uses: fedora-python/tox-github-action@main with: tox_env: ${{ matrix.tox_env }} diff --git a/.github/workflows/python-ocp-generator-tests.yml b/.github/workflows/python-ocp-generator-tests.yml index 855eabf..0b24398 100644 --- a/.github/workflows/python-ocp-generator-tests.yml +++ b/.github/workflows/python-ocp-generator-tests.yml @@ -15,7 +15,7 @@ jobs: tox_env: [py311, py312, py313, py314] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - uses: fedora-python/tox-github-action@main with: tox_env: ${{ matrix.tox_env }}