From d5aabd02baedfc3afa0542941c2f04860cfef984 Mon Sep 17 00:00:00 2001 From: Nathaniel Case Date: Mon, 4 Mar 2019 08:27:18 -0500 Subject: [PATCH] restconf_config module (#51971) * Add restconf_config module * Try to do the right thing when given partial paths * Add PATCH * Delete should not require content * Non-JSON exceptions need raising, too * Let ConnectionError objects pass through exec_jsonrpc --- .../module_utils/network/common/utils.py | 4 +- .../network/restconf/restconf_config.py | 163 ++++++++++++++++++ lib/ansible/plugins/connection/httpapi.py | 2 + lib/ansible/plugins/httpapi/restconf.py | 34 ++-- lib/ansible/utils/jsonrpc.py | 5 + 5 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 lib/ansible/modules/network/restconf/restconf_config.py diff --git a/lib/ansible/module_utils/network/common/utils.py b/lib/ansible/module_utils/network/common/utils.py index 927c2f5fdb..76275b2618 100644 --- a/lib/ansible/module_utils/network/common/utils.py +++ b/lib/ansible/module_utils/network/common/utils.py @@ -264,7 +264,9 @@ def dict_diff(base, comparable): if isinstance(value, dict): item = comparable.get(key) if item is not None: - updates[key] = dict_diff(value, comparable[key]) + sub_diff = dict_diff(value, comparable[key]) + if sub_diff: + updates[key] = sub_diff else: comparable_value = comparable.get(key) if comparable_value is not None: diff --git a/lib/ansible/modules/network/restconf/restconf_config.py b/lib/ansible/modules/network/restconf/restconf_config.py new file mode 100644 index 0000000000..2dfd3fb0c1 --- /dev/null +++ b/lib/ansible/modules/network/restconf/restconf_config.py @@ -0,0 +1,163 @@ +#!/usr/bin/python +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'network'} + + +DOCUMENTATION = ''' +--- +module: restconf_config +version_added: "2.8" +author: "Ganesh Nalawade (@ganeshrn)" +short_description: Handles create, update, read and delete of configuration data on RESTCONF enabled devices. +description: + - RESTCONF is a standard mechanisms to allow web applications to configure and manage + data. RESTCONF is a IETF standard and documented on RFC 8040. + - This module allows the user to configure data on RESTCONF enabled devices. +options: + path: + description: + - URI being used to execute API calls. + required: true + content: + description: + - The configuration data in format as specififed in C(format) option. Required unless C(method) is + I(delete). + method: + description: + - The RESTCONF method to manage the configuration change on device. The value I(post) is used to + create a data resource or invoke an operation resource, I(put) is used to replace the target + data resource, I(patch) is used to modify the target resource, and I(delete) is used to delete + the target resource. + required: false + default: post + choices: ['post', 'put', 'patch', 'delete'] + format: + description: + - The format of the configuration provided as value of C(content). Accepted values are I(xml) and I(json) and + the given configuration format should be supported by remote RESTCONF server. + default: json + choices: ['json', 'xml'] +''' + +EXAMPLES = ''' +- name: create l3vpn services + restconf_config: + path: /config/ietf-l3vpn-svc:l3vpn-svc/vpn-services + content: | + { + "vpn-service":[ + { + "vpn-id": "red_vpn2", + "customer-name": "blue", + "vpn-service-topology": "ietf-l3vpn-svc:any-to-any" + }, + { + "vpn-id": "blue_vpn1", + "customer-name": "red", + "vpn-service-topology": "ietf-l3vpn-svc:any-to-any" + } + ] + } +''' + +RETURN = ''' +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_text +from ansible.module_utils.connection import ConnectionError +from ansible.module_utils.network.common.utils import dict_diff +from ansible.module_utils.network.restconf import restconf +from ansible.module_utils.six import string_types + + +def main(): + """entry point for module execution + """ + argument_spec = dict( + path=dict(required=True), + content=dict(), + method=dict(choices=['post', 'put', 'patch', 'delete'], default='post'), + format=dict(choices=['json', 'xml'], default='json'), + ) + required_if = [ + ['method', 'post', ['content']], + ['method', 'put', ['content']], + ['method', 'patch', ['content']], + ] + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=required_if, + supports_check_mode=True + ) + + path = module.params['path'] + candidate = module.params['content'] + method = module.params['method'] + format = module.params['format'] + + if isinstance(candidate, string_types): + candidate = json.loads(candidate) + + warnings = list() + result = {'changed': False, 'warnings': warnings} + + running = None + response = None + commit = not module.check_mode + try: + running = restconf.get(module, path, output=format) + except ConnectionError as exc: + if exc.code == 404: + running = None + else: + module.fail_json(msg=to_text(exc), code=exc.code) + + try: + if method == 'delete': + if running: + if commit: + response = restconf.edit_config(module, path=path, method='DELETE') + result['changed'] = True + else: + warnings.append("delete not executed as resource '%s' does not exist" % path) + else: + if running: + if method == 'post': + module.fail_json(msg="resource '%s' already exist" % path, code=409) + diff = dict_diff(running, candidate) + result['candidate'] = candidate + result['running'] = running + else: + method = 'POST' + diff = candidate + + if diff: + if module._diff: + result['diff'] = {'prepared': diff, 'before': candidate, 'after': running} + + if commit: + response = restconf.edit_config(module, path=path, content=diff, method=method.upper(), format=format) + result['changed'] = True + + except ConnectionError as exc: + module.fail_json(msg=str(exc), code=exc.code) + + result['response'] = response + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/connection/httpapi.py b/lib/ansible/plugins/connection/httpapi.py index db39b321a4..7919964065 100644 --- a/lib/ansible/plugins/connection/httpapi.py +++ b/lib/ansible/plugins/connection/httpapi.py @@ -232,6 +232,8 @@ class Connection(NetworkConnectionBase): port = self.get_option('port') or (443 if protocol == 'https' else 80) self._url = '%s://%s:%s' % (protocol, host, port) + self.queue_message('vvv', "ESTABLISH HTTP(S) CONNECTFOR USER: %s TO %s" % + (self._play_context.remote_user, self._url)) self.httpapi.set_become(self._play_context) self.httpapi.login(self.get_option('remote_user'), self.get_option('password')) diff --git a/lib/ansible/plugins/httpapi/restconf.py b/lib/ansible/plugins/httpapi/restconf.py index 7e9b6ee266..3a5def2c97 100644 --- a/lib/ansible/plugins/httpapi/restconf.py +++ b/lib/ansible/plugins/httpapi/restconf.py @@ -41,8 +41,10 @@ options: import json +from ansible.module_utils._text import to_text from ansible.module_utils.network.common.utils import to_list from ansible.module_utils.connection import ConnectionError +from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.plugins.httpapi import HttpApiBase @@ -54,26 +56,36 @@ class HttpApi(HttpApiBase): if data: data = json.dumps(data) - path = self.get_option('root_path') + message_kwargs.get('path', '') + path = '/'.join([self.get_option('root_path').rstrip('/'), message_kwargs.get('path', '').lstrip('/')]) headers = { 'Content-Type': message_kwargs.get('content_type') or CONTENT_TYPE, 'Accept': message_kwargs.get('accept') or CONTENT_TYPE, } - response, response_data = self.connection.send(path, data, headers=headers, method=message_kwargs.get('method')) + try: + response, response_data = self.connection.send(path, data, headers=headers, method=message_kwargs.get('method')) + except HTTPError as exc: + response_data = exc - return handle_response(response_data.read()) + return handle_response(response_data) + + def handle_httperror(self, exc): + return None def handle_response(response): - if 'error' in response and 'jsonrpc' not in response: - error = response['error'] + try: + response_json = json.loads(response.read()) + except ValueError: + if isinstance(response, HTTPError): + raise ConnectionError(to_text(response), code=response.code) + return response.read() - error_text = [] - for data in error['data']: - error_text.extend(data.get('errors', [])) - error_text = '\n'.join(error_text) or error['message'] + if 'errors' in response_json and 'jsonrpc' not in response_json: + errors = response_json['errors']['error'] - raise ConnectionError(error_text, code=error['code']) + error_text = '\n'.join((error['error-message'] for error in errors)) - return response + raise ConnectionError(error_text, code=response.code) + + return response_json diff --git a/lib/ansible/utils/jsonrpc.py b/lib/ansible/utils/jsonrpc.py index fe73545012..9497e11ca0 100644 --- a/lib/ansible/utils/jsonrpc.py +++ b/lib/ansible/utils/jsonrpc.py @@ -8,6 +8,7 @@ import json import traceback from ansible.module_utils._text import to_text +from ansible.module_utils.connection import ConnectionError from ansible.module_utils.six import binary_type from ansible.utils.display import Display @@ -42,6 +43,10 @@ class JsonRpcServer(object): else: try: result = rpc_method(*args, **kwargs) + except ConnectionError as exc: + display.vvv(traceback.format_exc()) + error = self.error(code=exc.code, message=to_text(exc)) + response = json.dumps(error) except Exception as exc: display.vvv(traceback.format_exc()) error = self.internal_error(data=to_text(exc, errors='surrogate_then_replace'))