Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions static/compatibilities/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ names:
- wiz-network-analyzer
- kuberay-operator
- mongodb-kubernetes
- scylladb-operator
20 changes: 20 additions & 0 deletions static/compatibilities/scylladb-operator.yaml
Original file line number Diff line number Diff line change
@@ -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']
202 changes: 202 additions & 0 deletions utils/compatibility/scrapers/scylladb-operator.py
Original file line number Diff line number Diff line change
@@ -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,
)
103 changes: 103 additions & 0 deletions utils/compatibility/tests/test_scylladb_operator.py
Original file line number Diff line number Diff line change
@@ -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")
Comment on lines +95 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Minor support goes unchecked

The fixture gives the 1.21 documentation a different Kubernetes support range, but the test only checks the 1.22 row. It would therefore still pass if the scraper accidentally reused the 1.22 documentation for every minor, leaving the per-minor compatibility mapping unprotected.

Suggested change
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([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[1]["kube"], ["1.32", "1.33", "1.34", "1.35"])
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()
Loading