From 44e306d316b195a4fbaab804b740ee647f350859 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Mon, 27 Apr 2026 13:07:00 +0200 Subject: [PATCH 01/11] 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 d458b11c..28758011 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 5530d297..24d01a15 100644 --- a/ymir/common/requirements.txt +++ b/ymir/common/requirements.txt @@ -1,2 +1,4 @@ # Dependencies specific to ymir-common redis>=6.4.0 +requests>=2.32.0 +requests-gssapi>=1.3.0 From ec69762e0d281c44c03a9dc8d81b7374143c9fa0 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/11] [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 441380d12c5a8d59cd488a15c361a100318cfb65 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Mon, 27 Apr 2026 14:28:53 +0200 Subject: [PATCH 03/11] 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 eeda027079d453ea0e27160216bd2b8b9ba70c9e 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/11] [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 15370618d17cb6ff0a88e2f51440c37e149ab675 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Wed, 29 Apr 2026 14:55:12 +0200 Subject: [PATCH 05/11] 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 f9a56850f01b99c827a9f6f316b872589f8c4c50 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/11] [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 cd0c0a9aebe8d53d4f3561665906b522866705b9 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Tue, 5 May 2026 13:40:52 +0200 Subject: [PATCH 07/11] Replacing the load_rhel_config function call --- ymir/agents/triage_agent.py | 4 ++-- ymir/common/version_utils.py | 7 +++--- ymir/tools/privileged/jira.py | 7 +++--- ymir/tools/privileged/tests/unit/test_jira.py | 24 +++++++++---------- ymir/tools/unprivileged/version_mapper.py | 4 ++-- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 610a9349..151ce437 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -28,7 +28,6 @@ mcp_tools, run_tool, ) -from ymir.common.config import load_rhel_config from ymir.common.constants import JiraLabels, RedisQueues from ymir.common.models import ( ClarificationNeededData, @@ -46,6 +45,7 @@ from ymir.common.models import ( TriageOutputSchema as OutputSchema, ) +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.utils import fix_await, redis_client from ymir.common.version_utils import is_older_zstream, parse_rhel_version from ymir.tools.unprivileged.commands import RunShellCommandTool @@ -106,7 +106,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", {}) current_z_streams = config.get("current_z_streams", {}) diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index 6065f5dd..581c9346 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -69,15 +69,16 @@ 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. """ 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 85ce1e7c..3f146641 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -18,8 +18,9 @@ ) from pydantic import BaseModel, Field -from ymir.common import CVEEligibilityResult, TriageEligibility, load_rhel_config +from ymir.common import CVEEligibilityResult, TriageEligibility from ymir.common.constants import AIOHTTP_TIMEOUT, JIRA_SEARCH_PATH +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.utils import get_jira_auth_headers from ymir.common.version_utils import parse_rhel_version @@ -317,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 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) @@ -464,7 +465,7 @@ async def _run( ).model_dump() ) - rhel_config = await load_rhel_config() + rhel_config = await fetch_rhel_streams_snapshot() upcoming_z_streams = rhel_config.get("upcoming_z_streams", {}) current_z_streams = rhel_config.get("current_z_streams", {}) latest_z_streams = current_z_streams | upcoming_z_streams diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 4db6591a..6fcc68e0 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() @@ -730,7 +730,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"}, @@ -754,9 +754,9 @@ async def test_eligibility_maintenance_zstream_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() + ).twice() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( "CVE-2025-12345", "curl", "RHEL-12345" ).and_return(_create_async_return((True, []))).once() @@ -776,9 +776,9 @@ async def test_eligibility_maintenance_zstream_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() + ).twice() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( "CVE-2025-12345", "curl", "RHEL-12345" ).and_return(_create_async_return((False, ["RHEL-555"]))).once() diff --git a/ymir/tools/unprivileged/version_mapper.py b/ymir/tools/unprivileged/version_mapper.py index 5d560ba8..9f7f2dc6 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, Tool, ToolRunOptions from pydantic import BaseModel, Field -from ymir.common.config import load_rhel_config +from ymir.common.product_pages import fetch_rhel_streams_snapshot class VersionMapperInput(BaseModel): @@ -65,7 +65,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() upcoming_z_streams = config.get("upcoming_z_streams", {}) current_z_streams = config.get("current_z_streams", {}) From b74c5f2a4b1be964fb6afa5a13ac256a62f57f85 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/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/common/version_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index a6b3a91b..809a301e 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -127,8 +127,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.config import load_rhel_config - config = await fetch_rhel_streams_snapshot() current_z_streams = config.get("current_z_streams", {}) From f51e21004b23fa520c4bccc8de9cebda0c0c611d Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Tue, 5 May 2026 14:03:35 +0200 Subject: [PATCH 09/11] Pulling recent changes --- ymir/agents/triage_agent.py | 4 ++-- ymir/common/product_pages.py | 4 ++-- ymir/common/tests/unit/test_product_pages.py | 2 +- ymir/common/version_utils.py | 1 + ymir/tools/privileged/jira.py | 5 +++-- ymir/tools/privileged/tests/unit/test_jira.py | 14 +++++++------- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 25f27246..c5360ca7 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -33,7 +33,7 @@ run_tool, ) from ymir.common.base_utils import fix_await, redis_client -from ymir.common.config import load_rhel_config +from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.constants import JiraLabels, RedisQueues from ymir.common.models import ( ApplicabilityResult, @@ -736,7 +736,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/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 809a301e..8e3bb160 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -127,6 +127,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", {}) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 48dc1e60..8ae18082 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -18,9 +18,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, parse_rhel_version from ymir.tools.constants import AIOHTTP_TIMEOUT @@ -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 56e7b9b2..d4fb6c38 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -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() From 9c15e311df420c28c721eae03c35ffc83cf8ec17 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/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/agents/triage_agent.py | 2 +- ymir/common/version_utils.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index c5360ca7..d551b621 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -33,7 +33,6 @@ run_tool, ) from ymir.common.base_utils import fix_await, redis_client -from ymir.common.product_pages import fetch_rhel_streams_snapshot from ymir.common.constants import JiraLabels, RedisQueues from ymir.common.models import ( ApplicabilityResult, @@ -53,6 +52,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.tools.unprivileged.commands import RunShellCommandTool from ymir.tools.unprivileged.upstream_search import UpstreamSearchTool diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index 8e3bb160..59426440 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -128,6 +128,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 190e87737cad058319e1c07c054291014355cbe1 Mon Sep 17 00:00:00 2001 From: Michal Ruprich Date: Tue, 5 May 2026 16:17:54 +0200 Subject: [PATCH 11/11] Fixing tests --- ymir/tools/privileged/tests/unit/test_jira.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index d4fb6c38..1f1c3571 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -799,7 +799,7 @@ async def test_eligibility_maintenance_zstream_clone_shipped(): flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) - ).twice() + ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( "CVE-2025-12345", "curl", "RHEL-12345" ).and_return(_create_async_return((True, []))).once() @@ -821,7 +821,7 @@ async def test_eligibility_maintenance_zstream_clones_pending(): flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) flexmock(jira_tools).should_receive("fetch_rhel_streams_snapshot").and_return( _create_async_return(RHEL_CONFIG) - ).twice() + ).once() flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( "CVE-2025-12345", "curl", "RHEL-12345" ).and_return(_create_async_return((False, ["RHEL-555"]))).once()