Skip to content

Commit ab40bf0

Browse files
davidfowleerhardtJamesNK
authored
Add Azure provisioning command handling and settings configuration (#10038)
* Add Azure provisioning command handling and settings configuration Improve user prompt for Azure subscription settings in provisioning context Update Azure subscription prompt to include HTML link for creating a free account * WIP: move to message bar prompt * - Populate a default resource group name - Save data to user secrets * PR feedback * Tweak the dialog message * Add a test Need to refactor InteractionResult to allow for mocking without using IVT. * Fix new tests for new APIs. * Fix tests * Add a test for PromptInputAsync with ValidationCallback * Add a test for validation * Apply suggestions from code review Co-authored-by: James Newton-King <james@newtonking.com> * Refactor provisioning options initialization and improve user secrets handling --------- Co-authored-by: Eric Erhardt <eric.erhardt@microsoft.com> Co-authored-by: James Newton-King <james@newtonking.com>
1 parent 19bfc33 commit ab40bf0

12 files changed

Lines changed: 412 additions & 63 deletions

File tree

src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultProvisioningContextProvider.cs

Lines changed: 181 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
2+
13
// Licensed to the .NET Foundation under one or more agreements.
24
// The .NET Foundation licenses this file to you under the MIT license.
35

6+
using System.Reflection;
47
using System.Security.Cryptography;
58
using System.Text.Json.Nodes;
9+
using System.Text.RegularExpressions;
610
using Aspire.Hosting.Azure.Utils;
711
using Azure;
812
using Azure.Core;
@@ -16,7 +20,8 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal;
1620
/// <summary>
1721
/// Default implementation of <see cref="IProvisioningContextProvider"/>.
1822
/// </summary>
19-
internal sealed class DefaultProvisioningContextProvider(
23+
internal sealed partial class DefaultProvisioningContextProvider(
24+
IInteractionService interactionService,
2025
IOptions<AzureProvisionerOptions> options,
2126
IHostEnvironment environment,
2227
ILogger<DefaultProvisioningContextProvider> logger,
@@ -26,8 +31,159 @@ internal sealed class DefaultProvisioningContextProvider(
2631
{
2732
private readonly AzureProvisionerOptions _options = options.Value;
2833

34+
private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously);
35+
36+
private void EnsureProvisioningOptions(JsonObject userSecrets)
37+
{
38+
if (!interactionService.IsAvailable ||
39+
(!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)))
40+
{
41+
// If the interaction service is not available, or
42+
// if both options are already set, we can skip the prompt
43+
_provisioningOptionsAvailable.TrySetResult();
44+
return;
45+
}
46+
47+
// Start the loop that will allow the user to specify the Azure provisioning options
48+
_ = Task.Run(async () =>
49+
{
50+
try
51+
{
52+
await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false);
53+
54+
logger.LogDebug("Azure provisioning options have been handled successfully.");
55+
}
56+
catch (Exception ex)
57+
{
58+
logger.LogError(ex, "Failed to retrieve Azure provisioning options.");
59+
_provisioningOptionsAvailable.SetException(ex);
60+
}
61+
});
62+
}
63+
64+
private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default)
65+
{
66+
var locations = typeof(AzureLocation).GetProperties(BindingFlags.Public | BindingFlags.Static)
67+
.Where(p => p.PropertyType == typeof(AzureLocation))
68+
.Select(p => (AzureLocation)p.GetValue(null)!)
69+
.Select(location => KeyValuePair.Create(location.Name, location.DisplayName ?? location.Name))
70+
.OrderBy(kvp => kvp.Value)
71+
.ToList();
72+
73+
while (_options.Location == null || _options.SubscriptionId == null)
74+
{
75+
var messageBarResult = await interactionService.PromptMessageBarAsync(
76+
"Azure provisioning",
77+
"The model contains Azure resources that require an Azure Subscription.",
78+
new MessageBarInteractionOptions
79+
{
80+
Intent = MessageIntent.Warning,
81+
PrimaryButtonText = "Enter values"
82+
},
83+
cancellationToken)
84+
.ConfigureAwait(false);
85+
86+
if (messageBarResult.Canceled)
87+
{
88+
// User canceled the prompt, so we exit the loop
89+
_provisioningOptionsAvailable.SetException(new MissingConfigurationException("Azure provisioning options were not provided."));
90+
return;
91+
}
92+
93+
if (messageBarResult.Data)
94+
{
95+
var result = await interactionService.PromptInputsAsync(
96+
"Azure provisioning",
97+
"""
98+
The model contains Azure resources that require an Azure Subscription.
99+
100+
To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/azure/provisioning).
101+
""",
102+
[
103+
new InteractionInput { InputType = InputType.Choice, Label = "Location", Placeholder = "Select location", Required = true, Options = [..locations] },
104+
new InteractionInput { InputType = InputType.SecretText, Label = "Subscription ID", Placeholder = "Select subscription ID", Required = true },
105+
new InteractionInput { InputType = InputType.Text, Label = "Resource group", Value = GetDefaultResourceGroupName() },
106+
],
107+
new InputsDialogInteractionOptions
108+
{
109+
EnableMessageMarkdown = true,
110+
ValidationCallback = static (validationContext) =>
111+
{
112+
var subscriptionInput = validationContext.Inputs[1];
113+
if (!Guid.TryParse(subscriptionInput.Value, out var _))
114+
{
115+
validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID.");
116+
}
117+
118+
var resourceGroupInput = validationContext.Inputs[2];
119+
if (!IsValidResourceGroupName(resourceGroupInput.Value))
120+
{
121+
validationContext.AddValidationError(resourceGroupInput, "Resource group name must be a valid Azure resource group name.");
122+
}
123+
124+
return Task.CompletedTask;
125+
}
126+
},
127+
cancellationToken).ConfigureAwait(false);
128+
129+
if (!result.Canceled)
130+
{
131+
_options.Location = result.Data?[0].Value;
132+
_options.SubscriptionId = result.Data?[1].Value;
133+
_options.ResourceGroup = result.Data?[2].Value;
134+
_options.AllowResourceGroupCreation = true; // Allow the creation of the resource group if it does not exist.
135+
136+
var azureSection = userSecrets.Prop("Azure");
137+
138+
// Persist the parameter value to user secrets so they can be reused in the future
139+
azureSection["Location"] = _options.Location;
140+
azureSection["SubscriptionId"] = _options.SubscriptionId;
141+
azureSection["ResourceGroup"] = _options.ResourceGroup;
142+
143+
_provisioningOptionsAvailable.SetResult();
144+
}
145+
}
146+
}
147+
}
148+
149+
[GeneratedRegex(@"^[a-zA-Z0-9_\-\.\(\)]+$")]
150+
private static partial Regex ResourceGroupValidCharacters();
151+
152+
private static bool IsValidResourceGroupName(string? name)
153+
{
154+
if (string.IsNullOrWhiteSpace(name) || name.Length > 90)
155+
{
156+
return false;
157+
}
158+
159+
// Only allow valid characters - letters, digits, underscores, hyphens, periods, and parentheses
160+
if (!ResourceGroupValidCharacters().IsMatch(name))
161+
{
162+
return false;
163+
}
164+
165+
// Must start with a letter
166+
if (!char.IsLetter(name[0]))
167+
{
168+
return false;
169+
}
170+
171+
// Cannot end with a period
172+
if (name.EndsWith('.'))
173+
{
174+
return false;
175+
}
176+
177+
// No consecutive periods
178+
return !name.Contains("..");
179+
}
180+
29181
public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
30182
{
183+
EnsureProvisioningOptions(userSecrets);
184+
185+
await _provisioningOptionsAvailable.Task.ConfigureAwait(false);
186+
31187
var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value.");
32188

33189
var credential = tokenCredentialProvider.TokenCredential;
@@ -57,26 +213,8 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
57213
if (string.IsNullOrEmpty(_options.ResourceGroup))
58214
{
59215
// Generate an resource group name since none was provided
60-
61-
var prefix = "rg-aspire";
62-
63-
if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix))
64-
{
65-
prefix = _options.ResourceGroupPrefix;
66-
}
67-
68-
var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true);
69-
70-
var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s
71-
72-
var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant());
73-
if (normalizedApplicationName.Length > maxApplicationNameSize)
74-
{
75-
normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize];
76-
}
77-
78216
// Create a unique resource group name and save it in user secrets
79-
resourceGroupName = $"{prefix}-{normalizedApplicationName}-{suffix}";
217+
resourceGroupName = GetDefaultResourceGroupName();
80218

