diff --git a/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs b/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs index f02d09ff740..6ca4260b80f 100644 --- a/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ParameterResource.cs @@ -74,5 +74,19 @@ internal string ConfigurationKey set => _configurationKey = value; } - ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) => new(Value); + /// + /// A task completion source that can be used to wait for the value of the parameter to be set. + /// + internal TaskCompletionSource? WaitForValueTcs { get; set; } + + async ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) + { + if (WaitForValueTcs is not null) + { + // Wait for the value to be set if the task completion source is available. + return await WaitForValueTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return Value; + } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 05486e9a500..6bba69b5df1 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -350,6 +350,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Orchestrator _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.AddHostedService(); // DCP stuff diff --git a/src/Aspire.Hosting/MissingParameterValueException.cs b/src/Aspire.Hosting/MissingParameterValueException.cs new file mode 100644 index 00000000000..2f02f9d9cce --- /dev/null +++ b/src/Aspire.Hosting/MissingParameterValueException.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting; + +/// +/// The exception that is thrown when a parameter resource cannot be initialized because its value is missing or cannot be resolved. +/// +/// +/// This exception is typically thrown when: +/// +/// A parameter value is not provided in configuration and has no default value +/// A parameter's value callback throws an exception during execution +/// A parameter's value cannot be retrieved from the configured source (e.g., user secrets, environment variables) +/// +/// +public class MissingParameterValueException : DistributedApplicationException +{ + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public MissingParameterValueException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public MissingParameterValueException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 239db3e6dd9..153431f6b96 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -24,6 +24,7 @@ internal sealed class ApplicationOrchestrator private readonly IDistributedApplicationEventing _eventing; private readonly IServiceProvider _serviceProvider; private readonly DistributedApplicationExecutionContext _executionContext; + private readonly ParameterProcessor _parameterProcessor; private readonly CancellationTokenSource _shutdownCancellation = new(); public ApplicationOrchestrator(DistributedApplicationModel model, @@ -34,7 +35,8 @@ public ApplicationOrchestrator(DistributedApplicationModel model, ResourceLoggerService loggerService, IDistributedApplicationEventing eventing, IServiceProvider serviceProvider, - DistributedApplicationExecutionContext executionContext) + DistributedApplicationExecutionContext executionContext, + ParameterProcessor parameterProcessor) { _dcpExecutor = dcpExecutor; _model = model; @@ -45,6 +47,7 @@ public ApplicationOrchestrator(DistributedApplicationModel model, _eventing = eventing; _serviceProvider = serviceProvider; _executionContext = executionContext; + _parameterProcessor = parameterProcessor; dcpExecutorEvents.Subscribe(OnResourcesPrepared); dcpExecutorEvents.Subscribe(OnResourceChanged); @@ -269,51 +272,15 @@ private async Task OnResourceEndpointsAllocated(ResourceEndpointsAllocatedEvent await PublishResourceEndpointUrls(@event.Resource, cancellationToken).ConfigureAwait(false); } - private async Task OnResourceInitialized(InitializeResourceEvent @event, CancellationToken cancellationToken) + private Task OnResourceInitialized(InitializeResourceEvent @event, CancellationToken cancellationToken) { var resource = @event.Resource; - if (resource is ParameterResource parameterResource) - { - await InitializeParameter(parameterResource).ConfigureAwait(false); - } - else if (resource is ConnectionStringResource connectionStringResource) + if (resource is ConnectionStringResource connectionStringResource) { InitializeConnectionString(connectionStringResource); } - async Task InitializeParameter(ParameterResource parameterResource) - { - try - { - await _notificationService.PublishUpdateAsync(parameterResource, s => - { - return s with - { - Properties = s.Properties.SetResourceProperty(KnownProperties.Parameter.Value, parameterResource.Value ?? "", parameterResource.Secret), - State = new(KnownResourceStates.Active, KnownResourceStateStyles.Info) - }; - }) - .ConfigureAwait(false); - } - catch (Exception ex) - { - await _notificationService.PublishUpdateAsync(parameterResource, s => - { - return s with - { - State = new("Value missing", KnownResourceStateStyles.Error), - Properties = s.Properties.SetResourceProperty(KnownProperties.Parameter.Value, ex.Message), - IsHidden = false - }; - }) - .ConfigureAwait(false); - - _loggerService.GetLogger(parameterResource) - .LogError(ex, "Failed to initialize parameter resource {ResourceName}", parameterResource.Name); - } - } - void InitializeConnectionString(ConnectionStringResource connectionStringResource) { var logger = _loggerService.GetLogger(resource); @@ -349,7 +316,7 @@ void InitializeConnectionString(ConnectionStringResource connectionStringResourc tcs.SetResult(); return Task.CompletedTask; }); - + waitFor.Add(tcs.Task.WaitAsync(cancellationToken)); } } @@ -364,6 +331,8 @@ await _notificationService.PublishUpdateAsync(connectionStringResource, s => s w }).ConfigureAwait(false); }, cancellationToken); } + + return Task.CompletedTask; } private async Task OnResourceChanged(OnResourceChangedContext context) @@ -467,6 +436,9 @@ await SetChildResourceAsync(child, state, startTimeStamp, stopTimeStamp) private async Task PublishResourcesInitialStateAsync(CancellationToken cancellationToken) { + // Initialize all parameter resources up front + await _parameterProcessor.InitializeParametersAsync(_model.Resources.OfType()).ConfigureAwait(false); + // Publish the initial state of the resources that have a snapshot annotation. foreach (var resource in _model.Resources) { diff --git a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs new file mode 100644 index 00000000000..f44b1d65fc0 --- /dev/null +++ b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs @@ -0,0 +1,199 @@ +#pragma warning disable ASPIREINTERACTION001 + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Orchestrator; + +/// +/// Handles processing of parameter resources during application orchestration. +/// +internal sealed class ParameterProcessor( + ResourceNotificationService notificationService, + ResourceLoggerService loggerService, + IInteractionService interactionService, + ILogger logger) +{ + private readonly List _unresolvedParameters = []; + + public async Task InitializeParametersAsync(IEnumerable parameterResources) + { + // Initialize all parameter resources by setting their WaitForValueTcs. + // This allows them to be processed asynchronously later. + foreach (var parameterResource in parameterResources) + { + parameterResource.WaitForValueTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await ProcessParameterAsync(parameterResource).ConfigureAwait(false); + } + + // If interaction service is available, we can handle unresolved parameters. + // This will allow the user to provide values for parameters that could not be initialized. + if (interactionService.IsAvailable) + { + // All parameters have been processed, we can now handle unresolved parameters if any. + if (_unresolvedParameters.Count > 0) + { + // Start the loop that will allow the user to specify values for unresolved parameters. + _ = Task.Run(async () => + { + try + { + await HandleUnresolvedParametersAsync().ConfigureAwait(false); + + logger.LogDebug("All unresolved parameters have been handled successfully."); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to handle unresolved parameters."); + } + }); + } + } + } + + private async Task ProcessParameterAsync(ParameterResource parameterResource) + { + try + { + var value = parameterResource.Value ?? ""; + + await notificationService.PublishUpdateAsync(parameterResource, s => + { + return s with + { + Properties = s.Properties.SetResourceProperty(KnownProperties.Parameter.Value, value, parameterResource.Secret), + State = new(KnownResourceStates.Active, KnownResourceStateStyles.Success) + }; + }) + .ConfigureAwait(false); + + parameterResource.WaitForValueTcs?.TrySetResult(value); + } + catch (Exception ex) + { + // Missing parameter values throw a MissingParameterValueException. + if (interactionService.IsAvailable && ex is MissingParameterValueException) + { + // If interaction service is available, we can prompt the user to provide a value. + // Add the parameter to unresolved parameters list. + _unresolvedParameters.Add(parameterResource); + + loggerService.GetLogger(parameterResource) + .LogWarning(ex, "Parameter resource {ResourceName} could not be initialized. Waiting for user input.", parameterResource.Name); + } + else + { + // If interaction service is not available, we log the error and set the state to error. + parameterResource.WaitForValueTcs?.TrySetException(ex); + + loggerService.GetLogger(parameterResource) + .LogError(ex, "Failed to initialize parameter resource {ResourceName}.", parameterResource.Name); + } + + var stateText = ex is MissingParameterValueException ? + "Value missing" : + "Error initializing parameter"; + + await notificationService.PublishUpdateAsync(parameterResource, s => + { + return s with + { + State = new(stateText, KnownResourceStateStyles.Error), + Properties = s.Properties.SetResourceProperty(KnownProperties.Parameter.Value, ex.Message), + IsHidden = false + }; + }) + .ConfigureAwait(false); + } + } + + // Internal for testing purposes. + private async Task HandleUnresolvedParametersAsync() + { + await HandleUnresolvedParametersAsync(_unresolvedParameters).ConfigureAwait(false); + } + + // Internal for testing purposes - allows passing specific parameters to test. + internal async Task HandleUnresolvedParametersAsync(IList unresolvedParameters) + { + // This method will continue in a loop until all unresolved parameters are resolved. + while (unresolvedParameters.Count > 0) + { + // First we show a notification that there are unresolved parameters. + var result = await interactionService.PromptMessageBarAsync( + "Unresolved parameters", + "There are unresolved parameters that need to be set. Please provide values for them.", + new MessageBarInteractionOptions + { + Intent = MessageIntent.Warning, + PrimaryButtonText = "Enter values" + }) + .ConfigureAwait(false); + + if (result.Data) + { + // Now we build up a new form base on the unresolved parameters. + var inputs = new List(); + + foreach (var parameter in unresolvedParameters) + { + // Create an input for each unresolved parameter. + inputs.Add(new InteractionInput + { + InputType = parameter.Secret ? InputType.SecretText : InputType.Text, + Label = parameter.Name, + Placeholder = "Enter value for " + parameter.Name, + }); + } + + var valuesPrompt = await interactionService.PromptInputsAsync( + "Set unresolved parameters", + "Please provide values for the unresolved parameters.", + inputs, + new InputsDialogInteractionOptions + { + PrimaryButtonText = "Save", + ShowDismiss = true + }) + .ConfigureAwait(false); + + if (!valuesPrompt.Canceled) + { + // Iterate through the unresolved parameters and set their values based on user input. + for (var i = unresolvedParameters.Count - 1; i >= 0; i--) + { + var parameter = unresolvedParameters[i]; + var inputValue = valuesPrompt.Data[i].Value; + + if (string.IsNullOrEmpty(inputValue)) + { + // If the input value is null, we skip this parameter. + continue; + } + + parameter.WaitForValueTcs?.TrySetResult(inputValue); + + // Update the parameter resource state to active with the provided value. + await notificationService.PublishUpdateAsync(parameter, s => + { + return s with + { + Properties = s.Properties.SetResourceProperty(KnownProperties.Parameter.Value, inputValue, parameter.Secret), + State = new(KnownResourceStates.Active, KnownResourceStateStyles.Success) + }; + }) + .ConfigureAwait(false); + + // Remove the parameter from unresolved parameters list. + unresolvedParameters.RemoveAt(i); + } + } + } + } + } +} diff --git a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs index 5aef83ac681..08fd709dc1c 100644 --- a/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ParameterResourceBuilderExtensions.cs @@ -153,7 +153,7 @@ private static string GetParameterValue(ConfigurationManager configuration, stri configurationKey ??= $"Parameters:{name}"; return configuration[configurationKey] ?? parameterDefault?.GetDefaultValue() - ?? throw new DistributedApplicationException($"Parameter resource could not be used because configuration key '{configurationKey}' is missing and the Parameter has no default value."); + ?? throw new MissingParameterValueException($"Parameter resource could not be used because configuration key '{configurationKey}' is missing and the Parameter has no default value."); } internal static IResourceBuilder AddParameter(this IDistributedApplicationBuilder builder, T resource) @@ -191,7 +191,7 @@ public static IResourceBuilder AddConnectionStrin new ConnectionStringParameterResource( name, _ => builder.Configuration.GetConnectionString(name) ?? - throw new DistributedApplicationException($"Connection string parameter resource could not be used because connection string '{name}' is missing."), + throw new MissingParameterValueException($"Connection string parameter resource could not be used because connection string '{name}' is missing."), environmentVariableName) ); } diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs index cbac272013c..945c7da7fb4 100644 --- a/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ApplicationOrchestratorTests.cs @@ -9,6 +9,7 @@ using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Aspire.Hosting.Tests.Orchestrator; @@ -444,6 +445,7 @@ private static ApplicationOrchestrator CreateOrchestrator( ResourceLoggerService? resourceLoggerService = null) { var serviceProvider = new ServiceCollection().BuildServiceProvider(); + resourceLoggerService ??= new ResourceLoggerService(); return new ApplicationOrchestrator( distributedAppModel, @@ -451,14 +453,27 @@ private static ApplicationOrchestrator CreateOrchestrator( dcpEvents ?? new DcpExecutorEvents(), [], notificationService, - resourceLoggerService ?? new ResourceLoggerService(), + resourceLoggerService, applicationEventing ?? new DistributedApplicationEventing(), serviceProvider, new DistributedApplicationExecutionContext( - new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = serviceProvider }) + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = serviceProvider }), + new ParameterProcessor( + notificationService, + resourceLoggerService, + CreateInteractionService(), + NullLogger.Instance) ); } + private static InteractionService CreateInteractionService(DistributedApplicationOptions? options = null) + { + return new InteractionService( + NullLogger.Instance, + options ?? new DistributedApplicationOptions(), + new ServiceCollection().BuildServiceProvider()); + } + private sealed class CustomResource(string name) : Resource(name); private sealed class CustomChildResource(string name, IResource parent) : Resource(name), IResourceWithParent diff --git a/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs new file mode 100644 index 00000000000..a6a5e651f50 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Orchestrator/ParameterProcessorTests.cs @@ -0,0 +1,378 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Aspire.Hosting.Orchestrator; +using Aspire.Hosting.Tests.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Threading.Channels; +using Xunit; + +#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. + +namespace Aspire.Hosting.Tests.Orchestrator; + +public class ParameterProcessorTests +{ + [Fact] + public async Task InitializeParametersAsync_WithValidParameters_SetsActiveState() + { + // Arrange + var parameterProcessor = CreateParameterProcessor(); + var parameters = new[] + { + CreateParameterResource("param1", "value1"), + CreateParameterResource("param2", "value2") + }; + + // Act + await parameterProcessor.InitializeParametersAsync(parameters); + + // Assert + foreach (var param in parameters) + { + Assert.NotNull(param.WaitForValueTcs); + Assert.True(param.WaitForValueTcs.Task.IsCompletedSuccessfully); + Assert.Equal(param.Value, await param.WaitForValueTcs.Task); + } + } + + [Fact] + public async Task InitializeParametersAsync_WithValidParametersAndDashboardEnabled_SetsActiveState() + { + // Arrange + var interactionService = CreateInteractionService(disableDashboard: false); + var parameterProcessor = CreateParameterProcessor(interactionService: interactionService, disableDashboard: false); + var parameters = new[] + { + CreateParameterResource("param1", "value1"), + CreateParameterResource("param2", "value2") + }; + + // Act + await parameterProcessor.InitializeParametersAsync(parameters); + + // Assert + foreach (var param in parameters) + { + Assert.NotNull(param.WaitForValueTcs); + Assert.True(param.WaitForValueTcs.Task.IsCompletedSuccessfully); + Assert.Equal(param.Value, await param.WaitForValueTcs.Task); + } + } + + [Fact] + public async Task InitializeParametersAsync_WithSecretParameter_MarksAsSecret() + { + // Arrange + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + var parameterProcessor = CreateParameterProcessor(notificationService: notificationService); + var secretParam = CreateParameterResource("secret", "secretValue", secret: true); + + var updates = new List<(IResource Resource, CustomResourceSnapshot Snapshot)>(); + var watchTask = Task.Run(async () => + { + await foreach (var resourceEvent in notificationService.WatchAsync().ConfigureAwait(false)) + { + updates.Add((resourceEvent.Resource, resourceEvent.Snapshot)); + break; // Only collect the first update + } + }); + + // Act + await parameterProcessor.InitializeParametersAsync([secretParam]); + + // Wait for the notification + await watchTask.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + var (resource, snapshot) = Assert.Single(updates); + Assert.Same(secretParam, resource); + Assert.Equal(KnownResourceStates.Active, snapshot.State?.Text); + Assert.Equal(KnownResourceStateStyles.Success, snapshot.State?.Style); + } + + [Fact] + public async Task InitializeParametersAsync_WithMissingParameterValue_AddsToUnresolvedWhenInteractionAvailable() + { + // Arrange + var interactionService = CreateInteractionService(); + var parameterProcessor = CreateParameterProcessor(interactionService: interactionService); + var parameterWithMissingValue = CreateParameterWithMissingValue("missingParam"); + + // Act + await parameterProcessor.InitializeParametersAsync([parameterWithMissingValue]); + + // Assert + Assert.NotNull(parameterWithMissingValue.WaitForValueTcs); + Assert.False(parameterWithMissingValue.WaitForValueTcs.Task.IsCompleted); + } + + [Fact] + public async Task InitializeParametersAsync_WithMissingParameterValue_SetsExceptionWhenInteractionNotAvailable() + { + // Arrange + var interactionService = CreateInteractionService(disableDashboard: true); + var parameterProcessor = CreateParameterProcessor(interactionService: interactionService); + var parameterWithMissingValue = CreateParameterWithMissingValue("missingParam"); + + // Act + await parameterProcessor.InitializeParametersAsync([parameterWithMissingValue]); + + // Assert + Assert.NotNull(parameterWithMissingValue.WaitForValueTcs); + Assert.True(parameterWithMissingValue.WaitForValueTcs.Task.IsCompleted); + Assert.True(parameterWithMissingValue.WaitForValueTcs.Task.IsFaulted); + Assert.IsType(parameterWithMissingValue.WaitForValueTcs.Task.Exception?.InnerException); + } + + [Fact] + public async Task InitializeParametersAsync_WithMissingParameterValueAndDashboardEnabled_LeavesUnresolved() + { + // Arrange + var interactionService = CreateInteractionService(disableDashboard: false); + var parameterProcessor = CreateParameterProcessor(interactionService: interactionService, disableDashboard: false); + var parameterWithMissingValue = CreateParameterWithMissingValue("missingParam"); + + // Act + await parameterProcessor.InitializeParametersAsync([parameterWithMissingValue]); + + // Assert - Parameter should remain unresolved when dashboard is enabled + Assert.NotNull(parameterWithMissingValue.WaitForValueTcs); + Assert.False(parameterWithMissingValue.WaitForValueTcs.Task.IsCompleted); + } + + [Fact] + public async Task InitializeParametersAsync_WithNonMissingParameterException_SetsException() + { + // Arrange + var parameterProcessor = CreateParameterProcessor(); + var parameterWithError = CreateParameterWithGenericError("errorParam"); + + // Act + await parameterProcessor.InitializeParametersAsync([parameterWithError]); + + // Assert + Assert.NotNull(parameterWithError.WaitForValueTcs); + Assert.True(parameterWithError.WaitForValueTcs.Task.IsCompleted); + Assert.True(parameterWithError.WaitForValueTcs.Task.IsFaulted); + Assert.IsType(parameterWithError.WaitForValueTcs.Task.Exception?.InnerException); + } + + [Fact] + public async Task HandleUnresolvedParametersAsync_WithMultipleUnresolvedParameters_CreatesInteractions() + { + // Arrange + var testInteractionService = new TestInteractionService(); + var notificationService = ResourceNotificationServiceTestHelpers.Create(); + var parameterProcessor = CreateParameterProcessor(notificationService: notificationService, interactionService: testInteractionService); + var param1 = CreateParameterWithMissingValue("param1"); + var param2 = CreateParameterWithMissingValue("param2"); + var secretParam = CreateParameterWithMissingValue("secretParam", secret: true); + + List parameters = [param1, param2, secretParam]; + + foreach (var param in parameters) + { + // Initialize the parameters' WaitForValueTcs + param.WaitForValueTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + var updates = notificationService.WatchAsync().GetAsyncEnumerator(); + + // Act - Start handling unresolved parameters + var handleTask = parameterProcessor.HandleUnresolvedParametersAsync(parameters); + + // Assert - Wait for the first interaction (message bar) + var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Unresolved parameters", messageBarInteraction.Title); + Assert.Equal("There are unresolved parameters that need to be set. Please provide values for them.", messageBarInteraction.Message); + + // Complete the message bar interaction to proceed to inputs dialog + messageBarInteraction.CompletionTcs.SetResult(new InteractionResult(true, false)); // Data = true (user clicked Enter Values) + + // Wait for the inputs interaction + var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Set unresolved parameters", inputsInteraction.Title); + Assert.Equal("Please provide values for the unresolved parameters.", inputsInteraction.Message); + + Assert.Collection(inputsInteraction.Inputs, + input => + { + Assert.Equal("param1", input.Label); + Assert.Equal(InputType.Text, input.InputType); + Assert.False(input.Required); + }, + input => + { + Assert.Equal("param2", input.Label); + Assert.Equal(InputType.Text, input.InputType); + Assert.False(input.Required); + }, + input => + { + Assert.Equal("secretParam", input.Label); + Assert.Equal(InputType.SecretText, input.InputType); + Assert.False(input.Required); + }); + + inputsInteraction.Inputs[0].SetValue("value1"); + inputsInteraction.Inputs[1].SetValue("value2"); + inputsInteraction.Inputs[2].SetValue("secretValue"); + + inputsInteraction.CompletionTcs.SetResult(new InteractionResult>(inputsInteraction.Inputs, false)); + + // Wait for the handle task to complete + await handleTask; + + // Assert - All parameters should now be resolved + Assert.True(param1.WaitForValueTcs!.Task.IsCompletedSuccessfully); + Assert.True(param2.WaitForValueTcs!.Task.IsCompletedSuccessfully); + Assert.True(secretParam.WaitForValueTcs!.Task.IsCompletedSuccessfully); + Assert.Equal("value1", await param1.WaitForValueTcs.Task); + Assert.Equal("value2", await param2.WaitForValueTcs.Task); + Assert.Equal("secretValue", await secretParam.WaitForValueTcs.Task); + + // Notification service should have received updates for each parameter + // Marking them as Active with the provided values + await updates.MoveNextAsync(); + Assert.Equal(KnownResourceStates.Active, updates.Current.Snapshot.State?.Text); + Assert.Equal("value1", updates.Current.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Parameter.Value)?.Value); + + await updates.MoveNextAsync(); + Assert.Equal(KnownResourceStates.Active, updates.Current.Snapshot.State?.Text); + Assert.Equal("value2", updates.Current.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Parameter.Value)?.Value); + + await updates.MoveNextAsync(); + Assert.Equal(KnownResourceStates.Active, updates.Current.Snapshot.State?.Text); + Assert.Equal("secretValue", updates.Current.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Parameter.Value)?.Value); + Assert.True(updates.Current.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Parameter.Value)?.IsSensitive ?? false); + } + + [Fact] + public async Task HandleUnresolvedParametersAsync_WhenUserCancelsInteraction_ParametersRemainUnresolved() + { + // Arrange + var testInteractionService = new TestInteractionService(); + var parameterProcessor = CreateParameterProcessor(interactionService: testInteractionService); + var parameterWithMissingValue = CreateParameterWithMissingValue("missingParam"); + + parameterWithMissingValue.WaitForValueTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act - Start handling unresolved parameters + _ = parameterProcessor.HandleUnresolvedParametersAsync([parameterWithMissingValue]); + + // Wait for the message bar interaction + var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Unresolved parameters", messageBarInteraction.Title); + + // Complete the message bar interaction with false (user chose not to enter values) + messageBarInteraction.CompletionTcs.SetResult(new InteractionResult(false, false)); // Data = false (user dismissed/cancelled) + + // Assert that the message bar will show up again if there are still unresolved parameters + var nextMessageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Unresolved parameters", nextMessageBarInteraction.Title); + + // Assert - Parameter should remain unresolved since user cancelled + Assert.NotNull(parameterWithMissingValue.WaitForValueTcs); + Assert.False(parameterWithMissingValue.WaitForValueTcs.Task.IsCompleted); + } + + [Fact] + public async Task InitializeParametersAsync_WithEmptyParameterList_CompletesSuccessfully() + { + // Arrange + var parameterProcessor = CreateParameterProcessor(); + + // Act & Assert - Should not throw + await parameterProcessor.InitializeParametersAsync([]); + } + + private static ParameterProcessor CreateParameterProcessor( + ResourceNotificationService? notificationService = null, + ResourceLoggerService? loggerService = null, + IInteractionService? interactionService = null, + ILogger? logger = null, + bool disableDashboard = true) + { + return new ParameterProcessor( + notificationService ?? ResourceNotificationServiceTestHelpers.Create(), + loggerService ?? new ResourceLoggerService(), + interactionService ?? CreateInteractionService(disableDashboard), + logger ?? new NullLogger() + ); + } + + private static InteractionService CreateInteractionService(bool disableDashboard = false) + { + return new InteractionService( + new NullLogger(), + new DistributedApplicationOptions { DisableDashboard = disableDashboard }, + new ServiceCollection().BuildServiceProvider()); + } + + private static ParameterResource CreateParameterResource(string name, string value, bool secret = false) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [$"Parameters:{name}"] = value }) + .Build(); + + return new ParameterResource(name, _ => configuration[$"Parameters:{name}"] ?? throw new MissingParameterValueException($"Parameter '{name}' is missing"), secret); + } + + private static ParameterResource CreateParameterWithMissingValue(string name, bool secret = false) + { + return new ParameterResource(name, _ => throw new MissingParameterValueException($"Parameter '{name}' is missing"), secret: secret); + } + + private static ParameterResource CreateParameterWithGenericError(string name) + { + return new ParameterResource(name, _ => throw new InvalidOperationException($"Generic error for parameter '{name}'"), secret: false); + } + + private sealed record InteractionData(string Title, string? Message, IReadOnlyList Inputs, InteractionOptions? Options, TaskCompletionSource CompletionTcs); + + private sealed class TestInteractionService : IInteractionService + { + public Channel Interactions { get; } = Channel.CreateUnbounded(); + + public bool IsAvailable { get; set; } = true; + + public Task> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task> PromptInputAsync(string title, string? message, InteractionInput input, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public async Task>> PromptInputsAsync(string title, string? message, IReadOnlyList inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + var data = new InteractionData(title, message, inputs, options, new TaskCompletionSource()); + Interactions.Writer.TryWrite(data); + return (InteractionResult>)await data.CompletionTcs.Task; + } + + public async Task> PromptMessageBarAsync(string title, string message, MessageBarInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + var data = new InteractionData(title, message, [], options, new TaskCompletionSource()); + Interactions.Writer.TryWrite(data); + return (InteractionResult)await data.CompletionTcs.Task; + } + + public Task> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index 659f8c41df0..00ed812bb34 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -161,7 +161,7 @@ public async Task EnvironmentCallbackThrowsWhenParameterValueMissingInDcpMode() var projectA = builder.AddProject("projectA") .WithEnvironment("MY_PARAMETER", parameter); - var exception = await Assert.ThrowsAsync(async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + var exception = await Assert.ThrowsAsync(async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( projectA.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index 7de1122c3e6..9d24cd3f647 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -191,7 +191,7 @@ public async Task ParameterAsConnectionStringResourceThrowsWhenConnectionStringS .WithReference(missingResource); // Call environment variable callbacks. - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); }).DefaultTimeout();