1# -------------------------------------------------------------------------------------------- 2# Copyright (c) Microsoft Corporation. All rights reserved. 3# Licensed under the MIT License. See License.txt in the project root for license information. 4# -------------------------------------------------------------------------------------------- 5 6from enum import Enum 7 8import azure.cli.core.telemetry as telemetry 9from knack.log import get_logger 10 11 12logger = get_logger(__name__) 13 14 15class AladdinUserFaultType(Enum): 16 """Define the userfault types required by aladdin service 17 to get the command recommendations""" 18 19 ExpectedArgument = 'ExpectedArgument' 20 UnrecognizedArguments = 'UnrecognizedArguments' 21 ValidationError = 'ValidationError' 22 UnknownSubcommand = 'UnknownSubcommand' 23 MissingRequiredParameters = 'MissingRequiredParameters' 24 MissingRequiredSubcommand = 'MissingRequiredSubcommand' 25 StorageAccountNotFound = 'StorageAccountNotFound' 26 Unknown = 'Unknown' 27 InvalidJMESPathQuery = 'InvalidJMESPathQuery' 28 InvalidOutputType = 'InvalidOutputType' 29 InvalidParameterValue = 'InvalidParameterValue' 30 UnableToParseCommandInput = 'UnableToParseCommandInput' 31 ResourceGroupNotFound = 'ResourceGroupNotFound' 32 InvalidDateTimeArgumentValue = 'InvalidDateTimeArgumentValue' 33 InvalidResourceGroupName = 'InvalidResourceGroupName' 34 AzureResourceNotFound = 'AzureResourceNotFound' 35 InvalidAccountName = 'InvalidAccountName' 36 37 38def get_error_type(error_msg): 39 """The the error type of the failed command from the error message. 40 The error types are only consumed by aladdin service for better recommendations. 41 """ 42 43 error_type = AladdinUserFaultType.Unknown 44 if not error_msg: 45 return error_type.value 46 47 error_msg = error_msg.lower() 48 if 'unrecognized' in error_msg: 49 error_type = AladdinUserFaultType.UnrecognizedArguments 50 elif 'expected one argument' in error_msg or 'expected at least one argument' in error_msg \ 51 or 'value required' in error_msg: 52 error_type = AladdinUserFaultType.ExpectedArgument 53 elif 'misspelled' in error_msg: 54 error_type = AladdinUserFaultType.UnknownSubcommand 55 elif 'arguments are required' in error_msg or 'argument required' in error_msg: 56 error_type = AladdinUserFaultType.MissingRequiredParameters 57 if '_subcommand' in error_msg: 58 error_type = AladdinUserFaultType.MissingRequiredSubcommand 59 elif '_command_package' in error_msg: 60 error_type = AladdinUserFaultType.UnableToParseCommandInput 61 elif 'not found' in error_msg or 'could not be found' in error_msg \ 62 or 'resource not found' in error_msg: 63 error_type = AladdinUserFaultType.AzureResourceNotFound 64 if 'storage_account' in error_msg or 'storage account' in error_msg: 65 error_type = AladdinUserFaultType.StorageAccountNotFound 66 elif 'resource_group' in error_msg or 'resource group' in error_msg: 67 error_type = AladdinUserFaultType.ResourceGroupNotFound 68 elif 'pattern' in error_msg or 'is not a valid value' in error_msg or 'invalid' in error_msg: 69 error_type = AladdinUserFaultType.InvalidParameterValue 70 if 'jmespath_type' in error_msg: 71 error_type = AladdinUserFaultType.InvalidJMESPathQuery 72 elif 'datetime_type' in error_msg: 73 error_type = AladdinUserFaultType.InvalidDateTimeArgumentValue 74 elif '--output' in error_msg: 75 error_type = AladdinUserFaultType.InvalidOutputType 76 elif 'resource_group' in error_msg: 77 error_type = AladdinUserFaultType.InvalidResourceGroupName 78 elif 'storage_account' in error_msg: 79 error_type = AladdinUserFaultType.InvalidAccountName 80 elif "validation error" in error_msg: 81 error_type = AladdinUserFaultType.ValidationError 82 83 return error_type.value 84 85 86class CommandRecommender(): # pylint: disable=too-few-public-methods 87 """Recommend a command for user when user's command fails. 88 It combines Aladdin recommendations and examples in help files.""" 89 90 def __init__(self, command, parameters, extension, error_msg, cli_ctx): 91 """ 92 :param command: The command name in user's input. 93 :type command: str 94 :param parameters: The raw parameters in users input. 95 :type parameters: list 96 :param extension: The extension name in user's input if the command comes from an extension. 97 :type extension: str 98 :param error_msg: The error message of the failed command. 99 :type error_msg: str 100 :param cli_ctx: CLI context when parser fails. 101 :type cli_ctx: knack.cli.CLI 102 """ 103 self.command = command.strip() 104 self.parameters = parameters 105 self.extension = extension 106 self.error_msg = error_msg 107 self.cli_ctx = cli_ctx 108 # the item is a dict with the form {'command': #, 'description': #} 109 self.help_examples = [] 110 # the item is a dict with the form {'command': #, 'description': #, 'link': #} 111 self.aladdin_recommendations = [] 112 113 def set_help_examples(self, examples): 114 """Set help examples. 115 116 :param examples: The examples from CLI help file. 117 :type examples: list 118 """ 119 120 self.help_examples.extend(examples) 121 122 def _set_aladdin_recommendations(self): # pylint: disable=too-many-locals 123 """Set Aladdin recommendations. 124 Call the API, parse the response and set aladdin_recommendations. 125 """ 126 127 import hashlib 128 import json 129 import requests 130 from requests import RequestException 131 from http import HTTPStatus 132 from azure.cli.core import __version__ as version 133 134 api_url = 'https://app.aladdin.microsoft.com/api/v1.0/suggestions' 135 correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access 136 subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access 137 # Used for DDOS protection and rate limiting 138 user_id = telemetry._get_user_azure_id() # pylint: disable=protected-access 139 hashed_user_id = hashlib.sha256(user_id.encode('utf-8')).hexdigest() 140 141 headers = { 142 'Content-Type': 'application/json', 143 'X-UserId': hashed_user_id 144 } 145 context = { 146 'versionNumber': version, 147 'errorType': get_error_type(self.error_msg) 148 } 149 150 if telemetry.is_telemetry_enabled(): 151 if correlation_id: 152 context['correlationId'] = correlation_id 153 if subscription_id: 154 context['subscriptionId'] = subscription_id 155 156 parameters = self._normalize_parameters(self.parameters) 157 parameters = [item for item in set(parameters) if item not in ['--debug', '--verbose', '--only-show-errors']] 158 query = { 159 "command": self.command, 160 "parameters": ','.join(parameters) 161 } 162 163 response = None 164 try: 165 response = requests.get( 166 api_url, 167 params={ 168 'query': json.dumps(query), 169 'clientType': 'AzureCli', 170 'context': json.dumps(context) 171 }, 172 headers=headers, 173 timeout=1) 174 telemetry.set_debug_info('AladdinResponseTime', response.elapsed.total_seconds()) 175 176 except RequestException as ex: 177 logger.debug('Recommendation requests.get() exception: %s', ex) 178 telemetry.set_debug_info('AladdinException', ex.__class__.__name__) 179 180 recommendations = [] 181 if response and response.status_code == HTTPStatus.OK: 182 for result in response.json(): 183 # parse the response to get the raw command 184 raw_command = 'az {} '.format(result['command']) 185 for parameter, placeholder in zip(result['parameters'].split(','), result['placeholders'].split('♠')): 186 raw_command += '{} {}{}'.format(parameter, placeholder, ' ' if placeholder else '') 187 188 # format the recommendation 189 recommendation = { 190 'command': raw_command.strip(), 191 'description': result['description'], 192 'link': result['link'] 193 } 194 recommendations.append(recommendation) 195 196 self.aladdin_recommendations.extend(recommendations) 197 198 def provide_recommendations(self): 199 """Provide recommendations when a command fails. 200 201 The recommendations are either from Aladdin service or CLI help examples, 202 which include both commands and reference links along with their descriptions. 203 204 :return: The decorated recommendations 205 :type: list 206 """ 207 208 from azure.cli.core.style import Style, highlight_command 209 from azure.cli.core.parser import OVERVIEW_REFERENCE 210 211 def sort_recommendations(recommendations): 212 """Sort the recommendations by parameter matching. 213 214 The sorting rules below are applied in order: 215 1. Commands starting with the user's input command name are ahead of those don't 216 2. Commands having more matched arguments are ahead of those having less 217 3. Commands having less arguments are ahead of those having more 218 219 :param recommendations: The unordered recommendations 220 :type recommendations: list 221 :return: The ordered recommendations 222 :type: list 223 """ 224 225 candidates = [] 226 target_arg_list = self._normalize_parameters(self.parameters) 227 for recommendation in recommendations: 228 matches = 0 229 arg_list = self._normalize_parameters(recommendation['command'].split(' ')) 230 231 # ignore commands that do not start with the use's input command name 232 if recommendation['command'].startswith('az {}'.format(self.command)): 233 for arg in arg_list: 234 if arg in target_arg_list: 235 matches += 1 236 else: 237 matches = -1 238 239 candidates.append({ 240 'recommendation': recommendation, 241 'arg_list': arg_list, 242 'matches': matches 243 }) 244 245 # sort the candidates by the number of matched arguments and total arguments 246 candidates.sort(key=lambda item: (item['matches'], -len(item['arg_list'])), reverse=True) 247 248 return [candidate['recommendation'] for candidate in candidates] 249 250 def replace_param_values(command): # pylint: disable=unused-variable 251 """Replace the parameter values in a command with user's input values 252 253 :param command: The command whose parameter value needs to be replaced 254 :type command: str 255 :return: The command with parameter values being replaced 256 :type: str 257 """ 258 259 # replace the parameter values only when the recommended 260 # command's name is the same with user's input command name 261 if not command.startswith('az {}'.format(self.command)): 262 return command 263 264 source_kwargs = get_parameter_kwargs(self.parameters) 265 param_mappings = self._get_param_mappings() 266 267 return replace_parameter_values(command, source_kwargs, param_mappings) 268 269 # do not recommend commands if it is disabled by config 270 if self.cli_ctx and self.cli_ctx.config.get('core', 'error_recommendation', 'on').upper() == 'OFF': 271 return [] 272 273 # get recommendations from Aladdin service 274 if not self._disable_aladdin_service(): 275 self._set_aladdin_recommendations() 276 277 # recommendations are either all from Aladdin or all from help examples 278 recommendations = self.aladdin_recommendations 279 if not recommendations: 280 recommendations = self.help_examples 281 282 # sort the recommendations by parameter matching, get the top 3 recommended commands 283 recommendations = sort_recommendations(recommendations)[:3] 284 285 raw_commands = [] 286 decorated_recommendations = [] 287 for recommendation in recommendations: 288 # generate raw commands recorded in Telemetry 289 raw_command = recommendation['command'] 290 raw_commands.append(raw_command) 291 292 # disable the parameter replacement feature because it will make command description inaccurate 293 # raw_command = replace_param_values(raw_command) 294 295 # generate decorated commands shown to users 296 decorated_command = highlight_command(raw_command) 297 decorated_description = [( 298 Style.SECONDARY, 299 recommendation.get('description', 'No description is found.') + '\n' 300 )] 301 decorated_recommendations.append((decorated_command, decorated_description)) 302 303 # add reference link as a recommendation 304 decorated_link = [(Style.HYPERLINK, OVERVIEW_REFERENCE)] 305 if self.aladdin_recommendations: 306 decorated_link = [(Style.HYPERLINK, self.aladdin_recommendations[0]['link'])] 307 308 decorated_description = [(Style.SECONDARY, 'Read more about the command in reference docs')] 309 decorated_recommendations.append((decorated_link, decorated_description)) 310 311 # set the recommend command into Telemetry 312 self._set_recommended_command_to_telemetry(raw_commands) 313 314 return decorated_recommendations 315 316 def _set_recommended_command_to_telemetry(self, raw_commands): 317 """Set the recommended commands to Telemetry 318 319 Aladdin recommended commands and commands from CLI help examples are 320 set to different properties in Telemetry. 321 322 :param raw_commands: The recommended raw commands 323 :type raw_commands: list 324 """ 325 326 if self.aladdin_recommendations: 327 telemetry.set_debug_info('AladdinRecommendCommand', ';'.join(raw_commands)) 328 else: 329 telemetry.set_debug_info('ExampleRecommendCommand', ';'.join(raw_commands)) 330 331 def _disable_aladdin_service(self): 332 """Decide whether to disable aladdin request when a command fails. 333 334 The possible cases to disable it are: 335 1. CLI context is missing 336 2. In air-gapped clouds 337 3. In testing environments 338 339 :return: whether Aladdin service need to be disabled or not 340 :type: bool 341 """ 342 343 from azure.cli.core.cloud import CLOUDS_FORBIDDING_ALADDIN_REQUEST 344 345 # CLI is not started well 346 if not self.cli_ctx or not self.cli_ctx.cloud: 347 return True 348 349 # for air-gapped clouds 350 if self.cli_ctx.cloud.name in CLOUDS_FORBIDDING_ALADDIN_REQUEST: 351 return True 352 353 # for testing environments 354 if self.cli_ctx.__class__.__name__ == 'DummyCli': 355 return True 356 357 return False 358 359 def _normalize_parameters(self, args): 360 """Normalize a parameter list. 361 362 Get the standard parameter name list of the raw parameters, which includes: 363 1. Use long options to replace short options 364 2. Remove the unrecognized parameter names 365 3. Sort the parameter names by their lengths 366 An example: ['-g', 'RG', '-n', 'NAME'] ==> ['--resource-group', '--name'] 367 368 :param args: The raw arg list of a command 369 :type args: list 370 :return: A standard, valid and sorted parameter name list 371 :type: list 372 """ 373 374 from azure.cli.core.commands import AzCliCommandInvoker 375 376 parameters = AzCliCommandInvoker._extract_parameter_names(args) # pylint: disable=protected-access 377 normalized_parameters = [] 378 379 param_mappings = self._get_param_mappings() 380 for parameter in parameters: 381 if parameter in param_mappings: 382 normalized_form = param_mappings.get(parameter, None) or parameter 383 normalized_parameters.append(normalized_form) 384 else: 385 logger.debug('"%s" is an invalid parameter for command "%s".', parameter, self.command) 386 387 return sorted(normalized_parameters) 388 389 def _get_param_mappings(self): 390 try: 391 cmd_table = self.cli_ctx.invocation.commands_loader.command_table.get(self.command, None) 392 except AttributeError: 393 cmd_table = None 394 395 return get_parameter_mappings(cmd_table) 396 397 398def get_parameter_mappings(command_table): 399 """Get the short option to long option mappings of a command 400 401 :param parameter_table: CLI command object 402 :type parameter_table: knack.commands.CLICommand 403 :param command_name: The command name 404 :type command name: str 405 :return: The short to long option mappings of the parameters 406 :type: dict 407 """ 408 409 from knack.deprecation import Deprecated 410 411 parameter_table = None 412 if hasattr(command_table, 'arguments'): 413 parameter_table = command_table.arguments 414 415 param_mappings = { 416 '-h': '--help', 417 '-o': '--output', 418 '--only-show-errors': None, 419 '--help': None, 420 '--output': None, 421 '--query': None, 422 '--debug': None, 423 '--verbose': None, 424 '--yes': None, 425 '--no-wait': None 426 } 427 428 if parameter_table: 429 for argument in parameter_table.values(): 430 options = argument.type.settings['options_list'] 431 options = [option for option in options if not isinstance(option, Deprecated)] 432 # skip the positional arguments 433 if not options: 434 continue 435 try: 436 sorted_options = sorted(options, key=len, reverse=True) 437 standard_form = sorted_options[0] 438 439 for option in sorted_options[1:]: 440 param_mappings[option] = standard_form 441 param_mappings[standard_form] = standard_form 442 except TypeError: 443 logger.debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) 444 445 return param_mappings 446 447 448def get_parameter_kwargs(args): 449 """Get parameter name-value mappings from the raw arg list 450 An example: ['-g', 'RG', '--name=NAME'] ==> {'-g': 'RG', '--name': 'NAME'} 451 452 :param args: The raw arg list of a command 453 :type args: list 454 :return: The parameter name-value mappings 455 :type: dict 456 """ 457 458 parameter_kwargs = dict() 459 for index, parameter in enumerate(args): 460 if parameter.startswith('-'): 461 462 param_name, param_val = parameter, None 463 if '=' in parameter: 464 pieces = parameter.split('=') 465 param_name, param_val = pieces[0], pieces[1] 466 elif index + 1 < len(args) and not args[index + 1].startswith('-'): 467 param_val = args[index + 1] 468 469 if param_val is not None and ' ' in param_val: 470 param_val = '"{}"'.format(param_val) 471 parameter_kwargs[param_name] = param_val 472 473 return parameter_kwargs 474 475 476def replace_parameter_values(target_command, source_kwargs, param_mappings): 477 """Replace the parameter values in target_command with values in source_kwargs 478 479 :param target_command: The command in which the parameter values need to be replaced 480 :type target_command: str 481 :param source_kwargs: The source key-val pairs used to replace the values 482 :type source_kwargs: dict 483 :param param_mappings: The short-long option mappings in terms of the target_command 484 :type param_mappings: dict 485 :returns: The target command with parameter values being replaced 486 :type: str 487 """ 488 489 def get_user_param_value(target_param): 490 """Get the value that is used as the replaced value of target_param 491 492 :param target_param: The parameter name whose value needs to be replaced 493 :type target_param: str 494 :return: The replaced value for target_param 495 :type: str 496 """ 497 standard_source_kwargs = dict() 498 499 for param, val in source_kwargs.items(): 500 if param in param_mappings: 501 standard_param = param_mappings[param] 502 standard_source_kwargs[standard_param] = val 503 504 if target_param in param_mappings: 505 standard_target_param = param_mappings[target_param] 506 if standard_target_param in standard_source_kwargs: 507 return standard_source_kwargs[standard_target_param] 508 509 return None 510 511 command_args = target_command.split(' ') 512 for index, arg in enumerate(command_args): 513 if arg.startswith('-') and index + 1 < len(command_args) and not command_args[index + 1].startswith('-'): 514 user_param_val = get_user_param_value(arg) 515 if user_param_val: 516 command_args[index + 1] = user_param_val 517 518 return ' '.join(command_args) 519