81219
createIfAbsent = true;
82220

@@ -131,4 +269,26 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
131269
principal,
132270
userSecrets);
133271
}
134-
}
272+
273+
private string GetDefaultResourceGroupName()
274+
{
275+
var prefix = "rg-aspire";
276+
277+
if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix))
278+
{
279+
prefix = _options.ResourceGroupPrefix;
280+
}
281+
282+
var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true);
283+
284+
var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s
285+
286+
var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant());
287+
if (normalizedApplicationName.Length > maxApplicationNameSize)
288+
{
289+
normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize];
290+
}
291+
292+
return $"{prefix}-{normalizedApplicationName}-{suffix}";
293+
}
294+
}

src/Aspire.Hosting/Dashboard/DashboardServiceData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ await _interactionService.CompleteInteractionAsync(
179179
incomingValue = (bool.TryParse(incomingValue, out var b) && b) ? "true" : "false";
180180
}
181181

182-
modelInput.SetValue(incomingValue);
182+
modelInput.Value = incomingValue;
183183
}
184184

185185
return new InteractionCompletionState { Complete = true, State = inputsInfo.Inputs };

src/Aspire.Hosting/IInteractionService.cs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,6 @@ public interface IInteractionService
103103
[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
104104
public sealed class InteractionInput
105105
{
106-
private string? _value;
107-
108106
/// <summary>
109107
/// Gets or sets the label for the input.
110108
/// </summary>
@@ -128,15 +126,13 @@ public sealed class InteractionInput
128126
/// <summary>
129127
/// Gets or sets the value of the input.
130128
/// </summary>
131-
public string? Value { get => _value; init => _value = value; }
129+
public string? Value { get; set; }
132130

133131
/// <summary>
134132
/// Gets or sets the placeholder text for the input.
135133
/// </summary>
136134
public string? Placeholder { get; set; }
137135

138-
internal void SetValue(string value) => _value = value;
139-
140136
internal List<string> ValidationErrors { get; } = [];
141137
}
142138

@@ -329,6 +325,38 @@ public class InteractionOptions
329325
public bool? EnableMessageMarkdown { get; set; }
330326
}
331327

