diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de95fdd2b38..54a10128d86 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -120,4 +120,6 @@ /src/kusto/ @ilayr @orhasban @astauben +/src/ai-did-you-mean-this/ @christopher-o-toole + /src/custom-providers/ @jsntcy diff --git a/UpgradeLog.htm b/UpgradeLog.htm deleted file mode 100644 index 684ad31e818..00000000000 Binary files a/UpgradeLog.htm and /dev/null differ diff --git a/src/ai-did-you-mean-this/HISTORY.rst b/src/ai-did-you-mean-this/HISTORY.rst new file mode 100644 index 00000000000..0162192acb9 --- /dev/null +++ b/src/ai-did-you-mean-this/HISTORY.rst @@ -0,0 +1,10 @@ +.. :changelog: + +Release History +=============== + +0.1.0 +++++++ +* Initial release. +* Add autogenerated recommendations for recovery from UserFault failures. +* Ensure that the hook is activated in common UserFault failure scenarios. \ No newline at end of file diff --git a/src/ai-did-you-mean-this/README.md b/src/ai-did-you-mean-this/README.md new file mode 100644 index 00000000000..634db4ef845 --- /dev/null +++ b/src/ai-did-you-mean-this/README.md @@ -0,0 +1,91 @@ +# Microsoft Azure CLI 'AI Did You Mean This' Extension # + +### Installation ### +To install the extension, use the below CLI command +``` +az extension add --name ai-did-you-mean-this +``` + +### Background ### +The purpose of this extension is help users recover from failure. Once installed, failure recovery recommendations will automatically be provided for supported command failures. In cases where no recommendations are available, a prompt to use `az find` will be shown provided that the command can be matched to a valid CLI command. +### Limitations ### +For now, recommendations are limited to parser failures (i.e. not in a command group, argument required, unrecognized parameter, expected one argument, etc). Support for more core failures is planned for a future release. +### Try it out! ### +The following examples demonstrate how to trigger the extension. For a more complete list of supported CLI failure types, see this [CLI PR](https://github.com/Azure/azure-cli/pull/12889). Keep in mind that the recommendations shown here may be different from the ones that you receive. + +#### az account #### +``` +> az account +az account: error: the following arguments are required: _subcommand +usage: az account [-h] + {list,set,show,clear,list-locations,get-access-token,lock,management-group} + ... + +Here are the most common ways users succeeded after [account] failed: + az account list + az account show +``` + +#### az account set #### +``` +> az account set +az account set: error: the following arguments are required: --subscription/-s +usage: az account set [-h] [--verbose] [--debug] [--only-show-errors] + [--output {json,jsonc,yaml,yamlc,table,tsv,none}] + [--query JMESPATH] --subscription SUBSCRIPTION + +Here are the most common ways users succeeded after [account set] failed: + az account set --subscription Subscription +``` + +#### az group create #### +``` +>az group create +az group create: error: the following arguments are required: --name/--resource-group/-n/-g, --location/-l +usage: az group create [-h] [--verbose] [--debug] [--only-show-errors] + [--output {json,jsonc,yaml,yamlc,table,tsv,none}] + [--query JMESPATH] [--subscription _SUBSCRIPTION] + --name RG_NAME --location LOCATION + [--tags [TAGS [TAGS ...]]] [--managed-by MANAGED_BY] + +Here are the most common ways users succeeded after [group create] failed: + az group create --location westeurope --resource-group MyResourceGroup +``` +#### az vm list ### +``` +> az vm list --query ".id" +az vm list: error: argument --query: invalid jmespath_type value: '.id' +usage: az vm list [-h] [--verbose] [--debug] [--only-show-errors] + [--output {json,jsonc,yaml,yamlc,table,tsv,none}] + [--query JMESPATH] [--subscription _SUBSCRIPTION] + [--resource-group RESOURCE_GROUP_NAME] [--show-details] + +Sorry I am not able to help with [vm list] +Try running [az find "az vm list"] to see examples of [vm list] from other users. +``` +### Developer Build ### +If you want to try an experimental release of the extension, it is recommended you do so in a [Docker container](https://www.docker.com/resources/what-container). Keep in mind that you'll need to install Docker and pull the desired [Azure CLI image](https://hub.docker.com/_/microsoft-azure-cli) from the Microsoft Container Registry before proceeding. + +#### Setting up your Docker Image #### +To run the Azure CLI Docker image as an interactive shell, run the below command by replacing `` with your desired CLI version +```bash +docker run -it mcr.microsoft.com/azure-cli: +export EXT="ai-did-you-mean-this" +pip install --upgrade --target ~/.azure/cliextensions/$EXT "git+https://github.com/christopher-o-toole/azure-cli-extensions.git@thoth-extension#subdirectory=src/$EXT&egg=$EXT" +``` +Each time you start a new shell, you'll need to login before you can start using the extension. To do so, run +```bash +az login +``` +and follow the instructions given in the prompt. Once you're logged in, try out the extension by issuing a faulty command +``` +> az account +az account: error: the following arguments are required: _subcommand +usage: az account [-h] + {list,set,show,clear,list-locations,get-access-token,lock,management-group} + ... + +Here are the most common ways users succeeded after [account] failed: + az account list + az account show +``` \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py new file mode 100644 index 00000000000..8db2b74fe04 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/__init__.py @@ -0,0 +1,52 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.core import AzCommandsLoader + +from knack.events import ( + EVENT_INVOKER_CMD_TBL_LOADED +) + +from azext_ai_did_you_mean_this._help import helps # pylint: disable=unused-import +from azext_ai_did_you_mean_this._cmd_table import on_command_table_loaded + + +def inject_functions_into_core(): + from azure.cli.core.parser import AzCliCommandParser + from azext_ai_did_you_mean_this.custom import recommend_recovery_options + AzCliCommandParser.recommendation_provider = recommend_recovery_options + + +# pylint: disable=too-few-public-methods +class GlobalConfig(): + ENABLE_STYLING = False + + +class AiDidYouMeanThisCommandsLoader(AzCommandsLoader): + def __init__(self, cli_ctx=None): + from azure.cli.core.commands import CliCommandType + + ai_did_you_mean_this_custom = CliCommandType( + operations_tmpl='azext_ai_did_you_mean_this.custom#{}') + super().__init__(cli_ctx=cli_ctx, + custom_command_type=ai_did_you_mean_this_custom) + self.cli_ctx.register_event(EVENT_INVOKER_CMD_TBL_LOADED, on_command_table_loaded) + inject_functions_into_core() + # per https://github.com/Azure/azure-cli/pull/12601 + try: + GlobalConfig.ENABLE_STYLING = cli_ctx.enable_color + except AttributeError: + pass + + def load_command_table(self, args): + from azext_ai_did_you_mean_this.commands import load_command_table + load_command_table(self, args) + return self.command_table + + def load_arguments(self, command): + pass + + +COMMAND_LOADER_CLS = AiDidYouMeanThisCommandsLoader diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py new file mode 100644 index 00000000000..9d77a5db9bd --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_cmd_table.py @@ -0,0 +1,13 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +class CommandTable(): # pylint: disable=too-few-public-methods + CMD_TBL = None + + +def on_command_table_loaded(_, **kwargs): + cmd_tbl = kwargs.pop('cmd_tbl', None) + CommandTable.CMD_TBL = cmd_tbl diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py new file mode 100644 index 00000000000..238ec3151e6 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_const.py @@ -0,0 +1,34 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +UPDATE_RECOMMENDATION_STR = ( + "Better failure recovery recommendations are available from the latest version of the CLI. " + "Please update for the best experience.\n" +) + +UNABLE_TO_HELP_FMT_STR = ( + '\nSorry I am not able to help with [{command}]' + '\nTry running [az find "az {command}"] to see examples of [{command}] from other users.' +) + +RECOMMENDATION_HEADER_FMT_STR = ( + '\nHere are the most common ways users succeeded after [{command}] failed:' +) + +TELEMETRY_MUST_BE_ENABLED_STR = ( + 'User must agree to telemetry before failure recovery recommendations can be presented.' +) + +TELEMETRY_MISSING_SUBSCRIPTION_ID_STR = ( + "Subscription ID was not set in telemetry." +) + +TELEMETRY_MISSING_CORRELATION_ID_STR = ( + "Correlation ID was not set in telemetry." +) + +UNABLE_TO_CALL_SERVICE_STR = ( + 'Either the subscription ID or correlation ID was not set. Aborting operation.' +) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py new file mode 100644 index 00000000000..8c8e70d9a85 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_help.py @@ -0,0 +1,17 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps # pylint: disable=unused-import + +helps['ai-did-you-mean-this'] = """ + type: group + short-summary: Add recommendations for recovering from failure. +""" + +helps['ai-did-you-mean-this version'] = """ + type: command + short-summary: Prints the extension version. +""" diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py new file mode 100644 index 00000000000..f32e8452062 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/_style.py @@ -0,0 +1,28 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import sys + +from azext_ai_did_you_mean_this import GlobalConfig + + +def style_message(msg): + if should_enable_styling(): + import colorama # pylint: disable=import-error + + try: + msg = colorama.Style.BRIGHT + msg + colorama.Style.RESET_ALL + except KeyError: + pass + return msg + + +def should_enable_styling(): + try: + if GlobalConfig.ENABLE_STYLING and sys.stdout.isatty(): + return True + except AttributeError: + pass + return False diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json new file mode 100644 index 00000000000..6a44beb25b4 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.4.0" +} \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py new file mode 100644 index 00000000000..0732b3eba2c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/commands.py @@ -0,0 +1,12 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long + + +def load_command_table(self, _): + + with self.command_group('ai-did-you-mean-this', is_preview=True) as g: + g.custom_command('version', 'show_extension_version') diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py new file mode 100644 index 00000000000..ede97b1c7e4 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/custom.py @@ -0,0 +1,242 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +from http import HTTPStatus + +import requests +from requests import RequestException + +import azure.cli.core.telemetry as telemetry + +from knack.log import get_logger +from knack.util import CLIError # pylint: disable=unused-import + +from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation +from azext_ai_did_you_mean_this._style import style_message +from azext_ai_did_you_mean_this._const import ( + RECOMMENDATION_HEADER_FMT_STR, + UNABLE_TO_HELP_FMT_STR, + TELEMETRY_MUST_BE_ENABLED_STR, + TELEMETRY_MISSING_SUBSCRIPTION_ID_STR, + TELEMETRY_MISSING_CORRELATION_ID_STR, + UNABLE_TO_CALL_SERVICE_STR +) +from azext_ai_did_you_mean_this._cmd_table import CommandTable + +logger = get_logger(__name__) + + +# Commands +# note: at least one command is required in order for the CLI to load the extension. +def show_extension_version(): + print(f'Current version: 0.1.0') + + +def _log_debug(msg, *args, **kwargs): + # TODO: see if there's a way to change the log formatter locally without printing to stdout + msg = f'[Thoth]: {msg}' + logger.debug(msg, *args, **kwargs) + + +def get_parameter_table(cmd_table, command, recurse=True): + az_cli_command = cmd_table.get(command, None) + parameter_table = az_cli_command.arguments if az_cli_command else None + + # if the specified command was not found and recursive search is enabled... + if not az_cli_command and recurse: + # if there are at least two tokens separated by whitespace, remove the last token + last_delim_idx = command.rfind(' ') + if last_delim_idx != -1: + _log_debug('Removing unknown token "%s" from command.', command[last_delim_idx + 1:]) + # try to find the truncated command. + parameter_table, command = get_parameter_table(cmd_table, command[:last_delim_idx], recurse=False) + + return parameter_table, command + + +def normalize_and_sort_parameters(cmd_table, command, parameters): + from knack.deprecation import Deprecated + _log_debug('normalize_and_sort_parameters: command: "%s", parameters: "%s"', command, parameters) + + parameter_set = set() + parameter_table, command = get_parameter_table(cmd_table, command) + + if parameters: + # TODO: Avoid setting rules for global parameters manually. + rules = { + '-h': '--help', + '-o': '--output', + '--only-show-errors': None, + '--help': None, + '--output': None, + '--query': None, + '--debug': None, + '--verbose': None + } + + blocklisted = {'--debug', '--verbose'} + + if parameter_table: + for argument in parameter_table.values(): + options = argument.type.settings['options_list'] + # remove deprecated arguments. + options = (option for option in options if not isinstance(option, Deprecated)) + + # attempt to create a rule for each potential parameter. + try: + # sort parameters by decreasing length. + sorted_options = sorted(options, key=len, reverse=True) + # select the longest parameter as the standard form + standard_form = sorted_options[0] + + for option in sorted_options[1:]: + rules[option] = standard_form + + # don't apply any rules for the parameter's standard form. + rules[standard_form] = None + except TypeError: + # ignore cases in which one of the option objects is of an unsupported type. + _log_debug('Unexpected argument options `%s` of type `%s`.', options, type(options).__name__) + + for parameter in parameters: + if parameter in rules: + # normalize the parameter or do nothing if already normalized + normalized_form = rules.get(parameter, None) or parameter + # add the parameter to our result set + parameter_set.add(normalized_form) + else: + # ignore any parameters that we were unable to validate. + _log_debug('"%s" is an invalid parameter for command "%s".', parameter, command) + + # remove any special global parameters that would typically be removed by the CLI + parameter_set.difference_update(blocklisted) + + # get the list of parameters as a comma-separated list + return command, ','.join(sorted(parameter_set)) + + +def recommend_recovery_options(version, command, parameters, extension): + from timeit import default_timer as timer + start_time = timer() + elapsed_time = None + + result = [] + cmd_tbl = CommandTable.CMD_TBL + _log_debug('recommend_recovery_options: version: "%s", command: "%s", parameters: "%s", extension: "%s"', + version, command, parameters, extension) + + # if the user doesn't agree to telemetry... + if not telemetry.is_telemetry_enabled(): + _log_debug(TELEMETRY_MUST_BE_ENABLED_STR) + return result + + # if the command is empty... + if not command: + # try to get the raw command field from telemetry. + session = telemetry._session # pylint: disable=protected-access + # get the raw command parsed by the CommandInvoker object. + command = session.raw_command + if command: + _log_debug(f'Setting command to [{command}] from telemtry.') + + def append(line): + result.append(line) + + def unable_to_help(command): + msg = UNABLE_TO_HELP_FMT_STR.format(command=command) + append(msg) + + def show_recommendation_header(command): + msg = RECOMMENDATION_HEADER_FMT_STR.format(command=command) + append(style_message(msg)) + + if extension: + _log_debug('Detected extension. No action to perform.') + if not command: + _log_debug('Command is empty. No action to perform.') + + # if an extension is in-use or the command is empty... + if extension or not command: + return result + + # perform some rudimentary parsing to extract the parameters and command in a standard form + command, parameters = normalize_and_sort_parameters(cmd_tbl, command, parameters) + response = call_aladdin_service(command, parameters, version) + + # only show recommendations when we can contact the service. + if response and response.status_code == HTTPStatus.OK: + recommendations = get_recommendations_from_http_response(response) + + if recommendations: + show_recommendation_header(command) + + for recommendation in recommendations: + append(f"\t{recommendation}") + # only prompt user to use "az find" for valid CLI commands + # note: pylint has trouble resolving statically initialized variables, which is why + # we need to disable the unsupported membership test rule + elif any(cmd.startswith(command) for cmd in cmd_tbl.keys()): # pylint: disable=unsupported-membership-test + unable_to_help(command) + + elapsed_time = timer() - start_time + _log_debug('The overall time it took to process failure recovery recommendations was %.2fms.', elapsed_time * 1000) + + return result + + +def get_recommendations_from_http_response(response): + recommendations = [] + + for suggestion in response.json(): + recommendations.append(FailureRecoveryRecommendation(suggestion)) + + return recommendations + + +def call_aladdin_service(command, parameters, version): + _log_debug('call_aladdin_service: version: "%s", command: "%s", parameters: "%s"', + version, command, parameters) + + response = None + + correlation_id = telemetry._session.correlation_id # pylint: disable=protected-access + subscription_id = telemetry._get_azure_subscription_id() # pylint: disable=protected-access + + if subscription_id and correlation_id: + context = { + "sessionId": correlation_id, + "subscriptionId": subscription_id, + "versionNumber": version + } + + query = { + "command": command, + "parameters": parameters + } + + api_url = 'https://app.aladdindev.microsoft.com/api/v1.0/suggestions' + headers = {'Content-Type': 'application/json'} + + try: + response = requests.get( + api_url, + params={ + 'query': json.dumps(query), + 'clientType': 'AzureCli', + 'context': json.dumps(context) + }, + headers=headers) + except RequestException as ex: + _log_debug('requests.get() exception: %s', ex) + else: + if subscription_id is None: + _log_debug(TELEMETRY_MISSING_SUBSCRIPTION_ID_STR) + if correlation_id is None: + _log_debug(TELEMETRY_MISSING_CORRELATION_ID_STR) + + _log_debug(UNABLE_TO_CALL_SERVICE_STR) + + return response diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py new file mode 100644 index 00000000000..e52c9cb567b --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/failure_recovery_recommendation.py @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +def assert_has_split_method(field, value): + if not getattr(value, 'split') or not callable(value.split): + raise TypeError(f'value assigned to `{field}` must contain split method') + + +class FailureRecoveryRecommendation(): + def __init__(self, data): + data['SuccessCommand'] = data.get('SuccessCommand', '') + data['SuccessCommand_Parameters'] = data.get('SuccessCommand_Parameters', '') + data['SuccessCommand_ArgumentPlaceholders'] = data.get('SuccessCommand_ArgumentPlaceholders', '') + + self._command = data['SuccessCommand'] + self._parameters = data['SuccessCommand_Parameters'] + self._placeholders = data['SuccessCommand_ArgumentPlaceholders'] + + for attr in ('_parameters', '_placeholders'): + value = getattr(self, attr) + value = '' if value == '{}' else value + setattr(self, attr, value) + + @property + def command(self): + return self._command + + @command.setter + def command(self, value): + self._command = value + + @property + def parameters(self): + return self._parameters.split(',') + + @parameters.setter + def parameters(self, value): + assert_has_split_method('parameters', value) + self._parameters = value + + @property + def placeholders(self): + return self._placeholders.split(',') + + @placeholders.setter + def placeholders(self, value): + assert_has_split_method('placeholders', value) + self._placeholders = value + + def __str__(self): + parameter_and_argument_buffer = [] + + for pair in zip(self.parameters, self.placeholders): + parameter_and_argument_buffer.append(' '.join(pair)) + + return f"az {self.command} {' '.join(parameter_and_argument_buffer)}" + + def __eq__(self, value): + return (self.command == value.command and + self.parameters == value.parameters and + self.placeholders == value.placeholders) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py new file mode 100644 index 00000000000..99c0f28cd71 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/__init__.py @@ -0,0 +1,5 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ----------------------------------------------------------------------------- diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py new file mode 100644 index 00000000000..32f97e1a602 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_commands.py @@ -0,0 +1,132 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections import namedtuple +from enum import Enum + +GLOBAL_ARGS = set(('--debug', '--verbose', '--help', '--only-show-errors', '--output', '--query')) +GLOBAL_ARG_BLACKLIST = set(('--debug', '--verbose')) +GLOBAL_ARG_WHITELIST = GLOBAL_ARGS.difference(GLOBAL_ARG_BLACKLIST) +GLOBAL_ARGS_SHORTHAND_MAP = {'-h': '--help', '-o': '--output'} +GLOBAL_ARG_LIST = tuple(GLOBAL_ARGS) + tuple(GLOBAL_ARGS_SHORTHAND_MAP.keys()) + +Arguments = namedtuple('Arguments', ['actual', 'expected']) +CliCommand = namedtuple('CliCommand', ['module', 'command', 'args']) + + +def get_expected_args(args): + return [arg for arg in args if arg.startswith('--')] + + +VM_MODULE_ARGS = ['-g', '--name', '-n', '--resource-group', '--subscription'] +VM_MODULE_EXPECTED_ARGS = get_expected_args(VM_MODULE_ARGS) + +VM_SHOW_ARGS = Arguments( + actual=VM_MODULE_ARGS, + expected=VM_MODULE_EXPECTED_ARGS +) + +_VM_CREATE_ARGS = ['--zone', '-z', '--vmss', '--location', '-l', '--nsg', '--subnet'] + +VM_CREATE_ARGS = Arguments( + actual=VM_MODULE_ARGS + _VM_CREATE_ARGS, + expected=VM_MODULE_EXPECTED_ARGS + get_expected_args(_VM_CREATE_ARGS) +) + +ACCOUNT_ARGS = Arguments( + actual=[], + expected=[] +) + +ACCOUNT_SET_ARGS = Arguments( + actual=['-s', '--subscription'], + expected=['--subscription'] +) + +EXTENSION_LIST_ARGS = Arguments( + actual=['--foo', '--bar'], + expected=[] +) + +AI_DID_YOU_MEAN_THIS_VERSION_ARGS = Arguments( + actual=['--baz'], + expected=[] +) + +KUSTO_CLUSTER_CREATE_ARGS = Arguments( + actual=['-l', '-g', '--no-wait'], + expected=['--location', '--resource-group', '--no-wait'] +) + + +def add_global_args(args, global_args=GLOBAL_ARG_LIST): + expected_global_args = list(GLOBAL_ARG_WHITELIST) + args.actual.extend(global_args) + args.expected.extend(expected_global_args) + return args + + +class AzCommandType(Enum): + VM_SHOW = CliCommand( + module='vm', + command='vm show', + args=add_global_args(VM_SHOW_ARGS) + ) + VM_CREATE = CliCommand( + module='vm', + command='vm create', + args=add_global_args(VM_CREATE_ARGS) + ) + ACCOUNT = CliCommand( + module='account', + command='account', + args=add_global_args(ACCOUNT_ARGS) + ) + ACCOUNT_SET = CliCommand( + module='account', + command='account set', + args=add_global_args(ACCOUNT_SET_ARGS) + ) + EXTENSION_LIST = CliCommand( + module='extension', + command='extension list', + args=add_global_args(EXTENSION_LIST_ARGS) + ) + AI_DID_YOU_MEAN_THIS_VERSION = CliCommand( + module='ai-did-you-mean-this', + command='ai-did-you-mean-this version', + args=add_global_args(AI_DID_YOU_MEAN_THIS_VERSION_ARGS) + ) + KUSTO_CLUSTER_CREATE = CliCommand( + module='kusto', + command='kusto cluster create', + args=add_global_args(KUSTO_CLUSTER_CREATE_ARGS) + ) + + def __init__(self, module, command, args): + self._expected_args = list(sorted(args.expected)) + self._args = args.actual + self._module = module + self._command = command + + @property + def parameters(self): + return self._args + + @property + def expected_parameters(self): + return ','.join(self._expected_args) + + @property + def module(self): + return self._module + + @property + def command(self): + return self._command + + +def get_commands(): + return list({command_type.command for command_type in AzCommandType}) diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py new file mode 100644 index 00000000000..9617c48d3ba --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/_mock.py @@ -0,0 +1,103 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import json +import unittest.mock as mock +from enum import Enum, auto +from http import HTTPStatus +from collections import namedtuple +from contextlib import contextmanager + +import requests + +from azext_ai_did_you_mean_this.failure_recovery_recommendation import FailureRecoveryRecommendation + +# mock service call context attributes +MOCK_UUID = '00000000-0000-0000-0000-000000000000' +MOCK_VERSION = '2.4.0' + +# mock recommendation data constants +MOCK_MODEL_DIR = 'model' +MOCK_RECOMMENDATION_MODEL_FILENAME = 'recommendations.json' + + +RecommendationData = namedtuple('RecommendationData', ['recommendations', 'arguments', 'user_fault_type', 'extension']) + + +class UserFaultType(Enum): + MISSING_REQUIRED_SUBCOMMAND = auto() + NOT_IN_A_COMMAND_GROUP = auto() + EXPECTED_AT_LEAST_ONE_ARGUMENT = auto() + UNRECOGNIZED_ARGUMENTS = auto() + INVALID_JMESPATH_QUERY = auto() + NOT_APPLICABLE = auto() + + +def get_mock_recommendation_model_path(folder): + return os.path.join(folder, MOCK_MODEL_DIR, MOCK_RECOMMENDATION_MODEL_FILENAME) + + +def _parse_entity(entity): + kwargs = {} + kwargs['recommendations'] = entity.get('recommendations', []) + kwargs['arguments'] = entity.get('arguments', '') + kwargs['extension'] = entity.get('extension', None) + kwargs['user_fault_type'] = UserFaultType[entity.get('user_fault_type', 'not_applicable').upper()] + return RecommendationData(**kwargs) + + +class MockRecommendationModel(): + MODEL = None + NO_DATA = None + + @classmethod + def load(cls, path): + content = None + model = {} + + with open(os.path.join(path), 'r') as test_recommendation_data_file: + content = json.load(test_recommendation_data_file) + + for command, entity in content.items(): + model[command] = _parse_entity(entity) + + cls.MODEL = model + cls.NO_DATA = _parse_entity({}) + + @classmethod + def create_mock_aladdin_service_http_response(cls, command): + mock_response = requests.Response() + mock_response.status_code = HTTPStatus.OK.value + data = cls.get_recommendation_data(command) + mock_response._content = bytes(json.dumps(data.recommendations), 'utf-8') # pylint: disable=protected-access + return mock_response + + @classmethod + def get_recommendation_data(cls, command): + return cls.MODEL.get(command, cls.NO_DATA) + + @classmethod + def get_recommendations(cls, command): + data = cls.get_recommendation_data(command) + recommendations = [FailureRecoveryRecommendation(recommendation) for recommendation in data.recommendations] + return recommendations + + @classmethod + def get_test_cases(cls): + cases = [] + model = cls.MODEL or {} + for command, entity in model.items(): + cases.append((command, entity)) + return cases + + +@contextmanager +def mock_aladdin_service_call(command): + handlers = {} + handler = handlers.get(command, MockRecommendationModel.create_mock_aladdin_service_http_response) + + with mock.patch('requests.get', return_value=handler(command)): + yield None diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py new file mode 100644 index 00000000000..ba8215b698e --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/aladdin_scenario_test_base.py @@ -0,0 +1,152 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +import logging +import unittest.mock as mock + +from azure_devtools.scenario_tests import mock_in_unit_test +from azure.cli.testsdk import ScenarioTest + +from azext_ai_did_you_mean_this._const import UNABLE_TO_HELP_FMT_STR, RECOMMENDATION_HEADER_FMT_STR +from azext_ai_did_you_mean_this._cmd_table import CommandTable +from azext_ai_did_you_mean_this.tests.latest._mock import MOCK_UUID, MOCK_VERSION +from azext_ai_did_you_mean_this.custom import recommend_recovery_options +from azext_ai_did_you_mean_this.tests.latest._mock import UserFaultType + +TELEMETRY_MODULE = 'azure.cli.core.telemetry' +TELEMETRY_SESSION_OBJECT = f'{TELEMETRY_MODULE}._session' + +USER_FAULT_TYPE_KEYWORDS = { + UserFaultType.EXPECTED_AT_LEAST_ONE_ARGUMENT: 'expected', + UserFaultType.INVALID_JMESPATH_QUERY: 'jmespath', + UserFaultType.MISSING_REQUIRED_SUBCOMMAND: '_subcommand', + UserFaultType.NOT_IN_A_COMMAND_GROUP: 'command group', + UserFaultType.UNRECOGNIZED_ARGUMENTS: 'unrecognized' +} + +FMT_STR_PATTERN_REGEX = r'\[[^\]]+\]|{[^}]+}' +SUGGEST_AZ_FIND_PATTERN_REGEX = re.sub(FMT_STR_PATTERN_REGEX, r'.*', UNABLE_TO_HELP_FMT_STR) +SHOW_RECOMMENDATIONS_PATTERN_REGEX = re.sub(FMT_STR_PATTERN_REGEX, r'.*', RECOMMENDATION_HEADER_FMT_STR) + + +def patch_ids(unit_test): + def _mock_uuid(*args, **kwargs): # pylint: disable=unused-argument + return MOCK_UUID + + mock_in_unit_test(unit_test, + f'{TELEMETRY_SESSION_OBJECT}.correlation_id', + _mock_uuid()) + mock_in_unit_test(unit_test, + f'{TELEMETRY_MODULE}._get_azure_subscription_id', + _mock_uuid) + + +def patch_version(unit_test): + mock_in_unit_test(unit_test, + 'azure.cli.core.__version__', + MOCK_VERSION) + + +def patch_telemetry(unit_test): + mock_in_unit_test(unit_test, + 'azure.cli.core.telemetry.is_telemetry_enabled', + lambda: True) + + +class AladdinScenarioTest(ScenarioTest): + def __init__(self, method_name, **kwargs): + super().__init__(method_name, **kwargs) + + default_telemetry_patches = { + patch_ids, + patch_version, + patch_telemetry + } + + self._exception = None + self._exit_code = None + self._parser_error_msg = '' + self._recommendation_msg = '' + self._recommender_positional_arguments = None + + self.telemetry_patches = kwargs.pop('telemetry_patches', default_telemetry_patches) + self.recommendations = [] + + def setUp(self): + super().setUp() + + for patch in self.telemetry_patches: + patch(self) + + def cmd(self, command, checks=None, expect_failure=False, expect_user_fault_failure=False): + from azure.cli.core.azlogging import AzCliLogging + + func = recommend_recovery_options + logger_name = AzCliLogging._COMMAND_METADATA_LOGGER # pylint: disable=protected-access + base = super() + + def _hook(*args, **kwargs): + self._recommender_positional_arguments = args + result = func(*args, **kwargs) + self.recommendations = result + return result + + def run_cmd(): + base.cmd(command, checks=checks, expect_failure=expect_failure) + + with mock.patch('azext_ai_did_you_mean_this.custom.recommend_recovery_options', wraps=_hook): + with self.assertLogs(logger_name, level=logging.ERROR) as parser_logs: + if expect_user_fault_failure: + with self.assertRaises(SystemExit) as cm: + run_cmd() + + self._exception = cm.exception + self._exit_code = self._exception.code + self._parser_error_msg = '\n'.join(parser_logs.output) + self._recommendation_msg = '\n'.join(self.recommendations) + + if expect_user_fault_failure: + self.assert_cmd_was_user_fault_failure() + else: + run_cmd() + + if expect_user_fault_failure: + self.assert_cmd_table_not_empty() + self.assert_user_fault_is_of_correct_type(expect_user_fault_failure) + + def assert_user_fault_is_of_correct_type(self, expect_user_fault_failure): + # check the user fault type where applicable + if isinstance(expect_user_fault_failure, UserFaultType): + keyword = USER_FAULT_TYPE_KEYWORDS.get(expect_user_fault_failure, None) + if keyword: + self.assertRegex(self._parser_error_msg, keyword) + + def assert_cmd_was_user_fault_failure(self): + is_user_fault_failure = (isinstance(self._exception, SystemExit) and + self._exit_code == 2) + + self.assertTrue(is_user_fault_failure) + + def assert_cmd_table_not_empty(self): + self.assertIsNotNone(CommandTable.CMD_TBL) + + def assert_recommendations_were_shown(self): + self.assertRegex(self._recommendation_msg, SHOW_RECOMMENDATIONS_PATTERN_REGEX) + + def assert_az_find_was_suggested(self): + self.assertRegex(self._recommendation_msg, SUGGEST_AZ_FIND_PATTERN_REGEX) + + def assert_nothing_is_shown(self): + self.assertEqual(self._recommendation_msg, '') + + @property + def cli_version(self): + from azure.cli.core import __version__ as core_version + return core_version + + @property + def recommender_postional_arguments(self): + return self._recommender_positional_arguments diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json new file mode 100644 index 00000000000..5359be372f9 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/model/recommendations.json @@ -0,0 +1,75 @@ +{ + "account": { + "recommendations": [ + { + "SuccessCommand": "account list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + }, + { + "SuccessCommand": "account show", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + }, + { + "SuccessCommand": "account set", + "SuccessCommand_Parameters": "--subscription", + "SuccessCommand_ArgumentPlaceholders": "Subscription" + } + ], + "user_fault_type": "missing_required_subcommand" + + }, + "boi": { + "user_fault_type": "not_in_a_command_group" + }, + "vm show": { + "arguments": "--name \"BigJay\" --ids", + "user_fault_type": "expected_at_least_one_argument" + }, + "ai-did-you-mean-this ve": { + "user_fault_type": "not_in_a_command_group", + "extension": "ai-did-you-mean-this" + }, + "ai-did-you-mean-this version": { + "user_fault_type": "unrecognized_arguments", + "arguments": "--name \"Christopher\"", + "extension": "ai-did-you-mean-this" + }, + "extension": { + "recommendations": [ + { + "SuccessCommand": "extension list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + } + ], + "user_fault_type": "missing_required_subcommand" + }, + "vm": { + "recommendations": [ + { + "SuccessCommand": "vm list", + "SuccessCommand_Parameters": "", + "SuccessCommand_ArgumentPlaceholders": "" + } + ], + "user_fault_type": "missing_required_subcommand", + "arguments": "--debug" + }, + "account get-access-token": { + "user_fault_type": "unrecognized_arguments", + "arguments": "--test a --debug" + }, + "vm list": { + "recommendations": [ + { + "SuccessCommand": "vm list", + "SuccessCommand_Parameters": "--output,--query", + "SuccessCommand_ArgumentPlaceholders": "json,\"[].id\"" + } + ], + "arguments": "--query \".id\"", + "user_fault_type": "invalid_jmespath_query" + } +} \ No newline at end of file diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py new file mode 100644 index 00000000000..fa9578d1583 --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_ai_did_you_mean_this_scenario.py @@ -0,0 +1,75 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import unittest +import unittest.mock as mock +import json +from http import HTTPStatus +from collections import defaultdict + +import requests + +from azext_ai_did_you_mean_this.custom import call_aladdin_service, get_recommendations_from_http_response +from azext_ai_did_you_mean_this._cmd_table import CommandTable +from azext_ai_did_you_mean_this.tests.latest._mock import ( + get_mock_recommendation_model_path, + mock_aladdin_service_call, + MockRecommendationModel, + UserFaultType +) +from azext_ai_did_you_mean_this.tests.latest.aladdin_scenario_test_base import AladdinScenarioTest +from azext_ai_did_you_mean_this.tests.latest._commands import AzCommandType + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) + + +class AiDidYouMeanThisScenarioTest(AladdinScenarioTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + + MockRecommendationModel.load(get_mock_recommendation_model_path(TEST_DIR)) + cls.test_cases = MockRecommendationModel.get_test_cases() + + def setUp(self): + super().setUp() + + def test_ai_did_you_mean_this_aladdin_service_call(self): + for command, entity in self.test_cases: + tokens = entity.arguments.split() + parameters = [token for token in tokens if token.startswith('-')] + + with mock_aladdin_service_call(command): + response = call_aladdin_service(command, parameters, self.cli_version) + + self.assertEqual(HTTPStatus.OK, response.status_code) + recommendations = get_recommendations_from_http_response(response) + expected_recommendations = MockRecommendationModel.get_recommendations(command) + self.assertEquals(recommendations, expected_recommendations) + + def test_ai_did_you_mean_this_recommendations_for_user_fault_commands(self): + for command, entity in self.test_cases: + args = entity.arguments + command_with_args = command if not args else f'{command} {args}' + + with mock_aladdin_service_call(command): + self.cmd(command_with_args, expect_user_fault_failure=entity.user_fault_type) + + self.assert_cmd_table_not_empty() + cmd_tbl = CommandTable.CMD_TBL + + _version, _command, _parameters, _extension = self.recommender_postional_arguments + partial_command_match = command and any(cmd.startswith(command) for cmd in cmd_tbl.keys()) + self.assertEqual(_version, self.cli_version) + self.assertEqual(_command, command if partial_command_match else '') + self.assertEqual(bool(_extension), bool(entity.extension)) + + if entity.recommendations: + self.assert_recommendations_were_shown() + elif partial_command_match and not entity.extension: + self.assert_az_find_was_suggested() + else: + self.assert_nothing_is_shown() diff --git a/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py new file mode 100644 index 00000000000..06729b8b39c --- /dev/null +++ b/src/ai-did-you-mean-this/azext_ai_did_you_mean_this/tests/latest/test_normalize_and_sort_parameters.py @@ -0,0 +1,81 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from enum import Enum, auto + +from azure.cli.core.mock import DummyCli +from azure.cli.core import MainCommandsLoader + +from azext_ai_did_you_mean_this.custom import normalize_and_sort_parameters +from azext_ai_did_you_mean_this.tests.latest._commands import get_commands, AzCommandType + + +class TestNormalizeAndSortParameters(unittest.TestCase): + @classmethod + def setUpClass(cls): + super(TestNormalizeAndSortParameters, cls).setUpClass() + + from knack.events import EVENT_INVOKER_POST_CMD_TBL_CREATE + from azure.cli.core.commands.events import EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS + from azure.cli.core.commands.arm import register_global_subscription_argument, register_ids_argument + + # setup a dummy CLI with a valid invocation object. + cls.cli = DummyCli() + cli_ctx = cls.cli.commands_loader.cli_ctx + cls.cli.invocation = cli_ctx.invocation_cls(cli_ctx=cli_ctx, + parser_cls=cli_ctx.parser_cls, + commands_loader_cls=cli_ctx.commands_loader_cls, + help_cls=cli_ctx.help_cls) + # load command table for every module + cmd_loader = cls.cli.invocation.commands_loader + cmd_loader.load_command_table(None) + + # Note: Both of the below events rely on EVENT_INVOKER_POST_CMD_TBL_CREATE. + # register handler for adding subscription argument + register_global_subscription_argument(cli_ctx) + # register handler for adding ids argument. + register_ids_argument(cli_ctx) + + cli_ctx.raise_event(EVENT_INVOKER_PRE_LOAD_ARGUMENTS, commands_loader=cmd_loader) + + # load arguments for each command + for cmd in get_commands(): + # simulate command invocation by filling in required metadata. + cmd_loader.command_name = cmd + cli_ctx.invocation.data['command_string'] = cmd + # load argument info for the given command. + cmd_loader.load_arguments(cmd) + + cli_ctx.raise_event(EVENT_INVOKER_POST_LOAD_ARGUMENTS, commands_loader=cmd_loader) + cli_ctx.raise_event(EVENT_INVOKER_POST_CMD_TBL_CREATE, commands_loader=cmd_loader) + + cls.cmd_tbl = cmd_loader.command_table + + def test_custom_normalize_and_sort_parameters(self): + for cmd in AzCommandType: + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, cmd.parameters) + self.assertEqual(parameters, cmd.expected_parameters) + self.assertEqual(command, cmd.command) + + def test_custom_normalize_and_sort_parameters_remove_invalid_command_token(self): + for cmd in AzCommandType: + cmd_with_invalid_token = f'{cmd.command} oops' + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd_with_invalid_token, cmd.parameters) + self.assertEqual(parameters, cmd.expected_parameters) + self.assertEqual(command, cmd.command) + + def test_custom_normalize_and_sort_parameters_empty_parameter_list(self): + cmd = AzCommandType.ACCOUNT_SET + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, cmd.command, '') + self.assertEqual(parameters, '') + self.assertEqual(command, cmd.command) + + def test_custom_normalize_and_sort_parameters_invalid_command(self): + invalid_cmd = 'Lorem ipsum.' + command, parameters = normalize_and_sort_parameters(self.cmd_tbl, invalid_cmd, ['--foo', '--baz']) + self.assertEqual(parameters, '') + # verify that recursive parsing removes the last invalid whitespace delimited token. + self.assertEqual(command, 'Lorem') diff --git a/src/ai-did-you-mean-this/setup.cfg b/src/ai-did-you-mean-this/setup.cfg new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai-did-you-mean-this/setup.py b/src/ai-did-you-mean-this/setup.py new file mode 100644 index 00000000000..dc93ac897f0 --- /dev/null +++ b/src/ai-did-you-mean-this/setup.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +from codecs import open +from setuptools import setup, find_packages +try: + from azure_bdist_wheel import cmdclass +except ImportError: + from distutils import log as logger + logger.warn("Wheel is not available, disabling bdist_wheel hook") + +VERSION = '0.1.0' + +# The full list of classifiers is available at +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'License :: OSI Approved :: MIT License', +] + +DEPENDENCIES = [] + +with open('README.md', 'r', encoding='utf-8') as f: + README = f.read() +with open('HISTORY.rst', 'r', encoding='utf-8') as f: + HISTORY = f.read() + +setup( + name='ai_did_you_mean_this', + version=VERSION, + description='Recommend recovery options on failure.', + # TODO: Update author and email, if applicable + author="Christopher O'Toole", + author_email='chotool@microsoft.com', + # TODO: consider pointing directly to your source code instead of the generic repo + url='https://github.com/Azure/azure-cli-extensions/ai-did-you-mean-this', + long_description=README + '\n\n' + HISTORY, + license='MIT', + classifiers=CLASSIFIERS, + packages=find_packages(), + install_requires=DEPENDENCIES, + package_data={'azext_ai_did_you_mean_this': ['azext_metadata.json']}, +) diff --git a/src/aks-preview/HISTORY.md b/src/aks-preview/HISTORY.md index d4ba6a51c0a..6b5aeb291f7 100644 --- a/src/aks-preview/HISTORY.md +++ b/src/aks-preview/HISTORY.md @@ -2,6 +2,10 @@ Release History =============== +0.4.45 ++++++ +* Add "--aks-custom-headers" for "az aks nodepool add" and "az aks update" + 0.4.43 +++++ * Fix issues with monitoring addon enabling with CLI versions 2.4.0+ diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index 7e9a89fda3d..45075dc7b35 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -355,6 +355,9 @@ - name: --aad-tenant-id type: string short-summary: The ID of an Azure Active Directory tenant. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 examples: - name: Enable cluster-autoscaler within node count range [1,5] text: az aks update --enable-cluster-autoscaler --min-count 1 --max-count 5 -g MyResourceGroup -n MyManagedCluster @@ -511,6 +514,9 @@ - name: --mode type: string short-summary: The mode for a node pool which defines a node pool's primary function. If set as "System", AKS prefers system pods scheduling to node pools with mode `System`. Learn more at https://aka.ms/aks/nodepool/mode. + - name: --aks-custom-headers + type: string + short-summary: Send custom headers. When specified, format should be Key1=Value1,Key2=Value2 """ helps['aks nodepool scale'] = """ diff --git a/src/aks-preview/azext_aks_preview/_params.py b/src/aks-preview/azext_aks_preview/_params.py index badf811cd07..338a44cc4fe 100644 --- a/src/aks-preview/azext_aks_preview/_params.py +++ b/src/aks-preview/azext_aks_preview/_params.py @@ -119,6 +119,7 @@ def load_arguments(self, _): c.argument('disable_pod_security_policy', action='store_true') c.argument('attach_acr', acr_arg_type, validator=validate_acr) c.argument('detach_acr', acr_arg_type, validator=validate_acr) + c.argument('aks_custom_headers') with self.argument_context('aks scale') as c: c.argument('nodepool_name', type=str, @@ -146,6 +147,7 @@ def load_arguments(self, _): c.argument('spot_max_price', type=float, validator=validate_spot_max_price) c.argument('labels', nargs='*', validator=validate_nodepool_labels) c.argument('mode', arg_type=get_enum_type([CONST_NODEPOOL_MODE_SYSTEM, CONST_NODEPOOL_MODE_USER])) + c.argument('aks_custom_headers') for scope in ['aks nodepool show', 'aks nodepool delete', 'aks nodepool scale', 'aks nodepool upgrade', 'aks nodepool update']: with self.argument_context(scope) as c: diff --git a/src/aks-preview/azext_aks_preview/custom.py b/src/aks-preview/azext_aks_preview/custom.py index 229a780afb6..37a673da365 100644 --- a/src/aks-preview/azext_aks_preview/custom.py +++ b/src/aks-preview/azext_aks_preview/custom.py @@ -1009,15 +1009,7 @@ def aks_create(cmd, # pylint: disable=too-many-locals,too-many-statements,to tier="Paid" ) - headers = {} - if aks_custom_headers is not None: - if aks_custom_headers != "": - for pair in aks_custom_headers.split(','): - parts = pair.split('=') - if len(parts) != 2: - raise CLIError('custom headers format is incorrect') - - headers[parts[0]] = parts[1] + headers = get_aks_custom_headers(aks_custom_headers) # Due to SPN replication latency, we do a few retries here max_retry = 30 @@ -1096,7 +1088,8 @@ def aks_update(cmd, # pylint: disable=too-many-statements,too-many-branches, attach_acr=None, detach_acr=None, aad_tenant_id=None, - aad_admin_group_object_ids=None): + aad_admin_group_object_ids=None, + aks_custom_headers=None): update_autoscaler = enable_cluster_autoscaler or disable_cluster_autoscaler or update_cluster_autoscaler update_acr = attach_acr is not None or detach_acr is not None update_pod_security = enable_pod_security_policy or disable_pod_security_policy @@ -1244,7 +1237,8 @@ def aks_update(cmd, # pylint: disable=too-many-statements,too-many-branches, if aad_admin_group_object_ids is not None: instance.aad_profile.admin_group_object_ids = _parse_comma_separated_list(aad_admin_group_object_ids) - return sdk_no_wait(no_wait, client.create_or_update, resource_group_name, name, instance) + headers = get_aks_custom_headers(aks_custom_headers) + return sdk_no_wait(no_wait, client.create_or_update, resource_group_name, name, instance, custom_headers=headers) def aks_show(cmd, client, resource_group_name, name): # pylint: disable=unused-argument @@ -2071,6 +2065,7 @@ def aks_agentpool_add(cmd, # pylint: disable=unused-argument,too-many-local spot_max_price=float('nan'), labels=None, mode="User", + aks_custom_headers=None, no_wait=False): instances = client.list(resource_group_name, cluster_name) for agentpool_profile in instances: @@ -2124,7 +2119,8 @@ def aks_agentpool_add(cmd, # pylint: disable=unused-argument,too-many-local if node_osdisk_size: agent_pool.os_disk_size_gb = int(node_osdisk_size) - return sdk_no_wait(no_wait, client.create_or_update, resource_group_name, cluster_name, nodepool_name, agent_pool) + headers = get_aks_custom_headers(aks_custom_headers) + return sdk_no_wait(no_wait, client.create_or_update, resource_group_name, cluster_name, nodepool_name, agent_pool, custom_headers=headers) def aks_agentpool_scale(cmd, # pylint: disable=unused-argument @@ -2677,3 +2673,15 @@ def format_bright(msg): def format_hyperlink(the_link): return f'\033[1m{colorama.Style.BRIGHT}{colorama.Fore.BLUE}{the_link}{colorama.Style.RESET_ALL}' + + +def get_aks_custom_headers(aks_custom_headers=None): + headers = {} + if aks_custom_headers is not None: + if aks_custom_headers != "": + for pair in aks_custom_headers.split(','): + parts = pair.split('=') + if len(parts) != 2: + raise CLIError('custom headers format is incorrect') + headers[parts[0]] = parts[1] + return headers diff --git a/src/aks-preview/setup.py b/src/aks-preview/setup.py index 3b4bf7610f3..7acb3a823c0 100644 --- a/src/aks-preview/setup.py +++ b/src/aks-preview/setup.py @@ -8,7 +8,7 @@ from codecs import open as open1 from setuptools import setup, find_packages -VERSION = "0.4.44" +VERSION = "0.4.45" CLASSIFIERS = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', diff --git a/src/connectedk8s/HISTORY.rst b/src/connectedk8s/HISTORY.rst index 8c34bccfff8..26167b8bc70 100644 --- a/src/connectedk8s/HISTORY.rst +++ b/src/connectedk8s/HISTORY.rst @@ -3,6 +3,15 @@ Release History =============== -0.1.0 +0.2.1 +++++++ +* `az connectedk8s connect`: Added kubernetes distribution. + +0.2.0 +++++++ +* `az connectedk8s connect`: Added telemetry. +* `az connectedk8s delete`: Added telemetry. + +0.1.5 ++++++ * Initial release. \ No newline at end of file diff --git a/src/connectedk8s/azext_connectedk8s/custom.py b/src/connectedk8s/azext_connectedk8s/custom.py index d7f3033261a..eafb6b8460c 100644 --- a/src/connectedk8s/azext_connectedk8s/custom.py +++ b/src/connectedk8s/azext_connectedk8s/custom.py @@ -5,7 +5,6 @@ import os import json -import uuid import time import subprocess from subprocess import Popen, PIPE @@ -17,6 +16,7 @@ from azure.cli.core.commands.client_factory import get_subscription_id from azure.cli.core.util import sdk_no_wait from azure.cli.core._profile import Profile +from azure.cli.core import telemetry from azext_connectedk8s._client_factory import _graph_client_factory from azext_connectedk8s._client_factory import cf_resource_groups from azext_connectedk8s._client_factory import _resource_client_factory @@ -31,6 +31,34 @@ logger = get_logger(__name__) +Invalid_Location_Fault_Type = 'location-validation-error' +Load_Kubeconfig_Fault_Type = 'kubeconfig-load-error' +Read_ConfigMap_Fault_Type = 'configmap-read-error' +Create_ConnectedCluster_Fault_Type = 'connected-cluster-create-error' +Delete_ConnectedCluster_Fault_Type = 'connected-cluster-delete-error' +Bad_DeleteRequest_Fault_Type = 'bad-delete-request-error' +Cluster_Already_Onboarded_Fault_Type = 'cluster-already-onboarded-error' +Resource_Already_Exists_Fault_Type = 'resource-already-exists-error' +Create_ResourceGroup_Fault_Type = 'resource-group-creation-error' +Add_HelmRepo_Fault_Type = 'helm-repo-add-error' +List_HelmRelease_Fault_Type = 'helm-list-release-error' +KeyPair_Generate_Fault_Type = 'keypair-generation-error' +PublicKey_Export_Fault_Type = 'publickey-export-error' +PrivateKey_Export_Fault_Type = 'privatekey-export-error' +Install_HelmRelease_Fault_Type = 'helm-release-install-error' +Delete_HelmRelease_Fault_Type = 'helm-release-delete-error' +Check_PodStatus_Fault_Type = 'check-pod-status-error' +Kubernetes_Connectivity_FaultType = 'kubernetes-cluster-connection-error' +Helm_Version_Fault_Type = 'helm-not-updated-error' +Check_HelmVersion_Fault_Type = 'helm-version-check-error' +Helm_Installation_Fault_Type = 'helm-not-installed-error' +Check_HelmInstallation_Fault_Type = 'check-helm-installed-error' +Get_HelmRegistery_Path_Fault_Type = 'helm-registry-path-fetch-error' +Pull_HelmChart_Fault_Type = 'helm-chart-pull-error' +Export_HelmChart_Fault_Type = 'helm-chart-export-error' +Get_Kubernetes_Version_Fault_Type = 'kubernetes-get-version-error' +Get_Kubernetes_Distro_Fault_Type = 'kubernetes-get-distribution-error' + # pylint:disable=unused-argument # pylint: disable=too-many-locals @@ -62,6 +90,9 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location try: config.load_kube_config(config_file=kube_config, context=kube_context) except Exception as e: + telemetry.set_user_fault() + telemetry.set_exception(exception=e, fault_type=Load_Kubeconfig_Fault_Type, + summary='Problem loading the kubeconfig file') raise CLIError("Problem loading the kubeconfig file." + str(e)) configuration = kube_client.Configuration() @@ -70,11 +101,22 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location # if the user had not logged in. check_kube_connection(configuration) + # Get kubernetes cluster info for telemetry + kubernetes_version = get_server_version(configuration) + kubernetes_distro = get_kubernetes_distro(configuration) + + kubernetes_properties = { + 'Context.Default.AzureCLI.KubernetesVersion': kubernetes_version, + 'Context.Default.AzureCLI.KubernetesDistro': kubernetes_distro + } + telemetry.add_extension_event('connectedk8s', kubernetes_properties) + # Checking helm installation check_helm_install(kube_config, kube_context) # Check helm version - check_helm_version(kube_config, kube_context) + helm_version = check_helm_version(kube_config, kube_context) + telemetry.add_extension_event('connectedk8s', {'Context.Default.AzureCLI.HelmVersion': helm_version}) # Validate location rp_locations = [] @@ -84,6 +126,9 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location if resourceTypes.resource_type == 'connectedClusters': rp_locations = [location.replace(" ", "").lower() for location in resourceTypes.locations] if location.lower() not in rp_locations: + telemetry.set_user_fault() + telemetry.set_exception(exception='Location not supported', fault_type=Invalid_Location_Fault_Type, + summary='Provided location is not supported for creating connected clusters') raise CLIError("Connected cluster resource creation is supported only in the following locations: " + ', '.join(map(str, rp_locations)) + ". Use the --location flag to specify one of these locations.") @@ -97,6 +142,8 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location try: configmap = api_instance.read_namespaced_config_map('azure-clusterconfig', 'azure-arc') except Exception as e: # pylint: disable=broad-except + telemetry.set_exception(exception=e, fault_type=Read_ConfigMap_Fault_Type, + summary='Unable to read ConfigMap') raise CLIError("Unable to read ConfigMap 'azure-clusterconfig' in 'azure-arc' namespace: %s\n" % e) configmap_rg_name = configmap.data["AZURE_RESOURCE_GROUP"] configmap_cluster_name = configmap.data["AZURE_RESOURCE_NAME"] @@ -111,9 +158,14 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location return sdk_no_wait(no_wait, client.create, resource_group_name=resource_group_name, cluster_name=cluster_name, connected_cluster=cc) except CloudError as ex: + telemetry.set_exception(exception=ex, fault_type=Create_ConnectedCluster_Fault_Type, + summary='Unable to create connected cluster resource') raise CLIError(ex) else: - raise CLIError("The kubernetes cluster you are trying to onboard" + + telemetry.set_user_fault() + telemetry.set_exception(exception='The kubernetes cluster is already onboarded', fault_type=Cluster_Already_Onboarded_Fault_Type, + summary='Kubernetes cluster already onboarded') + raise CLIError("The kubernetes cluster you are trying to onboard " + "is already onboarded to the resource group" + " '{}' with resource name '{}'.".format(configmap_rg_name, configmap_cluster_name)) else: @@ -121,6 +173,9 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location delete_arc_agents(release_namespace, kube_config, kube_context, configuration) else: if connected_cluster_exists(client, resource_group_name, cluster_name): + telemetry.set_user_fault() + telemetry.set_exception(exception='The connected cluster resource already exists', fault_type=Resource_Already_Exists_Fault_Type, + summary='Connected cluster resource already exists') raise CLIError("The connected cluster resource {} already exists ".format(cluster_name) + "in the resource group {} ".format(resource_group_name) + "and corresponds to a different Kubernetes cluster. To onboard this Kubernetes cluster" + @@ -132,6 +187,8 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location try: resourceClient.resource_groups.create_or_update(resource_group_name, resource_group_params) except Exception as e: + telemetry.set_exception(exception=e, fault_type=Create_ResourceGroup_Fault_Type, + summary='Failed to create the resource group') raise CLIError("Failed to create the resource group {} :".format(resource_group_name) + str(e)) # Adding helm repo @@ -144,38 +201,51 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location response_helm_repo = Popen(cmd_helm_repo, stdout=PIPE, stderr=PIPE) _, error_helm_repo = response_helm_repo.communicate() if response_helm_repo.returncode != 0: + telemetry.set_exception(exception=error_helm_repo.decode("ascii"), fault_type=Add_HelmRepo_Fault_Type, + summary='Failed to add helm repository') raise CLIError("Unable to add repository {} to helm: ".format(repo_url) + error_helm_repo.decode("ascii")) # Retrieving Helm chart OCI Artifact location - registery_path = get_helm_registery(profile, location) + registry_path = os.getenv('HELMREGISTRY') if os.getenv('HELMREGISTRY') else get_helm_registry(profile, location) - # Pulling helm chart from registery + # Get azure-arc agent version for telemetry + azure_arc_agent_version = registry_path.split(':')[1] + telemetry.add_extension_event('connectedk8s', {'Context.Default.AzureCLI.AgentVersion': azure_arc_agent_version}) + + # Pulling helm chart from registry os.environ['HELM_EXPERIMENTAL_OCI'] = '1' - pull_helm_chart(registery_path, kube_config, kube_context) + pull_helm_chart(registry_path, kube_config, kube_context) # Exporting helm chart chart_export_path = os.path.join(os.path.expanduser('~'), '.azure', 'AzureArcCharts') - export_helm_chart(registery_path, chart_export_path, kube_config, kube_context) + export_helm_chart(registry_path, chart_export_path, kube_config, kube_context) # Generate public-private key pair try: key_pair = RSA.generate(4096) except Exception as e: + telemetry.set_exception(exception=e, fault_type=KeyPair_Generate_Fault_Type, + summary='Failed to generate public-private key pair') raise CLIError("Failed to generate public-private key pair. " + str(e)) try: public_key = get_public_key(key_pair) except Exception as e: - raise CLIError("Failed to generate public key." + str(e)) + telemetry.set_exception(exception=e, fault_type=PublicKey_Export_Fault_Type, + summary='Failed to export public key') + raise CLIError("Failed to export public key." + str(e)) try: private_key_pem = get_private_key(key_pair) except Exception as e: - raise CLIError("Failed to generate private key." + str(e)) + telemetry.set_exception(exception=e, fault_type=PrivateKey_Export_Fault_Type, + summary='Failed to export private key') + raise CLIError("Failed to export private key." + str(e)) # Helm Install helm_chart_path = os.path.join(chart_export_path, 'azure-arc-k8sagents') chart_path = os.getenv('HELMCHART') if os.getenv('HELMCHART') else helm_chart_path cmd_helm_install = ["helm", "upgrade", "--install", "azure-arc", chart_path, "--set", "global.subscriptionId={}".format(subscription_id), + "--set", "global.kubernetesDistro={}".format(kubernetes_distro), "--set", "global.resourceGroupName={}".format(resource_group_name), "--set", "global.resourceName={}".format(cluster_name), "--set", "global.location={}".format(location), @@ -188,6 +258,8 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location response_helm_install = Popen(cmd_helm_install, stdout=PIPE, stderr=PIPE) _, error_helm_install = response_helm_install.communicate() if response_helm_install.returncode != 0: + telemetry.set_exception(exception=error_helm_install.decode("ascii"), fault_type=Install_HelmRelease_Fault_Type, + summary='Unable to install helm release') raise CLIError("Unable to install helm release: " + error_helm_install.decode("ascii")) # Create connected cluster resource @@ -199,6 +271,8 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location if no_wait: return put_cc_response except CloudError as ex: + telemetry.set_exception(exception=ex, fault_type=Create_ConnectedCluster_Fault_Type, + summary='Unable to create connected cluster resource') raise CLIError(ex) # Getting total number of pods scheduled to run in azure-arc namespace @@ -209,6 +283,8 @@ def create_connectedk8s(cmd, client, resource_group_name, cluster_name, location try: check_pod_status(pod_dict) except Exception as e: # pylint: disable=broad-except + telemetry.set_exception(exception=e, fault_type=Check_PodStatus_Fault_Type, + summary='Failed to check arc agent pods statuses') logger.warning("Failed to check arc agent pods statuses: %s", e) return put_cc_response @@ -234,6 +310,9 @@ def check_kube_connection(configuration): try: api_instance.get_api_resources() except Exception as e: + telemetry.set_user_fault() + telemetry.set_exception(exception=e, fault_type=Kubernetes_Connectivity_FaultType, + summary='Unable to verify connectivity to the Kubernetes cluster') logger.warning("Unable to verify connectivity to the Kubernetes cluster: %s\n", e) raise CLIError("If you are using AAD Enabled cluster, " + "verify that you are able to access the cluster. Learn more at " + @@ -249,10 +328,18 @@ def check_helm_install(kube_config, kube_context): _, error_helm_installed = response_helm_installed.communicate() if response_helm_installed.returncode != 0: if "unknown flag" in error_helm_installed.decode("ascii"): + telemetry.set_user_fault() + telemetry.set_exception(exception='Helm 3 not found', fault_type=Helm_Version_Fault_Type, + summary='Helm3 not found on the machine') raise CLIError("Please install the latest version of Helm. " + "Learn more at https://aka.ms/arc/k8s/onboarding-helm-install") + telemetry.set_user_fault() + telemetry.set_exception(exception=error_helm_installed.decode("ascii"), fault_type=Helm_Installation_Fault_Type, + summary='Helm3 not installed on the machine') raise CLIError(error_helm_installed.decode("ascii")) - except FileNotFoundError: + except FileNotFoundError as e: + telemetry.set_exception(exception=e, fault_type=Check_HelmInstallation_Fault_Type, + summary='Unable to verify helm installation') raise CLIError("Helm is not installed or requires elevated permissions. " + "Ensure that you have the latest version of Helm installed on your machine. " + "Learn more at https://aka.ms/arc/k8s/onboarding-helm-install") @@ -268,11 +355,17 @@ def check_helm_version(kube_config, kube_context): response_helm_version = Popen(cmd_helm_version, stdout=PIPE, stderr=PIPE) output_helm_version, error_helm_version = response_helm_version.communicate() if response_helm_version.returncode != 0: + telemetry.set_exception(exception=error_helm_version.decode('ascii'), fault_type=Check_HelmVersion_Fault_Type, + summary='Unable to determine helm version') raise CLIError("Unable to determine helm version: " + error_helm_version.decode("ascii")) if "v2" in output_helm_version.decode("ascii"): + telemetry.set_user_fault() + telemetry.set_exception(exception='Helm 3 not found', fault_type=Helm_Version_Fault_Type, + summary='Helm3 not found on the machine') raise CLIError("Helm version 3+ is required. " + "Ensure that you have installed the latest version of Helm. " + "Learn more at https://aka.ms/arc/k8s/onboarding-helm-install") + return output_helm_version.decode('ascii') def resource_group_exists(ctx, resource_group_name, subscription_id=None): @@ -294,44 +387,52 @@ def connected_cluster_exists(client, resource_group_name, cluster_name): return True -def get_helm_registery(profile, location): +def get_helm_registry(profile, location): cred, _, _ = profile.get_login_credentials( resource='https://management.core.windows.net/') token = cred._token_retriever()[2].get('accessToken') # pylint: disable=protected-access get_chart_location_url = "https://{}.dp.kubernetesconfiguration.azure.com/{}/GetLatestHelmPackagePath?api-version=2019-11-01-preview".format(location, 'azure-arc-k8sagents') query_parameters = {} - query_parameters['releaseTrain'] = 'stable' + query_parameters['releaseTrain'] = os.getenv('RELEASETRAIN') if os.getenv('RELEASETRAIN') else 'stable' header_parameters = {} header_parameters['Authorization'] = "Bearer {}".format(str(token)) try: response = requests.post(get_chart_location_url, params=query_parameters, headers=header_parameters) except Exception as e: - raise CLIError("Error while fetching helm chart registery path: " + str(e)) + telemetry.set_exception(exception=e, fault_type=Get_HelmRegistery_Path_Fault_Type, + summary='Error while fetching helm chart registry path') + raise CLIError("Error while fetching helm chart registry path: " + str(e)) if response.status_code == 200: return response.json().get('repositoryPath') - raise CLIError("Error while fetching helm chart registery path: {}".format(str(response.json()))) + telemetry.set_exception(exception=str(response.json()), fault_type=Get_HelmRegistery_Path_Fault_Type, + summary='Error while fetching helm chart registry path') + raise CLIError("Error while fetching helm chart registry path: {}".format(str(response.json()))) -def pull_helm_chart(registery_path, kube_config, kube_context): - cmd_helm_chart_pull = ["helm", "chart", "pull", registery_path, "--kubeconfig", kube_config] +def pull_helm_chart(registry_path, kube_config, kube_context): + cmd_helm_chart_pull = ["helm", "chart", "pull", registry_path, "--kubeconfig", kube_config] if kube_context: cmd_helm_chart_pull.extend(["--kube-context", kube_context]) response_helm_chart_pull = subprocess.Popen(cmd_helm_chart_pull, stdout=PIPE, stderr=PIPE) _, error_helm_chart_pull = response_helm_chart_pull.communicate() if response_helm_chart_pull.returncode != 0: - raise CLIError("Unable to pull helm chart from the registery '{}': ".format(registery_path) + error_helm_chart_pull.decode("ascii")) + telemetry.set_exception(exception=error_helm_chart_pull.decode("ascii"), fault_type=Pull_HelmChart_Fault_Type, + summary='Unable to pull helm chart from the registry') + raise CLIError("Unable to pull helm chart from the registry '{}': ".format(registry_path) + error_helm_chart_pull.decode("ascii")) -def export_helm_chart(registery_path, chart_export_path, kube_config, kube_context): +def export_helm_chart(registry_path, chart_export_path, kube_config, kube_context): chart_export_path = os.path.join(os.path.expanduser('~'), '.azure', 'AzureArcCharts') - cmd_helm_chart_export = ["helm", "chart", "export", registery_path, "--destination", chart_export_path, "--kubeconfig", kube_config] + cmd_helm_chart_export = ["helm", "chart", "export", registry_path, "--destination", chart_export_path, "--kubeconfig", kube_config] if kube_context: cmd_helm_chart_export.extend(["--kube-context", kube_context]) response_helm_chart_export = subprocess.Popen(cmd_helm_chart_export, stdout=PIPE, stderr=PIPE) _, error_helm_chart_export = response_helm_chart_export.communicate() if response_helm_chart_export.returncode != 0: - raise CLIError("Unable to export helm chart from the registery '{}': ".format(registery_path) + error_helm_chart_export.decode("ascii")) + telemetry.set_exception(exception=error_helm_chart_export.decode("ascii"), fault_type=Export_HelmChart_Fault_Type, + summary='Unable to export helm chart from the registry') + raise CLIError("Unable to export helm chart from the registry '{}': ".format(registry_path) + error_helm_chart_export.decode("ascii")) def get_public_key(key_pair): @@ -346,39 +447,33 @@ def get_private_key(key_pair): return PEM.encode(privKey_DER, "RSA PRIVATE KEY") -def get_node_count(configuration): - api_instance = kube_client.CoreV1Api(kube_client.ApiClient(configuration)) - try: - api_response = api_instance.list_node() - return len(api_response.items) - except Exception as e: # pylint: disable=broad-except - logger.warning("Exception while fetching nodes: %s\n", e) - - def get_server_version(configuration): api_instance = kube_client.VersionApi(kube_client.ApiClient(configuration)) try: api_response = api_instance.get_code() return api_response.git_version except Exception as e: # pylint: disable=broad-except + telemetry.set_exception(exception=e, fault_type=Get_Kubernetes_Version_Fault_Type, + summary='Unable to fetch kubernetes version') logger.warning("Unable to fetch kubernetes version: %s\n", e) -def get_agent_version(configuration): +def get_kubernetes_distro(configuration): api_instance = kube_client.CoreV1Api(kube_client.ApiClient(configuration)) try: - api_response = api_instance.read_namespaced_config_map('azure-clusterconfig', 'azure-arc') - return api_response.data["AZURE_ARC_AGENT_VERSION"] + api_response = api_instance.list_node() + if api_response.items: + labels = api_response.items[0].metadata.labels + if labels.get("node.openshift.io/os_id") == "rhcos" or labels.get("node.openshift.io/os_id") == "rhel": + return "openshift" + return "default" except Exception as e: # pylint: disable=broad-except - logger.warning("Unable to read ConfigMap 'azure-clusterconfig' in 'azure-arc' namespace: %s\n", e) + telemetry.set_exception(exception=e, fault_type=Get_Kubernetes_Distro_Fault_Type, + summary='Unable to fetch kubernetes distribution') + logger.warning("Exception while trying to fetch kubernetes distribution: %s\n", e) def generate_request_payload(configuration, location, public_key, tags): - # Fetch cluster info - total_node_count = get_node_count(configuration) - kubernetes_version = get_server_version(configuration) - azure_arc_agent_version = get_agent_version(configuration) - # Create connected cluster resource object aad_profile = ConnectedClusterAADProfile( tenant_id="", @@ -395,9 +490,6 @@ def generate_request_payload(configuration, location, public_key, tags): identity=identity, agent_public_key_certificate=public_key, aad_profile=aad_profile, - kubernetes_version=kubernetes_version, - total_node_count=total_node_count, - agent_version=azure_arc_agent_version, tags=tags ) return cc @@ -439,6 +531,7 @@ def check_pod_status(pod_dict): "Run 'kubectl get pods -n azure-arc' to check the pod status.") if all(ele == 1 for ele in list(pod_dict.values())): return + telemetry.add_extension_event('connectedk8s', {'Context.Default.AzureCLI.ExitStatus': 'Timedout'}) logger.warning("%s%s", 'The pods were unable to start before timeout. ', 'Please run "kubectl get pods -n azure-arc" to ensure if the pods are in running state.') @@ -468,6 +561,9 @@ def delete_connectedk8s(cmd, client, resource_group_name, cluster_name, try: config.load_kube_config(config_file=kube_config, context=kube_context) except Exception as e: + telemetry.set_user_fault() + telemetry.set_exception(exception=e, fault_type=Load_Kubeconfig_Fault_Type, + summary='Problem loading the kubeconfig file') raise CLIError("Problem loading the kubeconfig file." + str(e)) configuration = kube_client.Configuration() @@ -493,12 +589,17 @@ def delete_connectedk8s(cmd, client, resource_group_name, cluster_name, try: configmap = api_instance.read_namespaced_config_map('azure-clusterconfig', 'azure-arc') except Exception as e: # pylint: disable=broad-except - logger.warning("Unable to read ConfigMap 'azure-clusterconfig' in 'azure-arc' namespace: %s\n", e) + telemetry.set_exception(exception=e, fault_type=Read_ConfigMap_Fault_Type, + summary='Unable to read ConfigMap') + raise CLIError("Unable to read ConfigMap 'azure-clusterconfig' in 'azure-arc' namespace: %s\n" % e) if (configmap.data["AZURE_RESOURCE_GROUP"].lower() == resource_group_name.lower() and configmap.data["AZURE_RESOURCE_NAME"].lower() == cluster_name.lower()): delete_cc_resource(client, resource_group_name, cluster_name, no_wait) else: + telemetry.set_user_fault() + telemetry.set_exception(exception='Unable to delete connected cluster', fault_type=Bad_DeleteRequest_Fault_Type, + summary='The resource cannot be deleted as kubernetes cluster is onboarded with some other resource id') raise CLIError("The current context in the kubeconfig file does not correspond " + "to the connected cluster resource specified. Agents installed on this cluster correspond " + "to the resource group name '{}' ".format(configmap.data["AZURE_RESOURCE_GROUP"]) + @@ -515,6 +616,8 @@ def get_release_namespace(kube_config, kube_context): response_helm_release = Popen(cmd_helm_release, stdout=PIPE, stderr=PIPE) output_helm_release, error_helm_release = response_helm_release.communicate() if response_helm_release.returncode != 0: + telemetry.set_exception(exception=error_helm_release.decode("ascii"), fault_type=List_HelmRelease_Fault_Type, + summary='Unable to list helm release') raise CLIError("Helm list release failed: " + error_helm_release.decode("ascii")) output_helm_release = output_helm_release.decode("ascii") output_helm_release = json.loads(output_helm_release) @@ -530,6 +633,8 @@ def delete_cc_resource(client, resource_group_name, cluster_name, no_wait): resource_group_name=resource_group_name, cluster_name=cluster_name) except CloudError as ex: + telemetry.set_exception(exception=ex, fault_type=Delete_ConnectedCluster_Fault_Type, + summary='Unable to create connected cluster resource') raise CLIError(ex) @@ -540,6 +645,8 @@ def delete_arc_agents(release_namespace, kube_config, kube_context, configuratio response_helm_delete = Popen(cmd_helm_delete, stdout=PIPE, stderr=PIPE) _, error_helm_delete = response_helm_delete.communicate() if response_helm_delete.returncode != 0: + telemetry.set_exception(exception=error_helm_delete.decode("ascii"), fault_type=Delete_HelmRelease_Fault_Type, + summary='Unable to delete helm release') raise CLIError("Error occured while cleaning up arc agents. " + "Helm release deletion failed: " + error_helm_delete.decode("ascii")) ensure_namespace_cleanup(configuration) @@ -565,11 +672,3 @@ def update_connectedk8s(cmd, instance, tags=None): with cmd.update_context(instance) as c: c.set_param('tags', tags) return instance - - -def _is_guid(guid): - try: - uuid.UUID(guid) - return True - except ValueError: - return False diff --git a/src/connectedk8s/setup.py b/src/connectedk8s/setup.py index 2b549d2c1e5..572dfe2f9e3 100644 --- a/src/connectedk8s/setup.py +++ b/src/connectedk8s/setup.py @@ -16,7 +16,8 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '0.1.5' + +VERSION = '0.2.1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers diff --git a/src/index.json b/src/index.json index 421f02e32f5..25dc65fdbcf 100644 --- a/src/index.json +++ b/src/index.json @@ -621,6 +621,49 @@ "version": "0.4.44" }, "sha256Digest": "544d8dbab694fd5a2e04a1cac310e36881682e309c5df772f037451e3d7da51c" + }, + { + "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/aks_preview-0.4.45-py2.py3-none-any.whl", + "filename": "aks_preview-0.4.45-py2.py3-none-any.whl", + "metadata": { + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.0.49", + "classifiers": [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "License :: OSI Approved :: MIT License" + ], + "extensions": { + "python.details": { + "contacts": [ + { + "email": "azpycli@microsoft.com", + "name": "Microsoft Corporation", + "role": "author" + } + ], + "document_names": { + "description": "DESCRIPTION.rst" + }, + "project_urls": { + "Home": "https://github.com/Azure/azure-cli-extensions/tree/master/src/aks-preview" + } + } + }, + "generator": "bdist_wheel (0.30.0)", + "license": "MIT", + "metadata_version": "2.0", + "name": "aks-preview", + "summary": "Provides a preview for upcoming AKS features", + "version": "0.4.45" + }, + "sha256Digest": "78b8536cf5b4349d47a3d1742d36514f99780ef8eff31336d8cb5dfc2e5c6080" } ], "alertsmanagement": [ @@ -1907,6 +1950,58 @@ "version": "0.1.5" }, "sha256Digest": "1b529c1fedb5db9dee3dc877ca036f5373d307ca8a07c278d07126531b1c55b6" + }, + { + "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/connectedk8s-0.2.0-py3-none-any.whl", + "filename": "connectedk8s-0.2.0-py3-none-any.whl", + "metadata": { + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.0.67", + "classifiers": [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "License :: OSI Approved :: MIT License" + ], + "extensions": { + "python.details": { + "contacts": [ + { + "email": "k8connect@microsoft.com", + "name": "Microsoft Corporation", + "role": "author" + } + ], + "document_names": { + "description": "DESCRIPTION.rst" + }, + "project_urls": { + "Home": "https://github.com/Azure/azure-cli-extensions" + } + } + }, + "extras": [], + "generator": "bdist_wheel (0.30.0)", + "license": "MIT", + "metadata_version": "2.0", + "name": "connectedk8s", + "run_requires": [ + { + "requires": [ + "kubernetes", + "pycryptodome" + ] + } + ], + "summary": "Microsoft Azure Command-Line Tools Connectedk8s Extension", + "version": "0.2.0" + }, + "sha256Digest": "d306355d5568f9f5b201db9f5bda28fc0b142c6b70164a87bf56974239749ebd" } ], "connectedmachine": [ @@ -1980,7 +2075,7 @@ ], "csvmware": [ { - "downloadUrl": "https://github.com/Azure/az-vmware-cli/releases/download/0.3.0/csvmware-0.3.0-py2.py3-none-any.whl", + "downloadUrl": "https://github.com/Azure/az-csvmware-cli/releases/download/0.3.0/csvmware-0.3.0-py2.py3-none-any.whl", "filename": "csvmware-0.3.0-py2.py3-none-any.whl", "metadata": { "azext.isPreview": true, @@ -3576,6 +3671,49 @@ "version": "0.1.7" }, "sha256Digest": "6440f1f1bebda0b3288ab95654a107e3f803d1ad2a23276cd5e27abe6a71dd60" + }, + { + "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/k8sconfiguration-0.1.8-py2.py3-none-any.whl", + "filename": "k8sconfiguration-0.1.8-py2.py3-none-any.whl", + "metadata": { + "azext.isPreview": true, + "azext.minCliCoreVersion": "2.3.1", + "classifiers": [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "License :: OSI Approved :: MIT License" + ], + "extensions": { + "python.details": { + "contacts": [ + { + "email": "azpycli@microsoft.com", + "name": "Microsoft Corporation", + "role": "author" + } + ], + "document_names": { + "description": "DESCRIPTION.rst" + }, + "project_urls": { + "Home": "https://github.com/Azure/azure-cli-extensions" + } + } + }, + "generator": "bdist_wheel (0.30.0)", + "license": "MIT", + "metadata_version": "2.0", + "name": "k8sconfiguration", + "summary": "Microsoft Azure Command-Line Tools K8sconfiguration Extension", + "version": "0.1.8" + }, + "sha256Digest": "9d4b9d9dfcd8793297af670de10254804f5ce6d1bac6b0ad8e872cc5fdc5f761" } ], "keyvault-preview": [ @@ -5620,8 +5758,8 @@ ], "vmware": [ { - "downloadUrl": "https://github.com/virtustream/azure-vmware-virtustream-cli-extension/releases/download/0.5.5/vmware-0.5.5-py2.py3-none-any.whl", - "filename": "vmware-0.5.5-py2.py3-none-any.whl", + "downloadUrl": "https://github.com/virtustream/az-vmware-cli/releases/download/0.6.0/vmware-0.6.0-py2.py3-none-any.whl", + "filename": "vmware-0.6.0-py2.py3-none-any.whl", "metadata": { "azext.isPreview": true, "azext.minCliCoreVersion": "2.0.66", @@ -5629,8 +5767,8 @@ "python.details": { "contacts": [ { - "email": "azpycli@virtustream.com", - "name": "Virtustream", + "email": "azpycli@microsoft.com", + "name": "Microsoft", "role": "author" } ], @@ -5646,10 +5784,10 @@ "license": "MIT", "metadata_version": "2.0", "name": "vmware", - "summary": "Preview Azure VMware Solution by Virtustream commands.", - "version": "0.5.5" + "summary": "Preview Azure VMware Solution commands.", + "version": "0.6.0" }, - "sha256Digest": "89c5c09ee859b4b03c57cf2d2c477054aef97a5cca6f9a67da8a19d792572b02" + "sha256Digest": "517b737a0f812ae8520297836c16318d7d04357002e578e49befb7e5974d0d79" } ], "webapp": [ diff --git a/src/k8sconfiguration/azext_k8sconfiguration/azext_metadata.json b/src/k8sconfiguration/azext_k8sconfiguration/azext_metadata.json index 55c81bf3328..8cfc6da9485 100644 --- a/src/k8sconfiguration/azext_k8sconfiguration/azext_metadata.json +++ b/src/k8sconfiguration/azext_k8sconfiguration/azext_metadata.json @@ -1,4 +1,4 @@ { "azext.isPreview": true, - "azext.minCliCoreVersion": "2.0.67" + "azext.minCliCoreVersion": "2.3.1" } \ No newline at end of file diff --git a/src/k8sconfiguration/setup.py b/src/k8sconfiguration/setup.py index fa2142e0d88..17b8070b62c 100644 --- a/src/k8sconfiguration/setup.py +++ b/src/k8sconfiguration/setup.py @@ -16,7 +16,7 @@ # TODO: Confirm this is the right version number you want and it matches your # HISTORY.rst entry. -VERSION = '0.1.7' +VERSION = '0.1.8' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers