Skip to content
Merged
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 client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"opentelemetry-sdk==1.29.0",
"opentelemetry-semantic-conventions==0.50b0",
"packaging==24.2",
"pydantic>=2.13.3",
"pyjson5==1.6.8",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
Expand Down
117 changes: 89 additions & 28 deletions client/src/cbltest/api/syncgateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
from typing import Any, cast
from urllib.parse import urljoin

import packaging.version
import requests
import tenacity
from aiohttp import BasicAuth, ClientError, ClientSession, ClientTimeout, TCPConnector
from aiohttp.client_exceptions import ClientConnectorError
from opentelemetry.trace import get_tracer
from pydantic import BaseModel

from cbltest.api.error import CblSyncGatewayBadResponseError, CblTestError
from cbltest.api.jsonserializable import JSONDictionary, JSONSerializable
Expand Down Expand Up @@ -486,32 +488,66 @@ def __init__(self, input: str):
self.__version = parsed[0]
self.__build_number = parsed[1]

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.__raw})"


class SyncGatewayVersion(CouchbaseVersion):
"""
A class for parsing Sync Gateway Version
"""

def parse(self, input: str) -> tuple[str, int]:
first_lparen = input.find("(")
first_semicol = input.find(";")
if first_lparen == -1 or first_semicol == -1:
return ("unknown", 0)

version = input[0:first_lparen].strip()
if not version:
# Version parsing can be different for dev builds and release builds. In a dev build, it is possible to miss a build number.
#
# Example input:
# Couchbase Sync Gateway/4.0.0(350;def456)
# 4.0.0(350;def456)
# 4.0.0

# extract everything between an optional / and an option ( to represent a major.minor.patch build number
m = re.search(r"(?:^|/)([\d\.]+)(?=\s*\(|$)", input)
# (?:^|/)(?P<v>[\d\.]+)(?:\s*\(|$)", input)
if m:
version = m.group(1).strip()
else:
cbl_warning(f"Could not extract version from SGW version string: '{input}'")
version = "unknown"

try:
build = int(input[first_lparen + 1 : first_semicol])
except ValueError:
# extract everything between ( and a ; character to guess at a build number
m = re.search(r"(?<=\()([^;)]+)", input)
Comment thread
torcolvin marked this conversation as resolved.
if m:
try:
build = int(m.group())
except ValueError as e:
cbl_warning(
f"Could not parse build number {m.group()} from SGW version string: '{input}': {e}"
)
build = 0
else:
cbl_warning(
f"Could not parse build number from SGW version string: '{input}'"
)
build = 0
return version, build


class SyncGatewayStatusVendor(BaseModel):
"""
Output of vendor field of /_status endpoint of Sync Gateway
"""

name: str
version: str

return (version, build)

class SyncGatewayStatusResponse(BaseModel):
"""
Output of GET /_status endpoint of Sync Gateway
"""

version: str # this version does not always include the full build number if it is a dev build
vendor: SyncGatewayStatusVendor


