1#
2# (c) 2017 Red Hat Inc.
3#
4# This file is part of Ansible
5#
6# Ansible is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# Ansible is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
18#
19from __future__ import (absolute_import, division, print_function)
20__metaclass__ = type
21
22from abc import abstractmethod
23from functools import wraps
24
25from ansible.errors import AnsibleError
26from ansible.plugins import AnsiblePlugin
27from ansible.module_utils._text import to_native
28from ansible.module_utils.basic import missing_required_lib
29
30try:
31    from ncclient.operations import RPCError
32    from ncclient.xml_ import to_xml, to_ele, NCElement
33    HAS_NCCLIENT = True
34    NCCLIENT_IMP_ERR = None
35# paramiko and gssapi are incompatible and raise AttributeError not ImportError
36# When running in FIPS mode, cryptography raises InternalError
37# https://bugzilla.redhat.com/show_bug.cgi?id=1778939
38except Exception as err:
39    HAS_NCCLIENT = False
40    NCCLIENT_IMP_ERR = err
41
42try:
43    from lxml.etree import Element, SubElement, tostring, fromstring
44except ImportError:
45    from xml.etree.ElementTree import Element, SubElement, tostring, fromstring
46
47
48def ensure_ncclient(func):
49    @wraps(func)
50    def wrapped(self, *args, **kwargs):
51        if not HAS_NCCLIENT:
52            raise AnsibleError("%s: %s" % (missing_required_lib('ncclient'), to_native(NCCLIENT_IMP_ERR)))
53        return func(self, *args, **kwargs)
54    return wrapped
55
56
57class NetconfBase(AnsiblePlugin):
58    """
59    A base class for implementing Netconf connections
60
61    .. note:: Unlike most of Ansible, nearly all strings in
62        :class:`TerminalBase` plugins are byte strings.  This is because of
63        how close to the underlying platform these plugins operate.  Remember
64        to mark literal strings as byte string (``b"string"``) and to use
65        :func:`~ansible.module_utils._text.to_bytes` and
66        :func:`~ansible.module_utils._text.to_text` to avoid unexpected
67        problems.
68
69        List of supported rpc's:
70            :get: Retrieves running configuration and device state information
71            :get_config: Retrieves the specified configuration from the device
72            :edit_config: Loads the specified commands into the remote device
73            :commit: Load configuration from candidate to running
74            :discard_changes: Discard changes to candidate datastore
75            :validate: Validate the contents of the specified configuration.
76            :lock: Allows the client to lock the configuration system of a device.
77            :unlock: Release a configuration lock, previously obtained with the lock operation.
78            :copy_config: create or replace an entire configuration datastore with the contents of another complete
79                          configuration datastore.
80            :get-schema: Retrieves the required schema from the device
81            :get_capabilities: Retrieves device information and supported rpc methods
82
83            For JUNOS:
84            :execute_rpc: RPC to be execute on remote device
85            :load_configuration: Loads given configuration on device
86
87        Note: rpc support depends on the capabilites of remote device.
88
89        :returns: Returns output received from remote device as byte string
90        Note: the 'result' or 'error' from response should to be converted to object
91              of ElementTree using 'fromstring' to parse output as xml doc
92
93              'get_capabilities()' returns 'result' as a json string.
94
95            Usage:
96            from ansible.module_utils.connection import Connection
97
98            conn = Connection()
99            data = conn.execute_rpc(rpc)
100            reply = fromstring(reply)
101
102            data = conn.get_capabilities()
103            json.loads(data)
104
105            conn.load_configuration(config=[''set system ntp server 1.1.1.1''], action='set', format='text')
106    """
107
108    __rpc__ = ['rpc', 'get_config', 'get', 'edit_config', 'validate', 'copy_config', 'dispatch', 'lock', 'unlock',
109               'discard_changes', 'commit', 'get_schema', 'delete_config', 'get_device_operations']
110
111    def __init__(self, connection):
112        super(NetconfBase, self).__init__()
113        self._connection = connection
114
115    @property
116    def m(self):
117        return self._connection.manager
118
119    def rpc(self, name):
120        """
121        RPC to be execute on remote device
122        :param name: Name of rpc in string format
123        :return: Received rpc response from remote host
124        """
125        try:
126            obj = to_ele(name)
127            resp = self.m.rpc(obj)
128            return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
129        except RPCError as exc:
130            msg = exc.xml
131            raise Exception(to_xml(msg))
132
133    def get_config(self, source=None, filter=None):
134        """
135        Retrieve all or part of a specified configuration
136        (by default entire configuration is retrieved).
137        :param source: Name of the configuration datastore being queried, defaults to running datastore
138        :param filter: This argument specifies the portion of the configuration data to retrieve
139        :return: Returns xml string containing the RPC response received from remote host
140        """
141        if isinstance(filter, list):
142            filter = tuple(filter)
143
144        if not source:
145            source = 'running'
146        resp = self.m.get_config(source=source, filter=filter)
147        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
148
149    def get(self, filter=None, with_defaults=None):
150        """
151        Retrieve device configuration and state information.
152        :param filter: This argument specifies the portion of the state data to retrieve
153                       (by default entire state data is retrieved)
154        :param with_defaults: defines an explicit method of retrieving default values
155                              from the configuration
156        :return: Returns xml string containing the RPC response received from remote host
157        """
158        if isinstance(filter, list):
159            filter = tuple(filter)
160        resp = self.m.get(filter=filter, with_defaults=with_defaults)
161        response = resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
162        return response
163
164    def edit_config(self, config=None, format='xml', target='candidate', default_operation=None, test_option=None, error_option=None):
165        """
166        Loads all or part of the specified *config* to the *target* configuration datastore.
167        :param config: Is the configuration, which must be rooted in the `config` element.
168                       It can be specified either as a string or an :class:`~xml.etree.ElementTree.Element`.
169        :param format: The format of configuration eg. xml, text
170        :param target: Is the name of the configuration datastore being edited
171        :param default_operation: If specified must be one of { `"merge"`, `"replace"`, or `"none"` }
172        :param test_option: If specified must be one of { `"test_then_set"`, `"set"` }
173        :param error_option: If specified must be one of { `"stop-on-error"`, `"continue-on-error"`, `"rollback-on-error"` }
174                             The `"rollback-on-error"` *error_option* depends on the `:rollback-on-error` capability.
175        :return: Returns xml string containing the RPC response received from remote host
176        """
177        if config is None:
178            raise ValueError('config value must be provided')
179        resp = self.m.edit_config(config, format=format, target=target, default_operation=default_operation, test_option=test_option,
180                                  error_option=error_option)
181        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
182
183    def validate(self, source='candidate'):
184        """
185        Validate the contents of the specified configuration.
186        :param source: Is the name of the configuration datastore being validated or `config` element
187                       containing the configuration subtree to be validated
188        :return: Returns xml string containing the RPC response received from remote host
189        """
190        resp = self.m.validate(source=source)
191        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
192
193    def copy_config(self, source, target):
194        """
195        Create or replace an entire configuration datastore with the contents of another complete configuration datastore.
196        :param source: Is the name of the configuration datastore to use as the source of the copy operation or `config`
197                       element containing the configuration subtree to copy
198        :param target: Is the name of the configuration datastore to use as the destination of the copy operation
199        :return: Returns xml string containing the RPC response received from remote host
200        """
201        resp = self.m.copy_config(source, target)
202        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
203
204    def dispatch(self, rpc_command=None, source=None, filter=None):
205        """
206        Execute rpc on the remote device eg. dispatch('clear-arp-table')
207        :param rpc_command: specifies rpc command to be dispatched either in plain text or in xml element format (depending on command)
208        :param source: name of the configuration datastore being queried
209        :param filter: specifies the portion of the configuration to retrieve (by default entire configuration is retrieved)
210        :return: Returns xml string containing the RPC response received from remote host
211        """
212        if rpc_command is None:
213            raise ValueError('rpc_command value must be provided')
214
215        resp = self.m.dispatch(fromstring(rpc_command), source=source, filter=filter)
216
217        if isinstance(resp, NCElement):
218            # In case xml reply is transformed or namespace is removed in
219            # ncclient device specific handler return modified xml response
220            result = resp.data_xml
221        elif hasattr(resp, 'data_ele') and resp.data_ele:
222            # if data node is present in xml response return the xml string
223            # with data node as root
224            result = resp.data_xml
225        else:
226            # return raw xml string received from host with rpc-reply as the root node
227            result = resp.xml
228
229        return result
230
231    def lock(self, target="candidate"):
232        """
233        Allows the client to lock the configuration system of a device.
234        :param target: is the name of the configuration datastore to lock,
235                        defaults to candidate datastore
236        :return: Returns xml string containing the RPC response received from remote host
237        """
238        resp = self.m.lock(target=target)
239        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
240
241    def unlock(self, target="candidate"):
242        """
243        Release a configuration lock, previously obtained with the lock operation.
244        :param target: is the name of the configuration datastore to unlock,
245                       defaults to candidate datastore
246        :return: Returns xml string containing the RPC response received from remote host
247        """
248        resp = self.m.unlock(target=target)
249        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
250
251    def discard_changes(self):
252        """
253        Revert the candidate configuration to the currently running configuration.
254        Any uncommitted changes are discarded.
255        :return: Returns xml string containing the RPC response received from remote host
256        """
257        resp = self.m.discard_changes()
258        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
259
260    def commit(self, confirmed=False, timeout=None, persist=None):
261        """
262        Commit the candidate configuration as the device's new current configuration.
263        Depends on the `:candidate` capability.
264        A confirmed commit (i.e. if *confirmed* is `True`) is reverted if there is no
265        followup commit within the *timeout* interval. If no timeout is specified the
266        confirm timeout defaults to 600 seconds (10 minutes).
267        A confirming commit may have the *confirmed* parameter but this is not required.
268        Depends on the `:confirmed-commit` capability.
269        :param confirmed: whether this is a confirmed commit
270        :param timeout: specifies the confirm timeout in seconds
271        :param persist: make the confirmed commit survive a session termination,
272                        and set a token on the ongoing confirmed commit
273        :return: Returns xml string containing the RPC response received from remote host
274        """
275        resp = self.m.commit(confirmed=confirmed, timeout=timeout, persist=persist)
276        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
277
278    def get_schema(self, identifier=None, version=None, format=None):
279        """
280        Retrieve a named schema, with optional revision and type.
281        :param identifier: name of the schema to be retrieved
282        :param version: version of schema to get
283        :param format: format of the schema to be retrieved, yang is the default
284        :return: Returns xml string containing the RPC response received from remote host
285        """
286        resp = self.m.get_schema(identifier, version=version, format=format)
287        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
288
289    def delete_config(self, target):
290        """
291        delete a configuration datastore
292        :param target: specifies the  name or URL of configuration datastore to delete
293        :return: Returns xml string containing the RPC response received from remote host
294        """
295        resp = self.m.delete_config(target)
296        return resp.data_xml if hasattr(resp, 'data_xml') else resp.xml
297
298    def locked(self, target):
299        return self.m.locked(target)
300
301    @abstractmethod
302    def get_capabilities(self):
303        """
304        Retrieves device information and supported
305        rpc methods by device platform and return result
306        as a string
307        :return: Netconf session capability
308        """
309        pass
310
311    @staticmethod
312    def guess_network_os(obj):
313        """
314        Identifies the operating system of network device.
315        :param obj: ncclient manager connection instance
316        :return: The name of network operating system.
317        """
318        pass
319
320    def get_base_rpc(self):
321        """
322        Returns list of base rpc method supported by remote device
323        :return: List of RPC supported
324        """
325        return self.__rpc__
326
327    def put_file(self, source, destination):
328        """
329        Copies file to remote host
330        :param source: Source location of file
331        :param destination: Destination file path
332        :return: Returns xml string containing the RPC response received from remote host
333        """
334        pass
335
336    def fetch_file(self, source, destination):
337        """
338        Fetch file from remote host
339        :param source: Source location of file
340        :param destination: Source location of file
341        :return: Returns xml string containing the RPC response received from remote host
342        """
343        pass
344
345    def get_device_operations(self, server_capabilities):
346        """
347        Retrieve remote host capability from Netconf server hello message.
348        :param server_capabilities: Server capabilities received during Netconf session initialization
349        :return: Remote host capabilities in dictionary format
350        """
351        operations = {}
352        capabilities = '\n'.join(server_capabilities)
353        operations['supports_commit'] = ':candidate' in capabilities
354        operations['supports_defaults'] = ':with-defaults' in capabilities
355        operations['supports_confirm_commit'] = ':confirmed-commit' in capabilities
356        operations['supports_startup'] = ':startup' in capabilities
357        operations['supports_xpath'] = ':xpath' in capabilities
358        operations['supports_writable_running'] = ':writable-running' in capabilities
359        operations['supports_validate'] = ':validate' in capabilities
360
361        operations['lock_datastore'] = []
362        if operations['supports_writable_running']:
363            operations['lock_datastore'].append('running')
364
365        if operations['supports_commit']:
366            operations['lock_datastore'].append('candidate')
367
368        if operations['supports_startup']:
369            operations['lock_datastore'].append('startup')
370
371        operations['supports_lock'] = bool(operations['lock_datastore'])
372
373        return operations
374
375# TODO Restore .xml, when ncclient supports it for all platforms
376