-
Notifications
You must be signed in to change notification settings - Fork 55
Refactor Azure authentication and service calls #164
Changes from all commits
89282df
f53eb9c
75a8e35
1787ea4
52098e6
53fd8e9
a33a2c9
e47c9df
4449040
6f3a6fe
d47fddd
5215155
eeb4b11
5e62413
26a79ea
69a4fe6
c63a006
1c7d83f
cbdd00e
944cec0
12c7e12
b8e680b
126f43b
43c07ae
2552632
4bf71db
52c481d
a470662
354d826
9041701
0300ecd
8ce9c63
e3e7bcc
f807723
1409213
78196ff
fce176e
0164134
edbe96e
1b27c4b
3dabab4
7c69473
a9bc3d3
c9dec08
b92db91
bb82ac4
78886f7
274ebde
62a358e
204ed5a
7450585
bc90371
f30a387
ce542ca
9b3b74b
eb8de48
0429635
5213b99
7e1146e
c0a7ce0
eb76029
2c39bfe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,20 +5,15 @@ | |
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Net; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Azure.Quantum; | ||
| using Microsoft.Azure.Quantum.Client; | ||
| using Microsoft.Azure.Quantum.Client.Models; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Identity.Client; | ||
| using Microsoft.Identity.Client.Extensions.Msal; | ||
| using Microsoft.Jupyter.Core; | ||
| using Microsoft.Quantum.IQSharp.Common; | ||
| using Microsoft.Quantum.Simulation.Common; | ||
| using Microsoft.Rest.Azure; | ||
|
|
||
| namespace Microsoft.Quantum.IQSharp.AzureClient | ||
| { | ||
|
|
@@ -30,19 +25,15 @@ public class AzureClient : IAzureClient | |
| private IEntryPointGenerator EntryPointGenerator { get; } | ||
| private string ConnectionString { get; set; } = string.Empty; | ||
| private AzureExecutionTarget? ActiveTarget { get; set; } | ||
| private AuthenticationResult? AuthenticationResult { get; set; } | ||
| private IQuantumClient? QuantumClient { get; set; } | ||
| private Azure.Quantum.IWorkspace? ActiveWorkspace { get; set; } | ||
| private IAzureWorkspace? ActiveWorkspace { get; set; } | ||
| private string MostRecentJobId { get; set; } = string.Empty; | ||
| private IPage<ProviderStatus>? AvailableProviders { get; set; } | ||
| private IEnumerable<TargetStatus>? AvailableTargets { get => AvailableProviders?.SelectMany(provider => provider.Targets); } | ||
| private IEnumerable<TargetStatus>? ValidExecutionTargets { get => AvailableTargets?.Where(target => AzureExecutionTarget.IsValid(target.Id)); } | ||
| private string ValidExecutionTargetsDisplayText | ||
| { | ||
| get => ValidExecutionTargets == null | ||
| ? "(no execution targets available)" | ||
| : string.Join(", ", ValidExecutionTargets.Select(target => target.Id)); | ||
| } | ||
| private IEnumerable<ProviderStatus>? AvailableProviders { get; set; } | ||
| private IEnumerable<TargetStatus>? AvailableTargets => AvailableProviders?.SelectMany(provider => provider.Targets); | ||
| private IEnumerable<TargetStatus>? ValidExecutionTargets => AvailableTargets?.Where(target => AzureExecutionTarget.IsValid(target.Id)); | ||
| private string ValidExecutionTargetsDisplayText => | ||
| ValidExecutionTargets == null | ||
| ? "(no execution targets available)" | ||
| : string.Join(", ", ValidExecutionTargets.Select(target => target.Id)); | ||
|
|
||
| public AzureClient( | ||
| IExecutionEngine engine, | ||
|
|
@@ -77,98 +68,33 @@ public async Task<ExecutionResult> ConnectAsync(IChannel channel, | |
| { | ||
| ConnectionString = storageAccountConnectionString; | ||
|
|
||
| var azureEnvironmentEnvVarName = "AZURE_QUANTUM_ENV"; | ||
| var azureEnvironmentName = System.Environment.GetEnvironmentVariable(azureEnvironmentEnvVarName); | ||
| var azureEnvironment = AzureEnvironment.Create(azureEnvironmentName, subscriptionId); | ||
|
|
||
| var msalApp = PublicClientApplicationBuilder | ||
| .Create(azureEnvironment.ClientId) | ||
| .WithAuthority(azureEnvironment.Authority) | ||
| .Build(); | ||
|
|
||
| // Register the token cache for serialization | ||
| var cacheFileName = "aad.bin"; | ||
| var cacheDirectoryEnvVarName = "AZURE_QUANTUM_TOKEN_CACHE"; | ||
| var cacheDirectory = System.Environment.GetEnvironmentVariable(cacheDirectoryEnvVarName); | ||
| if (string.IsNullOrEmpty(cacheDirectory)) | ||
| { | ||
| cacheDirectory = Path.Join(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), ".azure-quantum"); | ||
| } | ||
|
|
||
| var storageCreationProperties = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory, azureEnvironment.ClientId).Build(); | ||
| var cacheHelper = await MsalCacheHelper.CreateAsync(storageCreationProperties); | ||
| cacheHelper.RegisterCache(msalApp.UserTokenCache); | ||
|
|
||
| bool shouldShowLoginPrompt = refreshCredentials; | ||
| if (!shouldShowLoginPrompt) | ||
| { | ||
| try | ||
| { | ||
| var accounts = await msalApp.GetAccountsAsync(); | ||
| AuthenticationResult = await msalApp.AcquireTokenSilent( | ||
| azureEnvironment.Scopes, accounts.FirstOrDefault()).WithAuthority(msalApp.Authority).ExecuteAsync(); | ||
| } | ||
| catch (MsalUiRequiredException) | ||
| { | ||
| shouldShowLoginPrompt = true; | ||
| } | ||
| } | ||
|
|
||
| if (shouldShowLoginPrompt) | ||
| { | ||
| AuthenticationResult = await msalApp.AcquireTokenWithDeviceCode( | ||
| azureEnvironment.Scopes, | ||
| deviceCodeResult => | ||
| { | ||
| channel.Stdout(deviceCodeResult.Message); | ||
| return Task.FromResult(0); | ||
| }).WithAuthority(msalApp.Authority).ExecuteAsync(); | ||
| } | ||
|
|
||
| if (AuthenticationResult == null) | ||
| var azureEnvironment = AzureEnvironment.Create(subscriptionId); | ||
| ActiveWorkspace = await azureEnvironment.GetAuthenticatedWorkspaceAsync(channel, resourceGroupName, workspaceName, refreshCredentials); | ||
| if (ActiveWorkspace == null) | ||
| { | ||
| return AzureClientError.AuthenticationFailed.ToExecutionResult(); | ||
| } | ||
|
|
||
| var credentials = new Rest.TokenCredentials(AuthenticationResult.AccessToken); | ||
| QuantumClient = new QuantumClient(credentials) | ||
| { | ||
| SubscriptionId = subscriptionId, | ||
| ResourceGroupName = resourceGroupName, | ||
| WorkspaceName = workspaceName, | ||
| BaseUri = azureEnvironment.BaseUri, | ||
| }; | ||
| ActiveWorkspace = new Azure.Quantum.Workspace( | ||
| QuantumClient.SubscriptionId, | ||
| QuantumClient.ResourceGroupName, | ||
| QuantumClient.WorkspaceName, | ||
| AuthenticationResult?.AccessToken, | ||
| azureEnvironment.BaseUri); | ||
|
|
||
| try | ||
| { | ||
| AvailableProviders = await QuantumClient.Providers.GetStatusAsync(); | ||
| } | ||
| catch (Exception e) | ||
| AvailableProviders = await ActiveWorkspace.GetProvidersAsync(); | ||
| if (AvailableProviders == null) | ||
| { | ||
| Logger?.LogError(e, $"Failed to download providers list from Azure Quantum workspace: {e.Message}"); | ||
| return AzureClientError.WorkspaceNotFound.ToExecutionResult(); | ||
| } | ||
|
|
||
| channel.Stdout($"Connected to Azure Quantum workspace {QuantumClient.WorkspaceName}."); | ||
| channel.Stdout($"Connected to Azure Quantum workspace {ActiveWorkspace.Name}."); | ||
|
|
||
| return ValidExecutionTargets.ToExecutionResult(); | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public async Task<ExecutionResult> GetConnectionStatusAsync(IChannel channel) | ||
| { | ||
| if (QuantumClient == null || AvailableProviders == null) | ||
| if (ActiveWorkspace == null || AvailableProviders == null) | ||
| { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if it's the right message that you can't connect to a workspace unless it has Q# providers.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The behavior in this case is that the connection to the workspace will succeed, but there will simply be no valid targets to choose from. But it's a good point, and I'll add an output message to make this clear. It would still be possible, for example, to query the jobs list in this case. |
||
| return AzureClientError.NotConnected.ToExecutionResult(); | ||
| } | ||
|
|
||
| channel.Stdout($"Connected to Azure Quantum workspace {QuantumClient.WorkspaceName}."); | ||
| channel.Stdout($"Connected to Azure Quantum workspace {ActiveWorkspace.Name}."); | ||
|
|
||
| return ValidExecutionTargets.ToExecutionResult(); | ||
| } | ||
|
|
@@ -194,7 +120,7 @@ private async Task<ExecutionResult> SubmitOrExecuteJobAsync(IChannel channel, Az | |
| return AzureClientError.NoOperationName.ToExecutionResult(); | ||
| } | ||
|
|
||
| var machine = QuantumMachineFactory.CreateMachine(ActiveWorkspace, ActiveTarget.TargetId, ConnectionString); | ||
| var machine = ActiveWorkspace.CreateQuantumMachine(ActiveTarget.TargetId, ConnectionString); | ||
| if (machine == null) | ||
| { | ||
| // We should never get here, since ActiveTarget should have already been validated at the time it was set. | ||
|
|
@@ -258,7 +184,7 @@ private async Task<ExecutionResult> SubmitOrExecuteJobAsync(IChannel channel, Az | |
| // handle Jupyter kernel interrupt here and break out of this loop | ||
| await Task.Delay(TimeSpan.FromSeconds(submissionContext.ExecutionPollingInterval)); | ||
| if (cts.IsCancellationRequested) break; | ||
| cloudJob = await GetCloudJob(MostRecentJobId); | ||
| cloudJob = await ActiveWorkspace.GetJobAsync(MostRecentJobId); | ||
| channel.Stdout($"[{DateTime.Now.ToLongTimeString()}] Current job status: {cloudJob?.Status ?? "Unknown"}"); | ||
| } | ||
| while (cloudJob == null || cloudJob.InProgress); | ||
|
|
@@ -351,7 +277,7 @@ public async Task<ExecutionResult> GetJobResultAsync(IChannel channel, string jo | |
| jobId = MostRecentJobId; | ||
| } | ||
|
|
||
| var job = await GetCloudJob(jobId); | ||
| var job = await ActiveWorkspace.GetJobAsync(jobId); | ||
| if (job == null) | ||
| { | ||
| channel.Stderr($"Job ID {jobId} not found in current Azure Quantum workspace."); | ||
|
|
@@ -398,7 +324,7 @@ public async Task<ExecutionResult> GetJobStatusAsync(IChannel channel, string jo | |
| jobId = MostRecentJobId; | ||
| } | ||
|
|
||
| var job = await GetCloudJob(jobId); | ||
| var job = await ActiveWorkspace.GetJobAsync(jobId); | ||
| if (job == null) | ||
| { | ||
| channel.Stderr($"Job ID {jobId} not found in current Azure Quantum workspace."); | ||
|
|
@@ -417,7 +343,7 @@ public async Task<ExecutionResult> GetJobListAsync(IChannel channel) | |
| return AzureClientError.NotConnected.ToExecutionResult(); | ||
| } | ||
|
|
||
| var jobs = await GetCloudJobs(); | ||
| var jobs = await ActiveWorkspace.ListJobsAsync(); | ||
| if (jobs == null || jobs.Count() == 0) | ||
| { | ||
| channel.Stderr("No jobs found in current Azure Quantum workspace."); | ||
|
|
@@ -426,33 +352,5 @@ public async Task<ExecutionResult> GetJobListAsync(IChannel channel) | |
|
|
||
| return jobs.ToExecutionResult(); | ||
| } | ||
|
|
||
| private async Task<CloudJob?> GetCloudJob(string jobId) | ||
| { | ||
| try | ||
| { | ||
| return await ActiveWorkspace.GetJobAsync(jobId); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| Logger?.LogError(e, $"Failed to retrieve the specified Azure Quantum job: {e.Message}"); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private async Task<IEnumerable<CloudJob>?> GetCloudJobs() | ||
| { | ||
| try | ||
| { | ||
| return await ActiveWorkspace.ListJobsAsync(); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| Logger?.LogError(e, $"Failed to retrieve the list of jobs from the Azure Quantum workspace: {e.Message}"); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,66 +5,151 @@ | |
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Net; | ||
| using System.Text; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Azure.Quantum.Client; | ||
| using Microsoft.Identity.Client; | ||
| using Microsoft.Identity.Client.Extensions.Msal; | ||
| using Microsoft.Jupyter.Core; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code looks great.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, I had the same thought while I was writing it. There are a few Jupyter dependencies sprinkled in, and I want some of these abstractions in IQ# anyway in order to easily mock them, but a lot of this can and probably should be moved into Azure.Quantum.Client. I'll create a task for this. |
||
|
|
||
| namespace Microsoft.Quantum.IQSharp.AzureClient | ||
| { | ||
| internal enum AzureEnvironmentType { Production, Canary, Dogfood }; | ||
|
|
||
| internal class AzureEnvironment | ||
| { | ||
| public string ClientId { get; private set; } = string.Empty; | ||
| public string Authority { get; private set; } = string.Empty; | ||
| public List<string> Scopes { get; private set; } = new List<string>(); | ||
| public Uri? BaseUri { get; private set; } | ||
| public AzureEnvironmentType Type { get; private set; } | ||
|
|
||
| private string SubscriptionId { get; set; } = string.Empty; | ||
| private string ClientId { get; set; } = string.Empty; | ||
| private string Authority { get; set; } = string.Empty; | ||
| private List<string> Scopes { get; set; } = new List<string>(); | ||
| private Uri? BaseUri { get; set; } | ||
|
|
||
| private AzureEnvironment() | ||
| { | ||
| } | ||
|
|
||
| public static AzureEnvironment Create(string environment, string subscriptionId) | ||
| public static AzureEnvironment Create(string subscriptionId) | ||
| { | ||
| if (Enum.TryParse(environment, true, out AzureEnvironmentType environmentType)) | ||
| var azureEnvironmentEnvVarName = "AZURE_QUANTUM_ENV"; | ||
| var azureEnvironmentName = System.Environment.GetEnvironmentVariable(azureEnvironmentEnvVarName); | ||
|
|
||
| if (Enum.TryParse(azureEnvironmentName, true, out AzureEnvironmentType environmentType)) | ||
| { | ||
| switch (environmentType) | ||
| { | ||
| case AzureEnvironmentType.Production: | ||
| return Production(); | ||
| return Production(subscriptionId); | ||
| case AzureEnvironmentType.Canary: | ||
| return Canary(); | ||
| return Canary(subscriptionId); | ||
| case AzureEnvironmentType.Dogfood: | ||
| return Dogfood(subscriptionId); | ||
| default: | ||
| throw new InvalidOperationException("Unexpected EnvironmentType value."); | ||
| } | ||
| } | ||
|
|
||
| return Production(); | ||
| return Production(subscriptionId); | ||
| } | ||
|
|
||
| public async Task<IAzureWorkspace?> GetAuthenticatedWorkspaceAsync(IChannel channel, string resourceGroupName, string workspaceName, bool refreshCredentials) | ||
| { | ||
| // Find the token cache folder | ||
| var cacheDirectoryEnvVarName = "AZURE_QUANTUM_TOKEN_CACHE"; | ||
| var cacheDirectory = System.Environment.GetEnvironmentVariable(cacheDirectoryEnvVarName); | ||
| if (string.IsNullOrEmpty(cacheDirectory)) | ||
| { | ||
| cacheDirectory = Path.Join(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), ".azure-quantum"); | ||
| } | ||
|
|
||
| // Register the token cache for serialization | ||
| var cacheFileName = "aad.bin"; | ||
| var storageCreationProperties = new StorageCreationPropertiesBuilder(cacheFileName, cacheDirectory, ClientId).Build(); | ||
| var cacheHelper = await MsalCacheHelper.CreateAsync(storageCreationProperties); | ||
| var msalApp = PublicClientApplicationBuilder.Create(ClientId).WithAuthority(Authority).Build(); | ||
| cacheHelper.RegisterCache(msalApp.UserTokenCache); | ||
|
|
||
| // Perform the authentication | ||
| bool shouldShowLoginPrompt = refreshCredentials; | ||
| AuthenticationResult? authenticationResult = null; | ||
| if (!shouldShowLoginPrompt) | ||
| { | ||
| try | ||
| { | ||
| var accounts = await msalApp.GetAccountsAsync(); | ||
| authenticationResult = await msalApp.AcquireTokenSilent( | ||
| Scopes, accounts.FirstOrDefault()).WithAuthority(msalApp.Authority).ExecuteAsync(); | ||
| } | ||
| catch (MsalUiRequiredException) | ||
| { | ||
| shouldShowLoginPrompt = true; | ||
| } | ||
| } | ||
|
|
||
| if (shouldShowLoginPrompt) | ||
| { | ||
| authenticationResult = await msalApp.AcquireTokenWithDeviceCode( | ||
| Scopes, | ||
| deviceCodeResult => | ||
| { | ||
| channel.Stdout(deviceCodeResult.Message); | ||
| return Task.FromResult(0); | ||
| }).WithAuthority(msalApp.Authority).ExecuteAsync(); | ||
| } | ||
|
|
||
| if (authenticationResult == null) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| // Construct and return the AzureWorkspace object | ||
| var credentials = new Rest.TokenCredentials(authenticationResult.AccessToken); | ||
| var azureQuantumClient = new QuantumClient(credentials) | ||
| { | ||
| SubscriptionId = SubscriptionId, | ||
| ResourceGroupName = resourceGroupName, | ||
| WorkspaceName = workspaceName, | ||
| BaseUri = BaseUri, | ||
| }; | ||
| var azureQuantumWorkspace = new Azure.Quantum.Workspace( | ||
| azureQuantumClient.SubscriptionId, | ||
| azureQuantumClient.ResourceGroupName, | ||
| azureQuantumClient.WorkspaceName, | ||
| authenticationResult?.AccessToken, | ||
| BaseUri); | ||
|
|
||
| return new AzureWorkspace(azureQuantumClient, azureQuantumWorkspace); | ||
| } | ||
|
|
||
| private static AzureEnvironment Production() => | ||
| private static AzureEnvironment Production(string subscriptionId) => | ||
| new AzureEnvironment() | ||
| { | ||
| Type = AzureEnvironmentType.Production, | ||
| ClientId = "84ba0947-6c53-4dd2-9ca9-b3694761521b", // QDK client ID | ||
| Authority = "https://login.microsoftonline.com/common", | ||
| Scopes = new List<string>() { "https://quantum.microsoft.com/Jobs.ReadWrite" }, | ||
| BaseUri = new Uri("https://app-jobscheduler-prod.azurewebsites.net/"), | ||
| SubscriptionId = subscriptionId, | ||
| }; | ||
|
|
||
| private static AzureEnvironment Dogfood(string subscriptionId) => | ||
| new AzureEnvironment() | ||
| { | ||
| Type = AzureEnvironmentType.Dogfood, | ||
| ClientId = "46a998aa-43d0-4281-9cbb-5709a507ac36", // QDK dogfood client ID | ||
| Authority = GetDogfoodAuthority(subscriptionId), | ||
| Scopes = new List<string>() { "api://dogfood.azure-quantum/Jobs.ReadWrite" }, | ||
| BaseUri = new Uri("https://app-jobscheduler-test.azurewebsites.net/"), | ||
| SubscriptionId = subscriptionId, | ||
| }; | ||
|
|
||
| private static AzureEnvironment Canary() | ||
| private static AzureEnvironment Canary(string subscriptionId) | ||
| { | ||
| var canary = Production(); | ||
| var canary = Production(subscriptionId); | ||
| canary.Type = AzureEnvironmentType.Canary; | ||
| canary.BaseUri = new Uri("https://app-jobs-canarysouthcentralus.azurewebsites.net/"); | ||
| return canary; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be a different error, as there is a workspace...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we have created the "workspace" object on the client, but downloading the providers list is the first time we actually attempt to communicate with Azure Quantum. For example, if the user has mistyped the workspace name, this is the error they will see. The friendly string displayed is: "No Azure Quantum workspace was found that matches the specified criteria."