diff options
Diffstat (limited to 'ansible_collections/community/mongodb/plugins')
28 files changed, 6750 insertions, 0 deletions
diff --git a/ansible_collections/community/mongodb/plugins/cache/__init__.py b/ansible_collections/community/mongodb/plugins/cache/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/cache/__init__.py diff --git a/ansible_collections/community/mongodb/plugins/cache/mongodb.py b/ansible_collections/community/mongodb/plugins/cache/mongodb.py new file mode 100644 index 000000000..b51b7b293 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/cache/mongodb.py @@ -0,0 +1,204 @@ +# (c) 2018, Matt Martz <matt@sivel.net> +# 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 = ''' +name: mongodb +author: + - Matt Martz (@sivel) +version_added: "1.0.0" +short_description: Use MongoDB for caching +description: + - This cache uses per host records saved in MongoDB. +requirements: + - pymongo>=3 +options: + _uri: + description: + - MongoDB Connection String URI + required: False + env: + - name: ANSIBLE_CACHE_PLUGIN_CONNECTION + ini: + - key: fact_caching_connection + section: defaults + _prefix: + description: User defined prefix to use when creating the DB entries + default: ansible_facts + env: + - name: ANSIBLE_CACHE_PLUGIN_PREFIX + ini: + - key: fact_caching_prefix + section: defaults + _timeout: + default: 86400 + description: Expiration timeout in seconds for the cache plugin data. Set to 0 to never expire + env: + - name: ANSIBLE_CACHE_PLUGIN_TIMEOUT + ini: + - key: fact_caching_timeout + section: defaults + type: integer +''' + +import datetime + +from contextlib import contextmanager + +from ansible import constants as C +from ansible.errors import AnsibleError +from ansible.plugins.cache import BaseCacheModule +from ansible.utils.display import Display +from ansible.module_utils._text import to_native + +pymongo_missing = False + +try: + import pymongo +except ImportError: + pymongo_missing = True + +display = Display() + + +class CacheModule(BaseCacheModule): + """ + A caching module backed by mongodb. + """ + def __init__(self, *args, **kwargs): + try: + if pymongo_missing: + raise AnsibleError("The 'pymongo' python module is required for the mongodb fact cache, 'pip install pymongo>=3.0'") + super(CacheModule, self).__init__(*args, **kwargs) + self._connection = self.get_option('_uri') + self._timeout = int(self.get_option('_timeout')) + self._prefix = self.get_option('_prefix') + except KeyError: + self._connection = C.CACHE_PLUGIN_CONNECTION + self._timeout = int(C.CACHE_PLUGIN_TIMEOUT) + self._prefix = C.CACHE_PLUGIN_PREFIX + + self._cache = {} + self._managed_indexes = False + + def _ttl_index_exists(self, collection): + ''' + Returns true if an index named ttl exists + on the given collection. + ''' + exists = False + try: + indexes = collection.list_indexes() + for index in indexes: + if index["name"] == "ttl": + exists = True + break + except pymongo.errors.OperationFailure as excep: + raise AnsibleError('Error checking MongoDB index: %s' % to_native(excep)) + return exists + + def _manage_indexes(self, collection): + ''' + This function manages indexes on the mongo collection. + We only do this once, at run time based on _managed_indexes, + rather than per connection instantiation as that would be overkill + ''' + _timeout = self._timeout + if _timeout and _timeout > 0: + try: + collection.create_index( + 'date', + name='ttl', + expireAfterSeconds=_timeout + ) + except pymongo.errors.OperationFailure: + # We make it here when the fact_caching_timeout was set to a different value between runs + if self._ttl_index_exists(collection): + collection.drop_index('ttl') + return self._manage_indexes(collection) + else: + if self._ttl_index_exists(collection): + collection.drop_index('ttl') + + @contextmanager + def _collection(self): + ''' + This is a context manager for opening and closing mongo connections as needed. This exists as to not create a global + connection, due to pymongo not being fork safe (http://api.mongodb.com/python/current/faq.html#is-pymongo-fork-safe) + ''' + mongo = pymongo.MongoClient(self._connection) + try: + db = mongo.get_default_database() + except pymongo.errors.ConfigurationError: + # We'll fall back to using ``ansible`` as the database if one was not provided + # in the MongoDB Connection String URI + db = mongo['ansible'] + + # The collection is hard coded as ``cache``, there are no configuration options for this + collection = db['cache'] + if not self._managed_indexes: + # Only manage the indexes once per run, not per connection + self._manage_indexes(collection) + self._managed_indexes = True + + yield collection + + mongo.close() + + def _make_key(self, key): + return '%s%s' % (self._prefix, key) + + def get(self, key): + if key not in self._cache: + with self._collection() as collection: + value = collection.find_one({'_id': self._make_key(key)}) + self._cache[key] = value['data'] + + return self._cache.get(key) + + def set(self, key, value): + self._cache[key] = value + with self._collection() as collection: + collection.update_one( + {'_id': self._make_key(key)}, + { + '$set': { + '_id': self._make_key(key), + 'data': value, + 'date': datetime.datetime.utcnow() + } + }, + upsert=True + ) + + def keys(self): + with self._collection() as collection: + return [doc['_id'] for doc in collection.find({}, {'_id': True})] + + def contains(self, key): + with self._collection() as collection: + return bool(collection.count({'_id': self._make_key(key)})) + + def delete(self, key): + del self._cache[key] + with self._collection() as collection: + collection.delete_one({'_id': self._make_key(key)}) + + def flush(self): + with self._collection() as collection: + collection.delete_many({}) + + def copy(self): + with self._collection() as collection: + return dict((d['_id'], d['data']) for d in collection.find({})) + + def __getstate__(self): + return dict() + + def __setstate__(self, data): + self.__init__() diff --git a/ansible_collections/community/mongodb/plugins/doc_fragments/login_options.py b/ansible_collections/community/mongodb/plugins/doc_fragments/login_options.py new file mode 100644 index 000000000..5307bca6e --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/doc_fragments/login_options.py @@ -0,0 +1,49 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + # Standard documentation + DOCUMENTATION = r''' +options: + login_user: + description: + - The MongoDB user to login with. + - Required when I(login_password) is specified. + required: no + type: str + login_password: + description: + - The password used to authenticate with. + - Required when I(login_user) is specified. + required: no + type: str + login_database: + description: + - The database where login credentials are stored. + required: no + type: str + default: 'admin' + login_host: + description: + - The host running MongoDB instance to login to. + required: no + type: str + default: 'localhost' + login_port: + description: + - The MongoDB server port to login to. + required: no + type: int + default: 27017 + strict_compatibility: + description: + - Enforce strict requirements for pymongo and MongoDB software versions + type: bool + default: True + atlas_auth: + description: + - Authentication path intended for MongoDB Atlas Instances + type: bool + default: False +''' diff --git a/ansible_collections/community/mongodb/plugins/doc_fragments/ssl_options.py b/ansible_collections/community/mongodb/plugins/doc_fragments/ssl_options.py new file mode 100644 index 000000000..efd330b7d --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/doc_fragments/ssl_options.py @@ -0,0 +1,79 @@ +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + # Standard documentation + DOCUMENTATION = r''' +options: + ssl: + description: + - Whether to use an SSL connection when connecting to the database. + required: no + type: bool + default: no + aliases: + - tls + 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. + required: no + type: str + default: 'CERT_REQUIRED' + choices: + - 'CERT_NONE' + - 'CERT_OPTIONAL' + - 'CERT_REQUIRED' + aliases: + - tlsAllowInvalidCertificates + ssl_ca_certs: + description: + - The ssl_ca_certs option takes a path to a CA file. + required: no + type: str + aliases: + - tlsCAFile + ssl_crlfile: + description: + - The ssl_crlfile option takes a path to a CRL file. + required: no + type: str + ssl_certfile: + description: + - Present a client certificate using the ssl_certfile option. + required: no + type: str + aliases: + - tlsCertificateKeyFile + ssl_keyfile: + description: + - Private key for the client certificate. + required: no + type: str + ssl_pem_passphrase: + description: + - Passphrase to decrypt encrypted private keys. + required: no + type: str + aliases: + - tlsCertificateKeyFilePassword + auth_mechanism: + description: + - Authentication type. + required: no + type: str + choices: + - 'SCRAM-SHA-256' + - 'SCRAM-SHA-1' + - 'MONGODB-X509' + - 'GSSAPI' + - 'PLAIN' + connection_options: + description: + - Additional connection options. + - Supply as a list of dicts or strings containing key value pairs seperated with '='. + required: no + type: list + elements: raw +''' diff --git a/ansible_collections/community/mongodb/plugins/lookup/__init__.py b/ansible_collections/community/mongodb/plugins/lookup/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/lookup/__init__.py diff --git a/ansible_collections/community/mongodb/plugins/lookup/mongodb.py b/ansible_collections/community/mongodb/plugins/lookup/mongodb.py new file mode 100644 index 000000000..b49c3f334 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/lookup/mongodb.py @@ -0,0 +1,269 @@ +# (c) 2016, Marcos Diez <marcos@unitron.com.br> +# https://github.com/marcosdiez/ +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = ''' +name: mongodb +author: + - Marcos Diez (@marcosdiez) +version_added: "1.0.0" +short_description: lookup info from MongoDB +description: + - 'The ``MongoDB`` lookup runs the *find()* command on a given *collection* on a given *MongoDB* server.' + - 'The result is a list of jsons, so slightly different from what PyMongo returns. In particular, *timestamps* are converted to epoch integers.' +options: + connect_string: + description: + - Can be any valid MongoDB connection string, supporting authentication, replica sets, etc. + - "More info at U(https://docs.mongodb.org/manual/reference/connection-string/)" + default: "mongodb://localhost/" + database: + description: + - Name of the database which the query will be made + required: True + collection: + description: + - Name of the collection which the query will be made + required: True + filter: + description: + - Criteria of the output + type: 'dict' + default: {} + projection: + description: + - Fields you want returned + type: dict + default: {} + skip: + description: + - How many results should be skipped + type: integer + limit: + description: + - How many results should be shown + type: integer + sort: + description: + - Sorting rules. + - Please use the strings C(ASCENDING) and C(DESCENDING) to set the order. + - Check the example for more information. + type: list + elements: list + default: [] + extra_connection_parameters: + description: + - Extra connection parameters that to be sent to pymongo.MongoClient + - Check the example to see how to connect to mongo using an SSL certificate. + - "All possible parameters are here: U(https://api.mongodb.com/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient)" + type: dict + default: {} +notes: + - "Please check https://api.mongodb.org/python/current/api/pymongo/collection.html?highlight=find#pymongo.collection.Collection.find for more details." +requirements: + - pymongo >= 2.4 (python library) +''' + +EXAMPLES = ''' +- hosts: localhost + gather_facts: false + vars: + mongodb_parameters: + #mandatory parameters + database: 'local' + collection: "startup_log" + #optional + connection_string: "mongodb://localhost/" + # connection_string: "mongodb://username:password@my.server.com:27017/" + # extra_connection_parameters: { "ssl" : True , "ssl_certfile": /etc/self_signed_certificate.pem" } + #optional query parameters, we accept any parameter from the normal mongodb query. + # filter: { "hostname": "u18" } + projection: { "pid": True , "_id" : False , "hostname" : True } + skip: 0 + limit: 1 + sort: [ [ "startTime" , "ASCENDING" ] , [ "age", "DESCENDING" ] ] + tasks: + - debug: msg="The PID from MongoDB is {{ lookup('mongodb', mongodb_parameters ).pid }}" + + - debug: msg="The HostName from the MongoDB server is {{ lookup('mongodb', mongodb_parameters ).hostname }}" + + - debug: msg="Mongo DB is stored at {{ lookup('mongodb', mongodb_parameters_inline )}}" + vars: + mongodb_parameters_inline: + database: 'local' + collection: "startup_log" + connection_string: "mongodb://localhost/" + limit: 1 + projection: { "cmdline.storage": True } + + # lookup syntax, does the same as below + - debug: msg="The hostname is {{ item.hostname }} and the pid is {{ item.pid }}" + loop: "{{ lookup('mongodb', mongodb_parameters, wantlist=True) }}" + + # query syntax, does the same as above + - debug: msg="The hostname is {{ item.hostname }} and the pid is {{ item.pid }}" + loop: "{{ query('mongodb', mongodb_parameters) }}" + + - name: "Raw output from the mongodb lookup (a json with pid and hostname )" + debug: msg="{{ lookup('mongodb', mongodb_parameters) }}" + + - name: "Yet another mongodb query, now with the parameters on the task itself" + debug: msg="pid={{item.pid}} hostname={{item.hostname}} version={{ item.buildinfo.version }}" + with_mongodb: + - database: 'local' + collection: "startup_log" + connection_string: "mongodb://localhost/" + limit: 1 + projection: { "pid": True , "hostname": True , "buildinfo.version": True } + + # Please notice this specific query may result more than one result. This is expected + - name: "Shows the whole output from mongodb" + debug: msg="{{ item }}" + with_mongodb: + - database: 'local' + collection: "startup_log" + connection_string: "mongodb://localhost/" + + +''' + +RETURN = """ + _list_of_jsons: + description: + - a list of JSONs with the results of the MongoDB query. + type: list +""" + +import datetime + +from ansible.module_utils.six import string_types, integer_types +from ansible.module_utils._text import to_native +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase + +try: + from pymongo import ASCENDING, DESCENDING + from pymongo.errors import ConnectionFailure + from pymongo import MongoClient +except ImportError: + try: # for older PyMongo 2.2 + from pymongo import Connection as MongoClient + except ImportError: + pymongo_found = False + else: + pymongo_found = True +else: + pymongo_found = True + + +class LookupModule(LookupBase): + + def _fix_sort_parameter(self, sort_parameter): + if sort_parameter is None: + return sort_parameter + + if not isinstance(sort_parameter, list): + raise AnsibleError(u"Error. Sort parameters must be a list, not [ {0} ]".format(sort_parameter)) + + for item in sort_parameter: + self._convert_sort_string_to_constant(item) + + return sort_parameter + + def _convert_sort_string_to_constant(self, item): + original_sort_order = item[1] + sort_order = original_sort_order.upper() + if sort_order == u"ASCENDING": + item[1] = ASCENDING + elif sort_order == u"DESCENDING": + item[1] = DESCENDING + # else the user knows what s/he is doing and we won't predict. PyMongo will return an error if necessary + + def convert_mongo_result_to_valid_json(self, result): + if result is None: + return result + if isinstance(result, integer_types + (float, bool)): + return result + if isinstance(result, string_types): + return result + elif isinstance(result, list): + new_list = [] + for elem in result: + new_list.append(self.convert_mongo_result_to_valid_json(elem)) + return new_list + elif isinstance(result, dict): + new_dict = {} + for key in result.keys(): + value = result[key] # python2 and 3 compatible.... + new_dict[key] = self.convert_mongo_result_to_valid_json(value) + return new_dict + elif isinstance(result, datetime.datetime): + # epoch + return (result - datetime.datetime(1970, 1, 1)). total_seconds() + else: + # failsafe + return u"{0}".format(result) + + def run(self, terms, variables, **kwargs): + try: + return self._run_helper(terms) + except Exception as e: + print(u"There was an exception on the mongodb_lookup: {0}".format(to_native(e))) + raise e + + def _run_helper(self, terms): + if not pymongo_found: + raise AnsibleError(u"pymongo is required in the control node (this machine) for mongodb lookup.") + ret = [] + for term in terms: + for required_parameter in [u"database", u"collection"]: + if required_parameter not in term: + raise AnsibleError(u"missing mandatory parameter [{0}]".format(required_parameter)) + + connection_string = term.get(u'connection_string', u"mongodb://localhost") + database = term[u"database"] + collection = term[u'collection'] + extra_connection_parameters = term.get(u'extra_connection_parameters', {}) + + if u"extra_connection_parameters" in term: + del term[u"extra_connection_parameters"] + if u"connection_string" in term: + del term[u"connection_string"] + del term[u"database"] + del term[u"collection"] + + if u"sort" in term: + term[u"sort"] = self._fix_sort_parameter(term[u"sort"]) + + # all other parameters are sent to mongo, so we are future and past proof + + try: + client = MongoClient(connection_string, **extra_connection_parameters) + results = client[database][collection].find(**term) + + for result in results: + result = self.convert_mongo_result_to_valid_json(result) + ret.append(result) + + except ConnectionFailure as e: + raise AnsibleError(u'unable to connect to database: %s' % str(e)) + + return ret diff --git a/ansible_collections/community/mongodb/plugins/module_utils/__init__.py b/ansible_collections/community/mongodb/plugins/module_utils/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/module_utils/__init__.py diff --git a/ansible_collections/community/mongodb/plugins/module_utils/mongodb_common.py b/ansible_collections/community/mongodb/plugins/module_utils/mongodb_common.py new file mode 100644 index 000000000..e1ab27293 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/module_utils/mongodb_common.py @@ -0,0 +1,482 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type +from ansible.module_utils.basic import missing_required_lib # pylint: disable=unused-import: +from ansible.module_utils.six.moves import configparser +from ansible.module_utils._text import to_native +import traceback +import os +import ssl as ssl_lib + + +try: + from bson.timestamp import Timestamp + from bson import ObjectId +except ImportError: + pass # TODO Should we do something here or are we covered by pymongo? + +MongoClient = None +PYMONGO_IMP_ERR = None +pymongo_found = None +PyMongoVersion = None +ConnectionFailure = None +OperationFailure = None +TYPES_NEED_TO_CONVERT = None + +try: + from pymongo.errors import ConnectionFailure # pylint: disable=unused-import: + from pymongo.errors import OperationFailure # pylint: disable=unused-import: + from pymongo import version as PyMongoVersion + from pymongo import MongoClient + pymongo_found = True +except ImportError: + PYMONGO_IMP_ERR = traceback.format_exc() + pymongo_found = False + +try: + TYPES_NEED_TO_CONVERT = (Timestamp, ObjectId) +except NameError: + pass # sanity tests + + +def check_compatibility(module, srv_version, driver_version): + if int(driver_version[0]) >= 4: + if int(srv_version[0]) < 4: + if module.params['strict_compatibility']: + module.fail_json(msg="This version of MongoDB is pretty old and these modules are no longer tested against this version.") + else: + module.warn("This version of MongoDB is pretty old and these modules are no longer tested against this version.") + else: + if module.params['strict_compatibility']: + module.fail_json(msg="You must use pymongo 4+.") + else: + module.warn("You must use pymongo 4+.") + + +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 + + +def index_exists(client, database, collection, index_name): + """ + Returns true if an index on the collection exists with the given name + @client: MongoDB connection. + @database: MongoDB Database. + @collection: MongoDB collection. + @index_name: The index name. + """ + exists = False + indexes = client[database][collection].list_indexes() + for index in indexes: + if index["name"] == index_name: + exists = True + return exists + + +def create_index(client, database, collection, keys, options): + """ + Creates an index on the given collection + @client: MongoDB connection. + @database: MongoDB Database - str. + @collection: MongoDB collection - str. + @keys: Specification of index - dict. + """ + client[database][collection].create_index(list(keys.items()), + **options) + + +def drop_index(client, database, collection, index_name): + client[database][collection].drop_index(index_name) + + +def member_state(client): + """Check if a replicaset exists. + + Args: + client (cursor): Mongodb cursor on admin database. + + Returns: + str: member state i.e. PRIMARY, SECONDARY + """ + state = None + doc = client['admin'].command('replSetGetStatus') + for member in doc["members"]: + if "self" in member.keys(): + state = str(member['stateStr']) + return state + + +def mongodb_common_argument_spec(ssl_options=True): + """ + Returns a dict containing common options shared across the MongoDB modules. + """ + options = dict( + login_user=dict(type='str', required=False), + login_password=dict(type='str', required=False, no_log=True), + login_database=dict(type='str', required=False, default='admin'), + login_host=dict(type='str', required=False, default='localhost'), + login_port=dict(type='int', required=False, default=27017), + strict_compatibility=dict(type='bool', default=True), + atlas_auth=dict(type='bool', default=False), + ) + ssl_options_dict = dict( + ssl=dict(type='bool', required=False, default=False, aliases=['tls']), + ssl_cert_reqs=dict(type='str', + required=False, + default='CERT_REQUIRED', + choices=['CERT_NONE', + 'CERT_OPTIONAL', + 'CERT_REQUIRED'], + aliases=['tlsAllowInvalidCertificates']), + ssl_ca_certs=dict(type='str', default=None, aliases=['tlsCAFile']), + ssl_crlfile=dict(type='str', default=None), + ssl_certfile=dict(type='str', default=None, aliases=['tlsCertificateKeyFile']), + ssl_keyfile=dict(type='str', default=None, no_log=True), + ssl_pem_passphrase=dict(type='str', default=None, no_log=True, aliases=['tlsCertificateKeyFilePassword']), + auth_mechanism=dict(type='str', + required=False, + default=None, + choices=['SCRAM-SHA-256', + 'SCRAM-SHA-1', + 'MONGODB-X509', + 'GSSAPI', + 'PLAIN']), + connection_options=dict(type='list', + elements='raw', + default=None) + ) + if ssl_options: + options.update(ssl_options_dict) + return options + + +def rename_ssl_option_for_pymongo4(connection_options): + """ + This function renames the old ssl parameter, and sorts the data out, + when the driver use is >= PyMongo 4 + """ + if int(PyMongoVersion[0]) >= 4: + if connection_options.get('ssl_cert_reqs', None) == 'CERT_NONE': + connection_options['tlsAllowInvalidCertificates'] = False + elif connection_options.get('ssl_cert_reqs', None) == 'CERT_REQUIRED': + connection_options['tlsAllowInvalidCertificates'] = False + connection_options.pop('ssl_cert_reqs', None) + if connection_options.get('ssl_ca_certs', None) is not None: + connection_options['tlsCAFile'] = connection_options['ssl_ca_certs'] + connection_options.pop('ssl_ca_certs', None) + connection_options.pop('ssl_crlfile', None) + if connection_options.get('ssl_certfile', None) is not None: + connection_options['tlsCertificateKeyFile'] = connection_options['ssl_certfile'] + elif connection_options.get('ssl_keyfile', None) is not None: + connection_options['tlsCertificateKeyFile'] = connection_options['ssl_keyfile'] + connection_options.pop('ssl_certfile', None) + connection_options.pop('ssl_keyfile', None) + if connection_options.get('ssl_pem_passphrase', None) is not None: + connection_options['tlsCertificateKeyFilePassword'] = connection_options['ssl_pem_passphrase'] + connection_options.pop('ssl_pem_passphrase', None) + return connection_options + + +def add_option_if_not_none(param_name, module, connection_params): + ''' + @param_name - The parameter name to check + @module - The ansible module object + @connection_params - Dict containing the connection params + ''' + if module.params[param_name] is not None: + connection_params[param_name] = module.params[param_name] + return connection_params + + +def ssl_connection_options(connection_params, module): + connection_params['ssl'] = True + if module.params['ssl_cert_reqs'] is not None: + connection_params['ssl_cert_reqs'] = getattr(ssl_lib, module.params['ssl_cert_reqs']) + connection_params = add_option_if_not_none('ssl_ca_certs', module, connection_params) + connection_params = add_option_if_not_none('ssl_crlfile', module, connection_params) + connection_params = add_option_if_not_none('ssl_certfile', module, connection_params) + connection_params = add_option_if_not_none('ssl_keyfile', module, connection_params) + connection_params = add_option_if_not_none('ssl_pem_passphrase', module, connection_params) + if module.params['auth_mechanism'] is not None: + connection_params['authMechanism'] = module.params['auth_mechanism'] + if module.params['connection_options'] is not None: + for item in module.params['connection_options']: + if isinstance(item, dict): + for key, value in item.items(): + connection_params[key] = value + elif isinstance(item, str) and "=" in item: + connection_params[item.split('=')[0]] = item.split('=')[1] + else: + raise ValueError("Invalid value supplied in connection_options: {0} .".format(str(item))) + return connection_params + + +def check_srv_version(module, client): + srv_version = None + try: + srv_version = client.server_info()['version'] + except Exception as excep: + module.fail_json(msg='Unable to get MongoDB server version: %s' % to_native(excep)) + return srv_version + + +def check_driver_compatibility(module, client, srv_version): + try: + # Get driver version:: + driver_version = PyMongoVersion + # Check driver and server version compatibility: + check_compatibility(module, srv_version, driver_version) + except Exception as excep: + module.fail_json(msg='Unable to check driver compatibility: %s' % to_native(excep)) + + +def get_mongodb_client(module, login_user=None, login_password=None, login_database=None, directConnection=False): + """ + Build the connection params dict and returns a MongoDB Client object + """ + connection_params = { + 'host': module.params['login_host'], + 'port': module.params['login_port'], + } + + if directConnection: + connection_params['directConnection'] = True + if module.params['ssl']: + connection_params = ssl_connection_options(connection_params, module) + connection_params = rename_ssl_option_for_pymongo4(connection_params) + # param exists only in some modules + if 'replica_set' in module.params and 'reconfigure' not in module.params: + connection_params["replicaset"] = module.params['replica_set'] + elif 'replica_set' in module.params and 'reconfigure' in module.params \ + and module.params['reconfigure']: + connection_params["replicaset"] = module.params['replica_set'] + if login_user: + connection_params['username'] = login_user + connection_params['password'] = login_password + connection_params['authSource'] = login_database + client = MongoClient(**connection_params) + return client + + +def is_auth_enabled(module): + """ + Returns True if auth is enabled on the mongo instance + For PyMongo 4+ we have to connect directly to the instance + rather than the replicaset + """ + auth_is_enabled = None + connection_params = {} + connection_params['host'] = module.params['login_host'] + connection_params['port'] = module.params['login_port'] + connection_params['directConnection'] = True # Need to do this for 3.12.* as well + if int(PyMongoVersion[0]) >= 4: # we need to connect directly to the instance + connection_params['directConnection'] = True + else: + if 'replica_set' in module.params and module.params['replica_set'] is not None: + connection_params['replicaset'] = module.params['replica_set'] + if module.params['ssl']: + connection_params = ssl_connection_options(connection_params, module) + connection_params = rename_ssl_option_for_pymongo4(connection_params) + try: + myclient = MongoClient(**connection_params) + myclient['admin'].command('listDatabases', 1.0) + auth_is_enabled = False + except Exception as excep: + if hasattr(excep, 'code') and excep.code in [13]: + auth_is_enabled = True + if auth_is_enabled is None: # if this is still none we have a problem + module.fail_json(msg='Unable to determine if auth is enabled: {0}'.format(traceback.format_exc())) + finally: + myclient.close() + return auth_is_enabled + + +def mongo_auth(module, client, directConnection=False): + """ + TODO: This function was extracted from code from the mongodb_replicaset module. + We should refactor other modules to use this where appropriate. - DONE? + @module - The calling Ansible module + @client - The MongoDB connection object + """ + login_user = module.params['login_user'] + login_password = module.params['login_password'] + login_database = module.params['login_database'] + + atlas_auth = module.params['atlas_auth'] + + fail_msg = None # Our test code had issues with multiple exit points with fail_json + + crypt_flag = 'ssl' + if 'tls' in module.params: + crypt_flag = 'tls' + + if not atlas_auth: + + 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 not all([login_user, login_password]) and module.params[crypt_flag] is False: + fail_msg = "When supplying login arguments, both 'login_user' and 'login_password' must be provided" + + if 'create_for_localhost_exception' not in module.params and fail_msg is None: + try: + if is_auth_enabled(module): + if login_user is not None and login_password is not None: + client = get_mongodb_client(module, login_user, login_password, login_database, directConnection=directConnection) + else: + fail_msg = 'No credentials to authenticate' + except Exception as excep: + fail_msg = 'unable to connect to database: %s' % to_native(excep) + # Get server version: + if fail_msg is None: + srv_version = check_srv_version(module, client) + check_driver_compatibility(module, client, srv_version) + elif fail_msg is None: # this is the mongodb_user module + if login_user is not None and login_password is not None: + client = get_mongodb_client(module, login_user, login_password, login_database, directConnection=directConnection) + # Get server version: + srv_version = check_srv_version(module, client) + check_driver_compatibility(module, client, srv_version) + elif module.params['strict_compatibility'] is False: + if module.params['database'] not in ["admin", "$external"]: + fail_msg = 'The localhost login exception only allows the first admin account to be created' + # else: this has to be the first admin user added + if fail_msg: + module.fail_json(msg=fail_msg) + else: # Atlas auth path + if 'create_for_localhost_exception' not in module.params and fail_msg is None: + try: + if login_user is not None and login_password is not None: + # pymongo >= 4. There's no authenticate method in pymongo 4.0. Recreate the connection object + client = get_mongodb_client(module, login_user, login_password, login_database) + else: + fail_msg = 'No credentials to authenticate' + except Exception as excep: + fail_msg = 'unable to connect to database: %s' % to_native(excep) + elif fail_msg is None: # this is the mongodb_user module + if login_user is not None and login_password is not None: + client = get_mongodb_client(module, login_user, login_password, login_database, directConnection=False) + # Get server version: + srv_version = check_srv_version(module, client) + check_driver_compatibility(module, client, srv_version) + elif module.params['strict_compatibility'] is False: + if module.params['database'] not in ["admin", "$external"]: + fail_msg = 'The localhost login exception only allows the first admin account to be created' + # else: this has to be the first admin user added + if fail_msg: + module.fail_json(msg=fail_msg) + return client + + +def member_dicts_different(conf, member_config): + ''' + Returns if there is a difference in the replicaset configuration that we care about + @con - The current MongoDB Replicaset configure document + @member_config - The member dict config provided by the module. List of dicts + ''' + current_member_config = conf['members'] + member_config_defaults = { + "arbiterOnly": False, + "buildIndexes": True, + "hidden": False, + "priority": {"nonarbiter": 1.0, "arbiter": 0}, + "tags": {}, + "secondardDelaySecs": 0, + "votes": 1 + } + different = False + msg = "None" + current_member_hosts = [] + for member in current_member_config: + current_member_hosts.append(member['host']) + member_config_hosts = [] + for member in member_config: + if ':' not in member['host']: # no port supplied + member_config_hosts.append(member['host'] + ":27017") + else: + member_config_hosts.append(member['host']) + if sorted(current_member_hosts) != sorted(member_config_hosts): # compare if members are the same + different = True + msg = "hosts different" + else: # Compare dict key to see if votes, tags etc have changed. We also default value if key is not specified + for host in current_member_hosts: + member_index = next((index for (index, d) in enumerate(current_member_config) if d["host"] == host), None) + new_member_index = next((index for (index, d) in enumerate(member_config) if d["host"] == host), None) + for config_item in member_config_defaults: + if config_item != "priority": + if current_member_config[member_index].get(config_item, member_config_defaults[config_item]) != \ + member_config[new_member_index].get(config_item, member_config_defaults[config_item]): + different = True + msg = "var different {0} {1} {2}".format(config_item, + current_member_config[member_index].get(config_item, member_config_defaults[config_item]), + member_config[new_member_index].get(config_item, member_config_defaults[config_item])) + break + else: # priority a special case + role = "nonarbiter" + if current_member_config[member_index]["arbiterOnly"]: + role = "arbiter" + if current_member_config[member_index][config_item] != \ + member_config[new_member_index].get(config_item, member_config_defaults[config_item][role]): + different = True + msg = "var different {0}".format(config_item) + break + else: # for case when the member is not an arbiter + if current_member_config[member_index]["priority"] != \ + member_config[new_member_index].get(config_item, 1.0): + different = True + msg = "var different {0}".format(config_item) + break + return different # , msg + + +def lists_are_different(list1, list2): + diff = False + if sorted(list1) != sorted(list2): + diff = True + return diff + + +# Taken from https://github.com/ansible-collections/community.postgresql/blob/main/plugins/module_utils/postgres.py#L420 +def convert_to_supported(val): + """Convert unsupported type to appropriate. + Args: + val (any) -- Any value fetched from database. + Returns value of appropriate type. + """ + if isinstance(val, Timestamp): + return str(val) + elif isinstance(val, ObjectId): + return str(val) + + return val # By default returns the same value + + +def convert_bson_values_recur(mydict): + """ + Converts values that Ansible doesn't like + # https://github.com/ansible-collections/community.mongodb/issues/462 + """ + if isinstance(mydict, dict): + for key, value in mydict.items(): + if isinstance(value, dict): + mydict[key] = convert_bson_values_recur(value) + else: + if isinstance(value, TYPES_NEED_TO_CONVERT): + mydict[key] = convert_to_supported(value) + else: + mydict[key] = value + return mydict diff --git a/ansible_collections/community/mongodb/plugins/module_utils/mongodb_shell.py b/ansible_collections/community/mongodb/plugins/module_utils/mongodb_shell.py new file mode 100644 index 000000000..5108757c8 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/module_utils/mongodb_shell.py @@ -0,0 +1,135 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import shlex +import pipes +import re +import json +import os + + +def escape_param(param): + ''' + Escapes the given parameter + @param - The parameter to escape + ''' + escaped = None + if hasattr(shlex, 'quote'): + escaped = shlex.quote(param) + elif hasattr(pipes, 'quote'): + escaped = pipes.quote(param) + else: + escaped = "'" + param.replace("'", "'\\''") + "'" + return escaped + + +def add_arg_to_cmd(cmd_list, param_name, param_value, is_bool=False, omit=None): + """ + @cmd_list - List of cmd args. + @param_name - Param name / flag. + @param_value - Value of the parameter. + @is_bool - Flag is a boolean and has no value. + @omit - List of parameter to omit from the command line. + """ + if param_name.replace('-', '') not in omit: + if is_bool is False and param_value is not None: + cmd_list.append(param_name) + if param_name == "--eval": + cmd_list.append("{0}".format(escape_param(param_value))) + else: + cmd_list.append(param_value) + elif is_bool is True: + cmd_list.append(param_name) + return cmd_list + + +def extract_json_document(output): + """ + This is for specific type of mongo shell return data in the format SomeText() + https://github.com/ansible-collections/community.mongodb/issues/436 + i.e. + + """ + output = output.strip() + if re.match(r"^[a-zA-Z].*\(", output) and output.endswith(')'): + first_bracket = output.find('{') + last_bracket = output.rfind('}') + if first_bracket > 0 and last_bracket > 0: + tmp = output[first_bracket:last_bracket + 1] + tmp = tmp.replace('\n', '') + tmp = tmp.replace('\t', '') + if tmp is not None: + output = tmp + return output + + +def transform_output(output, transform_type, split_char): + output = extract_json_document(output) + if transform_type == "auto": # determine what transform_type to perform + if output.strip().startswith("{") or output.strip().startswith("["): + transform_type = "json" + elif isinstance(output.strip().split(None), list): # Splits on whitespace + transform_type = "split" + split_char = None + elif isinstance(output.strip().split(","), list): + transform_type = "split" + split_char = "," + elif isinstance(output.strip().split(" "), list): + transform_type = "split" + split_char = " " + elif isinstance(output.strip().split("|"), list): + transform_type = "split" + split_char = "|" + elif isinstance(output.strip().split("\t"), list): + transform_type = "split" + split_char = "\t" + else: + transform_type = "raw" + if transform_type == "json": + try: + output = json.loads(output) + except json.decoder.JSONDecodeError: + # Strip Extended JSON stuff like: + # "_id": ObjectId("58f56171ee9d4bd5e610d6b7"), + # "count": NumberLong(999), + output = re.sub(r'\:\s*\S+\s*\(\s*(\S+)\s*\)', r':\1', output) + try: + output = json.dumps(output, separators=(',', ':')) + doc = json.loads(output) + except json.decoder.JSONDecodeError as excep: + raise excep + elif transform_type == "split": + output = output.strip().split(split_char) + elif transform_type == "raw": + output = output.strip() + return output + + +def get_hash_value(module): + ''' + Returns the hash value of either the provided file or eval command + ''' + hash_value = None + try: + import hashlib + except ImportError as excep: + module.fail_json(msg="Unable to import hashlib: {0}".format(excep.message)) + if module.params['file'] is not None: + hash_value = hashlib.md5(module.params['file'].encode('utf-8')).hexdigest() + else: + hash_value = hashlib.md5(module.params['eval'].encode('utf-8')).hexdigest() + return hash_value + + +def touch(fname, times=None): + with open(fname, 'a'): + os.utime(fname, times) + + +def detect_if_cmd_exist(cmd="mongosh"): + path = os.getenv('PATH') + for folder in path.split(os.path.pathsep): + mongoCmd = os.path.join(folder, cmd) + if os.path.exists(mongoCmd) and os.access(mongoCmd, os.X_OK): + return True + return False diff --git a/ansible_collections/community/mongodb/plugins/modules/__init__.py b/ansible_collections/community/mongodb/plugins/modules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/__init__.py diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_balancer.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_balancer.py new file mode 100644 index 000000000..0e9b33a34 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_balancer.py @@ -0,0 +1,452 @@ +#!/usr/bin/python + +# Copyright: (c) 2020, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_balancer +short_description: Manages the MongoDB Sharded Cluster Balancer. +description: + - Manages the MongoDB Sharded Cluster Balancer. + - Start or stop the balancer. + - Adjust the cluster chunksize. + - Enable or disable autosplit. + - Add or remove a balancer window. +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + autosplit: + description: + - Disable or enable the autosplit flag in the config.settings collection. + required: false + type: bool + chunksize: + description: + - Control the size of chunks in the sharded cluster. + - Value should be given in MB. + required: false + type: int + state: + description: + - Manage the Balancer for the Cluster + required: false + type: str + choices: + - "started" + - "stopped" + default: "started" + mongos_process: + description: + - Provide a custom name for the mongos process. + - Most users can ignore this setting. + required: false + type: str + default: "mongos" + window: + description: + - Schedule the balancer window. + - Provide the following dictionary keys start, stop, state + - The state key should be "present" or "absent". + - The start and stop keys are ignored when state is "absent". + - start and stop should be strings in "HH:MM" format indicating the time bounds of the window. + type: raw + required: false +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''' +- name: Start the balancer + community.mongodb.mongodb_balancer: + state: started + +- name: Stop the balancer and disable autosplit + community.mongodb.mongodb_balancer: + state: stopped + autosplit: false + +- name: Enable autosplit + community.mongodb.mongodb_balancer: + autosplit: true + +- name: Change the default chunksize to 128MB + community.mongodb.mongodb_balancer: + chunksize: 128 + +- name: Add or update a balancing window + community.mongodb.mongodb_balancer: + window: + start: "23:00" + stop: "06:00" + state: "present" + +- name: Remove a balancing window + community.mongodb.mongodb_balancer: + window: + state: "absent" +''' + +RETURN = r''' +changed: + description: Whether the balancer state or autosplit changed. + returned: success + type: bool +old_balancer_state: + description: The previous state of the balancer + returned: When balancer state is changed + type: str +new_balancer_state: + description: The new state of the balancer. + returned: When balancer state is changed + type: str +old_autosplit: + description: The previous state of autosplit. + returned: When autosplit is changed. + type: str +new_autosplit: + description: The new state of autosplit. + returned: When autosplit is changed. + type: str +old_chunksize: + description: The previous value for chunksize. + returned: When chunksize is changed. + type: int +new_chunksize: + description: The new value for chunksize. + returned: When chunksize is changed. + type: int +msg: + description: A short description of what happened. + returned: failure + type: str +failed: + description: If something went wrong + returned: failed + type: bool +''' + +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + +has_ordereddict = False +try: + from collections import OrderedDict + has_ordereddict = True +except ImportError as excep: + try: + from ordereddict import OrderedDict + has_ordereddict = True + except ImportError as excep: + pass + + +def get_balancer_state(client): + ''' + Gets the state of the MongoDB balancer. The config.settings collection does + not exist until the balancer has been started for the first time + { "_id" : "balancer", "mode" : "full", "stopped" : false } + { "_id" : "autosplit", "enabled" : true } + ''' + balancer_state = None + result = client["config"].settings.find_one({"_id": "balancer"}) + if not result: + balancer_state = "stopped" + else: + if result['stopped'] is False: + balancer_state = "started" + else: + balancer_state = "stopped" + return balancer_state + + +def stop_balancer(client): + ''' + Stops MongoDB balancer + ''' + cmd_doc = OrderedDict([ + ('balancerStop', 1), + ('maxTimeMS', 60000) + ]) + client['admin'].command(cmd_doc) + time.sleep(1) + + +def start_balancer(client): + ''' + Starts MongoDB balancer + ''' + cmd_doc = OrderedDict([ + ('balancerStart', 1), + ('maxTimeMS', 60000) + ]) + client['admin'].command(cmd_doc) + time.sleep(1) + + +def enable_autosplit(client): + client["config"].settings.update_one({"_id": "autosplit"}, + {"$set": {"enabled": True}}, + upsert=True) + + +def disable_autosplit(client): + client["config"].settings.update_one({"_id": "autosplit"}, + {"$set": {"enabled": False}}, + upsert=True) + + +def get_autosplit(client): + autosplit = False + result = client["config"].settings.find_one({"_id": "autosplit"}) + if result is not None: + autosplit = result['enabled'] + return autosplit + + +def get_chunksize(client): + ''' + Default chunksize is 64MB + ''' + chunksize = None + result = client["config"].settings.find_one({"_id": "chunksize"}) + if not result: + chunksize = 64 + else: + chunksize = result['value'] + return chunksize + + +def set_chunksize(client, chunksize): + client["config"].settings.update_one({"_id": "chunksize"}, + {"$set": {"value": chunksize}}, + upsert=True) + + +def set_balancing_window(client, start, stop): + s = False + result = client["config"].settings.update_one({"_id": "balancer"}, + {"$set": { + "activeWindow": { + "start": start, + "stop": stop}}}, + upsert=True) + if result.modified_count == 1 or result.upserted_id is not None: + s = True + return s + + +def remove_balancing_window(client): + s = False + result = client["config"].settings.update_one({"_id": "balancer"}, + {"$unset": {"activeWindow": True}}) + if result.modified_count == 1: + s = True + return s + + +def balancing_window(client, start, stop): + s = False + if start is not None and stop is not None: + result = client["config"].settings.find_one({"_id": "balancer", + "activeWindow.start": start, + "activeWindow.stop": stop}) + else: + result = client["config"].settings.find_one({"_id": "balancer", "activeWindow": {"$exists": True}}) + if result: + s = True + return s + + +def validate_window(window, module): + if window is not None: + if 'state' not in window.keys(): + module.fail_json(msg="Balancing window state must be specified") + elif window['state'] not in ['present', 'absent']: + module.fail_json(msg="Balancing window state must be present or absent") + elif window['state'] == "present" \ + and ("start" not in window.keys() + or "stop" not in window.keys()): + module.fail_json(msg="Balancing window start and stop values must be specified") + return True + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + autosplit=dict(type='bool', default=None), + chunksize=dict(type='int', default=None), + mongos_process=dict(type='str', required=False, default="mongos"), + state=dict(type='str', default="started", choices=["started", "stopped"]), + window=dict(type='raw', default=None) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not has_ordereddict: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict') + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + login_host = module.params['login_host'] + login_port = module.params['login_port'] + balancer_state = module.params['state'] + autosplit = module.params['autosplit'] + chunksize = module.params['chunksize'] + mongos_process = module.params['mongos_process'] + window = module.params['window'] + + # Validate window + validate_window(window, module) + + result = dict( + changed=False, + ) + + try: + client = get_mongodb_client(module) + client = mongo_auth(module, client) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + changed = False + cluster_balancer_state = None + cluster_autosplit = None + cluster_chunksize = None + old_balancer_state = None + new_balancer_state = None + old_autosplit = None + new_autosplit = None + old_chunksize = None + new_chunksize = None + + try: + + if client["admin"].command("serverStatus")["process"] != mongos_process: + module.fail_json(msg="Process running on {0}:{1} is not a {2}".format(login_host, login_port, mongos_process)) + + cluster_balancer_state = get_balancer_state(client) + if autosplit is not None: + cluster_autosplit = get_autosplit(client) + if chunksize is not None: + cluster_chunksize = get_chunksize(client) + + if module.check_mode: + if balancer_state != cluster_balancer_state: + old_balancer_state = cluster_balancer_state + new_balancer_state = balancer_state + changed = True + if (autosplit is not None + and autosplit != cluster_autosplit): + old_autosplit = cluster_autosplit + new_autosplit = autosplit + changed = True + if (chunksize is not None + and chunksize != cluster_chunksize): + old_chunksize = cluster_chunksize + new_chunksize = chunksize + changed = True + if window is not None: + if balancing_window(client, window.get('start'), window.get('stop')): + if window['state'] == "present": + pass + else: + changed = True + else: + if window['state'] == "present": + changed = True + else: + pass + else: + if balancer_state is not None \ + and balancer_state != cluster_balancer_state: + if balancer_state == "started": + start_balancer(client) + old_balancer_state = cluster_balancer_state + new_balancer_state = get_balancer_state(client) + changed = True + else: + stop_balancer(client) + old_balancer_state = cluster_balancer_state + new_balancer_state = get_balancer_state(client) + changed = True + if autosplit is not None \ + and autosplit != cluster_autosplit: + if autosplit: + enable_autosplit(client) + old_autosplit = cluster_autosplit + new_autosplit = autosplit + changed = True + else: + disable_autosplit(client) + old_autosplit = cluster_autosplit + new_autosplit = autosplit + changed = True + if (chunksize is not None + and chunksize != cluster_chunksize): + set_chunksize(client, chunksize) + old_chunksize = cluster_chunksize + new_chunksize = chunksize + changed = True + if window is not None: + if balancing_window(client, window.get('start'), window.get('stop')): + if window['state'] == "present": + pass + else: + remove_balancing_window(client) + changed = True + else: + if window['state'] == "present": + set_balancing_window(client, + window['start'], + window['stop']) + changed = True + else: + pass + except Exception as excep: + result["msg"] = "An error occurred: {0}".format(excep) + + result['changed'] = changed + if old_balancer_state is not None: + result['old_balancer_state'] = old_balancer_state + result['new_balancer_state'] = new_balancer_state + if old_autosplit is not None: + result['old_autosplit'] = old_autosplit + result['new_autosplit'] = new_autosplit + if old_chunksize is not None: + result['old_chunksize'] = old_chunksize + result['new_chunksize'] = new_chunksize + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_index.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_index.py new file mode 100644 index 000000000..ee69c093b --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_index.py @@ -0,0 +1,405 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Rhys Campbell (@rhysmeister) <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_index + +short_description: Creates or drops indexes on MongoDB collections. + +description: + - Creates or drops indexes on MongoDB collections. + - Supports multiple index options, i.e. unique, sparse and partial. + - Validates existence of indexes by name only. + +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + indexes: + description: + - List of indexes to create or drop + type: list + elements: raw + required: yes + replica_set: + description: + - Replica set to connect to (automatically connects to primary for writes). + type: str +notes: + - Requires the pymongo Python package on the remote host, version 2.4.2+. + +requirements: + - pymongo +''' + +EXAMPLES = r''' +- name: Create a single index on a collection + community.mongodb.mongodb_index: + login_user: admin + login_password: secret + indexes: + - database: mydb + collection: test + keys: + - username: 1 + last_login: -1 + options: + name: myindex + state: present + +- name: Drop an index on a collection + community.mongodb.mongodb_index: + login_user: admin + login_password: secret + indexes: + - database: mydb + collection: test + options: + name: myindex + state: absent + +- name: Create multiple indexes + community.mongodb.mongodb_index: + login_user: admin + login_password: secret + indexes: + - database: mydb + collection: test + keys: + - username: 1 + last_login: -1 + options: + name: myindex + state: present + - database: mydb + collection: test + keys: + - email: 1 + last_login: -1 + options: + name: myindex2 + state: present + +- name: Add a unique index + community.mongodb.mongodb_index: + login_port: 27017 + login_user: admin + login_password: secret + login_database: "admin" + indexes: + - database: "test" + collection: "rhys" + keys: + username: 1 + options: + name: myuniqueindex + unique: true + state: present + +- name: Add a ttl index + community.mongodb.mongodb_index: + login_port: 27017 + login_user: admin + login_password: secret + login_database: "admin" + indexes: + - database: "test" + collection: "rhys" + keys: + created: 1 + options: + name: myttlindex + expireAfterSeconds: 3600 + state: present + +- name: Add a sparse index + community.mongodb.mongodb_index: + login_port: 27017 + login_user: admin + login_password: secret + login_database: "admin" + indexes: + - database: "test" + collection: "rhys" + keys: + last_login: -1 + options: + name: mysparseindex + sparse: true + state: present + +- name: Add a partial index + community.mongodb.mongodb_index: + login_port: 27017 + login_user: admin + login_password: secret + login_database: "admin" + indexes: + - database: "test" + collection: "rhys" + keys: + last_login: -1 + options: + name: mypartialindex + partialFilterExpression: + rating: + $gt: 5 + state: present + +- name: Add a index in the background (background option is deprecated from 4.2+) + community.mongodb.mongodb_index: + login_port: 27017 + login_user: admin + login_password: secret + login_database: "admin" + indexes: + - database: "test" + collection: "rhys" + options: + name: idxbackground + keys: + username: -1 + backgroud: true + state: present + +- name: Check creating 5 index all with multiple options specified + community.mongodb.mongodb_index: + login_port: 27017 + login_user: admin + login_password: secret + login_database: "admin" + indexes: + - database: "test" + collection: "indextest" + options: + name: "idx_unq_username" + unique: true + keys: + username: -1 + state: present + - database: "test" + collection: "indextest" + options: + name: "idx_last_login" + sparse: true + keys: + last_login: -1 + state: present + - database: "test" + collection: "indextest" + options: + name: "myindex" + keys: + first_name: 1 + last_name: -1 + city: 1 + state: present + - database: "test" + collection: partialtest + options: + name: "idx_partialtest" + partialFilterExpression: + rating: + $gt: 5 + keys: + rating: -1 + title: 1 + state: present + - database: "test" + collection: "wideindex" + options: + name: "mywideindex" + keys: + email: -1 + username: 1 + first_name: 1 + last_name: 1 + dob: -1 + city: 1 + last_login: -1 + review_count: 1 + rating_count: 1 + last_post: -1 + state: present +''' + +RETURN = r''' +indexes_created: + description: List of indexes created. + returned: always + type: list + sample: ["myindex", "myindex2"] +indexes_dropped: + description: List of indexes dropped. + returned: always + type: list + sample: ["myindex", "myindex2"] +changed: + description: Indicates the module has changed something. + returned: When the module has changed something. + type: bool +failed: + description: Indicates the module has failed. + returned: When the module has encountered an error. + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + PYMONGO_IMP_ERR, + pymongo_found, + index_exists, + create_index, + drop_index, + mongo_auth, + get_mongodb_client, +) + + +def validate_module(module): + ''' + Runs validation rules specific the mongodb_index module + ''' + required_index_keys = [ + "database", + "collection", + "options", + "state", + ] + indexes = module.params['indexes'] + + if len(indexes) == 0: + module.fail_json(msg="One or more indexes must be specified") + if not all(isinstance(i, dict) for i in indexes): + module.fail_json(msg="Indexes must be supplied as dictionaries") + + # Ensure keys are present in index spec + for k in required_index_keys: + for i in indexes: + if k not in i.keys(): + module.fail_json(msg="Missing required index key {0}".format(k)) + + # Check index subkeys look correct + for i in indexes: + if not isinstance(i["database"], str): + module.fail_json(msg="database key should be str") + elif not isinstance(i["collection"], str): + module.fail_json(msg="collection key should be str") + elif i["state"] == "present" and "keys" not in i.keys(): + module.fail_json(msg="keys must be supplied when state is present") + elif i["state"] == "present" and not isinstance(i["keys"], dict): + module.fail_json(msg="keys key should be dict") + elif not isinstance(i["options"], dict): + module.fail_json(msg="options key should be dict") + elif "name" not in i["options"]: + module.fail_json(msg="The options dict must contain a name field") + elif i["state"] not in ["present", "absent"]: + module.fail_json(msg="state must be one of present or absent") + + +# ================ +# Module execution +# +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + indexes=dict(type='list', elements='raw', required=True), + replica_set=dict(type='str'), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + validate_module(module) + + indexes = module.params['indexes'] + + client = get_mongodb_client(module) + client = mongo_auth(module, client) + + # Pre flight checks done + indexes_created = [] + indexes_dropped = [] + changed = None + for i in indexes: + try: + idx = index_exists(client, i["database"], i["collection"], i["options"]["name"]) + except Exception as excep: + module.fail_json(msg="Could not determine index status: {0}".format(str(excep))) + if module.check_mode: + if idx: + if i["state"] == "present": + changed = False + elif i["state"] == "absent": + indexes_dropped.append("{0}.{1}.{2}".format(i["database"], + i["collection"], + i["options"]["name"])) + changed = True + else: + if i["state"] == "present": + indexes_created.append("{0}.{1}.{2}".format(i["database"], + i["collection"], + i["options"]["name"])) + changed = True + elif i["state"] == "absent": + changed = False + else: + if idx: + if i["state"] == "present": + changed = False + elif i["state"] == "absent": + try: + drop_index(client, i["database"], i["collection"], + i["options"]["name"]) + indexes_dropped.append("{0}.{1}.{2}".format(i["database"], + i["collection"], + i["options"]["name"])) + changed = True + except Exception as excep: + module.fail_json(msg="Error dropping index: {0}".format(str(excep))) + + else: + if i["state"] == "present": + try: + create_index(client=client, + database=i["database"], + collection=i["collection"], + keys=i["keys"], + options=i["options"]) + indexes_created.append("{0}.{1}.{2}".format(i["database"], + i["collection"], + i["options"]["name"])) + changed = True + except Exception as excep: + module.fail_json(msg="Error creating index: {0}".format(str(excep))) + elif i["state"] == "absent": + changed = False + + module.exit_json(changed=changed, + indexes_created=indexes_created, + indexes_dropped=indexes_dropped) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_info.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_info.py new file mode 100644 index 000000000..3341c870b --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_info.py @@ -0,0 +1,315 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Andrew Klychkov (@Andersson007) <aaklychkov@mail.ru> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_info + +short_description: Gather information about MongoDB instance. + +description: +- Gather information about MongoDB instance. + +author: Andrew Klychkov (@Andersson007) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + filter: + description: + - Limit the collected information by comma separated string or YAML list. + - Allowable values are C(general), C(databases), C(total_size), C(parameters), C(users), C(roles). + - By default, collects all subsets. + - You can use '!' before value (for example, C(!users)) to exclude it from the information. + - If you pass including and excluding values to the filter, for example, I(filter=!general,users), + the excluding values, C(!general) in this case, will be ignored. + required: no + type: list + elements: str + +notes: + - Requires the pymongo Python package on the remote host, version 2.4.2+. + +requirements: + - pymongo +''' + +EXAMPLES = r''' +- name: Gather all supported information + community.mongodb.mongodb_info: + login_user: admin + login_password: secret + register: result + +- name: Show gathered info + debug: + msg: '{{ result }}' + +- name: Gather only information about databases and their total size + community.mongodb.mongodb_info: + login_user: admin + login_password: secret + filter: databases, total_size + +- name: Gather all information except parameters + community.mongodb.mongodb_info: + login_user: admin + login_password: secret + filter: '!parameters' +''' + +RETURN = r''' +general: + description: General instance information. + returned: always + type: dict + sample: {"allocator": "tcmalloc", "bits": 64, "storageEngines": ["biggie"], "version": "4.2.3", "maxBsonObjectSize": 16777216} +databases: + description: Database information. + returned: always + type: dict + sample: {"admin": {"empty": false, "sizeOnDisk": 245760}, "config": {"empty": false, "sizeOnDisk": 110592}} +total_size: + description: Total size of all databases in bytes. + returned: always + type: int + sample: 397312 +users: + description: User information. + returned: always + type: dict + sample: { "db": {"new_user": {"_id": "config.new_user", "mechanisms": ["SCRAM-SHA-1", "SCRAM-SHA-256"], "roles": []}}} +roles: + description: Role information. + returned: always + type: dict + sample: { "db": {"restore": {"inheritedRoles": [], "isBuiltin": true, "roles": []}}} +parameters: + description: Server parameters information. + returned: always + type: dict + sample: {"maxOplogTruncationPointsAfterStartup": 100, "maxOplogTruncationPointsDuringStartup": 100, "maxSessions": 1000000} +''' + +from uuid import UUID + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.six import iteritems +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + convert_bson_values_recur, + get_mongodb_client, + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, +) + + +class MongoDbInfo(): + """Class for gathering MongoDB instance information. + + Args: + module (AnsibleModule): Object of AnsibleModule class. + client (pymongo): pymongo client object to interact with the database. + """ + def __init__(self, module, client): + self.module = module + self.client = client + self.admin_db = self.client.admin + self.info = { + 'general': {}, + 'databases': {}, + 'total_size': {}, + 'parameters': {}, + 'users': {}, + 'roles': {}, + } + + def get_info(self, filter_): + """Get MongoDB instance information and return it based on filter_. + + Args: + filter_ (list): List of collected subsets (e.g., general, users, etc.), + when it is empty, return all available information. + """ + self.__collect() + + inc_list = [] + exc_list = [] + + if filter_: + partial_info = {} + + for fi in filter_: + if fi.lstrip('!') not in self.info: + self.module.warn("filter element '%s' is not allowable, ignored" % fi) + continue + + if fi[0] == '!': + exc_list.append(fi.lstrip('!')) + + else: + inc_list.append(fi) + + if inc_list: + for i in self.info: + if i in inc_list: + partial_info[i] = self.info[i] + + else: + for i in self.info: + if i not in exc_list: + partial_info[i] = self.info[i] + + return partial_info + + else: + return self.info + + def __collect(self): + """Collect information.""" + # Get general info: + self.info['general'] = self.client.server_info() + + # Get parameters: + self.info['parameters'] = self.get_parameters_info() + + # Gather info about databases and their total size: + self.info['databases'], self.info['total_size'] = self.get_db_info() + + for dbname, val in iteritems(self.info['databases']): + # Gather info about users for each database: + self.info['users'].update(self.get_users_info(dbname)) + + # Gather info about roles for each database: + self.info['roles'].update(self.get_roles_info(dbname)) + + self.info = convert_bson_values_recur(self.info) + + def get_roles_info(self, dbname): + """Gather information about roles. + + Args: + dbname (str): Database name to get role info from. + + Returns a dictionary with role information for the given db. + """ + db = self.client[dbname] + result = db.command({'rolesInfo': 1, 'showBuiltinRoles': True})['roles'] + + roles_dict = {} + for elem in result: + roles_dict[elem['role']] = {} + for key, val in iteritems(elem): + if key in ['role', 'db']: + continue + + roles_dict[elem['role']][key] = val + + return {dbname: roles_dict} + + def get_users_info(self, dbname): + """Gather information about users. + + Args: + dbname (str): Database name to get user info from. + + Returns a dictionary with user information for the given db. + """ + db = self.client[dbname] + result = db.command({'usersInfo': 1})['users'] + + users_dict = {} + for elem in result: + users_dict[elem['user']] = {} + for key, val in iteritems(elem): + if key in ['user', 'db']: + continue + + if isinstance(val, UUID): + val = val.hex + + users_dict[elem['user']][key] = val + + return {dbname: users_dict} + + def get_db_info(self): + """Gather information about databases. + + Returns a dictionary with database information. + """ + result = self.admin_db.command({'listDatabases': 1}) + total_size = int(result['totalSize']) + result = result['databases'] + + db_dict = {} + for elem in result: + db_dict[elem['name']] = {} + for key, val in iteritems(elem): + if key == 'name': + continue + + if key == 'sizeOnDisk': + val = int(val) + + db_dict[elem['name']][key] = val + + return db_dict, total_size + + def get_parameters_info(self): + """Gather parameters information. + + Returns a dictionary with parameters. + """ + return self.admin_db.command({'getParameter': '*'}) + + +# ================ +# Module execution +# +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + filter=dict(type='list', elements='str', required=False) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + filter_ = module.params['filter'] + + if filter_: + filter_ = [f.strip() for f in filter_] + + try: + client = get_mongodb_client(module) + client = mongo_auth(module, client) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + # Initialize an object and start main work: + mongodb = MongoDbInfo(module, client) + + module.exit_json(changed=False, **mongodb.get_info(filter_)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_maintenance.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_maintenance.py new file mode 100644 index 000000000..d470040ce --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_maintenance.py @@ -0,0 +1,146 @@ +#!/usr/bin/python + +# Copyright: (c) 2020, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_maintenance +short_description: Enables or disables maintenance mode for a secondary member. +description: + - Enables or disables maintenance mode for a secondary member. + - Wrapper around the replSetMaintenance command. + - Performs no actions against a PRIMARY member. + - When enabled SECONDARY members will not service reads. +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + maintenance: + description: Enable or disable maintenance mode. + type: bool + default: false +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''' +- name: Enable maintenance mode + community.mongodb.mongodb_maintenance: + maintenance: true + +- name: Disable maintenance mode + community.mongodb.mongodb_maintenance: + maintenance: false +''' + +RETURN = r''' +changed: + description: Whether the member was placed into maintenance mode or not. + returned: success + type: bool +msg: + description: A short description of what happened. + returned: success + type: str +failed: + description: If something went wrong + returned: failed + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + member_state, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def put_in_maint_mode(client): + client['admin'].command('replSetMaintenance', True) + + +def remove_maint_mode(client): + client['admin'].command('replSetMaintenance', False) + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + maintenance=dict(type='bool', default=False) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + maintenance = module.params['maintenance'] + + result = dict( + changed=False, + ) + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + try: + state = member_state(client) + if state == "PRIMARY": + result["msg"] = "no action taken as member state was PRIMARY" + elif state == "SECONDARY": + if maintenance: + if module.check_mode: + result["changed"] = True + result["msg"] = "member was placed into maintenance mode" + else: + put_in_maint_mode(client) + result["changed"] = True + result["msg"] = "member was placed into maintenance mode" + else: + result["msg"] = "No action taken as maintenance parameter is false and member state is SECONDARY" + elif state == "RECOVERING": + if maintenance: + result["msg"] = "no action taken as member is already in a RECOVERING state" + else: + if module.check_mode: + result["changed"] = True + result["msg"] = "the member was removed from maintenance mode" + else: + remove_maint_mode(client) + result["changed"] = True + result["msg"] = "the member was removed from maintenance mode" + else: + result["msg"] = "no action taken as member state was {0}".format(state) + except Exception as excep: + module.fail_json(msg='module encountered an error: %s' % to_native(excep)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_monitoring.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_monitoring.py new file mode 100644 index 000000000..d399a9907 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_monitoring.py @@ -0,0 +1,197 @@ +#!/usr/bin/python + +# Copyright: (c) 2021, Rhys Campbell rhyscampbell@blueiwn.ch +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_monitoring +short_description: Manages the free monitoring feature. +description: + - Manages the free monitoring feature. + - Optionally return the monitoring url. +author: Rhys Campbell (@rhysmeister) +version_added: "1.3.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + state: + description: Manage the free monitoring feature. + type: str + choices: + - "started" + - "stopped" + default: "started" + return_url: + description: When true return the monitoring url if available. + type: bool + default: false + +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''' +- name: Enable monitoring + community.mongodb.mongodb_monitoring: + state: "started" + +- name: Disable monitoring + community.mongodb.mongodb_monitoring: + state: "stopped" + +- name: Enable monitoring and return the monitoring url + community.mongodb_monitoring: + state: "started" + return_url: "yes" +''' + +RETURN = r''' +changed: + description: Whether the monitoring status changed. + returned: success + type: bool +msg: + description: A short description of what happened. + returned: success + type: str +failed: + description: If something went wrong + returned: failed + type: bool +url: + description: The MongoDB instance Monitoring url. + returned: When requested and available. + type: str +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + PYMONGO_IMP_ERR, + pymongo_found, + mongo_auth, + get_mongodb_client, +) + +has_ordereddict = False +try: + from collections import OrderedDict + has_ordereddict = True +except ImportError as excep: + try: + from ordereddict import OrderedDict + has_ordereddict = True + except ImportError as excep: + pass + + +def stop_monitoring(client): + ''' + Stops MongoDB Free Monitoring + ''' + cmd_doc = OrderedDict([('setFreeMonitoring', 1), + ('action', 'disable')]) + client['admin'].command(cmd_doc) + + +def start_monitoring(client): + ''' + Stops MongoDB Free Monitoring + ''' + cmd_doc = OrderedDict([('setFreeMonitoring', 1), + ('action', 'enable')]) + client['admin'].command(cmd_doc) + + +def get_monitoring_status(client): + ''' + Gets the state of MongoDB Monitoring. + N.B. If Monitoring has never been enabled the + free_monitoring record in admin.system.version + will not yet exist. + ''' + monitoring_state = None + url = None + result = client["admin"]['system.version'].find_one({"_id": "free_monitoring"}) + if not result: + monitoring_state = "stopped" + else: + url = result["informationalURL"] + if result["state"] == "enabled": + monitoring_state = "started" + else: + monitoring_state = "stopped" + return monitoring_state, url + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + state=dict(type='str', default='started', choices=['started', 'stopped']), + return_url=dict(type='bool', default=False) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not has_ordereddict: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict') + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + state = module.params['state'] + return_url = module.params['return_url'] + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + current_monitoring_state, url = get_monitoring_status(client) + result = {} + if state == "started": + if current_monitoring_state == "started": + result['changed'] = False + result['msg'] = "Free monitoring is already started" + else: + if module.check_mode is False: + start_monitoring(client) + result['changed'] = True + result['msg'] = "Free monitoring has been started" + else: + if current_monitoring_state == "started": + if module.check_mode is False: + stop_monitoring(client) + result['changed'] = True + result['msg'] = "Free monitoring has been stopped" + else: + result['changed'] = False + result['msg'] = "Free monitoring is already stopped" + + if return_url and url: + result['url'] = url + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_oplog.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_oplog.py new file mode 100644 index 000000000..5735f0ddf --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_oplog.py @@ -0,0 +1,190 @@ +#!/usr/bin/python + +# Copyright: (c) 2020, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_oplog +short_description: Resizes the MongoDB oplog. +description: + - Resizes the MongoDB oplog. + - This module should only be used with MongoDB 3.6 and above. + - Old MongoDB versions should use an alternative method. + - Consult U(https://docs.mongodb.com/manual/tutorial/change-oplog-size) for further info. +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + oplog_size_mb: + description: + - New size of the oplog in MB. + type: int + required: true + compact: + description: + - Runs compact against the oplog.rs collection in the local database to reclaim disk space. + - Performs no actions against PRIMARY members. + - The MongoDB user must have the compact role on the local database for this feature to work. + type: bool + default: false + required: false +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''' +- name: Resize oplog to 16 gigabytes, or 16000 megabytes + community.mongodb.mongodb_oplog: + oplog_size_mb: 16000 + +- name: Resize oplog to 8 gigabytes and compact secondaries to reclaim space + community.mongodb.mongodb_oplog: + oplog_size_mb: 8000 + compact: true +''' + +RETURN = r''' +changed: + description: Whether the member oplog was modified. + returned: success + type: bool +compacted: + description: Whether the member oplog was compacted. + returned: success + type: bool +msg: + description: A short description of what happened. + returned: success + type: str +failed: + description: If something went wrong + returned: failed + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + member_state, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + +has_ordereddict = False +try: + from collections import OrderedDict + has_ordereddict = True +except ImportError as excep: + try: + from ordereddict import OrderedDict + has_ordereddict = True + except ImportError as excep: + pass + + +def get_olplog_size(client): + return int(client["local"].command("collStats", "oplog.rs")["maxSize"]) / 1024 / 1024 + + +def set_oplog_size(client, oplog_size_mb): + cmd_doc = OrderedDict([ + ('replSetResizeOplog', 1), + ('size', oplog_size_mb) + ]) + client["admin"].command(cmd_doc) + + +def compact_oplog(client): + client["local"].command("compact", "oplog.rs") + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + compact=dict(type='bool', default=False), + oplog_size_mb=dict(type='int', required=True), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not has_ordereddict: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict') + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + oplog_size_mb = float(module.params['oplog_size_mb']) # MongoDB 4.4 inists on a real + compact = module.params['compact'] + + result = dict( + changed=False, + ) + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + try: + current_oplog_size = get_olplog_size(client) + except Exception as excep: + module.fail_json(msg='Unable to get current oplog size: %s' % to_native(excep)) + if oplog_size_mb == current_oplog_size: + result["msg"] = "oplog_size_mb is already {0} mb".format(int(oplog_size_mb)) + result["compacted"] = False + else: + try: + state = member_state(client) + except Exception as excep: + module.fail_json(msg='Unable to get member state: %s' % to_native(excep)) + if module.check_mode: + result["changed"] = True + result["msg"] = "oplog has been resized from {0} mb to {1} mb".format(int(current_oplog_size), + int(oplog_size_mb)) + if state == "SECONDARY" and compact and current_oplog_size > oplog_size_mb: + result["compacted"] = True + else: + result["compacted"] = False + else: + try: + set_oplog_size(client, oplog_size_mb) + result["changed"] = True + result["msg"] = "oplog has been resized from {0} mb to {1} mb".format(int(current_oplog_size), + int(oplog_size_mb)) + except Exception as excep: + module.fail_json(msg='Unable to set oplog size: %s' % to_native(excep)) + if state == "SECONDARY" and compact and current_oplog_size > oplog_size_mb: + try: + compact_oplog(client) + result["compacted"] = True + except Exception as excep: + module.fail_json(msg='Error compacting member oplog: %s' % to_native(excep)) + else: + result["compacted"] = False + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_parameter.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_parameter.py new file mode 100644 index 000000000..d167cf646 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_parameter.py @@ -0,0 +1,144 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2016, Loic Blot <loic.blot@unix-experience.fr> +# Sponsored by Infopro Digital. http://www.infopro-digital.com/ +# Sponsored by E.T.A.I. http://www.etai.fr/ +# +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_parameter +short_description: Change an administrative parameter on a MongoDB server +description: + - Change an administrative parameter on a MongoDB server. +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + replica_set: + description: + - Replica set to connect to (automatically connects to primary for writes). + type: str + param: + description: + - MongoDB administrative parameter to modify. + type: str + required: true + value: + description: + - MongoDB administrative parameter value to set. + type: str + required: true + param_type: + description: + - Define the type of parameter value. + default: str + type: str + choices: [int, str] + +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 also U(http://api.mongodb.org/python/current/installation.html) +requirements: [ "pymongo" ] +author: "Loic Blot (@nerzhul)" +''' + +EXAMPLES = r''' +- name: Set MongoDB syncdelay to 60 (this is an int) + community.mongodb.mongodb_parameter: + param: syncdelay + value: 60 + param_type: int +''' + +RETURN = r''' +before: + description: value before modification + returned: success + type: str +after: + description: value after modification + returned: success + type: str +''' + +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + OperationFailure, + get_mongodb_client, +) + +# ========================================= +# Module execution. +# + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + replica_set=dict(default=None), + param=dict(required=True), + value=dict(required=True), + param_type=dict(default="str", choices=['str', 'int']) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=[['login_user', 'login_password']], + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + param = module.params['param'] + param_type = module.params['param_type'] + value = module.params['value'] + + # Verify parameter is coherent with specified type + try: + if param_type == 'int': + value = int(value) + except ValueError: + module.fail_json(msg="value '%s' is not %s" % (value, param_type)) + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + db = client.admin + + try: + after_value = db.command("setParameter", **{param: value}) + except OperationFailure as e: + module.fail_json(msg="unable to change parameter: %s" % to_native(e), exception=traceback.format_exc()) + + if "was" not in after_value: + module.exit_json(changed=True, msg="Unable to determine old value, assume it changed.") + else: + module.exit_json(changed=(value != after_value["was"]), before=after_value["was"], + after=value) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_replicaset.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_replicaset.py new file mode 100644 index 000000000..d0baf661e --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_replicaset.py @@ -0,0 +1,611 @@ +#!/usr/bin/python + +# Copyright: (c) 2018, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +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. + - Advanced replicaset member (re)configuration possible (see examples). + - Initialize the replicaset before adding users as per \ + [best practice](https://www.mongodb.com/docs/manual/tutorial/deploy-replica-set-with-keyfile-access-control/). +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + replica_set: + description: + - Replicaset name. + type: str + default: rs0 + members: + description: + - Yaml list consisting of the replicaset members. + - Csv string will also be accepted i.e. mongodb1:27017,mongodb2:27017,mongodb3:27017. + - A dictionary can also be used to specify advanced replicaset member options. + - If a port number is not provided then 27017 is assumed. + type: list + elements: raw + validate: + description: + - Performs some basic validation on the provided replicaset config. + type: bool + default: yes + 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 + reconfigure: + description: + - This feature is currently experimental. Please test your scenario thoroughly. + - Consult the integration test file for supported scenarios - \ + [Integration tests](https://github.com/ansible-collections/community.mongodb/tree/master/tests/integration/targets/mongodb_replicaset/tasks). \ + See files prefixed with 330. + - Whether to perform replicaset reconfiguration actions. + - Only relevant when the replicaset already exists. + - Only one member should be removed or added per invocation. + - Members should be specific as either all strings or all dicts when reconfiguring. + - Currently no support for replicaset settings document changes. + type: bool + default: false + force: + description: + - Only relevant when reconfigure = true. + - Specify true to force the available replica set members to accept the new configuration. + - Force reconfiguration can result in unexpected or undesired behavior, including rollback of "majority" committed writes. + type: bool + default: false + max_time_ms: + description: + - Specifies a cumulative time limit in milliseconds for processing the replicaset reconfiguration. + type: int + default: null + debug: + description: + - Add additonal info for debug. + type: bool + default: false + cluster_cmd: + description: + - Command the module should use to obtain information about the MongoDB node we are connecting to. + type: str + choices: + - isMaster + - hello + default: hello +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 + community.mongodb.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 + community.mongodb.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 + community.mongodb.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 + +- name: Create a replicaset and use a custom priority for each member + community.mongodb.mongodb_replicaset: + login_host: localhost + login_user: admin + login_password: admin + replica_set: rs0 + members: + - host: "localhost:3001" + priority: 1 + - host: "localhost:3002" + priority: 0.5 + - host: "localhost:3003" + priority: 0.5 + when: groups.mongod.index(inventory_hostname) == 0 + +- name: Create replicaset rs1 with options and member tags + community.mongodb.mongodb_replicaset: + login_host: localhost + login_port: 3001 + login_database: admin + replica_set: rs1 + members: + - host: "localhost:3001" + priority: 1 + tags: + dc: "east" + usage: "production" + - host: "localhost:3002" + priority: 1 + tags: + dc: "east" + usage: "production" + - host: "localhost:3003" + priority: 0 + hidden: true + slaveDelay: 3600 + tags: + dc: "west" + usage: "reporting" + +- name: Replicaset with one arbiter node (mongodb3 - index is zero-based) + community.mongodb.mongodb_replicaset: + login_user: admin + login_password: admin + replica_set: rs0 + members: + - mongodb1:27017 + - mongodb2:27017 + - mongodb3:27017 + arbiter_at_index: 2 + when: groups.mongod.index(inventory_hostname) == 0 + +- name: Add a new member to a replicaset - Safe for pre-5.0 consult documentation - https://docs.mongodb.com/manual/tutorial/expand-replica-set/ + block: + - name: Create replicaset with module - with dicts + community.mongodb.mongodb_replicaset: + replica_set: "rs0" + members: + - host: localhost:3001 + - host: localhost:3002 + - host: localhost:3003 + + - name: Wait for the replicaset to stabilise + community.mongodb.mongodb_status: + replica_set: "rs0" + poll: 5 + interval: 10 + + - name: Remove a member from the replicaset + community.mongodb.mongodb_replicaset: + replica_set: "rs0" + reconfigure: yes + members: + - host: localhost:3001 + - host: localhost:3002 + + - name: Wait for the replicaset to stabilise after member removal + community.mongodb.mongodb_status: + replica_set: "rs0" + validate: minimal + poll: 5 + interval: 10 + + - name: Add a member to the replicaset + community.mongodb.mongodb_replicaset: + replica_set: "rs0" + reconfigure: yes + members: + - host: localhost:3001 + - host: localhost:3002 + - host: localhost:3004 + hidden: true + votes: 0 + priority: 0 + + - name: Wait for the replicaset to stabilise after member addition + community.mongodb.mongodb_status: + replica_set: "rs0" + validate: minimal + poll: 5 + interval: 30 + + - name: Reconfigure the replicaset - Make member 3004 a normal voting member + community.mongodb.mongodb_replicaset: + replica_set: "rs0" + reconfigure: yes + members: + - host: localhost:3001 + - host: localhost:3002 + - host: localhost:3004 + hidden: false + votes: 1 + priority: 1 + + - name: Wait for the replicaset to stabilise + community.mongodb.mongodb_status: + replica_set: "rs0" + poll: 5 + interval: 30 +''' + +RETURN = r''' +mongodb_replicaset: + description: The name of the replicaset that has been created. + returned: success + type: str +reconfigure: + description: If a replicaset reconfiguration occured. + returned: On rpelicaset reconfiguration + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + member_dicts_different, + lists_are_different, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def get_replicaset_config(client): + conf = client.admin.command({'replSetGetConfig': 1}) + return conf['config'] + + +def get_member_names(client): + conf = get_replicaset_config(client) + members = [] + for member in conf['members']: + members.append(member['host']) + return members + + +def modify_members(module, config, members): + """ + Modifies the members section of the config document as appropriate. + @module - Ansible module object + @config - Replicaset config document from MongoDB + @members - Members config from module + """ + try: # refactor repeated code + 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)) + new_member_config = [] # the list of dicts containing the members for the replicaset configuration document + existing_members = [] # members that are staying in the config + max_id = 0 + if all(isinstance(member, str) for member in members): + for current_member in config['members']: + if current_member["host"] in members: + new_member_config.append(current_member) + existing_members.append(current_member["host"]) + if current_member["_id"] > max_id: + max_id = current_member["_id"] + member_additions = list(set(members) - set(existing_members)) + if len(member_additions) > 0: + for member in member_additions: + if ':' not in member: # No port supplied. Assume 27017 + member += ":27017" + new_member_config.append(OrderedDict([("_id", max_id + 1), ("host", member)])) + max_id += 1 + config["members"] = new_member_config + elif all(isinstance(member, dict) for member in members): + # We need to put the _id values in into the matching document and generate them for new hosts + # TODO: https://docs.mongodb.com/manual/reference/replica-configuration/#mongodb-rsconf-rsconf.members-n-._id + # Maybe we can add a new member id parameter value, stick with the incrementing for now + # Perhaps even save this in the mongodb instance? + + # first get all the existing members of the replicaset + new_member_config = [] + existing_members = {} + matched_members = [] # members that have been supplied by the moduel and matched with existing members + max_id = 0 + for member in config["members"]: + existing_members[member["host"]] = member["_id"] + if member["_id"] > max_id: + max_id = member["_id"] + # append existing members with the appropriate _id + for member in members: + if member["host"] in existing_members: + member["_id"] = existing_members[member["host"]] + matched_members.append(member["host"]) + new_member_config.append(member) + for member in members: + if member["host"] not in matched_members: # new member , append and increment id + max_id = max_id + 1 + member["_id"] = max_id + new_member_config.append(member) + config["members"] = new_member_config + else: + module.fail_json(msg="All items in members must be either of type dict of str") + return config + + +def replicaset_reconfigure(module, client, config, force, max_time_ms): + + config['version'] += 1 + + 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)) + + cmd_doc = OrderedDict([("replSetReconfig", config), + ("force", force)]) + if max_time_ms is not None: + cmd_doc.update({"maxTimeMS": max_time_ms}) + + client.admin.command(cmd_doc) + # return result + + +def replicaset_find(client, cluster_cmd): + """Check if a replicaset exists. + + Args: + client (cursor): Mongodb cursor on admin database. + cluster_cmd (str): Either isMaster or hello + + Returns: + str: when the node is a member of a replicaset , False otherwise. + """ + doc = client['admin'].command(cluster_cmd) + if 'setName' in doc: + return str(doc['setName']) + 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 isinstance(member, str): + if ':' not in member: # No port supplied. Assume 27017 + member += ":27017" + members_dict_list.append(OrderedDict([("_id", int(index)), ("host", member)])) + if index == arbiter_at_index: + members_dict_list[index]['arbiterOnly'] = True + index += 1 + elif isinstance(member, dict): + hostname = member["host"] + if ':' not in hostname: + hostname += ":27017" + members_dict_list.append(OrderedDict([("_id", int(index)), ("host", hostname)])) + for key in list(member.keys()): + if key != "host": + members_dict_list[index][key] = member[key] + if index == arbiter_at_index: + members_dict_list[index]['arbiterOnly'] = True + index += 1 + else: + raise ValueError("member should be a str or dict. Instead found: {0}".format(str(type(members)))) + + conf = OrderedDict([("_id", replica_set), + ("protocolVersion", protocol_version), + ("members", members_dict_list), + ("settings", settings)]) + try: + client["admin"].command('replSetInitiate', conf) + except Exception as excep: + raise Exception("Some problem {0} | {1}".format(str(excep), str(conf))) + + +def replicaset_remove(module, client, replica_set): + raise NotImplementedError + + +def modify_members_flow(module, client, members, result): + debug = module.params['debug'] + force = module.params['force'] + max_time_ms = module.params['max_time_ms'] + diff = False + modified_config = None + config = None + + try: + config = get_replicaset_config(client) + except Exception as excep: + module.fail_json(msg="Unable to get replicaset configuration {0}".format(excep)) + + if isinstance(members[0], str): + diff = lists_are_different(members, get_member_names(client)) + elif isinstance(members[0], dict): + diff = member_dicts_different(config, members) + else: + module.fail_json(msg="members must be either str or dict") + if diff: + if not module.check_mode: + try: + modified_config = modify_members(module, config, members) + if debug: + result['config'] = str(config) + result['modified_config'] = str(modified_config) + replicaset_reconfigure(module, client, modified_config, force, max_time_ms) + except Exception as excep: + module.fail_json(msg="Failed reconfiguring replicaset {0}, config doc {1}".format(excep, modified_config)) + result['changed'] = True + result['msg'] = "replicaset reconfigured" + else: + result['changed'] = False + return result + +# ========================================= +# Module execution. +# + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + arbiter_at_index=dict(type='int'), + chaining_allowed=dict(type='bool', default=True), + election_timeout_millis=dict(type='int', default=10000), + heartbeat_timeout_secs=dict(type='int', default=10), + members=dict(type='list', elements='raw'), + protocol_version=dict(type='int', default=1, choices=[0, 1]), + replica_set=dict(type='str', default="rs0"), + validate=dict(type='bool', default=True), + reconfigure=dict(type='bool', default=False), + force=dict(type='bool', default=False), + max_time_ms=dict(type='int', default=None), + debug=dict(type='bool', default=False), + cluster_cmd=dict(type='str', choices=['isMaster', 'hello'], default='hello') + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + replica_set = module.params['replica_set'] + members = module.params['members'] + arbiter_at_index = module.params['arbiter_at_index'] + validate = module.params['validate'] + 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'] + reconfigure = module.params['reconfigure'] + force = module.params['force'] # TODO tidy this stuff up + max_time_ms = module.params['max_time_ms'] + debug = module.params['debug'] + cluster_cmd = module.params['cluster_cmd'] + + if validate and reconfigure is False: + 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, + ) + + try: + client = get_mongodb_client(module, directConnection=True) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + try: + rs = replicaset_find(client, cluster_cmd) # does not require auth + except Exception as e: + module.fail_json(msg='Unable to connect to query replicaset: %s' % to_native(e)) + + if isinstance(rs, str): + if replica_set == rs: + if reconfigure: + client = mongo_auth(module, client) + result = modify_members_flow(module, client, members, result) + else: + result['changed'] = False + result['replica_set'] = rs + module.exit_json(**result) + else: + module.fail_json(msg="The replica_set name of {0} does not match the expected: {1}".format(rs, replica_set)) + else: # replicaset does not exist + + # Some validation stuff + if len(replica_set) == 0: + module.fail_json(msg="Parameter replica_set must not be an empty string") + + if module.check_mode is False: + 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: + result['changed'] = True + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_role.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_role.py new file mode 100644 index 000000000..012f553a0 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_role.py @@ -0,0 +1,387 @@ +#!/usr/bin/python + +# (c) 2022, Rhys Campbell <rhyscampbell@bluewin.ch> + +# 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 + + +DOCUMENTATION = ''' +--- +module: mongodb_role +short_description: Adds or removes a role from a MongoDB database +description: + - Adds or removes a role from a MongoDB database. + - For further information on the required format for \ + the privileges, authenticationRestriction or roles \ + parameters, see the MongoDB Documentation https://www.mongodb.com/docs/manual/reference/command/createRole/ +version_added: "1.5.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + replica_set: + description: + - Replica set to connect to (automatically connects to primary for writes). + type: str + database: + description: + - The name of the database to add/remove the role from. + required: true + type: str + aliases: [db] + name: + description: + - The name of the role to add or remove. + required: true + aliases: [user] + type: str + privileges: + type: list + elements: raw + description: + - > + The privileges to grant the role. A privilege consists of a resource + and permitted actions. + default: [] + authenticationRestrictions: + type: list + elements: raw + description: + - > + The authentication restrictions the server enforces on the role. + Specifies a list of IP addresses and CIDR ranges users granted + this role are allowed to connect to and/or which they can connect from. + Provide a list of dictionaries with the following + fields: clientSource (list), serverAddress (list). + Provide an empty list if you don't want to use the field. + default: [] + roles: + type: list + elements: raw + description: + - > + The database user roles should be provided as a dictionary with the db and role keys. + default: [] + state: + description: + - The database user state. + default: present + choices: [absent, present] + type: str + debug: + description: + - Enable extra debugging output. + default: false + type: bool +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. Newer mongo server versions require newer + pymongo versions. @see http://api.mongodb.org/python/current/installation.html +requirements: + - "pymongo" +author: + - "Rhys Campbell (@rhysmeister)" +''' + +EXAMPLES = ''' +- name: Create sales role + community.mongodb.mongodb_role: + name: sales + database: salesdb + privileges: + - resource: + db: salesdb + collection: "" + actions: + - find + state: present + +- name: Create ClusterAdmin Role + community.mongodb.mongodb_role: + name: myClusterwideAdmin + database: admin + privileges: + - resource: + cluster: true + actions: + - addShard + - resource: + db: config + collection: "" + actions: + - find + - update + - insert + - remove + - resource: + db: "users" + collection: "usersCollection" + actions: + - update + - insert + - remove + - resource: + db: "" + collection: "" + actions: + - find + roles: + - role: "read" + db: "admin" + state: present + +- name: Create ClusterAdmin Role with a login only from 127.0.0.1 restriction + community.mongodb.mongodb_role: + name: myClusterwideAdmin + database: admin + privileges: + - resource: + cluster: true + actions: + - addShard + - resource: + db: config + collection: "" + actions: + - find + - update + - insert + - resource: + db: "users" + collection: "usersCollection" + actions: + - update + - insert + - remove + - resource: + db: "" + collection: "" + actions: + - find + roles: + - role: "read" + db: "admin" + - role: "read" + db: "mynewdb" + authenticationRestrictions: + - clientSource: + - "127.0.0.1" + serverAddress: [] + state: present + +- name: Delete sales role + community.mongodb.mongodb_role: + name: sales + database: "salesdb" + state: absent + +- name: Delete myClusterwideAdmin role + community.mongodb.mongodb_role: + name: myClusterwideAdmin + database: admin + state: absent +''' + +RETURN = ''' +user: + description: The name of the role to add or remove. + returned: success + type: str +''' + +import traceback + + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def role_find(client, role, db_name): + """Check if the role exists. + + Args: + client (cursor): Mongodb cursor on admin database. + user (str): Role to check. + db_name (str): Role's database. + + Returns: + dict: when role exists, False otherwise. + """ + try: + mongo_role = None + rolesDoc = { + 'rolesInfo': 1, + 'showAuthenticationRestrictions': True, + 'showPrivileges': True + } + for mongo_role in client[db_name].command(rolesDoc)['roles']: + if mongo_role['role'] == role: + # NOTE: there is no 'db' field in mongo 2.4. + if 'db' not in mongo_role: + return mongo_role + # Workaround to make the condition works with AWS DocumentDB, + # since all users are in the admin database. + if mongo_role["db"] in [db_name, "admin"]: + return mongo_role + except Exception as excep: + if hasattr(excep, 'code') and excep.code == 31: # 31=RoleNotFound + pass # Allow return False + else: + raise + return False + + +def role_add(client, db_name, role, privileges, roles, authenticationRestrictions): + db = client[db_name] + + try: + exists = role_find(client, role, db_name) + except Exception as excep: + # probably not needed for role create... to clarify + # We get this exception: "not authorized on admin to execute command" + # when auth is enabled on a new instance. The localhost exception should + # allow us to create the first user. If the localhost exception does not apply, + # then user creation will also fail with unauthorized. So, ignore Unauthorized here. + if hasattr(excep, 'code') and excep.code == 13: # 13=Unauthorized + exists = False + else: + raise + + if exists: + role_add_db_command = 'updateRole' + else: + role_add_db_command = 'createRole' + + role_dict = {} + + role_dict["privileges"] = privileges + role_dict["roles"] = roles + role_dict["authenticationRestrictions"] = authenticationRestrictions + db.command(role_add_db_command, role, **role_dict) + + +def role_remove(module, client, db_name, role): + exists = role_find(client, role, db_name) + if exists: + if module.check_mode: + module.exit_json(changed=True, role=role) + db = client[db_name] + db.command("dropRole", role) + else: + module.exit_json(changed=False, role=role) + + +def check_if_role_changed(client, role, db_name, privileges, authenticationRestrictions, roles): + role_dict = role_find(client, role, db_name) + changed = False + if role_dict: + reformat_authenticationRestrictions = [] + if 'authenticationRestrictions' in role_dict: + for item in role_dict['authenticationRestrictions']: + reformat_authenticationRestrictions.append(item[0]) # seems to be a list of lists of dict, we want a list of dicts + if ('privileges' in role_dict and + [{'resource': d['resource'], 'actions': sorted(d['actions'])} for d in role_dict['privileges']] != + [{'resource': d['resource'], 'actions': sorted(d['actions'])} for d in privileges] or + 'privileges' not in role_dict and privileges != []): + changed = True + elif ('roles' in role_dict and + sorted(role_dict['roles'], key=lambda x: (x["db"], x["role"])) != + sorted(roles, key=lambda x: (x["db"], x["role"])) or + 'roles' not in role_dict and roles != []): + changed = True + elif ('authenticationRestrictions' in role_dict and + sorted(reformat_authenticationRestrictions, key=lambda x: (x['clientSource'], x['serverAddress'])) != + sorted(authenticationRestrictions, key=lambda x: (x['clientSource'], x['serverAddress'])) or + 'authenticationRestrictions' not in role_dict and authenticationRestrictions != []): + changed = True + else: + raise Exception("Role not found") # TODO replace with proper exception + return changed + + +# ========================================= +# Module execution. +# + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + replica_set=dict(default=None), + database=dict(required=True, aliases=['db']), + name=dict(required=True, aliases=['user']), + privileges=dict(default=[], type='list', elements='raw'), + authenticationRestrictions=dict(default=[], type='list', elements='raw'), + roles=dict(default=[], type='list', elements='raw'), + state=dict(default='present', choices=['absent', 'present']), + debug=dict(type='bool', default=False), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + try: + directConnection = False + if module.params['replica_set'] is None: + directConnection = True + client = get_mongodb_client(module, directConnection=directConnection) + client = mongo_auth(module, client, directConnection=directConnection) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + changed = None + role = state = module.params['name'] + state = module.params['state'] + db_name = module.params['database'] + privileges = module.params['privileges'] + roles = module.params['roles'] + authenticationRestrictions = module.params['authenticationRestrictions'] + debug = module.params['debug'] + # TODO _ Functions use a different param order... make consistent + try: + if state == 'present': + if role_find(client, role, db_name) is False: + if module.check_mode is False: + role_add(client, db_name, role, privileges, roles, authenticationRestrictions) + changed = True + else: + if check_if_role_changed(client, role, db_name, privileges, authenticationRestrictions, roles): + if module.check_mode is False: + role_add(client, db_name, role, privileges, roles, authenticationRestrictions) + changed = True + else: + changed = False + elif state == 'absent': + if role_find(client, role, db_name): + if module.check_mode is False: + role_remove(module, client, db_name, role) + changed = True + else: + changed = False + module.exit_json(changed=changed, role=role) + except Exception as e: + if debug: + module.fail_json(msg=str(e), traceback=traceback.format_exc()) + else: + module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_schema.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_schema.py new file mode 100644 index 000000000..4443c0c82 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_schema.py @@ -0,0 +1,346 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Rhys Campbell (@rhysmeister) <rhyscampbell@bluewin.ch> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_schema + +short_description: Manages MongoDB Document Schema Validators. + +description: +- Manages MongoDB Document Schema Validators. +- Create, update and remove Validators on a collection. +- Supports the entire range of jsonSchema keywords. +- See [jsonSchema Available Keywords](https://docs.mongodb.com/manual/reference/operator/query/jsonSchema/#available-keywords) for details. + +author: Rhys Campbell (@rhysmeister) +version_added: "1.3.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + db: + description: + - The database to work with. + required: yes + type: str + collection: + description: + - The collection to work with. + required: yes + type: str + required: + description: + - List of fields that are required. + type: list + elements: str + properties: + description: + - Individual property specification. + type: dict + default: {} + action: + description: + - The validation action for MongoDB to perform when handling invalid documents. + type: str + choices: + - "error" + - "warn" + default: "error" + level: + description: + - The validation level MongoDB should apply when updating existing documents. + type: str + choices: + - "strict" + - "moderate" + default: "strict" + replica_set: + description: + - Replicaset name. + type: str + default: null + state: + description: + - The state of the validator. + type: str + choices: + - "present" + - "absent" + default: "present" + debug: + description: + - Enable additional debugging output. + type: bool + default: false + +notes: + - Requires the pymongo Python package on the remote host, version 2.4.2+. + +requirements: + - pymongo +''' + +EXAMPLES = r''' +--- +- name: Require that an email address field is in every document + community.mongodb.mongodb_schema: + collection: contacts + db: rhys + required: + - email + +- name: Remove a schema rule + community.mongodb.mongodb_schema: + collection: contacts + db: rhys + state: absent + + +- name: More advanced example using properties + community.mongodb.mongodb_schema: + collection: contacts + db: rhys + properties: + email: + maxLength: 150 + minLength: 5 + options: + bsonType: array + maxItems: 10 + minItems: 5 + uniqueItems: true + status: + bsonType: string + description: "can only be ACTIVE or DISABLED" + enum: + - ACTIVE + - DISABLED + year: + bsonType: int + description: "must be an integer from 2021 to 3020" + exclusiveMaximum: false + maximum: 3020 + minimum: 2021 + required: + - email + - first_name + - last_name +''' + +RETURN = r''' +changed: + description: If the module caused a change. + returned: on success + type: bool +msg: + description: Status message. + returned: always + type: str +validator: + description: The validator document as read from the instance. + returned: when debug is true + type: dict +module_config: + description: The validator document as indicated by the module invocation. + returned: when debug is true + type: dict +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) +import json + + +has_ordereddict = False +try: + from collections import OrderedDict + has_ordereddict = True +except ImportError as excep: + try: + from ordereddict import OrderedDict + has_ordereddict = True + except ImportError as excep: + pass + + +def get_validator(client, db, collection): + validator = None + cmd_doc = OrderedDict([ + ('listCollections', 1), + ('filter', {"name": collection}) + ]) + doc = None + results = client[db].command(cmd_doc)["cursor"]["firstBatch"] + if len(results) > 0: + doc = results[0] + if doc is not None and 'options' in doc and 'validator' in doc['options']: + validator = doc['options']['validator']["$jsonSchema"] + if 'validationAction' in doc['options']: + validator['validationAction'] = doc['options']['validationAction'] + if 'validationLevel' in doc['options']: + validator['validationLevel'] = doc['options']['validationLevel'] + return validator + + +def validator_is_different(client, db, collection, required, properties, action, level): + is_different = False + validator = get_validator(client, db, collection) + if validator is not None: + if sorted(required) != sorted(validator.get('required', [])): + is_different = True + if action != validator.get('validationAction', 'error'): + is_different = True + if level != validator.get('validationLevel', 'strict'): + is_different = True + dict1 = json.dumps(properties, sort_keys=True) + dict2 = json.dumps(validator.get('properties', {}), sort_keys=True) + if dict1 != dict2: + is_different = True + else: + is_different = True + return is_different + + +def add_validator(client, db, collection, required, properties, action, level): + cmd_doc = OrderedDict([ + ('collMod', collection), + ('validator', {'$jsonSchema': {"bsonType": "object", + "required": required, + "properties": properties}}), + ('validationAction', action), + ('validationLevel', level) + ]) + if collection not in client[db].list_collection_names(): + client[db].create_collection(collection) + client[db].command(cmd_doc) + + +def remove_validator(client, db, collection): + cmd_doc = OrderedDict([ + ('collMod', collection), + ('validator', {}), + ('validationLevel', "off") + ]) + client[db].command(cmd_doc) + + +# ================ +# Module execution +# + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + db=dict(type='str', required=True), + collection=dict(type='str', required=True), + required=dict(type='list', elements='str'), + properties=dict(type='dict', default={}), + action=dict(type='str', choices=['error', 'warn'], default="error"), + level=dict(type='str', choices=['strict', 'moderate'], default="strict"), + state=dict(type='str', choices=['present', 'absent'], default='present'), + debug=dict(type='bool', default=False), + replica_set=dict(type='str', default=None), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + required_if=[("state", "present", ("db", "collection"))] + ) + + if not has_ordereddict: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict') + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + db = module.params['db'] + collection = module.params['collection'] + required = module.params['required'] + properties = module.params['properties'] + action = module.params['action'] + level = module.params['level'] + state = module.params['state'] + debug = module.params['debug'] + + try: + client = get_mongodb_client(module) + client = mongo_auth(module, client) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + result = dict( + changed=False, + ) + + validator = get_validator(client, db, collection) + if state == "present": + if validator is not None: + diff = validator_is_different(client, db, collection, required, + properties, action, level) + if diff: + if not module.check_mode: + add_validator(client, + db, + collection, + required, + properties, + action, + level) + result['changed'] = True + result['msg'] = "The validator was updated on the given collection" + else: + result['changed'] = False + result['msg'] = "The validator exists as configured on the given collection" + else: + if not module.check_mode: + add_validator(client, + db, + collection, + required, + properties, + action, + level) + result['changed'] = True + result['msg'] = "The validator has been added to the given collection" + elif state == "absent": + if validator is None: + result['changed'] = False + result['msg'] = "A validator does not exist on the given collection." + else: + if not module.check_mode: + remove_validator(client, db, collection) + result['changed'] = True + result['msg'] = "The validator has been removed from the given collection" + + if debug: + result['validator'] = validator + result['module_config'] = {"required": required, + "properties": properties, + "validationAction": action, + "validationLevel": level} + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_shard.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_shard.py new file mode 100644 index 000000000..bc37f59db --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_shard.py @@ -0,0 +1,306 @@ +#!/usr/bin/python + +# (c) 2018, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = ''' +--- +module: mongodb_shard +short_description: Add or remove shards from a MongoDB Cluster +description: + - Add or remove shards from a MongoDB Cluster. +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + shard: + description: + - The shard connection string. + - Should be supplied in the form <replicaset>/host:port as detailed in U(https://docs.mongodb.com/manual/tutorial/add-shards-to-shard-cluster/). + - For example rs0/example1.mongodb.com:27017. + required: true + type: str + sharded_databases: + description: + - Enable sharding on the listed database. + - Can be supplied as a string or a list of strings. + - Sharding cannot be disabled on a database. + - Starting in MongoDB 6.0, the enableSharding command is no longer required to shard a collection and this parameter is ignored. + required: false + type: raw + mongos_process: + description: + - Provide a custom name for the mongos process you are connecting to. + - Most users can ignore this setting. + required: false + type: str + default: "mongos" + state: + description: + - Whether the shard should be present or absent from the Cluster. + required: false + type: str + default: present + choices: + - "absent" + - "present" + +notes: + - Requires the pymongo Python package on the remote host, version 2.4.2+. +requirements: [ pymongo ] +''' + +EXAMPLES = ''' +- name: Add a replicaset shard named rs1 with a member running on port 27018 on mongodb0.example.net + community.mongodb.mongodb_shard: + login_user: admin + login_password: admin + shard: "rs1/mongodb0.example.net:27018" + state: present + +- name: Add a standalone mongod shard running on port 27018 of mongodb0.example.net + community.mongodb.mongodb_shard: + login_user: admin + login_password: admin + shard: "mongodb0.example.net:27018" + state: present + +- name: To remove a shard called 'rs1' + community.mongodb.mongodb_shard: + login_user: admin + login_password: admin + shard: rs1 + state: absent + +# Single node shard running on localhost +- name: Ensure shard rs0 exists + community.mongodb.mongodb_shard: + login_user: admin + login_password: secret + shard: "rs0/localhost:3001" + state: present + +# Single node shard running on localhost +- name: Ensure shard rs1 exists + community.mongodb.mongodb_shard: + login_user: admin + login_password: secret + shard: "rs1/localhost:3002" + state: present + +# Enable sharding on a few databases when creating the shard +- name: To remove a shard called 'rs1' + community.mongodb.mongodb_shard: + login_user: admin + login_password: admin + shard: rs1 + sharded_databases: + - db1 + - db2 + state: present +''' + +RETURN = ''' +mongodb_shard: + description: The name of the shard to create. + returned: success + type: str +sharded_enabled: + description: Databases that have had sharding enabled during module execution. + returned: success when sharding is enabled + type: list +''' + +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, + check_srv_version +) + + +def shard_find(client, shard): + """Check if a shard exists. + + Args: + client (cursor): Mongodb cursor on admin database. + shard (str): shard to check. + + Returns: + dict: when user exists, False otherwise. + """ + if '/' in shard: + s = shard.split('/')[0] + else: + s = shard + for shard in client["config"].shards.find({"_id": s}): + return shard + return False + + +def shard_add(client, shard): + try: + sh = client["admin"].command('addShard', shard) + except Exception as excep: + raise excep + return sh + + +def shard_remove(client, shard): + try: + sh = client["admin"].command('removeShard', shard) + except Exception as excep: + raise excep + return sh + + +def sharded_dbs(client): + ''' + Returns the sharded databases + Args: + client (cursor): Mongodb cursor on admin database. + Returns: + a list of database names that are sharded + ''' + sharded_databases = [] + for entry in client["config"].databases.find({"partitioned": True}, {"_id": 1}): + sharded_databases.append(entry["_id"]) + return sharded_databases + + +def enable_database_sharding(client, database): + ''' + Enables sharding on a database + Args: + client (cursor): Mongodb cursor on admin database. + Returns: + true on success, false on failure + ''' + s = False + db = client["admin"].command('enableSharding', database) + if db: + s = True + return s + + +def any_dbs_to_shard(client, sharded_databases): + ''' + Return a list of databases that need to have sharding enabled + sharded_databases - Provided by module + cluster_sharded_databases - List of sharded dbs from the mongos + ''' + dbs_to_shard = [] + cluster_sharded_databases = sharded_dbs(client) + for db in sharded_databases: + if db not in cluster_sharded_databases: + dbs_to_shard.append(db) + return dbs_to_shard + + +# ========================================= +# Module execution. +# + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + mongos_process=dict(type='str', required=False, default="mongos"), + shard=dict(type='str', required=True), + sharded_databases=dict(type="raw", required=False), + state=dict(type='str', required=False, default='present', choices=['absent', 'present']) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + login_host = module.params['login_host'] + login_port = module.params['login_port'] + shard = module.params['shard'] + state = module.params['state'] + sharded_databases = module.params['sharded_databases'] + mongos_process = module.params['mongos_process'] + + try: + client = get_mongodb_client(module) + client = mongo_auth(module, client) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + try: + if client["admin"].command("serverStatus")["process"] != mongos_process: + module.fail_json(msg="Process running on {0}:{1} is not a {2}".format(login_host, login_port, mongos_process)) + + dbs_to_shard = [] + + if sharded_databases is not None and int(check_srv_version(module, client)[0]) < 6: + if isinstance(sharded_databases, str): + sharded_databases = list(sharded_databases) + dbs_to_shard = any_dbs_to_shard(client, sharded_databases) + + if module.check_mode: + if state == "present": + changed = False + if not shard_find(client, shard) or len(dbs_to_shard) > 0: + changed = True + elif state == "absent": + if not shard_find(client, shard): + changed = False + else: + changed = True + else: + if state == "present": + if not shard_find(client, shard): + shard_add(client, shard) + changed = True + else: + changed = False + if len(dbs_to_shard) > 0: + for db in dbs_to_shard: + enable_database_sharding(client, db) + changed = True + elif state == "absent": + if shard_find(client, shard): + shard_remove(client, shard) + changed = True + else: + changed = False + except Exception as e: + action = "add" + if state == "absent": + action = "remove" + module.fail_json(msg='Unable to {0} shard: %s'.format(action) % to_native(e), exception=traceback.format_exc()) + + result = { + "changed": changed, + "shard": shard, + } + if len(dbs_to_shard) > 0: + result['sharded_enabled'] = dbs_to_shard + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_shard_tag.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_shard_tag.py new file mode 100644 index 000000000..c6b1a1339 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_shard_tag.py @@ -0,0 +1,216 @@ +#!/usr/bin/python + +# Copyright: (c) 2021, Rhys Campbell <rhyscampbell@blueiwn.ch> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_shard_tag +short_description: Manage Shard Tags. +description: + - Manage Shard Tags.. + - Add and remove shard tags. +author: Rhys Campbell (@rhysmeister) +version_added: "1.3.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + name: + description: + - The name of the tag. + required: true + type: str + shard: + description: + - The name of the shard to assign or remove the tag from. + required: true + type: str + state: + description: + - The state of the zone. + required: false + type: str + choices: + - "present" + - "absent" + default: "present" + mongos_process: + description: + - Provide a custom name for the mongos process. + - Most users can ignore this setting. + required: false + type: str + default: "mongos" +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''' +- name: Add the NYC tag to a shard called rs0 + community.mongodb.mongodb_shard_tag: + name: "NYC" + shard: "rs0" + state: "present" + +- name: Remove the NYC tag from rs0 + community.mongodb.mongodb_shard_tag: + name: "NYC" + shard: "rs0" + state: "absent" +''' + +RETURN = r''' +changed: + description: True when a change has happened + returned: success + type: bool +msg: + description: A short description of what happened. + returned: failure + type: str +failed: + description: If something went wrong + returned: failed + type: bool +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + +has_ordereddict = False +try: + from collections import OrderedDict + has_ordereddict = True +except ImportError as excep: + try: + from ordereddict import OrderedDict + has_ordereddict = True + except ImportError as excep: + pass + + +def tag_exists(client, shard, tag): + ''' + Returns True if the giventag is assign to the shard + @client - MongoDB connection + @tag - The zone to check for + @shard - The shard name + ''' + status = None + result = client["config"].shards.find_one({"_id": shard, "tags": tag}) + if result: + status = True + else: + status = False + return status + + +def add_zone_tag(client, shard, tag): + ''' + Adds a tag to a shard + @client - MongoDB connection + @shard - The shard name + @tag - The tag or Zone name + ''' + cmd_doc = OrderedDict([ + ('addShardToZone', shard), + ('zone', tag), + ]) + client['admin'].command(cmd_doc) + + +def remove_zone_tag(client, shard, tag): + ''' + Remove a zone tag from a shard. + @client - MongoDB connection + @shard - The shard name + @tag - The tag or Zone name + ''' + cmd_doc = OrderedDict([ + ('removeShardFromZone', shard), + ('zone', tag), + ]) + client['admin'].command(cmd_doc) + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + name=dict(type='str', required=True), + shard=dict(type='str', required=True), + mongos_process=dict(type='str', required=False, default="mongos"), + state=dict(type='str', default="present", choices=["present", "absent"]), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + if not has_ordereddict: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict') + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + state = module.params['state'] + tag = module.params['name'] + shard = module.params['shard'] + + result = dict( + changed=False, + ) + + try: + client = get_mongodb_client(module) + client = mongo_auth(module, client) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + try: + if tag_exists(client, shard, tag): + if state == "present": + result['changed'] = False + result['msg'] = "The tag {0} is already assigned to the shard {1}".format(tag, shard) + elif state == "absent": + if not module.check_mode: + remove_zone_tag(client, shard, tag) + result['changed'] = True + result['msg'] = "The tag {0} was removed from the shard {1}".format(tag, shard) + else: + if state == "present": + if not module.check_mode: + add_zone_tag(client, shard, tag) + result['changed'] = True + result['msg'] = "The tag {0} was assigned to the shard {1}".format(tag, shard) + elif state == "absent": + result['changed'] = False + result['msg'] = "The tag {0} is not assigned to the shard {1}".format(tag, shard) + except Exception as excep: + module.fail_json(msg="An error occurred: {0}".format(excep)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_shard_zone.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_shard_zone.py new file mode 100644 index 000000000..1d1c68fde --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_shard_zone.py @@ -0,0 +1,323 @@ +#!/usr/bin/python + +# Copyright: (c) 2021, Rhys Campbell <rhyscampbell@blueiwn.ch> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_shard_zone +short_description: Manage Shard Zones. +description: + - Manage Shard Zones. + - Add and remove shard zones. +author: Rhys Campbell (@rhysmeister) +version_added: "1.3.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + name: + description: + - The name of the zone. + required: true + type: str + namespace: + description: + - The namespace the zone is assigned to + - Should be given in the form database.collection. + type: str + ranges: + description: + - The ranges assigned to the Zone. + type: list + elements: list + state: + description: + - The state of the zone. + required: false + type: str + choices: + - "present" + - "absent" + default: "present" + mongos_process: + description: + - Provide a custom name for the mongos process. + - Most users can ignore this setting. + required: false + type: str + default: "mongos" +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''' +- name: Add a shard zone for NYC + community.mongodb.mongodb_shard_zone: + name: "NYC" + namespace: "records.users" + ranges: + - [{ zipcode: "10001" }, { zipcode: "10281" }] + - [{ zipcode: "11201" }, { zipcode: "11240" }] + state: "present" + +- name: Remove all zone ranges + community.mongodb.mongodb_shard_zone: + name: "NYC" + namespace: "records.users" + state: "absent" + +- name: Remove a specific zone range + community.mongodb.mongodb_shard_zone: + name: "NYC" + namespace: "records.users" + ranges: + - [{ zipcode: "11201" }, { zipcode: "11240" }] + state: "absent" +''' + +RETURN = r''' +changed: + description: True when a change has happened + returned: success + type: bool +msg: + description: A short description of what happened. + returned: failure + type: str +failed: + description: If something went wrong + returned: failed + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + +has_ordereddict = False +try: + from collections import OrderedDict + has_ordereddict = True +except ImportError as excep: + try: + from ordereddict import OrderedDict + has_ordereddict = True + except ImportError as excep: + pass + + +def zone_range_exists(client, namespace, min, max, tag): + ''' + Returns true if a particular zone range exists + Record format seems to be different than the docs state in 4.4.6 + { "_id" : ObjectId("60e2e7cff7c9d447440bb114"), + "ns" : "records.users", + "min" : { "zipcode" : "10001" }, + "max" : { "zipcode" : "10281" }, + "tag" : "NYC" } + + @client - MongoDB connection + @namespace - In the form database.collection + @min - The min range value + @max - The max range value + @tag - The tag or Zone name + ''' + query = { + # "_id.ns": namespace, 4.4.X Bug??? ObjectId given as id + # "_id.min": min, + 'ns': namespace, + 'min': min, + 'max': max, + 'tag': tag + } + + status = None + result = client["config"].tags.find_one(query) + if result: + status = True + else: + status = False + return status + + +def zone_exists(client, tag): + ''' + Returns True if the given zone exists + @client - MongoDB connection + @tag - The zone to check for + ''' + status = None + result = client["config"].shards.find_one({"tags": tag}) + if result: + status = True + else: + status = False + return status + + +def add_zone_range(client, namespace, min, max, tag): + ''' + Adds a zone range + @client - MongoDB connection + @namespace - In the form database.collection + @min - The min range value + @max - The max range value + @tag - The tag or Zone name + ''' + cmd_doc = OrderedDict([ + ('updateZoneKeyRange', namespace), + ('min', min), + ('max', max), + ('zone', tag), + ]) + client['admin'].command(cmd_doc) + + +def remove_zone_range(client, namespace, min, max): + ''' + Remove a zone range. + We do this by setting the zone to None + @client - MongoDB connection + @namespace - In the form database.collection + @min - The min range value + @max - The max range value + ''' + cmd_doc = OrderedDict([ + ('updateZoneKeyRange', namespace), + ('min', min), + ('max', max), + ('zone', None), + ]) + client['admin'].command(cmd_doc) + + +def remove_all_zone_range_by_tag(client, tag): + result = client["config"].tags.find({"tag": tag}) + for r in result: + remove_zone_range(client, r['ns'], r['min'], r['max']) + + +def zone_range_count(client, tag): + ''' + Returns the count of records that exists for the given tag in config.tags + ''' + return client['config'].tags.count_documents({"tag": tag}) + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + name=dict(type='str', required=True), + namespace=dict(type='str'), + ranges=dict(type='list', elements='list'), + mongos_process=dict(type='str', required=False, default="mongos"), + state=dict(type='str', default="present", choices=["present", "absent"]), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + required_if=[("state", "present", ("namespace", "ranges"))] + ) + + if not has_ordereddict: + module.fail_json(msg='Cannot import OrderedDict class. You can probably install with: pip install ordereddict') + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + state = module.params['state'] + zone_name = module.params['name'] + namespace = module.params['namespace'] + ranges = module.params['ranges'] + + if ranges is not None: + if not isinstance(ranges, list) or not isinstance(ranges[0], list) or not isinstance(ranges[0][0], dict): + module.fail_json(msg="Provided ranges are invalid {0} {1} {2}".format(str(type(ranges)), + str(type(ranges[0])), + str(type(ranges[0][0])))) + + result = dict( + changed=False, + ) + + try: + client = get_mongodb_client(module) + client = mongo_auth(module, client) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + try: + if not zone_exists(client, zone_name): + msg = ("The tag {0} does not exist. You need to associate a tag with" + " a shard before using this module. You can do that with the" + " mongodb_shard_tag module.".format(zone_name)) + module.fail_json(msg=msg) + else: + # first check if the ranges exist + range_count = 0 + if state == "present": + for range in ranges: + if zone_range_exists(client, namespace, range[0], range[1], zone_name): + range_count += 1 + result['range_count'] = range_count + result['ranges'] = len(ranges) + if range_count == len(ranges): # All ranges are the same + result['changed'] = False + result['msg'] = "All Zone Ranges present for {0}".format(zone_name) + else: + for range in ranges: + if not module.check_mode: + add_zone_range(client, namespace, range[0], range[1], zone_name) + result['changed'] = True + result['msg'] = "Added zone ranges for {0}".format(zone_name) + elif state == "absent": + range_count = zone_range_count(client, zone_name) + deleted_count = 0 + if range_count > 0 and ranges is None: + if not module.check_mode: + remove_all_zone_range_by_tag(client, zone_name) + deleted_count = range_count + result['changed'] = True + result['msg'] = "{0} zone ranges for {1} deleted.".format(deleted_count, zone_name) + elif ranges is not None: + for range in ranges: + if zone_range_exists(client, namespace, range[0], range[1], zone_name): + if not module.check_mode: + remove_zone_range(client, namespace, range[0], range[1]) + deleted_count += 1 + if deleted_count > 0: + result['changed'] = True + result['msg'] = "{0} zone ranges for {1} deleted.".format(deleted_count, zone_name) + else: + result['changed'] = False + result['msg'] = "The provided zone ranges are not present for {0}".format(zone_name) + else: + result['changed'] = False + result['msg'] = "No zone ranges present for {0}".format(zone_name) + except Exception as excep: + module.fail_json(msg="An error occurred: {0}".format(excep)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_shell.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_shell.py new file mode 100644 index 000000000..618cafae9 --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_shell.py @@ -0,0 +1,328 @@ +#!/usr/bin/python + +# 2020 Rhys Campbell <rhys.james.campbell@googlemail.com> +# https://github.com/rhysmeister +# 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 + + +DOCUMENTATION = ''' +--- +module: mongodb_shell +author: Rhys Campbell (@rhysmeister) +version_added: "1.1.0" +short_description: Run commands via the MongoDB shell. +requirements: + - mongosh +description: + - Run commands via the MongoDB shell. + - Commands provided with the eval parameter or included in a Javascript file. + - Attempts to parse returned data into a format that Ansible can use. + - Module uses the mongosh shell by default. + - Support for mongo is depreciated. + +extends_documentation_fragment: + - community.mongodb.login_options + +options: + mongo_cmd: + description: + - The MongoDB shell command. + - auto - Automatically detect which MongoDB shell command used. Use "mongosh" if available, else use "mongo" command. + - mongo - This should still work for most cases but you might have problems with json parsinf. Use transform_type of 'raw' is you encounter problems. + type: str + default: "mongosh" + db: + description: + - The database to run commands against + type: str + required: false + default: "test" + file: + description: + - Path to a file containing MongoDB commands. + type: str + eval: + description: + - A MongoDB command to run. + type: str + nodb: + description: + - Specify a non-default encoding for output. + type: bool + default: false + norc: + description: + - Prevents the shell from sourcing and evaluating ~/.mongorc.js on start up. + type: bool + default: false + quiet: + description: + - Silences output from the shell during the connection process.. + type: bool + default: true + debug: + description: + - show additional debug info. + type: bool + default: false + transform: + description: + - Transform the output returned to the user. + - auto - Attempt to automatically decide the best tranformation. + - split - Split output on a character. + - json - parse as json. + - raw - Return the raw output. + type: str + choices: + - "auto" + - "split" + - "json" + - "raw" + default: "auto" + split_char: + description: + - Used by the split action in the transform stage. + type: str + default: " " + stringify: + description: + - Wraps the command in eval in JSON.stringify(<js cmd>) (mongo) or EJSON.stringify(<js cmd>) (mongosh). + - Useful for escaping documents that are returned in Extended JSON format. + - Automatically set to false when using mongo. + - Automatically set to true when using mongosh. + - Set explicitly to override automatic selection. + type: bool + default: null + additional_args: + description: + - Additional arguments to supply to the mongo command. + - Supply as key-value pairs. + - If the parameter is a valueless flag supply an empty string as the value. + type: raw + idempotent: + description: + - Provides a form of pseudo-idempotency to the module. + - We perform a hash calculation on the contents of the eval key or the file name provided in the file key. + - When the command is first execute a filed called <hash>.success will be created. + - The module will not rerun the command if this file exists and idempotent is set to true. + type: bool + default: false + omit: + description: + - Parameter to omit from the command line. + - This should match the parameter name that the MongoDB shell accepts not the module name. + type: list + elements: str + default: [] +''' + +EXAMPLES = ''' +- name: Run the listDatabases command + community.mongodb.mongodb_shell: + login_user: user + login_password: secret + eval: "db.adminCommand('listDatabases')" + +- name: List collections and stringify the output + community.mongodb.mongodb_shell: + login_user: user + login_password: secret + eval: "db.adminCommand('listCollections')" + stringify: yes + +- name: Run the showBuiltinRoles command + community.mongodb.mongodb_shell: + login_user: user + login_password: secret + eval: "db.getRoles({showBuiltinRoles: true})" + +- name: Run a js file containing MongoDB commands with pseudo-idempotency + community.mongodb.mongodb_shell: + login_user: user + login_password: secret + file: "/path/to/mongo/file.js" + idempotent: yes + +- name: Provide a couple of additional cmd args + community.mongodb.mongodb_shell: + login_user: user + login_password: secret + eval: "db.adminCommand('listDatabases')" + additional_args: + verbose: True + networkMessageCompressors: "snappy" +''' + +RETURN = ''' +file: + description: JS file that was executed successfully. + returned: When a js file is used. + type: str +msg: + description: A message indicating what has happened. + returned: always + type: str +transformed_output: + description: Output from the mongo command. We attempt to parse this into a list or json where possible. + returned: on success + type: list +changed: + description: Change status. + returned: always + type: bool +failed: + description: Something went wrong. + returned: on failure + type: bool +out: + description: Raw stdout from mongo. + returned: when debug is set to true + type: str +err: + description: Raw stderr from mongo. + returned: when debug is set to true + type: str +rc: + description: Return code from mongo. + returned: when debug is set to true + type: int +''' + +from ansible.module_utils.basic import AnsibleModule +import os +__metaclass__ = type + +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + mongodb_common_argument_spec +) + +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_shell import ( + add_arg_to_cmd, + transform_output, + get_hash_value, + touch, + detect_if_cmd_exist +) + + +def main(): + argument_spec = mongodb_common_argument_spec(ssl_options=False) + argument_spec.update( + mongo_cmd=dict(type='str', default="mongosh"), + file=dict(type='str', required=False), + eval=dict(type='str', required=False), + db=dict(type='str', required=False, default="test"), + nodb=dict(type='bool', required=False, default=False), + norc=dict(type='bool', required=False, default=False), + quiet=dict(type='bool', required=False, default=True), + debug=dict(type='bool', required=False, default=False), + transform=dict(type='str', choices=["auto", "split", "json", "raw"], default="auto"), + split_char=dict(type='str', default=" "), + stringify=dict(type='bool', default=None), + additional_args=dict(type='raw'), + idempotent=dict(type='bool', default=False), + omit=dict(type='list', elements='str', default=[]), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=[['login_user', 'login_password']], + mutually_exclusive=[["eval", "file"]] + ) + + if module.params['mongo_cmd'] == "auto": + module.params['mongo_cmd'] = "mongosh" if detect_if_cmd_exist() else "mongo" + + if module.params['mongo_cmd'] == "mongo" and module.params['stringify'] is None: + module.params['stringify'] = False + elif module.params['mongo_cmd'] == "mongosh" and module.params['stringify'] is None: + module.params['stringify'] = True + + args = [ + module.params['mongo_cmd'], + module.params['db'] + ] + + hash_value = get_hash_value(module) + + if module.params['idempotent']: + if os.path.isfile("{0}.success".format(hash_value)): + module.exit_json(changed=False, + msg="The file {0}.success was found meaning this " + "command has already successfully executed " + "on this MongoDB host.".format(hash_value)) + + if not module.params['file']: + if module.params['eval'].startswith("show "): + msg = "You cannot use any shell helper (e.g. use <dbname>, show dbs, etc.)"\ + " inside the eval parameter because they are not valid JavaScript." + module.fail_json(msg=msg) + if module.params['stringify']: + if module.params['mongo_cmd'] != "mongosh": + module.params['eval'] = "JSON.stringify({0})".format(module.params['eval']) + else: + module.params['eval'] = "EJSON.stringify({0})".format(module.params['eval']) + + omit = module.params['omit'] + + args = add_arg_to_cmd(args, "--host", module.params['login_host'], omit=omit) + args = add_arg_to_cmd(args, "--port", module.params['login_port'], omit=omit) + args = add_arg_to_cmd(args, "--username", module.params['login_user'], omit=omit) + args = add_arg_to_cmd(args, "--password", module.params['login_password'], omit=omit) + args = add_arg_to_cmd(args, "--authenticationDatabase", module.params['login_database'], omit=omit) + args = add_arg_to_cmd(args, "--eval", module.params['eval'], omit=omit) + args = add_arg_to_cmd(args, "--nodb", None, module.params['nodb'], omit=omit) + args = add_arg_to_cmd(args, "--norc", None, module.params['norc'], omit=omit) + args = add_arg_to_cmd(args, "--quiet", None, module.params['quiet'], omit=omit) + + additional_args = module.params['additional_args'] + if additional_args is not None: + for key, value in additional_args.items(): + if isinstance(value, bool): + args.append(" --{0}".format(key)) + elif isinstance(value, str) or isinstance(value, int): + args.append(" --{0} {1}".format(key, value)) + if module.params['file']: + args.append(module.params['file']) + + rc = None + out = '' + err = '' + result = {} + cmd = " ".join(str(item) for item in args) + + (rc, out, err) = module.run_command(cmd, check_rc=False) + + if module.params['debug']: + result['out'] = out + result['err'] = err + result['rc'] = rc + result['cmd'] = cmd + + if rc != 0: + if err is None or err == "": + err = out + module.fail_json(msg=err.strip(), **result) + else: + result['changed'] = True + if module.params['idempotent']: + touch("{0}.success".format(hash_value)) + try: + output = transform_output(out, + module.params['transform'], + module.params['split_char']) + result['transformed_output'] = output + result['msg'] = "transform type was {0}".format(module.params['transform']) + if module.params['file'] is not None: + result['file'] = module.params['file'] + except Exception as excep: + result['msg'] = "Error tranforming output: {0}".format(str(excep)) + result['transformed_output'] = None + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_shutdown.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_shutdown.py new file mode 100644 index 000000000..60e3f027b --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_shutdown.py @@ -0,0 +1,137 @@ +#!/usr/bin/python + +# Copyright: (c) 2020, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_shutdown +short_description: Cleans up all database resources and then terminates the mongod/mongos process. +description: + - Cleans up all database resources and then terminates the process. +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + force: + description: + - Specify true to force the mongod to shut down. + - Force shutdown interrupts any ongoing operations on the mongod and may result in unexpected behavior. + type: bool + default: false + timeout: + description: + - The number of seconds the primary should wait for a secondary to catch up. + type: int + default: 10 +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''' +- name: Attempt to perform a clean shutdown + community.mongodb.mongodb_shutdown: + +- name: Force shutdown with a timeout of 60 seconds + community.mongodb.mongodb_shutdown: + force: true + timeout: 60 +''' + +RETURN = r''' +changed: + description: Whether the member was shutdown. + returned: success + type: bool +msg: + description: A short description of what happened. + returned: success + type: str +failed: + description: If something went wrong + returned: failed + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + force=dict(type='bool', default=False), + timeout=dict(type='int', default=10) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + + 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)) + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + force = module.params['force'] + timeout = module.params['timeout'] + + result = dict( + changed=False, + ) + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as excep: + module.fail_json(msg='Unable to connect to MongoDB: %s' % to_native(excep)) + + try: + cmd_doc = OrderedDict([ + ('shutdown', 1), + ('force', force), + ('timeoutSecs', timeout) + ]) + client['admin'].command(cmd_doc) + result["changed"] = True + result["msg"] = "mongod process was terminated sucessfully" + except Exception as excep: + if "connection closed" in str(excep): + result["changed"] = True + result["msg"] = "mongod process was terminated sucessfully" + else: + result["msg"] = "An error occurred: {0}".format(excep) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_status.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_status.py new file mode 100644 index 000000000..4b7a208db --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_status.py @@ -0,0 +1,353 @@ +#!/usr/bin/python + +# Copyright: (c) 2018, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_status +short_description: Validates the status of the replicaset. +description: + - Validates the status of the replicaset. + - The module expects all replicaset nodes to be PRIMARY, SECONDARY or ARBITER. + - Will wait until a timeout for the replicaset state to converge if required. + - Can also be used to lookup the current PRIMARY member (see examples). +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + replica_set: + description: + - Replicaset name. + type: str + default: rs0 + poll: + description: + - The maximum number of times to query for the replicaset status before the set converges or we fail. + type: int + default: 1 + interval: + description: + - The number of seconds to wait between polling executions. + type: int + default: 30 + validate: + description: + - The type of validate to perform on the replicaset. + - default, Suitable for most purposes. Validate that there are an odd + number of servers and one is PRIMARY and the remainder are in a SECONDARY + or ARBITER state. + - votes, Check the number of votes is odd and one is a PRIMARY and the + remainder are in a SECONDARY or ARBITER state. Authentication is + required here to get the replicaset configuration. + - minimal, Just checks that one server is in a PRIMARY state with the + remainder being SECONDARY or ARBITER. + type: str + choices: + - default + - votes + - minimal + default: default +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''' +- name: Check replicaset is healthy, fail if not after first attempt + community.mongodb.mongodb_status: + replica_set: rs0 + when: ansible_hostname == "mongodb1" + +- name: Wait for the replicaset rs0 to converge, check 5 times, 10 second interval between checks + community.mongodb.mongodb_status: + replica_set: rs0 + poll: 5 + interval: 10 + when: ansible_hostname == "mongodb1" + +# Get the replicaset status and then lookup the primary's hostname and save to a variable +- name: Ensure replicaset is stable before beginning + community.mongodb.mongodb_status: + login_user: "{{ admin_user }}" + login_password: "{{ admin_user_password }}" + poll: 3 + interval: 10 + register: rs + +- name: Lookup PRIMARY replicaset member + set_fact: + primary: "{{ item.key.split('.')[0] }}" + loop: "{{ lookup('dict', rs.replicaset) }}" + when: "'PRIMARY' in item.value" +''' + +RETURN = r''' +failed: + description: If the module has failed or not. + returned: always + type: bool +iterations: + description: Number of times the module has queried the replicaset status. + returned: always + type: int +msg: + description: Status message. + returned: always + type: str +replicaset: + description: The last queried status of all the members of the replicaset if obtainable. + returned: always + type: dict +''' + + +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def replicaset_config(client): + """ + Return the replicaset config document + https://docs.mongodb.com/manual/reference/command/replSetGetConfig/ + """ + rs = client.admin.command('replSetGetConfig') + return rs + + +def replicaset_votes(config_document): + """ + Return the number of votes in the replicaset + """ + votes = 0 + for member in config_document["config"]['members']: + votes += member['votes'] + return votes + + +def replicaset_status(client, module): + """ + Return the replicaset status document from MongoDB + # https://docs.mongodb.com/manual/reference/command/replSetGetStatus/ + """ + rs = client.admin.command('replSetGetStatus') + return rs + + +def replicaset_members(replicaset_document): + """ + Returns the members section of the MongoDB replicaset document + """ + return replicaset_document["members"] + + +def replicaset_friendly_document(members_document): + """ + Returns a version of the members document with + only the info this module requires: name & stateStr + """ + friendly_document = {} + + for member in members_document: + friendly_document[member["name"]] = member["stateStr"] + return friendly_document + + +def replicaset_statuses(members_document, module): + """ + Return a list of the statuses + """ + statuses = [] + for member in members_document: + statuses.append(members_document[member]) + return statuses + + +def replicaset_good(statuses, module, votes): + """ + Returns true if the replicaset is in a "good" condition. + Good is defined as an odd number of servers >= 3, with + max one primary, and any even amount of + secondary and arbiter servers + """ + msg = "Unset" + status = None + valid_statuses = ["PRIMARY", "SECONDARY", "ARBITER"] + validate = module.params['validate'] + + if validate == "default": + if len(statuses) % 2 == 1: + if (statuses.count("PRIMARY") == 1 + and ((statuses.count("SECONDARY") + + statuses.count("ARBITER")) % 2 == 0) + and len(set(statuses) - set(valid_statuses)) == 0): + status = True + msg = "replicaset is in a converged state" + else: + status = False + msg = "replicaset is not currently in a converged state" + else: + msg = "Even number of servers in replicaset." + status = False + elif validate == "votes": + # Need to validate the number of votes in the replicaset + if votes % 2 == 1: # We have a good number of votes + if (statuses.count("PRIMARY") == 1 + and len(set(statuses) - set(valid_statuses)) == 0): + status = True + msg = "replicaset is in a converged state" + else: + status = False + msg = "replicaset is not currently in a converged state" + else: + msg = "Even number of votes in replicaset." + status = False + elif validate == "minimal": + if (statuses.count("PRIMARY") == 1 + and len(set(statuses) - set(valid_statuses)) == 0): + status = True + msg = "replicaset is in a converged state" + else: + status = False + msg = "replicaset is not currently in a converged state" + else: + module.fail_json(msg="Invalid value for validate has been provided: {0}".format(validate)) + return status, msg + + +def replicaset_status_poll(client, module): + """ + client - MongoDB Client + poll - Number of times to poll + interval - interval between polling attempts + """ + iterations = 0 # How many times we have queried the cluster + failures = 0 # Number of failures when querying the replicaset + poll = module.params['poll'] + interval = module.params['interval'] + status = None + return_doc = {} + votes = None + config = None + + while iterations < poll: + try: + iterations += 1 + replicaset_document = replicaset_status(client, module) + members = replicaset_members(replicaset_document) + friendly_document = replicaset_friendly_document(members) + statuses = replicaset_statuses(friendly_document, module) + + if module.params['validate'] == "votes": # Requires auth + config = replicaset_config(client) + votes = replicaset_votes(config) + + status, msg = replicaset_good(statuses, module, votes) + + if status: # replicaset looks good + return_doc = {"failures": failures, + "poll": poll, + "iterations": iterations, + "msg": msg, + "replicaset": friendly_document} + break + else: + failures += 1 + return_doc = {"failures": failures, + "poll": poll, + "iterations": iterations, + "msg": msg, + "replicaset": friendly_document, + "failed": True} + if iterations == poll: + break + else: + time.sleep(interval) + except Exception as e: + failures += 1 + return_doc['failed'] = True + return_doc['msg'] = str(e) + status = False + if iterations == poll: + break + else: + time.sleep(interval) + + return_doc['failures'] = failures + return status, return_doc['msg'], return_doc + + +# ========================================= +# Module execution. +# + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + interval=dict(type='int', default=30), + poll=dict(type='int', default=1), + replica_set=dict(type='str', default="rs0"), + validate=dict(type='str', choices=['default', 'votes', 'minimal'], default='default'), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=[['login_user', 'login_password']], + ) + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + replica_set = module.params['replica_set'] + msg = None + + result = dict( + failed=False, + replica_set=replica_set, + ) + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + if len(replica_set) == 0: + module.fail_json(msg="Parameter 'replica_set' must not be an empty string") + + try: + status, msg, return_doc = replicaset_status_poll(client, module) # Sort out the return doc + replicaset = return_doc['replicaset'] + iterations = return_doc['iterations'] + except Exception as e: + module.fail_json(msg='Unable to query replica_set info: {0}: {1}'.format(str(e), msg)) + + if status is False: + module.fail_json(msg=msg, replicaset=replicaset, iterations=iterations) + else: + module.exit_json(msg=msg, replicaset=replicaset, iterations=iterations) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_stepdown.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_stepdown.py new file mode 100644 index 000000000..cd0580e7a --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_stepdown.py @@ -0,0 +1,248 @@ +#!/usr/bin/python + +# Copyright: (c) 2020, Rhys Campbell <rhys.james.campbell@googlemail.com> +# 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 + + +DOCUMENTATION = r''' +--- +module: mongodb_stepdown +short_description: Step down the MongoDB node from a PRIMARY state. +description: > + Step down the MongoDB node from the PRIMARY state if it has that status. + Returns OK immediately if the member is already in the SECONDARY or ARBITER states. + Will wait until a timeout for the member state to reach SECONDARY or PRIMARY, + if the member state is currently STARTUP, RECOVERING, STARTUP2 or ROLLBACK, + before taking any needed action. +author: Rhys Campbell (@rhysmeister) +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + poll: + description: + - The maximum number of times query for the member status. + type: int + default: 1 + interval: + description: + - The number of seconds to wait between poll executions. + type: int + default: 30 + stepdown_seconds: + description: + - The number of seconds to step down the primary, during which time the stepdown member is ineligible for becoming primary. + type: int + default: 60 + secondary_catch_up: + description: + - The secondaryCatchUpPeriodSecs parameter for the stepDown command. + - The number of seconds that mongod will wait for an electable secondary to catch up to the primary. + type: int + default: 10 + force: + description: + - Optional. A boolean that determines whether the primary steps down if no electable and up-to-date secondary exists within the wait period. + type: bool + default: false +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''' +- name: Step down the current MongoDB member + community.mongodb.mongodb_stepdown: + login_user: admin + login_password: secret + +- name: Step down the current MongoDB member, poll a maximum of 5 times if member state is recovering + community.mongodb.mongodb_stepdown: + login_user: admin + login_password: secret + poll: 5 + interval: 10 +''' + +RETURN = r''' +failed: + description: If the module had failed or not. + returned: always + type: bool +iteration: + description: Number of times the module has queried the replicaset status. + returned: always + type: int +msg: + description: Status message. + returned: always + type: str +''' + + +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def member_status(client): + """ + Return the member status string + # https://docs.mongodb.com/manual/reference/command/replSetGetStatus/ + """ + myStateStr = None + rs = client.admin.command('replSetGetStatus') + for member in rs["members"]: + if "self" in member.keys(): + myStateStr = member["stateStr"] + return myStateStr + + +def member_stepdown(client, module): + """ + client - MongoDB Client + module - Ansible module object + """ + + 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)) + + iterations = 0 # How many times we have queried the member + failures = 0 # Number of failures when querying the replicaset + poll = module.params['poll'] + interval = module.params['interval'] + stepdown_seconds = module.params['stepdown_seconds'] + secondary_catch_up = module.params['secondary_catch_up'] + force = module.params['force'] + return_doc = {} + status = None + + while iterations < poll: + try: + iterations += 1 + return_doc['iterations'] = iterations + myStateStr = member_status(client) + if myStateStr == "PRIMARY": + # Run step down command + if module.check_mode: + return_doc["msg"] = "member was stepped down" + return_doc['changed'] = True + status = True + break + else: + cmd_doc = OrderedDict([ + ('replSetStepDown', stepdown_seconds), + ('secondaryCatchUpPeriodSecs', secondary_catch_up), + ('force', force) + ]) + try: + client.admin.command(cmd_doc) # For now we assume the stepDown was successful + except Exception as excep: + # 4.0 and below close the connection as part of the stepdown. + # This code should be removed once we support 4.2+ onwards + # https://tinyurl.com/yc79g9ay + if str(excep) == "connection closed": + pass + else: + raise excep + return_doc['changed'] = True + status = True + return_doc["msg"] = "member was stepped down" + break + elif myStateStr in ["SECONDARY", "ARBITER"]: + return_doc["msg"] = "member was already at {0} state".format(myStateStr) + return_doc['changed'] = False + status = True + break + elif myStateStr in ["STARTUP", "RECOVERING", "STARTUP2", "ROLLBACK"]: + time.sleep(interval) # Wait for interval + else: + return_doc["msg"] = "Unexpected member state {0}".format(myStateStr) + return_doc['changed'] = False + status = False + break + except Exception as e: + failures += 1 + return_doc['failed'] = True + return_doc['changed'] = False + return_doc['msg'] = str(e) + status = False + if iterations == poll: + break + else: + time.sleep(interval) + + return status, return_doc['msg'], return_doc + + +# ========================================= +# Module execution. +# + + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + force=dict(type='bool', default=False), + interval=dict(type='int', default=30), + poll=dict(type='int', default=1), + secondary_catch_up=dict(type='int', default=10), + stepdown_seconds=dict(type='int', default=60) + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_together=[['login_user', 'login_password']], + ) + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + result = dict( + failed=False, + ) + + try: + client = get_mongodb_client(module, directConnection=True) + client = mongo_auth(module, client, directConnection=True) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + try: + status, msg, return_doc = member_stepdown(client, module) + iterations = return_doc['iterations'] + changed = return_doc['changed'] + except Exception as e: + module.fail_json(msg='Unable to query replica_set info: %s' % str(e)) + + if status is False: + module.fail_json(msg=msg, iterations=iterations, changed=changed) + else: + module.exit_json(msg=msg, iterations=iterations, changed=changed) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/mongodb/plugins/modules/mongodb_user.py b/ansible_collections/community/mongodb/plugins/modules/mongodb_user.py new file mode 100644 index 000000000..eab0d186c --- /dev/null +++ b/ansible_collections/community/mongodb/plugins/modules/mongodb_user.py @@ -0,0 +1,428 @@ +#!/usr/bin/python + +# (c) 2012, Elliott Foster <elliott@fourkitchens.com> +# Sponsored by Four Kitchens http://fourkitchens.com. +# (c) 2014, Epic Games, Inc. +# +# 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 + + +DOCUMENTATION = ''' +--- +module: mongodb_user +short_description: Adds or removes a user from a MongoDB database +description: + - Adds or removes a user from a MongoDB database. +version_added: "1.0.0" + +extends_documentation_fragment: + - community.mongodb.login_options + - community.mongodb.ssl_options + +options: + replica_set: + description: + - Replica set to connect to (automatically connects to primary for writes). + type: str + database: + description: + - The name of the database to add/remove the user from. + required: true + type: str + aliases: [db] + name: + description: + - The name of the user to add or remove. + required: true + aliases: [user] + type: str + password: + description: + - The password to use for the user. + type: str + aliases: [pass] + roles: + type: list + elements: raw + description: + - > + The database user roles valid values could either be one or more of the following strings: + 'read', 'readWrite', 'dbAdmin', 'userAdmin', 'clusterAdmin', 'readAnyDatabase', 'readWriteAnyDatabase', 'userAdminAnyDatabase', + 'dbAdminAnyDatabase' + - "Or the following dictionary '{ db: DATABASE_NAME, role: ROLE_NAME }'." + - "This param requires pymongo 2.5+. If it is a string, mongodb 2.4+ is also required. If it is a dictionary, mongo 2.6+ is required." + state: + description: + - The database user state. + default: present + choices: [absent, present] + type: str + update_password: + default: always + choices: [always, on_create] + description: + - C(always) will always update passwords and cause the module to return changed. + - C(on_create) will only set the password for newly created users. + - This must be C(always) to use the localhost exception when adding the first admin user. + - This option is effectively ignored when using x.509 certs. It is defaulted to 'on_create' to maintain a \ + a specific module behaviour when the login_database is '$external'. + type: str + create_for_localhost_exception: + type: path + description: + - This is parmeter is only useful for handling special treatment around the localhost exception. + - If C(login_user) is defined, then the localhost exception is not active and this parameter has no effect. + - If this file is NOT present (and C(login_user) is not defined), then touch this file after successfully adding the user. + - If this file is present (and C(login_user) is not defined), then skip this task. + +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. Newer mongo server versions require newer + pymongo versions. @see http://api.mongodb.org/python/current/installation.html +requirements: + - "pymongo" +author: + - "Elliott Foster (@elliotttf)" + - "Julien Thebault (@Lujeni)" +''' + +EXAMPLES = ''' +- name: Create 'burgers' database user with name 'bob' and password '12345'. + community.mongodb.mongodb_user: + database: burgers + name: bob + password: 12345 + state: present + +- name: Create a database user via SSL (MongoDB must be compiled with the SSL option and configured properly) + community.mongodb.mongodb_user: + database: burgers + name: bob + password: 12345 + state: present + ssl: True + +- name: Delete 'burgers' database user with name 'bob'. + community.mongodb.mongodb_user: + database: burgers + name: bob + state: absent + +- name: Define more users with various specific roles (if not defined, no roles is assigned, and the user will be added via pre mongo 2.2 style) + community.mongodb.mongodb_user: + database: burgers + name: ben + password: 12345 + roles: read + state: present + +- name: Define roles + community.mongodb.mongodb_user: + database: burgers + name: jim + password: 12345 + roles: readWrite,dbAdmin,userAdmin + state: present + +- name: Define roles + community.mongodb.mongodb_user: + database: burgers + name: joe + password: 12345 + roles: readWriteAnyDatabase + state: present + +- name: Add a user to database in a replica set, the primary server is automatically discovered and written to + community.mongodb.mongodb_user: + database: burgers + name: bob + replica_set: belcher + password: 12345 + roles: readWriteAnyDatabase + state: present + +# add a user 'oplog_reader' with read only access to the 'local' database on the replica_set 'belcher'. This is useful for oplog access (MONGO_OPLOG_URL). +# please notice the credentials must be added to the 'admin' database because the 'local' database is not synchronized and can't receive user credentials +# To login with such user, the connection string should be MONGO_OPLOG_URL="mongodb://oplog_reader:oplog_reader_password@server1,server2/local?authSource=admin" +# This syntax requires mongodb 2.6+ and pymongo 2.5+ +- name: Roles as a dictionary + community.mongodb.mongodb_user: + login_user: root + login_password: root_password + database: admin + user: oplog_reader + password: oplog_reader_password + state: present + replica_set: belcher + roles: + - db: local + role: read + +- name: Adding a user with X.509 Member Authentication + community.mongodb.mongodb_user: + login_host: "mongodb-host.test" + login_port: 27001 + login_database: "$external" + database: "admin" + name: "admin" + password: "test" + roles: + - dbAdminAnyDatabase + ssl: true + ssl_ca_certs: "/tmp/ca.crt" + ssl_certfile: "/tmp/tls.key" #cert and key in one file + state: present + auth_mechanism: "MONGODB-X509" + connection_options: + - "tlsAllowInvalidHostnames=true" +''' + +RETURN = ''' +user: + description: The name of the user to add or remove. + returned: success + type: str +''' + +import os +import traceback +from operator import itemgetter + + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.six import binary_type, text_type +from ansible.module_utils._text import to_native, to_bytes +from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( + missing_required_lib, + mongodb_common_argument_spec, + mongo_auth, + PYMONGO_IMP_ERR, + pymongo_found, + get_mongodb_client, +) + + +def user_find(client, user, db_name): + """Check if the user exists. + + Args: + client (cursor): Mongodb cursor on admin database. + user (str): User to check. + db_name (str): User's database. + + Returns: + dict: when user exists, False otherwise. + """ + try: + for mongo_user in client[db_name].command('usersInfo')['users']: + if mongo_user['user'] == user: + # NOTE: there is no 'db' field in mongo 2.4. + if 'db' not in mongo_user: + return mongo_user + # Workaround to make the condition works with AWS DocumentDB, + # since all users are in the admin database. + if mongo_user["db"] in [db_name, "admin"]: + return mongo_user + except Exception as excep: + if hasattr(excep, 'code') and excep.code == 11: # 11=UserNotFound + pass # Allow return False + else: + raise + return False + + +def user_add(module, client, db_name, user, password, roles): + # pymongo's user_add is a _create_or_update_user so we won't know if it was changed or updated + # without reproducing a lot of the logic in database.py of pymongo + db = client[db_name] + + try: + exists = user_find(client, user, db_name) + except Exception as excep: + # We get this exception: "not authorized on admin to execute command" + # when auth is enabled on a new instance. The loalhost exception should + # allow us to create the first user. If the localhost exception does not apply, + # then user creation will also fail with unauthorized. So, ignore Unauthorized here. + if hasattr(excep, 'code') and excep.code == 13: # 13=Unauthorized + exists = False + else: + raise + + if exists: + user_add_db_command = 'updateUser' + else: + user_add_db_command = 'createUser' + + user_dict = {} + + if password is not None: + user_dict["pwd"] = password + if roles is not None: + user_dict["roles"] = roles + + db.command(user_add_db_command, user, **user_dict) + + +def user_remove(module, client, db_name, user): + exists = user_find(client, user, db_name) + if exists: + if module.check_mode: + module.exit_json(changed=True, user=user) + db = client[db_name] + db.command("dropUser", user) + else: + module.exit_json(changed=False, user=user) + + +def check_if_roles_changed(uinfo, roles, db_name): + # We must be aware of users which can read the oplog on a replicaset + # Such users must have access to the local DB, but since this DB does not store users credentials + # and is not synchronized among replica sets, the user must be stored on the admin db + # Therefore their structure is the following : + # { + # "_id" : "admin.oplog_reader", + # "user" : "oplog_reader", + # "db" : "admin", # <-- admin DB + # "roles" : [ + # { + # "role" : "read", + # "db" : "local" # <-- local DB + # } + # ] + # } + + def make_sure_roles_are_a_list_of_dict(roles, db_name): + output = list() + for role in roles: + if isinstance(role, (binary_type, text_type)): + new_role = {"role": role, "db": db_name} + output.append(new_role) + else: + output.append(role) + return output + + roles_as_list_of_dict = make_sure_roles_are_a_list_of_dict(roles, db_name) + uinfo_roles = uinfo.get('roles', []) + + if sorted(roles_as_list_of_dict, key=itemgetter('db')) == sorted(uinfo_roles, key=itemgetter('db')): + return False + return True + + +# ========================================= +# Module execution. +# + +def main(): + argument_spec = mongodb_common_argument_spec() + argument_spec.update( + database=dict(required=True, aliases=['db']), + name=dict(required=True, aliases=['user']), + password=dict(aliases=['pass'], no_log=True), + replica_set=dict(default=None), + roles=dict(default=None, type='list', elements='raw'), + state=dict(default='present', choices=['absent', 'present']), + update_password=dict(default="always", choices=["always", "on_create"], no_log=False), + create_for_localhost_exception=dict(default=None, type='path'), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + login_user = module.params['login_user'] + + # Certs don't have a password but we want this module behaviour + if module.params['login_database'] == '$external': + module.params['update_password'] = 'on_create' + + if not pymongo_found: + module.fail_json(msg=missing_required_lib('pymongo'), + exception=PYMONGO_IMP_ERR) + + create_for_localhost_exception = module.params['create_for_localhost_exception'] + b_create_for_localhost_exception = ( + to_bytes(create_for_localhost_exception, errors='surrogate_or_strict') + if create_for_localhost_exception is not None else None + ) + + db_name = module.params['database'] + user = module.params['name'] + password = module.params['password'] + roles = module.params['roles'] or [] + state = module.params['state'] + update_password = module.params['update_password'] + + try: + directConnection = False + if module.params['replica_set'] is None: + directConnection = True + client = get_mongodb_client(module, directConnection=directConnection) + client = mongo_auth(module, client, directConnection=directConnection) + except Exception as e: + module.fail_json(msg='Unable to connect to database: %s' % to_native(e)) + + if state == 'present': + if password is None and update_password == 'always': + module.fail_json(msg='password parameter required when adding a user unless update_password is set to on_create') + + if login_user is None and create_for_localhost_exception is not None: + if os.path.exists(b_create_for_localhost_exception): + try: + client.close() + except Exception: + pass + module.exit_json(changed=False, user=user, skipped=True, msg="The path in create_for_localhost_exception exists.") + + try: + if update_password != 'always': + uinfo = user_find(client, user, db_name) + if uinfo: + password = None + if not check_if_roles_changed(uinfo, roles, db_name): + module.exit_json(changed=False, user=user) + + if module.check_mode: + module.exit_json(changed=True, user=user) + user_add(module, client, db_name, user, password, roles) + except Exception as e: + module.fail_json(msg='Unable to add or update user: %s' % to_native(e), exception=traceback.format_exc()) + finally: + try: + client.close() + except Exception: + pass + # Here we can check password change if mongo provide a query for that : https://jira.mongodb.org/browse/SERVER-22848 + # newuinfo = user_find(client, user, db_name) + # if uinfo['role'] == newuinfo['role'] and CheckPasswordHere: + # module.exit_json(changed=False, user=user) + + if login_user is None and create_for_localhost_exception is not None: + # localhost exception applied. + try: + # touch the file + open(b_create_for_localhost_exception, 'wb').close() + except Exception as e: + module.fail_json( + changed=True, + msg='Added user but unable to touch create_for_localhost_exception file %s: %s' % (create_for_localhost_exception, to_native(e)), + exception=traceback.format_exc() + ) + + elif state == 'absent': + try: + user_remove(module, client, db_name, user) + except Exception as e: + module.fail_json(msg='Unable to remove user: %s' % to_native(e), exception=traceback.format_exc()) + finally: + try: + client.close() + except Exception: + pass + module.exit_json(changed=True, user=user) + + +if __name__ == '__main__': + main() |