From f4d697ae6a3cee54513c7c643f34c990273439b7 Mon Sep 17 00:00:00 2001 From: Ales Raszka Date: Tue, 30 Apr 2024 17:00:51 +0200 Subject: [PATCH] Move static check from common to isv The checks are usually skipped by support to it doesn't make sense to have them in isv pipelines. Signed-off-by: Ales Raszka --- .../static_tests/common/bundle.py | 118 +------------ .../static_tests/community/bundle.py | 117 ++++++++++++- .../tests/static_tests/common/test_bundle.py | 158 +----------------- .../static_tests/community/test_bundle.py | 151 +++++++++++++++++ 4 files changed, 270 insertions(+), 274 deletions(-) diff --git a/operator-pipeline-images/operatorcert/static_tests/common/bundle.py b/operator-pipeline-images/operatorcert/static_tests/common/bundle.py index cf9c41f40..0abfa95ed 100644 --- a/operator-pipeline-images/operatorcert/static_tests/common/bundle.py +++ b/operator-pipeline-images/operatorcert/static_tests/common/bundle.py @@ -1,17 +1,9 @@ """A common test suite for operator bundles""" from collections.abc import Iterator -from typing import Any, List from operator_repo import Bundle -from operator_repo.checks import CheckResult, Fail, Warn -from operatorcert import utils - - -class GraphLoopException(Exception): - """ - Exception raised when a loop is detected in the update graph - """ +from operator_repo.checks import CheckResult, Warn def check_operator_name(bundle: Bundle) -> Iterator[CheckResult]: @@ -31,111 +23,3 @@ def check_operator_name(bundle: Bundle) -> Iterator[CheckResult]: "To fix this issue define the annotation in " "'metadata/annotations.yaml' file that matches the CSV name." ) - - -def check_upgrade_graph_loop(bundle: Bundle) -> Iterator[CheckResult]: - """ - Detect loops in the upgrade graph - - Example: - - Channel beta: A -> B -> C -> B - - Args: - bundle (Bundle): Operator bundle - - Yields: - Iterator[CheckResult]: Failure if a loop is detected - """ - all_channels: set[str] = set(bundle.channels) - if bundle.default_channel is not None: - all_channels.add(bundle.default_channel) - operator = bundle.operator - for channel in sorted(all_channels): - visited: List[Bundle] = [] - try: - channel_bundles = operator.channel_bundles(channel) - try: - graph = operator.update_graph(channel) - except (NotImplementedError, ValueError) as exc: - yield Fail(str(exc)) - return - follow_graph(graph, channel_bundles[0], visited) - except GraphLoopException as exc: - yield Fail(str(exc)) - - -def follow_graph(graph: Any, bundle: Bundle, visited: List[Bundle]) -> List[Bundle]: - """ - Follow operator upgrade graph and raise exception if loop is detected - - Args: - graph (Any): Operator update graph - bundle (Bundle): Current bundle that started the graph traversal - visited (List[Bundle]): List of bundles visited so far - - Raises: - GraphLoopException: Graph loop detected - - Returns: - List[Bundle]: List of bundles visited so far - """ - if bundle in visited: - visited.append(bundle) - raise GraphLoopException(f"Upgrade graph loop detected for bundle: {visited}") - if bundle not in graph: - return visited - - visited.append(bundle) - next_bundles = graph[bundle] - for next_bundle in next_bundles: - visited_copy = visited.copy() - follow_graph(graph, next_bundle, visited_copy) - return visited - - -def check_replaces_availability(bundle: Bundle) -> Iterator[CheckResult]: - """ - Check if the current bundle and the replaced bundle support the same OCP versions - - Args: - bundle (Bundle): Operator bundle - - Yields: - Iterator[CheckResult]: Failure if the version of the replaced bundle - does not match with the current bundle - """ - - replaces = bundle.csv.get("spec", {}).get("replaces") - if not replaces: - return - delimiter = ".v" if ".v" in replaces else "." - replaces_version = replaces.split(delimiter, 1)[1] - replaces_bundle = bundle.operator.bundle(replaces_version) - ocp_versions_str = bundle.annotations.get("com.redhat.openshift.versions") - replaces_ocp_version_str = replaces_bundle.annotations.get( - "com.redhat.openshift.versions" - ) - if ocp_versions_str == replaces_ocp_version_str: - # The annotations match, no need to check further - return - organization = bundle.operator.repo.config.get("organization") - - indexes = set(utils.get_ocp_supported_versions(organization, ocp_versions_str)) - replaces_indexes = set( - utils.get_ocp_supported_versions(organization, replaces_ocp_version_str) - ) - - if indexes - replaces_indexes == set(): - # The replaces bundle supports all the same versions as the current bundle - return - yield Fail( - f"Replaces bundle {replaces_bundle} {sorted(replaces_indexes)} does not support " - f"the same OCP versions as bundle {bundle} {sorted(indexes)}. In order to fix this issue, " - "align the OCP version range to match the range of the replaced bundle. " - "This can be done by setting the `com.redhat.openshift.versions` annotation in the " - "`metadata/annotations.yaml` file.\n" - f"`{bundle}` - `{ocp_versions_str}`\n" - f"`{replaces_bundle}` - `{replaces_ocp_version_str}`" - ) - yield from [] diff --git a/operator-pipeline-images/operatorcert/static_tests/community/bundle.py b/operator-pipeline-images/operatorcert/static_tests/community/bundle.py index 9c05b4db0..c33e81439 100644 --- a/operator-pipeline-images/operatorcert/static_tests/community/bundle.py +++ b/operator-pipeline-images/operatorcert/static_tests/community/bundle.py @@ -11,12 +11,13 @@ import re import subprocess from collections.abc import Iterator +from typing import Any, List from operator_repo import Bundle from operator_repo.checks import CheckResult, Fail, Warn from operator_repo.utils import lookup_dict -from semver import Version from operatorcert import utils +from semver import Version from .validations import ( validate_capabilities, @@ -54,6 +55,12 @@ } +class GraphLoopException(Exception): + """ + Exception raised when a loop is detected in the update graph + """ + + def _parse_semver(version: str) -> Version: return Version.parse(version.strip(), optional_minor_and_patch=True).replace( prerelease=None, build=None @@ -288,3 +295,111 @@ def check_api_version_constraints(bundle: Bundle) -> Iterator[CheckResult]: f"OCP version(s) {conflicting_str} conflict with " f"minKubeVersion={k8s_version_min}" ) + + +def check_upgrade_graph_loop(bundle: Bundle) -> Iterator[CheckResult]: + """ + Detect loops in the upgrade graph + + Example: + + Channel beta: A -> B -> C -> B + + Args: + bundle (Bundle): Operator bundle + + Yields: + Iterator[CheckResult]: Failure if a loop is detected + """ + all_channels: set[str] = set(bundle.channels) + if bundle.default_channel is not None: + all_channels.add(bundle.default_channel) + operator = bundle.operator + for channel in sorted(all_channels): + visited: List[Bundle] = [] + try: + channel_bundles = operator.channel_bundles(channel) + try: + graph = operator.update_graph(channel) + except (NotImplementedError, ValueError) as exc: + yield Fail(str(exc)) + return + follow_graph(graph, channel_bundles[0], visited) + except GraphLoopException as exc: + yield Fail(str(exc)) + + +def follow_graph(graph: Any, bundle: Bundle, visited: List[Bundle]) -> List[Bundle]: + """ + Follow operator upgrade graph and raise exception if loop is detected + + Args: + graph (Any): Operator update graph + bundle (Bundle): Current bundle that started the graph traversal + visited (List[Bundle]): List of bundles visited so far + + Raises: + GraphLoopException: Graph loop detected + + Returns: + List[Bundle]: List of bundles visited so far + """ + if bundle in visited: + visited.append(bundle) + raise GraphLoopException(f"Upgrade graph loop detected for bundle: {visited}") + if bundle not in graph: + return visited + + visited.append(bundle) + next_bundles = graph[bundle] + for next_bundle in next_bundles: + visited_copy = visited.copy() + follow_graph(graph, next_bundle, visited_copy) + return visited + + +def check_replaces_availability(bundle: Bundle) -> Iterator[CheckResult]: + """ + Check if the current bundle and the replaced bundle support the same OCP versions + + Args: + bundle (Bundle): Operator bundle + + Yields: + Iterator[CheckResult]: Failure if the version of the replaced bundle + does not match with the current bundle + """ + + replaces = bundle.csv.get("spec", {}).get("replaces") + if not replaces: + return + delimiter = ".v" if ".v" in replaces else "." + replaces_version = replaces.split(delimiter, 1)[1] + replaces_bundle = bundle.operator.bundle(replaces_version) + ocp_versions_str = bundle.annotations.get("com.redhat.openshift.versions") + replaces_ocp_version_str = replaces_bundle.annotations.get( + "com.redhat.openshift.versions" + ) + if ocp_versions_str == replaces_ocp_version_str: + # The annotations match, no need to check further + return + organization = bundle.operator.repo.config.get("organization") + + indexes = set(utils.get_ocp_supported_versions(organization, ocp_versions_str)) + replaces_indexes = set( + utils.get_ocp_supported_versions(organization, replaces_ocp_version_str) + ) + + if indexes - replaces_indexes == set(): + # The replaces bundle supports all the same versions as the current bundle + return + yield Fail( + f"Replaces bundle {replaces_bundle} {sorted(replaces_indexes)} does not support " + f"the same OCP versions as bundle {bundle} {sorted(indexes)}. In order to fix this issue, " + "align the OCP version range to match the range of the replaced bundle. " + "This can be done by setting the `com.redhat.openshift.versions` annotation in the " + "`metadata/annotations.yaml` file.\n" + f"`{bundle}` - `{ocp_versions_str}`\n" + f"`{replaces_bundle}` - `{replaces_ocp_version_str}`" + ) + yield from [] diff --git a/operator-pipeline-images/tests/static_tests/common/test_bundle.py b/operator-pipeline-images/tests/static_tests/common/test_bundle.py index 611f9a854..268598107 100644 --- a/operator-pipeline-images/tests/static_tests/common/test_bundle.py +++ b/operator-pipeline-images/tests/static_tests/common/test_bundle.py @@ -3,13 +3,8 @@ import pytest from operator_repo import Repo from operator_repo.checks import Fail, Warn -from operatorcert.static_tests.common.bundle import ( - check_operator_name, - check_upgrade_graph_loop, - check_replaces_availability, -) -from requests import HTTPError -from tests.utils import bundle_files, create_files, merge +from operatorcert.static_tests.common.bundle import check_operator_name +from tests.utils import bundle_files, create_files from typing import Any @@ -45,152 +40,3 @@ def test_check_operator_name( bundle = repo.operator(csv_package).bundle("0.0.1") assert set(check_operator_name(bundle)) == expected - - -@patch("operator_repo.core.Operator.config") -def test_check_upgrade_graph_loop(mock_config: MagicMock, tmp_path: Path) -> None: - mock_config.get.return_value = "replaces-mode" - create_files( - tmp_path, - bundle_files("hello", "0.0.1"), - bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}), - ) - - repo = Repo(tmp_path) - operator = repo.operator("hello") - bundle = operator.bundle("0.0.1") - is_loop = list(check_upgrade_graph_loop(bundle)) - assert is_loop == [] - - mock_config.get.return_value = "unknown-mode" - is_loop = list(check_upgrade_graph_loop(bundle)) - assert is_loop == [ - Fail("Operator(hello): unsupported updateGraph value: unknown-mode") - ] - - mock_config.get.return_value = "replaces-mode" - # Both bundles replace each other - create_files( - tmp_path, - bundle_files("hello", "0.0.1", csv={"spec": {"replaces": "hello.v0.0.2"}}), - bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}), - ) - - repo = Repo(tmp_path) - operator = repo.operator("hello") - bundle = operator.bundle("0.0.1") - is_loop = list(check_upgrade_graph_loop(bundle)) - assert len(is_loop) == 1 and isinstance(is_loop[0], Fail) - assert ( - is_loop[0].reason - == "Upgrade graph loop detected for bundle: [Bundle(hello/0.0.1), " - "Bundle(hello/0.0.2), Bundle(hello/0.0.1)]" - ) - - # Malformed .spec.replaces - create_files( - tmp_path, - bundle_files("malformed", "0.0.1", csv={"spec": {"replaces": ""}}), - ) - - repo = Repo(tmp_path) - operator = repo.operator("malformed") - bundle = operator.bundle("0.0.1") - failures = list(check_upgrade_graph_loop(bundle)) - assert len(failures) == 1 and isinstance(failures[0], Fail) - assert "Bundle(malformed/0.0.1) has invalid 'replaces' field:" in failures[0].reason - - -def test_check_replaces_availability_no_replaces( - tmp_path: Path, -) -> None: - bundle_annotation = { - "com.redhat.openshift.versions": "v4.10", - } - replaces_bundle_annotation = { - "com.redhat.openshift.versions": "v4.11", - } - create_files( - tmp_path, - bundle_files("hello", "0.0.1", annotations=replaces_bundle_annotation), - bundle_files( - "hello", - "0.0.2", - annotations=bundle_annotation, - ), - ) - - repo = Repo(tmp_path) - operator = repo.operator("hello") - bundle = operator.bundle("0.0.2") - errors = list(check_replaces_availability(bundle)) - - assert set(errors) == set() - - -@pytest.mark.parametrize( - "bundle_version_annotation,replaces_version_annotation,ocp_range,expected", - [ - pytest.param(None, None, [], set(), id="No annotations"), - pytest.param("v4.10", "v4.10", [], set(), id="Same annotations"), - pytest.param( - "v4.15", - "v4.15,v4.16", - [["v4.15", "v4.16"], ["v4.15", "v4.16"]], - set(), - id="Different annotation, versions match", - ), - pytest.param( - "v4.15", - "v4.16", - [["v4.15", "v4.16"], ["v4.16"]], - { - Fail( - "Replaces bundle Bundle(hello/0.0.1) ['v4.16'] does not support " - "the same OCP versions as bundle Bundle(hello/0.0.2) ['v4.15', 'v4.16']. " - "In order to fix this issue, align the OCP version range to match the " - "range of the replaced bundle. " - "This can be done by setting the `com.redhat.openshift.versions` annotation " - "in the `metadata/annotations.yaml` file.\n" - "`Bundle(hello/0.0.2)` - `v4.15`\n" - "`Bundle(hello/0.0.1)` - `v4.16`" - ) - }, - id="Different annotation, different version", - ), - ], -) -@patch("operatorcert.static_tests.common.bundle.utils.get_ocp_supported_versions") -def test_check_replaces_availability( - mock_get_ocp_supported_versions: MagicMock, - bundle_version_annotation: str, - replaces_version_annotation: str, - ocp_range: Any, - expected: Any, - tmp_path: Path, -) -> None: - bundle_annotation = { - "com.redhat.openshift.versions": bundle_version_annotation, - } - replaces_bundle_annotation = { - "com.redhat.openshift.versions": replaces_version_annotation, - } - create_files( - tmp_path, - bundle_files("hello", "0.0.1", annotations=replaces_bundle_annotation), - bundle_files( - "hello", - "0.0.2", - annotations=bundle_annotation, - csv={"spec": {"replaces": "hello.v0.0.1"}}, - ), - ) - - mock_get_ocp_supported_versions.side_effect = ocp_range - - repo = Repo(tmp_path) - operator = repo.operator("hello") - bundle = operator.bundle("0.0.2") - errors = list(check_replaces_availability(bundle)) - - assert set(errors) == expected diff --git a/operator-pipeline-images/tests/static_tests/community/test_bundle.py b/operator-pipeline-images/tests/static_tests/community/test_bundle.py index 336482c21..0fe49c255 100644 --- a/operator-pipeline-images/tests/static_tests/community/test_bundle.py +++ b/operator-pipeline-images/tests/static_tests/community/test_bundle.py @@ -13,6 +13,8 @@ check_required_fields, run_operator_sdk_bundle_validate, check_api_version_constraints, + check_replaces_availability, + check_upgrade_graph_loop, ) from tests.utils import bundle_files, create_files, merge @@ -440,3 +442,152 @@ def test_check_api_version_constraints( ) bundle = Repo(tmp_path).operator("hello").bundle("0.0.1") assert set(check_api_version_constraints(bundle)) == expected + + +@patch("operator_repo.core.Operator.config") +def test_check_upgrade_graph_loop(mock_config: MagicMock, tmp_path: Path) -> None: + mock_config.get.return_value = "replaces-mode" + create_files( + tmp_path, + bundle_files("hello", "0.0.1"), + bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}), + ) + + repo = Repo(tmp_path) + operator = repo.operator("hello") + bundle = operator.bundle("0.0.1") + is_loop = list(check_upgrade_graph_loop(bundle)) + assert is_loop == [] + + mock_config.get.return_value = "unknown-mode" + is_loop = list(check_upgrade_graph_loop(bundle)) + assert is_loop == [ + Fail("Operator(hello): unsupported updateGraph value: unknown-mode") + ] + + mock_config.get.return_value = "replaces-mode" + # Both bundles replace each other + create_files( + tmp_path, + bundle_files("hello", "0.0.1", csv={"spec": {"replaces": "hello.v0.0.2"}}), + bundle_files("hello", "0.0.2", csv={"spec": {"replaces": "hello.v0.0.1"}}), + ) + + repo = Repo(tmp_path) + operator = repo.operator("hello") + bundle = operator.bundle("0.0.1") + is_loop = list(check_upgrade_graph_loop(bundle)) + assert len(is_loop) == 1 and isinstance(is_loop[0], Fail) + assert ( + is_loop[0].reason + == "Upgrade graph loop detected for bundle: [Bundle(hello/0.0.1), " + "Bundle(hello/0.0.2), Bundle(hello/0.0.1)]" + ) + + # Malformed .spec.replaces + create_files( + tmp_path, + bundle_files("malformed", "0.0.1", csv={"spec": {"replaces": ""}}), + ) + + repo = Repo(tmp_path) + operator = repo.operator("malformed") + bundle = operator.bundle("0.0.1") + failures = list(check_upgrade_graph_loop(bundle)) + assert len(failures) == 1 and isinstance(failures[0], Fail) + assert "Bundle(malformed/0.0.1) has invalid 'replaces' field:" in failures[0].reason + + +def test_check_replaces_availability_no_replaces( + tmp_path: Path, +) -> None: + bundle_annotation = { + "com.redhat.openshift.versions": "v4.10", + } + replaces_bundle_annotation = { + "com.redhat.openshift.versions": "v4.11", + } + create_files( + tmp_path, + bundle_files("hello", "0.0.1", annotations=replaces_bundle_annotation), + bundle_files( + "hello", + "0.0.2", + annotations=bundle_annotation, + ), + ) + + repo = Repo(tmp_path) + operator = repo.operator("hello") + bundle = operator.bundle("0.0.2") + errors = list(check_replaces_availability(bundle)) + + assert set(errors) == set() + + +@pytest.mark.parametrize( + "bundle_version_annotation,replaces_version_annotation,ocp_range,expected", + [ + pytest.param(None, None, [], set(), id="No annotations"), + pytest.param("v4.10", "v4.10", [], set(), id="Same annotations"), + pytest.param( + "v4.15", + "v4.15,v4.16", + [["v4.15", "v4.16"], ["v4.15", "v4.16"]], + set(), + id="Different annotation, versions match", + ), + pytest.param( + "v4.15", + "v4.16", + [["v4.15", "v4.16"], ["v4.16"]], + { + Fail( + "Replaces bundle Bundle(hello/0.0.1) ['v4.16'] does not support " + "the same OCP versions as bundle Bundle(hello/0.0.2) ['v4.15', 'v4.16']. " + "In order to fix this issue, align the OCP version range to match the " + "range of the replaced bundle. " + "This can be done by setting the `com.redhat.openshift.versions` annotation " + "in the `metadata/annotations.yaml` file.\n" + "`Bundle(hello/0.0.2)` - `v4.15`\n" + "`Bundle(hello/0.0.1)` - `v4.16`" + ) + }, + id="Different annotation, different version", + ), + ], +) +@patch("operatorcert.static_tests.community.bundle.utils.get_ocp_supported_versions") +def test_check_replaces_availability( + mock_get_ocp_supported_versions: MagicMock, + bundle_version_annotation: str, + replaces_version_annotation: str, + ocp_range: Any, + expected: Any, + tmp_path: Path, +) -> None: + bundle_annotation = { + "com.redhat.openshift.versions": bundle_version_annotation, + } + replaces_bundle_annotation = { + "com.redhat.openshift.versions": replaces_version_annotation, + } + create_files( + tmp_path, + bundle_files("hello", "0.0.1", annotations=replaces_bundle_annotation), + bundle_files( + "hello", + "0.0.2", + annotations=bundle_annotation, + csv={"spec": {"replaces": "hello.v0.0.1"}}, + ), + ) + + mock_get_ocp_supported_versions.side_effect = ocp_range + + repo = Repo(tmp_path) + operator = repo.operator("hello") + bundle = operator.bundle("0.0.2") + errors = list(check_replaces_availability(bundle)) + + assert set(errors) == expected