Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#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.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Aspire.Hosting.Azure.Utils;
using Azure;
using Azure.Core;
Expand All @@ -16,7 +20,8 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal;
/// <summary>
/// Default implementation of <see cref="IProvisioningContextProvider"/>.
/// </summary>
internal sealed class DefaultProvisioningContextProvider(
internal sealed partial class DefaultProvisioningContextProvider(
IInteractionService interactionService,
IOptions<AzureProvisionerOptions> options,
IHostEnvironment environment,
ILogger<DefaultProvisioningContextProvider> logger,
Expand All @@ -26,8 +31,159 @@ internal sealed class DefaultProvisioningContextProvider(
{
private readonly AzureProvisionerOptions _options = options.Value;

private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously);

private void EnsureProvisioningOptions(JsonObject userSecrets)
{
if (!interactionService.IsAvailable ||
(!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId)))
{
// If the interaction service is not available, or
// if both options are already set, we can skip the prompt
_provisioningOptionsAvailable.TrySetResult();
return;
}

// Start the loop that will allow the user to specify the Azure provisioning options
_ = Task.Run(async () =>
{
try
{
await RetrieveAzureProvisioningOptions(userSecrets).ConfigureAwait(false);

logger.LogDebug("Azure provisioning options have been handled successfully.");
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to retrieve Azure provisioning options.");
_provisioningOptionsAvailable.SetException(ex);
}
});
}

private async Task RetrieveAzureProvisioningOptions(JsonObject userSecrets, CancellationToken cancellationToken = default)
{
var locations = typeof(AzureLocation).GetProperties(BindingFlags.Public | BindingFlags.Static)
.Where(p => p.PropertyType == typeof(AzureLocation))
.Select(p => (AzureLocation)p.GetValue(null)!)
.Select(location => KeyValuePair.Create(location.Name, location.DisplayName ?? location.Name))
.OrderBy(kvp => kvp.Value)
.ToList();

while (_options.Location == null || _options.SubscriptionId == null)
{
var messageBarResult = await interactionService.PromptMessageBarAsync(
"Azure provisioning",
"The model contains Azure resources that require an Azure Subscription.",
new MessageBarInteractionOptions
{
Intent = MessageIntent.Warning,
PrimaryButtonText = "Enter values"
},
cancellationToken)
.ConfigureAwait(false);

if (messageBarResult.Canceled)
{
// User canceled the prompt, so we exit the loop
_provisioningOptionsAvailable.SetException(new MissingConfigurationException("Azure provisioning options were not provided."));
return;
}

if (messageBarResult.Data)
{
var result = await interactionService.PromptInputsAsync(
"Azure provisioning",
"""
The model contains Azure resources that require an Azure Subscription.

To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/azure/provisioning).
""",
[
new InteractionInput { InputType = InputType.Choice, Label = "Location", Placeholder = "Select location", Required = true, Options = [..locations] },
new InteractionInput { InputType = InputType.SecretText, Label = "Subscription ID", Placeholder = "Select subscription ID", Required = true },
new InteractionInput { InputType = InputType.Text, Label = "Resource group", Value = GetDefaultResourceGroupName() },
],
new InputsDialogInteractionOptions
{
EnableMessageMarkdown = true,
ValidationCallback = static (validationContext) =>
{
var subscriptionInput = validationContext.Inputs[1];
Copy link
Member

@JamesNK JamesNK Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of referencing inputs by index, you could create them earlier and assign them to variables, use the variable in the array passed to PromptInputsAsync and here in the validation. The instance will be the same. It's non-obvious that you can do that.

Although I'm starting to wonder if inputs should have a name and be accessible via name here and in the result...

Keep referencing by index and in 9.5 I'll change the API to make inputs a KeyedCollection.

if (!Guid.TryParse(subscriptionInput.Value, out var _))
{
validationContext.AddValidationError(subscriptionInput, "Subscription ID must be a valid GUID.");
}

var resourceGroupInput = validationContext.Inputs[2];
if (!IsValidResourceGroupName(resourceGroupInput.Value))
{
validationContext.AddValidationError(resourceGroupInput, "Resource group name must be a valid Azure resource group name.");
}
Comment on lines +119 to +122
Copy link
Member

@JamesNK JamesNK Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't say why the name isn't valid. It's not a good user experience.


return Task.CompletedTask;
}
},
cancellationToken).ConfigureAwait(false);

if (!result.Canceled)
{
_options.Location = result.Data?[0].Value;
_options.SubscriptionId = result.Data?[1].Value;
_options.ResourceGroup = result.Data?[2].Value;
_options.AllowResourceGroupCreation = true; // Allow the creation of the resource group if it does not exist.

var azureSection = userSecrets.Prop("Azure");

// Persist the parameter value to user secrets so they can be reused in the future
azureSection["Location"] = _options.Location;
azureSection["SubscriptionId"] = _options.SubscriptionId;
azureSection["ResourceGroup"] = _options.ResourceGroup;

_provisioningOptionsAvailable.SetResult();
}
}
}
}

[GeneratedRegex(@"^[a-zA-Z0-9_\-\.\(\)]+$")]
private static partial Regex ResourceGroupValidCharacters();

private static bool IsValidResourceGroupName(string? name)
{
if (string.IsNullOrWhiteSpace(name) || name.Length > 90)
{
return false;
}

// Only allow valid characters - letters, digits, underscores, hyphens, periods, and parentheses
if (!ResourceGroupValidCharacters().IsMatch(name))
{
return false;
}

// Must start with a letter
if (!char.IsLetter(name[0]))
{
return false;
}

// Cannot end with a period
if (name.EndsWith('.'))
{
return false;
}

// No consecutive periods
return !name.Contains("..");
}

public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject userSecrets, CancellationToken cancellationToken = default)
{
EnsureProvisioningOptions(userSecrets);

await _provisioningOptionsAvailable.Task.ConfigureAwait(false);

var subscriptionId = _options.SubscriptionId ?? throw new MissingConfigurationException("An Azure subscription id is required. Set the Azure:SubscriptionId configuration value.");

var credential = tokenCredentialProvider.TokenCredential;
Expand Down Expand Up @@ -57,26 +213,8 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
if (string.IsNullOrEmpty(_options.ResourceGroup))
{
// Generate an resource group name since none was provided

var prefix = "rg-aspire";

if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix))
{
prefix = _options.ResourceGroupPrefix;
}

var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true);

