|
1 | 1 | from datetime import timedelta |
2 | 2 | from time import sleep |
| 3 | +from typing import cast |
| 4 | +from urllib.parse import quote_plus, urlparse |
3 | 5 |
|
| 6 | +import requests |
4 | 7 | from couchbase.auth import PasswordAuthenticator |
5 | 8 | from couchbase.cluster import Cluster |
6 | 9 | from couchbase.exceptions import ( |
@@ -34,8 +37,13 @@ def __init__(self, url: str, username: str, password: str): |
34 | 37 | if "://" not in url: |
35 | 38 | url = f"couchbase://{url}" |
36 | 39 |
|
| 40 | + self.__hostname = ( |
| 41 | + urlparse(url).hostname or urlparse(url).netloc.split(":")[0] |
| 42 | + ) |
37 | 43 | auth = PasswordAuthenticator(username, password) |
38 | 44 | opts = ClusterOptions(auth) |
| 45 | + self.__username = username |
| 46 | + self.__password = password |
39 | 47 | self.__cluster = Cluster(url, opts) |
40 | 48 | self.__cluster.wait_until_ready(timedelta(seconds=10)) |
41 | 49 |
|
@@ -176,3 +184,160 @@ def run_query( |
176 | 184 | pass |
177 | 185 |
|
178 | 186 | 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() |
0 commit comments