From ce4f9b70643f5f498e5ff0f7447c4f94792d4a29 Mon Sep 17 00:00:00 2001 From: Ralph El Hage Date: Fri, 29 Mar 2024 22:01:59 -0700 Subject: [PATCH] Tested init code paths. Fixed bugs. --- src/ai/Program_AI.cs | 11 +- src/ai/commands/init_command.cs | 11 +- src/clients.cli/AzCliClient.cs | 139 ++++++++---------- src/clients.cli/AzConsoleLoginManager.cs | 28 +++- src/clients.cli/login_helpers.cs | 97 ------------ src/common/Clients/ILoginManager.cs | 28 ++++ src/common/Clients/Models/ClientResult.cs | 65 +++++--- src/common/Telemetry/ITelemetry.cs | 29 +++- src/common/details/azcli/AzCli.cs | 4 +- src/common/details/azcli/AzCliConsoleGui.cs | 2 +- ...gnitiveServicesResourceDeploymentPicker.cs | 3 +- ...soleGui_CognitiveServicesResourcePicker.cs | 6 +- ...ig_CognitiveServicesResource_OpenAiKind.cs | 2 +- .../AzCliConsoleGui_RegionLocationPicker.cs | 11 +- .../AzCliConsoleGui_ResourceGroupPicker.cs | 16 +- .../AzCliConsoleGui_SubscriptionPicker.cs | 21 +-- .../details/console/ConsoleTempWriter.cs | 18 ++- src/common/details/helpers/login_helpers.cs | 80 ++++++++++ .../UnitTests/AzCliClientIntegrationTests.cs | 12 +- 19 files changed, 324 insertions(+), 259 deletions(-) delete mode 100644 src/clients.cli/login_helpers.cs create mode 100644 src/common/details/helpers/login_helpers.cs diff --git a/src/ai/Program_AI.cs b/src/ai/Program_AI.cs index 601ec151..8c1194c8 100644 --- a/src/ai/Program_AI.cs +++ b/src/ai/Program_AI.cs @@ -90,12 +90,13 @@ public AiProgramData() () => TelemetryHelpers.InstantiateFromConfig(this), System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); - LoginManager = new AzConsoleLoginManager(TelemetryUserAgent); - var azCliClient = new AzCliClient( - LoginManager, - () => Values?.GetOrDefault("init.service.interactive", true) ?? true, - TelemetryUserAgent); + var environmentVariables = LegacyAzCli.GetUserAgentEnv(); + LoginManager = new AzConsoleLoginManager( + () => Values?.GetOrDefault("init.service.interactive", true) ?? true, + environmentVariables); + + var azCliClient = new AzCliClient(environmentVariables); SubscriptionsClient = azCliClient; CognitiveServicesClient = azCliClient; SearchClient = azCliClient; diff --git a/src/ai/commands/init_command.cs b/src/ai/commands/init_command.cs index 6eb81ff2..d6873343 100644 --- a/src/ai/commands/init_command.cs +++ b/src/ai/commands/init_command.cs @@ -187,18 +187,19 @@ private async Task DoInitRootVerifyConfigFileAsync(bool interactive, string file ConsoleHelpers.WriteLineWithHighlight("\n `ATTACHED SERVICES AND RESOURCES`\n"); - var message = " Validating..."; - Console.Write(message); + string indent = " "; + using var cw = new ConsoleTempWriter($"{indent}Validating..."); var (hubName, openai, search) = await AiSdkConsoleGui.VerifyResourceConnections(_values, validated?.Id, groupName, projectName); if (openai != null && search != null) { - Console.Write($"\r{new string(' ', message.Length)}\r"); return (hubName, openai, search); } else { - ConsoleHelpers.WriteLineWithHighlight($"\r{message} `#e_;WARNING: Configuration could not be validated!`"); + cw.Clear(); + Console.Write(indent); + ConsoleHelpers.WriteLineWithHighlight($"`#e_;WARNING: Configuration could not be validated!`"); Console.WriteLine(); return (null, null, null); } @@ -536,7 +537,7 @@ private async Task DoInitOpenAi(bool interactive, bool skipChat = false, bool al var regionFilter = _values.GetOrDefault("init.service.resource.region.name", ""); var groupFilter = _values.GetOrDefault("init.service.resource.group.name", ""); var resourceFilter = _values.GetOrDefault("init.service.cognitiveservices.resource.name", ""); - var kind = _values.GetOrDefault("init.service.cognitiveservices.resource.kind", "OpenAI;AIServices"); + var kind = _values.GetOrDefault("init.service.cognitiveservices.resource.kind", "AIServices;OpenAI"); var sku = _values.GetOrDefault("init.service.cognitiveservices.resource.sku", Program.CognitiveServiceResourceSku); var yes = _values.GetOrDefault("init.service.cognitiveservices.terms.agree", false); diff --git a/src/clients.cli/AzCliClient.cs b/src/clients.cli/AzCliClient.cs index dc743446..6502bba5 100644 --- a/src/clients.cli/AzCliClient.cs +++ b/src/clients.cli/AzCliClient.cs @@ -12,9 +12,9 @@ namespace Azure.AI.CLI.Clients.AzPython /// /// A client for Azure subscriptions that uses the AZ CLI /// - public class AzCliClient : LoginHelpers, ISubscriptionsClient, ICognitiveServicesClient, ISearchClient + public class AzCliClient : ISubscriptionsClient, ICognitiveServicesClient, ISearchClient { - private static readonly System.Text.Json.JsonSerializerOptions JSON_OPTIONS = new System.Text.Json.JsonSerializerOptions() + private static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions() { NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.AllowNamedFloatingPointLiterals, PropertyNameCaseInsensitive = true, @@ -37,23 +37,16 @@ public class AzCliClient : LoginHelpers, ISubscriptionsClient, ICognitiveService /// /// Creates a new instance /// - /// The instance to use for logging in to Azure - /// Used to retrieve whether or not we are running interactive /// The user agent string to add to all HTTP/HTTPS requests - public AzCliClient(ILoginManager loginManager, Func getIsInteractive, string? userAgent) - : base(loginManager, getIsInteractive) + public AzCliClient(IDictionary? cliEnvironmentVariables = null) { - _cliEnv = new Dictionary(); - if (!string.IsNullOrWhiteSpace(userAgent)) - { - _cliEnv["AZURE_HTTP_USER_AGENT"] = userAgent; - } + _cliEnv = cliEnvironmentVariables ?? new Dictionary(); } /// public Task> GetAllSubscriptionsAsync(CancellationToken token) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => ProcessHelpers.RunShellCommandAsync( "az", "account list" @@ -71,14 +64,12 @@ public Task> GetAllSubscriptionsAsync(Cancellat { ValidateString(subscriptionId, nameof(subscriptionId)); - var cmdOut = await GetResponseOnLogin( - () => ProcessHelpers.RunShellCommandAsync( + var cmdOut = await ProcessHelpers.RunShellCommandAsync( "az", $"account show" + $" --subscription {subscriptionId}" + $" --output json", - _cliEnv), - token); + _cliEnv); if (cmdOut.HasError) { // special case: check if the error indicates the subscription does not exist @@ -111,7 +102,7 @@ public async Task> GetAllRegionsAsync( // 2 step process, first see the regions where we can have OpenAI resources, and query for all possible regions // and filter that list. The second call is needed to get more details about the regions - var csRegionsOutput = await RunOrLoginArrayAsync( + var csRegionsOutput = await ParseArrayAsync( () => ProcessHelpers.RunShellCommandAsync( "az", $"cognitiveservices account list-skus" + @@ -137,7 +128,7 @@ public async Task> GetAllRegionsAsync( csRegionsOutput.Value ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); - var allRegionsOutput = await RunOrLoginArrayAsync( + var allRegionsOutput = await ParseArrayAsync( () => ProcessHelpers.RunShellCommandAsync( "az", "account list-locations" + @@ -163,7 +154,7 @@ public async Task> GetAllRegionsAsync( /// public Task> GetAllResourceGroupsAsync(string subscriptionId, CancellationToken token) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -182,7 +173,7 @@ public Task> GetAllResourceGroupsAsync(string public Task> CreateResourceGroupAsync( string subscriptionId, string regionName, string name, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -204,7 +195,7 @@ public Task> CreateResourceGroupAsync( /// public Task DeleteResourceGroupAsync(string subscriptionId, string resourceGroup, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -226,7 +217,7 @@ public Task DeleteResourceGroupAsync(string subscriptionId, strin public Task> GetAllResourcesAsync( string subscriptionId, CancellationToken token, ResourceKind? filter = null) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -268,16 +259,14 @@ public Task> GetAllResourcesAsync( ValidateString(resourceGroup, nameof(resourceGroup)); ValidateString(name, nameof(name)); - var cmdOut = await GetResponseOnLogin( - () => ProcessHelpers.RunShellCommandAsync( - "az", - $"cognitiveservices account show" + - $" --output json" + - $" --subscription {subscriptionId}" + - $" -g {resourceGroup}" + - $" -n {name}", - _cliEnv), - token); + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + $"cognitiveservices account show" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {name}", + _cliEnv); if (cmdOut.HasError) { // special case: check if the error indicates the subscription does not exist @@ -304,7 +293,7 @@ public Task> GetAllResourcesAsync( public Task> CreateResourceAsync( ResourceKind kind, string subscriptionId, string resourceGroup, string region, string name, string sku, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { if (!IsCognitiveServicesResourceKind(kind)) @@ -337,7 +326,7 @@ public Task> CreateResourceAsync( /// public Task DeleteResourceAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -360,7 +349,7 @@ public Task DeleteResourceAsync(string subscriptionId, string reso public Task> GetAllDeploymentsAsync( string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -383,7 +372,7 @@ public Task> GetAllDeploymentsAs public Task> GetAllModelsAsync( string subscriptionId, string regionName, CancellationToken token) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -404,7 +393,7 @@ public Task> GetAllModelsAsync( public Task> GetAllModelUsageAsync( string subscriptionId, string regionName, CancellationToken token) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -427,7 +416,7 @@ public Task> CreateDeploymentAsync string deploymentName, string modelName, string modelVersion, string modelFormat, string scaleCapacity, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -459,7 +448,7 @@ public Task> CreateDeploymentAsync public Task DeleteDeploymentAsync(string subscriptionId, string resourceGroup, string resourceName, string deploymentName, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -484,7 +473,7 @@ public Task DeleteDeploymentAsync(string subscriptionId, string re public Task> GetResourceKeysFromNameAsync( string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -515,7 +504,7 @@ public Task DeleteDeploymentAsync(string subscriptionId, string re Task> ISearchClient.GetAllAsync( string subscriptionId, string? regionName, CancellationToken token) { - return RunOrLoginArrayAsync( + return ParseArrayAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -541,16 +530,14 @@ Task> ISearchClient.GetAllAsync( ValidateString(resourceGroup, nameof(resourceGroup)); ValidateString(name, nameof(name)); - var cmdOut = await GetResponseOnLogin( - () => ProcessHelpers.RunShellCommandAsync( - "az", - $"search service show" + - $" --output json" + - $" --subscription {subscriptionId}" + - $" -g {resourceGroup}" + - $" -n {name}", - _cliEnv), - token); + var cmdOut = await ProcessHelpers.RunShellCommandAsync( + "az", + $"search service show" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {name}", + _cliEnv); if (cmdOut.HasError) { // special case: check if the error indicates the subscription does not exist @@ -576,7 +563,7 @@ Task> ISearchClient.GetAllAsync( Task> ISearchClient.CreateAsync( string subscriptionId, string resourceGroup, string regionName, string name, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -600,22 +587,22 @@ Task> ISearchClient.CreateAsync( Task ISearchClient.DeleteAsync(string subscriptionId, string resourceGroup, string resourceName, CancellationToken token) { - return RunOrLoginAsync( - () => - { - ValidateString(subscriptionId, nameof(subscriptionId)); - ValidateString(resourceGroup, nameof(resourceGroup)); - ValidateString(resourceName, nameof(resourceName)); + return ParseAsync( + () => + { + ValidateString(subscriptionId, nameof(subscriptionId)); + ValidateString(resourceGroup, nameof(resourceGroup)); + ValidateString(resourceName, nameof(resourceName)); - return ProcessHelpers.RunShellCommandAsync( - "az", - $"search service delete" + - $" --output json" + - $" --subscription {subscriptionId}" + - $" -g {resourceGroup}" + - $" -n {resourceName}" + - $" --yes", - _cliEnv); + return ProcessHelpers.RunShellCommandAsync( + "az", + $"search service delete" + + $" --output json" + + $" --subscription {subscriptionId}" + + $" -g {resourceGroup}" + + $" -n {resourceName}" + + $" --yes", + _cliEnv); }, token); } @@ -623,7 +610,7 @@ Task ISearchClient.DeleteAsync(string subscriptionId, string resou Task> ISearchClient.GetKeysAsync( string subscriptionId, string resourceGroup, string name, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( () => { ValidateString(subscriptionId, nameof(subscriptionId)); @@ -689,32 +676,32 @@ private static bool IsCognitiveServicesResourceKind(ResourceKind resourceKind) = return JsonSerializer.Deserialize(json, options); } - private Task> RunOrLoginArrayAsync( + private Task> ParseArrayAsync( Func> func, CancellationToken token, Func? @default = null) { @default ??= Array.Empty; - return RunOrLoginAsync( + return ParseAsync( func, DeserializeJson, @default, token); } - private Task> RunOrLoginAsync( + private Task> ParseAsync( Func> func, CancellationToken token) { - return RunOrLoginAsync( + return ParseAsync( func, DeserializeJson, () => default!, token); } - private async Task> RunOrLoginAsync( + private async Task> ParseAsync( Func> func, Func conv, Func @default, @@ -722,7 +709,7 @@ private async Task> RunOrLoginAsync( { try { - ProcessOutput cmdOut = await GetResponseOnLogin(func, token); + ProcessOutput cmdOut = await func(); if (cmdOut.HasError) { return ClientResult.From(cmdOut, @default()); @@ -738,13 +725,13 @@ private async Task> RunOrLoginAsync( } } - private async Task RunOrLoginAsync( + private async Task ParseAsync( Func> func, CancellationToken token) { try { - ProcessOutput cmdOut = await GetResponseOnLogin(func, token); + ProcessOutput cmdOut = await func(); return ClientResult.From(cmdOut); } catch (Exception ex) diff --git a/src/clients.cli/AzConsoleLoginManager.cs b/src/clients.cli/AzConsoleLoginManager.cs index 78d91d0e..0278280c 100644 --- a/src/clients.cli/AzConsoleLoginManager.cs +++ b/src/clients.cli/AzConsoleLoginManager.cs @@ -11,20 +11,35 @@ namespace Azure.AI.CLI.Clients.AzPython /// public class AzConsoleLoginManager : ILoginManager { + private readonly Func _getAllowInteractive; private readonly IDictionary _cliEnv; private readonly TimeSpan _minValidity; private AuthenticationToken? _authToken; - public AzConsoleLoginManager(string userAgent, TimeSpan? minValidity = null) + /// + /// Creates a new instance of + /// + /// A function that returns whether interactive login is allowed + /// Environment variables to use when running the AZ CLI + /// The minimum validity an auth token should have before renewing it + public AzConsoleLoginManager(Func getAllowInteractiveLogin, IDictionary? environmentVariables = null, TimeSpan? minValidity = null) { - _cliEnv = new Dictionary() - { - { "AZURE_HTTP_USER_AGENT", userAgent ?? throw new ArgumentNullException(nameof(userAgent)) } - }; - + _getAllowInteractive = getAllowInteractiveLogin ?? throw new ArgumentNullException(nameof(getAllowInteractiveLogin)); + _cliEnv = environmentVariables ?? new Dictionary(); _minValidity = minValidity ?? TimeSpan.FromMinutes(5); } + /// + public bool CanAttemptLogin + { + get + { + try { return _getAllowInteractive(); } + catch (Exception) { return false; } + } + } + + /// public async Task LoginAsync(LoginOptions options, CancellationToken token) { try @@ -66,6 +81,7 @@ public async Task LoginAsync(LoginOptions options, CancellationTok } } + /// public async Task> GetOrRenewAuthToken(CancellationToken token) { try diff --git a/src/clients.cli/login_helpers.cs b/src/clients.cli/login_helpers.cs deleted file mode 100644 index a5acf903..00000000 --- a/src/clients.cli/login_helpers.cs +++ /dev/null @@ -1,97 +0,0 @@ -#nullable enable - -using Azure.AI.CLI.Common.Clients; -using Azure.AI.CLI.Common.Clients.Models; -using Azure.AI.Details.Common.CLI; -using Azure.AI.Details.Common.CLI.ConsoleGui; -using Azure.AI.Details.Common.CLI.details.console; - -namespace Azure.AI.CLI.Clients.AzPython -{ - public abstract class LoginHelpers - { - private const string LOADING_CHOICES = "*** Loading choices ***"; - protected readonly ILoginManager _loginManager; - private readonly Func _getAllowInteractiveLogin; - - protected LoginHelpers(ILoginManager loginManager, Func getAllowInteractiveLogin) - { - _loginManager = loginManager ?? throw new ArgumentNullException(nameof(loginManager)); - _getAllowInteractiveLogin = getAllowInteractiveLogin ?? throw new ArgumentNullException(nameof(getAllowInteractiveLogin)); - } - - protected bool IsInteractive => _getAllowInteractiveLogin(); - - protected async Task GetResponseOnLogin(Func> getResponse, CancellationToken token) - { - using var writer = new ConsoleTempWriter(); - writer.WriteTemp(LOADING_CHOICES); - - var response = await getResponse(); - if (string.IsNullOrEmpty(response.StdOutput) && !string.IsNullOrEmpty(response.StdError)) - { - if (HasLoginError(response.StdError)) - { - var loginResponse = await AttemptLogin(writer, token); - if (loginResponse.IsSuccess) - { - writer.WriteTemp(LOADING_CHOICES); - - response = await getResponse(); - } - else - { - writer.Clear(); - ConsoleHelpers.WriteLineError("*** Please run `az login` and try again ***"); - } - } - } - - return response; - } - - protected async Task AttemptLogin(ConsoleTempWriter writer, CancellationToken token) - { - bool cancelLogin = !IsInteractive; - bool useDeviceCode = false; - if (IsInteractive) - { - writer.WriteErrorTemp("*** WARNING: `az login` required ***"); - writer.AppendTemp(" "); - - var selection = 0; - var choices = new List() { - "LAUNCH: `az login` (interactive device code)", - "CANCEL: `az login ...` (non-interactive)", - }; - - if (!OS.IsCodeSpaces()) - { - choices.Insert(0, "LAUNCH: `az login` (interactive browser)"); - selection = OS.IsWindows() ? 0 : 1; - } - - var picked = ListBoxPicker.PickIndexOf(choices.ToArray(), selection); - - cancelLogin = picked < 0 || picked == choices.Count() - 1; - useDeviceCode = picked == choices.Count() - 2; - } - - if (cancelLogin) - { - return new ClientResult() - { - Outcome = ClientOutcome.Canceled, - }; - } - - writer.WriteTemp($"*** Launching `az login` (interactive) ***"); - var response = await _loginManager.LoginAsync( - new() { Mode = useDeviceCode ? LoginMode.UseDeviceCode : LoginMode.UseWebPage }, - token); - return response; - } - - private static bool HasLoginError(string errorMessage) => errorMessage.Split('\'', '"').Contains("az login") || errorMessage.Contains("refresh token"); - } -} diff --git a/src/common/Clients/ILoginManager.cs b/src/common/Clients/ILoginManager.cs index 1b388d97..86139051 100644 --- a/src/common/Clients/ILoginManager.cs +++ b/src/common/Clients/ILoginManager.cs @@ -23,16 +23,44 @@ public enum LoginMode UseDeviceCode, } + /// + /// Options for logging in + /// public struct LoginOptions { + /// + /// The type of login to attempt + /// public LoginMode Mode { get; set; } + //public string? TenantId { get; set; } //public string? UserName { get; set; } } + /// + /// Manages the login process for a client + /// public interface ILoginManager : IDisposable { + /// + /// Whether or not we can attempt to log in + /// + /// True or false + public bool CanAttemptLogin { get; } + + /// + /// Logs the client in + /// + /// The options to use for the login + /// The cancellation token to use + /// Asynchronous task that completes once the login has completed (or failed) Task LoginAsync(LoginOptions options, CancellationToken token); + + /// + /// Gets an authentication token to use for sending HTTP REST requests + /// + /// The cancellation token to use + /// Asynchronous task that returns the authentication token Task> GetOrRenewAuthToken(CancellationToken token); } } diff --git a/src/common/Clients/Models/ClientResult.cs b/src/common/Clients/Models/ClientResult.cs index 945319be..dbe230c4 100644 --- a/src/common/Clients/Models/ClientResult.cs +++ b/src/common/Clients/Models/ClientResult.cs @@ -163,27 +163,24 @@ internal static bool CheckIsError(ClientOutcome Outcome, string? ErrorDetails, E || Exception != null || !string.IsNullOrWhiteSpace(ErrorDetails); + internal static Exception GenerateException(ClientOutcome outcome, string? message = null, string? errorDetails = null, Exception? ex = null) + => outcome switch + { + ClientOutcome.Canceled => new OperationCanceledException(CreateExceptionMessage("CANCELED", message, errorDetails), ex), + ClientOutcome.Failed or ClientOutcome.Unknown => new ApplicationException(CreateExceptionMessage("FAILED", message, errorDetails), ex), + ClientOutcome.LoginNeeded => new ApplicationException(CreateExceptionMessage("LOGIN REQUIRED", message, errorDetails), ex), + ClientOutcome.TimedOut => new TimeoutException(CreateExceptionMessage("TIMED OUT", message, errorDetails), ex), + _ => new ApplicationException(CreateExceptionMessage("FAILED", message, errorDetails), ex), + }; + internal static void ThrowOnFail(ClientOutcome outcome, string? message, string? errorDetails, Exception? ex) { - switch (outcome) + if (ClientOutcome.Success == outcome) { - case ClientOutcome.Canceled: - throw new OperationCanceledException(CreateExceptionMessage("CANCELED", message, errorDetails), ex); - - case ClientOutcome.Failed: - case ClientOutcome.Unknown: - throw new ApplicationException(CreateExceptionMessage("FAILED", message, errorDetails), ex); - - case ClientOutcome.LoginNeeded: - throw new ApplicationException(CreateExceptionMessage("LOGIN REQUIRED", message, errorDetails), ex); - - case ClientOutcome.Success: - // nothing to do - return; - - case ClientOutcome.TimedOut: - throw new TimeoutException(CreateExceptionMessage("TIMED OUT", message, errorDetails), ex); + return; } + + throw GenerateException(outcome, message, errorDetails, ex); } private static string CreateExceptionMessage(string typeStr, string? messageStr, string? detailsStr) @@ -239,27 +236,45 @@ internal static string ToString(T result) where T : IClientResult internal static ClientResult From(ProcessOutput output) { - bool hasError = output.HasError; + ClientOutcome outcome = ClientOutcome.Success; + if (HasLoginError(output.StdError)) + { + outcome = ClientOutcome.LoginNeeded; + } + else if (output.HasError) + { + outcome = ClientOutcome.Failed; + } + return new ClientResult { ErrorDetails = string.IsNullOrWhiteSpace(output.StdError) ? null : output.StdError, - Exception = hasError + Exception = outcome != ClientOutcome.Success ? new ApplicationException($"Process failed with exit code {output.ExitCode}. Details: {output.StdError}") : null, - Outcome = hasError ? ClientOutcome.Failed : ClientOutcome.Success, + Outcome = outcome, }; } internal static ClientResult From(ProcessOutput output, TValue value) { - bool hasError = output.HasError; + ClientOutcome outcome = ClientOutcome.Success; + if (HasLoginError(output.StdError)) + { + outcome = ClientOutcome.LoginNeeded; + } + else if (output.HasError) + { + outcome = ClientOutcome.Failed; + } + return new ClientResult { ErrorDetails = string.IsNullOrWhiteSpace(output.StdError) ? null : output.StdError, - Exception = hasError + Exception = outcome != ClientOutcome.Success ? new ApplicationException($"Process failed with exit code {output.ExitCode}. Details: {output.StdError}") : null, - Outcome = hasError ? ClientOutcome.Failed : ClientOutcome.Success, + Outcome = outcome, Value = value }; } @@ -290,6 +305,10 @@ internal static ClientResult FromException(Exception ex, TValue ErrorDetails = ex.Message, Value = result }; + + private static bool HasLoginError(string? errorMessage) + => errorMessage != null + && (errorMessage.Split('\'', '"').Contains("az login") || errorMessage.Contains("refresh token")); } #endregion diff --git a/src/common/Telemetry/ITelemetry.cs b/src/common/Telemetry/ITelemetry.cs index 80497b90..875d9af4 100644 --- a/src/common/Telemetry/ITelemetry.cs +++ b/src/common/Telemetry/ITelemetry.cs @@ -28,9 +28,12 @@ public interface ITelemetry : IAsyncDisposable /// /// Helper methods to make working with telemetry easier /// - [DebuggerStepThrough] + [DebuggerNonUserCode] public static class TelemetryExtensions { + // NOTE: To make stepping through the code easier, we've added DebuggerNonUserCode attributes to these methods + // so that the debugger will not step into them + #region delegates /// @@ -66,6 +69,7 @@ public delegate ITelemetryEvent CreateTelemetryEvent( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static Task WrapAsync( this ITelemetry telemetry, Func doWorkAsync, @@ -73,7 +77,7 @@ public static Task WrapAsync( { return WrapResultAsync( telemetry, - async () => + [DebuggerNonUserCode] async () => { await doWorkAsync().ConfigureAwait(false); return (Outcome.Success, true); @@ -88,6 +92,7 @@ public static Task WrapAsync( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static async Task WrapAsync( this ITelemetry telemetry, Func> doWorkAsync, @@ -95,7 +100,7 @@ public static async Task WrapAsync( { var (outcome, _) = await WrapResultAsync( telemetry, - async () => + [DebuggerNonUserCode] async () => { var outcome = await doWorkAsync() .ConfigureAwait(false); @@ -115,6 +120,7 @@ public static async Task WrapAsync( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static async Task WrapAsync( this ITelemetry telemetry, Func> doWorkAsync, @@ -122,7 +128,7 @@ public static async Task WrapAsync( { var (_, result) = await WrapResultAsync( telemetry, - async () => + [DebuggerNonUserCode] async () => { var result = await doWorkAsync().ConfigureAwait(false); return (Outcome.Success, result); @@ -141,6 +147,7 @@ public static async Task WrapAsync( /// The async function to call /// The method to call to create the telemetry event /// Asynchronous task + [DebuggerNonUserCode] public static async Task<(Outcome, TResult)> WrapResultAsync( this ITelemetry telemetry, Func> doWorkAsync, @@ -179,6 +186,7 @@ public static async Task WrapAsync( /// The telemetry instance to use /// The work to do /// The method to call to create the telemetry event + [DebuggerNonUserCode] public static void Wrap( this ITelemetry telemetry, Action doWork, @@ -186,7 +194,7 @@ public static void Wrap( { var (outcome, _) = WrapResult( telemetry, - () => + [DebuggerNonUserCode] () => { doWork(); return (Outcome.Success, true); @@ -201,6 +209,7 @@ public static void Wrap( /// The work to do /// The method to call to create the telemetry event /// The returned outcome from the work + [DebuggerNonUserCode] public static Outcome Wrap( this ITelemetry telemetry, Func doWork, @@ -208,7 +217,7 @@ public static Outcome Wrap( { var (outcome, _) = WrapResult( telemetry, - () => + [DebuggerNonUserCode] () => { var outcome = doWork(); return (outcome, true); @@ -226,6 +235,7 @@ public static Outcome Wrap( /// The work to do /// The method to call to create the telemetry event /// The returned result from the work + [DebuggerNonUserCode] public static TResult Wrap( this ITelemetry telemetry, Func doWork, @@ -233,7 +243,7 @@ public static TResult Wrap( { var (_, result) = WrapResult( telemetry, - () => + [DebuggerNonUserCode] () => { var result = doWork(); return (Outcome.Success, result); @@ -251,6 +261,7 @@ public static TResult Wrap( /// The work to do /// The method to call to create the telemetry event /// The returned outcome, and result from the work + [DebuggerNonUserCode] public static (Outcome, TResult) WrapResult( this ITelemetry telemetry, Func<(Outcome, TResult)> doWork, @@ -283,9 +294,11 @@ public static (Outcome, TResult) WrapResult( #region helper methods + [DebuggerNonUserCode] private static CreateTelemetryEvent Wrap(CreateTelemetryEvent creator) => (Outcome outcome, TResult _, Exception ex, TimeSpan duration) => creator(outcome, ex, duration); + [DebuggerNonUserCode] private static Exception FlattenException(Exception ex) { // special case for AggregateExceptions that wrap only a single exception. This can happen for @@ -300,6 +313,7 @@ private static Exception FlattenException(Exception ex) } } + [DebuggerNonUserCode] private static Outcome ToOutcome(Exception ex) => ex switch { @@ -309,6 +323,7 @@ private static Outcome ToOutcome(Exception ex) => }; + [DebuggerNonUserCode] private static void SendEvent(ITelemetry telemetry, Func creator) { if (telemetry != null) diff --git a/src/common/details/azcli/AzCli.cs b/src/common/details/azcli/AzCli.cs index ef34e28a..9f6695a7 100644 --- a/src/common/details/azcli/AzCli.cs +++ b/src/common/details/azcli/AzCli.cs @@ -39,9 +39,9 @@ public struct CognitiveSearchResourceInfoEx } } - public class LegacyAzCli + public static class LegacyAzCli { - private static Dictionary GetUserAgentEnv() + public static Dictionary GetUserAgentEnv() { var dict = new Dictionary(); dict.Add("AZURE_HTTP_USER_AGENT", Program.TelemetryUserAgent); diff --git a/src/common/details/azcli/AzCliConsoleGui.cs b/src/common/details/azcli/AzCliConsoleGui.cs index 76ee0b76..16d229f5 100644 --- a/src/common/details/azcli/AzCliConsoleGui.cs +++ b/src/common/details/azcli/AzCliConsoleGui.cs @@ -39,7 +39,7 @@ public partial class AzCliConsoleGui Console.Write("\rName: *** Loading choices ***"); var response = await Program.SearchClient.GetAllAsync(subscription, location, Program.CancelToken); - response.ThrowOnFail("Listing search resources"); + response.ThrowOnFail("Loading search resources"); var resources = response.Value.OrderBy(x => x.Name).ToList(); var choices = resources.Select(x => $"{x.Name} ({x.RegionLocation})").ToList(); diff --git a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs index cf7bf8fb..233be047 100644 --- a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourceDeploymentPicker.cs @@ -41,7 +41,8 @@ public partial class AzCliConsoleGui { var allowCreateDeployment = !string.IsNullOrEmpty(allowCreateDeploymentOption); - var response = await Program.CognitiveServicesClient.GetAllDeploymentsAsync(subscriptionId, groupName, resourceName, Program.CancelToken); + var listDeploymentsFunc = () => Program.CognitiveServicesClient.GetAllDeploymentsAsync(subscriptionId, groupName, resourceName, Program.CancelToken); + var response = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, listDeploymentsFunc, Program.CancelToken); response.ThrowOnFail("Loading Cognitive Services deployments"); var lookForChatCompletionCapable = deploymentExtra.ToLower() == "chat" || deploymentExtra.ToLower() == "evaluation"; diff --git a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs index 5c46172c..8f6d670c 100644 --- a/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_CognitiveServicesResourcePicker.cs @@ -64,7 +64,9 @@ public partial class AzCliConsoleGui // TODO FIXME: Switch to ResourceKind as flags instead of passing strings? ResourceKind? kind = ResourceKindHelpers.ParseString(kinds, ';'); - var response = await Program.CognitiveServicesClient.GetAllResourcesAsync(subscriptionId, Program.CancelToken, kind); + + var listResourcesFunc = () => Program.CognitiveServicesClient.GetAllResourcesAsync(subscriptionId, Program.CancelToken, kind); + var response = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, listResourcesFunc, Program.CancelToken); response.ThrowOnFail("Loading Cognitive Services resources"); var resources = response.Value @@ -164,7 +166,7 @@ public partial class AzCliConsoleGui if (hasP0 && picked == 0) { Console.WriteLine(p0); - return new AzCli.CognitiveServicesResourceInfo(); + return new AzCli.CognitiveServicesResourceInfo() { Name = null! }; // Explicitly set name to null since this is how we detect "new" vs "canceled" (latter returns null) } if (hasP0) picked--; diff --git a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs index 9c5de8ab..896e12d7 100644 --- a/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs +++ b/src/common/details/azcli/AzCliConsoleGui_PickOrCreateAndConfig_CognitiveServicesResource_OpenAiKind.cs @@ -43,7 +43,7 @@ public partial class AzCliConsoleGui string embeddingsModelFilter = null, string evaluationsModelFilter = null) { - kinds ??= "OpenAI;AIServices"; + kinds ??= "AIServices;OpenAI"; var sectionHeader = "AZURE OPENAI RESOURCE"; var regionLocation = !string.IsNullOrEmpty(regionFilter) ? await AzCliConsoleGui.PickRegionLocationAsync(interactive, subscriptionId, regionFilter) : new AzCli.AccountRegionLocationInfo(); diff --git a/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs b/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs index 4ae3d4ff..0fc95289 100644 --- a/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_RegionLocationPicker.cs @@ -26,9 +26,9 @@ public partial class AzCliConsoleGui Console.Write("\rRegion: *** Loading choices ***"); var allRegions = await Program.SubscriptionClient.GetAllRegionsAsync(subscriptionId, Program.CancelToken); - + Console.Write("\rRegion: "); - allRegions.ThrowOnFail("reource region/locations"); + allRegions.ThrowOnFail("resource region/locations"); var regions = allRegions.Value .Where(x => MatchRegionLocationFilter(x, regionFilter)) @@ -38,6 +38,7 @@ public partial class AzCliConsoleGui var exactMatches = regionFilter == null ? Array.Empty() : regions.Where(x => ExactMatchRegionLocation(x, regionFilter)).ToArray(); + if (exactMatches.Length == 1) regions = regions.Where(x => ExactMatchRegionLocation(x, regionFilter)).ToArray(); if (regions.Count() == 0) { @@ -93,7 +94,7 @@ public partial class AzCliConsoleGui static bool ExactMatchRegionLocation(AzCli.AccountRegionLocationInfo regionLocation, string regionLocationFilter) { - return regionLocation.Name.ToLower() == regionLocationFilter || regionLocation.DisplayName == regionLocationFilter || regionLocation.RegionalDisplayName == regionLocationFilter; + return regionLocation.Name.ToLowerInvariant() == regionLocationFilter || regionLocation.DisplayName == regionLocationFilter || regionLocation.RegionalDisplayName == regionLocationFilter; } private static bool MatchRegionLocationFilter(AzCli.AccountRegionLocationInfo regionLocation, string regionLocationFilter) @@ -103,8 +104,8 @@ private static bool MatchRegionLocationFilter(AzCli.AccountRegionLocationInfo re return true; } - var displayName = regionLocation.DisplayName.ToLower(); - var regionalName = regionLocation.RegionalDisplayName.ToLower(); + var displayName = regionLocation.DisplayName.ToLowerInvariant(); + var regionalName = regionLocation.RegionalDisplayName.ToLowerInvariant(); return displayName.Contains(regionLocationFilter) || StringHelpers.ContainsAllCharsInOrder(displayName, regionLocationFilter) || regionalName.Contains(regionLocationFilter) || StringHelpers.ContainsAllCharsInOrder(regionalName, regionLocationFilter); diff --git a/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs b/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs index f3573d40..7371ead5 100644 --- a/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_ResourceGroupPicker.cs @@ -45,12 +45,14 @@ public partial class AzCliConsoleGui { var allowCreateGroup = !string.IsNullOrEmpty(allowCreateGroupOption); - var allResGroups = await Program.SubscriptionClient.GetAllResourceGroupsAsync(subscription, Program.CancelToken); - allResGroups.ThrowOnFail("Loading resource groups"); - - var groups = allResGroups.Value - .Where(x => MatchGroupFilter(x, groupFilter)) - .OrderBy(x => x.Name) + var listResourcesFunc = () => Program.SubscriptionClient.GetAllResourceGroupsAsync(subscription, Program.CancelToken); + var response = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, listResourcesFunc, Program.CancelToken, "Group"); + response.ThrowOnFail("Loading resource groups"); + + var groups = response.Value + .Where(rg => regionLocation == null || string.Equals(regionLocation, rg.Region, StringComparison.OrdinalIgnoreCase)) + .Where(rg => MatchGroupFilter(rg, groupFilter)) + .OrderBy(rg => rg.Name) .ToList(); var exactMatch = groupFilter != null && groups.Count(x => ExactMatchGroup(x, groupFilter)) == 1; @@ -60,7 +62,7 @@ public partial class AzCliConsoleGui { if (!allowCreateGroup) { - ConsoleHelpers.WriteLineError(allResGroups.Value.Count() > 0 + ConsoleHelpers.WriteLineError(response.Value.Count() > 0 ? $"*** No matching resource groups found ***" : $"*** No resource groups found ***"); return null; diff --git a/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs b/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs index 63310e5a..e54bb8bf 100644 --- a/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs +++ b/src/common/details/azcli/AzCliConsoleGui_SubscriptionPicker.cs @@ -45,30 +45,33 @@ public static async Task PickSubscriptionIdAsync(bool allowInteractiveLo public static async Task ValidateSubscriptionAsync(bool allowInteractiveLogin, string subscriptionId) { - string prefix = " Subscription: "; - Console.Write($"{prefix}*** Validating ***"); + var getSubscription = () => Program.SubscriptionClient.GetSubscriptionAsync(subscriptionId, Program.CancelToken); + var result = await LoginHelpers.GetResponseOnLogin(Program.LoginManager, getSubscription, Program.CancelToken, " SUBSCRIPTION", "*** Validating ***"); - AzCli.SubscriptionInfo? subscription = (await Program.SubscriptionClient.GetSubscriptionAsync(subscriptionId, Program.CancelToken)) - .Value; + AzCli.SubscriptionInfo? subscription = result.Value; bool found = subscription != null; if (found) { - Console.WriteLine($"{prefix}{subscription?.Name} ({subscription?.Id})"); + Console.WriteLine($"{subscription?.Name} ({subscription?.Id})"); CacheSubscriptionUserName(subscription.Value); return subscription; } else { - ConsoleHelpers.WriteLineWithHighlight($"{prefix}`#e_;WARNING: Could not find subscription {subscriptionId}!`"); + ConsoleHelpers.WriteLineWithHighlight($"`#e_;WARNING: Could not find subscription {subscriptionId}!`"); + Console.WriteLine(); return null; } } private static async Task FindSubscriptionAsync(bool allowInteractiveLogin, bool allowInteractivePickSubscription, string subscriptionFilter = null, string subscriptionLabel = "Subscription") { - Console.Write($"\r{subscriptionLabel}: *** Loading choices ***"); - var subsResult = await Program.SubscriptionClient.GetAllSubscriptionsAsync(Program.CancelToken); + var subsResult = await LoginHelpers.GetResponseOnLogin( + Program.LoginManager, + () => Program.SubscriptionClient.GetAllSubscriptionsAsync(Program.CancelToken), + Program.CancelToken, + subscriptionLabel); if (subsResult.IsError) { @@ -78,7 +81,7 @@ public static async Task PickSubscriptionIdAsync(bool allowInteractiveLo } else { - subsResult.ThrowOnFail(); + subsResult.ThrowOnFail("Loading subscriptions"); } } diff --git a/src/common/details/console/ConsoleTempWriter.cs b/src/common/details/console/ConsoleTempWriter.cs index 7316553f..0e02f28b 100644 --- a/src/common/details/console/ConsoleTempWriter.cs +++ b/src/common/details/console/ConsoleTempWriter.cs @@ -1,15 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Azure.AI.Details.Common.CLI.details.console +namespace Azure.AI.Details.Common.CLI { public struct ConsoleTempWriter : IDisposable { private int _tempCount; + public ConsoleTempWriter() + { } + + public ConsoleTempWriter(string message) + { + WriteTemp(message ?? string.Empty); + } + public void WriteTemp(string message) => Overwrite(message, Console.Write); public void AppendTemp(string message) @@ -32,7 +34,7 @@ private void Overwrite(string message, Action writer) try { // 1. Go back to the start of temp string (does not delete anything just moves the caret so to speak) - Console.WriteLine(new string('\b', _tempCount)); + Console.Write(new string('\b', _tempCount)); // 2. Write out new message writer(message); diff --git a/src/common/details/helpers/login_helpers.cs b/src/common/details/helpers/login_helpers.cs new file mode 100644 index 00000000..fa0b8f8e --- /dev/null +++ b/src/common/details/helpers/login_helpers.cs @@ -0,0 +1,80 @@ +#nullable enable + +using Azure.AI.CLI.Common.Clients; +using Azure.AI.CLI.Common.Clients.Models; +using Azure.AI.Details.Common.CLI.ConsoleGui; + +namespace Azure.AI.Details.Common.CLI +{ + public static class LoginHelpers + { + public static async Task GetResponseOnLogin(ILoginManager loginManager, Func> getResult, CancellationToken token, string prompt = "Name", string loadingMessage = "*** Loading choices ***") + where TResult : IClientResult + { + Console.Write($"{prompt}: "); + + using var writer = new ConsoleTempWriter(); + writer.WriteTemp(loadingMessage); + + var result = await getResult(); + if (result.Outcome == ClientOutcome.LoginNeeded) + { + var loginResponse = await AttemptLogin(loginManager, writer, token); + if (loginResponse.IsSuccess) + { + writer.WriteTemp(loadingMessage); + result = await getResult(); + } + else + { + writer.Clear(); + ConsoleHelpers.WriteLineError("*** Please run `az login` and try again ***"); + } + } + + return result; + } + + private static async Task AttemptLogin(ILoginManager loginManager, ConsoleTempWriter writer, CancellationToken token) + { + bool cancelLogin = !loginManager.CanAttemptLogin; + bool useDeviceCode = false; + if (loginManager.CanAttemptLogin) + { + writer.WriteErrorTemp("*** WARNING: `az login` required ***"); + writer.AppendTemp(" "); + + var selection = 0; + var choices = new List() { + "LAUNCH: `az login` (interactive device code)", + "CANCEL", + }; + + if (!OS.IsCodeSpaces()) + { + choices.Insert(0, "LAUNCH: `az login` (interactive browser)"); + selection = OS.IsWindows() ? 0 : 1; + } + + var picked = ListBoxPicker.PickIndexOf(choices.ToArray(), selection); + + cancelLogin = picked < 0 || picked == choices.Count() - 1; + useDeviceCode = picked == choices.Count() - 2; + } + + if (cancelLogin) + { + return new ClientResult() + { + Outcome = ClientOutcome.Canceled, + }; + } + + writer.WriteTemp($"*** Launching `az login` ***"); + var response = await loginManager.LoginAsync( + new() { Mode = useDeviceCode ? LoginMode.UseDeviceCode : LoginMode.UseWebPage }, + token); + return response; + } + } +} diff --git a/tests/UnitTests/AzCliClientIntegrationTests.cs b/tests/UnitTests/AzCliClientIntegrationTests.cs index a9f8a321..5c984c4d 100644 --- a/tests/UnitTests/AzCliClientIntegrationTests.cs +++ b/tests/UnitTests/AzCliClientIntegrationTests.cs @@ -835,11 +835,15 @@ public async Task GetSearchResourceKeyAsync() #region helper methods and classes - private AzCliClient CreateClient() + private AzCliClient CreateClient() { - string userAgent = "ai-integration-tests"; - var login = new AzConsoleLoginManager(userAgent); - return new AzCliClient(login, () => false, userAgent); + var environmentVariables = new Dictionary() + { + { "AZURE_HTTP_USER_AGENT", "ai-integration-tests" }, + // TODO add HTTP_PROXY, HTTPS_PROXY, and REQUESTS_CA_BUNDLE to enable the use of the Test proxy at a later point + }; + + return new AzCliClient(environmentVariables); } private static void HasException(IClientResult result, ClientOutcome expectedOutcome = ClientOutcome.Failed)