diff --git a/static/compatibilities.yaml b/static/compatibilities.yaml index 61ed8195ae..0760b12925 100644 --- a/static/compatibilities.yaml +++ b/static/compatibilities.yaml @@ -30910,3 +30910,45 @@ addons: chart_version: 0.1.1 images: ['wiziopublic.azurecr.io/wiz-app/wiz-network-analyzer:0.1'] name: wiz-network-analyzer +- icon: https://helm.elastic.co/icons/eck.png + git_url: https://github.com/elastic/cloud-on-k8s + release_url: https://github.com/elastic/cloud-on-k8s/releases/tag/v{vsn} + helm_repository_url: https://helm.elastic.co + versions: + - version: 3.5.0 + kube: ['1.36', '1.35', '1.34', '1.33', '1.32'] + chart_version: 3.5.0 + requirements: [] + incompatibilities: [] + summary: null + - version: 3.4.1 + kube: ['1.36', '1.35', '1.34', '1.33', '1.32', '1.31'] + chart_version: 3.4.1 + requirements: [] + incompatibilities: [] + summary: null + - version: 3.3.2 + kube: ['1.35', '1.34', '1.33', '1.32', '1.31'] + chart_version: 3.3.2 + requirements: [] + incompatibilities: [] + summary: null + - version: 3.2.0 + kube: ['1.34', '1.33', '1.32', '1.31', '1.30'] + chart_version: 3.2.0 + requirements: [] + incompatibilities: [] + summary: null + - version: 3.1.0 + kube: ['1.33', '1.32', '1.31', '1.30', '1.29'] + chart_version: 3.1.0 + requirements: [] + incompatibilities: [] + summary: null + - version: 3.0.0 + kube: ['1.32', '1.31', '1.30', '1.29', '1.28'] + chart_version: 3.0.0 + requirements: [] + incompatibilities: [] + summary: null + name: eck-operator diff --git a/static/compatibilities/eck-operator.yaml b/static/compatibilities/eck-operator.yaml new file mode 100644 index 0000000000..b47732e767 --- /dev/null +++ b/static/compatibilities/eck-operator.yaml @@ -0,0 +1,42 @@ +icon: https://helm.elastic.co/icons/eck.png +git_url: https://github.com/elastic/cloud-on-k8s +release_url: https://github.com/elastic/cloud-on-k8s/releases/tag/v{vsn} +helm_repository_url: https://helm.elastic.co +versions: +- version: 3.5.0 + kube: ['1.36', '1.35', '1.34', '1.33', '1.32'] + chart_version: 3.5.0 + requirements: [] + incompatibilities: [] + summary: null +- version: 3.4.1 + kube: ['1.36', '1.35', '1.34', '1.33', '1.32', '1.31'] + chart_version: 3.4.1 + requirements: [] + incompatibilities: [] + summary: null +- version: 3.3.2 + kube: ['1.35', '1.34', '1.33', '1.32', '1.31'] + chart_version: 3.3.2 + requirements: [] + incompatibilities: [] + summary: null +- version: 3.2.0 + kube: ['1.34', '1.33', '1.32', '1.31', '1.30'] + chart_version: 3.2.0 + requirements: [] + incompatibilities: [] + summary: null +- version: 3.1.0 + kube: ['1.33', '1.32', '1.31', '1.30', '1.29'] + chart_version: 3.1.0 + requirements: [] + incompatibilities: [] + summary: null +- version: 3.0.0 + kube: ['1.32', '1.31', '1.30', '1.29', '1.28'] + chart_version: 3.0.0 + requirements: [] + incompatibilities: [] + summary: null +name: eck-operator diff --git a/static/compatibilities/manifest.yaml b/static/compatibilities/manifest.yaml index 6d325d796f..6edfc83601 100644 --- a/static/compatibilities/manifest.yaml +++ b/static/compatibilities/manifest.yaml @@ -49,3 +49,4 @@ names: - wiz-sensor - wiz-admission-controller - wiz-network-analyzer +- eck-operator diff --git a/utils/compatibility/scrapers/eck-operator.py b/utils/compatibility/scrapers/eck-operator.py new file mode 100644 index 0000000000..2613b1cf78 --- /dev/null +++ b/utils/compatibility/scrapers/eck-operator.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import re +from collections import OrderedDict + +from packaging.version import InvalidVersion, Version + +from utils import fetch_page, get_chart_versions, update_compatibility_info + +app_name = "eck-operator" +README_URL = "https://raw.githubusercontent.com/elastic/cloud-on-k8s/v{version}/README.md" +MIN_MAJOR = 3 + + +def parse_kube_range(markdown: str) -> list[str]: + match = re.search( + r"^\s*[*-]\s+Kubernetes\s+1\.(\d+)\s*-\s*1\.(\d+)\s*$", + markdown, + re.MULTILINE, + ) + if not match: + raise ValueError("ECK Kubernetes support range not found") + + start, end = map(int, match.groups()) + if start > end: + raise ValueError("ECK Kubernetes support range is reversed") + + return [f"1.{minor}" for minor in range(end, start - 1, -1)] + + +def latest_chart_per_minor(chart_versions: dict[str, str]) -> list[tuple[str, str]]: + latest: dict[tuple[int, int], tuple[Version, str]] = {} + + for app_version, chart_version in chart_versions.items(): + try: + parsed = Version(app_version) + except InvalidVersion: + continue + + if parsed.is_prerelease or parsed.is_devrelease or parsed.major < MIN_MAJOR: + continue + + key = (parsed.major, parsed.minor) + current = latest.get(key) + if current is None or parsed > current[0]: + latest[key] = (parsed, chart_version) + + return [ + (str(parsed), chart_version) + for parsed, chart_version in sorted( + latest.values(), + key=lambda item: item[0], + reverse=True, + ) + ] + + +def build_rows( + chart_versions: dict[str, str], + fetcher=fetch_page, +) -> list[OrderedDict[str, object]]: + candidates = latest_chart_per_minor(chart_versions) + if not candidates: + raise ValueError("No supported ECK Helm releases found") + + rows: list[OrderedDict[str, object]] = [] + for version, chart_version in candidates: + url = README_URL.format(version=version) + content = fetcher(url) + if not content: + raise ValueError(f"Could not fetch ECK {version} README") + + if isinstance(content, bytes): + try: + markdown = content.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"Could not decode ECK {version} README") from exc + elif isinstance(content, str): + markdown = content + else: + raise ValueError(f"Unexpected ECK {version} README payload") + + rows.append( + OrderedDict( + [ + ("version", version), + ("kube", parse_kube_range(markdown)), + ("chart_version", chart_version), + ("requirements", []), + ("incompatibilities", []), + ] + ) + ) + + return rows + + +def scrape() -> None: + chart_versions = get_chart_versions(app_name) + if not chart_versions: + raise ValueError("No official ECK Helm releases found") + + rows = build_rows(chart_versions) + update_compatibility_info( + f"../../static/compatibilities/{app_name}.yaml", + rows, + ) diff --git a/utils/compatibility/tests/test_eck_operator.py b/utils/compatibility/tests/test_eck_operator.py new file mode 100644 index 0000000000..878b205589 --- /dev/null +++ b/utils/compatibility/tests/test_eck_operator.py @@ -0,0 +1,128 @@ +import importlib.util +from pathlib import Path +from types import ModuleType +import sys +import unittest +from unittest.mock import Mock, patch + +helpers = ModuleType("utils") +helpers.fetch_page = Mock() +helpers.get_chart_versions = Mock() +helpers.update_compatibility_info = Mock() + +# The scraper imports its helpers from a top-level `utils` module. Keep that +# temporary stub strictly scoped to module loading so this test cannot affect +# compatibility tests imported later in the same unittest discovery process. +_original_utils = sys.modules.get("utils") +sys.modules["utils"] = helpers +try: + SCRAPER_PATH = Path(__file__).parents[1] / "scrapers" / "eck-operator.py" + spec = importlib.util.spec_from_file_location("eck_operator", SCRAPER_PATH) + scraper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(scraper) +finally: + if _original_utils is None: + sys.modules.pop("utils", None) + else: + sys.modules["utils"] = _original_utils + + +def readme(kube_range: str) -> bytes: + return f"""# Elastic Cloud on Kubernetes (ECK) + +Supported versions: + +* Kubernetes {kube_range} +* OpenShift 4.16-4.22 +""".encode("utf-8") + + +class EckOperatorTests(unittest.TestCase): + def test_parses_kubernetes_range_descending(self): + self.assertEqual( + scraper.parse_kube_range(readme("1.32-1.36").decode()), + ["1.36", "1.35", "1.34", "1.33", "1.32"], + ) + + def test_accepts_whitespace_around_range_separator(self): + self.assertEqual( + scraper.parse_kube_range(readme("1.31 - 1.35").decode()), + ["1.35", "1.34", "1.33", "1.32", "1.31"], + ) + + def test_missing_kubernetes_range_fails_closed(self): + with self.assertRaisesRegex(ValueError, "support range not found"): + scraper.parse_kube_range("# ECK\n* OpenShift 4.16-4.22") + + def test_reversed_range_fails_closed(self): + with self.assertRaisesRegex(ValueError, "reversed"): + scraper.parse_kube_range(readme("1.36-1.32").decode()) + + def test_latest_patch_per_minor_and_legacy_filter(self): + charts = { + "3.5.0": "3.5.0", + "3.4.0": "3.4.0", + "3.4.1": "3.4.1", + "3.3.2": "3.3.2", + "3.6.0-beta1": "3.6.0-beta1", + "2.16.0": "2.16.0", + "not-a-version": "x", + } + self.assertEqual( + scraper.latest_chart_per_minor(charts), + [ + ("3.5.0", "3.5.0"), + ("3.4.1", "3.4.1"), + ("3.3.2", "3.3.2"), + ], + ) + + def test_build_rows_uses_tagged_release_readmes(self): + charts = {"3.5.0": "3.5.0", "3.4.1": "3.4.1"} + payloads = { + scraper.README_URL.format(version="3.5.0"): readme("1.32-1.36"), + scraper.README_URL.format(version="3.4.1"): readme("1.31-1.36"), + } + + rows = scraper.build_rows(charts, payloads.get) + + self.assertEqual([row["version"] for row in rows], ["3.5.0", "3.4.1"]) + self.assertEqual(rows[0]["kube"], ["1.36", "1.35", "1.34", "1.33", "1.32"]) + self.assertEqual(rows[1]["chart_version"], "3.4.1") + + def test_missing_tagged_readme_fails_closed(self): + with self.assertRaisesRegex(ValueError, "Could not fetch"): + scraper.build_rows({"3.5.0": "3.5.0"}, lambda _: None) + + def test_invalid_utf8_fails_closed(self): + with self.assertRaisesRegex(ValueError, "Could not decode"): + scraper.build_rows({"3.5.0": "3.5.0"}, lambda _: b"\xff") + + def test_scrape_connects_helm_versions_to_shared_updater(self): + charts = {"3.5.0": "3.5.0"} + with ( + patch.object(scraper, "get_chart_versions", return_value=charts) as get_charts, + patch.object(scraper, "fetch_page", return_value=readme("1.32-1.36")), + patch.object(scraper, "update_compatibility_info") as update, + ): + original = scraper.build_rows + + def wired(chart_versions): + return original(chart_versions, scraper.fetch_page) + + with patch.object(scraper, "build_rows", side_effect=wired): + scraper.scrape() + + get_charts.assert_called_once_with("eck-operator") + path, rows = update.call_args.args + self.assertEqual(path, "../../static/compatibilities/eck-operator.yaml") + self.assertEqual(rows[0]["version"], "3.5.0") + + def test_scrape_rejects_empty_helm_mapping(self): + with patch.object(scraper, "get_chart_versions", return_value={}): + with self.assertRaisesRegex(ValueError, "No official ECK Helm"): + scraper.scrape() + + +if __name__ == "__main__": + unittest.main()