diff --git a/sssd_test_framework/hosts/base.py b/sssd_test_framework/hosts/base.py index 431dd23e..2bd96920 100644 --- a/sssd_test_framework/hosts/base.py +++ b/sssd_test_framework/hosts/base.py @@ -4,7 +4,6 @@ import contextlib import csv -import re from typing import Any, Generator import ldap @@ -14,7 +13,7 @@ from pytest_mh.utils.services import SystemdServices from ..config import SSSDMultihostDomain -from ..misc import retry +from ..misc import parse_package_version, retry __all__ = [ "BaseHost", @@ -207,6 +206,7 @@ def __init__(self, *args, **kwargs) -> None: self.svc: SystemdServices = SystemdServices(self) self._os_release: dict = {} self._distro_name: str = "unknown" + self._distro_id: str = "unknown" self._distro_major: int = 0 self._distro_minor: int = 0 @@ -219,6 +219,8 @@ def _distro_information(self): self._os_release = dict(csv.reader([x for x in os_release.splitlines() if x], delimiter="=")) if "NAME" in self._os_release: self._distro_name = self._os_release["NAME"] + if "ID" in self._os_release: + self._distro_id = self._os_release["ID"] if "VERSION_ID" not in self._os_release: return if "." in self._os_release["VERSION_ID"]: @@ -240,6 +242,18 @@ def distro_name(self) -> str: self._distro_information() return self._distro_name + @property + def distro_id(self) -> str: + """ + Host distribution id from ``/etc/os-release`` ``ID`` (e.g. ``rhel``, ``centos``, ``fedora``). + + :return: Distribution id or ``"unknown"`` + :rtype: str + """ + if not self._os_release: + self._distro_information() + return self._distro_id + @property def distro_major(self) -> int: """ @@ -298,23 +312,12 @@ def get_package_version(self, package: str = "sssd", raise_on_error: bool = True raise OSError(f"Package {package} not found!") return vers - v_match = re.match( - r"([0-9]+)(?:\.)?([0-9]+)?(?:\.)?([0-9]+)?(?:~)?([a-z0-9]+)?[\.-]?([0-9]+)?(?:\.)?(.*)?", - ver, - re.IGNORECASE, - ) - - if v_match is None: + try: + return parse_package_version(ver.strip()) + except ValueError: if raise_on_error: raise OSError(f"Package {package} version could not be parsed!") return vers - vers["major"] = int(v_match.group(1)) - vers["minor"] = int(v_match.group(2)) if v_match.group(2) else 0 - vers["patch"] = int(v_match.group(3)) if v_match.group(3) else 0 - vers["prerelease"] = v_match.group(4) if v_match.group(4) else "" - vers["update"] = int(v_match.group(5)) if v_match.group(5) else 0 - vers["release"] = v_match.group(6) if v_match.group(6) else "" - return vers def compare_package_version(self, other_version: dict, package: str = "sssd") -> int: """ @@ -347,6 +350,29 @@ def version_tuple(ver): else: return 0 + def package_version_at_least( + self, + version: str | dict, + package: str = "sssd", + *, + distro_ids: tuple[str, ...] | list[str] | None = None, + ) -> bool: + """ + Return True if the installed package version is greater than or equal to ``version``. + + :param version: Minimum version as a string (``"2.13.1-3"``) or version dict. + :param package: Package name (default: sssd). + :param distro_ids: If set (e.g. ``("rhel",)``), only hosts whose + ``/etc/os-release`` ``ID`` is in this list are version-checked. + Other distros (CentOS upstream PRCI, Fedora, …) are treated as + meeting the requirement. ``None`` compares on every distro. + :return: Whether the installed package meets the minimum version. + """ + min_version = parse_package_version(version) if isinstance(version, str) else version + if distro_ids is not None and self.distro_id not in distro_ids: + return True + return self.compare_package_version(min_version, package) >= 0 + @contextlib.contextmanager def selinux_permissive_for_test(self) -> Generator[None, None, None]: """Temporarily set SELinux to permissive for tests.""" diff --git a/sssd_test_framework/markers.py b/sssd_test_framework/markers.py index 67fdc87e..f1f0419e 100644 --- a/sssd_test_framework/markers.py +++ b/sssd_test_framework/markers.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +from typing import Callable import pytest from pytest_mh import MultihostItemData, Topology @@ -25,6 +26,44 @@ def pytest_configure(config: pytest.Config): ) +def require_sssd_version( + version: str, + *, + distro_ids: tuple[str, ...] | list[str] | None = ("rhel",), + package: str = "sssd", +) -> Callable[..., bool]: + """ + Build a condition for ``@pytest.mark.require``. + + Skips the test when the client's installed package is older than ``version``. + + By default the check applies only on RHEL (``/etc/os-release`` ``ID=rhel``): + downstream gating, nightlies, and signoff. Upstream PRCI on CentOS, Fedora, + and other distros are not skipped. Pass ``distro_ids=None`` to compare on + every distro. + + Example:: + + from sssd_test_framework.markers import require_sssd_version + + @pytest.mark.require( + require_sssd_version("2.13.1-3", distro_ids=("rhel",)), + "Needs sssd >= 2.13.1-3 on RHEL (RHEL-248164)", + ) + def test_something(client): + ... + """ + + def _check(client) -> bool: + return client.host.package_version_at_least( + version, + package=package, + distro_ids=distro_ids, + ) + + return _check + + def builtwith(item: pytest.Function, requirements: dict[str, str], **kwargs: BaseRole): def value_error(msg: str) -> ValueError: return ValueError(f"{item.nodeid}::{item.originalname}: @pytest.mark.builtwith: {msg}") diff --git a/sssd_test_framework/misc/__init__.py b/sssd_test_framework/misc/__init__.py index ed539072..31ba51fa 100644 --- a/sssd_test_framework/misc/__init__.py +++ b/sssd_test_framework/misc/__init__.py @@ -4,6 +4,7 @@ import ipaddress import itertools +import re from functools import wraps from time import sleep from typing import Any, Callable, ParamSpec, TypeVar @@ -379,3 +380,34 @@ def attrs_ad_parse(output: str) -> dict[str, list[str]]: """ lines = [line.strip() for line in (output or "").splitlines() if line.strip()] return attrs_parse(lines) + + +def parse_package_version(version: str) -> dict[str, Any]: + """ + Parse a ``VERSION-RELEASE`` style string into the dict used by + :meth:`sssd_test_framework.hosts.base.BaseLinuxHost.compare_package_version`. + + Examples: ``"2.13.1-3"``, ``"2.13.1-2.el10"``, ``"2.9.9"``. + + :param version: Version string to parse. + :type version: str + :return: Dictionary with major, minor, patch, prerelease, update, release. + :rtype: dict[str, Any] + :raises ValueError: If the version string cannot be parsed. + """ + match = re.match( + r"([0-9]+)(?:\.)?([0-9]+)?(?:\.)?([0-9]+)?(?:~)?([a-z0-9]+)?[\.-]?([0-9]+)?(?:\.)?(.*)?", + version, + re.IGNORECASE, + ) + if match is None: + raise ValueError(f"Invalid package version string: {version!r}") + + return { + "major": int(match.group(1)), + "minor": int(match.group(2)) if match.group(2) else 0, + "patch": int(match.group(3)) if match.group(3) else 0, + "prerelease": match.group(4) if match.group(4) else "", + "update": int(match.group(5)) if match.group(5) else 0, + "release": match.group(6) if match.group(6) else "", + } diff --git a/tests/test_misc.py b/tests/test_misc.py index ecbb9a7f..fbfabb49 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -17,6 +17,7 @@ ip_to_ptr, ip_version, parse_ldif, + parse_package_version, retry, seconds_to_timespan, to_list, @@ -471,3 +472,33 @@ def test_get_attr(get_attr_sample_data, key, expected, check_membership): def test_attrs_ad_parse(value, expected): value = textwrap.dedent(value).strip() assert attrs_ad_parse(value) == expected + + +@pytest.mark.parametrize( + "version,expected", + [ + ( + "2.13.1-3", + {"major": 2, "minor": 13, "patch": 1, "prerelease": "", "update": 3, "release": ""}, + ), + ( + "2.13.1-2.el10", + {"major": 2, "minor": 13, "patch": 1, "prerelease": "", "update": 2, "release": "el10"}, + ), + ( + "2.13.1-3.el10_3", + {"major": 2, "minor": 13, "patch": 1, "prerelease": "", "update": 3, "release": "el10_3"}, + ), + ( + "2.9.9", + {"major": 2, "minor": 9, "patch": 9, "prerelease": "", "update": 0, "release": ""}, + ), + ], +) +def test_parse_package_version(version, expected): + assert parse_package_version(version) == expected + + +def test_parse_package_version__invalid(): + with pytest.raises(ValueError, match="Invalid package version"): + parse_package_version("not-a-version")