1# Copyright (c) 2019 Extreme Networks. 2# 3# This file is part of Ansible 4# 5# Ansible is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Ansible is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Ansible. If not, see <http://www.gnu.org/licenses/>. 17# 18 19from __future__ import (absolute_import, division, print_function) 20__metaclass__ = type 21 22DOCUMENTATION = """ 23--- 24author: 25 - "Ujwal Komarla (@ujwalkomarla)" 26httpapi: exos 27short_description: Use EXOS REST APIs to communicate with EXOS platform 28description: 29 - This plugin provides low level abstraction api's to send REST API 30 requests to EXOS network devices and receive JSON responses. 31version_added: "2.8" 32""" 33 34import json 35import re 36from ansible.module_utils._text import to_text 37from ansible.module_utils.connection import ConnectionError 38from ansible.module_utils.network.common.utils import to_list 39from ansible.plugins.httpapi import HttpApiBase 40import ansible.module_utils.six.moves.http_cookiejar as cookiejar 41from ansible.module_utils.common._collections_compat import Mapping 42from ansible.module_utils.network.common.config import NetworkConfig, dumps 43 44 45class HttpApi(HttpApiBase): 46 47 def __init__(self, *args, **kwargs): 48 super(HttpApi, self).__init__(*args, **kwargs) 49 self._device_info = None 50 self._auth_token = cookiejar.CookieJar() 51 52 def login(self, username, password): 53 auth_path = '/auth/token' 54 credentials = {'username': username, 'password': password} 55 self.send_request(path=auth_path, data=json.dumps(credentials), method='POST') 56 57 def logout(self): 58 pass 59 60 def handle_httperror(self, exc): 61 return False 62 63 def send_request(self, path, data=None, method='GET', **message_kwargs): 64 headers = {'Content-Type': 'application/json'} 65 response, response_data = self.connection.send(path, data, method=method, cookies=self._auth_token, headers=headers, **message_kwargs) 66 try: 67 if response.status == 204: 68 response_data = {} 69 else: 70 response_data = json.loads(to_text(response_data.getvalue())) 71 except ValueError: 72 raise ConnectionError('Response was not valid JSON, got {0}'.format( 73 to_text(response_data.getvalue()) 74 )) 75 return response_data 76 77 def run_commands(self, commands, check_rc=True): 78 if commands is None: 79 raise ValueError("'commands' value is required") 80 81 headers = {'Content-Type': 'application/json'} 82 responses = list() 83 for cmd in to_list(commands): 84 if not isinstance(cmd, Mapping): 85 cmd = {'command': cmd} 86 87 cmd['command'] = strip_run_script_cli2json(cmd['command']) 88 89 output = cmd.pop('output', None) 90 if output and output not in self.get_option_values().get('output'): 91 raise ValueError("'output' value is %s is invalid. Valid values are %s" % (output, ','.join(self.get_option_values().get('output')))) 92 93 data = request_builder(cmd['command']) 94 95 response, response_data = self.connection.send('/jsonrpc', data, cookies=self._auth_token, headers=headers, method='POST') 96 try: 97 response_data = json.loads(to_text(response_data.getvalue())) 98 except ValueError: 99 raise ConnectionError('Response was not valid JSON, got {0}'.format( 100 to_text(response_data.getvalue()) 101 )) 102 103 if response_data.get('error', None): 104 raise ConnectionError("Request Error, got {0}".format(response_data['error'])) 105 if not response_data.get('result', None): 106 raise ConnectionError("Request Error, got {0}".format(response_data)) 107 108 response_data = response_data['result'] 109 110 if output and output == 'text': 111 statusOut = getKeyInResponse(response_data, 'status') 112 cliOut = getKeyInResponse(response_data, 'CLIoutput') 113 if statusOut == "ERROR": 114 raise ConnectionError("Command error({1}) for request {0}".format(cmd['command'], cliOut)) 115 if cliOut is None: 116 raise ValueError("Response for request {0} doesn't have the CLIoutput field, got {1}".format(cmd['command'], response_data)) 117 response_data = cliOut 118 119 responses.append(response_data) 120 return responses 121 122 def get_device_info(self): 123 device_info = {} 124 device_info['network_os'] = 'exos' 125 126 reply = self.run_commands({'command': 'show switch detail', 'output': 'text'}) 127 data = to_text(reply, errors='surrogate_or_strict').strip() 128 129 match = re.search(r'ExtremeXOS version (\S+)', data) 130 if match: 131 device_info['network_os_version'] = match.group(1) 132 133 match = re.search(r'System Type: +(\S+)', data) 134 if match: 135 device_info['network_os_model'] = match.group(1) 136 137 match = re.search(r'SysName: +(\S+)', data) 138 if match: 139 device_info['network_os_hostname'] = match.group(1) 140 141 return device_info 142 143 def get_device_operations(self): 144 return { 145 'supports_diff_replace': False, # identify if config should be merged or replaced is supported 146 'supports_commit': False, # identify if commit is supported by device or not 147 'supports_rollback': False, # identify if rollback is supported or not 148 'supports_defaults': True, # identify if fetching running config with default is supported 149 'supports_commit_comment': False, # identify if adding comment to commit is supported of not 150 'supports_onbox_diff': False, # identify if on box diff capability is supported or not 151 'supports_generate_diff': True, # identify if diff capability is supported within plugin 152 'supports_multiline_delimiter': False, # identify if multiline demiliter is supported within config 153 'supports_diff_match': True, # identify if match is supported 154 'supports_diff_ignore_lines': True, # identify if ignore line in diff is supported 155 'supports_config_replace': False, # identify if running config replace with candidate config is supported 156 'supports_admin': False, # identify if admin configure mode is supported or not 157 'supports_commit_label': False # identify if commit label is supported or not 158 } 159 160 def get_option_values(self): 161 return { 162 'format': ['text', 'json'], 163 'diff_match': ['line', 'strict', 'exact', 'none'], 164 'diff_replace': ['line', 'block'], 165 'output': ['text', 'json'] 166 } 167 168 def get_capabilities(self): 169 result = {} 170 result['rpc'] = ['get_default_flag', 'run_commands', 'get_config', 'send_request', 'get_capabilities', 'get_diff'] 171 result['device_info'] = self.get_device_info() 172 result['device_operations'] = self.get_device_operations() 173 result.update(self.get_option_values()) 174 result['network_api'] = 'exosapi' 175 return json.dumps(result) 176 177 def get_default_flag(self): 178 # The flag to modify the command to collect configuration with defaults 179 return 'detail' 180 181 def get_diff(self, candidate=None, running=None, diff_match='line', diff_ignore_lines=None, path=None, diff_replace='line'): 182 diff = {} 183 device_operations = self.get_device_operations() 184 option_values = self.get_option_values() 185 186 if candidate is None and device_operations['supports_generate_diff']: 187 raise ValueError("candidate configuration is required to generate diff") 188 189 if diff_match not in option_values['diff_match']: 190 raise ValueError("'match' value %s in invalid, valid values are %s" % (diff_match, ', '.join(option_values['diff_match']))) 191 192 if diff_replace not in option_values['diff_replace']: 193 raise ValueError("'replace' value %s in invalid, valid values are %s" % (diff_replace, ', '.join(option_values['diff_replace']))) 194 195 # prepare candidate configuration 196 candidate_obj = NetworkConfig(indent=1) 197 candidate_obj.load(candidate) 198 199 if running and diff_match != 'none' and diff_replace != 'config': 200 # running configuration 201 running_obj = NetworkConfig(indent=1, contents=running, ignore_lines=diff_ignore_lines) 202 configdiffobjs = candidate_obj.difference(running_obj, path=path, match=diff_match, replace=diff_replace) 203 204 else: 205 configdiffobjs = candidate_obj.items 206 207 diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' 208 return diff 209 210 def get_config(self, source='running', format='text', flags=None): 211 options_values = self.get_option_values() 212 if format not in options_values['format']: 213 raise ValueError("'format' value %s is invalid. Valid values are %s" % (format, ','.join(options_values['format']))) 214 215 lookup = {'running': 'show configuration', 'startup': 'debug cfgmgr show configuration file'} 216 if source not in lookup: 217 raise ValueError("fetching configuration from %s is not supported" % source) 218 219 cmd = {'command': lookup[source], 'output': 'text'} 220 221 if source == 'startup': 222 reply = self.run_commands({'command': 'show switch', 'format': 'text'}) 223 data = to_text(reply, errors='surrogate_or_strict').strip() 224 match = re.search(r'Config Selected: +(\S+)\.cfg', data, re.MULTILINE) 225 if match: 226 cmd['command'] += match.group(1) 227 else: 228 # No Startup(/Selected) Config 229 return {} 230 231 cmd['command'] += ' '.join(to_list(flags)) 232 cmd['command'] = cmd['command'].strip() 233 234 return self.run_commands(cmd)[0] 235 236 237def request_builder(command, reqid=""): 238 return json.dumps(dict(jsonrpc='2.0', id=reqid, method='cli', params=to_list(command))) 239 240 241def strip_run_script_cli2json(command): 242 if to_text(command, errors="surrogate_then_replace").startswith('run script cli2json.py'): 243 command = str(command).replace('run script cli2json.py', '') 244 return command 245 246 247def getKeyInResponse(response, key): 248 keyOut = None 249 for item in response: 250 if key in item: 251 keyOut = item[key] 252 break 253 return keyOut 254