#!/usr/bin/env python DOCUMENTATION = """ --- module: mongodb_replica_set short_description: Modify replica set config. description: - Modify replica set config, including modifying/adding/removing members from a replica set changing replica set options, and initiating the replica set if necessary. Uses replSetReconfig and replSetInitiate. version_added: "1.9" author: - Max Rothman - Feanil Patel options: rs_host: description: - The hostname or ip of a server already in the mongo cluster. required: false default: 'localhost' rs_port: description: - The port to connect to mongo on. required: false default: 27017 username: description: - The username of the mongo user to connect as. required: false password: description: - The password to use when authenticating. required: false auth_database: description: - The database to authenticate against. requred: false force: description: Whether to pass the "force" option to replSetReconfig. For more details, see `<https://docs.mongodb.org/manual/reference/command/replSetReconfig/>` required: false default: false rs_config: description: A `replica set configuration document <https://docs.mongodb.org/manual/reference/replica-configuration/>`. This structure can be a valid document, but this module can also manage some details for you: - members can have separate ``host`` and ``port`` properties. ``port`` defaults to 27017. To override this, provide a ``host`` like ``somehost:27017``. - ``_id`` is automatically managed if not provided - members' ``_id`` are automatically managed - ``version`` is automatically incremented required: true """ EXAMPLES = ''' - name: Basic example mongodb_replica_set: username: root password: password rs_config: members: - host: some.host - host: other.host port: 27018 hidden: true - name: Fully specify a whole document mongodb_replica_set: username: admin password: password rs_config: _id: myReplicaSetName version: 5 members: - _id: 1 host: some.host:27017 - _id: 2 host: other.host:27017 hidden: true ''' # Magic import from ansible.module_utils.basic import * try: from pymongo import MongoClient from pymongo.errors import OperationFailure from bson import json_util except ImportError: pymongo_found = False else: pymongo_found = True import json, copy from urllib import quote_plus ########### Mongo API calls ########### def get_replset(): # Not using `replSetGetConfig` because it's not supported in MongoDB 2.x. try: rs_config = client.local.system.replset.find_one() except OperationFailure as e: return None return rs_config def initialize_replset(rs_config): try: client.admin.command("replSetInitiate", rs_config) except OperationFailure as e: module.fail_json(msg="Failed to initiate replSet: {}".format(e.message)) def reconfig_replset(rs_config): try: client.admin.command("replSetReconfig", rs_config, force=module.params['force']) except OperationFailure as e: module.fail_json(msg="Failed to reconfigure replSet: {}".format(e.message)) def get_rs_config_id(): try: return client.admin.command('getCmdLineOpts')['parsed']['replication']['replSetName'] except (OperationFailure, KeyError) as e: module.fail_json(msg=("Unable to get replSet name. " "Was mongod started with --replSet, " "or was replication.replSetName set in the config file? Error: ") + e.message) ########### Helper functions ########### def set_member_ids(members, old_members=None): ''' Set the _id property of members who don't already have one. Prefer the _id of the "matching" member from `old_members`. ''' #Add a little padding to ensure we don't run out of IDs available_ids = set(range(len(members)*2)) available_ids -= {m['_id'] for m in members if '_id' in m} if old_members is not None: available_ids -= {m['_id'] for m in old_members} available_ids = list(sorted(available_ids, reverse=True)) for member in members: if '_id' not in member: if old_members is not None: match = get_matching_member(member, old_members) member['_id'] = match['_id'] if match is not None else available_ids.pop() else: member['_id'] = available_ids.pop() def get_matching_member(member, members): '''Return the rs_member from `members` that "matches" `member` (currently on host)''' match = [m for m in members if m['host'] == member['host']] return match[0] if len(match) > 0 else None def members_match(new, old): "Compare 2 lists of members, discounting their `_id`s and matching on hostname" if len(new) != len(old): return False for old_member in old: new_member = get_matching_member(old_member, new).copy() #Don't compare on _id new_member.pop('_id', None) old_member = old_member.copy() old_member.pop('_id', None) if old_member != new_member: return False return True def fix_host_port(rs_config): '''Fix host, port to host:port''' if 'members' in rs_config: if not isinstance(rs_config['members'], list): module.fail_json(msg='rs_config.members must be a list') for member in rs_config['members']: if ':' not in member['host']: member['host'] = '{}:{}'.format(member['host'], member.get('port', 27017)) if 'port' in member: del member['port'] def check_config_subset(old_config, new_config): ''' Compares the old config (what we pass in to Mongo) to the new config (returned from Mongo) It is assumed that old_config will be a subset of new_config because Mongo tracks many more details about the replica set and the members in a replica set that we don't track in our secure repo. ''' for k in old_config: if k == 'members': matches = is_member_subset(old_config['members'],new_config['members']) if not matches: return False else: if old_config[k] != new_config[k]: return False return True def is_member_subset(old_members,new_members): ''' Compares the member list of a replica set configuration as specified (old_members) to what Mongo has returned (new_members). If it finds anything in old_members that does not match new_members, it will return False. new_members is allowed to contain extra information that is not reflected in old_members because we do not necesarily track all of mongo's internal data in the config. ''' for member in old_members: for k in member: if member[k] != new_members[member['_id']][k]: return False return True def update_replset(rs_config): changed = False old_rs_config = get_replset() fix_host_port(rs_config) #fix host, port to host:port #Decide whether we need to initialize if old_rs_config is None: changed = True if '_id' not in rs_config: rs_config['_id'] = get_rs_config_id() #Errors if no replSet specified to mongod set_member_ids(rs_config['members']) #Noop if all _ids are set #Don't set the version, it'll auto-set initialize_replset(rs_config) else: old_rs_config_scalars = {k:v for k,v in old_rs_config.items() if not isinstance(v, (list, dict))} rs_config_scalars = {k:v for k,v in rs_config.items() if not isinstance(v, (list, dict))} if '_id' not in rs_config_scalars and '_id' in old_rs_config_scalars: # _id is going to be managed, don't compare on it del old_rs_config_scalars['_id'] if 'version' not in rs_config and 'version' in old_rs_config_scalars: # version is going to be managed, don't compare on it del old_rs_config_scalars['version'] # Special comparison to test whether 2 rs_configs are "equivalent" # We can't simply use == because of special logic in `members_match()` # 1. Compare the scalars (i.e. non-collections) # 2. Compare the "settings" dict # 3. Compare the members dicts using `members_match()` # Since the only nested structures in the rs_config spec are "members" and "settings", # if all of the above 3 match, the structures are equivalent. if rs_config_scalars != old_rs_config_scalars \ or rs_config.get('settings') != old_rs_config.get('settings') \ or not members_match(rs_config['members'], old_rs_config['members']): changed=True if '_id' not in rs_config: rs_config['_id'] = old_rs_config['_id'] if 'version' not in rs_config: #Using manual increment to prevent race condition rs_config['version'] = old_rs_config['version'] + 1 set_member_ids(rs_config['members'], old_rs_config['members']) #Noop if all _ids are set reconfig_replset(rs_config) #Validate it worked if changed: changed_rs_config = get_replset() if not check_config_subset(rs_config, changed_rs_config): module.fail_json(msg="Failed to validate that the replica set was changed", new_config=changed_rs_config, config=rs_config) module.exit_json(changed=changed, config=rs_config, new_config=changed_rs_config) ######### Client making stuff ######### def get_mongo_uri(host, port, username, password, auth_database): mongo_uri = 'mongodb://' if username and password: mongo_uri += "{}:{}@".format(*map(quote_plus, [username,password])) mongo_uri += "{}:{}".format(quote_plus(host), port) if auth_database: mongo_uri += "/{}".format(quote_plus(auth_database)) return mongo_uri def primary_client(some_host, some_port, username, password, auth_database): ''' Given a member of a replica set, find out who the primary is and provide a client that is connected to the primary for running commands. Because this function attempts to find the primary of your replica set, it can fail and throw PyMongo exceptions. You should handle these and fall back to get_client. ''' client = get_client(some_host, some_port, username, password, auth_database) # This can fail (throws OperationFailure), in which case code will need to # fall back to using get_client since there either is no primary, or we can't # know it for some reason. status = client.admin.command("replSetGetStatus") # Find out who the primary is. rs_primary = filter(lambda member: member['stateStr']=='PRIMARY', status['members'])[0] primary_host, primary_port = rs_primary['name'].split(':') # Connect to the primary if this is not the primary. if primary_host != some_host or primary_port != some_port: client.close() new_uri = get_mongo_uri(primary_host, primary_port, username, password, auth_database) client = MongoClient(new_uri) return client def get_client(some_host, some_port, username, password, auth_database): ''' Connects to the given host. Does not have any of the logic of primary_client, so is safer to use when handling an uninitialized replica set or some other mongo instance that requires special logic. This function connects to Mongo, and as such can throw any of the PyMongo exceptions. ''' mongo_uri = get_mongo_uri(some_host, some_port, username, password, auth_database) client = MongoClient(mongo_uri) return client ################ Main ################ def validate_args(): arg_spec = dict( username = dict(required=False, type='str'), password = dict(required=False, type='str'), auth_database = dict(required=False, type='str'), rs_host = dict(required=False, type='str', default="localhost"), rs_port = dict(required=False, type='int', default=27017), rs_config = dict(required=True, type='dict'), force = dict(required=False, type='bool', default=False), ) module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=False) username = module.params.get('username') password = module.params.get('password') if (username and not password) or (password and not username): module.fail_json(msg="Must provide both username and password or neither.") return module if __name__ == '__main__': module = validate_args() if not pymongo_found: module.fail_json(msg="The python pymongo module is not installed.") username = module.params.get('username') password = module.params.get('password') auth_database = module.params.get('auth_database') rs_host = module.params['rs_host'] rs_port = module.params['rs_port'] try: client = primary_client(rs_host, rs_port, username, password, auth_database) except OperationFailure: client = get_client(rs_host, rs_port, username, password, auth_database) update_replset(module.params['rs_config'])