Skip to content

Commit c965a4a

Browse files
authored
Add API for starting and stopping XDCR (#250)
Alongside this, implement 1.2.2 of the API spec (headers property) and fix bug in greenboard uploader when no test servers are present.
1 parent 6b78d19 commit c965a4a

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

client/src/cbltest/api/couchbaseserver.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from datetime import timedelta
22
from time import sleep
3+
from typing import cast
4+
from urllib.parse import quote_plus, urlparse
35

6+
import requests
47
from couchbase.auth import PasswordAuthenticator
58
from couchbase.cluster import Cluster
69
from couchbase.exceptions import (
@@ -34,8 +37,13 @@ def __init__(self, url: str, username: str, password: str):
3437
if "://" not in url:
3538
url = f"couchbase://{url}"
3639

40+
self.__hostname = (
41+
urlparse(url).hostname or urlparse(url).netloc.split(":")[0]
42+
)
3743
auth = PasswordAuthenticator(username, password)
3844
opts = ClusterOptions(auth)
45+
self.__username = username
46+
self.__password = password
3947
self.__cluster = Cluster(url, opts)
4048
self.__cluster.wait_until_ready(timedelta(seconds=10))
4149

@@ -176,3 +184,160 @@ def run_query(
176184
pass
177185

178186
return list(dict(result) for result in query_obj.execute())
187+
188+
def start_xdcr(self, target: "CouchbaseServer", bucket_name: str) -> None:
189+
"""
190+
Starts an XDCR replication from this cluster to the target cluster
191+
192+
:param target: The target CouchbaseServer instance to replicate to
193+
:param source_bucket: The bucket on this cluster to replicate from
194+
:param target_bucket: The bucket on the target cluster to replicate to
195+
"""
196+
with self.__tracer.start_as_current_span(
197+
"start_xdcr",
198+
attributes={
199+
"cbl.bucket": bucket_name,
200+
"cbl.target.hostname": target.__hostname,
201+
},
202+
):
203+
with requests.Session() as session:
204+
session.auth = (self.__username, self.__password)
205+
206+
# Get the existing remote cluster, if any...
207+
resp = session.get(
208+
f"http://{self.__hostname}:8091/pools/default/remoteClusters"
209+
)
210+
resp.raise_for_status()
211+
resp_body = resp.json()
212+
remote_cluster_uuid: str | None = None
213+
for cluster in resp_body:
214+
if (
215+
"name" in cluster
216+
and cast(str, cluster["name"]) == target.__hostname
217+
):
218+
remote_cluster_uuid = cluster["uuid"]
219+
break
220+
221+
# https://docs.couchbase.com/server/current/learn/clusters-and-availability/xdcr-active-active-sgw.html#xdcr-active-active-sgw-prerequisites
222+
# Set the prerequisite properties. These return 409 is they are already set.
223+
resp = session.post(
224+
f"http://{self.__hostname}:8091/pools/default/buckets/{bucket_name}",
225+
data={"enableCrossClusterVersioning": "true"},
226+
)
227+
if resp.status_code != 409:
228+
resp.raise_for_status()
229+
230+
resp = session.post(
231+
f"http://{target.__hostname}:8091/pools/default/buckets/{bucket_name}",
232+
data={"enableCrossClusterVersioning": "true"},
233+
)
234+
if resp.status_code != 409:
235+
resp.raise_for_status()
236+
237+
# https://docs.couchbase.com/server/current/manage/manage-xdcr/create-xdcr-replication.html#create-an-xdcr-replication-with-the-rest-api
238+
# Create the remote cluster, if necessary
239+
if remote_cluster_uuid is None:
240+
resp = session.post(
241+
f"http://{self.__hostname}:8091/pools/default/remoteClusters",
242+
data={
243+
"username": target.__username,
244+
"password": target.__password,
245+
"hostname": target.__hostname,
246+
"name": target.__hostname,
247+
"demandEncryption": 0,
248+
"mobile": "active",
249+
},
250+
)
251+
resp.raise_for_status()
252+
253+
needs_replication = True
254+
if remote_cluster_uuid is not None:
255+
# If the remote cluster didn't exist, the replication could not have existed
256+
# so skip the lookup. Otherwise, check for a replication that is already
257+
# going out to the remote cluster in question.
258+
resp = session.get(
259+
f"http://{self.__hostname}:8091/pools/default/tasks"
260+
)
261+
resp.raise_for_status()
262+
for task in resp.json():
263+
if "type" in task and task["type"] == "xdcr":
264+
if "id" in task:
265+
id = task["id"]
266+
if (
267+
id
268+
== f"{remote_cluster_uuid}/{bucket_name}/{bucket_name}"
269+
):
270+
needs_replication = False
271+
break
272+
273+
if needs_replication:
274+
resp = session.post(
275+
f"http://{self.__hostname}:8091/controller/createReplication",
276+
data={
277+
"fromBucket": bucket_name,
278+
"toCluster": target.__hostname,
279+
"toBucket": bucket_name,
280+
"replicationType": "continuous",
281+
"compressionLevel": "Auto",
282+
},
283+
)
284+
resp.raise_for_status()
285+
286+
def stop_xcdr(self, target: "CouchbaseServer", bucket_name: str) -> None:
287+
"""
288+
Stops an XDCR replication from this cluster to the target cluster. Note
289+
that this does not remove the remote cluster.
290+
291+
:param target: The target CouchbaseServer instance to replicate to
292+
:param source_bucket: The bucket on this cluster to replicate from
293+
:param target_bucket: The bucket on the target cluster to replicate to
294+
"""
295+
with self.__tracer.start_as_current_span(
296+
"stop_xdcr",
297+
attributes={
298+
"cbl.bucket": bucket_name,
299+
"cbl.target.hostname": target.__hostname,
300+
},
301+
):
302+
with requests.Session() as session:
303+
session.auth = (self.__username, self.__password)
304+
305+
# See if the remote cluster already exists
306+
resp = session.get(
307+
f"http://{self.__hostname}:8091/pools/default/remoteClusters"
308+
)
309+
resp.raise_for_status()
310+
resp_body = resp.json()
311+
remote_cluster_uuid: str | None = None
312+
for cluster in resp_body:
313+
if (
314+
"name" in cluster
315+
and cast(str, cluster["name"]) == target.__hostname
316+
):
317+
remote_cluster_uuid = cluster["uuid"]
318+
break
319+
320+
if remote_cluster_uuid is None:
321+
return
322+
323+
# See if the XDCR already exists
324+
resp = session.get(f"http://{self.__hostname}:8091/pools/default/tasks")
325+
resp.raise_for_status()
326+
xdcr_id: str | None = None
327+
for task in resp.json():
328+
if "type" in task and task["type"] == "xdcr":
329+
if "id" in task:
330+
id = task["id"]
331+
if (
332+
id
333+
== f"{remote_cluster_uuid}/{bucket_name}/{bucket_name}"
334+
):
335+
xdcr_id = id
336+
break
337+
338+
if xdcr_id is not None:
339+
encoded = quote_plus(xdcr_id)
340+
resp = session.delete(
341+
f"http://{self.__hostname}:8091/controller/cancelXDCR/{encoded}",
342+
)
343+
resp.raise_for_status()

client/src/cbltest/api/replicator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(
7575
enable_document_listener: bool = False,
7676
enable_auto_purge: bool = True,
7777
pinned_server_cert: str | None = None,
78+
headers: dict[str, str] | None = None,
7879
):
7980
assert database._request_factory.version == 1, (
8081
"This version of the cbl test API requires request API v1"
@@ -113,6 +114,9 @@ def __init__(
113114
self.pinned_server_cert: str | None = pinned_server_cert
114115
"""The PEM representation of the certificate that the remote is using"""
115116

117+
self.headers: dict[str, str] | None = headers
118+
"""Optional headers to add to the replication requests"""
119+
116120
def add_default_collection(self) -> None:
117121
"""A convenience method for adding the default config for the default collection, if desired"""
118122
self.collections.append(ReplicatorCollectionEntry())

client/src/cbltest/plugins/greenboard_fixture.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ async def greenboard(cblpytest: CBLPyTest, pytestconfig: pytest.Config):
2525
yield
2626
return
2727

28+
if len(cblpytest.test_servers) == 0:
29+
cbl_info("No test servers available, skipping greenboard upload")
30+
yield
31+
return
32+
2833
uploader = GreenboardUploader(
2934
cblpytest.config.greenboard_url,
3035
cblpytest.config.greenboard_username,

client/src/cbltest/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Final
22

33
# For hatchling to easily detect the version
4-
__version__ = "1.2.1"
4+
__version__ = "1.2.2"
55

66
# Typed version for outside use
77
VERSION: Final[str] = __version__

0 commit comments

Comments
 (0)