Skip to content
Merged
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];
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.");
}

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