diff --git a/static/compatibilities/manifest.yaml b/static/compatibilities/manifest.yaml index 13378e1e2f..56c417b43b 100644 --- a/static/compatibilities/manifest.yaml +++ b/static/compatibilities/manifest.yaml @@ -53,3 +53,4 @@ names: - wiz-network-analyzer - kuberay-operator - mongodb-kubernetes +- scylladb-operator diff --git a/static/compatibilities/scylladb-operator.yaml b/static/compatibilities/scylladb-operator.yaml new file mode 100644 index 0000000000..7ad0038ef7 --- /dev/null +++ b/static/compatibilities/scylladb-operator.yaml @@ -0,0 +1,20 @@ +icon: https://avatars.githubusercontent.com/u/797456?s=200&v=4 +git_url: https://github.com/scylladb/scylla-operator +release_url: https://github.com/scylladb/scylla-operator/releases/tag/v{vsn} +helm_repository_url: https://scylla-operator-charts.storage.googleapis.com/stable +chart_name: scylla-operator +versions: +- version: 1.22.0 + kube: ['1.36', '1.35', '1.34', '1.33'] + requirements: [] + incompatibilities: [] + summary: null + chart_version: 1.22.0 + images: ['scylladb/scylla-operator:1.22.0'] +- version: 1.21.1 + kube: ['1.36', '1.35', '1.34', '1.33'] + requirements: [] + incompatibilities: [] + summary: null + chart_version: 1.21.1 + images: ['scylladb/scylla-operator:1.21.1'] diff --git a/utils/compatibility/scrapers/scylladb-operator.py b/utils/compatibility/scrapers/scylladb-operator.py new file mode 100644 index 0000000000..ca3941752b --- /dev/null +++ b/utils/compatibility/scrapers/scylladb-operator.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import re +from collections import OrderedDict + +import requests +from packaging.version import Version + +from utils import ( + expand_kube_versions, + get_chart_versions, + print_error, + update_compatibility_info, +) + + +APP_NAME = "scylladb-operator" +CHART_NAME = "scylla-operator" +HELM_REPO_URL = "https://scylla-operator-charts.storage.googleapis.com/stable" +RELEASES_URL = "https://api.github.com/repos/scylladb/scylla-operator/releases" +RELEASE_DOCS_URL = "https://operator.docs.scylladb.com/v{minor}/reference/releases.md" +REQUEST_TIMEOUT = 30 + + +def _decode(content): + try: + return content.decode("utf-8") if isinstance(content, bytes) else content + except UnicodeDecodeError as exc: + print_error(f"Failed to decode ScyllaDB Operator releases page: {exc}") + return None + + +def _stable_version(tag): + version = str(tag).strip().lstrip("v") + if not re.match(r"^\d+\.\d+\.\d+$", version): + return None + return version + + +def _minor(version): + parsed = Version(version) + return f"{parsed.major}.{parsed.minor}" + + +def latest_stable_release_by_minor(): + latest = {} + + for page in range(1, 4): + response = requests.get( + RELEASES_URL, + params={"page": page, "per_page": 100}, + timeout=REQUEST_TIMEOUT, + ) + if response.status_code != 200: + raise Exception(f"Failed to fetch ScyllaDB Operator releases: {response.status_code}") + + releases = response.json() + if not releases: + break + + for release in releases: + if release.get("draft") or release.get("prerelease"): + continue + + version = _stable_version(release.get("tag_name", "")) + if not version: + continue + + minor = _minor(version) + current = latest.get(minor) + if current is None or Version(version) > Version(current): + latest[minor] = version + + return latest + + +def _support_matrix_lines(markdown): + lines = markdown.splitlines() + table = [] + found_heading = False + + for line in lines: + stripped = line.strip() + if stripped == "## Support matrix": + found_heading = True + continue + if not found_heading: + continue + + if stripped.startswith("|"): + table.append(stripped) + elif table: + break + + return table + + +def _strip_markup(text): + text = re.sub(r"<[^>]+>", "", text) + text = text.replace("**", "") + return text.strip() + + +def _parse_table_row(line): + return [_strip_markup(cell) for cell in line.strip().strip("|").split("|")] + + +def _expand_version_range(value): + versions = [] + + for part in value.split(","): + part = part.strip() + range_match = re.fullmatch(r"(\d+\.\d+)\s*-\s*(\d+\.\d+)", part) + if range_match: + versions.extend(expand_kube_versions(range_match.group(1), range_match.group(2))) + continue + + versions.extend(re.findall(r"\b\d+\.\d+\b", part)) + + deduped = [] + for version in versions: + if version not in deduped: + deduped.append(version) + return deduped + + +def parse_kubernetes_support(markdown): + for line in _support_matrix_lines(markdown): + cells = _parse_table_row(line) + if len(cells) < 2: + continue + + if cells[0].lower() == "kubernetes": + return _expand_version_range(cells[1]) + + return [] + + +def _docs_for_minor(minor): + response = requests.get(RELEASE_DOCS_URL.format(minor=minor), timeout=REQUEST_TIMEOUT) + if response.status_code != 200: + return None + return _decode(response.content) + + +def build_rows(release_versions, chart_versions): + rows = [] + + for minor, version in sorted( + release_versions.items(), + key=lambda item: Version(item[1]), + reverse=True, + ): + chart_version = chart_versions.get(version) + if not chart_version: + continue + + markdown = _docs_for_minor(minor) + if not markdown: + continue + + kube_versions = parse_kubernetes_support(markdown) + if not kube_versions: + print_error(f"No Kubernetes support range found for ScyllaDB Operator {minor}") + continue + + rows.append( + OrderedDict( + [ + ("version", version), + ("kube", kube_versions), + ("chart_version", chart_version), + ("images", [f"docker.io/scylladb/scylla-operator:{version}"]), + ("requirements", []), + ("incompatibilities", []), + ] + ) + ) + + return rows + + +def scrape(): + release_versions = latest_stable_release_by_minor() + if not release_versions: + print_error("No stable ScyllaDB Operator releases found") + return + + chart_versions = get_chart_versions(APP_NAME, CHART_NAME) + if not chart_versions: + print_error("No ScyllaDB Operator chart versions found") + return + + rows = build_rows(release_versions, chart_versions) + if not rows: + print_error("No ScyllaDB Operator compatibility rows generated") + return + + update_compatibility_info( + f"../../static/compatibilities/{APP_NAME}.yaml", + rows, + ) diff --git a/utils/compatibility/tests/test_scylladb_operator.py b/utils/compatibility/tests/test_scylladb_operator.py new file mode 100644 index 0000000000..932b7725db --- /dev/null +++ b/utils/compatibility/tests/test_scylladb_operator.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + + +COMPATIBILITY_DIR = Path(__file__).resolve().parents[1] +MODULE_PATH = COMPATIBILITY_DIR / "scrapers" / "scylladb-operator.py" +sys.path.insert(0, str(COMPATIBILITY_DIR)) + +utils_spec = importlib.util.spec_from_file_location("utils", COMPATIBILITY_DIR / "utils.py") +compat_utils = importlib.util.module_from_spec(utils_spec) +assert utils_spec.loader is not None +utils_spec.loader.exec_module(compat_utils) + +utils_module = sys.modules.get("utils") +if utils_module is None: + sys.modules["utils"] = compat_utils +else: + for name in ( + "expand_kube_versions", + "get_chart_versions", + "print_error", + "update_compatibility_info", + ): + setattr(utils_module, name, getattr(compat_utils, name)) + +spec = importlib.util.spec_from_file_location("scylladb_operator", MODULE_PATH) +scylladb_operator = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(scylladb_operator) + + +class ScyllaDBOperatorScraperTest(unittest.TestCase): + def test_parse_kubernetes_support_expands_range(self): + markdown = """ +## Support matrix +| Component | Supported versions | +|-----------|--------------------| +| Kubernetes | 1.33 - 1.36 | +| ScyllaDB | 2025.1, 2026.1 - 2026.3 | +""" + + self.assertEqual( + scylladb_operator.parse_kubernetes_support(markdown), + ["1.33", "1.34", "1.35", "1.36"], + ) + + def test_parse_kubernetes_support_handles_mixed_lists_and_ranges(self): + markdown = """ +## Support matrix +| Component | Supported versions | +|-----------|--------------------| +| Kubernetes | 1.31, 1.33 - 1.35 | +""" + + self.assertEqual( + scylladb_operator.parse_kubernetes_support(markdown), + ["1.31", "1.33", "1.34", "1.35"], + ) + + def test_build_rows_uses_only_versions_with_published_docs(self): + docs = { + "1.22": """ +## Support matrix +| Component | Supported versions | +|-----------|--------------------| +| Kubernetes | 1.33 - 1.36 | +""", + "1.21": """ +## Support matrix +| Component | Supported versions | +|-----------|--------------------| +| Kubernetes | 1.32 - 1.35 | +""", + } + + with patch.object(scylladb_operator, "_docs_for_minor", side_effect=lambda minor: docs.get(minor)): + rows = scylladb_operator.build_rows( + { + "1.22": "1.22.0", + "1.21": "1.21.1", + "1.20": "1.20.3", + }, + { + "1.22.0": "1.22.0", + "1.21.1": "1.21.1", + "1.20.3": "1.20.3", + }, + ) + + self.assertEqual([row["version"] for row in rows], ["1.22.0", "1.21.1"]) + self.assertEqual(rows[0]["kube"], ["1.33", "1.34", "1.35", "1.36"]) + self.assertEqual(rows[0]["chart_version"], "1.22.0") + self.assertEqual(rows[0]["images"], ["docker.io/scylladb/scylla-operator:1.22.0"]) + self.assertEqual(rows[1]["kube"], ["1.32", "1.33", "1.34", "1.35"]) + + +if __name__ == "__main__": + unittest.main()