diff --git a/src/distro_support/rhel.json b/src/distro_support/rhel.json new file mode 100644 index 0000000..86afdf5 --- /dev/null +++ b/src/distro_support/rhel.json @@ -0,0 +1,42 @@ +{ + "6": { + "distribution": "rhel", + "version": "6", + "begin_support": "2010-11-10", + "end_support": "2020-11-30", + "begin_dev": "2010-11-10", + "end_extended_support": "2024-06-30" + }, + "7": { + "distribution": "rhel", + "version": "7", + "begin_support": "2014-06-10", + "end_support": "2024-06-30", + "begin_dev": "2014-06-10", + "end_extended_support": "2029-05-31" + }, + "8": { + "distribution": "rhel", + "version": "8", + "begin_support": "2019-05-07", + "end_support": "2029-05-31", + "begin_dev": "2019-05-07", + "end_extended_support": "2032-05-31" + }, + "9": { + "distribution": "rhel", + "version": "9", + "begin_support": "2022-05-18", + "end_support": "2032-05-31", + "begin_dev": "2022-05-18", + "end_extended_support": "2035-05-31" + }, + "10": { + "distribution": "rhel", + "version": "10", + "begin_support": "2025-05-20", + "end_support": "2035-05-31", + "begin_dev": "2025-05-20", + "end_extended_support": "2038-05-31" + } +} diff --git a/src/distro_support/rhel.py b/src/distro_support/rhel.py new file mode 100644 index 0000000..d6977c9 --- /dev/null +++ b/src/distro_support/rhel.py @@ -0,0 +1,50 @@ +"""Information about Red Hat Enterprise Linux support.""" + +import json +from urllib import request + +SUPPORT_INFO_URL = "https://access.redhat.com/product-life-cycles/api/v1/products?name=Red+Hat+Enterprise+Linux" + +_PHASE_GA = "general availability" +_PHASE_MAINTENANCE = "maintenance support" +_PHASE_ELS = "extended life cycle support (els) add-on" + + +def _parse_date(value: str | None) -> str | None: + """Return an ISO date string (YYYY-MM-DD), or None for missing/non-date values.""" + if not value or value.upper() == "N/A" or value.lower() == "ongoing": + return None + return value[:10] + + +def get_distro_info() -> dict[str, dict[str, str | None]]: + req = request.Request(SUPPORT_INFO_URL, headers={"User-Agent": "distro-support"}) + with request.urlopen(req) as response: # nosec B310 + if response.status != 200: + raise RuntimeError( + f"Unexpected HTTP status from Red Hat API: {response.status}" + ) + data = json.load(response) + + if not data.get("data"): + return {} + + series = {} + for version in data["data"][0].get("versions", []): + ver = version["name"] + phases = {p["name"].lower(): p for p in version.get("phases", [])} + ga_date = _parse_date(phases.get(_PHASE_GA, {}).get("end_date")) + + series[ver] = { + "distribution": "rhel", + "version": ver, + "begin_support": ga_date, + "end_support": _parse_date( + phases.get(_PHASE_MAINTENANCE, {}).get("end_date") + ), + "begin_dev": ga_date, + "end_extended_support": _parse_date( + phases.get(_PHASE_ELS, {}).get("end_date") + ), + } + return series diff --git a/tests/test_get_support_range.py b/tests/test_get_support_range.py index 7e67cd4..0aa18fb 100644 --- a/tests/test_get_support_range.py +++ b/tests/test_get_support_range.py @@ -22,6 +22,9 @@ # Alpine has no begin_dev, so in_dev=None signals NoDevelopmentInfoError ("alpine", "3.20", date(2025, 1, 1), True, None, None), ("alpine", "3.17", date(2025, 1, 1), False, None, None), + ("rhel", "9", date(2023, 1, 1), True, False, False), + ("rhel", "9", date(2033, 1, 1), False, False, True), + ("rhel", "7", date(2025, 1, 1), False, False, True), ], ) def test_get_support_range( diff --git a/tests/test_rhel_downloader.py b/tests/test_rhel_downloader.py new file mode 100644 index 0000000..fe46790 --- /dev/null +++ b/tests/test_rhel_downloader.py @@ -0,0 +1,178 @@ +"""Tests for the RHEL downloader.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from distro_support.rhel import _parse_date, get_distro_info + +# Minimal realistic API response mirroring the Red Hat lifecycle API structure. +_FAKE_API_RESPONSE = { + "data": [ + { + "versions": [ + { + "name": "9", + "phases": [ + { + "name": "General availability", + "end_date": "2022-05-18T00:00:00.000Z", + }, + { + "name": "Full support", + "end_date": "2027-05-31T00:00:00.000Z", + }, + { + "name": "Maintenance support", + "end_date": "2032-05-31T00:00:00.000Z", + }, + { + "name": "Extended life cycle support (ELS) add-on", + "end_date": "2035-05-31T00:00:00.000Z", + }, + { + "name": "Extended life phase", + "end_date": "Ongoing", + }, + ], + }, + { + "name": "7", + "phases": [ + { + "name": "General availability", + "end_date": "2014-06-10T00:00:00.000Z", + }, + { + "name": "Full support", + "end_date": "2019-08-06T00:00:00.000Z", + }, + { + "name": "Maintenance support", + "end_date": "2024-06-30T00:00:00.000Z", + }, + { + "name": "Extended life cycle support (ELS) add-on", + "end_date": "2029-05-31T00:00:00.000Z", + }, + { + "name": "Extended life phase", + "end_date": "Ongoing", + }, + ], + }, + { + # Version with N/A GA date (as seen in some older entries) + "name": "6", + "phases": [ + { + "name": "General availability", + "end_date": "N/A", + }, + { + "name": "Maintenance support", + "end_date": "2020-11-30T00:00:00.000Z", + }, + # No ELS phase for this entry + ], + }, + ] + } + ] +} + + +def _make_mock_response(data: dict): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.read.return_value = json.dumps(data).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + return mock_response + + +@pytest.fixture +def mock_urlopen(): + with patch("distro_support.rhel.request.urlopen") as mock: + mock.return_value = _make_mock_response(_FAKE_API_RESPONSE) + yield mock + + +class TestParseDate: + def test_iso_timestamp(self): + assert _parse_date("2022-05-18T00:00:00.000Z") == "2022-05-18" + + def test_plain_iso_date(self): + assert _parse_date("2022-05-18") == "2022-05-18" + + def test_ongoing_returns_none(self): + assert _parse_date("Ongoing") is None + + def test_ongoing_case_insensitive(self): + assert _parse_date("ongoing") is None + + def test_na_returns_none(self): + assert _parse_date("N/A") is None + + def test_na_case_insensitive(self): + assert _parse_date("n/a") is None + + def test_none_returns_none(self): + assert _parse_date(None) is None + + def test_empty_string_returns_none(self): + assert _parse_date("") is None + + +class TestGetDistroInfo: + def test_returns_all_versions(self, mock_urlopen): + result = get_distro_info() + assert set(result.keys()) == {"9", "7", "6"} + + def test_distribution_name(self, mock_urlopen): + result = get_distro_info() + for ver in result.values(): + assert ver["distribution"] == "rhel" + + def test_version_field_matches_key(self, mock_urlopen): + result = get_distro_info() + for key, ver in result.items(): + assert ver["version"] == key + + def test_begin_dev_equals_begin_support(self, mock_urlopen): + """begin_dev is set to the GA date so is_in_development_on always returns False.""" + result = get_distro_info() + for ver in result.values(): + assert ver["begin_dev"] == ver["begin_support"] + + def test_full_version_dates(self, mock_urlopen): + result = get_distro_info() + assert result["9"]["begin_support"] == "2022-05-18" + assert result["9"]["end_support"] == "2032-05-31" + assert result["9"]["end_extended_support"] == "2035-05-31" + + def test_ongoing_eol_is_none(self, mock_urlopen): + # Extended life phase is "Ongoing" — should not bleed into our fields + result = get_distro_info() + assert result["9"]["end_extended_support"] == "2035-05-31" + + def test_na_ga_date_is_none(self, mock_urlopen): + result = get_distro_info() + assert result["6"]["begin_support"] is None + + def test_missing_els_phase_is_none(self, mock_urlopen): + result = get_distro_info() + assert result["6"]["end_extended_support"] is None + + def test_sends_user_agent(self, mock_urlopen): + get_distro_info() + req = mock_urlopen.call_args[0][0] + assert req.get_header("User-agent") == "distro-support" + + def test_http_error_raises(self): + mock_response = _make_mock_response(_FAKE_API_RESPONSE) + mock_response.status = 503 + with patch("distro_support.rhel.request.urlopen", return_value=mock_response): + with pytest.raises(RuntimeError): + get_distro_info() diff --git a/tools/update.py b/tools/update.py index 0e47d7f..1c3064e 100644 --- a/tools/update.py +++ b/tools/update.py @@ -2,13 +2,27 @@ import json import pathlib +import sys -from distro_support import alpine, debian, devuan, ubuntu +from distro_support import alpine, debian, devuan, rhel, ubuntu + + +def _version_sort_key(version: str) -> tuple[int, ...]: + if not version: + return (sys.maxsize,) + try: + return tuple(int(x) for x in version.split(".")) + except ValueError: + return (0,) def update(module): - ubuntu_data = pathlib.Path(module.__file__).with_suffix(".json") - ubuntu_data.write_text(json.dumps(module.get_distro_info(), indent=" ") + "\n") + data_path = pathlib.Path(module.__file__).with_suffix(".json") + data = module.get_distro_info() + sorted_data = dict( + sorted(data.items(), key=lambda item: _version_sort_key(item[0])) + ) + data_path.write_text(json.dumps(sorted_data, indent=" ") + "\n") if __name__ == "__main__": @@ -20,3 +34,5 @@ def update(module): update(devuan) print("Updating Alpine data") update(alpine) + print("Updating RHEL data") + update(rhel)