Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
113 changes: 76 additions & 37 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,56 @@ 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:
m = re.match(r"^[^(\n]+", input)
Comment thread
torcolvin marked this conversation as resolved.
Outdated
if m:
version = m.group().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:
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

return (version, build)

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

name: str
version: str


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 @@ -553,12 +579,13 @@ def __init__(
password: str,
port: int,
secure: bool = False,
public_port: int = 4984,
):
scheme = "https://" if secure else "http://"
ws_scheme = "wss://" if secure else "ws://"
self.__http_url = f"{scheme}{url}:{port}"
# Replication always uses public port 4984
self.__replication_url = f"{ws_scheme}{url}:4984"
self.__public_port = public_port
self.__replication_url = f"{ws_scheme}{url}:{public_port}"
self._tracer = get_tracer(__name__, VERSION)
self.__secure: bool = secure
self.__hostname: str = url
Expand All @@ -581,6 +608,11 @@ def port(self) -> int:
"""Gets the HTTP API port of the Sync Gateway instance"""
return self.__port

@property
def public_port(self) -> int:
"""Gets the public API port of the Sync Gateway instance"""
return self.__public_port
Comment thread
torcolvin marked this conversation as resolved.
Outdated

@property
def secure(self) -> bool:
"""Gets whether the Sync Gateway instance uses TLS"""
Expand Down Expand Up @@ -648,23 +680,31 @@ 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 a dev build /_status "version" does not contain major.minor so use model.vendor.version
Comment thread
torcolvin marked this conversation as resolved.
Outdated
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 Expand Up @@ -1355,7 +1395,7 @@ async def get_document_revision_public(
params = {"rev": revision}

async with self._create_session(
self.secure, self.scheme, self.hostname, 4984, auth
self.secure, self.scheme, self.hostname, self.public_port, auth
) as session:
return await self._send_request("GET", path, params=params, session=session)

Expand Down Expand Up @@ -1621,8 +1661,7 @@ def __init__(
:param secure: Whether to use TLS/HTTPS
:param public_port: Public API port (default 4984)
"""
super().__init__(url, username, password, port, secure)
self.__public_port = public_port
super().__init__(url, username, password, port, secure, public_port)
r = requests.get(
f"{self.scheme}{url}:{port}/_config",
auth=(username, password),
Expand Down Expand Up @@ -1866,11 +1905,11 @@ async def start(self, config_name: str = "bootstrap") -> None:
:param config_name: Name of the config file (without .json extension).
:raises Exception: If the start fails
"""
# Check if SGW is already running by probing the public endpoint (4984)
# Check if SGW is already running by probing the public endpoint
try:
# Use a short timeout to distinguish "not running" from "slow"
async with self._create_session(
self.secure, self.scheme, self.hostname, 4984, None
self.secure, self.scheme, self.hostname, self.public_port, None
) as session:
async with session.get("/", timeout=ClientTimeout(total=5)) as resp:
if resp.status == 200:
Expand Down Expand Up @@ -2002,7 +2041,7 @@ async def create_user_client(
self.hostname,
username,
password,
port=self.__public_port,
port=self.public_port,
secure=self.secure,
)

Expand Down Expand Up @@ -2143,4 +2182,4 @@ def __init__(
:param port: Public API port (default 4984)
:param secure: Whether to use TLS/HTTPS
"""
super().__init__(url, username, password, port, secure)
super().__init__(url, username, password, port, secure, port)
103 changes: 33 additions & 70 deletions client/tests/test_version_parsing.py
Original file line number Diff line number Diff line change
@@ -1,85 +1,48 @@
"""Unit tests for CouchbaseVersion parsing (SyncGatewayVersion / EdgeServerVersion)
and GreenboardUploader version handling."""

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),
("4.0.0(350;def456)", "4.0.0", 350),
("4.0.0", "4.0.0", 0),
("4.0.0(271)", "4.0.0", 271),
("(271;abc)", "unknown", 271),
("4.0.0(abc;def)", "4.0.0", 0),
("4.0.0 (271;commit)", "4.0.0", 271),
("", "unknown", 0),
("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


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
Loading
Loading