diff --git a/client/src/cbltest/api/edgeserver.py b/client/src/cbltest/api/edgeserver.py index a9637c40..3d5a86d9 100644 --- a/client/src/cbltest/api/edgeserver.py +++ b/client/src/cbltest/api/edgeserver.py @@ -26,6 +26,7 @@ from cbltest.assertions import _assert_not_null from cbltest.httplog import get_next_writer from cbltest.jsonhelper import _get_typed_required +from cbltest.logging import cbl_warning from cbltest.version import VERSION @@ -40,7 +41,22 @@ def parse(self, input: str) -> tuple[str, int]: if first_lparen == -1 or first_semicol == -1: return ("unknown", 0) - return input[0:first_lparen], int(input[first_lparen + 1 : first_semicol]) + version = input[0:first_lparen].strip() + if not version: + cbl_warning( + f"Could not extract version from Edge Server version string: '{input}'" + ) + version = "unknown" + + try: + build = int(input[first_lparen + 1 : first_semicol]) + except ValueError: + cbl_warning( + f"Could not parse build number from Edge Server version string: '{input}'" + ) + build = 0 + + return (version, build) class BulkDocOperation(JSONSerializable): @@ -229,8 +245,14 @@ async def get_version(self) -> CouchbaseVersion: assert isinstance(resp, dict) resp_dict = cast(dict, resp) raw_version = _get_typed_required(resp_dict, "version", str) - assert "/" in raw_version - return EdgeServerVersion(raw_version.split("/")[1]) + if "/" in raw_version: + version_part = raw_version.rsplit("/", 1)[1] + else: + cbl_warning( + f"Unexpected Edge Server version format (no '/' separator): '{raw_version}'" + ) + version_part = raw_version + return EdgeServerVersion(version_part) async def get_all_documents( self, diff --git a/client/src/cbltest/api/syncgateway.py b/client/src/cbltest/api/syncgateway.py index a38fdb6e..ac34f212 100644 --- a/client/src/cbltest/api/syncgateway.py +++ b/client/src/cbltest/api/syncgateway.py @@ -498,7 +498,20 @@ def parse(self, input: str) -> tuple[str, int]: if first_lparen == -1 or first_semicol == -1: return ("unknown", 0) - return input[0:first_lparen], int(input[first_lparen + 1 : first_semicol]) + version = input[0:first_lparen].strip() + if not version: + 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: + cbl_warning( + f"Could not parse build number from SGW version string: '{input}'" + ) + build = 0 + + return (version, build) class DatabaseStatusResponse: @@ -644,8 +657,14 @@ async def get_version(self) -> CouchbaseVersion: assert isinstance(resp, dict) resp_dict = cast(dict, resp) raw_version = _get_typed_required(resp_dict, "version", str) - assert "/" in raw_version - return SyncGatewayVersion(raw_version.split("/")[1]) + 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) def tls_cert(self) -> str | None: if not self.secure: diff --git a/client/src/cbltest/greenboarduploader.py b/client/src/cbltest/greenboarduploader.py index 93a8a098..8c5b85ab 100644 --- a/client/src/cbltest/greenboarduploader.py +++ b/client/src/cbltest/greenboarduploader.py @@ -7,6 +7,7 @@ from couchbase.cluster import Cluster from couchbase.options import ClusterOptions +from cbltest.api.syncgateway import CouchbaseVersion from cbltest.logging import cbl_warning @@ -54,7 +55,13 @@ def has_sgw_marker(self) -> bool: """ return self.__has_sgw_marker - def upload(self, platform: str, os_name: str, version: str, sgw_version: str): + def upload( + self, + platform: str, + os_name: str, + version: str, + sgw_version: CouchbaseVersion | None, + ): """ Uploads the results using the specified platform and version. The reason that they are specified here is because they are probably unknown at the time that this object @@ -62,27 +69,43 @@ def upload(self, platform: str, os_name: str, version: str, sgw_version: str): :param platform: The platform name (e.g. couchbase-lite-net) as specified by the test server :param version: The version string (e.g. 3.2.0-b0136, etc) as specified by the test server + :param sgw_version: The parsed SGW CouchbaseVersion object, or None if unavailable """ if self.__overall_fail: cbl_warning("Overall result is failure, skipping upload...") return - version_to_parse = sgw_version if platform == "sync-gateway" else version - version_components = version_to_parse.split("-") - parsed_version = "0.0.0" parsed_build = 0 - - if len(version_components) > 0 and version_components[0]: - parsed_version = version_components[0] - - if len(version_components) > 1: - try: - # Handles build numbers like 'b1234' or just '1234' - parsed_build = int(version_components[1].lstrip("b")) - except ValueError: - # If the part after '-' is not a number, build remains 0 - cbl_warning(f"Could not parse build number from '{version_to_parse}'") + sgw_version_str = "n/a" + + if sgw_version is not None: + sgw_ver = sgw_version.version + sgw_build = sgw_version.build_number + sgw_version_str = f"{sgw_ver}-{sgw_build}" + + if platform == "sync-gateway" and sgw_version is not None: + # For SGW jobs, use the SGW version directly from the parsed object + # to avoid the fragile serialize-then-reparse pattern. + parsed_version = ( + sgw_version.version + if sgw_version.version and sgw_version.version != "unknown" + else "0.0.0" + ) + parsed_build = sgw_version.build_number + else: + version_components = version.split("-") + + if len(version_components) > 0 and version_components[0]: + parsed_version = version_components[0] + + if len(version_components) > 1: + try: + # Handles build numbers like 'b1234' or just '1234' + parsed_build = int(version_components[1].lstrip("b")) + except ValueError: + # If the part after '-' is not a number, build remains 0 + cbl_warning(f"Could not parse build number from '{version}'") auth = PasswordAuthenticator(self.__username, self.__password) opts = ClusterOptions(auth) @@ -98,7 +121,7 @@ def upload(self, platform: str, os_name: str, version: str, sgw_version: str): { "build": parsed_build, "version": parsed_version, - "sgwVersion": sgw_version, + "sgwVersion": sgw_version_str, "failCount": self.__fail_count, "passCount": self.__pass_count, "platform": platform, diff --git a/client/src/cbltest/plugins/greenboard_fixture.py b/client/src/cbltest/plugins/greenboard_fixture.py index bd2ab077..9974c5f4 100644 --- a/client/src/cbltest/plugins/greenboard_fixture.py +++ b/client/src/cbltest/plugins/greenboard_fixture.py @@ -1,6 +1,7 @@ import pytest import pytest_asyncio from cbltest import CBLPyTest +from cbltest.api.syncgateway import CouchbaseVersion from cbltest.greenboarduploader import GreenboardUploader from cbltest.logging import cbl_info, cbl_warning @@ -43,7 +44,7 @@ async def greenboard(cblpytest: CBLPyTest, pytestconfig: pytest.Config): yield try: - sgw_version: str = "n/a" + sgw_version: CouchbaseVersion | None = None test_platform: str = "sync-gateway" os_name: str = "n/a" library_version: str = "n/a" @@ -58,10 +59,7 @@ async def greenboard(cblpytest: CBLPyTest, pytestconfig: pytest.Config): if "systemName" in test_server_info.device: os_name = test_server_info.device["systemName"] if len(cblpytest.sync_gateways) > 0: - sgw_version_parts = await cblpytest.sync_gateways[0].get_version() - sgw_version = ( - f"{sgw_version_parts.version}-{sgw_version_parts.build_number}" - ) + sgw_version = await cblpytest.sync_gateways[0].get_version() uploader.upload(test_platform, os_name, library_version, sgw_version) except Exception as e: cbl_warning(f"Failed to upload results to Greenboard: {e}") diff --git a/client/tests/test_version_parsing.py b/client/tests/test_version_parsing.py new file mode 100644 index 00000000..5ccddb64 --- /dev/null +++ b/client/tests/test_version_parsing.py @@ -0,0 +1,85 @@ +"""Unit tests for CouchbaseVersion parsing (SyncGatewayVersion / EdgeServerVersion) +and GreenboardUploader version handling.""" + +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 + + +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 diff --git a/jenkins/pipelines/QE/sgw/Jenkinsfile b/jenkins/pipelines/QE/sgw/Jenkinsfile index 125d44ae..f14e5822 100644 --- a/jenkins/pipelines/QE/sgw/Jenkinsfile +++ b/jenkins/pipelines/QE/sgw/Jenkinsfile @@ -1,16 +1,36 @@ +def resolveProgetVersion(String product, String version, String label) { + def url = "http://proget.build.couchbase.com:8080/api/latest_release?product=${product}&version=${version}&prerelease=true" + echo "Resolving ${label}: ${url}" + def resolved + if (isUnix()) { + resolved = sh(script: "curl -sf '${url}' | jq -r .version", returnStdout: true).trim() + } else { + resolved = powershell(script: """ + try { (Invoke-RestMethod '${url}').version } + catch { Write-Error "ProGet request failed for ${label}: \$_"; exit 1 } + """.stripIndent(), returnStdout: true).trim() + } + if (!resolved || resolved == 'null') { error "Could not resolve ${label} from '${version}' (url: ${url})" } + echo "Resolved ${label}: ${resolved}" + return resolved +} + pipeline { agent none parameters { - string(name: 'CBL_VERSION', defaultValue: '4.0.0', description: 'Couchbase Lite Version to use') - string(name: 'SGW_VERSION', defaultValue: '4.0.0', description: 'Sync Gateway Version to use') + string(name: 'CBL_VERSION', defaultValue: '3', description: 'Couchbase Lite Version to use') + string(name: 'SGW_VERSION', defaultValue: '4', description: 'Sync Gateway Version to use') } stages { stage('Init') { + agent any steps { script { if (params.CBL_VERSION == '') { error "CBL_VERSION is required" } if (params.SGW_VERSION == '') { error "SGW_VERSION is required" } - currentBuild.displayName = "CBL:${params.CBL_VERSION} SGW:${params.SGW_VERSION} (#${currentBuild.number})" + env.CBL_VERSION = resolveProgetVersion("couchbase-lite-ios", params.CBL_VERSION, 'CBL_VERSION') + env.SGW_VERSION = resolveProgetVersion('sync-gateway', params.SGW_VERSION, 'SGW_VERSION') + currentBuild.displayName = "CBL:${env.CBL_VERSION} SGW:${env.SGW_VERSION} (#${currentBuild.number})" } } } @@ -19,7 +39,7 @@ pipeline { build job: 'prebuild-test-server', parameters: [ string(name: 'TS_PLATFORM', value: 'swift_ios'), - string(name: 'CBL_VERSION', value: params.CBL_VERSION), + string(name: 'CBL_VERSION', value: env.CBL_VERSION), ], wait: true, propagate: true @@ -37,7 +57,7 @@ pipeline { sh "security unlock-keychain -p '${KEYCHAIN_PASSWORD}' ~/Library/Keychains/login.keychain-db" echo "=== Run SGW Tests" timeout(time: 90, unit: 'MINUTES') { - sh "jenkins/pipelines/QE/sgw/test.sh ${params.CBL_VERSION} ${params.SGW_VERSION}" + sh "jenkins/pipelines/QE/sgw/test.sh ${env.CBL_VERSION} ${env.SGW_VERSION}" } echo "=== SGW Tests Complete" } diff --git a/jenkins/pipelines/QE/upg-sgw/Jenkinsfile b/jenkins/pipelines/QE/upg-sgw/Jenkinsfile index 947fe985..784dcea1 100644 --- a/jenkins/pipelines/QE/upg-sgw/Jenkinsfile +++ b/jenkins/pipelines/QE/upg-sgw/Jenkinsfile @@ -1,16 +1,35 @@ +def resolveProgetVersion(String product, String version, String label) { + def url = "http://proget.build.couchbase.com:8080/api/latest_release?product=${product}&version=${version}&prerelease=true" + echo "Resolving ${label}: ${url}" + def resolved + if (isUnix()) { + resolved = sh(script: "curl -sf '${url}' | jq -r .version", returnStdout: true).trim() + } else { + resolved = powershell(script: """ + try { (Invoke-RestMethod '${url}').version } + catch { Write-Error "ProGet request failed for ${label}: \$_"; exit 1 } + """.stripIndent(), returnStdout: true).trim() + } + if (!resolved || resolved == 'null') { error "Could not resolve ${label} from '${version}' (url: ${url})" } + echo "Resolved ${label}: ${resolved}" + return resolved +} + pipeline { agent none parameters { - string(name: 'CBL_VERSION', defaultValue: '3.3.3', description: 'Couchbase Lite Version to use') + string(name: 'CBL_VERSION', defaultValue: '3', description: 'Couchbase Lite Version (major only auto-resolves via ProGet)') string(name: 'SGW_VERSIONS', defaultValue: '3.2.7 3.3.3 4.0.0', description: 'Sync Gateway Versions to upgrade across') } stages { stage('Init') { + agent any steps { script { if (params.CBL_VERSION == '') { error "CBL_VERSION is required" } if (params.SGW_VERSIONS == '') { error "SGW_VERSIONS are required (atleast one)" } - currentBuild.displayName = "CBL:${params.CBL_VERSION} SGW:${params.SGW_VERSIONS} (#${currentBuild.number})" + env.CBL_VERSION = resolveProgetVersion("couchbase-lite-ios", params.CBL_VERSION, 'CBL_VERSION') + currentBuild.displayName = "CBL:${env.CBL_VERSION} SGW:${params.SGW_VERSIONS} (#${currentBuild.number})" } } } @@ -19,7 +38,7 @@ pipeline { build job: 'prebuild-test-server', parameters: [ string(name: 'TS_PLATFORM', value: 'swift_ios'), - string(name: 'CBL_VERSION', value: params.CBL_VERSION), + string(name: 'CBL_VERSION', value: env.CBL_VERSION), ], wait: true, propagate: true @@ -37,7 +56,7 @@ pipeline { sh "security unlock-keychain -p '${KEYCHAIN_PASSWORD}' ~/Library/Keychains/login.keychain-db" echo "=== Run SGW Upgrades" timeout(time: 90, unit: 'MINUTES') { - sh "jenkins/pipelines/QE/upg-sgw/test.sh ${params.CBL_VERSION} ${params.SGW_VERSIONS}" + sh "jenkins/pipelines/QE/upg-sgw/test.sh ${env.CBL_VERSION} ${params.SGW_VERSIONS}" } echo "=== SGW Upgrades Complete" } diff --git a/jenkins/pipelines/QE/upg-sgw/Jenkinsfile_rolling b/jenkins/pipelines/QE/upg-sgw/Jenkinsfile_rolling index 7907cb1f..77b8191a 100644 --- a/jenkins/pipelines/QE/upg-sgw/Jenkinsfile_rolling +++ b/jenkins/pipelines/QE/upg-sgw/Jenkinsfile_rolling @@ -1,16 +1,35 @@ +def resolveProgetVersion(String product, String version, String label) { + def url = "http://proget.build.couchbase.com:8080/api/latest_release?product=${product}&version=${version}&prerelease=true" + echo "Resolving ${label}: ${url}" + def resolved + if (isUnix()) { + resolved = sh(script: "curl -sf '${url}' | jq -r .version", returnStdout: true).trim() + } else { + resolved = powershell(script: """ + try { (Invoke-RestMethod '${url}').version } + catch { Write-Error "ProGet request failed for ${label}: \$_"; exit 1 } + """.stripIndent(), returnStdout: true).trim() + } + if (!resolved || resolved == 'null') { error "Could not resolve ${label} from '${version}' (url: ${url})" } + echo "Resolved ${label}: ${resolved}" + return resolved +} + pipeline { agent none parameters { - string(name: 'CBL_VERSION', defaultValue: '3.3.3', description: 'Couchbase Lite Version to use') + string(name: 'CBL_VERSION', defaultValue: '3', description: 'Couchbase Lite Version (major only auto-resolves via ProGet)') string(name: 'SGW_VERSIONS', defaultValue: '3.2.7 3.3.3 3.3.4', description: 'Sync Gateway Versions to upgrade across') } stages { stage('Init') { + agent any steps { script { if (params.CBL_VERSION == '') { error "CBL_VERSION is required" } if (params.SGW_VERSIONS == '') { error "SGW_VERSIONS are required (atleast one)" } - currentBuild.displayName = "CBL:${params.CBL_VERSION} SGW:${params.SGW_VERSIONS} (#${currentBuild.number})" + env.CBL_VERSION = resolveProgetVersion("couchbase-lite-ios", params.CBL_VERSION, 'CBL_VERSION') + currentBuild.displayName = "CBL:${env.CBL_VERSION} SGW:${params.SGW_VERSIONS} (#${currentBuild.number})" } } } @@ -19,7 +38,7 @@ pipeline { build job: 'prebuild-test-server', parameters: [ string(name: 'TS_PLATFORM', value: 'swift_ios'), - string(name: 'CBL_VERSION', value: params.CBL_VERSION), + string(name: 'CBL_VERSION', value: env.CBL_VERSION), ], wait: true, propagate: true @@ -37,7 +56,7 @@ pipeline { sh "security unlock-keychain -p '${KEYCHAIN_PASSWORD}' ~/Library/Keychains/login.keychain-db" echo "=== Run SGW Upgrades" timeout(time: 90, unit: 'MINUTES') { - sh "jenkins/pipelines/QE/upg-sgw/test_rolling.sh ${params.CBL_VERSION} ${params.SGW_VERSIONS}" + sh "jenkins/pipelines/QE/upg-sgw/test_rolling.sh ${env.CBL_VERSION} ${params.SGW_VERSIONS}" } echo "=== SGW Upgrades Complete" }