ansible-role-mongodb/library/mongodb_replication.py

467 lines
18 KiB
Python

#!/usr/bin/env python3
# (c) 2015-2018, Sergei Antipov, 2GIS LLC
#
# 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/>.
DOCUMENTATION = '''
---
module: mongodb_replication
short_description: Adds or removes a node from a MongoDB Replica Set.
description:
- Adds or removes host from a MongoDB replica set. Initialize replica set if it needed.
version_added: "2.4"
options:
login_user:
description:
- The username used to authenticate with
required: false
default: null
login_password:
description:
- The password used to authenticate with
required: false
default: null
login_host:
description:
- The host running the database
required: false
default: localhost
login_port:
description:
- The port to connect to
required: false
default: 27017
login_database:
description:
- The database where login credentials are stored
required: false
default: admin
replica_set:
description:
- Replica set to connect to (automatically connects to primary for writes)
required: false
default: null
host_name:
description:
- The name of the host to add/remove from replica set
required: true
host_port:
description:
- The port of the host, which should be added/deleted from RS
required: true
default: null
host_type:
description:
- The type of the host in replica set
required: false
default: replica
choices: [ "replica", "arbiter" ]
ssl:
description:
- Whether to use an SSL connection when connecting to the database
default: False
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: false
default: "CERT_REQUIRED"
choices: ["CERT_REQUIRED", "CERT_OPTIONAL", "CERT_NONE"]
build_indexes:
description:
- Determines whether the mongod builds indexes on this member.
required: false
default: true
hidden:
description:
- When this value is true, the replica set hides this instance,
and does not include the member in the output of db.isMaster()
or isMaster
required: false
default: false
priority:
description:
- A number that indicates the relative eligibility of a member
to become a primary
required: false
default: 1.0
slave_delay:
description:
- The number of seconds behind the primary that this replica set
member should lag
required: false
default: 0
votes:
description:
- The number of votes a server will cast in a replica set election
default: 1
state:
state:
description:
- The replica set member state
required: false
default: present
choices: [ "present", "absent" ]
notes:
- Requires the pymongo Python package on the remote host, version 3.2+. It
can be installed using pip or the OS package manager. @see http://api.mongodb.org/python/current/installation.html
requirements: [ "pymongo" ]
author: "Sergei Antipov @UnderGreen"
'''
EXAMPLES = '''
# Add 'mongo1.dev:27017' host into replica set as replica (Replica will be initiated if it not exists)
- mongodb_replication: replica_set=replSet host_name=mongo1.dev host_port=27017 state=present
# Add 'mongo2.dev:30000' host into replica set as arbiter
- mongodb_replication: replica_set=replSet host_name=mongo2.dev host_port=30000 host_type=arbiter state=present
# Add 'mongo3.dev:27017' host into replica set as replica and authorization params
- mongodb_replication: replica_set=replSet login_host=mongo1.dev login_user=siteRootAdmin login_password=123456 host_name=mongo3.dev host_port=27017 state=present
# Add 'mongo4.dev:27017' host into replica set as replica via SSL
- mongodb_replication: replica_set=replSet host_name=mongo4.dev host_port=27017 ssl=True state=present
# Remove 'mongo4.dev:27017' host from the replica set
- mongodb_replication: replica_set=replSet host_name=mongo4.dev host_port=27017 state=absent
'''
RETURN = '''
host_name:
description: The name of the host to add/remove from replica set
returned: success
type: string
sample: "mongo3.dev"
host_port:
description: The port of the host, which should be added/deleted from RS
returned: success
type: int
sample: 27017
host_type:
description: The type of the host in replica set
returned: success
type: string
sample: "replica"
'''
import configparser
import ssl as ssl_lib
import time
from datetime import datetime as dtdatetime
from distutils.version import LooseVersion
try:
from pymongo.errors import ConnectionFailure
from pymongo.errors import OperationFailure
from pymongo.errors import ConfigurationError
from pymongo.errors import AutoReconnect
from pymongo.errors import ServerSelectionTimeoutError
from pymongo import version as PyMongoVersion
from pymongo import MongoClient
except ImportError:
pymongo_found = False
else:
pymongo_found = True
# =========================================
# MongoDB module specific support methods.
#
def check_compatibility(module, client):
srv_info = client.server_info()
if LooseVersion(PyMongoVersion) <= LooseVersion('3.2'):
module.fail_json(msg='Note: you must use pymongo 3.2+')
if LooseVersion(srv_info['version']) >= LooseVersion('3.4') and LooseVersion(PyMongoVersion) <= LooseVersion('3.4'):
module.fail_json(msg='Note: you must use pymongo 3.4+ with MongoDB 3.4.x')
if LooseVersion(srv_info['version']) >= LooseVersion('3.6') and LooseVersion(PyMongoVersion) <= LooseVersion('3.6'):
module.fail_json(msg='Note: you must use pymongo 3.6+ with MongoDB 3.6.x')
def check_members(state, module, client, host_name, host_port, host_type):
admin_db = client['admin']
local_db = client['local']
if local_db.system.replset.count() > 1:
module.fail_json(msg='local.system.replset has unexpected contents')
cfg = local_db.system.replset.find_one()
if not cfg:
module.fail_json(msg='no config object retrievable from local.system.replset')
for member in cfg['members']:
if state == 'present':
if host_type == 'replica':
if "{0}:{1}".format(host_name, host_port) in member['host']:
module.exit_json(changed=False, host_name=host_name, host_port=host_port, host_type=host_type)
else:
if "{0}:{1}".format(host_name, host_port) in member['host'] and member['arbiterOnly']:
module.exit_json(changed=False, host_name=host_name, host_port=host_port, host_type=host_type)
else:
if host_type == 'replica':
if "{0}:{1}".format(host_name, host_port) not in member['host']:
module.exit_json(changed=False, host_name=host_name, host_port=host_port, host_type=host_type)
else:
if "{0}:{1}".format(host_name, host_port) not in member['host'] and member['arbiterOnly']:
module.exit_json(changed=False, host_name=host_name, host_port=host_port, host_type=host_type)
def add_host(module, client, host_name, host_port, host_type, timeout=180, **kwargs):
start_time = dtdatetime.now()
while True:
try:
admin_db = client['admin']
local_db = client['local']
if local_db.system.replset.count() > 1:
module.fail_json(msg='local.system.replset has unexpected contents')
cfg = local_db.system.replset.find_one()
if not cfg:
module.fail_json(msg='no config object retrievable from local.system.replset')
cfg['version'] += 1
max_id = max(cfg['members'], key=lambda x:x['_id'])
new_host = { '_id': max_id['_id'] + 1, 'host': "{0}:{1}".format(host_name, host_port) }
if host_type == 'arbiter':
new_host['arbiterOnly'] = True
if not kwargs['build_indexes']:
new_host['buildIndexes'] = False
if kwargs['hidden']:
new_host['hidden'] = True
if kwargs['priority'] != 1.0:
new_host['priority'] = kwargs['priority']
if kwargs['slave_delay'] != 0:
new_host['slaveDelay'] = kwargs['slave_delay']
if kwargs['votes'] != 1:
new_host['votes'] = kwargs['votes']
cfg['members'].append(new_host)
admin_db.command('replSetReconfig', cfg)
return
except (OperationFailure, AutoReconnect) as e:
if (dtdatetime.now() - start_time).seconds > timeout:
module.fail_json(msg='reached timeout while waiting for rs.reconfig(): %s' % str(e))
time.sleep(5)
def remove_host(module, client, host_name, timeout=180):
start_time = dtdatetime.now()
while True:
try:
admin_db = client['admin']
local_db = client['local']
if local_db.system.replset.count() > 1:
module.fail_json(msg='local.system.replset has unexpected contents')
cfg = local_db.system.replset.find_one()
if not cfg:
module.fail_json(msg='no config object retrievable from local.system.replset')
cfg['version'] += 1
if len(cfg['members']) == 1:
module.fail_json(msg="You can't delete last member of replica set")
for member in cfg['members']:
if host_name in member['host']:
cfg['members'].remove(member)
else:
fail_msg = "couldn't find member with hostname: {0} in replica set members list".format(host_name)
module.fail_json(msg=fail_msg)
except (OperationFailure, AutoReconnect) as e:
if (dtdatetime.now() - start_time).seconds > timeout:
module.fail_json(msg='reached timeout while waiting for rs.reconfig(): %s' % str(e))
time.sleep(5)
def load_mongocnf():
config = configparser.RawConfigParser()
mongocnf = os.path.expanduser('~/.mongodb.cnf')
try:
config.readfp(open(mongocnf))
creds = dict(
user=config.get('client', 'user'),
password=config.get('client', 'pass')
)
except (configparser.NoOptionError, IOError):
return False
return creds
def wait_for_ok_and_master(module, connection_params, timeout = 180):
start_time = dtdatetime.now()
while True:
try:
client = MongoClient(**connection_params)
authenticate(client, connection_params["username"], connection_params["password"])
status = client.admin.command('replSetGetStatus', check=False)
if status['ok'] == 1 and status['myState'] == 1:
return
except ServerSelectionTimeoutError:
pass
client.close()
if (dtdatetime.now() - start_time).seconds > timeout:
module.fail_json(msg='reached timeout while waiting for rs.status() to become ok=1')
time.sleep(1)
def authenticate(client, login_user, login_password):
if login_user is None and login_password is None:
mongocnf_creds = load_mongocnf()
if mongocnf_creds is not False:
login_user = mongocnf_creds['user']
login_password = mongocnf_creds['password']
elif login_password is None and login_user is not None:
module.fail_json(msg='when supplying login arguments, both login_user and login_password must be provided')
if login_user is not None and login_password is not None:
client.admin.authenticate(login_user, login_password)
# =========================================
# Module execution.
#
def main():
module = AnsibleModule(
argument_spec = dict(
login_user=dict(default=None),
login_password=dict(default=None, no_log=True),
login_host=dict(default='localhost'),
login_port=dict(default='27017'),
login_database=dict(default="admin"),
replica_set=dict(default=None),
host_name=dict(default='localhost'),
host_port=dict(default='27017'),
host_type=dict(default='replica', choices=['replica','arbiter']),
ssl=dict(default=False, type='bool'),
ssl_cert_reqs=dict(default='CERT_REQUIRED', choices=['CERT_NONE', 'CERT_OPTIONAL', 'CERT_REQUIRED']),
build_indexes = dict(type='bool', default='yes'),
hidden = dict(type='bool', default='no'),
priority = dict(default='1.0'),
slave_delay = dict(type='int', default='0'),
votes = dict(type='int', default='1'),
state=dict(default='present', choices=['absent', 'present']),
)
)
if not pymongo_found:
module.fail_json(msg='the python pymongo (>= 3.2) module is required')
login_user = module.params['login_user']
login_password = module.params['login_password']
login_host = module.params['login_host']
login_port = module.params['login_port']
login_database = module.params['login_database']
replica_set = module.params['replica_set']
host_name = module.params['host_name']
host_port = module.params['host_port']
host_type = module.params['host_type']
ssl = module.params['ssl']
state = module.params['state']
priority = float(module.params['priority'])
replica_set_created = False
try:
if replica_set is None:
module.fail_json(msg='replica_set parameter is required')
else:
connection_params = {
"host": login_host,
"port": int(login_port),
"username": login_user,
"password": login_password,
"authsource": login_database,
"serverselectiontimeoutms": 5000,
"replicaset": replica_set,
}
if ssl:
connection_params["ssl"] = ssl
connection_params["ssl_cert_reqs"] = getattr(ssl_lib, module.params['ssl_cert_reqs'])
client = MongoClient(**connection_params)
authenticate(client, login_user, login_password)
client['admin'].command('replSetGetStatus')
except ServerSelectionTimeoutError:
try:
connection_params = {
"host": login_host,
"port": int(login_port),
"username": login_user,
"password": login_password,
"authsource": login_database,
"serverselectiontimeoutms": 10000,
}
if ssl:
connection_params["ssl"] = ssl
connection_params["ssl_cert_reqs"] = getattr(ssl_lib, module.params['ssl_cert_reqs'])
client = MongoClient(**connection_params)
authenticate(client, login_user, login_password)
if state == 'present':
new_host = { '_id': 0, 'host': "{0}:{1}".format(host_name, host_port) }
if priority != 1.0: new_host['priority'] = priority
config = { '_id': "{0}".format(replica_set), 'members': [new_host] }
client['admin'].command('replSetInitiate', config)
client.close()
wait_for_ok_and_master(module, connection_params)
replica_set_created = True
module.exit_json(changed=True, host_name=host_name, host_port=host_port, host_type=host_type)
except OperationFailure as e:
module.fail_json(msg='Unable to initiate replica set: %s' % str(e))
except ConnectionFailure as e:
module.fail_json(msg='unable to connect to database: %s' % str(e))
# reconnect again
client = MongoClient(**connection_params)
authenticate(client, login_user, login_password)
check_compatibility(module, client)
check_members(state, module, client, host_name, host_port, host_type)
if state == 'present':
if host_name is None and not replica_set_created:
module.fail_json(msg='host_name parameter required when adding new host into replica set')
try:
if not replica_set_created:
add_host(module, client, host_name, host_port, host_type,
build_indexes = module.params['build_indexes'],
hidden = module.params['hidden'],
priority = float(module.params['priority']),
slave_delay = module.params['slave_delay'],
votes = module.params['votes'])
except OperationFailure as e:
module.fail_json(msg='Unable to add new member to replica set: %s' % str(e))
elif state == 'absent':
try:
remove_host(module, client, host_name)
except OperationFailure as e:
module.fail_json(msg='Unable to remove member of replica set: %s' % str(e))
module.exit_json(changed=True, host_name=host_name, host_port=host_port, host_type=host_type)
# import module snippets
from ansible.module_utils.basic import *
main()