1# 2# This code is part of Ansible, but is an independent component. 3# 4# This particular file snippet, and this file snippet only, is BSD licensed. 5# Modules you write using this snippet, which is embedded dynamically by Ansible 6# still belong to the author of the module, and may assign their own license 7# to the complete work. 8# 9# (c) 2017 Red Hat, Inc. 10# 11# Redistribution and use in source and binary forms, with or without modification, 12# are permitted provided that the following conditions are met: 13# 14# * Redistributions of source code must retain the above copyright 15# notice, this list of conditions and the following disclaimer. 16# * Redistributions in binary form must reproduce the above copyright notice, 17# this list of conditions and the following disclaimer in the documentation 18# and/or other materials provided with the distribution. 19# 20# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 23# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 24# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 25# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 28# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29# 30import json 31import os 32import time 33 34from ansible.module_utils._text import to_text 35from ansible.module_utils.basic import env_fallback 36from ansible.module_utils.connection import Connection, ConnectionError 37from ansible.module_utils.network.common.config import NetworkConfig, dumps 38from ansible.module_utils.network.common.utils import to_list, ComplexList 39from ansible.module_utils.six import iteritems 40from ansible.module_utils.urls import fetch_url 41 42_DEVICE_CONNECTION = None 43 44eos_provider_spec = { 45 'host': dict(), 46 'port': dict(type='int'), 47 'username': dict(fallback=(env_fallback, ['ANSIBLE_NET_USERNAME'])), 48 'password': dict(fallback=(env_fallback, ['ANSIBLE_NET_PASSWORD']), no_log=True), 49 'ssh_keyfile': dict(fallback=(env_fallback, ['ANSIBLE_NET_SSH_KEYFILE']), type='path'), 50 51 'authorize': dict(fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), 52 'auth_pass': dict(no_log=True, fallback=(env_fallback, ['ANSIBLE_NET_AUTH_PASS'])), 53 54 'use_ssl': dict(default=True, type='bool'), 55 'use_proxy': dict(default=True, type='bool'), 56 'validate_certs': dict(default=True, type='bool'), 57 'timeout': dict(type='int'), 58 59 'transport': dict(default='cli', choices=['cli', 'eapi']) 60} 61eos_argument_spec = { 62 'provider': dict(type='dict', options=eos_provider_spec), 63} 64eos_top_spec = { 65 'host': dict(removed_in_version=2.9), 66 'port': dict(removed_in_version=2.9, type='int'), 67 'username': dict(removed_in_version=2.9), 68 'password': dict(removed_in_version=2.9, no_log=True), 69 'ssh_keyfile': dict(removed_in_version=2.9, type='path'), 70 71 'authorize': dict(fallback=(env_fallback, ['ANSIBLE_NET_AUTHORIZE']), type='bool'), 72 'auth_pass': dict(removed_in_version=2.9, no_log=True), 73 74 'use_ssl': dict(removed_in_version=2.9, type='bool'), 75 'validate_certs': dict(removed_in_version=2.9, type='bool'), 76 'timeout': dict(removed_in_version=2.9, type='int'), 77 78 'transport': dict(removed_in_version=2.9, choices=['cli', 'eapi']) 79} 80eos_argument_spec.update(eos_top_spec) 81 82 83def get_provider_argspec(): 84 return eos_provider_spec 85 86 87def check_args(module, warnings): 88 pass 89 90 91def load_params(module): 92 provider = module.params.get('provider') or dict() 93 for key, value in iteritems(provider): 94 if key in eos_argument_spec: 95 if module.params.get(key) is None and value is not None: 96 module.params[key] = value 97 98 99def get_connection(module): 100 global _DEVICE_CONNECTION 101 if not _DEVICE_CONNECTION: 102 load_params(module) 103 if is_local_eapi(module): 104 conn = LocalEapi(module) 105 else: 106 connection_proxy = Connection(module._socket_path) 107 cap = json.loads(connection_proxy.get_capabilities()) 108 if cap['network_api'] == 'cliconf': 109 conn = Cli(module) 110 elif cap['network_api'] == 'eapi': 111 conn = HttpApi(module) 112 _DEVICE_CONNECTION = conn 113 return _DEVICE_CONNECTION 114 115 116class Cli: 117 118 def __init__(self, module): 119 self._module = module 120 self._device_configs = {} 121 self._session_support = None 122 self._connection = None 123 124 @property 125 def supports_sessions(self): 126 if self._session_support is None: 127 self._session_support = self._get_connection().supports_sessions() 128 return self._session_support 129 130 def _get_connection(self): 131 if self._connection: 132 return self._connection 133 self._connection = Connection(self._module._socket_path) 134 135 return self._connection 136 137 def get_config(self, flags=None): 138 """Retrieves the current config from the device or cache 139 """ 140 flags = [] if flags is None else flags 141 142 cmd = 'show running-config ' 143 cmd += ' '.join(flags) 144 cmd = cmd.strip() 145 146 try: 147 return self._device_configs[cmd] 148 except KeyError: 149 conn = self._get_connection() 150 try: 151 out = conn.get_config(flags=flags) 152 except ConnectionError as exc: 153 self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) 154 155 cfg = to_text(out, errors='surrogate_then_replace').strip() 156 self._device_configs[cmd] = cfg 157 return cfg 158 159 def run_commands(self, commands, check_rc=True): 160 """Run list of commands on remote device and return results 161 """ 162 connection = self._get_connection() 163 try: 164 response = connection.run_commands(commands=commands, check_rc=check_rc) 165 except ConnectionError as exc: 166 self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) 167 return response 168 169 def load_config(self, commands, commit=False, replace=False): 170 """Loads the config commands onto the remote device 171 """ 172 conn = self._get_connection() 173 try: 174 response = conn.edit_config(commands, commit, replace) 175 except ConnectionError as exc: 176 message = getattr(exc, 'err', to_text(exc)) 177 if "check mode is not supported without configuration session" in message: 178 self._module.warn("EOS can not check config without config session") 179 response = {'changed': True} 180 else: 181 self._module.fail_json(msg="%s" % message, data=to_text(message, errors='surrogate_then_replace')) 182 183 return response 184 185 def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): 186 conn = self._get_connection() 187 try: 188 diff = conn.get_diff(candidate=candidate, running=running, diff_match=diff_match, diff_ignore_lines=diff_ignore_lines, path=path, 189 diff_replace=diff_replace) 190 except ConnectionError as exc: 191 self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) 192 return diff 193 194 def get_capabilities(self): 195 """Returns platform info of the remove device 196 """ 197 if hasattr(self._module, '_capabilities'): 198 return self._module._capabilities 199 200 connection = self._get_connection() 201 try: 202 capabilities = connection.get_capabilities() 203 except ConnectionError as exc: 204 self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) 205 self._module._capabilities = json.loads(capabilities) 206 return self._module._capabilities 207 208 209class LocalEapi: 210 211 def __init__(self, module): 212 self._module = module 213 self._enable = None 214 self._session_support = None 215 self._device_configs = {} 216 217 host = module.params['provider']['host'] 218 port = module.params['provider']['port'] 219 220 self._module.params['url_username'] = self._module.params['username'] 221 self._module.params['url_password'] = self._module.params['password'] 222 223 if module.params['provider']['use_ssl']: 224 proto = 'https' 225 else: 226 proto = 'http' 227 228 module.params['validate_certs'] = module.params['provider']['validate_certs'] 229 230 self._url = '%s://%s:%s/command-api' % (proto, host, port) 231 232 if module.params['auth_pass']: 233 self._enable = {'cmd': 'enable', 'input': module.params['auth_pass']} 234 else: 235 self._enable = 'enable' 236 237 @property 238 def supports_sessions(self): 239 if self._session_support is None: 240 response = self.send_request(['show configuration sessions']) 241 self._session_support = 'error' not in response 242 return self._session_support 243 244 def _request_builder(self, commands, output, reqid=None): 245 params = dict(version=1, cmds=commands, format=output) 246 return dict(jsonrpc='2.0', id=reqid, method='runCmds', params=params) 247 248 def send_request(self, commands, output='text'): 249 commands = to_list(commands) 250 251 if self._enable: 252 commands.insert(0, self._enable) 253 254 body = self._request_builder(commands, output) 255 data = self._module.jsonify(body) 256 257 headers = {'Content-Type': 'application/json-rpc'} 258 timeout = self._module.params['timeout'] 259 use_proxy = self._module.params['provider']['use_proxy'] 260 261 response, headers = fetch_url( 262 self._module, self._url, data=data, headers=headers, 263 method='POST', timeout=timeout, use_proxy=use_proxy 264 ) 265 266 if headers['status'] != 200: 267 self._module.fail_json(**headers) 268 269 try: 270 data = response.read() 271 response = self._module.from_json(to_text(data, errors='surrogate_then_replace')) 272 except ValueError: 273 self._module.fail_json(msg='unable to load response from device', data=data) 274 275 if self._enable and 'result' in response: 276 response['result'].pop(0) 277 278 return response 279 280 def run_commands(self, commands, check_rc=True): 281 """Runs list of commands on remote device and returns results 282 """ 283 output = None 284 queue = list() 285 responses = list() 286 287 def _send(commands, output): 288 response = self.send_request(commands, output=output) 289 if 'error' in response: 290 err = response['error'] 291 self._module.fail_json(msg=err['message'], code=err['code']) 292 return response['result'] 293 294 for item in to_list(commands): 295 if is_json(item['command']): 296 item['command'] = str(item['command']).replace('| json', '') 297 item['output'] = 'json' 298 299 if output and output != item['output']: 300 responses.extend(_send(queue, output)) 301 queue = list() 302 303 output = item['output'] or 'json' 304 queue.append(item['command']) 305 306 if queue: 307 responses.extend(_send(queue, output)) 308 309 for index, item in enumerate(commands): 310 try: 311 responses[index] = responses[index]['output'].strip() 312 except KeyError: 313 pass 314 315 return responses 316 317 def get_config(self, flags=None): 318 """Retrieves the current config from the device or cache 319 """ 320 flags = [] if flags is None else flags 321 322 cmd = 'show running-config ' 323 cmd += ' '.join(flags) 324 cmd = cmd.strip() 325 326 try: 327 return self._device_configs[cmd] 328 except KeyError: 329 out = self.send_request(cmd) 330 cfg = str(out['result'][0]['output']).strip() 331 self._device_configs[cmd] = cfg 332 return cfg 333 334 def configure(self, commands): 335 """Sends the ordered set of commands to the device 336 """ 337 cmds = ['configure terminal'] 338 cmds.extend(commands) 339 340 responses = self.send_request(commands) 341 if 'error' in responses: 342 err = responses['error'] 343 self._module.fail_json(msg=err['message'], code=err['code']) 344 345 return responses[1:] 346 347 def load_config(self, config, commit=False, replace=False): 348 """Loads the configuration onto the remote devices 349 350 If the device doesn't support configuration sessions, this will 351 fallback to using configure() to load the commands. If that happens, 352 there will be no returned diff or session values 353 """ 354 use_session = os.getenv('ANSIBLE_EOS_USE_SESSIONS', True) 355 try: 356 use_session = int(use_session) 357 except ValueError: 358 pass 359 360 if not all((bool(use_session), self.supports_sessions)): 361 if commit: 362 return self.configure(config) 363 else: 364 self._module.warn("EOS can not check config without config session") 365 result = {'changed': True} 366 return result 367 368 session = 'ansible_%s' % int(time.time()) 369 result = {'session': session} 370 commands = ['configure session %s' % session] 371 372 if replace: 373 commands.append('rollback clean-config') 374 375 commands.extend(config) 376 377 response = self.send_request(commands) 378 if 'error' in response: 379 commands = ['configure session %s' % session, 'abort'] 380 self.send_request(commands) 381 err = response['error'] 382 error_text = [] 383 for data in err['data']: 384 error_text.extend(data.get('errors', [])) 385 error_text = '\n'.join(error_text) or err['message'] 386 self._module.fail_json(msg=error_text, code=err['code']) 387 388 commands = ['configure session %s' % session, 'show session-config diffs'] 389 if commit: 390 commands.append('commit') 391 else: 392 commands.append('abort') 393 394 response = self.send_request(commands, output='text') 395 diff = response['result'][1]['output'] 396 if len(diff) > 0: 397 result['diff'] = diff 398 399 return result 400 401 # get_diff added here to support connection=local and transport=eapi scenario 402 def get_diff(self, candidate, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): 403 diff = {} 404 405 # prepare candidate configuration 406 candidate_obj = NetworkConfig(indent=3) 407 candidate_obj.load(candidate) 408 409 if running and diff_match != 'none' and diff_replace != 'config': 410 # running configuration 411 running_obj = NetworkConfig(indent=3, contents=running, ignore_lines=diff_ignore_lines) 412 configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) 413 414 else: 415 configdiffobjs = candidate_obj.items 416 417 configdiff = dumps(configdiffobjs, 'commands') if configdiffobjs else '' 418 diff['config_diff'] = configdiff if configdiffobjs else {} 419 return diff 420 421 422class HttpApi: 423 def __init__(self, module): 424 self._module = module 425 self._device_configs = {} 426 self._session_support = None 427 self._connection_obj = None 428 429 @property 430 def _connection(self): 431 if not self._connection_obj: 432 self._connection_obj = Connection(self._module._socket_path) 433 434 return self._connection_obj 435 436 @property 437 def supports_sessions(self): 438 if self._session_support is None: 439 self._session_support = self._connection.supports_sessions() 440 return self._session_support 441 442 def run_commands(self, commands, check_rc=True): 443 """Runs list of commands on remote device and returns results 444 """ 445 output = None 446 queue = list() 447 responses = list() 448 449 def run_queue(queue, output): 450 try: 451 response = to_list(self._connection.send_request(queue, output=output)) 452 except ConnectionError as exc: 453 if check_rc: 454 raise 455 return to_list(to_text(exc)) 456 457 if output == 'json': 458 response = [json.loads(item) for item in response] 459 return response 460 461 for item in to_list(commands): 462 cmd_output = 'text' 463 if isinstance(item, dict): 464 command = item['command'] 465 if 'output' in item: 466 cmd_output = item['output'] 467 else: 468 command = item 469 470 # Emulate '| json' from CLI 471 if is_json(command): 472 command = command.rsplit('|', 1)[0] 473 cmd_output = 'json' 474 475 if output and output != cmd_output: 476 responses.extend(run_queue(queue, output)) 477 queue = list() 478 479 output = cmd_output 480 queue.append(command) 481 482 if queue: 483 responses.extend(run_queue(queue, output)) 484 485 return responses 486 487 def get_config(self, flags=None): 488 """Retrieves the current config from the device or cache 489 """ 490 flags = [] if flags is None else flags 491 492 cmd = 'show running-config ' 493 cmd += ' '.join(flags) 494 cmd = cmd.strip() 495 496 try: 497 return self._device_configs[cmd] 498 except KeyError: 499 try: 500 out = self._connection.send_request(cmd) 501 except ConnectionError as exc: 502 self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) 503 504 cfg = to_text(out).strip() 505 self._device_configs[cmd] = cfg 506 return cfg 507 508 def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): 509 diff = {} 510 511 # prepare candidate configuration 512 candidate_obj = NetworkConfig(indent=3) 513 candidate_obj.load(candidate) 514 515 if running and diff_match != 'none' and diff_replace != 'config': 516 # running configuration 517 running_obj = NetworkConfig(indent=3, contents=running, ignore_lines=diff_ignore_lines) 518 configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) 519 520 else: 521 configdiffobjs = candidate_obj.items 522 523 diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else {} 524 return diff 525 526 def load_config(self, config, commit=False, replace=False): 527 """Loads the configuration onto the remote devices 528 529 If the device doesn't support configuration sessions, this will 530 fallback to using configure() to load the commands. If that happens, 531 there will be no returned diff or session values 532 """ 533 return self.edit_config(config, commit, replace) 534 535 def edit_config(self, config, commit=False, replace=False): 536 """Loads the configuration onto the remote devices 537 538 If the device doesn't support configuration sessions, this will 539 fallback to using configure() to load the commands. If that happens, 540 there will be no returned diff or session values 541 """ 542 session = 'ansible_%s' % int(time.time()) 543 result = {'session': session} 544 banner_cmd = None 545 banner_input = [] 546 547 commands = ['configure session %s' % session] 548 if replace: 549 commands.append('rollback clean-config') 550 551 for command in config: 552 if command.startswith('banner'): 553 banner_cmd = command 554 banner_input = [] 555 elif banner_cmd: 556 if command == 'EOF': 557 command = {'cmd': banner_cmd, 'input': '\n'.join(banner_input)} 558 banner_cmd = None 559 commands.append(command) 560 else: 561 banner_input.append(command) 562 continue 563 else: 564 commands.append(command) 565 566 try: 567 response = self._connection.send_request(commands) 568 except Exception: 569 commands = ['configure session %s' % session, 'abort'] 570 response = self._connection.send_request(commands, output='text') 571 raise 572 573 commands = ['configure session %s' % session, 'show session-config diffs'] 574 if commit: 575 commands.append('commit') 576 else: 577 commands.append('abort') 578 579 response = self._connection.send_request(commands, output='text') 580 diff = response[1].strip() 581 if diff: 582 result['diff'] = diff 583 584 return result 585 586 def get_capabilities(self): 587 """Returns platform info of the remove device 588 """ 589 try: 590 capabilities = self._connection.get_capabilities() 591 except ConnectionError as exc: 592 self._module.fail_json(msg=to_text(exc, errors='surrogate_then_replace')) 593 594 return json.loads(capabilities) 595 596 597def is_json(cmd): 598 return to_text(cmd, errors='surrogate_then_replace').endswith('| json') 599 600 601def is_local_eapi(module): 602 transports = [] 603 transports.append(module.params.get('transport', "")) 604 provider = module.params.get('provider') 605 if provider: 606 transports.append(provider.get('transport', "")) 607 return 'eapi' in transports 608 609 610def to_command(module, commands): 611 if is_local_eapi(module): 612 default_output = 'json' 613 else: 614 default_output = 'text' 615 616 transform = ComplexList(dict( 617 command=dict(key=True), 618 output=dict(default=default_output), 619 prompt=dict(type='list'), 620 answer=dict(type='list'), 621 newline=dict(type='bool', default=True), 622 sendonly=dict(type='bool', default=False), 623 check_all=dict(type='bool', default=False), 624 ), module) 625 626 return transform(to_list(commands)) 627 628 629def get_config(module, flags=None): 630 flags = None if flags is None else flags 631 632 conn = get_connection(module) 633 return conn.get_config(flags) 634 635 636def run_commands(module, commands, check_rc=True): 637 conn = get_connection(module) 638 return conn.run_commands(to_command(module, commands), check_rc=check_rc) 639 640 641def load_config(module, config, commit=False, replace=False): 642 conn = get_connection(module) 643 return conn.load_config(config, commit, replace) 644 645 646def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): 647 conn = self.get_connection() 648 return conn.get_diff(candidate=candidate, running=running, diff_match=diff_match, diff_ignore_lines=diff_ignore_lines, path=path, diff_replace=diff_replace) 649 650 651def get_capabilities(module): 652 conn = get_connection(module) 653 return conn.get_capabilities() 654