1#!/usr/local/bin/python3.8 2 3# Copyright: (c) 2018, Rhys Campbell <rhys.james.campbell@googlemail.com> 4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 6from __future__ import absolute_import, division, print_function 7__metaclass__ = type 8 9 10DOCUMENTATION = r''' 11--- 12module: mongodb_status 13short_description: Validates the status of the replicaset. 14description: 15 - Validates the status of the replicaset. 16 - The module expects all replicaset nodes to be PRIMARY, SECONDARY or ARBITER. 17 - Will wait until a timeout for the replicaset state to converge if required. 18 - Can also be used to lookup the current PRIMARY member (see examples). 19author: Rhys Campbell (@rhysmeister) 20version_added: "1.0.0" 21 22extends_documentation_fragment: 23 - community.mongodb.login_options 24 - community.mongodb.ssl_options 25 26options: 27 replica_set: 28 description: 29 - Replicaset name. 30 type: str 31 default: rs0 32 poll: 33 description: 34 - The maximum number of times to query for the replicaset status before the set converges or we fail. 35 type: int 36 default: 1 37 interval: 38 description: 39 - The number of seconds to wait between polling executions. 40 type: int 41 default: 30 42 validate: 43 description: 44 - The type of validate to perform on the replicaset. 45 - default, Suitable for most purposes. Validate that there are an odd 46 number of servers and one is PRIMARY and the remainder are in a SECONDARY 47 or ARBITER state. 48 - votes, Check the number of votes is odd and one is a PRIMARY and the 49 remainder are in a SECONDARY or ARBITER state. Authentication is 50 required here to get the replicaset configuration. 51 - minimal, Just checks that one server is in a PRIMARY state with the 52 remainder being SECONDARY or ARBITER. 53 type: str 54 choices: 55 - default 56 - votes 57 - minimal 58 default: default 59notes: 60- Requires the pymongo Python package on the remote host, version 2.4.2+. This 61 can be installed using pip or the OS package manager. @see U(http://api.mongodb.org/python/current/installation.html) 62requirements: 63- pymongo 64''' 65 66EXAMPLES = r''' 67- name: Check replicaset is healthy, fail if not after first attempt 68 community.mongodb.mongodb_status: 69 replica_set: rs0 70 when: ansible_hostname == "mongodb1" 71 72- name: Wait for the replicaset rs0 to converge, check 5 times, 10 second interval between checks 73 community.mongodb.mongodb_status: 74 replica_set: rs0 75 poll: 5 76 interval: 10 77 when: ansible_hostname == "mongodb1" 78 79# Get the replicaset status and then lookup the primary's hostname and save to a variable 80- name: Ensure replicaset is stable before beginning 81 community.mongodb.mongodb_status: 82 login_user: "{{ admin_user }}" 83 login_password: "{{ admin_user_password }}" 84 poll: 3 85 interval: 10 86 register: rs 87 88- name: Lookup PRIMARY replicaset member 89 set_fact: 90 primary: "{{ item.key.split('.')[0] }}" 91 loop: "{{ lookup('dict', rs.replicaset) }}" 92 when: "'PRIMARY' in item.value" 93''' 94 95RETURN = r''' 96failed: 97 description: If the module has failed or not. 98 returned: always 99 type: bool 100iterations: 101 description: Number of times the module has queried the replicaset status. 102 returned: always 103 type: int 104msg: 105 description: Status message. 106 returned: always 107 type: str 108replicaset: 109 description: The last queried status of all the members of the replicaset if obtainable. 110 returned: always 111 type: dict 112''' 113 114 115from copy import deepcopy 116import time 117 118import os 119import ssl as ssl_lib 120from distutils.version import LooseVersion 121import traceback 122 123 124from ansible.module_utils.basic import AnsibleModule 125from ansible.module_utils.six import binary_type, text_type 126from ansible.module_utils.six.moves import configparser 127from ansible.module_utils._text import to_native 128from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( 129 check_compatibility, 130 missing_required_lib, 131 load_mongocnf, 132 mongodb_common_argument_spec, 133 ssl_connection_options 134) 135from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import PyMongoVersion, PYMONGO_IMP_ERR, pymongo_found, MongoClient 136 137 138def replicaset_config(client): 139 """ 140 Return the replicaset config document 141 https://docs.mongodb.com/manual/reference/command/replSetGetConfig/ 142 """ 143 rs = client.admin.command('replSetGetConfig') 144 return rs 145 146 147def replicaset_votes(config_document): 148 """ 149 Return the number of votes in the replicaset 150 """ 151 votes = 0 152 for member in config_document["config"]['members']: 153 votes += member['votes'] 154 return votes 155 156 157def replicaset_status(client, module): 158 """ 159 Return the replicaset status document from MongoDB 160 # https://docs.mongodb.com/manual/reference/command/replSetGetStatus/ 161 """ 162 rs = client.admin.command('replSetGetStatus') 163 return rs 164 165 166def replicaset_members(replicaset_document): 167 """ 168 Returns the members section of the MongoDB replicaset document 169 """ 170 return replicaset_document["members"] 171 172 173def replicaset_friendly_document(members_document): 174 """ 175 Returns a version of the members document with 176 only the info this module requires: name & stateStr 177 """ 178 friendly_document = {} 179 180 for member in members_document: 181 friendly_document[member["name"]] = member["stateStr"] 182 return friendly_document 183 184 185def replicaset_statuses(members_document, module): 186 """ 187 Return a list of the statuses 188 """ 189 statuses = [] 190 for member in members_document: 191 statuses.append(members_document[member]) 192 return statuses 193 194 195def replicaset_good(statuses, module, votes): 196 """ 197 Returns true if the replicaset is in a "good" condition. 198 Good is defined as an odd number of servers >= 3, with 199 max one primary, and any even amount of 200 secondary and arbiter servers 201 """ 202 msg = "Unset" 203 status = None 204 valid_statuses = ["PRIMARY", "SECONDARY", "ARBITER"] 205 validate = module.params['validate'] 206 207 if validate == "default": 208 if len(statuses) % 2 == 1: 209 if (statuses.count("PRIMARY") == 1 210 and ((statuses.count("SECONDARY") 211 + statuses.count("ARBITER")) % 2 == 0) 212 and len(set(statuses) - set(valid_statuses)) == 0): 213 status = True 214 msg = "replicaset is in a converged state" 215 else: 216 status = False 217 msg = "replicaset is not currently in a converged state" 218 else: 219 msg = "Even number of servers in replicaset." 220 status = False 221 elif validate == "votes": 222 # Need to validate the number of votes in the replicaset 223 if votes % 2 == 1: # We have a good number of votes 224 if (statuses.count("PRIMARY") == 1 225 and len(set(statuses) - set(valid_statuses)) == 0): 226 status = True 227 msg = "replicaset is in a converged state" 228 else: 229 status = False 230 msg = "replicaset is not currently in a converged state" 231 else: 232 msg = "Even number of votes in replicaset." 233 status = False 234 elif validate == "minimal": 235 if (statuses.count("PRIMARY") == 1 236 and len(set(statuses) - set(valid_statuses)) == 0): 237 status = True 238 msg = "replicaset is in a converged state" 239 else: 240 status = False 241 msg = "replicaset is not currently in a converged state" 242 else: 243 module.fail_json(msg="Invalid value for validate has been provided: {0}".format(validate)) 244 return status, msg 245 246 247def replicaset_status_poll(client, module): 248 """ 249 client - MongoDB Client 250 poll - Number of times to poll 251 interval - interval between polling attempts 252 """ 253 iterations = 0 # How many times we have queried the cluster 254 failures = 0 # Number of failures when querying the replicaset 255 poll = module.params['poll'] 256 interval = module.params['interval'] 257 status = None 258 return_doc = {} 259 votes = None 260 config = None 261 262 while iterations < poll: 263 try: 264 iterations += 1 265 replicaset_document = replicaset_status(client, module) 266 members = replicaset_members(replicaset_document) 267 friendly_document = replicaset_friendly_document(members) 268 statuses = replicaset_statuses(friendly_document, module) 269 270 if module.params['validate'] == "votes": # Requires auth 271 config = replicaset_config(client) 272 votes = replicaset_votes(config) 273 274 status, msg = replicaset_good(statuses, module, votes) 275 276 if status: # replicaset looks good 277 return_doc = {"failures": failures, 278 "poll": poll, 279 "iterations": iterations, 280 "msg": msg, 281 "replicaset": friendly_document} 282 break 283 else: 284 failures += 1 285 return_doc = {"failures": failures, 286 "poll": poll, 287 "iterations": iterations, 288 "msg": msg, 289 "replicaset": friendly_document, 290 "failed": True} 291 if iterations == poll: 292 break 293 else: 294 time.sleep(interval) 295 except Exception as e: 296 failures += 1 297 return_doc['failed'] = True 298 return_doc['msg'] = str(e) 299 status = False 300 if iterations == poll: 301 break 302 else: 303 time.sleep(interval) 304 305 return_doc['failures'] = failures 306 return status, return_doc['msg'], return_doc 307 308 309# ========================================= 310# Module execution. 311# 312 313 314def main(): 315 argument_spec = mongodb_common_argument_spec() 316 argument_spec.update( 317 interval=dict(type='int', default=30), 318 poll=dict(type='int', default=1), 319 replica_set=dict(type='str', default="rs0"), 320 validate=dict(type='str', choices=['default', 'votes', 'minimal'], default='default'), 321 ) 322 module = AnsibleModule( 323 argument_spec=argument_spec, 324 supports_check_mode=False, 325 required_together=[['login_user', 'login_password']], 326 ) 327 if not pymongo_found: 328 module.fail_json(msg=missing_required_lib('pymongo'), 329 exception=PYMONGO_IMP_ERR) 330 331 login_user = module.params['login_user'] 332 login_password = module.params['login_password'] 333 login_database = module.params['login_database'] 334 login_host = module.params['login_host'] 335 login_port = module.params['login_port'] 336 replica_set = module.params['replica_set'] 337 ssl = module.params['ssl'] 338 poll = module.params['poll'] 339 interval = module.params['interval'] 340 341 result = dict( 342 failed=False, 343 replica_set=replica_set, 344 ) 345 346 connection_params = dict( 347 host=login_host, 348 port=int(login_port), 349 ) 350 351 if ssl: 352 connection_params = ssl_connection_options(connection_params, module) 353 354 try: 355 client = MongoClient(**connection_params) 356 except Exception as e: 357 module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) 358 359 try: 360 # Get server version: 361 try: 362 srv_version = LooseVersion(client.server_info()['version']) 363 except Exception as e: 364 module.fail_json(msg='Unable to get MongoDB server version: %s' % to_native(e)) 365 366 # Get driver version:: 367 driver_version = LooseVersion(PyMongoVersion) 368 369 # Check driver and server version compatibility: 370 check_compatibility(module, srv_version, driver_version) 371 except Exception as excep: 372 if hasattr(excep, 'code') and excep.code == 13: 373 raise excep 374 if login_user is None or login_password is None: 375 raise excep 376 client.admin.authenticate(login_user, login_password, source=login_database) 377 check_compatibility(module, client) 378 379 if login_user is None and login_password is None: 380 mongocnf_creds = load_mongocnf() 381 if mongocnf_creds is not False: 382 login_user = mongocnf_creds['user'] 383 login_password = mongocnf_creds['password'] 384 elif login_password is None or login_user is None: 385 module.fail_json(msg="When supplying login arguments, both 'login_user' and 'login_password' must be provided") 386 387 try: 388 client['admin'].command('listDatabases', 1.0) # if this throws an error we need to authenticate 389 except Exception as excep: 390 if "not authorized on" in str(excep) or "command listDatabases requires authentication" in str(excep): 391 if login_user is not None and login_password is not None: 392 try: 393 client.admin.authenticate(login_user, login_password, source=login_database) 394 except Exception as excep: 395 module.fail_json(msg='unable to connect to database: %s' % to_native(excep), exception=traceback.format_exc()) 396 else: 397 module.fail_json(msg='unable to connect to database: %s' % to_native(excep), exception=traceback.format_exc()) 398 else: 399 module.fail_json(msg='unable to connect to database: %s' % to_native(excep), exception=traceback.format_exc()) 400 401 if len(replica_set) == 0: 402 module.fail_json(msg="Parameter 'replica_set' must not be an empty string") 403 404 try: 405 status, msg, return_doc = replicaset_status_poll(client, module) # Sort out the return doc 406 replicaset = return_doc['replicaset'] 407 iterations = return_doc['iterations'] 408 except Exception as e: 409 module.fail_json(msg='Unable to query replica_set info: {0}: {1}'.format(str(e), msg)) 410 411 if status is False: 412 module.fail_json(msg=msg, replicaset=replicaset, iterations=iterations) 413 else: 414 module.exit_json(msg=msg, replicaset=replicaset, iterations=iterations) 415 416 417if __name__ == '__main__': 418 main() 419