From 7f50f467fee95be0fc290af5e4cb6a80a37ec1c8 Mon Sep 17 00:00:00 2001 From: Rhys Campbell Date: Mon, 25 Feb 2019 11:52:07 +0100 Subject: [PATCH] mongodb_replicaset module and test exception (#49690) * Clean up from previous fork * Minor doc update * Fix doc string return type * Minor doc updates * Keeping fresh * Various changes to documentation, cosmetics and code logic Please test :-) * Fix typo * Various small changes as requested * Remove traceback ref * try catch changes * Tidy description * Correct data type in documentation * Fix for 4.0 --- .../database/mongodb/mongodb_replicaset.py | 415 ++++++++++++++++++ .../code-smell/use-argspec-type-path.py | 1 + 2 files changed, 416 insertions(+) create mode 100644 lib/ansible/modules/database/mongodb/mongodb_replicaset.py diff --git a/lib/ansible/modules/database/mongodb/mongodb_replicaset.py b/lib/ansible/modules/database/mongodb/mongodb_replicaset.py new file mode 100644 index 00000000000..a8ed2a0bb43 --- /dev/null +++ b/lib/ansible/modules/database/mongodb/mongodb_replicaset.py @@ -0,0 +1,415 @@ +#!/usr/bin/python + +# Copyright: (c) 2018, Rhys Campbell +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: mongodb_replicaset +short_description: Initialises a MongoDB replicaset. +description: +- Initialises a MongoDB replicaset in a new deployment. +- Validates the replicaset name for existing deployments. +author: Rhys Campbell (@rhysmeister) +version_added: "2.8" +options: + login_user: + description: + - The username to authenticate with. + type: str + login_password: + description: + - The password to authenticate with. + type: str + login_database: + description: + - The database where login credentials are stored. + type: str + default: admin + login_host: + description: + - The MongoDB hostname. + type: str + default: localhost + login_port: + description: + - The MongoDB port to login to. + type: int + default: 27017 + replica_set: + description: + - Replicaset name. + type: str + default: rs0 + members: + description: + - A comma-separated string or a yaml list consisting of the replicaset members. + - Supply as a simple csv string, i.e. mongodb1:27017,mongodb2:27017,mongodb3:27017. + - If a port number is not provided then 27017 is assumed. + type: list + validate: + description: + - Performs some basic validation on the provided replicaset config. + type: bool + default: yes + ssl: + description: + - Whether to use an SSL connection when connecting to the database + type: bool + default: no + ssl_cert_reqs: + description: + - Specifies whether a certificate is required from the other side of the connection, and whether it will be validated if provided. + type: str + default: CERT_REQUIRED + choices: [ CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED ] + arbiter_at_index: + description: + - Identifies the position of the member in the array that is an arbiter. + type: int + chaining_allowed: + description: + - When I(settings.chaining_allowed=true), the replicaset allows secondary members to replicate from other + secondary members. + - When I(settings.chaining_allowed=false), secondaries can replicate only from the primary. + type: bool + default: yes + heartbeat_timeout_secs: + description: + - Number of seconds that the replicaset members wait for a successful heartbeat from each other. + - If a member does not respond in time, other members mark the delinquent member as inaccessible. + - The setting only applies when using I(protocol_version=0). When using I(protocol_version=1) the relevant + setting is I(settings.election_timeout_millis). + type: int + default: 10 + election_timeout_millis: + description: + - The time limit in milliseconds for detecting when a replicaset's primary is unreachable. + type: int + default: 10000 + protocol_version: + description: Version of the replicaset election protocol. + type: int + choices: [ 0, 1 ] + default: 1 +notes: +- Requires the pymongo Python package on the remote host, version 2.4.2+. This + can be installed using pip or the OS package manager. @see U(http://api.mongodb.org/python/current/installation.html) +requirements: +- pymongo +''' + +EXAMPLES = r''' +# Create a replicaset called 'rs0' with the 3 provided members +- name: Ensure replicaset rs0 exists + mongodb_replicaset: + login_host: localhost + login_user: admin + login_password: admin + replica_set: rs0 + members: + - mongodb1:27017 + - mongodb2:27017 + - mongodb3:27017 + when: groups.mongod.index(inventory_hostname) == 0 + +# Create two single-node replicasets on the localhost for testing +- name: Ensure replicaset rs0 exists + mongodb_replicaset: + login_host: localhost + login_port: 3001 + login_user: admin + login_password: secret + login_database: admin + replica_set: rs0 + members: localhost:3001 + validate: no + +- name: Ensure replicaset rs1 exists + mongodb_replicaset: + login_host: localhost + login_port: 3002 + login_user: admin + login_password: secret + login_database: admin + replica_set: rs1 + members: localhost:3002 + validate: no +''' + +RETURN = r''' +mongodb_replicaset: + description: The name of the replicaset that has been created. + returned: success + type: str +''' + +from copy import deepcopy + +import os +import ssl as ssl_lib +from distutils.version import LooseVersion + +try: + from pymongo.errors import ConnectionFailure + from pymongo.errors import OperationFailure + from pymongo import version as PyMongoVersion + from pymongo import MongoClient + HAS_PYMONGO = True +except ImportError: + try: # for older PyMongo 2.2 + from pymongo import Connection as MongoClient + HAS_PYMONGO = True + except ImportError: + HAS_PYMONGO = False + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import binary_type, text_type +from ansible.module_utils.six.moves import configparser +from ansible.module_utils._text import to_native + + +# ========================================= +# MongoDB module specific support methods. +# + +def check_compatibility(module, client): + """Check the compatibility between the driver and the database. + + See: https://docs.mongodb.com/ecosystem/drivers/driver-compatibility-reference/#python-driver-compatibility + + Args: + module: Ansible module. + client (cursor): Mongodb cursor on admin database. + """ + loose_srv_version = LooseVersion(client.server_info()['version']) + loose_driver_version = LooseVersion(PyMongoVersion) + + if loose_srv_version >= LooseVersion('3.2') and loose_driver_version < LooseVersion('3.2'): + module.fail_json(msg=' (Note: you must use pymongo 3.2+ with MongoDB >= 3.2)') + + elif loose_srv_version >= LooseVersion('3.0') and loose_driver_version <= LooseVersion('2.8'): + module.fail_json(msg=' (Note: you must use pymongo 2.8+ with MongoDB 3.0)') + + elif loose_srv_version >= LooseVersion('2.6') and loose_driver_version <= LooseVersion('2.7'): + module.fail_json(msg=' (Note: you must use pymongo 2.7+ with MongoDB 2.6)') + + elif LooseVersion(PyMongoVersion) <= LooseVersion('2.5'): + module.fail_json(msg=' (Note: you must be on mongodb 2.4+ and pymongo 2.5+ to use the roles param)') + + +def replicaset_find(client): + """Check if a replicaset exists. + + Args: + client (cursor): Mongodb cursor on admin database. + replica_set (str): replica_set to check. + + Returns: + dict: when user exists, False otherwise. + """ + for rs in client["local"].system.replset.find({}): + return rs["_id"] + return False + + +def replicaset_add(module, client, replica_set, members, arbiter_at_index, protocol_version, + chaining_allowed, heartbeat_timeout_secs, election_timeout_millis): + + try: + from collections import OrderedDict + except ImportError as excep: + try: + from ordereddict import OrderedDict + except ImportError as excep: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict: %s' + % to_native(excep)) + + members_dict_list = [] + index = 0 + settings = { + "chainingAllowed": bool(chaining_allowed), + } + if protocol_version == 0: + settings['heartbeatTimeoutSecs'] = heartbeat_timeout_secs + else: + settings['electionTimeoutMillis'] = election_timeout_millis + for member in members: + if ':' not in member: # No port supplied. Assume 27017 + member += ":27017" + members_dict_list.append(OrderedDict([("_id", index), ("host", member)])) + if index == arbiter_at_index: + members_dict_list[index]['arbiterOnly'] = True + index += 1 + + conf = OrderedDict([("_id", replica_set), + ("protocolVersion", protocol_version), + ("members", members_dict_list), + ("settings", settings)]) + client["admin"].command('replSetInitiate', conf) + + +def replicaset_remove(module, client, replica_set): + raise NotImplementedError + # exists = replicaset_find(client, replica_set) + # if exists: + # if module.check_mode: + # module.exit_json(changed=True, replica_set=replica_set) + # db = client[db_name] + # db.remove_user(replica_set) + # else: + # module.exit_json(changed=False, user=user) + + +def load_mongocnf(): + config = configparser.RawConfigParser() + mongocnf = os.path.expanduser('~/.mongodb.cnf') + + try: + config.readfp(open(mongocnf)) + except (configparser.NoOptionError, IOError): + return False + + creds = dict( + user=config.get('client', 'user'), + password=config.get('client', 'pass') + ) + + return creds + + +# ========================================= +# Module execution. +# + + +def main(): + module = AnsibleModule( + argument_spec=dict( + login_user=dict(type='str'), + login_password=dict(type='str', no_log=True), + login_database=dict(type='str', default="admin"), + login_host=dict(type='str', default="localhost"), + login_port=dict(type='int', default=27017), + replica_set=dict(type='str', default="rs0"), + members=dict(type='list'), + arbiter_at_index=dict(type='int'), + validate=dict(type='bool', default=True), + ssl=dict(type='bool', default=False), + ssl_cert_reqs=dict(type='str', default='CERT_REQUIRED', choices=['CERT_NONE', 'CERT_OPTIONAL', 'CERT_REQUIRED']), + protocol_version=dict(type='int', default=1, choices=[0, 1]), + chaining_allowed=dict(type='bool', default=True), + heartbeat_timeout_secs=dict(type='int', default=10), + election_timeout_millis=dict(type='int', default=10000) + ), + supports_check_mode=True, + ) + + if not HAS_PYMONGO: + module.fail_json(msg='the python pymongo module is required') + + login_user = module.params['login_user'] + login_password = module.params['login_password'] + login_database = module.params['login_database'] + login_host = module.params['login_host'] + login_port = module.params['login_port'] + replica_set = module.params['replica_set'] + members = module.params['members'] + arbiter_at_index = module.params['arbiter_at_index'] + validate = module.params['validate'] + ssl = module.params['ssl'] + protocol_version = module.params['protocol_version'] + chaining_allowed = module.params['chaining_allowed'] + heartbeat_timeout_secs = module.params['heartbeat_timeout_secs'] + election_timeout_millis = module.params['election_timeout_millis'] + + if validate: + if len(members) <= 2 or len(members) % 2 == 0: + module.fail_json(msg="MongoDB Replicaset validation failed. Invalid number of replicaset members.") + if arbiter_at_index is not None and len(members) - 1 > arbiter_at_index: + module.fail_json(msg="MongoDB Replicaset validation failed. Invalid arbiter index.") + + result = dict( + changed=False, + replica_set=replica_set, + ) + + connection_params = dict( + host=login_host, + port=int(login_port), + ) + + if ssl: + connection_params["ssl"] = ssl + connection_params["ssl_cert_reqs"] = getattr(ssl_lib, module.params['ssl_cert_reqs']) + + try: + client = MongoClient(**connection_params) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + try: + check_compatibility(module, client) + except Exception as excep: + if "not authorized on" not in str(excep) and "there are no users authenticated" not in str(excep): + raise excep + if login_user is None or login_password is None: + raise excep + client.admin.authenticate(login_user, login_password, source=login_database) + check_compatibility(module, client) + + if login_user is None and login_password is None: + mongocnf_creds = load_mongocnf() + if mongocnf_creds is not False: + login_user = mongocnf_creds['user'] + login_password = mongocnf_creds['password'] + elif login_password is None or login_user is None: + module.fail_json(msg="When supplying login arguments, both 'login_user' and 'login_password' must be provided") + + try: + client['admin'].command('listDatabases', 1.0) # if this throws an error we need to authenticate + except Exception as excep: + if "not authorized on" not in str(excep) and "command listDatabases requires authentication" not in str(excep): + raise excep + if login_user is None or login_password is None: + raise excep + client.admin.authenticate(login_user, login_password, source=login_database) + + if len(replica_set) == 0: + module.fail_json(msg="Parameter 'replica_set' must not be an empty string") + + try: + rs = replicaset_find(client) + except Exception as e: + module.fail_json(msg='Unable to query replica_set info: %s' % to_native(e)) + + if not rs: + if not module.check_mode: + try: + replicaset_add(module, client, replica_set, members, arbiter_at_index, protocol_version, + chaining_allowed, heartbeat_timeout_secs, election_timeout_millis) + result['changed'] = True + except Exception as e: + module.fail_json(msg='Unable to create replica_set: %s' % to_native(e)) + else: + if not module.check_mode: + try: + rs = replicaset_find(client) + except Exception as e: + module.fail_json(msg='Unable to query replica_set info: %s' % to_native(e)) + if rs is not None and rs != replica_set: + module.fail_json(msg="The replica_set name of '{0}' does not match the expected: '{1}'".format(rs, replica_set)) + result['changed'] = False + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/test/sanity/code-smell/use-argspec-type-path.py b/test/sanity/code-smell/use-argspec-type-path.py index b6e61d60bfa..058f9f9ff5e 100755 --- a/test/sanity/code-smell/use-argspec-type-path.py +++ b/test/sanity/code-smell/use-argspec-type-path.py @@ -11,6 +11,7 @@ def main(): 'lib/ansible/modules/cloud/lxc/lxc_container.py', 'lib/ansible/modules/cloud/rackspace/rax_files_objects.py', 'lib/ansible/modules/database/mongodb/mongodb_parameter.py', + 'lib/ansible/modules/database/mongodb/mongodb_replicaset.py', 'lib/ansible/modules/database/mongodb/mongodb_shard.py', 'lib/ansible/modules/database/mongodb/mongodb_user.py', 'lib/ansible/modules/database/postgresql/postgresql_db.py',