1#!/usr/local/bin/python3.8
2
3# Copyright: (c) 2018, Rhys Campbell <rhys.james.campbell@googlemail.com>
4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5
6from __future__ import absolute_import, division, print_function
7__metaclass__ = type
8
9
10DOCUMENTATION = r'''
11---
12module: mongodb_status
13short_description: Validates the status of the replicaset.
14description:
15  - Validates the status of the replicaset.
16  - The module expects all replicaset nodes to be PRIMARY, SECONDARY or ARBITER.
17  - Will wait until a timeout for the replicaset state to converge if required.
18  - Can also be used to lookup the current PRIMARY member (see examples).
19author: Rhys Campbell (@rhysmeister)
20version_added: "1.0.0"
21
22extends_documentation_fragment:
23  - community.mongodb.login_options
24  - community.mongodb.ssl_options
25
26options:
27  replica_set:
28    description:
29    - Replicaset name.
30    type: str
31    default: rs0
32  poll:
33    description:
34      - The maximum number of times to query for the replicaset status before the set converges or we fail.
35    type: int
36    default: 1
37  interval:
38    description:
39      - The number of seconds to wait between polling executions.
40    type: int
41    default: 30
42  validate:
43    description:
44      - The type of validate to perform on the replicaset.
45      - default, Suitable for most purposes. Validate that there are an odd
46        number of servers and one is PRIMARY and the remainder are in a SECONDARY
47        or ARBITER state.
48      - votes, Check the number of votes is odd and one is a PRIMARY and the
49        remainder are in a SECONDARY or ARBITER state. Authentication is
50        required here to get the replicaset configuration.
51      - minimal, Just checks that one server is in a PRIMARY state with the
52         remainder being SECONDARY or ARBITER.
53    type: str
54    choices:
55       - default
56       - votes
57       - minimal
58    default: default
59notes:
60- Requires the pymongo Python package on the remote host, version 2.4.2+. This
61  can be installed using pip or the OS package manager. @see U(http://api.mongodb.org/python/current/installation.html)
62requirements:
63- pymongo
64'''
65
66EXAMPLES = r'''
67- name: Check replicaset is healthy, fail if not after first attempt
68  community.mongodb.mongodb_status:
69    replica_set: rs0
70  when: ansible_hostname == "mongodb1"
71
72- name: Wait for the replicaset rs0 to converge, check 5 times, 10 second interval between checks
73  community.mongodb.mongodb_status:
74    replica_set: rs0
75    poll: 5
76    interval: 10
77  when: ansible_hostname == "mongodb1"
78
79# Get the replicaset status and then lookup the primary's hostname and save to a variable
80- name: Ensure replicaset is stable before beginning
81  community.mongodb.mongodb_status:
82    login_user: "{{ admin_user }}"
83    login_password: "{{ admin_user_password }}"
84    poll: 3
85    interval: 10
86  register: rs
87
88- name: Lookup PRIMARY replicaset member
89  set_fact:
90    primary: "{{ item.key.split('.')[0] }}"
91  loop: "{{ lookup('dict', rs.replicaset) }}"
92  when: "'PRIMARY' in item.value"
93'''
94
95RETURN = r'''
96failed:
97  description: If the module has failed or not.
98  returned: always
99  type: bool
100iterations:
101  description: Number of times the module has queried the replicaset status.
102  returned: always
103  type: int
104msg:
105  description: Status message.
106  returned: always
107  type: str
108replicaset:
109  description: The last queried status of all the members of the replicaset if obtainable.
110  returned: always
111  type: dict
112'''
113
114
115from copy import deepcopy
116import time
117
118import os
119import ssl as ssl_lib
120from distutils.version import LooseVersion
121import traceback
122
123
124from ansible.module_utils.basic import AnsibleModule
125from ansible.module_utils.six import binary_type, text_type
126from ansible.module_utils.six.moves import configparser
127from ansible.module_utils._text import to_native
128from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import (
129    check_compatibility,
130    missing_required_lib,
131    load_mongocnf,
132    mongodb_common_argument_spec,
133    ssl_connection_options
134)
135from ansible_collections.community.mongodb.plugins.module_utils.mongodb_common import PyMongoVersion, PYMONGO_IMP_ERR, pymongo_found, MongoClient
136
137
138def replicaset_config(client):
139    """
140    Return the replicaset config document
141    https://docs.mongodb.com/manual/reference/command/replSetGetConfig/
142    """
143    rs = client.admin.command('replSetGetConfig')
144    return rs
145
146
147def replicaset_votes(config_document):
148    """
149    Return the number of votes in the replicaset
150    """
151    votes = 0
152    for member in config_document["config"]['members']:
153        votes += member['votes']
154    return votes
155
156
157def replicaset_status(client, module):
158    """
159    Return the replicaset status document from MongoDB
160    # https://docs.mongodb.com/manual/reference/command/replSetGetStatus/
161    """
162    rs = client.admin.command('replSetGetStatus')
163    return rs
164
165
166def replicaset_members(replicaset_document):
167    """
168    Returns the members section of the MongoDB replicaset document
169    """
170    return replicaset_document["members"]
171
172
173def replicaset_friendly_document(members_document):
174    """
175    Returns a version of the members document with
176    only the info this module requires: name & stateStr
177    """
178    friendly_document = {}
179
180    for member in members_document:
181        friendly_document[member["name"]] = member["stateStr"]
182    return friendly_document
183
184
185def replicaset_statuses(members_document, module):
186    """
187    Return a list of the statuses
188    """
189    statuses = []
190    for member in members_document:
191        statuses.append(members_document[member])
192    return statuses
193
194
195def replicaset_good(statuses, module, votes):
196    """
197    Returns true if the replicaset is in a "good" condition.
198    Good is defined as an odd number of servers >= 3, with
199    max one primary, and any even amount of
200    secondary and arbiter servers
201    """
202    msg = "Unset"
203    status = None
204    valid_statuses = ["PRIMARY", "SECONDARY", "ARBITER"]
205    validate = module.params['validate']
206
207    if validate == "default":
208        if len(statuses) % 2 == 1:
209            if (statuses.count("PRIMARY") == 1
210                and ((statuses.count("SECONDARY")
211                      + statuses.count("ARBITER")) % 2 == 0)
212                    and len(set(statuses) - set(valid_statuses)) == 0):
213                status = True
214                msg = "replicaset is in a converged state"
215            else:
216                status = False
217                msg = "replicaset is not currently in a converged state"
218        else:
219            msg = "Even number of servers in replicaset."
220            status = False
221    elif validate == "votes":
222        # Need to validate the number of votes in the replicaset
223        if votes % 2 == 1:  # We have a good number of votes
224            if (statuses.count("PRIMARY") == 1
225                    and len(set(statuses) - set(valid_statuses)) == 0):
226                status = True
227                msg = "replicaset is in a converged state"
228            else:
229                status = False
230                msg = "replicaset is not currently in a converged state"
231        else:
232            msg = "Even number of votes in replicaset."
233            status = False
234    elif validate == "minimal":
235        if (statuses.count("PRIMARY") == 1
236                and len(set(statuses) - set(valid_statuses)) == 0):
237            status = True
238            msg = "replicaset is in a converged state"
239        else:
240            status = False
241            msg = "replicaset is not currently in a converged state"
242    else:
243        module.fail_json(msg="Invalid value for validate has been provided: {0}".format(validate))
244    return status, msg
245
246
247def replicaset_status_poll(client, module):
248    """
249    client - MongoDB Client
250    poll - Number of times to poll
251    interval - interval between polling attempts
252    """
253    iterations = 0  # How many times we have queried the cluster
254    failures = 0  # Number of failures when querying the replicaset
255    poll = module.params['poll']
256    interval = module.params['interval']
257    status = None
258    return_doc = {}
259    votes = None
260    config = None
261
262    while iterations < poll:
263        try:
264            iterations += 1
265            replicaset_document = replicaset_status(client, module)
266            members = replicaset_members(replicaset_document)
267            friendly_document = replicaset_friendly_document(members)
268            statuses = replicaset_statuses(friendly_document, module)
269
270            if module.params['validate'] == "votes":  # Requires auth
271                config = replicaset_config(client)
272                votes = replicaset_votes(config)
273
274            status, msg = replicaset_good(statuses, module, votes)
275
276            if status:  # replicaset looks good
277                return_doc = {"failures": failures,
278                              "poll": poll,
279                              "iterations": iterations,
280                              "msg": msg,
281                              "replicaset": friendly_document}
282                break
283            else:
284                failures += 1
285                return_doc = {"failures": failures,
286                              "poll": poll,
287                              "iterations": iterations,
288                              "msg": msg,
289                              "replicaset": friendly_document,
290                              "failed": True}
291                if iterations == poll:
292                    break
293                else:
294                    time.sleep(interval)
295        except Exception as e:
296            failures += 1
297            return_doc['failed'] = True
298            return_doc['msg'] = str(e)
299            status = False
300            if iterations == poll:
301                break
302            else:
303                time.sleep(interval)
304
305    return_doc['failures'] = failures
306    return status, return_doc['msg'], return_doc
307
308
309# =========================================
310# Module execution.
311#
312
313
314def main():
315    argument_spec = mongodb_common_argument_spec()
316    argument_spec.update(
317        interval=dict(type='int', default=30),
318        poll=dict(type='int', default=1),
319        replica_set=dict(type='str', default="rs0"),
320        validate=dict(type='str', choices=['default', 'votes', 'minimal'], default='default'),
321    )
322    module = AnsibleModule(
323        argument_spec=argument_spec,
324        supports_check_mode=False,
325        required_together=[['login_user', 'login_password']],
326    )
327    if not pymongo_found:
328        module.fail_json(msg=missing_required_lib('pymongo'),
329                         exception=PYMONGO_IMP_ERR)
330
331    login_user = module.params['login_user']
332    login_password = module.params['login_password']
333    login_database = module.params['login_database']
334    login_host = module.params['login_host']
335    login_port = module.params['login_port']
336    replica_set = module.params['replica_set']
337    ssl = module.params['ssl']
338    poll = module.params['poll']
339    interval = module.params['interval']
340
341    result = dict(
342        failed=False,
343        replica_set=replica_set,
344    )
345
346    connection_params = dict(
347        host=login_host,
348        port=int(login_port),
349    )
350
351    if ssl:
352        connection_params = ssl_connection_options(connection_params, module)
353
354    try:
355        client = MongoClient(**connection_params)
356    except Exception as e:
357        module.fail_json(msg='Unable to connect to database: %s' % to_native(e))
358
359    try:
360        # Get server version:
361        try:
362            srv_version = LooseVersion(client.server_info()['version'])
363        except Exception as e:
364            module.fail_json(msg='Unable to get MongoDB server version: %s' % to_native(e))
365
366        # Get driver version::
367        driver_version = LooseVersion(PyMongoVersion)
368
369        # Check driver and server version compatibility:
370        check_compatibility(module, srv_version, driver_version)
371    except Exception as excep:
372        if hasattr(excep, 'code') and excep.code == 13:
373            raise excep
374        if login_user is None or login_password is None:
375            raise excep
376        client.admin.authenticate(login_user, login_password, source=login_database)
377        check_compatibility(module, client)
378
379    if login_user is None and login_password is None:
380        mongocnf_creds = load_mongocnf()
381        if mongocnf_creds is not False:
382            login_user = mongocnf_creds['user']
383            login_password = mongocnf_creds['password']
384    elif login_password is None or login_user is None:
385        module.fail_json(msg="When supplying login arguments, both 'login_user' and 'login_password' must be provided")
386
387    try:
388        client['admin'].command('listDatabases', 1.0)  # if this throws an error we need to authenticate
389    except Exception as excep:
390        if "not authorized on" in str(excep) or "command listDatabases requires authentication" in str(excep):
391            if login_user is not None and login_password is not None:
392                try:
393                    client.admin.authenticate(login_user, login_password, source=login_database)
394                except Exception as excep:
395                    module.fail_json(msg='unable to connect to database: %s' % to_native(excep), exception=traceback.format_exc())
396            else:
397                module.fail_json(msg='unable to connect to database: %s' % to_native(excep), exception=traceback.format_exc())
398        else:
399            module.fail_json(msg='unable to connect to database: %s' % to_native(excep), exception=traceback.format_exc())
400
401    if len(replica_set) == 0:
402        module.fail_json(msg="Parameter 'replica_set' must not be an empty string")
403
404    try:
405        status, msg, return_doc = replicaset_status_poll(client, module)  # Sort out the return doc
406        replicaset = return_doc['replicaset']
407        iterations = return_doc['iterations']
408    except Exception as e:
409        module.fail_json(msg='Unable to query replica_set info: {0}: {1}'.format(str(e), msg))
410
411    if status is False:
412        module.fail_json(msg=msg, replicaset=replicaset, iterations=iterations)
413    else:
414        module.exit_json(msg=msg, replicaset=replicaset, iterations=iterations)
415
416
417if __name__ == '__main__':
418    main()
419