-
Notifications
You must be signed in to change notification settings - Fork 23.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New modules and updated HTTP API plugin for FTD devices #44578
Merged
rcarrillocruz
merged 10 commits into
ansible:devel
from
annikulin:ftd_plugin_and_modules
Aug 29, 2018
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
cbbc190
Add common and Swagger client utils for FTD modules
3d90ae8
Update FTD HTTP API plugin and add unit tests for it
517028b
Add configuration layer handling object idempotency
4a3293a
Add ftd_configuration module with unit tests
0fd4ae5
Add ftd_file_download and ftd_file_upload modules with unit tests
a651cfa
Validate operation data and parameters
fd685f7
Fix ansible-doc, boilerplate and import errors
cc8f0dc
Fix pip8 sanity errors
b5dc0fc
Update object comparison to work recursively
c775ec1
Add copyright
File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
# Copyright (c) 2018 Cisco and/or its affiliates. | ||
# | ||
# 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/>. | ||
# | ||
|
||
import re | ||
|
||
INVALID_IDENTIFIER_SYMBOLS = r'[^a-zA-Z0-9_]' | ||
|
||
IDENTITY_PROPERTIES = ['id', 'version', 'ruleId'] | ||
NON_COMPARABLE_PROPERTIES = IDENTITY_PROPERTIES + ['isSystemDefined', 'links'] | ||
|
||
|
||
class HTTPMethod: | ||
GET = 'get' | ||
POST = 'post' | ||
PUT = 'put' | ||
DELETE = 'delete' | ||
|
||
|
||
class ResponseParams: | ||
SUCCESS = 'success' | ||
STATUS_CODE = 'status_code' | ||
RESPONSE = 'response' | ||
|
||
|
||
class FtdConfigurationError(Exception): | ||
pass | ||
|
||
|
||
class FtdServerError(Exception): | ||
def __init__(self, response, code): | ||
super(FtdServerError, self).__init__(response) | ||
self.response = response | ||
self.code = code | ||
|
||
|
||
def construct_ansible_facts(response, params): | ||
facts = dict() | ||
if response: | ||
response_body = response['items'] if 'items' in response else response | ||
if params.get('register_as'): | ||
facts[params['register_as']] = response_body | ||
elif 'name' in response_body and 'type' in response_body: | ||
object_name = re.sub(INVALID_IDENTIFIER_SYMBOLS, '_', response_body['name'].lower()) | ||
fact_name = '%s_%s' % (response_body['type'], object_name) | ||
facts[fact_name] = response_body | ||
return facts | ||
|
||
|
||
def copy_identity_properties(source_obj, dest_obj): | ||
for property_name in IDENTITY_PROPERTIES: | ||
if property_name in source_obj: | ||
dest_obj[property_name] = source_obj[property_name] | ||
return dest_obj | ||
|
||
|
||
def is_object_ref(d): | ||
""" | ||
Checks if a dictionary is a reference object. The dictionary is considered to be a | ||
reference object when it contains non-empty 'id' and 'type' fields. | ||
|
||
:type d: dict | ||
:return: True if passed dictionary is a reference object, otherwise False | ||
""" | ||
has_id = 'id' in d.keys() and d['id'] | ||
has_type = 'type' in d.keys() and d['type'] | ||
return has_id and has_type | ||
|
||
|
||
def equal_object_refs(d1, d2): | ||
""" | ||
Checks whether two references point to the same object. | ||
|
||
:type d1: dict | ||
:type d2: dict | ||
:return: True if passed references point to the same object, otherwise False | ||
""" | ||
have_equal_ids = d1['id'] == d2['id'] | ||
have_equal_types = d1['type'] == d2['type'] | ||
return have_equal_ids and have_equal_types | ||
|
||
|
||
def equal_lists(l1, l2): | ||
""" | ||
Checks whether two lists are equal. The order of elements in the arrays is important. | ||
|
||
:type l1: list | ||
:type l2: list | ||
:return: True if passed lists, their elements and order of elements are equal. Otherwise, returns False. | ||
""" | ||
if len(l1) != len(l2): | ||
return False | ||
|
||
for v1, v2 in zip(l1, l2): | ||
if not equal_values(v1, v2): | ||
return False | ||
|
||
return True | ||
|
||
|
||
def equal_dicts(d1, d2, compare_by_reference=True): | ||
""" | ||
Checks whether two dictionaries are equal. If `compare_by_reference` is set to True, dictionaries referencing | ||
objects are compared using `equal_object_refs` method. Otherwise, every key and value is checked. | ||
|
||
:type d1: dict | ||
:type d2: dict | ||
:param compare_by_reference: if True, dictionaries referencing objects are compared using `equal_object_refs` method | ||
:return: True if passed dicts are equal. Otherwise, returns False. | ||
""" | ||
if compare_by_reference and is_object_ref(d1) and is_object_ref(d2): | ||
return equal_object_refs(d1, d2) | ||
|
||
if len(d1) != len(d2): | ||
return False | ||
|
||
for key, v1 in d1.items(): | ||
if key not in d2: | ||
return False | ||
|
||
v2 = d2[key] | ||
if not equal_values(v1, v2): | ||
return False | ||
|
||
return True | ||
|
||
|
||
def equal_values(v1, v2): | ||
""" | ||
Checks whether types and content of two values are the same. In case of complex objects, the method might be | ||
called recursively. | ||
|
||
:param v1: first value | ||
:param v2: second value | ||
:return: True if types and content of passed values are equal. Otherwise, returns False. | ||
:rtype: bool | ||
""" | ||
if type(v1) != type(v2): | ||
return False | ||
value_type = type(v1) | ||
|
||
if value_type == list: | ||
return equal_lists(v1, v2) | ||
elif value_type == dict: | ||
return equal_dicts(v1, v2) | ||
else: | ||
return v1 == v2 | ||
|
||
|
||
def equal_objects(d1, d2): | ||
""" | ||
Checks whether two objects are equal. Ignores special object properties (e.g. 'id', 'version') and | ||
properties with None and empty values. In case properties contains a reference to the other object, | ||
only object identities (ids and types) are checked. | ||
|
||
:type d1: dict | ||
:type d2: dict | ||
:return: True if passed objects and their properties are equal. Otherwise, returns False. | ||
""" | ||
d1 = dict((k, d1[k]) for k in d1.keys() if k not in NON_COMPARABLE_PROPERTIES and d1[k]) | ||
d2 = dict((k, d2[k]) for k in d2.keys() if k not in NON_COMPARABLE_PROPERTIES and d2[k]) | ||
|
||
return equal_dicts(d1, d2, compare_by_reference=False) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
# Copyright (c) 2018 Cisco and/or its affiliates. | ||
# | ||
# 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/>. | ||
# | ||
|
||
from functools import partial | ||
|
||
from ansible.module_utils.network.ftd.common import HTTPMethod, equal_objects, copy_identity_properties, \ | ||
FtdConfigurationError, FtdServerError, ResponseParams | ||
|
||
DEFAULT_PAGE_SIZE = 10 | ||
DEFAULT_OFFSET = 0 | ||
|
||
UNPROCESSABLE_ENTITY_STATUS = 422 | ||
INVALID_UUID_ERROR_MESSAGE = "Validation failed due to an invalid UUID" | ||
DUPLICATE_NAME_ERROR_MESSAGE = "Validation failed due to a duplicate name" | ||
|
||
|
||
class BaseConfigurationResource(object): | ||
def __init__(self, conn): | ||
self._conn = conn | ||
self.config_changed = False | ||
|
||
def get_object_by_name(self, url_path, name, path_params=None): | ||
item_generator = iterate_over_pageable_resource( | ||
partial(self.send_request, url_path=url_path, http_method=HTTPMethod.GET, path_params=path_params), | ||
{'filter': 'name:%s' % name} | ||
) | ||
# not all endpoints support filtering so checking name explicitly | ||
return next((item for item in item_generator if item['name'] == name), None) | ||
|
||
def get_objects_by_filter(self, url_path, filters, path_params=None, query_params=None): | ||
def match_filters(obj): | ||
for k, v in filters.items(): | ||
if k not in obj or obj[k] != v: | ||
return False | ||
return True | ||
|
||
item_generator = iterate_over_pageable_resource( | ||
partial(self.send_request, url_path=url_path, http_method=HTTPMethod.GET, path_params=path_params), | ||
query_params | ||
) | ||
return [i for i in item_generator if match_filters(i)] | ||
|
||
def add_object(self, url_path, body_params, path_params=None, query_params=None, update_if_exists=False): | ||
def is_duplicate_name_error(err): | ||
return err.code == UNPROCESSABLE_ENTITY_STATUS and DUPLICATE_NAME_ERROR_MESSAGE in str(err) | ||
|
||
def update_existing_object(obj): | ||
new_path_params = {} if path_params is None else path_params | ||
new_path_params['objId'] = obj['id'] | ||
return self.send_request(url_path=url_path + '/{objId}', | ||
http_method=HTTPMethod.PUT, | ||
body_params=copy_identity_properties(obj, body_params), | ||
path_params=new_path_params, | ||
query_params=query_params) | ||
|
||
try: | ||
return self.send_request(url_path=url_path, http_method=HTTPMethod.POST, body_params=body_params, | ||
path_params=path_params, query_params=query_params) | ||
except FtdServerError as e: | ||
if is_duplicate_name_error(e): | ||
existing_obj = self.get_object_by_name(url_path, body_params['name'], path_params) | ||
if equal_objects(existing_obj, body_params): | ||
return existing_obj | ||
elif update_if_exists: | ||
return update_existing_object(existing_obj) | ||
else: | ||
raise FtdConfigurationError( | ||
'Cannot add new object. An object with the same name but different parameters already exists.') | ||
else: | ||
raise e | ||
|
||
def delete_object(self, url_path, path_params): | ||
def is_invalid_uuid_error(err): | ||
return err.code == UNPROCESSABLE_ENTITY_STATUS and INVALID_UUID_ERROR_MESSAGE in str(err) | ||
|
||
try: | ||
return self.send_request(url_path=url_path, http_method=HTTPMethod.DELETE, path_params=path_params) | ||
except FtdServerError as e: | ||
if is_invalid_uuid_error(e): | ||
return {'status': 'Referenced object does not exist'} | ||
else: | ||
raise e | ||
|
||
def edit_object(self, url_path, body_params, path_params=None, query_params=None): | ||
existing_object = self.send_request(url_path=url_path, http_method=HTTPMethod.GET, path_params=path_params) | ||
|
||
if not existing_object: | ||
raise FtdConfigurationError('Referenced object does not exist') | ||
elif equal_objects(existing_object, body_params): | ||
return existing_object | ||
else: | ||
return self.send_request(url_path=url_path, http_method=HTTPMethod.PUT, body_params=body_params, | ||
path_params=path_params, query_params=query_params) | ||
|
||
def send_request(self, url_path, http_method, body_params=None, path_params=None, query_params=None): | ||
def raise_for_failure(resp): | ||
if not resp[ResponseParams.SUCCESS]: | ||
raise FtdServerError(resp[ResponseParams.RESPONSE], resp[ResponseParams.STATUS_CODE]) | ||
|
||
response = self._conn.send_request(url_path=url_path, http_method=http_method, body_params=body_params, | ||
path_params=path_params, query_params=query_params) | ||
raise_for_failure(response) | ||
if http_method != HTTPMethod.GET: | ||
self.config_changed = True | ||
return response[ResponseParams.RESPONSE] | ||
|
||
|
||
def iterate_over_pageable_resource(resource_func, query_params=None): | ||
""" | ||
A generator function that iterates over a resource that supports pagination and lazily returns present items | ||
one by one. | ||
|
||
:param resource_func: function that receives `query_params` named argument and returns a page of objects | ||
:type resource_func: callable | ||
:param query_params: initial dictionary of query parameters that will be passed to the resource_func | ||
:type query_params: dict | ||
:return: an iterator containing returned items | ||
:rtype: iterator of dict | ||
""" | ||
query_params = {} if query_params is None else dict(query_params) | ||
query_params.setdefault('limit', DEFAULT_PAGE_SIZE) | ||
query_params.setdefault('offset', DEFAULT_OFFSET) | ||
|
||
result = resource_func(query_params=query_params) | ||
while result['items']: | ||
for item in result['items']: | ||
yield item | ||
# creating a copy not to mutate existing dict | ||
query_params = dict(query_params) | ||
query_params['offset'] = int(query_params['offset']) + int(query_params['limit']) | ||
result = resource_func(query_params=query_params) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add copyright in all the files.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, added.