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