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