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.plugins import AnsiblePlugin
26from ansible.errors import AnsibleError, AnsibleConnectionFailure
27from ansible.module_utils._text import to_bytes, to_text
28
29try:
30    from scp import SCPClient
31    HAS_SCP = True
32except ImportError:
33    HAS_SCP = False
34
35
36def enable_mode(func):
37    @wraps(func)
38    def wrapped(self, *args, **kwargs):
39        prompt = self._connection.get_prompt()
40        if not to_text(prompt, errors='surrogate_or_strict').strip().endswith('#'):
41            raise AnsibleError('operation requires privilege escalation')
42        return func(self, *args, **kwargs)
43    return wrapped
44
45
46class CliconfBase(AnsiblePlugin):
47    """
48    A base class for implementing cli connections
49
50    .. note:: String inputs to :meth:`send_command` will be cast to byte strings
51         within this method and as such are not required to be made byte strings
52         beforehand.  Please avoid using literal byte strings (``b'string'``) in
53         :class:`CliConfBase` plugins as this can lead to unexpected errors when
54         running on Python 3
55
56    List of supported rpc's:
57        :get_config: Retrieves the specified configuration from the device
58        :edit_config: Loads the specified commands into the remote device
59        :get: Execute specified command on remote device
60        :get_capabilities: Retrieves device information and supported rpc methods
61        :commit: Load configuration from candidate to running
62        :discard_changes: Discard changes to candidate datastore
63
64    Note: List of supported rpc's for remote device can be extracted from
65          output of get_capabilities()
66
67    :returns: Returns output received from remote device as byte string
68
69            Usage:
70            from ansible.module_utils.connection import Connection
71
72            conn = Connection()
73            conn.get('show lldp neighbors detail'')
74            conn.get_config('running')
75            conn.edit_config(['hostname test', 'netconf ssh'])
76    """
77
78    __rpc__ = ['get_config', 'edit_config', 'get_capabilities', 'get', 'enable_response_logging', 'disable_response_logging']
79
80    def __init__(self, connection):
81        super(CliconfBase, self).__init__()
82        self._connection = connection
83        self.history = list()
84        self.response_logging = False
85
86    def _alarm_handler(self, signum, frame):
87        """Alarm handler raised in case of command timeout """
88        self._connection.queue_message('log', 'closing shell due to command timeout (%s seconds).' % self._connection._play_context.timeout)
89        self.close()
90
91    def send_command(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False, check_all=False):
92        """Executes a command over the device connection
93
94        This method will execute a command over the device connection and
95        return the results to the caller.  This method will also perform
96        logging of any commands based on the `nolog` argument.
97
98        :param command: The command to send over the connection to the device
99        :param prompt: A single regex pattern or a sequence of patterns to evaluate the expected prompt from the command
100        :param answer: The answer to respond with if the prompt is matched.
101        :param sendonly: Bool value that will send the command but not wait for a result.
102        :param newline: Bool value that will append the newline character to the command
103        :param prompt_retry_check: Bool value for trying to detect more prompts
104        :param check_all: Bool value to indicate if all the values in prompt sequence should be matched or any one of
105                          given prompt.
106        :returns: The output from the device after executing the command
107        """
108        kwargs = {
109            'command': to_bytes(command),
110            'sendonly': sendonly,
111            'newline': newline,
112            'prompt_retry_check': prompt_retry_check,
113            'check_all': check_all
114        }
115
116        if prompt is not None:
117            if isinstance(prompt, list):
118                kwargs['prompt'] = [to_bytes(p) for p in prompt]
119            else:
120                kwargs['prompt'] = to_bytes(prompt)
121        if answer is not None:
122            if isinstance(answer, list):
123                kwargs['answer'] = [to_bytes(p) for p in answer]
124            else:
125                kwargs['answer'] = to_bytes(answer)
126
127        resp = self._connection.send(**kwargs)
128
129        if not self.response_logging:
130            self.history.append(('*****', '*****'))
131        else:
132            self.history.append((kwargs['command'], resp))
133
134        return resp
135
136    def get_base_rpc(self):
137        """Returns list of base rpc method supported by remote device"""
138        return self.__rpc__
139
140    def get_history(self):
141        """ Returns the history file for all commands
142
143        This will return a log of all the commands that have been sent to
144        the device and all of the output received.  By default, all commands
145        and output will be redacted unless explicitly configured otherwise.
146
147        :return: An ordered list of command, output pairs
148        """
149        return self.history
150
151    def reset_history(self):
152        """ Resets the history of run commands
153        :return: None
154        """
155        self.history = list()
156
157    def enable_response_logging(self):
158        """Enable logging command response"""
159        self.response_logging = True
160
161    def disable_response_logging(self):
162        """Disable logging command response"""
163        self.response_logging = False
164
165    @abstractmethod
166    def get_config(self, source='running', flags=None, format=None):
167        """Retrieves the specified configuration from the device
168
169        This method will retrieve the configuration specified by source and
170        return it to the caller as a string.  Subsequent calls to this method
171        will retrieve a new configuration from the device
172
173        :param source: The configuration source to return from the device.
174            This argument accepts either `running` or `startup` as valid values.
175
176        :param flags: For devices that support configuration filtering, this
177            keyword argument is used to filter the returned configuration.
178            The use of this keyword argument is device dependent adn will be
179            silently ignored on devices that do not support it.
180
181        :param format: For devices that support fetching different configuration
182            format, this keyword argument is used to specify the format in which
183            configuration is to be retrieved.
184
185        :return: The device configuration as specified by the source argument.
186        """
187        pass
188
189    @abstractmethod
190    def edit_config(self, candidate=None, commit=True, replace=None, diff=False, comment=None):
191        """Loads the candidate configuration into the network device
192
193        This method will load the specified candidate config into the device
194        and merge with the current configuration unless replace is set to
195        True.  If the device does not support config replace an errors
196        is returned.
197
198        :param candidate: The configuration to load into the device and merge
199            with the current running configuration
200
201        :param commit: Boolean value that indicates if the device candidate
202            configuration should be  pushed in the running configuration or discarded.
203
204        :param replace: If the value is True/False it indicates if running configuration should be completely
205                        replace by candidate configuration. If can also take configuration file path as value,
206                        the file in this case should be present on the remote host in the mentioned path as a
207                        prerequisite.
208        :param comment: Commit comment provided it is supported by remote host
209        :return: Returns a json string with contains configuration applied on remote host, the returned
210                 response on executing configuration commands and platform relevant data.
211               {
212                   "diff": "",
213                   "response": [],
214                   "request": []
215               }
216
217        """
218        pass
219
220    @abstractmethod
221    def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, output=None, check_all=False):
222        """Execute specified command on remote device
223        This method will retrieve the specified data and
224        return it to the caller as a string.
225        :param command: command in string format to be executed on remote device
226        :param prompt: the expected prompt generated by executing command, this can
227                       be a string or a list of strings
228        :param answer: the string to respond to the prompt with
229        :param sendonly: bool to disable waiting for response, default is false
230        :param newline: bool to indicate if newline should be added at end of answer or not
231        :param output: For devices that support fetching command output in different
232                       format, this keyword argument is used to specify the output in which
233                        response is to be retrieved.
234        :param check_all: Bool value to indicate if all the values in prompt sequence should be matched or any one of
235                          given prompt.
236        :return: The output from the device after executing the command
237        """
238        pass
239
240    @abstractmethod
241    def get_capabilities(self):
242        """Returns the basic capabilities of the network device
243        This method will provide some basic facts about the device and
244        what capabilities it has to modify the configuration.  The minimum
245        return from this method takes the following format.
246        eg:
247            {
248
249                'rpc': [list of supported rpcs],
250                'network_api': <str>,            # the name of the transport
251                'device_info': {
252                    'network_os': <str>,
253                    'network_os_version': <str>,
254                    'network_os_model': <str>,
255                    'network_os_hostname': <str>,
256                    'network_os_image': <str>,
257                    'network_os_platform': <str>,
258                },
259                'device_operations': {
260                    'supports_diff_replace': <bool>,       # identify if config should be merged or replaced is supported
261                    'supports_commit': <bool>,             # identify if commit is supported by device or not
262                    'supports_rollback': <bool>,           # identify if rollback is supported or not
263                    'supports_defaults': <bool>,           # identify if fetching running config with default is supported
264                    'supports_commit_comment': <bool>,     # identify if adding comment to commit is supported of not
265                    'supports_onbox_diff: <bool>,          # identify if on box diff capability is supported or not
266                    'supports_generate_diff: <bool>,       # identify if diff capability is supported within plugin
267                    'supports_multiline_delimiter: <bool>, # identify if multiline demiliter is supported within config
268                    'supports_diff_match: <bool>,          # identify if match is supported
269                    'supports_diff_ignore_lines: <bool>,   # identify if ignore line in diff is supported
270                    'supports_config_replace': <bool>,     # identify if running config replace with candidate config is supported
271                    'supports_admin': <bool>,              # identify if admin configure mode is supported or not
272                    'supports_commit_label': <bool>,       # identify if commit label is supported or not
273                }
274                'format': [list of supported configuration format],
275                'diff_match': [list of supported match values],
276                'diff_replace': [list of supported replace values],
277                'output': [list of supported command output format]
278            }
279        :return: capability as json string
280        """
281        result = {}
282        result['rpc'] = self.get_base_rpc()
283        result['device_info'] = self.get_device_info()
284        result['network_api'] = 'cliconf'
285        return result
286
287    @abstractmethod
288    def get_device_info(self):
289        """Returns basic information about the network device.
290
291        This method will provide basic information about the device such as OS version and model
292        name. This data is expected to be used to fill the 'device_info' key in get_capabilities()
293        above.
294
295        :return: dictionary of device information
296        """
297        pass
298
299    def commit(self, comment=None):
300        """Commit configuration changes
301
302        This method will perform the commit operation on a previously loaded
303        candidate configuration that was loaded using `edit_config()`.  If
304        there is a candidate configuration, it will be committed to the
305        active configuration.  If there is not a candidate configuration, this
306        method should just silently return.
307
308        :return: None
309        """
310        return self._connection.method_not_found("commit is not supported by network_os %s" % self._play_context.network_os)
311
312    def discard_changes(self):
313        """Discard candidate configuration
314
315        This method will discard the current candidate configuration if one
316        is present.  If there is no candidate configuration currently loaded,
317        then this method should just silently return
318
319        :returns: None
320        """
321        return self._connection.method_not_found("discard_changes is not supported by network_os %s" % self._play_context.network_os)
322
323    def rollback(self, rollback_id, commit=True):
324        """
325
326        :param rollback_id: The commit id to which configuration should be rollbacked
327        :param commit: Flag to indicate if changes should be committed or not
328        :return: Returns diff between before and after change.
329        """
330        pass
331
332    def copy_file(self, source=None, destination=None, proto='scp', timeout=30):
333        """Copies file over scp/sftp to remote device
334
335        :param source: Source file path
336        :param destination: Destination file path on remote device
337        :param proto: Protocol to be used for file transfer,
338                      supported protocol: scp and sftp
339        :param timeout: Specifies the wait time to receive response from
340                        remote host before triggering timeout exception
341        :return: None
342        """
343        ssh = self._connection.paramiko_conn._connect_uncached()
344        if proto == 'scp':
345            if not HAS_SCP:
346                raise AnsibleError("Required library scp is not installed.  Please install it using `pip install scp`")
347            with SCPClient(ssh.get_transport(), socket_timeout=timeout) as scp:
348                out = scp.put(source, destination)
349        elif proto == 'sftp':
350            with ssh.open_sftp() as sftp:
351                sftp.put(source, destination)
352
353    def get_file(self, source=None, destination=None, proto='scp', timeout=30):
354        """Fetch file over scp/sftp from remote device
355        :param source: Source file path
356        :param destination: Destination file path
357        :param proto: Protocol to be used for file transfer,
358                      supported protocol: scp and sftp
359        :param timeout: Specifies the wait time to receive response from
360                        remote host before triggering timeout exception
361        :return: None
362        """
363        """Fetch file over scp/sftp from remote device"""
364        ssh = self._connection.paramiko_conn._connect_uncached()
365        if proto == 'scp':
366            if not HAS_SCP:
367                raise AnsibleError("Required library scp is not installed.  Please install it using `pip install scp`")
368            try:
369                with SCPClient(ssh.get_transport(), socket_timeout=timeout) as scp:
370                    scp.get(source, destination)
371            except EOFError:
372                # This appears to be benign.
373                pass
374        elif proto == 'sftp':
375            with ssh.open_sftp() as sftp:
376                sftp.get(source, destination)
377
378    def get_diff(self, candidate=None, running=None, diff_match=None, diff_ignore_lines=None, path=None, diff_replace=None):
379        """
380        Generate diff between candidate and running configuration. If the
381        remote host supports onbox diff capabilities ie. supports_onbox_diff in that case
382        candidate and running configurations are not required to be passed as argument.
383        In case if onbox diff capability is not supported candidate argument is mandatory
384        and running argument is optional.
385        :param candidate: The configuration which is expected to be present on remote host.
386        :param running: The base configuration which is used to generate diff.
387        :param diff_match: Instructs how to match the candidate configuration with current device configuration
388                      Valid values are 'line', 'strict', 'exact', 'none'.
389                      'line' - commands are matched line by line
390                      'strict' - command lines are matched with respect to position
391                      'exact' - command lines must be an equal match
392                      'none' - will not compare the candidate configuration with the running configuration
393        :param diff_ignore_lines: Use this argument to specify one or more lines that should be
394                                  ignored during the diff.  This is used for lines in the configuration
395                                  that are automatically updated by the system.  This argument takes
396                                  a list of regular expressions or exact line matches.
397        :param path: The ordered set of parents that uniquely identify the section or hierarchy
398                     the commands should be checked against.  If the parents argument
399                     is omitted, the commands are checked against the set of top
400                    level or global commands.
401        :param diff_replace: Instructs on the way to perform the configuration on the device.
402                        If the replace argument is set to I(line) then the modified lines are
403                        pushed to the device in configuration mode.  If the replace argument is
404                        set to I(block) then the entire command block is pushed to the device in
405                        configuration mode if any line is not correct.
406        :return: Configuration and/or banner diff in json format.
407               {
408                   'config_diff': ''
409               }
410
411        """
412        pass
413
414    def run_commands(self, commands=None, check_rc=True):
415        """
416        Execute a list of commands on remote host and return the list of response
417        :param commands: The list of command that needs to be executed on remote host.
418                The individual command in list can either be a command string or command dict.
419                If the command is dict the valid keys are
420                {
421                    'command': <command to be executed>
422                    'prompt': <expected prompt on executing the command>,
423                    'answer': <answer for the prompt>,
424                    'output': <the format in which command output should be rendered eg: 'json', 'text'>,
425                    'sendonly': <Boolean flag to indicate if it command execution response should be ignored or not>
426                }
427        :param check_rc: Boolean flag to check if returned response should be checked for error or not.
428                         If check_rc is False the error output is appended in return response list, else if the
429                         value is True an exception is raised.
430        :return: List of returned response
431        """
432        pass
433
434    def check_edit_config_capability(self, operations, candidate=None, commit=True, replace=None, comment=None):
435
436        if not candidate and not replace:
437            raise ValueError("must provide a candidate or replace to load configuration")
438
439        if commit not in (True, False):
440            raise ValueError("'commit' must be a bool, got %s" % commit)
441
442        if replace and not operations['supports_replace']:
443            raise ValueError("configuration replace is not supported")
444
445        if comment and not operations.get('supports_commit_comment', False):
446            raise ValueError("commit comment is not supported")
447
448        if replace and not operations.get('supports_replace', False):
449            raise ValueError("configuration replace is not supported")
450
451    def set_cli_prompt_context(self):
452        """
453        Ensure the command prompt on device is in right mode
454        :return: None
455        """
456        pass
457
458    def _update_cli_prompt_context(self, config_context=None, exit_command='exit'):
459        """
460        Update the cli prompt context to ensure it is in operational mode
461        :param config_context: It is string value to identify if the current cli prompt ends with config mode prompt
462        :param exit_command: Command to execute to exit the config mode
463        :return: None
464        """
465        out = self._connection.get_prompt()
466        if out is None:
467            raise AnsibleConnectionFailure(message=u'cli prompt is not identified from the last received'
468                                                   u' response window: %s' % self._connection._last_recv_window)
469
470        while True:
471            out = to_text(out, errors='surrogate_then_replace').strip()
472            if config_context and out.endswith(config_context):
473                self._connection.queue_message('vvvv', 'wrong context, sending exit to device')
474                self.send_command(exit_command)
475                out = self._connection.get_prompt()
476            else:
477                break
478