#!/usr/bin/python # 2020 Rhys Campbell # 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: - mongo or 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 currently uses the mongo shell by default. This will change to mongosh in an upcoming version and support for mongo will be dropped extends_documentation_fragment: - community.mongodb.login_options options: mongo_cmd: description: - The MongoDB shell command. type: str default: "mongo" 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() (mongo) or EJSON.stringify() (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 .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 re import json import os import shlex import pipes __metaclass__ = type from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import ( mongodb_common_argument_spec ) 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. WriteResult({ "nInserted" : 0, "writeError" : { "code" : 11000, "errmsg" : "E11000 duplicate key error collection: state.hosts index: _id_ dup key: { _id: \"r1\" }" } }) """ 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("\"", '\\\"') tmp = tmp.replace('\n', '') tmp = tmp.replace('\t', '') if tmp is not None: output = tmp # elif re.match(r"^[a-zA-Z].*", output): # first_bracket = output.find('{') # last_bracket = output.rfind('}') # tmp = output[first_bracket:last_bracket + 1] # 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.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 main(): argument_spec = mongodb_common_argument_spec(ssl_options=False) argument_spec.update( mongo_cmd=dict(type='str', default="mongo"), 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=True, required_together=[['login_user', 'login_password']], mutually_exclusive=[["eval", "file"]] ) 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 , 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()