328+
/// <summary>
329+
/// Provides a set of static methods for the <see cref="InteractionResult{T}"/>.
330+
/// </summary>
331+
public static class InteractionResult
332+
{
333+
/// <summary>
334+
/// Creates a new <see cref="InteractionResult{T}"/> with the specified result and a flag indicating that the interaction was not canceled.
335+
/// </summary>
336+
/// <typeparam name="T">The type of the data associated with the interaction result.</typeparam>
337+
/// <param name="result">The data returned from the interaction.</param>
338+
/// <returns>The new <see cref="InteractionResult{T}"/>.</returns>
339+
public static InteractionResult<T> Ok<T>(T result)
340+
{
341+
return new InteractionResult<T>(result, canceled: false);
342+
}
343+
344+
/// <summary>
345+
/// Creates an <see cref="InteractionResult{T}"/> indicating a canceled interaction.
346+
/// </summary>
347+
/// <typeparam name="T">The type of the data associated with the interaction result.</typeparam>
348+
/// <param name="data">Optional data to include with the interaction result. Defaults to the default value of type <typeparamref
349+
/// name="T"/> if not provided.</param>
350+
/// <returns>
351+
/// An <see cref="InteractionResult{T}"/> with the <c>canceled</c> flag set to <see langword="true"/> and containing
352+
/// the specified data.
353+
/// </returns>
354+
public static InteractionResult<T> Cancel<T>(T? data = default)
355+
{
356+
return new InteractionResult<T>(data ?? default, canceled: true);
357+
}
358+
}
359+
332360
/// <summary>
333361
/// Represents the result of an interaction.
334362
/// </summary>

0 commit comments

Comments
 (0)