mongodb_replica_set 13.7 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
from operator import itemgetter
97

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

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

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


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

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

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

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

    # Mongo returns the member set in no particular order, and we were
209 210 211 212 213 214 215
    # indexing into the list using _id before without sorting which led to failure.
    old_members, new_members = [sorted(k, key=itemgetter('_id'))
        for k in (old_members, new_members)]

    for k1, k2 in zip(old_members, new_members):
        for key, value in k1.items():
            if value != k2[key]: return False
216 217 218

    return True

219 220
def update_replset(rs_config):
    changed = False
221 222
    old_rs_config = get_replset()
    fix_host_port(rs_config)  #fix host, port to host:port
223 224 225 226 227

    #Decide whether we need to initialize
    if old_rs_config is None:
        changed = True
        if '_id' not in rs_config:
228 229
            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
230
        #Don't set the version, it'll auto-set
231
        initialize_replset(rs_config)
232 233

    else:
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
        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') \
253
            or not members_match(rs_config['members'], old_rs_config['members']):
254

255 256
            changed=True
            if '_id' not in rs_config:
257
                rs_config['_id'] = old_rs_config['_id']
258 259 260
            if 'version' not in rs_config:
                #Using manual increment to prevent race condition
                rs_config['version'] = old_rs_config['version'] + 1
261

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

264
            reconfig_replset(rs_config)
265 266 267

    #Validate it worked
    if changed:
268 269 270
        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)
271

Filippo Panessa committed
272 273 274
    # Remove settings from changed_rs_config before exit to avoid
    # problem with exit_json() and unserializable ObjectId
    # because MongoDB returns JSON which is not serializable
275 276 277
    if changed_rs_config.get('settings') is not None:
        changed_rs_config['settings'] = None
    
278
    module.exit_json(changed=changed, config=rs_config, new_config=changed_rs_config)
279 280 281


######### Client making stuff #########
282
def get_mongo_uri(host, port, username, password, auth_database):
283 284
    mongo_uri = 'mongodb://'
    if username and password:
285 286
        mongo_uri += "{}:{}@".format(*map(quote_plus, [username,password]))

287
    mongo_uri += "{}:{}".format(quote_plus(host), port)
288 289 290

    if auth_database:
        mongo_uri += "/{}".format(quote_plus(auth_database))
291 292 293

    return mongo_uri

294
def primary_client(some_host, some_port, username, password, auth_database):
295
    '''
296 297 298
    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.
299 300 301 302

    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.
303
    '''
304 305 306 307 308
    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")
309 310 311 312 313 314 315

    # 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:
316
        client.close()
317
        new_uri = get_mongo_uri(primary_host, primary_port, username, password, auth_database)
318 319 320 321
        client = MongoClient(new_uri)

    return client

322 323 324 325 326 327 328 329 330 331 332 333
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
334

335 336
################ Main ################
def validate_args():
337
    arg_spec = dict(
338 339 340 341 342
        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),
343
        rs_config = dict(required=True, type='dict'),
344
        force = dict(required=False, type='bool', default=False),
345 346 347 348
    )

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

349 350
    username = module.params.get('username')
    password = module.params.get('password')
351 352 353 354 355
    if (username and not password) or (password and not username):
        module.fail_json(msg="Must provide both username and password or neither.")

    return module

356

357
if __name__ == '__main__':
358
    module = validate_args()
359

360 361 362 363 364
    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')
365
    auth_database = module.params.get('auth_database')
366 367
    rs_host = module.params['rs_host']
    rs_port = module.params['rs_port']
368

369 370 371 372 373 374
    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'])