From 30f992f26001abccd99bcf27f31371c0d4d988c3 Mon Sep 17 00:00:00 2001 From: Ganesh Nalawade Date: Thu, 17 May 2018 17:38:12 +0530 Subject: [PATCH] Add netconf_get module (#39869) * Add netconf_get module Implements part-1 of proposal #104 https://github.com/ansible/proposals/issues/104 * Add netconf_get module * Refactor `get`, `get_config`, `lock`, `unlock` and `discard_changes` netconf plugin api's * Add netconf module_utils file which netconf module related common functions * Refactor junos and iosxr netconf plugins * Fix source option handling * Fix review comments * Update botmeta file * Update review comments and add support for lock * Lock update fix * Fix CI issue * Add integration test and minor fixes * Fix review comments * Fix CI failure * Fix CI issues * Fix CI issues * Fix review comments and update integration test * Fix review comments * Fix review comments * Fix review comments Fix reveiw comments --- .github/BOTMETA.yml | 6 +- .../module_utils/network/common/netconf.py | 43 +++ .../module_utils/network/netconf/__init__.py | 0 .../module_utils/network/netconf/netconf.py | 106 +++++++ .../modules/network/netconf/netconf_get.py | 262 ++++++++++++++++++ lib/ansible/plugins/netconf/__init__.py | 97 +++++-- lib/ansible/plugins/netconf/default.py | 1 + lib/ansible/plugins/netconf/iosxr.py | 73 ++--- lib/ansible/plugins/netconf/junos.py | 42 +-- .../targets/netconf_get/defaults/main.yaml | 2 + .../targets/netconf_get/meta/main.yml | 4 + .../targets/netconf_get/tasks/iosxr.yaml | 16 ++ .../targets/netconf_get/tasks/junos.yaml | 16 ++ .../targets/netconf_get/tasks/main.yaml | 3 + .../netconf_get/tests/iosxr/basic.yaml | 163 +++++++++++ .../netconf_get/tests/junos/basic.yaml | 126 +++++++++ 16 files changed, 841 insertions(+), 119 deletions(-) create mode 100644 lib/ansible/module_utils/network/netconf/__init__.py create mode 100644 lib/ansible/module_utils/network/netconf/netconf.py create mode 100644 lib/ansible/modules/network/netconf/netconf_get.py create mode 100644 test/integration/targets/netconf_get/defaults/main.yaml create mode 100644 test/integration/targets/netconf_get/meta/main.yml create mode 100644 test/integration/targets/netconf_get/tasks/iosxr.yaml create mode 100644 test/integration/targets/netconf_get/tasks/junos.yaml create mode 100644 test/integration/targets/netconf_get/tasks/main.yaml create mode 100644 test/integration/targets/netconf_get/tests/iosxr/basic.yaml create mode 100644 test/integration/targets/netconf_get/tests/junos/basic.yaml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 6340259c761..587ac36ac0a 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -476,7 +476,8 @@ files: $modules/network/layer2/: $team_networking $modules/network/layer3/: $team_networking $modules/network/meraki/: $team_meraki - $modules/network/netconf/netconf_config.py: ganeshrn lpenz userlerueda + $modules/network/netconf/netconf_config.py: lpenz userlerueda $team_networking + $modules/network/netconf/netconf_get.py: wisotzky $team_networking $modules/network/netscaler/: $team_netscaler $modules/network/netvisor/: $team_netvisor $modules/network/nuage/: pdellaert @@ -864,6 +865,9 @@ files: $module_utils/network/meraki: maintainers: $team_meraki labels: networking + $module_utils/network/netconf: + maintainers: $team_networking + labels: networking $module_utils/network/netscaler: maintainers: $team_netscaler labels: networking diff --git a/lib/ansible/module_utils/network/common/netconf.py b/lib/ansible/module_utils/network/common/netconf.py index 8227325d1e9..75e269998f0 100644 --- a/lib/ansible/module_utils/network/common/netconf.py +++ b/lib/ansible/module_utils/network/common/netconf.py @@ -30,6 +30,12 @@ import sys from ansible.module_utils._text import to_text, to_bytes from ansible.module_utils.connection import Connection, ConnectionError +try: + from ncclient.xml_ import NCElement + HAS_NCCLIENT = True +except ImportError: + HAS_NCCLIENT = False + try: from lxml.etree import Element, fromstring, XMLSyntaxError except ImportError: @@ -100,3 +106,40 @@ class NetconfConnection(Connection): return warnings except XMLSyntaxError: raise ConnectionError(rpc_error) + + +def transform_reply(): + reply = ''' + + + + + + + + + + + + + + + + + + + + + ''' + if sys.version < '3': + return reply + else: + return reply.encode('UTF-8') + + +# Note: Workaround for ncclient 0.5.3 +def remove_namespaces(data): + if not HAS_NCCLIENT: + raise ImportError("ncclient is required but does not appear to be installed. " + "It can be installed using `pip install ncclient`") + return NCElement(data, transform_reply()).data_xml diff --git a/lib/ansible/module_utils/network/netconf/__init__.py b/lib/ansible/module_utils/network/netconf/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/module_utils/network/netconf/netconf.py b/lib/ansible/module_utils/network/netconf/netconf.py new file mode 100644 index 00000000000..73479c3136e --- /dev/null +++ b/lib/ansible/module_utils/network/netconf/netconf.py @@ -0,0 +1,106 @@ +# +# (c) 2018 Red Hat, Inc. +# +# 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 . +# +import json + +from contextlib import contextmanager + +from ansible.module_utils._text import to_text +from ansible.module_utils.connection import Connection, ConnectionError +from ansible.module_utils.network.common.netconf import NetconfConnection + + +def get_connection(module): + if hasattr(module, '_netconf_connection'): + return module._netconf_connection + + capabilities = get_capabilities(module) + network_api = capabilities.get('network_api') + if network_api == 'netconf': + module._netconf_connection = NetconfConnection(module._socket_path) + else: + module.fail_json(msg='Invalid connection type %s' % network_api) + + return module._netconf_connection + + +def get_capabilities(module): + if hasattr(module, '_netconf_capabilities'): + return module._netconf_capabilities + + capabilities = Connection(module._socket_path).get_capabilities() + module._netconf_capabilities = json.loads(capabilities) + return module._netconf_capabilities + + +def lock_configuration(x, target=None): + conn = get_connection(x) + return conn.lock(target=target) + + +def unlock_configuration(x, target=None): + conn = get_connection(x) + return conn.unlock(target=target) + + +@contextmanager +def locked_config(module, target=None): + try: + lock_configuration(module, target=target) + yield + finally: + unlock_configuration(module, target=target) + + +def get_config(module, source, filter, lock=False): + conn = get_connection(module) + try: + locked = False + if lock: + conn.lock(target='running') + locked = True + response = conn.get_config(source=source, filter=filter) + + except ConnectionError as e: + module.fail_json(msg=to_text(e, errors='surrogate_then_replace').strip()) + + finally: + if locked: + conn.unlock(target='running') + + return response + + +def get(module, filter, lock=False): + conn = get_connection(module) + try: + locked = False + if lock: + conn.lock(target='running') + locked = True + + response = conn.get(filter=filter) + + except ConnectionError as e: + module.fail_json(msg=to_text(e, errors='surrogate_then_replace').strip()) + + finally: + if locked: + conn.unlock(target='running') + + return response diff --git a/lib/ansible/modules/network/netconf/netconf_get.py b/lib/ansible/modules/network/netconf/netconf_get.py new file mode 100644 index 00000000000..b41e15dc66f --- /dev/null +++ b/lib/ansible/modules/network/netconf/netconf_get.py @@ -0,0 +1,262 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018, Ansible by Red Hat, inc +# 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: netconf_get +version_added: "2.6" +author: + - "Ganesh Nalawade (@ganeshrn)" + - "Sven Wisotzky (@wisotzky)" +short_description: Fetch configuration/state data from NETCONF enabled network devices. +description: + - NETCONF is a network management protocol developed and standardized by + the IETF. It is documented in RFC 6241. + - This module allows the user to fetch configuration and state data from NETCONF + enabled network devices. +options: + source: + description: + - This argument specifies the datastore from which configuration data should be fetched. + Valid values are I(running), I(candidate) and I(startup). If the C(source) value is not + set both configuration and state information are returned in response from running datastore. + choices: ['running', 'candidate', 'startup'] + filter: + description: + - This argument specifies the XML string which acts as a filter to restrict the portions of + the data to be are retrieved from the remote device. If this option is not specified entire + configuration or state data is returned in result depending on the value of C(source) + option. The C(filter) value can be either XML string or XPath, if the filter is in + XPath format the NETCONF server running on remote host should support xpath capability + else it will result in an error. + display: + description: + - Encoding scheme to use when serializing output from the device. The option I(json) will + serialize the output as JSON data. If the option value is I(json) it requires jxmlease + to be installed on control node. The option I(pretty) is similar to received XML response + but is using human readable format (spaces, new lines). The option value I(xml) is similar + to received XML response but removes all XML namespaces. + choices: ['json', 'pretty', 'xml'] + lock: + description: + - Instructs the module to explicitly lock the datastore specified as C(source) before fetching + configuration and/or state information from remote host. If the value is I(never) in that case + the C(source) datastore is never locked, if the value is I(if-supported) the C(source) datastore + is locked only if the Netconf server running on remote host supports locking of that datastore, + if the lock on C(source) datastore is not supported module will report appropriate error before + executing lock. If the value is I(always) the lock operation on C(source) datastore will always + be executed irrespective if the remote host supports it or not, if it doesn't the module with + fail will the execption message received from remote host and might vary based on the platform. + default: 'never' + choices: ['never', 'always', 'if-supported'] +requirements: + - ncclient (>=v0.5.2) + - jxmlease + +notes: + - This module requires the NETCONF system service be enabled on + the remote device being managed. + - This module supports the use of connection=netconf +""" + +EXAMPLES = """ +- name: Get running configuration and state data + netconf_get: + +- name: Get configuration and state data from startup datastore + netconf_get: + source: startup + +- name: Get system configuration data from running datastore state (junos) + netconf_get: + source: running + filter: + +- name: Get configuration and state data in JSON format + netconf_get: + display: json + +- name: get schema list using subtree w/ namespaces + netconf_get: + format: json + filter: + lock: False + +- name: get schema list using xpath + netconf_get: + format: json + filter: /netconf-state/schemas/schema + +- name: get interface confiugration with filter (iosxr) + netconf_get: + filter: + +- name: Get system configuration data from running datastore state (sros) + netconf_get: + source: running + filter: + lock: True + +- name: Get state data (sros) + netconf_get: + filter: +""" + +RETURN = """ +stdout: + description: The raw XML string containing configuration or state data + received from the underlying ncclient library. + returned: always apart from low-level errors (such as action plugin) + type: string + sample: '...' +stdout_lines: + description: The value of stdout split into a list + returned: always apart from low-level errors (such as action plugin) + type: list + sample: ['...', '...'] +output: + description: Based on the value of display option will return either the set of + transformed XML to JSON format from the RPC response with type dict + or pretty XML string response (human-readable) or response with + namespace removed from XML string. + returned: when the display format is selected as JSON it is returned as dict type, if the + display format is xml or pretty pretty it is retured as a string apart from low-level + errors (such as action plugin). + type: complex + contains: + formatted_output: + - Contains formatted response received from remote host as per the value in display format. +""" +import sys + +try: + from lxml.etree import Element, SubElement, tostring, fromstring, XMLSyntaxError +except ImportError: + from xml.etree.ElementTree import Element, SubElement, tostring, fromstring + if sys.version_info < (2, 7): + from xml.parsers.expat import ExpatError as XMLSyntaxError + else: + from xml.etree.ElementTree import ParseError as XMLSyntaxError + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.network.netconf.netconf import get_capabilities, locked_config, get_config, get +from ansible.module_utils.network.common.netconf import remove_namespaces + +try: + import jxmlease + HAS_JXMLEASE = True +except ImportError: + HAS_JXMLEASE = False + + +def get_filter_type(filter): + if not filter: + return None + else: + try: + fromstring(filter) + return 'subtree' + except XMLSyntaxError: + return 'xpath' + + +def main(): + """entry point for module execution + """ + argument_spec = dict( + source=dict(choices=['running', 'candidate', 'startup']), + filter=dict(), + display=dict(choices=['json', 'pretty', 'xml']), + lock=dict(default='never', choices=['never', 'always', 'if-supported']) + ) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + capabilities = get_capabilities(module) + operations = capabilities['device_operations'] + + source = module.params['source'] + filter = module.params['filter'] + filter_type = get_filter_type(filter) + lock = module.params['lock'] + display = module.params['display'] + + if source == 'candidate' and not operations.get('supports_commit', False): + module.fail_json(msg='candidate source is not supported on this device') + + if source == 'startup' and not operations.get('supports_startup', False): + module.fail_json(msg='startup source is not supported on this device') + + if filter_type == 'xpath' and not operations.get('supports_xpath', False): + module.fail_json(msg="filter value '%s' of type xpath is not supported on this device" % filter) + + execute_lock = True if lock in ('always', 'if-supported') else False + + if lock == 'always' and not operations.get('supports_lock', False): + module.fail_json(msg='lock operation is not supported on this device') + + if execute_lock: + if source is None: + # if source is None, in that case operation is 'get' and `get` supports + # fetching data only from running datastore + if 'running' not in operations.get('lock_datastore', []): + # lock is not supported, don't execute lock operation + if lock == 'if-supported': + execute_lock = False + else: + module.warn("lock operation on 'running' source is not supported on this device") + else: + if source not in operations.get('lock_datastore', []): + if lock == 'if-supported': + # lock is not supported, don't execute lock operation + execute_lock = False + else: + module.warn("lock operation on '%s' source is not supported on this device" % source) + + if display == 'json' and not HAS_JXMLEASE: + module.fail_json(msg='jxmlease is required to display response in json format' + 'but does not appear to be installed. ' + 'It can be installed using `pip install jxmlease`') + + filter_spec = (filter_type, filter) if filter_type else None + + if source is not None: + response = get_config(module, source, filter_spec, execute_lock) + else: + response = get(module, filter_spec, execute_lock) + + xml_resp = tostring(response) + output = None + + if display == 'xml': + output = remove_namespaces(xml_resp) + elif display == 'json': + try: + output = jxmlease.parse(xml_resp) + except: + raise ValueError(xml_resp) + elif display == 'pretty': + output = tostring(response, pretty_print=True) + + result = { + 'stdout': xml_resp, + 'output': output + } + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/netconf/__init__.py b/lib/ansible/plugins/netconf/__init__.py index 73a99ea2095..8dd30e788e2 100644 --- a/lib/ansible/plugins/netconf/__init__.py +++ b/lib/ansible/plugins/netconf/__init__.py @@ -110,22 +110,35 @@ class NetconfBase(with_metaclass(ABCMeta, object)): raise Exception(to_xml(msg)) @ensure_connected - def get_config(self, *args, **kwargs): - """Retrieve all or part of a specified configuration. - :source: name of the configuration datastore being queried - :filter: specifies the portion of the configuration to retrieve - (by default entire configuration is retrieved)""" - resp = self.m.get_config(*args, **kwargs) + def get_config(self, source=None, filter=None): + """Retrieve all or part of a specified configuration + (by default entire configuration is retrieved). + + :param source: Name of the configuration datastore being queried, defaults to running datastore + :param filter: This argument specifies the portion of the configuration data to retrieve + :return: Returns xml string containing the RPC response received from remote host + """ + if isinstance(filter, list): + filter = tuple(filter) + + if not source: + source = 'running' + resp = self.m.get_config(source=source, filter=filter) return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml @ensure_connected - def get(self, *args, **kwargs): - """Retrieve running configuration and device state information. - *filter* specifies the portion of the configuration to retrieve - (by default entire configuration is retrieved) + def get(self, filter=None): + """Retrieve device configuration and state information. + + :param filter: This argument specifies the portion of the state data to retrieve + (by default entire state data is retrieved) + :return: Returns xml string containing the RPC response received from remote host """ - resp = self.m.get(*args, **kwargs) - return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml + if isinstance(filter, list): + filter = tuple(filter) + resp = self.m.get(filter=filter) + response = resp.data_xml if hasattr(resp, 'data_xml') else resp.xml + return response @ensure_connected def edit_config(self, *args, **kwargs): @@ -162,26 +175,42 @@ class NetconfBase(with_metaclass(ABCMeta, object)): return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml @ensure_connected - def lock(self, *args, **kwargs): - """Allows the client to lock the configuration system of a device. - *target* is the name of the configuration datastore to lock + def lock(self, target=None): + """ + Allows the client to lock the configuration system of a device. + :param target: is the name of the configuration datastore to lock, + defaults to candidate datastore + :return: Returns xml string containing the RPC response received from remote host """ - resp = self.m.lock(*args, **kwargs) + if not target: + target = 'candidate' + resp = self.m.lock(target=target) return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml @ensure_connected - def unlock(self, *args, **kwargs): + def unlock(self, target=None): + """ + Release a configuration lock, previously obtained with the lock operation. + :param target: is the name of the configuration datastore to unlock, + defaults to candidate datastore + :return: Returns xml string containing the RPC response received from remote host + """ """Release a configuration lock, previously obtained with the lock operation. :target: is the name of the configuration datastore to unlock """ - resp = self.m.unlock(*args, **kwargs) + if not target: + target = 'candidate' + resp = self.m.unlock(target=target) return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml @ensure_connected - def discard_changes(self, *args, **kwargs): - """Revert the candidate configuration to the currently running configuration. - Any uncommitted changes are discarded.""" - resp = self.m.discard_changes(*args, **kwargs) + def discard_changes(self): + """ + Revert the candidate configuration to the currently running configuration. + Any uncommitted changes are discarded. + :return: Returns xml string containing the RPC response received from remote host + """ + resp = self.m.discard_changes() return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml @ensure_connected @@ -245,4 +274,28 @@ class NetconfBase(with_metaclass(ABCMeta, object)): """Fetch file over scp from remote device""" pass + def get_device_operations(self, server_capabilities): + operations = {} + capabilities = '\n'.join(server_capabilities) + operations['supports_commit'] = True if ':candidate' in capabilities else False + operations['supports_defaults'] = True if ':with-defaults' in capabilities else False + operations['supports_confirm_commit'] = True if ':confirmed-commit' in capabilities else False + operations['supports_startup'] = True if ':startup' in capabilities else False + operations['supports_xpath'] = True if ':xpath' in capabilities else False + operations['supports_writeable_running'] = True if ':writable-running' in capabilities else False + + operations['lock_datastore'] = [] + if operations['supports_writeable_running']: + operations['lock_datastore'].append('running') + + if operations['supports_commit']: + operations['lock_datastore'].append('candidate') + + if operations['supports_startup']: + operations['lock_datastore'].append('startup') + + operations['supports_lock'] = True if len(operations['lock_datastore']) else False + + return operations + # TODO Restore .xml, when ncclient supports it for all platforms diff --git a/lib/ansible/plugins/netconf/default.py b/lib/ansible/plugins/netconf/default.py index 79b36b7a55b..dabebaaf06b 100644 --- a/lib/ansible/plugins/netconf/default.py +++ b/lib/ansible/plugins/netconf/default.py @@ -48,4 +48,5 @@ class Netconf(NetconfBase): result['server_capabilities'] = [c for c in self.m.server_capabilities] result['client_capabilities'] = [c for c in self.m.client_capabilities] result['session_id'] = self.m.session_id + result['device_operations'] = self.get_device_operations(result['server_capabilities']) return json.dumps(result) diff --git a/lib/ansible/plugins/netconf/iosxr.py b/lib/ansible/plugins/netconf/iosxr.py index ed189e30c41..1ac23acb954 100644 --- a/lib/ansible/plugins/netconf/iosxr.py +++ b/lib/ansible/plugins/netconf/iosxr.py @@ -22,12 +22,10 @@ __metaclass__ = type import json import re -import sys import collections -from io import BytesIO -from ansible.module_utils.six import StringIO from ansible import constants as C +from ansible.module_utils.network.common.netconf import remove_namespaces from ansible.module_utils.network.iosxr.iosxr import build_xml from ansible.errors import AnsibleConnectionFailure, AnsibleError from ansible.plugins.netconf import NetconfBase @@ -47,45 +45,6 @@ except ImportError: raise AnsibleError("lxml is not installed") -def transform_reply(): - reply = ''' - - - - - - - - - - - - - - - - - - - - - ''' - if sys.version < '3': - return reply - else: - return reply.encode('UTF-8') - - -# Note: Workaround for ncclient 0.5.3 -def remove_namespaces(rpc_reply): - xslt = transform_reply() - parser = etree.XMLParser(remove_blank_text=True) - xslt_doc = etree.parse(BytesIO(xslt), parser) - transform = etree.XSLT(xslt_doc) - - return etree.fromstring(str(transform(etree.parse(StringIO(str(rpc_reply)))))) - - class Netconf(NetconfBase): @ensure_connected @@ -129,7 +88,7 @@ class Netconf(NetconfBase): result['server_capabilities'] = [c for c in self.m.server_capabilities] result['client_capabilities'] = [c for c in self.m.client_capabilities] result['session_id'] = self.m.session_id - + result['device_operations'] = self.get_device_operations(result['server_capabilities']) return json.dumps(result) @staticmethod @@ -161,18 +120,22 @@ class Netconf(NetconfBase): # TODO: change .xml to .data_xml, when ncclient supports data_xml on all platforms @ensure_connected - def get(self, *args, **kwargs): + def get(self, filter=None): + if isinstance(filter, list): + filter = tuple(filter) try: - response = self.m.get(*args, **kwargs) - return to_xml(remove_namespaces(response)) + response = self.m.get(filter=filter) + return remove_namespaces(response) except RPCError as exc: raise Exception(to_xml(exc.xml)) @ensure_connected - def get_config(self, *args, **kwargs): + def get_config(self, source=None, filter=None): + if isinstance(filter, list): + filter = tuple(filter) try: - response = self.m.get_config(*args, **kwargs) - return to_xml(remove_namespaces(response)) + response = self.m.get_config(source=source, filter=filter) + return remove_namespaces(response) except RPCError as exc: raise Exception(to_xml(exc.xml)) @@ -180,7 +143,7 @@ class Netconf(NetconfBase): def edit_config(self, *args, **kwargs): try: response = self.m.edit_config(*args, **kwargs) - return to_xml(remove_namespaces(response)) + return remove_namespaces(response) except RPCError as exc: raise Exception(to_xml(exc.xml)) @@ -188,7 +151,7 @@ class Netconf(NetconfBase): def commit(self, *args, **kwargs): try: response = self.m.commit(*args, **kwargs) - return to_xml(remove_namespaces(response)) + return remove_namespaces(response) except RPCError as exc: raise Exception(to_xml(exc.xml)) @@ -196,14 +159,14 @@ class Netconf(NetconfBase): def validate(self, *args, **kwargs): try: response = self.m.validate(*args, **kwargs) - return to_xml(remove_namespaces(response)) + return remove_namespaces(response) except RPCError as exc: raise Exception(to_xml(exc.xml)) @ensure_connected - def discard_changes(self, *args, **kwargs): + def discard_changes(self): try: - response = self.m.discard_changes(*args, **kwargs) - return to_xml(remove_namespaces(response)) + response = self.m.discard_changes() + return remove_namespaces(response) except RPCError as exc: raise Exception(to_xml(exc.xml)) diff --git a/lib/ansible/plugins/netconf/junos.py b/lib/ansible/plugins/netconf/junos.py index f3b4f0e63c1..469e127617f 100644 --- a/lib/ansible/plugins/netconf/junos.py +++ b/lib/ansible/plugins/netconf/junos.py @@ -92,6 +92,7 @@ class Netconf(NetconfBase): result['server_capabilities'] = [c for c in self.m.server_capabilities] result['client_capabilities'] = [c for c in self.m.client_capabilities] result['session_id'] = self.m.session_id + result['device_operations'] = self.get_device_operations(result['server_capabilities']) return json.dumps(result) @staticmethod @@ -143,44 +144,3 @@ class Netconf(NetconfBase): def reboot(self): """reboot the device""" return self.m.reboot().data_xml - - @ensure_connected - def halt(self): - """reboot the device""" - return self.m.halt().data_xml - - @ensure_connected - def get(self, *args, **kwargs): - try: - return self.m.get(*args, **kwargs).data_xml - except RPCError as exc: - raise Exception(to_xml(exc.xml)) - - @ensure_connected - def get_config(self, *args, **kwargs): - try: - return self.m.get_config(*args, **kwargs).data_xml - except RPCError as exc: - raise Exception(to_xml(exc.xml)) - - @ensure_connected - def edit_config(self, *args, **kwargs): - try: - self.m.edit_config(*args, **kwargs).data_xml - except RPCError as exc: - raise Exception(to_xml(exc.xml)) - - @ensure_connected - def commit(self, *args, **kwargs): - try: - return self.m.commit(*args, **kwargs).data_xml - except RPCError as exc: - raise Exception(to_xml(exc.xml)) - - @ensure_connected - def validate(self, *args, **kwargs): - return self.m.validate(*args, **kwargs).data_xml - - @ensure_connected - def discard_changes(self, *args, **kwargs): - return self.m.discard_changes(*args, **kwargs).data_xml diff --git a/test/integration/targets/netconf_get/defaults/main.yaml b/test/integration/targets/netconf_get/defaults/main.yaml new file mode 100644 index 00000000000..5f709c5aac1 --- /dev/null +++ b/test/integration/targets/netconf_get/defaults/main.yaml @@ -0,0 +1,2 @@ +--- +testcase: "*" diff --git a/test/integration/targets/netconf_get/meta/main.yml b/test/integration/targets/netconf_get/meta/main.yml new file mode 100644 index 00000000000..3403f48112f --- /dev/null +++ b/test/integration/targets/netconf_get/meta/main.yml @@ -0,0 +1,4 @@ +--- +dependencies: + - { role: prepare_junos_tests, when: ansible_network_os == 'junos' } + - { role: prepare_iosxr_tests, when: ansible_network_os == 'iosxr' } diff --git a/test/integration/targets/netconf_get/tasks/iosxr.yaml b/test/integration/targets/netconf_get/tasks/iosxr.yaml new file mode 100644 index 00000000000..4f36f4c54d7 --- /dev/null +++ b/test/integration/targets/netconf_get/tasks/iosxr.yaml @@ -0,0 +1,16 @@ +--- +- name: collect all netconf test cases + find: + paths: "{{ role_path }}/tests/iosxr" + patterns: "{{ testcase }}.yaml" + register: test_cases + connection: local + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test case (connection=netconf) + include: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/targets/netconf_get/tasks/junos.yaml b/test/integration/targets/netconf_get/tasks/junos.yaml new file mode 100644 index 00000000000..86c56f83a5e --- /dev/null +++ b/test/integration/targets/netconf_get/tasks/junos.yaml @@ -0,0 +1,16 @@ +--- +- name: collect all netconf test cases + find: + paths: "{{ role_path }}/tests/junos" + patterns: "{{ testcase }}.yaml" + register: test_cases + connection: local + +- name: set test_items + set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}" + +- name: run test case (connection=netconf) + include: "{{ test_case_to_run }} ansible_connection=netconf" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/test/integration/targets/netconf_get/tasks/main.yaml b/test/integration/targets/netconf_get/tasks/main.yaml new file mode 100644 index 00000000000..4d8eb94cd5a --- /dev/null +++ b/test/integration/targets/netconf_get/tasks/main.yaml @@ -0,0 +1,3 @@ +--- +- { include: junos.yaml, when: ansible_network_os == 'junos', tags: ['netconf'] } +- { include: iosxr.yaml, when: ansible_network_os == 'iosxr', tags: ['netconf'] } diff --git a/test/integration/targets/netconf_get/tests/iosxr/basic.yaml b/test/integration/targets/netconf_get/tests/iosxr/basic.yaml new file mode 100644 index 00000000000..fa03bb30eca --- /dev/null +++ b/test/integration/targets/netconf_get/tests/iosxr/basic.yaml @@ -0,0 +1,163 @@ +--- +- debug: msg="START netconf_get iosxr/basic.yaml on connection={{ ansible_connection }}" + +- name: setup interface + iosxr_config: + commands: + - description this is test interface Loopback999 + - no shutdown + parents: + - interface Loopback999 + match: none + connection: network_cli + +- name: get running interface confiugration with filter + netconf_get: + source: running + filter: + register: result + connection: netconf + +- assert: + that: + - "'this is test interface Loopback999' in result.stdout" + - "'' not in result.stdout" + +- name: test lock=never, get-config, running interface confiugration with filter without lock + netconf_get: + source: running + lock: never + filter: + register: result + connection: netconf + +- assert: + that: + - "'this is test interface Loopback999' in result.stdout" + - "'' not in result.stdout" + +- name: test lock=if-supported, get-config, running interface confiugration with filter without lock + netconf_get: + source: running + lock: if-supported + filter: + register: result + connection: netconf + +- assert: + that: + - "'this is test interface Loopback999' in result.stdout" + - "'' not in result.stdout" + +- name: Failure scenario, get-config information with lock + netconf_get: + source: running + lock: always + register: result + ignore_errors: True + connection: netconf + +- assert: + that: + - "'running' in result.msg" + +- name: Failure scenario, fetch config from startup + netconf_get: + source: startup + register: result + ignore_errors: True + connection: netconf + +- assert: + that: + - "'startup source is not supported' in result.msg" + +- name: test get, information from running datastore without lock + netconf_get: + lock: never + filter: + register: result + connection: netconf + +- assert: + that: + - "'this is test interface Loopback999' in result.stdout" + +- name: test get, information from running datastore with lock if supported + netconf_get: + lock: if-supported + filter: + register: result + connection: netconf + +- assert: + that: + - "'this is test interface Loopback999' in result.stdout" + +- name: Failure scenario, get information from running with lock + netconf_get: + lock: always + register: result + ignore_errors: True + connection: netconf + +- assert: + that: + - "'running' in result.msg" + +- name: get configuration and state data in json format + netconf_get: + source: running + display: json + register: result + connection: netconf + +- assert: + that: + - "{{ result['output']['rpc-reply']['data']['aaa'] is defined}}" + +- name: get configuration data in xml pretty format + netconf_get: + source: running + display: pretty + register: result + connection: netconf + +- assert: + that: + - "{{ result['output'] is defined}}" + +- name: get configuration data in xml with namespace stripped + netconf_get: + source: running + display: xml + register: result + connection: netconf + +- assert: + that: + - "{{ result['output'] is defined}}" + - "{{ 'xmlns' not in result.output }}" + +- name: Failure scenario, unsupported filter + netconf_get: + filter: configuration/state + register: result + ignore_errors: True + connection: netconf + +- assert: + that: + - "'filter value \\'configuration/state\\' of type xpath is not supported' in result.msg" + +- name: setup - teardown + iosxr_config: + commands: + - no description + - shutdown + parents: + - interface Loopback999 + match: none + connection: network_cli + +- debug: msg="END netconf_get iosxr/basic.yaml on connection={{ ansible_connection }}" diff --git a/test/integration/targets/netconf_get/tests/junos/basic.yaml b/test/integration/targets/netconf_get/tests/junos/basic.yaml new file mode 100644 index 00000000000..3eee81e9209 --- /dev/null +++ b/test/integration/targets/netconf_get/tests/junos/basic.yaml @@ -0,0 +1,126 @@ +--- +- debug: msg="START netconf_get junos/basic.yaml on connection={{ ansible_connection }}" + +- name: Configure syslog file - setup + junos_config: + lines: + - set system syslog file test1 any any + register: result + +- name: Get system configuration data from running datastore state + netconf_get: + source: running + filter: + register: result + +- assert: + that: + - "'test1' in result.stdout" + - "'any' in result.stdout" + - "'' in result.stdout" + - "'' not in result.stdout" + - "'' not in result.stdout" + +- name: Failure scenario, fetch config from startup + netconf_get: + source: startup + register: result + ignore_errors: True + +- assert: + that: + - "'startup source is not supported' in result.msg" + +- name: Failure scenario, fetch config from running with lock + netconf_get: + lock: always + source: running + register: result + ignore_errors: True + +- assert: + that: + - "'syntax error' in result.msg" + +- name: Get system configuration data from running datastore state and lock if-supported + netconf_get: + source: running + filter: + lock: if-supported + register: result + +- assert: + that: + - "'test1' in result.stdout" + - "'any' in result.stdout" + - "'' in result.stdout" + - "'' not in result.stdout" + - "'' not in result.stdout" + +- name: get configuration and state data in json format + netconf_get: + source: running + display: json + register: result + +- assert: + that: + - "{{ result['output']['rpc-reply']['data']['configuration'] is defined}}" + +- name: get configuration and state data in xml pretty format + netconf_get: + source: running + display: pretty + register: result + +- assert: + that: + - "{{ result['output'] is defined}}" + +- name: get configuration data in xml with namespace stripped + netconf_get: + source: running + display: xml + register: result + +- assert: + that: + - "{{ result['output'] is defined}}" + - "{{ 'xmlns' not in result.output }}" + +- name: get configuration and state data without datastore lock + netconf_get: + lock: never + register: result + +- assert: + that: + - "'' in result.stdout" + - "'' in result.stdout" + +- name: get configuration and state data and lock data-store if supported + netconf_get: + lock: if-supported + register: result + +- assert: + that: + - "'' in result.stdout" + - "'' in result.stdout" + +- name: Failure scenario, unsupported filter + netconf_get: + filter: configuration/state + register: result + ignore_errors: True + +- assert: + that: + - "'filter value \\'configuration/state\\' of type xpath is not supported' in result.msg" + +- name: Configure syslog file - teardown + junos_config: + lines: + - delete system syslog file test1 any any + +- debug: msg="END netconf_get junos/basic.yaml on connection={{ ansible_connection }}"