class DatabaseStatusResponse:
Expand Down Expand Up @@ -648,23 +684,48 @@ async def _send_request(

return ret_val

async def get_version(self) -> CouchbaseVersion:
# Telemetry not really important for this call
async with self._create_session(
self.secure, self.scheme, self.hostname, 4984, None
) as s:
resp = await self._send_request("get", "/", session=s)
assert isinstance(resp, dict)
resp_dict = cast(dict, resp)
raw_version = _get_typed_required(resp_dict, "version", str)
if "/" in raw_version:
version_part = raw_version.rsplit("/", 1)[1]
else:
cbl_warning(
f"Unexpected SGW version format (no '/' separator): '{raw_version}'"
)
version_part = raw_version
return SyncGatewayVersion(version_part)
async def supports_version_vectors(self) -> bool:
"""Returns whether the Sync Gateway instance supports version vectors (i.e. is 4.0 or later)"""
version = await self.get_version()
return packaging.version.parse(version.version) >= packaging.version.parse(
"4.0"
)
Comment thread
torcolvin marked this conversation as resolved.

async def get_version(self) -> SyncGatewayVersion:
"""Return version of Sync Gateway"""
resp = await self._send_request("get", "/_status")
assert isinstance(resp, dict)
model = SyncGatewayStatusResponse.model_validate(resp)

# In the case of a dev build, it is not possible to determine a build number, but there is a
# major.minor version.
# There only backward compatible difference is if "vendor.version" substring is contained in "version""

# In a production build, the output:
#
# "vendor": {
# "version": "4.0"
# },
# "version": "Couchbase Sync Gateway/4.0.4(8;release) EE"
#
# In a dev build, the output:
#
# "vendor": {
# "version": "4.0"
# },
# "version": "Couchbase Sync Gateway/() EE"
if model.vendor.version in model.version:
sg_version = SyncGatewayVersion(model.version)
else:
sg_version = SyncGatewayVersion(model.vendor.version)
try:
packaging.version.parse(sg_version.version)
except packaging.version.InvalidVersion as exc:
raise CblTestError(
"Failed to parse Sync Gateway version from /_status response: {resp}\n"
f"version={model.version}"
) from exc
return sg_version

def tls_cert(self) -> str | None:
if not self.secure:
Expand Down
124 changes: 54 additions & 70 deletions client/tests/test_version_parsing.py
Original file line number Diff line number Diff line change
@@ -1,85 +1,69 @@
"""Unit tests for CouchbaseVersion parsing (SyncGatewayVersion / EdgeServerVersion)
and GreenboardUploader version handling."""

import packaging.version
import pytest
from cbltest.api.edgeserver import EdgeServerVersion
from cbltest.api.syncgateway import SyncGatewayVersion


class TestSyncGatewayVersionParse:
"""Tests for SyncGatewayVersion.parse()"""

def test_standard_3x_format(self):
v = SyncGatewayVersion("3.3.3(271;abc123)")
assert v.version == "3.3.3"
assert v.build_number == 271

def test_standard_4x_format(self):
v = SyncGatewayVersion("4.0.0(350;def456)")
assert v.version == "4.0.0"
assert v.build_number == 350

def test_missing_parentheses(self):
v = SyncGatewayVersion("4.0.0")
assert v.version == "unknown"
assert v.build_number == 0

def test_missing_semicolon(self):
v = SyncGatewayVersion("4.0.0(271)")
assert v.version == "unknown"
assert v.build_number == 0

def test_empty_version_before_lparen(self):
"""Previously returned ("", 271) — now returns ("unknown", 271)."""
v = SyncGatewayVersion("(271;abc)")
assert v.version == "unknown"
assert v.build_number == 271

def test_non_numeric_build(self):
"""Non-numeric build between ( and ; should not crash."""
v = SyncGatewayVersion("4.0.0(abc;def)")
assert v.version == "4.0.0"
assert v.build_number == 0

def test_version_with_spaces(self):
v = SyncGatewayVersion("4.0.0 (271;commit)")
assert v.version == "4.0.0"
assert v.build_number == 271

def test_empty_input(self):
v = SyncGatewayVersion("")
assert v.version == "unknown"
assert v.build_number == 0

def test_raw_preserved(self):
v = SyncGatewayVersion("3.3.3(271;abc)")
assert v.raw == "3.3.3(271;abc)"

def test_enterprise_edition_suffix(self):
"""Handle version strings like '4.0.0(271;commit) EE'."""
v = SyncGatewayVersion("4.0.0(271;commit) EE")
assert v.version == "4.0.0"
assert v.build_number == 271
@pytest.mark.parametrize(
"version_string, expected_version, expected_build",
[
("3.3.3(271;abc123)", "3.3.3", 271),
("3.3.3 (271;abc123)", "3.3.3", 271),
("4.0.0(350;def456)", "4.0.0", 350),
("4.0.0 (350;def456)", "4.0.0", 350),
("4.0.0", "4.0.0", 0),
("4.0.0(271)", "4.0.0", 271),
("4.0.0 (271)", "4.0.0", 271),
("(271;abc)", "unknown", 271),
("4.0.0(abc;def)", "4.0.0", 0),
("4.0.0 (abc;def)", "4.0.0", 0),
("4.0.0(271;commit)", "4.0.0", 271),
("4.0.0 (271;commit)", "4.0.0", 271),
("", "unknown", 0),
("4.0.0(271;commit) EE", "4.0.0", 271),
("Couchbase Sync Gateway/3.3.3(271;abc123)", "3.3.3", 271),
("Couchbase Sync Gateway/3.3.3 (271;abc123)", "3.3.3", 271),
("Couchbase Sync Gateway/4.0.0(350;def456)", "4.0.0", 350),
("Couchbase Sync Gateway/4.0.0 (350;def456)", "4.0.0", 350),
("Couchbase Sync Gateway/4.0.0", "4.0.0", 0),
("Couchbase Sync Gateway/4.0.0(271)", "4.0.0", 271),
("Couchbase Sync Gateway/4.0.0 (271)", "4.0.0", 271),
("Couchbase Sync Gateway/(271;abc)", "unknown", 271),
("Couchbase Sync Gateway/ (271;abc)", "unknown", 271),
("Couchbase Sync Gateway/4.0.0(abc;def)", "4.0.0", 0),
("Couchbase Sync Gateway/4.0.0 (abc;def)", "4.0.0", 0),
("Couchbase Sync Gateway/4.0.0 (271;commit)", "4.0.0", 271),
("Couchbase Sync Gateway/4.0.0(271;commit) EE", "4.0.0", 271),
],
)
def test_parse(self, version_string, expected_version, expected_build):
v = SyncGatewayVersion(version_string)
assert v.version == expected_version
assert v.build_number == expected_build
assert v.raw == version_string
if v.version != "unknown":
packaging.version.parse(v.version)


class TestEdgeServerVersionParse:
"""Tests for EdgeServerVersion.parse() — same logic as SyncGatewayVersion."""

def test_standard_format(self):
v = EdgeServerVersion("1.2.0(100;abc)")
assert v.version == "1.2.0"
assert v.build_number == 100

def test_empty_version_before_lparen(self):
v = EdgeServerVersion("(100;abc)")
assert v.version == "unknown"
assert v.build_number == 100

def test_non_numeric_build(self):
v = EdgeServerVersion("1.0.0(xyz;abc)")
assert v.version == "1.0.0"
assert v.build_number == 0

def test_no_parentheses(self):
v = EdgeServerVersion("1.0.0")
assert v.version == "unknown"
assert v.build_number == 0
@pytest.mark.parametrize(
"version_string, expected_version, expected_build",
[
("1.2.0(100;abc)", "1.2.0", 100),
("(100;abc)", "unknown", 100),
("1.0.0(xyz;abc)", "1.0.0", 0),
("1.0.0", "unknown", 0),
],
)
def test_parse(self, version_string, expected_version, expected_build):
v = EdgeServerVersion(version_string)
assert v.version == expected_version
assert v.build_number == expected_build
5 changes: 1 addition & 4 deletions tests/QE/test_multiple_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from cbltest import CBLPyTest
from cbltest.api.cbltestclass import CBLTestClass
from cbltest.api.syncgateway import DocumentUpdateEntry, ISGRPayload, PutDatabasePayload
from packaging.version import Version


def _check_node_in_cluster(cbs_hostname: str, cluster_nodes: list) -> tuple[bool, bool]:
Expand Down Expand Up @@ -138,9 +137,7 @@ async def test_rebalance_sanity(
original_revs = {row.id: row.revision for row in all_docs.rows}
original_vvs = {}

sgw_version_obj = await sg.get_version()
sgw_version = Version(sgw_version_obj.version)
supports_version_vectors = sgw_version >= Version("4.0.0")
supports_version_vectors = await sg.supports_version_vectors()
if supports_version_vectors:
changes_initial = await sg_user.get_changes(sg_db, version_type="cv")
original_vvs = {
Expand Down
4 changes: 1 addition & 3 deletions tests/QE/test_replication_multiple_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,7 @@ async def test_replication_with_multiple_client_dbs_and_single_sync_gateway_db(
f"Invalid revision format for {row.id}: {row.revision}"
)

sgw_version_obj = await sg.get_version()
sgw_version = Version(sgw_version_obj.version)
supports_version_vectors = sgw_version >= Version("4.0.0")
supports_version_vectors = await sg.supports_version_vectors()
if supports_version_vectors:
self.mark_test_step(
"Verify all documents have correct version vector format (SGW 4.0+)"
Expand Down
5 changes: 1 addition & 4 deletions tests/QE/test_users_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from cbltest import CBLPyTest
from cbltest.api.cbltestclass import CBLTestClass
from cbltest.api.syncgateway import DocumentUpdateEntry, PutDatabasePayload
from packaging.version import Version


@pytest.mark.sgw
Expand Down Expand Up @@ -121,9 +120,7 @@ async def test_single_user_multiple_channels(
f"Invalid revision format for {row.id}: {row.revision}"
)

sgw_version_obj = await sgs[0].get_version()
sgw_version = Version(sgw_version_obj.version)
supports_version_vectors = sgw_version >= Version("4.0.0")
supports_version_vectors = await sgs[0].supports_version_vectors()
if supports_version_vectors:
self.mark_test_step(
"Verify all documents have correct version vector format (SGW 4.0+)"
Expand Down
9 changes: 2 additions & 7 deletions tests/QE/test_xattrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from cbltest.api.cbltestclass import CBLTestClass
from cbltest.api.error import CblSyncGatewayBadResponseError
from cbltest.api.syncgateway import DocumentUpdateEntry, PutDatabasePayload
from packaging.version import Version


@pytest.mark.sgw
Expand Down Expand Up @@ -86,9 +85,7 @@ async def test_offline_processing_of_external_updates(
assert sg_created_count == num_docs, (
f"Expected {num_docs} SG docs, but found {sg_created_count}"
)
sgw_version_obj = await sg.get_version()
sgw_version = Version(sgw_version_obj.version)
supports_version_vectors = sgw_version >= Version("4.0.0")
supports_version_vectors = await sg.supports_version_vectors()
original_revisions = {row.id: row.revision for row in sg_all_docs.rows}
if supports_version_vectors:
original_vv = {row.id: row.cv for row in sg_all_docs.rows}
Expand Down Expand Up @@ -244,9 +241,7 @@ async def test_purge(self, cblpytest: CBLPyTest) -> None:
row.id: row.revision for row in sg_all_docs.rows
}

sgw_version_obj = await sg.get_version()
sgw_version = Version(sgw_version_obj.version)
supports_version_vectors = sgw_version >= Version("4.0.0")
supports_version_vectors = await sg.supports_version_vectors()
all_doc_version_vectors: dict[str, str | None] = {}
if supports_version_vectors:
self.mark_test_step("Store original version vectors for SG docs (optional)")
Expand Down
Loading
Loading