-
Notifications
You must be signed in to change notification settings - Fork 47
/
cluster_manager.py
2752 lines (2248 loc) · 108 KB
/
cluster_manager.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Management API's for Couchbase Cluster"""
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from enum import Enum, auto
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
import requests
import urllib3
import couchbaseConstants
from x509_adapter import X509Adapter, X509AdapterFactory
N1QL_SERVICE = 'n1ql'
INDEX_SERVICE = 'index'
MGMT_SERVICE = 'mgmt'
FTS_SERVICE = 'fts'
EVENT_SERVICE = 'eventing'
CBAS_SERVICE = 'cbas'
BACKUP_SERVICE = 'backup'
ERR_AUTH = 'unable to access the REST API - please check your username (-u) and password (-p)'
ERR_INTERNAL = 'Internal server error, please retry your request'
DEFAULT_REQUEST_TIMEOUT = 60
try:
from cb_version import VERSION
except ImportError:
VERSION = '0.0.0-0000'
def one_zero_boolean_to_string(value: str) -> str:
"""Helper function to convert arguments with 1/0 string values to true or false"""
return 'true' if value == '1' else 'false'
# Remove this once we can verify SSL certificates
urllib3.disable_warnings()
def request(f):
def g(*args, **kwargs):
cm = args[0]
url = args[1]
try:
return f(*args, **kwargs)
except requests.exceptions.ConnectionError as e:
if '[SSL: CERTIFICATE_VERIFY_FAILED]' in str(e):
return None, ['Certificate verification failed.\n' +
'If you are using self-signed certificates you can re-run this command with\n' +
'the --no-ssl-verify flag. Note however that disabling ssl verification\n' +
'means that couchbase-cli will be vulnerable to man-in-the-middle attacks.\n\n' +
'For the most secure access to Couchbase make sure that you have X.509\n' +
'certificates set up in your cluster and use the --cacert flag to specify\n' +
'your client certificate.']
elif str(e).startswith('[SSL]'):
return None, [f'Unable to connect with the given CA certificate: {str(e)}']
return None, [f'Unable to connect to host at {cm.hostname}: {str(e)}']
except requests.exceptions.ReadTimeout:
return None, [f'Request to host `{url}` timed out after {cm.timeout} seconds']
return g
class ServiceNotAvailableException(Exception):
"""An exception raised when a service does not exist in the target cluster"""
def __init__(self, service):
Exception.__init__(self, f'Service {service} not available in target cluster')
class ClusterManager(object):
"""A set of REST API's for managing a Couchbase cluster"""
def __init__(self, hostname, username, password, ssl_flag=False, verify_cert=True, ca_cert=True, debug=False,
timeout=DEFAULT_REQUEST_TIMEOUT, client_ca: Optional[Path] = None,
client_ca_password: Optional[str] = None,
client_pk: Optional[Path] = None, client_pk_password: Optional[str] = None):
hostname = hostname.replace("couchbase://", "http://", 1)
hostname = hostname.replace("couchbases://", "https://", 1)
self.hostname = hostname
# verify argument on Request functions can take boolean or a path to a CA if
# a path is not provide but the cert still needs to be verified it should use
# the system provided CAs
self.verify_cert = verify_cert
self.ca_cert = ca_cert
if not verify_cert:
self.ca_cert = False
parsed = urllib.parse.urlparse(hostname)
if ssl_flag:
if parsed.scheme == 'http://':
if parsed.port == 8091:
self.hostname = f'https://{parsed.hostname}:18091'
else:
self.hostname = f'https://{parsed.hostname}:{parsed.port}'
# Certificates and verification are not used when the ssl flag is
# specified.
self.verify_cert = False
self.ca_cert = False
self.username = username.encode('utf-8').decode('latin1') if username is not None else ""
self.password = password.encode('utf-8').decode('latin1') if password is not None else ""
self.timeout = timeout
self.ssl = self.hostname.startswith("https://")
self.debug = debug
self.headers = requests.utils.default_headers()
self.headers.update(
{
'User-Agent': f'{os.path.basename(sys.argv[0])} {VERSION}',
}
)
self.session = requests.Session()
adapter = self._generate_x509_adapter(hostname, client_ca, client_ca_password, client_pk, client_pk_password)
if adapter:
self.session.mount("https://", adapter)
@classmethod
def _generate_x509_adapter(cls,
hostname: str,
client_ca: Optional[Path] = None,
client_ca_password: Optional[str] = None,
client_pk: Optional[Path] = None,
client_pk_password: Optional[str] = None) -> Optional[X509Adapter]:
if client_ca is None:
return None
password = client_pk_password if client_pk_password is not None else client_ca_password
password = password if password is not None else ""
return X509AdapterFactory(hostname, client_ca, client_pk=client_pk, password=password).generate()
def restore_index_metadata(self, bucket, index_defs):
hosts, errors = self.get_hostnames_for_service(INDEX_SERVICE)
if errors:
return None, errors
if not hosts:
raise ServiceNotAvailableException(INDEX_SERVICE)
url = f'{hosts[0]}/restoreIndexMetadata?bucket={bucket}'
return self._post_json(url, index_defs)
def get_index_metadata(self, bucket):
hosts, errors = self.get_hostnames_for_service(INDEX_SERVICE)
if errors:
return None, errors
if not hosts:
raise ServiceNotAvailableException(INDEX_SERVICE)
url = f'{hosts[0]}/getIndexMetadata?bucket={bucket}'
return self._get(url)
def restore_fts_index_metadata(self, index_defs):
hosts, errors = self.get_hostnames_for_service(FTS_SERVICE)
if errors:
return None, errors
if not hosts:
raise ServiceNotAvailableException(FTS_SERVICE)
for index_def in index_defs:
url = f'{hosts[0]}/api/index/{index_def["name"]}?prevIndexUUID=*'
if "sourceUUID" in index_def:
del index_def["sourceUUID"]
_, errors = self._put_json(url, index_def)
if errors:
return None, errors
return None, None
def get_fts_index_alias(self):
hosts, errors = self.get_hostnames_for_service(FTS_SERVICE)
if errors:
return None, errors
if not hosts:
raise ServiceNotAvailableException(FTS_SERVICE)
url = f'{hosts[0]}/api/index'
result, errors = self._get(url)
if errors:
return None, errors
index_defs = []
if "indexDefs" in result and result["indexDefs"] is not None:
for _, index in result['indexDefs']['indexDefs'].items():
if index['type'] == 'fulltext-alias':
index_defs.append(index)
return index_defs, None
def get_fts_index_metadata(self, bucket):
hosts, errors = self.get_hostnames_for_service(FTS_SERVICE)
if errors:
return None, errors
if not hosts:
raise ServiceNotAvailableException(FTS_SERVICE)
url = f'{hosts[0]}/api/index'
result, errors = self._get(url)
if errors:
return None, errors
bucket_index_defs = []
if "indexDefs" in result and result["indexDefs"] is not None:
for _, index_def in result["indexDefs"]["indexDefs"].items():
if index_def["type"] == "fulltext-index" and index_def["sourceName"] == bucket:
bucket_index_defs.append(index_def)
return bucket_index_defs, None
def n1ql_query(self, stmt, args=None):
"""Sends a N1QL query
Sends a N1QL query and returns the result of the query. Raises a
ServiceNotAvailable exception if the target cluster is no running the n1ql
service."""
hosts, errors = self.get_hostnames_for_service(N1QL_SERVICE)
if errors:
return None, errors
if not hosts:
raise ServiceNotAvailableException(N1QL_SERVICE)
url = f'{hosts[0]}/query/service'
body = {'statement': str(stmt)}
if args:
body['args'] = str(args)
result, errors = self._post_form_encoded(url, body)
if errors:
return None, errors
return result, None
def is_cluster_initialized(self):
data, errors = self.pools()
if (errors and len(errors) == 1 and errors[0] == ERR_AUTH) or \
(data and data['pools'] and len(data['pools']) > 0):
return True, None
return False, errors
def is_enterprise(self):
data, errors = self.pools()
if errors:
return None, errors
return data["isEnterprise"], None
def get_hostnames_for_service(self, service_name):
""" Gets all hostnames that run a service
Gets all hostnames for specified service and returns a list of strings
in the form "http://hostname:port". If the ClusterManager is configured
to use SSL/TLS then "https://" is prefixed to each name instead of
"http://"."""
url = f'{self.hostname}/pools/default/nodeServices'
data, errors = self._get(url)
if errors:
return None, errors
# this block of code will check if we are using internal or external address
# first get the host being used to get the node services info
used_host = urllib.parse.urlparse(self.hostname).hostname
use_alt = False
# next check if its external or internal
for node in data['nodesExt']:
if 'hostname' not in node and used_host in ['127.0.0.1', 'localhost']:
use_alt = False
break
if 'hostname' in node and used_host == node['hostname']:
use_alt = False
break
if 'alternateAddresses' in node and node['alternateAddresses']['external']['hostname'] == used_host:
use_alt = True
break
hosts = []
for node in data['nodesExt']:
# Single node cluster will not have a hostname, default to the hostname specified
# to work with remote clusters
node_host = used_host
if 'hostname' in node:
node_host = node['hostname']
# Check for Raw IPv6 address
if ':' in node_host:
node_host = '[' + node_host + ']'
http_prefix = 'http://'
fts_port_name = 'fts'
n1ql_port_name = 'n1ql'
mgmt_port_name = 'mgmt'
index_port_name = 'indexHttp'
event_port_name = 'eventingAdminPort'
cbas_port_name = 'cbas'
backup_port_name = 'backupAPI'
if self.ssl:
http_prefix = 'https://'
n1ql_port_name = 'n1qlSSL'
mgmt_port_name = 'mgmtSSL'
event_port_name = 'eventingSSL'
index_port_name = 'indexHttps'
fts_port_name = 'ftsSSL'
cbas_port_name = 'cbasSSL'
backup_port_name = 'backupAPIHTTPS'
services = node['services']
if use_alt and 'alternateAddresses' not in node:
continue
if 'alternateAddresses' in node and use_alt:
alt_node_host = node['alternateAddresses']['external']['hostname']
# Check for Raw IPv6 address
if ':' in alt_node_host:
alt_node_host = '[' + alt_node_host + ']'
node_host = alt_node_host
services = node['alternateAddresses']['external']['ports']
if service_name == MGMT_SERVICE and mgmt_port_name in services:
hosts.append(http_prefix + node_host + ':' + str(services[mgmt_port_name]))
if service_name == N1QL_SERVICE and n1ql_port_name in services:
hosts.append(http_prefix + node_host + ':' + str(services[n1ql_port_name]))
if service_name == INDEX_SERVICE and index_port_name in services:
hosts.append(http_prefix + node_host + ':' + str(services[index_port_name]))
if service_name == FTS_SERVICE and fts_port_name in services:
hosts.append(http_prefix + node_host + ':' + str(services[fts_port_name]))
if service_name == EVENT_SERVICE and event_port_name in services:
hosts.append(http_prefix + node_host + ':' + str(services[event_port_name]))
if service_name == CBAS_SERVICE and cbas_port_name in services:
hosts.append(http_prefix + node_host + ':' + str(services[cbas_port_name]))
if service_name == BACKUP_SERVICE and backup_port_name in services:
hosts.append(f'{http_prefix}{node_host}:{services[backup_port_name]}')
return hosts, None
def pools(self, pool=None):
""" Retrieves information about Couchbase management pools
Returns Couchbase pools data"""
url = f'{self.hostname}/pools'
if pool:
url += '/' + pool
return self._get(url)
def set_admin_password(self, password):
url = f'{self.hostname}/controller/resetAdminPassword'
params = {"password": password}
return self._post_form_encoded(url, params)
def regenerate_admin_password(self):
url = f'{self.hostname}/controller/resetAdminPassword?generate=1'
return self._post_form_encoded(url, None)
def lock_admin(self):
url = f'{self.hostname}/controller/lockAdmin'
return self._post_form_encoded(url, None)
def unlock_admin(self):
url = f'{self.hostname}/controller/unlockAdmin'
return self._post_form_encoded(url, None)
def rotate_master_pwd(self):
url = f'{self.hostname}/node/controller/rotateDataKey'
return self._post_form_encoded(url, None)
def set_master_pwd(self, password):
url = f'{self.hostname}/node/controller/changeMasterPassword'
params = {"newPassword": password}
return self._post_form_encoded(url, params)
def user_change_passsword(self, new_password):
url = f'{self.hostname}/controller/changePassword'
params = {'password': new_password}
return self._post_form_encoded(url, params)
def set_pools_default(self, data_ramsize, index_ramsize, fts_ramsize, cbas_ramsize, eventing_ramsize, query_ramsize,
cluster_name):
""" Sets Couchbase RAM Quotas for various services
Options:
data_ramsize - An integer denoting the size in MiB, None skips the parameter
index_ramsize - An integer denoting the size in MiB, None skips the parameter
fts_ramsize - An integer denoting the size in MiB, None skips the parameter
cbas_ramsize - An integer denoting the size in MiB, None skips the parameter
eventing_ramsize - An integer denoting the size in MiB, None skips the parameter
query_ramsize - An integer denoting the size in MiB, None skips the parameter
cluster_name - Sets a name for the cluster, None skips the parameter
"""
url = f'{self.hostname}/pools/default'
params = {}
if data_ramsize:
params["memoryQuota"] = data_ramsize
if index_ramsize:
params["indexMemoryQuota"] = index_ramsize
if fts_ramsize:
params["ftsMemoryQuota"] = fts_ramsize
if cbas_ramsize:
params["cbasMemoryQuota"] = cbas_ramsize
if eventing_ramsize:
params["eventingMemoryQuota"] = eventing_ramsize
if query_ramsize:
params["queryMemoryQuota"] = query_ramsize
if cluster_name:
params["clusterName"] = cluster_name
return self._post_form_encoded(url, params)
def set_admin_credentials(self, username, password, port):
"""Sets the admin credentials and port for a cluster
Options:
username - The username for the cluster
password - The password for the cluster
port - The port number for the admin console to listen on. If set to
None then the port is kept the same as it currently is.
"""
url = f'{self.hostname}/settings/web'
params = {}
if username:
params["username"] = username
if password:
params["password"] = password
if port:
params["port"] = port
else:
params["port"] = "SAME"
return self._post_form_encoded(url, params)
def enable_notifications(self, enable):
url = f'{self.hostname}/settings/stats'
params = {"sendStats": "false"}
if enable:
params["sendStats"] = "true"
return self._post_form_encoded(url, params)
def get_server_groups(self):
url = f'{self.hostname}/pools/default/serverGroups'
return self._get(url)
def get_server_group(self, group_name):
groups, errors = self.get_server_groups()
if errors:
return None, errors
if not groups or not groups["groups"] or groups["groups"] == 0:
return None, ["No server groups found"]
for group in groups["groups"]:
if group["name"] == group_name:
return group, None
return None, [f'Group `{group_name}` not found']
def get_add_node_uri(self, group_name):
# Not given a group, use the default 'addNode' API; this avoids using the 'serverGroups' API on CE. See MB-51094
# for more information.
if group_name is None or group_name == "":
return f"{self.hostname}/controller/addNode", None
group, errors = self.get_server_group(group_name)
if errors:
return None, errors
return f'{self.hostname}{group["addNodeURI"]}', None
def add_server(self, add_server, group_name, username, password, services):
uri, errors = self.get_add_node_uri(group_name)
if errors:
return None, errors
return self._post_form_encoded(
uri,
{"hostname": add_server, "user": username, "password": password, "services": services},
)
def readd_server(self, server):
all_cluster_nodes_info, errors = self._get_all_cluster_nodes_info()
if errors:
return None, errors
readd, errors = self._get_otp_names_of_matched_nodes(all_cluster_nodes_info, [server])
if errors:
return None, errors
if len(readd) != 1:
return None, ["Server not found %s" % server]
url = f'{self.hostname}/controller/reAddNode'
params = {"otpNode": readd[0]}
return self._post_form_encoded(url, params)
def get_tasks(self):
url = f'{self.hostname}/pools/default/tasks'
return self._get(url)
def collect_logs_start(self, servers, redaction_level, salt, log_dir, tmp_dir, upload, upload_host, upload_proxy,
upload_customer, upload_ticket):
url = f'{self.hostname}/controller/startLogsCollection'
params = dict()
if servers == "*":
params["nodes"] = servers
else:
nodes = servers.split(",")
all_cluster_nodes_info, errors = self._get_all_cluster_nodes_info()
if errors:
return None, errors
readd, errors = self._get_otp_names_of_matched_nodes(all_cluster_nodes_info, nodes)
if errors:
return None, errors
if len(nodes) != len(readd):
return None, ["Servers list contains invalid servers"]
params["nodes"] = ",".join(readd)
if redaction_level:
params["logRedactionLevel"] = redaction_level
if log_dir:
params["logDir"] = log_dir
if tmp_dir:
params["tmpDir"] = tmp_dir
if salt:
params["logRedactionSalt"] = salt
if upload:
if upload_host:
params["uploadHost"] = upload_host
if upload_proxy:
params["uploadProxy"] = upload_proxy
if upload_customer:
params["customer"] = upload_customer
if upload_ticket:
params["ticket"] = upload_ticket
return self._post_form_encoded(url, params)
def collect_logs_stop(self):
url = f'{self.hostname}/controller/cancelLogsCollection'
return self._post_form_encoded(url, dict())
def failover(self, servers_to_failover, hard, force):
all_cluster_nodes_info, errors = self._get_all_cluster_nodes_info()
if errors:
return None, errors
failover, errors = self._get_otp_names_of_matched_nodes(all_cluster_nodes_info, servers_to_failover)
if errors:
return None, errors
if len(failover) != len(servers_to_failover):
if len(servers_to_failover) == 1:
return None, [f"Server {servers_to_failover[0]} can't be failed over because it's not part of the "
"cluster"]
return None, ["Some nodes specified to be failed over are not part of the cluster"]
failover_with_statuses, errors = self._get_nodes_to_failover_statuses_by_otp(all_cluster_nodes_info, failover,
get_inactive=force)
if errors:
return None, errors
params = {"otpNode": [server for server, _ in failover_with_statuses]}
if hard:
if force:
params["allowUnsafe"] = "true"
url = f'{self.hostname}/controller/failOver'
return self._post_form_encoded(url, params)
for server, server_status in failover_with_statuses:
if server_status != 'healthy':
return None, [f"{server} can't be gracefully failed over because it is not healthy"]
url = f'{self.hostname}/controller/startGracefulFailover'
return self._post_form_encoded(url, params)
def recovery(self, server, recovery_type):
all_cluster_nodes_info, errors = self._get_all_cluster_nodes_info()
if errors:
return None, errors
readd, errors = self._get_otp_names_of_matched_nodes(all_cluster_nodes_info, [server])
if errors:
return None, errors
if len(readd) != 1:
return None, [f'Server not found {server}']
url = f'{self.hostname}/controller/setRecoveryType'
params = {"otpNode": readd[0],
"recoveryType": recovery_type}
return self._post_form_encoded(url, params)
def rebalance(self, remove_nodes):
all_cluster_nodes_info, errors = self._get_all_cluster_nodes_info()
if errors:
return None, errors
all_nodes, errors = self._get_all_nodes_otp_names(all_cluster_nodes_info)
if errors:
return None, errors
eject, errors = self._get_otp_names_of_matched_nodes(all_cluster_nodes_info, remove_nodes)
if errors:
return None, errors
if len(eject) != len(remove_nodes):
return None, ["Some nodes specified to be removed are not part of the cluster"]
url = f'{self.hostname}/controller/rebalance'
params = {"knownNodes": ','.join(all_nodes),
"ejectedNodes": ','.join(eject)}
return self._post_form_encoded(url, params)
def get_settings_rebalance(self):
url = f'{self.hostname}/settings/rebalance'
return self._get(url)
def set_settings_rebalance(self, rebalance_moves_per_node):
url = f'{self.hostname}/settings/rebalance'
params = {'rebalanceMovesPerNode': rebalance_moves_per_node}
return self._post_form_encoded(url, params)
def set_settings_rebalance_retry(self, enabled, wait_for, max_attempts):
url = f'{self.hostname}/settings/retryRebalance'
params = {'enabled': enabled}
if wait_for:
params['afterTimePeriod'] = wait_for
if max_attempts:
params['maxAttempts'] = max_attempts
return self._post_form_encoded(url, params)
def get_settings_rebalance_retry(self):
url = f'{self.hostname}/settings/retryRebalance'
return self._get(url)
def cancel_rebalance_retry(self, rebalance_id):
url = f'{self.hostname}/controller/cancelRebalanceRetry/{rebalance_id}'
return self._post_form_encoded(url, {})
def get_rebalance_info(self):
url = f'{self.hostname}/pools/default/pendingRetryRebalance'
return self._get(url)
def rebalance_status(self):
data, errors = self.get_tasks()
if errors:
return (None, None), errors
rv = {
"status": "unknown",
"msg": "unknown state",
"details": {}
}
for task in data:
if task["type"] != "rebalance":
continue
if "errorMessage" in task:
rv["status"] = "errored"
rv["msg"] = task['errorMessage']
break
elif task["status"] == "running":
rv["status"] = task["status"]
rv["msg"] = "Rebalance is running"
rv["details"]["progress"] = task["progress"]
rv["details"]["refresh"] = task["recommendedRefreshPeriod"]
rv["details"]["totalBuckets"] = 0
rv["details"]["curBucket"] = 0
rv["details"]["curBucketName"] = ""
if "bucketsCount" in task["detailedProgress"]:
rv["details"]["totalBuckets"] = task["detailedProgress"]["bucketsCount"]
if "bucketNumber" in task["detailedProgress"]:
rv["details"]["curBucket"] = task["detailedProgress"]["bucketNumber"]
if "bucket" in task["detailedProgress"]:
rv["details"]["curBucketName"] = task["detailedProgress"]["bucket"]
acc = 0
if "perNode" in task["detailedProgress"]:
for _, node in task["detailedProgress"]["perNode"].items():
acc += node["ingoing"]["docsTotal"] - node["ingoing"]["docsTransferred"]
acc += node["outgoing"]["docsTotal"] - node["outgoing"]["docsTransferred"]
rv["details"]["docsRemaining"] = acc
elif task["status"] == "notRunning":
rv["status"] = task["status"]
rv["msg"] = "Rebalance is not running"
if "statusIsStale" in task and (task["statusIsStale"] or task["statusIsStale"] == "true"):
rv["status"] = "stale"
rv["msg"] = "Current status is stale, please retry"
elif "masterRequestTimedOut" in task and task["masterRequestTimedOut"]:
rv["status"] = "stale"
rv["msg"] = "Orchestrator request timed out, please retry"
break
return rv, None
def _get_all_cluster_nodes_info(self):
result, errors = self.pools('default')
if errors:
return None, errors
if "nodes" not in result:
return None, ["Could not get info about the nodes in the cluster"]
return result["nodes"], None
@classmethod
def _get_all_nodes_otp_names(cls, all_cluster_nodes_info):
all_cluster_nodes_otp_names = []
for node in all_cluster_nodes_info:
err = cls._check_otp_name_in_node(node)
if err is not None:
return None, [err]
all_cluster_nodes_otp_names.append(node['otpNode'])
return all_cluster_nodes_otp_names, None
# otpNode should only be printed out or handed back to ns_server
# It should never be used to create a connection to a node
@classmethod
def _get_otp_names_of_matched_nodes(cls, all_cluster_nodes_info, nodes_to_match):
matched_nodes_otp_names = []
nodes_to_match = cls._remove_schemes_from_nodes(nodes_to_match)
for node in all_cluster_nodes_info:
err = cls._check_otp_name_in_node(node)
if err is not None:
return None, [err]
otp_name = node['otpNode']
if "hostname" not in node:
return None, [f"Unable to get the hostname of the {otp_name} node"]
hostname = node['hostname']
if 'ports' not in node:
return None, [f"Unable to get the ports of the {hostname} node"]
ports = node['ports']
ports_to_check = []
if 'httpsMgmt' in ports:
ports_to_check.append(ports['httpsMgmt'])
_, otp_hostname = otp_name.split('@')
node_hostname, node_port = cls._get_hostname_and_port(hostname)
if node_hostname is None or node_port is None:
return None, [f"Failed to get hostname and port of the {hostname} node"]
ports_to_check.append(node_port)
for port in ports_to_check:
if f'[{node_hostname}]:{port}' in nodes_to_match or f'[{otp_hostname}]:{port}' in nodes_to_match or \
f'{node_hostname}:{port}' in nodes_to_match or f'{otp_hostname}:{port}' in nodes_to_match:
matched_nodes_otp_names.append(otp_name)
return matched_nodes_otp_names, None
@classmethod
def _check_otp_name_in_node(cls, node):
if "otpNode" not in node:
# Try to provide the hostname of the node to help with investigating why its Otp name could not be
# retrieved from the default cluster management pool
if "hostname" not in node:
return "Unable to get the Otp name or the hostname of a cluster node"
return f"Unable to get the Otp name of the {node['hostname']} node"
return None
@classmethod
def _remove_schemes_from_nodes(cls, nodes):
return [re.sub('.*//', '', node_url) for node_url in nodes]
@classmethod
def _get_hostname_and_port(cls, node):
# We need to support node urls without schemes, prepending '//' makes urlparse() correctly identify hostnames
if '//' not in node:
node = f'//{node}'
parse_result = urlparse(node)
hostname = parse_result.hostname
try:
port = parse_result.port
except ValueError:
return None, None
return hostname, port
@classmethod
def _get_nodes_to_failover_statuses_by_otp(cls, all_cluster_nodes_info, nodes_otp_names, get_inactive=False):
nodes_otp_names_with_states = []
for node in all_cluster_nodes_info:
otp_name = node['otpNode']
if otp_name in nodes_otp_names:
valid_states = ['active', 'inactiveFailed', 'inactiveAdded'] if get_inactive else ['active']
if node['clusterMembership'] not in valid_states:
return None, ["Can't failover a node that isn't in the cluster"]
nodes_otp_names_with_states.append((otp_name, node['status']))
return nodes_otp_names_with_states, None
def create_bucket(self, name, bucket_type, storage_type, memory_quota,
durability_min_level,
eviction_policy, replicas, replica_indexes,
threads_number, conflict_resolution, flush_enabled,
max_ttl, compression_mode, sync, db_frag_perc, db_frag_size, view_frag_perc,
view_frag_size, from_hour, from_min, to_hour, to_min,
abort_outside, paralleldb_and_view_compact, purge_interval,
history_retention_bytes, history_retention_seconds,
history_retention_default, rank, timeout=60):
url = f'{self.hostname}/pools/default/buckets'
if name is None:
return None, ["The bucket name is required when creating a bucket"]
if bucket_type is None:
return None, ["The bucket type is required when creating a bucket"]
if memory_quota is None:
return None, ["The bucket memory quota is required when creating a bucket"]
params = {"name": name,
"bucketType": bucket_type,
"ramQuotaMB": memory_quota}
if eviction_policy is not None:
params["evictionPolicy"] = eviction_policy
if replicas is not None:
params["replicaNumber"] = replicas
if replica_indexes is not None:
params["replicaIndex"] = replica_indexes
if threads_number is not None:
params["threadsNumber"] = threads_number
if conflict_resolution is not None:
params["conflictResolutionType"] = conflict_resolution
if flush_enabled is not None:
params["flushEnabled"] = flush_enabled
if max_ttl is not None:
params["maxTTL"] = max_ttl
if compression_mode is not None:
params["compressionMode"] = compression_mode
if storage_type is not None:
params["storageBackend"] = storage_type
if durability_min_level is not None:
params["durabilityMinLevel"] = durability_min_level
if history_retention_bytes is not None:
params["historyRetentionBytes"] = history_retention_bytes
if history_retention_seconds is not None:
params["historyRetentionSeconds"] = history_retention_seconds
if history_retention_default is not None:
params["historyRetentionCollectionDefault"] = one_zero_boolean_to_string(history_retention_default)
if rank is not None:
params["rank"] = rank
if bucket_type == "couchbase":
if (db_frag_perc is not None or db_frag_size is not None or view_frag_perc is not None or
view_frag_size is not None or from_hour is not None or from_min is not None or
to_hour is not None or to_min is not None or abort_outside is not None or
paralleldb_and_view_compact is not None or purge_interval is not None):
params["autoCompactionDefined"] = "true"
params["parallelDBAndViewCompaction"] = "false"
if db_frag_perc is not None:
params["databaseFragmentationThreshold[percentage]"] = db_frag_perc
if db_frag_size is not None:
params["databaseFragmentationThreshold[size]"] = db_frag_size * 1024 ** 2
if view_frag_perc is not None:
params["viewFragmentationThreshold[percentage]"] = view_frag_perc
if view_frag_size is not None:
params["viewFragmentationThreshold[size]"] = view_frag_size * 1024 ** 2
if to_min is not None:
params["allowedTimePeriod[toMinute]"] = to_min
if to_hour is not None:
params["allowedTimePeriod[toHour]"] = to_hour
if from_min is not None:
params["allowedTimePeriod[fromMinute]"] = from_min
if from_hour is not None:
params["allowedTimePeriod[fromHour]"] = from_hour
if abort_outside is not None:
params["allowedTimePeriod[abortOutside]"] = one_zero_boolean_to_string(abort_outside)
if paralleldb_and_view_compact is not None:
params["parallelDBAndViewCompaction"] = one_zero_boolean_to_string(paralleldb_and_view_compact)
if bucket_type != "memcached" and purge_interval is not None:
params["purgeInterval"] = purge_interval
result, errors = self._post_form_encoded(url, params)
if errors:
return None, errors
if sync:
all_node_ready = False
start = time.time()
while (time.time() - start) <= timeout and not all_node_ready:
buckets, errors = self.list_buckets()
if name not in buckets:
time.sleep(1)
continue
url = f'{self.hostname}/pools/default/buckets/{name}'
content, errors = self._get(url)
if errors:
return None, errors
nodes = content["nodes"]
if len(nodes) == 0:
time.sleep(1)
continue
all_node_ready = True
for node in nodes:
if node["status"] != "healthy":
all_node_ready = False
break
if not all_node_ready:
time.sleep(1)
if not all_node_ready:
return None, [f'Bucket created, but not ready after {timeout} seconds']
return result, None
def edit_bucket(self, name, memory_quota, durability_min_level, eviction_policy,
replicas, threads_number, flush_enabled, max_ttl,
compression_mode, remove_port, db_frag_perc, db_frag_size, view_frag_perc,
view_frag_size, from_hour, from_min, to_hour, to_min,
abort_outside, paralleldb_and_view_compact, purge_interval,
history_retention_bytes, history_retention_seconds, history_retention_default,
rank, couchbase_bucket: bool = True):
url = f'{self.hostname}/pools/default/buckets/{name}'
if name is None:
return None, ["The bucket name is required when editing a bucket"]
params = {}
if memory_quota is not None:
params["ramQuotaMB"] = memory_quota
if eviction_policy is not None:
params["evictionPolicy"] = eviction_policy
if replicas is not None:
params["replicaNumber"] = replicas
if threads_number is not None:
params["threadsNumber"] = threads_number
if flush_enabled is not None:
params["flushEnabled"] = flush_enabled
if max_ttl is not None:
params["maxTTL"] = max_ttl
if compression_mode is not None:
params["compressionMode"] = compression_mode
if remove_port:
params["proxyPort"] = "none"
if (db_frag_perc is not None or db_frag_size is not None or view_frag_perc is not None or
view_frag_size is not None or from_hour is not None or from_min is not None or
to_hour is not None or to_min is not None or abort_outside is not None or
paralleldb_and_view_compact is not None or purge_interval is not None) and couchbase_bucket:
params["autoCompactionDefined"] = "true"
params["parallelDBAndViewCompaction"] = "false"
if db_frag_perc is not None:
params["databaseFragmentationThreshold[percentage]"] = db_frag_perc
if db_frag_size is not None:
params["databaseFragmentationThreshold[size]"] = db_frag_size * 1024 ** 2
if view_frag_perc is not None: