mongodb_replica_set 13.1 KB
Newer Older
1 2 3 4
#!/usr/bin/env python

DOCUMENTATION = """
---
5
module: mongodb_replica_set
6
short_description: Modify replica set config.
7
description:
8
  - Modify replica set config, including modifying/adding/removing members from a replica set
9 10
    changing replica set options, and initiating the replica set if necessary. 
    Uses replSetReconfig and replSetInitiate.
11
version_added: "1.9"
12
author:
13 14
  - Max Rothman
  - Feanil Patel
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
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
34 35 36 37
  auth_database:
    description:
      - The database to authenticate against.
    requred: false
38 39 40
  force:
    description: Whether to pass the "force" option to replSetReconfig.
      For more details, see `<https://docs.mongodb.org/manual/reference/command/replSetReconfig/>`
41
    required: false
42 43 44 45 46 47 48 49 50 51 52 53
    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
54 55 56
"""

EXAMPLES = '''
57
- name: Basic example
58
  mongodb_replica_set:
59 60
    username: root
    password: password
61 62 63 64 65 66 67 68
    rs_config:
      members:
        - host: some.host
        - host: other.host
          port: 27018
          hidden: true

- name: Fully specify a whole document
69
  mongodb_replica_set:
70 71 72 73 74 75 76 77 78 79 80
    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
81 82 83 84 85 86 87 88 89 90 91 92 93
'''
# 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

94
import json, copy
95
from urllib import quote_plus
96

97
########### Mongo API calls ###########
98
def get_replset():
99 100 101 102 103
    # 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
104 105

    return rs_config
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120

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:
121 122 123 124 125
        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)
126 127 128 129 130 131 132 133


########### 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`.
    '''
134
    #Add a little padding to ensure we don't run out of IDs
135
    available_ids = set(range(len(members)*2))
136
    available_ids -= {m['_id'] for m in members if '_id' in m}
137 138
    if old_members is not None:
        available_ids -= {m['_id'] for m in old_members}
139
    available_ids = list(sorted(available_ids, reverse=True))
140 141 142 143 144 145 146 147 148 149

    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):
150
    '''Return the rs_member from `members` that "matches" `member` (currently on host)'''
151 152 153 154
    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):
155
    "Compare 2 lists of members, discounting their `_id`s and matching on hostname"
156 157 158
    if len(new) != len(old):
        return False
    for old_member in old:
159 160 161 162 163 164
        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:
165 166 167 168
            return False
    return True

def fix_host_port(rs_config):
169
    '''Fix host, port to host:port'''
170
    if 'members' in rs_config:
171
        if not isinstance(rs_config['members'], list):
172
            module.fail_json(msg='rs_config.members must be a list')
173

174 175 176
        for member in rs_config['members']:
            if ':' not in member['host']:
                member['host'] = '{}:{}'.format(member['host'], member.get('port', 27017))
177 178
                if 'port' in member:
                    del member['port']
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
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

212 213
def update_replset(rs_config):
    changed = False
214 215
    old_rs_config = get_replset()
    fix_host_port(rs_config)  #fix host, port to host:port
216 217 218 219 220

    #Decide whether we need to initialize
    if old_rs_config is None:
        changed = True
        if '_id' not in rs_config:
221 222
            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
223
        #Don't set the version, it'll auto-set
224
        initialize_replset(rs_config)
225 226

    else:
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
        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') \
246
            or not members_match(rs_config['members'], old_rs_config['members']):
247

248 249
            changed=True
            if '_id' not in rs_config:
250
                rs_config['_id'] = old_rs_config['_id']
251 252 253
            if 'version' not in rs_config:
                #Using manual increment to prevent race condition
                rs_config['version'] = old_rs_config['version'] + 1
254

255
            set_member_ids(rs_config['members'], old_rs_config['members'])  #Noop if all _ids are set
256

257
            reconfig_replset(rs_config)
258 259 260

    #Validate it worked
    if changed:
261 262 263
        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)
264

265
    module.exit_json(changed=changed, config=rs_config, new_config=changed_rs_config)
266 267 268


######### Client making stuff #########
269
def get_mongo_uri(host, port, username, password, auth_database):
270 271
    mongo_uri = 'mongodb://'
    if username and password:
272 273
        mongo_uri += "{}:{}@".format(*map(quote_plus, [username,password]))

274
    mongo_uri += "{}:{}".format(quote_plus(host), port)
275 276 277

    if auth_database:
        mongo_uri += "/{}".format(quote_plus(auth_database))
278 279 280

    return mongo_uri

281
def primary_client(some_host, some_port, username, password, auth_database):
282
    '''
283 284 285
    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.
286 287 288 289

    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.
290
    '''
291 292 293 294 295
    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")
296 297 298 299 300 301 302

    # 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:
303
        client.close()
304
        new_uri = get_mongo_uri(primary_host, primary_port, username, password, auth_database)
305 306 307 308
        client = MongoClient(new_uri)

    return client

309 310 311 312 313 314 315 316 317 318 319 320
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
321

322 323
################ Main ################
def validate_args():
324
    arg_spec = dict(
325 326 327 328 329
        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),
330
        rs_config = dict(required=True, type='dict'),
331
        force = dict(required=False, type='bool', default=False),
332 333 334 335
    )

    module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=False)

336 337
    username = module.params.get('username')
    password = module.params.get('password')
338 339 340 341 342
    if (username and not password) or (password and not username):
        module.fail_json(msg="Must provide both username and password or neither.")

    return module

343

344
if __name__ == '__main__':
345
    module = validate_args()
346

347 348 349 350 351
    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')
352
    auth_database = module.params.get('auth_database')
353 354
    rs_host = module.params['rs_host']
    rs_port = module.params['rs_port']
355

356 357 358 359 360 361
    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'])