From 7d3d47369765012adc297abfc9a33ebe2adafd58 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Mon, 27 Apr 2026 13:07:00 +0200 Subject: [PATCH 01/12] This should parse PP for current RHEL streams --- ymir/common/product_pages.py | 231 +++++++++++++++++++++++++++++++++++ ymir/common/pyproject.toml | 1 + ymir/common/requirements.txt | 2 + 3 files changed, 234 insertions(+) create mode 100644 ymir/common/product_pages.py diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py new file mode 100644 index 00000000..4da1e911 --- /dev/null +++ b/ymir/common/product_pages.py @@ -0,0 +1,231 @@ +""" +Product Pages helpers for RHEL y-stream and z-stream labels. + +This module authenticates to the internal Product Pages API (Kerberos via +requests-gssapi) and derives current y-streams, current z-streams, and upcoming +z-streams from active releases and GA/ZStream release metadata. + +Public API: ``await fetch_rhel_streams_snapshot()`` (async coroutine). Blocking +HTTP (``requests``) runs in a thread pool so the event loop is not blocked. +Everything else in this module is an implementation detail. +""" + +import asyncio +import re +from collections import defaultdict + +import requests +import requests_gssapi +from beeai_framework.tools import ToolError + +_PLAIN_SHORTNAME_RE = re.compile(r"^rhel-(\d+)\.(\d+)$") +_GA_ZSTREAM_RE = re.compile(r"\(GA\/ZStream\)") + +_OIDC_AUTHENTICATE_URL = "https://pp.engineering.redhat.com/oidc/authenticate" +_RELEASES_API_URL = "https://pp.engineering.redhat.com/api/v7/releases/" + + +def _rhel_sort_key(shortname: str) -> tuple[int, ...]: + """Sort key for RHEL shortnames by numeric major.minor (not lexicographic). + + Example: rhel-10.3 sorts after rhel-9.9. + + Returns: + Tuple of ints for lexical comparison ordering (major, minor, ...). + """ + body = shortname.removeprefix("rhel-").removesuffix(".z") + parts = body.split(".") + return tuple(int(p) for p in parts) + + +def _parse_plain_rhel_minor(shortname: str) -> tuple[int, int] | None: + """ + Parse rhel-M.m shortname (optional .z stripped). + + Args: + shortname: Release shortname such as ``rhel-9.6`` or ``rhel-9.6.z``. + + Returns: + ``(major, minor)`` or None if the pattern does not match. + """ + base = shortname.removesuffix(".z") + m = _PLAIN_SHORTNAME_RE.match(base) + if not m: + return None + return int(m.group(1)), int(m.group(2)) + + +def _format_z_label(shortname_or_stem: str) -> str: + """ + Display form for z-stream maps (e.g. ``rhel-9.6`` -> ``rhel-9.6.z``). + + Args: + shortname_or_stem: Shortname or stem; ``.z`` is appended when missing. + + Returns: + Canonical z-stream label string. + """ + s = shortname_or_stem.strip() + if s.endswith(".z"): + return s + return f"{s}.z" + + +def _build_current_y_streams(active_releases: list[dict]) -> dict[str, str]: + """ + Best current y-stream shortname per RHEL major. + + Args: + active_releases: Active release records (must include ``shortname``). + + Returns: + Mapping major version string -> highest ``rhel-M.m`` shortname among + active plain y-style names. + """ + best: dict[int, tuple[tuple[int, ...], str]] = {} + for item in active_releases: + sn = item.get("shortname") or "" + parsed = _parse_plain_rhel_minor(sn) + if not parsed: + continue + maj, _ = parsed + key = _rhel_sort_key(sn) + prev = best.get(maj) + if prev is None or key > prev[0]: + best[maj] = (key, sn) + return {str(m): sn for m, (_, sn) in sorted(best.items())} + + +def _build_upcoming_z_streams(active_releases: list[dict]) -> dict[str, str]: + """ + Upcoming z-stream label per major when multiple active streams exist. + + If a major has more than one active release stream, the lower version is + treated as the upcoming z-stream; otherwise that major is omitted. + + Args: + active_releases: Active release records (must include ``shortname``). + + Returns: + Mapping major version string -> upcoming z-stream label (with ``.z``). + """ + by_major: defaultdict[int, list[str]] = defaultdict(list) + for item in active_releases: + sn = item.get("shortname") or "" + parsed = _parse_plain_rhel_minor(sn) + if not parsed: + continue + maj, _ = parsed + by_major[maj].append(sn) + + out: dict[str, str] = {} + for maj in sorted(by_major): + sns = by_major[maj] + if len(sns) <= 1: + continue + lower = min(sns, key=_rhel_sort_key) + out[str(maj)] = _format_z_label(lower) + return out + + +def _build_current_z_streams_ga_zstream(ga_zstream_rows: list[dict]) -> dict[str, str]: + """ + Current z-stream labels from GA/ZStream maintenance releases. + + Rows should be releases whose ``name_incl_maint`` matches (GA/ZStream). + If several exist per major, the highest version is used. + + Args: + ga_zstream_rows: Filtered release dicts with ``shortname`` set. + + Returns: + Mapping major version string -> current z-stream label (with ``.z``). + """ + by_major: defaultdict[int, list[str]] = defaultdict(list) + for item in ga_zstream_rows: + sn = item.get("shortname") or "" + parsed = _parse_plain_rhel_minor(sn) + if not parsed: + continue + maj, _ = parsed + by_major[maj].append(sn) + + out: dict[str, str] = {} + for maj in sorted(by_major): + sns = by_major[maj] + top = max(sns, key=_rhel_sort_key) + out[str(maj)] = _format_z_label(top) + return out + + +def _require_ok(response: requests.Response, what: str) -> None: + """Raise ToolError unless *response* is HTTP 200.""" + if response.status_code != 200: + raise ToolError(f"Product Pages API error ({what}): expected HTTP 200, got {response.status_code}") + + +def _fetch_rhel_streams_snapshot_sync() -> dict[str, dict[str, str]]: + """Blocking implementation: HTTP via ``requests`` / GSSAPI.""" + s = requests.Session() + auth = requests_gssapi.HTTPSPNEGOAuth(mutual_authentication=requests_gssapi.OPTIONAL) + auth_resp = s.post(_OIDC_AUTHENTICATE_URL, auth=auth) + _require_ok(auth_resp, "OIDC authenticate") + + # Multiple active releases per major: lower stream is finishing; higher is main y-stream. + response_active = s.get( + _RELEASES_API_URL, + params={ + "fields": "shortname", + "active": "", + "product__shortname": "rhel", + }, + ) + _require_ok(response_active, "active releases") + active_data = response_active.json() + + current_y_streams = _build_current_y_streams(active_data) + upcoming_z_streams = _build_upcoming_z_streams(active_data) + + response_zstream = s.get( + _RELEASES_API_URL, + params={ + "fields": "shortname,name_incl_maint,name", + "product__shortname": "rhel", + }, + ) + _require_ok(response_zstream, "releases for z-stream filtering") + z_data = response_zstream.json() + + fields = [ + "shortname", + "name_incl_maint", + "name", + ] + filtered = [ + {k: item[k] for k in fields} + for item in z_data + if _GA_ZSTREAM_RE.search(item.get("name_incl_maint") or "") + ] + + current_z_streams = _build_current_z_streams_ga_zstream(filtered) + + return { + "current_y_streams": current_y_streams, + "current_z_streams": current_z_streams, + "upcoming_z_streams": upcoming_z_streams, + } + + +async def fetch_rhel_streams_snapshot() -> dict[str, dict[str, str]]: + """ + Query Product Pages and return y-stream and z-stream snapshot maps. + + Uses GSSAPI session authentication, then loads active releases and + GA/ZStream-filtered releases to compute stream labels. + + Returns: + Dict with keys ``current_y_streams``, ``current_z_streams``, and + ``upcoming_z_streams``; each value maps major version strings to + shortname labels. + """ + return await asyncio.to_thread(_fetch_rhel_streams_snapshot_sync) \ No newline at end of file diff --git a/ymir/common/pyproject.toml b/ymir/common/pyproject.toml index 08733a7a..a67856ef 100644 --- a/ymir/common/pyproject.toml +++ b/ymir/common/pyproject.toml @@ -30,6 +30,7 @@ packages = [] "config.py" = "ymir/common/config.py" "constants.py" = "ymir/common/constants.py" "models.py" = "ymir/common/models.py" +"product_pages.py" = "ymir/common/product_pages.py" "utils.py" = "ymir/common/utils.py" "validators.py" = "ymir/common/validators.py" "version_utils.py" = "ymir/common/version_utils.py" diff --git a/ymir/common/requirements.txt b/ymir/common/requirements.txt index 98051d51..2536504d 100644 --- a/ymir/common/requirements.txt +++ b/ymir/common/requirements.txt @@ -1,3 +1,5 @@ # Dependencies specific to ymir-common GitPython>=3.1.0 redis>=6.4.0 +requests>=2.32.0 +requests-gssapi>=1.3.0 From 20a2ad8254034dc3ab54cea19cb0594ea33ac2d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:03:02 +0000 Subject: [PATCH 02/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/common/product_pages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py index 4da1e911..dec23323 100644 --- a/ymir/common/product_pages.py +++ b/ymir/common/product_pages.py @@ -228,4 +228,4 @@ async def fetch_rhel_streams_snapshot() -> dict[str, dict[str, str]]: ``upcoming_z_streams``; each value maps major version strings to shortname labels. """ - return await asyncio.to_thread(_fetch_rhel_streams_snapshot_sync) \ No newline at end of file + return await asyncio.to_thread(_fetch_rhel_streams_snapshot_sync) From 8678bb9e20ef8de7f907e9e005fba13f4c52e893 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Mon, 27 Apr 2026 14:28:53 +0200 Subject: [PATCH 03/12] Adding suggestions from gemini-code-assist --- ymir/common/product_pages.py | 122 +++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 48 deletions(-) diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py index dec23323..21d891c5 100644 --- a/ymir/common/product_pages.py +++ b/ymir/common/product_pages.py @@ -11,6 +11,7 @@ """ import asyncio +import json import re from collections import defaultdict @@ -24,6 +25,10 @@ _OIDC_AUTHENTICATE_URL = "https://pp.engineering.redhat.com/oidc/authenticate" _RELEASES_API_URL = "https://pp.engineering.redhat.com/api/v7/releases/" +# ``requests`` accepts ``(connect, read)`` in seconds. OIDC/GSSAPI can be slow to +# establish; the releases listing can return a large JSON payload. +_PRODUCT_PAGES_TIMEOUT = (30.0, 120.0) + def _rhel_sort_key(shortname: str) -> tuple[int, ...]: """Sort key for RHEL shortnames by numeric major.minor (not lexicographic). @@ -166,54 +171,70 @@ def _require_ok(response: requests.Response, what: str) -> None: def _fetch_rhel_streams_snapshot_sync() -> dict[str, dict[str, str]]: """Blocking implementation: HTTP via ``requests`` / GSSAPI.""" - s = requests.Session() - auth = requests_gssapi.HTTPSPNEGOAuth(mutual_authentication=requests_gssapi.OPTIONAL) - auth_resp = s.post(_OIDC_AUTHENTICATE_URL, auth=auth) - _require_ok(auth_resp, "OIDC authenticate") - - # Multiple active releases per major: lower stream is finishing; higher is main y-stream. - response_active = s.get( - _RELEASES_API_URL, - params={ - "fields": "shortname", - "active": "", - "product__shortname": "rhel", - }, - ) - _require_ok(response_active, "active releases") - active_data = response_active.json() - - current_y_streams = _build_current_y_streams(active_data) - upcoming_z_streams = _build_upcoming_z_streams(active_data) - - response_zstream = s.get( - _RELEASES_API_URL, - params={ - "fields": "shortname,name_incl_maint,name", - "product__shortname": "rhel", - }, - ) - _require_ok(response_zstream, "releases for z-stream filtering") - z_data = response_zstream.json() - - fields = [ - "shortname", - "name_incl_maint", - "name", - ] - filtered = [ - {k: item[k] for k in fields} - for item in z_data - if _GA_ZSTREAM_RE.search(item.get("name_incl_maint") or "") - ] - - current_z_streams = _build_current_z_streams_ga_zstream(filtered) - - return { - "current_y_streams": current_y_streams, - "current_z_streams": current_z_streams, - "upcoming_z_streams": upcoming_z_streams, - } + timeout = _PRODUCT_PAGES_TIMEOUT + try: + with requests.Session() as s: + auth = requests_gssapi.HTTPSPNEGOAuth(mutual_authentication=requests_gssapi.OPTIONAL) + auth_resp = s.post(_OIDC_AUTHENTICATE_URL, auth=auth, timeout=timeout) + _require_ok(auth_resp, "OIDC authenticate") + + # Multiple active releases per major: lower stream is finishing; higher is main y-stream. + response_active = s.get( + _RELEASES_API_URL, + params={ + "fields": "shortname", + "active": "", + "product__shortname": "rhel", + }, + timeout=timeout, + ) + _require_ok(response_active, "active releases") + active_data = response_active.json() + + current_y_streams = _build_current_y_streams(active_data) + upcoming_z_streams = _build_upcoming_z_streams(active_data) + + response_zstream = s.get( + _RELEASES_API_URL, + params={ + "fields": "shortname,name_incl_maint,name", + "product__shortname": "rhel", + }, + timeout=timeout, + ) + _require_ok(response_zstream, "releases for z-stream filtering") + z_data = response_zstream.json() + + fields = [ + "shortname", + "name_incl_maint", + "name", + ] + filtered = [ + {k: item[k] for k in fields} + for item in z_data + if _GA_ZSTREAM_RE.search(item.get("name_incl_maint") or "") + ] + + current_z_streams = _build_current_z_streams_ga_zstream(filtered) + + return { + "current_y_streams": current_y_streams, + "current_z_streams": current_z_streams, + "upcoming_z_streams": upcoming_z_streams, + } + except requests.Timeout as e: + raise ToolError( + f"Product Pages API request timed out (connect {timeout[0]}s, read {timeout[1]}s)" + ) from e + except requests.RequestException as e: + raise ToolError(f"Product Pages API network error: {e}") from e + except json.JSONDecodeError as e: + raise ToolError( + "Product Pages API returned a response body that is not valid JSON" + ) from e + except ValueError as e: + raise ToolError(f"Product Pages API response could not be processed: {e}") from e async def fetch_rhel_streams_snapshot() -> dict[str, dict[str, str]]: @@ -227,5 +248,10 @@ async def fetch_rhel_streams_snapshot() -> dict[str, dict[str, str]]: Dict with keys ``current_y_streams``, ``current_z_streams``, and ``upcoming_z_streams``; each value maps major version strings to shortname labels. + + Raises: + ToolError: On non-success HTTP responses, timeouts, transport errors + (``requests.RequestException``), invalid JSON, or unexpected response + shape (``ValueError``). """ return await asyncio.to_thread(_fetch_rhel_streams_snapshot_sync) From 5ba9822e2b1a308d7fa999d99d60b6a4fa8bee3d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:30:57 +0000 Subject: [PATCH 04/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/common/product_pages.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py index 21d891c5..a6768161 100644 --- a/ymir/common/product_pages.py +++ b/ymir/common/product_pages.py @@ -230,9 +230,7 @@ def _fetch_rhel_streams_snapshot_sync() -> dict[str, dict[str, str]]: except requests.RequestException as e: raise ToolError(f"Product Pages API network error: {e}") from e except json.JSONDecodeError as e: - raise ToolError( - "Product Pages API returned a response body that is not valid JSON" - ) from e + raise ToolError("Product Pages API returned a response body that is not valid JSON") from e except ValueError as e: raise ToolError(f"Product Pages API response could not be processed: {e}") from e From 6f5fe5bde723f8e04d376491fe70d37bca021219 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Wed, 29 Apr 2026 14:55:12 +0200 Subject: [PATCH 05/12] Fixing kerberos fetching, adding unit test --- ymir/common/product_pages.py | 44 ++++- ymir/common/tests/unit/test_product_pages.py | 194 +++++++++++++++++++ 2 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 ymir/common/tests/unit/test_product_pages.py diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py index a6768161..30696f11 100644 --- a/ymir/common/product_pages.py +++ b/ymir/common/product_pages.py @@ -2,8 +2,9 @@ Product Pages helpers for RHEL y-stream and z-stream labels. This module authenticates to the internal Product Pages API (Kerberos via -requests-gssapi) and derives current y-streams, current z-streams, and upcoming -z-streams from active releases and GA/ZStream release metadata. +``init_kerberos_ticket`` from ``ymir.common.utils``, then HTTP SPNEGO via +``requests-gssapi``) and derives current y-streams, current z-streams, and +upcoming z-streams from active releases and GA/ZStream release metadata. Public API: ``await fetch_rhel_streams_snapshot()`` (async coroutine). Blocking HTTP (``requests``) runs in a thread pool so the event loop is not blocked. @@ -12,13 +13,17 @@ import asyncio import json +import os import re from collections import defaultdict +from functools import cache import requests import requests_gssapi from beeai_framework.tools import ToolError +from ymir.common.utils import KerberosError, init_kerberos_ticket + _PLAIN_SHORTNAME_RE = re.compile(r"^rhel-(\d+)\.(\d+)$") _GA_ZSTREAM_RE = re.compile(r"\(GA\/ZStream\)") @@ -30,6 +35,20 @@ _PRODUCT_PAGES_TIMEOUT = (30.0, 120.0) +@cache +def _product_pages_verify() -> bool | str: + """TLS ``verify`` argument for ``requests``: corporate CA bundle if configured. + + Matches ``ymir.supervisor.errata_utils.ET_verify`` (``REDHAT_IT_CA_BUNDLE``) + and OpenShift-style ``REQUESTS_CA_BUNDLE`` when set. + """ + for key in ("REDHAT_IT_CA_BUNDLE", "REQUESTS_CA_BUNDLE"): + path = os.getenv(key) + if path: + return path + return True + + def _rhel_sort_key(shortname: str) -> tuple[int, ...]: """Sort key for RHEL shortnames by numeric major.minor (not lexicographic). @@ -174,6 +193,7 @@ def _fetch_rhel_streams_snapshot_sync() -> dict[str, dict[str, str]]: timeout = _PRODUCT_PAGES_TIMEOUT try: with requests.Session() as s: + s.verify = _product_pages_verify() auth = requests_gssapi.HTTPSPNEGOAuth(mutual_authentication=requests_gssapi.OPTIONAL) auth_resp = s.post(_OIDC_AUTHENTICATE_URL, auth=auth, timeout=timeout) _require_ok(auth_resp, "OIDC authenticate") @@ -228,7 +248,15 @@ def _fetch_rhel_streams_snapshot_sync() -> dict[str, dict[str, str]]: f"Product Pages API request timed out (connect {timeout[0]}s, read {timeout[1]}s)" ) from e except requests.RequestException as e: - raise ToolError(f"Product Pages API network error: {e}") from e + msg = f"Product Pages API network error: {e}" + err_chain = f"{e!s} {e.__cause__!s}" if e.__cause__ else str(e) + err_lower = err_chain.lower() + if "certificate" in err_lower or "ssl" in err_lower: + msg += ( + " If this is a corporate TLS trust issue, set REDHAT_IT_CA_BUNDLE or " + "REQUESTS_CA_BUNDLE to a CA bundle path (e.g. /etc/pki/tls/certs/ca-bundle.crt)." + ) + raise ToolError(msg) from e except json.JSONDecodeError as e: raise ToolError("Product Pages API returned a response body that is not valid JSON") from e except ValueError as e: @@ -248,8 +276,12 @@ async def fetch_rhel_streams_snapshot() -> dict[str, dict[str, str]]: shortname labels. Raises: - ToolError: On non-success HTTP responses, timeouts, transport errors - (``requests.RequestException``), invalid JSON, or unexpected response - shape (``ValueError``). + ToolError: On Kerberos initialization failure, non-success HTTP responses, + timeouts, transport errors (``requests.RequestException``), invalid + JSON, or unexpected response shape (``ValueError``). """ + try: + await init_kerberos_ticket() + except KerberosError as e: + raise ToolError(f"Failed to initialize Kerberos ticket: {e}") from e return await asyncio.to_thread(_fetch_rhel_streams_snapshot_sync) diff --git a/ymir/common/tests/unit/test_product_pages.py b/ymir/common/tests/unit/test_product_pages.py new file mode 100644 index 00000000..e3b3a6d5 --- /dev/null +++ b/ymir/common/tests/unit/test_product_pages.py @@ -0,0 +1,194 @@ +""" +Unit tests for ``ymir.common.product_pages``. + +HTTP is simulated by replacing ``requests.Session`` with a small fake that returns +fixed status codes and JSON bodies — no network calls. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +import requests.exceptions +from beeai_framework.tools import ToolError + +import ymir.common.product_pages as pp +from ymir.common.utils import KerberosError + + +async def _fake_init_kerberos_ok() -> str: + return "user@EXAMPLE.COM" + + +async def _fake_init_kerberos_fail() -> None: + raise KerberosError("ticket unavailable") + + +class _JsonResponse: + __slots__ = ("status_code", "_data") + + def __init__(self, status_code: int, data: object | None = None) -> None: + self.status_code = status_code + self._data = data + + def json(self) -> object: + if self._data is None: + raise ValueError("no json payload") + return self._data + + +class _FakeSession: + """Minimal session stub: one POST (OIDC), then two GETs (active releases, z-stream list).""" + + def __init__( + self, + *, + post_response: _JsonResponse, + get_responses: list[_JsonResponse], + ) -> None: + self.verify: bool | str | None = None + self._post_response = post_response + self._get_responses = list(get_responses) + + def __enter__(self) -> _FakeSession: + return self + + def __exit__(self, *args: object) -> None: + return None + + def post(self, url: str, **kwargs: object) -> _JsonResponse: + return self._post_response + + def get(self, url: str, **kwargs: object) -> _JsonResponse: + return self._get_responses.pop(0) + + +@pytest.fixture(autouse=True) +def _clear_product_pages_verify_cache() -> None: + pp._product_pages_verify.cache_clear() + yield + pp._product_pages_verify.cache_clear() + + +def test_product_pages_verify_prefers_redhat_bundle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("REDHAT_IT_CA_BUNDLE", "/ca/redhat.pem") + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/ca/requests.pem") + assert pp._product_pages_verify() == "/ca/redhat.pem" + + +def test_product_pages_verify_falls_back_to_requests_bundle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/ca/requests.pem") + assert pp._product_pages_verify() == "/ca/requests.pem" + + +def test_product_pages_verify_default_true(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + assert pp._product_pages_verify() is True + + +def test_fetch_rhel_streams_snapshot_sync_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + + active = [ + {"shortname": "rhel-9.5"}, + {"shortname": "rhel-9.6"}, + ] + z_rows = [ + { + "shortname": "rhel-9.5", + "name_incl_maint": "RHEL 9.5 (GA/ZStream)", + "name": "RHEL 9.5", + }, + ] + fake = _FakeSession( + post_response=_JsonResponse(200), + get_responses=[ + _JsonResponse(200, active), + _JsonResponse(200, z_rows), + ], + ) + + with patch.object(pp.requests, "Session", return_value=fake): + result = pp._fetch_rhel_streams_snapshot_sync() + + assert fake.verify is True + assert result == { + "current_y_streams": {"9": "rhel-9.6"}, + "current_z_streams": {"9": "rhel-9.5.z"}, + "upcoming_z_streams": {"9": "rhel-9.5.z"}, + } + + +def test_fetch_rhel_streams_snapshot_sync_oidc_not_ok(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + + fake = _FakeSession( + post_response=_JsonResponse(401), + get_responses=[], + ) + + with patch.object(pp.requests, "Session", return_value=fake), pytest.raises( + ToolError, + match="OIDC authenticate", + ): + pp._fetch_rhel_streams_snapshot_sync() + + +def test_fetch_rhel_streams_snapshot_sync_ssl_error_includes_ca_hint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + + class _SslSession(_FakeSession): + def post(self, url: str, **kwargs: object) -> _JsonResponse: + raise requests.exceptions.SSLError("certificate verify failed") + + fake = _SslSession( + post_response=_JsonResponse(200), + get_responses=[], + ) + + with patch.object(pp.requests, "Session", return_value=fake), pytest.raises( + ToolError, + match="REDHAT_IT_CA_BUNDLE", + ): + pp._fetch_rhel_streams_snapshot_sync() + + +@pytest.mark.asyncio +async def test_fetch_rhel_streams_snapshot_kerberos_fails(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(pp, "init_kerberos_ticket", _fake_init_kerberos_fail) + + with pytest.raises(ToolError, match="Failed to initialize Kerberos ticket"): + await pp.fetch_rhel_streams_snapshot() + + +@pytest.mark.asyncio +async def test_fetch_rhel_streams_snapshot_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.setattr(pp, "init_kerberos_ticket", _fake_init_kerberos_ok) + + active = [{"shortname": "rhel-10.0"}] + z_rows: list[dict] = [] + + fake = _FakeSession( + post_response=_JsonResponse(200), + get_responses=[ + _JsonResponse(200, active), + _JsonResponse(200, z_rows), + ], + ) + + with patch.object(pp.requests, "Session", return_value=fake): + result = await pp.fetch_rhel_streams_snapshot() + + assert result["current_y_streams"] == {"10": "rhel-10.0"} + assert result["current_z_streams"] == {} + assert result["upcoming_z_streams"] == {} From c63e5e3bc25399d80a30d47f7b33ae00b523e29b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:55:44 +0000 Subject: [PATCH 06/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/common/tests/unit/test_product_pages.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ymir/common/tests/unit/test_product_pages.py b/ymir/common/tests/unit/test_product_pages.py index e3b3a6d5..46819df4 100644 --- a/ymir/common/tests/unit/test_product_pages.py +++ b/ymir/common/tests/unit/test_product_pages.py @@ -26,7 +26,7 @@ async def _fake_init_kerberos_fail() -> None: class _JsonResponse: - __slots__ = ("status_code", "_data") + __slots__ = ("_data", "status_code") def __init__(self, status_code: int, data: object | None = None) -> None: self.status_code = status_code @@ -132,9 +132,12 @@ def test_fetch_rhel_streams_snapshot_sync_oidc_not_ok(monkeypatch: pytest.Monkey get_responses=[], ) - with patch.object(pp.requests, "Session", return_value=fake), pytest.raises( - ToolError, - match="OIDC authenticate", + with ( + patch.object(pp.requests, "Session", return_value=fake), + pytest.raises( + ToolError, + match="OIDC authenticate", + ), ): pp._fetch_rhel_streams_snapshot_sync() @@ -154,9 +157,12 @@ def post(self, url: str, **kwargs: object) -> _JsonResponse: get_responses=[], ) - with patch.object(pp.requests, "Session", return_value=fake), pytest.raises( - ToolError, - match="REDHAT_IT_CA_BUNDLE", + with ( + patch.object(pp.requests, "Session", return_value=fake), + pytest.raises( + ToolError, + match="REDHAT_IT_CA_BUNDLE", + ), ): pp._fetch_rhel_streams_snapshot_sync() From 96be957b3f4211ff76e98a75791a4fda8f233ee3 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Tue, 5 May 2026 13:40:52 +0200 Subject: [PATCH 07/12] Replacing the load_rhel_config function call --- ymir/agents/triage_agent.py | 6 ++-- ymir/common/version_utils.py | 7 ++-- ymir/tools/privileged/jira.py | 7 ++-- ymir/tools/privileged/tests/unit/test_jira.py | 32 +++++++++---------- ymir/tools/unprivileged/version_mapper.py | 4 +-- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 08e34f4c..9dd1252c 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -35,7 +35,6 @@ run_tool, ) from ymir.common.base_utils import fix_await, redis_client -from ymir.common.config import load_rhel_config from ymir.common.constants import JiraLabels, RedisQueues from ymir.common.models import ( ApplicabilityResult, @@ -56,6 +55,7 @@ TriageOutputSchema as OutputSchema, ) from ymir.common.version_utils import is_older_zstream, normalize_fix_version, parse_rhel_version +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.tools.unprivileged.commands import RunShellCommandTool ## UpstreamSearchTool is currently unmaintained and disabled. @@ -135,7 +135,7 @@ async def _map_version_to_branch( major_version, minor_version, is_zstream = parsed # Load rhel-config to check which major versions have Y-stream mappings - config = await load_rhel_config() + config = await fetch_rhel_streams_snapshot() y_streams = config.get("current_y_streams", {}) # Check if this is an older z-stream than the current one @@ -832,7 +832,7 @@ async def run_triage_analysis(state): # Normalize stale Y-stream fixVersion (e.g. rhel-9.8 → rhel-9.8.z after GA) if hasattr(state.triage_result.data, "fix_version") and state.triage_result.data.fix_version: - rhel_config = await load_rhel_config() + rhel_config = await fetch_rhel_streams_snapshot() state.triage_result.data.fix_version = normalize_fix_version( state.triage_result.data.fix_version, rhel_config ) diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index fcd86433..59426440 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -118,7 +118,8 @@ async def is_older_zstream( Args: version_or_branch: Fix version string or dist-git branch name current_z_streams: Dict mapping major version to current z-stream - (e.g., {"9": "rhel-9.7.z"}). If None, loaded from rhel-config.json. + (e.g., {"9": "rhel-9.7.z"}). If None, loaded from Product Pages via + ``fetch_rhel_streams_snapshot``. Returns: True if the version targets an older z-stream, False otherwise. @@ -126,9 +127,9 @@ async def is_older_zstream( if current_z_streams is None: current_z_streams = current_z_streams_override.get() if current_z_streams is None: - from ymir.common.config import load_rhel_config + from ymir.common.product_pages import fetch_rhel_streams_snapshot - config = await load_rhel_config() + config = await fetch_rhel_streams_snapshot() current_z_streams = config.get("current_z_streams", {}) # Try parsing as a z-stream version string first (rhel-9.7.z) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 8d92c5cf..7cb4cea0 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -17,9 +17,10 @@ ) from pydantic import BaseModel, Field -from ymir.common import CVEEligibilityResult, TriageEligibility, load_rhel_config +from ymir.common import CVEEligibilityResult, TriageEligibility from ymir.common.base_utils import get_jira_auth_headers from ymir.common.constants import JIRA_SEARCH_PATH +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.version_utils import get_fix_version_variants, normalize_fix_version from ymir.tools.base import CloneableTool as Tool from ymir.tools.constants import AIOHTTP_TIMEOUT @@ -318,7 +319,7 @@ async def _check_zstream_clones_shipped( logger.info(f"Found {len(issues)} clone(s) for {cve_id} in component {component}") - rhel_config = await load_rhel_config() + rhel_config = await fetch_rhel_streams_snapshot() current_z_streams = rhel_config.get("current_z_streams", {}) upcoming_z_streams = rhel_config.get("upcoming_z_streams", {}) maintenance_majors = _get_maintenance_majors(rhel_config) @@ -453,7 +454,7 @@ async def _run( target_version = fix_versions[0].get("name", "") - rhel_config = await load_rhel_config() + rhel_config = await fetch_rhel_streams_snapshot() target_version = normalize_fix_version(target_version, rhel_config) if re.match(r"^rhel-\d+\.\d+$", target_version.lower()): diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 8529954f..9bd5b477 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -388,7 +388,7 @@ async def test_check_zstream_clones_all_closed(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -421,7 +421,7 @@ async def test_check_zstream_clones_one_shipped_one_open(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -445,7 +445,7 @@ async def test_check_zstream_clones_none_shipped(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -479,7 +479,7 @@ async def test_check_zstream_clones_eus_filtered_out(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -503,7 +503,7 @@ async def test_check_zstream_clones_maintenance_filtered_out(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -528,7 +528,7 @@ async def test_check_zstream_clones_closed_wontdo_ignored(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -561,7 +561,7 @@ async def test_check_zstream_clones_wontdo_with_pending(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -586,7 +586,7 @@ async def test_check_zstream_clones_stale_ystream_fixversion(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -660,7 +660,7 @@ async def test_eligibility_ystream_any_clone_shipped(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( @@ -682,7 +682,7 @@ async def test_eligibility_ystream_clones_pending(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( @@ -705,7 +705,7 @@ async def test_eligibility_ystream_low_moderate_skipped(severity): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -724,7 +724,7 @@ async def test_eligibility_ystream_no_cve_id(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -741,7 +741,7 @@ async def test_eligibility_ystream_no_component(): severity="Critical", ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -757,7 +757,7 @@ async def test_eligibility_embargoed(): embargo="True", ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -773,7 +773,7 @@ async def test_eligibility_zstream(): severity="moderate", ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return( { "current_y_streams": {"9": "rhel-9.8"}, @@ -798,7 +798,7 @@ async def test_eligibility_maintenance_zstream_no_dependency_check(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("load_rhel_config").and_return( + flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").never() diff --git a/ymir/tools/unprivileged/version_mapper.py b/ymir/tools/unprivileged/version_mapper.py index 8914865e..99714381 100644 --- a/ymir/tools/unprivileged/version_mapper.py +++ b/ymir/tools/unprivileged/version_mapper.py @@ -5,7 +5,7 @@ from beeai_framework.tools import JSONToolOutput, ToolRunOptions from pydantic import BaseModel, Field -from ymir.common.config import load_rhel_config +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.version_utils import current_z_streams_override, parse_rhel_version from ymir.tools.base import CloneableTool as Tool @@ -67,7 +67,7 @@ async def _run( major_version = tool_input.major_version major_version_str = str(major_version) - config = await load_rhel_config() + config = await fetch_rhel_streams_snapshot() override = current_z_streams_override.get() overridden_zstream = override.get(major_version_str) if override else None From 08f41dec49b8f6328df914ea917715bf7d634dd5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:56:14 +0000 Subject: [PATCH 08/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/common/version_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index 59426440..c0605c0a 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -8,6 +8,8 @@ import contextvars import re +from ymir.common.product_pages import fetch_rhel_streams_snapshot + current_z_streams_override: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar( "current_z_streams_override", default=None ) @@ -127,8 +129,6 @@ async def is_older_zstream( if current_z_streams is None: current_z_streams = current_z_streams_override.get() if current_z_streams is None: - from ymir.common.product_pages import fetch_rhel_streams_snapshot - config = await fetch_rhel_streams_snapshot() current_z_streams = config.get("current_z_streams", {}) From 416ce2ce4dc1934966aadb470cd920187d0237e4 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Tue, 5 May 2026 14:03:35 +0200 Subject: [PATCH 09/12] Pulling recent changes --- ymir/common/product_pages.py | 4 ++-- ymir/common/tests/unit/test_product_pages.py | 2 +- ymir/common/version_utils.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py index 30696f11..c1198f01 100644 --- a/ymir/common/product_pages.py +++ b/ymir/common/product_pages.py @@ -2,7 +2,7 @@ Product Pages helpers for RHEL y-stream and z-stream labels. This module authenticates to the internal Product Pages API (Kerberos via -``init_kerberos_ticket`` from ``ymir.common.utils``, then HTTP SPNEGO via +``init_kerberos_ticket`` from ``ymir.common.base_utils``, then HTTP SPNEGO via ``requests-gssapi``) and derives current y-streams, current z-streams, and upcoming z-streams from active releases and GA/ZStream release metadata. @@ -22,7 +22,7 @@ import requests_gssapi from beeai_framework.tools import ToolError -from ymir.common.utils import KerberosError, init_kerberos_ticket +from ymir.common.base_utils import KerberosError, init_kerberos_ticket _PLAIN_SHORTNAME_RE = re.compile(r"^rhel-(\d+)\.(\d+)$") _GA_ZSTREAM_RE = re.compile(r"\(GA\/ZStream\)") diff --git a/ymir/common/tests/unit/test_product_pages.py b/ymir/common/tests/unit/test_product_pages.py index 46819df4..aa4e2821 100644 --- a/ymir/common/tests/unit/test_product_pages.py +++ b/ymir/common/tests/unit/test_product_pages.py @@ -14,7 +14,7 @@ from beeai_framework.tools import ToolError import ymir.common.product_pages as pp -from ymir.common.utils import KerberosError +from ymir.common.base_utils import KerberosError async def _fake_init_kerberos_ok() -> str: diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index c0605c0a..dd373f81 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -129,6 +129,7 @@ async def is_older_zstream( if current_z_streams is None: current_z_streams = current_z_streams_override.get() if current_z_streams is None: + from ymir.common.product_pages import fetch_rhel_streams_snapshot config = await fetch_rhel_streams_snapshot() current_z_streams = config.get("current_z_streams", {}) From 6a6bee5e23c84b53dac7a44a89b200e70156ea71 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:48:24 +0000 Subject: [PATCH 10/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/agents/triage_agent.py | 1 + ymir/common/version_utils.py | 1 + 2 files changed, 2 insertions(+) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 9dd1252c..8012ec84 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -54,6 +54,7 @@ from ymir.common.models import ( TriageOutputSchema as OutputSchema, ) +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.version_utils import is_older_zstream, normalize_fix_version, parse_rhel_version from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.tools.unprivileged.commands import RunShellCommandTool diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index dd373f81..80a0922c 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -130,6 +130,7 @@ async def is_older_zstream( current_z_streams = current_z_streams_override.get() if current_z_streams is None: from ymir.common.product_pages import fetch_rhel_streams_snapshot + config = await fetch_rhel_streams_snapshot() current_z_streams = config.get("current_z_streams", {}) From 53bb34de758a33885cea70afb07efe155f0f6a01 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 08:48:53 +0000 Subject: [PATCH 11/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/agents/triage_agent.py | 1 - ymir/common/version_utils.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 8012ec84..44ac58f2 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -56,7 +56,6 @@ ) from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.version_utils import is_older_zstream, normalize_fix_version, parse_rhel_version -from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.tools.unprivileged.commands import RunShellCommandTool ## UpstreamSearchTool is currently unmaintained and disabled. diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index 80a0922c..59426440 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -8,8 +8,6 @@ import contextvars import re -from ymir.common.product_pages import fetch_rhel_streams_snapshot - current_z_streams_override: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar( "current_z_streams_override", default=None ) From 63e61ff8f0cd588e01be26b6546d09151c4e4416 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Fri, 29 May 2026 13:10:11 +0200 Subject: [PATCH 12/12] Revert Product Pages integration; keep library only --- ymir/agents/triage_agent.py | 6 ++-- ymir/common/product_pages.py | 21 ++++++------ ymir/common/tests/unit/test_product_pages.py | 18 ----------- ymir/common/version_utils.py | 7 ++-- ymir/tools/privileged/jira.py | 7 ++-- ymir/tools/privileged/tests/unit/test_jira.py | 32 +++++++++---------- ymir/tools/unprivileged/version_mapper.py | 4 +-- 7 files changed, 37 insertions(+), 58 deletions(-) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 44ac58f2..08e34f4c 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -35,6 +35,7 @@ run_tool, ) from ymir.common.base_utils import fix_await, redis_client +from ymir.common.config import load_rhel_config from ymir.common.constants import JiraLabels, RedisQueues from ymir.common.models import ( ApplicabilityResult, @@ -54,7 +55,6 @@ from ymir.common.models import ( TriageOutputSchema as OutputSchema, ) -from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.version_utils import is_older_zstream, normalize_fix_version, parse_rhel_version from ymir.tools.unprivileged.commands import RunShellCommandTool @@ -135,7 +135,7 @@ async def _map_version_to_branch( major_version, minor_version, is_zstream = parsed # Load rhel-config to check which major versions have Y-stream mappings - config = await fetch_rhel_streams_snapshot() + config = await load_rhel_config() y_streams = config.get("current_y_streams", {}) # Check if this is an older z-stream than the current one @@ -832,7 +832,7 @@ async def run_triage_analysis(state): # Normalize stale Y-stream fixVersion (e.g. rhel-9.8 → rhel-9.8.z after GA) if hasattr(state.triage_result.data, "fix_version") and state.triage_result.data.fix_version: - rhel_config = await fetch_rhel_streams_snapshot() + rhel_config = await load_rhel_config() state.triage_result.data.fix_version = normalize_fix_version( state.triage_result.data.fix_version, rhel_config ) diff --git a/ymir/common/product_pages.py b/ymir/common/product_pages.py index c1198f01..f95899bc 100644 --- a/ymir/common/product_pages.py +++ b/ymir/common/product_pages.py @@ -1,11 +1,13 @@ """ Product Pages helpers for RHEL y-stream and z-stream labels. -This module authenticates to the internal Product Pages API (Kerberos via -``init_kerberos_ticket`` from ``ymir.common.base_utils``, then HTTP SPNEGO via +This module queries the internal Product Pages API (HTTP SPNEGO via ``requests-gssapi``) and derives current y-streams, current z-streams, and upcoming z-streams from active releases and GA/ZStream release metadata. +Callers must ensure a valid Kerberos ticket is available before invoking +``fetch_rhel_streams_snapshot``; this module does not initialize Kerberos. + Public API: ``await fetch_rhel_streams_snapshot()`` (async coroutine). Blocking HTTP (``requests``) runs in a thread pool so the event loop is not blocked. Everything else in this module is an implementation detail. @@ -22,8 +24,6 @@ import requests_gssapi from beeai_framework.tools import ToolError -from ymir.common.base_utils import KerberosError, init_kerberos_ticket - _PLAIN_SHORTNAME_RE = re.compile(r"^rhel-(\d+)\.(\d+)$") _GA_ZSTREAM_RE = re.compile(r"\(GA\/ZStream\)") @@ -270,18 +270,17 @@ async def fetch_rhel_streams_snapshot() -> dict[str, dict[str, str]]: Uses GSSAPI session authentication, then loads active releases and GA/ZStream-filtered releases to compute stream labels. + Requires a valid Kerberos ticket in the environment; this module does not + initialize Kerberos itself. + Returns: Dict with keys ``current_y_streams``, ``current_z_streams``, and ``upcoming_z_streams``; each value maps major version strings to shortname labels. Raises: - ToolError: On Kerberos initialization failure, non-success HTTP responses, - timeouts, transport errors (``requests.RequestException``), invalid - JSON, or unexpected response shape (``ValueError``). + ToolError: On non-success HTTP responses, timeouts, transport errors + (``requests.RequestException``), invalid JSON, or unexpected response + shape (``ValueError``). """ - try: - await init_kerberos_ticket() - except KerberosError as e: - raise ToolError(f"Failed to initialize Kerberos ticket: {e}") from e return await asyncio.to_thread(_fetch_rhel_streams_snapshot_sync) diff --git a/ymir/common/tests/unit/test_product_pages.py b/ymir/common/tests/unit/test_product_pages.py index aa4e2821..14413417 100644 --- a/ymir/common/tests/unit/test_product_pages.py +++ b/ymir/common/tests/unit/test_product_pages.py @@ -14,15 +14,6 @@ from beeai_framework.tools import ToolError import ymir.common.product_pages as pp -from ymir.common.base_utils import KerberosError - - -async def _fake_init_kerberos_ok() -> str: - return "user@EXAMPLE.COM" - - -async def _fake_init_kerberos_fail() -> None: - raise KerberosError("ticket unavailable") class _JsonResponse: @@ -167,19 +158,10 @@ def post(self, url: str, **kwargs: object) -> _JsonResponse: pp._fetch_rhel_streams_snapshot_sync() -@pytest.mark.asyncio -async def test_fetch_rhel_streams_snapshot_kerberos_fails(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(pp, "init_kerberos_ticket", _fake_init_kerberos_fail) - - with pytest.raises(ToolError, match="Failed to initialize Kerberos ticket"): - await pp.fetch_rhel_streams_snapshot() - - @pytest.mark.asyncio async def test_fetch_rhel_streams_snapshot_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("REDHAT_IT_CA_BUNDLE", raising=False) monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) - monkeypatch.setattr(pp, "init_kerberos_ticket", _fake_init_kerberos_ok) active = [{"shortname": "rhel-10.0"}] z_rows: list[dict] = [] diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index 59426440..fcd86433 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -118,8 +118,7 @@ async def is_older_zstream( Args: version_or_branch: Fix version string or dist-git branch name current_z_streams: Dict mapping major version to current z-stream - (e.g., {"9": "rhel-9.7.z"}). If None, loaded from Product Pages via - ``fetch_rhel_streams_snapshot``. + (e.g., {"9": "rhel-9.7.z"}). If None, loaded from rhel-config.json. Returns: True if the version targets an older z-stream, False otherwise. @@ -127,9 +126,9 @@ async def is_older_zstream( if current_z_streams is None: current_z_streams = current_z_streams_override.get() if current_z_streams is None: - from ymir.common.product_pages import fetch_rhel_streams_snapshot + from ymir.common.config import load_rhel_config - config = await fetch_rhel_streams_snapshot() + config = await load_rhel_config() current_z_streams = config.get("current_z_streams", {}) # Try parsing as a z-stream version string first (rhel-9.7.z) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 7cb4cea0..8d92c5cf 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -17,10 +17,9 @@ ) from pydantic import BaseModel, Field -from ymir.common import CVEEligibilityResult, TriageEligibility +from ymir.common import CVEEligibilityResult, TriageEligibility, load_rhel_config from ymir.common.base_utils import get_jira_auth_headers from ymir.common.constants import JIRA_SEARCH_PATH -from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.version_utils import get_fix_version_variants, normalize_fix_version from ymir.tools.base import CloneableTool as Tool from ymir.tools.constants import AIOHTTP_TIMEOUT @@ -319,7 +318,7 @@ async def _check_zstream_clones_shipped( logger.info(f"Found {len(issues)} clone(s) for {cve_id} in component {component}") - rhel_config = await fetch_rhel_streams_snapshot() + rhel_config = await load_rhel_config() current_z_streams = rhel_config.get("current_z_streams", {}) upcoming_z_streams = rhel_config.get("upcoming_z_streams", {}) maintenance_majors = _get_maintenance_majors(rhel_config) @@ -454,7 +453,7 @@ async def _run( target_version = fix_versions[0].get("name", "") - rhel_config = await fetch_rhel_streams_snapshot() + rhel_config = await load_rhel_config() target_version = normalize_fix_version(target_version, rhel_config) if re.match(r"^rhel-\d+\.\d+$", target_version.lower()): diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 9bd5b477..8529954f 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -388,7 +388,7 @@ async def test_check_zstream_clones_all_closed(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -421,7 +421,7 @@ async def test_check_zstream_clones_one_shipped_one_open(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -445,7 +445,7 @@ async def test_check_zstream_clones_none_shipped(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -479,7 +479,7 @@ async def test_check_zstream_clones_eus_filtered_out(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -503,7 +503,7 @@ async def test_check_zstream_clones_maintenance_filtered_out(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -528,7 +528,7 @@ async def test_check_zstream_clones_closed_wontdo_ignored(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -561,7 +561,7 @@ async def test_check_zstream_clones_wontdo_with_pending(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -586,7 +586,7 @@ async def test_check_zstream_clones_stale_ystream_fixversion(): flexmock(SearchJiraIssuesTool).should_receive("run").and_return( _create_async_return(JSONToolOutput(result=search_result)) ).once() - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -660,7 +660,7 @@ async def test_eligibility_ystream_any_clone_shipped(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( @@ -682,7 +682,7 @@ async def test_eligibility_ystream_clones_pending(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( @@ -705,7 +705,7 @@ async def test_eligibility_ystream_low_moderate_skipped(severity): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -724,7 +724,7 @@ async def test_eligibility_ystream_no_cve_id(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -741,7 +741,7 @@ async def test_eligibility_ystream_no_component(): severity="Critical", ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -757,7 +757,7 @@ async def test_eligibility_embargoed(): embargo="True", ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() @@ -773,7 +773,7 @@ async def test_eligibility_zstream(): severity="moderate", ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return( { "current_y_streams": {"9": "rhel-9.8"}, @@ -798,7 +798,7 @@ async def test_eligibility_maintenance_zstream_no_dependency_check(): components=[{"name": "curl"}], ) flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) - flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( + flexmock(jira_tools).should_receive("load_rhel_config").and_return( _create_async_return(RHEL_CONFIG) ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").never() diff --git a/ymir/tools/unprivileged/version_mapper.py b/ymir/tools/unprivileged/version_mapper.py index 99714381..8914865e 100644 --- a/ymir/tools/unprivileged/version_mapper.py +++ b/ymir/tools/unprivileged/version_mapper.py @@ -5,7 +5,7 @@ from beeai_framework.tools import JSONToolOutput, ToolRunOptions from pydantic import BaseModel, Field -from ymir.common.product_pages import fetch_rhel_streams_snapshot +from ymir.common.config import load_rhel_config from ymir.common.version_utils import current_z_streams_override, parse_rhel_version from ymir.tools.base import CloneableTool as Tool @@ -67,7 +67,7 @@ async def _run( major_version = tool_input.major_version major_version_str = str(major_version) - config = await fetch_rhel_streams_snapshot() + config = await load_rhel_config() override = current_z_streams_override.get() overridden_zstream = override.get(major_version_str) if override else None