mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
572 lines
21 KiB
Python
572 lines
21 KiB
Python
from __future__ import absolute_import
|
|
|
|
"""
|
|
Created on Aug 16, 2016
|
|
|
|
@author: Gaurav Rastogi (grastogi@avinetworks.com)
|
|
"""
|
|
import os
|
|
import re
|
|
import logging
|
|
import sys
|
|
from copy import deepcopy
|
|
from ansible.module_utils.basic import env_fallback
|
|
|
|
try:
|
|
from ansible_collections.community.general.plugins.module_utils.network.avi.avi_api import (
|
|
ApiSession, ObjectNotFound, avi_sdk_syslog_logger, AviCredentials, HAS_AVI)
|
|
except ImportError:
|
|
HAS_AVI = False
|
|
|
|
|
|
if os.environ.get('AVI_LOG_HANDLER', '') != 'syslog':
|
|
log = logging.getLogger(__name__)
|
|
else:
|
|
# Ansible does not allow logging from the modules.
|
|
log = avi_sdk_syslog_logger()
|
|
|
|
|
|
def _check_type_string(x):
|
|
"""
|
|
:param x:
|
|
:return: True if it is of type string
|
|
"""
|
|
if isinstance(x, str):
|
|
return True
|
|
if sys.version_info[0] < 3:
|
|
try:
|
|
return isinstance(x, unicode)
|
|
except NameError:
|
|
return False
|
|
|
|
|
|
class AviCheckModeResponse(object):
|
|
"""
|
|
Class to support ansible check mode.
|
|
"""
|
|
|
|
def __init__(self, obj, status_code=200):
|
|
self.obj = obj
|
|
self.status_code = status_code
|
|
|
|
def json(self):
|
|
return self.obj
|
|
|
|
|
|
def ansible_return(module, rsp, changed, req=None, existing_obj=None,
|
|
api_context=None):
|
|
"""
|
|
:param module: AnsibleModule
|
|
:param rsp: ApiResponse from avi_api
|
|
:param changed: boolean
|
|
:param req: ApiRequest to avi_api
|
|
:param existing_obj: object to be passed debug output
|
|
:param api_context: api login context
|
|
|
|
helper function to return the right ansible based on the error code and
|
|
changed
|
|
Returns: specific ansible module exit function
|
|
"""
|
|
|
|
if rsp is not None and rsp.status_code > 299:
|
|
return module.fail_json(
|
|
msg='Error %d Msg %s req: %s api_context:%s ' % (
|
|
rsp.status_code, rsp.text, req, api_context))
|
|
api_creds = AviCredentials()
|
|
api_creds.update_from_ansible_module(module)
|
|
key = '%s:%s:%s' % (api_creds.controller, api_creds.username,
|
|
api_creds.port)
|
|
disable_fact = module.params.get('avi_disable_session_cache_as_fact')
|
|
|
|
fact_context = None
|
|
if not disable_fact:
|
|
fact_context = module.params.get('api_context', {})
|
|
if fact_context:
|
|
fact_context.update({key: api_context})
|
|
else:
|
|
fact_context = {key: api_context}
|
|
|
|
obj_val = rsp.json() if rsp else existing_obj
|
|
|
|
if (obj_val and module.params.get("obj_username", None) and
|
|
"username" in obj_val):
|
|
obj_val["obj_username"] = obj_val["username"]
|
|
if (obj_val and module.params.get("obj_password", None) and
|
|
"password" in obj_val):
|
|
obj_val["obj_password"] = obj_val["password"]
|
|
old_obj_val = existing_obj if changed and existing_obj else None
|
|
api_context_val = api_context if disable_fact else None
|
|
ansible_facts_val = dict(
|
|
avi_api_context=fact_context) if not disable_fact else {}
|
|
|
|
return module.exit_json(
|
|
changed=changed, obj=obj_val, old_obj=old_obj_val,
|
|
ansible_facts=ansible_facts_val, api_context=api_context_val)
|
|
|
|
|
|
def purge_optional_fields(obj, module):
|
|
"""
|
|
It purges the optional arguments to be sent to the controller.
|
|
:param obj: dictionary of the ansible object passed as argument.
|
|
:param module: AnsibleModule
|
|
return modified obj
|
|
"""
|
|
purge_fields = []
|
|
for param, spec in module.argument_spec.items():
|
|
if not spec.get('required', False):
|
|
if param not in obj:
|
|
# these are ansible common items
|
|
continue
|
|
if obj[param] is None:
|
|
purge_fields.append(param)
|
|
log.debug('purging fields %s', purge_fields)
|
|
for param in purge_fields:
|
|
obj.pop(param, None)
|
|
return obj
|
|
|
|
|
|
def cleanup_absent_fields(obj):
|
|
"""
|
|
cleans up any field that is marked as state: absent. It needs to be removed
|
|
from the object if it is present.
|
|
:param obj:
|
|
:return: Purged object
|
|
"""
|
|
if type(obj) != dict:
|
|
return obj
|
|
cleanup_keys = []
|
|
for k, v in obj.items():
|
|
if type(v) == dict:
|
|
if (('state' in v and v['state'] == 'absent') or
|
|
(v == "{'state': 'absent'}")):
|
|
cleanup_keys.append(k)
|
|
else:
|
|
cleanup_absent_fields(v)
|
|
if not v:
|
|
cleanup_keys.append(k)
|
|
elif type(v) == list:
|
|
new_list = []
|
|
for elem in v:
|
|
elem = cleanup_absent_fields(elem)
|
|
if elem:
|
|
# remove the item from list
|
|
new_list.append(elem)
|
|
if new_list:
|
|
obj[k] = new_list
|
|
else:
|
|
cleanup_keys.append(k)
|
|
elif isinstance(v, str) or isinstance(v, str):
|
|
if v == "{'state': 'absent'}":
|
|
cleanup_keys.append(k)
|
|
for k in cleanup_keys:
|
|
del obj[k]
|
|
return obj
|
|
|
|
|
|
RE_REF_MATCH = re.compile(r'^/api/[\w/]+\?name\=[\w]+[^#<>]*$')
|
|
# if HTTP ref match then strip out the #name
|
|
HTTP_REF_MATCH = re.compile(r'https://[\w.0-9:-]+/api/.+')
|
|
HTTP_REF_W_NAME_MATCH = re.compile(r'https://[\w.0-9:-]+/api/.*#.+')
|
|
|
|
|
|
def ref_n_str_cmp(x, y):
|
|
"""
|
|
compares two references
|
|
1. check for exact reference
|
|
2. check for obj_type/uuid
|
|
3. check for name
|
|
|
|
if x is ref=name then extract uuid and name from y and use it.
|
|
if x is http_ref then
|
|
strip x and y
|
|
compare them.
|
|
|
|
if x and y are urls then match with split on #
|
|
if x is a RE_REF_MATCH then extract name
|
|
if y is a REF_MATCH then extract name
|
|
:param x: first string
|
|
:param y: second string from controller's object
|
|
|
|
Returns
|
|
True if they are equivalent else False
|
|
"""
|
|
if type(y) in (int, float, bool, int, complex):
|
|
y = str(y)
|
|
x = str(x)
|
|
if not (_check_type_string(x) and _check_type_string(y)):
|
|
return False
|
|
y_uuid = y_name = str(y)
|
|
x = str(x)
|
|
if RE_REF_MATCH.match(x):
|
|
x = x.split('name=')[1]
|
|
elif HTTP_REF_MATCH.match(x):
|
|
x = x.rsplit('#', 1)[0]
|
|
y = y.rsplit('#', 1)[0]
|
|
elif RE_REF_MATCH.match(y):
|
|
y = y.split('name=')[1]
|
|
|
|
if HTTP_REF_W_NAME_MATCH.match(y):
|
|
path = y.split('api/', 1)[1]
|
|
# Fetching name or uuid from path /xxxx_xx/xx/xx_x/uuid_or_name
|
|
uuid_or_name = path.split('/')[-1]
|
|
parts = uuid_or_name.rsplit('#', 1)
|
|
y_uuid = parts[0]
|
|
y_name = parts[1] if len(parts) > 1 else ''
|
|
# is just string but y is a url so match either uuid or name
|
|
result = (x in (y, y_name, y_uuid))
|
|
if not result:
|
|
log.debug('x: %s y: %s y_name %s y_uuid %s',
|
|
x, y, y_name, y_uuid)
|
|
return result
|
|
|
|
|
|
def avi_obj_cmp(x, y, sensitive_fields=None):
|
|
"""
|
|
compares whether x is fully contained in y. The comparision is different
|
|
from a simple dictionary compare for following reasons
|
|
1. Some fields could be references. The object in controller returns the
|
|
full URL for those references. However, the ansible script would have
|
|
it specified as /api/pool?name=blah. So, the reference fields need
|
|
to match uuid, relative reference based on name and actual reference.
|
|
|
|
2. Optional fields with defaults: In case there are optional fields with
|
|
defaults then controller automatically fills it up. This would
|
|
cause the comparison with Ansible object specification to always return
|
|
changed.
|
|
|
|
3. Optional fields without defaults: This is most tricky. The issue is
|
|
how to specify deletion of such objects from ansible script. If the
|
|
ansible playbook has object specified as Null then Avi controller will
|
|
reject for non Message(dict) type fields. In addition, to deal with the
|
|
defaults=null issue all the fields that are set with None are purged
|
|
out before comparing with Avi controller's version
|
|
|
|
So, the solution is to pass state: absent if any optional field needs
|
|
to be deleted from the configuration. The script would return changed
|
|
=true if it finds a key in the controller version and it is marked with
|
|
state: absent in ansible playbook. Alternatively, it would return
|
|
false if key is not present in the controller object. Before, doing
|
|
put or post it would purge the fields that are marked state: absent.
|
|
|
|
:param x: first string
|
|
:param y: second string from controller's object
|
|
:param sensitive_fields: sensitive fields to ignore for diff
|
|
|
|
Returns:
|
|
True if x is subset of y else False
|
|
"""
|
|
if not sensitive_fields:
|
|
sensitive_fields = set()
|
|
if isinstance(x, str) or isinstance(x, str):
|
|
# Special handling for strings as they can be references.
|
|
return ref_n_str_cmp(x, y)
|
|
if type(x) not in [list, dict]:
|
|
# if it is not list or dict or string then simply compare the values
|
|
return x == y
|
|
if type(x) == list:
|
|
# should compare each item in the list and that should match
|
|
if len(x) != len(y):
|
|
log.debug('x has %d items y has %d', len(x), len(y))
|
|
return False
|
|
for i in zip(x, y):
|
|
if not avi_obj_cmp(i[0], i[1], sensitive_fields=sensitive_fields):
|
|
# no need to continue
|
|
return False
|
|
|
|
if type(x) == dict:
|
|
x.pop('_last_modified', None)
|
|
x.pop('tenant', None)
|
|
y.pop('_last_modified', None)
|
|
x.pop('api_version', None)
|
|
y.pop('api_verison', None)
|
|
d_xks = [k for k in x.keys() if k in sensitive_fields]
|
|
|
|
if d_xks:
|
|
# if there is sensitive field then always return changed
|
|
return False
|
|
# pop the keys that are marked deleted but not present in y
|
|
# return false if item is marked absent and is present in y
|
|
d_x_absent_ks = []
|
|
for k, v in x.items():
|
|
if v is None:
|
|
d_x_absent_ks.append(k)
|
|
continue
|
|
if isinstance(v, dict):
|
|
if ('state' in v) and (v['state'] == 'absent'):
|
|
if type(y) == dict and k not in y:
|
|
d_x_absent_ks.append(k)
|
|
else:
|
|
return False
|
|
elif not v:
|
|
d_x_absent_ks.append(k)
|
|
elif isinstance(v, list) and not v:
|
|
d_x_absent_ks.append(k)
|
|
# Added condition to check key in dict.
|
|
elif isinstance(v, str) or (k in y and isinstance(y[k], str)):
|
|
# this is the case when ansible converts the dictionary into a
|
|
# string.
|
|
if v == "{'state': 'absent'}" and k not in y:
|
|
d_x_absent_ks.append(k)
|
|
elif not v and k not in y:
|
|
# this is the case when x has set the value that qualifies
|
|
# as not but y does not have that value
|
|
d_x_absent_ks.append(k)
|
|
for k in d_x_absent_ks:
|
|
x.pop(k)
|
|
x_keys = set(x.keys())
|
|
y_keys = set(y.keys())
|
|
if not x_keys.issubset(y_keys):
|
|
# log.debug('x has %s and y has %s keys', len(x_keys), len(y_keys))
|
|
return False
|
|
for k, v in x.items():
|
|
if k not in y:
|
|
# log.debug('k %s is not in y %s', k, y)
|
|
return False
|
|
if not avi_obj_cmp(v, y[k], sensitive_fields=sensitive_fields):
|
|
# log.debug('k %s v %s did not match in y %s', k, v, y[k])
|
|
return False
|
|
return True
|
|
|
|
|
|
POP_FIELDS = ['state', 'controller', 'username', 'password', 'api_version',
|
|
'avi_credentials', 'avi_api_update_method', 'avi_api_patch_op',
|
|
'api_context', 'tenant', 'tenant_uuid', 'avi_disable_session_cache_as_fact']
|
|
|
|
|
|
def get_api_context(module, api_creds):
|
|
api_context = module.params.get('api_context')
|
|
if api_context and module.params.get('avi_disable_session_cache_as_fact'):
|
|
return api_context
|
|
elif api_context and not module.params.get(
|
|
'avi_disable_session_cache_as_fact'):
|
|
key = '%s:%s:%s' % (api_creds.controller, api_creds.username,
|
|
api_creds.port)
|
|
return api_context.get(key)
|
|
else:
|
|
return None
|
|
|
|
|
|
def avi_ansible_api(module, obj_type, sensitive_fields):
|
|
"""
|
|
This converts the Ansible module into AVI object and invokes APIs
|
|
:param module: Ansible module
|
|
:param obj_type: string representing Avi object type
|
|
:param sensitive_fields: sensitive fields to be excluded for comparison
|
|
purposes.
|
|
Returns:
|
|
success: module.exit_json with obj=avi object
|
|
faliure: module.fail_json
|
|
"""
|
|
|
|
api_creds = AviCredentials()
|
|
api_creds.update_from_ansible_module(module)
|
|
api_context = get_api_context(module, api_creds)
|
|
if api_context:
|
|
api = ApiSession.get_session(
|
|
api_creds.controller,
|
|
api_creds.username,
|
|
password=api_creds.password,
|
|
timeout=api_creds.timeout,
|
|
tenant=api_creds.tenant,
|
|
tenant_uuid=api_creds.tenant_uuid,
|
|
token=api_context['csrftoken'],
|
|
port=api_creds.port,
|
|
session_id=api_context['session_id'],
|
|
csrftoken=api_context['csrftoken'])
|
|
else:
|
|
api = ApiSession.get_session(
|
|
api_creds.controller,
|
|
api_creds.username,
|
|
password=api_creds.password,
|
|
timeout=api_creds.timeout,
|
|
tenant=api_creds.tenant,
|
|
tenant_uuid=api_creds.tenant_uuid,
|
|
token=api_creds.token,
|
|
port=api_creds.port)
|
|
state = module.params['state']
|
|
# Get the api version.
|
|
avi_update_method = module.params.get('avi_api_update_method', 'put')
|
|
avi_patch_op = module.params.get('avi_api_patch_op', 'add')
|
|
|
|
api_version = api_creds.api_version
|
|
name = module.params.get('name', None)
|
|
# Added Support to get uuid
|
|
uuid = module.params.get('uuid', None)
|
|
check_mode = module.check_mode
|
|
if uuid and obj_type != 'cluster':
|
|
obj_path = '%s/%s' % (obj_type, uuid)
|
|
else:
|
|
obj_path = '%s/' % obj_type
|
|
obj = deepcopy(module.params)
|
|
tenant = obj.pop('tenant', '')
|
|
tenant_uuid = obj.pop('tenant_uuid', '')
|
|
# obj.pop('cloud_ref', None)
|
|
for k in POP_FIELDS:
|
|
obj.pop(k, None)
|
|
purge_optional_fields(obj, module)
|
|
|
|
# Special code to handle situation where object has a field
|
|
# named username. This is used in case of api/user
|
|
# The following code copies the username and password
|
|
# from the obj_username and obj_password fields.
|
|
if 'obj_username' in obj:
|
|
obj['username'] = obj['obj_username']
|
|
obj.pop('obj_username')
|
|
if 'obj_password' in obj:
|
|
obj['password'] = obj['obj_password']
|
|
obj.pop('obj_password')
|
|
if 'full_name' not in obj and 'name' in obj and obj_type == "user":
|
|
obj['full_name'] = obj['name']
|
|
# Special case as name represent full_name in user module
|
|
# As per API response, name is always same as username regardless of full_name
|
|
obj['name'] = obj['username']
|
|
|
|
log.info('passed object %s ', obj)
|
|
|
|
if uuid:
|
|
# Get the object based on uuid.
|
|
try:
|
|
existing_obj = api.get(
|
|
obj_path, tenant=tenant, tenant_uuid=tenant_uuid,
|
|
params={'include_refs': '', 'include_name': ''},
|
|
api_version=api_version)
|
|
existing_obj = existing_obj.json()
|
|
except ObjectNotFound:
|
|
existing_obj = None
|
|
elif name:
|
|
params = {'include_refs': '', 'include_name': ''}
|
|
if obj.get('cloud_ref', None):
|
|
# this is the case when gets have to be scoped with cloud
|
|
cloud = obj['cloud_ref'].split('name=')[1]
|
|
params['cloud_ref.name'] = cloud
|
|
existing_obj = api.get_object_by_name(
|
|
obj_type, name, tenant=tenant, tenant_uuid=tenant_uuid,
|
|
params=params, api_version=api_version)
|
|
|
|
# Need to check if tenant_ref was provided and the object returned
|
|
# is actually in admin tenant.
|
|
if existing_obj and 'tenant_ref' in obj and 'tenant_ref' in existing_obj:
|
|
# https://10.10.25.42/api/tenant/admin#admin
|
|
existing_obj_tenant = existing_obj['tenant_ref'].split('#')[1]
|
|
obj_tenant = obj['tenant_ref'].split('name=')[1]
|
|
if obj_tenant != existing_obj_tenant:
|
|
existing_obj = None
|
|
else:
|
|
# added api version to avi api call.
|
|
existing_obj = api.get(obj_path, tenant=tenant, tenant_uuid=tenant_uuid,
|
|
params={'include_refs': '', 'include_name': ''},
|
|
api_version=api_version).json()
|
|
|
|
if state == 'absent':
|
|
rsp = None
|
|
changed = False
|
|
err = False
|
|
if not check_mode and existing_obj:
|
|
try:
|
|
if name is not None:
|
|
# added api version to avi api call.
|
|
rsp = api.delete_by_name(
|
|
obj_type, name, tenant=tenant, tenant_uuid=tenant_uuid,
|
|
api_version=api_version)
|
|
else:
|
|
# added api version to avi api call.
|
|
rsp = api.delete(
|
|
obj_path, tenant=tenant, tenant_uuid=tenant_uuid,
|
|
api_version=api_version)
|
|
except ObjectNotFound:
|
|
pass
|
|
if check_mode and existing_obj:
|
|
changed = True
|
|
|
|
if rsp:
|
|
if rsp.status_code == 204:
|
|
changed = True
|
|
else:
|
|
err = True
|
|
if not err:
|
|
return ansible_return(
|
|
module, rsp, changed, existing_obj=existing_obj,
|
|
api_context=api.get_context())
|
|
elif rsp:
|
|
return module.fail_json(msg=rsp.text)
|
|
|
|
rsp = None
|
|
req = None
|
|
if existing_obj:
|
|
# this is case of modify as object exists. should find out
|
|
# if changed is true or not
|
|
if name is not None and obj_type != 'cluster':
|
|
obj_uuid = existing_obj['uuid']
|
|
obj_path = '%s/%s' % (obj_type, obj_uuid)
|
|
if avi_update_method == 'put':
|
|
changed = not avi_obj_cmp(obj, existing_obj, sensitive_fields)
|
|
obj = cleanup_absent_fields(obj)
|
|
if changed:
|
|
req = obj
|
|
if check_mode:
|
|
# No need to process any further.
|
|
rsp = AviCheckModeResponse(obj=existing_obj)
|
|
else:
|
|
rsp = api.put(
|
|
obj_path, data=req, tenant=tenant,
|
|
tenant_uuid=tenant_uuid, api_version=api_version)
|
|
elif check_mode:
|
|
rsp = AviCheckModeResponse(obj=existing_obj)
|
|
else:
|
|
if check_mode:
|
|
# No need to process any further.
|
|
rsp = AviCheckModeResponse(obj=existing_obj)
|
|
changed = True
|
|
else:
|
|
obj.pop('name', None)
|
|
patch_data = {avi_patch_op: obj}
|
|
rsp = api.patch(
|
|
obj_path, data=patch_data, tenant=tenant,
|
|
tenant_uuid=tenant_uuid, api_version=api_version)
|
|
obj = rsp.json()
|
|
changed = not avi_obj_cmp(obj, existing_obj)
|
|
if changed:
|
|
log.debug('EXISTING OBJ %s', existing_obj)
|
|
log.debug('NEW OBJ %s', obj)
|
|
else:
|
|
changed = True
|
|
req = obj
|
|
if check_mode:
|
|
rsp = AviCheckModeResponse(obj=None)
|
|
else:
|
|
rsp = api.post(obj_type, data=obj, tenant=tenant,
|
|
tenant_uuid=tenant_uuid, api_version=api_version)
|
|
return ansible_return(module, rsp, changed, req, existing_obj=existing_obj,
|
|
api_context=api.get_context())
|
|
|
|
|
|
def avi_common_argument_spec():
|
|
"""
|
|
Returns common arguments for all Avi modules
|
|
:return: dict
|
|
"""
|
|
credentials_spec = dict(
|
|
controller=dict(fallback=(env_fallback, ['AVI_CONTROLLER'])),
|
|
username=dict(fallback=(env_fallback, ['AVI_USERNAME'])),
|
|
password=dict(fallback=(env_fallback, ['AVI_PASSWORD']), no_log=True),
|
|
api_version=dict(default='16.4.4', type='str'),
|
|
tenant=dict(default='admin'),
|
|
tenant_uuid=dict(default='', type='str'),
|
|
port=dict(type='int'),
|
|
timeout=dict(default=300, type='int'),
|
|
token=dict(default='', type='str', no_log=True),
|
|
session_id=dict(default='', type='str', no_log=True),
|
|
csrftoken=dict(default='', type='str', no_log=True)
|
|
)
|
|
|
|
return dict(
|
|
controller=dict(fallback=(env_fallback, ['AVI_CONTROLLER'])),
|
|
username=dict(fallback=(env_fallback, ['AVI_USERNAME'])),
|
|
password=dict(fallback=(env_fallback, ['AVI_PASSWORD']), no_log=True),
|
|
tenant=dict(default='admin'),
|
|
tenant_uuid=dict(default=''),
|
|
api_version=dict(default='16.4.4', type='str'),
|
|
avi_credentials=dict(default=None, type='dict',
|
|
options=credentials_spec),
|
|
api_context=dict(type='dict'),
|
|
avi_disable_session_cache_as_fact=dict(default=False, type='bool'))
|