mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-05-10 11:11:29 -07:00
Updated utils to remove Avi SDK dependency and Avi 18.2.2 version update (#54894)
* Updated utils to remove Avi SDK dependency and Avi 18.2.2 version update * Fixed the python 3.x errors failing for avi_disable_session_cache_as_fact not properly documented * Updated version added fields for new parameters * fixed pep8 errors * made requests import optional * removed setting requests to None * Added try catch for the avi helper methods such that any import fails then module fail gracefully. This was needed to pass the requests library not found error * removed deprecated modules. Also, trying another fix to deal with requests import error * Fixed python3 errors * fixed pep8, no-dict-iteritems and import test failures * added version 2.8 for new field * some more code cleanup and formatting * updated the fail message and fixed plint errors * added workaround for unicode pylint * fixed the version added for new parameter app_learning_memory_percent and removed unicode_literals import * Removed check of HAS_AVI for common argument spec * Updated version added value from 2.8 to 2.9 * Version added value fixes of CI error
This commit is contained in:
parent
63e33f7e71
commit
b5935486da
72 changed files with 2906 additions and 980 deletions
556
lib/ansible/module_utils/network/avi/ansible_utils.py
Normal file
556
lib/ansible/module_utils/network/avi/ansible_utils.py
Normal file
|
@ -0,0 +1,556 @@
|
|||
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
|
||||
|
||||
try:
|
||||
from ansible.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
|
||||
"""
|
||||
return dict(
|
||||
controller=dict(default=os.environ.get('AVI_CONTROLLER', '')),
|
||||
username=dict(default=os.environ.get('AVI_USERNAME', '')),
|
||||
password=dict(default=os.environ.get('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, no_log=True, type='dict'),
|
||||
api_context=dict(type='dict'),
|
||||
avi_disable_session_cache_as_fact=dict(default=False, type='bool'))
|
Loading…
Add table
Add a link
Reference in a new issue