var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s

var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant());
if (normalizedApplicationName.Length > maxApplicationNameSize)
{
normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize];
}

// Create a unique resource group name and save it in user secrets
resourceGroupName = $"{prefix}-{normalizedApplicationName}-{suffix}";
resourceGroupName = GetDefaultResourceGroupName();

createIfAbsent = true;

Expand Down Expand Up @@ -131,4 +269,26 @@ public async Task<ProvisioningContext> CreateProvisioningContextAsync(JsonObject
principal,
userSecrets);
}
}

private string GetDefaultResourceGroupName()
{
var prefix = "rg-aspire";

if (!string.IsNullOrWhiteSpace(_options.ResourceGroupPrefix))
{
prefix = _options.ResourceGroupPrefix;
}

var suffix = RandomNumberGenerator.GetHexString(8, lowercase: true);

var maxApplicationNameSize = ResourceGroupNameHelpers.MaxResourceGroupNameLength - prefix.Length - suffix.Length - 2; // extra '-'s

var normalizedApplicationName = ResourceGroupNameHelpers.NormalizeResourceGroupName(environment.ApplicationName.ToLowerInvariant());
if (normalizedApplicationName.Length > maxApplicationNameSize)
{
normalizedApplicationName = normalizedApplicationName[..maxApplicationNameSize];
}

return $"{prefix}-{normalizedApplicationName}-{suffix}";
}
}
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ await _interactionService.CompleteInteractionAsync(
incomingValue = (bool.TryParse(incomingValue, out var b) && b) ? "true" : "false";
}

modelInput.SetValue(incomingValue);
modelInput.Value = incomingValue;
}

return new InteractionCompletionState { Complete = true, State = inputsInfo.Inputs };
Expand Down
38 changes: 33 additions & 5 deletions src/Aspire.Hosting/IInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,6 @@ public interface IInteractionService
[Experimental(InteractionService.DiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public sealed class InteractionInput
{
private string? _value;

/// <summary>
/// Gets or sets the label for the input.
/// </summary>
Expand All @@ -128,15 +126,13 @@ public sealed class InteractionInput
/// <summary>
/// Gets or sets the value of the input.
/// </summary>
public string? Value { get => _value; init => _value = value; }
public string? Value { get; set; }

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

internal void SetValue(string value) => _value = value;

internal List<string> ValidationErrors { get; } = [];
}

Expand Down Expand Up @@ -329,6 +325,38 @@ public class InteractionOptions
public bool? EnableMessageMarkdown { get; set; }
}

/// <summary>
/// Provides a set of static methods for the <see cref="InteractionResult{T}"/>.
/// </summary>
public static class InteractionResult
{
/// <summary>
/// Creates a new <see cref="InteractionResult{T}"/> with the specified result and a flag indicating that the interaction was not canceled.
/// </summary>
/// <typeparam name="T">The type of the data associated with the interaction result.</typeparam>
/// <param name="result">The data returned from the interaction.</param>
/// <returns>The new <see cref="InteractionResult{T}"/>.</returns>
public static InteractionResult<T> Ok<T>(T result)
{
return new InteractionResult<T>(result, canceled: false);
}

/// <summary>
/// Creates an <see cref="InteractionResult{T}"/> indicating a canceled interaction.
/// </summary>
/// <typeparam name="T">The type of the data associated with the interaction result.</typeparam>
/// <param name="data">Optional data to include with the interaction result. Defaults to the default value of type <typeparamref
/// name="T"/> if not provided.</param>
/// <returns>
/// An <see cref="InteractionResult{T}"/> with the <c>canceled</c> flag set to <see langword="true"/> and containing
/// the specified data.
/// </returns>
public static InteractionResult<T> Cancel<T>(T? data = default)
{
return new InteractionResult<T>(data ?? default, canceled: true);
}
}

/// <summary>
/// Represents the result of an interaction.
/// </summary>
Expand Down
Loading
Loading